From 933125a023e423ca03dec321a7db4d4c1a9260bb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 3 Nov 2026 20:26:05 +0000 Subject: [PATCH 1/5] test: a reconcile that repairs a marked session leaves the old socket --- test/unit/host/lifecycle.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/unit/host/lifecycle.test.ts b/test/unit/host/lifecycle.test.ts index afe5837a..b086d42e 100644 --- a/test/unit/host/lifecycle.test.ts +++ b/test/unit/host/lifecycle.test.ts @@ -1633,6 +1633,20 @@ describe.skipIf(process.platform === 'win32')( await rm(dirname(oldSocketFile), { recursive: true, force: true }); }); + it('a reconcile that repairs a marked session removes it', async () => { + const directory = await seedSession( + makeRecord({ status: 'failed', hostPid: null, childPid: null }), + ); + writeFileSync(join(directory, 'destroyed'), ''); + + await reconcileSession(directory); + + expect(await readManifest(manifestPath(directory))).toMatchObject({ + status: 'destroyed', + }); + expect(existsSync(oldSocketFile)).toBe(false); + }); + it('destroy removes it when the host is dead', async () => { await seedSession(makeRecord({ status: 'destroyed', childPid: null })); killProbeReportsDead(); From 15f415e8f885ca670789f484a0694e35828c7c72 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 3 Nov 2026 20:26:07 +0000 Subject: [PATCH 2/5] fix(host): the marked repair also removes the old socket of an older host --- src/host/lifecycle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/host/lifecycle.ts b/src/host/lifecycle.ts index 727902d0..8d2694c8 100644 --- a/src/host/lifecycle.ts +++ b/src/host/lifecycle.ts @@ -1123,6 +1123,7 @@ export async function reconcileSession( const socketFile = socketPath(sessionDirectory); await unlinkIfPresent(socketFile); await removeSocketDirectoryIfEmpty(socketFile); + await removeLegacySocketBestEffort(socketFile); } return; } From f61f57837c1b2f78376877b071e3d51d77fb720a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 3 Nov 2026 20:26:54 +0000 Subject: [PATCH 3/5] fix(skills): read the old socket removal in the marked repair --- .agents/skills/verify-session-lifecycle/SKILL.md | 2 +- .agents/skills/verify-session-lifecycle/scripts/facts.mjs | 2 +- src/host/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/verify-session-lifecycle/SKILL.md b/.agents/skills/verify-session-lifecycle/SKILL.md index 53886fa8..09dfcb47 100644 --- a/.agents/skills/verify-session-lifecycle/SKILL.md +++ b/.agents/skills/verify-session-lifecycle/SKILL.md @@ -422,7 +422,7 @@ node -e 'console.log(require(process.argv[1]).status)' "$AGENT_TTY_HOME/sessions `ReconcileRepairsDestroyed` repairs a destroyed record in the reconcile that replaced it, right after its own write. A reconcile process that was killed between that write and the repair left `failed`, after `destroy` had printed `destroyed: true`, and every later reconcile skipped the terminal manifest (coder/agent-tty#265). A `destroy` of an ended session that failed after its marker write left the marker next to `exited` or `failed` the same way. -Now `reconcileSession` first checks a terminal manifest other than destroyed for the marker. When the marker is there, `writeMarkedDestroyed` writes destroyed from that copy, with `hostPid` and `childPid` null, the exit code kept and no failure fields, and the reconcile unlinks the socket, which a `destroy` whose write failed left, and returns. Only a `destroy` writes the marker, after it stopped the session and right before its destroyed write, so the marker means that `destroy` chose destroyed. `scanHome` skips a session with the marker, so `home list` and the dashboard picker do not count it while its manifest reads `failed`. The replay holds the repair write of the reconcile in `list` and kills that process: +Now `reconcileSession` first checks a terminal manifest other than destroyed for the marker. When the marker is there, `writeMarkedDestroyed` writes destroyed from that copy, with `hostPid` and `childPid` null, the exit code kept and no failure fields, and the reconcile unlinks the socket, which a `destroy` whose write failed left, and the old socket of an older host, and returns. Only a `destroy` writes the marker, after it stopped the session and right before its destroyed write, so the marker means that `destroy` chose destroyed. `scanHome` skips a session with the marker, so `home list` and the dashboard picker do not count it while its manifest reads `failed`. The replay holds the repair write of the reconcile in `list` and kills that process: ```sh id=$(node --import tsx src/cli/main.ts create --json -- sh -c 'exec cat' | node -e 'let s="";process.stdin.on("data",(d)=>(s+=d)).on("end",()=>console.log(JSON.parse(s).result.sessionId))') diff --git a/.agents/skills/verify-session-lifecycle/scripts/facts.mjs b/.agents/skills/verify-session-lifecycle/scripts/facts.mjs index aaec7eaa..82db0691 100755 --- a/.agents/skills/verify-session-lifecycle/scripts/facts.mjs +++ b/.agents/skills/verify-session-lifecycle/scripts/facts.mjs @@ -467,7 +467,7 @@ function repairFacts() { const reconcile = requireDeclaration(lifecycle, 'reconcileSession').body; const marked = indexOfStatement( reconcile, - /^if \(manifest !== null && isMarkedDestroyed\(sessionDirectory, manifest\)\) \{\s*if \(await writeMarkedDestroyed\(manifestFile, manifest\)\) \{\s*const socketFile = socketPath\(sessionDirectory\);\s*await unlinkIfPresent\(socketFile\);\s*await removeSocketDirectoryIfEmpty\(socketFile\);\s*\}\s*return;\s*\}$/, + /^if \(manifest !== null && isMarkedDestroyed\(sessionDirectory, manifest\)\) \{\s*if \(await writeMarkedDestroyed\(manifestFile, manifest\)\) \{\s*const socketFile = socketPath\(sessionDirectory\);\s*await unlinkIfPresent\(socketFile\);\s*await removeSocketDirectoryIfEmpty\(socketFile\);\s*await removeLegacySocketBestEffort\(socketFile\);\s*\}\s*return;\s*\}$/, ); const markedCheck = normalize( requireDeclaration(lifecycle, 'isMarkedDestroyed').body.getText(), diff --git a/src/host/README.md b/src/host/README.md index 41c4fe21..9f180e57 100644 --- a/src/host/README.md +++ b/src/host/README.md @@ -56,7 +56,7 @@ - The `run` handler waits 30,000 ms by default. The `wait` handler needs exactly one of `exit` or `idleMs`, else it throws `INVALID_DURATION`. `waitForRender` polls every `RENDER_WAIT_POLL_INTERVAL_MS` (200 ms, `src/renderWait/limits.ts`). [hostMain.ts:runHost] - `runHost` handles PTY exit once. If the status is `destroying` it records `destroyed`, else `exited`. Shutdown runs once. [hostMain.ts:runHost] - `destroySession` throws `SESSION_NOT_FOUND` when there is no manifest, or when the session directory is gone at its `destroyed` write, and `SESSION_ALREADY_DESTROYED` for a destroyed session. When it records `destroyed` itself, `recordDestroyed` first writes the empty `/destroyed` marker with `writeDestroyMarker`. Before it records a terminal session as `destroyed`, it calls `stopProgramGroup` with the group from `readProvenProgramGroup`, so only a group with a token gets a signal, and it throws `HOST_TIMEOUT` while that group lives. A session already recorded `destroyed` with the unshared group gets no wait, because only a process that saw the group recorded it, after its wait. A running session gets the `destroy` RPC. With `force` it gets `SIGKILL` for the program's group and, only when `hostMatches` proves the host, for the host: on Linux `judgeHost` must return `alive`, elsewhere `/bin/ps` must show `_host `, and on Windows the host gets no signal. It polls every 100 ms for at most 50 attempts and throws `HOST_TIMEOUT` if the status is still not terminal. [lifecycle.ts:destroySession] -- `reconcileSession` writes `destroyed` (both PIDs `null`, the exit code kept, no failure fields) over a terminal manifest other than `destroyed` when the destroy marker exists, and returns (`writeMarkedDestroyed`). Otherwise it does nothing when the manifest is missing, the status is terminal or the host can be alive: `judgeHost` with the record from `host.json` does not return `dead`. A null `hostPid` counts as a dead host. `allocateSession` writes the host PID into the first manifest, so a new session already has a `hostPid`. After the liveness check, `reconcileSession` reads the manifest again and does nothing when it is now missing or terminal. Otherwise it maps the status it read again: `running` to `failed` (failure origin `host-death`), `exiting` to `exited`, `destroying` to `destroyed` and any other status to `failed` (origin `unknown`), and sets both PIDs to `null`. Right after that write, when its status was not `destroyed` and the destroy marker exists, it writes `destroyed` again from the same copy, with both PIDs `null` and no failure fields. When the session directory is gone at any of these writes, it returns. [lifecycle.ts:reconcileSession] [lifecycle.ts:writeMarkedDestroyed] +- `reconcileSession` writes `destroyed` (both PIDs `null`, the exit code kept, no failure fields) over a terminal manifest other than `destroyed` when the destroy marker exists (`writeMarkedDestroyed`). It then unlinks the socket, removes the Home's socket directory if it is empty, removes the old socket at `legacySocketPath` when `isOwnLegacySocket` accepts it, and returns. Otherwise it does nothing when the manifest is missing, the status is terminal or the host can be alive: `judgeHost` with the record from `host.json` does not return `dead`. A null `hostPid` counts as a dead host. `allocateSession` writes the host PID into the first manifest, so a new session already has a `hostPid`. After the liveness check, `reconcileSession` reads the manifest again and does nothing when it is now missing or terminal. Otherwise it maps the status it read again: `running` to `failed` (failure origin `host-death`), `exiting` to `exited`, `destroying` to `destroyed` and any other status to `failed` (origin `unknown`), and sets both PIDs to `null`. Right after that write, when its status was not `destroyed` and the destroy marker exists, it writes `destroyed` again from the same copy, with both PIDs `null` and no failure fields. When the session directory is gone at any of these writes, it returns. [lifecycle.ts:reconcileSession] [lifecycle.ts:writeMarkedDestroyed] - Every kill of the program's process group goes through `stopProgramGroup`. For a record with a token, `judgeProgramGroup` returns `owned` only when a process that is not a zombie (a process whose main thread exited is not one while another of its threads runs) has the program's pid as its process group and session id, and the recorded autogroup on the recorded boot. A scan that finds no live member runs once more, because a member can fork and end after the scan listed `/proc`. `stopProgramGroup` reads that process again right before the kill. `unproven` (another boot id, or only members with another session id or autogroup) and `unknown` kill nothing. `gone` (no live member) first sends the group SIGSTOP with `signalProcessGroup`, because a fork chain whose members each live about 2 ms is never in view, and a stopped member is. The judge then runs again, and a frozen group that gets no kill gets SIGCONT, also when a scan throws. So a group id that an unrelated group took, whose members no scan shows either, gets a SIGSTOP and a SIGCONT. `unknown` means that `/proc` refused the boot id, or showed a member with the program's session id but no autogroup, so `isProgramGroupAlive` reads it alive. A record without a token gets the kill of its `pid`. A `{ childPid, sessionDirectory }` group gets the kill of `childPid`, and reads alive, only while `sessionDirectory` exists, checked in the same synchronous block. The unshared group `{ unshared: true }` gets no signal and reads alive. [programGroup.ts:stopProgramGroup] [programGroup.ts:judgeProgramGroup] - `captureProgram` records a token only on Linux, for its own child that leads its own session and has an autogroup other than the host's, within 250 ms, and only when `/proc` shows the boot id. `runHost` calls it right after `createPty` and writes the record before its first manifest write, with no `await` in between. [programGroup.ts:captureProgram] [hostMain.ts:runHost] - `allocateSession` throws `INVALID_DIMENSIONS` for non-positive `cols` or `rows`, `STORAGE_READ_ERROR` when `cwd` is not a directory and `INVALID_INPUT` when the shell path is not a file. An empty `command` defaults to the shell path. It creates the session directory and calls `launch` only after these checks pass. [lifecycle.ts:allocateSession] From 4eb86f8da618a0378cc13afe9055647037a05dba Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 3 Nov 2026 21:35:59 +0000 Subject: [PATCH 4/5] chore(skills): sync the lifecycle coverage record after the old socket removal --- .agents/skills/verify-session-lifecycle/coverage.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-session-lifecycle/coverage.json b/.agents/skills/verify-session-lifecycle/coverage.json index 2687578b..90708495 100644 --- a/.agents/skills/verify-session-lifecycle/coverage.json +++ b/.agents/skills/verify-session-lifecycle/coverage.json @@ -38,7 +38,7 @@ "src/host/lifecycle.ts#destroySession": "d840dd07a0e481f5", "src/host/lifecycle.ts#isMarkedDestroyed": "d178f4a63473915c", "src/host/lifecycle.ts#writeMarkedDestroyed": "4863c35f70699757", - "src/host/lifecycle.ts#reconcileSession": "4e5dac8144a0d9fb", + "src/host/lifecycle.ts#reconcileSession": "739c83bf945000c6", "src/host/hostMain.ts#waitForManifestFromCreate": "fd500651f83683eb", "src/host/hostMain.ts#isSessionCommandable": "260060f07b068ec1", "src/host/hostMain.ts#SHUTDOWN_KILL_GRACE_MS": "51171d9c5aa6c510", @@ -90,7 +90,7 @@ "src/host/hostMain.ts#runHost>handlers.resize": "d601680963309542", "src/host/hostMain.ts#runHost>handlers.destroy": "ccff44951e711dac", "skill:model/SessionLifecycle.tla": "f7270faba013acff", - "skill:scripts/facts.mjs": "6ae4fd3279517f42", + "skill:scripts/facts.mjs": "1b4427f8cee469b9", "skill:scripts/check.sh": "3a47553300cb217a" }, "callSites": { From a0ce3474c23fce9af0401098090330345d568636 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 3 Nov 2026 23:19:23 +0000 Subject: [PATCH 5/5] chore(skills): sync the lifecycle coverage record after the merge of main --- .agents/skills/verify-session-lifecycle/coverage.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/verify-session-lifecycle/coverage.json b/.agents/skills/verify-session-lifecycle/coverage.json index ce0072df..78085326 100644 --- a/.agents/skills/verify-session-lifecycle/coverage.json +++ b/.agents/skills/verify-session-lifecycle/coverage.json @@ -39,7 +39,7 @@ "src/host/lifecycle.ts#destroySession": "d840dd07a0e481f5", "src/host/lifecycle.ts#isMarkedDestroyed": "d178f4a63473915c", "src/host/lifecycle.ts#writeMarkedDestroyed": "4863c35f70699757", - "src/host/lifecycle.ts#reconcileSession": "4e5dac8144a0d9fb", + "src/host/lifecycle.ts#reconcileSession": "739c83bf945000c6", "src/host/hostMain.ts#waitForManifestFromCreate": "fd500651f83683eb", "src/host/hostMain.ts#isSessionCommandable": "260060f07b068ec1", "src/host/hostMain.ts#SHUTDOWN_KILL_GRACE_MS": "51171d9c5aa6c510", @@ -91,7 +91,7 @@ "src/host/hostMain.ts#runHost>handlers.resize": "d601680963309542", "src/host/hostMain.ts#runHost>handlers.destroy": "ccff44951e711dac", "skill:model/SessionLifecycle.tla": "f7270faba013acff", - "skill:scripts/facts.mjs": "b336aed63c9e6cc3", + "skill:scripts/facts.mjs": "82adfcc9fbc7fbb7", "skill:scripts/check.sh": "3a47553300cb217a" }, "callSites": {