fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919) (#923)

* fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #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 #915 (hub-restart
cascade-archive) and the hypothesis-pending #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 #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/hapi#913
Refs tiann/hapi#914
Refs tiann/hapi#916
Refs tiann/hapi#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>

* fix(cli): runner-spawned children use 'Stopped by runner' as default archive reason

Addresses bot review of #923: with the #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 #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/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): markSessionArchivedFromHub surfaces persistence failures as 5xx

Addresses second-round bot review of #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 #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/hapi#916.

Co-authored-by: Cursor <cursoragent@cursor.com>

* revert(cli): drop HAPI_DEFAULT_ARCHIVE_REASON env override

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 #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/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): clean completions get 'Session completed', not 'Hub restart'

Addresses bot review round 4 of #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/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): restore inactive-session guard on /archive except split-brain

Addresses post-rebase bot review Major on #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 #916).
Two route tests cover the guard and the exception.

Refs tiann/hapi#916.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): merge runnerLifecycle tests after upstream rebase

Post-rebase fix: Session completed tests referenced makeFakeSession
which was renamed to createMockApiSessionWithMetadataCapture when
merging upstream hasExplicitSessionEndReason tests with #914 archive
reason coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): pass lifecycle object to KillSession handler in Pi runner

Upstream #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/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-19 17:37:32 +08:00
committed by GitHub
co-authored by Cursor
parent a0259b531e
commit 2ab3b39887
21 changed files with 1032 additions and 125 deletions
+109
View File
@@ -23,6 +23,23 @@ function createMockApiSession() {
} as unknown as Parameters<typeof createRunnerLifecycle>[0]['session'];
}
function createMockApiSessionWithMetadataCapture() {
const metadataWrites: Array<Record<string, unknown>> = []
return {
updateMetadata: vi.fn((handler: (m: Record<string, unknown>) => Record<string, unknown>) => {
const next = handler({})
metadataWrites.push(next)
return next
}),
sendSessionDeath: vi.fn(),
flush: vi.fn(async () => {}),
close: vi.fn(async () => {}),
metadataWrites
} as unknown as Parameters<typeof createRunnerLifecycle>[0]['session'] & {
metadataWrites: Array<Record<string, unknown>>
}
}
describe('createRunnerLifecycle', () => {
let lifecycle: RunnerLifecycle;
@@ -85,3 +102,95 @@ describe('createRunnerLifecycle', () => {
});
});
});
// tiann/hapi#914: the runnerLifecycle's default archiveReason is now
// 'Hub restart' (was 'User terminated'). Out-of-band SIGTERM from the
// hub-restart cascade keeps that default. Explicit user actions
// (clicking Archive in the web UI, Ctrl-C in a local terminal,
// uncaught exception) reassign the reason before archive metadata is
// written.
describe('createRunnerLifecycle archiveReason defaults (tiann/hapi#914)', () => {
it('uses Hub restart as the default archiveReason when no override is applied', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})
await lifecycle.cleanup()
expect(session.metadataWrites).toHaveLength(1)
expect(session.metadataWrites[0]).toMatchObject({
lifecycleState: 'archived',
archivedBy: 'cli',
archiveReason: 'Hub restart'
})
})
it('writes the operator-supplied reason when setArchiveReason is called (e.g. KillSession RPC)', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})
lifecycle.setArchiveReason('User terminated')
await lifecycle.cleanup()
expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'User terminated'
})
})
it('markCrash overrides the default reason to "Session crashed"', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})
lifecycle.markCrash(new Error('boom'))
await lifecycle.cleanup()
expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'Session crashed'
})
})
// tiann/hapi#914 review round 4: clean agent-loop completions
// (runClaude / runCodex / runCursor / runGemini / runKimi /
// runOpencode all call setSessionEndReason('completed') without
// touching archiveReason) must not be archived as 'Hub restart'.
// The setSessionEndReason setter flips the default when the runner
// transitions to 'completed'.
it('setSessionEndReason("completed") flips the default reason to "Session completed"', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})
lifecycle.setSessionEndReason('completed')
await lifecycle.cleanup()
expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'Session completed'
})
})
it('an explicit setArchiveReason before setSessionEndReason("completed") still wins', async () => {
const session = createMockApiSessionWithMetadataCapture()
const lifecycle = createRunnerLifecycle({
session,
logTag: 'test'
})
lifecycle.setArchiveReason('User terminated')
lifecycle.setSessionEndReason('completed')
await lifecycle.cleanup()
expect(session.metadataWrites[0]).toMatchObject({
archiveReason: 'User terminated'
})
})
})
+41 -1
View File
@@ -24,7 +24,27 @@ export type RunnerLifecycle = {
export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLifecycle {
let exitCode = 0
let archiveReason = 'User terminated'
// tiann/hapi#914: default reason is 'Hub restart' (parent-driven SIGTERM
// is the most common non-user cause). Genuine user actions (clicking
// Archive in the web UI, or Ctrl-C in a local terminal) explicitly
// reassign this via `setArchiveReason` BEFORE `cleanupAndExit` runs:
// - KillSession RPC handler → 'User terminated' (see registerKillSessionHandler)
// - SIGINT handler → 'User terminated' (Ctrl-C in local terminal)
// - uncaughtException/Reject → 'Session crashed' (via markCrash)
//
// Out-of-band SIGTERM (hub-restart cascade, systemd cgroup kill on
// hapi-runner.service stop, `kill <pid>` from the operator) keeps the
// default and is correctly labelled 'Hub restart' on the audit trail.
//
// Runner-internal stop paths (`hapi runner stop-session`, webhook-timeout
// cleanup at run.ts:587, orphan cleanup at run.ts:267) also currently
// hit this default - that is technically inaccurate but follows the
// friction-mode "smallest defensible change" rule for this PR. Finer
// attribution would require an IPC channel (stdio: 'ipc' on spawn) so
// the runner can stamp `setArchiveReason` before SIGTERMing; tracked as
// a follow-up to keep this PR focussed on the user-action lie that
// motivated #914.
let archiveReason = 'Hub restart'
let sessionEndReason: SessionEndReason = 'terminated'
let sessionEndReasonExplicit = false
let cleanupStarted = false
@@ -98,6 +118,18 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi
const setSessionEndReason = (reason: SessionEndReason) => {
sessionEndReason = reason
sessionEndReasonExplicit = true
// tiann/hapi#914 review round 4: every agent runner
// (runClaude / runCodex / runCursor / runGemini / runKimi /
// runOpencode) calls setSessionEndReason('completed') before
// cleanupAndExit() on the natural-exit path without setting an
// archive reason. With the SIGTERM-driven default of 'Hub restart',
// clean completions would otherwise be audit-trailed as restart
// cascades. Flip the default to 'Session completed' when the end
// reason transitions to 'completed' AND no caller has already
// overridden the archive reason.
if (reason === 'completed' && archiveReason === 'Hub restart') {
archiveReason = 'Session completed'
}
}
const hasExplicitSessionEndReason = () => sessionEndReasonExplicit
@@ -110,11 +142,19 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi
}
const registerProcessHandlers = () => {
// tiann/hapi#914: SIGTERM is treated as the default reason ('Hub restart')
// because the runner is restarted by systemd as part of hub restart in
// production. If a future code path needs to distinguish "operator
// killed the host process" from "hub restart", it can call
// setArchiveReason() before the runner exits.
process.on('SIGTERM', () => {
void cleanupAndExit()
})
// Ctrl-C in a local terminal is genuine user intent — keep the
// pre-#914 label so the audit trail still shows it.
process.on('SIGINT', () => {
archiveReason = 'User terminated'
void cleanupAndExit()
})
+15
View File
@@ -748,6 +748,21 @@ export class ApiSessionClient extends EventEmitter {
})
}
/**
* tiann/hapi#913: wait until any pending `update-metadata` writes have
* been acked by the hub (or the timeout elapses). `updateMetadata` is
* fire-and-forget at the call site because it's invoked on the hot path
* for every turn; this helper lets the few callers who actually need
* durability — fresh ACP session-id pre-registration is the canonical
* case — synchronously gate on persistence without changing every
* caller's signature.
*
* Returns true when the lock drained, false when the timeout fired.
*/
async flushMetadata(timeoutMs: number = 5_000): Promise<boolean> {
return await this.drainLock(this.metadataLock, timeoutMs)
}
async flush(options?: { timeoutMs?: number }): Promise<void> {
const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000)
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest'
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
import { registerKillSessionHandler } from './registerKillSessionHandler'
// tiann/hapi#914: the KillSession RPC is the authoritative "user-terminated"
// signal because the hub only sends it when the operator clicks Archive in
// the web UI. Out-of-band SIGTERM (hub-restart cascade, host-level `kill`)
// hits the SIGTERM signal handler in runnerLifecycle, which now keeps the
// default reason 'Hub restart' so the audit trail stays correct.
describe('registerKillSessionHandler (tiann/hapi#914)', () => {
function makeRegistry() {
const handlers = new Map<string, (params?: unknown) => unknown>()
return {
registerHandler: (method: string, handler: (params: unknown) => unknown) => {
handlers.set(method, handler as (params?: unknown) => unknown)
},
handlers
}
}
it('stamps archiveReason=User terminated before triggering cleanupAndExit', async () => {
const registry = makeRegistry()
const lifecycle = {
setArchiveReason: vi.fn(),
cleanupAndExit: vi.fn(async () => {})
}
registerKillSessionHandler(
registry as unknown as Parameters<typeof registerKillSessionHandler>[0],
lifecycle
)
const handler = registry.handlers.get(RPC_METHODS.KillSession)
expect(handler).toBeDefined()
const result = await handler?.()
expect(result).toEqual({ success: true, message: 'Killing hapi CLI process' })
// setArchiveReason MUST be called BEFORE cleanupAndExit so the archive
// metadata write reads the correct reason.
const setReasonOrder = lifecycle.setArchiveReason.mock.invocationCallOrder[0]
const cleanupOrder = lifecycle.cleanupAndExit.mock.invocationCallOrder[0]
expect(setReasonOrder).toBeLessThan(cleanupOrder)
expect(lifecycle.setArchiveReason).toHaveBeenCalledWith('User terminated')
expect(lifecycle.cleanupAndExit).toHaveBeenCalled()
})
it('still works with the legacy `(cleanupAndExit: () => Promise<void>)` call shape', async () => {
// Back-compat: runAgentSession.ts passes a bare closure as the second
// argument instead of a lifecycle object. The handler should not crash
// when setArchiveReason is absent.
const registry = makeRegistry()
const cleanupAndExit = vi.fn(async () => {})
registerKillSessionHandler(
registry as unknown as Parameters<typeof registerKillSessionHandler>[0],
cleanupAndExit
)
const handler = registry.handlers.get(RPC_METHODS.KillSession)
await handler?.()
expect(cleanupAndExit).toHaveBeenCalled()
})
})
+27 -4
View File
@@ -11,18 +11,41 @@ interface KillSessionResponse {
message: string;
}
/**
* tiann/hapi#914: callers can pass either a bare `cleanupAndExit` closure
* (legacy) or an options object that lets the kill-RPC stamp an explicit
* `archiveReason` before the lifecycle teardown runs. The hub only sends
* KillSession when the operator clicked Archive in the UI, so this RPC is
* the authoritative "user-terminated" signal; out-of-band SIGTERM from a
* hub-restart cascade no longer collides with the default archive reason.
*/
export interface KillSessionLifecycle {
cleanupAndExit: () => Promise<void>;
setArchiveReason?: (reason: string) => void;
}
export function registerKillSessionHandler(
rpcHandlerManager: RpcHandlerManager,
killThisHappy: () => Promise<void>
lifecycleOrCleanup: KillSessionLifecycle | (() => Promise<void>)
) {
const lifecycle: KillSessionLifecycle = typeof lifecycleOrCleanup === 'function'
? { cleanupAndExit: lifecycleOrCleanup }
: lifecycleOrCleanup;
rpcHandlerManager.registerHandler<KillSessionRequest, KillSessionResponse>(RPC_METHODS.KillSession, async () => {
logger.debug('Kill session request received');
// This will start the cleanup process
void killThisHappy();
// tiann/hapi#914: stamp the archive reason from the RPC path so the
// default in `runnerLifecycle.ts` can be reassigned away from
// 'User terminated'. A hub-restart-cascade SIGTERM does NOT go
// through this handler — it hits the SIGTERM signal handler — so
// those archives now stay labelled `'Hub restart'` (the new default).
lifecycle.setArchiveReason?.('User terminated');
// We should still be able to respond the the client, though they
// This will start the cleanup process
void lifecycle.cleanupAndExit();
// We should still be able to respond to the client, though they
// should optimistically assume the session is dead.
return {
success: true,
+1 -1
View File
@@ -145,7 +145,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerKillSessionHandler(session.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
// Set initial agent state
+1 -1
View File
@@ -98,7 +98,7 @@ export async function runCodex(opts: {
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerKillSessionHandler(session.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => {
@@ -166,6 +166,7 @@ function makeClient() {
registerHandler: vi.fn()
},
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
@@ -297,11 +298,68 @@ describe('cursorAcpRemoteLauncher', () => {
expect(session.client.emitSessionReady).not.toHaveBeenCalled();
});
// tiann/hapi#913: fresh ACP sessions previously persisted `cursorSessionId`
// via fire-and-forget `updateMetadata`. A SIGTERM within ~1s of the first
// turn (hub-restart cascade) could strand the session because the ACK
// never arrived. The fix awaits `client.flushMetadata()` between
// `onSessionFoundWithProtocol` and the main loop, gating turn processing
// on a durable persist.
it('awaits flushMetadata after registering a fresh cursorSessionId so SIGTERM cannot strand the session', async () => {
const session = makeSession(null);
const flushSpy = vi.fn(async () => true);
// Replace the mock fixture's flushMetadata so we can observe ordering.
(session.client as unknown as { flushMetadata: typeof flushSpy }).flushMetadata = flushSpy;
let flushCalled = false;
flushSpy.mockImplementation(async () => {
flushCalled = true;
return true;
});
const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType<typeof vi.fn>;
let onSessionFoundCalledBeforeFlush = false;
onSessionFoundSpy.mockImplementation(() => {
if (!flushCalled) {
onSessionFoundCalledBeforeFlush = true;
}
});
await cursorAcpRemoteLauncher(session);
expect(onSessionFoundCalledBeforeFlush).toBe(true);
expect(flushSpy).toHaveBeenCalled();
});
it('preserves the #834 resume-path pre-registration shape (registration before backend.loadSession)', async () => {
// PR #834 pre-registers `cursorSessionId` BEFORE `backend.loadSession`
// so a load-session failure on a legacy store does not strand the
// session. The #913 fix must not relocate or remove that
// pre-registration. We verify by observing call ordering on the spy.
const session = makeSession('resume-acp-session');
const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType<typeof vi.fn>;
let preRegisterCalledBeforeLoadSession = false;
let preRegisterArgs: unknown[] | null = null;
onSessionFoundSpy.mockImplementation((id: string, protocol: string) => {
if (!harness.loadSessionCalled) {
preRegisterCalledBeforeLoadSession = true;
preRegisterArgs = [id, protocol];
}
});
await cursorAcpRemoteLauncher(session);
expect(preRegisterCalledBeforeLoadSession).toBe(true);
expect(preRegisterArgs).toEqual(['resume-acp-session', 'acp']);
expect(harness.loadSessionCalled).toBe(true);
});
it('applies debug mode immediately when setPermissionMode is called', async () => {
const queue = new MessageQueue2<EnhancedMode>((mode) => mode.permissionMode);
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
@@ -347,6 +405,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive,
@@ -392,6 +451,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive,
@@ -440,6 +500,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
@@ -485,6 +546,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
@@ -542,6 +604,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive,
@@ -584,6 +647,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
@@ -630,6 +694,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
@@ -671,6 +736,7 @@ describe('cursorAcpRemoteLauncher', () => {
const client = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
+12
View File
@@ -132,6 +132,18 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
if (acpSessionId !== resumeSessionId) {
session.onSessionFoundWithProtocol(acpSessionId, 'acp');
// tiann/hapi#913: block until the metadata write that pins
// `cursorSessionId` reaches the hub DB before we drop into
// `runMainLoop`. If SIGTERM (hub-restart cascade) lands during
// the first turn without this gate, the only durable handle
// linking the session to its on-disk ACP store is lost and the
// session strands. The resume path at lines 98-100 already
// relies on the latency of `backend.loadSession()` to flush the
// same write; the fresh-session path has no such cover.
const flushed = await session.client.flushMetadata();
if (!flushed) {
logger.warn(`[cursor-acp] cursorSessionId metadata write did not ACK within 5s; session may be unrecoverable if killed before the lock drains (acpSessionId=${acpSessionId})`);
}
}
session.client.emitSessionReady();
+1 -1
View File
@@ -81,7 +81,7 @@ export async function runCursor(opts: {
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerKillSessionHandler(session.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
+1 -1
View File
@@ -113,7 +113,7 @@ export async function runGemini(opts: {
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerKillSessionHandler(session.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
+1 -1
View File
@@ -82,7 +82,7 @@ export async function runKimi(opts: {
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerKillSessionHandler(session.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
+1 -1
View File
@@ -107,7 +107,7 @@ export async function runOpencode(opts: {
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerKillSessionHandler(session.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
+1 -1
View File
@@ -88,7 +88,7 @@ export async function runPi(opts: {
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle.cleanupAndExit);
registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(apiSession.rpcHandlerManager, lifecycle);
let cleanupInitiated = false;