Skip to content

WIP: refactor(agent) runtime around event-sourced protocol boundaries - #47

Draft
Fodesu wants to merge 125 commits into
felinics:mainfrom
Fodesu:feat/agent-runtime
Draft

WIP: refactor(agent) runtime around event-sourced protocol boundaries #47
Fodesu wants to merge 125 commits into
felinics:mainfrom
Fodesu:feat/agent-runtime

Conversation

@Fodesu

@Fodesu Fodesu commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

see spec

@Fodesu
Fodesu force-pushed the feat/agent-runtime branch 2 times, most recently from 6dc5728 to ffe9493 Compare August 29, 2026 06:37
@Fodesu Fodesu changed the title refactor(agent) runtime around event-sourced protocol boundaries WIP: refactor(agent) runtime around event-sourced protocol boundaries Aug 31, 2026
Spec: docs/design/agent-runtime-refactor.md — Machine (Decide/Evolve/Next),
command/fact vocabularies, EvaluateCommit, dual-materialization authority.

sdk: frozen Request/ToolDefinition/ToolChoice/BlobRef, ModelResult, ModelStream.
agent: canonical JCS encoding, digests, derive functions, 14 commands /
14 facts, MachineState, Decide/Evolve/Next, EvaluateCommit, MemoryRuntime,
Loop with bounded parallel tool execution. Conformance + machine + loop tests.
EvaluateCommit now rejects AcceptInput/Approve/Reject/SubmitToolResponse
carrying a caller-minted CommandID: the derivation IS the idempotency index
(spec §5.5), so a random ID silently bypassed duplicate-input detection.

Loop.planAndPrepare distinguishes concurrency rejections from content
rejections: a retriable Prepare error with no authority progress (same
Revision after reload) is surfaced instead of retrying the same plan forever.
Authority correctness:
- SubmitModelResult gates on the result's own tool calls: a result with
  calls and zero bindings no longer silently completes the run
- bindings are cross-checked against the model result (tool name via the
  frozen spec, canonical arguments); a self-consistent binding for a tool
  the model never called is rejected
- ToolStepOpened carries BindingSetDigest; Evolve folds it verbatim, so
  DeriveToolStepID(Source, StepRef.Digest) reproduces the step ID and a
  future schema bump cannot change how v1 events fold
- Evolve and ToolStepOpened validate ToolCallState combinations (spec 4.2)
- CancelRun's reason is fixed to cancelled; ingress cannot forge step_limit
- RejectToolCall accepts Waiting calls of either kind, giving abandoned
  ask-user calls a known-failure exit instead of stranding the run

Canonical encoding:
- integer tokens keep arbitrary precision (64-bit IDs above 2^53 no longer
  corrupt or collide digests); floats keep the ES6 double form
- duplicate object keys, trailing data and invalid UTF-8 are rejected
  instead of silently merging distinct payloads into one digest

Loop:
- settleWorkers replays a failed completion commit once, then surfaces the
  error instead of silently wedging the call in Executing
- a panicking tool settles as ToolExecutionUnknown instead of crashing the
  process
- streaming consumption has a ctx escape and a nil-result guard; reasoning
  deltas are forwarded (EventModelReasoningDelta)
- EventRunFinished fires on terminal
- bindings use ToolSpec.Ref (catalog key), fixing aliased tools
- PlanningHint.SourceStep populated from the new LastClosedStep field

Runtime:
- MemoryRuntime Load/Commit/Events return deep copies; caller mutation can
  no longer reach authoritative state or committed event bytes

Tests: golden digest assertion is now fatal; parallel-bound test gates
workers to prove real concurrency; regression test per finding.
Also: sdk.Usage.Add replaces the duplicated adder; ModelResult.Response is
a pointer so absent metadata stays out of fact digests; four wording fixes
in the design doc.
The AgentEvent log is now the source of truth; MachineState is the
required same-transaction projection (execution cache), rebuildable by
folding the log with Evolve alone — no Decide re-run, no command replay,
no external effects.

