Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7532,6 +7532,42 @@ credited by the key of the task producing it: the overlay a screen shows answers
at the position its session was opened at, so it would report an earlier entry's
retained output as the output of the entry running now.

**One entry's saved evidence is read through the projection, and metadata is
part of it.** A reader of a failed entry needs what it ran, what its turns
said, what it printed and where each of those sits in the file, and the last of
those is the only one the model did not already hold: a transcript is a
presentation and says nothing about the effects it does not draw. So
`buildEntry` forms one row per retained `Yield` and `Close` of the range it is
already reading — marker, type, name, status, position, prompt number and
agent, and no body, result payload, checkpoint or stack — and detaches and
freezes it with everything else the entry carries. A saved Prompt's row takes
its semantic outcome, its number and its agent from the turn the same reading
parsed, matched by the marker it was retained under, and the root close takes
the outcome that reading already produced; every other row states the protocol
outcome its record states. Raw `DurableEvent`s still terminate at the model,
and no new record, index or stored position is written anywhere.

The reads over it are a feature-local module of the Sidekick's own
(`sidekick/entry-inspection.ts`): pure selectors, Unicode and record windows,
and four page builders over frozen entry facts and the one exact printing fact
the session already publishes. It owns no task, listener, stream or cache, so
cancelling a read is cancelling its caller and nothing else, and it performs
nothing — no entry is opened, resumed, waited for or executed, and no entry's
provider is reached. `requests.ts` takes one acknowledged head and one printing
fact per read before handing them to it, which is the same per-read moment
every other read takes, and `controls.ts` records the ordinary intent and
outcome pair around it. Execution ownership, the journal partition and the
publication and secret boundaries are untouched by any of it.

The single output read these four replaced is retired from admission rather
than from history. A response written now cannot name it — the capture seals
the table `sidekick/controls.ts` publishes, and it is no longer in it — while
the designated set, the renderer and the legacy reader stay exactly as they
were, because a conversation reopened from a chat that holds that control's
own pairs is told what its history says it asked for. Current executable
admission and the decoding of a retained result are separate responsibilities,
and only the first one moved.

The reply's vocabulary and the entry's are described separately because they
differ — `<Each>` is ordinary in an entry and unavailable in a reply. What a
reply may write is the compact surface derived from the pinned ceiling; what an
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/documents/sidekick/entries.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ Sidekick responses that write an entry, and the REPL entry program they write.
- Add `<REPL.Submit />` after `<REPL.EntryInput>` only when the request calls
for execution. Submit runs the actual shared draft, waits for that entry to
finish and release its resources, and returns status and a read instruction —
not the output. Read the output with `<REPL.EntryOutput />`.
not the output. Read the output with `<REPL.Entry.Output />`.
- When an entry fails, its receipt points at `<REPL.Entry.Journal />` rather
than at its output, because an entry that failed may have printed nothing.
Read `<Guide.Recovery />` for what to do with what it answers.

## Example

Expand Down Expand Up @@ -70,7 +73,7 @@ before writing one; each requires `as`, renders nothing, and binds JSON.
</Let>
<REPL.EntryInput>{code}</REPL.EntryInput>
<REPL.Submit />
<REPL.EntryOutput />
<REPL.Entry.Output />
````

A page is bounded — 100 records, or 4096 characters of text — and `next` holds
Expand Down
97 changes: 85 additions & 12 deletions packages/cli/src/documents/sidekick/recovery.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Recovery

Read this when continuing work that was interrupted, or when a conversation has
been reopened and you need to know what actually happened.
Read this when continuing work that was interrupted, when a conversation has
been reopened, or when an entry failed and you need to find out what it actually
did.

## Applies to

