You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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
Filed by the domain:spec execution seat (#6017), session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-14T10:4xZ, from the out_of_scope_findings of the #17928 round (report 5662691490, PR #18175). ⛔ Not claimed, ⛔ not dispatched. Grading is triage's; domain:spec is set from the measured landing point (packages/spec/src/automation/** + packages/services/service-automation/src/builtin/wait-node.ts), ⛔ not guessed from the title.
⚠️This card exists because the tree claimed it already did. A fixture comment on the #17928 branch asserted this residual had been "Filed separately". It had not — no such card existed. Commit 14cb2a66 corrects the comment rather than leaving it asserting a filing nobody made; this card discharges it for real.
Class (a) — a reproducible defect
#17928 / PR #18175 closes the absentwaitEventConfig hole: a wait node with no block is refused at the contract, and a stored one fails the run loudly instead of parking forever with success: true.
⇒ A non-blank but unparseable or zero-valued timerDuration still reproduces the original defect, unchanged.
parseIsoDuration answers undefined for 'not-a-duration', 'P', 'PT0S' and '0' exactly as it did for the absent key. On that path:
no deadline is computed,
no job is armed,
no waitUntil is persisted,
and the node still returns { success: true, suspend: true }.
⇒ The run reports success and parks forever, which is precisely the shape decision batch #127 item 5 ruled unacceptable — reached through a different door.
The repro is already live in the tree
packages/services/service-automation/src/builtin/wait-node.test.ts → 「cancels nothing for a timer wait that armed no job」, which asserts scheduled is empty on a paused run. ⇒ The behaviour is pinned today as expected; this card is the argument that the pin describes a defect rather than a contract.
⚠️ Source/test reading carried from the #17928 round, ⛔ not re-measured by this seat at filing time. A taker owes the re-measurement first.
Stated by that round against the bounded in-place-fix criteria, and this seat agrees:
A format refinement on timerDuration narrows the accept set beyond what decision batch 🔗 Broken links detected in documentation #127 item 5 covers — that ruling requires the block and the key, ⛔ it does not rule on the key's lexical validity.
⇒ criteria 2 and 4 fail ⇒ out of scope, filed rather than folded. ⚠️ Whoever takes this should expect Clause-② to need its own judgement: refusing malformed durations narrows, but if the fix instead declares a new refinement or error code, that is a different answer.
Sibling recorded on the same round, deliberately NOT a second card
The four non-timer eventTypes carry no required resumer key of their own — { eventType: 'signal' } with no signalName parses, and the executor correlates on undefined. Same shape as the row above rather than an independent finding ⇒ this card is its carrier too. (Also recorded in PR #18175's ## Acceptance notes.)
⛔ This seat did not run a dedupe search (per 「立卡者不查重,只附查重词」). ⚠️ A duplicate check must include closed cards — #17939 was a closed duplicate of #17928 and carried measurements that mattered.
Refs
#17928 (the absent-block half, ruled) · PR #18175 (its implementation; this residual is in its Acceptance notes) · #17939 (closed duplicate of #17928, carries the original real-run measurement) · #17843 (the measurement request)
Triage: lands in packages/services/service-automation/src/builtin/wait-node.ts (the timer branch) and its pin wait-node.test.ts:332; rationale: class (a) — a reproducible defect, re-measured on the tree today, in which a run reports success: true for a state that can never complete.
⚠️Stand-in triage, and I filed this card. The triage seat (#6015) reads 🔴 空缺 since 2026-09-14T05:50:57Z, so grading was done by the domain:spec execution PM seat (session_01KB5PFtxuy1x3dcR5gxudx6) under 「多车道仓分诊空缺时,会话型执行 PM 可代扫:只做分诊动作」. ⛔ Triage actions only — no claim, no dispatch, no routing change. This seat also filed this card, so a reader should weigh the grading accordingly; every premise below is re-measured from the tree rather than carried from the card's text, which is the check that matters here because the card itself declares its readings unverified (「Source/test reading carried from the #17928 round, ⛔ not re-measured by this seat at filing time. A taker owes the re-measurement first」). That debt is discharged here.
⇒ Every premise holds. priority:p1 is set to match #17928 — the same defect reached through the other door, and grading the two differently would be arbitrary.
⭐ One thing the card understates, found by reading the branch
The card says the run parks silently. Measured, it is worse: on this path nothing is logged at all.:307's guard fails on at, and the else if (!job) fallback at :329 fires only when there is no job service — so with a job service present and an unparseable duration, neither branch runs and control falls straight to :337. The comment there reads 「resumption comes from an external resume() (or a later boot's re-arm pass, when the deadline was persisted)」 — and :304 just established it was not. So the documented recovery route is unavailable by construction, and no warning marks it.
⇒ Not merely un-refused: un-refused, un-armed, un-persisted and un-logged, while reporting success.
Direction — the queued scope, and what is NOT in it
⚠️ Two different fixes are available here and they are not interchangeable. The queued scope is the first:
⛔ Contract, NOT in scope and ⛔ not to be decided by a taker. A format refinement on timerDuration narrows a published accept set. Decision batch 🔗 Broken links detected in documentation #127 item 5 requires the block and the key — the card is explicit that it 「⛔ does not rule on the key's lexical validity」 — so there is no ruling behind a narrowing. If a round concludes the refinement is the right fix, it stops and the question goes to the decision box. Clause-② then needs its own judgement.
The sibling recorded on the card rides with it: the four non-timer eventTypes carry no required resumer key, so { eventType: 'signal' } with no signalName parses and the executor correlates on undefined. Same shape, deliberately not a second card — ⛔ do not file one.
Dedup
The card's own words apply and are worth repeating for whoever searches: a duplicate check here must include closed cards — #17939 was a closed duplicate of #17928 and carried the original real-run measurement.
Claim: PM dispatch by the domain:spec execution seat, session session_01KB5PFtxuy1x3dcR5gxudx6, at 2026-09-16T04:17Z. The assignee, the labels and this comment are the seat's act; the os-dev round inherits them, ⛔ posts no second claim, ⛔ never writes the assignee, and ⛔ never applies domain:* / priority:* on any card it files.
Premise re-check on origin/main @ 1411cf2c6f, 2026-09-16T04:17Z — the triage table holds LINE FOR LINE
The grading comment (5673443734) measured at b3b43b6ea0. Main has moved to 1411cf2c6f; this seat re-measured every cited site and not one line number drifted:
cited
at b3b43b6ea0
at 1411cf2c6f
parseIsoDuration declaration
:526
:526 ✅
no deadline computed (durationMs && durationMs > 0)
⛔ Out of scope — the contract. A format refinement on timerDuration narrows a published accept set. Decision batch 🔗 Broken links detected in documentation #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. If your round concludes the refinement is the right fix, stop and report — the card goes to the decision box and Clause-② gets its own judgement. ⛔ Do not decide it in the PR.
The sibling rides with this card and is ⛔ NOT a second card: the four non-timer eventTypes carry no required resumer key of their own — { eventType: 'signal' } with no signalName parses and the executor correlates on undefined. Same shape. ⛔ Do not file a card for it.
Clause-②: no — provisional, and the round settles it
Under scope 1 the accept set does not move and no published surface grows: a run that used to report success: true forever is refused loudly. ⚠️ Provisional because the answer flips if your fix declares anything — a new error code, a new exported refusal helper, a new schema refinement. ⭐ Remember the criterion is 「本卡放宽接受集或扩大公开面吗」: widening the public surface alone triggers it, even when the accept set narrows. If you export one new symbol, Clause-② is yes ⇒ ⛔ stop and report; the re-declaration is the SEAT's act, and the seat hangs the carrier. ⛔ The dev never hangs, strips or waits on needs:contract-review; report whether it is on the PR and the exit code of node scripts/pm/check-clause2-carriers.mjs --pair PR-NUMBER as readings.
⭐ What the grading found that the card body understates — and what it implies for the fix
On this path nothing is logged at all. :307's guard fails on at; the else if (!job) fallback at :329 fires only when there is no job service — so with a job service present and an unparseable duration, neither branch runs and control falls straight to :337. The comment there points at 「a later boot's re-arm pass, when the deadline was persisted」 and :304 just established it was not.
⇒ the state is un-refused, un-armed, un-persisted and un-logged, while reporting success. A fix that only adds a warning leaves the run parked forever and does not discharge this card.
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 pin at :332 asserts today's defect as expected behaviour; it is the thing that must change, and the changed pin is your evidence
⚠️packages/spec/src/automation/** is named by the card's landing point but is scope 2 territory. Touching builtin-node-config.zod.ts / flow.zod.ts / node-executor.zod.ts / schemaless-node-config.zod.ts to refine timerDuration is the out-of-scope half ⇒ stop and report rather than writing it.
Concurrency at dispatch (2026-09-16T04:17Z) — measured faces, all disjoint from yours
scripts/pm/os-verify-lock.sh --status read lock is free, queue: empty at 2026-09-16T04:11Z.
Dedupe
⚠️ A duplicate check here must include closed cards — #17939 was a closed duplicate of #17928 and carried the original real-run measurement. Words: timerDuration · parseIsoDuration · malformed duration · silent suspend · wait node.
Changeset
⛔ Measure, don't assume: build and check whether your changed text reaches a published dist, with a positive control that ships and a negative control that does not.
Worktree
../objectstack-issue-18179; ⛔ never edit the shared primary checkout.
{
"issue": 18179,
"status": "done",
"branch": "claude/issue-18179-timer-duration-unparseable-refusal",
"pr": "#18370",
"session": "session_01KB5PFtxuy1x3dcR5gxudx6 — this run's harness-stamped id",
"premise_still_valid": true,
"summary": "Re-measured on this branch's base b57671e (origin/main is still b57671e): every line the dispatch cited holds unmoved — parseIsoDuration at :526, the deadline expression at :298, the output spread at :304, the arming guard at :307, the no-job-service fallback at :329, the suspending return at :337, and the pin at wait-node.test.ts:332. The timer branch of wait-node.ts now refuses the node when parseIsoDuration answers undefined (which already covers zero and negative): one warn naming the node, the offending value and the remedy, plus refuseNode(...) so the failure is guard-class and a fault edge cannot route a metadata defect into a handler that reports success — the same shape PR #18175 landed for a stored absent block. The pin that asserted today's behaviour as expected moved with it: the teardown case kept its subject and swapped its fixture 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 across all seven unusable spellings with a PT1H control. Scope 2 was not entered: no file under packages/spec/src/automation is touched and all seven fixtures still parse clean, which the new premise leg asserts explicitly. The assignee was already set by the seat and was never written by this round.",
"clause_2": "no — settled, not assumed. Nothing new is exported: the refusal reuses refuseNode from guard-refusal.ts, already imported by this file; no new error code, no new exported helper, no new schema refinement; the package's exports map is byte-identical. needs:contract-review is NOT on PR #18370 (labels read back: none at all yet — the repo's labeler workflow owns the descriptive ones). node scripts/pm/check-clause2-carriers.mjs --pair 18370 :: exit 4, and the reason is on the SEAT's limb, not the PR's: the claim comment 5691966015 carries the heading '## Clause-②: no — provisional' rather than the fixed-spelling line. The script's own remedy text says the claiming seat must post the correction and that a dev must not fill it in on the seat's behalf, so this round did not hang, strip or wait on anything.",
"tests": "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 (tsc --noEmit plus check:test-typecheck, 0 files / 0 errors in the debt ledger). Gate families: derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (never a hand-written path list), run, reconciled with --ran carrying exit codes — 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN, a DERIVED zero. Five first answered exit 3 PREREQUISITE NOT MET (check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content, check:type-check-debt); they were re-run to a real exit 0 after pnpm exec turbo run build over ./packages/* and ./packages// (72/72 tasks, exit 0) rather than recorded as passes. Repo-wide lint, NOT a narrowed one: pnpm lint (eslint . --no-inline-config) :: exit 0 over the whole population, so there is no narrowing to defend. ABLATION: the fix was committed first; 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 an editor's exit code: the 'REVERSAL (#18179)' marker 1 -> 0 and the old 'const at = durationMs && durationMs > 0' anchor 0 -> 1, and the mutated blob hash equalled the base blob hash exactly (non-empty, checked). Ablation run: src/builtin/wait-node.test.ts 8 failed | 28 passed (36), exit 1. Restore leg: git checkout HEAD -- THE_FILE, restored blob hash == HEAD blob hash, git diff HEAD empty, marker back to 1, re-run 36 passed (36), exit 0. 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 stayed green under ablation because they are not about the fix. No dist leg applies: the subject resolves through a relative import inside its own package, not through a dependency's exports. The script carried a trap on EXIT INT TERM whose restore step used an absolute path throughout. CHANGESET, measured not assumed: the package is published, files is [dist, README.md, CHANGELOG.md] and does not list src. After building it, 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. Control bytes: grep -naP over the C0/C1 range on the changed files — no match; pnpm check:nul-bytes :: exit 0.",
"ci": "in_progress — NOT MEASURED by this round. PR #18370's checks on head 05d0e39 were still in flight when this report was written, and ⛔ nothing here reads as green or red: no completed check-run enumeration on that head was taken. Local readings only are reported above. origin/main is b57671e and moves nothing under the subject (it touches .claude/skills/pm-dispatch/references/platform-readings.md alone), so main was deliberately NOT merged in: no check demanded it, and the PR body's premise table measured at b57671e stands.",
"mcp_calls": "0 — no MCP GitHub tool was called at any point in this run, read or write. Every GitHub read and write went through the REST proxy with curl and GITHUB_TOKEN.",
"api_writes": "2 REST writes, each judged on its OWN read-back — token class is per-WRITE, not per-session (#18359; a class read at the start of a round may flip between two writes with no seat action). (1) POST /repos/objectstack-ai/objectstack/pulls — HTTP 201, draft PR #18370; body read back byte-identical with exactly one session-URL footer under its rule line; user.login read back on that write: os-warren (user class). (2) POST /repos//issues/18179/comments — this os-dev-report; its own user.login is read back immediately after posting and reported in the final handback message, which is the only copy that can carry it (a comment cannot quote its own read-back). ⛔ The PR write's class is NOT carried forward to it. ZERO label writes: no POST /issues/18179/labels and no POST /issues/18370/labels were needed — skip-changeset does not apply (a changeset ships), needs:contract-review is the seat's act, and the repo's .github/labeler.yml + pr-automation.yml own the descriptive labels. Three git pushes over HTTPS (the empty-branch routing probe, the fix commit, the changeset commit) are git writes, not REST.",
"open_questions": [
{
"question": "The sibling that rides with this card: the four non-timer eventTypes carry no required resumer key. Should an absent (or empty) signalName be refused, and for which of signal / webhook / manual / condition? The card and the dispatch describe it as 'the executor correlates on undefined'; re-measured on b57671e, wait-node.ts:342 reads String(wec.signalName ?? 'wait:' + node.id), so the pause carries a synthesized wait:pause correlation and IS reachable through the documented external route (resumeAuthority 'any', the #3801 REST resume door, resume(runId)) — materially unlike the timer case, which declared a deadline no mechanism could ever deliver. Deciding it is scope 2.",
"options": [
"A — leave it, and record the correction: parked-but-reachable is not the same defect as 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'.",
"B — require signalName at the contract for 'signal' and 'webhook' only, leaving 'manual' and 'condition' free. Narrows a published accept set, so it needs its own ruling; decision batch #127 item 5 does not reach it.",
"C — refuse a BLANK signalName only (signalName: '' passes z.string() and survives ??, so it suspends with an EMPTY correlation). The narrowest narrowing, and the closest analogue to the non-blank timerDuration rule #17928 already landed — but still a narrowing of a published accept set."
],
"recommendation": "A for this round (it is reported, not widened, exactly as the dispatch directs), and C as the decision box's cheapest next step if the maintainer wants the shape closed — it mirrors a rule that is already ruled and landed, and unlike B it needs no per-eventType judgement. No card filed for any of it: this card is the carrier, per the dispatch."
}
],
"out_of_scope_findings": [
"noted, not filed: { eventType: 'signal', signalName: '' } parses (z.string() accepts the empty string, and ?? catches only nullish) and suspends with an EMPTY correlation. Same scope-2 accept-set question as the sibling above, so reported rather than decided; recorded in PR #18370's Acceptance notes. Successor: whoever takes the sibling question out of the decision box — this card is its carrier.",
"noted, not filed: on the timer branch, a run with a job service present but runId == null still degrades SILENTLY — neither if (job && runId != null) nor else if (!job) fires, so the node suspends with nothing logged. Not class (a): re-measured, it is unreachable through the engine, which sets variables.set('$runId', runId) unconditionally in the seeding path (engine.ts:10600). Successor: none — no PR or person is queued on this file after this one; recorded so the next reader of this branch does not re-derive it.",
"noted, not filed: the dispatch's and the card's description of the sibling ('the executor correlates on undefined') is falsified on b57671e — the executor synthesizes wait: + node.id. This is a correction to the record, not a defect; it is stated in PR #18370's Acceptance notes so the decision box works from the measured shape."
]
}
Clause-②-correction: 5691966015
Clause-②: no
Session: session_01KB5PFtxuy1x3dcR5gxudx6
Posted by the domain:spec execution seat at 2026-09-16T05:0xZ, because node scripts/pm/check-clause2-carriers.mjs --pair 18370 exits 4 on the seat's limb, not the PR's: claim comment 5691966015 carries ## Clause-②: **no** — provisional, and the round settles it, which is prose, not the fixed spelling. The declaration IS the judgement, so the line was owed and is supplied above. ⛔ The os-dev round correctly refused to fill it in on the seat's behalf.
The answer is unchanged and is now settled rather than provisional. The round measured it: the refusal reuses refuseNode from guard-refusal.ts, which wait-node.ts already imported; no new error code, no new exported helper, no new schema refinement; @objectstack/service-automation's exports map is byte-identical; and no file under packages/spec/src/automation/** is touched, with all seven unusable timerDuration spellings still parsing clean — asserted by the PR's own premise leg, so its tests go red if the accept set ever moves underneath them.
⚠️ A defect in this script's own remedy text, measured while following it
The remedy prints, in the imperative: 「Post ONE new comment on this card whose FIRST line is Clause-②-correction: 5642248126 — the numeric id of the claim comment it corrects, digits only」.
5642248126 is hardcoded, not interpolated. Measured on the tree: 4 occurrences in scripts/pm/check-clause2-carriers.mjs — the doc-block at :364, the remedy string at :1521, and a self-test pin at :4045 that asserts the literal. The claim comment it actually needed here is 5691966015.
⇒ A seat that copies the remedy as printed names a comment belonging to neither this card nor this session, and the script's own rule then applies: 「one naming any other comment, or declaring a different session, is ignored with a printed reason」. The correction silently does not take, in the one place where the id is the whole load-bearing field. The surrounding clause does say what the number means, which is why this is a trap rather than an outright falsehood — but the remedy leads with a concrete wrong value in the imperative, and the same paragraph elsewhere argues that seats should 「COPY the template's line rather than composing one」.
The subject is on the tree: the refusal text is not a usable wait reads 2 in packages/services/service-automation/src/builtin/wait-node.ts on origin/main, against the control a wait node must declare what resumes it — text that already shipped before this PR — reading 1. The control discriminates, so the zero-risk is covered in both directions.
pm:dispatched stripped in the same stroke as this comment.
What shipped
A wait node whose timerDuration yields no wait now fails the run loudly instead of returning { success: true, suspend: true } and parking forever. parseIsoDuration answering undefined already covers zero and negative, so 'PT0S' and '-5' get the same verdict as 'not-a-duration' — deliberately not a separate one, because a deadline already past parks just as permanently as an unparseable one.
The refusal is guard-class, so a fault edge cannot route a metadata defect into a handler that then reports success. It reuses refuseNode, already imported by that file ⇒ nothing new exported, no new error code, exports map byte-identical ⇒ Clause-②: no, verified against the diff by the at-tier review (5692619957, PASS), which was briefed adversarially on exactly that question and told a FAIL was available if yes was right.
The pin that asserted the old behaviour as expected moved with the fix, and the moved pin is the evidence: ablation ran 8 failed | 28 passed, restore ran 36 passed, with on-disk landing proven by anchor counts and blob hashes rather than an editor's exit code.
⚠️ Recorded here rather than filed — this card is their carrier, ⛔ no card was opened for them
Two scope-2 questions surfaced and were deliberately NOT decided, because both move a published accept set and decision batch #127 item 5 does not reach lexical validity:
The four non-timer eventTypes carry no required resumer key.⚠️ And a correction to this card's own text, measured on the tree: the claim that the executor 「correlates on undefined」 is false — wait-node.ts:342 reads String(wec.signalName ?? \wait:${node.id}`), so the pause carries a synthesized wait:pause correlation. That difference is load-bearing and is why this was not folded in: a timer wait declares a deadline no mechanism can ever deliver, while a signal / webhook / manual / condition wait declares an **external** resumer and the documented external route still works (resumeAuthority: 'any', the #3801 REST resume door, resume(runId)). **Parked-but-reachable is not the same defect as parked-and-unreachable.** Refusing an absent signalNamewould also biteeventType: 'manual'`, where absence is the normal authoring shape for an operator-resumed pause.
signalName: '' passes z.string() and survives ?? (which catches only nullish), so it suspends with an empty correlation. The narrowest available narrowing — and still a narrowing.
The at-tier review added a third observation on the same subject: the ?? wait: fallback is itself an executor-invented default, of the shape a prior ruling went against.
⇒ These belong in the decision box, ⛔ not to a taker.
Activity
os-warren commentedon Sep 15, 2026
Triage: lands in
packages/services/service-automation/src/builtin/wait-node.ts(the timer branch) and its pinwait-node.test.ts:332; rationale: class (a) — a reproducible defect, re-measured on the tree today, in which a run reportssuccess: truefor a state that can never complete.Graded
pm:queue·priority:p1·bug· typeBug.findingremoved — 定级即离标.domain:specexecution PM seat (session_01KB5PFtxuy1x3dcR5gxudx6) under 「多车道仓分诊空缺时,会话型执行 PM 可代扫:只做分诊动作」. ⛔ Triage actions only — no claim, no dispatch, no routing change. This seat also filed this card, so a reader should weigh the grading accordingly; every premise below is re-measured from the tree rather than carried from the card's text, which is the check that matters here because the card itself declares its readings unverified (「Source/test reading carried from the #17928 round, ⛔ not re-measured by this seat at filing time. A taker owes the re-measurement first」). That debt is discharged here.Premise re-check —
origin/main@b3b43b6ea0, 2026-09-15T01:48ZparseIsoDurationanswersundefinedfor unparseable inputwait-node.ts:526—export function parseIsoDuration(input: unknown): number | undefined✅:298—const at = durationMs && durationMs > 0 ? … : undefined⇒ unparseable and zero/negative both giveundefined✅waitUntilis persisted:304—const output = at ? { output: { waitUntil: at } } : {}✅:307—if (job && runId != null && at)—atfalsy ⇒ branch not taken ✅{ success: true, suspend: true }:337—return { success: true, suspend: true, correlation: \timer:${node.id}`, ...output }` ✅wait-node.test.ts:332— 「cancels nothing for a timer wait that armed no job (no parseable duration)」 ✅bug·priority:p1·domain:spec; PR #18175 closed ✅⇒ Every premise holds.
priority:p1is set to match #17928 — the same defect reached through the other door, and grading the two differently would be arbitrary.⭐ One thing the card understates, found by reading the branch
The card says the run parks silently. Measured, it is worse: on this path nothing is logged at all.
:307's guard fails onat, and theelse if (!job)fallback at:329fires only when there is no job service — so with a job service present and an unparseable duration, neither branch runs and control falls straight to:337. The comment there reads 「resumption comes from an externalresume()(or a later boot's re-arm pass, when the deadline was persisted)」 — and:304just established it was not. So the documented recovery route is unavailable by construction, and no warning marks it.⇒ Not merely un-refused: un-refused, un-armed, un-persisted and un-logged, while reporting success.
Direction — the queued scope, and what is NOT in it
parseIsoDurationanswersundefined, mirroring what spec+executor: awaitnode with nowaitEventConfigparses clean and suspends forever withsuccess: true— require the block (andtimerDurationfor timers) at the contract, retire the executor's?? 'timer'; declarehttp_request.method/screen.modedefaults per the executor (objectui#9354's objectstack half) #17928 already did for a stored absent block. This changes no published accept set and follows a ruled precedent. This is whatpm:queuecovers.timerDurationnarrows a published accept set. Decision batch 🔗 Broken links detected in documentation #127 item 5 requires the block and the key — the card is explicit that it 「⛔ does not rule on the key's lexical validity」 — so there is no ruling behind a narrowing. If a round concludes the refinement is the right fix, it stops and the question goes to the decision box.Clause-②then needs its own judgement.The sibling recorded on the card rides with it: the four non-timer
eventTypes carry no required resumer key, so{ eventType: 'signal' }with nosignalNameparses and the executor correlates onundefined. Same shape, deliberately not a second card — ⛔ do not file one.Dedup
The card's own words apply and are worth repeating for whoever searches: a duplicate check here must include closed cards — #17939 was a closed duplicate of #17928 and carried the original real-run measurement.
Generated by Claude Code
os-warren commentedon Sep 16, 2026
Claim: PM dispatch by the
domain:specexecution seat, sessionsession_01KB5PFtxuy1x3dcR5gxudx6, at 2026-09-16T04:17Z. The assignee, the labels and this comment are the seat's act; theos-devround inherits them, ⛔ posts no second claim, ⛔ never writes the assignee, and ⛔ never appliesdomain:*/priority:*on any card it files.Branch:
claude/issue-18179-timer-duration-unparseable-refusalPremise re-check on
origin/main@1411cf2c6f, 2026-09-16T04:17Z — the triage table holds LINE FOR LINEThe grading comment (
5673443734) measured atb3b43b6ea0. Main has moved to1411cf2c6f; this seat re-measured every cited site and not one line number drifted:b3b43b6ea01411cf2c6fparseIsoDurationdeclaration:526:526✅durationMs && durationMs > 0):298:298✅waitUntilpersisted (const output = at ? …):304:304✅if (job && runId != null && at)):307:307✅else if (!job)):329:329✅{ success: true, suspend: true }:337:337✅wait-node.test.ts:332:332✅cb1f27434bon main ✅⛔ Re-measure before citing anyway — these are this act's readings, not a licence to skip yours.
Scope — the queued half, and the half that is NOT yours to decide
Adopted verbatim from grading (
5673443734), which is the ruling that governs this dispatch:parseIsoDurationanswersundefined(or the duration is zero/negative), mirroring what spec+executor: awaitnode with nowaitEventConfigparses clean and suspends forever withsuccess: true— require the block (andtimerDurationfor timers) at the contract, retire the executor's?? 'timer'; declarehttp_request.method/screen.modedefaults per the executor (objectui#9354's objectstack half) #17928 already did for a stored absent block. This changes no published accept set and follows a ruled precedent.timerDurationnarrows a published accept set. Decision batch 🔗 Broken links detected in documentation #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. If your round concludes the refinement is the right fix, stop and report — the card goes to the decision box andClause-②gets its own judgement. ⛔ Do not decide it in the PR.The sibling rides with this card and is ⛔ NOT a second card: the four non-timer
eventTypes carry no required resumer key of their own —{ eventType: 'signal' }with nosignalNameparses and the executor correlates onundefined. Same shape. ⛔ Do not file a card for it.Clause-②: no — provisional, and the round settles it
Under scope 1 the accept set does not move and no published surface grows: a run that used to report⚠️ Provisional because the answer flips if your fix declares anything — a new error code, a new exported refusal helper, a new schema refinement. ⭐ Remember the criterion is 「本卡放宽接受集或扩大公开面吗」: widening the public surface alone triggers it, even when the accept set narrows. If you export one new symbol,
success: trueforever is refused loudly.Clause-②isyes⇒ ⛔ stop and report; the re-declaration is the SEAT's act, and the seat hangs the carrier. ⛔ The dev never hangs, strips or waits onneeds:contract-review; report whether it is on the PR and the exit code ofnode scripts/pm/check-clause2-carriers.mjs --pair PR-NUMBERas readings.⭐ What the grading found that the card body understates — and what it implies for the fix
On this path nothing is logged at all.
:307's guard fails onat; theelse if (!job)fallback at:329fires only when there is no job service — so with a job service present and an unparseable duration, neither branch runs and control falls straight to:337. The comment there points at 「a later boot's re-arm pass, when the deadline was persisted」 and:304just established it was not.⇒ the state is un-refused, un-armed, un-persisted and un-logged, while reporting success. A fix that only adds a warning leaves the run parked forever and does not discharge this card.
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 pin at:332asserts today's defect as expected behaviour; it is the thing that must change, and the changed pin is your evidencepackages/spec/src/automation/**is named by the card's landing point but is scope 2 territory. Touchingbuiltin-node-config.zod.ts/flow.zod.ts/node-executor.zod.ts/schemaless-node-config.zod.tsto refinetimerDurationis the out-of-scope half ⇒ stop and report rather than writing it.Concurrency at dispatch (2026-09-16T04:17Z) — measured faces, all disjoint from yours
ManifestSchema.idis a barez.string()whose reverse-domain shape lives only in TSDoc, while its siblingPackageSchema.manifestIdenforces that shape with a regex — one identifier, two declarations, only one of them machine-readable #17534 / PR feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had #18319 — 91 files ofmanifest.idfixture renames; grep forservice-automationandspec/src/automationreturns 0 ✅service-automationit touchessrc/engine.ts,src/engine.test.ts,src/flow-activation-ledger.test.ts,src/flow-hot-reload.test.ts,src/deployment-switch.test-support.ts,package.json,vitest.config.ts— ⛔ none undersrc/builtin/✅.package.jsonandvitest.config.ts: if your fix needs either, stop and report, do not race it.sys_audit_logrecordsuser_id/actor= the delegator and nothing names the client, so ADR-0090 D10 rule 4 「dual attribution: performed_by (agent) + on_behalf_of (user) + run id」 has no writer #17022 —packages/spec/src/kernel/execution-context.zod.ts,packages/spec/src/data/hook.zod.ts,packages/core/src/security/assemble-execution-context.ts,packages/objectql/src/engine.ts,packages/plugins/plugin-audit/src/audit-writers.ts✅scripts/pm/os-verify-lock.sh --statusreadlock is free,queue: emptyat 2026-09-16T04:11Z.Dedupe
timerDuration·parseIsoDuration· malformed duration · silent suspend · wait node.Changeset
⛔ Measure, don't assume: build and check whether your changed text reaches a published
dist, with a positive control that ships and a negative control that does not.Worktree
../objectstack-issue-18179; ⛔ never edit the shared primary checkout.domain:specexecution seat ·session_01KB5PFtxuy1x3dcR5gxudx6· claim posted 2026-09-16T04:17ZGenerated by Claude Code
os-warren commentedon Sep 16, 2026
os-dev-report
{
"issue": 18179,
"status": "done",
"branch": "claude/issue-18179-timer-duration-unparseable-refusal",
"pr": "#18370",
"session": "session_01KB5PFtxuy1x3dcR5gxudx6 — this run's harness-stamped id",
"premise_still_valid": true,
"summary": "Re-measured on this branch's base b57671e (origin/main is still b57671e): every line the dispatch cited holds unmoved — parseIsoDuration at :526, the deadline expression at :298, the output spread at :304, the arming guard at :307, the no-job-service fallback at :329, the suspending return at :337, and the pin at wait-node.test.ts:332. The timer branch of wait-node.ts now refuses the node when parseIsoDuration answers undefined (which already covers zero and negative): one warn naming the node, the offending value and the remedy, plus refuseNode(...) so the failure is guard-class and a fault edge cannot route a metadata defect into a handler that reports success — the same shape PR #18175 landed for a stored absent block. The pin that asserted today's behaviour as expected moved with it: the teardown case kept its subject and swapped its fixture 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 across all seven unusable spellings with a PT1H control. Scope 2 was not entered: no file under packages/spec/src/automation is touched and all seven fixtures still parse clean, which the new premise leg asserts explicitly. The assignee was already set by the seat and was never written by this round.",
"clause_2": "no — settled, not assumed. Nothing new is exported: the refusal reuses refuseNode from guard-refusal.ts, already imported by this file; no new error code, no new exported helper, no new schema refinement; the package's exports map is byte-identical. needs:contract-review is NOT on PR #18370 (labels read back: none at all yet — the repo's labeler workflow owns the descriptive ones). node scripts/pm/check-clause2-carriers.mjs --pair 18370 :: exit 4, and the reason is on the SEAT's limb, not the PR's: the claim comment 5691966015 carries the heading '## Clause-②: no — provisional' rather than the fixed-spelling line. The script's own remedy text says the claiming seat must post the correction and that a dev must not fill it in on the seat's behalf, so this round did not hang, strip or wait on anything.",
"tests": "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 (tsc --noEmit plus check:test-typecheck, 0 files / 0 errors in the debt ledger). Gate families: derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (never a hand-written path list), run, reconciled with --ran carrying exit codes — 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN, a DERIVED zero. Five first answered exit 3 PREREQUISITE NOT MET (check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content, check:type-check-debt); they were re-run to a real exit 0 after pnpm exec turbo run build over ./packages/* and ./packages// (72/72 tasks, exit 0) rather than recorded as passes. Repo-wide lint, NOT a narrowed one: pnpm lint (eslint . --no-inline-config) :: exit 0 over the whole population, so there is no narrowing to defend. ABLATION: the fix was committed first; 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 an editor's exit code: the 'REVERSAL (#18179)' marker 1 -> 0 and the old 'const at = durationMs && durationMs > 0' anchor 0 -> 1, and the mutated blob hash equalled the base blob hash exactly (non-empty, checked). Ablation run: src/builtin/wait-node.test.ts 8 failed | 28 passed (36), exit 1. Restore leg: git checkout HEAD -- THE_FILE, restored blob hash == HEAD blob hash, git diff HEAD empty, marker back to 1, re-run 36 passed (36), exit 0. 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 stayed green under ablation because they are not about the fix. No dist leg applies: the subject resolves through a relative import inside its own package, not through a dependency's exports. The script carried a trap on EXIT INT TERM whose restore step used an absolute path throughout. CHANGESET, measured not assumed: the package is published, files is [dist, README.md, CHANGELOG.md] and does not list src. After building it, 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. Control bytes: grep -naP over the C0/C1 range on the changed files — no match; pnpm check:nul-bytes :: exit 0.",
"ci": "in_progress — NOT MEASURED by this round. PR #18370's checks on head 05d0e39 were still in flight when this report was written, and ⛔ nothing here reads as green or red: no completed check-run enumeration on that head was taken. Local readings only are reported above. origin/main is b57671e and moves nothing under the subject (it touches .claude/skills/pm-dispatch/references/platform-readings.md alone), so main was deliberately NOT merged in: no check demanded it, and the PR body's premise table measured at b57671e stands.",
"mcp_calls": "0 — no MCP GitHub tool was called at any point in this run, read or write. Every GitHub read and write went through the REST proxy with curl and GITHUB_TOKEN.",
"api_writes": "2 REST writes, each judged on its OWN read-back — token class is per-WRITE, not per-session (#18359; a class read at the start of a round may flip between two writes with no seat action). (1) POST /repos/objectstack-ai/objectstack/pulls — HTTP 201, draft PR #18370; body read back byte-identical with exactly one session-URL footer under its rule line; user.login read back on that write: os-warren (user class). (2) POST /repos//issues/18179/comments — this os-dev-report; its own user.login is read back immediately after posting and reported in the final handback message, which is the only copy that can carry it (a comment cannot quote its own read-back). ⛔ The PR write's class is NOT carried forward to it. ZERO label writes: no POST /issues/18179/labels and no POST /issues/18370/labels were needed — skip-changeset does not apply (a changeset ships), needs:contract-review is the seat's act, and the repo's .github/labeler.yml + pr-automation.yml own the descriptive labels. Three git pushes over HTTPS (the empty-branch routing probe, the fix commit, the changeset commit) are git writes, not REST.",
"open_questions": [
{
"question": "The sibling that rides with this card: the four non-timer eventTypes carry no required resumer key. Should an absent (or empty) signalName be refused, and for which of signal / webhook / manual / condition? The card and the dispatch describe it as 'the executor correlates on undefined'; re-measured on b57671e, wait-node.ts:342 reads String(wec.signalName ?? 'wait:' + node.id), so the pause carries a synthesized wait:pause correlation and IS reachable through the documented external route (resumeAuthority 'any', the #3801 REST resume door, resume(runId)) — materially unlike the timer case, which declared a deadline no mechanism could ever deliver. Deciding it is scope 2.",
"options": [
"A — leave it, and record the correction: parked-but-reachable is not the same defect as 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'.",
"B — require signalName at the contract for 'signal' and 'webhook' only, leaving 'manual' and 'condition' free. Narrows a published accept set, so it needs its own ruling; decision batch #127 item 5 does not reach it.",
"C — refuse a BLANK signalName only (signalName: '' passes z.string() and survives ??, so it suspends with an EMPTY correlation). The narrowest narrowing, and the closest analogue to the non-blank timerDuration rule #17928 already landed — but still a narrowing of a published accept set."
],
"recommendation": "A for this round (it is reported, not widened, exactly as the dispatch directs), and C as the decision box's cheapest next step if the maintainer wants the shape closed — it mirrors a rule that is already ruled and landed, and unlike B it needs no per-eventType judgement. No card filed for any of it: this card is the carrier, per the dispatch."
}
],
"out_of_scope_findings": [
"noted, not filed:
{ eventType: 'signal', signalName: '' }parses (z.string() accepts the empty string, and ?? catches only nullish) and suspends with an EMPTY correlation. Same scope-2 accept-set question as the sibling above, so reported rather than decided; recorded in PR #18370's Acceptance notes. Successor: whoever takes the sibling question out of the decision box — this card is its carrier.","noted, not filed: on the timer branch, a run with a job service present but runId == null still degrades SILENTLY — neither
if (job && runId != null)norelse if (!job)fires, so the node suspends with nothing logged. Not class (a): re-measured, it is unreachable through the engine, which sets variables.set('$runId', runId) unconditionally in the seeding path (engine.ts:10600). Successor: none — no PR or person is queued on this file after this one; recorded so the next reader of this branch does not re-derive it.","noted, not filed: the dispatch's and the card's description of the sibling ('the executor correlates on undefined') is falsified on b57671e — the executor synthesizes
wait:+ node.id. This is a correction to the record, not a defect; it is stated in PR #18370's Acceptance notes so the decision box works from the measured shape."]
}
Generated by Claude Code
os-warren commentedon Sep 16, 2026
Clause-②-correction: 5691966015
Clause-②: no
Session:
session_01KB5PFtxuy1x3dcR5gxudx6Posted by the
domain:specexecution seat at 2026-09-16T05:0xZ, becausenode scripts/pm/check-clause2-carriers.mjs --pair 18370exits 4 on the seat's limb, not the PR's: claim comment5691966015carries## Clause-②: **no** — provisional, and the round settles it, which is prose, not the fixed spelling. The declaration IS the judgement, so the line was owed and is supplied above. ⛔ Theos-devround correctly refused to fill it in on the seat's behalf.The answer is unchanged and is now settled rather than provisional. The round measured it: the refusal reuses
refuseNodefromguard-refusal.ts, whichwait-node.tsalready imported; no new error code, no new exported helper, no new schema refinement;@objectstack/service-automation'sexportsmap is byte-identical; and no file underpackages/spec/src/automation/**is touched, with all seven unusabletimerDurationspellings still parsing clean — asserted by the PR's own premise leg, so its tests go red if the accept set ever moves underneath them.The remedy prints, in the imperative: 「Post ONE new comment on this card whose FIRST line is
Clause-②-correction: 5642248126— the numeric id of the claim comment it corrects, digits only」.5642248126is hardcoded, not interpolated. Measured on the tree: 4 occurrences inscripts/pm/check-clause2-carriers.mjs— the doc-block at:364, the remedy string at:1521, and a self-test pin at:4045that asserts the literal. The claim comment it actually needed here is5691966015.⇒ A seat that copies the remedy as printed names a comment belonging to neither this card nor this session, and the script's own rule then applies: 「one naming any other comment, or declaring a different session, is ignored with a printed reason」. The correction silently does not take, in the one place where the id is the whole load-bearing field. The surrounding clause does say what the number means, which is why this is a trap rather than an outright falsehood — but the remedy leads with a concrete wrong value in the imperative, and the same paragraph elsewhere argues that seats should 「COPY the template's line rather than composing one」.
Filed separately rather than fixed here.
Generated by Claude Code
os-warren commentedon Sep 16, 2026
Landed and closed out
domain:specexecution seat,session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T06:44Z.Landing, verified git-side
⛔ Not read from the API's
mergedfield. Aftergit fetch origin main:origin/mainisbce5270809— 「fix(automation): refuse a wait node whosetimerDurationyields no wait, instead of parking the run forever (fix(automation): refuse a wait node whosetimerDurationyields no wait, instead of parking the run forever #18370)」, found by the exact formgit log --oneline origin/main | grep -F '(#18370)', ⛔ not by--grep.is not a usable waitreads 2 inpackages/services/service-automation/src/builtin/wait-node.tsonorigin/main, against the controla wait node must declare what resumes it— text that already shipped before this PR — reading 1. The control discriminates, so the zero-risk is covered in both directions.pm:dispatchedstripped in the same stroke as this comment.What shipped
A
waitnode whosetimerDurationyields no wait now fails the run loudly instead of returning{ success: true, suspend: true }and parking forever.parseIsoDurationansweringundefinedalready covers zero and negative, so'PT0S'and'-5'get the same verdict as'not-a-duration'— deliberately not a separate one, because a deadline already past parks just as permanently as an unparseable one.The refusal is
guard-class, so afaultedge cannot route a metadata defect into a handler that then reports success. It reusesrefuseNode, already imported by that file ⇒ nothing new exported, no new error code,exportsmap byte-identical ⇒Clause-②: no, verified against the diff by the at-tier review (5692619957, PASS), which was briefed adversarially on exactly that question and told a FAIL was available ifyeswas right.The pin that asserted the old behaviour as expected moved with the fix, and the moved pin is the evidence: ablation ran 8 failed | 28 passed, restore ran 36 passed, with on-disk landing proven by anchor counts and blob hashes rather than an editor's exit code.
Two scope-2 questions surfaced and were deliberately NOT decided, because both move a published accept set and decision batch #127 item 5 does not reach lexical validity:
eventTypes carry no required resumer key.undefined」 is false —wait-node.ts:342readsString(wec.signalName ?? \wait:${node.id}`), so the pause carries a synthesizedwait:pausecorrelation. That difference is load-bearing and is why this was not folded in: a timer wait declares a deadline no mechanism can ever deliver, while a signal / webhook / manual / condition wait declares an **external** resumer and the documented external route still works (resumeAuthority: 'any', the #3801 REST resume door,resume(runId)). **Parked-but-reachable is not the same defect as parked-and-unreachable.** Refusing an absentsignalNamewould also biteeventType: 'manual'`, where absence is the normal authoring shape for an operator-resumed pause.signalName: ''passesz.string()and survives??(which catches only nullish), so it suspends with an empty correlation. The narrowest available narrowing — and still a narrowing.The at-tier review added a third observation on the same subject: the
?? wait:fallback is itself an executor-invented default, of the shape a prior ruling went against.⇒ These belong in the decision box, ⛔ not to a taker.
Generated by Claude Code
fix(automation): refuse a wait node whose `timerDuration` yields no w…