Skip to content

fix(service-automation): a completed run is no longer answered failed — or re-executed under strategy: retry — by its own history write - #17565

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-16274-initial-completion-history-guard
Sep 10, 2026
Merged

os-sales merged 3 commits into
mainfrom
claude/issue-16274-initial-completion-history-guard

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #16274

A completion-path recordLog({ status: 'completed' }) was called from inside the try whose catch exists for node failures, on both initial-execution paths. So a throw out of a history write, on a run that had already finished, was handled as though a node had thrown. This is the guard PR #16273 landed on resumeInternal's completion path, on the two sites that card deliberately scoped out.

⭐ The measurement came first, and it moved the card's grade

Triage attached a re-grade trigger and asked for it to be driven before any code was written — 「若它能重试一个已经完成的 run,本卡立刻从 p3 变成 p2 甚至更高」. It was driven on origin/main (9540590ba) with the doubles PR #16273 already shipped, and it can. Posted on the card first: #16274 (comment)

# path history sink node executions caller is told
M1 execute() store recordTerminal throws SYNCHRONOUSLY 1 the promise REJECTS — no envelope at all
M2 execute() host logger.info throws, NO store 1 success: false, status: 'failed', the log transport's text as the run's error
M3 retry host logger.info throws, NO store 3 same failed envelope
M4 retry store recordTerminal throws SYNCHRONOUSLY 1 the promise REJECTS (the strategy branch is never reached — not safety)
M5 retry healthy — CONTROL 1 success: true
M6 retry healthy, GENUINE node failure — CONTROL 3 success: false, status: 'failed', the node's text
M7 execute() healthy — CONTROL 1 success: true

M3 against M5 and M6 is the finding. Same flow, same maxRetries: 2, same counter: healthy sinks run the node once; a genuine node failure runs it three times (retry working correctly); and a run whose every node SUCCEEDED also runs three times — because the false failed is handed to retryExecution by the strategy branch, and that loop reads result.success. Three failed rows for one logical success, the node's side effects three times, unattended, inside one execute() call. It needs no store at all: the run-summary line is on by default (this.runSummaryLog = options?.runSummaryLog ?? 'info') and calls a host-injected Logger.

⇒ That is the double run #15944 was graded sharp for, and on one axis sharper: #15944's re-run needed an operator to restore and resume. ⛔ The priority:p3 -> priority:p2 flip is the triage/PM seat's to apply, not this seat's.