Expand All @@ -18,11 +19,37 @@ prepare.
- Act on actual results. A completed effect stays completed — a later failure
does not undo an earlier success — so correct the failure that happened
without repeating the work that already did.
- Read what an entry actually produced with `<REPL.EntryOutput />`, and read the
current published values with `<REPL.Bindings />`, rather than assuming the
state your earlier plan expected.
- A question that was interrupted is shown as waiting. It is not asked again:
asking again is asking a second question.
- Start a failed entry at its Journal. `<REPL.Entry.Journal entry={2} />`
answers with that entry's complete failure message, how its agent turns ended,
and a record for each thing it saved. An entry that printed nothing still has
all of that.
- Read the turn the Journal located. A record with a `prompt` number names a
saved agent turn, and `<REPL.Entry.Prompt entry={2} prompt={9} />` reads it.
`field="reply"` is what the agent said, `field="request"` is what it was
asked, and `field="error"` is the complete message the turn failed with.
- Read `<REPL.Entry.Source entry={2} />` for the exact source that was admitted,
and `<REPL.Entry.Output entry={2} />` for what the entry printed. Ask for them
when you need them; a Journal read is usually enough to decide what went
wrong.
- Inspect before retrying. Submitting the same draft again performs its work a
second time, and an entry that failed late may have completed most of it.
- A reply you can parse is not a turn that succeeded. A turn whose
`promptStatus` is `failed` failed, whatever its text looks like — read
`field="error"` before treating its JSON as an answer.
- Say what you suspect as a hypothesis, and name the evidence you read it from.
The records say what was saved; why it happened is your reading of them.
- Missing evidence is missing evidence. A turn with no saved record did not
record an outcome; it does not establish that nothing reached the outside
world, so do not report that nothing happened.
- `text: null` and `text: ""` are different answers. Null is a turn that
recorded nothing for that field — a completed turn has no error — and the
empty string is a turn that recorded emptiness.
- `status: "running"` is the entry this process is executing now. `unfinished`
is an entry whose outcome was never recorded, which is what an interrupted
run leaves behind. Neither is a failure, and a read waits for neither.
- Reading performs nothing. No read opens an entry, resumes one, runs its
source or asks an agent anything, so an entry you inspect is left exactly as
it was.
- When the host cannot establish what happened, it refuses rather than guessing,
and says so. That refusal is the answer — do not retry the same request hoping
for a different reading of the same records.
Expand All @@ -32,18 +59,64 @@ prepare.
## Example

```xmd
<REPL.EntryOutput entry={-1} />
<REPL.Entry.Journal entry={2} />
```

The host returns a window of that entry's observed output as feedback, with the
instruction for reading the window beside it, then asks for your next response.
The host answers with that entry's page as feedback, then asks for your next
response:

```json
{
"entry": 2,
"status": "failed",
"failure": "agent prompt failed with stop reason \"refusal\"",
"prompts": { "completed": 8, "failed": 1, "cancelled": 0 },
"records": [
{
"marker": "entry-2:yield:root:18",
"type": "agent_prompt",
"name": "prompt:<eval>:9:1#0",
"status": "failed",
"position": { "path": "<eval>", "generatedSource": null, "offset": 632, "line": 9, "column": 1 },
"prompt": 9,
"agent": "claude"
}
],
"offset": 0,
"length": 20,
"limit": 20,
"previous": null,
"next": null
}
```

The failed record names prompt 9, so that is the turn to read:

```xmd
<REPL.Entry.Prompt entry={2} prompt={9} field="error" />
```

## Reading more than one page

Every page is bounded — 4096 code points of text, or 20 records — and `previous`
and `next` hold the complete arguments of the read beside it, or `null` where
there is none. Write them back as they came:

```xmd
<REPL.Entry.Prompt entry={2} prompt={9} field="reply" offset={4096} limit={4096} />
```

Those arguments state the entry and the prompt as exact numbers, so the read
they describe stays the same read after another entry has been admitted. An
omitted `entry` means the latest entry, and a negative one counts back from it;
both resolve when the read happens rather than when you write it.

## Common mistake

Re-submitting the draft you prepared before the interruption, on the assumption
that it never ran. If the entry was admitted, its work is in the entry History
and submitting again performs it twice. Read the History and the entry's output
first, then continue from what is there.
and submitting again performs it twice. Read its Journal first, then continue
from what is there.

## Read next

Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/repl/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,30 @@ export interface ReplAgentSession {
readonly turns: readonly ReplAgentTurn[];
}

