Skip to content

feat: automatic lossless post-reboot recovery of worker sessions - #311

Open
kwliang1 wants to merge 1 commit into
mainfrom
kevinliang/hydra-post-reboot-recovery
Open

feat: automatic lossless post-reboot recovery of worker sessions#311
kwliang1 wants to merge 1 commit into
mainfrom
kevinliang/hydra-post-reboot-recovery

Conversation

@kwliang1

@kwliang1 kwliang1 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

On the 2026-08-19 laptop reboot: the daemon/bridge came back, but the slack-byte tmux session did not stay up and all 12 live worker sessions died with no automatic recovery. Manual respawn brought them back but lost each session's dashboard links (PR / claude.ai artifact URLs).

Root cause (verified against current code)

A. slack-byte didn't restart — config split + crash-loop. The loaded launchd job com.hydra.watchdog runs the deprecated watchdog.sh with CLAUDE_CONFIG_DIR=~/.claude-byte, while the daemon is run manually with ~/.claude. The .claude-byte byte crash-loops (weeks of Bot session 'slack-byte' missing … reviving every 120s). The maintained CLI path (hydra up/watchdog/install, cli/lifecycle.ts) already fixes the model/auth bugs the deprecated start-byte.sh has — it just wasn't installed, and hydra install writes a differently-labeled plist that would coexist with the legacy one.

B. Workers weren't recovered; manual respawn lost links. loadPersisted keeps dead records (with artifacts[]) — it stamps deadAt, doesn't prune — but nothing revives them at boot. And respawning onto a dead thread ran killSession, which registry.deleted the record (dropping artifacts[]) and destroyWorktreed the on-disk worktree.

Fix (repo code — helps every user)

  1. Lossless respawn (session-lifecycle.ts): doSpawnSession snapshots the replaced record's artifacts/contextLinks/description before killSession and re-applies them to the new record (+ resets artifactsBackfilled so the boot history-rescan self-heals anything missed). Fixes manual recover/respawn too.
  2. Worktree preservation (session-lifecycle.ts, worktree-manager.ts): killSession(…, {skipWorktreeDestroy}); recovery reuses the dead session's on-disk worktree in place (keeps unpushed work, and lets --resume find the transcript under the same CWD). If the dir is gone but the branch survives, reattachWorktree re-materializes it (distinguishing a pruned branch from a transiently-unreachable repo, so a boot-time hiccup never orphans a branch). The preserved branch is fenced against a concurrent spawn's stale git branch -D by reserving its base name across the whole cascade — keyed off the worktree branch, not the tmux name, since a re-recovered session carries a fresh tmux name but its original branch. Same-repo worktree ops are serialized (withRepoLock). Two cross-subsystem boot/timer paths that would otherwise destroy a preserved branch are closed the same way:
    • Phase-budget reaper (phase-budget.ts) is gated on liveness — it never arms for or reaps a dead record (doing so would killSession without skipWorktreeDestroy ~5 min after boot).
    • Factory sweep (factory.ts sweepOrphanedBuilders) now preserves a swept mid-build builder's worktree branch when it holds unpushed (or unverifiable) commits — re-persisting a dead, suppressAutoRecover thread_owner record (so pickSessionName keeps reserving the branch name and it stays recover-able) and snapshotting/restoring its PR watches (cursors intact), instead of branch -D-ing the branch and dropping the watches. (Factory builders are build sessions with worktrees too; a reboot mid-building/reviewing previously discarded their local commits.)
  3. Auto worker-recovery at boot (daemon/recovery.ts, wired in daemon.ts): opt-in HYDRA_AUTO_RECOVER=1. Reads dead workers, dedups by real work (PR/ticket), not per-thread (a naive per-thread respawn can put two sessions on one PR), revives each via the existing resume→fork→respawn cascade, then DMs a recovered N / skipped M summary so duplicates are easy to spot/kill. Idempotent (skips already-live tmux; shares the recoveryInProgress guard). The recovery engine (recoverOne cascade, work-key dedup, autoRecoverAfterBoot, and the manual recover handler) lives in its own daemon/recovery.ts at the lifecycle layer — keeping the commands layer a thin shell and the worktree-manager/pr-watch imports out of it.
  4. Re-verify safety guard: every recovered session is told to re-verify repo/PR/system state before any write/push/deploy (mid-task state is unknown after a crash).
  5. hydra install supersedes the legacy watchdog (cli/lifecycle.ts): removes com.hydra.watchdog so there's a single watchdog, not two fighting.

