Skip to content

Commit b25bfc2

Browse files
Merge pull request #1078 from corbitsdev/cl-7992-implement-consolidated-keeper-test-plan-post-merge
fix(approval): settle retried approvals with denial memory
2 parents efa2b1a + 6e2e9d0 commit b25bfc2

11 files changed

Lines changed: 831 additions & 39 deletions

‎src/agent/director.test.ts‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,195 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => {
426426
);
427427
expect(stale).toEqual([]);
428428
});
429+
430+
function keeperManageTasksTurn(): ReactorInboundEvent {
431+
return {
432+
type: "inference.done",
433+
turn: {
434+
role: "assistant",
435+
model: "test",
436+
timestamp: 0,
437+
content: [
438+
{
439+
type: "tool_call",
440+
id: "manage-tasks",
441+
name: "manage_tasks",
442+
arguments: {
443+
action: "create",
444+
tasks: [{ id: "t1", title: "work", status: "doing" }],
445+
},
446+
},
447+
],
448+
},
449+
usage: { input: 0, output: 0 },
450+
source: "test",
451+
} as unknown as ReactorInboundEvent;
452+
}
453+
454+
function keeperManageTasksPlusSubmitTurn(): ReactorInboundEvent {
455+
return {
456+
type: "inference.done",
457+
turn: {
458+
role: "assistant",
459+
model: "test",
460+
timestamp: 0,
461+
content: [
462+
{
463+
type: "tool_call",
464+
id: "manage-tasks",
465+
name: "manage_tasks",
466+
arguments: {
467+
action: "create",
468+
tasks: [{ id: "t1", title: "work", status: "doing" }],
469+
},
470+
},
471+
{
472+
type: "tool_call",
473+
id: "submit-1",
474+
name: "submit_output",
475+
arguments: { step: "step-1" },
476+
},
477+
],
478+
},
479+
usage: { input: 0, output: 0 },
480+
source: "test",
481+
} as unknown as ReactorInboundEvent;
482+
}
483+
484+
function keeperTextTurn(): ReactorInboundEvent {
485+
return {
486+
type: "inference.done",
487+
turn: {
488+
role: "assistant",
489+
model: "test",
490+
timestamp: 0,
491+
content: [{ type: "text", text: "all set" }],
492+
},
493+
usage: { input: 0, output: 0 },
494+
source: "test",
495+
} as unknown as ReactorInboundEvent;
496+
}
497+
498+
function keeperTasksChanged(actions: ReactorAction[]): ReactorAction[] {
499+
return actions.filter(
500+
(a) =>
501+
a.type === "emit" &&
502+
(a as { eventType?: string }).eventType === CHAT_TASKS_CHANGED_EVENT,
503+
);
504+
}
505+
506+
// CL-7992 K1: every coordinator rail consulted on the turn boundary
507+
// rethrows instead of degrading — a throwing rail rejects decide() and
508+
// the next turn carries no stale task-change notifications.
509+
test.each(["isActive", "currentStepIsGate", "currentStepId"] as const)(
510+
"a throwing %s rail rejects the inference turn without leaking task-change emits",
511+
async (rail) => {
512+
const failure = new Error(`${rail} exploded`);
513+
const coordinator = {
514+
isActive: () => {
515+
if (rail === "isActive") throw failure;
516+
return true;
517+
},
518+
currentStepIsGate: () => {
519+
if (rail === "currentStepIsGate") throw failure;
520+
return false;
521+
},
522+
currentStepId: () => {
523+
if (rail === "currentStepId") throw failure;
524+
return null;
525+
},
526+
directive: () => null,
527+
handleToolDone: () => false,
528+
} as unknown as WorkflowCoordinator;
529+
const director = createChatDirector("system", [], {});
530+
director.setWorkflowCoordinator(coordinator);
531+
const capabilities = makeCapabilities();
532+
533+
// The step-id rail only runs past a terminal base action, so it
534+
// throws on a text turn; the earlier rails throw on a manage_tasks
535+
// turn after it queues its task-change notification.
536+
const triggering =
537+
rail === "currentStepId" ? keeperTextTurn() : keeperManageTasksTurn();
538+
await expect(
539+
director.decide(triggering, mockState, capabilities),
540+
).rejects.toBe(failure);
541+
542+
director.setWorkflowCoordinator(undefined);
543+
const actions = actionsArray(
544+
await director.decide(keeperTextTurn(), mockState, capabilities),
545+
);
546+
expect(keeperTasksChanged(actions)).toEqual([]);
547+
},
548+
);
549+
550+
// CL-7992 K2: a mid-turn failure outside the coordinator still rejects
551+
// the turn, but already-queued notifications survive for the next turn.
552+
test("a non-coordinator mid-turn failure preserves queued task-change emits for the next turn", async () => {
553+
const failure = new Error("tool execution exploded");
554+
const director = createChatDirector("system", [], {});
555+
const capabilities = makeCapabilities();
556+
const executing: ReactorCapabilities = {
557+
...capabilities,
558+
executeTools: (): ReactorAction => {
559+
throw failure;
560+
},
561+
};
562+
563+
await expect(
564+
director.decide(keeperManageTasksTurn(), mockState, executing),
565+
).rejects.toBe(failure);
566+
567+
const actions = actionsArray(
568+
await director.decide(keeperTextTurn(), mockState, capabilities),
569+
);
570+
expect(keeperTasksChanged(actions)).toHaveLength(1);
571+
});
572+
573+
// CL-7992 K2 variant: a throwing handleToolDone degrades (it takes no
574+
// rethrow parameter) instead of marking the turn stale, so a later
575+
// non-coordinator failure still preserves the queued notifications.
576+
test("a throwing handleToolDone degrades without dropping preserved task-change emits", async () => {
577+
let handleToolDoneSeen = false;
578+
const coordinator = {
579+
isActive: () => false,
580+
currentStepIsGate: () => false,
581+
currentStepId: () => null,
582+
directive: () => null,
583+
handleToolDone: (): boolean => {
584+
handleToolDoneSeen = true;
585+
throw new Error("coordinator completion exploded");
586+
},
587+
} as unknown as WorkflowCoordinator;
588+
const director = createChatDirector("system", [], {});
589+
director.setWorkflowCoordinator(coordinator);
590+
const capabilities = makeCapabilities();
591+
const executing: ReactorCapabilities = {
592+
...capabilities,
593+
executeTools: (): ReactorAction => {
594+
throw new Error("tool execution exploded");
595+
},
596+
};
597+
598+
await expect(
599+
director.decide(keeperManageTasksPlusSubmitTurn(), mockState, executing),
600+
).rejects.toThrow("tool execution exploded");
601+
602+
const brokenState = {
603+
get turns(): never {
604+
throw new Error("history unavailable");
605+
},
606+
} as unknown as ReactorState;
607+
await expect(
608+
director.decide(toolDoneEvent("submit-1"), brokenState, capabilities),
609+
).rejects.toThrow("history unavailable");
610+
expect(handleToolDoneSeen).toBe(true);
611+
612+
director.setWorkflowCoordinator(undefined);
613+
const actions = actionsArray(
614+
await director.decide(keeperTextTurn(), mockState, capabilities),
615+
);
616+
expect(keeperTasksChanged(actions)).toHaveLength(1);
617+
});
429618
});
430619

431620
// CL-7973: the director's live source id (which stamps retry decisions so a

‎src/exec/runner.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,13 @@ import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
132132
import type { ReactorEmittedEvent } from "@intx/inference";
133133
import { setAgentSourceUnlessClosed } from "../tui/agent-source-sync.js";
134134
import { ensureFreshInferenceSource } from "../subagent/refresh-inference-source.js";
135-
import { getToolApprovalBudget } from "../tui/tool-execution-watchdog.js";
135+
import {
136+
MAX_TOOL_APPROVAL_PAUSE_MS,
137+
getToolApprovalBudget,
138+
} from "../tui/tool-execution-watchdog.js";
139+
import { createApprovalDeliverer } from "../tui/approval-delivery.js";
140+
import { createCorrelationAcceptance } from "../tui/correlation-acceptance.js";
141+
import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js";
136142
import { WorkflowHost } from "../workflows/host.js";
137143

138144
const logger = getLogger([LOG_NAMESPACE_ROOT, "exec"]);
@@ -968,10 +974,20 @@ export async function runExec(config: Config): Promise<ExecResult> {
968974
// Consume-once gate for the compaction continuation emit: a replayed
969975
// duplicate of an already-answered emission must not re-deliver.
970976
const continuationGate = createContinuationGate();
977+
// Approval-delivery parity with the TUI: the resume routes its deliver
978+
// through the shared acceptance-tracked deliverer, and the stream sink
979+
// below feeds acceptances so a delivered decision settles instead of
980+
// timing out.
981+
const approvalAcceptance = createCorrelationAcceptance();
982+
const approvalDeliverer = createApprovalDeliverer({
983+
deliverToAgent: (message) => activeAgent.deliver(message),
984+
acceptance: approvalAcceptance,
985+
});
971986
// Cycles persist to the context store only on inference.done; the recorder
972987
// keeps the in-flight cycle's text so an errored or aborted turn leaves
973988
// its partial output in partial.jsonl instead of vanishing.
974989
const sink = (event: ReactorEmittedEvent): void => {
990+
approvalAcceptance.observe(event);
975991
// Chat-director reactor events (replacing the former onTasksChange /
976992
// onActivateTools closures). Exec mode has no live task panel or task
977993
// stdout output today (unlike the TUI's chrome zone) — debug logging
@@ -1062,6 +1078,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
10621078
resolveParkedCallId: (correlationId) =>
10631079
resolveParkedCallIdFromStore(activeStorage, correlationId),
10641080
gate: permissionGate,
1081+
deliver: async (message, stillCurrent) => {
1082+
if (!stillCurrent()) return;
1083+
await approvalDeliverer.deliver(message);
1084+
},
10651085
}).handle(sendResult);
10661086
sendCompleted = true;
10671087
runError = runSink.getRunError();
@@ -1307,9 +1327,28 @@ async function promptPermission(
13071327
});
13081328
stderr.write(` [${scopes.length + 1}] Deny\n`);
13091329
const rl = createInterface({ input, output: stderr });
1330+
let timer: ReturnType<typeof setTimeout> | undefined;
13101331
try {
1311-
const line = (await rl.question("Choice: ")).trim();
1312-
const n = Number(line);
1332+
// The reactor bounds an approval suspension (default 1h); the prompt must
1333+
// settle below that so the gate answers instead of the suspension timing
1334+
// out. The deadline reuses the tool-budget approval-pause ceiling.
1335+
const answer = rl.question("Choice: ");
1336+
const expired = new Promise<undefined>((resolve) => {
1337+
timer = setTimeout(() => resolve(undefined), MAX_TOOL_APPROVAL_PAUSE_MS);
1338+
});
1339+
const line = await Promise.race([answer, expired]);
1340+
if (line === undefined) {
1341+
// The deadline won: rl.close() in the finally below does not settle a
1342+
// pending question, so abandon the loser explicitly. It can never
1343+
// resolve after close; the attached settlement keeps it contained (no
1344+
// floating promise) if a future runtime ever settles it.
1345+
void answer.then(
1346+
() => undefined,
1347+
() => undefined,
1348+
);
1349+
return { allow: false, message: APPROVAL_TIMEOUT_RESULT_TEXT };
1350+
}
1351+
const n = Number(line.trim());
13131352
if (!Number.isInteger(n) || n < 1 || n > scopes.length) {
13141353
return { allow: false };
13151354
}
@@ -1322,6 +1361,7 @@ async function promptPermission(
13221361
...(chosen.pattern !== null ? { persist: chosen } : {}),
13231362
};
13241363
} finally {
1364+
if (timer !== undefined) clearTimeout(timer);
13251365
rl.close();
13261366
if (pauseToken !== undefined) budget?.resume(pauseToken);
13271367
}

0 commit comments

Comments
 (0)