Commit bce5270
fix(automation): refuse a wait node whose
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 #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 build** — `pnpm --workspace-concurrency=2
--filter '@objectstack/service-automation^...' build` :: exit 0.
- **Package tests** — `pnpm --filter @objectstack/service-automation
test` :: exit 0 — **136 files, 1625 tests passed**.
- **Package typecheck** — `pnpm --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 one** — `pnpm 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 bytes** — `grep -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 #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](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>timerDuration yields no wait, instead of parking the run forever (#18370)1 parent 271d6bb commit bce5270
3 files changed
Lines changed: 367 additions & 29 deletions
File tree
- .changeset
- packages/services/service-automation/src/builtin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
Lines changed: 247 additions & 21 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
23 | | - | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
24 | 33 | | |
25 | 34 | | |
26 | 35 | | |
27 | | - | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
28 | 40 | | |
29 | 41 | | |
30 | 42 | | |
| |||
329 | 341 | | |
330 | 342 | | |
331 | 343 | | |
332 | | - | |
333 | | - | |
| 344 | + | |
| 345 | + | |
334 | 346 | | |
335 | | - | |
336 | | - | |
337 | | - | |
338 | | - | |
339 | | - | |
340 | | - | |
341 | | - | |
342 | | - | |
343 | | - | |
344 | | - | |
345 | | - | |
346 | | - | |
347 | | - | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
348 | 361 | | |
349 | 362 | | |
350 | | - | |
| 363 | + | |
| 364 | + | |
351 | 365 | | |
352 | 366 | | |
353 | 367 | | |
| |||
374 | 388 | | |
375 | 389 | | |
376 | 390 | | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
377 | 603 | | |
378 | 604 | | |
379 | 605 | | |
| |||
0 commit comments