From d782308b420bc886cc0d17b4911b998396a04989 Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 2 Aug 2026 14:23:24 -0400 Subject: [PATCH] fix(pi): expire terminal subagents automatically --- hosts/neogaia/default.nix | 1 + .../extensions/subagents/supervisor.test.ts | 20 +++++++------ .../pi/extensions/subagents/supervisor.ts | 30 +++++++++++++++++-- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index 7126720..19c6fa7 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -69,6 +69,7 @@ modules.agents.tools.gitea-axi.enable = true; modules.agents.pi.enable = true; modules.agents.pi.subagents.maxConcurrent = 8; + modules.agents.pi.subagents.recentTerminalTtlMs = 15 * 60 * 1000; modules.desktop.enable = true; modules.desktop.obsidian.enable = true; diff --git a/modules/agents/pi/extensions/subagents/supervisor.test.ts b/modules/agents/pi/extensions/subagents/supervisor.test.ts index 97b5b3c..626ed3e 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.test.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.test.ts @@ -335,29 +335,31 @@ test("clearTerminal returns only removed terminal ids", async () => { assert.equal(supervisor.status(running.id).state, "running"); }); -test("terminal records stay listed past ttl and remain retrievable until cleared", async () => { +test("terminal records expire after ttl while active children remain", async () => { const runner = new FakeRunner(); const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 }); const completed = await spawnStarted(supervisor, "one"); const failed = await spawnStarted(supervisor, "two"); + const running = await spawnStarted(supervisor, "three"); runner.starts[0].events.completed("one done", "agent_settled"); runner.starts[1].events.failed("two failed"); - await sleep(10); - const listedIds = supervisor.list().map((status) => status.id); - assert.ok(listedIds.includes(completed.id)); - assert.ok(listedIds.includes(failed.id)); assert.equal(supervisor.result(completed.id).result, "one done"); assert.equal(supervisor.result(failed.id).error, "two failed"); + assert.equal(supervisor.status(running.id).state, "running"); - (supervisor as Supervisor & { clearTerminal(): void }).clearTerminal(); + await sleep(20); - const afterClearIds = supervisor.list().map((status) => status.id); - assert.equal(afterClearIds.includes(completed.id), false); - assert.equal(afterClearIds.includes(failed.id), false); + const listedIds = supervisor.list().map((status) => status.id); + assert.equal(listedIds.includes(completed.id), false); + assert.equal(listedIds.includes(failed.id), false); + assert.equal(listedIds.includes(running.id), true); assert.throws(() => supervisor.status(completed.id), /unknown subagent id/); + assert.throws(() => supervisor.status(failed.id), /unknown subagent id/); + assert.throws(() => supervisor.result(completed.id), /unknown subagent id/); assert.throws(() => supervisor.result(failed.id), /unknown subagent id/); + assert.equal(supervisor.status(running.id).state, "running"); }); test("zero recent terminal ttl does not hide terminal statuses", async () => { diff --git a/modules/agents/pi/extensions/subagents/supervisor.ts b/modules/agents/pi/extensions/subagents/supervisor.ts index 75963a8..c803beb 100644 --- a/modules/agents/pi/extensions/subagents/supervisor.ts +++ b/modules/agents/pi/extensions/subagents/supervisor.ts @@ -20,6 +20,7 @@ interface RunningChild { handle?: ChildHandle; startTimer?: ReturnType; runTimer?: ReturnType; + expiryTimer?: ReturnType; } interface SupervisorOptions { @@ -99,6 +100,7 @@ export class Supervisor { for (const [id, child] of this.children) { if (selectedIds && !selectedIds.includes(id)) continue; if (!isTerminal(child.record.status.state)) continue; + this.clearTimer(child, "expiryTimer"); cleared.push(id); this.children.delete(id); } @@ -159,6 +161,7 @@ export class Supervisor { } }), ); + for (const child of this.children.values()) this.clearTimer(child, "expiryTimer"); } private createChild(request: SpawnRequest): SpawnAccepted { @@ -250,7 +253,10 @@ export class Supervisor { this.recordActivity(record, "completed", now); record.status.stopReason = stopReason; record.status.resultAvailable = true; - if (child) this.emitMilestone(child, "completed"); + if (child) { + this.armTerminalExpiry(child); + this.emitMilestone(child, "completed"); + } this.pumpQueue(); }, failed: (error) => this.fail(record, error), @@ -269,7 +275,10 @@ export class Supervisor { this.recordActivity(record, "failed", now); record.status.error = error; record.status.stopReason = "failed"; - if (child) this.emitMilestone(child, "failed"); + if (child) { + this.armTerminalExpiry(child); + this.emitMilestone(child, "failed"); + } this.pumpQueue(); } @@ -283,6 +292,7 @@ export class Supervisor { child.record.status.lastEventAt = now; this.recordActivity(child.record, state, now); child.record.status.stopReason = reason; + this.armTerminalExpiry(child); this.emitMilestone(child, state); } @@ -309,12 +319,26 @@ export class Supervisor { this.pumpQueue(); } + private armTerminalExpiry(child: RunningChild) { + const ttl = this.options.recentTerminalTtlMs; + if (ttl === undefined || ttl <= 0) return; + this.clearTimer(child, "expiryTimer"); + child.expiryTimer = setTimeout(() => { + child.expiryTimer = undefined; + const id = child.record.status.id; + if (this.children.get(id) !== child || !isTerminal(child.record.status.state)) return; + this.children.delete(id); + this.emitChange(); + }, ttl); + child.expiryTimer.unref?.(); + } + private clearTimers(child: RunningChild) { this.clearTimer(child, "startTimer"); this.clearTimer(child, "runTimer"); } - private clearTimer(child: RunningChild, key: "startTimer" | "runTimer") { + private clearTimer(child: RunningChild, key: "startTimer" | "runTimer" | "expiryTimer") { const timer = child[key]; if (!timer) return; clearTimeout(timer);