Spec (§5.1/§9.1/§14.3/§17, appendix B.11): authority declaration flipped;
the three stability conditions (sealed fact ontology, frozen Evolve
semantics, self-contained facts) are normative; a per-run revision
watermark witnesses log-tail completeness. Arbitration: snapshot
divergence or loss with a complete log rebuilds automatically and is
reported for audit; a log tail below the watermark halts the run
(ErrLogTruncated) — accepted facts cannot be recovered from nothing, and
continuing would upgrade the loss into repeated execution.

Protocol: ModelStepPrepared carries BindingDigest (computed by Decide,
folded verbatim by Evolve), closing the last self-containment gap.

Code: FoldEvents verifies (Revision, Index) continuity and per-fact
digests while folding; MemoryRuntime gains the watermark and Rebuild as
the reference arbitration implementation.

Tests: healthy rebuild is a no-op; corrupted snapshot repairs from the
log; truncated tail halts; interior gaps and tampered facts are rejected;
golden v1 event stream folds to frozen state bytes.
Persist agent-owned JSON-stable model request/result/tool data across commands, facts, state, and digest inputs; enforce canonical snapshots and rebuild MemoryRuntime authority from the event log.

Add SDK Request/ModelResult single-call entrypoints, compatibility adapters, and optional ModelInvoker interfaces while keeping legacy GenerateText/StreamText wrappers.
Add command/event JSON wire codecs that restore sealed variants and verify digests, move Runtime conformance into reusable agent/runtimetest, and harden FoldEvents replay validation.

Reject legacy provider fallback when Request.ProviderOptions would be silently dropped.
Move canonical JSON into agent/jsonstable.Value and use CanonicalJSON for persisted command, fact, state, request, result, metadata, tool argument, and response payload fields.

Harden wire decoding against ambiguous JSON shapes and enforce response decision/payload digests at Decide.
The lint fix that inlined the conformance suite into package run made it
_test.go-only, which no other package can import — but spec §2.3/§8.2
promise the suite to durable Runtime adapters (Memoh) as their acceptance
gate.

Restore the net/http/httptest layout: agent/run/runtimetest is a normal
package exporting RunConformance(t, factory) and importing run; run's own
conformance entry point moves to an external test package (package
run_test), so the dependency chain run_test -> runtimetest -> run has no
cycle and golangci-lint 2.11.3 (the CI version) reports zero issues with
no .golangci.yml exclusions.

The suite already used only the public run API; it moves verbatim modulo
package qualification. Also drops the stale 'Package runtimetest'
comment that sat on top of package run.
Upstream b561c3d renamed the module github.com/memohai/twilight-ai ->
github.com/memohai/twilight before this branch's packages landed, so every
cross-package import inside agent/{es,run,runtimetest} and the sdk test
helpers still pointed at the old module. This is what CI's typecheck
reported as 'no required module provides package .../agent/jsonstable' —
the path simply no longer names this module.

Rebased onto upstream main and rewrote the import paths; the remaining
twilight-ai strings are upstream's own User-Agent / MCP client display
names, unchanged on main.
RunHeader was specified (§5.1.1) but never implemented: BuildRunHeader
creates the immutable Revision-0 record (minimal InitializeRun state,
digest-bound, causation-linked); ValidateRunHeader rejects tampering and
non-minimal initial states; FoldRun validates the header then folds the
transition log — the entry point for durable adapters and run import,
which must never trust an uploaded snapshot.

Spec: mark agent/session as contract-first (the §2.6 materialization
contract binds now, on the application's existing storage; the generic
substrate waits for its first real consumer) and agent/queue as deferred
until a second consumer exists. Rollout order flips: the durable
application adapter is now phase C — the Runtime contract's first real
external consumer — ahead of any new packages; session/queue become
need-driven phases D/E and the harness drops to example status.

