Skip to content

Commit e555abd

Browse files
committed
Restore strip status when a followup turn is rejected
A rejected resume_agent followup left status running, so interrupt_agent succeeded with no turn in flight. Restore done or interrupted linger, and keep interruptOne's stamp if the followup then aborts. Shipped 0.3.0 and 0.3.1 notes keep followup_task.
1 parent 2879f21 commit e555abd

3 files changed

Lines changed: 72 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
226226

227227
### Fixed
228228

229-
- Retained worker sessions (`spawn_agent`, resumable via `resume_agent`) now have
229+
- Retained worker sessions (`spawn_agent`, resumable via `resume_agent`/`followup_task`) now have
230230
their own retention cap, separate from the TUI's finished-session display cap. Previously they
231231
shared that 20-item cap, so `resume_agent` on an early worker failed with a bare `not_found` once
232232
a fan-out of more than 20 workers had finished. A session dropped by the retention cap still
233233
releases its sidecars/reactor/lock entry, always evicts least-recently-used first, and never
234-
evicts a running session. `resume_agent` against an evicted session now reports
234+
evicts a running session. `resume_agent`/`followup_task` against an evicted session now report
235235
its terminal status plus a pointer to `read_agent_trace`, instead of `not_found`.
236236

237237
## [0.3.0] - 2026-08-24
@@ -273,17 +273,19 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
273273
operator interrupts it (`interrupt_agent`) rather than the harness enforcing
274274
a count.
275275

276-
- Added `interrupt_agent({ target })`, the second half of reusable worker
277-
sessions: `interrupt_agent` stops a retained worker's current turn while
278-
keeping it and its context alive (distinct from the permanent `close_agent`).
279-
Starting the next turn on that retained session is `resume_agent`. Both are
280-
gated to orchestrator tiers via the existing fleet-verb mechanism, denied to
281-
leaves. `interrupt_agent` fires a signal scoped only to the in-flight
282-
`agent.send()` call, never `close()`, so it cannot hit the close()-ordering
283-
workdir-lock issue tracked separately — the underlying reactor cycle keeps
284-
running in the background (there is no lower-level stop primitive for that in
285-
the vendored agent), so this is an approximation: it stops the caller from
286-
waiting, not the worker's compute.
276+
- Added `interrupt_agent({ target })` and `followup_task({ target, message })`,
277+
the second half of reusable worker sessions: `interrupt_agent` stops a
278+
retained worker's current turn while keeping it and its context alive
279+
(distinct from the permanent `close_agent`), and `followup_task` sends new
280+
work into a retained worker's existing session, reusing its prior context
281+
and tool outputs rather than starting fresh. Both are gated to orchestrator
282+
tiers via the existing fleet-verb mechanism, denied to leaves. `interrupt_agent`
283+
fires a signal scoped only to the in-flight `agent.send()` call, never
284+
`close()`, so it cannot hit the close()-ordering workdir-lock issue tracked
285+
separately — the underlying reactor cycle keeps running in the background
286+
(there is no lower-level stop primitive for that in the vendored agent), so
287+
this is an approximation: it stops the caller from waiting, not the
288+
worker's compute.
287289
- `evaluateSubAgentStop` now always requires the final assistant text; the
288290
omitted-text branch that unconditionally completed a tool-less turn is
289291
removed, so every call path gets the `incomplete-report` nudge and salvage

src/subagent/session-store.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,55 @@ describe("CL-6943 reusable worker sessions", () => {
369369
expect(store.get(session.id)?.lifecycleStatus).toBe("completed");
370370
});
371371

372+
test("rejected followup restores strip status so interrupt_agent fails closed", async () => {
373+
const store = createSubAgentSessionStore();
374+
const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true });
375+
store.markRunning(session.id);
376+
store.registerInterrupt(session.id, () => {});
377+
store.registerFollowup(session.id, async () => {
378+
throw new Error("send failed");
379+
});
380+
store.complete(session.id, "## Summary\nDone.");
381+
expect(store.get(session.id)?.status).toBe("done");
382+
expect(store.get(session.id)?.lifecycleStatus).toBe("completed");
383+
384+
expect(store.resumeOne(session.id, "continue")).toEqual({ ok: true, status: "running" });
385+
await new Promise((resolve) => setTimeout(resolve, 0));
386+
387+
const after = store.get(session.id);
388+
expect(after?.status).toBe("done");
389+
expect(after?.lifecycleStatus).toBe("completed");
390+
expect(store.interruptOne(session.id)).toEqual({ ok: false, status: "completed" });
391+
});
392+
393+
test("interrupt then abort does not overwrite interrupted stamp to completed", async () => {
394+
let rejectFollowup: (err: unknown) => void = () => {};
395+
const store = createSubAgentSessionStore();
396+
const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true });
397+
store.markRunning(session.id);
398+
store.registerInterrupt(session.id, () => {});
399+
store.registerFollowup(
400+
session.id,
401+
() =>
402+
new Promise<string>((_resolve, reject) => {
403+
rejectFollowup = reject;
404+
}),
405+
);
406+
store.complete(session.id, "## Summary\nDone.");
407+
408+
expect(store.resumeOne(session.id, "continue")).toEqual({ ok: true, status: "running" });
409+
expect(store.interruptOne(session.id).ok).toBe(true);
410+
expect(store.get(session.id)?.status).toBe("running");
411+
expect(store.get(session.id)?.lifecycleStatus).toBe("interrupted");
412+
413+
rejectFollowup(new Error("aborted"));
414+
await new Promise((resolve) => setTimeout(resolve, 0));
415+
416+
const after = store.get(session.id);
417+
expect(after?.status).toBe("running");
418+
expect(after?.lifecycleStatus).toBe("interrupted");
419+
});
420+
372421
test("resume_agent fails on a session that was never retained", () => {
373422
const store = createSubAgentSessionStore();
374423
const session = store.start({ description: "d", agentId: "a", brief: "b" });

src/subagent/session-store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,9 @@ export function createSubAgentSessionStore(
600600
// A follow-up turn takes the lane back over: the worker is live again, so
601601
// the interrupt's linger stamp must not outlive the new turn. Completion
602602
// re-stamps through the caller's own mutate; a rejected turn restores the
603-
// addressable state it started from so resume_agent can retry.
603+
// addressable strip state it started from (done, or interrupted linger) so
604+
// resume_agent can retry. interrupt_agent's stamp on this turn wins over
605+
// that restore — do not rewrite interrupted back to completed.
604606
const beginFollowupTurn = (id: string): void => {
605607
mutate(id, (s) => {
606608
s.status = "running";
@@ -610,8 +612,13 @@ export function createSubAgentSessionStore(
610612
};
611613
const endFollowupTurn = (id: string, lifecycleStatus: AgentLifecycleStatus): void => {
612614
mutate(id, (s) => {
615+
if (s.lifecycleStatus === "interrupted") {
616+
s.finishedAt = s.finishedAt ?? now();
617+
return;
618+
}
613619
s.lifecycleStatus = lifecycleStatus;
614620
s.finishedAt = now();
621+
s.status = lifecycleStatus === "interrupted" ? "running" : "done";
615622
});
616623
};
617624
const queueFollowupTurn = (

0 commit comments

Comments
 (0)