mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +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
114 lines
4.4 KiB
TypeScript
114 lines
4.4 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
backgroundTaskFallbackText,
|
|
canReplaceBackgroundTaskState,
|
|
claimBackgroundTaskTwins,
|
|
isSettledBackgroundTaskState,
|
|
normalizeBackgroundTaskState
|
|
} from './native-chat-background-task-row'
|
|
import type { NativeChatBackgroundTaskBlock, NativeChatBlock } from './native-chat-types'
|
|
|
|
function task(
|
|
overrides: Partial<NativeChatBackgroundTaskBlock> = {}
|
|
): NativeChatBackgroundTaskBlock {
|
|
return {
|
|
type: 'background-task',
|
|
taskId: 'task-1',
|
|
kind: 'command',
|
|
label: 'sleep 20',
|
|
state: 'working',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('background task row text', () => {
|
|
it('leads with the provider sentence — the only account of the failure it ever gave', () => {
|
|
expect(
|
|
backgroundTaskFallbackText(
|
|
task({ state: 'blocked', summary: 'Background command "X" failed with exit code 1' })
|
|
)
|
|
).toBe('Background command "X" failed with exit code 1')
|
|
})
|
|
|
|
it('falls back to the provider error when no summary arrived', () => {
|
|
expect(backgroundTaskFallbackText(task({ state: 'blocked', error: 'ENOENT' }))).toBe('ENOENT')
|
|
})
|
|
|
|
it('claims only that a live task 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.
|
|
expect(backgroundTaskFallbackText(task())).toBe('Started background command "sleep 20"')
|
|
expect(backgroundTaskFallbackText(task())).not.toContain('running')
|
|
})
|
|
|
|
it('names the outcome for a settled task with no provider sentence', () => {
|
|
expect(backgroundTaskFallbackText(task({ state: 'done' }))).toBe(
|
|
'Background command "sleep 20" finished'
|
|
)
|
|
expect(backgroundTaskFallbackText(task({ state: 'unverifiable' }))).toBe(
|
|
'Background command "sleep 20" stopped reporting'
|
|
)
|
|
})
|
|
|
|
it('falls through to the kind when the provider named nothing', () => {
|
|
expect(backgroundTaskFallbackText(task({ label: ' ', state: 'done' }))).toBe(
|
|
'Background command finished'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('background task row state', () => {
|
|
it('reads a state this build has no word for as unverifiable, never as live', () => {
|
|
expect(normalizeBackgroundTaskState('teleported')).toBe('unverifiable')
|
|
expect(isSettledBackgroundTaskState('teleported')).toBe(true)
|
|
expect(isSettledBackgroundTaskState('waiting')).toBe(false)
|
|
})
|
|
|
|
it('latches a reported outcome but lets a real verdict correct lost contact', () => {
|
|
expect(canReplaceBackgroundTaskState('working', 'blocked')).toBe(true)
|
|
expect(canReplaceBackgroundTaskState('blocked', 'working')).toBe(false)
|
|
expect(canReplaceBackgroundTaskState('done', 'blocked')).toBe(false)
|
|
expect(canReplaceBackgroundTaskState('unverifiable', 'done')).toBe(true)
|
|
expect(canReplaceBackgroundTaskState('unverifiable', 'working')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('background task twins', () => {
|
|
it('pairs each row with the frozen sentence written beside it', () => {
|
|
const row = task({ state: 'blocked', summary: 'it failed' })
|
|
const blocks: NativeChatBlock[] = [{ type: 'text', text: 'it failed' }, row]
|
|
const claims = claimBackgroundTaskTwins(blocks)
|
|
expect([...claims.twinTextIndexes]).toEqual([0])
|
|
expect(claims.unpairedRows.size).toBe(0)
|
|
})
|
|
|
|
it('leaves real prose beside a row alone', () => {
|
|
const row = task({ state: 'blocked', summary: 'it failed' })
|
|
const blocks: NativeChatBlock[] = [
|
|
{ type: 'text', text: 'here is what I found' },
|
|
{ type: 'text', text: 'it failed' },
|
|
row
|
|
]
|
|
expect([...claimBackgroundTaskTwins(blocks).twinTextIndexes]).toEqual([1])
|
|
})
|
|
|
|
it('makes a row with no twin print its own sentence rather than nothing', () => {
|
|
const row = task({ state: 'blocked', summary: 'it failed' })
|
|
const claims = claimBackgroundTaskTwins([row])
|
|
expect(claims.unpairedRows.get(0)).toBe('it failed')
|
|
})
|
|
|
|
it('gives two rows sharing a sentence one twin each', () => {
|
|
const rows = [task({ taskId: 'a', state: 'done' }), task({ taskId: 'b', state: 'done' })]
|
|
const blocks: NativeChatBlock[] = [
|
|
{ type: 'text', text: 'Background command "sleep 20" finished' },
|
|
{ type: 'text', text: 'Background command "sleep 20" finished' },
|
|
...rows
|
|
]
|
|
const claims = claimBackgroundTaskTwins(blocks)
|
|
expect([...claims.twinTextIndexes]).toEqual([0, 1])
|
|
expect(claims.unpairedRows.size).toBe(0)
|
|
})
|
|
})
|