mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(agent-status): clear the restart-stuck pane a compact was meant to clear
Review found the completion did not clear the pane STA-2915 actually reports, and that republishing it was a strict regression. - A manual completion now retires a subagent that exists only as a disk snapshot: a /compact only completes at an idle prompt, so a restored child is proof of nothing. Live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane. - A completion that cannot clear now publishes nothing instead of restating the row, which was stripping restoredUnconfirmed off a hydrated row and restarting the staleness clock for work the compact never observed. - The relay defers compact ownership to the client that owns pane identity, so a cold relay cache can no longer swallow the one event that clears a remote pane. - claudeConsumedCompactPromptIdByPaneKey joins all three pane-scoped teardown routes, and an auto compact no longer spends the pane's consumed-compact slot. - The promptless completion keeps the summarized turn's label with or without a trigger on the envelope. Tests: the two restart cases now deliver the completion while the hydrated row is still cached, so they exercise the restored-row branch instead of passing through the strict one; the triggerless working replay is asserted from a FINISHED pane so it can fail. Reverting the four source files turns 12 of 21 registration-gated and 8 of 12 relay/ingest tests red, and 18 of 18 targeted mutations are caught.
This commit is contained in:
@@ -3959,8 +3959,8 @@
|
||||
"https://github.com/stablyai/orca/issues/11352",
|
||||
"https://github.com/stablyai/orca/pull/11353"
|
||||
],
|
||||
"invariant": "PreCompact is never registered and may never move a pane, because it fires before the compact is validated and an aborted compact emits it alone. Only a manual PostCompact may mark done, and it does so as a session boundary so completion-reactive consumers stay out of it; an automatic PostCompact claims nothing, since its turn resumes and emits its own Stop. A completion clears an existing row but never creates one, so a retired pane cannot be resurrected. It requires a valid provider prompt UUID, matches the pane owner by connection and provider session -- except on a restored row, which carries the previous connection and may carry no session at all -- and is applied at most once per compact. A relay predating this contract forwards its own mapping, so ingest drops automatic envelopes, stamps the boundary on manual ones, and classifies a trigger-stripped replay by payload state while still enforcing ownership. Kimi and other Claude-compatible vendors remain fail-closed.",
|
||||
"oracle": "Replay the measured Claude 2.1.227 compact streams through a harness gated on the real CLAUDE_EVENTS, plus local HTTP, relay, SSH, and persisted-restore transports. Require the successful manual stream to end done with sessionBoundary set, and require the aborted stream (PreCompact alone) to leave both an idle and a working pane exactly as it found them. Require rejection of an automatic completion, a completion with absent prompt UUID, a foreign provider session, a duplicate, and a completion for a pane with no cached row; require acceptance for the three restart shapes (no cached row is rejected, stale connection id and missing provider session on a restored row are accepted). Require ingest to drop legacy automatic envelopes, stamp the boundary on legacy manual ones, and accept a trigger-stripped done replay only when ownership matches. Assert registration membership, emitted event order, and the authoritative lastStatusByPaneKey snapshot.",
|
||||
"invariant": "PreCompact is never registered and may never move a pane, because it fires before the compact is validated and an aborted compact emits it alone. Only a manual PostCompact may mark done, and it does so as a session boundary so completion-reactive consumers stay out of it; an automatic PostCompact claims nothing and writes no compact-scoped state, since its turn resumes and emits its own Stop. A completion either CLEARS a row or publishes nothing at all: it never creates a row, so a retired pane cannot be resurrected, and it never restates one it cannot clear, because a restated row loses restoredUnconfirmed and restarts the staleness clock. A manual /compact only completes at an idle prompt, so it retires a subagent that exists ONLY as a disk snapshot; live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane at working. A completion requires a valid provider prompt UUID, matches the pane owner by connection and provider session -- except on a restored row, which carries the previous connection and may carry no session at all -- and is applied at most once per compact. Ownership is enforced by the client that owns pane identity; a relay forwards and defers, so a cold relay cache cannot swallow the clearing event. A relay predating this contract forwards its own mapping, so ingest drops automatic envelopes, stamps the boundary on manual ones, carries the summarized turn label onto a promptless completion with or without a trigger, and classifies a trigger-stripped replay by payload state while still enforcing ownership. Kimi and other Claude-compatible vendors remain fail-closed.",
|
||||
"oracle": "Replay the measured Claude compact streams through a harness gated on the real CLAUDE_EVENTS, plus local HTTP, relay, SSH, and persisted-restore transports. Require the successful manual stream to end done with sessionBoundary set, and require the aborted stream (PreCompact alone) to leave both an idle and a working pane exactly as it found them. Drive the restart shape end to end over loopback HTTP from a hydrated row: a pane held only by a snapshot child must reach done with an empty roster, while a pane a runtime child still owns must emit NOTHING and keep its row object identical, so receivedAt and restoredUnconfirmed both survive. Deliver the restored-row cases with the hydrated row still cached at completion time and assert the summarizer published nothing, so the restored-row branch is the branch under test. Require rejection of an automatic completion, a completion with absent prompt UUID, a foreign provider session, a duplicate, and a completion for a pane with no cached row; require an automatic completion to leave the consumed-compact map untouched. Require a relay with a COLD cache to forward the completion while the client still refuses to mint a row it never had. Require ingest to drop legacy automatic envelopes, stamp the boundary on legacy manual ones, carry the summarized turn label onto a promptless completion with and without a trigger, and leave a FINISHED pane finished when a trigger-stripped working replay arrives. Require the consumed-compact map to be empty after each pane-scoped teardown route and to follow a pane move. Assert registration membership, emitted event order, and the authoritative lastStatusByPaneKey snapshot.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"Manual Claude Code interactive compact under a PTY: register every hook to a logging script, run one turn then /compact to observe the abort stream, then seven turns and /compact to observe the success stream; compare source, trigger, session_id, and prompt_id across both"
|
||||
@@ -3984,18 +3984,23 @@
|
||||
"PostCompact is registered in CLAUDE_EVENTS and written into the settings Claude reads, and PreCompact is absent from both",
|
||||
"the measured successful manual compact ends a stuck working row as a done with sessionBoundary",
|
||||
"an aborted compact (PreCompact alone) leaves an idle pane idle and a working pane working",
|
||||
"an automatic completion claims nothing and the turn's own Stop still closes it",
|
||||
"a completion clears a restored row despite a stale connection id or a missing provider session, but never resurrects a retired pane",
|
||||
"absent prompt UUID, foreign provider session, and duplicate delivery fail closed"
|
||||
"an automatic completion claims nothing, writes no compact-scoped state, and the turn's own Stop still closes it",
|
||||
"a completion retires a child that exists only as a disk snapshot, but a runtime child or an active session cron still holds the pane and the completion then publishes nothing at all",
|
||||
"a completion clears a restored row -- delivered while that hydrated row is still the cached owner -- despite a stale connection id or a missing provider session, but never resurrects a retired pane",
|
||||
"absent prompt UUID, foreign provider session, and duplicate delivery fail closed",
|
||||
"the consumed-compact record is dropped by clearPaneCacheState and clearAllListenerCaches and follows movePaneCacheState"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
|
||||
"assertions": [
|
||||
"the measured local HTTP stream settles once and rejects a duplicate completion",
|
||||
"a hydrated restart-stuck pane reaches done with an empty roster, and a pane a runtime child owns emits nothing and keeps its row object identical",
|
||||
"the relay forwards the completion and caches it with its compact identity neutralized so reconnect replay still delivers the clearing row",
|
||||
"a relay with a cold cache still forwards the completion, and the client still refuses to mint a row it never had",
|
||||
"a legacy relay's manual envelope is stamped as a session boundary and its automatic envelope is dropped instead of minting working",
|
||||
"a trigger-stripped replay is classified by payload state and still rejected on an owner mismatch",
|
||||
"a promptless completion keeps the summarized turn's label with and without a trigger on the envelope",
|
||||
"a trigger-stripped replay is classified by payload state, leaves a FINISHED pane finished, and is still rejected on an owner mismatch",
|
||||
"a PreCompact envelope never drives pane state"
|
||||
]
|
||||
},
|
||||
@@ -4017,8 +4022,8 @@
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/claude/compact-status-registration.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener-claude-turn-state.test.ts src/shared/agent-hook-listener-claude-compatible-vendors.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server-claude-normalization.test.ts src/main/agent-hooks/server-last-status-write.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.08,
|
||||
"summary": "Ten files and 105 tests passed across registration, listener, local HTTP, relay, SSH, persistence, and cleanup. Reverting only the four source files first turned 6 of 12 registration-gated tests and 5 of 8 relay/ingest tests red, so the suite observes its own failure rather than passing over unreachable code."
|
||||
"durationSeconds": 3.28,
|
||||
"summary": "Ten files and 116 tests passed across registration, listener, local HTTP, relay, SSH, persistence, and cleanup. Reverting only the four source files to the merge base first turned 12 of 21 registration-gated tests and 8 of 12 relay/ingest tests red, so the suite observes its own failure rather than passing over unreachable code. Every assertion was additionally mutation-tested: 18 of 18 targeted mutations -- including reaping runtime children as well as snapshot ones, restating a pane live evidence still holds, re-enforcing compact ownership on the relay, and dropping the consumed-compact map from any one teardown route -- are caught."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-17",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RelayAgentHookServer } from '../../relay/agent-hook-server'
|
||||
import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener'
|
||||
import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer } from './server'
|
||||
@@ -65,6 +66,26 @@ function legacyRelayCompactEnvelope(
|
||||
} as AgentHookRelayEnvelope
|
||||
}
|
||||
|
||||
/** What AgentHookServer.hydrate() rebuilds for a pane that was stuck `working` across a restart:
|
||||
* the previous session's connectionId, the unconfirmed flag, an older receivedAt, and the child
|
||||
* the turn had spawned — restored from disk, so proof of nothing. */
|
||||
function seedHydratedStuckPane(server: AgentHookServer, receivedAt: number) {
|
||||
const state = server._getStateForTests()
|
||||
const subagents = [{ id: 'child-1', state: 'working', startedAt: 0, agentType: 'general' }]
|
||||
state.lastStatusByPaneKey.set(PANE_KEY, {
|
||||
paneKey: PANE_KEY,
|
||||
source: 'claude',
|
||||
connectionId: null,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
providerPromptId: TURN_PROMPT_ID,
|
||||
providerSession: SESSION,
|
||||
restoredUnconfirmed: true,
|
||||
receivedAt,
|
||||
payload: { state: 'working', prompt: 'work before the restart', agentType: 'claude', subagents }
|
||||
} as never)
|
||||
seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, subagents as never)
|
||||
}
|
||||
|
||||
describe('manual Claude compact hook stream', () => {
|
||||
const servers: { stop: () => void }[] = []
|
||||
const temporaryPaths: string[] = []
|
||||
@@ -119,6 +140,56 @@ describe('manual Claude compact hook stream', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('clears the restart-stuck pane STA-2915 reports, without resurrecting anything', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
const env = server.buildPtyEnv()
|
||||
const port = Number(env.ORCA_AGENT_HOOK_PORT)
|
||||
const token = env.ORCA_AGENT_HOOK_TOKEN!
|
||||
seedHydratedStuckPane(server, Date.now() - 60_000)
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
|
||||
|
||||
await postHook(port, token, claudeHook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }))
|
||||
await postHook(
|
||||
port,
|
||||
token,
|
||||
claudeHook('SubagentStop', COMPACT_PROMPT_ID, { agent_id: 'a75b38b59774e1f31', agent_type: '' })
|
||||
)
|
||||
await postHook(port, token, claudeHook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' }))
|
||||
await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }))
|
||||
|
||||
const row = server.getStatusSnapshot()[0]
|
||||
expect(row).toMatchObject({ state: 'done', prompt: 'work before the restart' })
|
||||
expect(row?.subagents ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('does not restate a pane a live child still owns, so the staleness clock keeps running', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
const env = server.buildPtyEnv()
|
||||
const port = Number(env.ORCA_AGENT_HOOK_PORT)
|
||||
const token = env.ORCA_AGENT_HOOK_TOKEN!
|
||||
seedHydratedStuckPane(server, Date.now() - 60_000)
|
||||
// A child THIS runtime observed: real agent work in flight, which a compact may not retire.
|
||||
await postHook(port, token, claudeHook('SubagentStart', TURN_PROMPT_ID, { agent_id: 'child-live' }))
|
||||
const before = server.getStatusSnapshot()[0]
|
||||
expect(before).toMatchObject({ state: 'working' })
|
||||
|
||||
const emitted: string[] = []
|
||||
const unsubscribe = server.subscribeEnrichedStatus((event) => {
|
||||
emitted.push(`${event.hookEventName}:${event.payload.state}`)
|
||||
})
|
||||
await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }))
|
||||
|
||||
// Why: restating the row here is the regression, not the fix — it would refresh receivedAt and
|
||||
// hand the stale sweep a brand-new clock for work the compact never observed.
|
||||
expect(emitted).toEqual([])
|
||||
expect(server.getStatusSnapshot()[0]).toEqual(before)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('forwards the completion over the relay and neutralizes its cached compact identity', async () => {
|
||||
const main = new AgentHookServer()
|
||||
const forwarded: AgentHookRelayEnvelope[] = []
|
||||
@@ -163,6 +234,36 @@ describe('manual Claude compact hook stream', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('forwards the completion from a relay whose cache is cold, and the client still guards it', async () => {
|
||||
const main = new AgentHookServer()
|
||||
const forwarded: AgentHookRelayEnvelope[] = []
|
||||
const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-cold-'))
|
||||
temporaryPaths.push(endpointDir)
|
||||
// A relay that restarted while the agent session kept running: hooks resolve the endpoint file
|
||||
// per invocation, so they reconnect — but the relay's per-process cache is empty, and the
|
||||
// compact completion may be the first event it ever sees for this pane.
|
||||
const relay = new RelayAgentHookServer({
|
||||
endpointDir,
|
||||
token: 'cold-cache-token',
|
||||
forward: (envelope) => {
|
||||
forwarded.push(envelope)
|
||||
main.ingestRemote(envelope, 'conn-a')
|
||||
}
|
||||
})
|
||||
servers.push(main, relay)
|
||||
await relay.start({ publishEndpoint: false })
|
||||
const { port, token } = relay.getCoordinates()
|
||||
|
||||
await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }))
|
||||
|
||||
// Why: the relay is a forwarder, not the authority on pane identity. Dropping the event here
|
||||
// is how the one signal that can clear a remote pane goes missing after a restart.
|
||||
expect(forwarded.map((envelope) => envelope.hookEventName)).toEqual(['PostCompact'])
|
||||
expect(forwarded[0]).toMatchObject({ compactTrigger: 'manual', providerPromptId: COMPACT_PROMPT_ID })
|
||||
// ...and the client, which DOES own pane identity, still refuses to mint a row it never had.
|
||||
expect(main.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('stamps the silent boundary on a manual completion from a relay that predates it', () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
@@ -228,14 +329,26 @@ describe('manual Claude compact hook stream', () => {
|
||||
)
|
||||
expect(done.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true })
|
||||
|
||||
// Why: assert this arm from a pane that already FINISHED. Asserting `working` on a pane that
|
||||
// was already `working` cannot fail — it holds whether the replay was dropped or applied, which
|
||||
// is exactly how a trigger-stripped auto compact could start re-arming the stuck row again.
|
||||
const working = new AgentHookServer()
|
||||
servers.push(working)
|
||||
working.ingestRemote(turnEnvelope(), 'conn-a')
|
||||
working.ingestRemote(
|
||||
{
|
||||
...turnEnvelope(),
|
||||
hookEventName: 'Stop',
|
||||
payload: { state: 'done', prompt: 'work before compact', agentType: 'claude' }
|
||||
} as AgentHookRelayEnvelope,
|
||||
'conn-a'
|
||||
)
|
||||
expect(working.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
|
||||
working.ingestRemote(
|
||||
legacyRelayCompactEnvelope('working', { compactTrigger: undefined, isReplay: true }),
|
||||
'conn-a'
|
||||
)
|
||||
expect(working.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
|
||||
expect(working.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
|
||||
|
||||
const foreign = new AgentHookServer()
|
||||
servers.push(foreign)
|
||||
@@ -252,6 +365,38 @@ describe('manual Claude compact hook stream', () => {
|
||||
expect(foreign.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
|
||||
})
|
||||
|
||||
it('keeps the summarized turn as the label, with or without a trigger on the envelope', () => {
|
||||
// The compact's own event carries no prompt of its own, so the row's label can only come from
|
||||
// the turn it summarized. The trigger-stripped replay is the one shape that arrives with no
|
||||
// trigger at all, so the carry-forward must not be keyed on the trigger being present.
|
||||
const blank = { state: 'done' as const, prompt: '', agentType: 'claude' }
|
||||
|
||||
const tagged = new AgentHookServer()
|
||||
servers.push(tagged)
|
||||
tagged.ingestRemote(turnEnvelope(), 'conn-a')
|
||||
tagged.ingestRemote(legacyRelayCompactEnvelope('done', { payload: blank }), 'conn-a')
|
||||
expect(tagged.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
prompt: 'work before compact'
|
||||
})
|
||||
|
||||
const stripped = new AgentHookServer()
|
||||
servers.push(stripped)
|
||||
stripped.ingestRemote(turnEnvelope(), 'conn-a')
|
||||
stripped.ingestRemote(
|
||||
legacyRelayCompactEnvelope('done', {
|
||||
compactTrigger: undefined,
|
||||
isReplay: true,
|
||||
payload: blank
|
||||
}),
|
||||
'conn-a'
|
||||
)
|
||||
expect(stripped.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
prompt: 'work before compact'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a completion with no provider prompt id', () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
|
||||
@@ -2319,6 +2319,7 @@ export class AgentHookServer {
|
||||
this.state.lastStatusByPaneKey.get(paneKey)?.payload
|
||||
)
|
||||
const previousStatus = this.state.lastStatusByPaneKey.get(paneKey)
|
||||
let acceptedCompactCompletion = false
|
||||
if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') {
|
||||
// Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone);
|
||||
// reject it here too so a host on any version cannot drive pane state from it.
|
||||
@@ -2363,10 +2364,14 @@ export class AgentHookServer {
|
||||
if (normalizedPayload.sessionBoundary !== true) {
|
||||
normalizedPayload = { ...normalizedPayload, sessionBoundary: true }
|
||||
}
|
||||
acceptedCompactCompletion = true
|
||||
}
|
||||
// Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the
|
||||
// trigger-stripped replay is exactly the shape that arrives without one, and it is still the
|
||||
// compact's own promptless event, so it still needs the summarized turn's label.
|
||||
if (
|
||||
source === 'claude' &&
|
||||
compactTrigger !== undefined &&
|
||||
(compactTrigger !== undefined || acceptedCompactCompletion) &&
|
||||
normalizedPayload.prompt.length === 0 &&
|
||||
previousStatus?.payload.prompt
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearAllListenerCaches,
|
||||
clearPaneCacheState,
|
||||
createHookListenerState,
|
||||
movePaneCacheState,
|
||||
normalizeHookPayload,
|
||||
seedClaudeSubagentRosterFromSnapshots,
|
||||
type AgentHookEventPayload,
|
||||
type HookListenerState
|
||||
} from '../../shared/agent-hook-listener'
|
||||
@@ -58,6 +62,39 @@ function startTurn(state: HookListenerState) {
|
||||
deliverIfRegistered(state, hook('UserPromptSubmit', TURN_PROMPT_ID, { prompt: 'do the thing' }))
|
||||
}
|
||||
|
||||
/** The row AgentHookServer.hydrate() rebuilds for a pane that was stuck `working` when Orca last
|
||||
* exited: the previous session's connectionId, the unconfirmed flag, and — when the turn had
|
||||
* spawned one — a child that exists only as a disk snapshot. */
|
||||
function hydrateStuckRow(
|
||||
state: HookListenerState,
|
||||
options: { withRestoredChild?: boolean; providerSession?: boolean } = {}
|
||||
): void {
|
||||
const subagents = options.withRestoredChild
|
||||
? [{ id: 'child-1', state: 'working' as const, startedAt: 0, agentType: 'general' }]
|
||||
: undefined
|
||||
const hydrated = {
|
||||
paneKey: PANE_KEY,
|
||||
source: 'claude',
|
||||
connectionId: 'a-previous-connection',
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
providerPromptId: TURN_PROMPT_ID,
|
||||
...(options.providerSession === false
|
||||
? {}
|
||||
: { providerSession: { key: 'session_id', id: 'session-a' } }),
|
||||
restoredUnconfirmed: true,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'work before the restart',
|
||||
agentType: 'claude',
|
||||
...(subagents ? { subagents } : {})
|
||||
}
|
||||
} as unknown as AgentHookEventPayload
|
||||
state.lastStatusByPaneKey.set(PANE_KEY, hydrated)
|
||||
if (subagents) {
|
||||
seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, subagents)
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude compact hook registration', () => {
|
||||
it('registers PostCompact and deliberately does not register PreCompact', () => {
|
||||
expect(REGISTERED_EVENT_NAMES.has('PostCompact')).toBe(true)
|
||||
@@ -171,6 +208,78 @@ describe('an auto compact claims nothing', () => {
|
||||
deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID))
|
||||
expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('writes no compact-scoped state, so it cannot spend the pane\'s consumed-compact slot', () => {
|
||||
const state = createHookListenerState()
|
||||
startTurn(state)
|
||||
|
||||
deliverIfRegistered(state, hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'auto' }))
|
||||
|
||||
// Why: only a manual completion claims anything. Running the ownership guard for an auto
|
||||
// compact would mark its prompt id consumed for an event that maps to no state at all.
|
||||
expect(state.claudeConsumedCompactPromptIdByPaneKey.has(PANE_KEY)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-2915: the pane the ticket actually reports', () => {
|
||||
it('clears a row stuck behind a child that exists only as a disk snapshot', () => {
|
||||
const state = createHookListenerState()
|
||||
hydrateStuckRow(state, { withRestoredChild: true })
|
||||
|
||||
deliverIfRegistered(state, hook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }))
|
||||
// Why: assert the summarizer stays silent rather than assuming it. With a restored roster it
|
||||
// publishes nothing, which is what leaves the hydrated row in place for the completion — if
|
||||
// that ever changes, this test must fail loudly instead of quietly testing something else.
|
||||
const summarizer = deliverIfRegistered(
|
||||
state,
|
||||
hook('SubagentStop', COMPACT_PROMPT_ID, { agent_id: 'a75b38b59774e1f31', agent_type: '' })
|
||||
)
|
||||
expect(summarizer).toBeNull()
|
||||
deliverIfRegistered(state, hook('SessionStart', COMPACT_PROMPT_ID, { source: 'compact' }))
|
||||
|
||||
const completion = deliverIfRegistered(
|
||||
state,
|
||||
hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })
|
||||
)
|
||||
|
||||
// A restored child is a disk snapshot, not evidence of live work, and a manual /compact only
|
||||
// completes at an idle prompt — so it may not keep the pane spinning.
|
||||
expect(completion?.payload.state).toBe('done')
|
||||
expect(completion?.payload.sessionBoundary).toBe(true)
|
||||
expect(completion?.payload.subagents ?? []).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a pane held by a RUNTIME child at working, and republishes nothing', () => {
|
||||
const state = createHookListenerState()
|
||||
startTurn(state)
|
||||
deliverIfRegistered(state, hook('SubagentStart', TURN_PROMPT_ID, { agent_id: 'child-live' }))
|
||||
const before = state.lastStatusByPaneKey.get(PANE_KEY)
|
||||
|
||||
const completion = deliverIfRegistered(
|
||||
state,
|
||||
hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })
|
||||
)
|
||||
|
||||
// Why: a child observed in this runtime IS live agent work. The compact must not retire it —
|
||||
// and it must not restate the row either, because a restated row loses `restoredUnconfirmed`
|
||||
// and restarts the staleness clock for work the compact never observed.
|
||||
expect(completion).toBeNull()
|
||||
expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(before)
|
||||
expect(before?.payload.state).toBe('working')
|
||||
})
|
||||
|
||||
it('leaves a pane held by an active session cron at working, and republishes nothing', () => {
|
||||
const state = createHookListenerState()
|
||||
startTurn(state)
|
||||
deliverIfRegistered(state, hook('Stop', TURN_PROMPT_ID, { session_crons: [{ id: 'cron-1' }] }))
|
||||
const before = state.lastStatusByPaneKey.get(PANE_KEY)
|
||||
expect(before?.payload.state).toBe('working')
|
||||
|
||||
expect(
|
||||
deliverIfRegistered(state, hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' }))
|
||||
).toBeNull()
|
||||
expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compact completion guards', () => {
|
||||
@@ -183,17 +292,15 @@ describe('compact completion guards', () => {
|
||||
|
||||
it('clears a stuck row restored from disk after a restart, despite a stale connection id', () => {
|
||||
const state = createHookListenerState()
|
||||
startTurn(state)
|
||||
const stuck = state.lastStatusByPaneKey.get(PANE_KEY)!
|
||||
// What hydration produces after an Orca update: the row survives, its connectionId is the
|
||||
// PREVIOUS session's, and it is flagged as restored-but-unconfirmed.
|
||||
state.lastStatusByPaneKey.set(PANE_KEY, {
|
||||
...stuck,
|
||||
connectionId: 'a-previous-connection',
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
|
||||
const completion = successfulManualCompact(state)
|
||||
hydrateStuckRow(state)
|
||||
// Why: the completion must land while the HYDRATED row is still the cached owner, or the
|
||||
// restored-row branch is never the one under test. Deliver it alone — an intervening
|
||||
// summarizer would replace the row with a live one and the assertion would pass through the
|
||||
// strict branch instead, which is exactly how this test used to be vacuous.
|
||||
const completion = deliverIfRegistered(
|
||||
state,
|
||||
hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })
|
||||
)
|
||||
|
||||
expect(completion?.payload.state).toBe('done')
|
||||
expect(completion?.payload.sessionBoundary).toBe(true)
|
||||
@@ -201,16 +308,15 @@ describe('compact completion guards', () => {
|
||||
|
||||
it('clears a restored row that predates provider-session persistence', () => {
|
||||
const state = createHookListenerState()
|
||||
startTurn(state)
|
||||
const stuck = state.lastStatusByPaneKey.get(PANE_KEY)!
|
||||
state.lastStatusByPaneKey.set(PANE_KEY, {
|
||||
...stuck,
|
||||
providerSession: undefined,
|
||||
connectionId: 'a-previous-connection',
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
hydrateStuckRow(state, { providerSession: false })
|
||||
expect(state.lastStatusByPaneKey.get(PANE_KEY)?.providerSession).toBeUndefined()
|
||||
|
||||
expect(successfulManualCompact(state)?.payload.state).toBe('done')
|
||||
const completion = deliverIfRegistered(
|
||||
state,
|
||||
hook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })
|
||||
)
|
||||
|
||||
expect(completion?.payload.state).toBe('done')
|
||||
})
|
||||
|
||||
it('does not clear a live turn owned by a different Claude session', () => {
|
||||
@@ -271,3 +377,37 @@ describe('compact completion guards', () => {
|
||||
expect(duplicate).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the consumed-compact record is torn down with its pane', () => {
|
||||
const OTHER_PANE = makePaneKey('compact-registration', '44444444-4444-4444-8444-444444444444')
|
||||
|
||||
function paneWithConsumedCompact(): HookListenerState {
|
||||
const state = createHookListenerState()
|
||||
startTurn(state)
|
||||
expect(successfulManualCompact(state)?.payload.state).toBe('done')
|
||||
expect(state.claudeConsumedCompactPromptIdByPaneKey.get(PANE_KEY)).toBe(COMPACT_PROMPT_ID)
|
||||
return state
|
||||
}
|
||||
|
||||
// Why: this codebase keeps growing a new per-pane store that lands in ONE teardown path and is
|
||||
// missed by the siblings, which leaks the map and leaves stale reads behind a pane that is gone.
|
||||
// Every route that tears pane state down has to take this map with it.
|
||||
it('clearPaneCacheState drops it', () => {
|
||||
const state = paneWithConsumedCompact()
|
||||
clearPaneCacheState(state, PANE_KEY)
|
||||
expect(state.claudeConsumedCompactPromptIdByPaneKey.has(PANE_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('movePaneCacheState carries it to the new pane key', () => {
|
||||
const state = paneWithConsumedCompact()
|
||||
movePaneCacheState(state, PANE_KEY, OTHER_PANE)
|
||||
expect(state.claudeConsumedCompactPromptIdByPaneKey.has(PANE_KEY)).toBe(false)
|
||||
expect(state.claudeConsumedCompactPromptIdByPaneKey.get(OTHER_PANE)).toBe(COMPACT_PROMPT_ID)
|
||||
})
|
||||
|
||||
it('clearAllListenerCaches empties it', () => {
|
||||
const state = paneWithConsumedCompact()
|
||||
clearAllListenerCaches(state)
|
||||
expect(state.claudeConsumedCompactPromptIdByPaneKey.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -247,7 +247,9 @@ export class RelayAgentHookServer {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const event = normalizeHookPayload(this.state, source, body, this.env)
|
||||
const event = normalizeHookPayload(this.state, source, body, this.env, {
|
||||
deferCompactOwnershipToClient: true
|
||||
})
|
||||
if (event) {
|
||||
// TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result.
|
||||
const env = hookBodyEnv(body)
|
||||
|
||||
@@ -201,6 +201,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
|
||||
deletePaneScopedCacheEntry(state.lastStatusByPaneKey, paneKey)
|
||||
deletePaneScopedCacheEntry(state.antigravityCompletedTranscriptByPaneKey, paneKey)
|
||||
deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey)
|
||||
deletePaneScopedCacheEntry(state.claudeConsumedCompactPromptIdByPaneKey, paneKey)
|
||||
state.claudeSubagentRosterByPaneKey.delete(paneKey)
|
||||
state.claudeLeadStateByPaneKey.delete(paneKey)
|
||||
state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey)
|
||||
@@ -248,6 +249,7 @@ export function movePaneCacheState(
|
||||
movePaneScopedMapEntries(state.lastStatusByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.claudeConsumedCompactPromptIdByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey)
|
||||
@@ -291,6 +293,7 @@ export function clearAllListenerCaches(state: HookListenerState): void {
|
||||
state.lastStatusByPaneKey.clear()
|
||||
state.antigravityCompletedTranscriptByPaneKey.clear()
|
||||
state.ampCompletedCacheKeys.clear()
|
||||
state.claudeConsumedCompactPromptIdByPaneKey.clear()
|
||||
state.warnedVersions.clear()
|
||||
state.warnedEnvs.clear()
|
||||
state.claudeSubagentRosterByPaneKey.clear()
|
||||
@@ -3043,6 +3046,22 @@ function normalizeClaudeEvent(
|
||||
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
|
||||
}
|
||||
|
||||
if (isManualCompactCompletion) {
|
||||
// Why: a manual /compact only ever completes at an idle prompt, so a child that exists ONLY as
|
||||
// a disk snapshot has nothing live behind it and must not keep the pane spinning — that
|
||||
// restored child is what holds the stuck row STA-2915 actually reports. Everything else the
|
||||
// done-gate consults is live evidence (a child observed in this runtime, an unclassifiable
|
||||
// running background task, a registered session cron) and still holds the pane.
|
||||
const restoredRoster = state.claudeSubagentRosterByPaneKey.get(paneKey)
|
||||
if (
|
||||
restoredRoster &&
|
||||
reapUnconfirmedRestoredClaudeSubagents(restoredRoster) &&
|
||||
restoredRoster.size === 0
|
||||
) {
|
||||
state.claudeSubagentRosterByPaneKey.delete(paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveState = resolveClaudePaneState(state, paneKey, {
|
||||
state: reportedStateName,
|
||||
interrupted
|
||||
@@ -3080,6 +3099,15 @@ function normalizeClaudeEvent(
|
||||
state.claudeUnconfirmedRestoredStatusPaneKeys.add(paneKey)
|
||||
}
|
||||
|
||||
if (isManualCompactCompletion && effectiveState !== 'done') {
|
||||
// Why: a compact completion CLEARS a row or says nothing at all. Live evidence still holds this
|
||||
// pane, so restating it would only strip `restoredUnconfirmed` off a hydrated row and restart
|
||||
// the staleness clock for work the compact never observed — strictly worse than the silence
|
||||
// this event replaced. The lead record above still marks the turn ended, so whichever event
|
||||
// retires that evidence can clear the pane.
|
||||
return null
|
||||
}
|
||||
|
||||
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
|
||||
stateName: effectiveState,
|
||||
updateToolSnapshot: true,
|
||||
@@ -4318,7 +4346,8 @@ export function normalizeHookPayload(
|
||||
state: HookListenerState,
|
||||
source: AgentHookSource,
|
||||
body: unknown,
|
||||
expectedEnv: string
|
||||
expectedEnv: string,
|
||||
options: { deferCompactOwnershipToClient?: boolean } = {}
|
||||
): AgentHookEventPayload | null {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return null
|
||||
@@ -4393,28 +4422,39 @@ export function normalizeHookPayload(
|
||||
return null
|
||||
}
|
||||
const previousStatus = state.lastStatusByPaneKey.get(paneKey)
|
||||
const isCompactCompletion = source === 'claude' && eventName === 'PostCompact'
|
||||
// Why: only a MANUAL completion claims anything, so only it may write compact-scoped state. An
|
||||
// auto compact runs inside a turn that resumes and emits its own Stop; running the ownership
|
||||
// guard for it would burn the pane's consumed-compact slot on an event that maps to nothing.
|
||||
const isCompactCompletion =
|
||||
source === 'claude' && eventName === 'PostCompact' && compactTrigger === 'manual'
|
||||
if (isCompactCompletion) {
|
||||
if (
|
||||
isClaudeCompactCompletionConsumed(
|
||||
// Why: a relay is a forwarder, not the authority on pane identity — pane retirement, tab
|
||||
// closure and hydrated rows all live on the client, and the client re-runs this exact guard on
|
||||
// ingest. Enforcing it on the relay too only adds a way to LOSE the event: the relay's cache is
|
||||
// per-process and capped, so a relay restart or an eviction silently drops the one signal that
|
||||
// can clear a remote pane. Ownership is deferred there, never skipped.
|
||||
if (options.deferCompactOwnershipToClient !== true) {
|
||||
if (
|
||||
isClaudeCompactCompletionConsumed(
|
||||
state.claudeConsumedCompactPromptIdByPaneKey,
|
||||
paneKey,
|
||||
providerPromptId
|
||||
) ||
|
||||
!canAcceptClaudeCompactCompletion(previousStatus, {
|
||||
source,
|
||||
connectionId: null,
|
||||
providerPromptId,
|
||||
providerSession: providerSession ?? undefined
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
markClaudeCompactCompletionConsumed(
|
||||
state.claudeConsumedCompactPromptIdByPaneKey,
|
||||
paneKey,
|
||||
providerPromptId
|
||||
) ||
|
||||
!canAcceptClaudeCompactCompletion(previousStatus, {
|
||||
source,
|
||||
connectionId: null,
|
||||
providerPromptId,
|
||||
providerSession: providerSession ?? undefined
|
||||
})
|
||||
) {
|
||||
return null
|
||||
)
|
||||
}
|
||||
markClaudeCompactCompletionConsumed(
|
||||
state.claudeConsumedCompactPromptIdByPaneKey,
|
||||
paneKey,
|
||||
providerPromptId
|
||||
)
|
||||
// Why: the compact's own event carries no prompt; keep the pane's label from the turn it
|
||||
// summarized rather than blanking the row as it clears.
|
||||
if (previousStatus?.payload.prompt && !state.lastPromptByPaneKey.has(paneKey)) {
|
||||
|
||||
Reference in New Issue
Block a user