Files
orca/src/main/runtime/structured-worker-terminal-read.ts
T
Brennan Benson 31f103535f fix(orchestration): serve a /clear'd native-chat worker through the session running it (#25875)
* fix(orchestration): serve a /clear'd structured worker through the session running it

A structured (native chat) worker the user /clear'd keeps working in a
successor session, but orchestration kept acting on the session it was
started with, which the clear had closed. Terminal read and @idle status
lost the handle, worker-read and the release archive served the pre-clear
transcript, worker-show reported the worker exited, worker-stop closed the
already-closed session and left the successor running, mail and Dispatch
nudges were refused, and the idle sweep could put the successor to rest
while its Dispatch was open.

One forward walk over the durable session records now answers which
session runs a worker's conversation. Worker authority and custody are
judged on that session, so every caller holding a worker handle gets it by
default; reads, archive, observation, status, group addressing and the
incarnation liveness probe use it too. Stop closes the running session and
re-resolves, so a clear that commits while the close waits is followed to
its successor. The reverse direction (a successor's child env, user
takeover, the idle sweep) maps a session to its worker through the
lineage root. A successor's idle edge re-derives the worker's Dispatch
mailbox, and settlement forgets parked mail on every session of the
lineage, derived rather than stored.

When the running session cannot be found (host not installed, a
successor with no record, a looping chain) observation answers
unverifiable, never exited; readers refuse with session_caller_not_live
and stop closes nothing. worker-read and the archive warn that earlier
conversation from before a /clear is not included.

* fix(orchestration): close the review gaps in serving a /clear'd structured worker

Review of the first change found places where a /clear'd worker still
went wrong, plus a type gap that let the original bug shape compile:

- worker-stop refused a structured worker whose old session read
  exited mid-clear (stopped, but the successor not yet committed); it
  now goes to the clear-aware stop once identity and ownership are
  proven. The terminal (PTY) gate is unchanged.
- A worker stopped while its /clear successor had never run could never
  be released: no close writes death evidence for an agent that never
  started, so the probe read unverifiable forever. A released session
  that never started anything and whose chat is gone now reads exited;
  it stays unverifiable while the chat is listed.
- worker-read, terminal read and the release archive now read the
  worker's whole lineage, oldest session first, under the same page
  limit and byte bound, instead of only the running session. This
  replaces the "earlier conversation is not included" warning. Cursors
  key later sessions' items by session, so a cursor taken before a
  clear stays valid and continues into the successor.
- Pointer delivery re-derives a mailbox's target when an attempt ends;
  if a /clear moved it meanwhile, it delivers once to the new session.
- Custody and status reads take the resolved running session, and one
  typed hold (held, not held, unverifiable) replaces the rebuilt
  authority in terminal read, worker-show and group addressing.
- Smaller fixes: stop keeps an earlier close on its receipt; abandoning
  a side task no longer forgets the worker's parked mail; a release
  whose lineage cannot be verified ends release_unknown instead of
  staying requested forever; mail reach reports an unverifiable lineage
  as unverifiable, not ended; party resolution scans records once.

A /clear typed into a worker's chat is reported as a user takeover, the
same as any other message the user types there; a test pins it.

* fix(orchestration): tighten the never-ran rule and the follow-ups to /clear handling

Round-2 review of the /clear'd structured worker fixes found that the new
rule for "a session no agent ever ran is exited" was too loose, and two
of the new follow-ups missed a case:

- The rule now requires the founding fence, which every reservation
  moves. A reservation that a restart released without proof, or a
  failed first start whose exit was never proven, stays unverifiable
  instead of reading exited. It no longer asks whether the
  conversation is open (any history read opens it, which stranded a
  stop, read, release sequence), and it treats only an authoritative
  tab index without the chat as retired.
- Pointer delivery follows a mailbox a /clear moved on a thrown attempt
  too, keeping the attempt's own failure, and tries each session once.
- Releasing a retired worker whose newest session cannot be read keeps
  the readable earlier sessions' output and says the latest one was
  lost, instead of freezing an empty archive.
- worker-show judges status and addressability on one lineage walk.
- The worker-stop comment says plainly that any structured worker that
  reads exited (a crashed agent too) is closed and its chat hidden;
  tests cover that and a takeover reported by the successor session.

* fix(orchestration): keep a retired worker's earliest readable output across several lost sessions

Releasing a worker whose chat was retired and whose newest session
could not be read kept the earlier sessions' output only when exactly
one later session was lost; after two clears with both later journals
gone, release still froze an empty archive although the first session
held the worker's answer. The archive now walks back past every
unreadable later session, under the same page limit, and its warning
says how many later sessions could not be preserved.

The rule that reads a never-started session as exited also requires
that the record carries no fence floor: a copy restored from backup
may hide a reservation that the backup lost, so it stays unverifiable.

* test(orchestration): build the takeover test's session record from the shared fixture

