fix(automation): refuse a wait node whose timerDuration yields no wait, instead of parking the run forever - #18370
Conversation
The #17928 hole reached through a different door. The contract requires a non-blank `timerDuration` under `eventType: 'timer'`, but `timerDuration` is `z.string()` and no schema can evaluate the string: `'not-a-duration'`, `'P'`, `'PT0S'`, `'0'` and `'-5'` all save, and `parseIsoDuration` answers `undefined` for every one of them. On that path the executor computed no deadline, persisted no `waitUntil` (so the cold-boot re-arm pass could never see the run), armed no job — and, with a job service ANSWERING, emitted no log line at any level, because the `!job` fallback fires only when the job service is missing. It then returned `{ success: true, suspend: true }`: un-refused, un-armed, un-persisted and un-logged, while reporting success. The timer branch now refuses the node the same way #17928 refuses a stored block-less one: a `warn` naming the node, the offending value (quoted through `JSON.stringify`, so an authored newline cannot split the record) and the remedy, plus a `guard`-class refusal so a `fault` edge cannot route a metadata defect into a handler that reports success. The pin that asserted today's behaviour as expected moves with it: the teardown case keeps its subject and swaps its fixture for a job service whose `schedule` throws, the one route left to a degraded `timer:<nodeId>` correlation. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Measured, not assumed: the changed text reaches both published bundles (`dist/index.js` and `dist/index.cjs`, 2 hits each) beside a positive control that ships (1 hit) and two test-only negative controls that do not (0 hits), and `files[]` does not list `src`. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b581e2d26fcfcd6380f446a7bf1ab8a897ccdf6b && git checkout b581e2d26fcfcd6380f446a7bf1ab8a897ccdf6b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b57671edc1166d49be40993000894128b3a63b44 05d0e397644784b3caac43561c65556c1052d768 && git checkout -B drift-repro b57671edc1166d49be40993000894128b3a63b44 && git merge --no-ff 05d0e397644784b3caac43561c65556c1052d768
node scripts/docs-audit/affected-docs.mjs --json b57671edc1166d49be40993000894128b3a63b44 |
Contract reviewServed-tier: Isolated at-tier review. Inputs: card #18179 and its four comments, the rulings the card cites (decision batch #127 item 5, read as quoted verbatim on #17928 and on PR #18175 — objectui#9354, where the ruling is recorded, answered 404 to this seat), and PR #18370 itself (body, diff, both commits, the 41 check-runs on this head). Not fed the dispatch order or the seat's conclusions. Base is ① Derived judgmentsNamed one by one, each judged against the diff, not the report.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing seat: boundary flag 4 is resolved from the log, and it is ⛔ NOT a flakePosted by the What the log says, verbatimThe failing step (job ⇒ The review's flag 4 is half right, and the half that is wrong matters✅ Right about which step: the step is named for its major-bump guard. That distinction is exactly why the review was right to refuse to call it flaky on an annotation alone, and why the annotation ( Why the re-run passed on the same head, base and changesetBecause the gate said it would: 「the line is read from the body on the next ⇒ Two runs of ⛔ No re-run was spent, and none is owed: nothing here was flaky. Generated by Claude Code |
Provenance — landing pre-check passed
Record of record: comment Tier, verified per transcript rather than from the record's own line. The reviewer's transcript reads 110 of 110 Independence pair: ⛔ No carrier to clear. Landing pre-check, all three, re-measured at 2026-09-16T06:15Z:
Recorded and ⛔ not decided here: the four non-timer Generated by Claude Code |
Fixes #18179
The #17928 hole reached through a different door. Under
eventType: 'timer'the contract requires a non-blanktimerDuration, but the key isz.string()and no schema can evaluate the string:'not-a-duration','1 hour','P','PT0S','PT0H0M0S','0'and'-5'are all documents that save, andparseIsoDurationanswersundefinedfor every one of them — exactly as it did for the absent key. This PR makes the executor refuse them loudly instead of parking the run forever while reporting success.Premise re-check on this branch's base,
b57671edc1— every cited line holdsThe dispatch measured at
1411cf2c6f; this branch forked atb57671edc1(origin/mainis stillb57671edc1as this PR opens). Re-measured here rather than carried:b57671edc1parseIsoDurationdeclaration:526:526OKdurationMs && durationMs > 0):298:298OKwaitUntilpersisted (const output = at ? …):304:304OKif (job && runId != null && at)):307:307OKelse if (!job)):329:329OK{ success: true, suspend: true }:337:337OKwait-node.test.ts:332:332OKThe state the old path left behind
With a job service answering, neither branch could run: the arming guard fails on the deadline, and the
else if (!job)fallback fires only when the job service is missing. Control fell straight to the suspending return, whose comment points at recovery through "a later boot's re-arm pass, when the deadline was persisted" — and:304had just established that it was not.Measured through a real
engine.execute()run, not read off the source:engine.execute(){ success: true, suspend: true }, statuspausedfailurewaitUntilwarnnaming the node, the value and the remedyerrorClass'guard', so afaultedge cannot route itA warning-only fix would have moved the last row and nothing else; the run would still park forever.
What lands
wait-node.ts— the timer branch refuses before it suspends. WhenparseIsoDurationanswersundefined(which already covers zero and negative), the node emits awarnand returnsrefuseNode(...), mirroring what PR #18175 (cb1f27434b, onmain) already did for a stored absent block. The refusal isguard-class for the same reason: the metadata is wrong, re-running changes nothing, and afaultedge must not be able to route a metadata defect into a handler that then reports success.warn, noterror, by AGENTS.md's degradation rule and for the same reason the block-less refusal above it iswarn: the failure is handed to the caller — the run fails and says so — so nothing looks normal from the outside and this is not a durability degradation.JSON.stringifyrather than spliced raw.timerDurationis author-controlled foreign text arriving on a log record, and a value carrying a newline would split the alarm into physical lines of which only the first carries its level — the finding(service-automation): builtin/wait-node.ts 里还有五处外来 cause 插进日志 message —— 其中三处是 #4632 亲自标为 error 的耐久性诊断,且已实测被切碎 #5737 hazard, arriving from the authoring side instead of the driver side. Pinned by its own test.'PT0S'is not a short wait; it is a deadline already past, and it parks just as permanently.outputis now unconditional in that branch. The oldat ? { output: … } : {}spread existed sooutputwould be absent rather than a present key holdingundefinedwhen no deadline was computed. That distinction still matters and is still pinned (inabsent-config-node-characterization.test.ts, on the signal branch); what is gone is the arm that reached it. The two degraded returns still carry the deadline — they lose the auto-resume, never thewaitUntila later boot re-arms from.wait-node.test.ts— the pin that asserted today's behaviour as expected moves, and the moved pin is the evidence.:332("cancels nothing for a timer wait that armed no job") kept its subject — teardown — and swapped its fixture: it usedtimerDuration: 'not-a-duration', and its own comment named that fixture as the repro for a residual with no card. That card is #18179 and this PR discharges it, so the fixture moved to a job service whoseschedulethrows, the one route left to a degradedtimer:pausecorrelation on a host that has a job service.A new block pins the reversal on every channel in the table above, across all seven unusable spellings, with a
PT1Hcontrol that still suspends, arms the one-shot and persists the deadline.Scope — and the half deliberately NOT decided here
The queued scope was the runtime half only, and that is all this PR contains. A format refinement on
timerDurationnarrows a published accept set; decision batch #127 item 5 requires the block and the key and 「⛔ does not rule on the key's lexical validity」, so there is no ruling behind a narrowing. No file underpackages/spec/src/automation/**is touched:builtin-node-config.zod.ts,flow.zod.ts,node-executor.zod.tsandschemaless-node-config.zod.tsare all unchanged, and every one of the seven fixtures above still parses clean — which the new premise leg asserts explicitly, so this PR's own tests would go red if the accept set ever moved underneath them.Clause-②: no
Clause-②: no — this PR widens nothing. The accept set does not move and no published surface grows: a run that used to report
success: trueforever is refused loudly instead.Nothing new is exported: the refusal reuses
refuseNodefromguard-refusal.ts, which this file already imported; no new error code, no new exported helper, no new schema refinement.packages/services/service-automation'sexportsmap is byte-identical.Evidence
Every command below was run on this branch, most of them through
scripts/pm/os-verify-lock.sh.pnpm --workspace-concurrency=2 --filter '@objectstack/service-automation^...' build:: exit 0.pnpm --filter @objectstack/service-automation test:: exit 0 — 136 files, 1625 tests passed.pnpm --filter @objectstack/service-automation typecheck:: exit 0, includingcheck:test-typecheck(0 files / 0 errors held in the debt ledger).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no hand-written path list), run, then reconciled with--ran: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN, all carrying recorded exit codes. Five of them first answeredexit 3 PREREQUISITE NOT MET; they were re-run to a realexit 0afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'(72/72 tasks) rather than recorded as passes.pnpm lint(eslint . --no-inline-config) :: exit 0 over the whole population, so no narrowing needs defending.git restore --source=b57671edc1put the pre-fix file back (tree only, never the index; 0 staged paths confirmed). On-disk landing proven by anchor counts, not by the editor's exit code: theREVERSAL (#18179)marker went 1 -> 0 and the oldconst at = durationMs && durationMs > 0anchor 0 -> 1, and the mutated blob hash equalled the base blob hash exactly.src/builtin/wait-node.test.tsthen failed: 8 failed | 28 passed (36). Restored withgit checkout HEAD -- THE_FILE; the restored blob hash equalled the HEAD blob hash,git diff HEADwas empty, and the re-run was 36 passed (36). The subject resolves through a relative import inside its own package, so nodistleg applies to this ablation. The ablation script carried atrap … EXIT INT TERMrestore on an absolute path throughout.PT1Hcontrol all stayed green under the ablation — they are not about the fix.@objectstack/service-automationis published and itsfilesis["dist","README.md","CHANGELOG.md"](nosrc). After building the package: the subject textis not a usable waitreaches both published bundles (dist/index.js2 hits,dist/index.cjs2 hits); positive controla wait node must declare what resumes it(text that already ships) 1 hit in each; negative controlsREFUSED, not parkedandscheduleThrows(both test-only) 0 hits in each. => it publishes =>.changeset/18179-timer-duration-unusable-refusal.md,patch, named for the card so it cannot collide with another PR's pending note.grep -naPover the changed files for the C0/C1 range: no match, besidepnpm check:nul-bytes:: exit 0.Declared file face
packages/services/service-automation/src/builtin/wait-node.ts— the timer branchpackages/services/service-automation/src/builtin/wait-node.test.ts— the moved pin plus the reversal block.changeset/18179-timer-duration-unusable-refusal.mdThree files, and nothing else. In particular not
packages/services/service-automation/package.jsonorvitest.config.ts, which PR #18198 owns right now — the fix needed neither, and no file undersrc/builtin/overlaps any of the three concurrent faces named at dispatch.Acceptance notes
Recorded here rather than filed, per the scope rules.
1. The sibling that rides with #18179 — and a correction to how it was described. The dispatch (and the card) say that
{ eventType: 'signal' }with nosignalNamemakes the executor "correlate onundefined". Re-measured onb57671edc1, it does not:wait-node.ts:342onb57671edc1readsconst signal = String(wec.signalName ?? `wait:${node.id}`), so the pause carries a synthesizedwait:pausecorrelation. That difference is load-bearing, and it is why the sibling is not discharged inside scope 1:resumeAuthority: 'any', the REST resume door automation: the generic run-resume route needs an authorization gate keyed on the suspended node #3801 deliberately leaves open forwait, andresume(runId)— which the existing pin "cancels nothing for a signal wait" exercises end to end. The run is parked but reachable, not parked and unreachable.signalNamewould biteeventType: 'manual', where absence is the normal authoring shape for an operator-resumed pause, and'condition'. Deciding which of the four must carry the key is a question about the contract's accept set — scope 2 — not a runtime bug the runtime may settle by itself. Reported rather than widened, as the dispatch directs. ⛔ No card filed for it; this PR is its carrier.signalName: ''passesz.string()and survives??(which catches only nullish), so{ eventType: 'signal', signalName: '' }suspends with an empty correlation. Same scope-2 question, same answer: reported, not decided.2.
runId == nullon the timer branch is still a silent degrade. With a job service present but no run id, neitherif (job && runId != null)norelse if (!job)runs, so the node suspends with no line logged. Re-measured, it is not reachable through the engine:engine.ts:10600setsvariables.set('$runId', runId)unconditionally in the seeding path. Not a class (a) finding, therefore noted, not filed. Successor: none — there is no PR or person queued on this file after this one; it is recorded so the next reader of this branch does not have to re-derive it.Authored by the
os-devround of sessionsession_01KB5PFtxuy1x3dcR5gxudx6.Generated by Claude Code