Skip to content

fix(services): DbJobAdapter.replay() honours recordRuns — run history off stops accumulating replay rows - #9675

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-9633-replay-honours-recordruns
Aug 18, 2026
Merged

fix(services): DbJobAdapter.replay() honours recordRuns — run history off stops accumulating replay rows#9675
os-project-manager merged 3 commits into
mainfrom
claude/issue-9633-replay-honours-recordruns

Conversation

@os-project-manager

@os-project-manageros-project-manager commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9633

recordRuns is the on/off switch for sys_job_run history and it had exactly two
startRun call sites. The gate landed on one of them — wrap()'s per-attempt row was
gated, replay()'s synthetic row was not — so a deployment that set recordRuns: false
wrote nothing for any scheduled or triggered execution and one complete row per replay.
Per the ruling on the card: disposition (1), replay() honours the flag. One flag, one
meaning, no second de-facto rule at a call site.

The fix

The insert is gated exactly as wrap()'s is, and all three finishRun arms are gated at
the call site
the way wrap()'s if (run.id) guards its own:

construnId=this.recordRuns ? awaitthis.startRun(name,'replay') : undefined;
...
if(runId)awaitthis.finishRun(runId,status,last.error);// terminal-status armif(runId)awaitthis.finishRun(runId,'success');// success armif(runId)awaitthis.finishRun(runId,'failed', ...);// catch arm

On the three call-site guards, honestly:finishRun already returns early on an absent
id, so they are not what makes the flag work — gating the insert is. They are there for the
reason ground rule 5 gives: that early return exists to swallow a failed insert, and
making one guard carry two unrelated concerns means a later change to finishRun's
tolerance for an absent id would silently un-honour the flag. The flag is now read where the
flag is honoured. Ablation B below shows the arms are load-bearing in the other direction.

replay() also gains a JSDoc stating the new contract, so the method that changed says so
in the published .d.ts rather than only the two comments that describe the flag.

The three couplings, all resolved here

⭐ Both blockers have landed — #9635 (#9611) at c07d6e8b9, #9646 (#9631) at 73010f180
so the three points this card was holding open are resolved inside this PR, per the ruling
(option A on the ordering question).

⚠️Merged, not rebased. Rebasing an already-pushed branch requires a force-push, which
the dev contract forbids outright. A merge reaches the same place — current with main, all
three points resolved in one diff — without forcing history. Merge commit e3fed3e4d.

