Skip to content

Commit ce39c49

Browse files
committed
Resume runs from human interrupt follow up
1 parent b44f113 commit ce39c49

6 files changed

Lines changed: 150 additions & 23 deletions

File tree

internal/gateway/api.go

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,30 +1028,68 @@ func (s *Server) handleProjectRunResume(w http.ResponseWriter, r *http.Request,
10281028
args[key] = value
10291029
}
10301030
}
1031-
match, apiErr := s.resolveProject(r.Context(), principal, projectID, args, true)
1031+
payload, apiErr := s.resumeProjectRunFromRecord(r.Context(), principal, RunRecord{ProjectID: projectID, RunID: runID}, args)
10321032
if apiErr != nil {
1033-
s.recordGatewayAuditWithMetadata(r.Context(), principal, "resume_project_run_requested", "Run resume route resolution failed for project "+projectID+": "+apiErr.Code, map[string]any{"project_id": projectID, "run_id": runID})
10341033
writeAPIError(w, apiErr.Status, apiErr.Code, apiErr.Message)
10351034
return
10361035
}
1037-
auditMetadata := projectLifecycleAuditMetadata(match, args)
1038-
s.recordGatewayAuditWithMetadata(r.Context(), principal, "resume_project_run_requested", "Requested resume_project_run for run "+runID+" in project "+projectID, auditMetadata)
1039-
path, body, apiErr := resumeProjectRunRoute(args)
1036+
writeJSON(w, http.StatusOK, payload)
1037+
}
1038+
1039+
func (s *Server) resumeProjectRunFromRecord(ctx context.Context, principal *authPrincipal, record RunRecord, args map[string]any) (map[string]any, *apiError) {
1040+
if args == nil {
1041+
args = map[string]any{}
1042+
}
1043+
projectID := firstNonEmpty(stringValueFromAny(args["project_id"]), record.ProjectID)
1044+
runID := firstNonEmpty(stringValueFromAny(args["run_id"]), record.RunID)
1045+
if strings.TrimSpace(projectID) == "" {
1046+
return nil, &apiError{Status: http.StatusBadRequest, Code: "project_id_required", Message: "project_id is required"}
1047+
}
1048+
if strings.TrimSpace(runID) == "" {
1049+
return nil, &apiError{Status: http.StatusBadRequest, Code: "run_id_required", Message: "run_id is required"}
1050+
}
1051+
routeArgs := map[string]any{
1052+
"project_id": projectID,
1053+
"run_id": runID,
1054+
}
1055+
for _, key := range []string{"relay_profile_id", "machine_id", "host_label", "reason"} {
1056+
if value := strings.TrimSpace(stringValueFromAny(args[key])); value != "" {
1057+
routeArgs[key] = value
1058+
}
1059+
}
1060+
for key, value := range map[string]string{
1061+
"relay_profile_id": record.RelayProfileID,
1062+
"machine_id": record.MachineID,
1063+
"host_label": record.HostLabel,
1064+
} {
1065+
if _, exists := routeArgs[key]; !exists && strings.TrimSpace(value) != "" {
1066+
routeArgs[key] = strings.TrimSpace(value)
1067+
}
1068+
}
1069+
if record.ID != "" {
1070+
routeArgs["run_history_id"] = record.ID
1071+
}
1072+
match, apiErr := s.resolveProject(ctx, principal, projectID, routeArgs, true)
10401073
if apiErr != nil {
1041-
writeAPIError(w, apiErr.Status, apiErr.Code, apiErr.Message)
1042-
return
1074+
s.recordGatewayAuditWithMetadata(ctx, principal, "resume_project_run_requested", "Run resume route resolution failed for project "+projectID+": "+apiErr.Code, map[string]any{"project_id": projectID, "run_id": runID})
1075+
return nil, apiErr
10431076
}
1044-
path = appendSelector(path, args)
1045-
_, response, apiErr := s.callRelay(r.Context(), match.Profile, http.MethodPost, path, body)
1077+
auditMetadata := projectLifecycleAuditMetadata(match, routeArgs)
1078+
s.recordGatewayAuditWithMetadata(ctx, principal, "resume_project_run_requested", "Requested resume_project_run for run "+runID+" in project "+projectID, auditMetadata)
1079+
path, body, apiErr := resumeProjectRunRoute(routeArgs)
1080+
if apiErr != nil {
1081+
return nil, apiErr
1082+
}
1083+
path = appendSelector(path, routeArgs)
1084+
_, response, apiErr := s.callRelay(ctx, match.Profile, http.MethodPost, path, body)
10461085
payload, apiErr := responsePayload(match.Profile, response, apiErr)
10471086
if apiErr != nil {
1048-
s.recordGatewayAuditWithMetadata(r.Context(), principal, "run_failed", "Run resume failed for project "+projectID+": "+apiErr.Code, auditMetadata)
1049-
writeAPIError(w, apiErr.Status, apiErr.Code, apiErr.Message)
1050-
return
1087+
s.recordGatewayAuditWithMetadata(ctx, principal, "run_failed", "Run resume failed for project "+projectID+": "+apiErr.Code, auditMetadata)
1088+
return nil, apiErr
10511089
}
1052-
runRecord, auditMetadata := s.refreshRunRecordFromLifecyclePayload(r.Context(), principal, match, args, payload, reportStatusForPayload(payload, "available"))
1090+
runRecord, auditMetadata := s.refreshRunRecordFromLifecyclePayload(ctx, principal, match, routeArgs, payload, reportStatusForPayload(payload, "available"))
10531091
eventType := terminalAuditType(payload)
1054-
s.recordGatewayAuditWithMetadata(r.Context(), principal, eventType, terminalAuditSummary(projectID, payload), auditMetadata)
1092+
s.recordGatewayAuditWithMetadata(ctx, principal, eventType, terminalAuditSummary(projectID, payload), auditMetadata)
10551093
if eventType == "blocker" {
10561094
blockedMetadata := map[string]any{}
10571095
for key, value := range auditMetadata {
@@ -1060,10 +1098,10 @@ func (s *Server) handleProjectRunResume(w http.ResponseWriter, r *http.Request,
10601098
blockedMetadata["operation"] = "resume_project_run"
10611099
blockedMetadata["status"] = "blocked"
10621100
blockedMetadata["blocker_type"] = "unsupported_operation"
1063-
s.recordGatewayAuditWithMetadata(r.Context(), principal, "resume_project_run_blocked", "Blocked unsupported resume_project_run for run "+runID+" in project "+projectID, blockedMetadata)
1064-
s.recordHumanInterruptAudit(r.Context(), principal, projectID, payload, auditMetadata)
1101+
s.recordGatewayAuditWithMetadata(ctx, principal, "resume_project_run_blocked", "Blocked unsupported resume_project_run for run "+runID+" in project "+projectID, blockedMetadata)
1102+
s.recordHumanInterruptAudit(ctx, principal, projectID, payload, auditMetadata)
10651103
}
1066-
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "project_id": projectID, "run_id": runID, "run_history_id": runRecord.ID, "result": payload})
1104+
return map[string]any{"ok": true, "project_id": projectID, "run_id": runID, "run_history_id": runRecord.ID, "result": payload}, nil
10671105
}
10681106

