mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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:
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user