diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 363117a9b..a22b589be 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -31,7 +31,7 @@ import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, ty import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts"; import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts"; import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts"; -import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts"; +import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask, type StoredTask } from "../lib/agents-history.ts"; import { sessionToMarkdown } from "../lib/agents-transcript.ts"; import { AgentsView } from "../lib/agents-view.ts"; import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; @@ -654,6 +654,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = .catch(() => {}); }; + // A launched task is written to disk immediately so abrupt parent process + // termination (SIGKILL, host reboot, crash) leaves a durable record (#1741). + // Pruning is deferred until final persistence in onFinish. + const persistLaunch = (task: TaskRecord) => { + void saveTask(tasksDir, task, store.thread(task.id)).catch(() => {}); + }; + // A background result used to be handed straight to the host as a followUp // message, but the host only drains that queue when the parent agent stops // calling tools entirely, so in a long orchestrator run the notification @@ -1177,16 +1184,39 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = return confirmation; }; + // A task stored on disk that never reached a terminal state was in flight + // when the parent process was abruptly terminated (kill -9, host reboot, + // OOM). When loaded into a new process where it has no live runner, it is + // reconciled into a terminal failed state and updated on disk (#1741). + const reconcileStored = async (stored: StoredTask): Promise => { + if (isFinished(stored.task.status)) return stored; + const reconciledTask: TaskRecord = { + ...stored.task, + status: TASK_STATUS.FAILED, + error: stored.task.error ?? "interrupted: parent process terminated while task was in flight", + lastStep: "interrupted", + endedAt: stored.task.endedAt ?? deps.now(), + }; + try { await saveTask(tasksDir, reconciledTask, stored.thread); } catch { /* best effort */ } + return { task: reconciledTask, thread: stored.thread }; + }; + // Tasks from earlier sessions come back from disk on demand. const resolveTask = async (id: string): Promise => { const live = store.get(id); - if (live) return live; + if (live && !restoredTaskIds.has(id)) return live; const stored = await loadStoredTask(tasksDir, id); if (stored) { - restoredTaskIds.add(stored.task.id); - store.restore(stored.task, stored.thread); + if (!isFinished(stored.task.status) && stored.task.parentSessionId !== activeSessionId()) { + return undefined; + } + const reconciled = await reconcileStored(stored); + restoredTaskIds.add(reconciled.task.id); + store.restore(reconciled.task, reconciled.thread); + store.update(reconciled.task.id, reconciled.task); + return reconciled.task; } - return stored?.task; + return live; }; // A guessed id ("1") leads back to real ids instead of a dead end, so the @@ -1492,6 +1522,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = launched = true; const foreign = foreignRequests.get(request); if (foreign && launchedTaskId) foreignTasks.set(launchedTaskId, foreign); + const current = store.get(task.id) ?? task; + persistLaunch(current); }, ...(observe ? { canCollectResponseObservations: metrics.valid, prepareResponseObservations: async () => { if (metrics.finished || owner !== metricsOwner || request.parentSessionId !== activeSessionId() || !runtimeMetricsEnvAllows(deps.env)) return false; @@ -1506,6 +1538,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = const foreignRequest = foreignRequests.get(request); if (launched && foreignRequest) foreignTasks.set(task.id, foreignRequest); ownedTaskIds.add(task.id); + persistLaunch(task); publishWork?.(task.id); publishActivity(); // Admission's summary notification precedes runtime ownership. store.subscribe(task.id, () => { publishActivity(); requestRender(); }); @@ -1875,8 +1908,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = }); tool("list_tasks", "List the subagent tasks of this session, newest first.", { properties: {} }, async (_params, ctx) => { - const tasks = store.list(ctx.sessionManager.getSessionId() ?? ""); - return text(tasks.length === 0 ? "No subagent tasks in this session." : tasks.map(describeTask).join("\n")); + const sessionId = ctx.sessionManager.getSessionId() ?? ""; + const tasks = store.list(sessionId); + for (const task of tasks) { + if (!isFinished(task.status) && restoredTaskIds.has(task.id)) { + await resolveTask(task.id); + } + } + const reconciledTasks = store.list(sessionId); + return text(reconciledTasks.length === 0 ? "No subagent tasks in this session." : reconciledTasks.map(describeTask).join("\n")); }); tool("reply", "Reply once to a live query from a child of the current parent session.", { required: ["task_id", "request_id", "message"], properties: { task_id: { type: "string" }, request_id: { type: "string" }, message: { type: "string" } } }, async (params, ctx) => { diff --git a/lib/agents-history.ts b/lib/agents-history.ts index a7638b875..84c8561dc 100644 --- a/lib/agents-history.ts +++ b/lib/agents-history.ts @@ -1,6 +1,6 @@ import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { emptyThread, type TaskRecord, type TaskThread } from "./agents-protocol.ts"; +import { emptyThread, isFinished, type TaskRecord, type TaskThread } from "./agents-protocol.ts"; // Gentle Agents history: one JSON file per finished task, written by the // host after the child is gone and read back lazily when the overlay opens @@ -74,8 +74,8 @@ export async function loadHistory(dir: string): Promise { // Keep the newest `maxTasks` files; the rest go. Returns how many were removed. export async function pruneHistory(dir: string, maxTasks: number): Promise { const stored = await loadHistory(dir); - // Preserve historical remediation payloads without interpreting or replaying their retired ledger. - const extra = stored.filter(({ task }) => task.sddRemediation === undefined).slice(Math.max(0, maxTasks)); + // Preserve historical remediation payloads and any active in-flight tasks. + const extra = stored.filter(({ task }) => task.sddRemediation === undefined && isFinished(task.status)).slice(Math.max(0, maxTasks)); await Promise.all(extra.map((entry) => rm(fileFor(dir, entry.task.id), { force: true }))); return extra.length; } diff --git a/odd/tasks/fix-1741-subagent-tasks-durable-launch-state.md b/odd/tasks/fix-1741-subagent-tasks-durable-launch-state.md new file mode 100644 index 000000000..56c66b9f3 --- /dev/null +++ b/odd/tasks/fix-1741-subagent-tasks-durable-launch-state.md @@ -0,0 +1,31 @@ +# Task Breakdown: Fix #1741 Subagent Tasks Durable Launch State & Interruption Reconciliation + +**Feature:** `fix-1741-subagent-tasks-durable-launch-state` +**Issue:** #1741 (`bug(agents): abrupt parent death loses running subagent tasks with no durable trace`) +**Strategy:** Strict TDD, ODD workflow, Conventional Commits + +--- + +## Tasks + +### 1. Test (RED): Write unit tests for launch persistence and abrupt-death reconciliation +- [ ] Add tests in `tests/gentle-agents.test.ts`: + - Verify that a launched task is durably written to disk (`tasksDir`) upon launch before any completion event. + - Verify that if the parent dies abruptly (no `session_shutdown` or `runner.cancelAll`), a subsequent session or `resolveTask` / `subagent_status` recovers the task in a terminal `failed` state with error `"interrupted: parent process terminated while task was in flight"`. + - Verify that `subagent_list_tasks` in a resumed session shows the interrupted task. + - Evidence: Red test failure before implementation. + +### 2. Implementation (GREEN): Persist on launch and reconcile interrupted stored tasks +- [ ] In `extensions/gentle-agents.ts`: + - Add `persistLaunch(task)` to persist the task to disk asynchronously upon launch (and update upon child process spawn). + - Add `reconcileStored(stored: StoredTask)` to reconcile un-terminalized tasks (`!isFinished(stored.task.status)`) into `TASK_STATUS.FAILED` with error `"interrupted: parent process terminated while task was in flight"`, `lastStep: "interrupted"`, and `endedAt`. Persist the reconciled state to disk. + - Wire `reconcileStored` into `resolveTask(id)` and `restoreSessionHistory(ctx, sessionId)`. + - Evidence: Green test pass. + +### 3. Verification & Regressions +- [ ] Run targeted test suite: `node --test tests/gentle-agents.test.ts`. +- [ ] Run full test suite and typecheck (`pnpm typecheck`, `pnpm test`). +- [ ] Evidence: 0 regressions, all tests green. + +### 4. Work-Unit Commit +- [ ] Create conventional commit: `fix(agents): persist durable launch state and reconcile interrupted subagent tasks (#1741)`. diff --git a/tests/agents-history.test.ts b/tests/agents-history.test.ts index ebfd7d7dd..ae2e02f5b 100644 --- a/tests/agents-history.test.ts +++ b/tests/agents-history.test.ts @@ -62,3 +62,20 @@ test("retired remediation payloads remain readable and are never automatically p assert.deepEqual((await loadStoredTask(legacyDir, "legacy"))?.task, JSON.parse(before).task); assert.equal(readFileSync(join(legacyDir, "legacy.json"), "utf8"), before); }); + +test("pruneHistory preserves in-flight unfinished tasks regardless of history cap", async () => { + const inFlightDir = join(root, "inflight-tasks"); + const inFlightTask: TaskRecord = { ...task("inflight", 500), status: TASK_STATUS.RUNNING, endedAt: null }; + const completedTask1 = task("c1", 1000); + const completedTask2 = task("c2", 2000); + await saveTask(inFlightDir, inFlightTask, emptyThread()); + await saveTask(inFlightDir, completedTask1, emptyThread()); + await saveTask(inFlightDir, completedTask2, emptyThread()); + + // Prune with maxTasks = 1 (should prune the older completed task c1, but keep inFlightTask) + await pruneHistory(inFlightDir, 1); + const remaining = await loadHistory(inFlightDir); + assert.ok(remaining.some((entry) => entry.task.id === "inflight"), "in-flight task is preserved"); + assert.ok(remaining.some((entry) => entry.task.id === "c2"), "newest completed task is preserved"); + assert.ok(!remaining.some((entry) => entry.task.id === "c1"), "older completed task is pruned"); +}); diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index b89344aa8..73d34bcab 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -18,7 +18,7 @@ import { sidebarState } from "../lib/shell-sidebar.ts"; import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, agentResultPreview, answerThroughUi, childContextExtensionPaths, completionText, createDefaultSessionTransport, legacySubagentsInstalled, PARENT_WAKE_GRACE_MS, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts"; import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts"; import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts"; -import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts"; +import { historyDir, loadHistory, loadStoredTask, saveTask } from "../lib/agents-history.ts"; import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts"; import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts"; import { NativePointerScope } from "../lib/native-pointer-region.ts"; @@ -3543,6 +3543,63 @@ test("a task that finishes live stays visible as history, in both scopes, and it await opened; }); +test("an in-flight task is persisted on launch, and reconciles to interrupted if the parent dies before it finishes (#1741)", async () => { + const { pi, tools, fire } = fakePi(); + const harness = deps(); + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + await fire("session_start", ctx); + const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Survive abrupt crash", mode: "background" }, undefined, undefined, ctx); + const id = (started.details.gentleAgents as { taskId: string }).taskId; + assert.ok(id, "task ID allocated"); + + // Wait for the async launch persistence to write the file + const tasksDir = join(home, ".pi", "agent", "gentle-agents", "tasks"); + let stored = await loadStoredTask(tasksDir, id); + for (let attempt = 0; attempt < 10 && !stored; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + stored = await loadStoredTask(tasksDir, id); + } + assert.ok(stored, "the in-flight task is written to disk upon launch"); + assert.equal(stored.task.id, id); + assert.ok(stored.task.status === "queued" || stored.task.status === "running", "task status is in-flight on disk"); + assert.equal(stored.task.endedAt, null, "task is not ended yet"); + + // Simulate abrupt death of parent: no session_shutdown, no runner.cancelAll. + // A completely new session in another process starts up with the same disk store. + const fresh = fakePi(); + const freshHarness = deps(); + gentleAgents(fresh.pi, {}, freshHarness.deps); + const freshCtx = fakeContext(); + await fresh.fire("session_start", freshCtx.ctx); + + // In the new session, subagent_status must NOT fail with "Error: no task " + const statusResult = await fresh.tools.get("subagent_status")!.execute("s1", { task_id: id }, undefined, undefined, freshCtx.ctx); + assert.doesNotMatch(statusResult.content[0].text, /Error: no task/, "interrupted task must resolve"); + assert.match(statusResult.content[0].text, /failed/, "status must report terminal failure"); + assert.match(statusResult.content[0].text, /interrupted/i, "status must explain interruption"); + + // The disk record must now be updated to the terminal reconciled state + const reconciledOnDisk = await loadStoredTask(tasksDir, id); + assert.ok(reconciledOnDisk, "reconciled record on disk"); + assert.equal(reconciledOnDisk.task.status, "failed"); + assert.match(reconciledOnDisk.task.error ?? "", /interrupted/i); + assert.ok(typeof reconciledOnDisk.task.endedAt === "number", "endedAt is populated"); + + // On resume of the original session, subagent_list_tasks must list the interrupted task + const resumedPi = fakePi(); + const resumedHarness = deps(); + gentleAgents(resumedPi.pi, {}, resumedHarness.deps); + const resumedCtx = fakeContext(); + (resumedCtx.ctx.sessionManager as { getSessionId(): string }).getSessionId = () => ctx.sessionManager.getSessionId(); + (resumedCtx.ctx.sessionManager as { getEntries(): unknown[] }).getEntries = () => [{ type: "custom" }]; + await resumedPi.fire("session_start", resumedCtx.ctx, { reason: "resume" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + const listResult = await resumedPi.tools.get("subagent_list_tasks")!.execute("l1", {}, undefined, undefined, resumedCtx.ctx); + assert.match(listResult.content[0].text, new RegExp(id), "interrupted task must appear in resumed session task list"); + assert.match(listResult.content[0].text, /interrupted/i); +}); + test("the overlay confirms a running task once and reports when it finishes during confirmation", async () => { const { pi, tools, fire, commands, sent } = fakePi(); const harness = deps();