Repository navigation
feat(serve)!: --state-url, one leader, and no rebuild from markers - #246
Conversation
- serve --state-url opens the object store (ParseURL); exclusive with --state-dir; a durable store is required unless --state-memory or --dev-fake-providers; --state-sync-interval. - The leader lock (controllers.Leader: TTL 3 min, renewed every minute, 5 s tries): the controllers' reconciles are gated on it (Controller.SetGate), and the scheduler's lease controller and Refresh, the sweeper and the installation check run on the leader only. - Per-process state: a self-contained signed OAuth state, used once through UsedLoginState; FACTORY_SESSION_KEY required with --state-url, FACTORY_SESSION_KEY_PREVIOUS for rotation; /stream syncs and retries a Last-Event-ID ahead of the store; the resync's baselines are stored in LinkObservations, and a new leader's first resync is jittered. - The scheduler's sched lock around lease commits (mint before the lock, errSchedBusy, busy-until, 30 s TTL, 15 s section). - The work-item rebuild is removed; Recover runs from 10 s before the process start. - /readyz and factory_ready follow the store's Ready(); ErrReadOnly answers 503 store-read-only. - Overlapping leaders: the rule controller re-checks EventStatus and the debounce controller its record under the work-item lock; projections run under it. - factory state --state-url (objectstore.Read, read-only); reset's messages say what is lost and how to recover (Decision 7). - serve closes connections that never sent a request once its listeners stop, and the bucket after the store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no rebuild Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lease, tests - factory state --state-url reads under the command's context. - Leader: a Renew that lands after Release no longer starts a new term. - Tests: the bucket closed after the store, the leader's sweep steps, the --state-sync-interval warning; the idle-connection test accepts a reset. - controllers.md names the idempotent deletes among the leader-only writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
size-exception (Gustavo, 2026-10-28): P3's PR 5 is specified as one change: running several dispatchers safely needs the leader, the scheduler's Reading order: |
…ations everywhere - store.UnchangedDeleter / store.DeleteIfUnchanged: a delete only at the resourceVersion the caller read (ErrConflict when the object changed or was recreated). Memory, the file store and the object store implement it; storetest's CRUD/DeleteIfUnchanged checks all three, and a two-store test checks a stale store's delete. - The provider controller deletes a Delivery only at the version its reconcile read, so a redelivery under the same id after another leader's delete survives and is normalised (TestIntakeRedeliveryRacesTheDelete). - The App installation check runs on every instance (reads only), off the leader gate; a non-leader's getCatalog shows the warnings (TestServeInstallationsOnEveryInstance). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he rebuild elsewhere The rebuild mentions outside PR 5's first list (design/README.md, 06, 09, 10-poc.md, rules-engine.md, rollout.md) now say the store is the authority (P3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…before the state write - scheduler: a candidate that fails the re-check under the sched lock ends the pass (errSchedStale, no busy window), so a stale instance mints and locks at most once per pass, not once per pending task (TestSchedLockStalePassMintsOnce). - factory state --state-url keeps the bucket open while the subcommand reads through it, and runState closes it after (TestStateURLBucketLifetime). - auth: FinishLogin checks and exchanges the code before it creates the UsedLoginState, so a callback without a real OAuth round trip writes nothing; returnTo is capped at 1 KB so the state cookie stays small. - Leader.While ends a term's context when the lease lapses on this process's clock too (a timer re-armed on each renewal), and runs the loop again once the lock is held again. - serve: a connection that hasn't sent a request gets a 1 s grace after the listener stops before it is closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local go build ./cmd/factory left it at the repo root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…jections - .gitignore: /factory and /factory-agent, what go build ./cmd/factory and ./cmd/factory-agent leave at the repo root. - observationDigest keeps deleting the "projections" key, now with a comment: it is a no-op once PR 6 removes Observation.Projections, and LinkObservations is new in this release, so no deployed baseline depends on the encoding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
ccf-review: COMMENT
no findings.
Round 1 (review 5681190143) is fixed at 536bd87: the one-mint-per-pass re-check (errSchedStale), the state bucket's lifetime, the code exchange before the UsedLoginState write, the While term ending at the local lease end, and both nits (the 1 s connection grace, returnTo capped at 1 KB). Each has a test. The scheduler, leader, two-serve, shutdown and state-URL tests pass with -race -count=10; the full suite (-race) and golangci-lint are clean.
Stack (base-chain): #223 → #239 → #238 → #244 → #236 → #234 → #246 → #247
The lock's sync usually wakes it; after a catalog swap or a time-based failure, the next tick does (review 5682109855). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s deleted at the version read Gustavo's decisions on #246's triage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fx/imp5b-5-serve-state-url
… fx/imp5b-5-serve-state-url # Conflicts: # design/workstreams/dispatcher-runtime/LLD/api-server.md
…n; finish writes on fresh reads
TestCascadeRunningControllers flaked with "end run …: resourceVersion conflict". The run
controller's Reconcile read a task as Pending, then found the open run a lease created in
between and closed it as lost ("the task left the run (Pending)"); the agent's complete
then conflicted on that run. It reproduces on main too under load (2 of 12 parallel runs of
150), so it predates this stack.
- Reconcile re-reads the task after finding an open run, and leaves the run alone when the
task holds one again: a run is created only after its task's lease is stored.
- finish writes the run and the task on fresh reads, under the work item's lock, with a
compare-and-swap retried on a conflict: a heartbeat's progress or lease renewal in between
(they take no work item lock) is kept, not a failed finish. A task that moved out of the
phase the finish was decided on is still a conflict.
- Tests that force both interleavings (run_interleave_test.go); both fail before the fix.
TestOneRunPerAttempt's "finish failed after the run's end" now injects a failed task
write, since a lease renewal no longer fails the finish.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
984792a made finish write on fresh reads, and so let a complete whose lease expired while it waited for the work item's lock finish the task: its run already lost, the task leased again on a new run, the Result stored on no run and factory.run.completed fired for a lost run (review 5682958060, FACTORY-IDEM-001). - finishLocked: a run that ended lost or abandoned meanwhile (not by this finish, not counted) means the lease is gone: a conflict. A counted end is this finish's retry. - endTask: a task on another attempt or another agent is a conflict too; its comment says exactly what a fresh read keeps. - TestFinishAfterTheLeaseMovedOn: expiry, reconcile and a new lease between the complete's read and its lock. Complete is a conflict, the task stays Leased on -2, -1 stays lost with no Result, and no run.completed fires. It fails at 984792a. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if cur.Status.Phase != t.Status.Phase && (!holdsRun(cur) || !holdsRun(t)) { | ||
| return nil, fmt.Errorf("finish task %s: it moved from %s to %s meanwhile: %w", t.Metadata.ID, t.Status.Phase, cur.Status.Phase, store.ErrConflict) | ||
| } | ||
| if cur.Status.Attempts != t.Status.Attempts || !sameAgent(cur, t) { |
There was a problem hiding this comment.
[Must-fix] CORE-TEST-001 · New behaviour without a test
TestFinishAfterTheLeaseMovedOn passes when either check is removed, because its scenario (an attempt spent, -1 closed lost, a new lease) trips both checks. Each check also covers a case of its own, and neither case has a test:
- The
endTaskattempt/agent check (line 700). Suppose the task is leased again before any reconcile closes-1.LeaseIssued'sensureRunthen reuses the open-1, soendRunends itbyThisand thefinishLockedcheck never runs. With line 700 replaced byif false {, ahookStoreprobe getsComplete = Succeeded, <nil>. The probe'sbeforeLocksets the task Pending,Attempts++, thene.lease(id), with noReconcile. The probe then shows the re-leased task Succeeded and onerun.completed. - The
finishLockedcheck (line 605). Suppose a throttledAbandon, which spends no attempt and closes-1as abandoned, and then a new lease for the same agent. Attempt and agent both match, soendTaskpasses. With line 605 replaced byif false {, the probe (beforeLock:e.run.Abandon(.., Abandonment{Reason: AbandonThrottled}), thene.lease(id)) getsComplete = Succeeded, <nil>and onerun.completedfor the abandoned-1.
At da79c38 both probes return a conflict and leave the task Leased.
Why: Untested behaviour regresses silently and the next refactor removes it without anyone noticing.
Fix: Add both probes as cases next to TestFinishAfterTheLeaseMovedOn in run_interleave_test.go, for example as a table over the beforeLock hook. The cases are (a) re-leased before any reconcile: want a conflict and the task Leased on attempt 1; (b) a throttled abandon, then leased again to the same agent: want a conflict, the task Leased on attempt 0, -1 abandoned with no Result, and no run.completed. Check that (a) fails with line 700 disabled and (b) fails with line 605 disabled.
ccf-review · 4bf7e7b9777a · rules@fe7cb9dc86ce
…mp5b-5-serve-state-url The stack below was rebased onto main; this keeps the branch's own changes on the new base. README §11: this PR's paragraph, then main's new §12.
…223) * docs(design): P3, the dispatcher's state as a log in object storage Proposal P3 and ADR-0009 for IMPROVEMENTS item 5(a): a shared append-only log of create-only objects (GCS ifGenerationMatch=0, S3 If-None-Match: *) replayed into the memory engine, snapshots, locks as stored records, one leader for the background loops, log retention that no stale writer can break, the cost at our load, and the rollout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: own-nonce check, sched lock, lock cleanup, transcript putAt, leader start-up work - A 'taken' create reads log/<v> and compares the nonce: SDK retries can land the same write. - Lease commits serialise on a store lock (sched): matching checks limits across many objects. - Unlock writes under its own deadline; the leader sweeps expired lock records. - TranscriptBlob carries putAt, set on every put, for eviction and retention. - Rebuild runs only on a leader with an empty store; Recover on becoming leader. - Provider calls are guarded by the store-wide wi/ locks, not the Effect id; the audit covers them. - The leader-lock attempt has a short deadline; LIST is billed like writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: a bounded sched section, Rebuild once per bucket - Lease credentials are minted before the sched lock; the lock is taken with a 2 s context (timeout = errSkip), has a 30 s TTL and its section a 15 s deadline; Scheduler.mu is held across the bounded wait; the cost is per commit attempt. - Rebuild is gated on a RebuildDone record created when it succeeds; the controllers that read work items wait for it; a failed rebuild is retried, and the next leader finishes it. - Effects are applied by the rule and debounce reconciles (not run). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: errSchedBusy ends the pass, rebuild per binding, failures reported - A sched lock timeout is a new sentinel that ends the pass (one wait, one mint per pass), not errSkip, which tries the next candidate. - The rebuild is tracked per binding in a RebuildStatus record: bindings added to the catalog later are rebuilt; failures are retried with a capped backoff and reported (error log naming provider and RuleSet, a pending gauge, factory state). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: rebuild keys, a per-RuleSet gate, sched busy-until - The rebuild key is the provider plus sorted scope.repos/exclude (not the RuleSet name); keys are rebuilt on becoming leader and on catalog sync; wic.Rebuild takes keys and returns a result per key. - Only the rule controller is gated, per RuleSet: events of a RuleSet with a pending key are requeued, the rest go on; a failing key holds only its own RuleSets. - The pending gauge is computed from the stored record; /metrics is loopback-only, so the error log is the cloud's channel. - A sched timeout records busy-until, so parked polls don't each wait in turn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: the rebuild gate covers replay, items that span keys - One gate check before workItemFor, called by Reconcile (requeues the whole event) and by Replay (refuses with a 409 naming the RuleSet and pending key). - A rebuild observes every link a marker names, across keys, and merges them before creating the item; if one fails the item waits and the key stays pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: permanent observe errors don't hold keys, the replay 409 is a contract PR - A rebuild's cross-key observe treats not-found, forbidden and no-connection as gone (item created without the link, a warning); only transient errors keep the key pending. - The replay refusal is a new problem type, rebuild-pending, added by its own contract PR (openapi, web client, contracts.md, api-server.md) before the serve PR; the header says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 nits: Problem.code, no-connection as gone, the ADR's consequences, named docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 decisions: no rebuild from markers, store only events that can matter, costs at our load Gustavo's answers (2026-10-27): a small bill is fine, the cloud instance starts fresh with a new catalog, the work-item rebuild is removed (durable state + stored events replace it), and events that no rule's on/scope could match, about objects with no work item, are dropped at intake. The rebuild gate, the replay 409 and the contract PR go with the rebuild; the rollout's PR 3 is now the intake filter. Costs redone for 20 matched webhooks and 3 runs a day: under $1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: today's intake as it is, CouldFire, old markers, a durable store required - Today's flow restated: unmapped types make no Event; a WorkItem only when CouldFire; mapped events cost ~7 writes over their life. The filter is the eventLink lookup plus rules.CouldFire, as workItemFor makes them; no new export. - Store deletes are log writes: a dropped delivery costs 2; redeliveries are re-filtered; the factory's own dropped events log at Info; unmapped deliveries are deleted at once too. - No rebuild: Conn.List goes, ADR-0001's rebuild is marked superseded, serve needs a durable store unless --dev-fake-providers or --state-memory, the three old-marker cases, the full doc list. - Costs redone with token renewals and delete writes: about $1 a month at most. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: completed checks, releases and deployments are mapped, costs recounted - Completed check suites/runs (pr.checks.completed), releases and deployment statuses are mapped types; checks on tracked PRs are stored through the link rule and now counted. - A WorkItem is created only when a rule fires (permissive rails), not when CouldFire passes. - Observe's marker parsing (Observation.Projections, projectionsIn) goes with the rebuild; Project still reads the App's own markers. - PR 4 lists the serve tests that need --state-memory; free-tier days and totals recounted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 decisions 5-6: brief polling in supervise, the webhook gap as a known limit - agent.yaml's poll block (wait 0s-60s, interval after a 204); defaults keep the long poll; no dispatcher or contract change. Its own AGT PR. - A failed live webhook delivery is a known limit (no recovery change); a VM behind a load balancer is the fallback if cold starts make it bite. - Costs restated by polling mode: ~$0.55 (brief polls on Cloud Run) to ~$1.05 a month. - The provider-side removals (Conn.List, Observe's marker parsing) move to their own PR (FND + GH), so PR 4 stays in RT's directories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: a zero wait reaches the wire, the leader's work between requests - worker.New reads a zero LeaseWait as unset: the config fills the default only when poll.wait is absent, and the worker's poll settings treat zero as zero; tested on the wire. - With CPU only during requests (Cloud Run's request billing, Lambda), the leader's loops advance only during requests; the options (work inside requests, always-allocated CPU, a VM) are named for item 9, and Decision 6 says so. - Nits: the lease loop's wording, whole seconds, cost table figures, the free-tier days, the no-List test's wording, the deploykit type list, ADR-0009's punctuation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 nits: the S3 long-poll cost, PR 6's poll settings, ADR-0009's polling consequence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 breaking-change review fixes From an independent review focused on breaking changes: - Two releases on one log: a schema version per entry and snapshot; an instance that meets a newer one stops writing and reports not-ready. - Per-process state across instances: a signed OAuth state, /stream's resume syncs and retries, the resync's baselines stored (LinkObservations). - Lookups whose empty answer leads to a create or a drop sync once first (intake filter, workItemFor), so another instance's fresh link isn't missed. - /readyz's description is a contract change: its own PR (now PR 4 of 7). - Recover runs at each instance's start as today; dropped events logged at Info with a metric; failed deliveries keep their retention; the cutover step; file:// and P2 dirs refuse each other; Lock waits wake on local unlock and the store's clock; wait 0s needs an interval; older agents reject the poll key. - Rollout: every test that breaks and the PR that fixes it, the missing docs, ownership kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 decision 7: nothing comes back after lost state After a reset, a lost bucket or dir, or a corrupt dir that is reset, open items start fresh (state, counters and the run cap); people replay events or reopen issues to recover. factory state reset's prompt, output, golden file and README §6 say so (PR 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: a shared session key and single-use OAuth state, expand-then-write, readiness - The signed OAuth state has its own label and stays single-use (UsedLoginState, create-if- absent, swept after the TTL); serve --state-url requires FACTORY_SESSION_KEY, with a previous key accepted for rotation. - Schema changes ship expand-then-write (ReadableSchema, then SchemaVersion), so a rollback keeps writing; a snapshot past ReadableSchema refuses to start; a golden pins the types. - Readiness defined without a draining probe: /readyz 200 once loaded and synced, 503 when writing stopped or the last sync is older than MaxLag; writes answer 503; PR 4 adds the 503s to the contract. - LinkObservations are deleted with their item or link, swept when orphaned, and registered; a new leader's first resync is jittered. - After lost state, recovery is redelivery, reopening and re-labelling (no events to replay). - Tests: Info drop logging and the metric, failed deliveries keep retention. Nits: the Syncer mechanism and its cost, Recover from start minus 10 s, ADR-0009's consequences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 keeps the factory's own events at intake Found while building PR 3 (#236): a PR an agent's effect opens can send pr.opened before linkCreated records the link, so events the factory caused are kept even when unlinked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 follows what PR 1 (#238) settled - file:// creates: tmp file, fsync, link(2) into place. - A write the local copy refuses syncs once and runs again (writes check the latest state). - An unknown create outcome is retried once, so an outage holds the write lock ~20 s at most. - The orphan scan uses the blob's age; retention and the scan run at the first sweep, then daily. - Close compacts on any instance; snapshots carry the newest schema they hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3's PR 1 ships in two parts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3, stored-type changes accumulate into the pending schema version Gustavo's decision on #239's held question. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3, N+1 freezes at the expand release; each transcript put gets its own blob - Gustavo's decision on #239's held question: once a release reading N+1 ships, later changes wait for N+1 to be written and open N+2. - #238's re-review found a drop and a re-put on two instances could still lose an acknowledged upload. Each put now writes transcripts/<run>.<id>.jsonl.gz with create-if-absent, the record names the id, and drops delete only their record's blob. Bucket.Put goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 records what PR 2 (#244) settled Scheme-only URL errors, https-only S3 endpoints except loopback, the GCS missing-bucket check confirmed by a list at most once a minute, the pgsty/minio emulator (Garage ignores If-None-Match), and S3's 409 as an unknown outcome. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3, the installation check on every instance, deliveries deleted at the version read Gustavo's decisions on #246's triage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Gustavo Carvalho <gusfcarvalho@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…only objects (#238) * docs(design): P3, the dispatcher's state as a log in object storage Proposal P3 and ADR-0009 for IMPROVEMENTS item 5(a): a shared append-only log of create-only objects (GCS ifGenerationMatch=0, S3 If-None-Match: *) replayed into the memory engine, snapshots, locks as stored records, one leader for the background loops, log retention that no stale writer can break, the cost at our load, and the rollout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: own-nonce check, sched lock, lock cleanup, transcript putAt, leader start-up work - A 'taken' create reads log/<v> and compares the nonce: SDK retries can land the same write. - Lease commits serialise on a store lock (sched): matching checks limits across many objects. - Unlock writes under its own deadline; the leader sweeps expired lock records. - TranscriptBlob carries putAt, set on every put, for eviction and retention. - Rebuild runs only on a leader with an empty store; Recover on becoming leader. - Provider calls are guarded by the store-wide wi/ locks, not the Effect id; the audit covers them. - The leader-lock attempt has a short deadline; LIST is billed like writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: a bounded sched section, Rebuild once per bucket - Lease credentials are minted before the sched lock; the lock is taken with a 2 s context (timeout = errSkip), has a 30 s TTL and its section a 15 s deadline; Scheduler.mu is held across the bounded wait; the cost is per commit attempt. - Rebuild is gated on a RebuildDone record created when it succeeds; the controllers that read work items wait for it; a failed rebuild is retried, and the next leader finishes it. - Effects are applied by the rule and debounce reconciles (not run). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: errSchedBusy ends the pass, rebuild per binding, failures reported - A sched lock timeout is a new sentinel that ends the pass (one wait, one mint per pass), not errSkip, which tries the next candidate. - The rebuild is tracked per binding in a RebuildStatus record: bindings added to the catalog later are rebuilt; failures are retried with a capped backoff and reported (error log naming provider and RuleSet, a pending gauge, factory state). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: rebuild keys, a per-RuleSet gate, sched busy-until - The rebuild key is the provider plus sorted scope.repos/exclude (not the RuleSet name); keys are rebuilt on becoming leader and on catalog sync; wic.Rebuild takes keys and returns a result per key. - Only the rule controller is gated, per RuleSet: events of a RuleSet with a pending key are requeued, the rest go on; a failing key holds only its own RuleSets. - The pending gauge is computed from the stored record; /metrics is loopback-only, so the error log is the cloud's channel. - A sched timeout records busy-until, so parked polls don't each wait in turn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: the rebuild gate covers replay, items that span keys - One gate check before workItemFor, called by Reconcile (requeues the whole event) and by Replay (refuses with a 409 naming the RuleSet and pending key). - A rebuild observes every link a marker names, across keys, and merges them before creating the item; if one fails the item waits and the key stays pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: permanent observe errors don't hold keys, the replay 409 is a contract PR - A rebuild's cross-key observe treats not-found, forbidden and no-connection as gone (item created without the link, a warning); only transient errors keep the key pending. - The replay refusal is a new problem type, rebuild-pending, added by its own contract PR (openapi, web client, contracts.md, api-server.md) before the serve PR; the header says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 nits: Problem.code, no-connection as gone, the ADR's consequences, named docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 decisions: no rebuild from markers, store only events that can matter, costs at our load Gustavo's answers (2026-10-27): a small bill is fine, the cloud instance starts fresh with a new catalog, the work-item rebuild is removed (durable state + stored events replace it), and events that no rule's on/scope could match, about objects with no work item, are dropped at intake. The rebuild gate, the replay 409 and the contract PR go with the rebuild; the rollout's PR 3 is now the intake filter. Costs redone for 20 matched webhooks and 3 runs a day: under $1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: today's intake as it is, CouldFire, old markers, a durable store required - Today's flow restated: unmapped types make no Event; a WorkItem only when CouldFire; mapped events cost ~7 writes over their life. The filter is the eventLink lookup plus rules.CouldFire, as workItemFor makes them; no new export. - Store deletes are log writes: a dropped delivery costs 2; redeliveries are re-filtered; the factory's own dropped events log at Info; unmapped deliveries are deleted at once too. - No rebuild: Conn.List goes, ADR-0001's rebuild is marked superseded, serve needs a durable store unless --dev-fake-providers or --state-memory, the three old-marker cases, the full doc list. - Costs redone with token renewals and delete writes: about $1 a month at most. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: completed checks, releases and deployments are mapped, costs recounted - Completed check suites/runs (pr.checks.completed), releases and deployment statuses are mapped types; checks on tracked PRs are stored through the link rule and now counted. - A WorkItem is created only when a rule fires (permissive rails), not when CouldFire passes. - Observe's marker parsing (Observation.Projections, projectionsIn) goes with the rebuild; Project still reads the App's own markers. - PR 4 lists the serve tests that need --state-memory; free-tier days and totals recounted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 decisions 5-6: brief polling in supervise, the webhook gap as a known limit - agent.yaml's poll block (wait 0s-60s, interval after a 204); defaults keep the long poll; no dispatcher or contract change. Its own AGT PR. - A failed live webhook delivery is a known limit (no recovery change); a VM behind a load balancer is the fallback if cold starts make it bite. - Costs restated by polling mode: ~$0.55 (brief polls on Cloud Run) to ~$1.05 a month. - The provider-side removals (Conn.List, Observe's marker parsing) move to their own PR (FND + GH), so PR 4 stays in RT's directories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: a zero wait reaches the wire, the leader's work between requests - worker.New reads a zero LeaseWait as unset: the config fills the default only when poll.wait is absent, and the worker's poll settings treat zero as zero; tested on the wire. - With CPU only during requests (Cloud Run's request billing, Lambda), the leader's loops advance only during requests; the options (work inside requests, always-allocated CPU, a VM) are named for item 9, and Decision 6 says so. - Nits: the lease loop's wording, whole seconds, cost table figures, the free-tier days, the no-List test's wording, the deploykit type list, ADR-0009's punctuation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 nits: the S3 long-poll cost, PR 6's poll settings, ADR-0009's polling consequence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 breaking-change review fixes From an independent review focused on breaking changes: - Two releases on one log: a schema version per entry and snapshot; an instance that meets a newer one stops writing and reports not-ready. - Per-process state across instances: a signed OAuth state, /stream's resume syncs and retries, the resync's baselines stored (LinkObservations). - Lookups whose empty answer leads to a create or a drop sync once first (intake filter, workItemFor), so another instance's fresh link isn't missed. - /readyz's description is a contract change: its own PR (now PR 4 of 7). - Recover runs at each instance's start as today; dropped events logged at Info with a metric; failed deliveries keep their retention; the cutover step; file:// and P2 dirs refuse each other; Lock waits wake on local unlock and the store's clock; wait 0s needs an interval; older agents reject the poll key. - Rollout: every test that breaks and the PR that fixes it, the missing docs, ownership kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 decision 7: nothing comes back after lost state After a reset, a lost bucket or dir, or a corrupt dir that is reset, open items start fresh (state, counters and the run cap); people replay events or reopen issues to recover. factory state reset's prompt, output, golden file and README §6 say so (PR 5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 review fixes: a shared session key and single-use OAuth state, expand-then-write, readiness - The signed OAuth state has its own label and stays single-use (UsedLoginState, create-if- absent, swept after the TTL); serve --state-url requires FACTORY_SESSION_KEY, with a previous key accepted for rotation. - Schema changes ship expand-then-write (ReadableSchema, then SchemaVersion), so a rollback keeps writing; a snapshot past ReadableSchema refuses to start; a golden pins the types. - Readiness defined without a draining probe: /readyz 200 once loaded and synced, 503 when writing stopped or the last sync is older than MaxLag; writes answer 503; PR 4 adds the 503s to the contract. - LinkObservations are deleted with their item or link, swept when orphaned, and registered; a new leader's first resync is jittered. - After lost state, recovery is redelivery, reopening and re-labelling (no events to replay). - Tests: Info drop logging and the metric, failed deliveries keep retention. Nits: the Syncer mechanism and its cost, Recover from start minus 10 s, ADR-0009's consequences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 keeps the factory's own events at intake Found while building PR 3 (#236): a PR an agent's effect opens can send pr.opened before linkCreated records the link, so events the factory caused are kept even when unlinked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(store): an object store that keeps the state as a log of create-only objects internal/store/objectstore: the store several dispatchers share (P3). Memory stays the engine; every change is committed by creating log/<v> in a Bucket, and memory syncs from the log before stale reads, on a Get that finds nothing, after a lost race and in the background. Locks are StoreLock records and transcript sizes TranscriptBlob records, both written through the log; snapshots, cold start, log retention and the orphan scan; SchemaVersion/ReadableSchema with ErrReadOnly; the fake and file:// buckets and ParseURL's registry. The file store moves its encoding to internal/store/journal (no format change) and refuses an object store's dir; the secrets audit moves to internal/store/storeaudit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): self-review: surface sweep failures, share dir names, per-write Run cleanup Sweep returns compaction, retention and orphan-scan failures; eviction fails when a transcript can't be dropped; the stores' directory names live in store/journal; each write collects the Runs it deleted through its context; maintenance runs at the first sweep; sync failures on the read path log once while they last; tests for log and snapshot retention and Ready after Close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 follows what PR 1 (#238) settled - file:// creates: tmp file, fsync, link(2) into place. - A write the local copy refuses syncs once and runs again (writes check the latest state). - An unknown create outcome is retried once, so an outage holds the write lock ~20 s at most. - The orphan scan uses the blob's age; retention and the scan run at the first sweep, then daily. - Close compacts on any instance; snapshots carry the newest schema they hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3's PR 1 ships in two parts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(store): the journal format, the secrets audit and memory's apply, shared by the stores The shared part of P3's PR 1, split out of #238: - internal/store/journal: P2's journal line, snapshot, kind registry and transcript naming; the file store uses it with no change on disk, and refuses a dir that holds an object store's log/ or snapshots/ (ErrObjectStoreDir). - internal/store/storeaudit: the kind audit, token scan and fixtures, which the file store's tests delegate to; Phase 6's Enrollment and AgentRefresh get audit lines. - memory: the commit hook takes the write's ctx; Version, Apply, DeleteAt, Replace. - store: Syncer, SyncIfSupported, ErrReadOnly, SchemaVersion and ReadableSchema. - cmd/factory: TestStoredSchema with its golden, and TestInternalKindsAudited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): review fixes: MoveAside's guard, the schema golden as a set, one CheckType - filestore.MoveAside refuses an object store's dir before taking the lock, so factory state reset never creates LOCK in, or moves, a file:// bucket. - TestStoredSchema compares the golden as a sorted set, and changes accumulate into a pending ReadableSchema (SchemaVersion+1) until a release writes it (schemaVerdict, with a table test); store.md and schema.go say so. - The file store's checkType is journal.CheckType, which gets a table test. - ReadHeader decodes the format alone first; read.go's journal variable renamed; comments name an object store without its package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3, stored-type changes accumulate into the pending schema version Gustavo's decision on #239's held question. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): a cold start re-lists snapshots on an empty tail; a re-put transcript survives a drop - readImage: an empty log tail after snapshot V lists snapshots/ again and starts over when a newer one appeared, instead of settling at V and re-creating the deleted entries on its next write (TestColdStartRetries/list/empty tail). - dropTranscript: an ErrConflict on the record's conditional delete means another store re-put the blob, so both stay; eviction moves on to the next oldest and the sweep doesn't count it (TestTranscriptDropRacesRePut). - dropRunTranscripts gives each blob its own request-bounded context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3, N+1 freezes at the expand release; each transcript put gets its own blob - Gustavo's decision on #239's held question: once a release reading N+1 ships, later changes wait for N+1 to be written and open N+2. - #238's re-review found a drop and a re-put on two instances could still lose an acknowledged upload. Each put now writes transcripts/<run>.<id>.jsonl.gz with create-if-absent, the record names the id, and drops delete only their record's blob. Bucket.Put goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): the schema golden refuses a change that bumps both versions; N+1 freezes once shipped - schemaVerdict: -update writes only when the shape is unchanged or ReadableSchema is pending (SchemaVersion+1); a change that moves both versions past the golden gets its own message (expand first), with TestSchemaVerdict rows. - The golden's header states the policy, and TestStoredSchema keeps its text in sync. - schema.go and store.md: once a release that reads N+1 ships, N+1 is frozen; later changes wait for SchemaVersion N+1 and open N+2 (P3, Gustavo's decision). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): each transcript put gets its own blob; Bucket.Put goes A re-put that landed between a drop's record delete and its blob delete lost its blob (ccf-review on #238). Per P3 (15838c1): each put creates transcripts/<run>.<id>.jsonl.gz with a new random id, create-if-absent; the TranscriptBlob record names the id and reads follow it; a drop deletes only the blob its deleted record named; a re-put replaces the record, then deletes the blob it replaced. The orphan scan deletes blobs whose id no record names, by age. Nothing writes unconditionally any more, so Put leaves the Bucket interface, the fake and file://. TranscriptBlob gains id (the golden at schema 1, as this stack keeps it). evictFor logs only what it evicted. The reviewer's interleaving is a case of TestTranscriptDropRacesRePut; it fails on 552fdc8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): schemaVerdict: a missing golden regenerates; invalid versions say so "Expand first" applies only when SchemaVersion moved past the golden; -update writes when there is no golden; a ReadableSchema that isn't SchemaVersion or SchemaVersion+1, or is below the golden's, gets its own message. TestSchemaVerdict rows for each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): ParseURL never prints a URL's credential; GetTranscript's retry is tested - ParseURL: a URL that doesn't parse reports only (*url.Error).Err, with what it quotes masked; an unknown scheme prints u.Redacted(); file:// refuses userinfo. TestParseURL asserts the secret is absent for each (FACTORY-CRED-001, from #244). - GetTranscript: TestTranscriptGetFollowsRePut covers the retry after a re-put (it fails with one attempt); a read-only store answers from what it has. - evictFor: a drop whose record delete committed but whose blob delete failed counts as evicted, with a warning (TestTranscriptEvictionBlobDeleteFails). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): mask a state URL's whole userinfo in errors (RedactURL) url.URL.Redacted masks only a password, so a user-only credential (s3a://secret@bucket) was printed. RedactURL masks user and password; the unknown-scheme and file:// errors use it, and it is the one copy #244's GCS and S3 openers can use. TestParseURL asserts the secret is absent when the user alone is the secret, for an unknown scheme and for file:// with and without a host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): TestStoredSchema says when the golden has no readableSchema line A golden that exists without its header was reported as missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): errors about a state URL print only its scheme Masking userinfo wasn't enough: a secret holding '/', '?', '#' or '"' doesn't parse as userinfo (s3a://AKIA:/secret@bucket puts it in the path), and the parse-error mask stopped at an escaped quote. ParseURL's errors now say which part is wrong and print at most the scheme: "doesn't parse"; an unknown scheme; for file://, userinfo, a host, a relative path, a query or a fragment. RedactURL and the quote mask are gone. TestParseURL adds the reviewer's URLs and asserts no substring of the secret is in any error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3 records what PR 2 (#244) settled Scheme-only URL errors, https-only S3 endpoints except loopback, the GCS missing-bucket check confirmed by a list at most once a minute, the pgsty/minio emulator (Garage ignores If-None-Match), and S3's 409 as an unknown outcome. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(design): P3, the installation check on every instance, deliveries deleted at the version read Gustavo's decisions on #246's triage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Gustavo Carvalho <gusfcarvalho@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com>
Task: IMPROVEMENTS item 5(b), stack 5 PR 5 of 8:
fx/imp5b-5-serve-state-urlonfx/imp5b-4-readyz-contract. Design: P3 (#223).Merge with squash (Gustavo, 2026-10-28): a stray build output was committed and removed in 31df42e, so squash keeps it out of main.
.gitignorenow covers the repo-root/factoryand/factory-agent(536bd87).BREAKING CHANGE:
factory serveneeds a durable store. Without--state-diror--state-urlit refuses to start, unless it is given--state-memory(tests and quick local runs, where every work item starts fresh on a restart) or--dev-fake-providers. The run kit already passes--state-dir.Workstream RT (
internal/store/,internal/controllers/,internal/scheduler/,internal/server/,internal/auth/,cmd/factory serveandfactory state), plus the design docs the spec names.Summary
This PR implements P3 §Rollout PR 5:
/stream,LinkObservationsand recovery;schedlock;workItemFor's empty lookup, tested across two stores;Conn.Listand Observe's parsing, which are PR 6's;serveflags and the refusal;Flags and readiness
serve --state-url gs://…|s3://…|file:///…opens the bucket withobjectstore.ParseURLand the store withobjectstore.Open. It registersinternalKindsand--state-sync-interval(default 2 s).--state-urlis exclusive with--state-dir. The URL is never logged: the start-up line names its scheme only.--state-diror--state-url,serverefuses to start, unless it has--state-memoryor--dev-fake-providers. A memory store logs at warn that every work item starts fresh on a restart, with no state and no run counters.--state-urlwithoutFACTORY_SESSION_KEYis refused, and the error says why.FACTORY_SESSION_KEY_PREVIOUS(at least 32 bytes) is accepted for verifying./readyzandfactory_readyfollow PR 4's contract. The server checks any store that hasReady()(server.StoreReadiness), andserver.Options.Ready(the rebuild's wait) is gone. A store that stopped writing answers503 store-read-only; one not synced, or pastMaxLag, answers503 unavailable. A catalog that isn't loaded is nowcatalog-not-loaded, the code the contract names; it wasunavailable.CodeStoreReadOnly = "store-read-only":toProblemmapsstore.ErrReadOnlyto a 503 with noRetry-After.The leader
controllers.Leaderholds theleaderlock: TTL 3 min, renewed every minute, and tried at start and every minute with a 5 s context.Held()is local. It is true from a successful Lock or Renew until the lease that call set ends on this process's clock. So a holder paused past its lease stops before another instance can take over. A failed Renew ends the term.Controller.SetGate(leader), which is checked before each reconcile. Informers run everywhere, so the queues are full at a takeover.Controller.Runcan be called only once, so gating is used instead of restarting controllers.Leader.While. The scheduler'sRefreshis skipped by the tick on non-leaders (scheduler.Options.Leader); the tick's wake of parked polls runs everywhere.getCatalogshows its own result, Decision 2), and runsRecoverat its start withsince= process start − 10 s.Per-process state
stateis self-contained:<payload>.<HMAC(session key, "factory-oauth-state\0" + payload)>, where the payload is{id, exp, rt}.UsedLoginStatecreate nameduls_<sha256(id)>. A second use, on any instance, is400 oauth-state-mismatch.MaxPendingLoginsand the pending map are gone./stream: aLast-Event-IDahead of the store syncs and is tried once more, then starts from now.LinkObservations, one record per work item:{digests: {"<provider>/<ref>": sha256}}.observationDigestdrops theprojectionsJSON key rather than naming the Go field, so PR 6 can deleteObservation.Projectionswithout touching RT files. Kept, with a comment (536bd87): until PR 6 merges, Observe still reports the factory's own markers, and the digest must leave them out. After PR 6 the key is never there and the deletion is a no-op. No deployed baseline depends on the encoding, sinceLinkObservationsis new in this release.LinkObservationsandUsedLoginStateare in:internalKinds();storeaudit(kinds, audit lines and fixtures, which the file store'sfixtures_test.goaudit runs over);readableSchema 1.SchemaVersionandReadableSchemastay 1, as decided on feat(store): an object store that keeps the state as a log of create-only objects #238.Scheduler: the
schedlock (P3 §Locks and leases)schedwith a 2 s context (Options.SchedWait) and a 30 s TTL (SchedTTL).errSchedBusy. It ends the pass with no grant and setsschedBusyUntil= now + 2 s. A pass that starts before then returns at once, with no wait and no mint.check). Then it runs the writes (commitLocked). A candidate that fails the re-check is skipped, and its credential is left to expire.Rebuild removal
wic.Rebuild,RebuildResult,mergeProjections,RebuildTimeout, the call in serve and/readyz's wait are all gone.Conn.Liststays in the interface; PR 6 removes it.TestRestartRebuildsFromProjectionsis gone, andTestServeNormalisesAfterRebuildbecameTestServeRecoversFromBeforeItsStart.Observation.Projections(rule_test.go,serve_store_test.go,workitem_test.go) now use the fake's log (gh.Projections()).Reset (Decision 7) and
factory state --state-urlfactory state reset's prompt and output, its golden and README §6 and §11 now say what is lost and how to recover:factory state --state-url URLis read-only, as--state-diris. It uses the newobjectstore.Read(aView: no sync, no lock, no write), and every read subcommand works on it.reset --state-urlis refused, and says how a bucket is reset.Shutdown
ServereturnsErrServerClosed), its connections still inStateNeware closed. They are tracked withConnState.TestServeShutdownWithIdleConnectioncovers it and fails without the fix ("drain requests: context deadline exceeded"). feat(agent): supervise can poll briefly instead of long-polling #237's test-side workaround is untouched.sharedStore.Close), when it is anio.Closer.Audit: every store write and provider call a leader-only loop or the recovery makes
Every row is a create, a compare-and-swap (
update()andLinkObjectre-read and retry), a conditional delete at the version read (store.DeleteIfUnchanged), or runs under the store-widewi/<id>lock, except the deletes marked delete. A second delete of those finds nothing (ErrNotFoundignored), and each removes a record whose purpose is over. Four gaps were fixed in this PR (fixed).Conn.Recover: lists the provider's deliveriesAccept: CreateDeliverynamed by the delivery idErrDuplicateDelivery)Conn.NormaliseEvent(deterministic id)Delivery(normalised / failed)Deliverywith no stored event; delete after its retentionstore.DeleteIfUnchanged); a redelivery under the same id since is kept and normalised (TestIntakeRedeliveryRacesTheDelete)WorkItem(deterministic id), CreateEventStatusEventStatusof an aged-out EventConn.Observe(observeItem)WorkItem(decisions, state, links), CreateTask/TaskPrompt(deterministic, looked up first), CreateDebounceRecordEventStatusis read again under the lock, so a second leader whose reconcile saw the event unprocessed applies nothing (TestOverlapRuleEventProcessedOnce)Conn.Apply(notify, effects), then CreateEffectIdempotencyKey)WorkItem,Apply, tasks (as the rule controller)TestOverlapDebounceFiresOnce)Conn.ProjectTestOverlapProjectionUnderTheLock)Conn.Observe(cacheRefresh)factory.workitem.resyncedEvent (keyresync/<wi>/<digest>)LinkObservationsLinkObservations(item gone or retired)WorkItemafter a re-checkRun(named per attempt)ErrAlreadyExistsreads the existing one)Run,Task;count(WorkItem counters)factory.run.completed/failedEvent (keyrun/<run>)expire(Update Task),endLease(Update Lease),AckCancelLeaseafterLeaseRetentionRefreshAgentTokensyncAgent(Update Agent),setReason(Update Task)Sweep: retention deletesdeleteAt) / bucket deletesUsedLoginState, orphanLinkObservationsLease matching isn't leader-only: it runs in the agent's request on any instance, and its commits now serialise on
sched.Verification
PATH=<scratchpad>/bin:$PATH make reviewable: passes at the head. golangci-lint (go1.26 build) reports0 issues, and every package passes.TestStateURL*), 10 times each;internal/store/...,internal/authandinternal/server, 5 times.make reviewablepasses, andgit statusstays clean after building./cmd/factoryand./cmd/factory-agentat the repo root.servetests.go test -race -count=10 -run 'TestServeSharedState|TestServeShutdownWithIdleConnection|TestServeStateURLRefusals|TestServeInstallationsOnEveryInstance' ./cmd/factory/passes.go test -race -count=10 -run 'TestSched|TestLeader|TestOverlap|TestWorkItemFor|TestResync|TestIntake' ./internal/scheduler/ ./internal/controllers/passes. Both packages also passed whole, 10 times, with-race, before the decisions' changes.go test -race -count=5 ./internal/store/...passes, withstoretest's newCRUD/DeleteIfUnchangedon memory, the file store and the object store (fake andfile://buckets), andTestTwoStoresDeleteIfUnchanged(a stale store's delete of a recreated object isErrConflict).TestSchedLockTwoInstancesConcurrencyOne"leased 2 tasks";linkedWorkItem's sync:TestWorkItemForSyncsOnAnEmptyLookup;TestServeRecoversFromBeforeItsStart;TestServeShutdownWithIdleConnection;TestServeSharedState;TestOverlap*;TestIntakeRedeliveryRacesTheDelete("the redelivery after the stale delete: ");TestServeInstallationsOnEveryInstance(B's warnings never appear behind the leader gate).TestServeSharedStateruns twoserves on onefile://bucket, plus a third that lags (sync 1 h):Succeededseen from A, and only the leader normalising;/streamresumed on the lagging one with A'sLast-Event-ID;sched_test.go):concurrency: 1, each behind, lease exactly one task, including a second round past the busy window;Refreshruns on the leader only.TestServeStateURLRefusals:--state-url file://on a P2 dir,--state-diron a bucket, and an unusable URL, none printing the URL;TestParseServeConfig: no store, exclusive flags,--state-urlwithout a session key, and the previous key.Only a real cloud bucket can check: the leader lock's and the
schedlock's timings against GCS's and S3's latencies, which are tested here onfile://and the fake bucket only. The emulator job from PR 2 doesn't runserve. Twoserves on Cloud Run or Lambda, with CPU throttled between requests, are IMPROVEMENTS item 9's.Size
This PR is over the 1,000-line rule, and the spec says it would be: +3,728 / −608 across 72 files. That is code +1,633 / −407, tests and goldens +2,006 / −153, and docs +89 / −48 (
.gitignorecounted as code). It carries thesize-exceptionlabel: P3 §Rollout PR 5 is one checklist, and its pieces (the leader, the gate, the per-process state and the rebuild's removal) only make a working multi-dispatcherservetogether. Over half of the added lines are tests.Review fixes (ccf-review 5681190143, 9787478)
internal/scheduler/lease.go). A candidate that fails the re-check underschednow ends the pass with a new sentinel,errSchedStale. It is handled likeerrSchedBusy, but sets no busy window, and the lock's sync has already woken the parked poll. So a pass mints at most once and takes the lock at most once.TestSchedLockStalePassMintsOnce(concurrency 1, 5 pending tasks, a stale instance polling once) asserts 1 mint and 1 lock round trip, then nothing on the next pass. With the old behaviour it fails at 5 mints and 10 writes.factory state --state-urlclosed the bucket before reading through it (cmd/factory/state.go). The bucket now stays open on thestateRun, andrunStatecloses it after the subcommand.TestStateURLBucketLifetimeuses a bucket that fails on use afterClose.internal/auth/oauth.go).FinishLoginnow checks the code and exchanges it before it creates theUsedLoginState.TestFinishLoginWritesNothingWithoutACodechecks that an empty or refused code writes nothing.Whileterm outlived the local lease (internal/controllers/leader.go). A watcher now cancels the term's context at the local lease end, with a timer re-armed on each renewal.TestLeaderPausedPastItsLeaseis extended to cover it.TestConnTrackerGrace).returnTois capped at 1 KB (auth.MaxReturnTo), so the state cookie stays small (TestSafeReturnTo).Decisions (Gustavo, 2026-10-28)
internal/controllers/provider.go,deleteDelivery). The store gains an optionalstore.UnchangedDeleter(DeleteIfUnchanged(ctx, k, resourceVersion)) with the helperstore.DeleteIfUnchanged, which falls back toDelete. Memory implements it over its existingDeleteAt, the file store through its journal, and the object store through the log (its internaldeleteAt). TheStoreinterface doesn't change. OnErrConflictthe delivery is kept and enqueued. Tests:storetestCRUD/DeleteIfUnchangedon all three stores,TestTwoStoresDeleteIfUnchanged, andTestIntakeRedeliveryRacesTheDelete(the redelivery survives the stale delete and is normalised by its own reconcile).getCatalog's installation warnings (Installations.Gaps) are now the same on every instance. Test:TestServeInstallationsOnEveryInstance(a non-leader'sgetCatalogshows them), using a new test-onlyserveDeps.WrapAdapter.feat(serve)!: …, with the BREAKING CHANGE paragraph at the top.design/README.md,06-technology-options.md,09-open-questions.md,10-poc.md,rules-engine.mdandrollout.md.github-provider.mdstays for PR 6.objectstore.Read/Viewforfactory state --state-urlis accepted, and kept. It is the only change tointernal/store/objectstorebeyondOpen, apart from decision 1's exportedDeleteIfUnchanged:view.gowith its tests, plus an unexportedcollectLogflag (log.go,objectstore.go) that keeps the log entries a load applies.\.Projections\b) matches the fake's accessorgh.Projections(), which RT's tests now use and PR 6 keeps. Those hits are expected.P3 on the design branch records decisions 1 and 2.
Deviations from the spec
--state-memoryinTestParseServeConfig. Each case without a store flag gets--state-memoryfrom the test loop (abarefield opts a case out), rather than being edited case by case. Each case keeps its own expected error, and newbarecases cover the refusal.servestarts without callingConn.List" is shown by the code's removal: the call is gone. There is no runtime assertion, because one would needfake.MethodList, which PR 6 removes from FND's fake./readyzwith a catalog not loaded answerscatalog-not-loaded, the code PR 4's contract names, instead ofunavailable.--state-sync-intervalis a new flag. P3 §Reads names it, and the objectstore package docs refer to it.Open questions for reviewers
None: Gustavo's decisions above settle them.
🤖 Generated with Claude Code