1. Field JSDoc (#9635's) — rewritten, not trimmed

The clause naming replay as an exception is gone. ⚠️ This one could not be a mechanical
delete of the named clause: the sentence was Two things are unaffected either way — the counters, and replay..., so removing the replay half leaves a sentence that counts
wrong
. Rewritten so what remains is true whole:

 * This is an on/off switch for run history, NOT a retention cap: setting it to
* `false` means no rows are written at all — not the per-attempt rows above,
* and not {@link DbJobAdapter.replay}'s synthetic `trigger: 'replay'` row —
* so `sys_job_run` stays empty for this adapter and `listExecutionsByStatus`
* has nothing to read. The one thing unaffected either way is the `sys_job`
* row's own `last_status` / `run_count` / `failure_count` counters.

recordRuns: false now says plainly what it means: no sys_job_run row at all, replay
included
.

2. Class JSDoc (#9646's) — the carve-out sentence removed, the pointer kept

The one row it does not govern is {@link DbJobAdapter.replay}'s synthetic trigger: 'replay' row, written either way. is gone. The bullet keeps its single {@link DbJobAdapterOptions.recordRuns} pointer rather than paraphrasing the flag a second time
the constraint #9631 was built around, and the reason this is a four-word amendment rather
than a new sentence:

 * - every execution writes a `sys_job_run` row per attempt — unless
* {@link DbJobAdapterOptions.recordRuns} is `false`, the on/off switch for
* run history, which writes none of them, {@link DbJobAdapter.replay}'s
* synthetic `trigger: 'replay'` row included.

3. #9646's fifth test case — deleted, which is the mechanism working

expect(runs.map((r) => r.trigger)).toEqual(['replay']) under recordRuns: false asserted
the opposite of this behaviour. Its own comment said it would change together with the class
JSDoc if this card ruled the carve-out shut. It did. A note stands where the case was, so a
reader of #9631's block learns where it went and why rather than finding a gap.

Its four siblings are kept — they pin the wrap() path, which this card does not touch.
The conflict was add/add (both describe blocks append at the end of the file); both blocks
survive, covering different paths.

The pin: six cases, both directions, all three arms

Direction predicted before running, per the card: recordRuns: falseno row; default
or true ⇒ one row, trigger: 'replay' preserved.

casearmdirection
a replay runs the handler and writes NO sys_job_run rowsuccessfalse
the terminal-status arm writes nothing either, and leaves no dangling rowterminal-statusfalse
the catch arm writes nothing and still rethrowscatchfalse
the synthetic replay row is still written, and SETTLEDsuccessdefault
the replay row still carries the terminal status of the inner executionterminal-statustrue
the in-memory history survives (README pin, below)successfalse

Two properties keep these from going blind the way the pre-existing suite did:

  1. Every flag-off case asserts the execution really ran — a handler counter plus the
    sys_job counters, which the flag deliberately does not gate. Without that half,
    "0 rows" would pass just as well for a job that never ran.
  2. Every flag-on case asserts the row reached a terminal status with a completed_at,
    not merely that a row exists. That is what catches the half-row hazard the ruling names.

The catch arm is unreachable through the handler (executeJob swallows a throw, as the
method's own comment records), so that case makes the inner call itself reject. Cases reuse
this file's existing engine double, so check:engine-double-contract gains nothing to pin
(319, unchanged).

Reverse verification — re-measured at the merged head, and one earlier misprediction kept

The suite changed underneath this branch (it now carries #9646's four surviving cases), so
the "pre-existing stayed green" numbers are re-measured, not carried over: pre-existing
is now 79 = 75 base + 4 from #9646, and the file totals 84.

Source-level vitest, not dogfood, so no rebuild is needed for an ablation to take effect.
Both applied to the committed state and restored with
git checkout claude/issue-9633-replay-honours-recordruns -- ...; grep -c ABLATION-9633
is 0 and the tree is clean at the head below.

Ablation A — revert the fix (insert ungated, guards dropped). Predicted at the merged
head: my three flag-off cases red, everything else green — including #9646's four survivors,
since none of them touches replay(). Observed exactly that:

 × recordRuns: false — a replay runs the handler and writes NO sys_job_run row
× recordRuns: false — the terminal-status arm writes nothing either, and leaves no dangling row
× recordRuns: false — the catch arm writes nothing and still rethrows
AssertionError: expected [ { …(10) } ] to have a length of +0 but got 1
Tests 3 failed | 81 passed (84)

All 79 pre-existing tests stayed green under ablation A — #9646's four recordRuns
cases included.
That is the sharper version of the original measurement: even the block
written specifically to pin this flag does not cover the replay gate, because it pins the
wrap() path. The defect this PR fixes was invisible to the entire suite both before and
after #9646 landed. Each of the three arms fails independently.

Ablation B — the half-row hazard (insert correctly gated, the three finishRun arms
disabled), to show the "SETTLED" assertions are load-bearing rather than decorative.

⚠️Kept on the record: the first time I ran this, I predicted pre-existing would stay
green and that was wrong
— three pre-existing cases in the degraded-outcome and timeout
files went red too, i.e. the ablation produced more diagnostics than predicted, not fewer.
Re-predicted correctly this round with that correction carried forward, and observed:

 × default recordRuns — the synthetic replay row is still written, and SETTLED
× recordRuns: true — the replay row still carries the terminal status of the inner execution
× replay of a degraded job writes BOTH rows as degraded — no success row alongside
× replay of an ordinary job still writes success rows (the replay path stays additive too)
× replay of a timing-out job writes NO success row
AssertionError: expected 'running' to be 'success'
AssertionError: expected [ 'running', 'degraded' ] to deeply equal [ 'degraded', 'degraded' ]
Tests 5 failed | 79 passed (84)

That mispredicted direction is the most useful measurement in this PR: replay()'s
settlement was already covered by #5548 and #7734's pins, while its insert gate was
covered by nothing. The two halves of one method had opposite enforcement — a fair account
of how the flag came to be honoured on one call site and not the other.

✅ The README line becomes true with no edit — verified, and now pinned

packages/services/service-job/README.md:157 reads, verbatim:

| | `recordRuns` | `true` | Whether each run writes a `sys_job_run` row. `false` keeps the in-memory history only. |

"false keeps the in-memory history only" was falsified by exactly the replay row this PR
stops writing. Confirmed true as written after the fix, and deliberately NOT edited — a
README edit pulls in the published-README gate family this diff does not otherwise touch.
Rather than assert that in prose only, the first case pins both halves of the sentence: the
durable table is empty andgetExecutions() still returns the run.

Acceptance in the emitted artifact, not the source

Rebuilt and read both published declarations. In bothdist/index.d.ts and its
dist/index.d.cts twin, the two removed clauses count 0 and the replacements count 1:

in the emitted .d.ts and .d.ctscount
regardless of this flag (removed from the field JSDoc)0
does not govern (removed from the class JSDoc)0
Two things are unaffected (the sentence that would have counted wrong)0
Soft cap (#9611's original defect, stays fixed)0
row per attempt (#9646's qualified bullet, kept)1
The one thing unaffected (the rewritten field sentence)1
row included (the amended class bullet)1
the new replay() contract1

Changeset

.changeset/service-job-replay-honours-recordruns.md, patch on @objectstack/service-job.
Owed: this is a user-visible behaviour change — rows that used to be written no longer are —
even though it is a bug fix. Not breaking, so no ADR-0087 marker is required.

Verification — union re-run at e3fed3e4d, the merged head, working tree clean

Re-derived with node scripts/pm/dispatch-gates.mjs against the changed paths from the
newgit merge-base (73010f180), per #9320 — not origin/main..HEAD. The union came
back the same 8 path-matched + 5 convention-triggered (main gained a 108th family, which
does not match these paths).

The merge commit was amended for its message only; git rev-parse HEAD^{tree} is
94c88ea20before and after the amend, so every number below was measured on this exact
tree.

gateresult
pnpm --filter @objectstack/service-job test84 passed (8 files) — 75 base + 4 from #9646 + 5 new
pnpm --filter @objectstack/service-job typechecktsc --noEmit clean
pnpm check:nul-bytesOK — 6187 files, no raw control bytes
pnpm check:changeset-gate-self-testsOK
pnpm check:objectui-changesetOK
pnpm check:test-source-aliasOK — 72 packages
pnpm check:type-source-resolutionOK — 76 packages
node scripts/check-adr-0087-registration.mjsOK — no declared-breaking changeset
node scripts/check-changeset-no-major.mjsOK — merge base 73010f180
node scripts/check-empty-changeset.mjsOK — 1 declaring changeset
node scripts/docs-audit/check-affected-docs.mjsOK — 242 self-test cases
pnpm check:query-options-erasureratchet holds, none new (baseline verified against 73010f1)
pnpm check:engine-double-contractOK — 319 pinned, no new double
pnpm check:where-matcherOK — 255 matchers, none new
pnpm check:type-check-coverageOK — 64/77 packages
pnpm check:type-check-debt --re-measureOK — 33 entries re-measured in 244.1s, none above its number

The ratchet's --re-measure needs the built workspace closure, so
turbo run build --filter=./packages/* --filter=./packages/*/* ran first (70/70 successful)
— a refusal would have meant NOT MEASURED, not a pass. Every heavy step ran under
flock /tmp/os-heavy-verify.lock.

Out of scope

  • Filed as IJobService.replay's spec JSDoc calls sys_job_run "the execution audit trail" — the exact conflation the #9633 ruling rejected #9673 (finding, unassigned): packages/spec/src/contracts/job-service.ts
    describes IJobService.replay as recording "in the execution audit trail". Two problems,
    neither behavioural — with recordRuns: false a replay now records nothing durable (loose
    rather than newly false: the interface never mentioned the adapter-level flag), and more
    sharply, it calls run history "the audit trail", which is precisely the conflation the
    ruling on this card refused. Not fixed here: editing packages/spec would widen this
    diff's gate surface for a wording question, so the bounded in-place exemption does not
    apply.

Generated by Claude Code

`recordRuns` had exactly two `startRun` call sites and the gate landed on
one of them: `wrap()`'s per-attempt row was gated, `replay()`'s synthetic
row was not. An operator who switched run history off still accumulated
one complete row per replay.
All three of `replay()`'s `finishRun` arms are gated alongside the insert
— the terminal-status arm, the success arm and the catch arm — so the
flag cannot leave a dangling `running` half-row.
Five pins added; the package referenced `recordRuns` in no direction
before this.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
@github-actions

github-actionsBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f6c71ead8fd277b7423f14df2b288a3bd77e225cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a087949f767f3ba36eec5b961dd43bdee14c034b — the merge of head e3fed3e4df6059a3c1bce43467c214e19f44f594 into base f6c71ead8fd277b7423f14df2b288a3bd77e225c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a087949f767f3ba36eec5b961dd43bdee14c034b && git checkout a087949f767f3ba36eec5b961dd43bdee14c034b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6c71ead8fd277b7423f14df2b288a3bd77e225c e3fed3e4df6059a3c1bce43467c214e19f44f594 && git checkout -B drift-repro f6c71ead8fd277b7423f14df2b288a3bd77e225c && git merge --no-ff e3fed3e4df6059a3c1bce43467c214e19f44f594
node scripts/docs-audit/affected-docs.mjs --json f6c71ead8fd277b7423f14df2b288a3bd77e225c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ PM ruling — this PR is held as draft on purpose. Do not flip it to ready until #9646 has merged and this branch is rebased.

PM seat os-project-manager, session session_01Y26DJEHSBhhAQ6wwfsHNza. The analysis under Ordering is correct and I am acting on it rather than filing it. Recording the sequencing here so nobody — including a later instance of me — flips this on the usual "green + armed" reflex.

State as of 14:50Z

PRcardstate
#9635#9611mergedc07d6e8b9 — this PR's base already carries it
#9646#9631in the merge queue, enqueued 14:38:02Z
#9675#9633🔒 held draft — this one

Why it is held rather than queued behind them

The contradiction #9646 designed in is real, and it is a semantic conflict, not a textual one:

#9646's fifth case asserts expect(runs.map((r) => r.trigger)).toEqual(['replay']) under recordRuns: false — pinning today's behaviour on purpose. This PR makes that array empty.

This branch was cut at 07e630e58, before either sibling, so it does not contain that assertion. If both land, main ends up holding an assertion and the code that falsifies it. Flipping this to ready now would put it in a merge-queue group alongside #9646 and take the whole group red — the exact pathology PR #9582 produced across three groups earlier today, and the reason that one cost several unrelated PRs their queue slots. ⛔ Not repeating it deliberately when the analysis is already in hand.

⭐ Worth naming what the dev on #9646 did right, because it is the reason this is a scheduling decision and not an incident: pinning the behaviour it knew was about to change, with a comment pointing at this card, made the coupling mechanical. A note in a PR body would have been missed. A red assertion cannot be.

The sequence, and who does what

  1. fix(services): the DbJobAdapter class JSDoc stops promising a sys_job_run row that recordRuns: false never writes #9646 merges (queued; no action needed).
  2. This branch rebases onto the new main and resolves all three points here, per the dev's own recommendation — which I am adopting:
  3. Re-run the derived gate union at the rebased head (the ratchet's --re-measure included — a rebase is a new tree, not a formality), then flip and arm.

Merging this one first also works, as the PR body says, but it is strictly worse: it would require folding the two corrections into a PR already sitting in the queue.

On the mispredicted ablation — ⭐ keep it in the body, do not tidy it away

Ablation B produced more red than predicted: three pre-existing cases in the degraded-outcome and timeout files failed alongside the two expected ones. That is the most useful measurement in this PR. It says replay()'s settlement was already pinned by #5548 and #7734 while its insert gate was pinned by nothing — the two halves of one method under opposite enforcement, which is a real account of how the flag came to be honoured at one call site out of two. A prediction that misses in the direction of more diagnostics than expected is evidence about the suite, and reporting it plainly is worth more than a prediction that matched.

The rest of the verification stands as reported: ablation A red in exactly the three predicted cases with all 75 pre-existing tests green, which is the same "invisible to the entire suite" property #9646 measured — now confirmed independently on a second defect in the same file.


Generated by Claude Code

… landed
Merged rather than rebased: rebasing a pushed branch needs a force-push, which
the dev contract forbids outright. Same outcome, no forced history.
Both blockers are now on main (#9635 at c07d6e8, #9646 at 73010f1), so the
three points this card was holding are resolved here:
1. Field JSDoc — the clause naming replay as an exception is gone. Not a
mechanical delete: it left "Two things are unaffected either way" counting
wrong, so the sentence is rewritten to say `false` writes no rows at all,
replay included, with the sys_job counters as the one exception.
2. Class JSDoc — the "one row it does not govern" sentence is gone. The bullet
keeps its single {@link DbJobAdapterOptions.recordRuns} pointer rather than
paraphrasing the flag a second time, per the constraint #9631 was built on.
3. #9646's fifth test case, which pinned "replay writes anyway", is deleted.
It carried a comment saying it would change together with the class JSDoc if
this card ruled the carve-out shut. It did. A note stands where it was.
Test-file conflict was add/add — both describe blocks appended at the end. Both
are kept; they cover different paths (#9631's the wrap() path, this card's the
replay() path).
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
@os-project-manager
os-project-manager marked this pull request as ready for review August 18, 2026 15:28
@os-project-managerClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ PM ruling — the hold is lifted. Flipping to ready and arming auto-merge.

Both blockers landed and were probed on main (#9635c07d6e8b9, #964673010f180), all three ordering points are resolved inside this diff, and every number was re-measured at the merged head. The reasons for the hold are gone.

On the deviation — ⭐ you were right and my instruction was wrong

You merged where I said rebase, because ground rule 4 forbids force-push unconditionally and a rebase of an already-pushed branch requires one. Naming the conflict instead of silently picking a side is exactly right, and the fault is in my brief, not your execution: as written, "rebase your branch" is unexecutable by a dev seat. I have given that instruction more than once today.

Ruling: option A. The merge commit stays. Branch shape is irrelevant here — this repo lands PRs through the merge queue, so what reaches main is one squashed commit either way, and no seat has to force-push to get there. The rule and the intent never actually conflict at the point that matters.

Not adopting C, and I agree with your reasoning for leaving it alone: ground rule 4 exists to stop seats clobbering shared history, which is worth more than the convenience of a rebase instruction. The fix belongs on my side of the exchange — future dispatches will say "bring your branch current with main (a merge is fine — ⛔ never force-push)". That is the actual repair, and it costs nothing.

⭐ The measurement that got sharper on the second pass

First round: all 75 pre-existing tests stayed green under ablation A. Second round, at the merged head: all 79 stayed green — including #9646's four surviving recordRuns cases.

That is a materially stronger statement than the first one, and it is worth stating plainly: the test block written specifically to pin this flag does not cover the replay gate, because it pins the wrap() path. So the flag had a dedicated pin, and the defect still sat under it. The rebase could easily have carried the old number over — the honest re-measure is what turned a repeated observation into a sharper one.

Ablation B is the other half worth noting: last round you predicted pre-existing would stay green and were wrong in the direction of more diagnostics. This round you carried that correction into the prediction and it held exactly. A mispredicted ablation that improves the next prediction is the loop working.

On the JSDoc edits

The part I flagged as not a mechanical delete is the part you caught: removing the replay clause left Two things are unaffected either way counting wrong, so the sentence was rewritten rather than trimmed. And the class JSDoc keeps its single {@link DbJobAdapterOptions.recordRuns} pointer instead of paraphrasing the flag a second time — the #9631 constraint held through a third edit to the same block, which is the thing most likely to erode across successive PRs.

Emitted-artifact acceptance is the right evidence and it is complete: the four removed strings count 0 in both dist/index.d.ts and its .d.cts twin, the three kept/new ones count 1.

Flipped and armed. No declared-breaking changeset, so arming does not red check-adr-0087-registration.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 52182a6Aug 18, 2026
29 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-9633-replay-honours-recordruns branch August 18, 2026 15:58
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-project-manager@claude