Skip to content

feat(coil): loops — a durable supervisor for unattended threads - #133

Merged
radroid merged 28 commits into
mainfrom
coil/loops-backend
Sep 3, 2026
Merged

feat(coil): loops — a durable supervisor for unattended threads#133
radroid merged 28 commits into
mainfrom
coil/loops-backend

Conversation

@radroid

@radroid radroid commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Implements the loops design (PR #120, docs/coil/loops-v2/) — phases 1 through 5 — and closes the review findings in #125. Phase 0 landed separately as #131.

Problem. A thread left to work overnight stalls silently: a turn ends while background work is still going, a scheduled wake armed by the agent is lost to a server restart, or one correctly-asked question at 01:00 blocks the turn until someone answers at 09:00. Nothing durable restarts the run, bounds what it may spend, or collects what the human needs to decide.

What this adds (fork-owned, apps/server/src/coil/loop/, apps/server/src/mcp/toolkits/loop/, apps/web/src/coil/):

  • The durable record (coil-loop.json, atomic writes, every field with a fail-closed decoding default) and the Claude Stop/SubagentStop hooks that record the agent's own session_crons, with a fork-owned 5-field cron parser and a hook wrapper that can never break the turn.
  • A pure decision table and guard chain (100% branch coverage, measured): trigger on updatedAt staleness, never session.status; defer to a recorded wake while it is at or before the loop's deadline (grace inclusive); stop conditions (deadline, budget, done signal, strikes, takeover) evaluated before the idle guards so a busy self-paced run still stops at its deadline; spent is never reported as done; the master toggle is a guard, not a lifecycle.
  • The reactor: a tick fiber with boot grace and zero SQL when nothing is armed, a rate-limit tap reusing auto-resume's classifier, and fork-side recording of user-input.requested so a question voided by session teardown stays visible. Three firing disciplines: reserve the check-in before dispatch, re-read the shell before dispatch, repair the keep-active pin only when it was already there. Bounds stop a self-paced run via stopSession.
  • The HTTP API: arm / re-arm / edit / disarm with mandatory bounds (400, never clamp), pin on arm and unpin only when the loop pinned, machine settings, the armed roster, and a blocker answer route.
  • The console as an overlay on the thread route (transcript stays the default view): blocking items, deferred blockers, loop state and bounds, an iteration ledger of derived facts, a useful empty state, and a named degraded state when agent browser access is off. Polls 30s and on focus; no continuously repainting UI; no hardcoded origin; no client-side scope check.
  • Settings → Loops: master toggle, defaults, the armed roster.
  • The MCP toolkit: raise_blocker (returns immediately, per-window cap reported back rather than dropped), loop_status (never errors), loop_done (ends the loop as done). Attribution from McpInvocationContext, never an argument.

Seams. Six upstream-owned files, five new ledger rows, +47/−3 in total: ClaudeAdapter.ts +3 (the hooks spread), settingsSearch.ts +14, SettingsSidebarNav.tsx +2, routeTree.gen.ts +21 (generated), McpHttpServer.ts +5/−1 (the one displacing line: a second export const layer would shadow the first), and the thread route's overlay element swapped for a fork aggregator at an unchanged +10/−6. docs/coil/SEAMS.md re-measured: 58 files, +2654/−982. Zero packages/contracts edits.

Docs. docs/user/loops.md (unlinked from the docs index on purpose — linking it would open a new prose seam row); the design docs resolved per #125 with every [A] claim the build depended on flipped to [V].

Verified in a worktree: server vp test run src/coil src/mcp src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts → 802 passed; web vp test run src/coil src/components/settings → 234 passed; tsgo --noEmit clean in both; lint clean on every touched directory; vp fmt --check clean. Not done: a pass in a real client — the console pill and the auto-resume capsule should be eyeballed together on a narrow window. Mobile surfacing is deferred per the plan (no mobile seam row taken).

Closes #125. Refs #42, #38.

Work done by Claude Fable 5.1 orchestrating Claude Opus 5 subagents in Claude Code.

radroid and others added 23 commits September 2, 2026 13:29
…sumptions

Issue #125 listed twelve findings against the Loops design package, all of
which had to land before Phase 2 is built from the plan. This resolves every
one and re-verifies the assumptions the build depends on against the tree.

Four of the fixes are latent bugs rather than contradictions. `deadlineAtMs`
is now mandatory and non-nullable (the old null branch in guard 10b meant *no
deference at all*, so a deadline-less loop fired on top of a healthy
self-pacing thread). Stop conditions move from guard 13 to guard 4b, ahead of
every non-consuming skip, because they were only evaluated on a tick that had
already decided the thread was idle — so a busy self-paced run walked past its
own deadline. Guard 5 is retired: upstream pingdotgg#8600 made settlement a server-side
sweep with no provenance marker, the same correction autoResume/guards.ts
already made after a timer destroyed an armed week-long resume. And
`thread.pin` turns out to emit companion unsettled/unsnoozed events, so arming
a snoozed thread would have cancelled the snooze.

Costs are re-measured against merge-base 941acb4, and the result inverts the
plan's intuition: Phase 5 falls from "0-3 rows [A]" to one row at risk 6, while
Phase 4's settings entry rises to ~350 of the feature's ~411 total risk. The
master toggle gained a data model and routes and moved from Phase 4 to Phase 2,
since Phases 2 and 3 were otherwise unswitchable as ordered.

build-report.mjs now asserts the prototype count in both directions. The old
guard only fired at zero matches, so one drifted marker emitted a report
missing a frame — invisible in the browser and in a 15k-line diff. Reproduced
and fixed: a `>` in a hint now throws "inlined 7 prototypes but prototypes/
holds 8".

TESTS.md goes 162 -> 183 cases, each addition marked [#125].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1 foundation)

Three new fork-owned files under apps/server/src/coil/loop/, mirroring the
proven autoResume module. Nothing acts yet: this is the state everything else
reads.

state.ts — LoopRecord, LoopGlobalSettings and the durable LoopStore
(SynchronizedRef + atomic write + the four-way rehydrate, unreadable meaning
persistence OFF). Every field carries a decoding default and every default is
the fail-closed reading, because a missing required key fails the whole-file
decode and the boot path turns that into EMPTY_STATE — which would silently
disarm every loop on the machine. A schema-reflective test drops each field in
turn, so adding one without a default fails the suite.

config.ts — resolveConfig(env) over COIL_LOOP_*, the derived wake grace, and
check-in prompt composition. Prompt and sentinel roots resolve
worktreePath ?? workspaceRoot, worktree FIRST: autoResume has this inverted, and
copying it would put the supervisor and the agent in different directories on
every worktree-backed thread.

cron/parse.ts — a ~250-line parser for exactly the producer's 5-field grammar,
no dependency. Wall-clock evaluation makes it DST-correct (the spring-forward
gap is skipped, the fall-back repeat resolves to its first occurrence), and
anything outside the grammar yields null, which means no deference from that
entry rather than deferring forever.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g questions (Phase 1)

`cron_durable` is false, so the provider's cron table is in-process: a wake lost
to a restart leaves no trace anywhere. T3's store is the trace. The `Stop` /
`SubagentStop` hooks read `session_crons`, the fork parses each 5-field schedule
into a `nextFireAtMs` at the moment it is observed (a one-shot re-parsed after it
fires resolves to next year), and the snapshot is persisted. `[]` clears the
record — the agent stopped self-pacing — while an absent field leaves it
untouched; getting that backwards would silently retire deference. A
`PostToolUse` probe on `ScheduleWakeup` records `gate_off` as a substring, never
a parse, so a probe that finds nothing behaves exactly like no probe.

A Stop hook can halt a turn, so "observability only" is a property the code
holds: every callback catches every failure and defect, logs at debug, and
always resolves to `{ continue: true }`, with a timeout on each matcher.

Also records `user-input.requested` fork-side, because upstream pingdotgg#5127 settles
pending inputs as an empty answer during teardown — a question nobody saw is
otherwise indistinguishable from an answered one. Empty resolutions are recorded
as `voided`, and the pingdotgg#8144 resume dialog is recognised through the shared
predicate the web client already uses, so the console can name what it is
waiting on.

The one upstream edit is 3 additive lines in `ClaudeAdapter.ts`. `loopHooksFor`
reads `LoopStore` through `Effect.serviceOption`, so the adapter's layer
requirements do not widen and no second seam edit appears in `server.ts`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The whole loop decision table, with no server, clock or provider anywhere
in it: `decide(input)` returns one action, `evaluateGuards(input)` returns
the first thing that blocks, and both read plain values.

Two orderings are the product and both are asserted. Guard 4b (the stop
sweep) runs before every non-consuming skip, so a self-paced run stops at
its deadline while the thread is busy and a done-file written mid-work is
honoured immediately; it still runs after guard 4, and guard 2 still runs
before both, so a deleted thread disarms and the master toggle never
manufactures a terminal state. Guard 5 is retired and the module says why.

Deference is bounded by the run's own deadline, its grace is derived from
the recorded entry rather than a constant, and both boundaries are
inclusive. `session.status` and `backgroundLiveness` only lengthen the
fuse; neither can veto a fire.

223 tests across the loop module, covering TESTS.md cases 1-46 by number
plus the table-driven invariant that no stand-down touches the budget.
Branch coverage on both new files is 100%, measured with a V8 block
coverage harness (@vitest/coverage-v8 is not installed in this worktree).

Model: Claude Fable 5.1 via Claude Code.
`sentinel.ts` stats `<root>/.coil/loop-done` across `worktreePath ?? workspaceRoot`,
worktree first, newest mtime wins, honoured only when it is newer than `armedAtMs`.
Read-only by construction, and every filesystem failure resolves to "no sentinel"
rather than a defect in the tick fiber.

`http.ts` ships the loop API: the per-thread read, arm/rearm/edit/disarm, the loops
list and the master-toggle settings pair. Nothing is clamped — every out-of-range
bound is a 400 with its own code, and no refusal touches the durable record. Arming
pins only when the thread was not already pinned, and `pinnedByLoop` gates the unpin
so disarming never removes a pin the user set themselves; arming a snoozed thread is
`400 thread_snoozed` rather than a silently cancelled snooze.

`POST /api/coil/loop/answer` is left as a documented TODO for phase 3, where the
console can supply the question ids the native resolve path needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Opening a loop thread in the morning answered nothing: the state lived in
coil-loop.json and the only way to read it was the HTTP route. This adds the
console — the standing answer to "what do you need from me?" — as an overlay
above the composer, plus the one route it needs to answer a deferred blocker.

The seam cost is zero. `_chat.$environmentId.$threadId.tsx` swaps one JSX
element and one import for `<ThreadCoilOverlay>`, a fork-owned aggregator, so
the row is still +10/-6 against the merge-base and every future per-thread fork
surface is now free.

The console shows what stopped the loop, what it worked around, what was never
answered, the bounds, and the check-in ledger — and, when there is nothing
waiting, still says what happened, what it consumed and when it last moved.
`spent` is never rendered as success, and that distinction is pinned by tests
rather than by styling. With agent browser access off, the deferred section
renders a named degraded state instead of an empty list, because a missing
question channel that looks like "no questions" is the exact failure the
channel exists to prevent.

Two deliberate refusals worth stating. The blocking section does not mount the
native `AskUserQuestion` card: it is already rendered live by
`ComposerPendingUserInputPanel` fifty pixels below, so a second instance would
duplicate the card and register a second document-level 1-9 keydown handler.
The console names it and points there, leaving upstream's answer path with one
instance of itself on the page. `POST /api/coil/loop/answer` therefore ships
the blocker half only, with an explicit `blockerId` rather than §9's
polymorphic `id`.

`useComposerAnchor` moves out of `AutoResumeOverlay.tsx` into
`coil/composerAnchor.ts` unchanged, so the two overlays share one measurement
instead of two copies of it.

No spinners and no per-second repaints: deadlines are absolute, ages are
computed once per poll, and the panel states when it last heard from the
server.

Model: Claude Opus 5. Harness: T3 Code.
The master toggle shipped in phase 2 with no way to flip it. This adds the
Settings section that owns it, the defaults a newly armed loop starts from, and
the roster answering "did any of my runs give up overnight?" from one page
rather than three threads.

The help text is the point of the section. The toggle is a **guard, not a
lifecycle**: with it off nothing fires, every armed loop stands down keeping its
budget, its deadline and its arm, and nothing is disarmed or stopped. The same
rule covers the ceiling — lowering it below the number armed stands the excess
down rather than disarming them. Wording it as an on/off for loops themselves
would teach people that flipping it cancels their overnight runs, which is
exactly what it does not do.

The panel is fork-owned and reads and writes `/api/coil/loop/settings`, so
neither `SettingsPanels.tsx` (churn 43) nor `packages/contracts/src/settings.ts`
(churn 38, persisted) is touched. Loop state stays in coil-loop.json.

Two new seam rows, both fully additive and both type-forced rather than
stylistic — `SETTINGS_SECTION_LABELS` and `SETTINGS_SECTION_ICONS` are both
`Readonly<Record<SettingsPath, …>>`, so a union member without both entries is a
type error. The search catalog also buys the command palette for free, since
`CommandPalette.tsx` builds its settings results from `searchSettings` over the
same catalog; no palette or keybinding row is needed.

The ledger is brought back in step with the tree it now describes: 53 files
+2609/-981 → 57 files +2649/-981. That is `settingsSearch.ts` +14, the sidebar
nav +2, the generated `routeTree.gen.ts` +21, and `ClaudeAdapter.ts` +3, whose
row landed in phase 1 without its header totals. Every one is +N/-0, so the
additive-seam invariant holds and the deletion count is untouched.

Model: Claude Opus 5. Harness: T3 Code.
What a loop is, how to arm one from a thread, the bounds it runs under, how to
read the console, and what the master switch in Settings → Loops does and does
not do. Also states why the "answer when you can" channel needs agent browser
access on, and why an empty list there is not evidence the agent had nothing to
ask.

The loop vocabulary goes into the fork's own plan rather than
`docs/internals/glossary.md`. That file is upstream-owned with no fork edit
today, so four terms of prose would open a new seam row against the tripwire in
SEAMS.md. `docs/README.md` is left alone for the same reason, which means the
new page is not yet linked from the docs index — a maintainer call, recorded in
the ledger rather than taken here.

Model: Claude Opus 5. Harness: T3 Code.
`AskUserQuestion` parks the turn on a `Deferred`, so an agent that hits a real
fork in the road at 01:00 and asks about it correctly stops working until
morning — and guard 8 then correctly refuses to nudge it. No tuning resolves
that; a second, non-blocking channel does.

`raise_blocker` records a question against the calling thread and returns.
`loop_status` reports the run's remaining budget. `loop_done` sets exactly what
`guards.ts` `doneSignal` reads — a `loopDoneAtMs` newer than `armedAtMs` — so
the reactor's stop sweep ends the run through the same path as the done-file,
including the terminal work a directly-written `stopped` record would skip.

Ungated (Path B): no `"loop"` `McpCapability`, no `packages/contracts` edit.
Nothing at registration or dispatch consults `capabilities`, and the real gate
is `global.enabled` plus the armed record, checked in the handlers. One seam
row, `McpHttpServer.ts` +5/-1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The supervisor that acts. Three scoped fibers self-start at layer
construction, mirroring `autoResume/Reactor.ts`: a tick that lists the
armed loops and executes whatever the pure `decide` returns, a rate-limit
tap that holds a loop out of a live usage limit (auto-resume only arms
when its own per-thread switch is on, so a thread with it off would
otherwise be nudged straight into the wall), and the user-input recorder
from `userInputs.ts`.

Two lessons copied from the neighbour rather than its bugs: the tick uses
`getThreadShellById` / `getProjectShellById` instead of `getSnapshot()`,
and issues zero queries of any kind when nothing is armed; the check-in
prompt and the done-file resolve `worktreePath ?? workspaceRoot`,
worktree first, so the agent and the supervisor agree on a directory.

The three firing disciplines are the whole of the fire path: reserve the
check-in before dispatching, so a provider that cannot spawn burns budget
instead of tight-looping; re-read the shell and re-decide against the
pre-reserve record before dispatching, so a thread that wakes inside the
tick is not nudged; and restore the keep-active pin with `thread.unsettle`
only when the pre-dispatch shell already carried it, so the repair can
never create one.

Wired into `coil/index.ts` with the store defined once at module scope and
provided to both the reactor and the routes, the same shared-identity rule
the auto-resume and Web Push stores already rely on. `loop/sharing.test.ts`
pins it, because two copies of this record over one file would show an
armed loop in the console that never checks in.

Tests: `Reactor.test.ts` (TESTS.md 87–107) and `integration.test.ts`
(128–137), on TestClock with condition-based settling and no sleeps.

Claude Opus 4.6 via Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Row 44 (`ClaudeAdapter.ts` +3/-0) was appended when the Claude hooks
landed, but the header still read the pre-loops totals. Regenerated with
the command in the file's own Regenerating section against merge-base
`941acb4f9`: 53 files / +2609 on `origin/main`, 54 / +2612 with this
branch, the difference being exactly that one row.

PLAN.md's status line said "proposed — nothing built", which stopped
being true three commits ago. Phases 0-2 are on this branch; 3-5 are
still proposed.

Claude Opus 4.6 via Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three phases landed on three branches, and each needed the same record.
The reactor and the routes were reading a `LoopStoreLive` declared in
`coil/index.ts`; the MCP toolkit registers off `mcp/McpHttpServer.ts`,
which cannot import from the coil aggregator, so it brought its own
`loop/layer.ts`. Two layer values over one JSON file, and Effect memoises
by layer identity, so the toolkit would have been writing to a store
neither the supervisor nor the console ever read: `loop_done` would report
success and the loop would keep checking in. `coil/index.ts` now imports
`layer.ts`'s value; the declaration it replaces was identical in substance
(same `coil-loop.json`, same `stateDir`), so nothing was reconciled.

`loop_done` also had no reader. The reactor was passing a hard-coded
`loopDoneAtMs: null` into the decision input with a comment saying the
tool had not shipped yet; it has, so the record's field is passed through
and `doneSignal` takes the newer of the two done channels. The reason
string the agent gives is stitched onto the terminal in the reactor rather
than the decision table, which is pure and takes a timestamp.

Ledger: the Phase 5 row for `McpHttpServer.ts` was lost resolving the
console/MCP conflict. Re-added as row 48 and the header regenerated —
58 files, +2654/-982. That `-1` is the fork's only displaced upstream
line: registering a second toolkit rewrites the terminal `export const
layer` rather than adding beside it, because a second export would shadow
the first. It is now called out in the header note as a displacement the
additive-seam invariant cannot avoid, with the instruction to re-read that
expression every sync.

Claude Opus 4.6 via Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ffef26c1b` edited three prototypes and rebuilt `report.html` from them,
but skipped the `vp fmt` half of the documented build→fmt pipeline, so
`vp fmt --check` over the branch was red on files that are correctly
formatted on `main`. Formatted the three sources, re-ran
`build-report.mjs` because the report inlines them, then formatted the
report. No content change.

Claude Opus 4.6 via Claude Code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t the decision table

loopHooksFor read LoopStore through Effect.serviceOption, which never resolves in
production: the adapter's fiber runs in upstream's layer graph, where CoilLayerLive
has already discharged the store with Layer.provide. session_crons was therefore
recorded on no real machine; only the tests, which provide the store themselves,
saw hooks at all. The supervisor now publishes its store in a fork-owned
process-level holder for the life of its scope and loopHooksFor falls back to it,
context first.

Also in the decision table: the stop sweep reads the done signal before the bounds
(a run that finished on its last check-in reported 'spent' and had its session
killed), guard 14 counts the OTHER armed loops (at exactly maxArmedThreads every
loop stood down), resolveWake skips wakes that already landed (one dropped Stop
hook and a still-pending wake was masked), tookOver treats an empty createdAtIso as
no baseline, an ambiguous fall-back wall clock resolves to the later instant, guard
6 reports 'held' like every other bounded hold, and the pre-dispatch re-read
distinguishes a projection failure from a missing thread and acts on a takeover it
observes rather than leaving it for the next tick. The Stop/SubagentStop hooks now
record only for armed threads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it holes

The routes: a re-arm no longer disowns a pin the loop itself created, disarm and
edit are refused with 409 unless the loop is armed (a stale tab could rewrite how
a night ended), a body that is not JSON gets the invalid_body code rather than a
bare empty 400, a disarm that leaves the agent's own wakes pending banks a session
stop for the supervisor to service, and 'clear' is the way to dismiss a finished
run — the reverse state the stopped pill was missing.

The toolkit: loop_done records the signal regardless of the master toggle (the
toggle gates firing, not recording, and discarding it meant re-enabling loops
resumed check-ins on a finished run), and raise_blocker on a thread with no armed
loop reports 'unavailable' instead of promising a delivery that cannot happen.

The store: blockers and recorded questions are capped at fifty per thread, and
records nothing will read again — a stopped run past its retention window, or one
carrying nothing at all — are pruned inside the same critical section that
persists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The settings panel said 'Loading…' forever with every control disabled on any
non-200 read; it now names the failure and offers a retry. The console's empty
state disappeared when a run's only questions were already answered, taking with it
the one card that explains a finished run; the browser-access warning rendered on
every thread in the app rather than on threads with a loop; a snoozed thread was
told to answer something in the composer; a finished run could not be dismissed;
and the deadline printed as a time of day, so a run armed at 23:00 read as having
ended eight hours ago.

The console also no longer renders on a thread belonging to a non-primary
environment, where every fork route would have answered for a thread id that server
has never heard of. docs/user/loops.md states the limitation, which auto-resume
shares.

SEAMS row 543 now describes the chat route as mounting <ThreadCoilOverlay>, at the
same +10/-6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@radroid

radroid commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review round (4 lenses, 25 findings verified against the code, all confirmed and fixed):

  • Blocker: the Claude Stop/SubagentStop hooks were never installed on a real server. CoilLayerLive provides LoopStore with Layer.provide, which discharges it, so the adapter's Effect.serviceOption(LoopStore) was always None; every test had used provideMerge, which hid it. Fixed fork-side with a scoped process-level holder the reactor installs (coil/loop/hooksRegistry.ts); a new test builds a server-shaped composition and asserts hooks are built with no LoopStore in context.
  • Majors: a loop finishing on its final check-in read as spent (budget swept before the done signal, and spent stops the session); a wake that had already landed masked a later pending one; the ceiling guard counted the loop under evaluation; hooks wrote the store for every Claude thread rather than armed ones; re-arming overwrote pinnedByLoop; loop_done was discarded while the toggle was off; raise_blocker promised delivery with no loop.
  • Minors: disarm-with-pending-wakes now stops the session via a request the tick services; not_armed 409s; non-JSON bodies → invalid_body; record caps and pruning; a clear reverse state for finished loops; settings error/retry state; console gating and copy fixes; dates on multi-day deadlines; the DST fall-back direction; held as the one vocabulary for snooze; boundary tests the mutation probe showed were missing.

Server: 826 tests pass; web: 250 tests pass; both typechecks and lint clean; the six upstream files are byte-identical to the previous push.

radroid and others added 5 commits September 2, 2026 20:09
…for the answer

CI failed 136c with 'timed out waiting for the covered wake'. It was not the
landed-wake fix: a probe of resolveWake with 136c's exact inputs resolves the wake
at 26, 27 and 46 minutes with landed=false, because that shell's updatedAt (5 min)
precedes the wake (25 min), so the skip never fires there.

It was the stop-request sweep. serviceStopRequests read the store a second time on
every tick, and that extra trip through the synchronized ref costs a scheduler turn
per tick — enough to halve 136c's margin, which is the tightest in the suite (a
20-poll budget meeting the heaviest tick). Measured by binary-searching the
harness's per-poll pump budget: pre-fix 136c survives 10, 5 and 3; with the sweep
added it failed at 5; removing just the sweep restored it, while removing the
prune's clock read did not. Both work lists now come from the tick's single read.

The harness was the other half. Its per-poll settle is a fixed ten scheduler turns
— a guess about how much real progress a turn buys — so on a loaded runner the
simulated clock outruns a tick that is still in flight and the wait dies while the
answer is one turn away. advanceUntil now drains on the condition once the caller's
poll budget is spent, bounded by iterations and never by wall-clock time. At a
budget of 3 that takes the file from 3/5 green to 5/5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`advanceUntil` decided how far to move the simulated clock partly from how
busy the machine was: it settled each poll with a fixed handful of scheduler
turns, so on a loaded runner "the condition is still false" meant "the tick's
write has not landed yet" rather than "nothing has happened", and it advanced
anyway. Every borrowed minute landed in the assertions — CI reported one wake
covered twice, because the extra minutes carried the following advancePolls(10)
over the loop's 15-minute check-in floor, and a check-in five simulated minutes
late. Both are assertion failures about the product, produced entirely by the
harness.

Each poll now settles until the condition holds, bounded by scheduler turns
rather than by time, so the clock stops at the instant that satisfied the
condition and nowhere else. Simulated time moves only where a test says it
moves. reactorHarness.test.ts pins that contract, including on the give-up
path, which is where a clock-nudging drain shows itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Retention pruning ran inside the critical section of every store write,
including the reactor's per-poll bookkeeping. Stopped records only appear when
a run ends, so that is where the sweep belongs: stop, disarm, clear, and the
global settings write. The hot path goes back to a thin modify.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every milestone the tests had to infer — a tick finishing, a check-in reserved,
dispatched or aborted, a stop, a disarm, a banked stop serviced, blockers
delivered, a rate-limit hold, a recorded question — is now published on a
receipt stream. `tick.completed` is last in every tick, including the early
exit when nothing is armed, so "one poll happened and the reactor is done" is
an exact fact rather than something a test guesses at by spinning the
scheduler.

The service is optional. No production layer provides it, so `receiptEmitter`
resolves to a constant no-op and `enabled` is false — which is also what keeps
the empty-armed tick from reading a clock for a receipt nobody will read. The
buffer is bounded and dropping: a subscriber that stops draining loses
receipts rather than stalling the supervisor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The harness waited by spinning both schedulers a fixed number of turns and
looking again, which is a bet on how fast the machine is: the store persists
through real filesystem I/O, so on a two-core runner a turn buys less progress
and a budget that is generous on a laptop runs out. It cost this branch three
CI failures naming three different sets of tests, and because the harness kept
advancing the simulated clock while it guessed, they arrived as assertions
about the product — a wake covered twice, a check-in five simulated minutes
late, an answer not yet marked delivered — rather than as anything that looked
like a timing bug.

`advancePolls(n)` is now n × (advance one poll, await that tick's
`tick.completed`), so it is exactly n whole ticks on any machine, and
`advanceUntil` evaluates its condition once per completed tick and rests on
the poll where the answer arrived. The two stream-driven waits await the tap's
own receipt and spend no simulated time at all. Two `settleQuiet` calls are
gone because a completed tick already implies the writes inside it; the one
that remains asserts an absence, which is the only thing a bounded spin can
honestly do. `PER_POLL_TURNS` and the turn budgets are deleted.

`reactorHarness.test.ts` pins the contract that is left: a poll waits for the
whole tick however slow the machine is, and simulated time moves only where a
scenario says it moves — including on the give-up path, which is where a
harness that buys time on its own shows itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@radroid
radroid marked this pull request as ready for review September 3, 2026 01:24
@radroid
radroid merged commit e39f8be into main Sep 3, 2026
5 checks passed
@radroid
radroid deleted the coil/loops-backend branch September 3, 2026 01:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Loops docs (#120): resolve review findings before Phase 2 is built

1 participant