Skip to content

feat(serve)!: --state-url, one leader, and no rebuild from markers - #246

Merged
gusfcarvalho merged 14 commits into
fx/imp5b-4-readyz-contractfrom
fx/imp5b-5-serve-state-url
Oct 28, 2026
Merged

gusfcarvalho merged 14 commits into
fx/imp5b-4-readyz-contractfrom
fx/imp5b-5-serve-state-url

Conversation

@ccf-lisa

@ccf-lisa ccf-lisa Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Task: IMPROVEMENTS item 5(b), stack 5 PR 5 of 8: fx/imp5b-5-serve-state-url on fx/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. .gitignore now covers the repo-root /factory and /factory-agent (536bd87).

BREAKING CHANGE: factory serve needs a durable store. Without --state-dir or --state-url it 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 serve and factory state), plus the design docs the spec names.

Summary

This PR implements P3 §Rollout PR 5:

  • §Several dispatchers: who runs what, including per-process state, OAuth, /stream, LinkObservations and recovery;
  • §Locks and leases: the sched lock;
  • §Reads and freshness: workItemFor's empty lookup, tested across two stores;
  • §Two releases on one log: readiness, and the 503 on writes;
  • §No rebuild from markers, except Conn.List and Observe's parsing, which are PR 6's;
  • §Backends: the serve flags and the refusal;
  • the known limit, and Decision 7 (the reset messages).

Flags and readiness

  • serve --state-url gs://…|s3://…|file:///… opens the bucket with objectstore.ParseURL and the store with objectstore.Open. It registers internalKinds and --state-sync-interval (default 2 s).
  • --state-url is exclusive with --state-dir. The URL is never logged: the start-up line names its scheme only.
  • A durable store is required. Without --state-dir or --state-url, serve refuses to start, unless it has --state-memory or --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.
  • Session keys. --state-url without FACTORY_SESSION_KEY is refused, and the error says why. FACTORY_SESSION_KEY_PREVIOUS (at least 32 bytes) is accepted for verifying.
  • /readyz and factory_ready follow PR 4's contract. The server checks any store that has Ready() (server.StoreReadiness), and server.Options.Ready (the rebuild's wait) is gone. A store that stopped writing answers 503 store-read-only; one not synced, or past MaxLag, answers 503 unavailable. A catalog that isn't loaded is now catalog-not-loaded, the code the contract names; it was unavailable.
  • CodeStoreReadOnly = "store-read-only": toProblem maps store.ErrReadOnly to a 503 with no Retry-After.

The leader

  • controllers.Leader holds the leader lock: 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 reconciles (provider, rule, debounce, work-item, run, and the scheduler's lease controller) are gated with the new Controller.SetGate(leader), which is checked before each reconcile. Informers run everywhere, so the queues are full at a takeover. Controller.Run can be called only once, so gating is used instead of restarting controllers.
  • The sweeper, the other leader-only loop, runs per term through Leader.While. The scheduler's Refresh is skipped by the tick on non-leaders (scheduler.Options.Leader); the tick's wake of parked polls runs everywhere.
  • Every instance serves the API, runs the tick, checks the App installations (reads only; each instance's getCatalog shows its own result, Decision 2), and runs Recover at its start with since = process start − 10 s.
  • Release. At shutdown, the lock is released once the loops have stopped and before the store closes, so another instance takes over at its next try.

Per-process state

  • OAuth state is self-contained: <payload>.<HMAC(session key, "factory-oauth-state\0" + payload)>, where the payload is {id, exp, rt}.
    • It is single-use through a UsedLoginState create named uls_<sha256(id)>. A second use, on any instance, is 400 oauth-state-mismatch.
    • The leader's sweep deletes the record once the state has expired.
    • MaxPendingLogins and the pending map are gone.
    • Session cookies verify under the previous key too.
  • /stream: a Last-Event-ID ahead of the store syncs and is tried once more, then starts from now.
  • Resync baselines are stored in LinkObservations, one record per work item: {digests: {"<provider>/<ref>": sha256}}.
    • A record is written only when the digests change: a create, or a CAS.
    • It is deleted when the item goes (found gone, or retired). A removed link's entry goes at the next resync, and the leader's sweep deletes orphans.
    • A new leader's first resync of an item that has stored baselines is jittered uniformly over the interval.
    • observationDigest drops the projections JSON key rather than naming the Go field, so PR 6 can delete Observation.Projections without 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, since LinkObservations is new in this release.
  • New internal kinds LinkObservations and UsedLoginState are in:

Scheduler: the sched lock (P3 §Locks and leases)

  • Mint before the lock. The credentials are minted first. Then the commit takes sched with a 2 s context (Options.SchedWait) and a 30 s TTL (SchedTTL).
  • Busy lock. A timeout is errSchedBusy. It ends the pass with no grant and sets schedBusyUntil = now + 2 s. A pass that starts before then returns at once, with no wait and no mint.
  • The section runs under a 15 s deadline. It reloads the snapshot and re-checks the candidate: still pending, its agent still there, and every matching row passing (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.List stays in the interface; PR 6 removes it.
  • TestRestartRebuildsFromProjections is gone, and TestServeNormalisesAfterRebuild became TestServeRecoversFromBeforeItsStart.
  • The RT and cmd tests that read 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-url

  • factory state reset's prompt and output, its golden and README §6 and §11 now say what is lost and how to recover:
    • open items restart with no state and no run counters (the run cap included), and no stored event survives to replay;
    • to recover, redeliver from the App's delivery log (about 3 days), reopen issues or re-apply labels.
  • factory state --state-url URL is read-only, as --state-dir is. It uses the new objectstore.Read (a View: no sync, no lock, no write), and every read subcommand works on it. reset --state-url is refused, and says how a bucket is reset.

Shutdown

  • Connections that never sent a request. Once a listener stops (Serve returns ErrServerClosed), its connections still in StateNew are closed. They are tracked with ConnState. TestServeShutdownWithIdleConnection covers 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.
  • The bucket is closed after the store (sharedStore.Close), when it is an io.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() and LinkObject re-read and retry), a conditional delete at the version read (store.DeleteIfUnchanged), or runs under the store-wide wi/<id> lock, except the deletes marked delete. A second delete of those finds nothing (ErrNotFound ignored), and each removes a record whose purpose is over. Four gaps were fixed in this PR (fixed).

Loop Write or provider call Guard
Recovery (every instance, at start) Conn.Recover: lists the provider's deliveries read
Accept: Create Delivery named by the delivery id create (a duplicate is ErrDuplicateDelivery)
Provider controller (normalise) Conn.Normalise provider read, no write
Create Event (deterministic id) create
Update Delivery (normalised / failed) CAS
Delete Delivery with no stored event; delete after its retention Fixed (Decision 1): conditional delete at the version read (store.DeleteIfUnchanged); a redelivery under the same id since is kept and normalised (TestIntakeRedeliveryRacesTheDelete)
Rule controller Create WorkItem (deterministic id), Create EventStatus create
Delete EventStatus of an aged-out Event delete
Conn.Observe (observeItem) provider read
Update WorkItem (decisions, state, links), Create Task/TaskPrompt (deterministic, looked up first), Create DebounceRecord wi lock + CAS / create. Fixed: EventStatus is read again under the lock, so a second leader whose reconcile saw the event unprocessed applies nothing (TestOverlapRuleEventProcessedOnce)
Conn.Apply (notify, effects), then Create Effect wi lock + the provider's idempotency key (IdempotencyKey)
Debounce controller Update WorkItem, Apply, tasks (as the rule controller) wi lock. Fixed: the record is read again and deleted under the lock; it was deleted after the lock was released, so a second leader could fire it twice (TestOverlapDebounceFiresOnce)
Work-item controller Conn.Project Fixed: under the wi lock, on the item read again there, so an overlapping leader can't write an older projection over a newer one (TestOverlapProjectionUnderTheLock)
Conn.Observe (cache Refresh) provider read
Create factory.workitem.resynced Event (key resync/<wi>/<digest>) create
Create / Update LinkObservations create / CAS
Delete LinkObservations (item gone or retired) delete (the next resync records a fresh baseline)
Retire: Delete WorkItem after a re-check wi lock
Run controller Create Run (named per attempt) create (ErrAlreadyExists reads the existing one)
Update Run, Task; count (WorkItem counters) CAS; a finish holds the wi lock, and a retry that finds the run ended skips the count
Create factory.run.completed/failed Event (key run/<run>) create
Scheduler lease controller expire (Update Task), endLease (Update Lease), AckCancel CAS (each re-checks its condition)
Delete an ended Lease after LeaseRetention delete
Scheduler Refresh Delete expired AgentToken delete
syncAgent (Update Agent), setReason (Update Task) CAS
Sweeper store Sweep: retention deletes delete (by retention rule)
object store: compaction, lock records, log and snapshot retention, transcript orphans create (snapshot, create-if-absent) / conditional delete (deleteAt) / bucket deletes
Delete expired UsedLoginState, orphan LinkObservations delete
Installation check (every instance, Decision 2) the providers' installation lookups provider read; each instance keeps its own result

Lease 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) reports 0 issues, and every package passes.
  • After the review fixes (9787478): the race suites were re-run and pass:
    • the serve tests (shared state, idle connection, refusals, installations, connection grace, TestStateURL*), 10 times each;
    • the scheduler and controllers suites, 10 times;
    • internal/store/..., internal/auth and internal/server, 5 times.
  • At the head (536bd87): make reviewable passes, and git status stays clean after building ./cmd/factory and ./cmd/factory-agent at the repo root.
  • The two-serve tests. go test -race -count=10 -run 'TestServeSharedState|TestServeShutdownWithIdleConnection|TestServeStateURLRefusals|TestServeInstallationsOnEveryInstance' ./cmd/factory/ passes.
  • The scheduler, leader, overlap and intake tests. 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.
  • The stores. go test -race -count=5 ./internal/store/... passes, with storetest's new CRUD/DeleteIfUnchanged on memory, the file store and the object store (fake and file:// buckets), and TestTwoStoresDeleteIfUnchanged (a stale store's delete of a recreated object is ErrConflict).
  • Mutation checks. Each of these fails when its fix is reverted:
    • the sched lock's re-check: TestSchedLockTwoInstancesConcurrencyOne "leased 2 tasks";
    • linkedWorkItem's sync: TestWorkItemForSyncsOnAnEmptyLookup;
    • the recovery slack: TestServeRecoversFromBeforeItsStart;
    • closing the connections that never sent a request: TestServeShutdownWithIdleConnection;
    • the stream's sync: TestServeSharedState;
    • the rule and debounce re-checks: TestOverlap*;
    • the conditional delivery delete: TestIntakeRedeliveryRacesTheDelete ("the redelivery after the stale delete: ");
    • the installation check on every instance: TestServeInstallationsOnEveryInstance (B's warnings never appear behind the leader gate).
  • TestServeSharedState runs two serves on one file:// bucket, plus a third that lags (sync 1 h):
    • one leader at a time;
    • a webhook on A, then a lease, heartbeat and complete on B, with the task Succeeded seen from A, and only the leader normalising;
    • a /stream resumed on the lagging one with A's Last-Event-ID;
    • a login started on A, finished on B with the shared key, the session used on A, and the state's second use refused;
    • a change on GitHub while A leads, then A stops and releases the lock, B takes over, and B's first resync reports the change.
  • Scheduler, two object stores on one bucket (sched_test.go):
    • two schedulers with concurrency: 1, each behind, lease exactly one task, including a second round past the busy window;
    • a dead holder: one mint and one wait in the first pass; passes within 2 s neither mint nor wait; after the TTL, the task is leased once;
    • Refresh runs on the leader only.
  • Refusals:
    • TestServeStateURLRefusals: --state-url file:// on a P2 dir, --state-dir on a bucket, and an unusable URL, none printing the URL;
    • TestParseServeConfig: no store, exclusive flags, --state-url without a session key, and the previous key.

Only a real cloud bucket can check: the leader lock's and the sched lock's timings against GCS's and S3's latencies, which are tested here on file:// and the fake bucket only. The emulator job from PR 2 doesn't run serve. Two serves 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 (.gitignore counted as code). It carries the size-exception label: 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-dispatcher serve together. Over half of the added lines are tests.

Review fixes (ccf-review 5681190143, 9787478)

  1. Must-fix: a stale pass minted once per pending task (internal/scheduler/lease.go). A candidate that fails the re-check under sched now ends the pass with a new sentinel, errSchedStale. It is handled like errSchedBusy, 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.
  2. factory state --state-url closed the bucket before reading through it (cmd/factory/state.go). The bucket now stays open on the stateRun, and runState closes it after the subcommand. TestStateURLBucketLifetime uses a bucket that fails on use after Close.
  3. The OAuth callback wrote before checking the code (internal/auth/oauth.go). FinishLogin now checks the code and exchanges it before it creates the UsedLoginState. TestFinishLoginWritesNothingWithoutACode checks that an empty or refused code writes nothing.
  4. A While term 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. TestLeaderPausedPastItsLease is extended to cover it.
  5. Nits:
    • A connection that hasn't sent a request gets a 1 s grace before shutdown closes it (TestConnTrackerGrace).
    • returnTo is capped at 1 KB (auth.MaxReturnTo), so the state cookie stays small (TestSafeReturnTo).

Decisions (Gustavo, 2026-10-28)

  1. The delivery delete and a redelivery: fixed here. A provider-controller reconcile on an overlapping (paused) leader could delete a Delivery that a redelivery re-created with the same id after the other leader had deleted it. The provider controller now deletes a Delivery only at the resourceVersion it read (internal/controllers/provider.go, deleteDelivery). The store gains an optional store.UnchangedDeleter (DeleteIfUnchanged(ctx, k, resourceVersion)) with the helper store.DeleteIfUnchanged, which falls back to Delete. Memory implements it over its existing DeleteAt, the file store through its journal, and the object store through the log (its internal deleteAt). The Store interface doesn't change. On ErrConflict the delivery is kept and enqueued. Tests: storetest CRUD/DeleteIfUnchanged on all three stores, TestTwoStoresDeleteIfUnchanged, and TestIntakeRedeliveryRacesTheDelete (the redelivery survives the stale delete and is normalised by its own reconcile).
  2. The installation check runs on every instance. It only reads GitHub, and each instance keeps its own result, so it is off the leader gate. getCatalog's installation warnings (Installations.Gaps) are now the same on every instance. Test: TestServeInstallationsOnEveryInstance (a non-leader's getCatalog shows them), using a new test-only serveDeps.WrapAdapter.
  3. The title is breaking: feat(serve)!: …, with the BREAKING CHANGE paragraph at the top.
  4. Rebuild mentions outside the spec's list are updated here: design/README.md, 06-technology-options.md, 09-open-questions.md, 10-poc.md, rules-engine.md and rollout.md. github-provider.md stays for PR 6.
  5. objectstore.Read/View for factory state --state-url is accepted, and kept. It is the only change to internal/store/objectstore beyond Open, apart from decision 1's exported DeleteIfUnchanged: view.go with its tests, plus an unexported collectLog flag (log.go, objectstore.go) that keeps the log entries a load applies.
  6. Merge with squash, so no history rewrite: see the note at the top.
  7. PR 6's verification grep (\.Projections\b) matches the fake's accessor gh.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-memory in TestParseServeConfig. Each case without a store flag gets --state-memory from the test loop (a bare field opts a case out), rather than being edited case by case. Each case keeps its own expected error, and new bare cases cover the refusal.
  • "serve starts without calling Conn.List" is shown by the code's removal: the call is gone. There is no runtime assertion, because one would need fake.MethodList, which PR 6 removes from FND's fake.
  • /readyz with a catalog not loaded answers catalog-not-loaded, the code PR 4's contract names, instead of unavailable.
  • --state-sync-interval is 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

ccf-lisa Bot and others added 3 commits October 28, 2026 07:00
- 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>
@ccf-lisa

ccf-lisa Bot commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

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 sched lock, the per-process state moved into the store or signed tokens, and the rebuild's removal together, and the two-serve test exercises them together. About half the diff is tests and goldens.

Reading order: cmd/factory/serve*.go (flags, store opening, readiness, shutdown) → internal/controllers/leader.go and the gates → internal/scheduler (the sched lock) → internal/auth (signed OAuth state, UsedLoginState) → internal/server/stream.go → internal/controllers/workitem.go (LinkObservations, no Rebuild) → the reset messages → the tests (serve_shared_test.go, twostore_test.go, overlap_test.go, sched_test.go).

ccf-lisa Bot and others added 2 commits October 28, 2026 07:18
…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>
@ccf-lisa ccf-lisa Bot changed the title feat(serve): --state-url, one leader, and no rebuild from markers feat(serve)!: --state-url, one leader, and no rebuild from markers Oct 28, 2026

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ccf-review: REQUEST_CHANGES

1 Must-fix, 3 Should-fix.

Stack (base-chain): #223 → #239 → #238 → #244 → #236 → #234 → #246

Comment thread internal/scheduler/lease.go Resolved
Comment thread cmd/factory/state.go Resolved
Comment thread internal/auth/oauth.go Resolved
Comment thread internal/controllers/leader.go Resolved
ccf-lisa Bot and others added 3 commits October 28, 2026 07:37
…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>

@gusfcarvalho gusfcarvalho left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
gusfcarvalho added a commit that referenced this pull request Oct 28, 2026
…s deleted at the version read

Gustavo's decisions on #246's triage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ccf-lisa
ccf-lisa Bot added this pull request to stack #245 October 28, 2026 11:58
ccf-lisa Bot and others added 2 commits October 28, 2026 09:07
… 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>

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ccf-review: REQUEST_CHANGES

1 Must-fix.

Stack (gh stack 245): #223 → #239 → #238 → #244 → #236 → #234 → #246 → #247 → #237

Comment thread internal/controllers/run.go Resolved
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>

@gusfcarvalho gusfcarvalho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ccf-review: REQUEST_CHANGES

1 Must-fix.

Stack (gh stack 245): #223 → #239 → #238 → #244 → #236 → #234 → #246 → #247 → #237

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 endTask attempt/agent check (line 700). Suppose the task is leased again before any reconcile closes -1. LeaseIssued's ensureRun then reuses the open -1, so endRun ends it byThis and the finishLocked check never runs. With line 700 replaced by if false {, a hookStore probe gets Complete = Succeeded, <nil>. The probe's beforeLock sets the task Pending, Attempts++, then e.lease(id), with no Reconcile. The probe then shows the re-leased task Succeeded and one run.completed.
  • The finishLocked check (line 605). Suppose a throttled Abandon, which spends no attempt and closes -1 as abandoned, and then a new lease for the same agent. Attempt and agent both match, so endTask passes. With line 605 replaced by if false {, the probe (beforeLock: e.run.Abandon(.., Abandonment{Reason: AbandonThrottled}), then e.lease(id)) gets Complete = Succeeded, <nil> and one run.completed for 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.
@gusfcarvalho
gusfcarvalho merged commit 74541bb into main Oct 28, 2026
29 checks passed
gusfcarvalho added a commit that referenced this pull request Oct 28, 2026
…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>
gusfcarvalho added a commit that referenced this pull request Oct 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant