diff --git a/agentcore/artifact/artifact.go b/agentcore/artifact/artifact.go index fb2d5187..441b45d0 100644 --- a/agentcore/artifact/artifact.go +++ b/agentcore/artifact/artifact.go @@ -1,7 +1,7 @@ // Package artifact is the Artifact Core: // Ref, Binding and the two-state RetentionLedger. The ledger // persists itself; claims are activated before the owner fact is appended and -// orphans are released by the pre-collection reconciliation (ART-RET-3). +// orphans are released by the pre-collection reconciliation. package artifact import ( @@ -64,7 +64,7 @@ type Integrity struct { Value string `json:"value"` } -// Ref locates and verifies immutable content (ART-REF-1). +// Ref locates and verifies immutable content. type Ref struct { Scheme Scheme `json:"scheme"` Authority Authority `json:"authority"` @@ -76,7 +76,7 @@ type Ref struct { ExpiresAtUnixMilli *int64 `json:"expiresAtUnixMilli,omitempty"` } -// Validate applies ART-ID-1 and ART-REF-2. +// Validate checks locator components, durability, integrity and expiry. // //nolint:gocritic // Ref is intentionally passed by value as an immutable wire value. func (r Ref) Validate() error { @@ -109,7 +109,7 @@ func (r Ref) Identity() (string, error) { return string(raw), nil } -// Binding maps a stable BindingID to an immutable Ref (ART-BND-1). +// Binding maps a stable BindingID to an immutable Ref. type Binding struct { ID BindingID `json:"id"` Ref Ref `json:"ref"` @@ -179,7 +179,7 @@ const ( ClaimReleased ClaimState = "released" ) -// BindingSet is a canonical, resolved retention set (ART-RET-1). +// BindingSet is a canonical, resolved retention set. type BindingSet struct { BindingIDs []BindingID `json:"bindingIds"` RefSetDigest RefSetDigest `json:"refSetDigest"` @@ -243,7 +243,7 @@ func SortedUniqueBindingIDs(ids []BindingID) []BindingID { return out[:n] } -// ClaimOwnerQuery selects claims by owner (ART-RET-3). Identities nil selects +// ClaimOwnerQuery selects claims by owner. Identities nil selects // every owner of the Kind under the Authority; an explicit list selects those // owners only, and an empty identity in it matches nothing. Limit is the page // size, 0 meaning DefaultClaimPageSize. @@ -272,14 +272,14 @@ type ClaimPage struct { // DefaultClaimPageSize is the ClaimsByOwner page size when the query gives none. const DefaultClaimPageSize = 256 -// RetentionLedger keeps Active/Released claims (ART-RET-2). It persists +// RetentionLedger keeps Active/Released claims. It persists // itself; Activate returns only once the claim is durable. type RetentionLedger interface { Activate(context.Context, ClaimID, ClaimOwner, BindingSet) (RetentionClaim, error) LookupClaim(context.Context, ClaimID) (RetentionClaim, bool, error) ReleaseActive(context.Context, ClaimID) error // ClaimsByOwner enumerates the matching claims of every state in stable - // ClaimID order under a watermark cursor (ART-RET-3). + // ClaimID order under a watermark cursor. ClaimsByOwner(context.Context, ClaimOwnerQuery, ClaimCursor) (ClaimPage, error) } @@ -311,7 +311,7 @@ type OwnerVerifier interface { } // Reconcile releases Active claims in scope whose owner no longer exists -// (ART-RET-3). The caller guarantees no owner write in scope is in flight. +// The caller guarantees no owner write in scope is in flight. func Reconcile(ctx context.Context, ledger RetentionLedger, scope ClaimOwnerScope, verifier OwnerVerifier) (int, error) { claims, err := ActiveClaims(ctx, ledger, scope) if err != nil { diff --git a/agentcore/artifact/artifacttest/conformance.go b/agentcore/artifact/artifacttest/conformance.go index 02d387cb..e0849a9d 100644 --- a/agentcore/artifact/artifacttest/conformance.go +++ b/agentcore/artifact/artifacttest/conformance.go @@ -17,7 +17,7 @@ import ( ) // Fixture is one adapter under test. Ledger must verify sets through a -// BindingSetBuilder over Bindings (ART-RET-1). NewContent builds a cas +// BindingSetBuilder over Bindings. NewContent builds a cas // ContentStore for one Authority; Advance moves the clock those stores judge // expiry by, nil skips the expiry checks. type Fixture struct { @@ -80,7 +80,7 @@ func mustBinding(t *testing.T, ctx context.Context, store artifact.BindingStore, return b } -// ART-ID-1, ART-REF-1, ART-REF-2: validation, identity stability and the +// validation, identity stability and the // identity-bound MediaType. func testRef(t *testing.T) { expires := int64(5) @@ -129,7 +129,7 @@ func testRef(t *testing.T) { } } -// ART-BND-1: immutable bindings, exact-rebuild idempotency, conflict. +// immutable bindings, exact-rebuild idempotency, conflict. func testBinding(t *testing.T, f Fixture) { ctx := context.Background() ref := sampleRef("k1", artifact.EventBound) @@ -160,7 +160,7 @@ func testBinding(t *testing.T, f Fixture) { } } -// ART-RET-1, ART-RET-2 (builder half): sorted-unique ids, order-independent +// (builder half): sorted-unique ids, order-independent // digest, ephemeral rejection. func testSet(t *testing.T, f Fixture) { ctx := context.Background() @@ -187,7 +187,7 @@ func testSet(t *testing.T, f Fixture) { } } -// ART-RET-2: the state table, exact verification of incoming sets. +// the state table, exact verification of incoming sets. func testLedger(t *testing.T, f Fixture) { ctx := context.Background() mustBinding(t, ctx, f.Bindings, "b1", sampleRef("k1", artifact.EventBound)) @@ -240,7 +240,7 @@ func (s ownerSet) OwnerExists(_ context.Context, o artifact.ClaimOwner) (bool, e return s[o.Identity], nil } -// ART-RET-3: reconciliation releases orphans and leaves owned claims alone. +// reconciliation releases orphans and leaves owned claims alone. func testReconcile(t *testing.T, f Fixture) { ctx := context.Background() mustBinding(t, ctx, f.Bindings, "b1", sampleRef("k1", artifact.EventBound)) @@ -265,7 +265,7 @@ func testReconcile(t *testing.T, f Fixture) { } } -// ART-RET-3: watermark cursor pagination and owner filters. +// watermark cursor pagination and owner filters. func testPaging(t *testing.T, f Fixture) { ctx := context.Background() mustBinding(t, ctx, f.Bindings, "b1", sampleRef("k1", artifact.EventBound)) @@ -389,7 +389,7 @@ func readAll(t *testing.T, r artifact.Resolver, ref artifact.Ref) string { return string(data) } -// ART-BND-2, ART-CAP-1, ART-CAP-2: cas keys, idempotent Put, verified reads +// cas keys, idempotent Put, verified reads // and the error classification. func testContent(t *testing.T, f Fixture) { ctx := context.Background() @@ -417,7 +417,7 @@ func testContent(t *testing.T, f Fixture) { if info, err := store.Stat(ctx, ref); err != nil || info.MediaType != "text/plain" || *info.Integrity != *ref.Integrity { t.Fatalf("stat = %+v %v", info, err) } - // Classification (ART-CAP-1). + // Classification. missing := ref missing.Key = artifact.Key("sha256:" + strings.Repeat("0", 64)) missing.Integrity = &artifact.Integrity{Algorithm: "sha256", Value: strings.Repeat("0", 64)} @@ -472,7 +472,7 @@ func testContent(t *testing.T, f Fixture) { } } -// ART-REF-2, ART-BND-2: promotion never lowers durability, clears expiry, +// promotion never lowers durability, clears expiry, // yields a new Ref and a new Binding without touching the old one. func testPromote(t *testing.T, f Fixture) { ctx := context.Background() @@ -522,7 +522,7 @@ func testPromote(t *testing.T, f Fixture) { } } -// ART-PRO-1: immutable registry, unique scheme and (scheme, authority), +// immutable registry, unique scheme and (scheme, authority), // verification outcomes. func testRegistry(t *testing.T) { cas := artifact.CASScheme() diff --git a/agentcore/artifact/artifacttest/map.go b/agentcore/artifact/artifacttest/map.go index c6f0c84e..afcb4c2c 100644 --- a/agentcore/artifact/artifacttest/map.go +++ b/agentcore/artifact/artifacttest/map.go @@ -9,7 +9,7 @@ import ( "github.com/felinics/twilight/agentcore/artifact" ) -// MapBindings is artifact.BindingStore over a Go map (ART-BND-1): the +// MapBindings is artifact.BindingStore over a Go map: the // reference the suite is written against and the store the kernel's own // tests bind through. The zero value is ready. type MapBindings struct { @@ -60,8 +60,7 @@ func (s *MapBindings) ResolveBinding(ctx context.Context, id artifact.BindingID) return b, nil } -// MapLedger is artifact.RetentionLedger over a Go map (ART-RET-2, -// ART-RET-3). Builder, when set, verifies every activated set against the +// MapLedger is artifact.RetentionLedger over a Go map. Builder, when set, verifies every activated set against the // bindings it was built from. type MapLedger struct { Builder artifact.BindingSetBuilder @@ -126,7 +125,7 @@ func (l *MapLedger) ReleaseActive(_ context.Context, id artifact.ClaimID) error } // ClaimsByOwner pages the owner's claims in ClaimID order under a watermark -// cursor (ART-RET-3): a first page fixes its watermark at the last matching +// cursor: a first page fixes its watermark at the last matching // id, so claims activated while paging stay out of the enumeration. func (l *MapLedger) ClaimsByOwner(_ context.Context, q artifact.ClaimOwnerQuery, cursor artifact.ClaimCursor) (artifact.ClaimPage, error) { if q.Kind == "" || q.Authority == "" { @@ -162,7 +161,7 @@ func (l *MapLedger) ClaimsByOwner(_ context.Context, q artifact.ClaimOwnerQuery, return artifact.ClaimPage{Items: items}, nil } -// matchesIdentity is ART-RET-3: nil selects every identity, an explicit +// matchesIdentity is nil selects every identity, an explicit // list selects its members, and an empty identity never matches. func matchesIdentity(q artifact.ClaimOwnerQuery, identity string) bool { if identity == "" { diff --git a/agentcore/artifact/content.go b/agentcore/artifact/content.go index 7d19f8ae..1da44c2e 100644 --- a/agentcore/artifact/content.go +++ b/agentcore/artifact/content.go @@ -9,14 +9,14 @@ import ( "io" ) -// SchemeCAS is the standard content-addressed scheme (ART-CAP-2): Key is the +// SchemeCAS is the standard content-addressed scheme: Key is the // content digest in the form : and equals Integrity. const SchemeCAS Scheme = "cas" // IntegritySHA256 is the one integrity algorithm the reference store speaks. const IntegritySHA256 = "sha256" -// Info is what a Resolver knows about a Ref's content (ART-BND-2). +// Info is what a Resolver knows about a Ref's content. type Info struct { MediaType string SizeBytes *uint64 @@ -32,7 +32,7 @@ type PutRequest struct { } // PromoteRequest asks for a Ref of the same content at TargetScheme and -// TargetAuthority with at least the durability of the source (ART-REF-2). +// TargetAuthority with at least the durability of the source. type PromoteRequest struct { TargetScheme Scheme TargetAuthority Authority @@ -40,8 +40,8 @@ type PromoteRequest struct { } // Resolver reads content behind a Ref and verifies it against the Ref's -// declared size and integrity (ART-BND-2). Failures are classified by -// ErrorCode (ART-CAP-1): ErrNotFound, ErrExpired, ErrUnauthorized (a key of +// declared size and integrity. Failures are classified by +// ErrorCode: ErrNotFound, ErrExpired, ErrUnauthorized (a key of // another Authority), ErrCorrupt (stored bytes disagree with the Ref) and // ErrUnsupported (a scheme this resolver does not speak). type Resolver interface { @@ -50,14 +50,13 @@ type Resolver interface { } // Store writes content and returns its Ref only once the write is durable. -// A repeated Put of identical bytes is idempotent (ART-BND-2). +// A repeated Put of identical bytes is idempotent. type Store interface { Put(context.Context, PutRequest) (Ref, error) } // Promoter produces a new Ref for existing content at the requested target // and durability; it never lowers durability and never rewrites a Binding -// (ART-BND-2, ART-REF-2). type Promoter interface { Promote(context.Context, Ref, PromoteRequest) (Ref, error) } @@ -70,7 +69,7 @@ type ContentStore interface { } // DefaultMaxContentBytes bounds a Put when a store's options give no cap -// (ART-CAP-1, size amplification). +// (size amplification). const DefaultMaxContentBytes int64 = 64 << 20 // CASKey is the cas Key and Integrity value of data. diff --git a/agentcore/artifact/registry.go b/agentcore/artifact/registry.go index ba7ec9e9..1161b12a 100644 --- a/agentcore/artifact/registry.go +++ b/agentcore/artifact/registry.go @@ -11,7 +11,7 @@ type ( ProviderInstanceID string ) -// SchemeDefinition is the resolution contract of one Scheme (ART-CAP-2). +// SchemeDefinition is the resolution contract of one Scheme. type SchemeDefinition struct { Scheme Scheme SupportedDurabilities []Durability @@ -35,7 +35,7 @@ type ProviderBinding struct { } // Registry is the immutable scheme and provider index of one deployment -// (ART-PRO-1): every Scheme has one definition, every (Scheme, Authority) one +// every Scheme has one definition, every (Scheme, Authority) one // provider instance, and verified use requires both. type Registry struct { schemes map[Scheme]SchemeDefinition @@ -99,7 +99,7 @@ func (r *Registry) Provider(scheme Scheme, authority Authority) (ProviderBinding // Verify admits a Ref for verified use: a registered scheme that supports the // Ref's durability, the scheme's own rules, and a bound provider for its // Authority. An unknown scheme is ErrUnsupported so callers can keep such a -// Ref conservatively (ART-RET-2) rather than treat it as invalid. +// Ref conservatively rather than treat it as invalid. // //nolint:gocritic // Ref is intentionally passed by value as an immutable wire value. func (r *Registry) Verify(ref Ref) error { @@ -131,7 +131,7 @@ func (r *Registry) Verify(ref Ref) error { } // CASScheme is the standard cas definition: every durability, and a Key that -// equals the Ref's integrity in : form (ART-CAP-2). +// equals the Ref's integrity in : form. func CASScheme() SchemeDefinition { return SchemeDefinition{ Scheme: SchemeCAS, diff --git a/agentcore/chatlog/chatlog.go b/agentcore/chatlog/chatlog.go index 8435696d..db33222a 100644 --- a/agentcore/chatlog/chatlog.go +++ b/agentcore/chatlog/chatlog.go @@ -33,10 +33,10 @@ type ( CompactionID string ) -// EventTypes (CHT-EVT-1): the conversation's own facts. Inputs are written by +// EventTypes: the conversation's own facts. Inputs are written by // the host and the Coordinator, compactions by the host's compaction -// (CHT-EVT-3), tool_result_superseded by the Application after an -// out-of-band verification (CHT-ENT-2). +// tool_result_superseded by the Application after an +// out-of-band verification. const ( TypeInputSubmitted ledger.EventType = "twilight/chatlog/input_submitted" TypeInputDelivered ledger.EventType = "twilight/chatlog/input_delivered" @@ -48,7 +48,7 @@ const ( TypeCompactionInvalidated ledger.EventType = "twilight/chatlog/compaction_invalidated" ) -// Digest domains of the projected entries (CHT-COD-3). They are not event +// Digest domains of the projected entries. They are not event // types: an assistant or tool_result entry is folded from Run facts. const ( assistantDomain = "twilight/chatlog/assistant" @@ -194,7 +194,7 @@ type Input struct { } // Assistant is the structural projection of one ModelStepCompleted -// (CHT-ENT-1): the identities of the step and its Turn, the digest that names +// the identities of the step and its Turn, the digest that names // the frozen ModelResult, and the CallIDs of the calls the result issued, in // the result's ToolCalls order (from ToolStepOpened). The text, reasoning and // tool call arguments live in the frozen body; Materialize resolves them. @@ -216,7 +216,7 @@ type Assistant struct { } // ToolResult is the structural projection of one call's terminal outcome -// (CHT-ENT-2): success names the frozen output or response by digest; +// success names the frozen output or response by digest; // error and unknown carry the Run-recorded failure. type ToolResult struct { ID ToolResultID `json:"id"` @@ -249,7 +249,7 @@ type Summary struct { } // AssistantIDFor is the entry identity of a model step's result: the StepID -// itself, which is unique across Runs (TRN-MAP-2). +// itself, which is unique across Runs. func AssistantIDFor(step run.StepID) AssistantID { return AssistantID(step) } // ToolResultIDFor is the entry identity of a call's terminal result: the @@ -257,10 +257,10 @@ func AssistantIDFor(step run.StepID) AssistantID { return AssistantID(step) } func ToolResultIDFor(call run.CallID) ToolResultID { return ToolResultID(call) } // SupersedingToolResultID is the identity of the result an out-of-band -// verification substitutes for id (CHT-ENT-2). +// verification substitutes for id. func SupersedingToolResultID(id ToolResultID) ToolResultID { return id + "/superseded" } -// Digests (CHT-COD-3): the domain is the EventType or the entry domain; `v` +// Digests: the domain is the EventType or the entry domain; `v` // is not covered. func DigestInput(id InputID, content jsonstable.Value) (jsonstable.Digest, error) { @@ -302,7 +302,7 @@ func DigestSummary(s *Summary) (jsonstable.Digest, error) { }{s.ID, s.Parts}) } -// EntryDigestPair names one active Context entry (CHT-EVT-3). +// EntryDigestPair names one active Context entry. type EntryDigestPair struct { Kind EntryKind `json:"kind"` ID string `json:"id"` @@ -353,7 +353,7 @@ type InputSubmittedPayload struct { } // InputDeliveredPayload records an input entering a Turn's Run: the Turn -// and the one Run that executes it (TRN-SCP-2). The chatlog learns which +// and the one Run that executes it. The chatlog learns which // Turn a Run serves from this delivery, so the entries it folds from that // Run's facts carry the Turn. type InputDeliveredPayload struct { @@ -371,7 +371,7 @@ type InputRejectedPayload struct { } // ToolResultSupersededPayload substitutes a verified outcome for a call's -// projected result (CHT-ENT-2): an unknown result the Application confirmed +// projected result: an unknown result the Application confirmed // out of band becomes success (its output frozen under OutputDigest, written // through the run frozen.Store) or error. The Run fact is untouched; the // substitution is a conversation decision. @@ -385,7 +385,7 @@ type SummaryPayload struct { Summary Summary `json:"summary"` } -// CompactionCreatedPayload compacts the context (CHT-EVT-3): entries up to +// CompactionCreatedPayload compacts the context: entries up to // CoveredThrough — the ledger Position of the last covered entry's event — // are replaced by the summary plus the Retained subset. type CompactionCreatedPayload struct { @@ -468,7 +468,7 @@ func checkSummary(p *SummaryPayload) error { } // PartsExtractor returns the BindingIDs of ReferenceParts in appearance -// order (CHT-COD-2). +// order. var PartsExtractor module.BindingExtractor = module.BindingExtractorFunc(func(val any) ([]artifact.BindingID, error) { p, ok := val.(SummaryPayload) if !ok { @@ -514,7 +514,7 @@ var supersededBinding = module.BindingReferenceDefinition{ const StreamDomain = "chatlog" // Version is the payload version the chatlog writes every event with -// (EXT-REG-2); older versions keep their codecs beside it. +// older versions keep their codecs beside it. const Version module.PayloadVersion = 1 var streamDefinition = module.StreamDefinition{Domain: StreamDomain, Inheritance: module.Inherited} @@ -529,7 +529,7 @@ func def[T any](typ ledger.EventType, check func(*T) error, bindings ...module.B } // consumedRunFacts are the Run facts the chatlog projections fold -// (CHT-SCP-1): run_created for the Run's Turn, the model and tool outcomes +// run_created for the Run's Turn, the model and tool outcomes // for the entries, tool_step_opened for the CallIDs a result issued, and // run_ended to forget the Run's Turn. var consumedRunFacts = []string{"model_step_completed", "tool_step_opened", "tool_call_completed", "tool_call_answered", "tool_call_failed", "run_ended"} @@ -542,7 +542,7 @@ func runRequirement() module.ModuleRequirement { return module.ModuleRequirement{Source: module.SourceTwilight, Module: sessionstore.ModuleID, Events: events} } -// Module is the chatlog ModuleDescriptor (CHT-SCP-1: Requires run facts). +// Module is the chatlog ModuleDescriptor (Requires run facts). var Module = module.ModuleDescriptor{ Source: module.SourceTwilight, ID: ModuleID, diff --git a/agentcore/chatlog/chatlog_test.go b/agentcore/chatlog/chatlog_test.go index d63cd736..5d37ffea 100644 --- a/agentcore/chatlog/chatlog_test.go +++ b/agentcore/chatlog/chatlog_test.go @@ -77,7 +77,7 @@ func foldSteps(t *testing.T, steps []step) (Context, Surface, error) { } // Summary parts round-trip through the discriminated wire; foreign fields -// and unknown kinds are rejected (CHT-COD-1). +// and unknown kinds are rejected. func TestPartsCodecRoundTripAndRejects(t *testing.T) { parts := Parts{TextPart{Text: "hello"}, ReferencePart{BindingID: "b1", Name: "file.txt"}} raw, err := parts.MarshalJSON() @@ -109,7 +109,7 @@ func TestPartsCodecRoundTripAndRejects(t *testing.T) { } } -// CHT-ENT-1/2: Run facts project into structural entries. The assistant +// Run facts project into structural entries. The assistant // names the frozen result and the calls its ToolStepOpened assigned; each // call's terminal fact projects one tool_result; a verified supersession // replaces the unknown result in place. @@ -183,7 +183,7 @@ func TestRunFactsProjectEntries(t *testing.T) { // Surface and Context agree on the input lifecycle: only delivered inputs // enter the context, in fold order; withdrawn and rejected inputs leave the -// queue; a second delivery is a reducer error (CHT-EVT-2). +// queue; a second delivery is a reducer error. func TestSurfaceAndContextFold(t *testing.T) { content := jsonstable.MustParse(`{"text":"hi"}`) steps := []step{ @@ -220,7 +220,7 @@ func TestSurfaceAndContextFold(t *testing.T) { } } -// EXT-COD-1: every registered event type's current codec is canonical +// every registered event type's current codec is canonical // round-trip stable — Encode, Decode, Encode reproduces the bytes. func TestEventCodecCanonicalRoundTrip(t *testing.T) { summary := Summary{ID: "sum1", Parts: Parts{TextPart{Text: "so far"}, ReferencePart{BindingID: "b1", Name: "f"}}} @@ -282,7 +282,7 @@ func TestEventCodecCanonicalRoundTrip(t *testing.T) { } } -// --- materialization (CHT-MAT-1) --------------------------------------------- +// --- materialization --------------------------------------------- type fakeContent struct { results map[jsonstable.Digest]model.ModelResult @@ -364,7 +364,7 @@ func TestMaterialize(t *testing.T) { } } -// --- compaction fold (CHT-EVT-3, CHT-CTX-2, CHT-SUR-1) -------------------------- +// --- compaction fold -------------------------- func mustSummary(t *testing.T, id SummaryID, text string) Summary { t.Helper() diff --git a/agentcore/chatlog/materialize.go b/agentcore/chatlog/materialize.go index 6494d085..11398dcb 100644 --- a/agentcore/chatlog/materialize.go +++ b/agentcore/chatlog/materialize.go @@ -11,8 +11,8 @@ import ( ) // ContentResolver resolves the frozen bodies structural entries name by -// digest (RUN-WIR-4). run/sessionstore.Content is the first-party -// implementation; the projections never call it (CHT-MAT-1). +// digest. run/sessionstore.Content is the first-party +// implementation; the projections never call it. type ContentResolver interface { ModelResult(context.Context, jsonstable.Digest) (model.ModelResult, error) ToolOutput(context.Context, jsonstable.Digest) (run.CanonicalJSON, error) @@ -40,7 +40,7 @@ type Materialized struct { // Text is the rendering of the entry's main content: the assistant text, the // tool output or failure, the summary text. Inputs are rendered by the layer -// that knows their payload shape (DEC-INP-1). +// that knows their payload shape. func (m *Materialized) Text() string { switch m.Entry.Kind { case EntryAssistant: @@ -59,7 +59,7 @@ func (m *Materialized) Text() string { } // Materializer resolves entries through a ContentResolver, reading each -// digest at most once per Materializer (CHT-MAT-1). +// digest at most once per Materializer. type Materializer struct { content ContentResolver results map[jsonstable.Digest]*model.ModelResult @@ -160,7 +160,7 @@ func (m *Materializer) output(ctx context.Context, r *ToolResult) (*run.Canonica // pairCalls zips the Run-assigned CallIDs with the result's ToolCalls. A // result issued without a ToolStepOpened in the entry (no CallIDs) derives -// them the way the protocol does (RUN-WIR-4 identity table). +// them the way the protocol does (identity table). func pairCalls(a *Assistant, result *model.ModelResult) ([]Call, error) { if len(result.ToolCalls) == 0 { return nil, nil diff --git a/agentcore/chatlog/projection.go b/agentcore/chatlog/projection.go index 98990fbd..4cb7ca47 100644 --- a/agentcore/chatlog/projection.go +++ b/agentcore/chatlog/projection.go @@ -55,7 +55,7 @@ const ( ) // CompactionView records one compaction for readers; compaction never touches -// EntryOrder or the input queue (CHT-SUR-1). +// EntryOrder or the input queue. type CompactionView struct { Compaction CompactionCreatedPayload `json:"compaction"` Status CompactionStatus `json:"status"` @@ -63,7 +63,7 @@ type CompactionView struct { Position ledger.Position `json:"position"` } -// Surface is the UI-facing read model (CHT-SUR-1). Every table is persistent +// Surface is the UI-facing read model. Every table is persistent // (Table): a fold shares them between states and pays O(sqrt(n)) per write. // Assistants and ToolResults are structural: they name frozen bodies by // digest and are rendered through Materialize. @@ -108,7 +108,7 @@ func sortViews(views []InputView) { // chatlogConsumes is what both projections fold: the module's own facts and // the Run facts that produce assistant and tool_result entries, and the -// attempt binding that names their Turn (CHT-SCP-1). +// attempt binding that names their Turn. var chatlogConsumes = func() []ledger.EventType { out := make([]ledger.EventType, 0, 9+len(consumedRunFacts)) out = append(out, TypeInputSubmitted, TypeInputDelivered, TypeInputWithdrawn, TypeInputRejected, @@ -123,7 +123,7 @@ var SurfaceProjection = module.ProjectionDefinition{ ID: SurfaceProjectionID, Version: 1, Consumes: chatlogConsumes, // Assistant and tool_result entries are projected from run facts - // (CHT-SCP-1); a fork inherits that conversation content (EXT-PRJ-8). + // a fork inherits that conversation content. Inherits: module.InheritAll, Authoritative: true, Initial: func() (any, error) { @@ -137,7 +137,7 @@ var SurfaceProjection = module.ProjectionDefinition{ // shared with the previous state and Set returns a new one, and EntryOrder // grows by append. Apply stays pure -- the previous state is never written. // Every position an entry carries is the ledger Position of the event that -// produced it (EXT-PRJ-1): the projection keeps no counter and a snapshot +// produced it: the projection keeps no counter and a snapshot // restores without rescanning. func applySurface(state any, e module.DecodedEvent) (any, error) { //nolint:gocritic // hugeParam: projection Apply is copy-on-write over value states; DecodedEvent is the extension API shape s, ok := state.(Surface) @@ -219,7 +219,7 @@ func applySurface(state any, e module.DecodedEvent) (any, error) { //nolint:gocr return s, nil } -// applyRun folds one Run fact into the Surface (CHT-ENT-1, CHT-ENT-2). The +// applyRun folds one Run fact into the Surface. The // Run's Turn is remembered from run_created until run_ended: no fact of the // Run follows its end, so the table is bounded by the Runs active now. func (s Surface) applyRun(ev sessionstore.Event, pos ledger.Position) (any, error) { //nolint:gocritic // hugeParam: projection Apply is copy-on-write over value states; DecodedEvent is the extension API shape @@ -296,8 +296,7 @@ func attachCalls(a *Assistant, f *run.ToolStepOpened) error { } // toolResultOf projects a call's terminal fact into a structural entry -// (TRN-MAP-3, TRN-MAP-4 semantics: Unknown outcome or effect_unknown class -// is status unknown, every other failure is error). +// (semantics: Unknown outcome or effect_unknown class is status unknown, every other failure is error). func toolResultOf(turnID TurnID, runID run.RunID, fact run.Fact) (ToolResult, error) { var r ToolResult switch f := fact.(type) { @@ -325,7 +324,7 @@ func toolResultOf(turnID TurnID, runID run.RunID, fact run.Fact) (ToolResult, er } // supersedingResult is the entry an out-of-band verification substitutes for -// old (CHT-ENT-2): same call, the verified status and body. +// old: same call, the verified status and body. func supersedingResult(old *ToolResult, p *ToolResultSupersededPayload) (ToolResult, error) { r := ToolResult{ID: SupersedingToolResultID(old.ID), TurnID: old.TurnID, RunID: old.RunID, CallID: old.CallID, Status: p.Status} if p.Status == ToolSuccess { @@ -369,9 +368,9 @@ func clip[T any](s []T) []T { return s[:len(s):len(s)] } // --- context ------------------------------------------------------------------ -// Entry is one element of the model-facing conversation (CHT-CTX-1). Position is +// Entry is one element of the model-facing conversation. Position is // the projection-internal position of the entry; compactions split base from -// gap by it (CHT-EVT-3). An assistant or tool_result entry is structural: it +// gap by it. An assistant or tool_result entry is structural: it // names its frozen body by digest and is rendered through Materialize. type Entry struct { Kind EntryKind `json:"kind"` @@ -390,7 +389,7 @@ func (e *Entry) Pair() EntryDigestPair { } // AppliedCompaction archives what a compaction replaced so an explicit -// invalidation restores it (CHT-EVT-3). Base excludes the compaction's own +// invalidation restores it. Base excludes the compaction's own // summary entry: invalidation drops the summary from the active context. type AppliedCompaction struct { ID CompactionID `json:"id"` @@ -467,7 +466,7 @@ func applyContext(state any, e module.DecodedEvent) (any, error) { //nolint:gocr if i < 0 { // A result outside the active context was either never created or // compacted; its Turn completed, so superseding it violates - // CHT-ENT-2 rather than invalidating the compaction. + // rather than invalidating the compaction. return nil, fmt.Errorf("tool_result %s superseded outside the active context", p.ToolResultID) } if _, twice := c.Superseded[p.ToolResultID]; twice { @@ -506,7 +505,7 @@ func applyContext(state any, e module.DecodedEvent) (any, error) { //nolint:gocr return c, nil } -// applyRun folds one Run fact into the Context (CHT-CTX-2). Runs is bounded +// applyRun folds one Run fact into the Context. Runs is bounded // like the Surface's: the Turn is forgotten at run_ended. func (c Context) applyRun(ev sessionstore.Event, pos ledger.Position) (any, error) { switch f := ev.Fact.(type) { @@ -555,7 +554,7 @@ func (c *Context) indexOf(kind EntryKind, id string) int { return -1 } -// applyCompaction validates and applies one compaction_created (CHT-EVT-3). +// applyCompaction validates and applies one compaction_created. // pos is the compaction's own position; CoveredThrough names an entry // position, so it must precede the compaction event. func applyCompaction(c Context, p *CompactionCreatedPayload, pos ledger.Position) (any, error) { @@ -614,7 +613,7 @@ func selectRetained(base []Entry, pairs []EntryDigestPair) ([]Entry, error) { return out, nil } -// ContextFold folds decoded chatlog and run events into entries (CHT-CTX-1). +// ContextFold folds decoded chatlog and run events into entries. func ContextFold(events []module.DecodedEvent) ([]Entry, error) { state, _ := ContextProjection.Initial() for i := range events { diff --git a/agentcore/chatlog/read.go b/agentcore/chatlog/read.go index 56f71e9c..f65d997e 100644 --- a/agentcore/chatlog/read.go +++ b/agentcore/chatlog/read.go @@ -37,7 +37,7 @@ func ReadContext(ctx context.Context, r session.ProjectionReader, sid session.Se // LastAssistantText is the text of the Turn's last assistant entry: the // Surface names the frozen ModelResult by digest and content resolves it -// (CHT-MAT-1). It is empty when the Turn has no assistant entry. +// It is empty when the Turn has no assistant entry. func LastAssistantText(ctx context.Context, r session.ProjectionReader, content ContentResolver, sid session.SessionID, turnID TurnID) (string, error) { surface, err := ReadSurface(ctx, r, sid) if err != nil { @@ -68,7 +68,7 @@ func (s *Surface) LastAssistantText(ctx context.Context, content ContentResolver } // NewInputID mints a collision-free InputID; the chatlog requires -// session-global uniqueness across restarts (CHT-EVT-1). +// session-global uniqueness across restarts. func NewInputID() run.InputID { return run.InputID("in-" + randomSuffix(8)) } func randomSuffix(n int) string { diff --git a/agentcore/chatlog/service.go b/agentcore/chatlog/service.go index ba0b9ef8..9f53aee6 100644 --- a/agentcore/chatlog/service.go +++ b/agentcore/chatlog/service.go @@ -16,7 +16,7 @@ import ( // Commands are the chatlog's canonical commands -- submitting and // withdrawing inputs, committing compactions -- each through the Writer the -// caller owns (CHT-EVT, OWN-HDL-2). They are the only writer of chatlog +// caller owns. They are the only writer of chatlog // facts: callers go through these methods instead of building chatlog // TypedEvents by hand. type Commands struct { @@ -25,17 +25,16 @@ type Commands struct { // Guard is a caller-supplied precondition evaluated inside a command's // commit critical section, on the same View the command reads. Compaction -// uses it for the "no Turn may be active" rule (APP-CKP-1), which is turn +// uses it for the "no Turn may be active" rule, which is turn // domain policy this package cannot import. type Guard func(v writer.View) error // ErrNotSubmitted reports a withdraw of an input that is not in the -// submitted state (CHT-EVT-2). +// submitted state. var ErrNotSubmitted = errors.New("chatlog: input is not submitted") -// Submit records one user input as submitted (CHT-EVT-1): content is the -// input's body, opaque to this module and digested as given (the agent -// decides its shape, DEC-INP-1). Idempotency rides on the CommitID, so a +// Submit records one user input as submitted: content is the +// input's body, opaque to this module and digested as given (the agent decides its shape). Idempotency rides on the CommitID, so a // retried submission replays. func (s *Commands) Submit(ctx context.Context, w writer.Writer, id run.InputID, content run.CanonicalJSON) (run.AgentInput, error) { if content.IsZero() { @@ -64,8 +63,8 @@ func (s *Commands) Submit(ctx context.Context, w writer.Writer, id run.InputID, } // Withdraw marks a submitted, undelivered input as withdrawn -// (CHT-EVT-2), for example the original input of a Turn the caller forked -// before in order to edit it (OWN-FRK-2). +// for example the original input of a Turn the caller forked +// before in order to edit it. func (s *Commands) Withdraw(ctx context.Context, w writer.Writer, id run.InputID, reason string) error { res, err := w.Commit(ctx, func(v writer.View) (*writer.SemanticGroup, error) { state, err := v.Projection(SurfaceProjectionID, SurfaceProjection.Version) @@ -97,7 +96,7 @@ func (s *Commands) Withdraw(ctx context.Context, w writer.Writer, id run.InputID } } -// Compaction commits a summary and its compaction in one group (CHT-EVT-3). +// Compaction commits a summary and its compaction in one group. // The base is read inside the commit's critical section, so the digest pins // exactly the context being replaced. guard (when non-nil) runs in the same // critical section first; callers pass the active-Turn rule there. @@ -175,7 +174,7 @@ func (s *Commands) Compact(ctx context.Context, w writer.Writer, summaryText str // compaction replaces: the Session, the base context digest and the summary // text. A Compaction retried over the same base therefore carries the same // CommitID and is answered as already applied instead of writing a second -// compaction (APP-CKP-1). +// compaction. // compactionDerivationVersion versions this preimage; it is chatlog's own, // not a kernel version: the kernel has none. const compactionDerivationVersion uint16 = 1 @@ -204,7 +203,7 @@ func compactionIDs(sid session.SessionID, base jsonstable.Digest, summaryText st // CheckRetainClosure requires retained tool results and their issuing // assistants to travel together, and an assistant with a call whose result // is not yet in the context to be retained, so the compacted context stays -// valid provider input now and when that result lands (APP-CKP-2). Subset +// valid provider input now and when that result lands. Subset // and order are the fold's job. func CheckRetainClosure(entries []Entry, retain []EntryDigestPair) error { kept := make(map[EntryDigestPair]bool, len(retain)) @@ -257,7 +256,7 @@ func CheckRetainClosure(entries []Entry, retain []EntryDigestPair) error { } // DeliverInputs is the chatlog's Part of a Turn's start or delivery unit -// (CHT-EVT, TRN-DLV-1): it checks, on the unit's own View, that every input +// it checks, on the unit's own View, that every input // is a submitted chatlog Input whose content digest is the one the Run // accepts, and writes one input_delivered per input. The body stays here; // the Run carries only the digest. A failed check is diff --git a/agentcore/chatlog/service_internal_test.go b/agentcore/chatlog/service_internal_test.go index 4de1e021..633f8be8 100644 --- a/agentcore/chatlog/service_internal_test.go +++ b/agentcore/chatlog/service_internal_test.go @@ -8,7 +8,7 @@ import ( "github.com/felinics/twilight/agentcore/session" ) -// APP-CKP-1: compaction identifiers are a function of the Session, the base +// compaction identifiers are a function of the Session, the base // context digest and the summary text, so a Compaction retried over the same // base replays the same CommitID. func TestCompactionIDsAreDerived(t *testing.T) { diff --git a/agentcore/chatlog/service_test.go b/agentcore/chatlog/service_test.go index 9cfee434..41553454 100644 --- a/agentcore/chatlog/service_test.go +++ b/agentcore/chatlog/service_test.go @@ -22,7 +22,7 @@ func pairEntries() []chatlog.Entry { } // CheckRetainClosure rejects a retained set that splits a tool pair in either -// direction and accepts closed sets (APP-CKP-2). +// direction and accepts closed sets. func TestCheckRetainClosure(t *testing.T) { entries := pairEntries() pair := func(i int) chatlog.EntryDigestPair { return entries[i].Pair() } @@ -46,7 +46,7 @@ func TestCheckRetainClosure(t *testing.T) { }) } // An assistant whose call has no result yet must be retained, whatever - // else is (APP-CKP-2): the result will land after the compaction. + // else is the result will land after the compaction. open := openEntries() openPair := func(i int) chatlog.EntryDigestPair { return open[i].Pair() } openCases := []struct { diff --git a/agentcore/chatlog/table.go b/agentcore/chatlog/table.go index 3841c4eb..bf6b4991 100644 --- a/agentcore/chatlog/table.go +++ b/agentcore/chatlog/table.go @@ -6,7 +6,7 @@ import ( // Table is the persistent map of the Surface projection. A state derived by // Apply shares the previous state's storage and never writes it, so every -// state a reader holds keeps the contents it had (EXT-PRJ-1); Set returns a new +// state a reader holds keeps the contents it had; Set returns a new // Table and leaves the receiver untouched. // Storage is two layers that are both immutable once built: base carries the // bulk and is shared by every state derived from the one that merged it; diff --git a/agentcore/chatlog/table_test.go b/agentcore/chatlog/table_test.go index 591ae2c6..5d259d68 100644 --- a/agentcore/chatlog/table_test.go +++ b/agentcore/chatlog/table_test.go @@ -70,7 +70,7 @@ func TestTablePersistence(t *testing.T) { // TestSurfaceFoldIsPure folds two different continuations from one state: the // old state keeps its contents and the two results do not see each other -// (EXT-PRJ-1). Each event carries its own ledger Position, as a fold would +// Each event carries its own ledger Position, as a fold would // stamp it. func TestSurfaceFoldIsPure(t *testing.T) { var seq ledger.CommitSeq diff --git a/agentcore/driver/recovery.go b/agentcore/driver/recovery.go index 86b79c93..0926783b 100644 --- a/agentcore/driver/recovery.go +++ b/agentcore/driver/recovery.go @@ -48,6 +48,11 @@ type Recovery struct { // MaxRedispatches bounds redispatches per effect; zero selects the // reconciler's default. MaxRedispatches int + // RedispatchRetry is the interval between reconciliations after the + // executor refused a redispatch before the effect boundary. Unknown + // boundaries are never automatically redispatched. Zero selects + // DefaultRedispatchRetry. + RedispatchRetry time.Duration // OrphanProbe is how often an effect still waiting is attached and, when // orphaned, handed to RecoverExecution by the Reconciler of a takeover; // zero selects its default. @@ -68,8 +73,15 @@ type lifetime struct { ctx context.Context cancel context.CancelFunc w writer.Writer + + retryMu sync.Mutex + retries map[effect.AssignmentKey]struct{} } +// DefaultRedispatchRetry bounds how long a live owner leaves an effect at a +// temporary redispatch boundary before reconciling it again. +const DefaultRedispatchRetry = time.Second + func (r *Recovery) fail(sid session.SessionID, err error) { if r.Fail != nil { r.Fail(sid, err) @@ -124,20 +136,92 @@ func (r *Recovery) Open(ctx context.Context, w writer.Writer) (int, error) { // durable record, not a duplicate dispatch, decides the settlement. func (r *Recovery) Recover(ctx context.Context, w writer.Writer) (int, error) { lt := r.lifetimeOf(w) - sid := w.SessionID() + return r.Runs.RecoverInterrupted(ctx, w, r.reconciler(lt)) +} + +// reconciler builds one pass over lt. A transient redispatch asks the +// lifetime's deduplicated interval worker to revisit only that effect. +func (r *Recovery) reconciler(lt *lifetime) *reconcile.Reconciler { + sid := lt.w.SessionID() rec := &reconcile.Reconciler{Executions: r.Executor, Lifetime: lt.ctx, Watcher: r.Watcher, Deliver: r.reattachDeliver(lt), Fail: func(key effect.AssignmentKey, err error) { - r.fail(sid, fmt.Errorf("driver: outcome of run %s effect %s cannot be read; the target stays executing until the next takeover: %w", key.RunID, key.Effect, err)) + r.fail(sid, fmt.Errorf("driver: outcome of run %s effect %s cannot be read; the target stays executing until reconciliation: %w", key.RunID, key.Effect, err)) }} rec.Missing = r.MissingEffects rec.OrphanProbe = r.OrphanProbe if r.MissingEffects == reconcile.RedispatchMissing { // Missing effects are handed to the Executor again within the // budget; the dispatch ledger remembers the attempts. - rec.Attempts, rec.Epoch, rec.MaxRedispatches = r.Redispatches, w.Epoch(), r.MaxRedispatches + rec.Attempts, rec.Epoch, rec.MaxRedispatches = r.Redispatches, lt.w.Epoch(), r.MaxRedispatches rec.Redispatch = func(ctx context.Context, key effect.AssignmentKey) error { return r.redispatch(ctx, lt.w, key) } + rec.Requeue = func(key effect.AssignmentKey) { r.requeue(lt, key) } } - return r.Runs.RecoverInterrupted(ctx, w, rec) + return rec +} + +// requeue starts at most one cancellable reconciliation worker for a safely +// retryable refusal in this Session lifetime. Every pass reloads the durable +// Run snapshot and redispatch ledger. It stops when the effect is gone, is +// disposed, reaches an accepted execution, or the Session lifetime closes. +func (r *Recovery) requeue(lt *lifetime, key effect.AssignmentKey) { + lt.retryMu.Lock() + if lt.ctx.Err() != nil { + lt.retryMu.Unlock() + return + } + if lt.retries == nil { + lt.retries = make(map[effect.AssignmentKey]struct{}) + } + if _, exists := lt.retries[key]; exists { + lt.retryMu.Unlock() + return + } + lt.retries[key] = struct{}{} + lt.retryMu.Unlock() + + go func() { + defer func() { + lt.retryMu.Lock() + delete(lt.retries, key) + lt.retryMu.Unlock() + }() + interval := r.RedispatchRetry + if interval <= 0 { + interval = DefaultRedispatchRetry + } + timer := time.NewTimer(interval) + defer timer.Stop() + for { + select { + case <-lt.ctx.Done(): + return + case <-timer.C: + } + st := r.Runs.Bind(lt.w) + snapshot, err := st.Load(lt.ctx, key.RunID) + if err != nil { + if lt.ctx.Err() != nil { + return + } + r.fail(lt.w.SessionID(), fmt.Errorf("driver: reload run %s for redispatch reconciliation: %w", key.RunID, err)) + timer.Reset(interval) + continue + } + decision, ok, err := r.reconciler(lt).ReconcileEffect(lt.ctx, st, &snapshot, key) + if err != nil { + if lt.ctx.Err() != nil { + return + } + r.fail(lt.w.SessionID(), fmt.Errorf("driver: reconcile redispatch of run %s effect %s: %w", key.RunID, key.Effect, err)) + timer.Reset(interval) + continue + } + if !ok || !decision.Retry { + return + } + timer.Reset(interval) + } + }() } // redispatch is the reconciler's Redispatch port: the Assignment of an diff --git a/agentcore/executor/backend.go b/agentcore/executor/backend.go index 8c411e32..b2debdc9 100644 --- a/agentcore/executor/backend.go +++ b/agentcore/executor/backend.go @@ -13,12 +13,12 @@ import ( // ExecutionRef is the Executor's physical binding of the current attempt for // one effect: the provider (backend) it was handed to and that backend's -// opaque handle (RUN-EXE-9). It lives in the Execution Record and in the +// opaque handle. It lives in the Execution Record and in the // Backend contract; Agent Core addresses executions by AssignmentKey only. type ExecutionRef = executionstore.ExecutionRef // ErrUnknownProvider reports a record whose ExecutionRef names a provider -// this Worker has no Backend for (RUN-EXE-10). It is a definitive answer for +// this Worker has no Backend. It is a definitive answer for // Outcome reads (effect.ErrOutcomeUnavailable): this process cannot reach the // execution and retrying will not change that. var ErrUnknownProvider = fmt.Errorf("executor: unknown execution provider: %w", effect.ErrOutcomeUnavailable) @@ -29,11 +29,11 @@ var ErrUnknownProvider = fmt.Errorf("executor: unknown execution provider: %w", // AssignmentKey. type ExecutionBackend interface { // Validate checks that this Backend can serve the Assignment; it produces - // no external effect (RUN-EXE-5). + // no external effect. Validate(ctx context.Context, a effect.Assignment) (*run.ToolFailure, error) // Prepare derives the Ref of the Assignment without starting the effect // and without allocating anything outside the Backend: it runs before - // the acceptance is written, and an Abort may win that write (RUN-EXE-16), + // the acceptance is written, and an Abort may win that write, // in which case the Ref is simply never used. It is a pure function of // the AssignmentKey (a repeated Prepare returns the same Ref), so a // process that dies between Prepare and the record write recovers the @@ -47,17 +47,17 @@ type ExecutionBackend interface { Start(ctx context.Context, ref string, a effect.Assignment) error // Restart allocates the Ref of a new physical execution of the same // Assignment after the Backend reported the previous one missing: the - // next attempt for the effect (RUN-EXE-9). Unlike Prepare it is not + // next attempt for the effect. Unlike Prepare it is not // required to return the same Ref; a Backend whose physical execution is // the durable object itself (a child Session) returns the same Ref. // Whether a tool may be restarted at all is decided before the call, - // by the Worker, from the Assignment's Replay policy (RUN-EXE-9). + // by the Worker, from the Assignment's Replay policy. Restart(ctx context.Context, previous string, a effect.Assignment) (ref string, err error) // Attach reports what the Backend finds for Ref. Missing is a proof: the // execution Ref names does not exist and will not start, so the Worker // may replay or settle it. A Backend that cannot confirm either way // answers orphaned, never missing; the Worker then keeps the lease and - // asks again (RUN-EXE-3). Active and terminal are observations of a + // asks again. Active and terminal are observations of a // live or finished execution. A Ref the Backend cannot interpret is an // error, not an observation. Attach(ctx context.Context, ref string) (effect.Attachment, error) @@ -66,14 +66,14 @@ type ExecutionBackend interface { // effect.ErrOutcomeNotReady while it has none, effect.ErrExecutionNotFound // for a Ref the Backend does not know. It never blocks on the execution; // a Backend that can say when to read implements notice.Source, and the - // Worker waits on that stream (RUN-EXE-17). + // Worker waits on that stream. Outcome(ctx context.Context, ref string) (effect.Outcome, error) Cancel(ctx context.Context, ref string) error } // Route hands the Assignments Match accepts to one provider's Backend. The // Worker evaluates routes once, at Dispatch, and persists the chosen -// provider in the record (RUN-EXE-10); a nil Match accepts every Assignment. +// provider in the record; a nil Match accepts every Assignment. type Route struct { Provider string Match func(effect.Assignment) bool @@ -88,7 +88,7 @@ func Default(provider string, b ExecutionBackend) Route { return Route{Provider: func MatchModel(a effect.Assignment) bool { _, ok := a.Model(); return ok } //nolint:gocritic // hugeParam: Route.Match takes the Assignment by value // MatchTool is the Route predicate of tool calls declared with placement -// (RUN-LOP-9): a Worker routes by the declaration, never by whether a target +// a Worker routes by the declaration, never by whether a target // is present. func MatchTool(placement run.ToolPlacement) func(effect.Assignment) bool { return func(a effect.Assignment) bool { //nolint:gocritic // hugeParam: Route.Match takes the Assignment by value @@ -111,7 +111,7 @@ func (b portBackend) Validate(ctx context.Context, a effect.Assignment) (*run.To } // Progress relays the frames of the port behind this backend when it offers -// them (RUN-EXE-12); a port without progress ends the stream at once. +// them; a port without progress ends the stream at once. func (b portBackend) Progress(ctx context.Context, key effect.AssignmentKey, after uint64, fn func(effect.ProgressFrame) bool) error { if p, ok := b.port.(effect.ProgressPort); ok { return p.Progress(ctx, key, after, fn) @@ -173,7 +173,7 @@ func (b portBackend) Outcome(ctx context.Context, ref string) (effect.Outcome, e // Settled relays the port's settlement stream as Ref notices, the Ref being // the encoded key Prepare derived, so a Worker in front of a remote executor -// waits on the remote's notices instead of polling it (RUN-EXE-17). A port +// waits on the remote's notices instead of polling it. A port // without SettlementPort has no stream: Settled returns at once and the // Worker falls back to reading at intervals. func (b portBackend) Settled(ctx context.Context, epoch string, after uint64, fn func(notice.Ref) bool) error { diff --git a/agentcore/executor/executor_test.go b/agentcore/executor/executor_test.go index 74965b9c..a12baa8f 100644 --- a/agentcore/executor/executor_test.go +++ b/agentcore/executor/executor_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/executor" "github.com/felinics/twilight/agentcore/executor/protocol" "github.com/felinics/twilight/agentcore/executor/store" @@ -17,7 +18,6 @@ import ( "github.com/felinics/twilight/agentcore/run/effect" "github.com/felinics/twilight/agentcore/run/model" "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" ) @@ -225,7 +225,7 @@ func TestWorkerIdempotentAndOutcome(t *testing.T) { // A replay of the same Assignment against an execution that has started // leaves it to its lease holder, live or expired: recovery of a started // execution is RecoverExecution's, asked for by the Owner that observed it -// orphaned (RUN-EXE-3). +// orphaned. func TestWorkerDispatchReplayPreservesExistingExecution(t *testing.T) { for _, state := range []effect.ExecutionStatus{effect.ExecutionDispatching, effect.ExecutionRunning, effect.ExecutionCancelRequested} { t.Run(string(state), func(t *testing.T) { @@ -261,7 +261,7 @@ func TestWorkerDispatchReplayPreservesExistingExecution(t *testing.T) { // A replay of the same Assignment against an acceptance that never started // and that no live lease holds continues it: the Dispatch that wrote the // acceptance failed before Start, and the replay is that request again -// (RUN-EXE-3). Under a live lease the replay starts nothing; the holder is +// Under a live lease the replay starts nothing; the holder is // about to. func TestWorkerDispatchReplayContinuesUnstartedAcceptance(t *testing.T) { for _, tc := range []struct { @@ -415,7 +415,7 @@ func TestWorkerCloseStopsGoroutines(t *testing.T) { // A record whose ExecutionRef names a provider this Worker has no Backend // for is left untouched: RecoverExecution and GetStatus report ErrUnknownProvider and -// no backend is called (RUN-EXE-10). +// no backend is called. func TestWorkerRecoverExecutionRefusesUnknownProvider(t *testing.T) { ctx := context.Background() records := storetest.NewMap(nil) @@ -448,7 +448,7 @@ func TestWorkerRecoverExecutionRefusesUnknownProvider(t *testing.T) { } // Dispatch selects the Backend once, prepares the Ref and persists it with the -// record before Start; Start receives the persisted Ref (RUN-EXE-9/10). +// record before Start; Start receives the persisted Ref. func TestWorkerPersistsExecutionRefBeforeStart(t *testing.T) { ctx := context.Background() records := storetest.NewMap(nil) @@ -673,7 +673,7 @@ func TestPortBackendAttachRejectsForeignRef(t *testing.T) { // Attach classifies by the record's lease in the shared store, not by which // Worker answers: a live lease held by another incarnation is active, an -// expired one orphaned (RUN-EXE-3). +// expired one orphaned. func TestWorkerAttachClassifiesByLease(t *testing.T) { ctx := context.Background() now := time.Unix(2_000_000, 0) @@ -718,7 +718,7 @@ func (failingCreateStore) Append(context.Context, store.Lease, effect.Assignment // A Dispatch the Worker cannot record is a retryable refusal; a definite // rejection of the Assignment is a plain error; neither is an unknown -// outcome (RUN-EXE-3). The HTTP binding's status mapping is tested with it. +// outcome. The HTTP binding's status mapping is tested with it. func TestDispatchRefusalClassification(t *testing.T) { ctx := context.Background() cases := []struct { @@ -750,7 +750,6 @@ func TestDispatchRefusalClassification(t *testing.T) { // still answers Attach with terminal, GetOutcome with collected, and a // Dispatch of the same key starts nothing. An unacknowledged record keeps // its Outcome readable, and an execution in flight cannot be acknowledged -// (RUN-EXE-13). func TestWorkerAcknowledgeCollects(t *testing.T) { ctx := context.Background() now := time.Unix(3_000_000, 0) @@ -827,7 +826,7 @@ func TestWorkerAcknowledgeCollects(t *testing.T) { } } -// RUN-EXE-16: Abort and the acceptance a Dispatch writes contend for the +// Abort and the acceptance a Dispatch writes contend for the // first commit of a key's ledger; exactly one stands. A key aborted first // rejects every later Dispatch and answers aborted everywhere; a key // dispatched first is reported live by Abort and left alone. @@ -891,7 +890,7 @@ func TestAbortAndDispatchAreMutuallyExclusive(t *testing.T) { // GetOutcome is a read: before settlement it answers ErrOutcomeNotReady at // once. The Worker's settlement stream carries the notice of every key it // settles under its hub's epoch, a subscriber that reconnects with its last -// sequence gets what it missed, and Close ends the stream (RUN-EXE-17). +// sequence gets what it missed, and Close ends the stream. func TestWorkerGetOutcomeIsAReadAndSettlementsNotify(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/agentcore/executor/notice/notice.go b/agentcore/executor/notice/notice.go index 9b5ae4e7..4cd8c844 100644 --- a/agentcore/executor/notice/notice.go +++ b/agentcore/executor/notice/notice.go @@ -1,5 +1,5 @@ // Package notice is the one bounded notice log every settlement source in -// the executor tier keeps (RUN-EXE-17): a Worker announcing that an +// the executor tier keeps: a Worker announcing that an // AssignmentKey settled, a Backend announcing that a Ref settled. A notice // says when to read; the read says what. The log is per incarnation // (Epoch), ordered by Sequence, bounded by a window, and a subscriber that @@ -146,7 +146,7 @@ func (r *Ring[T]) SubscribeAnnounced(ctx context.Context, epoch string, after ui } // Ref is a Backend's notice that the execution behind Ref reached its -// Outcome, readable through ExecutionBackend.Outcome (RUN-EXE-9). It is the +// Outcome, readable through ExecutionBackend.Outcome. It is the // Backend-tier twin of effect.Settlement, keyed by the Backend's Ref rather // than the Worker's AssignmentKey. type Ref struct { diff --git a/agentcore/executor/notices.go b/agentcore/executor/notices.go index 456c51a2..b47d6272 100644 --- a/agentcore/executor/notices.go +++ b/agentcore/executor/notices.go @@ -12,7 +12,7 @@ import ( // backendNotices is the Worker's subscriber to its Backends' settled Refs // (notice.Source): one stream per Backend for every execution the Worker // observes on it, the way effect.Watcher keeps one stream per executor for -// the Owner (RUN-EXE-17). A waiter registers a Ref and is woken when the +// the Owner. A waiter registers a Ref and is woken when the // Backend announces it; a stream that drops or changes epoch wakes every // waiter so each re-reads, which is what the notice protocol asks of a // subscriber that lost track. diff --git a/agentcore/executor/progress.go b/agentcore/executor/progress.go index 7e7ee025..b4b42e88 100644 --- a/agentcore/executor/progress.go +++ b/agentcore/executor/progress.go @@ -11,7 +11,7 @@ import ( // WorkerOptions.Progress is built with a zero window. const DefaultProgressWindow = 256 -// ProgressHub is the Worker's in-memory progress buffer (RUN-EXE-12): the +// ProgressHub is the Worker's in-memory progress buffer: the // effect.ProgressSink its backends publish into and the effect.ProgressPort // the Owner reads from. Each key keeps a bounded ring of frames stamped // with the key's generation and a running sequence; a Reset opens the next diff --git a/agentcore/executor/progress_test.go b/agentcore/executor/progress_test.go index 3e600c60..9d9b0418 100644 --- a/agentcore/executor/progress_test.go +++ b/agentcore/executor/progress_test.go @@ -10,7 +10,7 @@ import ( "github.com/felinics/twilight/agentcore/run/effect" ) -// RUN-EXE-12: the hub stamps generation and sequence, replays after a +// the hub stamps generation and sequence, replays after a // sequence, voids a generation with a reset, closes with an end, and bounds // what it keeps. func TestProgressHub(t *testing.T) { diff --git a/agentcore/executor/settlement.go b/agentcore/executor/settlement.go index 1cb0369b..e3a44e8c 100644 --- a/agentcore/executor/settlement.go +++ b/agentcore/executor/settlement.go @@ -11,7 +11,7 @@ import ( // late subscribers. const DefaultSettlementWindow = notice.DefaultWindow -// SettlementHub is the Worker's settlement notice log (RUN-EXE-17): the +// SettlementHub is the Worker's settlement notice log: the // notice.Ring of effect.Settlement, the SettlementPort the Worker and the // HTTP Server expose. One hub per Worker incarnation; every settlement the // Worker records, whoever dispatched it, goes through it. @@ -46,7 +46,7 @@ func (h *SettlementHub) Close() { // Settlements implements effect.SettlementPort: an announcement of the // hub's epoch and head first (a Settlement with a zero Key), then every -// settled key (RUN-EXE-17). +// settled key. func (h *SettlementHub) Settlements(ctx context.Context, epoch string, after uint64, fn func(effect.Settlement) bool) error { return h.ring.SubscribeAnnounced(ctx, epoch, after, func(e string, head uint64) effect.Settlement { return effect.Settlement{Epoch: e, Sequence: head} diff --git a/agentcore/executor/store/seed.go b/agentcore/executor/store/seed.go index db6ef9e4..ed40ac4e 100644 --- a/agentcore/executor/store/seed.go +++ b/agentcore/executor/store/seed.go @@ -30,7 +30,6 @@ func SeedCommits(state Execution, at int64) ([]ledger.Commit, error) { //nolint: case effect.ExecutionAborted: // A tombstone is the whole ledger of an aborted key: the abort stood // at Seq 0, so no acceptance, binding or lease ever existed - // (RUN-EXE-16). if err := add(EventExecutionAborted, Aborted{Reason: "seeded"}); err != nil { return nil, err } diff --git a/agentcore/executor/store/store.go b/agentcore/executor/store/store.go index 94db3a44..e9467536 100644 --- a/agentcore/executor/store/store.go +++ b/agentcore/executor/store/store.go @@ -1,5 +1,5 @@ // Package store is the Executor's authority over one execution: the commit -// ledger of an effect's attempts (RUN-EXE-3, RUN-EXE-9) and the lease that +// ledger of an effect's attempts and the lease that // fences its writers. It speaks the shared commit vocabulary // (agentcore/ledger), so what is true of any commit ledger is true here: // the ledger is the only fact authority, ExecutionState is its fold, a @@ -22,7 +22,7 @@ import ( var ( // ErrAssignmentConflict: the ledger was accepted for a different - // Assignment of the same key (RUN-EXE-3). + // Assignment of the same key. ErrAssignmentConflict = errors.New("executor/store: assignment conflict") // ErrLeaseLost: the lease the commit was made under is no longer the // key's lease — another holder has a later Epoch, or it expired. @@ -35,16 +35,16 @@ var ( const ( // EventExecutionAccepted: the Assignment was accepted into this ledger, - // before anything started (RUN-EXE-3). The first event of a ledger that + // before anything started. The first event of a ledger that // was opened by a Dispatch. EventExecutionAccepted ledger.EventType = "execution_accepted" // EventExecutionAborted: the key was closed before any acceptance - // (RUN-EXE-16). The first and only event of a ledger opened by Abort: + // The first and only event of a ledger opened by Abort: // it and execution_accepted contend for Seq 0, so a ledger holds one or // the other, never both. EventExecutionAborted ledger.EventType = "execution_aborted" // EventExecutionBound: the attempt's physical binding, Provider and Ref, - // was chosen (RUN-EXE-9, RUN-EXE-10). + // was chosen. EventExecutionBound ledger.EventType = "execution_bound" // EventExecutionClaimed: a Worker took the key under a new Epoch. Lease // renewals are not events; the lease row is the fence's authority. @@ -57,16 +57,16 @@ const ( // EventCancelRequested: cancellation was asked of the backend. EventCancelRequested ledger.EventType = "cancel_requested" // EventExecutionRestarted: the attempt moved to a new Ref, the previous - // one joining the audit trail (RUN-EXE-9, RUN-EXE-11). + // one joining the audit trail. EventExecutionRestarted ledger.EventType = "execution_restarted" // EventExecutionSettled: the terminal state and the Outcome. EventExecutionSettled ledger.EventType = "execution_settled" // EventOutcomeAcknowledged: the Owner reported the Outcome settled as a - // Session fact; the Executor no longer serves it (RUN-EXE-13). + // Session fact; the Executor no longer serves it. EventOutcomeAcknowledged ledger.EventType = "outcome_acknowledged" ) -// Lease is a fenced hold on one execution (RUN-EXE-6). The store is its +// Lease is a fenced hold on one execution. The store is its // authority: Acquire creates it under a new Epoch, Renew extends it, and // Append under it is refused once the key has a later Epoch. type Lease struct { @@ -80,7 +80,7 @@ type Lease struct { func (l Lease) IsZero() bool { return l.Owner == "" && l.Epoch == 0 } // ExecutionRef is the physical binding of the current attempt made for one -// effect (RUN-EXE-9): the provider (backend) the execution was handed to and +// effect: the provider (backend) the execution was handed to and // that provider's opaque handle. It never leaves the execution domain: // executions are addressed by AssignmentKey, and the Run records the // EffectID alone. @@ -152,7 +152,7 @@ func AcceptCommitID(key effect.AssignmentKey) ledger.CommitID { return DeriveCommitID(key, "accept", "") } -// AbortCommitID names the one tombstone of a key (RUN-EXE-16). It differs +// AbortCommitID names the one tombstone of a key. It differs // from AcceptCommitID, so the two commands are told apart by Seq, not by // replay: whichever reaches Seq 0 first stands and the other is ErrConflict. func AbortCommitID(key effect.AssignmentKey) ledger.CommitID { return DeriveCommitID(key, "abort", "") } @@ -172,12 +172,12 @@ type ExecutionState struct { Assignment effect.Assignment `json:"assignment"` ExecutionRef ExecutionRef `json:"executionRef"` // Superseded lists the ExecutionRefs of the earlier attempts made for - // this effect, oldest first (RUN-EXE-9). + // this effect, oldest first. Superseded []ExecutionRef `json:"superseded,omitempty"` State effect.ExecutionStatus `json:"state"` Outcome *protocol.OutcomeEnvelope `json:"outcome,omitempty"` // Acknowledged records that the Owner settled the Outcome into its - // Session (RUN-EXE-13): the Outcome is no longer served and the record + // Session: the Outcome is no longer served and the record // may be reclaimed by retention. Acknowledged bool `json:"acknowledged,omitempty"` } @@ -198,7 +198,7 @@ type Execution struct { Lease Lease } -// LegalTransition is the execution state machine (RUN-EXE-3). +// LegalTransition is the execution state machine. func LegalTransition(from, to effect.ExecutionStatus) bool { if to == effect.ExecutionCancelRequested { return from == effect.ExecutionAccepted || from == effect.ExecutionDispatching || from == effect.ExecutionRunning @@ -210,7 +210,7 @@ func LegalTransition(from, to effect.ExecutionStatus) bool { return to == effect.ExecutionRunning case effect.ExecutionRunning: // A restart after the backend proved the execution missing, or after - // a retryable failure (RUN-EXE-9, RUN-EXE-11). + // a retryable failure. return to == effect.ExecutionDispatching default: return false @@ -220,7 +220,7 @@ func LegalTransition(from, to effect.ExecutionStatus) bool { // Fenced reports whether an event may only be committed under the key's // lease. Acceptance and the abort tombstone precede any lease; settlement // by Dispose and the Owner's acknowledgement come from outside the lease -// (RUN-EXE-6, RUN-EXE-13, RUN-EXE-16). Everything else is the lease +// Everything else is the lease // holder's. func Fenced(typ ledger.EventType) bool { switch typ { @@ -344,13 +344,20 @@ func apply(s ExecutionState, e *ledger.Event) (ExecutionState, error) { //nolint return s, nil } +// SettlementFeed is the durable notification capability of a shared execution +// store. Unlike the optional in-process notice hub, its cursor is shared by +// every Worker replica and survives a Worker restart. +type SettlementFeed interface { + SettlementEvents(context.Context, string, uint64, func(effect.Settlement) bool) error +} + // Store is the ledger and lease authority of executions, keyed by // AssignmentKey. Every write is one transaction; the store is the fence for -// any number of Workers over the same file (RUN-EXE-6). +// any number of Workers over the same file. type Store interface { // Load folds the key's ledger and reads its lease row in one // transaction; ok is false for a key with no ledger, which is a proven - // absence (RUN-EXE-3). + // absence. Load(context.Context, effect.AssignmentKey) (Execution, ledger.Head, bool, error) // Read returns the key's commits from Seq from, in order, and the Head. Read(context.Context, effect.AssignmentKey, ledger.CommitSeq) ([]ledger.Commit, ledger.Head, error) @@ -377,6 +384,6 @@ type Store interface { LeaseOf(context.Context, effect.AssignmentKey) (Lease, bool, error) // ListOwned returns the keys whose lease row names owner: what a // restarted incarnation resumes. No other listing is part of the data - // plane (RUN-EXE-6). + // plane. ListOwned(context.Context, string) ([]effect.AssignmentKey, error) } diff --git a/agentcore/executor/store/storetest/conformance.go b/agentcore/executor/store/storetest/conformance.go index 36d64a7c..4568d53a 100644 --- a/agentcore/executor/store/storetest/conformance.go +++ b/agentcore/executor/store/storetest/conformance.go @@ -67,7 +67,7 @@ func step(s executionstore.Store, lease executionstore.Lease, seq ledger.CommitS // One execution ledger, two handles: the second handle stands for a second // process. Commits appended through one are read through the other, leases // fence across them, and the state machine, identity and lease checks -// answer with the store's sentinel errors (RUN-EXE-3, RUN-EXE-6). +// answer with the store's sentinel errors. func testLedger(t *testing.T, f Fixture) { //nolint:gocyclo // one scenario, checked step by step ctx := context.Background() a := f.Store @@ -205,7 +205,7 @@ func testLedger(t *testing.T, f Fixture) { //nolint:gocyclo // one scenario, che } } -// A seeded aborted key has the ledger a real Abort leaves (RUN-EXE-16): the +// A seeded aborted key has the ledger a real Abort leaves: the // tombstone alone at Seq 0 under the abort identity, so a Dispatch's // acceptance is already applied against it and no lease exists. A seeded // live key carries the lease it was given. diff --git a/agentcore/executor/store/storetest/map.go b/agentcore/executor/store/storetest/map.go index 60e842d6..e7ab2b92 100644 --- a/agentcore/executor/store/storetest/map.go +++ b/agentcore/executor/store/storetest/map.go @@ -20,7 +20,7 @@ import ( // Map is executionstore.Store over Go maps. The zero value is ready; Now is // the lease clock, nil selecting time.Now. Every method holds one mutex for // its whole read-fold-judge-write, which is the transaction the contract -// requires (RUN-EXE-6). +// requires. type Map struct { Now func() time.Time @@ -120,8 +120,7 @@ func (m *Map) Read(_ context.Context, key effect.AssignmentKey, from ledger.Comm if from >= ledger.CommitSeq(len(l.commits)) { return nil, l.head(), nil } - out := make([]ledger.Commit, uint64(len(l.commits))-uint64(from)) - copy(out, l.commits[from:]) + out := append([]ledger.Commit(nil), l.commits[from:]...) return out, l.head(), nil } diff --git a/agentcore/executor/supervise.go b/agentcore/executor/supervise.go index 3af3ec97..4698a668 100644 --- a/agentcore/executor/supervise.go +++ b/agentcore/executor/supervise.go @@ -23,10 +23,10 @@ import ( // from outside the package. // acquireAndStart takes the key's lease and continues the execution from its -// fold (RUN-EXE-3, RUN-EXE-9): an execution with an attachable backend +// fold: an execution with an attachable backend // execution is observed, one whose execution the backend proves missing is // re-started (a model, or a tool whose Replay policy allows it) or settled -// Unknown (any other tool, TRN-DUR-4), one whose execution the backend +// Unknown (any other tool), one whose execution the backend // cannot confirm (orphaned) is held under the lease and asked about again // until the backend can, and one never started is started. func (w *Worker) acquireAndStart(ctx context.Context, key effect.AssignmentKey) error { @@ -46,7 +46,7 @@ func (w *Worker) acquireAndStart(ctx context.Context, key effect.AssignmentKey) } // A ledger opened without its binding (an older store, a test fixture) // is routed and prepared here, before any backend call, and the Ref is - // committed first (RUN-EXE-9). + // committed first. if claimed.ExecutionRef.Provider == "" && claimed.State != effect.ExecutionCancelRequested { route, err := w.route(claimed.Assignment) if err != nil { @@ -125,7 +125,7 @@ func (w *Worker) acquireAndStart(ctx context.Context, key effect.AssignmentKey) // heartbeat leaseDone ends, until the backend observes the execution (then // it is watched), proves it missing (then it is replayed or settled as a // missing one would be), or this incarnation loses the lease. Nothing is -// re-dispatched while the backend is undecided (RUN-EXE-3, TRN-DUR-4). +// re-dispatched while the backend is undecided. func (w *Worker) awaitBackend(lease executionstore.Lease, claimed *executionstore.Execution, backend ExecutionBackend, ref string, leaseDone chan struct{}) { delay := 10 * time.Millisecond for { @@ -157,13 +157,13 @@ func (w *Worker) awaitBackend(lease executionstore.Lease, claimed *executionstor // replay is what a takeover does with an execution the backend proved // missing. A model is always replayed; a tool only when the Replay policy // its Assignment carries allows it, because its lost execution may have -// crossed the effect boundary before its worker died (TRN-DUR-4). The policy +// crossed the effect boundary before its worker died. The policy // travels with the Assignment, so a local and a remote Worker decide alike // from the ledger; a forbidden or unjudged tool settles Unknown, the message // naming the declaration. Restart replays the same Assignment as a new // generation: it allocates the Ref of the new physical execution and the old // Ref, just proved missing, moves to the audit trail as execution_restarted -// (RUN-EXE-9). leaseDone ends the running heartbeat when nothing is left to +// leaseDone ends the running heartbeat when nothing is left to // watch. func (w *Worker) replay(ctx context.Context, lease executionstore.Lease, claimed *executionstore.Execution, backend ExecutionBackend, ref string, leaseDone chan struct{}) error { if tool, ok := claimed.Assignment.Tool(); ok && tool.Replay != run.ReplayAllowed { @@ -258,7 +258,7 @@ func (w *Worker) watch(lease executionstore.Lease, backend ExecutionBackend, ref // observe reads the Outcome of ref and settles it, or restarts the effect // after a retryable failure and reports true with ref moved to the new // execution; false ends the watch. It reads once, then waits for the -// Backend's notice that the Ref settled (RUN-EXE-17); a read at intervals +// Backend's notice that the Ref settled; a read at intervals // bounded by the lease covers a Backend without notices and a notice that // was lost, and every wait re-checks that this Worker still owns the record. func (w *Worker) observe(lease executionstore.Lease, backend ExecutionBackend, ref *string) bool { @@ -289,7 +289,7 @@ func (w *Worker) observe(lease executionstore.Lease, backend ExecutionBackend, r case errors.Is(err, effect.ErrExecutionNotFound), errors.Is(err, effect.ErrOutcomeUnavailable): // A definitive answer: the Backend will never produce this // Outcome. The record settles Unknown instead of waiting on a - // read that cannot change (RUN-EXE-3). + // read that cannot change. env := protocol.OutcomeEnvelope{ProtocolVersion: protocol.ProtocolVersion, Key: lease.Key, Unknown: true, Error: &protocol.WireError{Code: "outcome_unavailable", Message: err.Error()}} w.settleWithRetry(lease, &env, effect.ExecutionUnknown, delay) @@ -305,14 +305,14 @@ func (w *Worker) observe(lease executionstore.Lease, backend ExecutionBackend, r break } // A Known transient failure of an effect whose policy allows it is - // re-dispatched under the same ledger within the budget (RUN-EXE-11): + // re-dispatched under the same ledger within the budget: // the old Ref joins the audit trail and the watch continues on the new one. if next, ok := w.retryAfter(lease, backend, *ref, out); ok { *ref = next return true } // The backend knows the Ref, not the attempt: the ledger's key is the - // Outcome's key (RUN-EXE-9). + // Outcome's key. out.Key = lease.Key env := protocol.EncodeOutcome(out) w.settleWithRetry(lease, &env, protocol.StatusForOutcome(out), delay) @@ -381,7 +381,7 @@ func (w *Worker) retryAfter(lease executionstore.Lease, backend ExecutionBackend return "", false } // The next generation of frames starts here; what the receiver saw of - // the failed attempt is void (RUN-EXE-12). + // the failed attempt is void. w.progress.Reset(lease.Key) if err := backend.Start(context.WithoutCancel(ctx), fresh, state.Assignment); err != nil && !errors.Is(err, effect.ErrDispatchUnknown) { // The retry itself was refused before starting: settle the original @@ -392,7 +392,7 @@ func (w *Worker) retryAfter(lease executionstore.Lease, backend ExecutionBackend } // retryableFailure reports whether a Known outcome declares itself worth a -// second execution (RUN-EXE-11): the disposition the effect layer derived +// second execution: the disposition the effect layer derived // for a model failure, or the one the tool gave its failure. func retryableFailure(result effect.OutcomeResult) bool { switch r := result.(type) { diff --git a/agentcore/executor/supervise_test.go b/agentcore/executor/supervise_test.go index 5344d093..806adfb2 100644 --- a/agentcore/executor/supervise_test.go +++ b/agentcore/executor/supervise_test.go @@ -79,7 +79,7 @@ func TestWorkerOutcomeReadFailurePreservesExecution(t *testing.T) { // Adopting a model record whose execution the backend no longer finds // restarts it as a new generation: the previous Ref moves to Superseded and -// Start receives the new one (RUN-EXE-9). +// Start receives the new one. func TestWorkerRestartSupersedesRef(t *testing.T) { ctx := context.Background() records := storetest.NewMap(nil) @@ -226,7 +226,7 @@ func TestWorkerRecoverExecutionLeavesLiveLease(t *testing.T) { } // Adoption never re-dispatches an unbound tool whose prior execution may have -// crossed the effect boundary (TRN-DUR-4); it settles Unknown instead. A +// crossed the effect boundary; it settles Unknown instead. A // record that never dispatched (Accepted) still executes on adoption, and // model assignments stay replayable (TestWorkerReconcileAdoptsExpiredLease). func TestWorkerAdoptionOfUnattachableToolSettlesUnknown(t *testing.T) { @@ -344,7 +344,7 @@ func TestWorkerHeartbeatRetriesTransientRenewErrors(t *testing.T) { // A takeover whose backend cannot confirm the execution (orphaned) holds the // lease and asks again instead of restarting: nothing is re-dispatched until // the backend proves the execution missing, and a backend that then observes -// it hands the Worker the original Outcome (RUN-EXE-3, TRN-DUR-4). +// it hands the Worker the original Outcome. func TestWorkerRecoverExecutionWaitsForUnconfirmedBackend(t *testing.T) { rows := []struct { name string @@ -429,7 +429,7 @@ func modelText(out effect.Outcome) string { // A lost tool execution is re-dispatched by adoption only when the Replay // policy its Assignment carries is allowed; a forbidden or unjudged tool is // settled Unknown, the message naming the declaration, and never started -// again (RUN-EXE-9, TRN-DUR-4). +// again. func TestWorkerAdoptsToolByReplayDeclaration(t *testing.T) { cases := []struct { name string @@ -541,7 +541,7 @@ func (b *transientBackend) Cancel(ctx context.Context, _ string) error { // Restart within the Worker's budget, the earlier Refs kept in Superseded: // a model failure by the disposition the effect layer derived from its // code, a tool failure by the disposition the tool gave it. A failure that -// does not, or an exhausted budget, settles the failure (RUN-EXE-11). +// does not, or an exhausted budget, settles the failure. func TestWorkerRetriesRetryableFailures(t *testing.T) { cases := []struct { name string @@ -635,7 +635,7 @@ func (s *gateStore) Append(ctx context.Context, lease store.Lease, key effect.As // A Dispose that settles the execution between its acceptance and the // execution_started the Worker commits next wins: the Worker's step is a // state conflict, Backend.Start is never called, and the ledger keeps the -// Dispose's Unknown settlement (RUN-EXE-16, TRN-DUR-4). +// Dispose's Unknown settlement. func TestWorkerStartYieldsToSettlementBeforeStart(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/agentcore/executor/worker.go b/agentcore/executor/worker.go index ca9d20f7..c422b145 100644 --- a/agentcore/executor/worker.go +++ b/agentcore/executor/worker.go @@ -1,7 +1,7 @@ // Package executor makes provider effects durable: a Worker owns the // execution record of every accepted Assignment -- its payload, its // ExecutionRef, its lifecycle state and its Outcome -- and hands the effect -// itself to one Backend chosen once, at Dispatch (RUN-EXE-3, RUN-EXE-10). +// itself to one Backend chosen once, at Dispatch. // Agent Core sees the Worker as an effect.ExecutionPort addressed by AssignmentKey; // Backends see only the Ref the record holds for them. package executor @@ -34,13 +34,13 @@ type WorkerOptions struct { // Store remains the fencing authority. Defaults to time.Now. Clock func() time.Time // Retry is the deployment's budget for re-dispatching an effect after a - // Known failure that declares itself retryable (RUN-EXE-11): at most + // Known failure that declares itself retryable: at most // MaxAttempts executions in total, Backoff multiplied by the attempts so // far between them. The zero value disables retries; whether a given // failure is worth retrying is the failure's own disposition. Retry RetryBudget // Progress is the hub the Worker's backends publish progress into and - // the Worker serves through Progress (RUN-EXE-12); nil builds one with + // the Worker serves through Progress; nil builds one with // the default window. The composer hands the same hub to its backends. Progress *ProgressHub // Settlements is the hub the Worker records settlements into and serves @@ -65,9 +65,9 @@ const defaultLeaseDuration = 30 * time.Second // live lease and continues it from the persisted payload and Ref, and // Dispose settles a record its caller has given up on. Which records to // recover, when to ask and when to give up are decisions of whoever observes -// the record as orphaned — the Owner reconciling its own Run (RUN-CMT-7) or +// the record as orphaned — the Owner reconciling its own Run or // an external controller — and the Worker runs no loop of its own -// (RUN-EXE-6). Neither operation is part of effect.ExecutionPort, which +// Neither operation is part of effect.ExecutionPort, which // stays the per-assignment data plane. type Worker struct { store executionstore.Store @@ -158,7 +158,7 @@ func (w *Worker) spawn(fn func()) { // Close stops every heartbeat and every watcher, and // waits for them. Records keep their leases until they expire: another -// incarnation adopts them through RecoverExecution (RUN-EXE-6). Close +// incarnation adopts them through RecoverExecution. Close // does not cancel backend executions. func (w *Worker) Close() { w.stop() @@ -167,7 +167,7 @@ func (w *Worker) Close() { } // route selects the Backend for an Assignment: the first Route whose Match -// accepts it (RUN-EXE-10). +// accepts it. func (w *Worker) route(a effect.Assignment) (Route, error) { for _, r := range w.routes { if r.Match == nil || r.Match(a) { @@ -262,7 +262,7 @@ func (w *Worker) holdsLease(s *executionstore.Execution) bool { return !s.Terminal() && s.Lease.Owner == w.id && s.Lease.Epoch > 0 && s.Lease.UntilUnixMilli > w.now().UnixMilli() } -// Dispatch accepts an Assignment (RUN-EXE-3): it selects the Backend, +// Dispatch accepts an Assignment: it selects the Backend, // prepares the Ref, opens the execution's ledger with the acceptance and // the binding in one commit, then starts the execution. A replay of the // same Assignment is answered from the ledger; recovery of an existing @@ -290,7 +290,7 @@ func (w *Worker) Dispatch(ctx context.Context, a effect.Assignment) error { key := a.Key() // A key whose ledger is already open is answered from the ledger before // any backend is touched: a replay, a conflicting Assignment or a - // tombstone prepares nothing (RUN-EXE-3, RUN-EXE-16). + // tombstone prepares nothing. if state, _, ok, loadErr := w.store.Load(ctx, key); loadErr != nil { return fmt.Errorf("%w: %w", effect.ErrDispatchRetryable, loadErr) } else if ok { @@ -317,7 +317,7 @@ func (w *Worker) Dispatch(ctx context.Context, a effect.Assignment) error { case errors.Is(err, executionstore.ErrAlreadyApplied), errors.Is(err, executionstore.ErrConflict): // The ledger was opened between the read above and this write (a // concurrent Dispatch, an Abort): the ledger, not this Worker, - // decided (RUN-EXE-14, RUN-EXE-16). + // decided. state, _, ok, loadErr := w.store.Load(ctx, key) if loadErr != nil { return fmt.Errorf("%w: %w", effect.ErrDispatchRetryable, loadErr) @@ -328,7 +328,7 @@ func (w *Worker) Dispatch(ctx context.Context, a effect.Assignment) error { return w.opened(ctx, &state, digest) default: // The ledger store, not the Assignment, refused: nothing started, - // and the same Dispatch may succeed later (RUN-EXE-3). + // and the same Dispatch may succeed later. return fmt.Errorf("%w: %w", effect.ErrDispatchRetryable, err) } if err := w.acquireAndStart(ctx, key); err != nil { @@ -351,11 +351,11 @@ func (w *Worker) Dispatch(ctx context.Context, a effect.Assignment) error { // acknowledges the acceptance and starts nothing new, with one exception: an // acceptance that never started and that no live lease holds is continued, // because the Dispatch that wrote it failed before Start (or its Worker died -// before Start) and this replay is the same request (RUN-EXE-3). An +// before Start) and this replay is the same request. An // execution that has started belongs to its lease holder, or to // RecoverExecution once the Owner observes it orphaned. The ledger holds one // acceptance per key and does not compare content, so telling the two -// Assignments apart is the Worker's reading (RUN-EXE-14). +// Assignments apart is the Worker's reading. func (w *Worker) opened(ctx context.Context, state *executionstore.Execution, digest run.Digest) error { if state.Aborted() { return effect.ErrExecutionAborted @@ -378,7 +378,7 @@ func (w *Worker) opened(ctx context.Context, state *executionstore.Execution, di return err } -// Abort closes key before any acceptance (RUN-EXE-16): it writes the +// Abort closes key before any acceptance: it writes the // tombstone as the first commit of the key's ledger, where it contends with // the acceptance a Dispatch writes; the store's Seq rule lets exactly one of // the two stand. The result is the key's attachment afterwards: aborted when @@ -400,7 +400,7 @@ func (w *Worker) Abort(ctx context.Context, key effect.AssignmentKey) (effect.At // held, and continues it from the persisted payload and Ref — attaching the // previous backend execution, restarting it once the backend proves it // missing, or settling it as Unknown when the tool's replay declaration -// forbids a restart (RUN-EXE-3, RUN-EXE-9). A settled execution, one already +// forbids a restart. A settled execution, one already // under this Worker's lease and one under another live lease are left as // they are. The caller has observed the execution as orphaned; how often to // ask again, and when to give up through Dispose, is the caller's. @@ -426,7 +426,7 @@ func (w *Worker) RecoverExecution(ctx context.Context, key effect.AssignmentKey) return w.acquireAndStart(ctx, key) } -// Acknowledge is effect.Acknowledger (RUN-EXE-13): the Owner reports that +// Acknowledge is effect.Acknowledger: the Owner reports that // the Outcome of key is settled as a Session fact. The ledger records // outcome_acknowledged and its fold collects the execution: the payload, // Outcome and Superseded refs leave the fold, while the key, its digest, @@ -451,7 +451,7 @@ func (w *Worker) Acknowledge(ctx context.Context, key effect.AssignmentKey) erro // Dispose settles an unsettled execution as Unknown without re-dispatching // it. The caller has given the execution up: it will not be recovered, and -// the Owner disposes the Run target on its next read (RUN-CMT-7). Unlike +// the Owner disposes the Run target on its next read. Unlike // RecoverExecution, Dispose is unconditional — it also applies to executions // whose lease holder is dead or absent — and unlike Cancel it does not // require backend reachability: the backend is cancelled best-effort after @@ -489,16 +489,22 @@ func (w *Worker) settlement(key effect.AssignmentKey, outcome *protocol.OutcomeE // notice every Settlements subscriber waits for. It follows the commit; a // subscriber that reads before the notice arrives finds the Outcome anyway. func (w *Worker) settled(key effect.AssignmentKey) { + if _, durable := w.store.(executionstore.SettlementFeed); durable { + return + } w.settlements.Record(key) } -// Settlements is effect.SettlementPort: the settlements this Worker wrote, -// from its hub. +// Settlements is effect.SettlementPort. Shared stores provide a durable feed; +// local stores retain the in-process hub used by single-process deployments. func (w *Worker) Settlements(ctx context.Context, epoch string, after uint64, fn func(effect.Settlement) bool) error { + if feed, durable := w.store.(executionstore.SettlementFeed); durable { + return feed.SettlementEvents(ctx, epoch, after, fn) + } return w.settlements.Settlements(ctx, epoch, after, fn) } -// Progress is effect.ProgressPort (RUN-EXE-12): the frames of an execution +// Progress is effect.ProgressPort: the frames of an execution // this Worker's backends published, from the hub. An execution this Worker // holds but whose Backend is itself a port (a remote Worker behind // PortBackend) is served from that port, so a chain of Workers relays the @@ -527,7 +533,7 @@ func (w *Worker) Progress(ctx context.Context, key effect.AssignmentKey, after u return w.progress.Progress(ctx, key, after, fn) } -// Attach reports the execution's observation state (RUN-EXE-3). The ledger +// Attach reports the execution's observation state. The ledger // is the authority: missing without a ledger, terminal once settled, // orphaned when no incarnation holds a live lease on it, active while one // does. Which Worker answers does not matter: a live lease held by another @@ -544,7 +550,7 @@ func (w *Worker) Attach(ctx context.Context, key effect.AssignmentKey) (effect.A if !ok { // Dispatch opens the ledger before any Start and the store is // durable, so a key without a ledger never started: missing is a - // proof (RUN-EXE-3). + // proof. return effect.Attachment{State: effect.AttachmentMissing, Execution: effect.ExecutionNotFound}, nil } if state.Aborted() { diff --git a/agentcore/inbox/inbox.go b/agentcore/inbox/inbox.go index af8dbba1..576703e4 100644 --- a/agentcore/inbox/inbox.go +++ b/agentcore/inbox/inbox.go @@ -1,4 +1,4 @@ -// Package inbox is the durable command inbox of a Session (CLD-CMD): the +// Package inbox is the durable command inbox of a Session: the // place a caller that does not own the Session -- a gateway, another // process, the same process before it opened the Session -- leaves a // command for whoever owns the Session next. A command becomes a fact the @@ -91,7 +91,7 @@ type Store interface { Resolve(ctx context.Context, sid session.SessionID, seq uint64, r Result) error // Sessions returns Sessions with at least one pending entry, in // SessionID order, at most limit of them (0 means every one): the - // activation index of an owner pool (CLD-CMD-4). A page is a sample of + // activation index of an owner pool. A page is a sample of // what needs an owner, not a cursor over all of it: a Session left out // appears once earlier ones are drained. Sessions(ctx context.Context, limit int) ([]session.SessionID, error) diff --git a/agentcore/jsonstable/identity_test.go b/agentcore/jsonstable/identity_test.go index 0bfd8441..1b123a40 100644 --- a/agentcore/jsonstable/identity_test.go +++ b/agentcore/jsonstable/identity_test.go @@ -6,7 +6,7 @@ import ( ) // TestEncodeTypedPayload pins the domain separator every Twilight digest is -// built on (, RUN-WIR-4): the exact byte shape, and the fact that +// built on: the exact byte shape, and the fact that // the schema version, the type name and the canonical payload each change the // preimage. A change here moves every digest in every protocol version, so it // is a wire change and not a refactor. diff --git a/agentcore/ledger/boundary.go b/agentcore/ledger/boundary.go index fe090c76..0f0def13 100644 --- a/agentcore/ledger/boundary.go +++ b/agentcore/ledger/boundary.go @@ -2,7 +2,7 @@ package ledger // CommitAt reports whether c is the commit through records: the commit at // through.Next-1. History is append-only, so the position names the commit; -// it is the one head-alignment predicate of EXT-PRJ-3, shared by the Writer +// it is the one head-alignment predicate, shared by the Writer // and the store reader so a cache entry is judged the same way on both // paths. The commit is the atomic unit of the ledger: there is no finer // boundary to check. @@ -13,11 +13,11 @@ func CommitAt(c Commit, through Head) bool { // OwnBoundary reports whether through is a commit boundary of the tip // segment itself: the commit before through.Next is one the tip wrote, not // one it inherits; seed is the head of the tip before its first own commit. A projection state was folded under the inheritance -// policy of the tip that was current when it was recorded (EXT-PRJ-8); a +// policy of the tip that was current when it was recorded; a // fork makes every earlier commit inherited, so an entry ending on an -// inherited boundary is not started from (EXT-PRJ-3) and the fold restarts +// inherited boundary is not started from and the fold restarts // from the initial state until the tip holds a commit of its own. It is -// the second head-alignment predicate of EXT-PRJ-3, shared by the Writer +// the second head-alignment predicate, shared by the Writer // and the store reader like CommitAt. func OwnBoundary(seed, through Head) bool { return through.Next > seed.Next diff --git a/agentcore/ledger/errors.go b/agentcore/ledger/errors.go index 1e55e015..22fcf638 100644 --- a/agentcore/ledger/errors.go +++ b/agentcore/ledger/errors.go @@ -11,12 +11,12 @@ const ( CodeOwnershipLost ErrorCode = "ownership_lost" // CodeProjectionUnhealthy: a derived projection failed to fold a commit // the Writer applied; its state is frozen at the last good commit until - // the Writer reopens and rebuilds it (EXT-PRJ-9). + // the Writer reopens and rebuilds it. CodeProjectionUnhealthy ErrorCode = "projection_unhealthy" // CodeUnknownOutcome: an Append failed in a way that leaves what reached // the log unknown (an IO error, or the kernel's ErrHandleFailed). The // Writer's head and projections may no longer match the log, so it fails - // closed; the host reopens and replays (EXT-WRT-4). + // closed; the host reopens and replays. CodeUnknownOutcome ErrorCode = "unknown_outcome" ) diff --git a/agentcore/ledger/stream.go b/agentcore/ledger/stream.go index 2c1eb19a..dfadc23a 100644 --- a/agentcore/ledger/stream.go +++ b/agentcore/ledger/stream.go @@ -82,7 +82,7 @@ type StreamSeq uint64 // ValidateStreamRef checks a domain as a stream attribution: ledger-valid // and not empty. The empty domain is the unpartitioned ledger's group, not // a stream; which domains a writer owns and which ID an event binds to are -// the module framework's checks (EXT-STR-1). +// the module framework's checks. func ValidateStreamRef(r Domain) error { if r.Name == "" { return errors.New("stream domain is empty") diff --git a/agentcore/module/binding.go b/agentcore/module/binding.go index fce0e35f..d663c6ed 100644 --- a/agentcore/module/binding.go +++ b/agentcore/module/binding.go @@ -11,7 +11,7 @@ type Cardinality struct { } // BindingExtractor returns every Artifact reference inside a decoded typed -// value, in appearance order (EXT-REF-1). +// value, in appearance order. type BindingExtractor interface { BindingIDs(value any) ([]artifact.BindingID, error) } @@ -22,7 +22,7 @@ type BindingExtractorFunc func(value any) ([]artifact.BindingID, error) func (f BindingExtractorFunc) BindingIDs(value any) ([]artifact.BindingID, error) { return f(value) } // BindingReferenceDefinition declares where an event may reference Artifacts -// and what admission requires of them (EXT-REF-2). +// and what admission requires of them. type BindingReferenceDefinition struct { Extractor BindingExtractor Cardinality Cardinality diff --git a/agentcore/module/projection.go b/agentcore/module/projection.go index ccd08745..61f9fee6 100644 --- a/agentcore/module/projection.go +++ b/agentcore/module/projection.go @@ -9,7 +9,7 @@ import ( "github.com/felinics/twilight/agentcore/jsonstable" ) -// ProjectionDefinition is a pure fold over decoded events (EXT-PRJ-1). +// ProjectionDefinition is a pure fold over decoded events. type ProjectionDefinition struct { ID ProjectionID Version ProjectionVersion @@ -18,7 +18,7 @@ type ProjectionDefinition struct { Apply func(any, DecodedEvent) (any, error) StateCodec PayloadCodec // Inherits decides, per logical stream, what the fold takes from the - // commits a fork inherits (EXT-PRJ-8). nil follows the lineage each + // commits a fork inherits. nil follows the lineage each // stream's domain declared: inherited batches of Inherited domains // are folded and those of Own domains are skipped, so a // child never interprets its parent's execution history as its own. A @@ -30,7 +30,7 @@ type ProjectionDefinition struct { // invariants with: a provisional commit its fold cannot fold is refused // before anything is persisted. Every other projection is a derived read // model: a fold failure marks it unhealthy for the Writer's lifetime and - // never blocks the facts (EXT-PRJ-9). Write-time invariants belong to + // never blocks the facts. Write-time invariants belong to // the Writer's decision, not to projections; an authoritative fold // failing is a defect, not a business rejection. Authoritative bool @@ -75,7 +75,7 @@ type ProjectionScope struct { } // ScopeFor resolves a projection definition together with the module scope -// its fold must honour (EXT-PRJ-2). It is the entry point of the fold +// its fold must honour. It is the entry point of the fold // engine: every fold starts from a Scope. func (r *Registry) ScopeFor(id ProjectionID, v ProjectionVersion) (*ProjectionScope, error) { def, module, ok := r.LookupProjection(id, v) @@ -90,7 +90,7 @@ func (r *Registry) ScopeFor(id ProjectionID, v ProjectionVersion) (*ProjectionSc } // Fold applies whole commits to state, event by event in CommitSeq order -// (EXT-PRJ-1/2). commits must be contiguous from the ledger and complete: +// commits must be contiguous from the ledger and complete: // the store never exposes a torn commit. A fold reads every stream: the // commits carry their own stream attribution, and a projection whose // Consumes spans modules sees their events wherever the commits placed @@ -101,7 +101,7 @@ func (r *Registry) Fold(s *ProjectionScope, state any, commits []ledger.Commit) } // FoldFrom folds commits under the inheritance policy of the projection -// (EXT-PRJ-8): header is the tip segment's header, whose Parent edge marks +// header is the tip segment's header, whose Parent edge marks // the inherited prefix; commits at or below Parent.Seq contribute only the // batches the policy admits, by default those of Inherited domains. A // header without a Parent (a root, or a caller folding tip commits only) @@ -144,7 +144,7 @@ func (r *Registry) applyEvent(s *ProjectionScope, state any, pos ledger.Position if _, inScope := s.modules[module]; known && inScope { // The registry is the only authority on Ignorable, and an // unregistered type has no entry to consult: an in-scope event - // the registry does not know is an error (EXT-PRJ-2). + // the registry does not know is an error. return nil, &ledger.Error{Code: ledger.CodeUnknownEvent, Type: e.Type, Detail: fmt.Sprintf("projection %q: unregistered event of module %s/%s at commit %d", s.Def.ID, module.Source, module.ID, seq)} } return state, nil @@ -184,7 +184,7 @@ func checkpointDigest(id ProjectionID, v ProjectionVersion, through ledger.Head, } // SealCheckpoint wraps an authoritative projection's encoded state with its -// digest for the cache (EXT-PRJ-10). +// digest for the cache. func SealCheckpoint(id ProjectionID, v ProjectionVersion, through ledger.Head, state jsonstable.Value) (jsonstable.Value, error) { return jsonstable.FromValue(sealedCheckpoint{State: state.Bytes(), Digest: checkpointDigest(id, v, through, state)}) } @@ -233,7 +233,7 @@ type CachePolicy func(id ProjectionID, v ProjectionVersion, head, cached ledger. // the entry the cache already covers, at Close as at any other time: the // entry may lag the head by up to n commits, and a Writer reopening folds // at most that many. Writing a large projection's whole state on every -// Close would cost the state's size per Turn (EXT-PRJ-7); a deployment +// Close would cost the state's size per Turn; a deployment // that wants a clean Close to leave nothing to fold wraps the policy in // AtClose. n <= 0 means DefaultCacheEvery. func CacheEvery(n ledger.CommitSeq) CachePolicy { diff --git a/agentcore/module/registry.go b/agentcore/module/registry.go index 7ba53d4e..967602bc 100644 --- a/agentcore/module/registry.go +++ b/agentcore/module/registry.go @@ -24,14 +24,14 @@ type ( ) // SourceTwilight is the source reserved for this repository's first-party -// modules; application modules register under their own SourceID (EXT-REG-1). +// modules; application modules register under their own SourceID. const SourceTwilight SourceID = "twilight" // TwilightModule is the ModuleKey of a first-party module. func TwilightModule(id ModuleID) ModuleKey { return ModuleKey{Source: SourceTwilight, ID: id} } // PayloadCodec encodes and decodes one payload version. Encode never writes -// the `v` field: the Registry adds it (EXT-COD-2). +// the `v` field: the Registry adds it. type PayloadCodec interface { Encode(value any) (jsonstable.Value, error) Decode(wire jsonstable.Value) (any, error) @@ -39,7 +39,7 @@ type PayloadCodec interface { } // PayloadVersion is the version of one event type's payload codec -// (, EXT-REG-2). It belongs to the event type, not to the segment: +// It belongs to the event type, not to the segment: // every payload carries the version it was written under as `v`, the module // keeps a codec for every version it ever published, and every codec of one // type decodes to the module's current in-memory value (upcasting inside the @@ -56,16 +56,16 @@ type EventDefinition struct { Version PayloadVersion Bindings []BindingReferenceDefinition // Ignorable marks purely informational events: a fold that cannot decode - // the event's payload version skips it instead of failing (EXT-PRJ-2). + // the event's payload version skips it instead of failing. Ignorable bool // Stream names the stream domain the event may be appended to: one the - // same module declares in ModuleDescriptor.Streams (EXT-STR-1). The + // same module declares in ModuleDescriptor.Streams. The // Writer rejects an event placed in a batch of another domain. Domain string } // StreamDefinition declares one logical stream domain a module owns -// (EXT-STR-1): the boundary of the invariants its events keep. The kernel +// the boundary of the invariants its events keep. The kernel // names no domain; every domain a Session writes is declared here by // exactly one module, which is the only module allowed to append to it. type StreamDefinition struct { @@ -75,7 +75,7 @@ type StreamDefinition struct { // stream it belongs to. Nil declares a singleton domain: one stream, no // ID. Non-nil declares a keyed domain whose streams are domain/; the // Writer requires Key(value) to equal the batch's stream ID - // (EXT-STR-1). The binding is a property of the module's Go values, not + // The binding is a property of the module's Go values, not // of a payload field name. Key StreamKey // Inheritance is how a child segment reads the domain: @@ -97,7 +97,7 @@ func (d StreamDefinition) Ref(id string) ledger.Domain { } // ModuleRequirement declares that a module consumes another module's events -// (EXT-REG-4). Source is required: module identity is the (Source, ID) +// Source is required: module identity is the (Source, ID) // pair. Versions are not part of the handshake: the producer's codecs // upcast every version to its current value, which is all a consumer sees. type ModuleRequirement struct { @@ -113,7 +113,7 @@ type ModuleDescriptor struct { Source SourceID ID ModuleID Requires []ModuleRequirement - // Streams are the stream domains the module owns (EXT-STR-1). Every + // Streams are the stream domains the module owns. Every // event the module declares names one of them. Streams []StreamDefinition Events []EventDefinition @@ -138,7 +138,7 @@ type DecodedEvent struct { Unknown bool } -// Registry is the immutable index built once at startup (EXT-REG-1). +// Registry is the immutable index built once at startup. type Registry struct { modules map[ModuleKey]ModuleDescriptor streams map[string]streamEntry @@ -182,7 +182,7 @@ func validSegment(kind, v string) error { // BuildRegistry validates the module set and freezes the indexes. Every // module passed here is trusted: the caller vouches for it, so it may -// declare authoritative projections (EXT-PRJ-9). Modules from outside the +// declare authoritative projections. Modules from outside the // deployment's trust boundary go through BuildRegistryWithExtensions. func BuildRegistry(modules ...ModuleDescriptor) (*Registry, error) { return BuildRegistryWithExtensions(modules, nil) @@ -251,7 +251,7 @@ func BuildRegistryWithExtensions(core, extensions []ModuleDescriptor) (*Registry return nil, &ledger.Error{Code: ledger.CodeInvalid, Detail: fmt.Sprintf("projection %q is incomplete", p.ID)} } // Refusing commits is a capability of trusted core modules, not - // something a descriptor declares for itself (EXT-PRJ-9); the + // something a descriptor declares for itself; the // extension path above already refused it, this guards the map. if p.Authoritative && !trusted[key] { return nil, &ledger.Error{Code: ledger.CodeInvalid, Detail: fmt.Sprintf("projection %q of module %s/%s declares Authoritative without trust", p.ID, m.Source, m.ID)} @@ -269,7 +269,7 @@ func BuildRegistryWithExtensions(core, extensions []ModuleDescriptor) (*Registry return r, nil } -// checkRequirements enforces EXT-REG-4: registered dependencies, no cycles, +// checkRequirements enforces registered dependencies, no cycles, // projection consumption within scope, and handled payload versions. func (r *Registry) checkRequirements() error { state := make(map[ModuleKey]int) // 0 unvisited, 1 visiting, 2 done @@ -326,7 +326,7 @@ func (r *Registry) checkRequirements() error { } // scopeOf is the module plus its Requires: the modules whose unknown events a -// projection must not silently skip (EXT-PRJ-2). +// projection must not silently skip. func (r *Registry) scopeOf(key ModuleKey) map[ModuleKey]struct{} { scope := map[ModuleKey]struct{}{key: {}} for _, req := range r.modules[key].Requires { @@ -433,7 +433,7 @@ func ModulePrefix(source SourceID, id ModuleID) ledger.EventType { } // Encode validates value, encodes it with the codec of the event type's -// write Version and records that Version as the payload's `v` (EXT-REG-2). +// write Version and records that Version as the payload's `v`. func (r *Registry) Encode(typ ledger.EventType, value any) (jsonstable.Value, error) { _, def, ok := r.LookupEvent(typ) if !ok { @@ -452,12 +452,12 @@ func (r *Registry) Encode(typ ledger.EventType, value any) (jsonstable.Value, er return jsonstable.Value{}, &ledger.Error{Code: ledger.CodeCodec, Type: typ, Detail: err.Error()} } // The canonical Encode/Decode/Encode round trip is a module test - // obligation (EXT-COD-1), not re-verified per Encode. + // obligation, not re-verified per Encode. return wire, nil } // Decode selects the codec by (EventType, v). Unknown types or versions are -// returned as Unknown with the raw payload retained (EXT-REG-3). +// returned as Unknown with the raw payload retained. func (r *Registry) Decode(e ledger.Event) (DecodedEvent, error) { out := DecodedEvent{Event: e} module, def, ok := r.LookupEvent(e.Type) diff --git a/agentcore/module/registry_golden_test.go b/agentcore/module/registry_golden_test.go index a6520a32..f30a0408 100644 --- a/agentcore/module/registry_golden_test.go +++ b/agentcore/module/registry_golden_test.go @@ -9,7 +9,7 @@ type goldenPayload struct { } // TestEncodeWireGolden freezes the framework's payload wire: the canonical -// bytes after the `v` field is injected (EXT-COD-2). A drift with a non-empty +// bytes after the `v` field is injected. A drift with a non-empty // want is an intentional wire change or an accident — update the fixture and // agent-session-md only for the former. func TestEncodeWireGolden(t *testing.T) { diff --git a/agentcore/module/registry_test.go b/agentcore/module/registry_test.go index 2d2d572c..10f465e2 100644 --- a/agentcore/module/registry_test.go +++ b/agentcore/module/registry_test.go @@ -90,7 +90,7 @@ func srcModule(source SourceID, id ModuleID) ModuleDescriptor { }}} } -// EXT-REG-1: module identity is (Source, ID); sources are validated segments. +// module identity is (Source, ID); sources are validated segments. func TestBuildRegistryValidatesSource(t *testing.T) { rejects := map[string][]ModuleDescriptor{ "empty source": {srcModule("", "a")}, @@ -145,7 +145,7 @@ func TestRegistrySchemaVersion(t *testing.T) { } } -// EXT-SCH-2: every codec entry names a real Schema and a real codec. Encode +// every codec entry names a real Schema and a real codec. Encode // selects def.Codecs[segment schema], so an unusable entry must be refused // when the registry is built rather than discovered on first write. func TestBuildRegistryRequiresCodec(t *testing.T) { @@ -214,7 +214,7 @@ func (legacyCodec) Validate(v any) error { return nil } -// , EXT-REG-2, EXT-COD-1/2: one type has codecs for two versions. +// one type has codecs for two versions. // Encode writes the type's write Version and records it as `v` (the highest // codec by default, or the one the module declares); a row of either version // decodes through its own codec, while a version no codec claims stays @@ -275,7 +275,7 @@ func TestRegistryMultiVersionCodecsCoexist(t *testing.T) { } } -// EXT-PRJ-9: Authoritative is a capability of trusted core modules. An +// Authoritative is a capability of trusted core modules. An // extension cannot declare it, nor claim the first-party source to get it. func TestExtensionsCannotBeAuthoritative(t *testing.T) { core := ModuleDescriptor{Source: SourceTwilight, ID: "core", Projections: []ProjectionDefinition{{ diff --git a/agentcore/module/streampolicy_test.go b/agentcore/module/streampolicy_test.go index 108b43a7..9d84a866 100644 --- a/agentcore/module/streampolicy_test.go +++ b/agentcore/module/streampolicy_test.go @@ -25,7 +25,6 @@ func policyModule(streams []StreamDefinition, eventStream string) ModuleDescript // TestBuildRegistryValidatesStreamDeclarations: stream declarations and the // domains events name are assembly errors, caught before any write -// (EXT-STR-1). func TestBuildRegistryValidatesStreamDeclarations(t *testing.T) { singleton := StreamDefinition{Domain: "pol", Inheritance: Inherited} keyed := StreamDefinition{Domain: "polrun", Key: func(any) (string, error) { return "r1", nil }, Inheritance: Own} diff --git a/agentcore/observe/bus.go b/agentcore/observe/bus.go index 71d8ba4e..5c2094c0 100644 --- a/agentcore/observe/bus.go +++ b/agentcore/observe/bus.go @@ -1,40 +1,30 @@ -// Package observe carries a Session's observations (OBS-1) in two streams. -// The Bus is the committed stream: a writer.CommitObserver that decodes -// every applied commit through the Registry and fans it out, in commit -// order, to the subscribers of each Session. Its subscription is a catch-up -// subscription: it may start from a CommitSeq, reading the ledger up to its -// head and continuing live, so a consumer that keeps its own checkpoint -// resumes after a crash without a gap. The Progresses are the transient -// stream: what the executor reported of an effect while it ran, and the -// failures of background work -- none of it a fact, all of it expendable. A -// host that serves one audience from both merges the two. +// Package observe carries a Session's observations in two streams. +// Bus and Progresses are process-local fan-outs for committed and transient +// observations. SessionLedgerTailer is the durable stream: it follows the +// shared ledger and uses CommitObserver notifications only as polling hints. package observe import ( "context" "encoding/json" - "errors" + "sync" + "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/session" - "sync" ) // Event is one item of a Session's observation stream, in either of two -// shapes. A committed Event carries one event decoded from the applied -// commits through the Registry: Module, Version and Value are the decoded -// payload, Unknown reports a type or version this process has no codec for -// (the event is still delivered) and Err reports the decode or history-read -// failure. A transient Event has a zero Row: Progress carries what the -// executor reported of an effect while it ran, or Err alone carries a -// failure of background work; neither is a fact, either may be lost, and -// the committed result that follows replaces it. +// shapes. A committed Event carries one event decoded from an applied commit: +// Module, Version and Value are its decoded payload, while Unknown reports a +// type or version this process has no codec for. A transient Event has a zero +// Row and carries Progress or Err. type Event struct { Session session.SessionID // Position is the event's place in the Session's ledger: the commit's - // Seq and the event's index within the commit. It is what a consumer - // checkpoints; zero on a transient Event. + // sequence and the event's index in that commit. It is zero for transient + // observations. Position ledger.Position Row ledger.Event Module module.ModuleKey @@ -42,11 +32,8 @@ type Event struct { Value any Unknown bool Err error - // Progress is set for a transient observation of an effect in flight - // (RUN-EXE-12, OBS-1): a model or tool delta relayed from the executor, - // or a reset that voids the deltas received so far. Such an Event has a - // zero Row: it is not a fact, may be lost, and is replaced by the - // committed result that follows. + // Progress is expendable executor output. The durable committed result + // which follows replaces it. Progress *Progress } @@ -61,48 +48,35 @@ type Progress struct { Payload json.RawMessage } -// History reads a Session's committed history: the Store, or anything that -// serves its ReadCommits. It is what a catch-up subscription reads before -// it goes live. -type History interface { - ReadCommits(context.Context, session.CommitReadRequest) (session.CommitPage, error) -} - -// ErrNoHistory reports SubscribeFrom on a Bus built without a History. -var ErrNoHistory = errors.New("observe: the bus has no history to catch up from") +const subscriberBuffer = 64 -// Bus decodes applied commits and fans them out per Session. It carries -// committed facts only; the transient observations of the running effects -// are the Progresses' stream. +// Bus is the process-local committed fan-out. It neither reads nor retains a +// ledger: Committed decodes the supplied commit and immediately publishes it +// to the subscribers that are present in this process. type Bus struct { registry *module.Registry - history History mu sync.Mutex - subs map[session.SessionID]map[*subscriber]struct{} + subs map[session.SessionID]map[*localSubscriber]struct{} } -// NewBus returns a Bus decoding through registry. history, when not nil, -// lets SubscribeFrom catch up from the ledger; a Bus without it serves live -// subscriptions only. -func NewBus(registry *module.Registry, history History) *Bus { - return &Bus{registry: registry, history: history, subs: make(map[session.SessionID]map[*subscriber]struct{})} +// NewBus returns an empty process-local committed fan-out. +func NewBus(registry *module.Registry) *Bus { + return &Bus{registry: registry, subs: make(map[session.SessionID]map[*localSubscriber]struct{})} } -// Committed is writer.CommitObserver: one Event per committed event, in -// commit order. +// Committed implements writer.CommitObserver. func (b *Bus) Committed(_ context.Context, sid session.SessionID, commit ledger.Commit) { - b.publish(sid, b.decode(sid, &commit)...) + b.publish(sid, decodeCommit(b.registry, sid, &commit)) } -// decode renders one commit as its Events, each at its Position. -func (b *Bus) decode(sid session.SessionID, commit *ledger.Commit) []Event { - var events []Event +func decodeCommit(registry *module.Registry, sid session.SessionID, commit *ledger.Commit) []Event { + events := make([]Event, 0) index := uint32(0) for _, batch := range commit.Batches { for _, row := range batch.Events { e := Event{Session: sid, Position: ledger.Position{Commit: commit.Seq, Index: index}, Row: row} index++ - decoded, err := b.registry.Decode(row) + decoded, err := registry.Decode(row) if err != nil { e.Unknown, e.Err = true, err } else { @@ -114,65 +88,42 @@ func (b *Bus) decode(sid session.SessionID, commit *ledger.Commit) []Event { return events } -func (b *Bus) publish(sid session.SessionID, events ...Event) { +func (b *Bus) publish(sid session.SessionID, events []Event) { b.mu.Lock() - subs := make([]*subscriber, 0, len(b.subs[sid])) + subs := make([]*localSubscriber, 0, len(b.subs[sid])) for s := range b.subs[sid] { subs = append(subs, s) } b.mu.Unlock() for _, s := range subs { - s.push(events) + if !s.push(events) { + b.detach(sid, s) + } } } -// Subscribe registers a subscriber to one Session's stream from this moment -// on; the channel closes when ctx is done. +// Subscribe registers a live-only subscriber. A subscriber that does not +// keep up is detached and closed rather than blocking a committer. func (b *Bus) Subscribe(ctx context.Context, sid session.SessionID) <-chan Event { - s := b.attach(sid) - go s.drain(ctx, func() { b.detach(sid, s) }) - return s.out -} - -// SubscribeFrom is the catch-up form of Subscribe: every committed event of -// the Session from CommitSeq from, in order, then the live stream. The -// subscriber is attached before the history is read, so no commit made -// meanwhile is lost, and a live commit the history already covered is -// dropped, so none is delivered twice. The channel closes when ctx is done; -// a read failure closes it after an Event with Err. -func (b *Bus) SubscribeFrom(ctx context.Context, sid session.SessionID, from ledger.CommitSeq) (<-chan Event, error) { - if b.history == nil { - return nil, ErrNoHistory - } - s := b.attach(sid) - page, err := b.history.ReadCommits(ctx, session.CommitReadRequest{SessionID: sid, From: from}) - if err != nil { - b.detach(sid, s) - return nil, err - } - var history []Event - for i := range page.Commits { - history = append(history, b.decode(sid, &page.Commits[i])...) - } - s.mu.Lock() - s.history, s.skipBelow = history, page.Head.Next - s.mu.Unlock() - go s.drain(ctx, func() { b.detach(sid, s) }) - return s.out, nil -} - -func (b *Bus) attach(sid session.SessionID) *subscriber { - s := &subscriber{out: make(chan Event, 64), wake: make(chan struct{}, 1)} + s := newLocalSubscriber() b.mu.Lock() if b.subs[sid] == nil { - b.subs[sid] = make(map[*subscriber]struct{}) + b.subs[sid] = make(map[*localSubscriber]struct{}) } b.subs[sid][s] = struct{}{} b.mu.Unlock() - return s + go func() { + select { + case <-ctx.Done(): + s.close() + case <-s.done: + } + b.detach(sid, s) + }() + return s.out } -func (b *Bus) detach(sid session.SessionID, s *subscriber) { +func (b *Bus) detach(sid session.SessionID, s *localSubscriber) { b.mu.Lock() delete(b.subs[sid], s) if len(b.subs[sid]) == 0 { @@ -181,64 +132,46 @@ func (b *Bus) detach(sid session.SessionID, s *subscriber) { b.mu.Unlock() } -// subscriber decouples the committer from the consumer: push appends to an -// unbounded queue and never blocks, so a slow reader delays only its own -// delivery, never a Commit; drain moves the history, then the queue, into -// the channel in order and closes it when the subscription ends. skipBelow -// is the ledger head the history covered: a live committed Event below it -// was delivered from the history already. -type subscriber struct { - mu sync.Mutex - history []Event - skipBelow ledger.CommitSeq - queue []Event - out chan Event - wake chan struct{} +// localSubscriber owns closure of its bounded output. push and close may race, +// so both are serialized by mu. +type localSubscriber struct { + mu sync.Mutex + out chan Event + done chan struct{} + closed bool } -func (s *subscriber) push(events []Event) { +func newLocalSubscriber() *localSubscriber { + return &localSubscriber{out: make(chan Event, subscriberBuffer), done: make(chan struct{})} +} + +func (s *localSubscriber) push(events []Event) bool { s.mu.Lock() - s.queue = append(s.queue, events...) - s.mu.Unlock() - select { - case s.wake <- struct{}{}: - default: + defer s.mu.Unlock() + if s.closed { + return false + } + for i := range events { + select { + case s.out <- events[i]: + default: + s.closeLocked() + return false + } } + return true +} + +func (s *localSubscriber) close() { + s.mu.Lock() + defer s.mu.Unlock() + s.closeLocked() } -func (s *subscriber) drain(ctx context.Context, unsubscribe func()) { - defer func() { - unsubscribe() +func (s *localSubscriber) closeLocked() { + if !s.closed { close(s.out) - }() - for { - s.mu.Lock() - history, live, skipBelow := s.history, s.queue, s.skipBelow - s.history, s.queue = nil, nil - s.mu.Unlock() - for i := range history { - select { - case s.out <- history[i]: - case <-ctx.Done(): - return - } - } - for i := range live { - e := &live[i] - if e.Position.Commit < skipBelow { - // Delivered from the history already. - continue - } - select { - case s.out <- *e: - case <-ctx.Done(): - return - } - } - select { - case <-s.wake: - case <-ctx.Done(): - return - } + close(s.done) + s.closed = true } } diff --git a/agentcore/observe/bus_test.go b/agentcore/observe/bus_test.go index 3e125273..406474ef 100644 --- a/agentcore/observe/bus_test.go +++ b/agentcore/observe/bus_test.go @@ -2,15 +2,15 @@ package observe_test import ( "context" - "errors" + "testing" + "time" + "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" "github.com/felinics/twilight/agentcore/observe" "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" - "testing" - "time" ) type rowPayload struct { @@ -73,12 +73,14 @@ func TestSubscribeFromCatchesUpThenGoesLive(t *testing.T) { defer cancel() store := filestoretest.Store(t) reg := registry(t) - bus := observe.NewBus(reg, store) + bus := observe.NewBus(reg) + tailer := observe.NewSessionLedgerTailer(reg, store) + defer tailer.Close() const sid session.SessionID = "s1" if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { t.Fatal(err) } - w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{Observers: []writer.CommitObserver{bus}}).Writer(ctx, sid) + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{Observers: []writer.CommitObserver{bus, tailer}}).Writer(ctx, sid) if err != nil { t.Fatal(err) } @@ -104,7 +106,7 @@ func TestSubscribeFromCatchesUpThenGoesLive(t *testing.T) { t.Run(tc.name, func(t *testing.T) { sctx, scancel := context.WithCancel(ctx) defer scancel() - ch, err := bus.SubscribeFrom(sctx, sid, tc.from) + ch, err := tailer.SubscribeFrom(sctx, sid, tc.from) if err != nil { t.Fatal(err) } @@ -139,7 +141,89 @@ func TestSubscribeFromCatchesUpThenGoesLive(t *testing.T) { if got[0] != "g" { t.Fatalf("live = %v, want [g]", got) } - if _, err := observe.NewBus(reg, nil).SubscribeFrom(ctx, sid, 0); !errors.Is(err, observe.ErrNoHistory) { - t.Fatalf("SubscribeFrom without history = %v, want ErrNoHistory", err) +} + +// A durable subscriber tails the shared History, not only the Committed +// callbacks made on its own Bus. This models a Session moving between +// owners while an observer remains connected to the first owner. +func TestSubscribeFromTailsCommitsFromAnotherBus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := filestoretest.Store(t) + reg := registry(t) + readerTailer := observe.NewSessionLedgerTailer(reg, store) + defer readerTailer.Close() + writerBus := observe.NewBus(reg) + const sid session.SessionID = "cross-owner" + if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { + t.Fatal(err) + } + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{Observers: []writer.CommitObserver{writerBus}}).Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + defer w.Close(ctx) + + events, err := readerTailer.SubscribeFrom(ctx, sid, 0) + if err != nil { + t.Fatal(err) + } + commitRow(t, w, "foreign", "from another bus") + got, positions := texts(t, events, 1) + if got[0] != "from another bus" || positions[0] != (ledger.Position{Commit: 0, Index: 0}) { + t.Fatalf("events = %v at %v", got, positions) + } +} + +// Live committed and transient publishers have a hard per-subscriber bound. +// A consumer that never reads is closed instead of growing an unbounded +// queue, and publishing remains synchronous and quick. +func TestSlowSubscribersAreClosedWithoutBlockingPublishers(t *testing.T) { + reg := registry(t) + bus := observe.NewBus(reg) + progress := observe.NewProgresses() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + committed := bus.Subscribe(ctx, "slow-committed") + transient := progress.Subscribe(ctx, "slow-progress") + payload, err := reg.Encode(rowType, rowPayload{Text: "x"}) + if err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 1000; i++ { + bus.Committed(context.Background(), "slow-committed", ledger.Commit{ + Seq: ledger.CommitSeq(i), + Batches: []ledger.EventBatch{{Events: []ledger.Event{{ + Type: rowType, Payload: payload, + }}}}, + }) + progress.Publish("slow-progress", observe.Progress{Sequence: uint64(i)}) + } + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("publishers blocked behind slow subscribers") + } + + assertClosed := func(name string, ch <-chan observe.Event) { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-deadline: + t.Fatalf("%s subscriber was not closed at its bound", name) + } + } } + assertClosed("committed", committed) + assertClosed("progress", transient) } diff --git a/agentcore/observe/progress.go b/agentcore/observe/progress.go index c0037286..f3252e3f 100644 --- a/agentcore/observe/progress.go +++ b/agentcore/observe/progress.go @@ -9,18 +9,18 @@ import ( // Progresses fans a Session's transient observations out to its // subscribers: the progress the executor reported of an effect while it ran -// (RUN-EXE-12) and the failures of background work, both reported for the +// and the failures of background work, both reported for the // same audience as the committed stream but never entering it. None of it // is a fact: an item has a zero Position, may be lost, and is replaced by // the committed result that follows. type Progresses struct { mu sync.Mutex - subs map[session.SessionID]map[*subscriber]struct{} + subs map[session.SessionID]map[*localSubscriber]struct{} } // NewProgresses returns an empty transient stream. func NewProgresses() *Progresses { - return &Progresses{subs: make(map[session.SessionID]map[*subscriber]struct{})} + return &Progresses{subs: make(map[session.SessionID]map[*localSubscriber]struct{})} } // Publish delivers one transient progress observation to the Session's @@ -37,33 +37,46 @@ func (p *Progresses) Failed(sid session.SessionID, err error) { func (p *Progresses) publish(sid session.SessionID, events ...Event) { p.mu.Lock() - subs := make([]*subscriber, 0, len(p.subs[sid])) + subs := make([]*localSubscriber, 0, len(p.subs[sid])) for s := range p.subs[sid] { subs = append(subs, s) } p.mu.Unlock() for _, s := range subs { - s.push(events) + if !s.push(events) { + p.detach(sid, s) + } } } // Subscribe registers a subscriber to one Session's transient stream from -// this moment on; the channel closes when ctx is done. +// this moment on; the channel closes when ctx is done. A subscriber which +// does not keep up is detached and closed at the same hard bound as a live +// committed subscription. func (p *Progresses) Subscribe(ctx context.Context, sid session.SessionID) <-chan Event { - s := &subscriber{out: make(chan Event, 64), wake: make(chan struct{}, 1)} + s := newLocalSubscriber() p.mu.Lock() if p.subs[sid] == nil { - p.subs[sid] = make(map[*subscriber]struct{}) + p.subs[sid] = make(map[*localSubscriber]struct{}) } p.subs[sid][s] = struct{}{} p.mu.Unlock() - go s.drain(ctx, func() { - p.mu.Lock() - delete(p.subs[sid], s) - if len(p.subs[sid]) == 0 { - delete(p.subs, sid) + go func() { + select { + case <-ctx.Done(): + s.close() + case <-s.done: } - p.mu.Unlock() - }) + p.detach(sid, s) + }() return s.out } + +func (p *Progresses) detach(sid session.SessionID, s *localSubscriber) { + p.mu.Lock() + delete(p.subs[sid], s) + if len(p.subs[sid]) == 0 { + delete(p.subs, sid) + } + p.mu.Unlock() +} diff --git a/agentcore/observe/tailer.go b/agentcore/observe/tailer.go new file mode 100644 index 00000000..ba7f1ce4 --- /dev/null +++ b/agentcore/observe/tailer.go @@ -0,0 +1,533 @@ +package observe + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/felinics/twilight/agentcore/ledger" + "github.com/felinics/twilight/agentcore/module" + "github.com/felinics/twilight/agentcore/session" +) + +// History is the shared source of truth followed by SessionLedgerTailer. +type History interface { + ReadCommits(context.Context, session.CommitReadRequest) (session.CommitPage, error) +} + +var ( + // ErrNoHistory reports a durable subscription on a tailer with no ledger. + ErrNoHistory = errors.New("observe: the tailer has no history") + // ErrTailerClosed reports a subscription attempted after Close. + ErrTailerClosed = errors.New("observe: session ledger tailer is closed") +) + +const ( + historyPageCommits = 64 + historyPoll = 100 * time.Millisecond + subscriberSendTimeout = 250 * time.Millisecond +) + +// SessionLedgerTailer follows committed events in shared History. All durable +// subscribers of one Session share one continuous polling loop. Individual +// subscribers may perform a finite, paged catch-up to the loop's attachment +// boundary; after that boundary only the shared loop reads the ledger. +type SessionLedgerTailer struct { + registry *module.Registry + history History + + mu sync.Mutex + closed bool + closeDone chan struct{} + sessions map[session.SessionID]*sessionTail +} + +// NewSessionLedgerTailer returns a durable ledger follower. +func NewSessionLedgerTailer(registry *module.Registry, history History) *SessionLedgerTailer { + return &SessionLedgerTailer{ + registry: registry, history: history, closeDone: make(chan struct{}), + sessions: make(map[session.SessionID]*sessionTail), + } +} + +// Committed implements writer.CommitObserver. A local commit is only a +// coalesced low-latency hint; the polling loop still obtains it from History. +func (l *SessionLedgerTailer) Committed(_ context.Context, sid session.SessionID, _ ledger.Commit) { + l.mu.Lock() + t := l.sessions[sid] + l.mu.Unlock() + if t != nil { + select { + case t.wake <- struct{}{}: + default: + } + } +} + +// SubscribeFrom follows sid from the inclusive commit sequence from. The +// initial head read is completed before this method returns, so invalid +// Sessions and immediately failing histories are reported synchronously. +func (l *SessionLedgerTailer) SubscribeFrom(ctx context.Context, sid session.SessionID, from ledger.CommitSeq) (<-chan Event, error) { + if l.history == nil { + return nil, ErrNoHistory + } + for { + if err := ctx.Err(); err != nil { + return nil, err + } + t, err := l.acquire(ctx, sid) + if err != nil { + return nil, err + } + select { + case <-t.ready: + case <-ctx.Done(): + t.releasePending() + return nil, ctx.Err() + } + if t.initErr != nil { + t.releasePending() + return nil, t.initErr + } + out, err := t.attach(ctx, from) + if !errors.Is(err, ErrTailerClosed) { + return out, err + } + // A shared tail can finish between acquisition and attachment after + // its last previous subscriber leaves or a poll fails. The tailer + // itself is still usable, so transparently acquire the replacement. + } +} + +func (l *SessionLedgerTailer) acquire(ctx context.Context, sid session.SessionID) (*sessionTail, error) { + for { + l.mu.Lock() + if l.closed { + l.mu.Unlock() + return nil, ErrTailerClosed + } + if old := l.sessions[sid]; old != nil { + old.mu.Lock() + if !old.stopping { + old.pending++ + old.mu.Unlock() + l.mu.Unlock() + return old, nil + } + done := old.done + old.mu.Unlock() + l.mu.Unlock() + // Keep the stopping loop registered until it is completely done. + // This lets CloseContext account for every loop and avoids replacing + // it in the small detach-to-cancel window. + select { + case <-done: + continue + case <-ctx.Done(): + return nil, ctx.Err() + } + } + ctx, cancel := context.WithCancel(context.Background()) + t := &sessionTail{ + owner: l, sid: sid, ctx: ctx, cancel: cancel, + ready: make(chan struct{}), active: make(chan struct{}), wake: make(chan struct{}, 1), done: make(chan struct{}), + subs: make(map[*durableSubscriber]struct{}), pending: 1, + } + l.sessions[sid] = t + l.mu.Unlock() + go t.run() + return t, nil + } +} + +// drop atomically removes a finished loop and publishes its completion. +// CloseContext therefore either observes the loop in sessions and waits for +// done, or observes it absent only after done has already closed. +func (l *SessionLedgerTailer) drop(t *sessionTail) { + l.mu.Lock() + if l.sessions[t.sid] == t { + delete(l.sessions, t.sid) + } + close(t.done) + l.mu.Unlock() +} + +// Close stops every Session loop and waits without a deadline. Use +// CloseContext when shutdown must respect a caller's budget. +func (l *SessionLedgerTailer) Close() { _ = l.CloseContext(context.Background()) } + +// CloseContext stops every Session loop and closes all durable subscriptions. +// It is idempotent. The first caller starts the shutdown; every caller waits +// for it only until ctx ends, while shutdown itself continues in the +// background so a later caller can still observe completion. +func (l *SessionLedgerTailer) CloseContext(ctx context.Context) error { + l.mu.Lock() + if !l.closed { + l.closed = true + tails := make([]*sessionTail, 0, len(l.sessions)) + for _, t := range l.sessions { + t.mu.Lock() + t.stopping = true + t.mu.Unlock() + tails = append(tails, t) + } + for _, t := range tails { + t.cancel() + } + done := l.closeDone + go func() { + for _, t := range tails { + <-t.done + } + close(done) + }() + } + done := l.closeDone + l.mu.Unlock() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +type sessionTail struct { + owner *SessionLedgerTailer + sid session.SessionID + ctx context.Context + cancel context.CancelFunc + ready chan struct{} + active chan struct{} + wake chan struct{} + done chan struct{} + + initErr error + + mu sync.Mutex + cursor ledger.CommitSeq + pending int + subs map[*durableSubscriber]struct{} + stopping bool + activeOnce sync.Once + subsWG sync.WaitGroup +} + +func (t *sessionTail) run() { + defer func() { + t.cancel() + t.mu.Lock() + t.stopping = true + for s := range t.subs { + s.stop(nil) + delete(t.subs, s) + } + t.mu.Unlock() + t.subsWG.Wait() + t.owner.drop(t) + }() + + // Limit one is enough to obtain Head without making a first subscriber's + // requested history the shared loop's cursor. + page, err := t.owner.history.ReadCommits(t.ctx, session.CommitReadRequest{SessionID: t.sid, Limit: 1}) + if err != nil { + t.initErr = err + close(t.ready) + return + } + t.mu.Lock() + t.cursor = page.Head.Next + t.mu.Unlock() + close(t.ready) + + select { + case <-t.active: + case <-t.ctx.Done(): + return + } + + for { + t.mu.Lock() + cursor := t.cursor + empty := len(t.subs) == 0 && t.pending == 0 + if empty { + t.stopping = true + } + t.mu.Unlock() + if empty { + return + } + page, err = t.owner.history.ReadCommits(t.ctx, session.CommitReadRequest{ + SessionID: t.sid, From: cursor, Limit: historyPageCommits, + }) + if err != nil { + if t.ctx.Err() == nil { + t.failAll(err) + } + return + } + + type delivery struct { + seq ledger.CommitSeq + events []Event + } + deliveries := make([]delivery, 0, len(page.Commits)) + for i := range page.Commits { + commit := &page.Commits[i] + deliveries = append(deliveries, delivery{seq: commit.Seq, events: decodeCommit(t.owner.registry, t.sid, commit)}) + } + + t.mu.Lock() + for _, d := range deliveries { + if d.seq < t.cursor { + continue + } + for s := range t.subs { + if d.seq >= s.liveFrom && !s.enqueue(d.events) { + delete(t.subs, s) + s.stop(nil) + } + } + t.cursor = d.seq + 1 + } + empty = len(t.subs) == 0 && t.pending == 0 + if empty { + t.stopping = true + } + t.mu.Unlock() + if empty { + return + } + if page.HasMore { + continue + } + timer := time.NewTimer(historyPoll) + select { + case <-t.wake: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + case <-timer.C: + case <-t.ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return + } + } +} + +func (t *sessionTail) attach(ctx context.Context, from ledger.CommitSeq) (<-chan Event, error) { + subCtx, cancel := context.WithCancel(ctx) + s := &durableSubscriber{ + tail: t, ctx: subCtx, cancel: cancel, from: from, + out: make(chan Event, subscriberBuffer), mailbox: make(chan Event, subscriberBuffer), stopCh: make(chan struct{}), + } + t.mu.Lock() + if t.pending > 0 { + t.pending-- + } + if t.stopping || t.ctx.Err() != nil { + t.mu.Unlock() + cancel() + return nil, ErrTailerClosed + } + s.boundary = t.cursor + s.liveFrom = t.cursor + if from > s.liveFrom { + s.liveFrom = from + } + t.subs[s] = struct{}{} + t.subsWG.Add(1) + t.activeOnce.Do(func() { close(t.active) }) + t.mu.Unlock() + go s.run() + return s.out, nil +} + +func (t *sessionTail) releasePending() { + t.mu.Lock() + if t.pending > 0 { + t.pending-- + } + empty := t.pending == 0 && len(t.subs) == 0 + if empty { + t.stopping = true + } + t.mu.Unlock() + if empty { + t.cancel() + } +} + +func (t *sessionTail) detach(s *durableSubscriber) { + t.mu.Lock() + delete(t.subs, s) + empty := t.pending == 0 && len(t.subs) == 0 + if empty { + t.stopping = true + } + t.mu.Unlock() + if empty { + t.cancel() + } +} + +func (t *sessionTail) failAll(err error) { + t.mu.Lock() + t.stopping = true + for s := range t.subs { + s.stop(err) + delete(t.subs, s) + } + t.mu.Unlock() +} + +type durableSubscriber struct { + tail *sessionTail + ctx context.Context + cancel context.CancelFunc + from ledger.CommitSeq + boundary ledger.CommitSeq + liveFrom ledger.CommitSeq + out chan Event + mailbox chan Event + stopCh chan struct{} + stopOnce sync.Once + + errMu sync.Mutex + err error +} + +// enqueue is called under the Session tail's lock and never blocks. +func (s *durableSubscriber) enqueue(events []Event) bool { + for i := range events { + select { + case s.mailbox <- events[i]: + default: + return false + } + } + return true +} + +func (s *durableSubscriber) stop(err error) { + if err != nil { + s.errMu.Lock() + if s.err == nil { + s.err = err + } + s.errMu.Unlock() + } + s.stopOnce.Do(func() { + close(s.stopCh) + s.cancel() + }) +} + +func (s *durableSubscriber) run() { + defer s.tail.subsWG.Done() + defer close(s.out) + defer s.tail.detach(s) + if err := s.catchUp(); err != nil { + if s.terminalError() != nil { + s.emitTerminalError() + } else if s.ctx.Err() == nil && !errors.Is(err, context.Canceled) { + s.emitError(err) + } + return + } + for { + select { + case event := <-s.mailbox: + if err := s.send(event); err != nil { + s.emitTerminalError() + return + } + case <-s.stopCh: + s.emitTerminalError() + return + case <-s.ctx.Done(): + s.emitTerminalError() + return + } + } +} + +func (s *durableSubscriber) catchUp() error { + cursor := s.from + for cursor < s.boundary { + page, err := s.tail.owner.history.ReadCommits(s.ctx, session.CommitReadRequest{ + SessionID: s.tail.sid, From: cursor, Limit: historyPageCommits, + }) + if err != nil { + return err + } + advanced := false + for i := range page.Commits { + commit := &page.Commits[i] + if commit.Seq >= s.boundary { + break + } + events := decodeCommit(s.tail.owner.registry, s.tail.sid, commit) + for j := range events { + if err := s.send(events[j]); err != nil { + return err + } + } + cursor = commit.Seq + 1 + advanced = true + } + if !advanced { + return fmt.Errorf("observe: history ended at commit %d before attachment boundary %d", cursor, s.boundary) + } + } + return nil +} + +func (s *durableSubscriber) send(event Event) error { + select { + case s.out <- event: + return nil + default: + } + timer := time.NewTimer(subscriberSendTimeout) + defer timer.Stop() + select { + case s.out <- event: + return nil + case <-timer.C: + return errors.New("observe: durable subscriber is too slow") + case <-s.stopCh: + return context.Canceled + case <-s.ctx.Done(): + return s.ctx.Err() + } +} + +func (s *durableSubscriber) terminalError() error { + s.errMu.Lock() + defer s.errMu.Unlock() + return s.err +} + +func (s *durableSubscriber) emitTerminalError() { + if err := s.terminalError(); err != nil { + s.emitError(err) + } +} + +func (s *durableSubscriber) emitError(err error) { + select { + case s.out <- Event{Session: s.tail.sid, Err: err}: + default: + } +} diff --git a/agentcore/observe/tailer_test.go b/agentcore/observe/tailer_test.go new file mode 100644 index 00000000..0325182a --- /dev/null +++ b/agentcore/observe/tailer_test.go @@ -0,0 +1,514 @@ +package observe_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/felinics/twilight/agentcore/ledger" + "github.com/felinics/twilight/agentcore/observe" + "github.com/felinics/twilight/agentcore/session" + "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" + "github.com/felinics/twilight/agentcore/session/writer" +) + +type blockingPollHistory struct { + base observe.History + polls atomic.Int32 + started chan struct{} + stopped chan struct{} +} + +func (h *blockingPollHistory) ReadCommits(ctx context.Context, req session.CommitReadRequest) (session.CommitPage, error) { + if req.Limit == 1 { + return h.base.ReadCommits(ctx, req) + } + h.polls.Add(1) + select { + case h.started <- struct{}{}: + default: + } + <-ctx.Done() + select { + case h.stopped <- struct{}{}: + default: + } + return session.CommitPage{}, ctx.Err() +} + +// Durable subscribers of one Session share the same sustained read, and the +// read is canceled as soon as the last subscriber leaves. +func TestTailerSharesOnePollAndStopsAfterLastSubscriber(t *testing.T) { + ctx := context.Background() + store := filestoretest.Store(t) + if _, err := store.Create(ctx, session.CreateRequest{SessionID: "shared"}); err != nil { + t.Fatal(err) + } + history := &blockingPollHistory{base: store, started: make(chan struct{}, 4), stopped: make(chan struct{}, 4)} + tailer := observe.NewSessionLedgerTailer(registry(t), history) + defer tailer.Close() + + ctx1, cancel1 := context.WithCancel(ctx) + if _, err := tailer.SubscribeFrom(ctx1, "shared", 0); err != nil { + t.Fatal(err) + } + select { + case <-history.started: + case <-time.After(time.Second): + t.Fatal("shared polling loop did not start") + } + ctx2, cancel2 := context.WithCancel(ctx) + if _, err := tailer.SubscribeFrom(ctx2, "shared", 0); err != nil { + t.Fatal(err) + } + select { + case <-history.started: + t.Fatal("a second subscriber started another sustained poll") + case <-time.After(50 * time.Millisecond): + } + cancel1() + select { + case <-history.stopped: + t.Fatal("the poll stopped while one subscriber remained") + case <-time.After(50 * time.Millisecond): + } + cancel2() + select { + case <-history.stopped: + case <-time.After(time.Second): + t.Fatal("the last cancellation did not stop the poll") + } + if got := history.polls.Load(); got != 1 { + t.Fatalf("sustained polls = %d, want 1", got) + } + + // A new subscriber arriving as the idle loop retires must transparently + // get a replacement loop, not a transient ErrTailerClosed. + ctx3, cancel3 := context.WithCancel(ctx) + if _, err := tailer.SubscribeFrom(ctx3, "shared", 0); err != nil { + t.Fatal(err) + } + select { + case <-history.started: + case <-time.After(time.Second): + t.Fatal("replacement polling loop did not start") + } + cancel3() + select { + case <-history.stopped: + case <-time.After(time.Second): + t.Fatal("replacement polling loop did not stop") + } + if got := history.polls.Load(); got != 2 { + t.Fatalf("sustained polls after resubscribe = %d, want 2", got) + } +} + +type notifyingHistory struct { + observe.History + poll chan session.CommitReadRequest +} + +func (h *notifyingHistory) ReadCommits(ctx context.Context, req session.CommitReadRequest) (session.CommitPage, error) { + page, err := h.History.ReadCommits(ctx, req) + if req.Limit != 1 { + select { + case h.poll <- req: + default: + } + } + return page, err +} + +func TestTailerLocalCommitWakesPoll(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := filestoretest.Store(t) + reg := registry(t) + const sid session.SessionID = "wake" + if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { + t.Fatal(err) + } + history := ¬ifyingHistory{History: store, poll: make(chan session.CommitReadRequest, 8)} + tailer := observe.NewSessionLedgerTailer(reg, history) + defer tailer.Close() + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{ + Observers: []writer.CommitObserver{tailer}, + }).Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + defer w.Close(ctx) + events, err := tailer.SubscribeFrom(ctx, sid, 0) + if err != nil { + t.Fatal(err) + } + select { + case <-history.poll: // the immediate poll at attachment + case <-time.After(time.Second): + t.Fatal("initial poll did not run") + } + commitRow(t, w, "wake-commit", "woken") + select { + case e := <-events: + if e.Err != nil || e.Value.(rowPayload).Text != "woken" { + t.Fatalf("event = %+v", e) + } + case <-time.After(90 * time.Millisecond): + t.Fatal("local commit did not wake the poll") + } +} + +func TestTailerSubscribersCatchUpFromDifferentPositions(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := filestoretest.Store(t) + reg := registry(t) + const sid session.SessionID = "positions" + if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { + t.Fatal(err) + } + tailer := observe.NewSessionLedgerTailer(reg, store) + defer tailer.Close() + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{ + Observers: []writer.CommitObserver{tailer}, + }).Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + defer w.Close(ctx) + commitRow(t, w, "p0", "a") + commitRow(t, w, "p1", "b") + commitRow(t, w, "p2", "c") + all, err := tailer.SubscribeFrom(ctx, sid, 0) + if err != nil { + t.Fatal(err) + } + late, err := tailer.SubscribeFrom(ctx, sid, 2) + if err != nil { + t.Fatal(err) + } + commitRow(t, w, "p3", "d") + gotAll, _ := texts(t, all, 4) + gotLate, _ := texts(t, late, 2) + if join(gotAll) != "abcd" || join(gotLate) != "cd" { + t.Fatalf("all=%v late=%v", gotAll, gotLate) + } +} + +func join(values []string) string { + var out string + for _, value := range values { + out += value + } + return out +} + +type catchupWindowHistory struct { + base observe.History + started chan struct{} + release chan struct{} +} + +func (h *catchupWindowHistory) ReadCommits(ctx context.Context, req session.CommitReadRequest) (session.CommitPage, error) { + if req.Limit != 1 && req.From == 0 { + select { + case h.started <- struct{}{}: + default: + } + select { + case <-h.release: + case <-ctx.Done(): + return session.CommitPage{}, ctx.Err() + } + } + return h.base.ReadCommits(ctx, req) +} + +func TestTailerCatchupAndSharedLiveHaveNoAttachmentGap(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := filestoretest.Store(t) + reg := registry(t) + const sid session.SessionID = "catchup-window" + if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { + t.Fatal(err) + } + history := &catchupWindowHistory{base: store, started: make(chan struct{}, 1), release: make(chan struct{})} + tailer := observe.NewSessionLedgerTailer(reg, history) + defer tailer.Close() + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{ + Observers: []writer.CommitObserver{tailer}, + }).Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + defer w.Close(ctx) + commitRow(t, w, "window-0", "a") + commitRow(t, w, "window-1", "b") + + liveCtx, stopLive := context.WithCancel(ctx) + defer stopLive() + if _, err := tailer.SubscribeFrom(liveCtx, sid, 2); err != nil { + t.Fatal(err) + } + catchup, err := tailer.SubscribeFrom(ctx, sid, 0) + if err != nil { + t.Fatal(err) + } + select { + case <-history.started: + case <-time.After(time.Second): + t.Fatal("historical catch-up did not reach the attachment window") + } + + // This commit lands after the new subscriber was attached but before its + // historical catch-up can finish. The shared tail must retain it exactly + // once while the subscriber replays the older prefix. + commitRow(t, w, "window-2", "c") + close(history.release) + got, positions := texts(t, catchup, 3) + if join(got) != "abc" { + t.Fatalf("events across attachment window = %v, want [a b c]", got) + } + for i := 1; i < len(positions); i++ { + if !positions[i-1].Less(positions[i]) { + t.Fatalf("positions not strictly increasing: %v", positions) + } + } + select { + case event := <-catchup: + t.Fatalf("duplicate event across attachment window: %+v", event) + case <-time.After(50 * time.Millisecond): + } +} + +func TestSlowDurableSubscriberIsClosed(t *testing.T) { + ctx := context.Background() + store := filestoretest.Store(t) + reg := registry(t) + const sid session.SessionID = "slow-durable" + if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { + t.Fatal(err) + } + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{}).Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 100; i++ { + commitRow(t, w, "slow-"+string(rune('a'+i%26))+string(rune('A'+i/26)), "x") + } + if err := w.Close(ctx); err != nil { + t.Fatal(err) + } + tailer := observe.NewSessionLedgerTailer(reg, store) + defer tailer.Close() + events, err := tailer.SubscribeFrom(ctx, sid, 0) + if err != nil { + t.Fatal(err) + } + // Do not consume until the bounded output has remained full long enough + // for the subscription to classify us as slow. + time.Sleep(500 * time.Millisecond) + deadline := time.After(time.Second) + for { + select { + case _, ok := <-events: + if !ok { + return + } + case <-deadline: + t.Fatal("slow durable subscriber was not closed") + } + } +} + +func TestTailerOverflowWithinCommitResumesFromFullPosition(t *testing.T) { + ctx := context.Background() + store := filestoretest.Store(t) + reg := registry(t) + const sid session.SessionID = "overflow-position" + if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { + t.Fatal(err) + } + tailer := observe.NewSessionLedgerTailer(reg, store) + defer tailer.Close() + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{ + Observers: []writer.CommitObserver{tailer}, + }).Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + defer w.Close(ctx) + + slow, err := tailer.SubscribeFrom(ctx, sid, 0) + if err != nil { + t.Fatal(err) + } + typed := make([]writer.TypedEvent, 256) + for i := range typed { + typed[i] = writer.TypedEvent{Type: rowType, Value: rowPayload{Text: "x"}} + } + result, err := w.Commit(ctx, func(writer.View) (*writer.SemanticGroup, error) { + return &writer.SemanticGroup{CommitID: "large-commit", Batches: []writer.TypedBatch{{ + Domain: ledger.Domain{Name: "z"}, Events: typed, + }}}, nil + }) + if err != nil || result.Outcome != writer.CommitApplied { + t.Fatalf("large commit: outcome=%s err=%v", result.Outcome, err) + } + time.Sleep(500 * time.Millisecond) + + var last ledger.Position + count := 0 + deadline := time.After(2 * time.Second) + for { + select { + case event, ok := <-slow: + if !ok { + if count == 0 || count >= len(typed) { + t.Fatalf("overflow delivered %d events, want a non-empty strict prefix", count) + } + goto reconnect + } + last = event.Position + count++ + case <-deadline: + t.Fatal("overflowed subscriber did not close") + } + } + +reconnect: + replayCtx, stopReplay := context.WithCancel(ctx) + defer stopReplay() + replayed, err := tailer.SubscribeFrom(replayCtx, sid, last.Commit) + if err != nil { + t.Fatal(err) + } + next := last.Index + 1 + deadline = time.After(2 * time.Second) + for next < uint32(len(typed)) { + select { + case event, ok := <-replayed: + if !ok { + t.Fatalf("replay closed at index %d", next) + } + if !last.Less(event.Position) { + continue // Inclusive commit replay: the client de-duplicates this prefix. + } + if event.Position.Commit != last.Commit || event.Position.Index != next { + t.Fatalf("replay position = %+v, want commit %d index %d", event.Position, last.Commit, next) + } + next++ + case <-deadline: + t.Fatalf("replay stopped at index %d", next) + } + } +} + +type failingPollHistory struct { + base observe.History + err error +} + +func (h failingPollHistory) ReadCommits(ctx context.Context, req session.CommitReadRequest) (session.CommitPage, error) { + if req.Limit == 1 { + return h.base.ReadCommits(ctx, req) + } + return session.CommitPage{}, h.err +} + +func TestTailerReadErrorIsReportedThenClosesSubscription(t *testing.T) { + ctx := context.Background() + store := filestoretest.Store(t) + if _, err := store.Create(ctx, session.CreateRequest{SessionID: "read-error"}); err != nil { + t.Fatal(err) + } + want := errors.New("read failed") + tailer := observe.NewSessionLedgerTailer(registry(t), failingPollHistory{base: store, err: want}) + defer tailer.Close() + events, err := tailer.SubscribeFrom(ctx, "read-error", 0) + if err != nil { + t.Fatal(err) + } + deadline := time.After(time.Second) + gotError := false + for { + select { + case event, ok := <-events: + if !ok { + if !gotError { + t.Fatal("read error closed subscription without an error event") + } + return + } + if errors.Is(event.Err, want) { + gotError = true + } + case <-deadline: + t.Fatal("read error did not close subscription") + } + } +} + +type blockingCatchupHistory struct { + base observe.History + started chan struct{} +} + +func (h *blockingCatchupHistory) ReadCommits(ctx context.Context, req session.CommitReadRequest) (session.CommitPage, error) { + if req.Limit != 1 && req.From == 0 { + select { + case h.started <- struct{}{}: + default: + } + <-ctx.Done() + return session.CommitPage{}, ctx.Err() + } + return h.base.ReadCommits(ctx, req) +} + +func TestTailerCloseWaitsForCatchupSubscriber(t *testing.T) { + ctx := context.Background() + store := filestoretest.Store(t) + reg := registry(t) + const sid session.SessionID = "close-catchup" + if _, err := store.Create(ctx, session.CreateRequest{SessionID: sid}); err != nil { + t.Fatal(err) + } + w, err := writer.NewWriters(store, reg, writer.Admission{}, session.OpenOptions{}, writer.WritersConfig{}).Writer(ctx, sid) + if err != nil { + t.Fatal(err) + } + commitRow(t, w, "before-close", "history") + if err := w.Close(ctx); err != nil { + t.Fatal(err) + } + + history := &blockingCatchupHistory{base: store, started: make(chan struct{}, 1)} + tailer := observe.NewSessionLedgerTailer(reg, history) + events, err := tailer.SubscribeFrom(ctx, sid, 0) + if err != nil { + t.Fatal(err) + } + select { + case <-history.started: + case <-time.After(time.Second): + t.Fatal("catch-up read did not start") + } + closeCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + if err := tailer.CloseContext(closeCtx); err != nil { + t.Fatal(err) + } + select { + case _, ok := <-events: + if ok { + t.Fatal("subscriber channel remained open after CloseContext returned") + } + default: + t.Fatal("subscriber channel remained open after CloseContext returned") + } +} diff --git a/agentcore/owner/handle_test.go b/agentcore/owner/handle_test.go index cef16cdb..ac7a41e1 100644 --- a/agentcore/owner/handle_test.go +++ b/agentcore/owner/handle_test.go @@ -5,14 +5,13 @@ import ( "errors" "testing" - "time" - + "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/artifact/artifacttest" "github.com/felinics/twilight/agentcore/chatlog" + "github.com/felinics/twilight/agentcore/prompt" "github.com/felinics/twilight/agentcore/executor" "github.com/felinics/twilight/agentcore/executor/store/storetest" "github.com/felinics/twilight/agentcore/owner" - "github.com/felinics/twilight/agentcore/prompt" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/sessionstore" rt "github.com/felinics/twilight/agentcore/runtime" @@ -20,7 +19,7 @@ import ( "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" "github.com/felinics/twilight/agentcore/sessionkernel" - "github.com/felinics/twilight/agentcore/testutil/local" + "time" ) // newAuthority is the deployment every owner test starts from: a local @@ -66,7 +65,7 @@ func newAuthority(t *testing.T) (*owner.Owner, *sessionkernel.Kernel) { return a, k } -// OWN-HDL-1: one generation of ownership at a time. A second Open of an +// one generation of ownership at a time. A second Open of an // open Session is refused; a Handle whose generation was released does not // close the generation that replaced it; reads need no Handle. func TestHandleGenerations(t *testing.T) { diff --git a/agentcore/owner/lifecycle_internal_test.go b/agentcore/owner/lifecycle_internal_test.go index 550d76a5..ee266af3 100644 --- a/agentcore/owner/lifecycle_internal_test.go +++ b/agentcore/owner/lifecycle_internal_test.go @@ -9,7 +9,7 @@ import ( ) // A generation that is still closing keeps Open out until its release has -// completed (OWN-HDL-1): the table entry, not the resources, decides. +// completed: the table entry, not the resources, decides. func TestOpenRefusesWhileClosing(t *testing.T) { a := &Owner{open: map[session.SessionID]*openSession{"s": {state: closing}}} if _, err := a.Open(context.Background(), "s"); !errors.Is(err, ErrSessionOpen) { diff --git a/agentcore/prompt/errors.go b/agentcore/prompt/errors.go index 45fd9b28..0a73ccef 100644 --- a/agentcore/prompt/errors.go +++ b/agentcore/prompt/errors.go @@ -5,5 +5,5 @@ import ( ) // ErrUnknownPromptBuilder reports a PromptBuilderRef the catalog does not -// hold: the Owner cannot rebuild the Turn's prompt builder (DEC-CAT-2). +// hold: the Owner cannot rebuild the Turn's prompt builder. var ErrUnknownPromptBuilder = errors.New("prompt: unknown prompt builder") diff --git a/agentcore/prompt/sources.go b/agentcore/prompt/sources.go index 327c8d8a..17b66e40 100644 --- a/agentcore/prompt/sources.go +++ b/agentcore/prompt/sources.go @@ -1,7 +1,7 @@ // Package prompt is the model-input seam of the agent core: the catalog that // resolves an AgentPreset's PromptBuilderRef to a PromptBuilder and the // Sources a builder reads from. It holds no builder of its own and no input -// content shape (the agent's input package owns that, DEC-INP-1): how a model +// content shape (the agent's input package owns that): how a model // request is assembled from Session state is a strategy of the agent built on // the core (agent/prompt is the first-party one), named by a ref the // AgentPreset digest covers, so the process that takes a Turn over resolves @@ -18,7 +18,7 @@ import ( // from the Store. type ProjectionSource = session.ProjectionReader -// Sources are the two read ports of a prompt builder (DEC-PMT-1): the +// Sources are the two read ports of a prompt builder: the // structural projections and the content resolver that materializes the // frozen bodies the projections name by digest. Folding is pure; reading a // body is I/O and happens only here. diff --git a/agentcore/run/canonical/canonical_test.go b/agentcore/run/canonical/canonical_test.go index eb65d354..3a2d9a6f 100644 --- a/agentcore/run/canonical/canonical_test.go +++ b/agentcore/run/canonical/canonical_test.go @@ -3,10 +3,10 @@ package canonical import ( "testing" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/wire" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" ) @@ -184,7 +184,7 @@ func TestCanonicalGolden(t *testing.T) { if err != nil { t.Fatal(err) } - // The input body is not in the fact (RUN-WIR-4): the Run records the + // The input body is not in the fact: the Run records the // input's identity and content digest, the chatlog holds the body. wantFact := `v1:14:input_accepted:{"input":{"digest":"sha256:e7b995efa755c5ff3b84d2188b58cb4ae916a59470eb3761df8a814f11763500","id":"in-1"}}` if string(fbody) != wantFact { diff --git a/agentcore/run/canonical/digest.go b/agentcore/run/canonical/digest.go index ccd15d25..fe9fcf84 100644 --- a/agentcore/run/canonical/digest.go +++ b/agentcore/run/canonical/digest.go @@ -9,7 +9,7 @@ import ( "github.com/felinics/twilight/agentcore/run/model" ) -// Envelope types of the bodies facts name by digest only (RUN-WIR-4). The +// Envelope types of the bodies facts name by digest only. The // frozen store (agent/run/frozen) keeps each body as the typed envelope its // digest was computed from, so it encodes and decodes under the same names. const ( @@ -82,7 +82,7 @@ func (Digests) DigestToolResponsePayload(payload run.CanonicalJSON) (run.Digest, } // DigestModelResult names a frozen model result; ModelStepCompleted carries -// this digest and the frozen store holds the body (RUN-WIR-4). +// this digest and the frozen store holds the body. func (Digests) DigestModelResult(result model.ModelResult) (run.Digest, error) { //nolint:gocritic // hugeParam: digest covers the complete immutable ModelResult value. body, err := jsonstable.EncodeTypedPayload(preimageVersion, ModelResultType, result) if err != nil { diff --git a/agentcore/run/canonical/doc.go b/agentcore/run/canonical/doc.go index 357029af..0f33c663 100644 --- a/agentcore/run/canonical/doc.go +++ b/agentcore/run/canonical/doc.go @@ -1,8 +1,8 @@ // Package canonical is the canonical identity of a Run: the digest rules // (Digests) for every body a fact names or a derived identity covers, and the // derivation (Identity) of every RunID-scoped identity -- step, call, -// response, effect and command ids (RUN-WIR-1, RUN-WIR-4). Both are persisted -// semantics: their preimages never change (RUN-CMT-8). The state machine of +// response, effect and command ids. Both are persisted +// semantics: their preimages never change. The state machine of // agentcore/run is composed with them in agentcore/run/schema. The package // also declares the envelope types under which agentcore/run/frozen stores // the bodies these digests name. diff --git a/agentcore/run/canonical/golden_test.go b/agentcore/run/canonical/golden_test.go index 81954529..7bed96c6 100644 --- a/agentcore/run/canonical/golden_test.go +++ b/agentcore/run/canonical/golden_test.go @@ -6,7 +6,7 @@ import ( "github.com/felinics/twilight/agentcore/run" ) -// TestDerivedIdentityGolden freezes every derived identity of RUN-WIR-4 for +// TestDerivedIdentityGolden freezes every derived identity for // one fixed input set. A drift with a non-empty want means the derivation // preimage changed: intentional protocol changes must update this fixture and // agent-run.md; anything else is accidental drift. diff --git a/agentcore/run/canonical/identity.go b/agentcore/run/canonical/identity.go index 9ee675ae..2187f962 100644 --- a/agentcore/run/canonical/identity.go +++ b/agentcore/run/canonical/identity.go @@ -23,11 +23,10 @@ func namespacedHash(namespace string, parts ...string) string { // calls, responses, effects, command ids) is a namespaced hash of its // preimage. The preimages are persisted semantics and never change: a // different derivation would give a replayed command another CommitID -// (RUN-CMT-8). type Identity struct{} // DeriveModelRequestCommandID derives the CommandID for PrepareModelRequest -// from the Run and the RunPosition the prompt builder loaded (RUN-WIR-3): concurrent +// from the Run and the RunPosition the prompt builder loaded: concurrent // prompt builders on the same position converge on one command identity. func (Identity) DeriveModelRequestCommandID(runID run.RunID, position run.RunPosition) run.CommandID { return run.CommandID(namespacedHash("twilight/model-request", string(runID), fmt.Sprintf("%d", position))) @@ -87,7 +86,7 @@ func (Identity) DeriveWithdrawCommandID(runID run.RunID, step run.StepID) run.Co } // DeriveEffectID derives the identity of one request for an external effect -// (RUN-WIR-1): the model call of a ModelStep (empty call) or one tool call of +// the model call of a ModelStep (empty call) or one tool call of // a ToolStep. sequence distinguishes the requests one step makes: a ModelStep // whose result was rejected requests its model call again, so its sequence // is the count of rejections before the request; a tool call starts at most @@ -100,7 +99,7 @@ func (Identity) DeriveEffectID(runID run.RunID, step run.StepID, call run.CallID // DeriveStartCommandID derives the CommandID of StartModelExecution or // StartToolCall from the effect it requests. Commit enforces this derivation -// so a caller-minted ID cannot bypass the idempotency index (RUN-WIR-3). +// so a caller-minted ID cannot bypass the idempotency index. func (Identity) DeriveStartCommandID(effect run.EffectID) run.CommandID { return run.CommandID(namespacedHash("twilight/start-command", string(effect))) } @@ -114,7 +113,7 @@ func (Identity) DeriveSettlementCommandID(effect run.EffectID) run.CommandID { } // DeriveRecoveryCommandID derives the CommandID of the recovery of one effect -// whose outcome cannot be reached (RUN-CMT-7): RecoverModelExecution for a +// whose outcome cannot be reached: RecoverModelExecution for a // model effect, the Unknown SubmitToolFailure for a tool effect. The identity // is a function of the effect alone, so whichever owner repeats the recovery // replays the same command. diff --git a/agentcore/run/clone.go b/agentcore/run/clone.go index fa9da9b3..9e6447e9 100644 --- a/agentcore/run/clone.go +++ b/agentcore/run/clone.go @@ -7,7 +7,7 @@ import ( ) // Deep-copy helpers: Runtime return values must be read-only snapshots -// (RUN-CMT-6) — a caller mutating a returned slice or map must never reach +// — a caller mutating a returned slice or map must never reach // authoritative storage or committed event bytes. // The agent Runtime is an Owner boundary. All persisted request/result // shapes are agent-owned JSON-stable values, so cloning is mechanical: copy diff --git a/agentcore/run/command.go b/agentcore/run/command.go index 163ec996..fffef312 100644 --- a/agentcore/run/command.go +++ b/agentcore/run/command.go @@ -5,10 +5,10 @@ import ( ) // AgentCommand is the intent submitted through Runtime.Commit for an existing -// Run. Accepting one command constitutes one transition (RUN-MCH-3). The +// Run. Accepting one command constitutes one transition. The // interface is sealed: only the variants below exist. Commands may carry // transient content bodies (frozen request, model result, tool output); the -// facts they produce keep only digests (RUN-WIR-4). +// facts they produce keep only digests. type AgentCommand interface{ agentCommand() } // AgentInput is a queue-safe input: a stable ID plus an immutable payload. @@ -16,7 +16,7 @@ type AgentCommand interface{ agentCommand() } // AgentInput is one input as the Run knows it: its identity and the digest of // its content. The content itself lives with the module that owns inputs // (the chatlog's input_submitted); the Run only records that this input -// entered this logical run (RUN-WIR-4), so an input has one body in the +// entered this logical run, so an input has one body in the // ledger and editing or forking it never touches a second copy. type AgentInput struct { ID InputID `json:"id"` @@ -68,7 +68,7 @@ func (StartModelExecution) agentCommand() {} // returns to Open and the next Prepare plans from the current state (the // frozen request is a transfer copy for the Executor, not a replay target). // Sources: the Loop when a dispatched effect ends without a result, or the -// recovery disposition of a new owner (RUN-CMT-7). +// recovery disposition of a new owner. type RecoverModelExecution struct { StepID StepID `json:"stepId"` // Effect is the model effect being recovered: the one the step's @@ -172,7 +172,7 @@ func (SubmitToolFailure) agentCommand() {} // DeclineToolCall fails one Pending call before its tool effect is // requested: the pre-start validation found the call cannot be dispatched -// (RUN-EXE-5). It records ToolCallFailed(Known) with no ToolCallStarted, so +// It records ToolCallFailed(Known) with no ToolCallStarted, so // the call has no effect and no start barrier is crossed. A call is declined // at most once, so the command's identity is the call's coordinates // (DeriveDeclineCommandID) and needs no content. @@ -223,7 +223,7 @@ type SubmitToolResponse struct { func (SubmitToolResponse) agentCommand() {} // CancelRun stops a non-terminal Run as a business cancellation. Hosts must -// commit this before cancelling the Loop's context (RUN-LOP-5). +// commit this before cancelling the Loop's context. type CancelRun struct { Reason RunReason `json:"reason,omitempty"` } diff --git a/agentcore/run/decide.go b/agentcore/run/decide.go index c76c5d29..31415175 100644 --- a/agentcore/run/decide.go +++ b/agentcore/run/decide.go @@ -23,7 +23,7 @@ func rejectionf(format string, args ...any) error { } // Decide produces the facts a command yields against s, or the rejection -// (RUN-MCH-1). +// // //nolint:gocritic // hugeParam: v1 Decide is value-based. func (m StateMachine) Decide(s MachineState, c AgentCommand) ([]Fact, error) { @@ -161,7 +161,7 @@ func currentModelStep(s *MachineState, step StepID) (*ModelStep, error) { // decideStartModelExecution admits the start of the step's next model // effect. The effect identity is derived, so a start that names any other // effect -- one derived against a different rejection count, or minted by -// the caller -- is stale rather than a new grant (RUN-WIR-1). +// the caller -- is stale rather than a new grant. func (m StateMachine) decideStartModelExecution(s *MachineState, cmd StartModelExecution) ([]Fact, error) { ms, err := currentModelStep(s, cmd.StepID) if err != nil { @@ -197,7 +197,7 @@ func (m StateMachine) decideRecoverModelExecution(s *MachineState, cmd RecoverMo } // settlesModelEffect checks that a settlement names the effect the step is -// executing (RUN-WIR-1): a settlement of an earlier effect of the same step +// executing: a settlement of an earlier effect of the same step // is stale. func settlesModelEffect(ms *ModelStep, effect EffectID, op string) error { if effect == "" { @@ -254,7 +254,7 @@ func (m StateMachine) decideSubmitModelResult(s *MachineState, cmd *SubmitModelR } // checkToolCallBindings validates the caller's bindings one-to-one against -// the model result and the frozen ToolSpecs (RUN-MCH-2) and returns them with +// the model result and the frozen ToolSpecs and returns them with // Response cleared, ready for openToolStep to derive. func (m StateMachine) checkToolCallBindings(ms *ModelStep, cmd *SubmitModelResult) ([]ToolCallBinding, error) { if len(cmd.Calls) != len(cmd.Result.ToolCalls) { @@ -319,7 +319,7 @@ func (m StateMachine) checkBindingAgainstResult(b *ToolCallBinding, rc *model.Mo // openToolStep derives the ToolStep identity from its source, attaches a // ResponseRequest to every call whose policy waits, and freezes the -// scheduling (RUN-LOP-1). +// scheduling. func (m StateMachine) openToolStep(runID RunID, source StepID, bindings []ToolCallBinding, scheduling ToolScheduling) (ToolStepOpened, error) { toolStepID := m.Identity.DeriveToolStepID(source) for i := range bindings { @@ -503,7 +503,7 @@ func decideSubmitToolFailure(s *MachineState, cmd SubmitToolFailure) ([]Fact, er } // settlesToolEffect checks that a settlement names the effect the call is -// executing (RUN-WIR-1): the call's one tool effect, recorded by its +// executing: the call's one tool effect, recorded by its // ToolCallStarted. func settlesToolEffect(call *ToolCallState, effect EffectID, op string) error { if effect == "" { @@ -516,7 +516,7 @@ func settlesToolEffect(call *ToolCallState, effect EffectID, op string) error { } // decideDeclineToolCall fails a Pending call before its tool effect is -// requested (RUN-EXE-5): no ToolCallStarted, no effect, no start barrier. +// requested: no ToolCallStarted, no effect, no start barrier. func decideDeclineToolCall(s *MachineState, cmd DeclineToolCall) ([]Fact, error) { ts, err := currentToolStep(s, cmd.StepID) if err != nil { @@ -645,7 +645,7 @@ func decideCancelRun(s *MachineState, cmd CancelRun) ([]Fact, error) { } } // A model call still executing is settled as unknown under its effect - // before the Run stops: RunEnded closes no effect by itself (RUN-WIR-1). + // before the Run stops: RunEnded closes no effect by itself. var uncertainModel StepID if ms, ok := s.Current.(ModelStep); ok && ms.Status == ModelExecuting { uncertainModel = ms.RefValue.ID @@ -684,7 +684,7 @@ func cancelledToolCalls(s *MachineState) []Fact { } // decideAcceptInput queues a batch of inputs in any non-terminal state -// (RUN-MCH-4): PendingInputs is the durable mid-run input queue, consumed by +// PendingInputs is the durable mid-run input queue, consumed by // the next Prepare. A Prepared step with a non-empty queue is withdrawn by // Next. The batch is all-or-nothing: an empty list or an empty InputID is a // rejection, and an input already pending, or repeated inside the batch, is diff --git a/agentcore/run/doc.go b/agentcore/run/doc.go index b633e26f..b57be004 100644 --- a/agentcore/run/doc.go +++ b/agentcore/run/doc.go @@ -13,7 +13,7 @@ // The Machine computes no digest and derives no identity itself: it is bound // to the Canonical and Identity rules of the schema version a Run was created // under, so a Run replays under its own rules whatever the current version is. -// Everything around a Run lives in the subpackages, in two tiers (RUN-SCP-1). +// Everything around a Run lives in the subpackages, in two tiers. // The protocol tier changes with the Run schema or is a port the core defines: // agent/run/model holds the durable model data that facts name by digest, // agent/run/canonical the digest and identity rules, agent/run/wire the diff --git a/agentcore/run/effect/classify.go b/agentcore/run/effect/classify.go index a49eca99..00248821 100644 --- a/agentcore/run/effect/classify.go +++ b/agentcore/run/effect/classify.go @@ -11,7 +11,7 @@ import ( ) // ClassifyModelError maps a model invocation error to a wire-stable -// FailureCode (RUN-EXE-11). Provider errors that expose their HTTP status +// FailureCode. Provider errors that expose their HTTP status // (sdk.APIError) classify by status; transport errors classify as // connection failures; anything else is FailureExecutor, which is not // transient, so an unclassified error is never retried. Cancellation and the diff --git a/agentcore/run/effect/classify_test.go b/agentcore/run/effect/classify_test.go index 9452b923..6192adc8 100644 --- a/agentcore/run/effect/classify_test.go +++ b/agentcore/run/effect/classify_test.go @@ -20,7 +20,7 @@ func (timeoutError) Temporary() bool { return true } var _ net.Error = timeoutError{} -// RUN-EXE-11: provider errors classify by HTTP status, transport errors as +// provider errors classify by HTTP status, transport errors as // connection failures, everything else as executor_error; the disposition // is read off the class, and only the rate limit, provider outage and // connection classes allow a retry. diff --git a/agentcore/run/effect/progress.go b/agentcore/run/effect/progress.go index 43e4171d..3917992f 100644 --- a/agentcore/run/effect/progress.go +++ b/agentcore/run/effect/progress.go @@ -5,7 +5,7 @@ import ( "encoding/json" ) -// ProgressKind names one kind of progress frame (RUN-EXE-12). +// ProgressKind names one kind of progress frame. type ProgressKind string const ( @@ -16,7 +16,7 @@ const ( // ProgressToolProgress carries a tool's own progress payload. ProgressToolProgress ProgressKind = "tool_progress" // ProgressReset opens a new generation of the same effect: the Worker - // re-dispatched it (RUN-EXE-9/11), so every frame of the earlier + // re-dispatched it, so every frame of the earlier // generation is void and the receiver starts over. ProgressReset ProgressKind = "reset" // ProgressEnd closes the stream: the execution reached a terminal state @@ -25,7 +25,7 @@ const ( ) // ProgressFrame is one provisional observation of an execution in flight -// (RUN-EXE-12). Frames are not facts: they may be lost, and a Reset voids +// Frames are not facts: they may be lost, and a Reset voids // the ones before it. Generation counts the effect's attempts under the // Worker (a Reset raises it), Sequence orders frames within the key. type ProgressFrame struct { @@ -43,7 +43,7 @@ type ProgressSink interface { Publish(context.Context, ProgressFrame) } -// ProgressPort is the progress side of an ExecutionPort (RUN-EXE-12), an +// ProgressPort is the progress side of an ExecutionPort, an // optional capability. Progress delivers the frames of key with Sequence // greater than after to fn, in order, until fn returns false, ctx ends, or // an End frame closes the stream; a key the port never saw is diff --git a/agentcore/run/effect/replay_test.go b/agentcore/run/effect/replay_test.go index eeca0c87..fb6b246f 100644 --- a/agentcore/run/effect/replay_test.go +++ b/agentcore/run/effect/replay_test.go @@ -10,7 +10,7 @@ import ( ) // The tool's Replay policy travels with the Assignment across the wire and -// the execution record (RUN-EXE-9); an unjudged tool omits the field, so an +// the execution record; an unjudged tool omits the field, so an // Assignment written before the declaration existed still decodes as unknown. func TestToolAssignmentCarriesReplayPolicy(t *testing.T) { cases := []struct { diff --git a/agentcore/run/effect/settlement.go b/agentcore/run/effect/settlement.go index 8fa97f3a..5775bcca 100644 --- a/agentcore/run/effect/settlement.go +++ b/agentcore/run/effect/settlement.go @@ -11,10 +11,10 @@ import ( // that missed a notice (it was not subscribed, its stream dropped) loses // nothing it cannot recover by reading, so a subscriber and a reader agree // without any coordination between them. -// Sequence orders notices within one port incarnation, so a subscriber that -// reconnects can ask for the ones after the last it saw. It is not durable: -// a port that restarts starts a new incarnation (Epoch changes) and the -// subscriber treats the change as a gap, re-reading every key it waits on. +// Sequence orders notices within the Epoch. A process-local port may change +// Epoch on restart and keep only a bounded window; a shared durable port may +// keep one stable Epoch and replay from its cursor across any replica or +// restart. type Settlement struct { Key AssignmentKey `json:"key"` Epoch string `json:"epoch"` @@ -23,16 +23,13 @@ type Settlement struct { // SettlementPort is the notification side of an ExecutionPort, an optional // capability like ProgressPort. Settlements delivers to fn, in order, every -// Settlement the port records with Sequence greater than after in the -// incarnation named by epoch, then every new one as it happens, until fn -// returns false or ctx ends. An empty epoch, or one the port does not -// recognise, subscribes from the head of the current incarnation: the -// caller re-reads what it waits on (GetOutcome is a plain read) and relies -// on the stream only for what settles afterwards. The port keeps a bounded -// window of past Settlements; asking for a Sequence it has evicted is -// ErrSettlementsEvicted, and the caller re-reads as for an unknown epoch. -// One subscription per port covers every key that port settles, whoever -// dispatched them: the cost of waiting is per executor, not per effect. +// Settlement with Sequence greater than after in the stream named by epoch, +// then every new one as it happens, until fn returns false or ctx ends. A +// durable implementation replays from its shared cursor across replicas and +// restarts; an in-process implementation may start a new Epoch and ask the +// caller to re-read. One subscription per port covers every key that port +// settles, whoever dispatched them: the cost of waiting is per executor, not +// per effect. type SettlementPort interface { Settlements(ctx context.Context, epoch string, after uint64, fn func(Settlement) bool) error } diff --git a/agentcore/run/effect/types.go b/agentcore/run/effect/types.go index fba254ae..ebb25d2a 100644 --- a/agentcore/run/effect/types.go +++ b/agentcore/run/effect/types.go @@ -26,7 +26,7 @@ const ( // Session is the Run's Scope (its Session, in Twilight) and part of the // identity, so a shared executor cannot collide two stores that happen to use // the same Run and effect identifiers. Effect is the Run's own name for the -// effect (RUN-WIR-1) and the only handle the two planes share; which step or +// effect and the only handle the two planes share; which step or // call requested it travels in the Assignment for the backend's use. type AssignmentKey struct { Session run.Scope @@ -58,10 +58,10 @@ type ToolAssignment struct { Policy run.ResponsePolicy // Replay is the tool's declared replay policy, copied from the frozen // call: the Worker that adopts a lost execution decides from it alone - // (RUN-EXE-9). Omitted on the wire when unknown. + // Omitted on the wire when unknown. Replay run.ReplayPolicy `json:",omitempty"` // Placement is the tool's declared placement, copied from the frozen - // call: the Worker's routes select the backend by it (RUN-LOP-9). + // call: the Worker's routes select the backend by it. Placement run.ToolPlacement `json:",omitempty"` } @@ -203,7 +203,7 @@ const ( ) // Retry is the disposition the effect layer derives for a model failure of -// this code (RUN-EXE-11). A model call has no effect on the world, so a +// this code. A model call has no effect on the world, so a // Known failure never leaves anything that a second call could repeat; the // only question is whether the failure may pass, and a rate limit, a // provider outage or a lost connection may, every other code is a definite @@ -247,13 +247,13 @@ type ToolExecutionSucceeded struct{ Result run.ToolExecutionResult } // ToolExecutionFailed is a Known failure the tool reports: the effect did // not complete. Failure.Class says what went wrong; Retry is the tool's -// answer for this failure alone (RUN-EXE-11). RetryAllowed asserts more +// answer for this failure alone. RetryAllowed asserts more // than "this looks transient": that this Known failure suffices to confirm // the attempt produced no external effect that cannot safely be repeated, // so the next attempt of the same Assignment may run. A failure that cannot // confirm that (a payment call that timed out) is not a ToolExecutionFailed // at all but a ToolExecutionUnknown, which enters replay and reconciliation -// (TRN-DUR-4). A tool that does not judge leaves RetryUnknown, never retried. +// A tool that does not judge leaves RetryUnknown, never retried. type ToolExecutionFailed struct { Failure run.ToolFailure Retry run.RetryDisposition @@ -351,11 +351,11 @@ var ( ErrOutcomeUnavailable = errors.New("agent: effect: outcome unavailable") // ErrOutcomeCollected means the executor still holds the key as an // accepted, settled execution but no longer serves its Outcome: the - // Owner acknowledged the settlement (RUN-EXE-13). Definitive, like + // Owner acknowledged the settlement. Definitive, like // ErrOutcomeUnavailable, which it wraps. ErrOutcomeCollected = fmt.Errorf("agent: effect: outcome collected after acknowledgement: %w", ErrOutcomeUnavailable) // ErrExecutionAborted means the key was closed by Abort before any - // Assignment was accepted for it (RUN-EXE-16): a Dispatch under it is + // Assignment was accepted for it: a Dispatch under it is // a definite rejection and nothing will ever be read for it, so it is // an ErrOutcomeUnavailable as well. ErrExecutionAborted = fmt.Errorf("agent: effect: execution aborted before acceptance: %w", ErrOutcomeUnavailable) @@ -367,7 +367,7 @@ var ( // anything started, for a reason of its own that may pass (its record // store was unavailable). Nothing crossed the effect boundary, so the // same Assignment may be dispatched again; it is neither a definite - // rejection of the Assignment nor an unknown outcome (RUN-EXE-3). + // rejection of the Assignment nor an unknown outcome. ErrDispatchRetryable = errors.New("agent: effect: dispatch refused before the effect started; retry later") ) @@ -402,7 +402,7 @@ func (s AttachmentState) Terminal() bool { return s == AttachmentTerminal } // Attachment is the result of an attach/inspection request. Missing is a // proof that no execution exists for the key now; it does not by itself // stop one from being accepted later, so a controller that acts on it -// first closes the key with Abort and disposes on aborted (RUN-EXE-16). +// first closes the key with Abort and disposes on aborted. // Orphaned is the absence of // that proof: a record exists without a live owner, or the executor cannot // tell whether an execution survived (a record store that may have lost the @@ -419,7 +419,7 @@ type Attachment struct { } // Acknowledger is the optional retention hint of an ExecutionPort -// (RUN-EXE-13): the Owner reports that the Outcome of key is settled as a +// the Owner reports that the Outcome of key is settled as a // Session fact and will not be read again. The executor may then collect // the record's payload and Outcome, but it keeps the key as accepted: a // late Dispatch of the same key starts nothing. A port without it relies on @@ -429,7 +429,7 @@ type Acknowledger interface { } // Recoverer is the optional recovery capability of an execution port -// (RUN-EXE-6). RecoverExecution asks the executor to take the orphaned record +// RecoverExecution asks the executor to take the orphaned record // of key back under a live lease and continue it from its persisted payload // and ExecutionRef: attach the previous physical execution, restart it once // the backend proves it missing, or settle it as Unknown when the tool's @@ -452,7 +452,7 @@ type ExecutionPort interface { Attach(context.Context, AssignmentKey) (Attachment, error) // Abort closes key when nothing has been accepted for it, so that no // Dispatch of the key, however late, is accepted afterwards - // (RUN-EXE-16). Abort and the acceptance a Dispatch writes contend for + // Abort and the acceptance a Dispatch writes contend for // the first commit of the key's ledger, and exactly one of them wins. // The result is the key's attachment after the attempt: aborted when // the tombstone stands (written now or before), otherwise the live @@ -462,7 +462,7 @@ type ExecutionPort interface { // GetOutcome is a read, not a wait: it answers at once. An unsettled // execution is ErrOutcomeNotReady; a settled one is its Outcome; a key // the port never accepted is ErrExecutionNotFound; a definitive refusal - // to ever answer is ErrOutcomeUnavailable (RUN-EXE-13). Any other error + // to ever answer is ErrOutcomeUnavailable. Any other error // describes the read operation and says nothing about the execution, // which remains unsettled until a read returns its explicit Outcome. // Learning when to read is SettlementPort's job; a caller without that diff --git a/agentcore/run/effect/watcher.go b/agentcore/run/effect/watcher.go index 905b5f3e..29337acd 100644 --- a/agentcore/run/effect/watcher.go +++ b/agentcore/run/effect/watcher.go @@ -30,10 +30,10 @@ type Watcher struct { // ends or fails. Zero selects DefaultWatchReconnect. Reconnect time.Duration // Probe is how long a registered key may wait without an Outcome before - // the Watcher attaches it (RUN-EXE-3): an orphaned execution, whose + // the Watcher attaches it: an orphaned execution, whose // Worker died holding it, is handed to Port's RecoverExecution when // Port implements Recoverer, so a live drive survives a worker - // replacement without an owner takeover (CLD-DEV-2). Each key is probed + // replacement without an owner takeover. Each key is probed // at most once per Probe. Zero selects DefaultWatchProbe; negative // disables probing. Probe time.Duration @@ -54,7 +54,12 @@ type Watcher struct { } // DefaultWatchPoll is the Watcher's read interval when no notice arrives. -const DefaultWatchPoll = 30 * time.Second +// Settlement notices are an optimization rather than the authority, and a +// distributed ExecutionPort may route a subscription to a different +// process-local notice hub than the one that settled the key. Keep the +// fallback bounded so a missed notice cannot hold a Turn for a lease-sized +// interval. +const DefaultWatchPoll = time.Second // DefaultWatchReconnect is the Watcher's pause before reopening its stream. const DefaultWatchReconnect = time.Second @@ -193,7 +198,7 @@ func (w *Watcher) run() { // probeStale attaches every key that has waited at least probe since it // was registered or last probed, and asks for the recovery of an orphaned -// one (RUN-EXE-3, RUN-EXE-6). Attach failures and refusals are left to the +// one. Attach failures and refusals are left to the // next probe: the Worker's record is the authority, the probe only asks. func (w *Watcher) probeStale(probe time.Duration) { now := time.Now() diff --git a/agentcore/run/evolve.go b/agentcore/run/evolve.go index dcc509fc..ede0493d 100644 --- a/agentcore/run/evolve.go +++ b/agentcore/run/evolve.go @@ -7,7 +7,7 @@ import ( "github.com/felinics/twilight/agentcore/run/model" ) -// Evolve is the fold semantics (RUN-MCH-3). It first +// Evolve is the fold semantics. It first // checks that the fact is a legal transition from s (fold and recovery must // defend themselves without access to commands), then applies it. // @@ -69,7 +69,7 @@ func applyRunCreated(fact *RunCreated) MachineState { return MachineState{RunID: fact.RunID, Status: RunActive, Current: Open{}} } -func applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state s.Current = ModelStep{ RefValue: StepRef{RunID: s.RunID, ID: fact.StepID}, RequestDigest: fact.RequestDigest, @@ -86,7 +86,7 @@ func applyModelStepPrepared(s MachineState, fact *ModelStepPrepared) MachineStat // never sent) and Recovered (Executing, attempt lost) both return the Run to // Open without counting a model step. PendingInputs are untouched, so the // next Prepare consumes them. -func applyModelStepWithdrawn(s MachineState) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyModelStepWithdrawn(s MachineState) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state s.Current = Open{} s.ModelSteps-- return s @@ -94,7 +94,7 @@ func applyModelStepWithdrawn(s MachineState) MachineState { //nolint:gocritic // // applyModelStatus moves the current ModelStep to status, adding usage and // counting a reject when the fact was a rejection. -func applyModelStatus(s MachineState, status ModelStepStatus, usage model.Usage, rejected bool) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyModelStatus(s MachineState, status ModelStepStatus, usage model.Usage, rejected bool) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state ms := s.Current.(ModelStep) //nolint:errcheck // guard established Current is this ModelStep ms.Status = status if rejected { @@ -106,7 +106,7 @@ func applyModelStatus(s MachineState, status ModelStepStatus, usage model.Usage, } // applyModelEffect records the effect the current ModelStep requested. -func applyModelEffect(s MachineState, effect EffectID) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyModelEffect(s MachineState, effect EffectID) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state ms := s.Current.(ModelStep) //nolint:errcheck // caller established Current is a ModelStep ms.Effect = effect s.Current = ms @@ -116,18 +116,18 @@ func applyModelEffect(s MachineState, effect EffectID) MachineState { //nolint:g // applyModelStepFailed settles the step's effect and leaves the Run Open for // the RunEnded that follows in the same group. The step ran, so ModelSteps // keeps counting it. -func applyModelStepFailed(s MachineState) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyModelStepFailed(s MachineState) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state s.Current = Open{} return s } -func applyModelStepCompleted(s MachineState, fact *ModelStepCompleted) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyModelStepCompleted(s MachineState, fact *ModelStepCompleted) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state s.Usage = s.Usage.Add(fact.Usage) s.Current = Open{} return s } -func applyToolStepOpened(s MachineState, fact *ToolStepOpened) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyToolStepOpened(s MachineState, fact *ToolStepOpened) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state calls := make([]ToolCallState, len(fact.Calls)) for i, b := range fact.Calls { calls[i] = ToolCallState{ @@ -157,7 +157,7 @@ func applyToolStepOpened(s MachineState, fact *ToolStepOpened) MachineState { // // applyCall mutates one call of the current ToolStep and closes the step when // every call has reached Completed or Failed. -func applyCall(s MachineState, callID CallID, mutate func(*ToolCallState)) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyCall(s MachineState, callID CallID, mutate func(*ToolCallState)) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state ts := s.Current.(ToolStep) //nolint:errcheck // guard established Current is this ToolStep calls := append([]ToolCallState(nil), ts.Calls...) mutate(&calls[ts.callIndex(callID)]) @@ -171,12 +171,12 @@ func applyCall(s MachineState, callID CallID, mutate func(*ToolCallState)) Machi return s } -func applyInputAccepted(s MachineState, fact *InputAccepted) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyInputAccepted(s MachineState, fact *InputAccepted) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state s.PendingInputs = append(append([]AgentInput(nil), s.PendingInputs...), fact.Input) return s } -func applyRunEnded(s MachineState, fact *RunEnded) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state (RUN-MCH-3) +func applyRunEnded(s MachineState, fact *RunEnded) MachineState { //nolint:gocritic // hugeParam: Evolve is a pure value transition; the caller keeps its state status, reason, failure := endProjection(fact.End) s.Status = status s.Current = nil @@ -268,7 +268,7 @@ func (m StateMachine) guardFact(s *MachineState, f Fact) error { } // requireNoExecutingEffect: a Run ends only after every effect it requested -// was settled by a fact naming it (RUN-WIR-1). RunEnded closes no effect, so +// was settled by a fact naming it. RunEnded closes no effect, so // an Executing model step or tool call at that point would be an effect no // settlement ever names. func requireNoExecutingEffect(s *MachineState) error { @@ -305,7 +305,7 @@ func requireModelStep(s *MachineState, stepID StepID, status ModelStepStatus) er // requireModelEffect is requireModelStep for an Executing step plus the // check that a settlement names the effect the step is executing: a -// settlement fact carries the EffectID its start fact recorded (RUN-WIR-1). +// settlement fact carries the EffectID its start fact recorded. func requireModelEffect(s *MachineState, stepID StepID, effect EffectID) error { if err := requireModelStep(s, stepID, ModelExecuting); err != nil { return err diff --git a/agentcore/run/fact.go b/agentcore/run/fact.go index 1207fb7f..1181e68b 100644 --- a/agentcore/run/fact.go +++ b/agentcore/run/fact.go @@ -12,15 +12,15 @@ import ( ) // Fact is one committed outcome produced by Machine.Decide. Facts are wrapped -// as AgentEvents; Machine.Evolve folds them mechanically (RUN-MCH-3). The +// as AgentEvents; Machine.Evolve folds them mechanically. The // interface is sealed: only the variants below exist. Facts carry execution // state and content digests only: a digest is the canonical identity of an // immutable body in the frozen.Store, and the fact that names it is the -// body's retention root (RUN-WIR-4). Conversation and Turn state are +// body's retention root. Conversation and Turn state are // projections of these facts; no second copy of a body is written. type Fact interface{ fact() } -// RunCreated is the first fact of a Run (RUN-NEW-1). Folding it onto the zero +// RunCreated is the first fact of a Run. Folding it onto the zero // MachineState yields the initial state; a second RunCreated is an error. type RunCreated struct { RunID RunID `json:"runId"` @@ -32,7 +32,7 @@ func (RunCreated) fact() {} // ModelStepPrepared establishes the frozen ModelStep and consumes the listed // pending inputs. The request body is not in the fact: RequestDigest names it // in the frozen.Store. StepID derives from the Run and the preparing -// command (RUN-WIR-3); the fact carries the step's content beside it. +// command; the fact carries the step's content beside it. type ModelStepPrepared struct { StepID StepID `json:"stepId"` Model ModelRef `json:"model"` @@ -53,8 +53,7 @@ func (ModelStepWithdrawn) fact() {} // ModelStepStarted: Prepared -> Executing. Effect identifies the model effect // the step requested: a late Outcome is accepted only under it, and a new -// owner recovers the step under a CommandID derived from it (RUN-WIR-1, -// RUN-CMT-7). +// owner recovers the step under a CommandID derived from it. type ModelStepStarted struct { StepID StepID `json:"stepId"` Effect EffectID `json:"effect"` @@ -63,14 +62,14 @@ type ModelStepStarted struct { func (ModelStepStarted) fact() {} // ModelStepRecovered: Executing -> Open. The model effect the step requested -// is lost and its result cannot be reached (RUN-CMT-7), so the step is withdrawn +// is lost and its result cannot be reached, so the step is withdrawn // like a Prepared step whose request went stale: the frozen request is not // resent. Recovery is a decision point -- the next Prepare plans again from // the state at recovery time, including inputs delivered meanwhile -- and the -// record shows it as one (TRN-DUR-1). ModelSteps is not counted for it. +// record shows it as one. ModelSteps is not counted for it. type ModelStepRecovered struct { StepID StepID `json:"stepId"` - // Effect is the model effect the recovery closes (RUN-CMT-7). + // Effect is the model effect the recovery closes. Effect EffectID `json:"effect,omitempty"` } @@ -81,7 +80,7 @@ func (ModelStepRecovered) fact() {} // settles the model effect the step was executing. type ModelStepRejected struct { StepID StepID `json:"stepId"` - // Effect is the model effect this rejection settles (RUN-WIR-1). + // Effect is the model effect this rejection settles. Effect EffectID `json:"effect,omitempty"` Usage model.Usage `json:"usage"` Failure StepFailure `json:"failure"` @@ -95,10 +94,10 @@ func (ModelStepRejected) fact() {} // CancelRun writes it with class effect_unknown for a model call still // executing, before RunEnded(stopped). RunEnded itself settles no effect: // every effect a Run requested is closed by a fact that names it -// (RUN-WIR-1), so a Run never ends with a step or call still Executing. +// so a Run never ends with a step or call still Executing. type ModelStepFailed struct { StepID StepID `json:"stepId"` - // Effect is the model effect this failure settles (RUN-WIR-1). + // Effect is the model effect this failure settles. Effect EffectID `json:"effect"` Failure StepFailure `json:"failure"` } @@ -112,7 +111,7 @@ func (ModelStepFailed) fact() {} type ModelStepCompleted struct { StepID StepID `json:"stepId"` // Effect is the model effect this settlement closes: the fact names the - // execution it answers, as its start fact did (RUN-WIR-1). + // execution it answers, as its start fact did. Effect EffectID `json:"effect,omitempty"` Usage model.Usage `json:"usage"` FinishReason model.FinishReason `json:"finishReason"` @@ -158,7 +157,7 @@ func (ToolCallApproved) fact() {} type ToolCallCompleted struct { StepID StepID `json:"stepId"` CallID CallID `json:"callId"` - // Effect is the tool effect this settlement closes (RUN-WIR-1). + // Effect is the tool effect this settlement closes. Effect EffectID `json:"effect,omitempty"` OutputDigest Digest `json:"outputDigest"` } diff --git a/agentcore/run/frozen/doc.go b/agentcore/run/frozen/doc.go index 0fd83ba9..6d9dbd6e 100644 --- a/agentcore/run/frozen/doc.go +++ b/agentcore/run/frozen/doc.go @@ -1,4 +1,4 @@ -// Package frozen is the frozen-body protocol of a Run (RUN-WIR-4). Facts name +// Package frozen is the frozen-body protocol of a Run. Facts name // the model request, model result, tool output and external tool response by // digest only; Store is the port to the content-addressed side store that holds // the bodies, and Codec stores each body as the typed envelope its digest was diff --git a/agentcore/run/frozen/frozen.go b/agentcore/run/frozen/frozen.go index 48debc89..f74d8a0d 100644 --- a/agentcore/run/frozen/frozen.go +++ b/agentcore/run/frozen/frozen.go @@ -13,7 +13,7 @@ import ( ) // Store is the run layer's port to the content-addressed side store for -// the bodies that facts name by digest only (RUN-WIR-4): the +// the bodies that facts name by digest only: the // ModelRequest of a Prepared step, the ModelResult of a completed step, a // tool output and an external tool response. The digest is the SHA-256 of // the stored bytes, so a body is one cas entry whose key is its digest; the diff --git a/agentcore/run/ids.go b/agentcore/run/ids.go index 11732736..80e97690 100644 --- a/agentcore/run/ids.go +++ b/agentcore/run/ids.go @@ -52,5 +52,5 @@ type Scope string // RunPosition is the index of a Run's last fact in the Run's own stream. // Only the Run's own facts move it; whatever else the surrounding store // appends leaves it untouched, which is what makes Prepare's hard CAS -// insensitive to concurrent writes of other modules (RUN-CMT-4). +// insensitive to concurrent writes of other modules. type RunPosition uint64 diff --git a/agentcore/run/input_test.go b/agentcore/run/input_test.go index 8dd2a6ec..924e8046 100644 --- a/agentcore/run/input_test.go +++ b/agentcore/run/input_test.go @@ -5,8 +5,6 @@ import ( "github.com/felinics/twilight/agentcore/run" ) -// inputDigest names an input body in tests. The Run stores only the digest, so -// any content-derived digest stands in for the chatlog's input body. -func inputDigest(raw string) run.Digest { - return jsonstable.DigestBytes([]byte(raw)) -} +// inputDigest names an input body in tests: the Run stores only the digest, +// so any content-derived digest stands in for the chatlog's. +func inputDigest(raw string) run.Digest { return jsonstable.DigestBytes([]byte(raw)) } diff --git a/agentcore/run/loop/contract.go b/agentcore/run/loop/contract.go index a3148906..0e154fa4 100644 --- a/agentcore/run/loop/contract.go +++ b/agentcore/run/loop/contract.go @@ -14,7 +14,7 @@ import ( // ErrRunAlreadyRunning identifies a second local driver for the same Run. // A Loop permits concurrent execution of different Runs and serializes each -// Run locally so every Executing target has one in-process owner (RUN-CMT-6). +// Run locally so every Executing target has one in-process owner. var ErrRunAlreadyRunning = errors.New("agent: loop: run already running") // EffectContext identifies the effect a target is resolved for: the Run's @@ -34,7 +34,7 @@ type EffectContext struct { Placement run.ToolPlacement } -// TargetResolver supplies an opaque target for one effect (RUN-LOP-9). It +// TargetResolver supplies an opaque target for one effect. It // belongs to the application/resource layer: the Loop asks it once for every // effect it is about to start, before the start barrier, and only copies the // returned reference into that effect's Assignment. A nil target means the @@ -45,21 +45,21 @@ type TargetResolver interface { } // Settings are the execution parameters the Loop takes from the AgentPreset -// (RUN-LOP-1). Scheduling is frozen onto each ToolStep; MalformedRetries +// Scheduling is frozen onto each ToolStep; MalformedRetries // bounds the retries of one model step after malformed results. type Settings struct { Scheduling run.ToolScheduling MalformedRetries uint8 TargetResolver TargetResolver // BeforePrepare runs each time the Run is Open and about to plan a model - // request, before the PromptBuilder reads the context (RUN-LOP-10). It + // request, before the PromptBuilder reads the context. It // is the application's seam for reshaping that context between steps, - // such as an in-turn checkpoint (APP-CKP-1); it commits through the + // such as an in-turn checkpoint; it commits through the // Writer the store is bound to. An error stops the drive with no fact // written; nil is no hook. BeforePrepare PrepareHook // Dispatch bounds the re-offers of an Assignment the Executor refused - // as retryable (RUN-EXE-3); the zero value selects the defaults. + // as retryable; the zero value selects the defaults. Dispatch DispatchPolicy // Watcher is where a blocking Run waits for the Outcomes it dispatched: // one per (owner, executor), shared with the Reconciler, so waiting on @@ -74,7 +74,7 @@ type Settings struct { // PromptBuilder. type PrepareHook func(ctx context.Context, store store.RunStore, input prompt.Input) error -// --- EventSink: realtime observation, never authority (RUN-LOP-6) --- +// --- EventSink: realtime observation, never authority --- type EventSink interface { Emit(context.Context, Event) error @@ -99,7 +99,7 @@ const ( EventRunFinished EventKind = "run_finished" // EventProgressReset voids the provisional observations of an effect // received so far: the Worker re-dispatched it and a new Generation of - // deltas begins (RUN-EXE-12). + // deltas begins. EventProgressReset EventKind = "progress_reset" ) @@ -168,7 +168,7 @@ const ( ) // DispatchPolicy is how a Loop repeats a Dispatch the Executor refused with -// effect.ErrDispatchRetryable (RUN-EXE-3): at most Retries offers inside one +// effect.ErrDispatchRetryable: at most Retries offers inside one // Advance, Backoff multiplied by the attempts so far between them. It is a // deployment's setting, not part of the AgentPreset. type DispatchPolicy struct { diff --git a/agentcore/run/loop/effectref.go b/agentcore/run/loop/effectref.go index 231b0b83..06fac8bc 100644 --- a/agentcore/run/loop/effectref.go +++ b/agentcore/run/loop/effectref.go @@ -11,7 +11,7 @@ import ( // effectRef is one effect of a Run as this Loop addresses it: the derived // EffectID the start fact records, with the schema whose Identity derives the -// start, settlement and recovery CommandIDs from it (RUN-WIR-1). It holds +// start, settlement and recovery CommandIDs from it. It holds // nothing of the physical execution -- worker, lease and backend handle stay // in the executor -- so any owner that reads the same state derives the same // identities and a repeated command replays instead of granting twice. @@ -60,7 +60,7 @@ func (l *Loop) settle(ctx context.Context, rt store.RunStore, events EventSink, } l.emitCommitted(ctx, events, rt.Scope(), e.runID, res.Facts) // The settlement is a Session fact: the executor may collect the - // effect's record (RUN-EXE-13). A refused or failed acknowledgement + // effect's record. A refused or failed acknowledgement // changes nothing here; the executor's time-based collection covers it. if a, ok := l.Executor.(Acknowledger); ok { _ = a.Acknowledge(ctx, AssignmentKey{Session: rt.Scope(), RunID: e.runID, Effect: e.id}) @@ -71,5 +71,5 @@ func (l *Loop) settle(ctx context.Context, rt store.RunStore, events EventSink, return nil, nil } -// ownershipLost reports the terminal ownership error (RUN-LOP-5). +// ownershipLost reports the terminal ownership error. func ownershipLost(err error) bool { return errors.Is(err, store.ErrOwnershipLost) } diff --git a/agentcore/run/loop/error_test.go b/agentcore/run/loop/error_test.go index 349b21a6..6762c962 100644 --- a/agentcore/run/loop/error_test.go +++ b/agentcore/run/loop/error_test.go @@ -13,7 +13,7 @@ func TestCatalogResolveErrorLeavesRunActive(t *testing.T) { f := newFeature(t) f.ModelResolveError(missing) // Validate finds the missing model before the start barrier: no start or - // recovery fact, the step stays Prepared for a later drive (RUN-EXE-5). + // recovery fact, the step stays Prepared for a later drive. f.RunError(loop.ErrModelUnavailable) f.RequireActive() f.RequirePrepared() diff --git a/agentcore/run/loop/events.go b/agentcore/run/loop/events.go index 30bbe130..89ac3cfa 100644 --- a/agentcore/run/loop/events.go +++ b/agentcore/run/loop/events.go @@ -36,7 +36,7 @@ func (l *Loop) emitCommitted(ctx context.Context, events EventSink, scope run.Sc } // forwardProgress subscribes to key's frames on a ProgressPort and emits -// each as a provisional Event on the drive's sink (RUN-LOP-6): this is how +// each as a provisional Event on the drive's sink: this is how // deltas produced wherever the effect runs reach the host's observation // stream. It ends with the stream, the context, or a port that has no // progress for the key. diff --git a/agentcore/run/loop/executor_test.go b/agentcore/run/loop/executor_test.go index 061d9073..9adf5b56 100644 --- a/agentcore/run/loop/executor_test.go +++ b/agentcore/run/loop/executor_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/felinics/twilight/agentcore/testutil/local" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" . "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/effect" "github.com/felinics/twilight/agentcore/run/frozen" @@ -15,8 +17,6 @@ import ( "github.com/felinics/twilight/agentcore/run/reconcile" "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/testutil/local" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" ) @@ -218,7 +218,7 @@ func (r *recordingTargetResolver) ResolveTarget(_ context.Context, ec EffectCont // The resolver is asked once per effect, before the effect starts, with the // effect's own coordinates, and its answer lands on that effect's Assignment -// only (RUN-LOP-9). +// only. func TestTargetResolvedPerEffect(t *testing.T) { rt, w := loopRuntime(t) exec := newRecordingExecutor() @@ -282,7 +282,7 @@ func TestAdvanceCopiesOpaqueTargetIntoAssignment(t *testing.T) { // Advance records the start barrier and hands the model call to the executor // without waiting for it; Deliver settles the Outcome and the next Advance -// finishes the Run (RUN-EXE-3/4). +// finishes the Run. func TestAdvanceDispatchesAndDeliverSettles(t *testing.T) { rt, w := loopRuntime(t) exec := newRecordingExecutor() @@ -317,7 +317,7 @@ func TestAdvanceDispatchesAndDeliverSettles(t *testing.T) { t.Fatalf("deliver = %+v %v", delivered, err) } // The settlement is a fact: the executor is told it may collect the - // effect's record (RUN-EXE-13). + // effect's record. exec.mu.Lock() acked := append([]AssignmentKey(nil), exec.acknowledged...) exec.mu.Unlock() @@ -426,7 +426,7 @@ func TestDeliverDropsStaleOutcome(t *testing.T) { // Takeover with a reachable executor: the new owner's RecoverInterrupted asks // the executor, which still holds an attempt for the effect, so the step stays -// Executing under its original effect and that Outcome settles it (RUN-CMT-7). +// Executing under its original effect and that Outcome settles it. func TestTakeoverReattachesRunningAttempt(t *testing.T) { stack := newTestStack(t, nil) stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) @@ -519,7 +519,7 @@ func TestTakeoverDisposesWhenAttachIsFalse(t *testing.T) { t.Fatalf("RecoverInterrupted = %d %v attached=%d, want one disposition after one refused attach", n, err, len(exec.attached)) } // The unreachable attempt is withdrawn: the Run is Open, the step is not - // counted, and the next Advance plans again (TRN-DUR-1). + // counted, and the next Advance plans again. state := loadState(t, stack.runtime, stack.writer(t), "run-1").State if _, open := state.Current.(Open); !open || state.ModelSteps != 0 { t.Fatalf("state after disposition = %+v, want Open with no counted step", state) @@ -598,7 +598,7 @@ func TestLocalExecutorAttachAndCancel(t *testing.T) { } // A model outcome that reports cancellation withdraws the step to Open for -// replanning rather than failing the Run (RUN-LOP-3). +// replanning rather than failing the Run. func TestDeliverCancelledModelRecovers(t *testing.T) { rt, w := loopRuntime(t) exec := newRecordingExecutor() @@ -625,7 +625,7 @@ func TestDeliverCancelledModelRecovers(t *testing.T) { // A frozen body a remote executor reports missing withdraws the step (the // settlement lands, the Run is Open) and the condition comes back as an error, // so the drive stops instead of prompt building again against the same missing -// store. A later Advance -- the host's decision -- plans afresh (RUN-LOP-3). +// store. A later Advance -- the host's decision -- plans afresh. func TestDeliverMissingFrozenBodyWithdrawsAndReturnsTheError(t *testing.T) { rt, w := loopRuntime(t) exec := newRecordingExecutor() @@ -728,7 +728,7 @@ func mustModel(t testing.TB, a Assignment) ModelAssignment { // Validate refuses a tool Assignment whose Replay policy or Placement // differs from the tool's own declaration, the same definition-mismatch -// answer as a response policy that differs (RUN-EXE-9, RUN-LOP-9); Restart +// answer as a response policy that differs; Restart // itself only derives the Ref. func TestLocalExecutorValidateChecksToolDeclarations(t *testing.T) { cases := []struct { diff --git a/agentcore/run/loop/feature_test.go b/agentcore/run/loop/feature_test.go index ed0b54d9..130226e4 100644 --- a/agentcore/run/loop/feature_test.go +++ b/agentcore/run/loop/feature_test.go @@ -10,6 +10,8 @@ import ( "context" "errors" "fmt" + "github.com/felinics/twilight/agentcore/testutil/local" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/artifact/artifacttest" "github.com/felinics/twilight/agentcore/executor" "github.com/felinics/twilight/agentcore/executor/store/storetest" @@ -24,8 +26,6 @@ import ( "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" - "github.com/felinics/twilight/agentcore/testutil/local" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" "testing" @@ -516,7 +516,7 @@ func (f *Feature) facts() []run.Fact { } // withEffect fills the derived effect identity of a start or recovery that -// names none (RUN-WIR-1): the model effect from the current step's rejected +// names none: the model effect from the current step's rejected // count, the tool effect from the call, the recovered effect from the step. func (f *Feature) withEffect(cmd run.AgentCommand, snap store.Snapshot) run.AgentCommand { switch c := cmd.(type) { diff --git a/agentcore/run/loop/helpers_test.go b/agentcore/run/loop/helpers_test.go index 3b1108a4..9dc2b7f4 100644 --- a/agentcore/run/loop/helpers_test.go +++ b/agentcore/run/loop/helpers_test.go @@ -3,17 +3,15 @@ package loop import ( "context" "errors" - "testing" - "time" - + "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/artifact" "github.com/felinics/twilight/agentcore/artifact/artifacttest" + "github.com/felinics/twilight/agentcore/prompt" "github.com/felinics/twilight/agentcore/executor" "github.com/felinics/twilight/agentcore/executor/store/storetest" "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/ledger" "github.com/felinics/twilight/agentcore/module" - "github.com/felinics/twilight/agentcore/prompt" . "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/agentcore/run/sessionstore" @@ -22,7 +20,8 @@ import ( "github.com/felinics/twilight/agentcore/session" "github.com/felinics/twilight/agentcore/session/filestore/filestoretest" "github.com/felinics/twilight/agentcore/session/writer" - "github.com/felinics/twilight/agentcore/testutil/local" + "testing" + "time" ) const ( @@ -92,7 +91,7 @@ func (s *testStack) writer(t testing.TB) writer.Writer { return w } -// createRun appends the Start group of one Run with a seed input (RUN-NEW-1). +// createRun appends the Start group of one Run with a seed input. func (s *testStack) createRun(t testing.TB, runID RunID, inputs ...AgentInput) { t.Helper() newRun, err := BuildNewRun(runID, "") @@ -155,7 +154,7 @@ func loadState(t testing.TB, rt *sessionstore.SessionRunStore, w writer.Writer, } // newLoop builds a Loop over a local.LocalExecutor for tests; the executor no -// longer reads frozen bodies (RUN-EXE-7), so the runtime is not wired in. +// longer reads frozen bodies, so the runtime is not wired in. func newLoop(t testing.TB, sink EventSink, models local.ModelCatalog, tools local.ToolCatalog, builder prompt.Builder, settings Settings, streaming bool) (*Loop, error) { if models == nil { return nil, errors.New("agent: loop: nil model catalog") diff --git a/agentcore/run/loop/loop.go b/agentcore/run/loop/loop.go index 72877818..0c58cda4 100644 --- a/agentcore/run/loop/loop.go +++ b/agentcore/run/loop/loop.go @@ -15,7 +15,7 @@ import ( "github.com/felinics/twilight/agentcore/run/store" ) -// Loop is the decision interpreter of one Run (RUN-LOP-2). It holds no +// Loop is the decision interpreter of one Run. It holds no // authoritative state: every step starts from RunStore.Load, derives the next // action with plan.Next, records the protocol transition and, for a start, // hands the requested effect to the Executor as an Assignment keyed by its @@ -37,7 +37,7 @@ type Loop struct { // runSlot serializes one Run: step guards a single Advance or Deliver at a // time; driving marks a blocking Run in progress so a second driver is -// reported instead of interleaved (RUN-CMT-6). refs counts the callers holding +// reported instead of interleaved. refs counts the callers holding // the slot; the entry is dropped with the last release, so the map is bounded // by the Runs being driven now rather than by every Run the Loop has seen. type runSlot struct { @@ -46,7 +46,7 @@ type runSlot struct { refs int } -// New validates the settings (RUN-LOP-1) and binds the executor and the +// New validates the settings and binds the executor and the // prompt builder. func New(exec Executor, builder prompt.Builder, settings Settings) (*Loop, error) { if exec == nil { @@ -73,10 +73,10 @@ func (l *Loop) toolScheduling() run.ToolScheduling { } // targetFor resolves the opaque target of the effect ec describes -// (RUN-LOP-9). A nil resolver or a nil answer leaves the Assignment without a +// A nil resolver or a nil answer leaves the Assignment without a // target; an incomplete answer is an error and the effect does not start. // dispatchRetries and dispatchBackoff bound the Loop's answer to -// effect.ErrDispatchRetryable (RUN-EXE-3): the executor refused before +// effect.ErrDispatchRetryable: the executor refused before // anything started, so the same Assignment is offered again a few times // inside this Advance, per Settings.Dispatch, before the refusal is treated // like any other Known dispatch failure. @@ -206,13 +206,13 @@ func (l *Loop) wrapSink(events EventSink) EventSink { } // Advance moves the Run to its next quiescent point without waiting on any -// effect (RUN-LOP-2): it records protocol transitions (prepare, withdraw, +// effect: it records protocol transitions (prepare, withdraw, // start barriers) and dispatches Assignments, then returns LoopDispatched, // LoopWaiting or LoopFinished. The caller reads each returned key through // Executor.GetOutcome and passes it to Deliver. A concurrent Advance or a // blocking Run of the same Run is reported as ErrRunAlreadyRunning. store is // the RunStore bound to the caller's write capability: every commit of the -// step goes through it (OWN-HDL-2). +// step goes through it. func (l *Loop) Advance(ctx context.Context, st store.RunStore, runID run.RunID, events EventSink) (LoopResult, error) { if err := l.checkArgs(ctx, st, runID); err != nil { return LoopResult{}, err @@ -257,7 +257,7 @@ func (l *Loop) advance(ctx context.Context, rt store.RunStore, runID run.RunID, switch act := action.(type) { case plan.NeedModelRequest: // The hook runs while the Run is Open, before the plan reads - // the context (RUN-LOP-10); what it commits moves no Run fact, + // the context; what it commits moves no Run fact, // so the snapshot's Position stays valid for the Prepare. if hook := l.Settings.BeforePrepare; hook != nil { if err := hook(ctx, rt, act.Hint); err != nil { @@ -269,7 +269,7 @@ func (l *Loop) advance(ctx context.Context, rt store.RunStore, runID run.RunID, } case plan.WithdrawPrepared: // Inputs arrived after this step was frozen: discard the unsent - // request and replan with them (RUN-LOP-8). A retriable rejection + // request and replan with them. A retriable rejection // means another actor moved the Run; the reload decides. res, err := l.commit(ctx, rt, runID, schema.Identity().DeriveWithdrawCommandID(runID, act.StepID), snapshot.Position, run.WithdrawPreparedStep(act)) @@ -317,13 +317,13 @@ func (l *Loop) finish(ctx context.Context, events EventSink, scope run.Scope, ru return LoopResult{Disposition: LoopFinished, Result: result} } -// Deliver settles one Outcome (RUN-EXE-4). It finds the model step or tool +// Deliver settles one Outcome. It finds the model step or tool // call Executing under the effect the Outcome's key names and commits the // settlement under the effect's derived CommandID; an Outcome whose effect // is no longer Executing is dropped and nothing is written. It returns // LoopFinished when the settlement terminated the Run, LoopDelivered when the // host should Advance next, LoopDropped for a stale Outcome. Ownership loss -// is returned as is (RUN-LOP-5). +// is returned as is. func (l *Loop) Deliver(ctx context.Context, st store.RunStore, out Outcome, events EventSink) (LoopResult, error) { if err := l.checkArgs(ctx, st, out.Key.RunID); err != nil { return LoopResult{}, err @@ -377,7 +377,7 @@ func (l *Loop) deliver(ctx context.Context, rt store.RunStore, out Outcome, even } // Settlement uses a detached control context: a cancelled host request must - // not discard an accepted effect's outcome (RUN-LOP-5). + // not discard an accepted effect's outcome. finished, err := l.settle(context.WithoutCancel(ctx), rt, events, &ref, snapshot.Position, cmd) if err != nil { return LoopResult{}, err @@ -390,7 +390,7 @@ func (l *Loop) deliver(ctx context.Context, rt store.RunStore, out Outcome, even // The settlement landed (the step is withdrawn to Open) but the // condition -- a frozen body the executor cannot read -- would recur // on the next Advance, so the drive stops here and the host decides - // whether to try again (RUN-LOP-3). + // whether to try again. return LoopResult{Disposition: LoopDelivered}, settleErr } if finished != nil { @@ -400,12 +400,12 @@ func (l *Loop) deliver(ctx context.Context, rt store.RunStore, out Outcome, even } // Run drives the Run until it finishes, has no executable action, or the -// context is cancelled (RUN-LOP-2): Advance, wait for the Outcomes of what it +// context is cancelled: Advance, wait for the Outcomes of what it // dispatched, GetOutcome, Deliver, repeat. It is the blocking form every // host uses; hosts that receive Outcomes from elsewhere call Advance and // Deliver themselves. The caller context bounds the drive: on cancellation the // in-flight assignments of the Run are cancelled and their Outcomes are still -// settled (RUN-LOP-5) before ctx.Err is returned. +// settled before ctx.Err is returned. func (l *Loop) Run(ctx context.Context, rt store.RunStore, runID run.RunID, events EventSink) (LoopResult, error) { if err := l.checkArgs(ctx, rt, runID); err != nil { return LoopResult{}, err @@ -433,7 +433,7 @@ func (l *Loop) Run(ctx context.Context, rt store.RunStore, runID run.RunID, even }() // onOwnershipLost stops every in-flight effect: their Outcomes are not ours - // to write any more (RUN-LOP-5). The cancelled effects still report, so the + // to write any more. The cancelled effects still report, so the // pending Outcomes are drained -- never settled -- before returning; a tool // that ignores its context blocks here as it always would. onOwnershipLost := func(err error) error { @@ -551,7 +551,7 @@ func (l *Loop) awaitOutcome(ctx context.Context, key AssignmentKey, outcomes cha // commit builds the envelope via the sanctioned constructor and submits it. // A non-sentinel commit failure is replayed once with the same CommandID -// (RUN-LOP-5): if the first attempt actually committed and only the response +// if the first attempt actually committed and only the response // was lost, the replay returns AlreadyApplied instead of re-executing an // expensive step. Ownership loss is never retried. func (l *Loop) commit(ctx context.Context, rt store.RunStore, runID run.RunID, id run.CommandID, base run.RunPosition, cmd run.AgentCommand) (store.CommitResult, error) { diff --git a/agentcore/run/loop/loop_test.go b/agentcore/run/loop/loop_test.go index ab174213..38c0e31e 100644 --- a/agentcore/run/loop/loop_test.go +++ b/agentcore/run/loop/loop_test.go @@ -9,6 +9,8 @@ import ( "sync/atomic" "testing" + "github.com/felinics/twilight/agentcore/testutil/local" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/prompt" . "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/plan" @@ -17,8 +19,6 @@ import ( "github.com/felinics/twilight/agentcore/run/sessionstore" "github.com/felinics/twilight/agentcore/run/store" "github.com/felinics/twilight/agentcore/session/writer" - "github.com/felinics/twilight/agentcore/testutil/local" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" @@ -205,7 +205,7 @@ func TestLoopModelCatalogErrorRecoversWithFreshLoop(t *testing.T) { } // Validate caught the missing model before the start barrier: the step is // still Prepared and no start or recovery fact was written, so a working - // Loop starts the same step (RUN-EXE-5, RUN-LOP-3). + // Loop starts the same step. step, isModel := snap.State.Current.(ModelStep) if !isModel || step.Status != ModelPrepared { t.Fatalf("current = %+v, want the Prepared model step", snap.State.Current) @@ -393,7 +393,7 @@ func TestLoopReplaysStartAfterTwoLostResponses(t *testing.T) { } // The first Loop reaches the start barrier; both start responses are lost, // so the Owner remains Executing while the worker's attempt is gone - // with the aborted process. A second Run has nothing to execute (RUN-LOP-4). + // with the aborted process. A second Run has nothing to execute. if current, ok := snapshot.State.Current.(ModelStep); !ok || current.Status != ModelExecuting { t.Fatalf("current = %#v, want Executing ModelStep", snapshot.State.Current) } @@ -402,7 +402,7 @@ func TestLoopReplaysStartAfterTwoLostResponses(t *testing.T) { t.Fatalf("run with an orphaned Executing step = %+v %v, want waiting for recovery", res, err) } // The owner's takeover disposition withdraws the orphaned step; the next - // Run plans again and calls the model exactly once (RUN-CMT-7). + // Run plans again and calls the model exactly once. if n, err := rt.RecoverInterrupted(context.Background(), w, &reconcile.Reconciler{Abandon: true}); err != nil || n != 1 { t.Fatalf("RecoverInterrupted = %d %v", n, err) } @@ -496,7 +496,7 @@ func TestLoopMidExecutionCancelRecoversModelStep(t *testing.T) { t.Fatalf("err = %v, want context.Canceled", err) } // The model step is withdrawn via RecoverModelExecution: the Run is Open, - // still active, and the aborted step is not counted (RUN-LOP-3). + // still active, and the aborted step is not counted. snap, _ := rt.Bind(w).Load(context.Background(), "run-1") if _, open := snap.State.Current.(Open); !open { t.Fatalf("current = %#v, want Open", snap.State.Current) diff --git a/agentcore/run/loop/model.go b/agentcore/run/loop/model.go index 44f3cb20..b0c51fc3 100644 --- a/agentcore/run/loop/model.go +++ b/agentcore/run/loop/model.go @@ -72,7 +72,7 @@ func (l *Loop) planAndPrepare(ctx context.Context, rt store.RunStore, events Eve // --- StartModelCall --- // startModelStep commits the start barrier of the step's next model effect -// and hands the call to the Executor (RUN-LOP-3). It returns the dispatched key, or nil when +// and hands the call to the Executor. It returns the dispatched key, or nil when // the reload should decide (another actor moved the step). A model catalog // that cannot serve the step withdraws it to Open and reports the error: no // model call has happened. @@ -89,7 +89,7 @@ func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events Eve } assignment := Assignment{Session: rt.Scope(), RunID: runID, StepID: stepID, Effect: ref.id, Target: target, Body: ModelAssignment{Model: prepared.Model, RequestDigest: prepared.RequestDigest}} - // Pre-start check (RUN-EXE-5): an executor that cannot serve the model + // Pre-start check: an executor that cannot serve the model // fails here, with the step still Prepared and no start or recovery fact. unavailable, err := l.Executor.Validate(ctx, assignment) if err != nil { @@ -134,7 +134,7 @@ func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events Eve return nil, fmt.Errorf("agent: loop: model dispatch outcome: %w", err) } // Nothing was called: withdraw the step to Open under this effect's - // recovery identity and surface the condition (RUN-LOP-3). + // recovery identity and surface the condition. if _, serr := l.settle(context.WithoutCancel(ctx), rt, events, &ref, start.Snapshot.Position, run.RecoverModelExecution{StepID: stepID, Effect: ref.id}); serr != nil { return nil, serr @@ -146,7 +146,7 @@ func (l *Loop) startModelStep(ctx context.Context, rt store.RunStore, events Eve } // modelCompletion maps a model Outcome to the effect's settlement command -// (RUN-LOP-3): a cancelled call withdraws the step to Open (the next Advance +// a cancelled call withdraws the step to Open (the next Advance // plans again from the current state); a provider failure is // SubmitModelFailure; a result that cannot be bound or frozen is // RejectModelResult with the host's disposition; a result binds its tool @@ -168,7 +168,7 @@ func (l *Loop) modelCompletion(step *run.ModelStep, out Outcome) (run.AgentComma // provider answer (Dispose, an adoption that could not restart). // A model effect has no world side effect, so the step is withdrawn // to Open and the next Advance replans, the same disposition the - // recovery path gives a missing execution (RUN-CMT-7, RUN-EXE-6). + // recovery path gives a missing execution. return withdraw, nil case effect.Cancelled: return withdraw, nil @@ -204,7 +204,7 @@ func (l *Loop) modelRejectDisposition(step *run.ModelStep, _ run.StepFailure) ru return run.ModelRejectFailRun } -// bindToolCalls produces bindings from the frozen ToolSpecs (RUN-MCH-2). +// bindToolCalls produces bindings from the frozen ToolSpecs. // It never calls local.ExecutableTool. func (l *Loop) bindToolCalls(result *model.ModelResult, step *run.ModelStep) []run.ToolCallBinding { if len(result.ToolCalls) == 0 { diff --git a/agentcore/run/loop/ownership_test.go b/agentcore/run/loop/ownership_test.go index 1afb60b0..09e55263 100644 --- a/agentcore/run/loop/ownership_test.go +++ b/agentcore/run/loop/ownership_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" + "github.com/felinics/twilight/agentcore/testutil/local" . "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/reconcile" "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/sdk" ) @@ -52,7 +52,7 @@ func (c *commitLog) settlementsFor(callID CallID) int { // Two tool calls run in parallel. The Session is taken over while both are // executing; the first worker to finish has its settlement fenced with -// ErrOwnershipLost. RUN-LOP-5 then requires the Loop to cancel every other +// ErrOwnershipLost. The Loop must then cancel every other // worker's ctx, commit no further settlement, and return that error without // waiting for the blocked worker to be released on its own. func TestOwnershipLossCancelsWorkersAndStopsSettling(t *testing.T) { @@ -154,7 +154,7 @@ func TestOwnershipLossCancelsWorkersAndStopsSettling(t *testing.T) { // The model path has the same terminal rule: a model-step settlement fenced by // ErrOwnershipLost is returned as is, without the one-shot replay a -// non-sentinel commit error would get (RUN-LOP-5). +// non-sentinel commit error would get. func TestOwnershipLossOnModelSettlementIsNotRetried(t *testing.T) { stack := newTestStack(t, nil) stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) diff --git a/agentcore/run/loop/redispatch.go b/agentcore/run/loop/redispatch.go index df7e815e..4c5eb4bd 100644 --- a/agentcore/run/loop/redispatch.go +++ b/agentcore/run/loop/redispatch.go @@ -15,11 +15,11 @@ var ErrEffectNotExecuting = errors.New("agent: loop: effect is not executing") // Redispatch rebuilds the Assignment of an Executing effect from the Run's // state and hands it to the Executor again. It is the effect process's -// dispatch step (RUN-EXE-15): the Run recorded the start barrier, so the +// dispatch step: the Run recorded the start barrier, so the // effect is owed to the Executor, and a Dispatch the Loop's own drive did // not complete (a crash between the start fact and the call) is completed // here. The Executor recognises a replayed Assignment by its key, so a -// Redispatch of an effect it already holds changes nothing (RUN-EXE-3). +// Redispatch of an effect it already holds changes nothing. func (l *Loop) Redispatch(ctx context.Context, rt store.RunStore, key AssignmentKey) error { if err := l.checkArgs(ctx, rt, key.RunID); err != nil { return err diff --git a/agentcore/run/loop/regression_test.go b/agentcore/run/loop/regression_test.go index 198d7f54..96eba795 100644 --- a/agentcore/run/loop/regression_test.go +++ b/agentcore/run/loop/regression_test.go @@ -7,10 +7,10 @@ import ( "sync/atomic" "testing" - . "github.com/felinics/twilight/agentcore/run" - "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/agentcore/testutil/sdkconv" + . "github.com/felinics/twilight/agentcore/run" + "github.com/felinics/twilight/agentcore/run/schema" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/agentcore/run/loop/require_test.go b/agentcore/run/loop/require_test.go index fc409dad..62f53249 100644 --- a/agentcore/run/loop/require_test.go +++ b/agentcore/run/loop/require_test.go @@ -1,10 +1,10 @@ package loop_test import ( + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/loop" "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" ) // RequireWaiting checks Loop yielded and a call is waiting for kind. diff --git a/agentcore/run/loop/retention_test.go b/agentcore/run/loop/retention_test.go index b4ffd153..90f86abe 100644 --- a/agentcore/run/loop/retention_test.go +++ b/agentcore/run/loop/retention_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - . "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/testutil/local" + . "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/sdk" ) diff --git a/agentcore/run/loop/script_test.go b/agentcore/run/loop/script_test.go index 74389a8e..528fcd92 100644 --- a/agentcore/run/loop/script_test.go +++ b/agentcore/run/loop/script_test.go @@ -7,11 +7,11 @@ import ( "sync" "sync/atomic" + "github.com/felinics/twilight/agentcore/testutil/local" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/prompt" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/loop" - "github.com/felinics/twilight/agentcore/testutil/local" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" ) diff --git a/agentcore/run/loop/takeover_test.go b/agentcore/run/loop/takeover_test.go index 03525f0e..61be22a5 100644 --- a/agentcore/run/loop/takeover_test.go +++ b/agentcore/run/loop/takeover_test.go @@ -5,17 +5,17 @@ import ( "errors" "testing" + "github.com/felinics/twilight/agentcore/testutil/local" . "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/reconcile" "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/testutil/local" "github.com/felinics/twilight/sdk" ) // The owner process dies while a tool call is Executing. A new owner takes // the Session over, RecoverInterrupted settles the call as Unknown, a fresh // Loop finishes the Run, and the dead owner's late settlement is fenced with -// ErrOwnershipLost (RUN-CMT-6/7, RUN-LOP-5). +// ErrOwnershipLost. func TestTakeoverDisposesExecutingCallAndFencesOldOwner(t *testing.T) { stack := newTestStack(t, nil) stack.createRun(t, "run-1", AgentInput{ID: "seed", Digest: inputDigest(`{}`)}) diff --git a/agentcore/run/loop/tool.go b/agentcore/run/loop/tool.go index 16db4962..c93a4941 100644 --- a/agentcore/run/loop/tool.go +++ b/agentcore/run/loop/tool.go @@ -22,9 +22,9 @@ func toolCallIndex(step run.ToolStep, callID run.CallID) int { } // startToolCalls validates, starts and dispatches the Pending calls the -// frozen Scheduling allows (RUN-LOP-4). Validation happens before the start +// frozen Scheduling allows. Validation happens before the start // barrier through the Executor and a failed call is declined without a start -// (DeclineToolCall, RUN-EXE-5); a validated call is started under its tool +// (DeclineToolCall); a validated call is started under its tool // effect and handed to the Executor. It returns the dispatched keys; an empty // list with no error means nothing is executing on this Loop's behalf and the // reload decides. @@ -59,11 +59,10 @@ func (l *Loop) startToolCalls(ctx context.Context, rt store.RunStore, events Eve if call.Status != run.ToolPending { // Executing calls settle through the Outcome of the effect they // started or the owner's takeover disposition; never re-run - // (TRN-DUR-4). continue } ref := toolEffect(runID, act.StepID, callID) - // The target is resolved per tool effect (RUN-LOP-9) before the + // The target is resolved per tool effect before the // pre-start check, so the Validate probe carries what the Assignment // will carry. target, err := l.targetFor(ctx, EffectContext{Session: rt.Scope(), RunID: runID, StepID: act.StepID, CallID: callID, @@ -81,7 +80,7 @@ func (l *Loop) startToolCalls(ctx context.Context, rt store.RunStore, events Eve // The call fails before its effect is requested: no start // barrier, no effect, no attempt. A call is declined at most // once, so the decline is identified by the call alone and a - // retry of the same rejection is idempotent (RUN-EXE-5). + // retry of the same rejection is idempotent. res, err := l.commit(ctx, rt, runID, schema.Identity().DeriveDeclineCommandID(runID, act.StepID, callID), snapshot.Position, run.DeclineToolCall{StepID: act.StepID, CallID: callID, Failure: *known}) if err != nil { @@ -164,7 +163,7 @@ func executingCall(step *run.ToolStep, id run.EffectID) (run.ToolCallState, bool // toolCompletion maps a tool Outcome to the settlement of the call's tool // effect. A sealed outcome maps directly; a missing outcome or a transport -// error is Unknown, because the effect may have happened (RUN-LOP-5). +// error is Unknown, because the effect may have happened. func toolCompletion(stepID run.StepID, callID run.CallID, eff run.EffectID, out Outcome) run.AgentCommand { switch o := out.Result.(type) { case ToolExecutionSucceeded: @@ -194,7 +193,7 @@ func toolCompletion(stepID run.StepID, callID run.CallID, eff run.EffectID, out Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: msg}, Outcome: run.ToolOutcomeUnknown} } // A model result or no result for a tool call: the effect may have - // happened, so it is Unknown (RUN-LOP-5). + // happened, so it is Unknown. return run.SubmitToolFailure{StepID: stepID, CallID: callID, Effect: eff, Failure: run.ToolFailure{Class: run.FailureEffectUnknown, Message: fmt.Sprintf("executor delivered %T for a tool call", out.Result)}, Outcome: run.ToolOutcomeUnknown} } diff --git a/agentcore/run/loop/unknown_model_test.go b/agentcore/run/loop/unknown_model_test.go index 5c48bce4..22b85262 100644 --- a/agentcore/run/loop/unknown_model_test.go +++ b/agentcore/run/loop/unknown_model_test.go @@ -9,7 +9,7 @@ import ( // A model outcome without an answer withdraws the step to Open, the same // disposition as a cancelled effect and as the recovery path's missing -// execution; only a provider failure ends the Run (RUN-CMT-7, RUN-EXE-6). +// execution; only a provider failure ends the Run. func TestModelCompletionWithdrawsOnUnknown(t *testing.T) { step := &run.ModelStep{RefValue: run.StepRef{RunID: "r", ID: "s1"}, Status: run.ModelExecuting, Effect: "e1"} cases := []struct { diff --git a/agentcore/run/loop/unknown_test.go b/agentcore/run/loop/unknown_test.go index 83c06bcc..c067f6fd 100644 --- a/agentcore/run/loop/unknown_test.go +++ b/agentcore/run/loop/unknown_test.go @@ -64,7 +64,7 @@ func (p *refusingPort) Settlements(ctx context.Context, epoch string, after uint return ctx.Err() } -// RUN-EXE-3: a retryable refusal is dispatched again inside the Advance and +// a retryable refusal is dispatched again inside the Advance and // the call completes; a refusal that persists past the bound is a Known // dispatch failure of the call, and the Run continues. func TestRetryableDispatchRefusal(t *testing.T) { diff --git a/agentcore/run/machine.go b/agentcore/run/machine.go index 214abc60..447d7102 100644 --- a/agentcore/run/machine.go +++ b/agentcore/run/machine.go @@ -75,7 +75,7 @@ func (m StateMachine) bound() error { } // CreateGroup produces the facts that establish a Run and queue its initial -// inputs (RUN-NEW-1). It is pure: the owning module places these facts in +// inputs. It is pure: the owning module places these facts in // its creation commit, the RunStore never sees a Create command. func (m StateMachine) CreateGroup(run NewRun, inputs []AgentInput) ([]Fact, error) { if err := m.bound(); err != nil { diff --git a/agentcore/run/machine_test.go b/agentcore/run/machine_test.go index 57a6fa63..90d2600e 100644 --- a/agentcore/run/machine_test.go +++ b/agentcore/run/machine_test.go @@ -5,11 +5,11 @@ import ( "strings" "testing" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/model" "github.com/felinics/twilight/agentcore/run/plan" "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) @@ -167,7 +167,7 @@ func modelResultWithNamedCalls(toolName, args string, callIDs ...string) model.M } // startModel is the start of the current Prepared step's next model effect -// (RUN-WIR-1): the step's rejected count is the effect's sequence. +// the step's rejected count is the effect's sequence. func startModel(s run.MachineState, stepID run.StepID) run.StartModelExecution { ms, _ := s.Current.(run.ModelStep) return run.StartModelExecution{StepID: stepID, Effect: schema.Identity().DeriveEffectID(s.RunID, stepID, "", ms.Rejects)} @@ -609,7 +609,7 @@ func TestAcceptInputDuplicateIsGuarded(t *testing.T) { } } -// Inputs queue in every non-terminal state (RUN-MCH-4). A Prepared step whose +// Inputs queue in every non-terminal state. A Prepared step whose // request predates the input is withdrawn and replanned; an Executing step // keeps the input for the Open that follows it. func TestAcceptInputQueuesInAnyActiveState(t *testing.T) { @@ -791,7 +791,7 @@ func TestDerivedCallIDToleratesProviderIDReuse(t *testing.T) { } // TestSettlementNamesExecutingEffect: a settlement carries the effect it -// closes and Decide checks it against the Executing step or call (RUN-WIR-1). +// closes and Decide checks it against the Executing step or call. // Decide is called directly because mustDecide fills a missing effect in. func TestSettlementNamesExecutingEffect(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") @@ -862,7 +862,7 @@ func TestSettlementNamesExecutingEffect(t *testing.T) { } // TestDeclineToolCallFailsPendingCallWithoutStart: DeclineToolCall records -// the Known failure of a Pending call with no start and no effect (RUN-EXE-5); +// the Known failure of a Pending call with no start and no effect; // any other call state is rejected. func TestDeclineToolCallFailsPendingCallWithoutStart(t *testing.T) { defA, defB := testToolDef("a"), testToolDef("b") diff --git a/agentcore/run/model/arguments.go b/agentcore/run/model/arguments.go index cdfebccd..42cecac2 100644 --- a/agentcore/run/model/arguments.go +++ b/agentcore/run/model/arguments.go @@ -19,7 +19,7 @@ type ToolArguments struct { // Valid reports whether the arguments are a JSON document a tool can decode. func (a ToolArguments) Valid() bool { return a.Text == "" } -// Canonical is the binding form of the arguments (RUN-MCH-2): the document +// Canonical is the binding form of the arguments: the document // itself, the empty object for the zero value, or the invalid text as a JSON // string so the binding digest still covers what the model wrote and // validation fails as invalid_arguments. diff --git a/agentcore/run/model/doc.go b/agentcore/run/model/doc.go index d345044a..a3412005 100644 --- a/agentcore/run/model/doc.go +++ b/agentcore/run/model/doc.go @@ -1,5 +1,5 @@ // Package model holds the durable, provider-neutral model data a Run persists -// by digest (RUN-WIR-4): ModelRequest, Message, ToolDefinition, ModelResult, +// by digest: ModelRequest, Message, ToolDefinition, ModelResult, // Usage and their parts, plus the canonical rendering of tool arguments. // Every type is a closed, JSON-stable value with no SDK interface inside, so // its canonical encoding is fixed for the life of the schema version that diff --git a/agentcore/run/plan/doc.go b/agentcore/run/plan/doc.go index c170eb95..edbee27f 100644 --- a/agentcore/run/plan/doc.go +++ b/agentcore/run/plan/doc.go @@ -1,8 +1,8 @@ // Package plan derives what a Run's MachineState asks for next and persists -// none of it. Next is the at-most-one pending action of a Run (RUN-MCH-4): +// none of it. Next is the at-most-one pending action of a Run: // the Loop re-derives the action after every Load. RecoveryTargets, // RecoveryCommand and RecoveryCommands derive the takeover dispositions of -// the Executing targets (RUN-CMT-7): the recovery command of each target with +// the Executing targets: the recovery command of each target with // its derived identity. agent/run/reconcile decides which dispositions a new // owner actually issues. Read-only queries over the state live in the run // package itself; the prompt boundary's input type is prompt.Input. diff --git a/agentcore/run/plan/next.go b/agentcore/run/plan/next.go index 23ec95ef..9a304809 100644 --- a/agentcore/run/plan/next.go +++ b/agentcore/run/plan/next.go @@ -8,7 +8,7 @@ import ( ) // Action is the at-most-one pending action Next derives from the current -// state (RUN-MCH-4): what the Loop does next to move the Run. Actions are +// state: what the Loop does next to move the Run. Actions are // never persisted; the Loop re-derives them after every Load. An action is // not an effect: a start action requests an effect (run.EffectID) only once // its start fact is committed, and the other actions request none. @@ -49,7 +49,7 @@ type Idle struct{} func (Idle) action() {} -// Next derives the pending action from the current state (RUN-MCH-4). +// Next derives the pending action from the current state. // Terminal states return ErrRunTerminal; callers check Status first. // //nolint:gocritic // hugeParam: Next is a pure value-state interpreter and must not mutate MachineState. diff --git a/agentcore/run/plan/recovery.go b/agentcore/run/plan/recovery.go index 3107a0f6..e4873fbe 100644 --- a/agentcore/run/plan/recovery.go +++ b/agentcore/run/plan/recovery.go @@ -14,7 +14,7 @@ type RecoveryDisposition struct { // model step or tool call, and the effect it requested (from the started // fact). The reconciler (agent/run/reconcile) asks the executor whether it // still holds an attempt for that effect; only if not is the recovery command -// issued (RUN-CMT-7). +// issued. type RecoveryTarget struct { RunID run.RunID StepID run.StepID @@ -55,7 +55,7 @@ func RecoveryTargets(state *run.MachineState) []RecoveryTarget { // RecoveryCommand is the disposition of one target whose effect is lost: an // Executing model step is withdrawn to Open (the next Prepare plans again); // an Executing tool call settles as Unknown. The command's identity derives -// from the effect alone (RUN-WIR-1), so the same disposition issued by any +// from the effect alone, so the same disposition issued by any // owner, or issued twice, replays instead of applying again. func RecoveryCommand(id run.Identity, target RecoveryTarget) RecoveryDisposition { if target.Call == nil { @@ -77,7 +77,7 @@ func RecoveryCommand(id run.Identity, target RecoveryTarget) RecoveryDisposition } // RecoveryCommands lists the takeover dispositions of every Executing target -// in state (RUN-CMT-7). Pending and Waiting calls are left alone. +// in state. Pending and Waiting calls are left alone. func RecoveryCommands(id run.Identity, state *run.MachineState) []RecoveryDisposition { targets := RecoveryTargets(state) if len(targets) == 0 { diff --git a/agentcore/run/reconcile/reconcile.go b/agentcore/run/reconcile/reconcile.go index c36dc718..6d814450 100644 --- a/agentcore/run/reconcile/reconcile.go +++ b/agentcore/run/reconcile/reconcile.go @@ -1,6 +1,6 @@ // Package reconcile compares what the Run machine believes about an effect // with what the execution store actually holds, and turns the difference into -// Run commands or a second Dispatch (RUN-CMT-7, RUN-EXE-15). It is the +// Run commands or a second Dispatch. It is the // process manager between the two authorities and the one place that reads // three sources: the machine says which effects are outstanding (an // Executing model step or tool call and the EffectID it requested), the @@ -43,12 +43,12 @@ const ( // disposal ends the wait otherwise. Defer Verdict = "defer" // Dispose: the executor holds no attempt for the effect and none will be - // made -- the key is closed with Abort first (RUN-EXE-16) -- so the Run + // made -- the key is closed with Abort first -- so the Run // recovers the target itself: an Executing model step is withdrawn to // Open, an Executing tool call settles as Unknown. Dispose Verdict = "dispose" // Redispatch: the executor held no attempt for the effect and was handed - // the Assignment again within the budget (RUN-EXE-15). The target stays + // the Assignment again within the budget. The target stays // Executing and its Outcome is awaited like a kept one. Redispatch Verdict = "redispatch" ) @@ -59,6 +59,12 @@ type Decision struct { Observed effect.AttachmentState Verdict Verdict Recovery *plan.RecoveryDisposition + // Retry reports a retryable refusal that happened before the effect + // boundary. No outcome watch is installed while it is true: the owner + // may safely offer the same pending assignment again after an interval. + // An unknown dispatch never sets Retry because it may have crossed the + // boundary and must not trigger a compensating dispatch. + Retry bool } // ErrNoExecutionPort reports a Plan over Executing targets with no executor @@ -76,12 +82,12 @@ var ErrDeliverWithoutLifetime = errors.New("reconcile: Deliver set without a Lif var ErrDeliverWithoutWatcher = errors.New("reconcile: Deliver set without a Watcher on the execution port") // ErrAbandonWithExecutor reports Abandon set beside an Executions port: with -// an executor to ask, disposal must go through Abort (RUN-EXE-16), and a +// an executor to ask, disposal must go through Abort, and a // caller that wants to bypass it has to give up the port explicitly. var ErrAbandonWithExecutor = errors.New("reconcile: Abandon set with an execution port; disposal without Abort is only for a scope no executor serves") // MissingPolicy is what the reconciler does with an Executing effect the -// executor holds nothing for (RUN-CMT-7, RUN-EXE-15). It is the +// executor holds nothing. It is the // deployment's recovery decision, stated explicitly: the ports a policy // needs are checked against the policy, and no port's presence or absence // selects a policy by itself. @@ -115,20 +121,20 @@ var ErrMissingPolicyPorts = errors.New("reconcile: RedispatchMissing requires th // ErrTargetWithoutEffect reports an Executing target whose start fact // recorded no EffectID: the executor cannot be asked about it and the -// machine state is inconsistent (RUN-WIR-1). +// machine state is inconsistent. var ErrTargetWithoutEffect = errors.New("reconcile: executing target records no effect") // Reconciler is the recovery decision of one owner over a Scope. type Reconciler struct { // Executions is the execution store's port, asked once per Executing // target. Nil with Abandon unset is an error whenever a target exists: - // "no executor to ask" is not "no execution" (RUN-CMT-7). A port that + // "no executor to ask" is not "no execution". A port that // also implements effect.Recoverer is asked to take an orphaned record // back; one that does not leaves orphaned records to an external // controller. Executions effect.ExecutionPort // Abandon disposes every Executing target without asking an executor and - // without closing the keys (RUN-EXE-16). It is the caller's statement + // without closing the keys. It is the caller's statement // that no executor serves this Scope at all: none holds an attempt, and // none can receive a late Dispatch, because there is no executor. That // holds for a scope that never had an execution port (conformance @@ -159,13 +165,13 @@ type Reconciler struct { Lifetime context.Context // OrphanProbe is how often a kept target still waiting is re-attached // to see whether its record has lost its Worker, in which case recovery - // is asked for once per orphaned episode (RUN-EXE-6). Zero selects + // is asked for once per orphaned episode. Zero selects // DefaultOrphanProbe; negative disables the probe. OrphanProbe time.Duration // Missing is the policy for an effect the executor holds nothing for. - // The zero value disposes (RUN-CMT-7); RedispatchMissing requires - // Redispatch and Attempts (RUN-EXE-15). + // The zero value disposes; RedispatchMissing requires + // Redispatch and Attempts. Missing MissingPolicy // Redispatch hands the Assignment of an Executing effect the executor // holds nothing for to the executor again (loop.Redispatch on the @@ -173,13 +179,18 @@ type Reconciler struct { Redispatch func(ctx context.Context, key effect.AssignmentKey) error // Attempts is the dispatch ledger: which redispatch of each effect was // planned, whether it reached the executor, and whether the reconciler - // gave up (RUN-EXE-15). + // gave up. Attempts redispatch.Store // Epoch fences the dispatch ledger: the Session owner's. Epoch ledger.Epoch // MaxRedispatches bounds redispatches per effect; zero selects // DefaultMaxRedispatches. MaxRedispatches int + // Requeue is notified when a redispatch ended at a temporary boundary + // and the effect needs another reconciliation. It must not block Plan; + // the Recovery implementation deduplicates these requests per Session + // lifetime. Nil leaves retry scheduling to the caller. + Requeue func(effect.AssignmentKey) // Now stamps the dispatch ledger; nil selects time.Now. Now func() time.Time } @@ -194,7 +205,7 @@ const DefaultOrphanProbe = 15 * time.Second // AssignmentFromTarget rebuilds the Assignment of an Executing target from // the machine state, so the executor can be asked whether it still holds an // attempt for the effect. Only the key and the digest-level description are -// known here; the inline request body never travels this way (RUN-EXE-7). +// known here; the inline request body never travels this way. func AssignmentFromTarget(scope run.Scope, t plan.RecoveryTarget) effect.Assignment { a := effect.Assignment{Session: scope, RunID: t.RunID, StepID: t.StepID, CallID: t.CallID, Effect: t.Effect} switch { @@ -225,12 +236,27 @@ func verdictOf(state effect.AttachmentState) (Verdict, error) { // It writes no Run fact: the Dispose decisions it returns are committed by // Apply. It does write outside the Run when a policy asks for it: under // RedispatchMissing the dispatch ledger records each redispatch attempt, and -// every effect it disposes has its key closed at the executor first (Abort, -// RUN-EXE-16). The recovery command of a disposed effect is identified by -// the effect (RUN-WIR-1), so any owner that plans the same state issues the +// every effect it disposes has its key closed at the executor first (Abort). The recovery command of a disposed effect is identified by +// the effect, so any owner that plans the same state issues the // same command. func (r *Reconciler) Plan(ctx context.Context, scope run.Scope, snapshot *store.Snapshot) ([]Decision, error) { + return r.plan(ctx, scope, snapshot, nil) +} + +// plan optionally limits reconciliation to one AssignmentKey. Recovery uses +// this form for an interval retry so unrelated effects do not acquire another +// watcher or get dispatched again. +func (r *Reconciler) plan(ctx context.Context, scope run.Scope, snapshot *store.Snapshot, only *effect.AssignmentKey) ([]Decision, error) { targets := plan.RecoveryTargets(&snapshot.State) + if only != nil { + filtered := targets[:0] + for _, target := range targets { + if (effect.AssignmentKey{Session: scope, RunID: target.RunID, Effect: target.Effect}) == *only { + filtered = append(filtered, target) + } + } + targets = filtered + } if len(targets) == 0 { return nil, nil } @@ -272,7 +298,7 @@ func (r *Reconciler) Plan(ctx context.Context, scope run.Scope, snapshot *store. r.recoverOrphan(ctx, assignment.Key()) } if d.Verdict == Dispose && attachment.State == effect.AttachmentMissing { - if d.Verdict, err = r.missing(ctx, assignment.Key()); err != nil { + if d.Verdict, d.Retry, err = r.missing(ctx, assignment.Key()); err != nil { return nil, err } } @@ -280,7 +306,7 @@ func (r *Reconciler) Plan(ctx context.Context, scope run.Scope, snapshot *store. // Missing is what the executor holds now, not what it will // accept: close the key before the Run disposes the effect, // so a Dispatch that arrives later starts nothing - // (RUN-EXE-16). An acceptance that got there first is kept. + // An acceptance that got there first is kept. closed, err := r.Executions.Abort(ctx, assignment.Key()) if err != nil { return nil, err @@ -293,7 +319,11 @@ func (r *Reconciler) Plan(ctx context.Context, scope run.Scope, snapshot *store. r.recoverOrphan(ctx, assignment.Key()) } } - if d.Verdict != Dispose { + if d.Retry { + if r.Requeue != nil { + r.Requeue(assignment.Key()) + } + } else if d.Verdict != Dispose { r.awaitOutcome(assignment.Key()) } } @@ -308,26 +338,28 @@ func (r *Reconciler) Plan(ctx context.Context, scope run.Scope, snapshot *store. // missing decides an effect the executor holds nothing for: within the // redispatch budget it is handed over again and stays Executing, otherwise -// it is disposed (RUN-EXE-15). The dispatch ledger records each attempt in +// it is disposed. The dispatch ledger records each attempt in // two steps: planned before the Dispatch, dispatched once the Dispatch // reached the executor. A planned attempt the ledger does not show as // dispatched is owed, and is made again before any new one is planned, so // a crash between the two steps costs no budget and never leaves a Dispatch // the ledger does not know about; the executor recognises the replay by -// its key (RUN-EXE-3). A refusal the executor may lift later -// (ErrDispatchRetryable) or a lost response (ErrDispatchUnknown) leaves the -// attempt owed and the target Executing for the next reconciliation; any -// other rejection ends the attempts. -func (r *Reconciler) missing(ctx context.Context, key effect.AssignmentKey) (Verdict, error) { +// its key. A refusal the executor may lift later +// ErrDispatchRetryable leaves the attempt owed and asks for a later retry. +// A lost response (ErrDispatchUnknown) also leaves the attempt owed, but does +// not ask for an automatic retry because the request may have crossed the +// effect boundary; its outcome watcher may still discover an accepted record. +// Any definite rejection ends the attempts. +func (r *Reconciler) missing(ctx context.Context, key effect.AssignmentKey) (Verdict, bool, error) { if r.Missing == DisposeMissing { - return Dispose, nil + return Dispose, false, nil } state, _, _, err := r.Attempts.Load(ctx, key) if err != nil { - return Dispose, err + return Dispose, false, err } if state.GivenUp { - return Dispose, nil + return Dispose, false, nil } budget := r.MaxRedispatches if budget <= 0 { @@ -335,28 +367,30 @@ func (r *Reconciler) missing(ctx context.Context, key effect.AssignmentKey) (Ver } if state.Pending() == 0 && state.Planned >= budget { if err := redispatch.GiveUp(ctx, r.Attempts, r.Epoch, key, fmt.Sprintf("redispatch budget of %d exhausted", budget), r.now()); err != nil { - return Dispose, err + return Dispose, false, err } - return Dispose, nil + return Dispose, false, nil } attempt, err := redispatch.Plan(ctx, r.Attempts, r.Epoch, key, r.now()) if err != nil { - return Dispose, err + return Dispose, false, err } err = r.Redispatch(ctx, key) switch { case err == nil: if err := redispatch.MarkDispatched(ctx, r.Attempts, r.Epoch, key, attempt, r.now()); err != nil { - return Dispose, err + return Dispose, false, err } - return Redispatch, nil - case errors.Is(err, effect.ErrDispatchRetryable), errors.Is(err, effect.ErrDispatchUnknown): - return Defer, nil + return Redispatch, false, nil + case errors.Is(err, effect.ErrDispatchRetryable): + return Defer, true, nil + case errors.Is(err, effect.ErrDispatchUnknown): + return Defer, false, nil default: if gerr := redispatch.GiveUp(ctx, r.Attempts, r.Epoch, key, err.Error(), r.now()); gerr != nil { - return Dispose, gerr + return Dispose, false, gerr } - return Dispose, nil + return Dispose, false, nil } } @@ -388,7 +422,7 @@ func (r *Reconciler) now() int64 { // for the key) goes to Fail. Nothing here holds a request open for the // length of the execution. While the target waits, the orphan probe // re-attaches it at OrphanProbe intervals and asks for recovery once per -// orphaned episode (RUN-EXE-6); Lifetime ends both. +// orphaned episode; Lifetime ends both. func (r *Reconciler) awaitOutcome(key effect.AssignmentKey) { if r.Deliver == nil || r.Lifetime == nil || r.Watcher == nil { return @@ -503,7 +537,7 @@ func Apply(ctx context.Context, st store.RunStore, decisions []Decision) (int, e } // Reconcile is Plan then Apply for one Run: the takeover disposition of its -// Executing targets (RUN-CMT-7). It returns the number of accepted recovery +// Executing targets. It returns the number of accepted recovery // commands. func (r *Reconciler) Reconcile(ctx context.Context, st store.RunStore, snapshot *store.Snapshot) (int, error) { if r.Lifetime != nil { @@ -517,3 +551,27 @@ func (r *Reconciler) Reconcile(ctx context.Context, st store.RunStore, snapshot } return Apply(ctx, st, decisions) } + +// ReconcileEffect re-reads and reconciles only key's target from snapshot. +// It is the interval-retry counterpart of Session-level Reconcile: limiting +// the pass to one effect prevents retries from replacing unrelated watches +// or redispatching unrelated targets. ok is false once the durable Run no +// longer has that effect executing. +func (r *Reconciler) ReconcileEffect(ctx context.Context, st store.RunStore, snapshot *store.Snapshot, key effect.AssignmentKey) (decision Decision, ok bool, err error) { + if r.Lifetime != nil { + if err := r.Lifetime.Err(); err != nil { + return Decision{}, false, err + } + } + decisions, err := r.plan(ctx, st.Scope(), snapshot, &key) + if err != nil { + return Decision{}, false, err + } + if len(decisions) == 0 { + return Decision{}, false, nil + } + if _, err := Apply(ctx, st, decisions); err != nil { + return Decision{}, false, err + } + return decisions[0], true, nil +} diff --git a/agentcore/run/reconcile/reconcile_test.go b/agentcore/run/reconcile/reconcile_test.go index 65d1f661..4437c96f 100644 --- a/agentcore/run/reconcile/reconcile_test.go +++ b/agentcore/run/reconcile/reconcile_test.go @@ -7,12 +7,12 @@ import ( "testing" "time" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/effect" "github.com/felinics/twilight/agentcore/run/model" "github.com/felinics/twilight/agentcore/run/plan" "github.com/felinics/twilight/agentcore/run/store" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" ) @@ -93,8 +93,7 @@ func executingModel(eff run.EffectID) *store.Snapshot { } // Each executor observation maps to exactly one verdict; only missing and -// aborted produce a recovery command (missing is closed to aborted first, -// RUN-EXE-16), and only an unknown state is an error. +// aborted produce a recovery command (missing is closed to aborted first), and only an unknown state is an error. func TestPlanVerdicts(t *testing.T) { for _, tc := range []struct { state effect.AttachmentState @@ -128,7 +127,7 @@ func TestPlanVerdicts(t *testing.T) { // No executor to ask proves nothing: an error, not a disposal. Only an // explicit Abandon disposes without asking, and only for a scope with no // executor at all: beside a port it is a configuration error, so the - // Abort of RUN-EXE-16 cannot be skipped while an executor exists. + // Abort cannot be skipped while an executor exists. if _, err := (&Reconciler{}).Plan(context.Background(), "s", executingModel("c1")); !errors.Is(err, ErrNoExecutionPort) { t.Fatalf("plan without executor = %v, want ErrNoExecutionPort", err) } @@ -157,7 +156,7 @@ func TestPlanVerdicts(t *testing.T) { } // AssignmentFromTarget carries the digest-level description of the target -// and never an inline request body (RUN-EXE-7). +// and never an inline request body. func TestAssignmentFromTarget(t *testing.T) { targets := plan.RecoveryTargets(&executingModel("c1").State) if len(targets) != 1 { @@ -334,7 +333,7 @@ func (p *recoveringPort) RecoverExecution(_ context.Context, key effect.Assignme // An orphaned target is deferred, and a port that can recover is asked to // take the record back once, at Plan time; a port that cannot is only -// observed. Keep and Dispose never ask (RUN-CMT-7, RUN-EXE-6). +// observed. Keep and Dispose never ask. func TestPlanAsksRecovererForOrphans(t *testing.T) { for _, tc := range []struct { state effect.AttachmentState @@ -417,7 +416,7 @@ func TestKeptOutcomeProbeRecoversOrphan(t *testing.T) { } } -// RUN-EXE-16: a missing effect is closed on the executor before the Run +// a missing effect is closed on the executor before the Run // disposes it, so a Dispatch that arrives later starts nothing; when an // acceptance reached the executor first, Abort reports it and the target is // kept or deferred instead of disposed. diff --git a/agentcore/run/reconcile/redispatch_test.go b/agentcore/run/reconcile/redispatch_test.go index 8fe8a634..6a6c2a0f 100644 --- a/agentcore/run/reconcile/redispatch_test.go +++ b/agentcore/run/reconcile/redispatch_test.go @@ -60,7 +60,7 @@ func (a *attempts) Append(ctx context.Context, epoch ledger.Epoch, k effect.Assi return nil } -// RUN-EXE-15: an effect the executor holds nothing for is handed over again +// an effect the executor holds nothing for is handed over again // within the redispatch budget. Each attempt is planned before the Dispatch // and marked dispatched after it; an attempt left planned (a crash or a // refusal between the two) is made again and costs no budget. A refusal the @@ -112,7 +112,7 @@ func TestPlanRedispatchesMissingWithinBudget(t *testing.T) { t.Fatal(err) } } - calls := 0 + calls, requeues := 0, 0 r := &Reconciler{Executions: &fakePort{state: effect.AttachmentMissing}, Missing: RedispatchMissing, Attempts: store, Epoch: 1, MaxRedispatches: 2, Redispatch: func(_ context.Context, k effect.AssignmentKey) error { calls++ @@ -120,14 +120,24 @@ func TestPlanRedispatchesMissingWithinBudget(t *testing.T) { t.Fatalf("redispatch of %+v", k) } return tc.redispatch + }, + Requeue: func(k effect.AssignmentKey) { + requeues++ + if k != key { + t.Fatalf("requeue of %+v", k) + } }} decisions, err := r.Plan(ctx, "s", executingModel("c1")) if err != nil || len(decisions) != 1 { t.Fatalf("plan = %+v %v", decisions, err) } d := decisions[0] - if d.Verdict != tc.want || (d.Recovery != nil) != (tc.want == Dispose) || calls != tc.calls { - t.Fatalf("decision = %+v calls=%d, want %s calls=%d", d, calls, tc.want, tc.calls) + wantRequeue := 0 + if errors.Is(tc.redispatch, effect.ErrDispatchRetryable) { + wantRequeue = 1 + } + if d.Verdict != tc.want || (d.Recovery != nil) != (tc.want == Dispose) || calls != tc.calls || requeues != wantRequeue { + t.Fatalf("decision = %+v calls=%d requeues=%d, want %s calls=%d requeues=%d", d, calls, requeues, tc.want, tc.calls, wantRequeue) } state, _, _, err := store.Load(ctx, key) if err != nil || state.Planned != tc.planned || state.Dispatched != tc.dispatched || state.GivenUp != tc.gaveUp { diff --git a/agentcore/run/redispatch/redispatch.go b/agentcore/run/redispatch/redispatch.go index 52280b73..5f6f1da7 100644 --- a/agentcore/run/redispatch/redispatch.go +++ b/agentcore/run/redispatch/redispatch.go @@ -4,7 +4,7 @@ // there; neither is repeated here. What only the process manager knows, and // what must survive its crash, is which redispatch it decided to make, // whether that dispatch reached the destination, and whether it gave up -// (RUN-EXE-15). Each record is written before the act it decides, so a +// Each record is written before the act it decides, so a // decision made and a dispatch sent cannot drift apart. package redispatch @@ -28,7 +28,7 @@ const ( // the next attempt be planned. EventDispatched ledger.EventType = "dispatched" // EventGivenUp: the process manager stopped redispatching; the reason is - // recorded and the Run disposes the effect (RUN-CMT-7). + // recorded and the Run disposes the effect. EventGivenUp ledger.EventType = "given_up" ) @@ -176,7 +176,7 @@ type Store interface { // Plan returns the attempt the process manager is to make now: the planned // attempt still owed to the Executor, or the next one, recorded before the -// Dispatch it announces (RUN-EXE-15). A crash between the record and the +// Dispatch it announces. A crash between the record and the // Dispatch leaves the attempt pending, and the next reconciliation makes // the same attempt instead of a new one. func Plan(ctx context.Context, s Store, epoch ledger.Epoch, key effect.AssignmentKey, now int64) (int, error) { diff --git a/agentcore/run/redispatch/redispatchtest/conformance.go b/agentcore/run/redispatch/redispatchtest/conformance.go index 37cb296d..8b3f9a2f 100644 --- a/agentcore/run/redispatch/redispatchtest/conformance.go +++ b/agentcore/run/redispatch/redispatchtest/conformance.go @@ -30,7 +30,7 @@ func commit(t *testing.T, seq ledger.CommitSeq, id ledger.CommitID, typ ledger.E // The dispatch ledger answers replays by identity, stale sequences and // illegal steps with the kernel's sentinels, and fences a writer whose epoch -// is behind the highest that has written the key (RUN-EXE-15). +// is behind the highest that has written the key. func testLedger(t *testing.T, store redispatch.Store) { ctx := context.Background() k1 := effect.AssignmentKey{Session: "s1", RunID: "r1", Effect: "sha256:e1"} diff --git a/agentcore/run/regression_test.go b/agentcore/run/regression_test.go index 7303606a..7b5d60c1 100644 --- a/agentcore/run/regression_test.go +++ b/agentcore/run/regression_test.go @@ -3,9 +3,9 @@ package run_test import ( "testing" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" ) diff --git a/agentcore/run/schema/doc.go b/agentcore/run/schema/doc.go index b631b50b..f995b861 100644 --- a/agentcore/run/schema/doc.go +++ b/agentcore/run/schema/doc.go @@ -1,7 +1,7 @@ // Package schema composes the Run protocol's contracts: the state machine, // the fact and command codec, the digest rules, the snapshot codec, the // identity derivation and the frozen-body codec. They have no version and -// cannot be replaced (RUN-CMT-8): identity derivation and digests are +// cannot be replaced: identity derivation and digests are // persisted semantics, and the shape of persisted facts evolves through each -// event type's payload version and its codec (, EXT-REG-2). +// event type's payload version and its codec. package schema diff --git a/agentcore/run/schema/schema.go b/agentcore/run/schema/schema.go index fb561a4c..7744191c 100644 --- a/agentcore/run/schema/schema.go +++ b/agentcore/run/schema/schema.go @@ -9,28 +9,28 @@ import ( // The Run protocol's contracts, each a zero-size value that nothing can // replace: every Run in every Session is decided, folded, named and -// digested by these (RUN-CMT-8). This package exists because the state +// digested by these. This package exists because the state // machine (package run) cannot import the derivations that depend on it // (package canonical); it is the one place the two are composed. // Identity derives every RunID-scoped identity: step, call, response, -// effect and command ids (RUN-WIR-1). Its preimages are persisted semantics. +// effect and command ids. Its preimages are persisted semantics. func Identity() canonical.Identity { return canonical.Identity{} } -// Canonical is the digest rules for the bodies facts name (RUN-WIR-4). +// Canonical is the digest rules for the bodies facts name. func Canonical() canonical.Digests { return canonical.Digests{} } -// Machine is the state machine: Decide, Evolve and CreateGroup (RUN-MCH), +// Machine is the state machine: Decide, Evolve and CreateGroup, // composed with the identity and digest rules above. func Machine() run.StateMachine { return run.StateMachine{Canonical: canonical.Digests{}, Identity: canonical.Identity{}} } -// Wire names and decodes the fact and command variants (RUN-WIR-2). +// Wire names and decodes the fact and command variants. func Wire() wire.Facts { return wire.Facts{} } // Snapshot encodes a MachineState for the projection cache. func Snapshot() wire.Snapshot { return wire.Snapshot{} } -// Bodies encodes the frozen bodies facts name by digest (RUN-WIR-4). +// Bodies encodes the frozen bodies facts name by digest. func Bodies() frozen.Bodies { return frozen.Bodies{} } diff --git a/agentcore/run/sessionstore/cachepolicy_test.go b/agentcore/run/sessionstore/cachepolicy_test.go index 00efdba7..bc3e7b23 100644 --- a/agentcore/run/sessionstore/cachepolicy_test.go +++ b/agentcore/run/sessionstore/cachepolicy_test.go @@ -8,7 +8,7 @@ import ( // WriterCachePolicy is the module's answer to "who may cache this projection": // the Writer may cache every projection except the machine projection, whose -// checkpoints the Runtime owns (RUN-CMT-2), and the interval is the +// checkpoints the Runtime owns, and the interval is the // deployment's, not a constant of this module. func TestWriterCachePolicyExcludesTheMachineProjection(t *testing.T) { other := module.ProjectionID("twilight/chatlog/surface") diff --git a/agentcore/run/sessionstore/codec_history_test.go b/agentcore/run/sessionstore/codec_history_test.go index 785a562d..2c250e34 100644 --- a/agentcore/run/sessionstore/codec_history_test.go +++ b/agentcore/run/sessionstore/codec_history_test.go @@ -31,7 +31,7 @@ func (withdrawnV1) Decode(w jsonstable.Value) (any, error) { return Event{RunID: body.RunID, Fact: run.ModelStepWithdrawn{StepID: body.Step}}, nil } -// EXT-REG-2: a fact type keeps the codec of every payload version it ever +// a fact type keeps the codec of every payload version it ever // wrote. With a superseded version in its history the type encodes at the // next version and still decodes the old wire to the current fact. func TestFactCodecHistoryDecodesEveryVersion(t *testing.T) { diff --git a/agentcore/run/sessionstore/frozen.go b/agentcore/run/sessionstore/frozen.go index 182f1c06..8f855b76 100644 --- a/agentcore/run/sessionstore/frozen.go +++ b/agentcore/run/sessionstore/frozen.go @@ -18,7 +18,7 @@ import ( // FrozenAuthority is the cas Authority of frozen run bodies: the run layer's // frozen.Store is one Authority of the artifact ContentStore, not a -// second content-addressed store (RUN-WIR-4). +// second content-addressed store. const FrozenAuthority artifact.Authority = frozen.Authority // FrozenMediaType is the media type frozen bodies are stored under; the cas @@ -31,7 +31,7 @@ const FrozenMediaType = "application/vnd.twilight.frozen+json" // no index maps digests to refs: Get rebuilds the Ref from the digest alone. // Put stores the body and registers its Binding in one call, so a fact that // names the digest is admissible as soon as Put returns and no caller has to -// coordinate two stores before a commit (RUN-WIR-4, EXT-WRT-3). Bodies are +// coordinate two stores before a commit. Bodies are // EventBound content whose retention root is the fact that names them; the // adapter offers no Delete. type frozenValues struct { @@ -70,7 +70,7 @@ func (f *frozenValues) Put(ctx context.Context, digest run.Digest, val []byte) e return err } // An identical Binding is already registered on a replay; only a - // differing Ref under the same BindingID conflicts (ART-BND-1). + // differing Ref under the same BindingID conflicts. _, err = f.bindings.CreateBinding(ctx, binding) return err } @@ -105,7 +105,6 @@ func (f *frozenValues) Get(ctx context.Context, digest run.Digest) (body []byte, // FrozenRef is the cas Ref of the body named by digest: Key and Integrity are // both the digest, MediaType and Durability are the adapter's constants. The // Run protocol knows only the digest; this derivation is the adapter's -// (RUN-WIR-4). func FrozenRef(digest run.Digest) (artifact.Ref, error) { algorithm, val, ok := strings.Cut(string(digest), ":") if !ok || algorithm != artifact.IntegritySHA256 || val == "" { @@ -134,7 +133,6 @@ func FrozenBinding(digest run.Digest) (artifact.Binding, error) { // frozenRefs is the BindingExtractor of the fact types that name a frozen // body: the one BindingID the Writer admits and claims for the commit -// (EXT-REF-2, EXT-WRT-3). func frozenRefs(val any) ([]artifact.BindingID, error) { ev, ok := val.(Event) if !ok { @@ -161,7 +159,7 @@ func frozenRefs(val any) ([]artifact.BindingID, error) { // --- materialization --------------------------------------------------------------- -// Content resolves the bodies facts name by digest (RUN-WIR-4) for the layer +// Content resolves the bodies facts name by digest for the layer // that renders a structural projection into a usable representation. It is // the materialization port: projections stay pure folds over facts and never // touch it. diff --git a/agentcore/run/sessionstore/frozen_test.go b/agentcore/run/sessionstore/frozen_test.go index 7d31b2b0..433425f4 100644 --- a/agentcore/run/sessionstore/frozen_test.go +++ b/agentcore/run/sessionstore/frozen_test.go @@ -43,7 +43,7 @@ func fileFrozen(t *testing.T, root string) frozen.Store { return fz } -// The frozen.Store adapter over the cas ContentStore (RUN-WIR-4): the +// The frozen.Store adapter over the cas ContentStore: the // digest is the SHA-256 of the stored bytes, so Put and Get need no index; a // body under a name it does not digest to is refused; and a second instance // over the same root reads what the first wrote -- the path a restarted diff --git a/agentcore/run/sessionstore/guard.go b/agentcore/run/sessionstore/guard.go index c33ad2d7..d821e316 100644 --- a/agentcore/run/sessionstore/guard.go +++ b/agentcore/run/sessionstore/guard.go @@ -8,7 +8,7 @@ import ( // RequireNoActiveRun is the Run module's quiescence precondition for a // Session-level operation evaluated inside the Writer's critical section: -// an error while any Run of the Session is active. Terminal Runs leave the machine projection (RUN-CMT-2), so an +// an error while any Run of the Session is active. Terminal Runs leave the machine projection, so an // empty Active set is the whole answer: no Run is executing, awaiting an // effect or holding an unresolved outcome. func RequireNoActiveRun(view writer.View) error { diff --git a/agentcore/run/sessionstore/module.go b/agentcore/run/sessionstore/module.go index a9de0f6e..efccde4a 100644 --- a/agentcore/run/sessionstore/module.go +++ b/agentcore/run/sessionstore/module.go @@ -29,7 +29,7 @@ const ( // lineage, so a fork never reads an ancestor's Runs as its own. var streamDefinition = module.StreamDefinition{Domain: StreamDomain, Key: streamKey, Inheritance: module.Own} -// streamKey binds a run event to its Run's stream (EXT-STR-1). +// streamKey binds a run event to its Run's stream. func streamKey(value any) (string, error) { ev, ok := value.(Event) if !ok { @@ -52,7 +52,7 @@ func EventType(f run.Fact) ledger.EventType { } // Event is the typed value of one twilight/run/ event: the fact plus the -// RunID that every payload carries at its first level (RUN-WIR-2). +// RunID that every payload carries at its first level. type Event struct { RunID run.RunID Fact run.Fact @@ -139,7 +139,7 @@ func (c factCodec) Decode(w jsonstable.Value) (any, error) { // frozenBodyFacts are the fact types whose digest names a frozen body; each // declares one artifact reference so the Writer admits the body's Binding -// and claims it for the commit (RUN-WIR-4, EXT-REF-2). +// and claims it for the commit. var frozenBodyFacts = map[string]bool{ "model_step_prepared": true, "model_step_completed": true, "tool_call_completed": true, "tool_call_answered": true, } @@ -153,11 +153,11 @@ var frozenBinding = module.BindingReferenceDefinition{ var one uint32 = 1 -// Module is the run ModuleDescriptor (RUN-SCP-2: no Requires). Conversation +// Module is the run ModuleDescriptor (no Requires). Conversation // and Turn projections consume its facts; nothing of theirs is written here. var Module = buildModule() -// factCodecs is the codec history of one fact type (, EXT-REG-2): +// factCodecs is the codec history of one fact type: // one PayloadCodec per payload version ever written, keyed by version, and // the current version. older holds the superseded versions 1..n-1, each a // codec of its own that decodes that version's wire shape and upcasts to diff --git a/agentcore/run/sessionstore/projection.go b/agentcore/run/sessionstore/projection.go index 2df0ba03..9c21feb5 100644 --- a/agentcore/run/sessionstore/projection.go +++ b/agentcore/run/sessionstore/projection.go @@ -15,7 +15,7 @@ import ( const MachineProjectionID module.ProjectionID = "twilight/run/machine" -// Machine is the twilight/run/machine projection state (RUN-CMT-2): every +// Machine is the twilight/run/machine projection state: every // non-terminal Run of the Session with its last event position and schema. // Terminal Runs leave the projection entirely; Record and the turn surface // keep their results, and a second creation of a used RunID is refused at @@ -50,7 +50,7 @@ func (m Machine) snapshot(runID run.RunID) (store.Snapshot, bool) { return store.Snapshot{State: ms, Position: m.Positions[runID]}, true } -// Apply folds one decoded run event (RUN-MCH-3 via Protocol.Evolve). +// Apply folds one decoded run event (via Protocol.Evolve). func (m Machine) Apply(e module.DecodedEvent) (Machine, error) { //nolint:gocritic // hugeParam: DecodedEvent is the extension Apply shape ev, ok := e.Value.(Event) if !ok { @@ -169,7 +169,7 @@ var _ ledger.EventType = Prefix // WriterCachePolicy is this module's use of the projection cache for the // Writer: every projection at the deployment's interval, except the machine // projection, whose entry the SessionRunStore refreshes itself through SnapshotPolicy -// and which must never be cached mid-step (RUN-CMT-2). every is the interval in +// and which must never be cached mid-step. every is the interval in // commits; zero or less takes module.DefaultCacheEvery. func WriterCachePolicy(every ledger.CommitSeq) module.CachePolicy { return module.CacheEvery(every).Exclude(MachineProjectionID) diff --git a/agentcore/run/sessionstore/recover.go b/agentcore/run/sessionstore/recover.go index d93820c3..bad1c36e 100644 --- a/agentcore/run/sessionstore/recover.go +++ b/agentcore/run/sessionstore/recover.go @@ -10,19 +10,19 @@ import ( ) // Reconciler decides the takeover disposition of one Run's Executing targets -// (RUN-CMT-7); reconcile.Reconciler is the implementation. +// reconcile.Reconciler is the implementation. type Reconciler interface { Reconcile(ctx context.Context, st store.RunStore, snapshot *store.Snapshot) (int, error) } -// RecoverInterrupted is the Session-level takeover (RUN-CMT-7): every active +// RecoverInterrupted is the Session-level takeover: every active // Run of the Session is reconciled. Each recovery command is identified by -// the effect it disposes (RUN-WIR-1), so an owner that repeats the takeover, +// the effect it disposes, so an owner that repeats the takeover, // or a later owner, replays the same commands idempotently. The host calls it // once after opening the Writer and before driving any Run. The Reconciler is // required: a caller that holds no executor and wants every Executing target // disposed says so with reconcile.Reconciler{Abandon: true}; nothing is -// disposed for want of an executor to ask (RUN-CMT-7). It returns the number +// disposed for want of an executor to ask. It returns the number // of accepted recovery commands. func (s *SessionRunStore) RecoverInterrupted(ctx context.Context, w writer.Writer, rec Reconciler) (int, error) { if err := store.CheckContext(ctx); err != nil { diff --git a/agentcore/run/sessionstore/store.go b/agentcore/run/sessionstore/store.go index 8069bc10..127734d7 100644 --- a/agentcore/run/sessionstore/store.go +++ b/agentcore/run/sessionstore/store.go @@ -36,9 +36,9 @@ type Config struct { Registry *module.Registry // Store is the read side: Record folds from it (through Cache) without // taking ownership; commands write through the Writer a port is bound - // to (OWN-HDL-2). + // to. Store session.Store - // Frozen holds the bodies facts name by digest (RUN-WIR-4). It is + // Frozen holds the bodies facts name by digest. It is // required, and it must register each body's Binding for the Writers' // admission: FrozenValues over a ContentStore and a BindingStore does. Frozen frozen.Store @@ -102,7 +102,7 @@ func loadMachine(view writer.View) (Machine, error) { // Bind returns the store.RunStore over one Session Writer: the caller's // ownership capability, so every command of a drive lands on the same Writer, -// epoch and projection view (OWN-HDL-2). +// epoch and projection view. func (s *SessionRunStore) Bind(w writer.Writer) store.RunStore { return &bound{s: s, w: w} } type bound struct { @@ -112,12 +112,12 @@ type bound struct { func (b *bound) Scope() run.Scope { return run.Scope(b.w.SessionID()) } -// Writer is the ownership capability the store is bound to (OWN-HDL-2); +// Writer is the ownership capability the store is bound to; // a Loop hook that must commit through the same Writer takes it from here. func (b *bound) Writer() writer.Writer { return b.w } -// Load reads the Writer's transactional projection (RUN-CMT-2); a Run the -// projection no longer holds is folded from its own stream (RUN-CMT-1). +// Load reads the Writer's transactional projection; a Run the +// projection no longer holds is folded from its own stream. func (b *bound) Load(ctx context.Context, runID run.RunID) (store.Snapshot, error) { if err := store.CheckContext(ctx); err != nil { return store.Snapshot{}, err @@ -142,7 +142,7 @@ func (b *bound) Load(ctx context.Context, runID run.RunID) (store.Snapshot, erro } // Commit is one Run command as a unit of work of its own: the Run module's -// Part alone (RUN-CMT-3). +// Part alone. func (b *bound) Commit(ctx context.Context, req store.CommitRequest) (store.CommitResult, error) { if err := store.CheckContext(ctx); err != nil { return store.CommitResult{}, err @@ -163,7 +163,7 @@ func (b *bound) FrozenRequest(ctx context.Context, digest run.Digest) (model.Mod } // FrozenRequest returns the request body a Prepared or Executing ModelStep -// names by RequestDigest (RUN-WIR-4). +// names by RequestDigest. func (s *SessionRunStore) FrozenRequest(ctx context.Context, digest run.Digest) (model.ModelRequest, error) { if err := store.CheckContext(ctx); err != nil { return model.ModelRequest{}, err @@ -183,7 +183,7 @@ func (s *SessionRunStore) FrozenRequest(ctx context.Context, digest run.Digest) // --- Command part ------------------------------------------------------------------- -// Command is the Run module's Part for one command (RUN-CMT-3): constructing +// Command is the Run module's Part for one command: constructing // it freezes the bodies the command's facts will name, Prepare evaluates the // command inside the Writer against the machine projection and contributes // the facts to the Run's stream, Result reads the accepted decision back @@ -222,7 +222,7 @@ func (c *Command) Prepare(_ context.Context, view writer.View, now int64) ([]wri } state, active := proj.Active[runID] if !active { - // Terminal Runs leave the projection (RUN-CMT-2): tell terminal from + // Terminal Runs leave the projection: tell terminal from // unknown by the Run's own stream. if _, exists := view.StreamHead(Stream(runID)); exists { return nil, run.ErrRunTerminal @@ -268,7 +268,7 @@ func (c *Command) Result(ctx context.Context, w writer.Writer, res *writer.Commi Snapshot: store.Snapshot{State: c.after, Position: c.position}}, nil case writer.CommitAlreadyApplied: // Idempotency is the Session's (SessionID, CommitID) index alone - // (RUN-CMT-5): every Run CommandID is content-derived, so a hit is the + // every Run CommandID is content-derived, so a hit is the // same command and nothing is re-decided. snapshot, err := c.s.Bind(w).Load(ctx, c.req.Command.RunID) if err != nil { @@ -307,11 +307,10 @@ func (s *SessionRunStore) factsOf(c ledger.Commit, runID run.RunID) ([]run.Fact, } // freezeBodies stores the bodies a command's facts will name by digest -// (RUN-WIR-4): the request of a Prepare, the result of a model settlement, +// the request of a Prepare, the result of a model settlement, // the output of a tool settlement and the payload of an external response. // Each body is encoded under the Run's schema as the envelope its digest was -// computed from (RUN-CMT-8: digest rules and body encoding are versioned -// together with Decide and Evolve). +// computed from (digest rules and body encoding are versioned together with Decide and Evolve). func (s *SessionRunStore) freezeBodies(ctx context.Context, cmd run.AgentCommand) error { var digest run.Digest var body []byte @@ -341,7 +340,7 @@ func (s *SessionRunStore) freezeBodies(ctx context.Context, cmd run.AgentCommand } // afterCommit writes the machine projection to the cache when the policy asks -// for it (RUN-CMT-2). Cache failures never affect the commit. +// for it. Cache failures never affect the commit. func (s *SessionRunStore) afterCommit(ctx context.Context, w writer.Writer, before, after *run.MachineState) { if s.cfg.Cache == nil || !s.cfg.Snapshot(before, after) { return @@ -359,7 +358,7 @@ func (s *SessionRunStore) afterCommit(ctx context.Context, w writer.Writer, befo // ErrRunExists reports a creation of a RunID this Session already holds. var ErrRunExists = errors.New("sessionstore: run already exists") -// CreateRun is the Part that establishes a Run (RUN-NEW-1): RunCreated and +// CreateRun is the Part that establishes a Run: RunCreated and // one InputAccepted per initial input, in the Run's own stream. The owning // module places it in its creation unit beside its own facts. A RunID whose // stream this Session already wrote -- active or ended -- is ErrRunExists. @@ -399,7 +398,6 @@ type Record struct { } // Record folds a Run from the Store by SessionID; it needs no ownership -// (OWN-HDL-2). func (s *SessionRunStore) Record(ctx context.Context, sid session.SessionID, runID run.RunID) (Record, error) { if err := store.CheckContext(ctx); err != nil { return Record{}, err @@ -420,7 +418,7 @@ func (s *SessionRunStore) Record(ctx context.Context, sid session.SessionID, run } // record reads the Run's events from the Store and folds them: the ledger -// fold is the authoritative read (RUN-CMT-1). When expect is given, the fold +// fold is the authoritative read. When expect is given, the fold // is compared with the projection state, but only if both were read at the // same head: the two reads are separate round trips, and a commit landing // between them makes both correct at different points, not divergent. diff --git a/agentcore/run/sessionstore/storetest/conformance.go b/agentcore/run/sessionstore/storetest/conformance.go index 3e87cc4c..1684e7d7 100644 --- a/agentcore/run/sessionstore/storetest/conformance.go +++ b/agentcore/run/sessionstore/storetest/conformance.go @@ -25,7 +25,7 @@ import ( "testing" ) -// Run executes the RUN-CMP-2 Runtime conformance suite against fixtures made +// Run executes the Runtime conformance suite against fixtures made // by factory. func Run(t *testing.T, factory Factory) { t.Helper() @@ -92,7 +92,7 @@ func testCreation(t *testing.T, factory Factory) { } // A second created for the same RunID -- active or ended -- is refused by // the Run module's creation Part from the ledger's stream index, before - // anything reaches the ledger (RUN-NEW-1). + // anything reaches the ledger. again, err := run.BuildNewRun("r1", "") if err != nil { t.Fatal(err) @@ -265,13 +265,12 @@ func testDeclineToolCall(t *testing.T, factory Factory) { if types := eventTypes(res.Events); len(types) != 1 || types[0] != sessionstore.Prefix+"tool_call_failed" { t.Fatalf("decline events = %v, want tool_call_failed alone", types) } - ts := mustToolStep(t, res.Snapshot.State.Current) + ts := mustType[run.ToolStep](h.t, res.Snapshot.State.Current) if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Effect != "" { t.Fatalf("declined call = %+v, want Failed with no effect", ts.Calls[0]) } // The CommandID names the decline of this call: a replay, with the same // or another reason, is AlreadyApplied and the first decline stands - // (RUN-CMT-5). if again := h.mustCommit("r1", id, 0, decline); again.Status != store.CommitAlreadyApplied { t.Fatalf("decline replay = %v", again.Status) } @@ -328,7 +327,7 @@ func testGroupComposition(t *testing.T, factory Factory) { resultDigest = c.ResultDigest } } - ts := mustToolStep(t, res.Snapshot.State.Current) + ts := mustType[run.ToolStep](h.t, res.Snapshot.State.Current) call := ts.Calls[0].CallID entries := h.contextEntries() last := entries[len(entries)-1] @@ -379,52 +378,7 @@ func (h *harness) contextEntries() []chatlog.Entry { if err != nil { h.fatal(err) } - return mustContext(h.t, state).Entries -} - -func mustToolStep(t testing.TB, current run.Current) run.ToolStep { - t.Helper() - step, ok := current.(run.ToolStep) - if !ok { - t.Fatalf("current = %T, want run.ToolStep", current) - } - return step -} - -func mustModelStep(t testing.TB, current run.Current) run.ModelStep { - t.Helper() - step, ok := current.(run.ModelStep) - if !ok { - t.Fatalf("current = %T, want run.ModelStep", current) - } - return step -} - -func mustContext(t testing.TB, state any) chatlog.Context { - t.Helper() - ctx, ok := state.(chatlog.Context) - if !ok { - t.Fatalf("projection state = %T, want chatlog.Context", state) - } - return ctx -} - -func mustTurnSurface(t testing.TB, state any) turn.TurnSurface { - t.Helper() - surface, ok := state.(turn.TurnSurface) - if !ok { - t.Fatalf("projection state = %T, want turn.TurnSurface", state) - } - return surface -} - -func mustMachine(t testing.TB, state any) sessionstore.Machine { - t.Helper() - machine, ok := state.(sessionstore.Machine) - if !ok { - t.Fatalf("projection state = %T, want sessionstore.Machine", state) - } - return machine + return mustType[chatlog.Context](h.t, state).Entries } // turnSurface reads the turn surface projection through the owner's Writer. @@ -434,7 +388,7 @@ func (h *harness) turnSurface() turn.TurnSurface { if err != nil { h.fatal(err) } - return mustTurnSurface(h.t, state) + return mustType[turn.TurnSurface](h.t, state) } func asStrings(types []ledger.EventType) []string { @@ -547,7 +501,7 @@ func testPrepareCASIgnoresOtherModules(t *testing.T, factory Factory) { } } -// RUN-CMT-5: a settlement CommandID names the effect rather than the +// a settlement CommandID names the effect rather than the // outcome, so every settlement of the same effect is the same operation: the // first one stands and later ones, whatever they carry, are AlreadyApplied // without a second Decide. @@ -593,7 +547,7 @@ func testProjection(t *testing.T, factory Factory) { result, bindings := h.toolCallResult(step, 1) opened := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: result, Calls: bindings}) - toolStep := mustToolStep(t, opened.Snapshot.State.Current).RefValue.ID + toolStep := mustType[run.ToolStep](h.t, opened.Snapshot.State.Current).RefValue.ID callID := bindings[0].CallID toolEff := h.startTool("r1", toolStep, callID) res := h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(toolEff), 0, @@ -617,7 +571,7 @@ func testProjection(t *testing.T, factory Factory) { if !ok || !wire.StatesEquivalent(&active, &loaded.State) || !wire.StatesEquivalent(&active, &rec.Snapshot.State) { t.Fatal("projection, Load and Record disagree") } - if fromCache := mustMachine(t, fromCache).Active["r1"]; !wire.StatesEquivalent(&fromCache, &active) { + if cached := mustType[sessionstore.Machine](h.t, fromCache).Active["r1"]; !wire.StatesEquivalent(&cached, &active) { t.Fatal("observer's cache+tail disagrees with the writer's projection") } // Terminal Run leaves the projection entirely; Load and Record still @@ -679,7 +633,7 @@ func testTakeover(t *testing.T, factory Factory) { h.executingModel("r1", false) before := h.load("r1") // An input delivered while the model runs waits in PendingInputs; the - // recovery-time plan must include it (TRN-DUR-1). + // recovery-time plan must include it. h.submitInputs(input("in-late")) h.mustCommit("r1", schema.Identity().DeriveInputCommandID("r1", "in-late"), 0, run.NextStep(input("in-late"))) toolStep, ids := h.openToolStep("r2", 2) @@ -692,7 +646,7 @@ func testTakeover(t *testing.T, factory Factory) { } // The unreachable model attempt is withdrawn: the Run is Open again with // the late input still pending and the step no longer counted, so the - // next Prepare is a fresh decision, not a replay (RUN-CMT-7, TRN-DUR-1). + // next Prepare is a fresh decision, not a replay. r1 := h.load("r1") if _, open := r1.State.Current.(run.Open); !open || r1.State.Status != run.RunActive || r1.State.ModelSteps != 0 || len(r1.State.PendingInputs) != 1 || r1.State.PendingInputs[0].ID != "in-late" { @@ -703,17 +657,17 @@ func testTakeover(t *testing.T, factory Factory) { // input. What the request contains is the PromptBuilder's business; the harness // plans a fixed request, so only the identities and the input flow are // asserted here. - aborted := mustModelStep(t, before.State.Current).RefValue.ID + aborted := mustType[run.ModelStep](h.t, before.State.Current).RefValue.ID replanned := h.prepare("r1", false) after := h.load("r1") - if replanned == aborted || mustModelStep(t, after.State.Current).RefValue.ID != replanned { + if replanned == aborted || mustType[run.ModelStep](h.t, after.State.Current).RefValue.ID != replanned { t.Fatalf("replan reused the aborted step %s", aborted) } if len(after.State.PendingInputs) != 0 || after.State.ModelSteps != 1 { t.Fatalf("replan left pending=%d steps=%d, want the late input consumed and one counted step", len(after.State.PendingInputs), after.State.ModelSteps) } r2 := h.load("r2") - ts := mustToolStep(t, r2.State.Current) + ts := mustType[run.ToolStep](h.t, r2.State.Current) if r2.State.Status != run.RunActive || ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { t.Fatalf("executing call after takeover = %+v", ts.Calls[0]) } @@ -721,7 +675,7 @@ func testTakeover(t *testing.T, factory Factory) { t.Fatalf("pending sibling = %+v, want untouched", ts.Calls[1]) } // The Unknown is the call's terminal fact; the conversation projects it - // as a tool_result with status unknown and no body (TRN-DUR-4). + // as a tool_result with status unknown and no body. rec := h.record("r2") found := false for _, e := range rec.Events { @@ -786,7 +740,7 @@ func (p *livePort) GetOutcome(context.Context, effect.AssignmentKey) (effect.Out } func (p *livePort) Cancel(context.Context, effect.AssignmentKey) error { return nil } -// RUN-CMT-7 with a reachable executor: a target whose effect the executor +// with a reachable executor: a target whose effect the executor // still executes is not disposed -- it stays Executing under its original // effect and that effect's settlement is accepted afterwards -- while a // target the executor no longer holds is disposed as before. @@ -825,11 +779,11 @@ func testReattach(t *testing.T, factory Factory) { t.Fatalf("takeover asked about an unknown effect %q", key.Effect) } } - ms := mustModelStep(t, h.load("r1").State.Current) + ms := mustType[run.ModelStep](h.t, h.load("r1").State.Current) if ms.Status != run.ModelExecuting || ms.Effect != modelEff { t.Fatalf("reattached model step = %+v, want Executing under the original effect", ms) } - ts := mustToolStep(t, h.load("r2").State.Current) + ts := mustType[run.ToolStep](h.t, h.load("r2").State.Current) if ts.Calls[0].Status != run.ToolFailed || ts.Calls[0].Failure == nil || ts.Calls[0].Failure.Outcome != run.ToolOutcomeUnknown { t.Fatalf("unreachable tool call = %+v, want Unknown", ts.Calls[0]) } @@ -865,13 +819,12 @@ func testOwnershipLost(t *testing.T, factory Factory) { } // Reading needs no ownership: the superseded process still reads the // ledger by SessionID and sees the state as the new owner left it - // (OWN-HDL-2); only its Writer's view and commits are fenced. - rec, err := old.Record(h.ctx, sid, "r1") - if err != nil || mustModelStep(t, rec.Snapshot.State.Current).Status != run.ModelExecuting { + // only its Writer's view and commits are fenced. + if rec, err := old.Record(h.ctx, sid, "r1"); err != nil || mustType[run.ModelStep](h.t, rec.Snapshot.State.Current).Status != run.ModelExecuting { t.Fatalf("old owner record = %v, want the current state without an ownership error", err) } // The new owner is unaffected. - if mustModelStep(t, h.load("r1").State.Current).Status != run.ModelExecuting { + if mustType[run.ModelStep](h.t, h.load("r1").State.Current).Status != run.ModelExecuting { t.Fatal("new owner's view changed") } } @@ -882,7 +835,7 @@ func testFrozenValues(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) h.startRun("t1", "r1", input("in-1")) step, eff := h.executingModel("r1", false) - digest := mustModelStep(t, h.load("r1").State.Current).RequestDigest + digest := mustType[run.ModelStep](h.t, h.load("r1").State.Current).RequestDigest body, _, err := h.frozen.Get(h.ctx, digest) if err != nil { t.Fatal(err) @@ -895,7 +848,7 @@ func testFrozenValues(t *testing.T, factory Factory) { } h.mustCommit("r1", schema.Identity().DeriveSettlementCommandID(eff), 0, run.SubmitModelResult{StepID: step, Effect: eff, Result: textResult("done")}) // The body is EventBound content the artifact layer retains; Record never - // depends on it (RUN-WIR-4). + // depends on it. if _, err := h.rt.Record(h.ctx, sid, "r1"); err != nil { t.Fatalf("record after settlement: %v", err) } diff --git a/agentcore/run/sessionstore/storetest/harness.go b/agentcore/run/sessionstore/storetest/harness.go index 95f8d1a1..c06840d4 100644 --- a/agentcore/run/sessionstore/storetest/harness.go +++ b/agentcore/run/sessionstore/storetest/harness.go @@ -1,4 +1,4 @@ -// Package storetest is the RunStore conformance suite (RUN-CMP-2). It takes a +// Package storetest is the RunStore conformance suite. It takes a // session.Store factory so the Memory store and every durable adapter run the // same assertions; it asserts Run semantics only and leaves group atomicity, // ownership fencing and cache equivalence to the kernel and @@ -41,6 +41,15 @@ type Factory func(t testing.TB) Fixture const sid session.SessionID = "conformance" +func mustType[T any](t testing.TB, value any) T { + t.Helper() + result, ok := value.(T) + if !ok { + t.Fatalf("state has type %T, want %T", value, result) + } + return result +} + type clock struct { mu sync.Mutex now time.Time @@ -108,7 +117,7 @@ func (h *harness) takeover() (*sessionstore.SessionRunStore, writer.Writer) { old, oldWriter := h.rt, h.writer() h.open() // Ownership changes hands when the new process opens its Writer, not - // when it reads: reads take no lease (OWN-HDL-2). + // when it reads: reads take no lease. h.writer() return old, oldWriter } @@ -180,7 +189,7 @@ func (h *harness) submitInputs(inputs ...run.AgentInput) { } } -// startGroup is TRN-STR-2 without a Coordinator: turn/started, +// startGroup builds the atomic turn-start group without a Coordinator: turn/started, // attempt/started, input_delivered*, run/created, input_accepted*. Owner // is the TurnID. func (h *harness) startGroup(turnID turn.TurnID, runID run.RunID, attempt uint32, inputs ...run.AgentInput) writer.SemanticGroup { @@ -214,7 +223,7 @@ func (h *harness) startGroup(turnID turn.TurnID, runID run.RunID, attempt uint32 for _, f := range facts { runEvents = append(runEvents, writer.TypedEvent{Type: sessionstore.EventType(f), RecordedAtUnixMilli: 1, Value: sessionstore.Event{RunID: runID, Fact: f}}) } - // One batch per stream: the Turn's (naming its Run, TRN-SCP-2), the + // One batch per stream: the Turn's (naming its Run), the // chatlog's when inputs are delivered, and the Run's. if len(turnEvents) > 0 { group.Batches = append(group.Batches, writer.TypedBatch{Domain: turn.Stream(turnID), Events: turnEvents}) @@ -344,7 +353,7 @@ func (h *harness) mustCommit(runID run.RunID, id run.CommandID, base run.RunPosi return res } -// modelEffect derives the next model effect of the Prepared step (RUN-WIR-1): +// modelEffect derives the next model effect of the Prepared step: // the step's rejected-result count is the effect's sequence. func (h *harness) modelEffect(runID run.RunID, step run.StepID) run.EffectID { h.t.Helper() @@ -479,7 +488,7 @@ func (h *harness) machine() sessionstore.Machine { if err != nil { h.fatal(err) } - return mustMachine(h.t, state) + return mustType[sessionstore.Machine](h.t, state) } func eventTypes(events []ledger.Event) []ledger.EventType { diff --git a/agentcore/run/settlement_test.go b/agentcore/run/settlement_test.go index 8c50a24f..9e5a356c 100644 --- a/agentcore/run/settlement_test.go +++ b/agentcore/run/settlement_test.go @@ -8,7 +8,7 @@ import ( "github.com/felinics/twilight/agentcore/run/schema" ) -// RUN-WIR-1: every fact that closes an effect names it, and RunEnded closes +// every fact that closes an effect names it, and RunEnded closes // none. A model failure and a cancellation settle the executing model effect // with ModelStepFailed before the Run ends; a rejection names the effect it // settles; Evolve refuses a RunEnded over an Executing target and a diff --git a/agentcore/run/state.go b/agentcore/run/state.go index 9d79d547..d5d2c01a 100644 --- a/agentcore/run/state.go +++ b/agentcore/run/state.go @@ -69,7 +69,7 @@ const ( FailureProvider = "provider_failure" ) -// Tool execution failure classes (RUN-EXE-11): what went wrong inside a +// Tool execution failure classes: what went wrong inside a // tool's Execute, as the tool reports it in ToolFailure.Class. The class // describes the error; whether it is worth retrying is the failure's own // RetryDisposition (effect.ToolExecutionFailed.Retry), the two are not @@ -94,7 +94,7 @@ const ( // ReplayPolicy is a tool's judgment of its own side effects: whether the // Worker that adopts a lost execution of the tool may run it again for the -// same call (RUN-EXE-9, TRN-DUR-4). It is declared by the tool +// same call. It is declared by the tool // implementation, frozen into the ToolSpec and copied onto each call and its // Assignment, so every Worker that reads the execution record decides alike. // Only ReplayAllowed is re-dispatched; ReplayForbidden and the zero value @@ -123,7 +123,7 @@ func (p ReplayPolicy) String() string { } } -// ToolPlacement is where a tool call must run (RUN-LOP-9): in the process +// ToolPlacement is where a tool call must run: in the process // that executes it, or inside the workspace the Session is bound to. It is // declared by the tool implementation, frozen into the ToolSpec and copied // onto each call and its Assignment, so the target resolver asks for a @@ -149,7 +149,7 @@ func (p ToolPlacement) String() string { } // RetryDisposition is a Known failure's own answer to whether the next -// attempt of the same Assignment may run (RUN-EXE-11). RetryAllowed means +// attempt of the same Assignment may run. RetryAllowed means // this failure suffices to confirm the attempt produced no external effect // that cannot safely be repeated; RetryNever means the failure is definite // or a second attempt is not safe to assert. It is a property of the @@ -208,7 +208,7 @@ type ResponseRequest struct { } // ToolSpec is the agent-side sidecar for a provider-neutral ToolDefinition. -// The definition body lives inside the frozen request (RUN-WIR-4); the spec +// The definition body lives inside the frozen request; the spec // keeps its model-facing Name for binding tool calls, the digest for // execution-time verification, and the ResponsePolicy, which is intentionally // kept out of sdk to preserve package layering. @@ -285,7 +285,7 @@ func (s ModelStepStatus) String() string { type ModelStep struct { RefValue StepRef `json:"ref"` // RequestDigest identifies the frozen request; its body is kept in the - // frozen.Store for the life of the step (RUN-WIR-4). + // frozen.Store for the life of the step. RequestDigest Digest `json:"requestDigest"` Model ModelRef `json:"model"` Tools []ToolSpec `json:"tools,omitempty"` @@ -296,7 +296,7 @@ type ModelStep struct { // keeps no effect record beside the step. A rejected result returns the // step to Prepared without clearing it, and the next start requests a new // effect. A new owner recovers an Executing step under a CommandID derived - // from it (RUN-CMT-7). + // from it. Effect EffectID `json:"effect,omitempty"` // Rejects counts accepted ModelStepRejected facts. Rejects int `json:"rejects,omitempty"` @@ -340,7 +340,7 @@ func (s ToolCallStatus) Terminal() bool { return s == ToolCompleted || s == Tool // ToolExecutionResult is the transient output a tool worker submits. The // state and the fact keep only its digest; the body is frozen under that -// digest before the fact is committed (RUN-WIR-4). +// digest before the fact is committed. type ToolExecutionResult struct { Output CanonicalJSON `json:"output"` } @@ -385,11 +385,11 @@ type ToolCallState struct { Result *ToolCallResult `json:"result,omitempty"` Failure *ToolCallFailure `json:"failure,omitempty"` // Waiting is the call's Wait while Status is ToolWaiting: the approval or - // external response it lacks (RUN-MCH-2). Nil in every other status. + // external response it lacks. Nil in every other status. Waiting *ResponseRequest `json:"waiting,omitempty"` } -// ValidateToolCallState rejects illegal field combinations (RUN-MCH-2). +// ValidateToolCallState rejects illegal field combinations. // //nolint:gocritic // hugeParam: public validator accepts the value stored in facts/state without mutating it. func ValidateToolCallState(c ToolCallState) error { @@ -491,7 +491,7 @@ func (s *ToolStep) callIndex(id CallID) int { return -1 } -// MachineState is the complete semantic state of one Run (RUN-MCH-1): the +// MachineState is the complete semantic state of one Run: the // fold of its facts along four dimensions. Progress is which Step the Run // is in and what it has frozen (Current, ModelSteps, LastToolStep). Inbox is // the input accepted and not yet consumed by a prepare (PendingInputs). diff --git a/agentcore/run/store/commit.go b/agentcore/run/store/commit.go index 469d8b6e..ce92673a 100644 --- a/agentcore/run/store/commit.go +++ b/agentcore/run/store/commit.go @@ -29,8 +29,8 @@ type CommitDecision struct { Reject error } -// ValidateEnvelope is step 1 of RUN-CMT-3: identity. Envelopes are only -// built by wire.Codec.Envelope (RUN-WIR-3), so there is no per-commit +// ValidateEnvelope checks command identity. Envelopes are only +// built by wire.Codec.Envelope, so there is no per-commit // self-verification of the command bytes. func ValidateEnvelope(env *wire.CommandEnvelope) error { if env.RunID == "" || env.ID == "" { @@ -40,8 +40,8 @@ func ValidateEnvelope(env *wire.CommandEnvelope) error { } // EvaluateCommit is the pure evaluation every RunStore runs inside its -// store's critical section after the replay lookup (RUN-CMT-3 steps 4-8). Execution ownership is -// Session-level (RUN-CMT-6), so there is no per-target authorization: a +// store's critical section after the replay lookup (steps 4-8). Execution ownership is +// Session-level, so there is no per-target authorization: a // command against a target whose state does not admit it is Stale. // //nolint:gocritic // hugeParam: public pure commit evaluator keeps state/request as value protocol inputs. @@ -54,11 +54,11 @@ func EvaluateCommit(cur run.MachineState, position run.RunPosition, req CommitRe return CommitDecision{}, err } // The effect a start, settlement or recovery names is part of the - // command identity (RUN-WIR-1): without it no CommandID derives. + // command identity: without it no CommandID derives. if op, effect, named := namedEffect(env.Command); named && effect == "" { return CommitDecision{Kind: DecisionConflict, Reject: fmt.Errorf("agent: commit: %s requires its effect identity", op)}, nil } - // Derived-identity families must use their derived CommandID (RUN-WIR-3): + // Derived-identity families must use their derived CommandID: // the derivation is the idempotency index, so a caller-minted random ID // cannot bypass duplicate detection. if err := checkDerivedCommandID(&env, req.Base, schema.Identity()); err != nil { @@ -70,7 +70,7 @@ func EvaluateCommit(cur run.MachineState, position run.RunPosition, req CommitRe return CommitDecision{Kind: DecisionTerminal, Reject: run.ErrRunTerminal}, nil } - // Base: PrepareModelRequest is the only hard-CAS command (RUN-CMT-4). + // Base: PrepareModelRequest is the only hard-CAS command. if _, plan := env.Command.(run.PrepareModelRequest); plan && req.Base != position { return CommitDecision{Kind: DecisionStale, Reject: run.ErrStaleRuntime}, nil } @@ -149,7 +149,7 @@ func namedEffect(cmd run.AgentCommand) (op string, effect run.EffectID, named bo } } -// checkDerivedCommandID enforces the derived-identity rules of RUN-WIR-3. +// checkDerivedCommandID enforces the derived-identity rules. func checkDerivedCommandID(env *wire.CommandEnvelope, base run.RunPosition, id run.Identity) error { var want run.CommandID switch cmd := env.Command.(type) { @@ -181,7 +181,7 @@ func checkDerivedCommandID(env *wire.CommandEnvelope, base run.RunPosition, id r want = id.DeriveSettlementCommandID(cmd.Effect) case run.SubmitToolFailure: // An Unknown tool failure is either the owner's settlement of a - // delivered Outcome or a takeover disposition (RUN-CMT-7). The two + // delivered Outcome or a takeover disposition. The two // are distinct operations with their own identities; either closes // the effect once. if cmd.Outcome == run.ToolOutcomeUnknown && env.ID == id.DeriveRecoveryCommandID(cmd.Effect) { diff --git a/agentcore/run/store/doc.go b/agentcore/run/store/doc.go index 73eee5ca..46c34dfa 100644 --- a/agentcore/run/store/doc.go +++ b/agentcore/run/store/doc.go @@ -1,4 +1,4 @@ -// Package store is the transactional boundary of a Run (RUN-CMT): the +// Package store is the transactional boundary of a Run: the // RunStore port a Loop drives, the Snapshot a command sees, the CommitRequest // and CommitResult of one command, the pure EvaluateCommit every adapter runs // inside its own commit, and FoldRun, the rebuild of a MachineState from a diff --git a/agentcore/run/store/fold.go b/agentcore/run/store/fold.go index 4741dc95..a90f0eff 100644 --- a/agentcore/run/store/fold.go +++ b/agentcore/run/store/fold.go @@ -9,7 +9,7 @@ import ( ) // FoldRun rebuilds a MachineState from the complete fact sequence of one Run -// in stream order (RUN-NEW-2): the first fact must be RunCreated. No Decide, +// in stream order: the first fact must be RunCreated. No Decide, // no effects, no replay. func FoldRun(facts []run.Fact) (run.MachineState, error) { if len(facts) == 0 { diff --git a/agentcore/run/store/store.go b/agentcore/run/store/store.go index 53eb2a96..c6c90a6e 100644 --- a/agentcore/run/store/store.go +++ b/agentcore/run/store/store.go @@ -24,11 +24,11 @@ func CheckContext(ctx context.Context) error { } // ErrOwnershipLost reports that the write capability behind a RunStore was -// superseded (RUN-CMT-6). It is terminal for the caller: no further command of +// superseded. It is terminal for the caller: no further command of // this process can reach the stream. var ErrOwnershipLost = errors.New("agent: run store ownership lost") -// RunStore is the transactional port of the Run core (RUN-CMT-1): the store +// RunStore is the transactional port of the Run core: the store // the Runs of one Scope live in, already bound to the caller's write // capability. It speaks only Run types; how a command reaches durable // storage, how facts are encoded on the wire and how the bound capability @@ -39,14 +39,14 @@ type RunStore interface { Scope() run.Scope // Load is the owner's view of a Run for the next command. A port whose // capability was superseded still answers from its own epoch's state and - // is fenced at Commit (RUN-LOP-5). + // is fenced at Commit. Load(context.Context, run.RunID) (Snapshot, error) - // Commit evaluates one command (RUN-CMT-3) and appends its facts as one + // Commit evaluates one command and appends its facts as one // atomic group. Replays are answered from the store's command index - // without re-deciding (RUN-CMT-5). + // without re-deciding. Commit(context.Context, CommitRequest) (CommitResult, error) // FrozenRequest returns the request body a Prepared or Executing ModelStep - // names by RequestDigest (RUN-WIR-4); a missing body is frozen.ErrMissing. + // names by RequestDigest; a missing body is frozen.ErrMissing. FrozenRequest(context.Context, run.Digest) (model.ModelRequest, error) } @@ -61,7 +61,6 @@ type Snapshot struct { type CommitRequest struct { // Base is the Position the caller loaded. PrepareModelRequest treats it // as a hard CAS; other commands rebase call-locally and may pass zero - // (RUN-CMT-4). Base run.RunPosition Command wire.CommandEnvelope } diff --git a/agentcore/run/wire/codec.go b/agentcore/run/wire/codec.go index 5f9f06fe..a8aefedf 100644 --- a/agentcore/run/wire/codec.go +++ b/agentcore/run/wire/codec.go @@ -16,8 +16,7 @@ const payloadVersion uint16 = 1 // CommandEnvelope carries one command with its protocol identity. Commands // are not persisted: ID is the CommitID of the commit the command produces, -// and replay is told from conflict by the store's command index (RUN-WIR-2, -// EXT-WRT-2). The envelope names no store: which store a command reaches is +// and replay is told from conflict by the store's command index. The envelope names no store: which store a command reaches is // the bound RunStore's. type CommandEnvelope struct { Type string `json:"type"` diff --git a/agentcore/run/wire/doc.go b/agentcore/run/wire/doc.go index e65c866e..c6666ea4 100644 --- a/agentcore/run/wire/doc.go +++ b/agentcore/run/wire/doc.go @@ -1,6 +1,6 @@ // Package wire is the persisted protocol of Run facts and commands: the sealed // variant registry of each schema version, the typed fact payloads and the -// command envelope (RUN-WIR-2, RUN-WIR-3), and the codec that stores a +// command envelope, and the codec that stores a // MachineState as a durable snapshot. // Decoding is strict: unknown fields, unknown discriminators and trailing data // are rejected before wire data can enter a RunStore. diff --git a/agentcore/run/wire/helpers_test.go b/agentcore/run/wire/helpers_test.go index 98bb6468..8157fc02 100644 --- a/agentcore/run/wire/helpers_test.go +++ b/agentcore/run/wire/helpers_test.go @@ -3,17 +3,17 @@ package wire_test import ( "testing" + "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/agentcore/jsonstable" "github.com/felinics/twilight/agentcore/run" "github.com/felinics/twilight/agentcore/run/model" "github.com/felinics/twilight/agentcore/run/schema" - "github.com/felinics/twilight/agentcore/testutil/sdkconv" "github.com/felinics/twilight/sdk" "github.com/google/jsonschema-go/jsonschema" ) // startModel is the start of the current Prepared step's next model effect -// (RUN-WIR-1): the step's rejected count is the effect's sequence. +// the step's rejected count is the effect's sequence. func startModel(s run.MachineState, stepID run.StepID) run.StartModelExecution { ms, _ := s.Current.(run.ModelStep) return run.StartModelExecution{StepID: stepID, Effect: schema.Identity().DeriveEffectID(s.RunID, stepID, "", ms.Rejects)} diff --git a/agentcore/run/wire/snapshot.go b/agentcore/run/wire/snapshot.go index 0d9bdbca..08831b98 100644 --- a/agentcore/run/wire/snapshot.go +++ b/agentcore/run/wire/snapshot.go @@ -29,7 +29,7 @@ type Snapshot struct{} // machineStateWire is the persisted snapshot shape of MachineState. It // flattens the interface-typed Current into a discriminator // plus at most one step body so the snapshot round-trips through JSON. Its -// canonical bytes are the InitialStateDigest preimage (RUN-NEW-1), so field +// canonical bytes are the InitialStateDigest preimage, so field // names and omission rules are frozen with the schema. type machineStateWire struct { RunID run.RunID `json:"runId"` diff --git a/agentcore/runtime/execution.go b/agentcore/runtime/execution.go index 63687eed..9c2d564e 100644 --- a/agentcore/runtime/execution.go +++ b/agentcore/runtime/execution.go @@ -35,23 +35,30 @@ type ExecutionSources struct { // ExecutionConfig composes one Execution: the effect port it drives, the // preset identities it resolves and the policies of the drive chain. type ExecutionConfig struct { - // Executor is the effect layer port (RUN-EXE-3): required. + // Executor is the effect layer port: required. Executor effect.ExecutionPort // Presets is the catalog of preset identities; nil selects an // in-memory catalog. Presets preset.Catalog - // PromptBuilders resolves each preset's PromptBuilderRef (DEC-CAT): + // PromptBuilders resolves each preset's PromptBuilderRef: // required. The runtime ships no builder; the agent built on it supplies // its catalog (agent/prompt.DefaultPromptBuilders for the reference agent). PromptBuilders *prompt.Catalog // MissingEffects is the takeover policy for an Executing effect the - // Executor holds nothing for (RUN-CMT-7): the zero value disposes it, + // Executor holds nothing: the zero value disposes it, // reconcile.RedispatchMissing hands it to the Executor again within a - // budget (RUN-EXE-15) and requires Redispatches. + // budget and requires Redispatches. MissingEffects reconcile.MissingPolicy // Redispatches is the dispatch ledger RedispatchMissing writes; durable - // like every store (OWN-PRT-3). Unused under DisposeMissing. + // like every store. Unused under DisposeMissing. Redispatches redispatch.Store + // MaxRedispatches bounds redispatches per effect; zero selects the + // reconciler's default. + MaxRedispatches int + // RedispatchRetry is the interval between reconciliations after the + // executor safely refused a redispatch before the effect boundary; zero + // selects the recovery default. Unknown boundaries are not redispatched. + RedispatchRetry time.Duration // Planner, when set, is consulted between the steps of every Run with // the Writer of the Session being driven: the application's in-turn // context policy. @@ -60,8 +67,8 @@ type ExecutionConfig struct { // they answer; nil answers none. Responders map[run.ToolRef]driver.Responder // TargetResolver supplies the opaque resource target of each effect - // (RUN-LOP-9). It belongs to the application's resource layer; nil - // gives every effect no target (APP-TGT-1). + // It belongs to the application's resource layer; nil + // gives every effect no target. TargetResolver loop.TargetResolver // Fail receives failures of work the Execution's components do outside // any caller's call, such as settling a reattached Outcome; nil @@ -109,8 +116,20 @@ func NewExecution(cfg ExecutionConfig, src ExecutionSources) (*Execution, error) if cfg.PromptBuilders == nil { return nil, errors.New("runtime: a prompt builder catalog is required (ExecutionConfig.PromptBuilders); the runtime ships no default") } - if cfg.MissingEffects == reconcile.RedispatchMissing && cfg.Redispatches == nil { - return nil, errors.New("runtime: MissingEffects=redispatch requires a dispatch ledger (ExecutionConfig.Redispatches, RUN-EXE-15)") + switch cfg.MissingEffects { + case reconcile.DisposeMissing: + case reconcile.RedispatchMissing: + if cfg.Redispatches == nil { + return nil, errors.New("runtime: MissingEffects=redispatch requires a dispatch ledger (ExecutionConfig.Redispatches)") + } + default: + return nil, errors.New("runtime: unknown MissingEffects policy") + } + if cfg.MaxRedispatches < 0 { + return nil, errors.New("runtime: MaxRedispatches must not be negative") + } + if cfg.RedispatchRetry < 0 { + return nil, errors.New("runtime: RedispatchRetry must not be negative") } presets := cfg.Presets if presets == nil { @@ -119,7 +138,7 @@ func NewExecution(cfg ExecutionConfig, src ExecutionSources) (*Execution, error) x := &Execution{Executor: cfg.Executor, Presets: presets, Progress: observe.NewProgresses()} x.Watcher = &effect.Watcher{Port: cfg.Executor, Probe: cfg.OrphanProbe} // Failures the components report outside any caller's call reach the - // caller's callback and the Session's transient stream (OBS-1). + // caller's callback and the Session's transient stream. fail := cfg.Fail if fail == nil { fail = func(session.SessionID, error) {} @@ -128,11 +147,12 @@ func NewExecution(cfg ExecutionConfig, src ExecutionSources) (*Execution, error) fail(sid, err) x.Progress.Failed(sid, err) } - // A nil resolver gives every effect no target (APP-TGT-1). + // A nil resolver gives every effect no target. loops := &driver.Loops{Executor: cfg.Executor, Presets: presets, PromptBuilders: cfg.PromptBuilders, Targets: cfg.TargetResolver, Sources: prompt.Sources{Projections: src.Projections, Content: src.Content}, Watcher: x.Watcher, Planner: cfg.Planner} x.Recovery = &driver.Recovery{Runs: src.Runs, Executor: cfg.Executor, Loops: loops, Watcher: x.Watcher, Fail: report, - MissingEffects: cfg.MissingEffects, Redispatches: cfg.Redispatches, OrphanProbe: cfg.OrphanProbe, Sink: progressSink{x.Progress}} + MissingEffects: cfg.MissingEffects, Redispatches: cfg.Redispatches, MaxRedispatches: cfg.MaxRedispatches, + RedispatchRetry: cfg.RedispatchRetry, OrphanProbe: cfg.OrphanProbe, Sink: progressSink{x.Progress}} var responders *driver.Responders if len(cfg.Responders) > 0 { responders = &driver.Responders{Runs: src.Runs, Tools: cfg.Responders, Fail: report} @@ -142,7 +162,7 @@ func NewExecution(cfg ExecutionConfig, src ExecutionSources) (*Execution, error) } // Open runs the takeover disposition for a Session whose Writer a host just -// acquired (RUN-EXE-8 order): the returned count is the recovery commands +// acquired (order): the returned count is the recovery commands // the disposition issued. func (x *Execution) Open(ctx context.Context, w writer.Writer) (int, error) { return x.Recovery.Open(ctx, w) diff --git a/agentcore/runtime/runtime.go b/agentcore/runtime/runtime.go index a7468387..e5f58baf 100644 --- a/agentcore/runtime/runtime.go +++ b/agentcore/runtime/runtime.go @@ -70,10 +70,10 @@ type Config struct { Preset preset.PresetRef // NewTurnID mints TurnIDs for new Turns; nil selects the random default. NewTurnID func() turn.TurnID - // RouteRetries bounds how many times one input's route is re-committed - // after a conflict with a concurrent route before the last conflict is - // returned; the input stays submitted and the next Submit, Advance or - // Resume routes it. Zero selects DefaultRouteRetries. + // RouteRetries bounds how many times one explicit delivery or Turn start + // is re-committed after a conflict before the last conflict is returned; + // the input stays submitted for a later QueueInput or Resume. Zero selects + // DefaultRouteRetries. RouteRetries int // TurnBudget bounds how many Turns one advance starts from submitted, // undelivered inputs before returning ErrTurnBudget with the Turns so @@ -90,20 +90,21 @@ const ( ) var ( - // ErrRouteContended reports a route that lost to concurrent routes - // RouteRetries times. The input is submitted and stays undelivered; the - // next Submit, Advance or Resume routes it. It is a transient answer, - // unlike the turn.ErrConflict of a Turn that admits no route. + // ErrRouteContended reports an explicit delivery or Turn start that lost + // to concurrent routes RouteRetries times. The input stays submitted for a + // later QueueInput or Resume. It is a transient answer, unlike the + // turn.ErrConflict of a Turn that admits no route. ErrRouteContended = errors.New("runtime: route contended") // ErrTurnBudget reports an advance that stopped at the TurnBudget with // inputs still submitted. ErrTurnBudget = errors.New("runtime: turn budget exhausted with inputs still submitted") ) -// SessionRuntime is the conversation process over one owned Session. Submit -// admits an input into a Turn, Advance drives a Turn to settlement and on -// through every Turn the remaining inputs start, Resume does the same for -// a Session as found after a restart, and Stop settles the active Turn. +// SessionRuntime is the conversation process over one owned Session. +// QueueInput, StartInput and DeliverInput make input assignment explicit; +// Advance drives a Turn to settlement and on through queued inputs, Resume +// does the same for a Session as found after a restart, and Stop settles the +// active Turn. // Every command runs through the Config's Writer; reads go by SessionID. // Concurrent calls are safe: writes serialize in the Writer, and a call // whose input lands in a running Turn reports AlreadyDriving. It starts no @@ -162,28 +163,30 @@ func (r *SessionRuntime) ref(turnID turn.TurnID) turn.TurnRef { return turn.TurnRef{SessionID: r.sid, TurnID: turnID} } -// Submitted is what Submit reports: the Turn the input landed in, and -// whether another driver of this process is already carrying that Turn, in -// which case it settles and reports there and the caller has nothing to -// advance. +// Submitted is what Deliver reports: the Turn the input entered, and whether +// another driver of this process is already carrying that Turn. type Submitted struct { Ref turn.TurnRef AlreadyDriving bool } -// Submit records one input body under id and commits its route: into the -// active Turn when there is one, into a new Turn otherwise. The body is -// opaque to the runtime; the idempotency key is the id, so a retried -// submission replays. Nothing is driven: the caller advances the Turn, -// here or on another goroutine, with Advance. -func (r *SessionRuntime) Submit(ctx context.Context, id run.InputID, content run.CanonicalJSON) (Submitted, error) { +// QueueInput records an input without assigning it to a Turn. The input +// remains a durable chatlog submission until Advance or Resume starts the next Turn. +func (r *SessionRuntime) QueueInput(ctx context.Context, id run.InputID, content run.CanonicalJSON) (run.AgentInput, error) { + return r.chat.Submit(ctx, r.w, id, content) +} + +// DeliverInput records an input and explicitly delivers it to the named active +// Turn. It never chooses an active Turn implicitly. +func (r *SessionRuntime) DeliverInput(ctx context.Context, turnID turn.TurnID, id run.InputID, content run.CanonicalJSON) (Submitted, error) { in, err := r.chat.Submit(ctx, r.w, id, content) if err != nil { return Submitted{}, err } var lastErr error for attempt := 0; attempt < r.routeRetries; attempt++ { - ref, err := r.route(ctx, []run.AgentInput{in}) + ref := r.ref(turnID) + err := r.deliver(ctx, ref, []run.AgentInput{in}) if err == nil { return Submitted{Ref: ref}, nil } @@ -198,21 +201,32 @@ func (r *SessionRuntime) Submit(ctx context.Context, id run.InputID, content run return Submitted{}, fmt.Errorf("%w after %d attempts: %s", ErrRouteContended, r.routeRetries, lastErr.Error()) } -// Send submits the input and advances the Session to quiescence: the first -// Turn of the Settlement is the one the input landed in, the rest are the -// Turns the remaining inputs started. When another driver of this process -// took the input, the single Turn reports AlreadyDriving and that driver -// settles. -func (r *SessionRuntime) Send(ctx context.Context, id run.InputID, content run.CanonicalJSON) (Settlement, error) { - sub, err := r.Submit(ctx, id, content) +// StartInput queues an input and starts a new Turn only when the Session is +// idle. It never selects or reuses an active Turn. +func (r *SessionRuntime) StartInput(ctx context.Context, id run.InputID, content run.CanonicalJSON) (turn.TurnRef, error) { + in, err := r.chat.Submit(ctx, r.w, id, content) if err != nil { + return turn.TurnRef{}, err + } + if _, active, err := r.active(ctx); err != nil { + return turn.TurnRef{}, err + } else if active { + return turn.TurnRef{}, fmt.Errorf("%w: cannot start input while a Turn is active", turn.ErrConflict) + } + return r.route(ctx, []run.AgentInput{in}) +} + +// Send queues the input and advances the Session to quiescence. Queued inputs +// are assigned to the next Turn rather than appended to an active one. +func (r *SessionRuntime) Send(ctx context.Context, id run.InputID, content run.CanonicalJSON) (Settlement, error) { + if _, err := r.QueueInput(ctx, id, content); err != nil { return Settlement{}, err } - if sub.AlreadyDriving { - resp, _ := r.absorbedStatus(ctx, sub.Ref) - return Settlement{Turns: []DriveResult{resp}}, nil + settlement, ok, err := r.Resume(ctx) + if !ok { + return Settlement{}, err } - return r.Advance(ctx, sub.Ref.TurnID) + return settlement, err } // Advance drives the Turn to its next quiescent point and, while its @@ -284,20 +298,14 @@ func (r *SessionRuntime) drive(ctx context.Context, turnID turn.TurnID) (DriveRe return DriveResult{TurnResult: resp, AlreadyDriving: taken}, nil } -// route commits the inputs' route: Deliver into the active Turn when there -// is one, Start a new one when there is none. +func (r *SessionRuntime) deliver(ctx context.Context, ref turn.TurnRef, inputs []run.AgentInput) error { + _, err := r.turns.Deliver(ctx, r.w, sessionkernel.DeliverRequest{Ref: ref, Inputs: inputs}) + return err +} + +// route starts a new Turn from inputs already admitted to the chatlog queue. +// It deliberately never inspects or reuses an active Turn. func (r *SessionRuntime) route(ctx context.Context, inputs []run.AgentInput) (turn.TurnRef, error) { - surface, err := turn.ReadSurface(ctx, r.proj, r.sid) - if err != nil { - return turn.TurnRef{}, err - } - if active, ok := surface.Active(); ok { - ref := r.ref(active.TurnID) - if _, err := r.turns.Deliver(ctx, r.w, sessionkernel.DeliverRequest{Ref: ref, Inputs: inputs}); err != nil { - return turn.TurnRef{}, err - } - return ref, nil - } ref := r.ref(r.newID()) if _, err := r.turns.Start(ctx, r.w, sessionkernel.StartRequest{Ref: ref, Inputs: inputs, Preset: r.preset}); err != nil { return turn.TurnRef{}, err diff --git a/agentcore/runtime/runtimetest/conformance.go b/agentcore/runtime/runtimetest/conformance.go index 5be5dfc7..46ff25a0 100644 --- a/agentcore/runtime/runtimetest/conformance.go +++ b/agentcore/runtime/runtimetest/conformance.go @@ -40,14 +40,14 @@ const ( typeEnded = sessionstore.Prefix + "run_ended" ) -// --- Start(TRN-STR-1/2/3/4、TRN-ID-3/4、TRN-EVT-2、TRN-SCP-2) ----------------------------- +// --- Start ----------------------------- func testStart(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) submitted := h.submit("in-1", "in-2") before := h.head() - // TRN-STR-1: every rejection leaves the ledger untouched. + // every rejection leaves the ledger untouched. altered := input("in-1") altered.Digest = "sha256:changed" rejects := []struct { @@ -70,8 +70,8 @@ func testStart(t *testing.T, factory Factory) { t.Fatal("a rejected Start wrote to the ledger") } - // TRN-STR-2/3: one atomic group in the specified order under the derived - // CommitID; the response reflects the committed state (TRN-STR-4). + // one atomic group in the specified order under the derived + // CommitID; the response reflects the committed state. resp, err := h.c.Start(h.ctx, h.writer(), h.startRequest("t1", submitted...)) if err != nil { t.Fatal(err) @@ -85,7 +85,7 @@ func testStart(t *testing.T, factory Factory) { if !sameTypes(group, turn.TypeStarted, chatlog.TypeInputDelivered, chatlog.TypeInputDelivered, typeCreated, typeAccepted, typeAccepted) { t.Fatalf("start group = %v", eventTypes(group)) } - // The Turn's fact names the one Run that executes it (TRN-SCP-2), and + // The Turn's fact names the one Run that executes it, and // each delivery names both. started := decode[turn.StartedPayload](t, h.registry, &group[0]) if started.TurnID != "t1" || started.RunID != runID || len(started.InputIDs) != 2 || started.Preset != presetRef { @@ -116,7 +116,7 @@ func testStart(t *testing.T, factory Factory) { t.Fatalf("run after start = %+v, want Open with both inputs pending", snap.State) } - // TRN-EVT-2: a replay at a later time is already-applied and writes nothing. + // a replay at a later time is already-applied and writes nothing. after := h.head() h.now += 60_000 again, err := h.c.Start(h.ctx, h.writer(), h.startRequest("t1", submitted...)) @@ -124,7 +124,7 @@ func testStart(t *testing.T, factory Factory) { t.Fatalf("replay = %+v %v, head moved=%v", again, err, h.head() != after) } - // TRN-EVT-3 / TRN-SCP-2: the same TurnID with another plan, and a second + // the same TurnID with another plan, and a second // Turn while one is active, are conflicts. extra := h.submit("in-3") if _, err := h.c.Start(h.ctx, h.writer(), h.startRequest("t1", extra...)); !errors.Is(err, turn.ErrConflict) { @@ -142,14 +142,14 @@ func testStart(t *testing.T, factory Factory) { } } -// --- Deliver(TRN-DLV-1/2) -------------------------------------------------------------- +// --- Deliver -------------------------------------------------------------- func testDeliver(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) resp := h.start("t1", "in-1") runID := resp.RunID - // TRN-DLV-2: input_accepted and input_delivered share one commit whose + // input_accepted and input_delivered share one commit whose // CommitID is the Run's input CommandID; the Run queues the input. in2 := h.submit("in-2") dresp, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("t1"), Inputs: in2}) @@ -175,7 +175,7 @@ func testDeliver(t *testing.T, factory Factory) { t.Fatalf("deliver replay: err=%v moved=%v", err, h.head() != head) } - // TRN-DLV-1: an unknown Turn and a Turn that is not active are conflicts; + // an unknown Turn and a Turn that is not active are conflicts; // the input stays submitted. in3 := h.submit("in-3") if _, err := h.c.Deliver(h.ctx, h.writer(), sessionkernel.DeliverRequest{Ref: h.ref("nope"), Inputs: in3}); !errors.Is(err, turn.ErrConflict) { @@ -192,9 +192,9 @@ func testDeliver(t *testing.T, factory Factory) { t.Fatalf("in-3 after refused deliver = %s, want submitted", v.Status) } - // TRN-DLV-1: Deliver validates like Start. An unsubmitted input, or a + // Deliver validates like Start. An unsubmitted input, or a // payload that differs from the submitted content, is a conflict and the - // batch writes nothing -- including its valid members (TRN-DLV-2). + // batch writes nothing -- including its valid members. h.start("t2", "in-4") run2 := h.surface().Turns["t2"].RunID in5 := h.submit("in-5") @@ -225,7 +225,7 @@ func testDeliver(t *testing.T, factory Factory) { t.Fatal("an unsubmitted input entered the chatlog") } - // TRN-DLV-2: a batch is one group under the batch CommandID -- every + // a batch is one group under the batch CommandID -- every // input_accepted and input_delivered together -- and its replay writes nothing. in6 := h.submit("in-6") batch := []run.AgentInput{in5[0], in6[0]} @@ -250,9 +250,9 @@ func testDeliver(t *testing.T, factory Factory) { } } -// --- Retry(TRN-RTY-1/2/3、TRN-ID-4) -------------------------------------------------- +// --- Retry -------------------------------------------------- -// --- Stop(TRN-STP-1/2、TRN-EVT-3) -------------------------------------------------- +// --- Stop -------------------------------------------------- func testStop(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) @@ -260,7 +260,7 @@ func testStop(t *testing.T, factory Factory) { t.Fatalf("stop unknown = %v", err) } - // TRN-STP-1: CancelRun and turn/failed{stopped} land in one commit. + // CancelRun and turn/failed{stopped} land in one commit. resp := h.start("t1", "in-1") sresp, err := h.c.Stop(h.ctx, h.writer(), sessionkernel.StopRequest{Ref: h.ref("t1"), Reason: "user"}) if err != nil || sresp.Status != turn.TurnStopped || sresp.Disposition != sessionkernel.ResumeFinished || sresp.End == nil { @@ -281,7 +281,7 @@ func testStop(t *testing.T, factory Factory) { if !sawEnded || failed == nil || failed.Settlement != turn.SettlementStopped || failed.FailureClass != "cancelled" || failed.Reason != "user" || failed.RunID != resp.RunID { t.Fatalf("stop group = %v, failed=%+v", eventTypes(group), failed) } - // A settled Turn admits nothing else (TRN-EVT-3). + // A settled Turn admits nothing else. for name, call := range map[string]func() error{ "stop": func() error { _, err := h.c.Stop(h.ctx, h.writer(), sessionkernel.StopRequest{Ref: h.ref("t1")}); return err }, "deliver": func() error { @@ -295,7 +295,7 @@ func testStop(t *testing.T, factory Factory) { } // A Run that ends without completing settles its Turn as failed from - // its own run_ended: the Turn and its Run are one (TRN-SCP-2), and the + // its own run_ended: the Turn and its Run are one, and the // failure is the Turn's outcome, shown to the user; the next input opens // a new Turn with the whole history, including this one, in view. resp = h.start("t2", "in-2") @@ -338,7 +338,7 @@ func testStop(t *testing.T, factory Factory) { } } -// --- Status(TRN-STA-1、TRN-API-3) ---------------------------------------------------- +// --- Status ---------------------------------------------------- func testStatus(t *testing.T, factory Factory) { cases := []struct { @@ -375,7 +375,7 @@ func testStatus(t *testing.T, factory Factory) { } } -// --- surface 投影(TRN-PRJ-1、TRN-EVT-3、TRN-SCP-3) ------------------------------------ +// --- surface 投影 ------------------------------------ func testProjection(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) @@ -402,10 +402,9 @@ func testProjection(t *testing.T, factory Factory) { t.Fatalf("foreign run entered the surface: %+v", surface) } - // TRN-EVT-3: a second started, a Run already named by a Turn and a + // a second started, a Run already named by a Turn and a // settlement of an unknown Turn are refused by the fold before anything - // is written. (A Turn's own settlement right after its Run's end is the - // Stop unit's shape and stays legal, TRN-STP-1.) + // is written. (A Turn's own settlement right after its Run's end is the Stop unit's shape and stays legal,.) failed := func(turnID turn.TurnID, s turn.Settlement) writer.TypedEvent { return writer.TypedEvent{Type: turn.TypeFailed, RecordedAtUnixMilli: h.now, Value: turn.FailedPayload{TurnID: turnID, RunID: resp.RunID, Settlement: s, FailureClass: "x"}} @@ -457,7 +456,7 @@ func testProjection(t *testing.T, factory Factory) { } } -// --- recovery(TRN-REC-1/2、TRN-SCP-3) ----------------------------------------------- +// --- recovery ----------------------------------------------- func testRecovery(t *testing.T, factory Factory) { h := newHarness(t, factory(t)) @@ -465,7 +464,7 @@ func testRecovery(t *testing.T, factory Factory) { // started committed, process gone before driving: the new owner has // nothing to dispose and the Turn is still active from the projections - // alone (TRN-SCP-3). + // alone. h.takeover() if n, err := h.rt.RecoverInterrupted(h.ctx, h.writer(), &reconcile.Reconciler{Abandon: true}); err != nil || n != 0 { t.Fatalf("recover after start = %d %v", n, err) diff --git a/agentcore/runtime/runtimetest/doc.go b/agentcore/runtime/runtimetest/doc.go index 7c365dc4..e253b37b 100644 --- a/agentcore/runtime/runtimetest/doc.go +++ b/agentcore/runtime/runtimetest/doc.go @@ -3,7 +3,7 @@ package runtimetest // The suite is Store-parameterized like session/sessiontest and // run/sessionstore/storetest: the reference memory store and every durable adapter run // the same assertions. Assertions on the projection of Run facts into -// conversation entries (TRN-MAP) live in the RUN-CMP-2 suite, which observes +// conversation entries live in the suite, which observes // them through the Runtime's group composition and the chatlog projections; // this suite covers the Coordinator, the surface projection and the recovery // table. diff --git a/agentcore/runtime/runtimetest/harness.go b/agentcore/runtime/runtimetest/harness.go index c3bcaf41..0d0593cb 100644 --- a/agentcore/runtime/runtimetest/harness.go +++ b/agentcore/runtime/runtimetest/harness.go @@ -301,7 +301,7 @@ func (h *harness) load(runID run.RunID) store.Snapshot { // --- driving a Run without a Loop ---------------------------------------------------- -// modelEffect derives the next model effect of the Prepared step (RUN-WIR-1). +// modelEffect derives the next model effect of the Prepared step. func (h *harness) modelEffect(runID run.RunID, step run.StepID) run.EffectID { h.t.Helper() ms, ok := h.load(runID).State.Current.(run.ModelStep) @@ -441,7 +441,7 @@ func (h *harness) waitingTool(runID run.RunID) { } // appCancel is the Application's own CancelRun: the Run ends stopped and -// its Turn settles as failed from the run_ended alone (TRN-PRJ-1). +// its Turn settles as failed from the run_ended alone. func (h *harness) appCancel(runID run.RunID) { h.t.Helper() h.seq++ diff --git a/agentcore/session/filestore/content.go b/agentcore/session/filestore/content.go index 4e3f7e82..ed9e7ba2 100644 --- a/agentcore/session/filestore/content.go +++ b/agentcore/session/filestore/content.go @@ -41,7 +41,7 @@ type ContentStoreOptions struct { // the two leaves an entry the store treats as absent and the next Put // completes. Two instances over one root see each other's content, which is // what lets a restarted process read the frozen request of an interrupted -// ModelStep (RUN-WIR-4). +// ModelStep. // Operation names of the content store's errors. const ( opPut = "put" diff --git a/agentcore/session/filestore/projectioncache.go b/agentcore/session/filestore/projectioncache.go index 4a397af6..74de5885 100644 --- a/agentcore/session/filestore/projectioncache.go +++ b/agentcore/session/filestore/projectioncache.go @@ -14,7 +14,7 @@ import ( // projectionsDir holds cached projection states beside the session's authoritative // files. Everything under it is derived data: losing it, or finding it corrupt -// or stale, only costs a reader a longer fold (EXT-PRJ-3). +// or stale, only costs a reader a longer fold. const projectionsDir = "projections" // ProjectionCache returns the durable session.ProjectionCache of this Store, @@ -50,7 +50,7 @@ func (c projectionCache) Load(_ context.Context, sid session.SessionID, id modul return state, rec.Through, true, nil } -// Save keeps the entry monotonic (EXT-PRJ-7): a write that lands after a +// Save keeps the entry monotonic: a write that lands after a // later one is dropped. func (c projectionCache) Save(ctx context.Context, sid session.SessionID, id module.ProjectionID, v module.ProjectionVersion, state jsonstable.Value, through ledger.Head) error { if _, current, ok, _ := c.Load(ctx, sid, id, v); ok && current.Next >= through.Next { diff --git a/agentcore/session/filestore/projectioncache_test.go b/agentcore/session/filestore/projectioncache_test.go index 798edd2c..93d951aa 100644 --- a/agentcore/session/filestore/projectioncache_test.go +++ b/agentcore/session/filestore/projectioncache_test.go @@ -13,8 +13,8 @@ import ( "testing" ) -// This file covers the durable side of EXT-PRJ-3: the cache entries a Writer -// leaves in the session directory, and a fresh process resuming from them. +// This file covers cache entries persisted by a Writer in the session +// directory and a fresh process resuming from them. const projectID = module.ProjectionID("twilight/z/rows") diff --git a/agentcore/session/projection.go b/agentcore/session/projection.go index fa7fc5c4..76f4c436 100644 --- a/agentcore/session/projection.go +++ b/agentcore/session/projection.go @@ -18,10 +18,10 @@ type ProjectionReader interface { Load(ctx context.Context, sid SessionID, id module.ProjectionID, v module.ProjectionVersion) (state any, through ledger.Head, err error) } -// ProjectionCache is the optional derived cache of EXT-PRJ-3. Entries may be +// ProjectionCache is the optional derived cache. Entries may be // lost or stale at any time; readers verify Through against the stream. // An Authoritative projection's entry is a checkpoint the Writer plans the -// next commit against (EXT-PRJ-10): its state bytes travel with the digest +// next commit: its state bytes travel with the digest // the Writer computed when it saved them, in the same Value (see // module.SealCheckpoint), and a reader that finds the digest missing or // wrong treats the entry as absent. A derived projection's entry carries the @@ -67,7 +67,7 @@ func (c *MemoryProjectionCache) Load(_ context.Context, sid SessionID, id module } // Save keeps the entry monotonic: a write that reaches the cache after a -// later one (Save runs outside the Writer's critical section, EXT-PRJ-7) +// later one (Save runs outside the Writer's critical section) // never moves the entry back. func (c *MemoryProjectionCache) Save(_ context.Context, sid SessionID, id module.ProjectionID, v module.ProjectionVersion, state jsonstable.Value, through ledger.Head) error { c.mu.Lock() @@ -110,7 +110,7 @@ func SaveProjection(ctx context.Context, cache ProjectionCache, registry *module } // LoadProjectionEntry reads a cache entry for def, unsealing and verifying -// an authoritative one (EXT-PRJ-10); an entry that does not verify is a +// an authoritative one; an entry that does not verify is a // miss. func LoadProjectionEntry(ctx context.Context, cache ProjectionCache, def *module.ProjectionDefinition, sid SessionID) (jsonstable.Value, ledger.Head, bool, error) { encoded, through, ok, err := cache.Load(ctx, sid, def.ID, def.Version) diff --git a/agentcore/session/unit/unit.go b/agentcore/session/unit/unit.go index 52d4690b..9c50d183 100644 --- a/agentcore/session/unit/unit.go +++ b/agentcore/session/unit/unit.go @@ -35,7 +35,7 @@ func (f PartFunc) Prepare(ctx context.Context, view writer.View, now int64) ([]w type Work struct { // CommitID names the operation: the writer derives it so that one // identity means one operation, and a second unit under it is the same - // operation replayed (EXT-WRT-2). + // operation replayed. CommitID ledger.CommitID Parts []Part } diff --git a/agentcore/session/unit/unit_test.go b/agentcore/session/unit/unit_test.go index c3591120..09724de0 100644 --- a/agentcore/session/unit/unit_test.go +++ b/agentcore/session/unit/unit_test.go @@ -97,7 +97,7 @@ func TestCommitMergesRefusesAndReplays(t *testing.T) { } // A CommitID the log holds is already applied without preparing a Part, -// whatever the later unit would have written (EXT-WRT-2): the identity is +// whatever the later unit would have written: the identity is // the operation. func TestCommitReplaysByCommitID(t *testing.T) { ctx := context.Background() diff --git a/agentcore/session/writer/admission.go b/agentcore/session/writer/admission.go index 573a860c..b360f24d 100644 --- a/agentcore/session/writer/admission.go +++ b/agentcore/session/writer/admission.go @@ -19,7 +19,7 @@ type Admission struct { Ledger artifact.RetentionLedger } -// ClaimOwnerKind is the ClaimOwner.Kind of Session commits (EXT-WRT-5). +// ClaimOwnerKind is the ClaimOwner.Kind of Session commits. const ClaimOwnerKind = "twilight/session/commit" // claimDerivationVersion versions the claim identity preimages of this @@ -27,7 +27,7 @@ const ClaimOwnerKind = "twilight/session/commit" // outlives the segment version that wrote its commit. const claimDerivationVersion uint16 = 1 -// DeriveClaimID is EXT-WRT-5: the retention root of one commit's references, +// DeriveClaimID is the retention root of one commit's references, // named by the segment that holds the commit. No Session and no protocol // version enter it: the segment is the canonical owner of the commit, and a // Session that forks or advances keeps reading the same claim. @@ -48,9 +48,9 @@ func CommitOwner(segment session.SegmentID, id ledger.CommitID) artifact.ClaimOw } // admitter is the artifact stage of the commit pipeline: it admits the -// Bindings a group references against their declarations (EXT-REF-2), -// activates the commit's retention claim before Append (EXT-WRT-3) and -// reconciles this Session's claims when the Writer opens (ART-RET-3). It +// Bindings a group references against their declarations, +// activates the commit's retention claim before Append and +// reconciles this Session's claims when the Writer opens. It // knows nothing of projections or ownership. type admitter struct { Admission @@ -102,7 +102,7 @@ func (a *admitter) admit(ctx context.Context, id artifact.BindingID, decl *modul return "", nil } -// claim activates the retention claim before Append (EXT-WRT-3); a commit +// claim activates the retention claim before Append; a commit // without references claims nothing. func (a *admitter) claim(ctx context.Context, commitID ledger.CommitID, refs []artifact.BindingID) (*artifact.RetentionClaim, string, error) { if len(refs) == 0 { diff --git a/agentcore/session/writer/cache_test.go b/agentcore/session/writer/cache_test.go index 10625188..30b06c1b 100644 --- a/agentcore/session/writer/cache_test.go +++ b/agentcore/session/writer/cache_test.go @@ -11,7 +11,7 @@ import ( "testing" ) -// This file covers EXT-PRJ-3: a projection's folded state living in the +// This file covers a projection's folded state living in the // session.ProjectionCache, and a reopening Writer resuming from it instead of folding // the log again. @@ -352,7 +352,7 @@ func TestCacheEveryBoundsHowFarBehindAnEntryFalls(t *testing.T) { } // Close is no exception to the interval: the entry lags by one commit, // which the next Writer folds, instead of the whole state being written - // once more (EXT-PRJ-7). + // once more. _, through, _, err = f.cache.Load(ctx, "s", alphaID, 1) if err != nil || through.Next != 3 { t.Fatalf("entry after Close: through=%d err=%v, want 3 (the interval governs Close too)", through.Next, err) @@ -391,7 +391,7 @@ func TestWriterWithoutCacheFoldsEverything(t *testing.T) { } } -// EXT-PRJ-10: an authoritative projection's entry is a checkpoint sealed +// an authoritative projection's entry is a checkpoint sealed // with a digest over its identity, position and state. A well-formed entry // whose state was not what the Writer saved (here: a derived projection's // entry copied under the authoritative one) is a miss, and the Writer diff --git a/agentcore/session/writer/cachebench_test.go b/agentcore/session/writer/cachebench_test.go index ac3024e6..e3bbe808 100644 --- a/agentcore/session/writer/cachebench_test.go +++ b/agentcore/session/writer/cachebench_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -// BenchmarkWriterOpen measures EXT-WRT-1/PRJ-5 against the same log twice: with +// BenchmarkWriterOpen compares cached and uncached reopening of the same log: with // no cache the Writer folds every group, with a warm cache entry it folds only // what the entry does not cover. full-fold is the before, from-cache the after. func BenchmarkWriterOpen(b *testing.B) { diff --git a/agentcore/session/writer/encode.go b/agentcore/session/writer/encode.go index b310ef0a..9cbb8cff 100644 --- a/agentcore/session/writer/encode.go +++ b/agentcore/session/writer/encode.go @@ -29,7 +29,7 @@ func bindingIDs(refs []bindingRef) []artifact.BindingID { } // encode is the pure stage of the pipeline: it validates and encodes every -// event against the Registry under its type's write version (EXT-REG-2), checks +// event against the Registry under its type's write version, checks // each batch's stream attribution against the stream domain its event types // declare, extracts the artifact references the events declare and returns // the proposal batches. It touches no store. @@ -76,7 +76,7 @@ func encode(registry *module.Registry, group *SemanticGroup) ([]ledger.EventBatc } // checkStreamAffinity verifies a batch's stream attribution against the -// declaration of the domain the event type names (EXT-STR-1). It returns a +// declaration of the domain the event type names. It returns a // human verdict for the commit's detail string; the declarations themselves // are validated at BuildRegistry. func checkStreamAffinity(stream ledger.Domain, def module.StreamDefinition, value any) string { diff --git a/agentcore/session/writer/faults_test.go b/agentcore/session/writer/faults_test.go index f96b86da..f89b6ef1 100644 --- a/agentcore/session/writer/faults_test.go +++ b/agentcore/session/writer/faults_test.go @@ -145,7 +145,7 @@ func TestWriterReconcilesClaimsAfterAppendFailure(t *testing.T) { } } -// EXT-WRT-4(b): an Append whose outcome is unknown fails the Writer closed. A +// an Append whose outcome is unknown fails the Writer closed. A // reopened Writer replays the same commit and the kernel's index answers: // AlreadyApplied when the commit reached the log, Applied when it did not. In // both cases the log stays a valid chain with contiguous Seqs. diff --git a/agentcore/session/writer/fork.go b/agentcore/session/writer/fork.go index 7a6f9c7f..01d64ea3 100644 --- a/agentcore/session/writer/fork.go +++ b/agentcore/session/writer/fork.go @@ -21,7 +21,7 @@ type ForkRequest struct { // Fork creates req.Child from req.Parent's history at commit req.At // . It holds no claim of its -// own (EXT-WRT-8): the content the inherited prefix references is retained +// own: the content the inherited prefix references is retained // by the commit claims of the segments that hold those commits. Those // commits stay while a live path's span still covers them. // Fork is idempotent: a repeat with the same arguments returns the same diff --git a/agentcore/session/writer/fork_test.go b/agentcore/session/writer/fork_test.go index 4ae073ad..ecd004c6 100644 --- a/agentcore/session/writer/fork_test.go +++ b/agentcore/session/writer/fork_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -// , EXT-WRT-8: a Writer over a fork folds its projections from +// a Writer over a fork folds its projections from // the inherited prefix, answers a replay of an inherited CommitID as already // applied and continues the ledger from the anchor; Fork itself holds no // claim, the prefix's content stays under the parent segment's commit @@ -102,7 +102,7 @@ func TestForkWriterInheritsPrefix(t *testing.T) { } } -// , EXT-WRT-9: Delete reclaims along the path and releases the +// Delete reclaims along the path and releases the // claims of what it dropped or removed. A fork keeps the inherited prefix, // so those claims stay Active with their segment. A repeated Delete is a // no-op. Collect after a completed Delete finds nothing left to reclaim. diff --git a/agentcore/session/writer/health_test.go b/agentcore/session/writer/health_test.go index 78423fc1..8f0481ab 100644 --- a/agentcore/session/writer/health_test.go +++ b/agentcore/session/writer/health_test.go @@ -36,7 +36,7 @@ func healthModule() module.ModuleDescriptor { Projections: []module.ProjectionDefinition{mk("h/authoritative", true), mk("h/derived", false)}} } -// EXT-PRJ-9: a derived projection that cannot fold a commit does not refuse +// a derived projection that cannot fold a commit does not refuse // the commit; it is marked unhealthy, reads report it, and it is not cached. // An authoritative projection that cannot fold refuses the commit. func TestDerivedProjectionFailureDoesNotBlockCommit(t *testing.T) { @@ -104,7 +104,7 @@ func TestDerivedProjectionFailureDoesNotBlockCommit(t *testing.T) { } } -// EXT-PRJ-9 across a reopen: a derived projection that cannot fold the log +// across a reopen: a derived projection that cannot fold the log // does not keep the Session from opening; it stops at its last good commit // and is unhealthy, the authoritative projections fold to head. func TestDerivedProjectionFailureDoesNotBlockReopen(t *testing.T) { diff --git a/agentcore/session/writer/heartbeat.go b/agentcore/session/writer/heartbeat.go index 27c0e605..41c63670 100644 --- a/agentcore/session/writer/heartbeat.go +++ b/agentcore/session/writer/heartbeat.go @@ -9,7 +9,7 @@ import ( ) // heartbeat renews a Writer's kernel lease at a third of its duration -// (EXT-WRT-11), the same shape as the Executor Worker's heartbeat over an +// the same shape as the Executor Worker's heartbeat over an // execution record. A fenced Renew reports the loss once and the heartbeat // ends; any other failure is retried at the next tick, because the lease // stays held until it expires or is superseded. diff --git a/agentcore/session/writer/heartbeat_test.go b/agentcore/session/writer/heartbeat_test.go index afff9477..681dca5c 100644 --- a/agentcore/session/writer/heartbeat_test.go +++ b/agentcore/session/writer/heartbeat_test.go @@ -9,7 +9,7 @@ import ( "time" ) -// EXT-WRT-11: a Writer opened with a lease keeps it renewed for as long as +// a Writer opened with a lease keeps it renewed for as long as // it is open, and learns of a takeover through the heartbeat, after which // it refuses to commit like a Writer fenced at Append. func TestWriterHeartbeatRenewsAndReportsLoss(t *testing.T) { diff --git a/agentcore/session/writer/observer_test.go b/agentcore/session/writer/observer_test.go index 68b9a248..8ec3df72 100644 --- a/agentcore/session/writer/observer_test.go +++ b/agentcore/session/writer/observer_test.go @@ -26,7 +26,7 @@ func (panickingObserver) Committed(context.Context, session.SessionID, ledger.Co panic("observer failed") } -// EXT-WRT-7: every applied commit reaches the observers once, in commit order, +// every applied commit reaches the observers once, in commit order, // with the sealed commit; rejected and replayed commits notify nothing; a // panicking observer neither fails the Commit nor starves the next observer. func TestCommitObserversSeeAppliedCommitsInOrder(t *testing.T) { diff --git a/agentcore/session/writer/observers.go b/agentcore/session/writer/observers.go index c0bfa678..860c5e82 100644 --- a/agentcore/session/writer/observers.go +++ b/agentcore/session/writer/observers.go @@ -8,7 +8,7 @@ import ( ) // CommitObserver sees every commit a Writer applies, in commit order, after -// it is durable (EXT-WRT-7). It is the one source every observation of a +// it is durable. It is the one source every observation of a // Session derives from: Loop events, turn lifecycle and chatlog entries are // all events of applied commits. Observers run outside the Writer's critical // section and are best effort: a panic is contained and never reaches the diff --git a/agentcore/session/writer/projector.go b/agentcore/session/writer/projector.go index 313b1afa..e1d470de 100644 --- a/agentcore/session/writer/projector.go +++ b/agentcore/session/writer/projector.go @@ -16,9 +16,9 @@ type projectionKey struct { // projector is the projection stage of the commit pipeline: it holds the // transactional state of every registered projection folded to the Writer's -// head (EXT-WRT-1), pre-folds each provisional commit so an invalid group is +// head, pre-folds each provisional commit so an invalid group is // refused before anything is persisted, and refreshes the projection cache -// per the deployment's policy (EXT-PRJ-3, EXT-PRJ-7). It knows nothing of +// per the deployment's policy. It knows nothing of // ownership, admission or observers. type projector struct { registry *module.Registry @@ -26,10 +26,10 @@ type projector struct { states map[projectionKey]any scopes map[projectionKey]*module.ProjectionScope // unhealthy records derived projections that failed to fold an applied - // commit (EXT-PRJ-9): their state stays at the last good commit, reads + // commit: their state stays at the last good commit, reads // report the failure and the cache is not refreshed for them. unhealthy map[projectionKey]error - // cache, policy and cached carry EXT-PRJ-3: cached records the head each + // cache and policy control persistence; cached records the head each // projection's cache entry already reflects, which is what a policy // measures the next refresh against. cache session.ProjectionCache @@ -50,14 +50,13 @@ func newProjector(registry *module.Registry, sid session.SessionID, cache sessio } // commitAt reads the commit at one stitched position of the Session, for -// judging a cache entry's boundary (EXT-PRJ-3); ok=false when there is none. +// judging a cache entry's boundary; ok=false when there is none. type commitAt func(ledger.CommitSeq) (ledger.Commit, bool) // prepare chooses each registered projection's starting state: its cache // entry when the entry ends on a commit boundary the tip segment wrote -// itself and still decodes, the initial state otherwise (EXT-PRJ-3). It +// itself and still decodes, the initial state otherwise. It // returns the earliest stitched CommitSeq any projection must fold from -// (EXT-PRJ-5). func (p *projector) prepare(ctx context.Context, header session.SegmentHeader, at commitAt) (ledger.CommitSeq, error) { from := ^ledger.CommitSeq(0) for _, def := range p.registry.Projections() { @@ -85,7 +84,7 @@ func (p *projector) prepare(ctx context.Context, header session.SegmentHeader, a } // resume folds the commits read from from into every projection, each from -// its own start (EXT-PRJ-3, EXT-PRJ-9). +// its own start. func (p *projector) resume(page *session.CommitPage, from ledger.CommitSeq) error { for k, scope := range p.scopes { start := p.starts[k] @@ -102,7 +101,7 @@ func (p *projector) resume(page *session.CommitPage, from ledger.CommitSeq) erro return err } // A derived projection that cannot fold the log does not keep - // the Session from opening (EXT-PRJ-9): it stops at its last + // the Session from opening: it stops at its last // good commit and stays unhealthy until a registry that folds // it reopens the Session. folded, err = p.lastGood(scope, p.states[k], page.Commits[off:], page.Header) @@ -131,7 +130,6 @@ func (p *projector) lastGood(scope *module.ProjectionScope, state any, commits [ // wrote itself and still decodes, otherwise nothing. Anything unusable -- // absent, corrupt, ahead of the log, recorded at a digest the log does not // have, or ending on an inherited commit -- falls back to a full fold -// (EXT-PRJ-3). func (p *projector) startState(ctx context.Context, scope *module.ProjectionScope, header session.SegmentHeader, at commitAt) (any, ledger.Head, bool) { if p.cache == nil { return nil, ledger.Head{}, false @@ -169,7 +167,7 @@ type folded struct { // fold pre-folds one provisional commit into every projection without // installing anything. An authoritative projection that refuses the commit // refuses it for the Writer; a derived one keeps its last good state and is -// marked unhealthy once the commit lands (EXT-PRJ-9). A projection already +// marked unhealthy once the commit lands. A projection already // unhealthy is not folded further. func (p *projector) fold(provisional ledger.Commit) (folded, error) { out := folded{next: make(map[projectionKey]any, len(p.states))} @@ -231,7 +229,7 @@ type cacheWrite struct { // planRefresh selects the entries the cache policy wants refreshed and // records them as covering head. The caller holds the Writer's lock; the -// writes happen outside it (saveRefresh, EXT-PRJ-7). +// writes happen outside it (saveRefresh). func (p *projector) planRefresh(head ledger.Head, closing bool) []cacheWrite { if p.cache == nil { return nil @@ -251,14 +249,14 @@ func (p *projector) planRefresh(head ledger.Head, closing bool) []cacheWrite { } // saveRefresh performs planned writes. Best effort, never fatal: a failed -// Save means a later Writer folds more (EXT-PRJ-3). +// Save means a later Writer folds more. func (p *projector) saveRefresh(ctx context.Context, writes []cacheWrite) { for _, cw := range writes { _ = session.SaveProjection(ctx, p.cache, p.registry, p.sid, cw.key.id, cw.key.version, cw.state, cw.head) } } -// memoryReader reads the Writer's transactional projections (EXT-PRJ-4). +// memoryReader reads the Writer's transactional projections. type memoryReader struct{ w *sessionWriter } func (r memoryReader) Load(_ context.Context, sid session.SessionID, id module.ProjectionID, v module.ProjectionVersion) (any, ledger.Head, error) { diff --git a/agentcore/session/writer/writer.go b/agentcore/session/writer/writer.go index c529917b..4c9f3dbc 100644 --- a/agentcore/session/writer/writer.go +++ b/agentcore/session/writer/writer.go @@ -1,5 +1,5 @@ // Package writer is the single in-process write entry of one Session -// (EXT-SCP-1). A commit is a pipeline of stages, one per file: encoding and +// A commit is a pipeline of stages, one per file: encoding and // stream affinity (encode.go), artifact admission and retention claims // (admission.go), projection folding and cache (projector.go), observer // fan-out (observers.go). The Writer itself reads the head, evaluates the @@ -42,7 +42,7 @@ type SemanticGroup struct { } // View is what a CommitFn may read: head, idempotency index and projections -// folded to the current head (EXT-WRT-1). +// folded to the current head. type View interface { Head() ledger.Head Epoch() ledger.Epoch @@ -69,7 +69,7 @@ type CommitOutcome string const ( CommitApplied CommitOutcome = "applied" - CommitAlreadyApplied CommitOutcome = "already_applied" // the CommitID is in the ledger (EXT-WRT-2) + CommitAlreadyApplied CommitOutcome = "already_applied" // the CommitID is in the ledger CommitConflict CommitOutcome = "conflict" // the kernel refused the commit as a conflict CommitInvalid CommitOutcome = "invalid" CommitNoop CommitOutcome = "noop" @@ -86,7 +86,7 @@ type CommitResult struct { Detail string } -// Writer is the single in-process write entry of one Session (EXT-SCP-1). +// Writer is the single in-process write entry of one Session. type Writer interface { SessionID() session.SessionID Epoch() ledger.Epoch @@ -98,7 +98,7 @@ type Writer interface { Close(context.Context) error } -// Writers is the host-maintained SessionID to Writer map (EXT-WRT-6). +// Writers is the host-maintained SessionID to Writer map. type Writers interface { Writer(context.Context, session.SessionID) (Writer, error) } @@ -109,13 +109,13 @@ type Writers interface { // notified. type WritersConfig struct { // Cache holds folded projection states, so a reopening Writer starts from - // one instead of refolding the whole log (EXT-PRJ-3). + // one instead of refolding the whole log. Cache session.ProjectionCache // CachePolicy decides which projections the Writer refreshes and when; nil // means module.CacheEvery(module.DefaultCacheEvery). It never affects reading: an entry // the cache already holds is used whoever wrote it. CachePolicy module.CachePolicy - // Observers are notified of every applied commit (EXT-WRT-7). + // Observers are notified of every applied commit. Observers []CommitObserver } @@ -132,14 +132,14 @@ type sessionWriter struct { admission *admitter observers *observers // heartbeat renews the kernel lease while the Writer is open - // (EXT-WRT-11); nil when the lease never expires. + // nil when the lease never expires. heartbeat *heartbeat } // OpenWriter takes ownership of sid and rebuilds the idempotency index and -// every registered projection from the whole log (EXT-WRT-1). When a ledger +// every registered projection from the whole log. When a ledger // is configured it reconciles this Session's claims before returning -// (ART-RET-3): no Commit can be in flight yet. +// no Commit can be in flight yet. func OpenWriter(ctx context.Context, store session.Store, registry *module.Registry, admission Admission, sid session.SessionID, opts session.OpenOptions) (Writer, error) { return openWriter(ctx, store, registry, admission, sid, opts, WritersConfig{}) } @@ -161,7 +161,7 @@ func openWriter(ctx context.Context, store session.Store, registry *module.Regis w.heartbeat = startHeartbeat(kernel, opts.LeaseDuration, w.onLeaseLost) } // The log is read from the earliest cache entry any projection resumes - // from (EXT-PRJ-5): judging an entry costs one commit read, and a clean + // judging an entry costs one commit read, and a clean // Close leaves every entry at the head. at := func(seq ledger.CommitSeq) (ledger.Commit, bool) { one, err := store.ReadCommits(ctx, session.CommitReadRequest{SessionID: sid, From: seq, Limit: 1}) @@ -221,7 +221,7 @@ func (w *sessionWriter) OwnerExists(_ context.Context, owner artifact.ClaimOwner var errWriterClosed = &ledger.Error{Code: ledger.CodeInvalid, Detail: "writer closed"} // onLeaseLost is the heartbeat's report that Renew was fenced: the Writer -// is lost exactly as it would be by a fenced Append (EXT-WRT-4). +// is lost exactly as it would be by a fenced Append. func (w *sessionWriter) onLeaseLost(err error) { w.mu.Lock() defer w.mu.Unlock() @@ -236,7 +236,7 @@ func (w *sessionWriter) Close(ctx context.Context) error { } w.mu.Lock() var writes []cacheWrite - // An entry at an inherited boundary is never started from (EXT-PRJ-3). + // An entry at an inherited boundary is never started. head := w.kernel.Head() if ledger.OwnBoundary(w.kernel.Header().Seed(), head) { writes = w.projections.planRefresh(head, true) @@ -280,9 +280,8 @@ func (w *sessionWriter) Projections() session.ProjectionReader { return memoryRe // Commit is the pipeline: read (View) -> decide (fn) -> encode -> CommitID // replay check -> projection pre-fold -> retention claim -> Append -> advance -// projections -> refresh cache / notify observers (EXT-WRT-1). Cache writes +// projections -> refresh cache / notify observers. Cache writes // and observer notifications run after the Writer's lock is released -// (EXT-PRJ-7, EXT-WRT-7). func (w *sessionWriter) Commit(ctx context.Context, fn CommitFn) (CommitResult, error) { if fn == nil { return CommitResult{}, errors.New("writer: nil fn") @@ -335,7 +334,7 @@ func (w *sessionWriter) Commit(ctx context.Context, fn CommitFn) (CommitResult, return CommitResult{}, err } else if committed { // The CommitID names the operation: a second commit under it is the - // same operation and is not written again (EXT-WRT-2). + // same operation and is not written again. return CommitResult{Outcome: CommitAlreadyApplied, Commit: existing}, nil } // Projections must accept the commit before anything is persisted. The @@ -373,7 +372,7 @@ func (w *sessionWriter) Commit(ctx context.Context, fn CommitFn) (CommitResult, // The claim stays active until reopening can verify the owner // commit. Anything else leaves the log's content unknown to this // Writer: fail closed; a replay of the same commit is answered by - // the kernel's index (EXT-WRT-4). + // the kernel's index. w.lost = &ledger.Error{Code: ledger.CodeUnknownOutcome, Detail: err.Error()} return CommitResult{}, w.lost } diff --git a/agentcore/session/writer/writer_test.go b/agentcore/session/writer/writer_test.go index 15ff11b0..9f1ce981 100644 --- a/agentcore/session/writer/writer_test.go +++ b/agentcore/session/writer/writer_test.go @@ -131,7 +131,7 @@ func notes(t *testing.T, w Writer) []string { return state.(noteState).Notes } -// EXT-WRT-1/2: serial commits, in-memory idempotency, rebuild on reopen, +// serial commits, in-memory idempotency, rebuild on reopen, // projections visible through View and Projections. func TestWriterCommitReplayAndRebuild(t *testing.T) { f := newFixture(t) @@ -149,7 +149,7 @@ func TestWriterCommitReplayAndRebuild(t *testing.T) { t.Fatalf("replay = %+v", replay) } // The CommitID names the operation: a second group under it is the - // same operation whatever it carries (EXT-WRT-2). + // same operation whatever it carries. if again, _ := w.Commit(ctx, noteGroup("c1", "changed")); again.Outcome != CommitAlreadyApplied || again.Commit.Seq != res.Commit.Seq { t.Fatalf("replay with other content = %+v, want already applied", again) } @@ -215,7 +215,7 @@ func TestWriterCommitReplayAndRebuild(t *testing.T) { } } -// EXT-WRT-4: a superseded writer fails closed. +// a superseded writer fails closed. func TestWriterOwnershipLost(t *testing.T) { f := newFixture(t) ctx := context.Background() @@ -247,7 +247,7 @@ func TestWriterOwnershipLost(t *testing.T) { } } -// EXT-PRJ-2: unknown events in scope fail the fold unless the module marked +// unknown events in scope fail the fold unless the module marked // the type Ignorable; unknown events of other modules are skipped. The // registry is the only authority on Ignorable: an in-scope event whose type // is not registered at all cannot prove it is ignorable and fails the fold. @@ -289,9 +289,9 @@ func TestProjectionUnknownEvents(t *testing.T) { } } -// EXT-WRT-3 and ART-RET-3: claims are Active before the commit exists; an +// claims are Active before the commit exists; an // orphan claim is released on the next OpenWriter; a live claim survives. -// EXT-REF-1/2, EXT-WRT-3: a missing resolver or ledger is a configuration +// a missing resolver or ledger is a configuration // error, so it must surface as an error rather than as a CommitInvalid outcome // that reads like a verdict on the commit. It must not be rejected earlier // either: an event type declaring Bindings only means its payloads may carry @@ -368,7 +368,7 @@ func TestCommitWithoutAdmission(t *testing.T) { } } -// EXT-WRT-1: the Writer is the single serialization point. Concurrent callers +// the Writer is the single serialization point. Concurrent callers // must each see the state left by the previous one, so every commit applies // exactly once and the stream stays contiguous. func TestWriterSerializesConcurrentCommits(t *testing.T) { @@ -418,7 +418,7 @@ func TestWriterSerializesConcurrentCommits(t *testing.T) { } } -// EXT-REF-1/2: the extractor returns every reference in appearance order, +// the extractor returns every reference in appearance order, // cardinality and scheme/durability admission bound what may commit, and a // rejected commit writes nothing. func TestBindingAdmission(t *testing.T) { @@ -558,7 +558,7 @@ func TestWriterClaimsAndReconcile(t *testing.T) { } } -// EXT-PRJ-3/4: cache entry plus tail equals the full fold; a stale or missing +// cache entry plus tail equals the full fold; a stale or missing // entry falls back to a full fold; Writer and Store readers agree. func TestProjectionCache(t *testing.T) { f := newFixture(t) diff --git a/agentcore/session/writer/writers.go b/agentcore/session/writer/writers.go index 0dd6097f..bd0f90f5 100644 --- a/agentcore/session/writer/writers.go +++ b/agentcore/session/writer/writers.go @@ -20,7 +20,7 @@ type writerSet struct { } // NewWriters returns a Writers that opens each Session once and hands out the -// same Writer afterwards (EXT-WRT-6). +// same Writer afterwards. func NewWriters(store session.Store, registry *module.Registry, admission Admission, opts session.OpenOptions, cfg WritersConfig) Writers { return &writerSet{store: store, registry: registry, admission: admission, opts: opts, cfg: cfg, open: make(map[session.SessionID]Writer)} } @@ -37,7 +37,7 @@ func (ws *writerSet) Writer(ctx context.Context, sid session.SessionID) (Writer, // the Session back from the process that owns it now, and that is the // host's decision (CloseWriter forgets the failed Writer first). Every // other failure is: a fresh Writer rebuilds from the log and a replay - // of the same CommitID is answered by the kernel's index (EXT-WRT-4). + // of the same CommitID is answered by the kernel's index. if errors.Is(lost, &ledger.Error{Code: ledger.CodeOwnershipLost}) { return nil, lost } @@ -108,7 +108,7 @@ func CloseWriters(ctx context.Context, ws Writers) error { } // CloseWriter closes and forgets one Session's Writer of a NewWriters value -// (EXT-WRT-6). Another Writers implementation has its Writer closed in place. +// Another Writers implementation has its Writer closed in place. func CloseWriter(ctx context.Context, ws Writers, sid session.SessionID) error { if c, ok := ws.(interface { CloseWriter(context.Context, session.SessionID) error diff --git a/agentcore/session/writer/writers_test.go b/agentcore/session/writer/writers_test.go index 372bee61..1b1c6c83 100644 --- a/agentcore/session/writer/writers_test.go +++ b/agentcore/session/writer/writers_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -// EXT-WRT-6: a Writers value confines a failed Writer to that instance. After +// a Writers value confines a failed Writer to that instance. After // an unknown outcome or a Close the next Writer(sid) reopens from the log; // after ownership loss it keeps answering ownership_lost until the host // forgets the Session with CloseWriter, because reopening would take the diff --git a/agentcore/sessionkernel/kernel.go b/agentcore/sessionkernel/kernel.go index 3065b913..381ba303 100644 --- a/agentcore/sessionkernel/kernel.go +++ b/agentcore/sessionkernel/kernel.go @@ -30,7 +30,7 @@ import ( "github.com/felinics/twilight/agentcore/turn" ) -// Artifacts groups the artifact ports (OWN-PRT-3): the binding index the +// Artifacts groups the artifact ports: the binding index the // Writers resolve against and the retention ledger their claims live in. // Both are required and durable; the facts of a Session name frozen bodies // through them, so they must survive every restart the facts survive. There @@ -42,18 +42,18 @@ type Artifacts struct { // Ports are the roles a Kernel is composed from. Every field is an interface // or a core value. The Store and the Content store are required and durable -// (OWN-PRT-3); the other nil fields take the documented defaults. +// the other nil fields take the documented defaults. type Ports struct { // Store is the Session kernel (required). Store session.Stores // Content is the cas ContentStore the frozen bodies live in under - // sessionstore.FrozenAuthority (RUN-WIR-4). The run store writes them; + // sessionstore.FrozenAuthority. The run store writes them; // the materializer reads them for prompts, replies and transcripts. // Required. Content artifact.ContentStore // Artifacts are the binding store and retention ledger (required). Artifacts Artifacts - // Observers are notified of every group the Writers apply (EXT-WRT-7) + // Observers are notified of every group the Writers apply // besides the Kernel's own event stream. Observers []writer.CommitObserver // Registry is the module registry every Writer, projection and event @@ -62,15 +62,15 @@ type Ports struct { // before the Kernel exists builds it with NewRegistry and passes it here. Registry *module.Registry // Modules are application modules registered after the first-party - // ones (EXT-APP); ignored when Registry is given. + // ones; ignored when Registry is given. Modules []module.ModuleDescriptor // Clock stamps event times; nil selects time.Now. Clock func() time.Time // Cache stores folded projection states; nil asks the Store for a durable - // cache and falls back to an in-memory one (APP-MEM-2). + // cache and falls back to an in-memory one. Cache session.ProjectionCache // CacheEvery bounds how far a cached projection may fall behind the head; - // zero takes module.DefaultCacheEvery (EXT-PRJ-7). + // zero takes module.DefaultCacheEvery. CacheEvery ledger.CommitSeq // Ownership configures how Writers open Sessions. Ownership session.OpenOptions @@ -89,16 +89,17 @@ type Kernel struct { Runs *sessionstore.SessionRunStore // Turns commits the Turn protocol and reads Turn status. Turns *Coordinator - // Bus is the committed event stream: a CommitObserver on the Writers - // (OBS-1), carrying the applied groups decoded, in commit order. It - // carries facts only; transient observations are not part of it. + // Bus is the process-local committed fan-out. Tailer is the durable + // committed stream backed by Store; both observe local Writers, but the + // Tailer uses callbacks only to wake its shared per-Session poll loop. Bus *observe.Bus + Tailer *observe.SessionLedgerTailer Frozen frozen.Store // Projections reads every projection through the Session's Writer. Projections session.ProjectionReader - // Content materializes the frozen bodies projections name (CHT-MAT-1). + // Content materializes the frozen bodies projections name. Content chatlog.ContentResolver - // Chatlog commits the chatlog's own facts (APP-INP-1, APP-CKP-1). + // Chatlog commits the chatlog's own facts. Chatlog *chatlog.Commands // History answers fork-boundary questions. History history.History @@ -111,10 +112,10 @@ func New(p Ports) (*Kernel, error) { //nolint:gocritic // hugeParam: Ports is a return nil, errors.New("sessionkernel: a session Store is required") } if p.Content == nil { - return nil, errors.New("sessionkernel: a content Store is required (OWN-PRT-3)") + return nil, errors.New("sessionkernel: a content Store is required") } if p.Artifacts.Bindings == nil || p.Artifacts.Ledger == nil { - return nil, errors.New("sessionkernel: a binding store and a retention ledger are required (OWN-PRT-3)") + return nil, errors.New("sessionkernel: a binding store and a retention ledger are required") } store := p.Store registry := p.Registry @@ -124,13 +125,14 @@ func New(p Ports) (*Kernel, error) { //nolint:gocritic // hugeParam: Ports is a return nil, err } } - // The event stream observes every group the Writers apply (EXT-WRT-7) + // The event stream observes every group the Writers apply // and decodes through the Registry, so both exist before the Writers do. - bus := observe.NewBus(registry, store) - observers := append([]writer.CommitObserver{bus}, p.Observers...) + bus := observe.NewBus(registry) + tailer := observe.NewSessionLedgerTailer(registry, store) + observers := append([]writer.CommitObserver{bus, tailer}, p.Observers...) bindings, retention := p.Artifacts.Bindings, p.Artifacts.Ledger // A projection cache lets a reopened Session start folding instead of - // refolding the whole log (EXT-PRJ-3). An adapter that can store entries + // refolding the whole log. An adapter that can store entries // durably provides its own; otherwise they live as long as the process. cache := p.Cache if cache == nil { @@ -141,7 +143,7 @@ func New(p Ports) (*Kernel, error) { //nolint:gocritic // hugeParam: Ports is a } } // Frozen bodies live in the content store and are admitted through the - // same binding store the Writers resolve against (RUN-WIR-4). + // same binding store the Writers resolve. fz, err := sessionstore.FrozenValues(p.Content, bindings) if err != nil { return nil, err @@ -158,14 +160,14 @@ func New(p Ports) (*Kernel, error) { //nolint:gocritic // hugeParam: Ports is a return nil, err } // The read model folds from the Store through the cache: reading a Session - // takes no ownership (OWN-HDL-2). The Writer keeps its own transactional + // takes no ownership. The Writer keeps its own transactional // projections for the commit critical section. projections := session.NewProjectionReader(store, registry, cache) content := sessionstore.NewContent(fz) return &Kernel{ Store: store, Writers: writers, Registry: registry, Admission: admission, Runs: runs, Turns: &Coordinator{Projections: projections, Runs: runs, Now: now}, - Bus: bus, Frozen: fz, Projections: projections, Content: content, + Bus: bus, Tailer: tailer, Frozen: fz, Projections: projections, Content: content, Chatlog: &chatlog.Commands{Now: now}, History: history.History{Store: store, Registry: registry, Projections: projections}, Clock: now, @@ -180,10 +182,14 @@ func NewRegistry(extensions []module.ModuleDescriptor) (*module.Registry, error) []module.ModuleDescriptor{chatlog.Module, sessionstore.Module, turn.Module}, extensions) } -// Close ends the Writers: the lease-free readers of other processes keep -// working; the execution side of a host closes before this. +// Close ends the Writers and every durable tail loop. The lease-free readers +// of other processes keep working; the execution side of a host closes first. func (a *Kernel) Close(ctx context.Context) error { - return writer.CloseWriters(ctx, a.Writers) + err := writer.CloseWriters(ctx, a.Writers) + if terr := a.Tailer.CloseContext(ctx); err == nil { + err = terr + } + return err } // CreateSession creates the Session; ext are the segment's module extension @@ -213,7 +219,7 @@ func (a *Kernel) EnsureSession(ctx context.Context, sid session.SessionID) error return nil } -// ForkRequest forks a Session at one commit of its ledger (OWN-FRK-1): the +// ForkRequest forks a Session at one commit of its ledger: the // child inherits every commit of Parent up to and including At and continues // from there under its own identity. type ForkRequest struct { @@ -225,7 +231,7 @@ type ForkRequest struct { } // Fork creates the child Session and claims the artifacts its -// inherited prefix references (EXT-WRT-8). The child is not opened. +// inherited prefix references. The child is not opened. func (a *Kernel) Fork(ctx context.Context, req ForkRequest) (session.SegmentHeader, error) { if req.Parent == "" || req.Child == "" { return session.SegmentHeader{}, errors.New("sessionkernel: fork requires parent and child session ids") @@ -235,7 +241,7 @@ func (a *Kernel) Fork(ctx context.Context, req ForkRequest) (session.SegmentHead } // A fork point inside a Turn would hand the child a Turn whose Run is // the parent's execution: semantic history branches only at - // quiescent points (OWN-FRK-1). + // quiescent points. if active, ok, err := a.History.ActiveAt(ctx, req.Parent, req.At); err != nil { return session.SegmentHeader{}, err } else if ok { @@ -248,7 +254,7 @@ func (a *Kernel) Fork(ctx context.Context, req ForkRequest) (session.SegmentHead } // ForkBeforeTurn forks Parent at the commit just before turnID started -// (OWN-FRK-2): the child holds the conversation as it was when that Turn's +// the child holds the conversation as it was when that Turn's // inputs were still submitted and undelivered. func (a *Kernel) ForkBeforeTurn(ctx context.Context, parent session.SessionID, turnID turn.TurnID, child session.SessionID) (session.SegmentHeader, error) { seq, err := a.History.StartCommit(ctx, parent, turnID) @@ -270,7 +276,7 @@ func (a *Kernel) Collect(ctx context.Context) (session.CollectReport, error) { } // Projection reads any registered projection through the Session's Writer -// (APP-MEM-1). Reading a Session needs no ownership. +// Reading a Session needs no ownership. func (a *Kernel) Projection(ctx context.Context, sid session.SessionID, id module.ProjectionID, v module.ProjectionVersion) (any, ledger.Head, error) { return a.Projections.Load(ctx, sid, id, v) } diff --git a/agentcore/testutil/local/executor.go b/agentcore/testutil/local/executor.go index b4fab9de..1190a295 100644 --- a/agentcore/testutil/local/executor.go +++ b/agentcore/testutil/local/executor.go @@ -48,7 +48,7 @@ type Attachment = effect.Attachment type Executor = effect.ExecutionPort // Acknowledger is effect.Acknowledger: the optional retention hint an -// Executor may implement (RUN-EXE-13). +// Executor may implement. type Acknowledger = effect.Acknowledger const ( @@ -116,7 +116,7 @@ type LocalExecutor struct { } // DefaultRetainedOutcomes bounds the terminal records a LocalExecutor keeps -// for idempotent reads (RUN-EXE-3); records still executing are never dropped. +// for idempotent reads; records still executing are never dropped. const DefaultRetainedOutcomes = 1024 type inflight struct { @@ -129,7 +129,7 @@ type inflight struct { } // NewLocalExecutor builds the in-process executor. sink receives progress -// frames (model deltas, tool progress; RUN-EXE-12), normally the Worker's +// frames (model deltas, tool progress), normally the Worker's // ProgressHub; nil discards them. streaming selects StreamingModelInvoker // when the invoker offers it. func NewLocalExecutor(models ModelCatalog, tools ToolCatalog, sink effect.ProgressSink, streaming bool) (*LocalExecutor, error) { @@ -167,7 +167,7 @@ func (e *LocalExecutor) Prepare(_ context.Context, a Assignment) (string, error) // Ref with a generation suffix, so a re-dispatched step gets its own entry // and the previous one stays readable until retention drops it. Whether a // tool may be re-dispatched is the Worker's decision from the Assignment's -// Replay policy (RUN-EXE-9); Restart only derives the Ref. +// Replay policy; Restart only derives the Ref. func (e *LocalExecutor) Restart(_ context.Context, previous string, a Assignment) (string, error) { base := RefOf(a.Key()) gen := 1 @@ -198,9 +198,8 @@ func (e *LocalExecutor) evictLocked() { } } -// Validate is the pre-start check (RUN-EXE-5): tools per RUN-LOP-4, models -// by resolving the ModelRef in the catalog so a missing model fails before -// any start fact is written (RUN-LOP-3). +// Validate checks the tool definition and policy or resolves the ModelRef +// in the catalog so invalid assignments fail before any start fact is written. func (e *LocalExecutor) Validate(_ context.Context, a Assignment) (*run.ToolFailure, error) { switch body := a.Body.(type) { case ModelAssignment: @@ -286,7 +285,7 @@ func (e *LocalExecutor) Start(ctx context.Context, ref string, a Assignment) err if invoker == nil { return fmt.Errorf("%w: model catalog returned a nil invoker", ErrExecutorRejected) } - // Dispatch MUST carry the inline request payload (RUN-EXE-7): the + // Dispatch MUST carry the inline request payload: the // digest-only reconstruction of AssignmentFromTarget never enters // Dispatch, so a missing body is a definite rejection here. if body.Request == nil { @@ -456,7 +455,7 @@ func (e *LocalExecutor) runModel(ctx context.Context, a Assignment, frozenReques } // modelFailure classifies a model invocation error into the wire-stable -// failure codes (RUN-EXE-11): the executor's own conditions first, then the +// failure codes: the executor's own conditions first, then the // provider's HTTP status or the transport through effect.ClassifyModelError. func modelFailure(err error) OutcomeResult { switch { diff --git a/agentcore/turn/golden_test.go b/agentcore/turn/golden_test.go index c698b32e..8600bcc3 100644 --- a/agentcore/turn/golden_test.go +++ b/agentcore/turn/golden_test.go @@ -20,7 +20,7 @@ func freezeTurn(t *testing.T, name, got, want string) { t.Errorf("golden %s drifted — an intentional wire change must update this fixture and agent-turn.md:\n got: %s\nwant: %s", name, got, want) } -// TestTurnDerivationGolden freezes the Turn's derived identities (TRN-ID-1/2). +// TestTurnDerivationGolden freezes the Turn's derived identities. func TestTurnDerivationGolden(t *testing.T) { plan := turn.PlanDigest("turn-1", jsonstable.Digest("sha256:aa"), []chatlog.InputID{"in-1", "in-2"}) freezeTurn(t, "plan digest", string(plan), "sha256:5194f3d5319f06e2362c816ca4dded69e8b9c30298c067c4508248f33a8d94a4") diff --git a/agentcore/turn/module_test.go b/agentcore/turn/module_test.go index 7b814967..6464c873 100644 --- a/agentcore/turn/module_test.go +++ b/agentcore/turn/module_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -// EXT-COD-1: every registered event type's current codec is canonical +// every registered event type's current codec is canonical // round-trip stable — Encode, Decode, Encode reproduces the bytes. func TestEventCodecCanonicalRoundTrip(t *testing.T) { samples := map[ledger.EventType]any{ diff --git a/agentcore/turn/projection.go b/agentcore/turn/projection.go index 73e85594..e6a4eeef 100644 --- a/agentcore/turn/projection.go +++ b/agentcore/turn/projection.go @@ -52,7 +52,6 @@ type TurnSurface struct { Order []TurnID `json:"order"` Turns map[TurnID]TurnView `json:"turns"` // RunOwner routes a Run to the Turn it executes, from twilight/turn/started - // (TRN-PRJ-1). RunOwner map[run.RunID]TurnID `json:"runOwner"` } @@ -75,7 +74,7 @@ func (s TurnSurface) clone() TurnSurface { return out } -// Active returns the single active Turn of the Session, if any (TRN-SCP-2). +// Active returns the single active Turn of the Session, if any. func (s *TurnSurface) Active() (TurnView, bool) { for _, id := range s.Order { if v := s.Turns[id]; v.Status == TurnActive { @@ -91,8 +90,8 @@ var SurfaceProjection = module.ProjectionDefinition{ chatlog.TypeInputDelivered, sessionstore.Prefix + "run_ended"}, // Attempt settlement is folded from run_ended, so inherited Turns settle // from the parent's run streams, whose domain is of segment lineage and - // would otherwise be skipped (EXT-PRJ-8); a fork point inside a Turn is - // refused by the Owner (OWN-FRK-1). + // would otherwise be skipped; a fork point inside a Turn is + // refused by the Owner. Inherits: module.InheritAll, Authoritative: true, Initial: func() (any, error) { @@ -200,7 +199,7 @@ func (s *TurnSurface) settling(id TurnID) (TurnView, error) { } // A Turn is settled once: while active, or right after its Run ended // without completing, when the unit that ended the Run (Stop) writes - // the Turn's own settlement beside it (TRN-STP-1). + // the Turn's own settlement beside it. if v.Status == TurnActive || (v.Status == TurnFailed && v.Settlement == "") { return v, nil } diff --git a/agentcore/turn/projection_test.go b/agentcore/turn/projection_test.go index 40f890b9..363d2a35 100644 --- a/agentcore/turn/projection_test.go +++ b/agentcore/turn/projection_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -// TRN-PRJ-1: run_ended settles the Turn whose started named the Run. A +// run_ended settles the Turn whose started named the Run. A // completed Run completes the Turn, any other end fails it; a Run no Turn of // the Session names is ignored; a second end of the Run is a fold error // rather than a silent no-op. diff --git a/docs/architecture/core.md b/docs/architecture/core.md new file mode 100644 index 00000000..7a3688f8 --- /dev/null +++ b/docs/architecture/core.md @@ -0,0 +1,270 @@ +# Agent Core + +Agent Core defines Twilight's durable execution model. It owns session history, +run state, effect lifecycle, persistence, ownership, and recovery. Hosts and +providers supply model access, tool execution, transports, and external +resources. + +## Semantic flow + +A client submits an Input to a Session. The chatlog commits the input, and a +Turn associates it with a preset and a Run. + +The Run loop reads the current Run state, derives the next command, and commits +its protocol transition. When the transition requires external work, the +execution boundary handles that work and returns an Outcome. The loop commits +the outcome and continues the Run. + +Session projections expose the resulting state to application code. + +## CQRS and event sourcing + +Agent Core uses event sourcing for durable semantic state and CQRS to separate +state changes from state reads. + +### Event sourcing + +Commands produce committed facts. The ledger stores those facts in order, and +module projections fold them into current state. The facts provide the durable +history; projections provide derived read models. + +```text +Command → validate current state → append facts → fold projections + │ + └── durable history +``` + +A restart rebuilds state from the ledger and retained content. A projection +cache improves read performance while the ledger remains the source of truth. + +### CQRS + +The write path uses a Writer. It owns the session lease, validates commands, +appends commits atomically, and applies the corresponding projection updates. + +The read path uses projections and query surfaces. Reads can inspect session, +turn, run, and workspace state without acquiring session ownership. + +CQRS is a logical separation in Agent Core. The write and read paths may share +an implementation while retaining independent responsibilities. + +## Durable state + +Session state is stored as an append-only sequence of committed facts. A commit +contains an ordered event group and a commit identity. Projections fold facts +into queryable state. + +The ledger enforces: + +- commit ordering; +- idempotent commit identities; +- legal state transitions; +- writer-epoch fencing. + +A process reconstructs session state by reopening the store and folding the +recorded facts. Projection caches accelerate this operation and do not replace +the ledger. + +Transient observations, such as executor progress, remain outside semantic +history unless their protocol commits them as facts. + +## Core concepts + +### Session + +A Session is the durable boundary of one interaction. Its history contains the +inputs, turns, runs, workspace bindings, and other module-owned facts associated +with that interaction. + +Opening a Session creates an ownership lease. Reads use projections and do not +require ownership. A Writer commits changes under its ownership epoch. + +### Input + +An Input is a durable entry in the session chatlog. A Turn references the input +IDs used to construct its request. + +### Turn + +A Turn is one user-visible unit of progress. It associates inputs with a +preset and a Run. The Turn surface combines Turn facts with the state of its +Run and settles as completed, failed, stopped, or superseded. + +The Turn module owns Turn lifecycle facts. The Run module owns Run lifecycle +facts. + +### Run + +A Run is the logical execution history of a Turn. It is a versioned state +machine with stable identities and deterministic transitions. + +A Run records: + +- the active logical step; +- model and tool effects; +- effect requests and settlements; +- recovery state; +- the terminal Run state. + +Run identity and canonicalization rules are bound to the schema version that +created the Run. Replays use those rules. + +### Step + +A Step represents logical progress inside a Run. The current implementation +uses model steps and tool steps. + +A Step may produce one or more physical execution attempts when its effect is +recovered and replay is permitted. + +### Effect + +An Effect is a request for external work. Model calls and tool calls are +represented as effects. The Run assigns each effect a stable identity and +records its lifecycle. + +### Execution + +An Execution is one physical attempt for an Effect. The executor persists the +selected provider and its opaque backend reference. + +The Core addresses the execution through the effect assignment key. Backend +handle formats remain inside the executor and provider layers. + +### Outcome + +An Outcome is the terminal result observed from an Execution. The executor +reports success, failure, cancellation, or an unavailable result. The Run +commits the outcome and performs its next transition. + +An uncertain dispatch remains recoverable until the backend provides a reliable +observation. An interrupted request therefore keeps its settlement unresolved. + +### Wait + +A Wait records an external response required by a tool call. The Run remains +active while waiting. The response is committed before the Run continues. + +### Artifact + +Facts reference large or immutable bodies through artifact identities. The +content store retains those bodies, and the binding and retention stores keep +the references valid across restarts and forks. + +## Run loop + +`agentcore/run/loop` is the Run decision interpreter. The RunStore remains the +source of authority. Each operation loads the Run, derives the next plan, +commits protocol transitions, and reloads state when another actor has advanced +it. + +The loop exposes three execution modes: + +- `Advance` interprets the Run until it dispatches one or more assignments or + reaches a waiting or finished state. It prepares and commits protocol + transitions, then returns assignment keys before outcome retrieval. +- `Deliver` accepts one available Outcome, verifies that the named effect is + still executing, commits the settlement, and reports the next action. A + stale outcome is dropped. +- `Run` is the blocking host convenience. It calls `Advance`, watches every + dispatched key, waits for outcomes, calls `Deliver`, and repeats. + +The blocking form still uses asynchronous effect execution. A tool step can +dispatch several assignments and keep them in a pending set. Tool scheduling +supports parallel execution, sequential execution, and a maximum parallel +count. The watcher receives settlement notices or reads outcomes when they +become available. Progress is forwarded independently of outcome delivery. + +The Loop serializes operations for one Run and permits different Runs to +advance concurrently. Its EventSink carries committed facts and provisional +model or tool observations. The ledger remains authoritative for committed +state. + +Cancellation cancels in-flight assignments, drains their reported outcomes, +and settles them under a detached control context before the loop returns. +Ownership loss cancels the current drive and leaves the durable execution +records for the next owner to recover. + +## Execution boundary + +The executor uses the following lifecycle: + +```text +Loop.Advance + │ + ├── Validate → Prepare → accepted commit → Start + │ │ + │ ▼ + │ asynchronous backend work + │ │ + └── assignment key ◄────────── Worker / Backend record + │ + settlement notice or outcome read + │ + ▼ + Loop.Deliver +``` + +`Validate` checks backend capability before an effect starts. + +`Prepare` deterministically derives an execution reference. It performs no +external allocation. The Worker persists the reference before `Start`. + +`Start` crosses the external effect boundary. The backend reports whether the +execution is active, terminal, absent, or uncertain. + +`Attach` reconnects to an existing execution. A missing execution can be +replayed when the assignment's replay policy permits it. An uncertain or +orphaned execution remains under recovery control. + +## Ownership and recovery + +A Session Writer commits under an ownership epoch. A later owner fences an older +Writer at the commit boundary. + +Execution recovery proceeds as follows: + +1. The Worker persists the execution reference. +2. The Worker starts or attaches to the backend execution. +3. A replacement Worker reads the durable execution record. +4. It queries the backend for the execution state. +5. It attaches to an active execution, commits a terminal outcome, or applies + the assignment's replay policy. + +The Worker owns execution leases and watcher goroutines. The owner and Driver +decide when records require recovery; the Worker provides the recovery +operations. + +Semantic ownership and physical execution records remain separate so that a +Session can change owners while an external execution is being recovered. + +## Session Kernel and Runtime + +The Session Kernel composes the durable semantic services: + +- session stores and Writers; +- the ledger and commit observers; +- the module registry; +- projections and projection caches; +- the Run adapter; +- Turn coordination; +- chatlog commands; +- artifacts, frozen content, and session history. + +The Runtime drives active work around the Kernel. It contains the Run loop, +model and tool ports, executor integration, progress delivery, responders, and +recovery handling. + +The Driver calls the blocking form of the Loop. The Loop waits for in-flight +outcomes internally, then returns when the Run is complete, waiting for an +external response, requires execution recovery, or reaches an error. The Driver +uses Responders for external waits and Recovery for executions left by a +previous owner. + +## Workspace boundary + +A logical Workspace has a durable binding to a physical Environment. The +Environment provider supplies filesystem and execution capabilities. + +Workspace provider policy covers path validation, command execution, lifecycle, +and isolation. Agent Core uses the provider-neutral contract.