Files
orca/src/shared/claude-compact-completion.ts
Brennan Benson 1921ba2250 fix(agent-status): clear the pane when a Claude compact finishes (STA-2915, STA-4613) (#15202)
* fix(agent-status): clear the pane when a Claude compact finishes (STA-2915, STA-4613)

A manual /compact ends at an idle prompt without emitting Stop, so nothing in the
compact window could ever clear the pane. A worktree that entered the compact
`working` stayed `working` until the 30-minute stale sweep -- and the summarizer's
start-less SubagentStop kept republishing the row, resetting that clock each time.

The correlation added by #12332 was supposed to own this, but it could never run:
PreCompact and PostCompact were never added to CLAUDE_EVENTS, so they were never
registered with Claude. compactTrigger was always undefined, and the transition
guard, the ownership cache, the relay wire field and the ingest branch were all
unreachable. Five test files exercised the logic by injecting events past the
registration boundary, so the suite stayed green over code that could not execute.

Register PostCompact -- and deliberately NOT PreCompact. Measured on Claude Code
2.1.227, a successful manual compact emits PreCompact, a start-less SubagentStop,
SessionStart(source=compact), then PostCompact; an ABORTED compact ("Not enough
messages to compact") emits PreCompact ALONE. Mapping PreCompact to `working`
would strand the pane on every aborted compact, which is the bug being fixed, so
the abort guard is structural: Orca never subscribes to the pre-validation event.

PostCompact carries its own trigger, so no anchor is needed to tell manual from
auto and the correlation machinery is deleted rather than repaired. Manual becomes
a `done` with sessionBoundary set -- a finished compact is a session-shaped
boundary, not a completed turn, so completion notifications, unread counts and
automation-run evidence stay out of it. Auto claims nothing: it runs inside a turn
that resumes and emits its own Stop.

The source-blind early return that dropped compact events for EVERY provider
before its normalizer ran is narrowed to Claude, so it keeps failing closed on a
malformed payload without pre-empting other providers.

Ownership is kept where the deleted guard had it: a valid provider prompt id is
required, a completion clears a row but never creates one (a retired pane must not
be resurrected), and a hydrated row is matched on provider session only -- it
carries the previous session's connectionId, and older rows carry no session at
all, so a strict check would reject the restart case this fixes. A consumed
prompt id keeps relay duplicates from refreshing the row.

Mixed versions: no new wire field and no new opcode. An older relay normalizes
with its own shipped mapping and forwards the event, so ingest drops `auto`
envelopes and stamps the boundary on `manual` ones; its replay strips the trigger
entirely, so payload state stands in for it while ownership is still enforced. The
relay now caches a completion with its compact identity removed, so a client that
was offline during the compact still receives the clearing row on reconnect.

Tests go red before this change and green after: 6 of 12 in the new
registration-gated suite and 5 of 8 in the relay/ingest suite. The harness delivers
only events present in CLAUDE_EVENTS, so a fix that is never registered cannot
pass -- the failure mode that let the original correlation ship unreachable.

* test(agent-status): restate the compact reliability gate around the new invariant

The gate pinned a test file this change deletes, so the manifest check failed.
Repointing the path alone would have left the gate describing an invariant that
no longer exists: it required a manual PostCompact to match its exact PreCompact
generation, and PreCompact is no longer consumed at all.

Restate it. The invariant is now that PreCompact never moves a pane, that only a
manual PostCompact marks done and does so as a session boundary, that a
completion clears an existing row but never creates one, and that a relay
predating the contract has its automatic envelopes dropped and its trigger-
stripped replays classified by payload state under the same ownership checks.

Evidence runs are the real ones: the 105-test suite from this branch, and the
Claude Code 2.1.227 PTY capture that measured PreCompact arriving alone on an
aborted compact.

* 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.

* fix(agent-hooks): preserve compact identity across relay replay

* docs(reliability): describe compact replay ownership
2026-08-24 13:06:44 -07:00

103 lines
4.4 KiB
TypeScript

// Claude's compact lifecycle, from Orca's side.
//
// Claude emits PreCompact -> (summarizer SubagentStop) -> SessionStart(source=compact) ->
// PostCompact for a successful /compact, but PreCompact ALONE when the compact aborts
// ("Not enough messages to compact"), and post-compact hooks are only reached on the success
// path. So PreCompact proves nothing and Orca deliberately does not register it: mapping it to
// `working` would strand the pane on every aborted compact, which is the bug this file exists to
// fix (STA-2915/STA-4613).
//
// PostCompact is the only compact event that proves something. `manual` ends at an idle prompt and
// is the pane's missing clearing signal; `auto` fires inside a turn that resumes and emits its own
// Stop, so Orca must not touch the state.
import { agentProviderSessionsEqual } from './agent-session-resume'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { AgentHookSource } from './agent-hook-relay'
export type ClaudeCompactCompletionOwner = {
source?: AgentHookSource
connectionId: string | null
providerSession?: AgentProviderSessionMetadata
restoredUnconfirmed?: true
payload: { agentType?: string }
}
export type ClaudeCompactCompletionIncoming = {
source?: AgentHookSource
connectionId: string | null
providerPromptId?: string
providerSession?: AgentProviderSessionMetadata
}
/** True when a PostCompact may retire the pane's row.
*
* Fails closed on a missing prompt id: the ownership check this replaces required one, and
* duplicate suppression has nothing to key on without it. */
export function canAcceptClaudeCompactCompletion(
previous: ClaudeCompactCompletionOwner | undefined,
incoming: ClaudeCompactCompletionIncoming
): boolean {
if (incoming.source !== 'claude' || incoming.providerPromptId === undefined) {
return false
}
// Why: a compact CLEARS a pane, it never creates one. An empty cache means the pane was retired
// (closed tab, deleted worktree, explicit clear) and a late completion must not resurrect it.
// The restart case is not this case: hydration restores the stuck row and marks it
// `restoredUnconfirmed`, so it is handled by the branch below.
if (previous === undefined) {
return false
}
if (previous.source !== 'claude' || previous.payload.agentType !== 'claude') {
return false
}
if (previous.restoredUnconfirmed) {
// Why: a hydrated row keeps the PREVIOUS session's connectionId, and rows predating provider
// session persistence have none at all; neither can contradict the live event, so neither may
// veto it. Match on provider session only, and only when the row actually carries one.
return (
previous.providerSession === undefined ||
agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession)
)
}
return (
previous.connectionId === incoming.connectionId &&
agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession)
)
}
/** The compact whose completion a pane has already applied, so relay duplicates cannot keep
* refreshing the row. Replaces the suppression the deleted ownership cache provided. */
export function isClaudeCompactCompletionConsumed(
consumedByPaneKey: Map<string, string>,
paneKey: string,
providerPromptId: string | undefined
): boolean {
return providerPromptId !== undefined && consumedByPaneKey.get(paneKey) === providerPromptId
}
export function markClaudeCompactCompletionConsumed(
consumedByPaneKey: Map<string, string>,
paneKey: string,
providerPromptId: string | undefined
): void {
if (providerPromptId !== undefined) {
consumedByPaneKey.set(paneKey, providerPromptId)
}
}
/** An old relay strips `compactTrigger` from its cached PostCompact before replaying it, so a
* replay arrives tagged PostCompact with no manual/auto discriminator. The baseline mapping it was
* built with is fixed and known — manual produced `done`, auto produced `working` — so the payload
* state stands in for the missing trigger. This substitutes for the trigger only: the caller still
* runs the ownership guard above. */
export function resolveLegacyCompactTrigger(
compactTrigger: 'manual' | 'auto' | undefined,
payloadState: string
): 'manual' | 'auto' | undefined {
if (compactTrigger !== undefined) {
return compactTrigger
}
return payloadState === 'done' ? 'manual' : undefined
}