docs: add the Memoh durable adapter work order (DDL sketch, commit
transaction skeleton, materialization contract on existing tables, queue
integration points, recovery scanner, acceptance checklist).
The work order describes Memoh-side implementation (DDL, transaction
skeleton, acceptance checklist); it lives with the code that will
implement it, not in the library repo. Moved to
Memoh/docs/design/durable-run-adapter-workorder.md.
Two pages: the legacy in-process SDK loop (authority in the library's
stack frame, callback weaving, deferred-approval single slot, manual
state capture — each red block a compensation Memoh had to build,
grounded in internal/agent/runtime/native and migrations 0073/0121) and
the Run ES design (authority in the runtime transaction as
RunHeader + TransitionRecord log, single Decide/Evolve implementation,
per-call response routing, watermark arbitration). The closing panels
map each old compensation to the mechanism that removes it.
Superseded by the progressive narrative page: the single-canvas layout
buried the argument; the replacement introduces one concept per chapter.
run/header: ValidateRunHeader's minimal-state check now also rejects
pre-seeded Usage and a forged LastClosedStep — both fields passed digest
verification as-is, letting an imported header inflate every RunResult's
usage or steer the first PlanningHint.SourceStep (spec §5.1.1 rule 2).

run/loop: planAndPrepare now emits the accepted ModelStepPrepared events;
it was the only accepted transition invisible to EventSink observers,
so projections keyed on committed events missed the frozen request. The
spec §6.6 one-shot same-CommandID commit replay moves from settleWorkers
into the shared commit helper, so model completion, start, StopRun and
prepare submissions get the same protection — a transient commit failure
on SubmitModelResult no longer aborts the Loop into a state that
MemoryRuntime (no lease expiry) can never recover.

sdk/request_adapter: the stream path materialized Response as a non-nil
pointer to a zero ResponseMetadata where the generate path yields nil,
so FreezeModelResult persisted different bytes per execution mode and
ModelStepCompleted digests were not reproducible across Streaming
on/off. Absent metadata now stays nil on both paths.
…record

The durable-continuation requirement — a new process resumes the same
run from the same step — needs durable MachineState plus lease/grant
coordination; it does not need the state to be rebuilt from events.
Event-log authority bought two capabilities on top of that (automatic
refold after state corruption, historical execution fork): the first
never fires on a correct implementation and has a manual FoldRun +
backup-restore fallback, the second has no product need and is
semantically unsound for executions with external side effects.

Spec §5.1/§8/§9.1/§14.3/§17/appendix B: MachineState is the Run
execution authority (recovery = Load); RunHeader + TransitionRecord log
is the same-transaction canonical record serving audit, projections,
and verified run import/migration via FoldRun. The replay-fold
equivalence stays as the dual-write correctness test. Divergence is an
implementation defect handled operationally; the watermark and the
arbitration protocol leave the spec. Evolve relaxes from permanently
frozen to stable-within-version; migration shipping re-tightens it via
the golden-stream check (recorded as the trigger, alongside execution
fork, for reconsidering log authority).

Code: Rebuild/ErrLogTruncated demoted to optional diagnostics in
comments; watermark kept as a diagnostic field only. Memoh work order:
watermark column dropped, rebuild acceptance item replaced by a
FoldRun consistency check. ES doc section 5 retitled to durable
execution state + canonical record; recovery narrative now reads
Load-not-fold, with the two layers' deliberately different answers to
the ES litmus question.
EXT-WRT-1 required OpenWriter to read the whole log and rebuild three in-memory
things, the first being an idempotency index of CommitID to the group's rows and
fingerprint. That index is what made a reopened Writer's memory grow with the
log: each entry held the group's rows, and each group was a subslice of the one
slice the rebuild had read, so as long as any group stayed in the index the whole
parsed log stayed alive.

