Skip to content

fix(hub): verify runner before trusting archiveSession's dead-CLI fallback - #1706

Open
heavygee wants to merge 5 commits into
tiann:mainfrom
heavygee:fix/archive-verify-runner-before-marking-gone
Open

fix(hub): verify runner before trusting archiveSession's dead-CLI fallback#1706
heavygee wants to merge 5 commits into
tiann:mainfrom
heavygee:fix/archive-verify-runner-before-marking-gone

Conversation

@heavygee

Copy link
Copy Markdown
Collaborator

Summary

  • archiveSession's only kill path (rpcGateway.killSession, a session-scoped socket RPC) treated any RpcTargetMissingError as proof the CLI process was dead and unconditionally archived the row. That RPC target goes stale independently of the process — most notably when SessionCache.mergeSessions rotates the hub's canonical session id during a resume — so a missing target does not prove the process is gone.
  • Before trusting that fallback, verify with the machine-level stopRunnerSession RPC, which resolves the child by PID on the runner side and checks both the requested and confirmed session ids (already used this way by the PTY/Pi resume reconciliation paths). Only archive once that also reports the process gone; if it reports still_alive, surface an error instead of silently archiving a live process.
  • When the session has no known machineId, or the machine RPC itself is unreachable, preserve the prior best-effort behavior (nothing stronger to check against).

Fixes #1705

Test plan

  • New unit tests in hub/src/sync/syncEngineArchiveSession.test.ts cover: still-alive (rejects, stays active), confirmed-gone (archives), no-known-machine (falls back to old behavior), machine-unreachable (best-effort archive as before)
  • bun test src/sync/ and bun test src/web/routes/sessions.test.ts pass (pre-existing unrelated failures in loadServerSettings, TitleSuggestionService, resolveFcmConfig reproduce identically on main)
  • tsc --noEmit clean in hub/

…lback

tiann#916 treats any RpcTargetMissingError from the session-scoped
killSession RPC as proof the CLI is gone and archives the row. That RPC
is keyed on whatever session id the CLI last registered under, which
goes stale independently of the process itself (SessionCache.mergeSessions
rotates the canonical id on resume; the session socket can also just be
reconnecting), so a missing target does not prove the process is dead.

Fall back to the machine-level stopRunnerSession RPC first, which
resolves the child by PID and checks both the requested and confirmed
session ids, before trusting archiveSession's fallback path.

Fixes tiann#1705

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Distinguish an unknown runner lookup from confirmed liveness — the new branch treats every still_alive result as proof that a process is running, but the runner also returns still_alive when the requested ID is not tracked and has no verified-exit tombstone (cli/src/runner/run.ts:1045-1050; explicitly asserted in cli/src/runner/runner.integration.test.ts:222). After a runner restart or tracking loss, an actually dead stale session with a reachable machine now fails archive indefinitely at hub/src/sync/syncEngine.ts:1702.
    Suggested fix:
    type StopSessionStatus = 'stopped' | 'already_gone' | 'still_alive' | 'unknown'
    
    // cli/src/runner/run.ts: no matching PID and no verified exit
    return 'unknown'
    
    // hub: only reject when a matched process survived the stop attempt
    if (status === 'still_alive') {
        throw new Error('Session process is still running and could not be stopped')
    }
    // Treat 'unknown' like the existing best-effort fallback.

Questions

  • None.

Summary

  • Review mode: initial
  • One Major finding: the runner RPC contract currently conflates “confirmed process survived termination” with “session ID unknown,” so this change can make stale sessions unarchivable.

Testing

  • Not run (automation; PR content was not executed).
  • Add a regression test using the real runner status semantics for an unknown session ID; the added unit tests only mock stopRunnerSession.
  • GitHub checks at review time: integration failed; test still in progress.

HAPI Bot

Comment thread hub/src/sync/syncEngine.ts Outdated
// to the prior best-effort behavior.
status = 'already_gone'
}
if (status === 'still_alive') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Distinguish unknown lookup from confirmed liveness

stopRunnerSession does not currently mean “a process was confirmed alive” whenever it returns still_alive. Its no-match path returns the same value when the ID is absent from all runner maps and has no verified-exit tombstone (cli/src/runner/run.ts:1045-1050), and that behavior is asserted for an arbitrary unknown ID at cli/src/runner/runner.integration.test.ts:222. Therefore, after runner restart/tracking loss, an actually dead stale row with a reachable machine will hit this throw and cannot be archived.

Suggested fix:

type StopSessionStatus = 'stopped' | 'already_gone' | 'still_alive' | 'unknown'

// Runner no-match path:
return 'unknown'

// Hub only rejects a matched process that survived termination:
if (status === 'still_alive') {
    throw new Error('Session process is still running and could not be stopped')
}
// 'unknown' keeps the prior best-effort archive behavior.

Please also add a regression test that exercises the real unknown-ID contract rather than only mocking the status.

Code review caught a regression of this PR's own bug one RPC layer
down: any failure of stopRunnerSession (ack timeout, protocol error —
not just a genuinely offline machine) was being coerced to
'already_gone' and archiving the session, exactly the false-positive
this fix exists to prevent.

Distinguish "no runner connected to ask" (machineCache reports the
machine offline — nothing stronger to check than the original
RpcTargetMissingError, so fall back to archiving as before) from "the
machine is online but the RPC itself failed" (treat as still_alive,
the same conservative default terminateInPlacePiResume /
terminateUnexpectedPiTemp already use for this exact RPC elsewhere in
this file).
@heavygee

Copy link
Copy Markdown
Collaborator Author

Follow-up commit: a self-review (/code-review) caught that the initial version coerced any stopRunnerSession failure (including transient ack timeouts / protocol errors on an online machine) into already_gone, reproducing this PR's own bug one RPC layer down. Fixed by only treating the fallback as "already gone" when the machine itself isn't currently connected (nothing stronger to check); an online machine whose RPC call fails is now treated as still_alive (the same conservative default used elsewhere in this file for the identical RPC). Added a regression test for both branches.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Distinguish an unknown runner lookup from confirmed liveness — the follow-up now separates offline machines from RPC failures, but it still rejects every still_alive response at hub/src/sync/syncEngine.ts:1717. The runner uses that same response when the requested session ID is not tracked and has no verified-exit tombstone (cli/src/runner/run.ts:1045-1050), with the behavior explicitly asserted at cli/src/runner/runner.integration.test.ts:222. After runner restart or tracking loss, an actually dead stale session on an online machine therefore remains unarchivable.
    Suggested fix:
    type StopSessionStatus = 'stopped' | 'already_gone' | 'still_alive' | 'unknown'
    
    // cli/src/runner/run.ts: no matching PID and no verified exit
    return 'unknown'
    
    // hub: only reject a matched process that survived termination
    if (status === 'still_alive') {
        throw new Error('Session process is still running and could not be stopped')
    }
    // Preserve the existing best-effort archive behavior for 'unknown'.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One Major finding remains unresolved: machine connectivity does not resolve the runner RPC contract's conflation of confirmed liveness with an unknown session ID.

Testing

  • Not run (automation; PR content was not executed).
  • Add a regression test covering the real runner response for an unknown session ID; the added hub tests only mock stopRunnerSession statuses.
  • GitHub checks at review time: integration failed; test was pending.

HAPI Bot

Comment thread hub/src/sync/syncEngine.ts Outdated
status = 'still_alive'
}
}
if (status === 'still_alive') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Distinguish unknown lookup from confirmed liveness

This still treats every still_alive result as proof that a process is running. The runner also returns still_alive when the requested ID is absent from all tracking maps and has no verified-exit tombstone (cli/src/runner/run.ts:1045-1050), and that behavior is asserted at cli/src/runner/runner.integration.test.ts:222. Thus, after runner restart or tracking loss, an actually dead stale session on an online machine cannot be archived.

