Skip to content

Commit af31a1b

Browse files
Merge pull request #730 from corbitsdev/cl-7265-make-resume_agent-start-retained-turns-and-delete
Start retained worker turns from resume_agent immediately
2 parents eca5320 + e555abd commit af31a1b

19 files changed

Lines changed: 592 additions & 360 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1919
`advance_workflow` is gone. Already-complete and not-current ids are
2020
acknowledged without advancing. The unused `autoAdvance` workflow field is
2121
removed.
22+
- `resume_agent(target, message)` starts the next turn on a retained completed
23+
or interrupted worker and returns immediately. `wait_agents` collects the
24+
reply. `send_input` steers only an in-flight running turn. Closed workers
25+
stay closed.
2226

2327
### Fixed
2428

docs/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ The ChatDirector counts consecutive assistant turns that contain tool calls and
130130

131131
#### Sub-agent stall management
132132

133-
`SubAgentDirector` tracks `lastActivityAt`, updated on every real `inference.done` and `tool.done`. Directors are pure `decide(event, ...)` functions with no timer of their own and the reactor has no proactive "idle" event, so a genuinely silent leaf (e.g. parked on a long-running background command with nothing else to do) produces no event for the director to react to. `runSubAgent` (`src/subagent/index.ts`) arms an external interval, at `subAgentStallTimeoutMs`, that pings the same content-less continuation channel the compaction governor uses to re-enter an idle reactor (`requestContinuation`). The director only acts on a ping if the elapsed time since `lastActivityAt` has crossed the timeout — a ping delivered while a tool call is still executing simply queues until that cycle finishes, so "no pending harness-tracked work" falls out of when the check can run at all rather than needing separate bookkeeping. The first stall past the timeout gets one continuation nudge (asking the leaf to check on the background work or report status); a second **consecutive** stall (no activity since that nudge) escalates to the existing salvage path, returning a `stalled` `forcedStopReport` with the same structured shape (summary/findings/blockers) as `turn-budget` and `cancelled`. Any real activity between pings resets the streak, so a leaf that is genuinely working through a slow single turn is never penalized. After the leaf has already replied with a terminal report (complete envelope or salvage), further empty continuations — idle-compact meter sync or stall pings — return `wait` instead of falling through to `DefaultDirector.infer`; only a non-empty parent message (`followup_task` / `send_input`) re-opens the brief.
133+
`SubAgentDirector` tracks `lastActivityAt`, updated on every real `inference.done` and `tool.done`. Directors are pure `decide(event, ...)` functions with no timer of their own and the reactor has no proactive "idle" event, so a genuinely silent leaf (e.g. parked on a long-running background command with nothing else to do) produces no event for the director to react to. `runSubAgent` (`src/subagent/index.ts`) arms an external interval, at `subAgentStallTimeoutMs`, that pings the same content-less continuation channel the compaction governor uses to re-enter an idle reactor (`requestContinuation`). The director only acts on a ping if the elapsed time since `lastActivityAt` has crossed the timeout — a ping delivered while a tool call is still executing simply queues until that cycle finishes, so "no pending harness-tracked work" falls out of when the check can run at all rather than needing separate bookkeeping. The first stall past the timeout gets one continuation nudge (asking the leaf to check on the background work or report status); a second **consecutive** stall (no activity since that nudge) escalates to the existing salvage path, returning a `stalled` `forcedStopReport` with the same structured shape (summary/findings/blockers) as `turn-budget` and `cancelled`. Any real activity between pings resets the streak, so a leaf that is genuinely working through a slow single turn is never penalized. After the leaf has already replied with a terminal report (complete envelope or salvage), further empty continuations — idle-compact meter sync or stall pings — return `wait` instead of falling through to `DefaultDirector.infer`; only a non-empty parent message (`resume_agent` / `send_input`) re-opens the brief.
134134