Measured on a session of single-row commits, a reopened Writer retained 2.2 MB at
6400 rows -- and the same 2.2 MB with a warm cache entry as without one, because
the cache buys fold time, not memory. With a projection whose state is O(1) the
same measurement is flat at 0.004 MB across 1600 and 12800 rows, which is what
retained_test.go now guards: letting the Writer hold the log again puts 12800 rows
at 1.588 MB and fails it.

The index was also a second copy of an index the kernel already keeps. Append has
to reject a duplicate CommitID (SES-APP-3), so both adapters hold one: the memory
store keeps each group's row range, the file store a membership set. The file
store parses the whole log at Open, keeps only that set, and the Writer then
parsed the same log a second time and pinned it.

So the index moves to where it was already needed. SES-REP-3/4 make the kernel's
read side explicit: Committed answers from the index without touching storage,
and LookupCommit returns a group's rows, reading them from storage only on a hit
-- the file adapter records each group's byte range at Open and reads that range,
so the cost of the answer does not grow with the log. View exposes both, so each
call site states its own cost: the Coordinator only asks whether a commit is
there, while the Run replay needs the rows.

The Writer now keeps nothing per commit. A replay asks the kernel for the old
group's rows and recomputes its fingerprint, since the kernel stores rows rather
than fingerprints; the value is the one the rebuild used to store, because a
fingerprint covers Type, SourceSeqs and Payload and not Seq, Digest, Index or
Last. Only a hit pays for it. Writer memory is O(projections), and opening got
faster, since rebuild no longer hashes every group.

What this does not change is the cost of a read. The file adapter's Read parses
the entire log on every call, so the resumed path still reads the whole log for
cache validation -- it just does not keep it. Section 11 of the refactor records
that, along with the reader's Load doing two such reads per call, as the follow-up
that range-addressed reads would solve.

sessiontest gains a query conformance that both adapters run, covering a group
appended by the current handle and one rebuilt from the log after a reopen, and
requiring that the rows a caller gets back cannot reach the stored ones.
Section 2.1's shape table and section 8.4's persistence table are the live
descriptions a reader consults, so they cannot keep saying the Writer holds an
idempotency index. The Writer row loses it, and the derived-state table gains the
row it moved to: the kernel handle's CommitID index, rebuilt at Open rather than
at OpenWriter. EXT-WRT-1's Commit step also stops claiming an index update it no
longer performs.

Section 8.5 is left alone -- it is the dated plan of the section 8 revision, which
section 10 already superseded without rewriting it either.
A provider boundary has two failure modes that no compile error catches. A
request field can be dropped on the way to the wire, leaving a provider that
compiles, runs, and quietly ignores the system prompt or the tool schema. And
the streaming and non-streaming paths can disagree about the same response,
because each provider converts the wire to a result twice.

provider/providertest reaches a provider only through sdk.Generate and
sdk.Stream, so it asserts behavior rather than method signatures and its
fixtures survive a change to the provider interface underneath. Each case is
provider-neutral:

  request   every marker in the request (model, system, user message, tool
            name, tool description) must reach the URL or the body, on both
            the generated and the streamed path;
  generate  the wire reply must map to a ModelResult carrying the declared
            text, reasoning, finish reason, usage and tool calls;
  stream    the streamed reply means the same logical response, so the
            assembled ModelResult must agree with the generated one;
  error     a provider-shaped error reply must become an error, not an empty
            success.

A fixture declares only how to build the provider against a test server, the
replies in its own wire format, and the provider-neutral meaning of the success
reply (Want); Caps records behavior a provider legitimately lacks so the case
skips instead of passing vacuously. An empty Want.ToolCalls[].ToolCallID means
the wire format carries no id and the provider must mint one.

Six chat providers supply a fixture; this commit lands five of them
(openai/completions, anthropic/messages, github/copilot, openai/responses,
openai/codex), each reusing the canned wire data its own tests already proved,
with a text fixture and a tool-call fixture, and each fixture's streamed reply
carrying the same logical content as its generated one.