/**
* One saved event of an entry, as metadata that locates evidence.
*
* A row per retained `Yield` and `Close`, the admission and the root close
* included, in the order the file holds them. It says where a thing is and what
* became of it; it holds no body, result payload, checkpoint or stack, because
* what a reader does with it is ask for the evidence it names.
*
* `status` is the semantic outcome for a saved Prompt and for the root close —
* an `ok` protocol wrapper never promotes a failed prompt into completed
* execution — and the protocol outcome for every other effect.
*/
export interface ReplRecord {
readonly marker: string;
/** The saved effect's type, or `close` for a coroutine's close. */
readonly type: string;
readonly name: string | undefined;
readonly status: string;
readonly position: ReplPosition | undefined;
/** A saved Prompt's absolute number, which is its retained sequence plus one. */
readonly prompt: number | undefined;
readonly agent: string | undefined;
}

/** One offered history position, in append order. */
export interface ReplCheckpoint {
readonly marker: string;
Expand Down Expand Up @@ -357,6 +381,15 @@ export interface ReplEntry {
readonly bindings: readonly ReplBinding[];
/** This entry's retained turns, in its own Prompt sequence order. */
readonly turns: readonly ReplAgentTurn[];
/**
* One row per saved event of this entry, in append order.
*
* Detached and frozen with the rest of the reading, so it is metadata about
* the file rather than a second hold on the events — and it is the whole
* range rather than the transcript's rows, because a transcript is a
* presentation and says nothing about the effects it does not draw.
*/
readonly records: readonly ReplRecord[];
}

/** One frozen reading of one validated Journal prefix. */
Expand Down Expand Up @@ -621,6 +654,7 @@ function build(
settled: reading.terminal !== undefined,
bindings,
turns: reading.turns,
records: reading.records,
}),
);
transcript.push(...reading.transcript);
Expand Down Expand Up @@ -686,6 +720,7 @@ interface EntryReading {
readonly transcript: readonly ReplRow[];
readonly checkpoints: readonly ReplCheckpoint[];
readonly turns: readonly ReplAgentTurn[];
readonly records: readonly ReplRecord[];
readonly terminal: ReplTerminal | undefined;
}

Expand Down Expand Up @@ -1132,11 +1167,73 @@ function buildEntry(segment: EntrySegment, markers: readonly string[]): Result<E
transcript: Object.freeze(transcript.map((row) => freeze({ ...row }))),
checkpoints: Object.freeze(checkpoints.map((checkpoint) => freeze({ ...checkpoint }))),
turns: Object.freeze(chronology),
records: entryRecords(events, markers, chronology, terminal),
terminal: terminal === undefined ? undefined : freeze({ ...terminal }),
}),
);
}

/**
* One row per saved event of one entry's range, in append order.
*
* Read from the same range the entry was read from, and told what a Prompt and
* the root close mean by the readings that already parsed them: a turn is
* matched by the marker it was retained under rather than by parsing its record
* a second time, and the root close takes the outcome `readTerminal` produced.
* Every other effect reports the protocol outcome its result states, which is
* the only thing that record says about itself.
*/
function entryRecords(
events: readonly DurableEvent[],
markers: readonly string[],
turns: readonly ReplAgentTurn[],
terminal: ReplTerminal | undefined,
): readonly ReplRecord[] {
const saved = new Map(turns.map((turn) => [turn.marker, turn]));
return Object.freeze(
events.map((event, index) => {
const marker = markers[index];
if (event.type === "close") {
return freeze({
marker,
type: "close",
name: undefined,
status:
event.coroutineId === "root" && terminal !== undefined
? semanticOutcome(terminal.status)
: event.result.status,
position: undefined,
prompt: undefined,
agent: undefined,
});
}
const read = readPosition(event);
const turn = saved.get(marker);
return freeze({
marker,
type: event.description.type,
name: typeof event.description.name === "string" ? event.description.name : undefined,
status: turn === undefined ? event.result.status : turn.status,
position: read === MALFORMED || read === undefined ? undefined : freeze({ ...read }),
prompt: turn === undefined ? undefined : turn.sequence + 1,
agent: turn?.agent,
});
}),
);
}

/** What an entry's own outcome is called, as a reader of its records reads it. */
function semanticOutcome(status: ReplTerminal["status"]): string {
switch (status) {
case "ok":
return "completed";
case "err":
return "failed";
default:
return "cancelled";
}
}

/**
* One retained turn, copied out of the parsed record and frozen all the way
* down.
Expand Down
Loading
Loading