diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e920e6d9..3dc49f723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,19 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename operator interrupts it (`interrupt_agent`) rather than the harness enforcing a count. +- Added `interrupt_agent({ target })` and `followup_task({ target, message })`, + the second half of reusable worker sessions: `interrupt_agent` stops a + retained worker's current turn while keeping it and its context alive + (distinct from the permanent `close_agent`), and `followup_task` sends new + work into a retained worker's existing session, reusing its prior context + and tool outputs rather than starting fresh. Both are gated to orchestrator + tiers via the existing fleet-verb mechanism, denied to leaves. `interrupt_agent` + fires a signal scoped only to the in-flight `agent.send()` call, never + `close()`, so it cannot hit the close()-ordering workdir-lock issue tracked + separately — the underlying reactor cycle keeps running in the background + (there is no lower-level stop primitive for that in the vendored agent), so + this is an approximation: it stops the caller from waiting, not the + worker's compute. - `evaluateSubAgentStop` now always requires the final assistant text; the omitted-text branch that unconditionally completed a tool-less turn is removed, so every call path gets the `incomplete-report` nudge and salvage diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index b581cc43e..49a9638e3 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -449,8 +449,10 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { // CL-6943: keep the session open after a clean completion, and hand // the store a bounded close for close_agent to call later. persist: true, - onAgentReady: (close) => { + onAgentReady: ({ close, interrupt, followup }) => { deps.sessions.registerClose(session.id, close); + deps.sessions.registerInterrupt(session.id, interrupt); + deps.sessions.registerFollowup(session.id, followup); deps.sessions.markRunning(session.id); }, }; @@ -466,6 +468,11 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { .run(params) .then((result) => { if (childCtl.signal.aborted) return; + // CL-6997: interrupt_agent already flipped this session to + // "interrupted" synchronously (session-store.interruptOne) — do + // not let the settling promise's normal bookkeeping overwrite + // that with a "completed" status. + if (result.interrupted === true) return; deps.fleetRecords.resolve(session.id, result.report); // CL-7001: result.agentRetained is only true on run.ts's clean- // completion path when persist actually skipped teardown — a diff --git a/src/subagent/authority.test.ts b/src/subagent/authority.test.ts index 4826eaeb1..cf38fa6ef 100644 --- a/src/subagent/authority.test.ts +++ b/src/subagent/authority.test.ts @@ -14,6 +14,11 @@ describe("assertTierMayMountFleetVerb", () => { // CL-6943: the reusable-session verbs are gated the same way. expect(() => assertTierMayMountFleetVerb("leaf", "close_agent")).toThrow(FleetAuthorityError); expect(() => assertTierMayMountFleetVerb("leaf", "resume_agent")).toThrow(FleetAuthorityError); + // CL-6997: interrupt_agent / followup_task are gated the same way. + expect(() => assertTierMayMountFleetVerb("leaf", "interrupt_agent")).toThrow( + FleetAuthorityError, + ); + expect(() => assertTierMayMountFleetVerb("leaf", "followup_task")).toThrow(FleetAuthorityError); }); test("leaves may still mount non-fleet tools", () => { diff --git a/src/subagent/followup-live-agent.test.ts b/src/subagent/followup-live-agent.test.ts new file mode 100644 index 000000000..c589c1eed --- /dev/null +++ b/src/subagent/followup-live-agent.test.ts @@ -0,0 +1,160 @@ +/** + * CL-6997 regression guard: lifecycle-tools.test.ts proves interrupt_agent / + * followup_task behave correctly against *fake registered closures* at the + * tool/store layer — it never exercises run.ts's real wiring, where + * `followup` calls `agent!.send()` on the same live agent object created by + * `createAgentWithLiveToolDispatch`. A future refactor could make + * `followup_task` rebuild the agent instead of reusing it (exactly the + * regression this feature exists to prevent — a rebuilt agent means the + * worker re-reads the codebase from scratch) without failing any existing + * test. + * + * This test drives the real `runSubAgent` (run.ts) end to end with the one + * real dependency that would require live inference credentials — + * `createAgentWithLiveToolDispatch` — replaced by a stub `Agent`. Everything + * else (tool assembly, environment gathering, the dispatch brief, the + * onAgentReady wiring, the interrupt/followup closures themselves) is the + * genuine run.ts code path. + */ +import { describe, expect, test } from "bun:test"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; +import { createPermissionGate } from "../permission/gate.js"; +import type { RunSubAgentParams } from "./types.js"; + +const testPermissionGate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, +}); + +async function tmpCwd(): Promise { + return mkdtemp(join(tmpdir(), "cl6997-live-agent-")); +} + +/** Minimal stand-in for the vendored `Agent` (dist/agent.d.ts), instrumented + * to prove reuse: `sendLog` accumulates every message across BOTH the + * original send and the later followup send, and rejects like the real + * `Agent.send`'s documented `signal` option when its signal fires. */ +function createStubAgent() { + const sendLog: string[] = []; + return { + sendLog, + async send(content: string, opts?: { signal?: AbortSignal }) { + sendLog.push(content); + return await new Promise((resolve, reject) => { + if (opts?.signal?.aborted === true) { + reject(opts.signal.reason instanceof Error ? opts.signal.reason : new Error("aborted")); + return; + } + const timer = setTimeout( + () => + resolve({ + reply: `reply #${sendLog.length}`, + turn: { role: "assistant", content: [] }, + }), + 20, + ); + opts?.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject( + opts.signal!.reason instanceof Error ? opts.signal!.reason : new Error("aborted"), + ); + }, + { once: true }, + ); + }); + }, + stream: () => (async function* () {})(), + deliver: () => {}, + close: async () => {}, + setSource: () => {}, + setSources: () => {}, + history: async () => [], + checkpoints: async () => [], + readAt: async () => [], + blobReader: {}, + }; +} + +describe("interrupt_agent / followup_task reuse the same live agent (CL-6997)", () => { + test("followup after interrupt sends into the SAME agent instance — not a rebuilt one", async () => { + const cwd = await tmpCwd(); + let constructions = 0; + let capturedAgent: ReturnType | undefined; + + const outcome = await withMockedModuleDuring( + import.meta.resolve("../agent/live-tool-dispatch.js"), + (real: typeof import("../agent/live-tool-dispatch.js")) => ({ + ...real, + createAgentWithLiveToolDispatch: async () => { + constructions++; + const stub = createStubAgent(); + capturedAgent = stub; + return stub as unknown as Awaited< + ReturnType + >; + }, + }), + async () => { + const { runSubAgent } = await import("./run.js"); + + let handles: + | { + close: (ms?: number) => Promise; + interrupt: () => void; + followup: (message: string) => Promise; + } + | undefined; + + const params: RunSubAgentParams = { + cwd, + workdirBase: join(cwd, ".ctx"), + permissionGate: testPermissionGate, + provider: { providerName: "test", baseURL: "http://localhost", model: "test-model" }, + description: "live-agent reuse probe", + prompt: "explore the codebase for the bug", + persist: true, + onAgentReady: (h) => { + handles = h; + }, + }; + + const runPromise = runSubAgent(params); + + // onAgentReady fires before agent.send() is awaited; poll briefly + // rather than assume a fixed number of ticks. + for (let i = 0; i < 500 && handles === undefined; i++) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + if (handles === undefined) throw new Error("onAgentReady never fired"); + + handles.interrupt(); + const interruptedResult = await runPromise; + + const reply = await handles.followup("do X instead, not what the original prompt said"); + return { interruptedResult, reply }; + }, + ); + + expect(outcome.interruptedResult.interrupted).toBe(true); + // Exactly one agent was ever constructed across the interrupted turn and + // the followup — a rebuild would show up here as constructions === 2. + expect(constructions).toBe(1); + expect(capturedAgent).toBeDefined(); + + // The load-bearing assertion: the SAME agent's message log holds both + // the original turn's prompt and the followup message, proving the + // followup was sent into the same live object rather than a fresh one + // with empty history. + expect(capturedAgent!.sendLog.length).toBe(2); + expect(capturedAgent!.sendLog[0]).toContain("explore the codebase for the bug"); + expect(capturedAgent!.sendLog[1]).toBe("do X instead, not what the original prompt said"); + expect(outcome.reply).toBe("reply #2"); + }); +}); diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index b3ea937a3..a853022d1 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -1,10 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { createCloseAgentTool, createResumeAgentTool } from "./lifecycle-tools.js"; +import { + createCloseAgentTool, + createResumeAgentTool, + createInterruptAgentTool, + createFollowupTaskTool, +} from "./lifecycle-tools.js"; import { createSubAgentSessionStore } from "./session-store.js"; async function callTool( - tool: ReturnType | ReturnType, + tool: + | ReturnType + | ReturnType + | ReturnType + | ReturnType, args: Record, ): Promise> { if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`); @@ -101,3 +110,151 @@ describe("resume_agent", () => { expect(rawResult.isError).toBe(true); }); }); + +describe("interrupt_agent / followup_task", () => { + test("interrupt then followup keeps prior context — the worker does not re-read from scratch", async () => { + const sessions = createSubAgentSessionStore(); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + + // Simulates the live agent's own message history (what run.ts's + // `followup`/`interrupt` closures actually close over) — a shared array, + // not something recreated per call. + const history: string[] = ["read src/index.ts", "found the bug on line 12"]; + let interruptFired = false; + sessions.registerInterrupt(worker.id, () => { + interruptFired = true; + }); + sessions.registerFollowup(worker.id, async (message: string) => { + history.push(message); + return `Applying fix given ${history.length} prior turns of context.`; + }); + + const interruptAgent = createInterruptAgentTool({ sessions }); + const followupTask = createFollowupTaskTool({ sessions }); + + const interruptResult = await callTool(interruptAgent, { target: worker.id }); + expect(interruptResult.status).toBe("interrupted"); + expect(interruptFired).toBe(true); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("interrupted"); + + const followupResult = await callTool(followupTask, { + target: worker.id, + message: "actually fix line 12 directly, not line 20", + }); + expect(followupResult.status).toBe("completed"); + + // The load-bearing assertion: the worker's own history object still + // holds the turns that predate the interrupt, plus the new one appended + // in place — not a fresh array the followup started from empty. + expect(history).toEqual([ + "read src/index.ts", + "found the bug on line 12", + "actually fix line 12 directly, not line 20", + ]); + expect(history.length).toBe(3); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("completed"); + expect(sessions.get(worker.id)?.report).toBe(followupResult.reply as string); + }); + + test("followup_task on a completed retained worker reuses its existing session, not a fresh one", async () => { + const sessions = createSubAgentSessionStore(); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + const history: string[] = ["did the first task"]; + sessions.registerFollowup(worker.id, async (message: string) => { + history.push(message); + return `done, history now ${history.length} turns`; + }); + sessions.complete(worker.id, "## Summary\nFirst task done."); + + const followupTask = createFollowupTaskTool({ sessions }); + const result = await callTool(followupTask, { target: worker.id, message: "now do task two" }); + + expect(result.status).toBe("completed"); + // Same session id throughout — never re-created — and its underlying + // history object grew rather than being replaced. + expect(sessions.get(worker.id)?.id).toBe(worker.id); + expect(history).toEqual(["did the first task", "now do task two"]); + + const nonRetained = sessions.start({ description: "d2", agentId: "a", brief: "b" }); + sessions.complete(nonRetained.id, "## Summary\nDone."); + if (followupTask.kind !== "full") throw new Error("expected full tool"); + const rejected = await followupTask.handler( + { + id: "c3", + name: "followup_task", + arguments: { target: nonRetained.id, message: "more work" }, + }, + new AbortController().signal, + ); + expect(rejected.isError).toBe(true); + }); + + test("an interrupted session is resumable via followup_task and interrupt never touches close()", async () => { + const sessions = createSubAgentSessionStore(); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + + let closeCalls = 0; + sessions.registerClose(worker.id, async () => { + closeCalls++; + }); + sessions.registerInterrupt(worker.id, () => { + // Real interrupt handle: fires a dedicated signal, never close(). + }); + sessions.registerFollowup(worker.id, async () => "resumed cleanly"); + + const interruptAgent = createInterruptAgentTool({ sessions }); + const followupTask = createFollowupTaskTool({ sessions }); + + await callTool(interruptAgent, { target: worker.id }); + expect(closeCalls).toBe(0); + + const followupResult = await callTool(followupTask, { target: worker.id, message: "continue" }); + expect(followupResult.status).toBe("completed"); + expect(closeCalls).toBe(0); + // No lock-strand risk from this path: close() was never invoked, so the + // workdir lock close_agent's bounded teardown would otherwise release + // was never at risk of being held by a wedged close in the first place. + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("completed"); + }); + + test("interrupt_agent and followup_task fail closed on a non-running / non-retained target", async () => { + const sessions = createSubAgentSessionStore(); + const notRunning = sessions.start({ description: "d", agentId: "a", brief: "b" }); + sessions.complete(notRunning.id, "## Summary\nDone."); + + const interruptAgent = createInterruptAgentTool({ sessions }); + const followupTask = createFollowupTaskTool({ sessions }); + + if (interruptAgent.kind !== "full") throw new Error("expected full tool"); + const interruptErr = await interruptAgent.handler( + { id: "c1", name: "interrupt_agent", arguments: { target: notRunning.id } }, + new AbortController().signal, + ); + expect(interruptErr.isError).toBe(true); + + if (followupTask.kind !== "full") throw new Error("expected full tool"); + const followupErr = await followupTask.handler( + { id: "c2", name: "followup_task", arguments: { target: notRunning.id, message: "x" } }, + new AbortController().signal, + ); + // Not retained, so followup_task must reject even though it is "completed". + expect(followupErr.isError).toBe(true); + }); +}); diff --git a/src/subagent/lifecycle-tools.ts b/src/subagent/lifecycle-tools.ts index 8f317e821..2d7575323 100644 --- a/src/subagent/lifecycle-tools.ts +++ b/src/subagent/lifecycle-tools.ts @@ -145,3 +145,105 @@ export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool { }, }); } + +const InterruptAgentArgs = type({ + target: "string", +}); + +export const interruptAgentToolDefinition: ToolDefinition = { + name: "interrupt_agent", + description: + "Stop a worker session's current turn while keeping the session and its context intact and " + + "reusable — distinct from close_agent, which is permanent. The worker's in-flight tool call or " + + "inference keeps running in the background (there is no way to hard-stop it without tearing the " + + "session down); this only stops the caller from waiting on it and marks the session " + + "'interrupted' so followup_task or resume_agent can pick it back up with full prior context. " + + "Fails on a session that is not currently running.", + inputSchema: { + type: "object", + properties: { + target: { type: "string", description: "agent_id of the session to interrupt." }, + }, + required: ["target"], + }, +}; + +export function createInterruptAgentTool(deps: LifecycleToolDeps): AgentTool { + return tool({ + definition: interruptAgentToolDefinition, + handler: async (call, _signal): Promise => { + const parsed = InterruptAgentArgs(call.arguments); + if (parsed instanceof type.errors) { + return lifecycleResult( + call.id, + `Error: interrupt_agent arguments invalid: ${parsed.summary}`, + ); + } + const target = parsed.target.trim(); + const outcome = deps.sessions.interruptOne(target); + if (!outcome.ok) { + return lifecycleResult( + call.id, + `Error: cannot interrupt "${target}" (status: ${outcome.status}).`, + ); + } + return lifecycleResult( + call.id, + JSON.stringify({ agent_id: target, status: "interrupted" satisfies AgentLifecycleStatus }), + ); + }, + }); +} + +const FollowupTaskArgs = type({ + target: "string", + message: "string", +}); + +export const followupTaskToolDefinition: ToolDefinition = { + name: "followup_task", + description: + "Send new work into an existing retained worker session (one that is 'completed' or " + + "'interrupted'), reusing its prior context and tool outputs rather than starting a fresh worker. " + + "Blocks until the worker replies to this new message, and returns its reply. Fails on a session " + + "that was never retained, is still running, or was closed via close_agent (closing is permanent).", + inputSchema: { + type: "object", + properties: { + target: { type: "string", description: "agent_id of the retained session to resume." }, + message: { type: "string", description: "The new instruction/message for the worker." }, + }, + required: ["target", "message"], + }, +}; + +export function createFollowupTaskTool(deps: LifecycleToolDeps): AgentTool { + return tool({ + definition: followupTaskToolDefinition, + handler: async (call, _signal): Promise => { + const parsed = FollowupTaskArgs(call.arguments); + if (parsed instanceof type.errors) { + return lifecycleResult( + call.id, + `Error: followup_task arguments invalid: ${parsed.summary}`, + ); + } + const target = parsed.target.trim(); + const message = parsed.message.trim(); + if (message.length === 0) { + return lifecycleResult(call.id, "Error: followup_task requires a non-empty message."); + } + const outcome = await deps.sessions.followupOne(target, message); + if (!outcome.ok) { + return lifecycleResult( + call.id, + `Error: cannot send followup to "${target}" (status: ${outcome.status}).`, + ); + } + return lifecycleResult( + call.id, + JSON.stringify({ agent_id: target, status: "completed", reply: outcome.reply }), + ); + }, + }); +} diff --git a/src/subagent/run.ts b/src/subagent/run.ts index d30c14ea3..0db131fee 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -107,7 +107,12 @@ import { } from "./dispose.js"; import { createTaskTool } from "./task-tool.js"; import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool } from "./agent-fleet.js"; -import { createCloseAgentTool, createResumeAgentTool } from "./lifecycle-tools.js"; +import { + createCloseAgentTool, + createResumeAgentTool, + createInterruptAgentTool, + createFollowupTaskTool, +} from "./lifecycle-tools.js"; import { createSubAgentSessionStore } from "./session-store.js"; import type { RunSubAgentParams, RunSubAgentResult, SubAgentProvider } from "./types.js"; import type { TaskIntent } from "./report.js"; @@ -338,6 +343,18 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { + if (!interruptController.signal.aborted) { + interruptController.abort(new Error("interrupted by interrupt_agent")); + } + }; + // CL-6997: followup_task's payoff — call agent.send() again on the + // same live agent object. The vendored send-queue serializes this + // behind whatever cycle was in flight (interrupted or not), and + // agent.history()/the context store already hold every prior turn, so + // this reuses full context rather than starting fresh. + const followup = async (message: string): Promise => { + const result = await agent!.send(message, { signal: runController.signal }); + return result.reply.trim().length > 0 + ? result.reply.trim() + : "Sub-agent finished without a textual result."; + }; + params.onAgentReady({ close: boundedClose, interrupt, followup }); } const fullPrompt = buildDispatchBrief({ @@ -849,7 +889,14 @@ export async function runSubAgent(params: RunSubAgentParams): Promise 0 ? lastPartialText : abortedCycleText.slice(-2000); + return { + report: appendActivitySummary( + forcedStopReport("cancelled", tail, "interrupted by interrupt_agent"), + toolNamesUsed, + ), + stopReason: "cancelled", + interrupted: true, + }; + } if (isSubAgentCancelError(err, runController.signal)) { // Close the recorder against the dead cycle before its inference.error // arrives: closing at entry stops that auto-flush from mislabeling this @@ -928,7 +993,11 @@ export async function runSubAgent(params: RunSubAgentParams): Promise void): void; + registerFollowup(id: string, followup: (message: string) => Promise): void; + // Fires the registered interrupt handle and flips lifecycleStatus to + // "interrupted" synchronously — the caller does not wait for the aborted + // run's promise to settle. Fails closed on anything not currently running + // or with no interrupt handle registered (e.g. a session past init). + interruptOne(id: string): { ok: true } | { ok: false; status: AgentLifecycleStatus }; + // Sends `message` through the registered followup handle (the same live + // agent, same context) and records the reply as this session's new report + // on success. Fails closed on a session that is not retained or not in a + // resumable state ("completed" or "interrupted"). + followupOne( + id: string, + message: string, + ): Promise<{ ok: true; reply: string } | { ok: false; status: AgentLifecycleStatus }>; subscribe(listener: () => void): () => void; clear(): void; } @@ -294,6 +312,10 @@ export function createSubAgentSessionStore( // Distinct from cancelHandles (a synchronous abort() signal) because // closing must be awaitable and bounded by a deadline. const closeHandles = new Map Promise>(); + // CL-6997: interrupt/followup handles, kept separate from closeHandles so + // an interrupt can never accidentally resolve to the close codepath. + const interruptHandles = new Map void>(); + const followupHandles = new Map Promise>(); const listeners = new Set<() => void>(); // Per-session revision counters, bumped on every mutation. Notify fires on @@ -481,6 +503,8 @@ export function createSubAgentSessionStore( // from growing duplicates when a tool call is retried. cancelHandles.delete(id); closeHandles.delete(id); + interruptHandles.delete(id); + followupHandles.delete(id); forgetRevision(id); const session: SubAgentSession = { id, @@ -781,10 +805,60 @@ export function createSubAgentSessionStore( } }); cancelHandles.delete(id); + interruptHandles.delete(id); + followupHandles.delete(id); pruneCompleted(); return "shutdown"; }, + registerInterrupt(id: string, interrupt: () => void): void { + if (!sessions.has(id)) return; + interruptHandles.set(id, interrupt); + }, + + registerFollowup(id: string, followup: (message: string) => Promise): void { + if (!sessions.has(id)) return; + followupHandles.set(id, followup); + }, + + interruptOne(id: string): { ok: true } | { ok: false; status: AgentLifecycleStatus } { + const session = sessions.get(id); + if (session === undefined) return { ok: false, status: "not_found" }; + if (session.status !== "running") return { ok: false, status: session.lifecycleStatus }; + const interrupt = interruptHandles.get(id); + if (interrupt === undefined) return { ok: false, status: session.lifecycleStatus }; + interrupt(); + mutate(id, (s) => { + s.lifecycleStatus = "interrupted"; + }); + return { ok: true }; + }, + + async followupOne( + id: string, + message: string, + ): Promise<{ ok: true; reply: string } | { ok: false; status: AgentLifecycleStatus }> { + const session = sessions.get(id); + if (session === undefined) return { ok: false, status: "not_found" }; + if ( + session.retained !== true || + (session.lifecycleStatus !== "completed" && session.lifecycleStatus !== "interrupted") + ) { + return { ok: false, status: session.lifecycleStatus }; + } + const followup = followupHandles.get(id); + if (followup === undefined) return { ok: false, status: session.lifecycleStatus }; + const reply = await followup(message); + mutate(id, (s) => { + s.status = "done"; + s.lifecycleStatus = "completed"; + s.finishedAt = now(); + s.report = reply; + pushEntry(s, { kind: "report", content: capText(reply, maxEntryChars) }); + }); + return { ok: true, reply }; + }, + resumeOne(id: string): { ok: true } | { ok: false; status: AgentLifecycleStatus } { const session = sessions.get(id); if (session === undefined) return { ok: false, status: "not_found" }; @@ -838,6 +912,8 @@ export function createSubAgentSessionStore( for (const id of closeHandles.keys()) releaseHandles(id); cancelHandles.clear(); closeHandles.clear(); + interruptHandles.clear(); + followupHandles.clear(); sessions.clear(); revisions.clear(); snapshotCache.clear(); diff --git a/src/subagent/types.ts b/src/subagent/types.ts index ca43fdec2..802a867f0 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -157,13 +157,33 @@ export type RunSubAgentParams = { persist?: boolean; /** * Fired once the underlying agent object exists (before the prompt is - * sent), with a bounded close function the caller can register for later - * (close_agent). Always fired regardless of `persist`, so a caller can - * close a still-running session too, not only a retained one. The deadline - * argument bounds how long teardown may take; a wedged close is abandoned - * (not awaited further) once it elapses rather than hanging the caller. + * sent), with handles the caller can register for later use against this + * session: + * + * - `close`: bounded teardown for close_agent (unchanged from CL-6943). + * - `interrupt`: stops the in-flight `agent.send()` by firing a signal + * scoped to that call only (CL-6997) — distinct from `close`'s + * AbortController, so firing it never touches agent.close() or the + * workdir lock. The reactor cycle itself keeps running in the + * background (same documented behavior as `Agent.send`'s own + * `signal` option); this only stops the caller from waiting on it. + * - `followup`: sends a new message into the same live agent (same + * history, same context store) once the current turn is no longer + * active — this is the resume mechanism `resume_agent`/`followup_task` + * build on, reusing `agent.send`'s own FIFO send-queue ordering rather + * than a second continuation scheme. + * + * Always fired regardless of `persist`, so a caller can act on a + * still-running session too, not only a retained one. The deadline + * argument to `close` bounds how long teardown may take; a wedged close is + * abandoned (not awaited further) once it elapses rather than hanging the + * caller. */ - onAgentReady?: (close: (deadlineMs?: number) => Promise) => void; + onAgentReady?: (handles: { + close: (deadlineMs?: number) => Promise; + interrupt: () => void; + followup: (message: string) => Promise; + }) => void; } & SubAgentSandboxDeps; /** runSubAgent's result: the parent-facing report plus, when force-stopped, the structured reason why (CL-6946 part 2) — classify outcomes from `stopReason`, never by parsing `report`. */ @@ -179,4 +199,11 @@ export interface RunSubAgentResult { * keep a disposed salvage from ever looking resumable. */ agentRetained?: boolean; + /** + * CL-6997: true only when this run ended because interrupt_agent fired + * (not a plain cancel/deadline) — the caller must not run its normal + * complete()/fail() bookkeeping over this result, since interrupt_agent + * already transitioned the session to "interrupted" synchronously. + */ + interrupted?: boolean; }