10691107
func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) {

internal/gateway/gateway_test.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,11 @@ func TestGatewayMCPAsyncLifecycleTools(t *testing.T) {
316316
})
317317
responseBody := mustJSON(t, response)
318318
if !strings.Contains(responseBody, `"status":"human_interrupt_response_recorded"`) ||
319-
!strings.Contains(responseBody, `"resume_supported":false`) ||
319+
!strings.Contains(responseBody, `"resume_supported":true`) ||
320+
!strings.Contains(responseBody, `"resume_attempted":true`) ||
320321
!strings.Contains(responseBody, `"follow_up":"resume"`) ||
322+
!strings.Contains(responseBody, `"follow_up_result"`) ||
323+
!strings.Contains(responseBody, `"run_resumed"`) ||
321324
!strings.Contains(responseBody, `"operator_response"`) {
322325
t.Fatalf("expected recorded human interrupt response, got %s", responseBody)
323326
}
@@ -328,7 +331,11 @@ func TestGatewayMCPAsyncLifecycleTools(t *testing.T) {
328331
"limit": 20,
329332
})
330333
blockedEventsBody := mustJSON(t, blockedEvents)
331-
if !strings.Contains(blockedEventsBody, `"human_interrupt_created"`) || !strings.Contains(blockedEventsBody, `"human_interrupt_responded"`) || !strings.Contains(blockedEventsBody, `"operator_response"`) {
334+
if !strings.Contains(blockedEventsBody, `"human_interrupt_created"`) ||
335+
!strings.Contains(blockedEventsBody, `"human_interrupt_responded"`) ||
336+
!strings.Contains(blockedEventsBody, `"resume_project_run_requested"`) ||
337+
!strings.Contains(blockedEventsBody, `"run_resumed"`) ||
338+
!strings.Contains(blockedEventsBody, `"operator_response"`) {
332339
t.Fatalf("expected human interrupt response audit event, got %s", blockedEventsBody)
333340
}
334341
assertNoGatewayMCPLeak(t, blockedEventsBody)
@@ -897,15 +904,21 @@ func TestGatewayStoreDeviceLoginRelayRegistryAndConnectorBinding(t *testing.T) {
897904
})
898905
interruptResponseBody := mustJSON(t, interruptResponse)
899906
if !strings.Contains(interruptResponseBody, `"status":"human_interrupt_response_recorded"`) ||
900-
!strings.Contains(interruptResponseBody, `"resume_supported":false`) ||
907+
!strings.Contains(interruptResponseBody, `"resume_supported":true`) ||
908+
!strings.Contains(interruptResponseBody, `"resume_attempted":true`) ||
901909
!strings.Contains(interruptResponseBody, `"follow_up":"resume"`) ||
910+
!strings.Contains(interruptResponseBody, `"follow_up_result"`) ||
911+
!strings.Contains(interruptResponseBody, `"run_resumed"`) ||
902912
!strings.Contains(interruptResponseBody, `"operator_response"`) {
903913
t.Fatalf("human interrupt response endpoint missing expected payload: %s", interruptResponseBody)
904914
}
905915
assertNoGatewayConsoleSensitiveLeak(t, interruptResponseBody)
906916
blockedEventsAfterResponse := apiGet[map[string]any](t, httpServer.URL+"/api/gateway/v1/runs/"+blockedRunHistoryID+"/events", token.AccessToken)
907917
blockedEventsAfterResponseBody := mustJSON(t, blockedEventsAfterResponse)
908-
if !strings.Contains(blockedEventsAfterResponseBody, `"human_interrupt_responded"`) || !strings.Contains(blockedEventsAfterResponseBody, `"operator_response"`) {
918+
if !strings.Contains(blockedEventsAfterResponseBody, `"human_interrupt_responded"`) ||
919+
!strings.Contains(blockedEventsAfterResponseBody, `"resume_project_run_requested"`) ||
920+
!strings.Contains(blockedEventsAfterResponseBody, `"run_resumed"`) ||
921+
!strings.Contains(blockedEventsAfterResponseBody, `"operator_response"`) {
909922
t.Fatalf("blocked run events missing human interrupt response audit: %s", blockedEventsAfterResponseBody)
910923
}
911924
assertNoGatewayConsoleSensitiveLeak(t, blockedEventsAfterResponseBody)
@@ -1143,6 +1156,29 @@ func newFakeRelay(t *testing.T, opts fakeRelayOptions) *fakeRelay {
11431156
"report_path": "/tmp/codencer/run-plans/run-async-gateway-test.json",
11441157
})
11451158
})
1159+
mux.HandleFunc("/api/v2/projects/codencer/runs/run-blocked/resume", func(w http.ResponseWriter, r *http.Request) {
1160+
requireRelayAuth(t, r)
1161+
if r.Method != http.MethodPost {
1162+
w.WriteHeader(http.StatusMethodNotAllowed)
1163+
return
1164+
}
1165+
writeTestJSON(t, w, map[string]any{
1166+
"ok": true,
1167+
"run_id": "run-blocked",
1168+
"status": "running",
1169+
"run": map[string]any{
1170+
"id": "run-blocked",
1171+
"state": "running",
1172+
},
1173+
"events": []map[string]any{{
1174+
"type": "run_resumed",
1175+
"run_id": "run-blocked",
1176+
"state": "running",
1177+
}},
1178+
"repo_root": "/Users/example/codencer",
1179+
"report_path": "/tmp/codencer/run-plans/run-blocked.json",
1180+
})
1181+
})
11461182
mux.HandleFunc("/api/v2/projects/codencer/run-plan", func(w http.ResponseWriter, r *http.Request) {
11471183
requireRelayAuth(t, r)
11481184
relay.lastMachineID = r.URL.Query().Get("machine_id")

internal/gateway/tools.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ func (s *Server) gatewayRunEventsTool() Tool {
389389
func (s *Server) humanInterruptResponseTool() Tool {
390390
return Tool{
391391
Name: "codencer.respond_to_human_interrupt",
392-
Description: "Record an explicit operator response to a Gateway-observed human interrupt without claiming automatic resume support.",
392+
Description: "Record an explicit operator response to a Gateway-observed human interrupt and optionally perform an explicit follow-up such as resume.",
393393
InputSchema: objectSchema([]string{"run_history_id", "response"}, map[string]any{
394394
"run_history_id": stringSchema("Gateway run history id returned by submit/start/report tools."),
395395
"response": stringSchema("Operator answer, approval, denial, or decision text."),
@@ -459,13 +459,46 @@ func (s *Server) recordHumanInterruptResponse(ctx context.Context, principal *au
459459
nextActions := map[string]any{
460460
"resume_supported": false,
461461
"resume_operation": "codencer.resume_project_run",
462+
"resume_attempted": false,
462463
"cancel_supported": true,
463464
"cancel_operation": "codencer.cancel_project_run",
464465
"status_read_tool": "codencer.get_project_run_status",
465466
"report_read_tool": "codencer.get_run_report",
466467
"events_read_tool": "codencer.get_gateway_run_events",
467468
"planner_decision_required": true,
468469
}
470+
var followUpResult map[string]any
471+
if strings.EqualFold(followUp, "resume") {
472+
nextActions["resume_attempted"] = true
473+
resumeArgs := map[string]any{}
474+
for key, value := range args {
475+
resumeArgs[key] = value
476+
}
477+
if reason != "" {
478+
resumeArgs["reason"] = reason
479+
}
480+
result, resumeErr := s.resumeProjectRunFromRecord(ctx, principal, record, resumeArgs)
481+
if resumeErr != nil {
482+
blockedMetadata := runAuditMetadata(record)
483+
blockedMetadata["operation"] = "resume_project_run"
484+
blockedMetadata["status"] = "blocked"
485+
blockedMetadata["blocker_type"] = resumeErr.Code
486+
s.recordGatewayAuditWithMetadata(ctx, principal, "resume_project_run_blocked", "Blocked follow-up resume_project_run for run "+firstNonEmpty(record.RunID, record.ID)+" in project "+record.ProjectID, blockedMetadata)
487+
followUpResult = map[string]any{
488+
"ok": false,
489+
"status": "blocked",
490+
"blocker": map[string]any{
491+
"type": resumeErr.Code,
492+
"message": resumeErr.Message,
493+
"operation": "resume_project_run",
494+
},
495+
}
496+
} else {
497+
nextActions["resume_supported"] = true
498+
nextActions["planner_decision_required"] = false
499+
followUpResult = result
500+
}
501+
}
469502
payload := map[string]any{
470503
"ok": true,
471504
"status": "human_interrupt_response_recorded",
@@ -484,6 +517,9 @@ func (s *Server) recordHumanInterruptResponse(ctx context.Context, principal *au
484517
if reason != "" {
485518
payload["reason"] = reason
486519
}
520+
if followUpResult != nil {
521+
payload["follow_up_result"] = followUpResult
522+
}
487523
return payload, nil
488524
}
489525

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,20 @@ export async function respondToHumanInterrupt(
8989
if (isDemoMode()) {
9090
return HumanInterruptResponseSchema.parse({
9191
follow_up: input.followUp,
92+
follow_up_result:
93+
input.followUp === "resume"
94+
? {
95+
ok: true,
96+
result: { events: [{ type: "run_resumed" }] },
97+
status: "running",
98+
}
99+
: undefined,
92100
next_actions: {
93101
cancel_operation: "codencer.cancel_project_run",
94102
cancel_supported: true,
103+
resume_attempted: input.followUp === "resume",
95104
resume_operation: "codencer.resume_project_run",
96-
resume_supported: false,
105+
resume_supported: input.followUp === "resume",
97106
},
98107
ok: true,
99108
project_id: "codencer",

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +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" ? (
221+
<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.
224+
</span>
225+
) : null}
220226
</Alert>
221227
) : null}
222228
<div className="grid min-w-0 gap-md md:grid-cols-2">

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const RunEventsResponseSchema = AuditEventListResponseSchema.transform(
8686
export const HumanInterruptResponseSchema = z
8787
.object({
8888
follow_up: z.string().optional(),
89+
follow_up_result: UnknownRecord.optional(),
8990
next_actions: UnknownRecord.optional(),
9091
ok: z.boolean(),
9192
project_id: z.string(),
@@ -102,6 +103,7 @@ export const HumanInterruptResponseSchema = z
102103
})
103104
.transform((payload) => ({
104105
followUp: payload.follow_up,
106+
followUpResult: payload.follow_up_result ?? {},
105107
nextActions: payload.next_actions ?? {},
106108
ok: payload.ok,
107109
projectId: payload.project_id,

0 commit comments

Comments
 (0)