The changed-code quality gate rejected the test's `as unknown as
AgentSessionRecord` stub, which this branch had touched. It now builds a
real record with agentSessionRecordFixture and a typed lease, so no
type assertion is needed.

* test(orchestration): give the cleared-worker pointer harness the sender-name dependency main added
2026-10-06 16:20:45 -07:00

120 lines
6.5 KiB
TypeScript

/**
* `terminal read` for a worker that IS a structured agent session.
*
* Peers peek at each other's recent output constantly, and for a PTY worker that is `terminal
* read`. A structured worker had no answer at all: `worker-read` demands a dispatch id and
* coordinator standing a peer does not have, so the only agent-to-agent read verb refused to
* resolve the handle. This serves the same verb from the session's journal.
*
* The result is a plain `RuntimeTerminalRead` — the journal is projected to LINES and bounded by
* the very reader the PTY tail uses — so nothing an agent reads reveals which kind of worker
* answered. `limit` and `truncated` keep their existing meanings.
*
* `cursor` does NOT, and is refused rather than approximated. The PTY contract is an index into an
* append-only completed-line buffer with a monotone count. A session journal is a REDUCED, MUTABLE
* timeline: an item's projected text changes at its original sequence after later items exist, the
* delta coalescer revises items repeatedly, settlement can rewrite one smaller, a pending approval
* renders as nothing and then as something, and `sequence` resets on epoch rollover — so no index,
* numeric or opaque, stays valid. `worker-read --source transcript` is a window index over the same
* bounded page, not an append-only anchor; do not point callers at it as one.
*
* The refusal is therefore permanent, not a stopgap, and no windowed alternative should be built:
* a broken cursor fails UNSAFE (a silent hole in a poller's output) while diffing a bounded tail
* fails safe (a harmless re-read), and a second paging-shaped verb would invite the PTY assumptions
* this one cannot honour.
*
* READ ONLY, deliberately. `terminal.show` still refuses a structured handle: synthesising a
* `ptyId`/`leafId`/`paneRuntimeId` would hand every public terminal verb something that looks
* writable and is not.
*/
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
import { formatWorkerTranscriptMessage } from '../../shared/worker-transcript-text'
import { AGENT_SESSION_NOT_ATTACHED } from '../native-chat/agent-session-wire/structured-agent-session-mutation-admission'
import type { OrchestrationDb } from './orchestration/db'
import { boundStructuredJournalTail } from './orchestration/structured-worker-journal-archive'
import { readStructuredLineageJournalPage } from './orchestration/structured-worker-journal-page'
import {
holdStructuredWorker,
observeStructuredSession,
resolveStructuredWorkerIdentity,
structuredWorkerTerminalState
} from './structured-worker-authority'
import { readTerminalTail } from './terminal-tail-read'
/**
* The recent output of a structured worker, or null when this handle is not one.
*
* Null is the "not mine" answer, so the PTY path keeps every handle it already owned. A handle that
* IS a structured worker never falls through: an unreadable journal refuses rather than answering
* an empty tail, which a caller cannot tell from a worker that has said nothing, and so does a
* worker whose running session (a `/clear` may have moved it) cannot be verified.
*/
export async function readStructuredWorkerTerminal(args: {
handle: string
db: OrchestrationDb | null
cursor?: number
limit?: number
}): Promise<RuntimeTerminalRead | null> {
const identity = resolveStructuredWorkerIdentity(args.handle, args.db)
if (!identity) {
return null
}
const hold = holdStructuredWorker(identity, args.db)
if (hold.kind === 'unverifiable') {
throw hold.refusal
}
if (hold.kind === 'not-held') {
return null
}
if (args.cursor !== undefined) {
// No index can be re-anchored here, so this refusal names no paging alternative — there is
// none. `terminal.read`'s cursor indexes an append-only completed-line buffer with a monotone
// count; this window is a bounded tail re-projected every read over a MUTABLE timeline, so the
// same index means different lines as items are revised in place, and `truncated`
// (`cursor < oldestCursor`) could never fire to say so because `oldestCursor` is always 0.
// Serving it would silently return wrong or duplicated lines to a poller.
//
// It must NOT redirect to `worker-read --source transcript`: a peer reaching this verb has
// neither a dispatch id nor coordinator standing (see the header), so it cannot run that one —
// and that verb is a window index over the same bounded page, so it would not be a paging
// answer even if it could.
throw new Error(
`${args.handle} serves recent output without a cursor; its history is not line-addressable. ` +
'Read it without --cursor: the tail is bounded and newest-last, so poll it and diff. ' +
'A structured session has no durable line anchor to page from — nothing else does either.'
)
}
const page = await readStructuredLineageJournalPage(hold.running.lineage)
if (!page) {
// Honest refusal, and the same one the send lane reports: an empty tail would read as "this
// worker has produced no output", which is a different and false claim.
throw new Error(AGENT_SESSION_NOT_ATTACHED.code)
}
// Redacts dispatch capabilities and clips oversized blocks under the archive path's byte bound.
const bounded = boundStructuredJournalTail(page.items)
const lines = bounded.messages.flatMap((message) =>
formatWorkerTranscriptMessage(message).split('\n')
)
const read = readTerminalTail({
handle: args.handle,
status: structuredWorkerTerminalState(observeStructuredSession(hold.running.sessionId).status),
previewLines: lines,
// Unreachable without a cursor, and deliberately empty rather than a copy of `lines`: a
// running turn's text is still growing, so calling it "completed" is the `"hel"`/`"hello"`
// hazard the PTY reader guards against.
completedLines: [],
partialLine: '',
completedLineCount: 0,
// Older items really were dropped, by the page limit, the byte bound or an earlier session that
// could not be read; `truncated` is how the PTY read already says exactly that.
bufferTruncated: page.hasOlder || bounded.limited,
...(args.limit === undefined ? {} : { limit: args.limit })
})
// No cursor space is claimed, because none exists here. `nextCursor: null` is the contract's own
// "nothing to continue from"; emitting 0/length would advertise an index the next read cannot
// honour.
const { oldestCursor: _oldest, latestCursor: _latest, ...withoutCursorSpace } = read
return { ...withoutCursorSpace, nextCursor: null }
}