135135
**Intervention log**: every stop and nudge is appended as one JSONL record to `interventions.jsonl` in the firing leaf's trace dir (`src/subagent/intervention-log.ts`), carrying the trigger's measured value beside the threshold it crossed, the provider/model/family it fired on, and the run state at that moment (turns used vs budget, tool calls, read/edit counts). A refused parent re-dispatch is recorded on the parent side, where no leaf run exists to record it. The parent also appends one `outcome` record per completed dispatch — the salvage kind `classifyBriefSalvage` assigned, or a clean-complete marker, plus the dispatch count — so the log carries dispatch outcomes as well as interventions, and a stop record can later be read alongside what the dispatch it touched actually produced. Writes are fire-and-forget and swallow their own errors — a diagnostic must not be able to fail a run. `scripts/intervention-forensics.ts` aggregates these across local sessions: per-intervention counts by model family, the measured-value distribution against the threshold, two context columns (stops that fired on runs which had already edited files; stops that fired before half the turn budget was spent — neither is a measured false-positive rate, since either is equally consistent with a correct stop or a wrong one), and outcome counts by kind. This exists because every threshold in this tree was set by judgment and four of those judgments were later reverted — a threshold change is expected to cite this data (CL-6938).
136136

@@ -218,8 +218,8 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
218218

219219
Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:
220220

221-
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
222-
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, and `followup_task`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown.
221+
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
222+
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown.
223223
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.
224224

225225
#### Closed director fleet (`src/agent/directors/`)

src/agent/fleet-verbs-mount.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ const FLEET_VERBS = [
1717
"close_agent",
1818
"resume_agent",
1919
"interrupt_agent",
20-
"followup_task",
2120
"send_input",
2221
] as const;
2322

src/agent/tool-search.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ describe("createToolIndex", () => {
9292
"close_agent",
9393
"resume_agent",
9494
"interrupt_agent",
95-
"followup_task",
9695
"send_input",
9796
] as const) {
9897
expect(CORE_TOOL_NAMES).toContain(name);
@@ -243,7 +242,6 @@ describe("advertisedTools", () => {
243242
"close_agent",
244243
"resume_agent",
245244
"interrupt_agent",
246-
"followup_task",
247245
"send_input",
248246
] as const) {
249247
expect(prefix).toContain(name);

src/agent/tool-search.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ export const CORE_TOOL_NAMES: readonly string[] = [
4949
"close_agent",
5050
"resume_agent",
5151
"interrupt_agent",
52-
"followup_task",
5352
"send_input",
5453
];
5554

@@ -62,7 +61,6 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = [
6261
"close_agent",
6362
"resume_agent",
6463
"interrupt_agent",
65-
"followup_task",
6664
"send_input",
6765
];
6866

src/agent/tools.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ import {
5151
createCloseAgentTool,
5252
createResumeAgentTool,
5353
createInterruptAgentTool,
54-
createFollowupTaskTool,
5554
createSendInputTool,
5655
} from "../subagent/lifecycle-tools.js";
5756
import { parseManageTasksArgs } from "./tasks.js";
@@ -398,9 +397,8 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
398397
createWaitAgentsTool({ sessions: fleetSessions, fleetRecords }),
399398
createListAgentsTool({ sessions: fleetSessions, fleetRecords }),
400399
createCloseAgentTool({ sessions: fleetSessions, fleetRecords }),
401-
createResumeAgentTool({ sessions: fleetSessions }),
400+
createResumeAgentTool({ sessions: fleetSessions, fleetRecords }),
402401
createInterruptAgentTool({ sessions: fleetSessions, fleetRecords }),
403-
createFollowupTaskTool({ sessions: fleetSessions }),
404402
createSendInputTool({ sessions: fleetSessions, fleetRecords }),
405403
);
406404
}

