Skip to content

fix(automation): refuse a wait node whose timerDuration yields no wait, instead of parking the run forever - #18370

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-18179-timer-duration-unparseable-refusal
Sep 16, 2026
Merged

os-warren merged 2 commits into
mainfrom
claude/issue-18179-timer-duration-unparseable-refusal

Conversation

@os-warren

@os-warren os-warren commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18179

The #17928 hole reached through a different door. Under eventType: 'timer' the contract requires a non-blank timerDuration, but the key is z.string() and no schema can evaluate the string: 'not-a-duration', '1 hour', 'P', 'PT0S', 'PT0H0M0S', '0' and '-5' are all documents that save, and parseIsoDuration answers undefined for 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 holds

The dispatch measured at 1411cf2c6f; this branch forked at b57671edc1 (origin/main is still b57671edc1 as this PR opens). Re-measured here rather than carried:

cited dispatch measured on b57671edc1
parseIsoDuration declaration :526 :526 OK
no deadline computed (durationMs && durationMs > 0) :298 :298 OK
no waitUntil persisted (const output = at ? …) :304 :304 OK
no job armed (if (job && runId != null && at)) :307 :307 OK
the no-job-service fallback (else if (!job)) :329 :329 OK
returns { success: true, suspend: true } :337 :337 OK
the tree pins it as expected wait-node.test.ts:332 :332 OK

The 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 :304 had just established that it was not.

Measured through a real engine.execute() run, not read off the source:

channel before after
engine.execute() { success: true, suspend: true }, status paused run fails, node status failure
suspended runs one, parked forever none — the run never suspends
persisted waitUntil absent, so the cold-boot re-arm pass is blind to the run nothing persisted, because nothing parks
scheduled jobs none, with a job service answering none, and the run is refused instead
log lines during the run 0 at any level one warn naming the node, the value and the remedy
errorClass n/a — it reported success 'guard', so a fault edge cannot route it