Suggested fix:

type StopSessionStatus = 'stopped' | 'already_gone' | 'still_alive' | 'unknown'

// Runner no-match path:
return 'unknown'

// Hub only rejects a matched process that survived termination:
if (status === 'still_alive') {
    throw new Error('Session process is still running and could not be stopped')
}
// 'unknown' keeps the prior best-effort archive behavior.

Please also add a regression test using the real unknown-ID contract rather than only mocking the status.

Codex review caught a real gap: cli/src/runner/run.ts's stopSession
returns 'still_alive' both when it finds a PID that's confirmed still
running AND when no PID matches the id anywhere and there's no
verified-exit tombstone (a fail-closed default for a genuinely unknown
id). archiveSession's new fallback treated both cases identically,
so a stale row whose original runner generation lost track of it
(the exact 235-row backlog this investigation started from) would
permanently fail to archive on an otherwise-healthy machine.

Add a fourth 'unknown' status distinct from 'still_alive' for the
no-match case, threaded through the runner<->hub RPC contract
(cli/src/runner/{run,controlServer,controlClient}.ts,
cli/src/api/apiMachine.ts, hub/src/sync/rpcGateway.ts). archiveSession
now treats 'unknown' like the offline-machine case (best-effort
archive); every other existing stopRunnerSession call site
(fork-child cleanup, OpenCode reserved-clear recovery, PTY/Pi resume
termination and reconciliation) keeps its prior conservative behavior
by treating 'unknown' the same as 'still_alive', since those all deal
with sessions the hub has fresh, concrete knowledge of.

Updated the existing runner.integration.test.ts assertion for an
unknown id (was 'still_alive', now correctly 'unknown') and added a
hub-side regression test for the archive path.
@heavygee