The suite was falsified before being trusted: dropping the system message from
the completions request makes request/generate fail; dropping it only when
streaming makes stream fail while generate passes; making the streamed text
diverge from the generated text fails stream only. All five fixtures pass, and
the suite is race-clean because the request recorder is guarded.

Two limits are deliberate and worth knowing. openai/codex has a single
transport -- DoGenerate delegates to DoStream, which hardcodes stream=true --
so for that provider the stream case compares it against itself and cannot
detect a buffered-versus-incremental divergence. And a fixture leaves
Want.Response nil when the wire format does not repeat response metadata on the
stream, so a stream that silently drops it passes unless the fixture asserts
it; openai/responses and openai/codex do assert it.
Google carries the model in the request path rather than the body, which the
suite already accounts for, and its wire format carries no tool-call id, so the
fixture leaves Want.ToolCalls[].ToolCallID empty and the suite requires the
provider to mint a non-empty id instead of comparing one.

With this fixture the net covers all six chat providers, and step 2 can change
the provider interface against it.
The provider seam still spoke the legacy types: GenerateParams in,
*GenerateResult / *StreamResult out. Orchestration state (Steps, Messages,
ToolResults, DeferredToolApproval) shared a type with the single-call result, so
"nothing orchestration-shaped crosses the seam" could only be a convention; stream
assembly existed in three places (StreamText's step accumulation,
StreamResult.ToResult, and the MaxSteps == 0 fast path that bypassed assembly
entirely) and they did not agree — ToResult read only Response from FinishStepPart
and dropped its finish reason and usage, and the responses provider's generate and
stream paths reported the same instant in different time zones; and the old core
adapter ignored ctx, so a consumer that walked away mid-stream left the forwarding
goroutine blocked on a send forever.

The seam is now Request in, ModelResult or <-chan StreamPart out. A provider emits
parts and stops; the fold happens once, in the SDK's assembleStream. Providers
never assemble, and GenerateResult/ModelResult are separate types rather than an
embedding, because Response is a pointer on the seam and a value in the client
layer — a promoted field would collide.

Conversion is one-way. RequestFromGenerateParams is the only legacy→new entry
point, GenerateResultFromModelResult the only new→legacy one (client-layer result
formatting, never inward). ModelStreamFromStreamResult, GenerateParamsFromRequest,
ModelResultFromGenerateResult and ToolChoice.Legacy are deleted.

Cancellation is handled in both directions: once ctx is done the assembler stops
recording, keeps draining the provider so it cannot block mid-send, and lets
Result report ctx.Err() immediately. Waiting for the provider's channel to close
instead turned a cancelled call into a ten-minute hang. Both paths now leave
through hardenResult, which also normalizes the response timestamp to UTC, so a
streamed and a non-streamed call answer with the same representation. codex, whose
only transport is streaming, answers DoGenerate with sdk.CollectStream so its fold
is still the SDK's one implementation, and the legacy StreamResult.ToResult routes
through the same assembler (collecting tool results itself, since those are
orchestration).

The acceptance mechanism is behavior, not method names: provider/providertest
reaches providers only through sdk.Generate/sdk.Stream, and the six chat providers
each have text and tool-call fixtures over their own canned wire data. The suite
was falsified four ways before being trusted — dropping the system message fails
request and generate, dropping it only when streaming fails stream alone, making
streamed text diverge fails stream alone.

Also: sdk's duplicate ModelInvoker/StreamingModelInvoker declarations and
cmd/twilight-agent's providerModel shim are gone (*sdk.Model satisfies
loop.ModelInvoker directly), and docs/providers.md plus docs/api-reference.md now
describe the new seam, since specs carry target design only.
ProviderOptions was plumbed from the run model request's frozen canonical form all
the way to sdk.Request and read by nobody: no provider consumed it, so setting an
option and setting nothing produced byte-identical requests. A field that
participates in the digest, is persisted, and silently does nothing is worse than
no field at all.

The contract now lives in the SDK and the meaning stays with the provider. Options
are keyed by provider namespace, which is Provider.Name(); each value is an object
whose members are request-body members, merged into the request the provider just
built, so an option can override a member the SDK set. sdk.ApplyProviderOptions
does the merge because it fixes where options live and how they are applied;
what they mean remains the provider's property, since the decode target is the
provider's own wire type. An unknown member is an error rather than a silent
no-op: an option that is quietly dropped is indistinguishable from one that was
never set, which is the failure this seam exists to make impossible.

All six chat providers call it at the end of buildRequest. The conformance suite
now asserts it behaviorally: a fixture may declare Options, which are sent under
that provider's namespace, and the suite requires each member to appear in the
request body on every path. Removing the wiring from any provider makes the
assertion fail, which was verified by deleting anthropic's call and watching
request, generate and stream fail in turn.
…environment

TestIntegration_Reasoning_ToolCall hardcoded deepseek/deepseek-r1, a model id
no endpoint in this repository's provider matrix serves, while every other
integration case in the package reads integrationModel, which takes
OPENAI_MODEL and falls back to gpt-4o-mini. Hardcoding made the case
unrunnable against any endpoint that does not serve that exact id, and it
kept the case from being pointed at a reasoning model by configuration.

The case now uses integrationModel like its siblings. A run that wants the
reasoning assertions still needs OPENAI_MODEL set to a model that emits
reasoning (deepseek-reasoner, o4-mini, ...); without one the case only logs
the warning it always did. The tool-call assertions run against whatever
model the endpoint serves.

Verified: go vet passes, and all five integration cases in the package still
skip without credentials, so the default path is unchanged. The case itself
has not had a successful live run against a real endpoint.
sdk.Request carries open JSON shapes (ProviderOptions values, tool
Parameters, ResponseFormat.JSONSchema, the message parts), so it is not the
value that gets digested: the agent runtime freezes a Request into its own
canonical run.ModelRequest and every digest is defined on that mirror. The
type-level comment already said so, but the ProviderOptions and CacheControl
field comments still claimed the fields "participate in the digest" on the
SDK type, which is the reading the mirror's own design note calls inaccurate.

Both fields now say where the digest is defined: the values enter the frozen
request, and the digest covers them there.
Skill.md and reference.md described the provider contract as DoGenerate and
DoStream over GenerateParams returning *GenerateResult and *StreamResult, and
the provider listings and the OpenAI Responses mapping notes named those same
types. The seam now takes a sdk.Request and returns either a sdk.ModelResult
or a channel of sdk.StreamPart that the core assembles, so this updates the
Provider interface block, the five provider signature listings, the request
and result type listings, and the mapping notes to match, and adds Request,
ModelResult and ModelStream to the reference.

Caller-facing types keep their documentation unchanged: options-built
GenerateParams, GenerateResult and StreamResult are still what the client API
takes and returns, and image, embedding and speech providers are untouched.
Request carries Tools as []ToolDefinition and ToolChoice as a ToolChoice
value, but the reference only documented the caller-side Tool, ToolCall and
ToolResult, so the provider contract referenced two types it never defined.
This adds ToolDefinition, ToolChoice with its mode constants, and the
CacheControl value both Tool and ToolDefinition embed.
The comment explained why TextProviderMetadata serializes on ModelResult by
contrasting it with the caller-side result type, which tags the field
json:"-". It called that type legacy, which names a version instead of the
distinction that actually matters: one type is persisted inside AgentEvents
and must round-trip, the other is a caller-facing projection that drops the
field. The comment now states that distinction.
The type comment introduced ModelResult by comparing it with the caller-side
result type it was derived from. That names a version rather than stating what
ModelResult is, and the caller-side type is not the only thing it differs
from: the comment now says directly which scope it covers and where steps and
messages live instead.
The layer owns a multi-step tool loop, tool execution, approval and step
accumulation, which duplicates what a runtime that persists every step and
steers a run mid-flight has to own itself. Memoh moved to the single-call seam
for that reason, so nothing new should be built on the layer.

