mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* fix(native-chat): give a failed Claude background task a typed row instead of an opcode
A failed backgrounded command printed red rows whose visible text was the wire
opcode, and printed one failure twice. All five task lifecycle kinds are
catalogued status-chrome, but the payload sniffer in classifyProviderFrame runs
first and promotes any frame reporting a failure to the generic unknown-frame
fallback, whose sentence lookup has no key for the field Claude puts its own
sentence in. Two frames for one task therefore produced two rows, both of them
the method name.
Suppressing those frames is not the fix: when the last background task settles
the tracker flushes it and the strip unmounts, local_bash is excluded from the
subagent roster, and the status feed publishes only live tasks, so for a lone
backgrounded command the transcript row is the only report of the failure that
exists anywhere.
So the catalogue now binds: kinds a dedicated typed translator owns are named
as covered, and the generic fallback refuses to emit for them in either
direction. A new row owner keeps one durable row per task id, opened by the
announcement, revised in place by the lifecycle frames and closed by the
notification, carrying the provider's summary, error, output path, usage and a
run state. The row is written on the same dual carrier the subagent roster
uses: a frozen text twin plus a typed block, so a client without the block type
reads the sentence rather than nothing.
hasProviderError keeps its authority everywhere else, unchanged.
* fix(native-chat): keep tool attribution across a background-task row
A background task's row is a system message landing mid-turn between the
assistant's tool calls, exactly where the spawn-group roster row lands. Without
the same exemption it ended the run the following tool messages fold into, so a
tool result arriving after one stopped folding into its own assistant turn.
Also syncs the catalog with the row's one new translate key.
* fix(native-chat): harden background task rows
* fix(native-chat): settle background rows on provider end
* fix(native-chat): scope malformed task fallback text
* test(native-chat): assert only eligibility at the disposition layer
The malformed-task-frame test asserted the generic fallback resolves Claude's
`summary` field itself, which was true only while that key sat in the shared
key list. Eligibility is what this layer decides; the sentence the row leads
with is Claude's, supplied through the display-text seam and proven in the
translation test.
* fix(native-chat): gate background-task admission and scope rows per run
Admission now matches the reference on all three gates. Type is the whole gate
and MONITORS ARE NOT ADMITTED: a monitor runs for the life of the session and
has no outcome a row could report, so it never reaches the timeline. On first
admission only, the task's tool_use_id must name a tool call this session
forwarded at the TOP level — a Task spawned inside a subagent's sidechain names
an id that never reached the transcript, and a top-level row for it would claim
an invocation the user never saw. And a task that already exists and has not
finished is not re-opened: a duplicate announcement is a redelivery, not a
second run.
Rows are now keyed per RUN. A provider may reuse a task id for a distinct later
invocation, and a row keyed by the id alone overwrote the first run's transcript
history instead of leaving it standing. Generation 1 keeps the bare key, so
every row already written is unaffected.
The spawning tool call is carried on the row as parentToolUseId. Orca's journal
has no structural parent link for an item — AgentJournalItemIdentity has four
arms and none carries one — so the relationship is data on the item rather than
nesting.
A terminal frame that names NO tool still opens a row. That is a named
deviation, recorded at its call site, and the measurement behind it is in the PR.
* fix(native-chat): read the aggregate roster by membership, not a phantom status
The background-tasks payload types every entry as exactly
{task_id, task_type, description, ambient?}. It has no per-entry status, so the
state this owner derived from one was always undefined and the reopen branch it
guarded was unreachable on every real payload — proven by deriving the state
from an SDK-shaped entry and getting null.
Membership is the only liveness the payload carries: it is the whole live set
after a change, so presence means live and absence means merely "no longer
listed", never an outcome. Presence does not revive a settled row either — the
level's ordering against the start/stop edges is unspecified and it carries no
evidence of a new run, so the task's own frames stay the only thing that opens
or settles one. Only the identity fields it really sends are read, and ambient
housekeeping entries are excluded as the payload asks.
The two helpers that served the dead branch are removed, along with the test
that exercised it through a synthetic status the CLI cannot send.
* fix(native-chat): mirror reference task admission and drop the synthesis path
The forwarded-parent gate is conditional on the field being PRESENT. An
announcement naming a tool this session never forwarded is a nested child and is
refused; one naming no tool at all is admitted, because absence of the field is
not evidence of an unforwarded parent. The previous rule required the field and
so refused every tool-less task.
Terminal frames now match on task_id alone. The forwarded-parent question is
settled once, at admission, and is never re-asked on a notification or a patch.
A frame for a task that was never admitted yields no row, and a patch is folded
into the row it names rather than opening one.
That removes the synthesized-row path entirely, and with it the named deviation
it carried: the captured tool-less failure lands on a row that already exists,
because its own tool-less announcement is admitted. The dead builders go with
it.
Left deliberately stricter than the reference, and flagged rather than changed:
a terminal frame still records its task id as terminal even for a task never
admitted, so a late announcement cannot open a row for work already reported
finished. Two existing tests pin that.
* fix(native-chat): preserve background task ownership across restarts
* chore: restore pnpm-lock.yaml to origin/main
A local pnpm run rewrote the lockfile and the merge commit swept it in. The
branch changes no dependencies, so it must carry no lockfile delta at all.
* fix(claude): harden background task lifecycle
* fix(claude): bound task generation history
* fix(claude): preserve task identity after history eviction
* fix(claude): resolve background task identity after rebind
* fix(claude): isolate queued task runs
* refactor(claude): give the background-task ledgers one bounded owner
The bounded collections behind a background-task row were read out of the
class with `Reflect.get` to prove they stay capped, which the anti-slop
gate rejects. Move them into `ClaudeBackgroundTaskLedgers`, which owns
the caps beside the eviction helpers and reports a typed readonly size
view the tests assert against.
Also replace a `Reflect.get` in the mobile recording proxy with typed
property access.
* fix(native-chat): report a background task failure the transcript never admitted
A terminal `task_notification` for a task no announcement ever admitted rendered
nothing at all. The typed row owner declined the row because its map held no
entry for the id, and reported the frame as handled — which is exactly what
tells the generic provider-frame fallback to stay quiet. Both surfaces declined
the same frame, so a real failed background task was dropped on the floor.
A terminal frame is self-sufficient: it states an outcome, and it carries the
summary, status, error, output path and usage that outcome needs. It now opens
its own row from those fields, with the summary as the label and `unknown` as
the kind when the frame names no task type. The row map enriches a terminal
frame; it never gates one. Every terminal status writes one, not failures alone,
so there is one rule here rather than a third behaviour for failures.
The deliberate hand-offs still win, because they are recorded rather than
implied: ambient, subagent and foreground tasks are claimed in the foreign-owner
ledger the notification path already checks first. A Task spawned inside a
subagent's sidechain now records its refusal there too, under `sidechain`,
instead of leaving no trace and reading as a task nothing ever decided about. A
capacity-refused task whose outcome the generic fallback already printed records
`fallback` the same way, so a redelivery neither prints twice nor mints the row
capacity refused.
The anti-resurrection guard stays and stays scoped to announcements: a late
`task_started` cannot reopen work already reported finished. The restart rule is
now stated once instead of twice — a different parent alias is the provider's
restart signal only when BOTH runs name their parent, which is what the terminal
ledger already required of an evicted row and what the live row now requires too.
* test(native-chat): pin that an orphan task row reopens the provider's turn
* fix(native-chat): stop an orphan task row drawing its sentence twice
An orphan row took its header label from the notification's summary, which
also renders as the row's sentence, so the same string appeared in both slots
of the same collapsed row. The label now stays empty and the header falls back
to the task kind, leaving the sentence to carry the provider's words.
* fix(native-chat): keep Claude task outcomes owned through capacity and redelivery
* fix(native-chat): keep settled overflow notifications from reopening a turn
* fix(native-chat): retain Claude task rows through journal pressure
169 lines
6.5 KiB
TypeScript
169 lines
6.5 KiB
TypeScript
// One background task's durable transcript row: the vocabulary its producer,
|
|
// the desktop renderer and the plain-text surfaces all read it by.
|
|
//
|
|
// Shared because the row is written once and read by clients that cannot draw
|
|
// the block. The frozen sentence beside it is built here too, so the twin and
|
|
// the block can never describe the task differently.
|
|
|
|
import {
|
|
isBackgroundTaskBlock,
|
|
type NativeChatBackgroundTaskBlock,
|
|
type NativeChatBlock
|
|
} from './native-chat-types'
|
|
|
|
/** The only states a task can still leave. Everything else is an outcome,
|
|
* including `unverifiable`, which records that we stopped being able to see
|
|
* the task rather than what it did (docs/reference/ssh-execution-boundary.md).
|
|
* A state this build does not know reads as settled, never as in-flight: a row
|
|
* written by a newer build must not leave the transcript spinning forever. */
|
|
const IN_FLIGHT_TASK_STATES: ReadonlySet<string> = new Set(['working', 'monitoring', 'waiting'])
|
|
|
|
/** The task's own verdict about itself, which a later frame may not overwrite.
|
|
* `unverifiable` is deliberately absent: contact can return, and latching the
|
|
* loss would report a task that finished as one we never saw finish. */
|
|
const LATCHED_TASK_STATES: ReadonlySet<string> = new Set(['done', 'blocked', 'idle'])
|
|
|
|
const KNOWN_TASK_STATES = [
|
|
'working',
|
|
'monitoring',
|
|
'waiting',
|
|
'blocked',
|
|
'done',
|
|
'idle',
|
|
'unverifiable'
|
|
] as const satisfies NativeChatBackgroundTaskBlock['state'][]
|
|
|
|
/** A state this build has no word for reads as `unverifiable` — we cannot say
|
|
* what the task did, only that we cannot name what it reported. */
|
|
export function normalizeBackgroundTaskState(
|
|
state: string
|
|
): NativeChatBackgroundTaskBlock['state'] {
|
|
return KNOWN_TASK_STATES.find((known) => known === state) ?? 'unverifiable'
|
|
}
|
|
|
|
export function isSettledBackgroundTaskState(state: string): boolean {
|
|
return !IN_FLIGHT_TASK_STATES.has(state)
|
|
}
|
|
|
|
/** Whether `next` may replace `current`. Nothing returns to in-flight once we
|
|
* have given up on it, so a straggler progress tick cannot re-light a settled
|
|
* row. */
|
|
export function canReplaceBackgroundTaskState(current: string, next: string): boolean {
|
|
if (!isSettledBackgroundTaskState(current)) {
|
|
return true
|
|
}
|
|
if (LATCHED_TASK_STATES.has(current)) {
|
|
return false
|
|
}
|
|
return LATCHED_TASK_STATES.has(next)
|
|
}
|
|
|
|
const KNOWN_TASK_KINDS = [
|
|
'agent',
|
|
'workflow',
|
|
'command',
|
|
'monitor',
|
|
'unknown'
|
|
] as const satisfies NativeChatBackgroundTaskBlock['kind'][]
|
|
|
|
/** A kind this build has no name for is simply an unnamed task, not a guess. */
|
|
export function normalizeBackgroundTaskKind(kind: string): NativeChatBackgroundTaskBlock['kind'] {
|
|
return KNOWN_TASK_KINDS.find((known) => known === kind) ?? 'unknown'
|
|
}
|
|
|
|
const KIND_NOUNS: Record<NativeChatBackgroundTaskBlock['kind'], string> = {
|
|
agent: 'background agent',
|
|
workflow: 'background workflow',
|
|
command: 'background command',
|
|
monitor: 'background monitor',
|
|
unknown: 'background task'
|
|
}
|
|
|
|
const SETTLED_VERBS: Record<string, string> = {
|
|
done: 'finished',
|
|
blocked: 'failed',
|
|
idle: 'was stopped'
|
|
}
|
|
|
|
/**
|
|
* Plain-text stand-in for the row, frozen into the journal at write time for
|
|
* clients without the block type — mobile renders only this.
|
|
*
|
|
* It leads with the provider's OWN sentence whenever it sent one: that sentence
|
|
* is the point of the row, and paraphrasing it would discard the only account
|
|
* of the failure the provider ever gave. Everything else states what stays true
|
|
* once the writing process is gone. A live task claims only that it was
|
|
* started, never that it is still running: the clients reading this instead of
|
|
* the block reconcile nothing and cannot re-check the task, so a frozen
|
|
* "running" would assert a liveness only the dead process could have observed.
|
|
*/
|
|
export function backgroundTaskFallbackText(block: NativeChatBackgroundTaskBlock): string {
|
|
const sentence = block.summary?.trim() || block.error?.trim()
|
|
if (sentence) {
|
|
return sentence
|
|
}
|
|
const noun = KIND_NOUNS[normalizeBackgroundTaskKind(block.kind)]
|
|
// A task the provider never named falls through to its kind: quoting an empty
|
|
// label would print `background command ""`.
|
|
const subject = block.label.trim() ? `${noun} "${block.label}"` : noun
|
|
if (!isSettledBackgroundTaskState(block.state)) {
|
|
return `Started ${subject}`
|
|
}
|
|
const verb = SETTLED_VERBS[block.state] ?? 'stopped reporting'
|
|
return `${subject.charAt(0).toUpperCase()}${subject.slice(1)} ${verb}`
|
|
}
|
|
|
|
/** The background-task rows in `blocks`. */
|
|
export function backgroundTaskBlocks(
|
|
blocks: readonly NativeChatBlock[]
|
|
): NativeChatBackgroundTaskBlock[] {
|
|
return blocks.filter(isBackgroundTaskBlock)
|
|
}
|
|
|
|
export type BackgroundTaskTwinClaims = {
|
|
/** Text blocks a row's frozen twin occupies, by position, so a surface
|
|
* drawing the block does not print the same sentence beside it. */
|
|
twinTextIndexes: Set<number>
|
|
/** Rows left with no twin, by position, and the sentence each must print
|
|
* itself on a surface that cannot draw the block. */
|
|
unpairedRows: Map<number, string>
|
|
}
|
|
|
|
/** Pair every task row with the frozen twin written beside it.
|
|
*
|
|
* Matched on exact text, which the producer guarantees: it writes the twin
|
|
* from `backgroundTaskFallbackText` and nothing else into the row. A row
|
|
* written by a newer build that phrases a state differently matches nothing,
|
|
* and then prints its own sentence beside the unclaimed text — redundant, but
|
|
* never a lost report, which is the only degradation this row may have. */
|
|
export function claimBackgroundTaskTwins(
|
|
blocks: readonly NativeChatBlock[]
|
|
): BackgroundTaskTwinClaims {
|
|
const twinTextIndexes = new Set<number>()
|
|
const unpairedRows = new Map<number, string>()
|
|
const wanted = new Map<string, number>()
|
|
const rows: { index: number; sentence: string }[] = []
|
|
blocks.forEach((block, index) => {
|
|
if (isBackgroundTaskBlock(block)) {
|
|
const sentence = backgroundTaskFallbackText(block)
|
|
rows.push({ index, sentence })
|
|
wanted.set(sentence, (wanted.get(sentence) ?? 0) + 1)
|
|
}
|
|
})
|
|
for (const [index, block] of blocks.entries()) {
|
|
const count = block.type === 'text' ? (wanted.get(block.text) ?? 0) : 0
|
|
if (block.type === 'text' && count > 0) {
|
|
wanted.set(block.text, count - 1)
|
|
twinTextIndexes.add(index)
|
|
}
|
|
}
|
|
for (const row of rows) {
|
|
const count = wanted.get(row.sentence) ?? 0
|
|
if (count > 0) {
|
|
wanted.set(row.sentence, count - 1)
|
|
unpairedRows.set(row.index, row.sentence)
|
|
}
|
|
}
|
|
return { twinTextIndexes, unpairedRows }
|
|
}
|