WIP: refactor(agent) runtime around event-sourced protocol boundaries - #47
Draft
Fodesu wants to merge 125 commits into
Draft
WIP: refactor(agent) runtime around event-sourced protocol boundaries #47Fodesu wants to merge 125 commits into
Fodesu wants to merge 125 commits into
Conversation
Fodesu
force-pushed
the
feat/agent-runtime
branch
2 times, most recently
from
August 29, 2026 06:37
6dc5728 to
ffe9493
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
see spec