src/subagent/agent-fleet.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe("spawn_agent + wait_agents", () => {
211211
// the store itself had already evicted and released the earliest
212212
// session, because a retained session shared the 20-item display cap
213213
// with every other finished session — exactly the shipped defect this
214-
// ticket fixes (resume_agent/followup_task failed with a bare
214+
// ticket fixes (resume_agent failed with a bare
215215
// "not_found" past 20 spawned workers, blaming the caller for nothing).
216216
// Open retained sessions now have their own cap (`maxRetained`, default
217217
// 50), so 25 of them all stay resumable; fleetRecords/wait_agents is

src/subagent/agent-fleet.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
704704
// store a bounded close for close_agent to call later.
705705
// Worktree cleanup is deferred until that close when the session
706706
// stays alive for followup (agentRetained / interrupt keep-alive) —
707-
// matching run.ts's persisting gate so followup_task does not hit a
707+
// matching run.ts's persisting gate so resume_agent does not hit a
708708
// removed cwd.
709709
persist: deps.persist !== false,
710710
onAgentReady: ({ close, interrupt, followup, deliver }) => {
@@ -802,7 +802,7 @@ interface WaitAgentsDeps {
802802
function isSoftInterrupted(
803803
session: ReturnType<SubAgentSessionStore["get"]>,
804804
): session is NonNullable<ReturnType<SubAgentSessionStore["get"]>> {
805-
// interrupt_agent keeps strip status "running" so followup_task can reuse
805+
// interrupt_agent keeps strip status "running" so resume_agent can reuse
806806
// the session. cancel() also sets lifecycleStatus "interrupted" but flips
807807
// status to "cancelled" — that path still owes wait_agents a salvage
808808
// report via fleetRecords, so it is not wait-terminal on its own.

src/subagent/authority.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ describe("assertTierMayMountFleetVerb", () => {
1414
// The reusable-session verbs are gated the same way.
1515
expect(() => assertTierMayMountFleetVerb("leaf", "close_agent")).toThrow(FleetAuthorityError);
1616
expect(() => assertTierMayMountFleetVerb("leaf", "resume_agent")).toThrow(FleetAuthorityError);
17-
// Interrupt_agent / followup_task are gated the same way.
17+
// Interrupt_agent / send_input are gated the same way.
1818
expect(() => assertTierMayMountFleetVerb("leaf", "interrupt_agent")).toThrow(
1919
FleetAuthorityError,
2020
);
21-
expect(() => assertTierMayMountFleetVerb("leaf", "followup_task")).toThrow(FleetAuthorityError);
21+
expect(() => assertTierMayMountFleetVerb("leaf", "send_input")).toThrow(FleetAuthorityError);
2222
});
2323

2424
test("leaves may still mount non-fleet tools", () => {

src/subagent/authority.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*
77
* - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb
88
* (task, spawn_agent, wait_agents, list_agents, interrupt_agent, close_agent,
9-
* resume_agent, followup_task, send_input, read_agent_trace, search_agents).
9+
* resume_agent, send_input, read_agent_trace, search_agents).
1010
* Fleet *discovery* of the director catalog (search_agents) is Tier 1 only
1111
* (CL-7051). list_agents is not catalog discovery — it lists this install's
1212
* own spawn_agent workers, the same scoped mailbox wait_agents uses, so
@@ -38,7 +38,6 @@ export const FLEET_VERBS = new Set([
3838
"close_agent",
3939
"resume_agent",
4040
"read_agent_trace",
41-
"followup_task",
4241
]);
4342

4443
/**
@@ -114,7 +113,7 @@ function isDescendant(
114113
* always fails closed here too.
115114
*
116115
* Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`,
117-
* `close_agent`, `resume_agent`, and `followup_task` (nested mounts pass
116+
* `close_agent`, and `resume_agent` (nested mounts pass
118117
* authority from run.ts; Tier-1 primary omits it and stays unrestricted).
119118
*/
120119
export function assertCanTargetAgent(

0 commit comments

Comments
 (0)