Nothing is removed: every entry point, the option-built request and result
types, StreamResult and its methods, and the options that configure the loop
now carry a Deprecated paragraph pointing at Client.Generate and Client.Stream,
which take an sdk.Request and return a ModelResult or a ModelStream. The
single-call seam entry points, the tool execution primitive, and the embedding,
image, speech, transcribe and video client surfaces keep their documentation
unchanged.

staticcheck reports SA1019 wherever the layer is used, and the layer's own
files plus the tests that pin its behavior necessarily use it, so .golangci.yml
excludes SA1019 for those paths by path and text.
SKILL.md offered GenerateText, GenerateTextResult and StreamText as the way to
generate text, and the reference documented their options and result types with
no hint that they are on the way out. Both now name what replaced them: build a
Request and call Client.Generate or Client.Stream.

The reference gains a deprecation note per section, the missing
WithApprovalHandlerBool entry, and the WithApprovalHandler signature now matches
the code, which returns ToolApprovalResult rather than bool.
Getting started, streaming, tools, the API reference and the provider guide all
demonstrated text generation through the client helpers, which are now
deprecated. Each gains a short notice naming the replacement, so a reader is not
led into the loop the SDK is withdrawing from. The provider interface itself is
untouched: DoGenerate and DoStream keep their contracts.

speech.md and embeddings.md need no notice of their own: the speech references
are SpeechStreamResult, and the single GenerateText call in embeddings.md is an
aside next to the embedding API this page documents.
Section 13 states the decision (36 symbols deprecated, nothing deleted), the
reasons the layer cannot stay the recommended path, the consumer evidence that
makes it safe, and the three preconditions that have to hold before the layer
can be removed.
The run digest covers the agent-owned mirror, so a field that exists on one side
of the mirror and not the other silently leaves the identity. Add a test that
compares field sets, JSON keys and omit behaviour for every mirrored type, pins
the three places where the mirror deliberately swaps an open SDK shape for
canonical JSON, and freezes one value of every SDK message part type to check
that the two discriminator families agree and that each part converts back to
the type it came from.

The guard was checked against injected drift: an extra mirror field and a changed
omit option each fail it.
Same five cases through the pre-migration build and the migrated one, captured
byte for byte, for six providers in both the generate and stream paths. Five
providers match exactly; copilot differs only in the key order of one JSON
object, which is not semantic, and google's forced tool choice is a fix: the old
build dropped toolConfig entirely, so the caller's chosen function was never
enforced.
Fold clears the row digest before Apply, so the Writer folding an
unsealed group and a reader folding the sealed one hand projections
the same input; EndsGroupAt is the single group-alignment predicate
behind both cache-reuse checks.

filestore keeps a row-to-byte index per Session (kept current by
Open and Append, validated by size and mtime, rebuilt after a
foreign change), so Read parses only from the requested group and
the hot-cache reader no longer scans the whole log. An equivalence
test compares it with a full-parse instance.

chatlog folds copy-on-write: only the map an event writes is copied.
The Context fold drops from quadratic to linear (3200 events:
65.7 ms to 0.21 ms); the Surface fold keeps its shape, 13% faster.
Since the write path moved to agent/session/writer, two adjacent
layers each had a Writer: the kernel handle Store.Open returns and
the in-process commit pipeline that holds it. The kernel type is
now Handle, matching the specs' "ownership handle"; the memory and
file adapters follow (memoryHandle, fileHandle). writer.Writer is
unchanged.
The record named the external agents whose inbox and session-log
designs were compared; the comparisons stand on the described
mechanics, so the names are dropped.
chatlog.Table is a layered persistent map: an immutable base plus a
small overlay copied per Set, merged into a new base once the
overlay passes sqrt(len). Assistants, ToolResults, Summaries,
Superseded and Checkpoints use it, so a fold shares the tables
between states and pays O(sqrt n) per write while Apply stays pure.
Encoding is the same JSON object as the map it replaces.

Surface fold, 3200 events: 169 ms and 928 MB down to 8.7 ms and
33 MB. Inputs stays a map because the turn Coordinator indexes it
directly; it is the one linear write left.
agent/turn/turntest drives the Coordinator with Writers and the
Runtime only — no Loop, driver or model stub — and asserts the TRN
clauses for Start, Deliver, Retry, Stop, Settle, Status, the surface
fold and takeover recovery; memory and file stores run it.

Tests pin two gaps for a later decision: Deliver commits each input
separately, so a bad second input leaves the first delivered, and
Deliver does not run the submitted-input check Start does, so an
unsubmitted input fails at the projection instead of as a conflict.
agent-turn.md section 8 now lists the assertions the suite makes.
content.go adds the cas ContentStore (Put/Stat/Open under one
Authority, integrity-verified, MediaType bound to the identity),
the Resolver error classes of ART-CAP-1/2, same-store Promote and
the cross-store CopyPromoter. registry.go binds schemes to
providers and answers Verify for GC. RetentionLedger.ClaimsByOwner
replaces ActiveClaims with the watermark cursor of ART-RET-3;
Reconcile drains the pages.

artifacttest runs the section 8 suite against any factory; the
memory implementations pass it. Spec shapes and roadmap rows
updated.
The Coordinator and the Session host now read inputs through Get,
so the last map copy in the Surface fold goes; 3200 events fold in
8.3 ms.
The record mixed migration status tables, decision history and
comparison notes. Status duplicates git history and was already
stale in places; the rationale the specs need is in their clauses
(SES-OWN-1, SES-REP-3, EXT-SCP-4). The seven specs' status lines
and the last cross-references now stand alone.
Section 0 lists the thirteen principles the Session specs rest on --
single authoritative stream, semantic serialization, the Writer
transaction boundary, atomic groups, ownership and fencing,
idempotent commits, derived projections, the payload-opaque
kernel, the verifiable chain, module isolation, companion writes,
the closed set of crash residue, and ownership-free reads -- each
pointing at the clauses that carry it.
planRefresh runs under the lock: it applies the cache policy and
snapshots the immutable states with the head they cover. saveRefresh
encodes and stores them after the unlock, in Commit and Close, so
derived-data IO no longer lengthens the read-decide-validate-append
section. Admission, claim and Append stay inside; they are the
boundary.

EXT-WRT-1 now states that a CommitFn is pure: it reads through View
and performs no external IO.
A Writer that got an infrastructure error from Append kept its stale
head and projections and carried on: the next group was folded at
the wrong Seq while the kernel sealed it at the real one, so owner
and reader state diverged. Now any Append error other than
ownership lost, conflict or a known not-written result puts the
Writer in a failed state (ErrUnknownOutcome) until reopened.

filestore poisons its handle after a write, sync or close error
(ErrHandleFailed); Open decides from disk what was persisted, so a
durable group replays as AlreadyApplied and a torn one as Applied.
Fault-injection tests cover both adapters and both failure points.
settleWorkers waited for all workers even after one settlement came
back ErrOwnershipLost, so a Loop whose Session had been taken over
sat on a blocked sibling worker, which then tried to settle too.
Workers now run under a cancellable context; the first fenced
settlement cancels the rest, workers finishing afterwards commit
nothing, and the Loop returns the ownership error at once.

Loop-level tests pin the path for tool and model settlements; the
first fails against the previous code by waiting on the blocked
worker. RUN-LOP-5 states the cancellation precisely.
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.

1 participant