Ops runbook (per-machine — not repo code)

CLAUDE_CONFIG_DIR lives in each machine's ~/.claude/channels/<platform>/.env, not the repo. To make slack-byte restart reliably on reboot on Kevin's machine: reconcile everything to ~/.claude (the config the working manual daemon already uses), then hydra install slack (installs the CLI watchdog + removes the legacy one). Note for other users: pick whichever single config dir your daemon actually runs under — recovery --resume needs the transcript under that same dir.

Safety

  • Auto-recovery is opt-in and read-only until invoked; default off.
  • On a broken boot (SPAWN_CWD unset), auto-recovery bails before touching any record (so it can't erase the dead fleet) and DMs the operator that it skipped — no silent stderr-only skip.
  • Does not restart or touch a live daemon; recovery only revives dead sessions.
  • Does not clobber surviving pr-watches.json; a recovered session's PR watches (with their seen-cursors) are snapshotted before the kill and restored onto the survivor. A watch whose owner record is present-but-dead is frozen (not polled) until the owner is revived, so the seen-cursors aren't advanced past feedback that no live agent consumed — the revived session re-notifies from the as-of-death cursor. A terminal dead record (branch gone → never revived) drops its watches instead of freezing them forever.
  • suppressAutoRecover (the flag that keeps a parked awaiting_pm builder / branch-gone / mid-build-preserved record from auto-reviving) clears itself the moment a live session produces a reply — so an awaiting_pm builder adopted as a working thread stays auto-recoverable across a later reboot, while dead suppressed records (which never reply) keep their suppression.
  • Worktree preservation is gated behind a preserveWorktree flag that only the recovery path sets — the shared tryResume helper is also used by the manual resume command, which keeps its prior destroy-and-respawn semantics (no behavior change for resume).

Testing

  • Compile-check clean on all 3 module graphs (daemon.ts, cli/hydra.ts, bridge.ts).
  • tsc --noEmit: 0 errors in any file this PR touches; unrelated pre-existing errors unchanged.
  • Unit tests recovery-dedup.test.ts (14 cases): workKey PR/ticket keying (the "two sessions on one PR" regression), dedupForRecovery winner/loser + most-recently-active tiebreak + null-key uniqueness + sibling-watch handoff (cursors preserved), and a SESSION_CATALOG hyphen-free invariant guarding baseNameFromBranch.
  • Tests are hermetic: a bunfig.toml [test] preload points HYDRA_STATE_DIR at a throwaway temp dir, so suites that import daemon modules never read or clobber the developer's live ~/.claude state.
  • Full suite: no new failures vs clean main (base 17 fail → with-delta 17 fail, +5 passing). The 17 are pre-existing cross-file fake-timer/global-state pollution in protocol-scenarios/protocol-notifications/build/review harness tests (bun-vs-jest timer gap); they fail identically on plain main and are untouched by this PR.
  • Not runtime-tested against the live daemon by design (must not restart the daemon serving live sessions). hydra restart slack by the operator will exercise the boot path.

Comment thread daemon/worktree-manager.ts Outdated
export async function reattachWorktree(repoDir: string, worktreePath: string, branch: string): Promise<ReattachResult> {
// Branch must exist to reattach — otherwise there's nothing to preserve.
try {
await execAsync('git', ['-C', repoDir, 'rev-parse', '--verify', branch], { timeout: 5_000 })

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (correctness): rev-parse --verify runs outside the repo lock, but worktree add runs inside it. Between the two, a concurrent killSession (or external prune) can delete the branch. When that happens, worktree add fails → returns 'failed' (treated as transient/retryable), but the branch is truly gone — the 'branch-gone' path (which correctly sets suppressAutoRecover) is never reached.

The session stays in limbo: not recovered, not marked gone, retried every boot forever.

Fix: Either move the rev-parse inside the withRepoLock call, or on worktree add failure re-check whether the branch still exists and return 'branch-gone' if it doesn't.

Flagged by both reviewers.

Comment thread daemon.ts
// builders in their post-sweep state (awaiting_pm ones marked suppressAutoRecover,
// others killed) rather than racing it.
const factorySweep = sweepOrphanedBuilders().catch(err => {
process.stderr.write(`daemon: factory sweep failed: ${err}\n`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (reliability): The .catch() here resolves the promise (returns Promise<void>), so await factorySweep at line 348 always succeeds — even if the sweep threw mid-way. autoRecoverAfterBoot then runs against a registry in a potentially inconsistent state (some builders swept, some not).

Either re-throw after logging so auto-recover is skipped on sweep failure, or explicitly document that partial-sweep + auto-recover is accepted.

Comment thread daemon/commands/global.ts Outdated
// ticket:SHA-256) — that remains the accepted ticket-dedup coarseness documented on
// workKey (bounded, surfaced in the recovery summary, manually recoverable).
function ticketKey(info: Partial<Pick<SessionInfo, 'topic' | 'description'>>): string | null {
for (const field of [info.topic, info.description]) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (correctness tradeoff — flagged by both reviewers): The ^ anchor means only the first token of topic/description matches. A topic like "Fix login — BANK-1750" won't extract the ticket, so its sibling "BANK-1750 classifier fix" gets deduped but this one doesn't — two sessions survive.

The tests confirm this is intentional (avoids false positives like UTF-8), and the collapse is lossless (skipped sessions stay recoverable). Worth a brief code comment noting the false-negative tradeoff since it's a common phrasing pattern.

Comment thread daemon/commands/global.ts Outdated
// finally guarantees release on every exit (success, throw, total-failure); once the
// survivor's record persists its worktreeBranch, pickSessionName's reservation takes over.
const reservedName = worktree
? (worktree.branch.startsWith('wt/') ? worktree.branch.slice(3).split('-')[0] : deadInfo!.tmuxName)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (safety): The deadInfo! non-null assertion is safe because worktree is only truthy when deadInfo?.worktreeRepo && deadInfo.worktreePath — but this coupling is non-obvious. A brief inline note (or a deadInfo!deadInfo ?? unreachable() guard) would make the invariant explicit.

Comment thread daemon/commands/global.ts Outdated
// for every session, and each doSpawnSession deletes the dead record before it fails.
// Bail before touching anything so a broken boot can't erase the whole dead fleet.
if (!process.env.SPAWN_CWD) {
process.stderr.write('daemon: auto-recover: SPAWN_CWD unset — skipping to avoid destroying dead records on a broken boot\n')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (observability): On a broken boot (SPAWN_CWD unset), this only logs to stderr — no Discord DM or channel notification. Every other auto-recovery outcome posts a summary. The operator may not notice the skip for hours. Consider posting a brief alert via gateway.sendDM or safeSend here too.

@kwliang1
kwliang1 force-pushed the kevinliang/hydra-post-reboot-recovery branch from 34b57da to cec823a Compare August 23, 2026 18:35
@kwliang1

Copy link
Copy Markdown
Collaborator Author

Addressed all 6 review comments in cec823a (amended, signed):

  • worktree-manager.ts:127 (should-fix, TOCTOU) ✅ Fixed. After an in-lock worktree add failure, reattachWorktree now re-verifies the branch — if it genuinely vanished (repo still reachable) it returns 'branch-gone' (terminal → suppressAutoRecover) instead of 'failed' (transient), so a mid-reattach concurrent delete no longer defers forever.
  • daemon.ts:85 (sweep .catch) ✅ Documented rather than re-thrown. Re-throwing would skip recovery of healthy, unrelated workers on any sweep hiccup; and a partial sweep is already safe — auto-recover excludes both un-swept (factory_builder) and swept (suppressAutoRecover) builders, so sweep completeness never changes what it acts on. Added a comment saying exactly that.
  • global.ts:682 (broken-boot observability) ✅ Fixed. The SPAWN_CWD-unset bail now DMs the operator via a shared notifyOperator helper (extracted from the summary path), not just stderr.
  • global.ts:549 (ticket false-negative) ✅ Comment added noting the leading-anchor tradeoff: a mid-text ticket won't dedup against a sibling that leads with it — the safe direction (keep both lossless sessions) vs. a false-positive collapse.
  • global.ts:382 (deadInfo! coupling) ✅ Inline note added: worktree is only truthy when built from a non-null deadInfo.

Verified: all 3 entry points build, affected suites 100/100, and a 2-round independent review pass (correctness + data-loss) on the delta came back clean — including a check that rev-parse --verify can't spuriously flip a still-present branch to branch-gone.

Comment thread docs/topology.mmd Outdated
daemon_commands_global --> daemon_bridge-transport
daemon_commands_global --> daemon_session-lifecycle
daemon_commands_global --> daemon_util
daemon_commands_global --> daemon_pr-watch

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (completeness): This PR adds a global→pr-watch edge, but also adds import { checkUnpushedCommits, reattachWorktree } from '../worktree-manager.js' in global.ts:9. The worktree-manager node is absent from the topology entirely — run bun scripts/gen-topology.ts to regenerate and pick up both new edges (and the missing node if needed). Per CLAUDE.md: "Regenerate after any import change."

Comment thread daemon/commands/global.ts Outdated

// Surface an auto-recovery message to the operator: DM the access allowlist if set,
// else fall back to the default channel so the run is visible.
async function notifyOperator(text: string): Promise<void> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (reliability): notifyOperator is async and callers await it, but the DM loop uses void gateway.sendDM(...) — fire-and-forget. The function resolves before any DM lands, so await notifyOperator(...) in postAutoRecoverySummary gives a false sense of sequencing. Either collect promises and await Promise.allSettled(...) so the caller knows DMs were attempted, or drop the async and make it explicitly fire-and-forget (matching the else branch's void safeSend pattern). Current shape is an async function that doesn't actually await its work.

Comment thread daemon/sessions.ts Outdated
// stale-cleanup `git branch -D wt/<name>` destroy that preserved branch and any
// unpushed commits on it.
for (const s of this.sessions.values()) {
if (s.worktreeBranch?.startsWith('wt/')) used.add(s.worktreeBranch.slice(3).split('-')[0])

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This s.worktreeBranch.slice(3).split('-')[0] pattern is duplicated in daemon/commands/global.ts:385. If the branch naming convention ever changes, both must update in sync. Consider a small shared helper (e.g. baseNameFromBranch(branch: string): string) to keep it DRY.

Comment thread cli/lifecycle.ts Outdated
if (existsSync(legacyPlist) && legacyPlist !== dest) {
try { execSync(`launchctl unload ${shq(legacyPlist)} 2>/dev/null`, { stdio: 'pipe' }) } catch {}
try { unlinkSync(legacyPlist) } catch {}
console.log(`removed legacy watchdog (com.hydra.watchdog) — superseded by ${label}`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (correctness): This log line runs unconditionally after the try/catch blocks. If the file exists (per the existsSync guard) but unlinkSync fails (e.g. permissions), the user sees "removed" but the file remains. Consider checking existsSync again after the delete, or moving the log inside a success path.

@kwliang1
kwliang1 force-pushed the kevinliang/hydra-post-reboot-recovery branch from cec823a to 0ec0b1c Compare August 23, 2026 21:27
@kwliang1

Copy link
Copy Markdown
Collaborator Author

Addressed all 4 in 0ec0b1c (amended, signed):

  • topology.mmd (should-fix) ✅ The global→pr-watch edge was already present; the real gap was worktree-manager missing from gen-topology.ts's LAYER_CONFIG (it was extracted after that config was written, so regen dropped it — a plain regen produced no diff). Added it (layer core) and regenerated — the node + all its import edges (global/factory/session-lifecycleworktree-manager) now render.
  • notifyOperator async/fire-and-forget (should-fix) ✅ Now await Promise.allSettled(...) over the DM allowlist (each with an inner .catch so one bad recipient can't reject the batch) and await safeSend(...).catch(...) for the channel fallback — the caller's await now genuinely means the sends were attempted. Recipient selection unchanged.
  • DRY baseNameFromBranch (nit) ✅ Extracted to daemon/util.ts (both sessions.ts and global.ts already import util → no new module edge / cycle), replacing the two inline slice(3).split('-')[0]. Byte-identical output; both callers keep their startsWith('wt/') guard; pickSessionName and recoverOne now provably derive the reserved name from the same helper.
  • cli/lifecycle.ts log-on-failure (nit) ✅ "removed legacy watchdog" now logs only on unlinkSync success; a failure logs a manual-removal warning with the path instead.

Verified: all 3 entry points build, affected suites 100/100, import-cycle check clean, and a 2-round independent review pass (correctness + data-loss/regression) on the delta came back clean.

Comment thread daemon/util.ts
// Load-bearing: pickSessionName reserves this token and createWorktree's stale
// `branch -D wt/<name>` targets it, so recoverOne's name reservation must derive it identically.
// Callers must first confirm the branch starts with `wt/`.
export function baseNameFromBranch(branch: string): string {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (correctness): branch.slice(3).split('-')[0] extracts the base name by splitting on -, but if session names ever contain hyphens (e.g. my-feature → branch wt/my-feature-suffix), this returns "my" instead of "my-feature". That means pickSessionName reserves the wrong token, and a new spawn could draw the unprotected name and branch -D the preserved branch.

If session names are guaranteed hyphen-free (word-list only), this works — but the invariant isn't documented, and worktreeBranchSuffix implies - is the delimiter between name and suffix. Consider either: (a) documenting the no-hyphen invariant on pickSessionName, or (b) using a separator that can't appear in names (e.g. --) so the split is unambiguous.

Flagged by both reviewers.

// Create worktree
try {
await execAsync('git', ['-C', repoDir, 'worktree', 'add', '-b', branchName, wtDir, base], { timeout: 15_000 })
process.stderr.write(`daemon: worktree: created ${wtDir} (branch ${branchName}) from ${base}\n`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (correctness): The initial rev-parse --verify branch runs outside withRepoLock. If a concurrent branch -D deletes the branch between this check and the lock acquisition, the unlocked rev-parse succeeds, then worktree add fails inside the lock. The in-lock re-verify (lines 127-132) correctly catches this — good.

However, the reverse race: if the unlocked rev-parse fails due to a concurrent delete, the code returns 'branch-gone' after only a repo-reachability heuristic. A race where the branch is deleted between the branch rev-parse and the --git-dir check would produce a false terminal 'branch-gone', permanently suppressing recovery. Moving the initial rev-parse inside the lock would close this gap cleanly. Low probability in practice since concurrent branch -D on the same branch is rare.

Comment thread daemon/commands/global.ts Outdated
}> {
// Reserve keys owned by tmux-alive sessions that are NOT themselves being recovered.
// Excluding candidates is essential: the manual path's candidate filter is `!isAlive`,
// so a deadAt-set-but-tmux-alive session is a candidate yet also tmux-alive — without

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (clarity): Promise.allSettled never sees a rejected status here because the inner .catch() converts every rejection to a fulfillment. Promise.all would behave identically. Not a bug — the sends are attempted and failures logged — but allSettled implies rejection-tolerance that isn't actually exercised. Minor.

test('empty session → null', () => {
expect(workKey({})).toBeNull()
})
})

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (coverage): workKey is well-tested, but dedupForRecovery itself — the function that picks winners/losers, handles sibling watches, and interacts with live sessions — has no unit tests. That's the higher-risk logic. Consider adding integration tests with a mocked registry for the dedup path (live-key reservation, sibling watch handoff, sort-by-lastActive tiebreaking).

@kwliang1
kwliang1 force-pushed the kevinliang/hydra-post-reboot-recovery branch from 0ec0b1c to a7b31ae Compare August 24, 2026 00:36
@kwliang1

Copy link
Copy Markdown
Collaborator Author

Addressed all 4 in a7b31ae (amended, signed):

  • util.ts baseNameFromBranch (should-fix) ✅ Documented the hyphen-free-session-name invariant in the helper, and added a test asserting no SESSION_CATALOG name contains - — so a future hyphenated name fails CI instead of silently reserving the wrong branch token.
  • recovery-dedup.test.ts coverage (nit, the meatiest) ✅ Exported dedupForRecovery and added tests for its higher-risk logic: same-key collapse with most-recently-active winner (input reversed to prove it's the lastActive sort, not input order), null-key always-unique, distinct-key survival, and sibling-watch handoff with cursor preservation. (Live-key reservation needs real tmux liveness; left to integration rather than a fragile mock.)
    • While adding these I caught (and a reviewer independently reproduced) that the sibling-watch test's real-pr-watch restoreWatches/unwatchBySession persisted to the live pr-watches.json, wiping running watches on every bun test. Fixed hermetically: a bunfig.toml [test] preload redirects HYDRA_STATE_DIR to a throwaway temp dir before any module loads (with on-exit cleanup). Verified the live file is now untouched and the full suite gains only the +5 passing tests.
  • worktree-manager.ts:101 (nit) ✅ Added a comment: a false branch-gone is non-destructive to the branch/worktree (suppress + skip; manual recover works); it does drop the session's re-addable PR watches, so the only cost of a rare false positive is lost watch cursors, never code.
  • global.ts:607 (nit, clarity)Promise.allSettledPromise.all (each send self-catches, so equivalent — no unexercised rejection-tolerance implied); doc comment corrected to match.

Verified: 3 entry points build, recovery-dedup 14/14, import-cycle clean, full suite shows no new failures (base 17 → 17, +5 passing), and a 2-round independent review pass (correctness + test-soundness + empirical clobber-fix verification) came back clean.

Comment thread daemon/worktree-manager.ts Outdated
export async function reattachWorktree(repoDir: string, worktreePath: string, branch: string): Promise<ReattachResult> {
// Branch must exist to reattach — otherwise there's nothing to preserve.
try {
await execAsync('git', ['-C', repoDir, 'rev-parse', '--verify', branch], { timeout: 5_000 })

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bump] Nit (TOCTOU — prior reviews #3839252297 + #3839992553, no reply): rev-parse --verify branch still runs outside withRepoLock. A concurrent destroyWorktree can delete the branch between this check and the locked worktree add. The inner catch re-verifies and returns branch-gone, so the race is handled — but the outer check can still produce a misleading branch-gone result if the repo is reachable but a concurrent op deleted the branch between the two rev-parse calls (the --git-dir check passes, rev-parse for branch fails → branch-gone even though a lock would have prevented the deletion).

If this is the accepted design (optimization: skip the lock when branch is absent), a one-line comment on the outer rev-parse noting "best-effort pre-check; canonical result determined inside the lock" would close this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed definitively in 033cfba (sorry for not replying on the earlier threads — I'd landed the in-lock re-verify but left this initial rev-parse --verify outside the lock). Moved the whole verify→prune→add sequence INSIDE withRepoLock, so no daemon worktree op (createWorktree/destroyWorktree — both lock on the same repoDir) can delete the branch between the check and the add. The internal TOCTOU is now closed; an external git branch -D is still classified correctly by the post-add re-verify.

Comment thread daemon/commands/global.ts Outdated
}

const deadInfos = [...registry.values()].filter(info =>
!!info.deadAt

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (completeness — flagged by both reviewers): autoRecoverAfterBoot filters on !!info.deadAt, but after a hard reboot (daemon killed, not graceful shutdown), sessions that were alive at crash time never had deadAt stamped. On the first boot after such a crash, those sessions are in the registry as alive (no deadAt) but their tmux is gone (!tmuxHasSession would be true). They pass the tmux check but fail the deadAt gate, so auto-recovery skips them.

They'd be caught by the session-health timer, marked dead, and recovered on the next boot — but that defeats the "post-reboot" promise for the most common crash scenario.

Consider stamping deadAt on sessions with missing tmux at the top of autoRecoverAfterBoot before filtering, or relaxing the deadAt requirement when !tmuxHasSession is already checked.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Respectfully, I think this one's already handled — loadPersisted (sessions.ts:427-449) runs in the SessionRegistry constructor (before autoRecoverAfterBoot) and stamps info.deadAt = info.deadAt ?? Date.now() on EVERY tmux-gone non-guest session at boot (only thread_guests are pruned earlier). So a hard-reboot survivor that was alive at crash time has deadAt set by the time the !!info.deadAt gate runs — it's recovered, not skipped. I added a comment on the filter documenting that loadPersisted guarantee since the invariant is non-obvious. Happy to reconsider if you're seeing a path where loadPersisted doesn't stamp it.

Comment thread daemon/commands/global.ts Outdated
// auto-recover filter (which is thread_owner-only + excludes parked/ephemeral/headless):
// a user may deliberately recover any dead non-guest session. Codex is excluded — the
// recoverOne cascade would relaunch it as Claude (codex reconnects via its own path).
function findDeadSessions(): SessionInfo[] {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (clarity): findDeadSessions intentionally does NOT filter on suppressAutoRecover — manual recover should still work on parked/branch-gone sessions. This is correct but non-obvious since autoRecoverAfterBoot does filter on it. A one-line comment like // suppressAutoRecover not checked — manual recovery overrides would prevent a future reader from "fixing" this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 033cfba — added: // suppressAutoRecover intentionally NOT checked — it gates only AUTOMATIC boot recovery … an explicit manual \recover` overrides it.`

@kwliang1
kwliang1 force-pushed the kevinliang/hydra-post-reboot-recovery branch from a7b31ae to 033cfba Compare August 24, 2026 03:38
Comment thread daemon/commands/global.ts Outdated
const deadInfos = [...registry.values()].filter(info =>
// deadAt reliably catches hard-reboot survivors: loadPersisted (sessions.ts, in the registry
// constructor — runs before this) stamps deadAt on EVERY tmux-gone non-guest session at boot,
// so a session that was alive at crash time (never gracefully stamped) is already deadAt here.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (architecture — sp-reviewer): recoverOne + dedupForRecovery + workKey + autoRecoverAfterBoot + notifyOperator + helpers = ~220 new lines in commands/global.ts, plus two new cross-layer imports (worktree-manager core → commands, pr-watch domain → commands). The commands layer was a thin orchestration shell; this PR makes it the widest node in the topology graph.

Structural alternative: extract a daemon/recovery.ts at the lifecycle layer (where session-lifecycle.ts already imports both worktree-manager and pr-watch). handleRecoverIntercept becomes a thin call into it, and autoRecoverAfterBoot moves there entirely. Eliminates the two new edges from commands into core/domain.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f3e73a8 — extracted the recovery engine (recoverOne cascade, workKey/dedupForRecovery, autoRecoverAfterBoot, notifyOperator, and the manual recover handler) into a new daemon/recovery.ts at the lifecycle layer. commands/global.ts drops ~480 lines and no longer imports worktree-manager or pr-watch — those cross-layer imports now live at the lifecycle layer alongside session-lifecycle. Verified byte-faithful move (mechanical diff shows only the intended skipReason log change) + rewired router/daemon/test importers; topology regenerated.

Comment thread daemon/factory.ts Outdated
clearFactoryIdentity(info)
info.deadAt = info.deadAt ?? Date.now()
info.suppressAutoRecover = true
registry.set(info.sessionId, info)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix (fragility — sp-reviewer): killSession deletes the record, then this block re-inserts the same info object reference with mutated fields. Any field killSession nullified or mutated on the object during cleanup is silently carried into the re-persisted record. Today killSession doesn't mutate fields before registry.delete, but the coupling is implicit — a future killSession change that clears a field before deletion would corrupt these re-persisted records.

Consider a skipRegistryDelete option on killSession (parallel to skipWorktreeDestroy) so the record is never removed in the first place, rather than delete-then-reinsert.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3e73a8: the sweep now snapshots preserved = {...info} BEFORE killSession and re-persists from that pristine copy, so a future killSession that mutates fields pre-delete can't corrupt the record. One nuance a reviewer caught: killSession's ONE current pre-delete mutation is discovering claudeSessionId from the live pane — so I also carry that single field forward (preserved.claudeSessionId ??= info.claudeSessionId) to keep a later recover's full-context tier-1 resume, while every other field stays pristine.

// so they run one-at-a-time; different repos still run concurrently.
// ---------------------------------------------------------------------------

const repoLocks = new Map<string, Promise<unknown>>()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (resource — ts-reviewer): repoLocks entries are never removed. Each settled promise is small, but the map pins closed-over values from the last fn call. Low practical risk with a handful of repos, but a one-liner cleanup when the chain settles empty (if (repoLocks.get(repoDir) === settled) repoLocks.delete(repoDir)) would keep it tidy.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f3e73a8withRepoLock now self-deletes a repo's entry once its chain settles, guarded by if (repoLocks.get(repoDir) === tail) so a newer chained op (which owns its own cleanup) is never clobbered. No lost mutual-exclusion.

Comment thread daemon/commands/global.ts Outdated
skipped: Array<{ info: SessionInfo; reason: string }>
siblingWatches: Map<string, WatchEntry[]>
}> {
// Reserve keys owned by tmux-alive sessions that are NOT themselves being recovered.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (observability — ts-reviewer): The outer try {} catch {} around checkUnpushedCommits swallows everything silently, including non-git errors (OOM, TypeError). checkUnpushedCommits already returns -1 for "branch exists but count failed", so this catch handles only truly unexpected errors — a process.stderr.write here would help debuggability without changing control flow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f3e73a8skipReason's catch now logs to stderr (checkUnpushedCommits already maps expected git failures to -1, so a throw here is genuinely unexpected) and still falls through to the base reason.

Revive dead worker sessions on daemon boot (opt-in HYDRA_AUTO_RECOVER=1)
without losing their dashboard links or unpushed worktree commits.

- Lossless respawn: carry artifacts/contextLinks/description across the
  replace; recovery kills pass skipWorktreeDestroy and reuse the on-disk
  worktree in place (reattach if the dir was pruned; distinguish a gone
  branch from a transiently-unreachable repo).
- Auto worker-recovery at boot: read dead thread_owner workers, dedup by
  the real work they own (PR-from-artifacts -> leading ticket, both coarse
  but lossless), revive via resume->fork->respawn, DM a summary. Idempotent
  and single-flight; excludes factory/codex/parked/ephemeral/headless.
- Preserved-branch protection: reserve the branch BASE name (not tmuxName,
  which diverges after a prior recovery) across the cascade; per-repo
  worktree-op serialization; checkUnpushedCommits fail-safe (-1 = unknown).
- Cross-subsystem guards so a preserved branch is never destroyed on a
  timer/boot path: phase-budget reaper and factory sweep both skip dead
  records; factory sweep preserves a mid-build builder's branch + watches;
  pollPr freezes a dead-owner watch (no cursor advance) until revival;
  terminal branch-gone records drop their watches.
- suppressAutoRecover clears when a live session replies (an adopted
  awaiting_pm builder stays auto-recoverable).
- Re-verify guard injected into every recovered session (post-crash state
  is unknown before any write/push/deploy).
- hydra install supersedes the legacy com.hydra.watchdog plist.

Config-dir-agnostic; the ~/.claude reconciliation is a per-machine ops
step, called out in the PR description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kwliang1
kwliang1 force-pushed the kevinliang/hydra-post-reboot-recovery branch from 033cfba to f3e73a8 Compare August 24, 2026 06:53
Comment thread daemon/recovery.ts
//
// Manual `recover [name]` (handleRecoverIntercept, router-dispatched) and automatic post-reboot
// recovery (autoRecoverAfterBoot, boot-wired in daemon.ts) share the recoverOne cascade,
// work-key dedup, and the single-flight guard here.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (architecture — flagged by sp-reviewer): recovery.ts sits at the lifecycle layer but imports two domain-layer modules: access.ts (line 7, loadAccess) and pr-watch.ts (line 4, parsePrUrl/getWatchesBySession/restoreWatches/unwatchBySession). These are downward-layer edges.

Consistent with the existing session-lifecycle → access and session-lifecycle → pr-watch edges on main, so not blocking — but this PR doubles the lifecycle→domain coupling. If you're ever looking to tighten the topology: the loadAccess call could be injected from the caller in daemon.ts (entry layer), and pr-watch could be promoted to lifecycle since both lifecycle modules now depend on it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's a nit, and I'm going to leave it as-is. Both edges are inherent to the recovery engine: loadAccess selects the DM recipients for the post-recovery summary, and the pr-watch helpers (getWatchesBySession/restoreWatches/unwatchBySession/parsePrUrl) are the watch snapshot/restore/dedup that make recovery lossless. They mirror the existing session-lifecycle → access/→ pr-watch edges (recovery is a sibling of session-lifecycle at the lifecycle layer), so this moves the imports out of the commands layer (worse) rather than creating a new class of edge. Eliminating them would need DI or relayering pr-watch/access — more churn than the nit warrants. Happy to revisit if you later want a formal lifecycle↛domain rule enforced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants