Skip to content

fix(waits): monotonic judge deadlines, early messages, hidden-hold count, pin with the first write, settings follow-ups - #119

Draft
scuttletoby wants to merge 1 commit into
mainfrom
fork/waits-fu2
Draft

scuttletoby wants to merge 1 commit into
mainfrom
fork/waits-fu2

Conversation

@scuttletoby

Copy link
Copy Markdown
Owner

Four small waits and slots follow-ups. Each fix has its own test that failed on the code without the fix.

Fixes #95
Fixes #105
Fixes #117
Fixes #118

What changes

#95: judge deadlines use the monotonic clock, and a met wait's message does not wait for slow checks. The judge's own deadlines (the late time of a running check, the busy wake time, the 3 s register limit, and the start and answer limits of a pass) now come from Clock.monotonicTimeNanos, not the wall clock. They are kept in memory only, so a server restart starts them from nothing. Stored times (nextCheckAt, the wait record) stay on the wall clock as before. A wall clock set back no longer stalls a pass or keeps a hung check from turning late. During the judge step, each wait that ends wakes a small deliverer, which runs the normal deliver step at once. So a met wait's message goes out while slower checks in the same pass are still running. The deliver step after the judge step is kept. judgeDue (used by the tests) is unchanged.

#105: settings screen.

  1. Agent slot account rows now list every selected environment's provider accounts (the primary's first), not only the primary's. So each environment's own accounts can be limited from an "all environments" view. The write is the same patch as before. When the selected environments have different account limits, the rows still say "select one environment".
  2. The per-check rows (Edit / Remove) now carry settingKeys={["waitChecks"]}, so an administrator lock on waitChecks makes them inert, like "Add check". They are not serverScoped, so they do not repeat the section's scope chip.
  3. The add/edit check dialog stays open until the write is saved. useUpdateScopedSettings gains an optional onSaved (called when every target saved the patch), next to the existing onFailed. While the write is out, the Save button shows "Saving…" and is disabled. On a refusal, the toast shows as before, and the dialog keeps the typed draft with a short note. Only the dialog that sent the write closes on success.

#117: a hidden old hold still counts against its own account. While a re-ask is not kept yet, the old hold is hidden (from #114). It now still counts against its own account (new accountOnly input to the shared slot decision), but not again against the machine. So a re-ask moved to another account (or to none) cannot let a third thread take that account's last slot, and the machine count is not doubled.

#118: a pin made while a new wait is being written is saved with it. When the pr judge pins the repository and host after register has started writing the new wait, register now writes that pin itself, under the store lock, right after the wait is adopted (shared pinInStore, the same compare-and-set as pinStored). Before, that pin could be lost, and the next read used the project's current remote.

What happens with stored or old state

Stored / written thing Runs again Stops half-way Old / empty / corrupt / newer-version state Server restart / clock set back
Judge deadlines (#95, memory only) Each pass reads the monotonic clock again A pass that stops leaves nothing stored Nothing stored, so no old format Restart: memory is empty, so every wait is judged again and a stuck check still turns late (tested with the monotonic clock a day behind). Wall clock set back 1 h while checks hang, and in the middle of a pass: the pass ends and a ready wait is still judged (tested)
Early message during a pass (#95) Same deliver step as before, under the same lock; a wait is delivered once (tested: sent at once and only once) The deliver step after the judge step still runs, so a missed early wake is caught there Ended waits from an older build are delivered by the normal step Undelivered ended waits are in the store, so they are sent after a restart, as before
Pin written with a new wait (#118) Compare-and-set: written only while the wait is still waiting with the spec it was built with; a pin equal to what was built is skipped Write fails: the wait stays unpinned and the next read pins it (as before) Old wait without host: pinned at its next read, as in #116 Pin is in the store, so it survives
Hidden old hold (#117, memory) Counted once per account and never against the machine twice (tested) Re-ask kept: the old hold is used up as before; dropped: it counts again Waits off: nothing is read or written Memory is empty, as before
Settings writes (#105) Same settings patch as before; a second click is blocked while saving Partial save on several environments: onFailed runs and the toast lists which saved; the draft stays No new stored field n/a

With waits off (the default), none of this runs: no judge or deliver step starts, and nothing is read or written.

Tests

General vs project

All general: this is T3's own waits, slots and settings code, with no project facts. The onSaved option on useUpdateScopedSettings is a general hook next to upstream's onFailed, and the account rows use upstream's useEnvironmentProviders. Nothing project-only.

🤖 Generated with Claude Code

…unt, pin with the first write, settings follow-ups

- Waits (#95): judge deadlines (late, busy, register limit) use the monotonic
  clock, so a wall clock set back cannot stall a pass or keep a stuck check
  from turning late. A met wait's message goes out while slower checks in
  the same pass are still running, once.
- Slots (#117): while a re-ask is not kept yet, the old hold still counts
  against its own account, and only once against the machine.
- PR waits (#118): a pin made while a new wait is being written is saved
  with it, so a changed remote is never read.
- Settings (#105): account rows list every selected environment's
  accounts; an administrator lock on waitChecks makes Edit and Remove
  inert; the check dialog keeps the draft until the write is saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: concurrency-and-abuse · reviewer: opus high (t3lens119a) · read at: b04ade9

covered: gh pr diff 119 plus the code it calls: WaitService (withEarlyDelivery, runDue, phase, judgeSweep/takeNext/saveIfEnds/applyJudgements, deliverSweep steps 1-4, send and its sending map, deliverDue/judgeDue and sweepLock, tryStartJudge/startJudgeWhenFree/awaitJudge/carry/isDue/startStored, register with pinned/builtWith/adopted, pinStored/pinInStore, commitWaits), SlotGate (liveHeld, hiddenHeld, takeHidden, useUp, decide, grant, keepLocked/keep, drop, usage), shared slotDecision/accountOnlyThreads/onePerThread, shared nextWaitState (cancel, sendStarted), the web WaitCheckDialog/WaitChecksSection/AgentSlotsSection, useRunScopedPlan/useUpdateScopedSettings, agentSlotAccountRows, and the server lock check (managedSettings vetWrite/lockTargets). I read #95, #105, #117 and #118 as reported, and the review comments they link (#91 correctness and security, #104, #116 concurrency).

checks:

  • waits: monotonic clock for check timing; send met file waits without waiting for the sweep #95 early sends: double send. The early deliverer runs deliverSweep inside the runDue sweep lock, one step at a time (the pending flag plus the latch cannot lose a wake). The deliver step after the judge step starts only after Fiber.join(deliverer). deliverDue and judgeDue take the same sweep lock, so no two deliver steps ever overlap. The message id is written before the send, and the sending map blocks a second send of a message that is still out. Probe P1: two waits end 3 s apart during a pass with a 55 s pr check. Each is sent within a second of its answer, and only once. It is red on the old code and green at the head.
  • waits: monotonic clock for check timing; send met file waits without waiting for the sweep #95: lost send / send for a cancelled wait. If the deliverer stops (waits off, or a failed step that is logged), the deliver step after the judge step still sends what ended. cancel changes only waiting waits, so a wait that has been saved as ended cannot be cancelled before its early send. A handover of a gone receiver (deliver step 1) and the judge's writes both read again under the store lock, and nextWaitState ignores a wait that is no longer waiting.
  • waits: monotonic clock for check timing; send met file waits without waiting for the sweep #95: unbounded work. There is at most one deliver step at a time, with at most 4 sends (SEND_CONCURRENCY). A late send keeps at most one fiber per message id. Many waits ending at once set the same flag, so they cause at most one extra deliver step each, run one after another.
  • waits: monotonic clock for check timing; send met file waits without waiting for the sweep #95 monotonic clock. Monotonic values reach only lateAt, wakeAt, the register until, and startUntil/answerUntil. carry turns the time left into a wall-clock nextCheckAt. Nothing monotonic is written to the store. Wall times (createdAt, deadlines, nextCheckAt, retryAt) are not compared with monotonic ones anywhere. The test clock starts monotonic time at 0 while wall time is in 2026, so a mix-up would show up in the existing tests.
  • Slot waits: an account can be booked twice while a re-ask on another account is unwritten #117 slots. A hidden hold counts on its own account only for other threads, once per thread, and not where that thread already holds the account. It never counts on the machine. Hidden holds exist only between grant and keep/drop in one grant step. A cut-off call keeps them until their old end at most, so a slot is never held past reserveSeconds and never extended. acquire and grant read hiddenHeld again for each request under the gate semaphore. I found no slot leak, no thread counted twice, and no over-full account or machine. The only effect on an older waiter is that it waits for that one sweep to end (the safe side).
  • Waits: a pr wait's repository pin made during the wait's first write is lost until the next check #118 two writers of repo/host. The register path writes the pin with pinInStore under the store lock it already holds, right after the commit. builtWith is taken in the same synchronous step that builds the record. A pin that arrives after adopted = true goes through pinStored, which waits for that lock. Both writes are compare-and-set on the judged key, so the first one wins and a cancel or end in between wins too. A failed write returns false (it is not an error), so register still answers "waiting".
  • Abuse. host is not in WaitForInput (contracts are unchanged). The pin is bound to the wait's own id, so an agent cannot pin someone else's wait. A lock on waitChecks is enforced by the server in vetWrite, including project overrides through lockTargets, so the greyed-out Edit/Remove buttons are not the only guard. onSaved closes only the dialog revision that sent the write. Save is disabled while a write is out. A plan that cannot be written calls onFailed synchronously, so the dialog does not hang on "Saving…". Account rows from several environments are de-duplicated by instance id.
  • State question: "What happens when this runs again, stops half-way, or meets existing/old/empty/corrupt/newer-version state, or a server restart?"
    • Runs again: a second deliver step finds nothing new to send (P1). A second pin is skipped by the key compare. A second keep or a late drop does nothing.
    • Stops half-way: probe P2 stops the server while an early send hangs. A new service sends the same message id once, and the wait ends delivered. A cut-off grant hides old holds only until they end.
    • Old/empty/corrupt/newer state: no new stored field. Waits that ended in an older build are delivered by the normal step. Waits off means nothing is read or written.
    • Restart: the deadlines, replacing and the deliverer live only in memory and start empty. The pin and ended results are in the store.
  • Failure table: every written step has a row. I put back the old WaitService.ts, SlotGate.ts and agentSlots.ts: 5 of the 6 new server tests go red (3 for waits: monotonic clock for check timing; send met file waits without waiting for the sweep #95, 1 for Slot waits: an account can be booked twice while a re-ask on another account is unwritten #117, 1 for Waits: a pr wait's repository pin made during the wait's first write is lost until the next check #118). The waits: monotonic clock for check timing; send met file waits without waiting for the sweep #95 restart test stays green, as the writer said (see the finding below). At the head: WaitService, WaitPrJudge and SlotGate pass 122/122. The settings browser file passes 3/3 (2 workers). I did not run the browser tests on the old component.

finding: minor (test gap, does not block) · apps/server/src/waits/WaitService.test.ts (the #95 "after a restart" test). The test proves nothing about a restart. It builds the second service with deadMount, which seeds the store again and overwrites whatever the first service wrote. A new service also has no monotonic stamps to compare with, so moving the monotonic clock a day back cannot matter. It would pass even if a monotonic time were written into a wait record, which is the one restart risk of #95. Today that risk does not exist: by reading the code, nothing monotonic reaches the store. Fix: build the second service on the store the first one left, without seeding again, and assert that the stored records hold only wall times. Or drop the test and rely on the existing restart tests. Confidence: high.

notes (no finding):

  • The early deliverer runs one step at a time. A send that hangs holds back other early messages of the same pass by up to SEND_LIMIT (60 s) per batch of 4 hung messages, and Fiber.join(deliverer) delays the grant step by the same amount. This is bounded and still sooner than before (the old code sent only after the whole judge step).
  • SlotGate.usage does not show hidden holds, so a pending re-ask's old account shows one slot fewer, for one grant step only. This affects the display only.
  • Probes P1 and P2 are kept by the reviewer (Lens119a.test.ts). Both pass at the head and both are red on the old WaitService.ts.

findings: 1 (minor)
verdict: SAFE. 0 blocker, 0 major, 1 minor (test gap), 2 notes. I found no double send, lost send or send for a cancelled wait. Checks and sends stay bounded. I found no slot leak, no thread with two slots and no over-full account. The repo/host pin cannot be aimed at another wait, and the server enforces the waitChecks lock.

@scuttletoby

Copy link
Copy Markdown
Owner Author

lens: correctness · reviewer: opus high (t3lens119c) · read at: b04ade9

covered: #95 (monotonic deadlines in WaitService register/tryStartJudge/awaitJudge/carry/startStored/isDue/judgeSweep, withEarlyDelivery + runDue), #118 (pinInStore in register), #117 (hiddenHeld in SlotGate.decide/grant, accountOnly in shared slotDecision), #105 (WaitsAndSlotsSettings account rows, per-check row lock, dialog + useUpdateScopedSettings.onSaved). Read against issues #95 #105 #117 #118 as reported, plus the code they call (WaitWorker, DueHint, deliverSweep, settingsLayout.SettingsRow, createScopedSettingsWriter).

checks:

finding 1 — minor (outside the diff, same symptom as #95 item 1), apps/server/src/scheduling/DueHint.ts:20-24 used by apps/server/src/waits/WaitWorker.ts:111: after the wall clock is set back, isIdle(nowMs) stays true until the wall clock reaches the recorded due time, because nowMs - checkedAtMs is negative and the 10-minute backstop never fires. So on a quiet server (no thread events, no settings change), no waits sweep runs at all for as long as the clock was set back: file waits are not judged and messages are not sent. This is the issue's reported symptom ("deliveries can stop ... for as long as the clock was set back"); the PR fixes it inside a pass, but the pass does not start. Any thread event (thread.*, run.*) clears the hint, so a busy server heals quickly. DueHint is upstream code (also used by scheduled tasks and usage-limit recovery). Fix idea: treat nowMs < checkedAtMs as not idle (one condition), or keep checkedAtMs on the monotonic clock. Confidence: high (failing test below).

finding 2 — minor (outside the diff, same pattern), apps/server/src/waits/WaitService.ts:1983 with :1915/:1919 (retryAt), also noticesRetryAt (:2264): the in-memory delivery retry times are on the wall clock. A wake message that got "try again later" (receiver busy) is held for the set-back time (an hour in the test) instead of its 10 s delay, even when sweeps run. Memory only, so a restart clears it. Fix idea: keep retryAt/noticesRetryAt on monotonicMillis, as this PR does for the check deadlines (and convert in nextDueAt, as carry does). Confidence: high (failing test below; the same test with no clock shift passes).

note (not a finding): while a re-ask is not kept yet, SlotGate.usage does not show the hidden hold on its account (it can show 0/1 while a new grant on that account is refused "full"). Display only, and only for the short gap until the re-ask is kept or dropped.

Since this PR says "Fixes #95", I suggest either fixing findings 1-2 here (small) or keeping #95 open / opening a follow-up for them.

Failing test for finding 1 (put at apps/server/src/scheduling/DueHint.reviewClock.test.ts)
// Review (PR #119, correctness lens): after the wall clock is set back, the waits worker's
// DueHint keeps reporting "idle" until the wall clock reaches the recorded due time, so on a
// quiet server no waits sweep runs (no judging, no messages) for as long as the clock was set
// back. The 10-minute backstop never fires because the age is negative.
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import * as DueHint from "./DueHint.ts";

const NOW = Date.parse("2026-11-14T12:00:00.000Z");
const HOUR = 3_600_000;

it.effect("a due hint recorded before the wall clock was set back an hour does not keep the poller idle", () =>
  Effect.gen(function* () {
    const due = DueHint.make();
    const version = yield* due.version;
    // The next sweep has work in 30 s.
    yield* due.record(version, NOW + 30_000, NOW);
    // The wall clock is set back an hour; a scheduler tick 5 s of real time later.
    assert.isFalse(yield* due.isIdle(NOW - HOUR + 5_000));
  }),
);
Failing test for finding 2 (put at apps/server/src/waits/WaitService.reviewClock.test.ts)
// Review (PR #119, correctness lens): the wall clock set back still holds a retried wake
// message for as long as the clock was set back, because the in-memory retry time
// (`retryAt`) is on the wall clock, like the deadlines #95 moved to the monotonic clock.
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { ProjectId, ThreadId } from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import { TestClock } from "effect/testing";

import { ServerSettingsService } from "../serverSettings.ts";
import type { WaitDelivery, WaitDeliveryResult } from "./WaitDelivery.ts";
import * as WaitService from "./WaitService.ts";
import { memoryFileSystem } from "./WaitService.testkit.ts";
import { WAIT_STORE_FILE } from "./WaitStore.ts";

const NOW = Date.parse("2026-11-14T12:00:00.000Z");
const HOUR = 3_600_000;
const ON = { waitFor: { enabled: true } } as const;

const shiftedClock = Effect.gen(function* () {
  const test = yield* TestClock.testClockWith(Effect.succeed);
  const shift = { wall: 0 };
  const wall = () => test.currentTimeMillisUnsafe() + shift.wall;
  const clock = {
    ...test,
    currentTimeMillisUnsafe: wall,
    currentTimeMillis: Effect.sync(wall),
    currentTimeNanosUnsafe: () => BigInt(wall()) * 1_000_000n,
    currentTimeNanos: Effect.sync(() => BigInt(wall()) * 1_000_000n),
  } as unknown as Clock.Clock;
  return { clock, shift };
});

it.layer(NodeServices.layer)("review #119: wall clock set back (retry timers)", (it) => {
  it.effect(
    "a wake message that got 'try again later' is retried after its delay even when the wall clock was set back an hour",
    () =>
      Effect.gen(function* () {
        const { clock, shift } = yield* shiftedClock;
        yield* Effect.gen(function* () {
          yield* TestClock.setTime(NOW);
          const filePath = `/waits/state/${WAIT_STORE_FILE}`;
          const fs = yield* FileSystem.FileSystem;
          yield* fs.makeDirectory("/waits/state", { recursive: true });
          yield* fs.writeFileString(
            filePath,
            JSON.stringify({
              version: 1,
              waits: [
                {
                  id: "w_aaaaaaaaaa",
                  threadId: ThreadId.make("thread-a"),
                  projectId: ProjectId.make("project-a"),
                  key: "0123456789abcdef",
                  createdAt: "2026-11-14T11:00:00.000Z",
                  deadlineAt: "2026-11-14T15:00:00.000Z",
                  state: "delivering",
                  result: { outcome: "exists", detail: "Exists.", at: "2026-11-14T11:30:00.000Z" },
                  attempt: 0,
                  kind: "file",
                  spec: { path: "/nowhere/ready", until: "exists" },
                },
              ],
            }),
          );
          let busy = true;
          let sent = 0;
          const delivery: WaitDelivery = {
            receiver: () => Effect.succeed({ type: "ok" }),
            deliver: () =>
              Effect.sync((): WaitDeliveryResult => {
                if (busy) return { type: "retry", detail: "busy" };
                sent += 1;
                return { type: "delivered" };
              }),
          };
          const service = yield* WaitService.make({ filePath, delivery, fileChildJudge: null });
          assert.strictEqual((yield* service.deliverDue()).sent, 0);
          busy = false;
          // A time sync sets the wall clock back an hour; real time moves on two minutes,
          // far past the retry delay (10 s).
          shift.wall = -HOUR;
          yield* TestClock.adjust(2 * 60_000);
          yield* service.deliverDue();
          assert.strictEqual(sent, 1, "the retried message is held until the wall clock catches up");
        }).pipe(Effect.provideService(Clock.Clock, clock));
      }).pipe(
        Effect.provideService(FileSystem.FileSystem, memoryFileSystem().fs),
        Effect.provide(ServerSettingsService.layerTest(ON)),
      ),
  );
});

findings: 0 blocker, 0 major, 2 minor (both outside the diff, for a follow-up), 1 note.
verdict: SAFE

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant