Repository navigation
fix(agents): persist durable launch state and reconcile interrupted subagent tasks (#1741) #1774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<StoredTask> => { | ||
| 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<TaskRecord | undefined> => { | ||
| 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); | ||
|
Comment on lines
+1210
to
+1213
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Confirm that the task owner stopped before marking its task interrupted. The session-ID check excludes a different session, but it does not distinguish two processes that opened the same session. If the first process still runs the child, 🤖 Prompt for AI Agents |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '30,60p' lib/agents-history.ts
sed -n '625,640p;1490,1525p' extensions/gentle-agents.ts
rg -n 'saveTask|persistFinal|onFinish' extensions/gentle-agents.tsRepository: Gentleman-Programming/gentle-shell Length of output: 4810 🏁 Script executed: sed -n '615,642p;1020,1065p' extensions/gentle-agents.ts
sed -n '42,60p' lib/agents-history.tsRepository: Gentleman-Programming/gentle-shell Length of output: 4796 🏁 Script executed: rg -n -C 4 'class TaskStore|type TaskStore|interface TaskStore|onLaunch\\??:|onFinish\\??:|onLaunch\\(|onFinish\\(|store\\.get\\(|store\\.set\\(|store\\.update\\(' extensions/gentle-agents.ts libRepository: Gentleman-Programming/gentle-shell Length of output: 516 🏁 Script executed: rg -n -C 3 'TaskStore|onLaunch|onFinish|store\.update|store\.get|store\.set' extensions/gentle-agents.ts libRepository: Gentleman-Programming/gentle-shell Length of output: 28436 🏁 Script executed: sed -n '450,515p' lib/agents-protocol.ts
sed -n '975,1002p' lib/agents-runner.ts
sed -n '1488,1518p;1030,1055p' extensions/gentle-agents.tsRepository: Gentleman-Programming/gentle-shell Length of output: 6935 🏁 Script executed: nl -ba lib/agents-history.ts | sed -n '1,58p'
nl -ba lib/agents-protocol.ts | sed -n '457,520p'
nl -ba lib/agents-runner.ts | sed -n '980,997p'
nl -ba extensions/gentle-agents.ts | sed -n '620,638p;1032,1055p;1492,1518p'Repository: Gentleman-Programming/gentle-shell Length of output: 10514 Serialize task-history writes by task ID.
🤖 Prompt for AI Agents |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Wait for session restoration before listing tasks. If 🤖 Prompt for AI Agents |
||
| for (const task of tasks) { | ||
| if (!isFinished(task.status) && restoredTaskIds.has(task.id)) { | ||
| await resolveTask(task.id); | ||
|
Comment on lines
+1914
to
+1915
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Reconcile restored tasks during session restoration.
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| 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) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Complete the launch save before reporting a durable launch.
persistLaunchstartssaveTaskwithout awaiting it. In background mode,launchcan return the task ID before the file exists. If the parent exits abruptly in that interval, the task again has no durable record. Await the initial save before reporting success, and surface a save failure rather than silently claiming durability.🤖 Prompt for AI Agents