heavygee commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the Major finding from both review rounds: added a fourth 'unknown' status distinct from 'still_alive' for the case where the runner has no PID match and no verified-exit tombstone for a session id at all (cli/src/runner/run.ts's fail-closed default). Threaded through the full runner<->hub RPC contract (run.ts, controlServer.ts, controlClient.ts, apiMachine.ts, rpcGateway.ts). archiveSession's new fallback now treats 'unknown' like the offline-machine case (best-effort archive — this is exactly the stale-row scenario the fallback needs to unblock); every other existing stopRunnerSession call site (fork-child cleanup, OpenCode reserved-clear, PTY/Pi resume termination + reconciliation) keeps its prior conservative behavior by treating 'unknown' the same as 'still_alive', since those all deal with sessions the hub has fresh knowledge of.

Updated the pre-existing runner.integration.test.ts assertion for an unknown id (was 'still_alive', now correctly 'unknown') and added a 6th hub-side regression test. Ran the real CLI integration suite locally via bun run test:cli:integration (matches CI's script) — 13/14 pass, 1 skipped; the 4 tests that failed in this PR's CI run (spawn/stop via HTTP, track sessions, webhook metadata, concurrent ops) all pass locally with this branch, and I confirmed the identical 4-test failure signature already exists on main at this PR's base commit (unrelated pre-existing CI flake, not a regression from this change).

tsc --noEmit clean in both hub/ and cli/.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Do not equate an expired machine heartbeat with an unreachable runner — MachineCache.active becomes false after 45 seconds without machine-alive (hub/src/sync/machineCache.ts:227), while the machine RPC registration is removed only when its socket disconnects (hub/src/socket/rpcRegistry.ts:37). A delayed/dropped heartbeat can therefore leave stop-session reachable, but the new branch at hub/src/sync/syncEngine.ts:1700 skips it, assumes already_gone, and archives a live child—the same silent-orphan failure this PR is intended to prevent.
    Suggested fix:
    try {
        status = await this.rpcGateway.stopRunnerSession(machineId, sessionId)
    } catch (runnerError) {
        status = runnerError instanceof RpcTargetMissingError
            ? 'already_gone'
            : 'still_alive'
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One Major finding: the heartbeat-derived active flag is not a safe proxy for machine-RPC reachability, so the fallback can still report successful archival while leaving the process running.

Testing

  • Not run (automation; PR content was not executed).
  • Add a regression test with a cached machine marked inactive while stopRunnerSession remains registered and returns still_alive; archival must fail and the session must remain active.
  • GitHub checks at review time: integration failed; test was still pending.

HAPI Bot

Comment thread hub/src/sync/syncEngine.ts Outdated
const machineId = this.sessionCache.getSession(sessionId)?.metadata?.machineId
if (machineId) {
let status: 'stopped' | 'already_gone' | 'still_alive' | 'unknown'
if (this.machineCache.getMachine(machineId)?.active !== true) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Use RPC reachability, not heartbeat state

machine.active === false does not establish that the runner RPC is unreachable. MachineCache.expireInactive flips this flag after 45 seconds without a heartbeat (hub/src/sync/machineCache.ts:227-238), but RpcRegistry retains the machine handler until the socket actually disconnects (hub/src/socket/rpcRegistry.ts:37-48). If heartbeats are delayed or dropped while the socket/runner is still usable, this branch skips stopRunnerSession, coerces the state to already_gone, and archives a live child.

Suggested fix:

let status: StopSessionStatus
try {
    status = await this.rpcGateway.stopRunnerSession(machineId, sessionId)
} catch (runnerError) {
    status = runnerError instanceof RpcTargetMissingError
        ? 'already_gone' // no machine RPC target: preserve best-effort fallback
        : 'still_alive'  // timeout/protocol failure: fail closed
}

Please add a test where the cached machine is inactive but the machine RPC still returns still_alive; the session must not be archived.

Codex review caught a third variant of this PR's own bug: MachineCache
active flips false 45s after the last machine-alive heartbeat
(machineCache.ts expireInactive), which is a different signal from
whether the machine's RPC target is actually registered (that only
clears on socket disconnect, rpcRegistry.unregisterAll). A delayed or
dropped heartbeat could leave stopRunnerSession fully reachable while
this fallback's `active !== true` pre-check skipped it and archived a
live session anyway.

Drop the machineCache pre-check entirely. Just attempt
stopRunnerSession and let its own failure mode decide: a thrown
RpcTargetMissingError means there is genuinely no RPC target to check
(fall back to archiving, same as before); any other failure means the
machine answered the transport layer but this call didn't resolve,
which is not evidence the process is gone (treat as still_alive).

Rewrote the regression tests to match: no longer registers a machine
in machineCache at all (proving the decision doesn't depend on it),
and asserts on the RpcTargetMissingError-vs-other-error distinction
directly.
@heavygee

Copy link
Copy Markdown
Collaborator Author

Addressed the third Major finding: MachineCache.active is a 45s heartbeat heuristic (machineCache.ts expireInactive), not the same signal as whether the machine's RPC target is actually registered (that only clears on socket disconnect, rpcRegistry.unregisterAll). A delayed heartbeat could leave stopRunnerSession fully reachable while my active !== true pre-check skipped it and archived a live session anyway — the same bug at a third layer.

Dropped the machineCache pre-check entirely. Now it just attempts stopRunnerSession and discriminates on the failure itself: RpcTargetMissingError (no RPC target at all — genuinely nothing to check) falls back to archiving; any other failure (ack timeout, protocol error — machine answered the transport but this call didn't resolve) is treated as still_alive. Rewrote the regression tests to no longer touch machineCache at all, proving the decision doesn't depend on it, and to assert on the RpcTargetMissingError-vs-other-error distinction directly.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • [Major] Treat unknown as unconfirmed, not gone — the new runner result explicitly means no matching PID or verified-exit tombstone was found, not that the process exited (cli/src/runner/run.ts:1049). Terminal-started sessions are registered only in the in-memory tracking map (cli/src/runner/run.ts:456), unlike runner-spawned sessions whose PID mapping is persisted. After a runner restart, a still-live terminal session can therefore return unknown; if its session RPC is also temporarily missing during reconnect, hub/src/sync/syncEngine.ts:1734 archives the row and leaves the process running.
    Suggested fix:
    if (status === "still_alive" || status === "unknown") {
        throw new Error("Session process termination was not confirmed")
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits

  • One Major finding: the fresh head removes the heartbeat pre-check, but still treats an explicitly ambiguous runner result as proof that archival is safe.

Testing

  • Not run (automation; PR content was not executed).
  • Replace the current unknown archival expectation with a regression test asserting that the session remains active and archival rejects.
  • GitHub checks at review time: integration failed; test was in progress. Failure logs were not yet available because the workflow was still running.

HAPI Bot

Comment thread hub/src/sync/syncEngine.ts Outdated
// fallback exists to unblock — so treat it like the
// machine-offline branch above, not like a confirmed-alive
// `'still_alive'`.
if (status === 'still_alive') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Treat unknown as unconfirmed, not gone

unknown explicitly means the runner found neither a matching PID nor verified-exit evidence; it does not confirm process death. A live terminal-started session can reach this state after the runner restarts because the terminal webhook only populates pidToTrackedSession in memory (cli/src/runner/run.ts:456-486), while only runner-spawned PID mappings are persisted. If the live CLI session RPC is simultaneously missing during reconnect, this condition allows the row to be archived while the process continues running.

Suggested fix:

if (status === "still_alive" || status === "unknown") {
    throw new Error("Session process termination was not confirmed")
}

Please invert the current unknown test: archival must reject and the session must remain active.

Codex review caught that treating 'unknown' as archivable was itself
unsafe: terminal-started sessions are tracked only in-memory
(pidToTrackedSession), never persisted to the resume-process file
unlike runner-spawned children. A still-alive terminal session can
therefore return 'unknown' right after this exact runner process
restarts and loses that in-memory tracking — indistinguishable, from
this RPC alone, from a row this runner generation genuinely never
knew about (the stale-row case 'unknown' was introduced for).

Given that ambiguity can't be resolved from this RPC's response alone,
treat 'unknown' conservatively — the same as 'still_alive' — matching
every other stopRunnerSession call site in this file. The 'unknown'
type distinction stays (it's still the runner's accurate contract, and
fixes the pre-existing integration test's assertion), it just doesn't
unblock archival on its own; that's now squarely tiann#842's reconciler
territory, not this fallback's job.
@heavygee

Copy link
Copy Markdown
Collaborator Author

Addressed the fourth finding: treating 'unknown' as archivable was itself unsafe. Terminal-started sessions are tracked only in-memory (pidToTrackedSession), never persisted, unlike runner-spawned children — so a still-alive terminal session can return 'unknown' right after this exact runner process restarts and loses that in-memory tracking, indistinguishable from this RPC alone from a row this runner generation never knew about at all. That ambiguity can't be resolved from the RPC response alone, so 'unknown' is now treated conservatively — the same as 'still_alive' — matching every other stopRunnerSession call site in this file, exactly per the suggested fix.

The 'unknown' type distinction stays (it's the runner's accurate contract and fixes the pre-existing integration test's assertion), it just no longer unblocks archival on its own — that stale-row cleanup case is #842's reconciler territory, not this fallback's job.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings

  • None.

Questions

  • None.

Summary
Review mode: follow-up after new commits

No ≥80%-confidence issues found in the fresh-head diff. Residual risk: the archive regression suite mocks RpcGateway; no end-to-end hub↔runner test exercises machine-level StopSession through Socket.IO on the stale-session-ID path.

Testing

  • Not run (automation; PR content was not executed).
  • Static diff check passed.
  • GitHub checks at review time: integration failed; test and pr-review were pending. Failure logs were not yet available because the workflow run was still in progress.

HAPI Bot

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.

archiveSession's RpcTargetMissingError fallback marks sessions archived without confirming the runner process is actually dead

1 participant