Skip to content

Commit 6ccc0c1

Browse files
fix(agents): skip stale orphaned subagent sessions during restart recovery
Add an isLiveUnendedSubagentRun guard in subagent orphan recovery so stale restart-aborted runs are finalized with an error instead of being resumed. This prevents resurrecting long-abandoned subagent runs after gateway restarts. - src/agents/subagent-orphan-recovery.ts: Import isLiveUnendedSubagentRun. Before recovery, check if the run is still live. Stale runs finalized inline via finalizeInterruptedSubagentRun. +27 lines. - src/agents/subagent-orphan-recovery.test.ts: 3 regression tests for stale/fresh/extended-timeout scenarios through the full production code path. +119 lines. - docs/tools/subagents.md: Update liveness-and-recovery section to describe stale-run finalization behavior. Fixes #90766
1 parent 418d7e1 commit 6ccc0c1

3 files changed

Lines changed: 154 additions & 4 deletions

File tree

docs/tools/subagents.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -605,10 +605,14 @@ stop counting as active/pending in `/subagents list`, status summaries,
605605
descendant completion gating, and per-session concurrency checks.
606606

607607
After a gateway restart, stale unended restored runs are pruned unless
608-
their child session is marked `abortedLastRun: true`. Those
609-
restart-aborted child sessions remain recoverable through the sub-agent
610-
orphan recovery flow, which sends a synthetic resume message before
611-
clearing the aborted marker.
608+
their child session is marked `abortedLastRun: true`. The orphan
609+
recovery flow checks whether the run is still within the subagent
610+
run-liveness window before attempting automatic recovery.
611+
Restart-aborted runs that exceed the configured liveness window are
612+
finalized with an error instead of being resumed, so long-abandoned
613+
subagents are not resurrected by gateway restarts. Runs still within
614+
the window remain recoverable: the flow sends a synthetic resume
615+
message before clearing the aborted marker.
612616

613617
Automatic restart recovery is bounded per child session. If the same
614618
sub-agent child is accepted for orphan recovery repeatedly inside the

src/agents/subagent-orphan-recovery.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,4 +658,123 @@ describe("subagent-orphan-recovery", () => {
658658
expect(finalizeParams.error).toContain("Automatic recovery failed after 2 attempts");
659659
expect(finalizeParams.error).toContain("service restart");
660660
});
661+
662+
// ── Stale-run liveness gate ──────────────────────────────────────────
663+
664+
it("finalizes stale restart-aborted runs inline instead of resuming", async () => {
665+
const staleStartedAt = Date.now() - 3 * 60 * 60 * 1_000;
666+
vi.mocked(sessions.loadSessionStore).mockReturnValue({
667+
"agent:main:subagent:test-session-1": {
668+
sessionId: "session-abc",
669+
updatedAt: Date.now(),
670+
abortedLastRun: true,
671+
},
672+
});
673+
674+
const activeRuns = createActiveRuns(
675+
createTestRunRecord({
676+
// started 3 hours ago — exceeds the 2-hour STALE_UNENDED_SUBAGENT_RUN_MS window
677+
startedAt: staleStartedAt,
678+
sessionStartedAt: staleStartedAt,
679+
createdAt: staleStartedAt - 60_000,
680+
}),
681+
);
682+
683+
const result = await recoverOrphanedSubagentSessions({
684+
getActiveRuns: () => activeRuns,
685+
});
686+
687+
expect(result.recovered).toBe(0);
688+
expect(result.failed).toBe(0);
689+
expect(result.skipped).toBe(1);
690+
expect(gateway.callGateway).not.toHaveBeenCalled();
691+
expect(
692+
subagentRegistrySteerRuntime.finalizeInterruptedSubagentRun,
693+
).toHaveBeenCalledOnce();
694+
const finalizeParams = requireRecord(
695+
firstCallParam(
696+
vi.mocked(subagentRegistrySteerRuntime.finalizeInterruptedSubagentRun)
697+
.mock.calls,
698+
"stale run finalization",
699+
),
700+
"stale run finalization params",
701+
);
702+
expect(finalizeParams.runId).toBe("run-1");
703+
expect(finalizeParams.childSessionKey).toBe(
704+
"agent:main:subagent:test-session-1",
705+
);
706+
expect(finalizeParams.error).toContain(
707+
"Subagent run was orphaned past the run-liveness window",
708+
);
709+
});
710+
711+
it("recovers fresh restart-aborted runs that are within the liveness window", async () => {
712+
const freshStartedAt = Date.now() - 30 * 60 * 1_000;
713+
vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "resumed-fresh" } as never);
714+
vi.mocked(sessions.loadSessionStore).mockReturnValue({
715+
"agent:main:subagent:test-session-1": {
716+
sessionId: "session-abc",
717+
updatedAt: Date.now(),
718+
abortedLastRun: true,
719+
},
720+
});
721+
722+
const activeRuns = createActiveRuns(
723+
createTestRunRecord({
724+
// 30 minutes ago — well within the 2-hour window
725+
startedAt: freshStartedAt,
726+
sessionStartedAt: freshStartedAt,
727+
createdAt: freshStartedAt - 60_000,
728+
}),
729+
);
730+
731+
const result = await recoverOrphanedSubagentSessions({
732+
getActiveRuns: () => activeRuns,
733+
});
734+
735+
expect(result.failed).toBe(0);
736+
expect(result.skipped).toBe(0);
737+
expect(result.recovered).toBe(1);
738+
expect(gateway.callGateway).toHaveBeenCalledOnce();
739+
expect(
740+
subagentRegistrySteerRuntime.finalizeInterruptedSubagentRun,
741+
).not.toHaveBeenCalled();
742+
});
743+
744+
it("recovers old restart-aborted runs that carry an explicit long timeout extending the window", async () => {
745+
const extendedStartedAt = Date.now() - 4 * 60 * 60 * 1_000;
746+
vi.mocked(gateway.callGateway).mockResolvedValue({ runId: "resumed-extended" } as never);
747+
vi.mocked(sessions.loadSessionStore).mockReturnValue({
748+
"agent:main:subagent:test-session-1": {
749+
sessionId: "session-abc",
750+
updatedAt: Date.now(),
751+
abortedLastRun: true,
752+
},
753+
});
754+
755+
const activeRuns = createActiveRuns(
756+
createTestRunRecord({
757+
// 4 hours ago — exceeds the default 2h window,
758+
// but the explicit 6-hour runTimeoutSeconds extends the cutoff to ~6h 1m
759+
startedAt: extendedStartedAt,
760+
sessionStartedAt: extendedStartedAt,
761+
createdAt: extendedStartedAt - 60_000,
762+
runTimeoutSeconds: 6 * 60 * 60,
763+
}),
764+
);
765+
766+
const result = await recoverOrphanedSubagentSessions({
767+
getActiveRuns: () => activeRuns,
768+
});
769+
770+
expect(result.failed).toBe(0);
771+
expect(result.skipped).toBe(0);
772+
expect(result.recovered).toBe(1);
773+
expect(result.failed).toBe(0);
774+
expect(result.skipped).toBe(0);
775+
expect(gateway.callGateway).toHaveBeenCalledOnce();
776+
expect(
777+
subagentRegistrySteerRuntime.finalizeInterruptedSubagentRun,
778+
).not.toHaveBeenCalled();
779+
});
661780
});