The two questions the card left unmeasured, both answered. The throw reaches the generic failure arm at both sites: the thrown value is a plain Error, so isSuspendSignal(err) is false and err instanceof InputSchemaViolationError is false — and the envelope proves both negatives, carrying status: 'failed' with NO code where the input-schema arm returns code: 'FLOW_INPUT_SCHEMA_INVALID' and the suspend arm returns status: 'paused'. And nothing downstream distinguishes it from a genuine node failure: compare M3 with M6 field by field — identical shape, identical discriminator, identical HTTP arm. The only difference is the content of error, which is a message regex away from being readable and therefore not a distinction (PD #12).

⚠️ Three completion sites in the tree; the card named two

Re-located by structure (the card's line numbers were from 48427620a and it said to), on origin/main at 9540590ba:

status: 'completed' enclosing catch enclosing function disposition
:4873 :4913 execute() (:4681) guarded here
:6089 :6154 resumeInternal() (:5609) ALREADY GUARDED by PR #16273 — ⛔ untouched
:10161 :10179 executeWithoutRetry() (:10064) guarded here

The card's "the retry path" site is executeWithoutRetry(), not retryExecution() (:9851): the latter holds the loop and the backoff and calls the former once per attempt, which is where that attempt's completion write and its node-failure catch live. ⚠️ Fixing execute() alone would not have closed it — a flow under strategy: 'retry' whose attempt 2 completes leaves through executeWithoutRetry()'s exit and never crosses execute()'s again.

What changed

  1. fix(service-automation) — each completion-path recordLog is guarded at its own call site, the summary is recomputed with the same pure function (logged?.summary ?? summarizeRun(steps)), and the swallowed failure is reported once at error with its consequence and fix. The precedent's shape, twice; ⛔ no third design. ⛔ No catch arm's meaning is widened: a genuine node failure still lands in the node-failure arm, still records failed, still carries the node's own text, and is still retried the full 1 + maxRetries times — pinned by two controls.
  2. chore(gates) ×2 — AGENTS.md's rule for check:durability-log-level is "it cannot discover a new seam, only stop known ones from regressing; found a new one, add it to DURABILITY_CRITICAL_CALLEES in the same PR that fixes it." recordLog is that seam. Measured before landing it: the population goes 29 -> 33 catch seams (this card's two guards plus the two the resume path already landed), all four judged LOUD, gate green, zero unrecognised constructs, the shrink-only baseline still empty. The paired by-value copy in measure-durability-swallow-family.mjs then reddened on drift exactly as designed (declared by the gate, missing from the copy: recordLog) and was carried over, its own remedy text followed.

⚠️ The level question this lane carries, and which side of #13398 this site is on

The card's suggested remedy says report at error, and both precedents (#16273, #15555) landed that level — but this lane carries the standing ruling that a site reporting through a published sink shape must not be raised to error. Established, not assumed:

  • the ruling's own criterion, as the landed [Decision] plugin-sharing's refused-backfill report lands at warn where AGENTS.md puts it at error — and the card that was supposed to carry the level is CLOSED #13398-class sites state it, is that what is forbidden is raising a site to error where doing so means GROWING error? onto a published sink that lacks it (its option B). A sink that already declares error widens nothing — share-link-service.ts says so in those words and takes error on that basis;
  • this site's sink is Logger from @objectstack/spec/contracts, where error(message, error?, meta?) is a REQUIRED member. Only fatal?, child? and withTrace? are optional. Nothing is grown, no warn fallback branch is owed, and no sink type changes in this diff;
  • corroborated mechanically: pnpm check:optional-error-sink (exit 0) censuses 50 sink types declaring error under packages/**, of which 14 declare it REQUIRED — "nothing to guarantee". Logger is in that 14, which is also why the two precedents on this same sink could land error.

This site is OUTSIDE #13398's class, so the precedent's shape AND its level are both copied. Reported here rather than resolved silently, as the dispatch required; it is a reading, not a judgement call.

Clause-②: no

Both limbs measured against the diff, not recalled. ① Accept/reject behaviour — no Zod schema, parse path or accept set is touched; AutomationResult.status already declares 'completed' | 'paused' | 'failed' and this change emits no new value, it emits the ABSENT status + success: true that the healthy completion path already returns. ② Public surface — no exported symbol, no new error code, no new key on any published payload; the change is a try/catch around an existing call plus one logger.error, and summarizeRun was already imported in the file. The two gate-script edits are repo tooling, not published (files[] is ["dist", "README.md", "CHANGELOG.md"]). This matches the seat's claim-time declaration.

Verification

RED before GREEN, proven by on-disk state. The fix was committed first, then ablated out of committed state (git checkout 9540590ba -- packages/services/service-automation/src/engine.ts), with the mutation proven by hash and by occurrence counts on the exact text touched, and restored by state afterwards:

HEAD blob       : 8223765305ab4f72b82329c3d1611d9a9addf46c
on-disk BEFORE  : 8223765305ab4f72b82329c3d1611d9a9addf46c   (equal -> tree really was at HEAD)
counts BEFORE   : catch (bookkeeping)=4   marker [#16274]=4   summary recompute=4
on-disk AFTER   : 7f77362b8733e494107e2f31ca809c73a54d6cba   (= the pre-fix blob; != BEFORE -> not a no-op)
counts AFTER    : catch (bookkeeping)=2   marker [#16274]=0   summary recompute=2
restore         : git checkout HEAD -- THE FILE ; `git diff HEAD` EMPTY, `git status --porcelain` EMPTY,
                  on-disk hash back to 8223765305ab4f72b82329c3d1611d9a9addf46c

The ablation script carried trap restore EXIT INT TERM with an absolute git rev-parse --show-toplevel path, and an empty hash was treated as FAILURE rather than as "nothing to compare".

The direction OBSERVED (not the one predicted): 5 pins failed, 3 controls passed — the controls passing on the ablated tree is correct, since they assert behaviour this change does not move. The two readings that matter, quoted from the run:

PIN 3  AssertionError: a completed run is NEVER re-attempted: expected 3 to be 1
PIN 1  AssertionError: a history write must never break the run that produced it: expected 'threw' to be 'returned'

⚠️ No dist/ could have served stale bytes on that leg: packages/services/service-automation/dist did not exist at ablation time (checked and printed by the script — dist present? : NO; only the dependency closure had been built), and the test resolves ./engine.js relatively to the TypeScript source rather than through the package's exports. The mutation changing the verdict is itself the proof that source is what ran.

Runs — every heavy run through scripts/pm/os-verify-lock.sh, quoting its own verdict line:

run result
dependency closure build ('@objectstack/service-automation^...') VERDICT command-exit 0 · held the lock 259s
@objectstack/service-automation pnpm test VERDICT command-exit 0131 files, 1554 tests, all passed
@objectstack/service-automation pnpm typecheck VERDICT command-exit 0tsc --noEmit plus check:test-typecheck (test layer compiles, 0 files / 0 errors in the debt ledger), so the new pins are really type-checked
new pin file, ablated tree VERDICT command-exit 1 — 5 failed / 3 passed (the RED above)
new pin file, restored tree VERDICT command-exit 0 — 8/8
CI-shaped turbo run build --filter='./packages/*' --filter='./packages/*/*' VERDICT command-exit 0 — 72/72 tasks, run to satisfy two gate prerequisites
pnpm lint (whole repo, eslint . --no-inline-config) VERDICT command-exit 0 at 18b17746 — no narrowing claimed, the full scan ran

Gates — derived from the actual diff with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the scripts/** edits (61 commands -> 82), and reconciled. All exit 0, with two earned the hard way:

  • pnpm check:dual-build-cjs-loads first returned exit 3 = PREREQUISITE NOT MET = NOT MEASURED ("this gate reads built output, and some package has no dist/"). ⛔ Not rounded up: the prerequisite was satisfied with the CI-shaped build above and the gate re-run to a real exit 0.
  • pnpm check:type-check-debt likewise: exit 3 -> prerequisite built -> exit 0 (5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number).
  • pnpm check:swallow-census-controls returned a genuine exit 1 on the drift described above, and is exit 0 after the paired copy was carried over.
  • The four artifact-roster families whose roster sits in a directory one of these paths is in — where the derivation says its silence is evidence in neither direction — were read and run anyway: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, all exit 0. So were the gate script's own suites (--self-test: 63 + 57 cases) and the siblings that read it (check:ratchet-remedy-authority, check:cross-package-test-inputs, check-scripts-symbol-anchors, check-declaration-mirrors, check-self-test-wired).

NOT MEASURED, each with its reason — all of them CI-owned, none of them a skipped local obligation: the 5 families whose argv takes a value only a CI run supplies (check-shard-attestation, check-test-completeness); the 5 CI jobs scheduled by these paths whose 27 steps have no local invocation (Test Core shards, Temporal Conformance on live PG/MySQL, the two Dogfood jobs, Build Core); and the 11 families the derivation marks as declaring a population too wide to place — of which the two that actually bear on this diff were run anyway (check:optional-error-sink, check:durability-log-level), the rest being unrelated roots.

Out-of-scope finding — reported, ⛔ not fixed here

Filed as #17562, bare, with a reproduction and a firing control. The failure-arm recordLog({ status: 'failed' }) on these same two paths is unguarded, so on the tree this PR produces, a genuine node failure plus a synchronously throwing store makes execute() reject instead of answering the declared envelope:

store = SYNC-THROW        -> {"kind":"threw","error":"run-history driver refused the terminal row"}
store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"}

A different site with a different consequence (the run really did fail, so nothing misleads an operator — what is lost is the SHAPE), which is why it is a separate card and is deliberately not pinned here: freezing a known defect in this PR's test file would be worse than naming it. #17562 is not addressed by this PR.

⛔ Untouched, as the card and the dispatch both fence: resumeInternal's already-guarded site, restoreConsumedSuspension, inspectStrandedRequests (#15358), suspended-run-store.ts and sys-automation-run.object.ts (held by the #15336 dev in flight), and packages/spec/**.

Authored by Claude Code in session session_01ToDPcx9AESFubJkDiFMtKW — attribution in prose deliberately, because a PR body's trailing rule line plus footer block is eaten by the platform on write while the call still reports success.

Generated by Claude Code


Generated by Claude Code

…istory writes (#16274)

`execute()` and `executeWithoutRetry()` called `recordLog({ status:
'completed' })` from inside the `try` whose `catch` exists for node failures,
so a throw out of a history write on a run that had already finished was
handled as though a node had thrown.

Measured on the unpatched tree, and sharper than the report assumed: the false
`failed` is handed to `retryExecution` by the strategy branch, whose loop reads
`result.success` and re-executes the whole flow — `1 + maxRetries` runs of every
node for one logical success, unattended, with one `failed` history row per
attempt. Controls: the same flow on healthy sinks runs the node once; a genuine
node failure runs it `1 + maxRetries` times.

Guarded at each call site in the shape the resume path already landed: report
the swallowed failure once at `error` with its consequence and fix, recompute
the summary with the same pure function. No `catch` arm's meaning is widened.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <[email protected]>
…16274)

AGENTS.md's rule for this gate: "it cannot discover a new seam, only stop
known ones from regressing; found a new one, add it to
DURABILITY_CRITICAL_CALLEES in the same PR that fixes it."

`recordLog` is that seam. Measured: the population goes 29 -> 33 catch seams,
the four newly covered are this card's two guards plus the two the resume path
already landed, all four are judged LOUD, and the gate stays green with zero
unrecognised constructs. The empty shrink-only baseline stays empty.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <[email protected]>
…16274)

`measure-durability-swallow-family.mjs` keeps a deliberate by-value copy of the
gate's `DURABILITY_CRITICAL_CALLEES` under `origin: 'gate-vocabulary'`, and its
`--self-test=gated` reddens on drift — which it did, naming `recordLog` as
"declared by the gate, missing from the copy". Its own remedy text: a name the
gate gained belongs in the copy.

check:swallow-census-controls: 21 copied gate-vocabulary names now match the
gate's declaration, exit 0.

Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 245f3607d3c8bb0a5f828d782a758c53c86aa5a8packageMentionDocs.

Which tree this was computed on

This run read content/docs from fc6c18d6a55eca8d4cf0f1e95a6e2c8b440bbfa6 — the merge of head 18b177466154e1a4ac78cf5ebaa435e56d35f973 into base 245f3607d3c8bb0a5f828d782a758c53c86aa5a8, 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 fc6c18d6a55eca8d4cf0f1e95a6e2c8b440bbfa6 && git checkout fc6c18d6a55eca8d4cf0f1e95a6e2c8b440bbfa6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 245f3607d3c8bb0a5f828d782a758c53c86aa5a8 18b177466154e1a4ac78cf5ebaa435e56d35f973 && git checkout -B drift-repro 245f3607d3c8bb0a5f828d782a758c53c86aa5a8 && git merge --no-ff 18b177466154e1a4ac78cf5ebaa435e56d35f973

node scripts/docs-audit/affected-docs.mjs --json 245f3607d3c8bb0a5f828d782a758c53c86aa5a8

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

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Answering the Docs Drift Check — on the tree it named, plus the read it says it can never do

Re-derived on the bot's own tree, not on my worktree's: fetched the merge commit fc6c18d6a55eca8d4cf0f1e95a6e2c8b440bbfa6 (head 18b177466 into base 245f3607d) into a detached worktree and ran node scripts/docs-audit/affected-docs.mjs --json 245f3607d3c8bb0a5f828d782a758c53c86aa5a8 there. Same answer as the bot's, from the same bytes: docs: [], anchorlessChanges: [], unanchoredRuleBlocks: [], overbroadAnchors: [], testFilesSkipped: 1.

It also names what the bot's prose summarised: the single anchor is executeWithoutRetry (symbol, a method of class AutomationEngine), and weakAnchorsDropped: ["execute (symbol)"] is the "too generic to anchor anything" row. So both halves of the derived surface were hand-read, not just the listed one.

Control for every zero. The instrument fires: automation matches 124 pages under content/docs, a nonsense token (zzznotaterm) matches 0. So the zeros below are readings. Hand-read tokens and their counts: runSummaryLog 1 page, errorHandling 8, maxRetries 18, AutomationResult 6, FLOW_DISABLED 9, run history 6 — and recordLog 0, recordTerminal 0, summarizeRun 0, suspendedRunStore 0 (those four are internal, which is why the derivation could only reach this diff through executeWithoutRetry).

The emitter-blind read, which is where a real answer had to come from

The bot's own warning: "a page that states a rule by its inputs shares no identifier with the emitter that implements the rule… a rule your change carries has to be re-read by hand in the pages that restate it." This diff carries exactly that kind of rule, so here are the three pages that restate it, each named with its collision rather than waved past.

1. content/docs/automation/flows.mdx:1346 — the retry rule, stated by its authoring inputs.

errorHandling: { strategy: 'retry', maxRetries: n } | the whole flow | the flow re-runs from the start, up to n more times

Not falsified, and the nearest miss in the set. The row's subject is a flow that FAILED. What this PR removes is a re-run of a flow that did not fail — every node succeeded and only its own history write threw — which this row never described. The row is if anything more true afterwards: before this PR a completed flow re-ran from the start up to n more times, a sentence no page claims. And the accounting the row does describe is pinned unchanged by a control (a genuine node failure still runs 1 + maxRetries times).

2. content/docs/api/client-sdk.mdx:711 — the door's rule, stated by its error codes.

client.automation.execute() rejects when the flow does not run — it does not resolve with an inner { success: false }

Not falsified; this PR moves the degraded case INTO agreement with it. The page shares no identifier with the emitter (it names FLOW_FAILED / FLOW_DISABLED, never execute's internals), so no derivation could list it. Before this PR, a run that completed reached that door as status: 'failed' and the SDK threw FLOW_FAILED for a flow that HAD run — the one case the sentence does not cover. It now resolves, which is what the page prescribes. FlowFailureDetails is unchanged: no member moves, and errorMessage / summary are still produced on the genuine failure exits.

3. content/docs/automation/flows.mdx:1189 — the run-summary line, the statement physically closest to the changed code.

The log line defaults to info; AutomationServicePlugin's runSummaryLog: 'debug' | 'off' turns the volume down…

Not falsified. This PR neither moves that line's level nor its emission nor its default; it only catches the throw a host-injected Logger can raise out of it. The sentence holds verbatim.

The coarse fallback, read too

The 6 packageMentionDocs from the bot's tree — automation/flows.mdx, kernel/services-checklist.mdx, permissions/system-context.mdx, plugins/packages.mdx, releases/implementation-status.mdx, releases/v9.mdx. Only the first documents this package's behaviour at all (read above, three rows); the other five mention the package in a checklist, a tenancy discussion, a packaging list and two release histories, and name nothing this diff changes. ⛔ The two releases/** pages are release-owned and would not be edited here in any case.

What I am NOT claiming

⛔ Not a clean bill of health, for the reason the bot gives: the SDK route bridge reached 60 of 215 client-bound ledger rows, and pages documenting the other 155 client methods are invisible on every run. I read the pages the rule lives in, not every page that could exist. The 55 structural rows in that gap are beyond any discovery change, per the bot's own accounting.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 10, 2026 23:28
@os-sales
os-sales enabled auto-merge September 10, 2026 23:29
@os-sales
os-sales added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 216b066 Sep 10, 2026
35 checks passed
@os-sales
os-sales deleted the claude/issue-16274-initial-completion-history-guard branch September 10, 2026 23:52
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/l tests tooling

Projects

None yet

2 participants