Skip to content

feat(tune-out): a reader fork that persists across looks (subconscious.persist) — #966 - #967

Draft
tel-0s wants to merge 73 commits into
mainfrom
feat/persistent-reader
Draft

tel-0s wants to merge 73 commits into
mainfrom
feat/persistent-reader

Conversation

@tel-0s

@tel-0s tel-0s commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Implements #966, option C of the design note posted in #architecture: a derived reader that persists across looks instead of being derived anew each time. Draft until antra's answers to the note's open questions, which shape the configuration, not the core. Based on feat/presentation (#844), whose window ordinals, read marks and noteFramed it uses; with persist unset nothing changes — the record, framing context, framing row and trace keep their shape, and every existing test passes.

What it does

  • ForkReaderConfig.persist?: 'look' | 'epoch' | 'resident' (default 'look', today's behaviour) and resync?: 'window' | 'none' (default 'window' when persist is set); pendingRound? lets the host replace the framework's pending-round note.
  • First look in a scope derives as invokeReaderFork does, under a stable name (reader/<resident>/<epoch8>/g<k> or reader/<resident>/g<k>). Each later look resumes that identity (resumeAgent, a new incarnation on its own branch); it is not derived again. resumeAgent gains { metadata } (this incarnation's epochId/channelId/trigger/persist/resync/residentBranch) and restores the tool surface for a shared derivation whose source is registered. One look at a time per identity.
  • Re-sync by framing: each framing row records residentThrough (the last ordinal the resident's window held) and residentBranch; the next look is handed the resident's window rows between the marks (residentRows), minus the reader's own rows and mail, the resident's stored injections and held rows, plus the backlog minus rows the reader's branch already holds. defaultReaderFraming adds one factual block ([In scout's context since your last look: n messages], tool calls as brief transcript, images through).
  • Re-derive, traced (tune-out:reader-rederived with reason): no-record, scope-ended (the coordinator's late look after the tune-out ended), branch-moved (the checkpoint left the host's branch, or under 'window' the last mark is on another branch). A beforeDerive refusal of a resume is a failed look whose rows go back; nothing is derived in its place.
  • beforeDerive is consulted at every derive and resume with that look's metadata; the derivationNotice collapse counts resumptions; the notice description and the tune_out sentence state the fact for a re-synced reader.
  • Docs: docs/dendrite.md forks section and a "A persisted reader" subsection (mechanics only; a persisted reader is a different preset from the one the 10-05 ruling named, and whether that consent covers it is Connectome's question, not the framework's); the AgentConfig.observes doc over-claim fixed; two fragments.

Semantics stay out: no check that a consent exists, no default that assumes one, no coupling between persist and agreement, no text that asks the resident; the record and traces carry persist, incarnation and inherit for a module built on top.

Tests

test/tune-out-reader-persist.test.ts, 20 tests (red-probed where the code could be broken): config accepted/refused; first derives, second resumes (one name, incarnations 1 then 2, one derivation, the second request starting with the whole first); one look at a time; resume after a restart from the ended record; re-sync hands exactly the rows between the marks; backlog dedupe; the default block; each re-derive reason traced; beforeDerive at each derive and resume, a refused resume a failed look; a failing late look leads to no further looks (fix 509f8d38: the invocation object is handed back itself, not a copy — a copy caused 111,328 refusals in 20 s); notices and text; 'look'/unset byte-identical shapes. tune-out-reader-framing-presented also runs with persist: 'look'; audience-stamp-writers lists the new own-row write. Full suite 9680 tests, 9630 pass, 0 fail after the audit entry, 49 platform skips. ~725 src lines, 674 test lines.

Open for antra (the note's questions, and the judgment calls made mechanics-first)

  1. Default lifetime when persistence is on: 'epoch' or 'resident'?
  2. The re-sync set: the resident's whole window since the last look (as now — framework notices such as [delivered] receipts and lifecycle lines included), or only its own rounds?
  3. How the resident's later rows reach the reader: a factual block in a user framing row (as now), or stored under the resident's name so they render as the reader's own turns?
  4. Whether anything of the reader enters the resident's window beyond its reports as mail; whether a resident may observe its reader.
  5. The collapse now counts resumptions for every resumed agent, not only persisted readers (a .changed fragment); scope it to metadata.persist?
  6. resumeAgent's metadata replaces the ended record's metadata rather than merging; the tool-surface restore applies to any resumed shared derivation whose source is registered.
  7. The default block says "context", not "window".
  8. Cost: after the first look the reader reads only its own previous cache, never the resident's — unmeasured.

Not in this PR: connectome-host recipe keys (persist, resync), an explicit resume marker on the beforeDerive spec, a host hook choosing resume vs derive, C′ (carrying earlier reports into a re-derived reader).

Written by an Opus agent under FableDendrite's direction from the design note; reviewed and pushed by FableDendrite.

🤖 Generated with Claude Code

tel-0s and others added 8 commits November 1, 2026 17:25
The configuration surface of a persisted reader (design note §3, option C),
and nothing that reads it yet:

- ForkReaderConfig.persist ('look' | 'epoch' | 'resident', default 'look'),
  resync ('window' | 'none', default 'window' when persist is set), and
  pendingRound (DeriveAgentOptions.pendingRound, or a function of the look's
  framing context that returns it). create() refuses a value with no mode.
- ReaderFramingContext gains resumed, incarnation, rederived and
  residentRows, each left out with 'look'. `resident` already names the
  resident, so the rows are residentRows.
- ReaderInvocation.epochEnded marks the one late look the coordinator asks
  for after its epoch ended (lateFork); its cancel looks don't carry it.
- tune-out:reader-fork gains optional incarnation/resumed/resynced; new
  trace type tune-out:reader-rederived.
- DeriveAgentOptions.pendingRound.madeBy.toolUseId may be left out, for the
  only open call, which is what framePendingRound already did with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ater look

With subconscious.persist 'epoch' or 'resident', a reader is one derived
identity per scope: reader/<resident>/<epoch8>/g<k>, or reader/<resident>/g<k>.
The identity's first look derives it exactly as a reader fork is derived; each
later look resumes it with resumeAgent, a new incarnation on the branch it
left, with the configuration deriveAgent gives a reader built again and this
look's epoch, channel and trigger as its metadata. Its framing is stored as
one row before the run, as a derivation's is. A failed look, a beforeDerive
refusal of the resumption included, gives its held rows back (readerFailed).

One look runs at a time per identity: a look asked for while another runs
waits for it (readerLooks). Without that, the all-except hold's one look per
channel in one tick would find the name still registered. The generation is
read from the registry at the identity's first look in a process, so after a
restart the next look resumes the ended record.

resumeAgent takes { metadata }, the new incarnation's record metadata in
place of the ended record's, and a shared derivation resumed on its source's
allowedTools shows its source's tool presentation again (toolSurfaceOwners),
as its derivation did. deriveAgent's config construction moves to
derivedAgentConfig, unchanged, so the resumption builds the same config.

With persist left out or 'look', invokeReaderFork runs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each persisted reader's framing row records where the resident's window
stood: residentThrough, the ordinal of the last row it held, and
residentBranch. A derivation's is read as deriveAgent is called, which waits
for nothing before context-manager's synchronous checkpoint, so it is the
checkpoint's; a resumption's is read in the same tick as the rows it hands.
The record's metadata carries residentBranch too, for the next look.

A resumed look with resync 'window' is handed, as
ReaderFramingContext.residentRows, what the resident's window took after
the mark on its last framing on the same branch (presentedSince), in window
order: the resident's rounds as stored, what it was shown, its mail and the
framework's notices to it, without the reader's own rows and report mail,
the resident's stored context injections, and held rows (the backlog's).
That mark is on the reader's own branch, so it needs no coordinator state.

The backlog it is handed leaves out every held row a framing on its branch
handed it before, a failed look's included: after a restart the
coordinator's cursor is gone and an epoch's first look is handed all it
held.

defaultReaderFraming, given residentRows, writes one block before the held
traffic: a count line ("[In scout's context since your last look: n
messages]", or "[Nothing has entered scout's context since your last
look.]"), then a line per row, author: text, with tool calls and results as
foreignRowAsSent's brief transcript and no thinking. Without residentRows
its output is what it was. tune-out:reader-fork's resynced counts the rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… can't resume

The identity's current generation is resumed only when the registry has an
ended record of it in scope; otherwise the look derives the next generation
(g<k+1>) and traces tune-out:reader-rederived with the earlier name and the
reason:

- no-record: no ended record of the name (a derivation whose run was
  refused, say), or with 'epoch' one of another tune-out's;
- scope-ended: with 'epoch', the tune-out had ended when the look was asked
  for. That is the coordinator's late look (ReaderInvocation.epochEnded);
  its cancel looks, asked for as it ends, still resume it;
- branch-moved: the derivation's checkpoint is no longer on the host's
  branch (branchesPartAt), or, with resync 'window', the last look was
  framed on another branch than the resident's window reads now (the
  record's residentBranch), whose ordinals its mark can't be read against.

A beforeDerive refusal of a resumption stays a failed look: its held rows
go back, and nothing is derived in its place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The derivationNotice collapse counts resumptions: each incarnation from a
  source is tallied with its kind's derivations, so a reader resumed at
  each look collapses to a count line past the threshold as a reader loop
  that forks does. The count line says how many of them were resumptions
  ("1 subconscious-fork has been derived from you, and resumed 3 times,
  since …"), and its detail carries `resumed`. Without resumptions the line
  and detail are what they were. Before, every resumption was told.
- derivationDescription, for a record whose metadata says it is a
  persisted reader: "reading your context up to the point it was derived"
  at a resumption, and with resync 'window', "and handed what entered your
  context after it at each look". Any other record reads as before.
- tune_out's text adds one sentence for a persisted reader: "Within one
  tune-out, later runs resume that fork, each handed what entered your
  context since the run before; a new fork is made only when it can't be
  resumed." ('resident': "Later runs resume that fork, across
  tune-outs, …"; resync 'none' leaves out the handed clause). Reader forks
  per look read as before.
- subconscious.pendingRound replaces the framework's pending-round note
  ("This call derived …") for the resident's call open when a reader is
  derived, as an object or a function of the look's framing context, with
  'look' as with a persisted reader's derivation. Left out, the default
  holds.

An existing forks test (tune-out-reader-framing-presented) runs both with
persist left out and with persist 'look'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/dendrite.md: the forks section says persist exists and what it
  leaves as it was; a new "A persisted reader" subsection says the
  mechanics alone: names, the first look's derivation, each later look's
  resumption, what a resumed look is handed and how its mark is recorded,
  the three reasons it is derived again, one look at a time, beforeDerive
  at every derivation and resumption, what the resident is told, restarts,
  cost and cache (unmeasured), the known undo/redo holes, and that a
  persisted reader is a different preset from the one the 10-05 ruling
  names, so whether a consent covers it is the deployment's question.
  Also: resumptions in the collapse, resumeAgent's metadata and tool
  surface, the live-follow entry, and choice 14 (resumed and re-synced by
  framing, not standing with a view).
- AgentConfig.observes: the doc said a derived agent's spec carries its
  own edges. deriveAgent declares none, and a shared derivation reads its
  source's slot on its own branch, so an edge there would merge nothing.
- tools.ts: ForkReaderConfig.framing and persist say what a resumed
  reader receives and when the identity is derived again.
- changelog.d/persistent-reader.added.md and .changed.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion itself

The failure path gave readerFailed a copy of the invocation, with the rows
this look was handed as its backlog. The coordinator knows its late look by
the object (lateFork), so a late look that failed was taken for an ordinary
one after the end, and another late look was asked for: with a resumption
beforeDerive refuses, the looks never stopped (111,328 refusals in 20 s in
the new test, 2 with the fix). The invocation itself now goes back, its
backlog set to what the look was handed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn-row writer

persistedReaderLook stores the resumed reader's framing with storeOwnRow
after resumeAgent and before its run registers it, as deriveAgent stores a
derived agent's framing: 'unregistered'. The audit lists every scope that
stores an agent's own row, so the new one is listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor

/review

@anima-review

anima-review Bot commented Nov 3, 2026

Copy link
Copy Markdown

Automatic review of e855c2b96ba0.

Findings, most severe first:

  • 1. should fix at src/framework.ts:17786, src/framework.ts:17806, src/framework.ts:17803, src/framework.ts:13135, test/tune-out-reader-persist.test.ts:465, test/tune-out-reader-persist.test.ts:472, docs/dendrite.md:288: Generation discovery stops at the first missing record, though a refused run can leave a gap before a later registered generation.
    • Failing case: A g1 derivation is refused at run admission while quiesced, so it has no registry record; after resume, g2 is derived and completes. On process restart, the scan stops at missing g1 and derives g1 afresh instead of resuming g2, losing the persisted reader's history and omitting the re-derivation trace.
  • 2. should fix at src/framework.ts:17915, src/framework.ts:18002, src/framework.ts:18047, docs/dendrite.md:242: The retry path treats a stored framing row as proof its held messages were read, so unread held messages drop out of the backlog.
    • Failing case: A resumption stores framing for a held message but its run is refused while quiesced, returning the message to the active epoch; at the next look the earlier framing filters that message out, and if the run is refused again readerFailed receives an empty backlog. The coordinator no longer schedules a look for that message even though no reader ran, and the same loss can occur when a late look fails after filtering.
  • 3. should fix at src/framework.ts:17743, src/framework.ts:17926, src/framework.ts:17975, src/framework.ts:18003: Shutdown neither stops nor drains persisted-reader looks, so a queued look can write framing after shutdown has begun.
    • Failing case: Stop the framework with a persisted reader running and another look queued: after the first settles, the queued look can reopen the reader and store its held IDs in a framing row before runEphemeralToCompletion rejects because provider admission is closed. The failure returns the rows to the in-memory coordinator, but on restart a resumed reader filters those IDs out as already framed even though no run read them; the untracked look can also race the store's teardown.
  • 4. should fix at src/framework.ts:17839, src/framework.ts:17746: An exception during reader planning is only logged by the outer catch, so held rows are never returned to the coordinator.
    • Failing case: If the registry's ended-log lookup or a branch check throws while planning a look, the coordinator has already advanced its handoff cursor, but readerFailed is not called; the rows are skipped by later looks and no reader-fork-failed trace identifies the lost handoff.
  • 5. should fix at src/tune-out/tools.ts:438, src/tune-out/tools.ts:445, src/tune-out/tools.ts:449: Media-only resident rows lose their author because the formatter writes an author line only for rows with text.
    • Failing case: If antra sends an image without text, or another agent mails an image without text, the reader receives the image with no antra or sender label, potentially appearing to follow the preceding person's message.
  • 6. should fix at src/tune-out/tools.ts:388, src/tune-out/tools.ts:436, src/framework.ts:17894: The default resync puts the whole uncapped resident gap into one persistent framing row, which can exceed request or context limits.
    • Failing case: With persist: 'resident', a resident can accrue a long history between tune-outs; the next look stores all of those rows and media in one framing message, which remains on the reader's branch on later resumes. Measure realistic row sizes, media sizes, provider limits, and recovery behavior to establish a suitable bound or chunking policy.
  • 7. should fix at docs/dendrite.md:292, src/framework.ts:15509, test/window-undo-redo.test.ts:150: The known-holes docs say redo restores rows at their old ordinals, but redo actually presents them at new ordinals.
    • Failing case: An operator who reads that a redone row stays below the reader's mark can instead get a row above the mark and handed again, including a duplicate of a row already handed before undo.
  • 8. should fix at docs/dendrite.md:272, src/framework.ts:17926, src/framework.ts:17953: The docs say framing can return rows stored under the resident's participant, but its result is always stored as a user row.
    • Failing case: A host trying to make the resident's later turns render as the persisted reader's own turns cannot do so by returning them from framing; both derivations and resumptions wrap the returned content in a user message.
Minor findings 9 to 33 (of 33)
  • 9. minor at src/framework.ts:17786, src/framework.ts:17804, src/framework.ts:12666, src/framework.ts:11865, test/tune-out-reader-persist.test.ts:366: Generation discovery uses a branch-local registry and process-local reservations, so an already used reader name can be derived again.
    • Failing case: Derive reader/scout/g1, switch to a host branch cut before its ended record, and restart, or let that record fall out of registry memory while on the other branch: the first lookup finds no g1 and derives a new g1 even though dendrite/reader/scout/g1 still exists; the new branch gets a suffix, but the new reader's mail is attributed to the same name and incarnation 1 as the earlier reader.
  • 10. minor at src/framework.ts:11377, src/framework.ts:11619, src/framework.ts:13359: Resumption guesses tool-presentation inheritance from the source's current allowedTools instead of keeping the derivation's actual surface owner.
    • Failing case: Derive a shared child without an allowedTools override, end it, then resume it with its original configuration after the source has ended or been re-registered with different allowedTools: the original derivation used the source's presentation, but the resumed child falls back to its own tool block. Conversely, an explicitly overridden child whose allowedTools happen to equal the source's is incorrectly assigned the source's presentation on resumption.
  • 11. minor at src/framework.ts:17837, src/framework.ts:18003: A queued look whose resident has ended returns silently without calling readerFailed or recording a failed look.
    • Failing case: The coordinator advances its handoff cursor for a look queued behind a running reader, then the resident ends before the queued look starts; this return loses the handed-off rows to reader processing and produces neither a failure trace nor a log.
  • 12. minor at src/framework.ts:17839, src/framework.ts:17869, src/framework.ts:17897, src/framework.ts:17909, src/framework.ts:17794, src/framework.ts:17904, src/framework.ts:17916, src/framework.ts:11589: The resume and branch-compatibility decision goes stale while resumeAgent awaits reopening the context, because the branch is never rechecked.
    • Failing case: After readerPlan approves a resumption on branch A, an operator switches the idle host to branch B before reopen completes; the look still resumes the A-derived reader and records A in its metadata, while reading B's mark and writing framing, rather than rechecking and deriving on B when its checkpoint or window branch no longer matches.
  • 13. minor at src/framework.ts:17916, src/framework.ts:18027, src/framework.ts:17794, src/framework.ts:17800, src/framework.ts:18043, src/framework.ts:18048, src/presentation.ts:498: A missing presented window or invalid framing mark is treated as permission to resume, so resync is silently skipped or uses a stale mark.
    • Failing case: In legacy mode, or after presentation falls back to the record, residentMark returns undefined; resident turns between two persisted-reader looks are never put in residentRows, and the reader continues from its old branch without being told those turns, despite the default window-resync behavior.
  • 14. minor at src/framework.ts:17953, src/framework.ts:11201, src/framework.ts:11885, src/framework.ts:18064, docs/dendrite.md:272, test/tune-out-reader-persist.test.ts:287: The first framing's resident mark is captured before beforeDerive runs, so it can miss rows the checkpoint later includes.
    • Failing case: Have beforeDerive synchronously add a row to the resident's context for a persisted-reader derivation: the derived reader inherits that row at the later checkpoint, but its framing records the earlier ordinal, so the next resumption hands the already inherited row again as newly entered context.
  • 15. minor at src/types/trace.ts:944, src/framework.ts:17841, src/framework.ts:17938, src/framework.ts:17963: The trace docs promise reader-fork follows reader-rederived, but rederived is emitted before the derivation can still fail.
    • Failing case: When beforeDerive refuses the new generation, a trace consumer sees reader-rederived followed by reader-fork-failed, not reader-fork, and would wait for an event that never arrives.
  • 16. minor at docs/dendrite.md:242, src/framework.ts:11475, docs/dendrite.md:239: The pendingRound madeBy example implies a result without a toolUseId words an open call, but it does so only when exactly one call is open.
    • Failing case: When a reader is derived during a parallel tool round with two open calls, a host following the example gets the generic pending results for both calls and its supplied result is silently unused; it must identify the call by toolUseId.
  • 17. minor at src/framework.ts:2137, src/framework.ts:12388, docs/dendrite.md:286, src/framework.ts:18067, src/framework.ts:12380, test/tune-out-reader-persist.test.ts:550, test/tune-out-reader-persist.test.ts:561: The tune_out description and lifecycle notice overstate the resync: stored context injections are excluded and the default framing also cuts content.
    • Failing case: If the resident receives a stored context injection between looks, the tune-out description and default lifecycle notice imply it will be handed to the reader, but residentRowsSince filters it out; a resident relying on that disclosure to understand the reader's knowledge is misled.
  • 18. minor at src/framework.ts:18043, src/framework.ts:18047, src/framework.ts:18048, src/framework.ts:17915, src/framework.ts:15359: readerHanded trusts framing rows from the full record even after they were retracted from the reader's presented window.
    • Failing case: Retract or undo a reader framing containing held traffic and resident rows, leaving it in the record but out of the presented window, then resume: the scan still suppresses those held IDs and advances past those resident rows, while the resumed context no longer contains their framing.
  • 19. minor at src/framework.ts:11624, src/framework.ts:11627: Resumed-candidate cleanup matches surface entries only by name and owner, so an earlier incarnation can delete a later one's entry.
    • Failing case: Run incarnation 2 to completion, resume incarnation 3 from the same source but do not start it yet, then call incarnation 2's returned cleanup: because no agent of that name is registered and both candidates have the same surface owner, cleanup deletes incarnation 3's entry and its run shows its own tool block.
  • 20. minor at src/tune-out/coordinator.ts:2285, src/framework.ts:17977, src/framework.ts:17998, src/framework.ts:18017: Reader report bookkeeping is cleared only in the reader-look wrapper, so a public resumeAgent run leaves a stale reported flag.
    • Failing case: Between two persisted looks, explicitly resume the ended reader and run it to completion after it calls deliver_summary; the next automatic look inherits readerReported=true, so its final prose is suppressed or, if it fails before reporting, its held backlog is not returned.
  • 21. minor at src/framework.ts:11189, src/framework.ts:11286, src/framework.ts:11537, src/framework.ts:11610: Creation and resumption share no incarnation-safe name reservation, so a failed creation's cleanup can erase a resumed incarnation's marker.
    • Failing case: After a restart with an ended record, start createEphemeralAgent for that name and leave its context open pending; concurrently resume the record, let the resume set usedEphemeralAgentNames, then let creation fail: its catch deletes the resumed job's marker, so after retirement takesGlobalFallback treats that ephemeral name as eligible for global fallback.
  • 22. minor at src/framework.ts:12374, changelog.d/persistent-reader.changed.md:52: The notice treats any shared derivation with persist 'epoch' or 'resident' metadata as a persisted reader.
    • Failing case: A host derives a task fork with metadata { persist: 'resident', resync: 'window' } for its own purposes: the source is now told that the task fork is handed later context at each look, although this path does not do that; the changelog says every other record's line is unchanged.
  • 23. minor at src/tune-out/tools.ts:427, src/tune-out/tools.ts:445: The resident-row count includes empty or image-only rows that get no text line, so the header overstates the message count.
    • Failing case: A resync containing an empty-content row, or an image-only row, is counted in n but produces no corresponding text line; an image-only row is emitted as a bare media block instead.
  • 24. minor at src/tune-out/tools.ts:441, src/tune-out/tools.ts:446: Splitting a row's text from its media reorders the blocks inside a resident row.
    • Failing case: A message containing text A, an image, and text B is resynced as A followed by B followed by the image, so the reader sees the explanation and image in a different order from the resident.
  • 25. minor at test/tune-out-reader-persist.test.ts:231, test/tune-out-reader-persist.test.ts:239, docs/dendrite.md:288: The restart test stops only after the first look completes, so recovery of an interrupted in-flight look goes untested.
    • Failing case: An implementation that fails to end an in-flight reader as host-restart, or cannot resume that interrupted record, still passes the test using a completed record.
  • 26. minor at test/tune-out-reader-persist.test.ts:243, test/tune-out-reader-persist.test.ts:251, test/tune-out-reader-persist.test.ts:258: The restart test never presents a resident row between looks, so it doesn't check that the persisted mark is read after reboot.
    • Failing case: If resumed readers lose or ignore the previous residentThrough mark only after restart, the restart test still passes, while the separate resync test exercises the mark only in one process.
  • 27. minor at test/tune-out-reader-persist.test.ts:313, test/tune-out-reader-persist.test.ts:321, src/framework.ts:18047: The deduplication test covers only a successful first look and never sets up the failed look's framing row it is meant to count.
    • Failing case: If readerHanded ignores framing rows left by a failed run, a returned held row is handed twice, but this test still passes because its first look completed successfully.
  • 28. minor at test/tune-out-reader-persist.test.ts:438, test/tune-out-reader-persist.test.ts:445: The resident-scope tests never start a second tune-out, so persistence across separate epochs is not pinned.
    • Failing case: An implementation that resumes within one epoch but derives a fresh resident reader on the next tune-out passes the current resident-mode tests.
  • 29. minor at src/framework.ts:2138, src/framework.ts:17797, docs/dendrite.md:286: The tune-out description says a new fork is made only when resumption is impossible, but an epoch's late look always rederives.
    • Failing case: With persist: 'epoch', a reader that fails after its tune-out ends can have an ended record on the same branch, yet the coordinator's late look selects scope-ended and makes a new generation rather than resuming it, contrary to what the resident was told.
  • 30. minor at src/framework.ts:18043, src/framework.ts:18047, docs/dendrite.md:290: Every resumed look scans the reader's whole history and rebuilds the held-ID set, a growing cost the docs don't disclose.
    • Failing case: A resident-persisted reader derived from a large resident history and resumed for weeks scans the inherited prefix as well as its growing own branch on every look, even with resync 'none'; as it receives more distinct held messages, each look also rebuilds a larger temporary set, regardless of the current backlog.
  • 31. minor at src/framework.ts:18064, src/presentation.ts:1091, docs/dendrite.md:290: Window resync filters the resident's whole presented window on every look instead of only the suffix after the mark.
    • Failing case: With the default resync 'window', a resident whose presented window has grown large pays a full-window pass on each resumed look even if no new resident rows entered since the previous one; there is no bound in this code on the window size.
  • 32. minor at src/framework.ts:17787, src/framework.ts:17807, docs/dendrite.md:290: The generation map keeps an entry for every reader identity, expired epochs included, for the whole process lifetime.
    • Failing case: A long-running process using persist 'epoch' accumulates another map entry for each distinct tune-out that invokes a reader, and ending those tune-outs does not remove the entries; this is per identity, not per look.
  • 33. minor at docs/dendrite.md:252, docs/dendrite.md:270: The fork section's 'one Chronicle branch per invocation' cost no longer holds for persisted readers, which reuse one branch.
    • Failing case: With persist: 'resident', several successful looks create one reader branch and reopen it for later incarnations, not one branch per look; an operator using the stated cost to estimate branch growth will overestimate it.

tel-0s and others added 19 commits November 2, 2026 21:09
Conflicts in src/framework.ts: the tools.js import keeps both sides' types
(ReaderFramingContext, ReaderRederivedReason and TuneOutReader); the
TuneOutReader type, which feat/presentation moved to tune-out/tools.ts, stays
there and takes this branch's persist/resync fields on its forks variant,
with framework.ts re-exporting it as feat/presentation does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…inals

A redo presents the rows it brings back at new ordinals past the
window's end, not at the ordinals they held. So a row undone before a
look and redone after it is handed at the next look, and a row handed
before an undo and redone after it is handed again. The known-holes
paragraph said the opposite. (The design note's mirror, outside this
repo, is reworded the same way.)

AF #967 bot review finding 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs said a host's `framing` may return rows stored under the
resident's participant, to render as the reader's own turns. The
framework stores whatever `framing` returns as one user row of the
reader's, at a derivation and at a resumption alike. Say that, and
that rows in the resident's own voice are an open question (#966).

AF #967 bot review finding 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reDerive

A persisted reader's first framing records the resident's window mark
as deriveAgent is called, before dendrite.beforeDerive runs. A row a
host's beforeDerive writes to the resident's context is in the
derivation's inheritance and is handed again at the next look. Say
that a host's beforeDerive must not write there.

AF #967 bot review finding 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-fork-failed

tune-out:reader-rederived is emitted before the new generation is
derived, so a derivation that then fails (a beforeDerive refusal, say)
is followed by tune-out:reader-fork-failed, not tune-out:reader-fork.
The trace's doc comment and the docs said reader-fork always follows.
The emit order is unchanged.

AF #967 bot review finding 15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pendingRound's madeBy without a toolUseId words the open call's
result only when exactly one call is open. With several, the host
names the call by madeBy.toolUseId (getPendingAssistantRound gives the
ids) or words each by its id in results; otherwise its madeBy.result
is not used.

AF #967 bot review finding 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…resentation by

A resumption infers its tool presentation from the configuration it is
given: a shared derivation resumed shows its source's presentation when
the allowed tools it is resumed with equal its source's current ones and
its source is registered. A derivation's rule is allowedTools left out.
So a host that overrides allowedTools with its source's own values gets
the shared presentation on resume. A persisted reader's configuration,
built again on the resident's current allowedTools, meets it at each
look. The docs said only "as its derivation did".

AF #967 bot review finding 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AF #967 bot review findings 5 and 23. residentRowLines wrote a line only
for a row with text. A row with media and no text, such as an image antra
sent or an agent mailed, came as its media alone, after the previous
row's line, so it read as that author's. A row with nothing to send was
counted in the count line and had no line.

Now a row with media and no text has its author's line, `antra: `,
before its media, as the held traffic's lines already did; with no author
to name it is its media alone. A row with nothing to send reads
`[no text]`, under its author when it has one: foreignRowAsSent's
NOTHING_SAID, now exported, which a thinking-only row already read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fork section's cost, one Chronicle branch and one context manager
per invocation, holds for a fork per look. A persisted reader has one
branch per generation, reopened at each look, and one context manager
per look. Each resumed look scans the reader's own branch, the
inherited prefix included, for its earlier framings, with resync 'none'
too: 17 ms at a 50k-row resident and 53 ms at 200k, measured cold after
the reopen (review4/p2/scan-cost.mjs, re-run on this tree; about 5-8% of
a look's local work over two runs). With resync 'window' it also
filters the resident's presented window past the mark (presentedSince:
8 ms and 35 ms).

AF #967 bot review findings 30 and 33.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming

AF #967 bot review finding 24. Both of the default reader framing's
formatters, the resident's rows (residentRowLines) and the held traffic,
put all of a row's text on its author's line and all its non-text blocks
after that line. So text A, an image and text B reached the reader as
"A\nB", then the image, in another order than the row's.

Both now write a row through one walk, writeRowInOrder: the row's text
runs into a line, the first under its author; at a non-text block the
line ends and the block follows it; the text after it is a line of its
own, with no author. A row that opens with a non-text block has its
author's line, `antra: `, before it.

This changes the held block's bytes on every host with reader forks and
the default framing, on the default persist: 'look' too, wherever a held
message has text after a non-text block; the changelog fragment says what
reads as before. No existing test pinned the old order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts row handed once

The restart test stopped only after its first look had completed, so an
interrupted look's recovery went untested. Here the first look's stream
is held and the framework stops with it in flight. After the reboot on
the same directory, the record has ended as host-restart. The next look,
handed the epoch's whole backlog as after a restart, resumes the
identity at incarnation 2. Its request holds the interrupted look's row
once (its own framing), and the new framing holds the new row alone.

New file test/tune-out-reader-persist-more.test.ts, with a copy of
tune-out-reader-persist.test.ts's boot().

AF #967 bot review finding 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…start

The restart test presented no resident row between its looks, so a mark
lost or ignored only after a restart passed. Here scout takes turns
after the first look, before the stop and after the reboot. The look
after the reboot is handed all of them, in window order, and nothing
from before the first look. The trace's resynced count and the default
framing's count line both equal the rows handed.

AF #967 bot review finding 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The resident-scope tests never started a second tune-out. Here
reader/scout/g1 is derived in the first tune-out and resumed for its
cancel look. After enter() again, the second tune-out's first look
resumes it at incarnation 3, with the new epochId in its record's
metadata. There is no rederived trace and no second branch. Its request
holds what it was handed and said in the first tune-out.

AF #967 bot review finding 28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… finding 2's fix)

Quiesced, a resumed look stores its framing and then has its run refused
at admission. The test makes two such refusals with the same held row,
then resumes the framework and runs the look. It pins the semantics
settled in the triage of finding 2: each refusal gives the row back
(returned 1 both times), and the look that runs is resumed at
incarnation 2 and holds the row exactly once.

On this tree the second refusal returns 0: the first refusal's stored
framing filters the row out of its backlog. So the test is skipped
until fix/review5-1 lands ('awaits fix/review5-1 (#967 finding 2)').

AF #967 bot review finding 27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ling

readerPlan ran before the look's try, so a registry or branch read that
threw while planning was only logged by the look chain's catch: the
coordinator had already advanced its handoff cursor, readerFailed was never
called and no reader-fork-failed trace was emitted, so the look's held rows
were lost. The plan and its rederived trace now run inside the try; a
failure before a generation is planned is traced under the identity's name
and gives the rows back, as any failed look's.

AF #967 bot review finding 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive its rows back

A persisted reader's look queued behind another returned silently when the
resident had ended meanwhile, after the coordinator had advanced its handoff
cursor: its held rows went to no reader, with neither a failure trace nor a
log. It now gives them back (readerFailed) and emits
tune-out:reader-fork-failed under the identity's name, error "the resident
ended before the look ran".

AF #967 bot review finding 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pping

stop() neither stopped nor waited for a persisted reader's looks, which run
one after another outside the event loop. A look queued behind a running one
ran during teardown: it derived a new generation (with a rederived trace and
a reader-fork trace for a run that never ran), created its branch and stored
a framing row, and then its run was refused because the framework was
stopping, all while the store was being torn down.

A look now plans nothing once provider admission is closed, and stopOwned
waits for the reader looks, bounded by teardownTimeoutMs, after it ends the
ephemeral runs and before it waits for operator operations. The rows of a
look not run aren't given back: the coordinator has stopped, and after a
restart an epoch's first look is handed all the epoch holds.

AF #967 bot review finding 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
readerPlan found the identity's newest generation by scanning registry
records and stopped at the first name with none. A derivation whose run was
refused (quiesced) has its branch and no record, so after a restart the scan
stopped there, derived g1 afresh and left the completed g2 and its history
unread, with no rederived trace. Likewise a generation whose ended record the
registry can't reach (on another branch's log, or past what it keeps) was
derived again under its own name, on a suffixed branch, at incarnation 1,
untraced.

A generation now counts as used when the registry has a record of it or the
store a branch a derivation made for it (dendrite/<name>, or ~n after it),
in the scan, in the names a new generation can't take, and in whether the
derivation follows an earlier one, so it is traced 'no-record'.

AF #967 bot review findings 1 and 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t is refused

A resumption stores its framing on the reader's branch before its run, so a
refused run (quiesced, stopping) leaves a framing row no run read. The next
look filtered every held id a framing on the branch named out of its
backlog, so a second refusal handed readerFailed an empty backlog and the
coordinator scheduled no reader for the row: lost whenever the next look
derived a new generation, or a late look failed after the end.

Each framing row now records the incarnation it was made for. readerHanded
also returns the held ids a run took on: those from framings made for an
incarnation the ended record reaches. The content is still filtered by every
held id (a refused look's framing is in the window of the next run that
takes the reader on), and a failed look gives back its backlog less the
taken ids only.

AF #967 bot review finding 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tel-0s

tel-0s commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation caf5a48 (a waiting cancel credits every caller it answered) → head c2f5d51; clean merge. Tune-out, review8, stop-stamp and channel-close-focus suites: 444/444.

— FableDendrite

@tel-0s

tel-0s commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation ec41c17 (a fork's wake-onboarding notice written where the fork reads) → head f2cb4a5; clean merge. Tune-out, review8, stop-stamp, channel-close-focus and wake-onboarding suites: 468/468.

— FableDendrite

# Conflicts:
#	docs/dendrite.md
#	src/tune-out/tools.ts
@tel-0s

tel-0s commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation 22dc7d4 (the second automatic review's 39 commits) → head 646ed36; two conflicts kept both ways (TuneOutReader's persistent variant gains running?: false while the forks variant keeps persist/resync; the forks doc paragraph keeps the persisted-reader additions and main's held-writes clause). Tune-out, review8, channel-close-focus, residue-scan and rollback-accounting suites: 478/478.

— FableDendrite

@tel-0s

tel-0s commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation 0f7e271 (the third automatic review's 35 commits, nissa's house's finding-15 fix among them, over main's #982) → head 546ae46; clean merge. Tune-out, review8, channel-close-focus, dendrite-lifecycle and present-while-acting suites: 622/622.

— FableDendrite

@tel-0s

tel-0s commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation 4a525c74 (docs only: the rollback texts and the chronicle 0.5.1 bound) → 1fcbc26c; build clean, no suite run for a docs merge.

@tel-0s

tel-0s commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation 1f96d1f7 (the fourth round's head with the house's three held-note fixes) → 99f40439. Full suite after a clean tsc build: 10292 tests, 10243 pass, 0 fail, 49 skipped.

@tel-0s

tel-0s commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation 412877b3 (the fifth round's head with the house's five held-note fixes, including the budgeted stop) → 9515f35a. One conflict in framework.ts (the persisted reader's block against deliverFromReader's new signature), both kept; my first resolution dropped the doc comment's opener and broke the build, fixed in the next commit before any suite was read. Full suite after a clean tsc build: 10370 tests, 10321 pass, 0 fail, 49 skipped.

@tel-0s

tel-0s commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation c4431b84 (the resident-told fix and the fragments' Before audit) → e21ed8e4, clean. Full suite after a clean tsc build: 10375 tests, 10326 pass, 0 fail, 49 skipped.

@tel-0s

tel-0s commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Merged feat/presentation 59704370 (the catch-up onto main 010bf98a, with the later-cancel row, the §9 pieces and the house's #976 round) → 2e0e02d6, clean. Full suite after a clean tsc build: 10406 tests, 10357 pass, 0 fail, 49 skipped.

@tel-0s
tel-0s changed the base branch from feat/presentation to main November 7, 2026 08:37
@tel-0s

tel-0s commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

#844 is merged into main (c788d078), so this draft is retargeted to main and main merged in → 31ba84d3; the tree is identical to 2e0e02d6, whose full suite was 10406 / 10357 / 0 / 49, so no new run. Still a draft, pending antra's answers to the design note.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants