stop the harness before deleting its transcript, and wait for it to actually stop

Deleting a commander removed its transcript and the `claude` CLI wrote it
straight back. Verified rather than inferred: the file returned with 22 lines,
every one timestamped after the delete, and the deleted commander was sitting in
/chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered
it out. Delete a commander, get an untitled chat back.

Two causes, and the first one hid the second.

The kill never happened. `killClaudeSession` was imported straight from
`claude-manager` into a route that runs in `officer`, while the session map lives
in `officer-claude-code`. It read an empty map in the wrong process and returned
false every time. `claude:kill` and `killClaude` already existed for this; the
route just wasn't using them.

And killing is not being dead. `abort()` returns while the CLI is still
unwinding, and officer does not own that file — the CLI does. So a session now
carries an `exited` promise resolved by the same exit hook that already reaps it,
`claude:kill` replies only once that resolves, and both delete routes await the
reply before touching the filesystem.

Bounded at 5s and never fatal. A child that will not die delays a delete by
seconds instead of hanging it, and a sidecar that is down leaves the caller with
exactly the race it already had.

`markExited` fires before `dropDeadSession`'s identity guard, deliberately: a
waiter has to be released even when a newer session has taken its key, or it
blocks for the full timeout on a process that died on schedule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 22:55:29 +00:00
co-authored by Claude Opus 5
parent 8ac3ca7fe5
commit eefcb06e35
5 changed files with 103 additions and 5 deletions
+10 -1
View File
@@ -417,6 +417,16 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
return ctx.json({ ok: true });
}
/*
Stop the harness BEFORE removing its file, and wait for it.
The `claude` CLI owns the transcript. Deleting it while a live session is still writing removes a file
that is written straight back, so the conversation reappears untitled on the next refresh — see
`killClaudeAndWait`. No live session means nothing to wait for, which is the ordinary case here.
*/
const liveKey = sessionKeyForTranscript(id);
if (liveKey) await sidecar.killClaudeAndWait(liveKey, ctx.get('user').id);
// `.length`, not truthiness: this returns the IDS it removed and an empty array is truthy in JavaScript,
// so `if (removed)` would report a delete that found nothing as a success.
const removed = deleteClaudeSession(who, cwd, id);
@@ -431,7 +441,6 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
`chat_session_events` is keyed on officer's session key. Undefined here is the normal case for an old
conversation — no live session holds it, so nothing is keyed on a key to remove.
*/
const liveKey = sessionKeyForTranscript(id);
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
return ctx.json({ ok: true });
});