src/agents/subagent-orphan-recovery.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { readSessionMessagesAsync } from "../gateway/session-utils.fs.js";
2424
import { formatErrorMessage } from "../infra/errors.js";
2525
import { createSubsystemLogger } from "../logging/subsystem.js";
2626
import { resolveInternalSessionEffectsTranscriptPath } from "./internal-session-effects.js";
27+
import { isLiveUnendedSubagentRun } from "./subagent-run-liveness.js";
2728
import {
2829
evaluateSubagentRecoveryGate,
2930
markSubagentRecoveryAttempt,
@@ -253,6 +254,32 @@ export async function recoverOrphanedSubagentSessions(params: {
253254
continue;
254255
}
255256

257+
// If the run is stale per the existing subagent-run liveness policy,
258+
// finalize it instead of attempting automatic recovery. This prevents
259+
// resurrecting long-abandoned subagent runs after a gateway restart.
260+
// We finalize inline (not via failedRuns) so the parent is notified
261+
// immediately rather than waiting for scheduleOrphanRecovery retries.
262+
if (!isLiveUnendedSubagentRun(runRecord, now)) {
263+
log.warn(
264+
`skipping orphan recovery for ${childSessionKey}: subagent run is stale (startedAt=${runRecord.startedAt})`,
265+
);
266+
try {
267+
await finalizeInterruptedSubagentRun({
268+
runId,
269+
childSessionKey,
270+
error:
271+
"Subagent run was orphaned past the run-liveness window and " +
272+
"could not be automatically recovered. Please retry.",
273+
});
274+
} catch (finalizeErr) {
275+
log.warn(
276+
`failed to finalize stale orphaned subagent run ${runId}: ${String(finalizeErr)}`,
277+
);
278+
}
279+
result.skipped++;
280+
continue;
281+
}
282+
256283
const recoveryGate = evaluateSubagentRecoveryGate(entry, now);
257284
if (!recoveryGate.allowed) {
258285
if (recoveryGate.shouldMarkWedged) {

0 commit comments

Comments
 (0)