mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* 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
103 lines
4.4 KiB
TypeScript
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
|
|
}
|