A 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. When parseIsoDuration answers undefined (which already covers zero and negative), the node emits a warn and returns refuseNode(...), mirroring what PR #18175 (cb1f27434b, on main) already did for a stored absent block. The refusal is guard-class for the same reason: the metadata is wrong, re-running changes nothing, and a fault edge must not be able to route a metadata defect into a handler that then reports success.

  • warn, not error, by AGENTS.md's degradation rule and for the same reason the block-less refusal above it is warn: 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.
  • The offending value travels with the complaint, quoted through JSON.stringify rather than spliced raw. timerDuration is 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.
  • Zero and negative are the same verdict, deliberately not a separate one. 'PT0S' is not a short wait; it is a deadline already past, and it parks just as permanently.
  • output is now unconditional in that branch. The old at ? { output: … } : {} spread existed so output would be absent rather than a present key holding undefined when no deadline was computed. That distinction still matters and is still pinned (in absent-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 the waitUntil a 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 used timerDuration: '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 whose schedule throws, the one route left to a degraded timer:pause correlation 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 PT1H control 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 timerDuration narrows 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 under packages/spec/src/automation/** is touched: builtin-node-config.zod.ts, flow.zod.ts, node-executor.zod.ts and schemaless-node-config.zod.ts are 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: true forever is refused loudly instead.

Nothing new is exported: the refusal reuses refuseNode from guard-refusal.ts, which this file already imported; no new error code, no new exported helper, no new schema refinement. packages/services/service-automation's exports map is byte-identical.

Evidence

Every command below was run on this branch, most of them through scripts/pm/os-verify-lock.sh.

  • Dependency-closure buildpnpm --workspace-concurrency=2 --filter '@objectstack/service-automation^...' build :: exit 0.
  • Package testspnpm --filter @objectstack/service-automation test :: exit 0 — 136 files, 1625 tests passed.
  • Package typecheckpnpm --filter @objectstack/service-automation typecheck :: exit 0, including check:test-typecheck (0 files / 0 errors held in the debt ledger).
  • Gate families — derived with 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 answered exit 3 PREREQUISITE NOT MET; they were re-run to a real exit 0 after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' (72/72 tasks) rather than recorded as passes.
  • Repo-wide lint, not a narrowed onepnpm lint (eslint . --no-inline-config) :: exit 0 over the whole population, so no narrowing needs defending.
  • Reverse verification — the fix was committed first, then git restore --source=b57671edc1 put 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: the REVERSAL (#18179) marker went 1 -> 0 and the old const at = durationMs && durationMs > 0 anchor 0 -> 1, and the mutated blob hash equalled the base blob hash exactly. src/builtin/wait-node.test.ts then failed: 8 failed | 28 passed (36). Restored with git checkout HEAD -- THE_FILE; the restored blob hash equalled the HEAD blob hash, git diff HEAD was empty, and the re-run was 36 passed (36). The subject resolves through a relative import inside its own package, so no dist leg applies to this ablation. The ablation script carried a trap … EXIT INT TERM restore on an absolute path throughout.
    • Direction, predicted before the run and observed: turns red, and only the new block did. The premise leg, the teardown pin and the PT1H control all stayed green under the ablation — they are not about the fix.
  • Changeset — measured, not assumed. @objectstack/service-automation is published and its files is ["dist","README.md","CHANGELOG.md"] (no src). After building the package: the subject text is not a usable wait reaches both published bundles (dist/index.js 2 hits, dist/index.cjs 2 hits); positive control a wait node must declare what resumes it (text that already ships) 1 hit in each; negative controls REFUSED, not parked and scheduleThrows (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.
  • Control bytesgrep -naP over the changed files for the C0/C1 range: no match, beside pnpm check:nul-bytes :: exit 0.

Declared file face

  • packages/services/service-automation/src/builtin/wait-node.ts — the timer branch
  • packages/services/service-automation/src/builtin/wait-node.test.ts — the moved pin plus the reversal block
  • .changeset/18179-timer-duration-unusable-refusal.md

Three files, and nothing else. In particular not packages/services/service-automation/package.json or vitest.config.ts, which PR #18198 owns right now — the fix needed neither, and no file under src/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 no signalName makes the executor "correlate on undefined". Re-measured on b57671edc1, it does not: wait-node.ts:342 on b57671edc1 reads const signal = String(wec.signalName ?? `wait:${node.id}`), so the pause carries a synthesized wait:pause correlation. That difference is load-bearing, and it is why the sibling is not discharged inside scope 1:

  • A timer wait declares a deadline and then has no mechanism that can ever arrive. A signal / webhook / manual / condition wait declares an external resumer, and the documented external route still works — 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 for wait, and resume(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.
  • Refusing an absent signalName would bite eventType: '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.
  • One narrower shape found while measuring it, recorded and not acted on: signalName: '' passes z.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 == null on the timer branch is still a silent degrade. With a job service present but no run id, neither if (job && runId != null) nor else if (!job) runs, so the node suspends with no line logged. Re-measured, it is not reachable through the engine: engine.ts:10600 sets variables.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-dev round of session session_01KB5PFtxuy1x3dcR5gxudx6.


Generated by Claude Code

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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 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 b57671edc1166d49be40993000894128b3a63b44packageMentionDocs.

Which tree this was computed on

This run read content/docs from b581e2d26fcfcd6380f446a7bf1ab8a897ccdf6b — the merge of head 05d0e397644784b3caac43561c65556c1052d768 into base b57671edc1166d49be40993000894128b3a63b44, 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 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

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 05d0e397644784b3caac43561c65556c1052d768

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 b57671edc1 (merge-base confirmed). Every reading below was re-measured on a --shared clone of this head in this seat's own scratchpad, never carried from the PR body.

① Derived judgments

Named one by one, each judged against the diff, not the report.

  1. Accept set of the published contract (packages/spec): does not move — RIGHT. git diff b57671edc1 05d0e397 -- packages/spec is 0 bytes; builtin-node-config.zod.ts, flow.zod.ts, node-executor.zod.ts and schemaless-node-config.zod.ts are untouched. The new premise leg asserts all seven unusable spellings ('not-a-duration', '1 hour', 'P', 'PT0S', 'PT0H0M0S', '0', '-5') still pass FlowNodeSchema.safeParse and that a blank timerDuration is still refused; it ran green here (item ⑦ below), so the PR's own tests go red if the accept set ever moves under them.

  2. Public surface of @objectstack/service-automation: does not grow — RIGHT. package.json (including the exports map: one . entry, import/require, types/default) and src/index.ts are 0-byte diffs base to head. The export lines of wait-node.ts are identical base to head. refuseNode is imported at wait-node.ts:7 from ../guard-refusal.js on BOTH b57671e and this head — the PR's claim that it reuses an already-imported helper is true (the file is src/guard-refusal.ts, not src/builtin/, a location detail only). refuseNode returns { success: false, error, errorClass: 'guard' } with no code field ⇒ no new error code, no ERROR_CODE_LEDGER / StandardErrorCode touch. No new schema refinement. The only bytes that reach dist are new log and error prose, which is not an API surface.

  3. Therefore the no declaration is TRUE on both limbs of the criterion (「放宽接受集或扩大公开面」): nothing is widened and no public surface grows. Both carriers agree: node scripts/pm/check-clause2-carriers.mjs --pair 18370 exits 0 from this seat (declaration read from correction comment 5692325950, superseding claim 5691966015).

  4. The one real semantic change — the crux. For the class { eventType: 'timer', timerDuration for which parseIsoDuration answers undefined }, execute() moves from { success: true, suspend: true } (no deadline, no waitUntil, no job, zero log lines with a job service answering) to a guard refusal plus one warn. Judged as follows:

  5. errorClass: 'guard' does what the PR says — RIGHT. engine.ts:9446 on the base: const faultEdge = result.errorClass === 'guard' ? undefined : flow.edges.find(...fault...), so a guard refusal is never routed and the run throws Node 'pause' failed. Both refuseNode preconditions hold (re-running an unchanged flow can never succeed; the fix is metadata). Pinned in the new block: returned.errorClass === 'guard', suspend undefined, run status failed, node status failure, nothing downstream ran.

  6. Smaller derived points, all RIGHT. warn not error follows AGENTS.md's degradation rule (the failure is handed to the caller). Folding zero and negative into the same verdict is sound: parseIsoDuration already answers undefined for non-positive input, so the durationMs not greater than 0 disjunct is dead-but-harmless defence, and 'PT0S' is the same forever-hang the fix(automation): require the wait / boundary_event config block at the contract, retire the executor's silent timer default #18175 refinement reasons from. output going unconditional is sound because the guard precedes it, and the absent-vs-present-undefined distinction remains pinned at absent-config-node-characterization.test.ts:313-323 (signal branch, toStrictEqual + Object.keys). JSON.stringify quoting of the authored value is pinned by its own newline test.

  7. Premise table and measurements re-run here, not trusted. On b57671e: :298, :304, :307, :329, :337, :526, test :332 — all hold. In the isolated clone at this head, after pnpm install --frozen-lockfile and the ^... closure build: wait-node.test.ts 36 passed (36), exit 0; ablation with git restore --source=b57671edc1 on wait-node.ts (tree only, 0 staged, marker 1→0, old anchor 0→1, mutated blob == base blob): 8 failed | 28 passed (36), exit 1 — only the [finding] a non-blank but unparseable or zero timerDuration still parks a wait node forever with success: true — the #17928 hole reached through a different door, and the tree already pins it as expected #18179 block reddened, the premise leg, the moved pin and the PT1H control stayed green, exactly as predicted; restored blob == HEAD blob, git diff HEAD empty, re-run 36 passed (36); whole package 136 files, 1625 tests passed, exit 0; typecheck exit 0 (including check:test-typecheck, 0 files / 0 errors held); after building the package, is not a usable wait 2 hits in dist/index.js and 2 in dist/index.cjs, positive control a wait node must declare what resumes it 1 hit in each, negative controls REFUSED, not parked / scheduleThrows 0 hits in each ⇒ the change publishes and a changeset is owed.

  8. The mechanical gates say nothing about this diff, so this review is the compensating reading. check-widening-tells --declaration no exits 0 with all 3 files NOT MEASURED (no declared surface covers service-automation/src), and dispatch-gates' clause-② suspect glob is packages/spec/src/** only. The zero is not evidence; items 1-2 above are.

② Semver level

patch on @objectstack/service-automationright, and consistent with the declaration. AGENTS.md's checklist grades a bug fix in a released package patch and forces minor only on a yes declaration; the CI level axis reads this body's declaration as no in both label states (I ran declarationFromPullRequest over the real body with [] and with the four live labels). #18175 was minor because it moved the spec accept set with a BREAKING banner; this PR removes, renames or narrows no authorable key, so no BREAKING banner and no ADR-0087 disposition applies (step 13 of Check Changeset agrees). Said plainly, the one exposure: a stored flow whose timer node carries an unusable duration and which an operator has been resuming by hand will now fail at that node. That is disclosed in the changeset's ⚠️ paragraph, and it is not contract-backed behaviour ('manual' exists for it), so it is a fix, not a semver break. In the fixed lockstep group the release bump is the group's maximum anyway.

③ Boundary flags

  1. The four non-timer eventTypes with no signalName — answered and ESCALATED, not folded. The PR's correction is right: wait-node.ts:342 on b57671e reads String(wec.signalName ?? \wait:${node.id}`), so the pause carries a synthesized wait:plus node-id label, notundefined— the card's and the dispatch's 「correlates onundefined」 is falsified. Measured further: nothing outside wait-node.tsreadssignalNameor matches a suspended run bycorrelation(grep overpackages/**/srcexcluding tests and spec; the engine uses correlation only for itssubflow:/map:prefixes and the timer teardown), so today a signal / webhook / manual / condition pause is resumed byrunIdthrough the documented door and its correlation is a label. Parked-but-reachable is a different class from parked-and-unreachable; the runtime has no defect of this PR's class there. What remains is a contract question — which of the four must carry a non-blanksignalName— and one more observation for the same box: the executor's?? wait:` fallback is itself a default the protocol does not declare, the very shape C′ ruled against. Both go to the decision box as scope 2. Not this PR's to decide; does not block it.

  2. { eventType: 'signal', signalName: '' } suspends with an empty correlation — confirmed by reading, ESCALATED with flag 1. z.string() accepts '' and ?? catches only nullish. With nothing dispatching on correlation today, the runtime harm is a blank label in the suspended-run ledger; the contract-hygiene gap is real. Refusing blank only (the PR's option C) is the smallest narrowing and mirrors the blank-timerDuration refinement fix(automation): require the wait / boundary_event config block at the contract, retire the executor's silent timer default #18175 landed, but it moves a published accept set ⇒ decision box, not this PR.

  3. The moved pin at wait-node.test.ts still tests what its name claims. 「cancels nothing for a timer wait that armed no job (the arming call failed)」: with scheduleThrows, the run suspends (paused), carries timer:pause, and after resume(runId) cancelled is [] — the [17.0-rc2验收] wait 定时暂停被外部 resume 短路后,一次性唤醒 job 仍保持 armed —— 次日对已完成 run 发起幽灵 resume,sys_job 里留下误导性的「待唤醒」行 #5512 teardown contract, unchanged. One note, not a defect: expect(scheduled).toEqual([]) is now guaranteed by the fixture (a throwing schedule never pushes), so that line is a tautology; the live assertions are the status, the correlation and the empty cancelled. The repro role the old fixture carried is now the [finding] a non-blank but unparseable or zero timerDuration still parks a wait node forever with success: true — the #17928 hole reached through a different door, and the tree already pins it as expected #18179 block, which is stronger (unstaged documents, every channel measured, a PT1H control).

  4. Checks. 41 check-runs on this head; every required context (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance) is success. One thing the PR body does not mention: the first Check Changeset run (job 104671150658, opened event, 04:58Z) FAILED at step 15 「Guard against accidental major bumps (launch window)」, and the labeled-event run three minutes later (job 104671793955) passed on the same head, same merge base, same changeset. The job log is unreadable from this seat (proxy 403 on the log host) and the only annotation is 「exit code 1」; the declaration-line reading is not the cause (item ② above). Unexplained ⇒ the landing seat should read that log before treating it as flaky.

  5. Carrier hygiene, seat-side, not the PR's: --pair 18370 warns that the governing claim comment 5691966015 carries no Session: line (「session ID 不可省」), so the correction's attribution is unverified. Recorded; does not touch this verdict.

Implemented-by: claude/issue-18179-timer-duration-unparseable-refusal
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Landing seat: boundary flag 4 is resolved from the log, and it is ⛔ NOT a flake

Posted by the domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T05:3xZ, discharging the contract review's request that 「the landing seat read the log before calling it flaky」. The review could not: the log host answered 403 to its token. This seat's token read it at 05:0xZ.

What the log says, verbatim

The failing step (job 104671150658) runs node scripts/check-changeset-no-major.mjs --base "$MERGE_BASE". Inside it, in this order:

Diffing HEAD from b57671edc (merge base with b57671edc1166d49be40993000894128b3a63b44).
✓ This diff introduces no `major` bump.
⛔ LEVEL AXIS: NOT MEASURED, and it is the one reading this PR needed.
   .changeset/18179-timer-duration-unusable-refusal.md
     - @objectstack/service-automation: patch
   ⇒ none of them is graded `minor` or above, so a `yes` here would REFUSE (#16361).

No clause-② declaration was readable …
   · carrier: `needs:contract-review` is not on this PR (0 label(s) read)
   · declaration line: a near miss, not a declaration — ## Clause-②: no
##[error]Process completed with exit code 1.

⇒ 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.
⛔ Wrong about the cause: 「declaration-line reading is not the cause」 is falsified by the log. The major-bump assertion passed on its own line (✓ This diff introduces no major bump). The assertion that exited 1 is the LEVEL AXIS / clause-② declaration — the step is merely named after its other check.

That distinction is exactly why the review was right to refuse to call it flaky on an annotation alone, and why the annotation (exit code 1) was not enough: a step name is not a failure reason.

Why the re-run passed on the same head, base and changeset

Because the gate said it would: 「the line is read from the body on the next edited event (pr-automation.yml subscribes to it), so this red clears with no push and no re-run」. The ## markdown-heading prefix is not an accepted prefix — only bare line start, - , > or **. A body-only PATCH at 05:01:55Z supplied the line at line start; job 104671793955 then passed on the identical head 05d0e39764.

⇒ Two runs of Check Changeset exist on this head with opposite conclusions, and the stale failure does not disappear. Landing pre-check ③ must be read as the latest run per check name, ⛔ not as「the set contains no failure」. Read that way this head is green: 34 distinct check names, 29 success, 5 skipped, 0 not-green at 05:25Z.

⛔ No re-run was spent, and none is owed: nothing here was flaky.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — landing pre-check passed

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T06:16Z.

Record of record: comment 5692619957 on this PR, naming head 05d0e397644784b3caac43561c65556c1052d768 — the head measured this act. VERDICT: PASS.

Tier, verified per transcript rather than from the record's own line. The reviewer's transcript reads 110 of 110 "model":"claude-fable-5-1" and zero other model strings. Control, same probe on an os-dev transcript from this shift: 243 × "model":"claude-opus-5" ⇒ the probe discriminates and the zero is not vacuous. CONTRACT_REVIEW_TIER = 'claude-fable-5-1' at scripts/pm/dispatch-gates.mjs:10689.

Independence pair: Implemented-by: claude/issue-18179-timer-duration-unparseable-refusal · Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6. Isolated subagent, fed only card #18179, the rulings it cites, and this PR.

No carrier to clear. Clause-②: no, so none was hung — the review still ran, because the declaration is 「按设计临时…⛔ 非终审」 and the real gate is the in-seat review. The reviewer was briefed adversarially on that exact question and was told a FAIL was the available answer if yes was the right call; it measured no true on both limbs (git diff over packages/spec is 0 bytes; the exports map and src/index.ts are 0-byte diffs; refuseNode is imported on base and head alike; no new error code).

Landing pre-check, all three, re-measured at 2026-09-16T06:15Z:

reading
① at-tier PASS naming the current head 5692619957 names 05d0e39764, which is the head right now ✅
check-clause2-carriers --pair 18370 exit 0
③ every check green latest run per check name: 29 success, 5 skipped, 0 not-green; mergeable_state: clean

⚠️ ③ on this PR is exactly why it must be read per check name: Check Changeset has two runs on this head — 104671150658 failed at 04:59Z, 104671793955 passed at 05:02Z after a body-only edit, with no push and no re-run. Boundary flag 4 is resolved from the log at 5692641340: the step is named for its major-bump guard, that guard passed on its own line, and the assertion that exited 1 was the clause-② level axis. ⛔ Not a flake, and no re-run was spent.

Recorded and ⛔ not decided here: the four non-timer eventTypes carrying no required resumer key, and signalName: '' suspending with an empty correlation. Both are scope 2 (they move a published accept set) and go to the decision box; this card is their carrier and ⛔ no card was filed for them.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 16, 2026 06:17
@os-warren
os-warren enabled auto-merge September 16, 2026 06:17
@os-warren
os-warren added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit bce5270 Sep 16, 2026
42 of 43 checks passed
@os-warren
os-warren deleted the claude/issue-18179-timer-duration-unparseable-refusal branch September 16, 2026 06:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants