Skip to content

Commit 39520cc

Browse files
committed
Cancel runs from human interrupt follow up
1 parent 467f2cf commit 39520cc

5 files changed

Lines changed: 216 additions & 24 deletions

File tree

internal/gateway/api.go

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -977,30 +977,68 @@ func (s *Server) handleProjectRunCancel(w http.ResponseWriter, r *http.Request,
977977
args[key] = value
978978
}
979979
}
980-
match, apiErr := s.resolveProject(r.Context(), principal, projectID, args, true)
980+
payload, apiErr := s.cancelProjectRunFromRecord(r.Context(), principal, RunRecord{ProjectID: projectID, RunID: runID}, args)
981981
if apiErr != nil {
982-
s.recordGatewayAuditWithMetadata(r.Context(), principal, "cancel_project_run_requested", "Run cancel route resolution failed for project "+projectID+": "+apiErr.Code, map[string]any{"project_id": projectID, "run_id": runID})
983982
writeAPIError(w, apiErr.Status, apiErr.Code, apiErr.Message)
984983
return
985984
}
986-
auditMetadata := projectLifecycleAuditMetadata(match, args)
987-
s.recordGatewayAuditWithMetadata(r.Context(), principal, "cancel_project_run_requested", "Requested cancel_project_run for run "+runID+" in project "+projectID, auditMetadata)
988-
path, body, apiErr := cancelProjectRunRoute(args)
985+
writeJSON(w, http.StatusOK, payload)
986+
}
987+
988+
func (s *Server) cancelProjectRunFromRecord(ctx context.Context, principal *authPrincipal, record RunRecord, args map[string]any) (map[string]any, *apiError) {
989+
if args == nil {
990+
args = map[string]any{}
991+
}
992+
projectID := firstNonEmpty(stringValueFromAny(args["project_id"]), record.ProjectID)
993+
runID := firstNonEmpty(stringValueFromAny(args["run_id"]), record.RunID)
994+
if strings.TrimSpace(projectID) == "" {
995+
return nil, &apiError{Status: http.StatusBadRequest, Code: "project_id_required", Message: "project_id is required"}
996+
}
997+
if strings.TrimSpace(runID) == "" {
998+
return nil, &apiError{Status: http.StatusBadRequest, Code: "run_id_required", Message: "run_id is required"}
999+
}
1000+
routeArgs := map[string]any{
1001+
"project_id": projectID,
1002+
"run_id": runID,
1003+
}
1004+
for _, key := range []string{"relay_profile_id", "machine_id", "host_label", "reason"} {
1005+
if value := strings.TrimSpace(stringValueFromAny(args[key])); value != "" {
1006+
routeArgs[key] = value
1007+
}
1008+
}
1009+
for key, value := range map[string]string{
1010+
"relay_profile_id": record.RelayProfileID,
1011+
"machine_id": record.MachineID,
1012+
"host_label": record.HostLabel,
1013+
} {
1014+
if _, exists := routeArgs[key]; !exists && strings.TrimSpace(value) != "" {
1015+
routeArgs[key] = strings.TrimSpace(value)
1016+
}
1017+
}
1018+
if record.ID != "" {
1019+
routeArgs["run_history_id"] = record.ID
1020+
}
1021+
match, apiErr := s.resolveProject(ctx, principal, projectID, routeArgs, true)
9891022
if apiErr != nil {
990-
writeAPIError(w, apiErr.Status, apiErr.Code, apiErr.Message)
991-
return
1023+
s.recordGatewayAuditWithMetadata(ctx, principal, "cancel_project_run_requested", "Run cancel route resolution failed for project "+projectID+": "+apiErr.Code, map[string]any{"project_id": projectID, "run_id": runID})
1024+
return nil, apiErr
9921025
}
993-
path = appendSelector(path, args)
994-
_, response, apiErr := s.callRelay(r.Context(), match.Profile, http.MethodPost, path, body)
1026+
auditMetadata := projectLifecycleAuditMetadata(match, routeArgs)
1027+
s.recordGatewayAuditWithMetadata(ctx, principal, "cancel_project_run_requested", "Requested cancel_project_run for run "+runID+" in project "+projectID, auditMetadata)
1028+
path, body, apiErr := cancelProjectRunRoute(routeArgs)
1029+
if apiErr != nil {
1030+
return nil, apiErr
1031+
}
1032+
path = appendSelector(path, routeArgs)
1033+
_, response, apiErr := s.callRelay(ctx, match.Profile, http.MethodPost, path, body)
9951034
payload, apiErr := responsePayload(match.Profile, response, apiErr)
9961035
if apiErr != nil {
997-
s.recordGatewayAuditWithMetadata(r.Context(), principal, "run_failed", "Run cancel failed for project "+projectID+": "+apiErr.Code, auditMetadata)
998-
writeAPIError(w, apiErr.Status, apiErr.Code, apiErr.Message)
999-
return
1036+
s.recordGatewayAuditWithMetadata(ctx, principal, "run_failed", "Run cancel failed for project "+projectID+": "+apiErr.Code, auditMetadata)
1037+
return nil, apiErr
10001038
}
1001-
runRecord, auditMetadata := s.refreshRunRecordFromLifecyclePayload(r.Context(), principal, match, args, payload, reportStatusForPayload(payload, "available"))
1002-
s.recordGatewayAuditWithMetadata(r.Context(), principal, terminalAuditType(payload), terminalAuditSummary(projectID, payload), auditMetadata)
1003-
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "project_id": projectID, "run_id": runID, "run_history_id": runRecord.ID, "result": payload})
1039+
runRecord, auditMetadata := s.refreshRunRecordFromLifecyclePayload(ctx, principal, match, routeArgs, payload, reportStatusForPayload(payload, "available"))
1040+
s.recordGatewayAuditWithMetadata(ctx, principal, terminalAuditType(payload), terminalAuditSummary(projectID, payload), auditMetadata)
1041+
return map[string]any{"ok": true, "project_id": projectID, "run_id": runID, "run_history_id": runRecord.ID, "result": payload}, nil
10041042
}
10051043

10061044
func (s *Server) handleProjectRunResume(w http.ResponseWriter, r *http.Request, projectID, runID string) {

internal/gateway/gateway_test.go

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,53 @@ func TestGatewayMCPAsyncLifecycleTools(t *testing.T) {
340340
}
341341
assertNoGatewayMCPLeak(t, blockedEventsBody)
342342

343+
blockedCancel := mcpToolCall(t, server.URL, session, "codencer.submit_project_task", map[string]any{
344+
"relay_profile_id": "default",
345+
"project_id": "codencer",
346+
"machine_id": "mach-1",
347+
"title": "Blocked Gateway cancel task",
348+
"goal": "Ask for a safe operator cancellation.",
349+
})
350+
blockedCancelBody := mustJSON(t, blockedCancel)
351+
blockedCancelPayload, _ := mcpStructuredContent(t, blockedCancel).(map[string]any)
352+
blockedCancelRunHistoryID := stringValueFromAny(blockedCancelPayload["run_history_id"])
353+
if blockedCancelRunHistoryID == "" || !strings.Contains(blockedCancelBody, `"status":"blocked"`) || !strings.Contains(blockedCancelBody, `"type":"question"`) {
354+
t.Fatalf("expected blocked cancel run response with history id, got %s", blockedCancelBody)
355+
}
356+
assertNoGatewayMCPLeak(t, blockedCancelBody)
357+
358+
cancelResponse := mcpToolCall(t, server.URL, session, "codencer.respond_to_human_interrupt", map[string]any{
359+
"run_history_id": blockedCancelRunHistoryID,
360+
"response_type": "deny",
361+
"response": "Cancel this run without using /Users/example/private token=relay-secret.",
362+
"follow_up": "cancel",
363+
})
364+
cancelResponseBody := mustJSON(t, cancelResponse)
365+
if !strings.Contains(cancelResponseBody, `"status":"human_interrupt_response_recorded"`) ||
366+
!strings.Contains(cancelResponseBody, `"cancel_supported":true`) ||
367+
!strings.Contains(cancelResponseBody, `"cancel_attempted":true`) ||
368+
!strings.Contains(cancelResponseBody, `"follow_up":"cancel"`) ||
369+
!strings.Contains(cancelResponseBody, `"follow_up_result"`) ||
370+
!strings.Contains(cancelResponseBody, `"run_cancelled"`) ||
371+
!strings.Contains(cancelResponseBody, `"operator_response"`) {
372+
t.Fatalf("expected recorded cancel human interrupt response, got %s", cancelResponseBody)
373+
}
374+
assertNoGatewayMCPLeak(t, cancelResponseBody)
375+
376+
blockedCancelEvents := mcpToolCall(t, server.URL, session, "codencer.get_gateway_run_events", map[string]any{
377+
"run_history_id": blockedCancelRunHistoryID,
378+
"limit": 20,
379+
})
380+
blockedCancelEventsBody := mustJSON(t, blockedCancelEvents)
381+
if !strings.Contains(blockedCancelEventsBody, `"human_interrupt_created"`) ||
382+
!strings.Contains(blockedCancelEventsBody, `"human_interrupt_responded"`) ||
383+
!strings.Contains(blockedCancelEventsBody, `"cancel_project_run_requested"`) ||
384+
!strings.Contains(blockedCancelEventsBody, `"run_cancelled"`) ||
385+
!strings.Contains(blockedCancelEventsBody, `"operator_response"`) {
386+
t.Fatalf("expected human interrupt cancel audit event, got %s", blockedCancelEventsBody)
387+
}
388+
assertNoGatewayMCPLeak(t, blockedCancelEventsBody)
389+
343390
cancel := mcpToolCall(t, server.URL, session, "codencer.cancel_project_run", map[string]any{
344391
"relay_profile_id": "default",
345392
"project_id": "codencer",
@@ -922,6 +969,45 @@ func TestGatewayStoreDeviceLoginRelayRegistryAndConnectorBinding(t *testing.T) {
922969
t.Fatalf("blocked run events missing human interrupt response audit: %s", blockedEventsAfterResponseBody)
923970
}
924971
assertNoGatewayConsoleSensitiveLeak(t, blockedEventsAfterResponseBody)
972+
blockedCancelRun := apiPost[map[string]any](t, httpServer.URL+"/api/gateway/v1/projects/codencer/runs", token.AccessToken, map[string]any{
973+
"relay_profile_id": "default",
974+
"machine_id": "mach-1",
975+
"title": "Blocked Gateway cancel task",
976+
"goal": "Ask for a safe operator cancellation.",
977+
"timeout_seconds": 30,
978+
})
979+
blockedCancelBody := mustJSON(t, blockedCancelRun)
980+
blockedCancelRunHistoryID, _ := blockedCancelRun["run_history_id"].(string)
981+
if blockedCancelRunHistoryID == "" || !strings.Contains(blockedCancelBody, `"status":"blocked"`) || !strings.Contains(blockedCancelBody, `"type":"question"`) {
982+
t.Fatalf("blocked cancel run did not return blocker and history id: %s", blockedCancelBody)
983+
}
984+
assertNoGatewayConsoleSensitiveLeak(t, blockedCancelBody)
985+
cancelInterruptResponse := apiPost[map[string]any](t, httpServer.URL+"/api/gateway/v1/runs/"+blockedCancelRunHistoryID+"/human-interrupts/respond", token.AccessToken, map[string]any{
986+
"response_type": "deny",
987+
"response": "Cancel this run without using /Users/example/private token=relay-secret.",
988+
"follow_up": "cancel",
989+
"reason": "Operator rejected the blocked run.",
990+
})
991+
cancelInterruptResponseBody := mustJSON(t, cancelInterruptResponse)
992+
if !strings.Contains(cancelInterruptResponseBody, `"status":"human_interrupt_response_recorded"`) ||
993+
!strings.Contains(cancelInterruptResponseBody, `"cancel_supported":true`) ||
994+
!strings.Contains(cancelInterruptResponseBody, `"cancel_attempted":true`) ||
995+
!strings.Contains(cancelInterruptResponseBody, `"follow_up":"cancel"`) ||
996+
!strings.Contains(cancelInterruptResponseBody, `"follow_up_result"`) ||
997+
!strings.Contains(cancelInterruptResponseBody, `"run_cancelled"`) ||
998+
!strings.Contains(cancelInterruptResponseBody, `"operator_response"`) {
999+
t.Fatalf("human interrupt cancel response endpoint missing expected payload: %s", cancelInterruptResponseBody)
1000+
}
1001+
assertNoGatewayConsoleSensitiveLeak(t, cancelInterruptResponseBody)
1002+
blockedCancelEventsAfterResponse := apiGet[map[string]any](t, httpServer.URL+"/api/gateway/v1/runs/"+blockedCancelRunHistoryID+"/events", token.AccessToken)
1003+
blockedCancelEventsAfterResponseBody := mustJSON(t, blockedCancelEventsAfterResponse)
1004+
if !strings.Contains(blockedCancelEventsAfterResponseBody, `"human_interrupt_responded"`) ||
1005+
!strings.Contains(blockedCancelEventsAfterResponseBody, `"cancel_project_run_requested"`) ||
1006+
!strings.Contains(blockedCancelEventsAfterResponseBody, `"run_cancelled"`) ||
1007+
!strings.Contains(blockedCancelEventsAfterResponseBody, `"operator_response"`) {
1008+
t.Fatalf("blocked cancel run events missing human interrupt cancel audit: %s", blockedCancelEventsAfterResponseBody)
1009+
}
1010+
assertNoGatewayConsoleSensitiveLeak(t, blockedCancelEventsAfterResponseBody)
9251011
audit := apiGet[map[string]any](t, httpServer.URL+"/api/gateway/v1/audit-events", token.AccessToken)
9261012
auditBody := mustJSON(t, audit)
9271013
for _, eventType := range []string{
@@ -1179,6 +1265,29 @@ func newFakeRelay(t *testing.T, opts fakeRelayOptions) *fakeRelay {
11791265
"report_path": "/tmp/codencer/run-plans/run-blocked.json",
11801266
})
11811267
})
1268+
mux.HandleFunc("/api/v2/projects/codencer/runs/run-blocked-cancel/cancel", func(w http.ResponseWriter, r *http.Request) {
1269+
requireRelayAuth(t, r)
1270+
if r.Method != http.MethodPost {
1271+
w.WriteHeader(http.StatusMethodNotAllowed)
1272+
return
1273+
}
1274+
writeTestJSON(t, w, map[string]any{
1275+
"ok": true,
1276+
"run_id": "run-blocked-cancel",
1277+
"status": "cancelled",
1278+
"run": map[string]any{
1279+
"id": "run-blocked-cancel",
1280+
"state": "cancelled",
1281+
},
1282+
"events": []map[string]any{{
1283+
"type": "run_cancelled",
1284+
"run_id": "run-blocked-cancel",
1285+
"state": "cancelled",
1286+
}},
1287+
"repo_root": "/Users/example/codencer",
1288+
"report_path": "/tmp/codencer/run-plans/run-blocked-cancel.json",
1289+
})
1290+
})
11821291
mux.HandleFunc("/api/v2/projects/codencer/run-plan", func(w http.ResponseWriter, r *http.Request) {
11831292
requireRelayAuth(t, r)
11841293
relay.lastMachineID = r.URL.Query().Get("machine_id")
@@ -1227,19 +1336,25 @@ func newFakeRelay(t *testing.T, opts fakeRelayOptions) *fakeRelay {
12271336
relay.lastHostLabel = r.URL.Query().Get("host_label")
12281337
var req map[string]any
12291338
_ = json.NewDecoder(r.Body).Decode(&req)
1230-
if req["title"] == "Blocked Gateway task" {
1339+
if req["title"] == "Blocked Gateway task" || req["title"] == "Blocked Gateway cancel task" {
1340+
runID := "run-blocked"
1341+
stepID := "step-blocked"
1342+
if req["title"] == "Blocked Gateway cancel task" {
1343+
runID = "run-blocked-cancel"
1344+
stepID = "step-blocked-cancel"
1345+
}
12311346
writeTestJSON(t, w, map[string]any{
12321347
"ok": false,
12331348
"status": "blocked",
1234-
"run_id": "run-blocked",
1235-
"step_id": "step-blocked",
1349+
"run_id": runID,
1350+
"step_id": stepID,
12361351
"blocker": map[string]any{
12371352
"type": "question",
12381353
"message": "Need operator choice from /Users/example/private token=relay-secret",
12391354
"questions": []string{"Choose a safe path without exposing /tmp/codencer/secret"},
12401355
},
12411356
"task": map[string]any{
1242-
"run_id": "run-blocked",
1357+
"run_id": runID,
12431358
"status": "blocked",
12441359
},
12451360
})

internal/gateway/tools.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ func (s *Server) recordHumanInterruptResponse(ctx context.Context, principal *au
462462
"resume_attempted": false,
463463
"cancel_supported": true,
464464
"cancel_operation": "codencer.cancel_project_run",
465+
"cancel_attempted": false,
465466
"status_read_tool": "codencer.get_project_run_status",
466467
"report_read_tool": "codencer.get_run_report",
467468
"events_read_tool": "codencer.get_gateway_run_events",
@@ -498,6 +499,35 @@ func (s *Server) recordHumanInterruptResponse(ctx context.Context, principal *au
498499
nextActions["planner_decision_required"] = false
499500
followUpResult = result
500501
}
502+
} else if strings.EqualFold(followUp, "cancel") {
503+
nextActions["cancel_attempted"] = true
504+
cancelArgs := map[string]any{}
505+
for key, value := range args {
506+
cancelArgs[key] = value
507+
}
508+
if reason != "" {
509+
cancelArgs["reason"] = reason
510+
}
511+
result, cancelErr := s.cancelProjectRunFromRecord(ctx, principal, record, cancelArgs)
512+
if cancelErr != nil {
513+
blockedMetadata := runAuditMetadata(record)
514+
blockedMetadata["operation"] = "cancel_project_run"
515+
blockedMetadata["status"] = "blocked"
516+
blockedMetadata["blocker_type"] = cancelErr.Code
517+
s.recordGatewayAuditWithMetadata(ctx, principal, "cancel_project_run_blocked", "Blocked follow-up cancel_project_run for run "+firstNonEmpty(record.RunID, record.ID)+" in project "+record.ProjectID, blockedMetadata)
518+
followUpResult = map[string]any{
519+
"ok": false,
520+
"status": "blocked",
521+
"blocker": map[string]any{
522+
"type": cancelErr.Code,
523+
"message": cancelErr.Message,
524+
"operation": "cancel_project_run",
525+
},
526+
}
527+
} else {
528+
nextActions["planner_decision_required"] = false
529+
followUpResult = result
530+
}
501531
}
502532
payload := map[string]any{
503533
"ok": true,

web/gateway-console/api/run-history.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,20 @@ export async function respondToHumanInterrupt(
9696
result: { events: [{ type: "run_resumed" }] },
9797
status: "running",
9898
}
99-
: undefined,
99+
: input.followUp === "cancel"
100+
? {
101+
ok: true,
102+
result: { events: [{ type: "run_cancelled" }] },
103+
status: "cancelled",
104+
}
105+
: undefined,
100106
next_actions: {
107+
cancel_attempted: input.followUp === "cancel",
101108
cancel_operation: "codencer.cancel_project_run",
102109
cancel_supported: true,
103110
resume_attempted: input.followUp === "resume",
104111
resume_operation: "codencer.resume_project_run",
105-
resume_supported: input.followUp === "resume",
112+
resume_supported: true,
106113
},
107114
ok: true,
108115
project_id: "codencer",

web/gateway-console/features/console/run-detail-screen.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,12 @@ function HumanInterruptResponsePanel({
217217
<Alert title="Response recorded" tone="success">
218218
{respond.data.response?.operatorResponse ||
219219
"The response was recorded and linked to this run."}
220-
{respond.data.followUp === "resume" ? (
220+
{respond.data.followUp === "resume" ||
221+
respond.data.followUp === "cancel" ? (
221222
<span className="mt-xs block text-xs text-muted">
222-
Resume follow-up requested; check the audit timeline for
223-
the resumed or blocked outcome.
223+
{respond.data.followUp === "cancel"
224+
? "Cancel follow-up requested; check the audit timeline for the cancelled or blocked outcome."
225+
: "Resume follow-up requested; check the audit timeline for the resumed or blocked outcome."}
224226
</span>
225227
) : null}
226228
</Alert>

0 commit comments

Comments
 (0)