Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
d4db159
fix(web): drop duplicate formatRelativeTime import path
heavygee Jun 19, 2026
e277f2b
fix(web): avoid duplicate formatRelativeTime import at soup merge
heavygee Jun 20, 2026
20bbdea
fix(runner): treat stop of unknown session as idempotent success
heavygee Jun 20, 2026
03f112e
fix(garden): merge-safe vite.config for upstream share-target + PWA p…
heavygee Jun 20, 2026
6c40406
fix(garden): minimal vite.config delta for soup merge (flat defineCon…
heavygee Jun 20, 2026
134f046
test(runner): serialize stress-test stops to avoid control-server flake
heavygee Jun 20, 2026
978ed2b
feat(hub): native companion (FCM) push channel + device registry
heavygee Jun 2, 2026
0bd7cf7
docs(contract): clarify scope - companion is remote-hub client, not h…
heavygee Jun 3, 2026
0ccb60f
docs(contract): correct Scope section - hub topology is unchanged
heavygee Jun 3, 2026
f389bd0
feat(web): companion app pairing QR in Settings
heavygee Jun 4, 2026
6866b97
feat(hub): terminal QR for companion app pairing alongside PWA QR
heavygee Jun 4, 2026
a2a0a30
fix(fcm): address HAPI Bot review on PR #803
heavygee Jun 5, 2026
1cdd154
docs(contract): correct FCM visibility rule and remove unsupported ev…
heavygee Jun 5, 2026
16bafb9
docs(contract): drop trailing whitespace, use blank line for paragrap…
heavygee Jun 5, 2026
6e3f66a
fix(web): persist CLI access token after Telegram bind so pairing QR …
heavygee Jun 5, 2026
5d444cc
fix(fcm): gate native-fallback probe on rolling FCM health
heavygee Jun 5, 2026
6322f67
refactor(telegram): drop duplicate tool-args formatter, use shared mo…
heavygee Jun 7, 2026
49c1d0a
fix(fcm): require positive evidence in health window before suppressi…
heavygee Jun 8, 2026
ccac1f9
fix(hub): bump FCM migration to V10→V11 after upstream service_tier V…
heavygee Jun 17, 2026
8f87051
fix(hub): per-dispatch native gate instead of stale FCM probe
heavygee Jun 17, 2026
8b5a5b3
fix(fcm): wrist push on model errors via sendModelError (#878)
heavygee Jun 17, 2026
dd370e1
fix(hub,web): cap notifySummary for FCM limits; fix PWA test cast
heavygee Jun 20, 2026
fd89f05
fix(hub): cap all FCM notifySummary fields and task bodies
heavygee Jun 20, 2026
80c3490
fix(hub): FCM fetch timeouts and cap Grep/Glob permission args
heavygee Jun 20, 2026
ab16803
fix(hub): bind FCM token to one namespace on re-pair
heavygee Jun 20, 2026
0757054
fix(web): localize Companion settings and pairing copy
heavygee Jun 20, 2026
6d6bf77
fix(hub): tighten FCM token-invalid detection and truncation edge cases
heavygee Jun 20, 2026
e0ab169
fix(hub): parse FcmError details.errorCode for UNREGISTERED tokens
heavygee Jun 20, 2026
1035913
Merge branch 'feat/mermaid-lightbox-737' into driver/integration
heavygee Jun 20, 2026
9633343
Merge branch 'feat/codex-usage-indicator-rebased' into driver/integra…
heavygee Jun 20, 2026
aaaea91
Merge branch 'soup/cursor-model-error-fcm-bridge' into driver/integra…
heavygee Jun 20, 2026
526c04b
Merge branch 'feat/sse-patch-extend-session-state' into driver/integr…
heavygee Jun 20, 2026
0562c0b
Merge branch 'fix/cursor-flat-picker-default-highlight' into driver/i…
heavygee Jun 20, 2026
f4b962b
Merge branch 'soup/scratchlist-v2-v11' into driver/integration
heavygee Jun 20, 2026
b804c6d
fix(hub): narrow types so manifest soup typecheck passes
heavygee Jun 16, 2026
54a6c56
fix(cli): Pi parse schemas for Zod 4 optional field output
heavygee Jun 19, 2026
012bd90
test(hub): allow same-ms updatedAt in session handler patch tests
heavygee Jun 19, 2026
8eac749
Merge branch 'feat/mermaid-lightbox-737' into driver/integration
heavygee Jun 20, 2026
b13f3c3
Merge branch 'feat/codex-usage-indicator-rebased' into driver/integra…
heavygee Jun 20, 2026
0accc41
Merge branch 'soup/cursor-model-error-fcm-bridge' into driver/integra…
heavygee Jun 20, 2026
b50729a
Merge branch 'feat/sse-patch-extend-session-state' into driver/integr…
heavygee Jun 20, 2026
3cdf481
Merge branch 'fix/cursor-flat-picker-default-highlight' into driver/i…
heavygee Jun 20, 2026
e489972
Merge branch 'soup/scratchlist-v2-v11' into driver/integration
heavygee Jun 20, 2026
f12d156
Merge branch 'fix/soup-typecheck-followups' into driver/integration
heavygee Jun 20, 2026
c4b7008
Merge branch 'feat/tier-b-reattach-orphan-runner-children' into drive…
heavygee Jun 20, 2026
95016c5
Merge branch 'feat/garden-route' into driver/integration
heavygee Jun 20, 2026
531c406
Merge branch 'feat/claude-session-import' into driver/integration
heavygee Jun 20, 2026
127ce54
Merge branch 'feat/agent-session-import-picker' into driver/integration
heavygee Jun 20, 2026
95ef7c6
Merge branch 'feat/session-copy-link' into driver/integration
heavygee Jun 20, 2026
ea2ae48
feat(overseer): events substrate from AGENT_NOTIFY_SUMMARY (#22)
heavygee Jun 19, 2026
fcbca54
fix(hub): type WebAppEnv in systemEvents route test
heavygee Jun 19, 2026
8684388
test(e2e): allow PLAYWRIGHT_WEB_PORT for events debug smoke
heavygee Jun 19, 2026
86d69ae
fix(hub): combine v11 FCM, scratchlist, and events migrations
heavygee Jun 19, 2026
627c7ad
fix(overseer): init-gate events schema; repair FTS triggers (#22)
heavygee Jun 19, 2026
32c86ad
fix(overseer): detach/repoint events on session delete and merge
heavygee Jun 20, 2026
a4c9592
feat(overseer): denormalize session identity + deleted_sessions tombs…
heavygee Jun 20, 2026
20efb03
feat(overseer): inbox substrate with dumb v1 ordering (#23)
heavygee Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 169 additions & 10 deletions bun.lock

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions cli/src/agent/runnerLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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'
})
})
})
42 changes: 41 additions & 1 deletion cli/src/agent/runnerLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
})

Expand Down
15 changes: 15 additions & 0 deletions cli/src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading