fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919)#923
Conversation
There was a problem hiding this comment.
Findings
- [Major] Runner-driven stop paths now get mislabeled as hub restarts — the new default makes every SIGTERM archive as
Hub restartunless a caller stamps a different reason first. Web Archive now does that throughregisterKillSessionHandler, buthapi runner stop-session, runner webhook timeout cleanup, and orphan cleanup terminate child sessions with SIGTERM directly (cli/src/runner/run.ts:267,cli/src/runner/run.ts:587). Those are operator/runner actions, not hub restarts, yet the changed SIGTERM handler keeps the new default atcli/src/agent/runnerLifecycle.ts:122, so archived metadata becomes misleading and the audit-trail fix regresses another supported termination path.
Suggested fix:// propagate an explicit reason from runner stop/cleanup paths, e.g. via env on spawn const archiveReason = process.env.HAPI_ARCHIVE_REASON if (archiveReason) { lifecycle.setArchiveReason(archiveReason) }
Summary
- Review mode: initial
- Found one audit-trail regression introduced by the SIGTERM default change. Remaining risk: I did not run the full test suite in this automation pass.
Testing
- Not run (automation)
HAPI Bot
…archive reason Addresses bot review of tiann#923: with the tiann#914 default-archiveReason flip to 'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`, webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all mislabel as 'Hub restart' which is also inaccurate audit-trail noise. Smallest defensible change: parameterise the lifecycle default via HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set 'Stopped by runner' on spawn. Terminal-launched sessions (no runner parent, no env var) still default to 'Hub restart' since hub-restart cascade documented at tiann#915 is the most plausible SIGTERM source for those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT Ctrl-C, markCrash uncaught exception) still win. Two new unit tests cover the env-var default and the override precedence. Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Bot review (Codex PR Review run 27567766623) caught a real Major: with the #914 default flipped to Verified active paths (hub never calls
Pushed Out of scope for this PR (flagging for maintainer): |
There was a problem hiding this comment.
Findings
- [Major] Missing-CLI archive can report success even when the hub archive metadata write fails —
markSessionArchivedFromHubreturns silently onupdateSessionMetadataerrors or exhausted version retries, andarchiveSessionthen still marks the in-memory session ended and lets/archivereturn{ ok: true }. That regresses the stated #916 contract that non-RPC failures still surface as 5xx; after a DB/write failure the operator sees success while the persisted row can remain unarchived/running. Evidencehub/src/sync/sessionCache.ts:532, caller continues athub/src/sync/syncEngine.ts:445andhub/src/sync/syncEngine.ts:450.
Suggested fix:markSessionArchivedFromHub(sessionId: string, reason: string): void { // ... if (result.result === 'error') { throw new Error('Failed to archive session metadata from hub') } // ... throw new Error('Session was modified concurrently while archiving from hub') }
Summary
- Review mode: follow-up after new commits
- Found one archive idempotency/data-persistence issue. Remaining risk: I did not run the full test suite in this automation pass.
Testing
- Not run (automation)
HAPI Bot
… 5xx Addresses second-round bot review of tiann#923 (Major): `markSessionArchivedFromHub` silently returned on DB write errors and on exhausted version-retry attempts, which would let `/archive` claim 200 OK while the row stayed unarchived. That regresses the tiann#916 acceptance criterion that non-RPC errors during archive must still propagate as 5xx. Both fall-through paths now throw, matching the contract of the sibling writers in this file (renameSession, mergeSessions). The sessionModel test suite gains two cases that spy on `store.sessions.updateSessionMetadata` to force `error` and `version-mismatch` shapes and asserts the helper throws. The existing route test at `hub/src/web/routes/sessions.test.ts:1015` already covers the route-level 500 propagation for any error thrown out of `archiveSession`, so no new route test is needed. Imports `spyOn` from `bun:test` to match this test file's runtime (the rest of the hub package uses bun:test, not vitest). Refs tiann#916. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Bot follow-up review on Pushed
|
There was a problem hiding this comment.
Findings
- [Major] Runner-spawned sessions are mislabeled on hub restart —
cli/src/runner/run.ts:468now injectsHAPI_DEFAULT_ARCHIVE_REASON='Stopped by runner'into every runner-spawned child. BecauserunnerLifecycleuses that env var as the child’s default archive reason, a systemd/hub restart that SIGTERMs the runner and its children directly still archives those child sessions as “Stopped by runner”, not “Hub restart”. That reintroduces audit-trail pollution for the same restart-cascade path this PR is trying to fix; the runner’s own SIGTERM handler only shuts the runner down and does not selectively restamp already-spawned child env before the external signal reaches them.
Suggested fix:// Keep the lifecycle default as Hub restart for externally delivered SIGTERM. // Stamp runner-owned stops at the kill sites instead of via static spawn env. const stopSession = (sessionId: string): boolean => { // before killProcessByChildProcess(...) void session.childProcess?.send?.({ type: 'set-archive-reason', reason: 'Stopped by runner' }) void killProcessByChildProcess(session.childProcess) return true }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous
markSessionArchivedFromHubpersistence failure finding is addressed, but the follow-up runner env override creates a new audit-attribution regression for hub-restart SIGTERM cascades.
Testing
- Not run (automation). Suggested coverage: runner-spawned child receiving plain SIGTERM without a stop-session request archives as
Hub restart; stop-session/webhook-timeout paths archive asStopped by runner.
HAPI Bot
|
Bot review round 3 on `123c625e` flagged that `HAPI_DEFAULT_ARCHIVE_REASON='Stopped by runner'` on spawn mislabels hub-restart-cascade SIGTERMs against runner-spawned children: systemd cgroup-kill on `hapi-runner.service` stop hits runner-children directly with SIGTERM, and those now archive as 'Stopped by runner' instead of the 'Hub restart' that the cascade narrative wants. This is the mirror image of the bot's round-1 finding. The two suggestions are mutually incompatible without adding an IPC channel (`stdio: 'ipc'` on spawn + `childProcess.send()`) so the runner can stamp `setArchiveReason` via IPC before SIGTERMing - which is a refactor, not a smallest-defensible change. Pushed `a97b9dca` to revert the env-var override. The SIGTERM default is now plain 'Hub restart' for everyone, including runner-internal stop paths (`hapi runner stop-session`, webhook-timeout cleanup, orphan cleanup). The original #914 acceptance criterion - "Hub-restart cascade archives recorded with a reason that is NOT 'User terminated'" - is still met. Finer attribution between cascade vs runner-stop is deferred as a follow-up that needs the IPC channel. `bun typecheck` clean. `bun run test` 982/983 (same pre-existing `runner.integration.test.ts` flake on upstream/main). @tiann happy to add the IPC channel as a separate PR if you'd rather have the finer attribution in-flight; just wanted to keep this bundle scoped to the four issues it's titled for. |
There was a problem hiding this comment.
Findings
- [Major] Clean completions are archived as hub restarts — changing the lifecycle default to
Hub restartmeans every normal agent-loop completion that only callssetSessionEndReason('completed')now writesarchiveReason: 'Hub restart'duringarchiveAndClose. For example, Codex/Claude/Cursor/Gemini/Kimi/OpenCode all set the end reason tocompletedbeforecleanupAndExit, but none set an archive reason on that path, so successful exits pollute the audit trail as restart cascades. Evidence:cli/src/agent/runnerLifecycle.ts:46(changed default), with the write atcli/src/agent/runnerLifecycle.ts:59and completion call sites such ascli/src/codex/runCodex.ts:377.
Suggested fix:const setSessionEndReason = (reason: SessionEndReason) => { sessionEndReason = reason if (reason === 'completed' && archiveReason === 'Hub restart') { archiveReason = 'Session completed' } }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous runner env override finding is addressed, but the new lifecycle default still leaks into clean completion paths and misattributes successful exits as hub restarts.
Testing
- Not run (automation). Suggested coverage: a lifecycle cleanup after
setSessionEndReason('completed')writes a non-restart archive reason while SIGTERM/default cleanup still writesHub restart.
HAPI Bot
Addresses bot review round 4 of tiann#923 (Major): every agent runner (runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode) calls setSessionEndReason('completed') on the natural exit path without touching archiveReason. With the SIGTERM default flipped to 'Hub restart', clean completions were now archived as restart cascades. Fix: setSessionEndReason flips archiveReason to 'Session completed' when it transitions to 'completed' AND no caller has already overridden the archive reason. This covers all six agent runners with a single setter change (no per-runner edits). Two new tests cover the natural-completion default and the override precedence (explicit setArchiveReason still wins). Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Round 4 bot finding is legitimate and was pre-existing — even before this PR, clean completions wrote `archiveReason: 'User terminated'` (the OLD default), which was also wrong. My default-flip just changed the noise from one wrong label to another. Worth fixing while we're here. Verified all six agent runners call `setSessionEndReason('completed')` before `cleanupAndExit()` on the natural exit path without setting an archive reason: `runClaude.ts:461`, `runCodex.ts:377`, `runCursor.ts:203`, `runGemini.ts:195`, `runKimi.ts:185`, `runOpencode.ts:308`. Pushed `98b10319`. Applied the bot's suggested shape: the setter now flips `archiveReason` to 'Session completed' when transitioning to 'completed' AND no caller has overridden first. One change covers all six runners. Two new tests cover the natural-completion default and the override-precedence guard (explicit `setArchiveReason('User terminated')` before `setSessionEndReason('completed')` still wins). `bun typecheck` clean, 984/985 (same pre-existing `runner.integration.test.ts` flake). |
There was a problem hiding this comment.
Findings
- No issues found.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- No high-confidence correctness, security, data-loss, performance, or maintainability issues found in the current diff. The previous bot finding about clean completions being archived as hub restarts is addressed in this head.
Testing
- Not run (automation).
HAPI Bot
|
Reviewer context (2026-06-16): relationship to #915 / PR #928 This PR (#923) is defense-in-depth for four concrete bugs filed from the 2026-06-15 hub-restart cascade incident (#913, #914, #916, #919). It does not attempt to solve the architectural root cause described in #915. Since filing #915, post-incident tracing narrowed the primary cascade mechanism:
How to read this PR alongside #928:
Recommend reviewing #923 on its own merits as belt-and-braces correctness, not as a substitute for #928. If both merge, the operator-facing incident class from 2026-06-15 should be largely closed; #917 (reopen post-load death) remains a separate open track. |
…archive reason Addresses bot review of tiann#923: with the tiann#914 default-archiveReason flip to 'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`, webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all mislabel as 'Hub restart' which is also inaccurate audit-trail noise. Smallest defensible change: parameterise the lifecycle default via HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set 'Stopped by runner' on spawn. Terminal-launched sessions (no runner parent, no env var) still default to 'Hub restart' since hub-restart cascade documented at tiann#915 is the most plausible SIGTERM source for those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT Ctrl-C, markCrash uncaught exception) still win. Two new unit tests cover the env-var default and the override precedence. Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
98b1031 to
1495971
Compare
… 5xx Addresses second-round bot review of tiann#923 (Major): `markSessionArchivedFromHub` silently returned on DB write errors and on exhausted version-retry attempts, which would let `/archive` claim 200 OK while the row stayed unarchived. That regresses the tiann#916 acceptance criterion that non-RPC errors during archive must still propagate as 5xx. Both fall-through paths now throw, matching the contract of the sibling writers in this file (renameSession, mergeSessions). The sessionModel test suite gains two cases that spy on `store.sessions.updateSessionMetadata` to force `error` and `version-mismatch` shapes and asserts the helper throws. The existing route test at `hub/src/web/routes/sessions.test.ts:1015` already covers the route-level 500 propagation for any error thrown out of `archiveSession`, so no new route test is needed. Imports `spyOn` from `bun:test` to match this test file's runtime (the rest of the hub package uses bun:test, not vitest). Refs tiann#916. Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses bot review round 4 of tiann#923 (Major): every agent runner (runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode) calls setSessionEndReason('completed') on the natural exit path without touching archiveReason. With the SIGTERM default flipped to 'Hub restart', clean completions were now archived as restart cascades. Fix: setSessionEndReason flips archiveReason to 'Session completed' when it transitions to 'completed' AND no caller has already overridden the archive reason. This covers all six agent runners with a single setter change (no per-runner edits). Two new tests cover the natural-completion default and the override precedence (explicit setArchiveReason still wins). Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Rebased onto sessionCache.ts conflict was against #886 machine-binding changes; rebased cleanly with #919 retry logic + #916 Bot Major status (orchestrator triage):
Round-5 bot review on prior head: "No issues found." CI re-running on rebased head. Local: |
There was a problem hiding this comment.
Findings
- [Major] Preserve the inactive-session guard in
/archive— the route now removesrequireActive: trueand only short-circuits rows already markedlifecycleState="archived". Any inactive, non-archived row (for example a completed/stub session or a normal inactive row that the UI would offer Delete/Reopen for) now falls through toengine.archiveSession; if no CLI RPC target exists, the new fallback writesarchivedBy="hub"/archiveReason="Archived from hub (CLI unreachable), changing audit metadata for a session that was not active. Evidencehub/src/web/routes/sessions.ts:304.
Suggested fix:const lifecycleState = sessionResult.session.metadata?.lifecycleState if (lifecycleState === "archived") { return c.json({ ok: true, alreadyArchived: true }) } if (!sessionResult.session.active && lifecycleState !== "running") { return c.json({ error: "Session is inactive" }, 409) }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The archive cleanup path still needs to preserve the old inactive-session contract while allowing the intended stale
lifecycleState="running"cleanup case.
Testing
- Not run (automation):
bunis unavailable in this runner (bun: command not found).
HAPI Bot
Addresses post-rebase bot review Major on tiann#923: dropping requireActive entirely let normal inactive non-archived rows (completed stubs, UI Delete/Reopen targets) fall through to archiveSession, which could stamp archivedBy=hub on sessions that were never active. Restore the 409 for inactive rows unless metadata.lifecycleState is still 'running' (hub-restart split-brain cleanup case from tiann#916). Two route tests cover the guard and the exception. Refs tiann#916. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
No issues found.
Questions
None.
Summary
- Review mode: follow-up after new commits
- Prior
/archiveinactive-session concern is addressed in the current head. Static review found no high-confidence correctness or security regressions in the changed lifecycle cleanup, archive fallback, metadata retry, and Cursor ACP flush paths. - Residual risk: tests were not executed here; validation was static because PR content is untrusted in this review workflow.
Testing
- Not run (automation): static review only; did not execute PR code.
HAPI Bot
tiann#916 tiann#919) These four contained bugs were uncovered by a 2026-06-15 hub-restart incident where `hapi-restart-hub` SIGTERMed 23 cursor ACP sessions. Each fix lands independently of the architectural tiann#915 (hub-restart cascade-archive) and the hypothesis-pending tiann#917 (reopen creates dead session); audit-trail correctness and idempotency wins stand on their own. Fresh ACP sessions could be SIGTERMed during the async `update-metadata` ACK round-trip, stranding the on-disk ACP store with no DB handle. Add `ApiSessionClient.flushMetadata()` and await it after `onSessionFoundWithProtocol` on the fresh-session branch. Resume-path pre-registration (PR tiann#834) is unchanged. Hub-restart-cascade SIGTERMs went through the same path as web-UI Archive clicks, both writing archiveReason='User terminated'. New default is 'Hub restart'; the KillSession RPC handler (the authoritative user-archive signal) now explicitly stamps 'User terminated' before cleanupAndExit. SIGINT (local-terminal Ctrl-C) keeps the 'User terminated' label too. `rpcGateway.killSession` threw a generic Error when no target socket was registered, and the archive route surfaced that as 500. Add typed `RpcTargetMissingError`, narrow on it in `syncEngine.archiveSession`, fall back to a hub-side `markSessionArchivedFromHub` write so lifecycleState still flips to 'archived'. Drop the requireActive guard on the route and 2xx-noop for already-archived rows. without refresh, producing forever-409 on rename/reopen until an unrelated event triggered a cache refresh. `renameSession`, `clearSessionArchiveMetadata`, `restoreSessionArchiveMetadata` now retry-with-refresh (5 attempts, then throw) mirroring the existing good pattern in `mergeSessions`. Refs tiann#913 Refs tiann#914 Refs tiann#916 Refs tiann#919 AI disclosure: implementation by Claude Sonnet 4.5 (Cursor agent peer) under operator supervision. Issue triage by a sibling discovery agent. Per CONTRIBUTING.md AI-assisted contributions policy. Co-authored-by: Cursor <cursoragent@cursor.com>
…archive reason Addresses bot review of tiann#923: with the tiann#914 default-archiveReason flip to 'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`, webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all mislabel as 'Hub restart' which is also inaccurate audit-trail noise. Smallest defensible change: parameterise the lifecycle default via HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set 'Stopped by runner' on spawn. Terminal-launched sessions (no runner parent, no env var) still default to 'Hub restart' since hub-restart cascade documented at tiann#915 is the most plausible SIGTERM source for those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT Ctrl-C, markCrash uncaught exception) still win. Two new unit tests cover the env-var default and the override precedence. Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
… 5xx Addresses second-round bot review of tiann#923 (Major): `markSessionArchivedFromHub` silently returned on DB write errors and on exhausted version-retry attempts, which would let `/archive` claim 200 OK while the row stayed unarchived. That regresses the tiann#916 acceptance criterion that non-RPC errors during archive must still propagate as 5xx. Both fall-through paths now throw, matching the contract of the sibling writers in this file (renameSession, mergeSessions). The sessionModel test suite gains two cases that spy on `store.sessions.updateSessionMetadata` to force `error` and `version-mismatch` shapes and asserts the helper throws. The existing route test at `hub/src/web/routes/sessions.test.ts:1015` already covers the route-level 500 propagation for any error thrown out of `archiveSession`, so no new route test is needed. Imports `spyOn` from `bun:test` to match this test file's runtime (the rest of the hub package uses bun:test, not vitest). Refs tiann#916. Co-authored-by: Cursor <cursoragent@cursor.com>
Reverts `1c8972a3`. Bot review round 3 surfaced that the env-on-spawn approach (the bot's own round-1 suggestion shape) mislabels hub-restart-cascade SIGTERMs against runner-spawned children: systemd killcgroup on `hapi-runner.service` stop sends SIGTERM to all runner-children directly, and those would archive as 'Stopped by runner' instead of 'Hub restart'. The two suggestions are mutually incompatible without adding an IPC channel (stdio: 'ipc' on spawn) so the runner can stamp setArchiveReason via childProcess.send() before SIGTERMing. That is a refactor, not a smallest-defensible change. Going back to the simple shape: SIGTERM default is 'Hub restart' for everyone, runner-internal stop paths share that label. The audit-trail-correctness criterion from the tiann#914 issue is met (SIGTERM no longer falsely labels as 'User terminated'). Finer attribution between cascade vs runner-stop is deferred as a follow-up. Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses bot review round 4 of tiann#923 (Major): every agent runner (runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode) calls setSessionEndReason('completed') on the natural exit path without touching archiveReason. With the SIGTERM default flipped to 'Hub restart', clean completions were now archived as restart cascades. Fix: setSessionEndReason flips archiveReason to 'Session completed' when it transitions to 'completed' AND no caller has already overridden the archive reason. This covers all six agent runners with a single setter change (no per-runner edits). Two new tests cover the natural-completion default and the override precedence (explicit setArchiveReason still wins). Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses post-rebase bot review Major on tiann#923: dropping requireActive entirely let normal inactive non-archived rows (completed stubs, UI Delete/Reopen targets) fall through to archiveSession, which could stamp archivedBy=hub on sessions that were never active. Restore the 409 for inactive rows unless metadata.lifecycleState is still 'running' (hub-restart split-brain cleanup case from tiann#916). Two route tests cover the guard and the exception. Refs tiann#916. Co-authored-by: Cursor <cursoragent@cursor.com>
Post-rebase fix: Session completed tests referenced makeFakeSession which was renamed to createMockApiSessionWithMetadataCapture when merging upstream hasExplicitSessionEndReason tests with tiann#914 archive reason coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
f0ead9b to
070e07d
Compare
There was a problem hiding this comment.
Findings
- [Major] Pi archive RPC still uses the legacy cleanup callback —
runnerLifecyclenow defaults archives toHub restart, andregisterKillSessionHandleronly stampsUser terminatedwhen callers pass an object withsetArchiveReason. The changed call sites were updated for Claude/Codex/Cursor/Gemini/Kimi/OpenCode, but the live Pi runner still registersregisterKillSessionHandler(apiSession.rpcHandlerManager, lifecycle.cleanupAndExit)atcli/src/pi/runPi.ts:91. That means clicking Archive from the web for a Pi session will go through KillSession but persistarchiveReason: 'Hub restart', reintroducing the audit-trail bug this PR is fixing. Evidence:cli/src/claude/registerKillSessionHandler.ts:43, related contextcli/src/agent/runnerLifecycle.ts:47andcli/src/pi/runPi.ts:91.
Suggested fix:// cli/src/pi/runPi.ts registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle);
Questions
None.
Summary
- Review mode: follow-up after new commits
- One audit-trail regression remains for Pi sessions because the new lifecycle default is not paired with the new KillSession registration shape on that runner.
- Residual risk: validation was static only; PR code was not executed in this review workflow.
Testing
- Not run (automation): static review only; did not execute PR code.
- Suggested: add a Pi-side KillSession registration test or extend an existing Pi runner mock test to assert the lifecycle object is passed.
HAPI Bot
Upstream tiann#862 (Pi agent) landed after this branch was cut. runPi.ts still registered the legacy bare cleanupAndExit callback, so web Archive for Pi sessions would persist archiveReason: Hub restart instead of User terminated. One-line fix matching the other six agent runners. Refs tiann#914. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
None.
Questions
None.
Summary
- Review mode: follow-up after new commits
- No issues found in the latest PR diff. The prior Pi KillSession lifecycle finding is addressed at
cli/src/pi/runPi.ts:91. - Residual risk: static review only; lifecycle timing and hub/CLI disconnect behavior still rely on existing integration coverage.
Testing
- Not run (automation): static review only; did not execute PR code.
HAPI Bot
…ate, drag-drop Merge tiann/hapi upstream (33 commits, v0.20.2) into the liuxin fork. Notable adoptions: - file viewer download button (tiann#926) — the download UI the user asked about; decodes existing base64 content to a Blob, no new endpoint - Claude Code 'auto' permission mode (tiann#879) - in-app PWA update prompt when a new service worker is ready (tiann#946) — PwaUpdateBannerWithStatusOffset, adopted WITHOUT enabling voice - drag-and-drop files onto chat to attach (tiann#936) + Web Share Target (tiann#933) - Codex fast mode / service-tier toggle (tiann#904), session service_tier column + merge-preserve - four hub-restart-cascade cleanup bugs (tiann#923), stale-PID detection after abnormal runner shutdown (tiann#931), OutgoingMessageQueue flush before next turn (tiann#909), inactive-session send error surface (tiann#922) - OLED black theme + per-appearance custom colors (tiann#937), session-list attention tooltips (tiann#941), active-only filter + paginated Show-N-more (tiann#903), file-explorer state persistence (tiann#911) Conflict resolutions (22 files) — fork features preserved: - store/index.ts: both forks bumped schema to V10 with different migrateFromV9ToV10; combined into one migration doing BOTH our content_uuid dedup index (messages) AND upstream service_tier (sessions) - socket/server.ts: collapsed duplicate maxHttpBufferSize key into Math.max(SOCKET_MAX_HTTP_BUFFER_SIZE, 100MB) — keeps our 100MB upload floor while honoring upstream's image-buffer constant - spawnSession plumbing (apiMachine/rpcGateway/syncEngine): unioned our sandbox+continueLatest with upstream's serviceTier across signature, RPC payload, and the resume call site - App.tsx: kept signOut + voice-disabled; adopted PWA update banner (PwaUpdateProvider auto-merged into App() wrapper) - SessionChat: nested both wrappers — HappyChatProvider > ShareSeedConsumer + DragDropZone > thread - sessionModel.test: split the two same-named merge tests (our codex dedup + upstream service-tier) back into separate it() blocks - useSendMessage: kept fork queue-stop AND upstream onError surface; keyed onError by non-null sid - startHappyServer: kept fork's manual-rename skip in change_title - schemas/locales/StatusBar/HappyComposer: unioned both sides Post-merge type fixes: pinned-session test mock gained pendingRequests + nextScheduledAt (new required SessionSummary fields); legacy-CLI model backfill in applySessionConfig now extracts modelId from upstream's Pi {provider,modelId} union. Validation: typecheck green (cli/web/hub); full suite 2260 tests pass incl. the runner stress test. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
Add OMP (@oh-my-pi/pi-coding-agent) as a first-class agent flavor,
aligning with the existing Claude Code adaptation and surfacing OMP's
unique capabilities without restructuring hapi's core.
OMP shares Pi's RPC protocol family (both build on @oh-my-pi/pi-agent-core
+ pi-ai), so cli/src/omp/ mirrors cli/src/pi/ with targeted protocol-diff
changes:
- ready-frame handshake gate (OMP pushes {type:ready} before accepting cmds)
- slash commands via available_commands_update push (not get_commands)
- Model thinking info under model.thinking.efforts/effortMap/defaultLevel
- resume via --continue (not --session-id)
- omp binary resolved from ~/.bun/bin/omp (not on PATH)
- spawn fixed --approval-mode=yolo (rpc has no runtime permission switch)
OMP-only capabilities map onto hapi's existing generic mechanisms — no
reducer/RPC-architecture changes:
- goal_updated -> thread-goal-updated (reuse latestGoal reducer)
- subagent frames -> sidechain (reuse tracer)
- auto_compaction_* -> compact event (reuse presentation)
- other events -> AgentEvent catch-all
- steer/follow_up/compact/branch/bash/handoff + steering/interrupt mode
switches via single-direction RPC handlers
Out of scope (OMP extension ecosystem, optional): host_tool_call,
host_uri_request, extension_ui_request — OMP does not send these unless
the host registers tools/URI schemes.
Verification: bun typecheck 0 error; bun run test 1119/1120 pass (sole
failure is the pre-existing runner.integration spawn/stop flake from
PR tiann#923, unrelated to omp); live smoke spawn->ready->prompt->streaming
text->turn_complete confirmed (OMP glm-5.2 replies).
6-round OCR code review converged to 0 comments; 29 real bugs fixed
across rounds (schema fault-tolerance, transport close/ENOENT/EPIPE/
stdin-error handling, ready/markReady race guards, initialModel/
initialThinkingLevel race flags, thinkingLevel runtime validation,
efforts case-normalization, cycle-response type guards, response-shape
validation). Style-only findings that mirror Pi's existing patterns were
left unchanged to avoid Pi/omp divergence.
Summary
Four contained bugs uncovered by the 2026-06-15 hub-restart cascade incident
where
systemctl restart hapi-hub.serviceSIGTERMed 23 cursor ACP sessions.Each fix is small and lands independently of the architectural #915
(cascade-archive root cause) and the hypothesis-pending #917. They produce
audit-trail correctness and idempotency wins that stand on their own.
Refs #913, #914, #916, #919.
#913 - cursor ACP fresh-session
cursorSessionIdpersistence raceFresh ACP sessions could be SIGTERMed mid-flight in the async
update-metadataACK round-trip, stranding the on-disk ACP store with no DBhandle. Distinct from #834 (which fixed the resume path via
pre-registration before
session/load).Fix: add
ApiSessionClient.flushMetadata()(drains the metadata lock,5s timeout) and
awaitit afteronSessionFoundWithProtocolon thefresh-session branch in
cursorAcpRemoteLauncher.ts. The resume-pathpre-registration shape stays intact. A 5s no-ACK timeout logs a warning so
the operator has a diagnostic if hub backpressure ever drops the write.
#914 - default
archiveReasonpolluted audit trail with "User terminated"runnerLifecycle.tsdefaultedarchiveReason = 'User terminated'. SIGTERMfrom a hub-restart cascade hit the same cleanup path that the web-UI
Archive click did, both recording "User terminated" — but only one was
intentional.
Fix:
runnerLifecycle.tsflipped to'Hub restart'.registerKillSessionHandler(the hub-driven kill-RPC, sent only onweb-UI Archive) now calls
setArchiveReason('User terminated')beforecleanupAndExit.SIGINT(local-terminal Ctrl-C) keeps'User terminated'.runClaude,runCodex,runCursor,runGemini,runKimi,runOpencode) updated to pass the lifecycle object instead ofthe bare closure. Legacy closure shape still accepted for
back-compat (
runAgentSession.ts).#916 -
POST /api/sessions/:id/archivereturned 500 when CLI is goneArchive route called
engine.archiveSession→rpcGateway.killSession→sessionRpc, which threw a genericErrorwhen no target socket wasregistered. The unhandled throw became 500. Operator clicking Archive on
split-brain zombies (after a cascade) saw nothing but a toast.
Fix:
RpcTargetMissingError(code:handler-not-registeredorsocket-disconnected) thrown fromrpcGateway.rpcCall.syncEngine.archiveSessionnarrows on it: still flipslifecycleStateto
archivedvia a newsessionCache.markSessionArchivedFromHubhelper(writes
archivedBy='hub',archiveReason='Archived from hub (CLI unreachable)'),then
handleSessionEndmarks inactive in cache.requireActive: trueand short-circuits 2xx with{ ok: true, alreadyArchived: true }for already-archived rows.#919 - sessionCache: three metadata writers threw on version-mismatch without refresh
renameSession,clearSessionArchiveMetadata, andrestoreSessionArchiveMetadatafollowed a one-shot-throw pattern onversion-mismatch. The cache stayed stale, so the caller's retry hit thesame mismatch → forever-409 on
PATCH /sessions/:id,POST /sessions/:id/reopen, and the reopen rollback path. The codebasealready had the good pattern:
sessionCache.mergeSessionsretries with afresh snapshot.
Fix: rewrite all three helpers as
for (attempt = 0; attempt < 5; …)retry-with-refresh loops. After 5 attempts of genuine concurrent contention
they still throw the original 409 message, so the existing operator-facing
contract is preserved.
§6 gates
bun typecheck— passes on the branch againstupstream/main@93d00414.bun run test— 982/983 tests pass. The single failure is thepre-existing
runner.integration.test.ts > should detect version mismatch and kill old runnerflake, confirmed to fail on upstream/main before thebranch too (
expected '0.0.0-integration-test-...' to be '0.20.2'—version-substitution issue unrelated to this diff).
cli/src/agent/runnerLifecycle.test.ts— default reason, override,crash override.
cli/src/claude/registerKillSessionHandler.test.ts— RPC stamps'User terminated'before cleanup; back-compat with bare closure.cli/src/cursor/cursorAcpRemoteLauncher.test.ts— fresh-pathflushMetadataordering; resume-path fix(cursor): pre-register cursorSessionId on resume before ACP session/load completes #834 shape preserved.hub/src/sync/rpcGateway.test.ts—RpcTargetMissingErrortypedthrown for both no-target cases.
hub/src/sync/sessionModel.test.ts—markSessionArchivedFromHubidempotency + self-heal; three sessionCache: renameSession / clearSessionArchiveMetadata / restoreSessionArchiveMetadata throw on version-mismatch without refresh; forever-409 until out-of-band cache refresh #919 self-heal tests for the metadata
writers.
hub/src/web/routes/sessions.test.ts—/archiveroute: active 2xx,already-archived 2xx idempotent, missing-CLI 2xx (via engine mock),
DB-failure 500, unknown id 404.
driver/integration; 529/529 hub tests + 409/409 relevant cli testspass on the integrated soup build.
Coordination notes
mergeSessionDatacascade-delete) is handed off to PR feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback #896 peer.This branch touches
sessionCache.tsbut in different methods(
renameSession,clearSessionArchiveMetadata,restoreSessionArchiveMetadata, newmarkSessionArchivedFromHub) — thefeat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback #896 peer's
mergeSessionDatawork is untouched.session) intentionally NOT addressed in this PR; if architecture: hub restart cascade-archives every idle agent session; sessions should be resilient to hub bouncing #915 lands later and
reshapes the cascade behavior, the four fixes here still stand
independently because their wins are audit-trail correctness and
idempotency, not preventing the cascade.
AI disclosure
Per
CONTRIBUTING.md: implementation by Claude Sonnet 4.5 (Cursor IDEagent, auto-model) acting as a feature peer in a multi-agent HAPI fork
workflow. Issue triage and codepath verification were done by a sibling
discovery agent (
c8ac5e86). Operator supervised; tests + cold review +soup verification gates passed before raising the PR. The code follows the
operator's friction-mode disposition: smallest defensible change per bug.
Made with Cursor