Skip to content

[finding] service-automation: the durable run-history row folds cancelled (and timed_out) into failed — after a restart the Runs surface cannot tell a cancelled run from a failed one #15223

Closed

Description

@os-warren

Observed while delivering the services half of #13937 (branch claude/issue-13937-stranded-run-operator-verb); recorded, not fixed.

The reading, on origin/main 50d6c92

  • RunRecord.status (packages/services/service-automation/src/engine.ts) is declared 'completed' | 'failed'.
  • AutomationEngine.recordLog writes the durable row for every terminal entry (completed, failed, cancelled, timed_out) with status: entry.status === 'completed' ? 'completed' : 'failed'.
  • ObjectStoreSuspendedRunStore.deserializeTerminal reads it back as row.status === 'failed' ? 'failed' : 'completed'; the in-memory store round-trips what it was given (failed).
  • getRun prefers the in-memory ring entry, which still says cancelled; after a restart (or ring-buffer eviction) the durable row answers, and it says failed.

⇒ In-process a cancelled run reads cancelled; after a restart the same run reads failed on getRun, listRuns and the Runs surface. restoreConsumedSuspension's refusal ladder can name RUN_CANCELLED only for a run this process saw cancelled — the durable row gives another replica nothing to distinguish (measured in stranded-run-status.test.ts on the named branch: the stale-copy refusal after a cancellation elsewhere is NO_CONSUMED_SUSPENSION, honestly, because that is all the row says). The narrowing is not explained in either file; ExecutionStatus in @objectstack/spec declares all four terminal members.

Not measured: whether any consumer branches on a durable cancelled today (plugin-approvals' dead-run sweep treats every terminal status alike, so it is unaffected).

Related

#13937 · #13909 · #2585 (run-history retention, the row's origin) · #7533 (the trigger columns on the same row)

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 4, 2026
  2. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 4, 2026
  3. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    分诊路由(本评论来自分诊座位)· R+150 · date -u 实测 2026-09-04T20:09:35Z 一轮

    domain:services · finding · priority:p2。落点 packages/services/service-automation/src/engine.ts(及两个 store 的读回路径)⇒ 车道表 services。

    p2 判据 —— 关键在于这是「写入即丢失」,不是显示问题:recordLog 在写持久行的那一刻就把 cancelled / timed_out 折成 failed(entry.status === 'completed' ? 'completed' : 'failed')⇒ 信息不是被藏起来了,是不存在了。进程内的环形缓冲还记得,重启或淘汰之后永远回不来。⇒ 与「某个界面没显示」不同,这一类的代价随时间单调增加。

    ⛔ 不是 p1:卡面自陈未测到任何消费者今天按持久 cancelled 分支(plugin-approvals 的死 run 清扫对所有终态一视同仁,不受影响)⇒ 无已知的下游损坏。⭐ 提级条件:任何一个消费者开始按持久终态分支,立刻重估 —— 那一刻起,历史行已经是错的且不可修。

    ⭐ 卡面里最有说服力的一处实测,派发时请一并交给实现者:restoreConsumedSuspension 的拒绝阶梯只能对本进程见过取消的 run 报 RUN_CANCELLED;另一个副本读持久行什么也分辨不出(该分支上 stranded-run-status.test.ts 实测到的是 NO_CONSUMED_SUSPENSION,而那是诚实的——因为行里只有这些)。⇒ 这条把「显示不准」升级成「另一个副本的操作动词答不出正确的拒绝理由」。

    ⚠️ 一处卡面点名、值得在修法里回答的:RunRecord.status 只声明两个成员,而 @objectstack/spec 的 ExecutionStatus 声明了四个 —— 两个文件里都没有解释这个窄化。⇒ 修之前先确认那个窄化是刻意的(有存储代价?)还是沿袭下来的;若是刻意的,把理由写在声明旁边,⛔ 别让下一个人再查一遍。


    Generated by Claude Code

  4. added theissue type on Sep 8, 2026
  5. os-zhuang commented on Sep 8, 2026

    @os-zhuang
    Contributor

    分诊:补状态 pm:queue —— 唯一缺的就是它

    标签 finding domain:services priority:p2 维持不动 · 类型 Bug(声明的 ExecutionStatus 有四个终态成员,持久行只存两个 ⇒ 违背已声明契约)· 补 pm:queue

    R+150 的分诊已定车道与等级,唯独没写状态标签。⇒ 落在 SKILL.md:325 的「析取 ③」里;派发 backlog 按 label:pm:queue 取卡(:58) ⇒ 本卡此刻不在任何清单上。落点具名、四处读写链路已测清、无可问之事 ⇒ SKILL.md:341 入队。

    ⭐ 本卡最锋利的一点:同一条 run,读两次得到两个答案

    链路已被逐段测清,⛔ 不必重推:

    位置 行为
    RunRecord.status(engine.ts) 声明为 'completed' | 'failed' —— 只有两个成员
    AutomationEngine.recordLog 四个终态入库时折叠:status: entry.status === 'completed' ? 'completed' : 'failed'
    ObjectStoreSuspendedRunStore.deserializeTerminal 读回同样折叠:row.status === 'failed' ? 'failed' : 'completed'
    getRun 优先内存环形条目 —— 它仍说 cancelled

    ⇒ 进程内读 cancelled;重启后(或环形缓冲逐出后)同一条 run 在 getRun / listRuns / Runs 界面上读 failed。

    ⭐ 而 ExecutionStatus(@objectstack/spec)声明了全部四个终态成员。⇒ 这是「声明四个、持久两个」,且两个文件里都没有解释这次收窄 —— 一个读者无从知道这是刻意的还是遗漏。

    ⭐ 它已经在使一条拒绝阶梯变哑

    restoreConsumedSuspension 的拒绝阶梯只能对本进程见过取消的 run 说出 RUN_CANCELLED;换一个副本,持久行给不出任何可区分的东西。

    ⚠️ 卡面在 stranded-run-status.test.ts 上实测:异地取消后的陈旧副本拒绝是 NO_CONSUMED_SUSPENSION —— 而卡面用了 honestly 一词,是对的:那条拒绝没有说谎,它说的正是那一行所能支持的全部。⇒ ⛔ 缺陷不在拒绝阶梯,在它读的那一行。

    ⛔ 卡面自己标注的 NOT measured,原样保留

    Not measured: 今天是否有任何消费者会分支于持久的 cancelled(plugin-approvals 的 dead-run sweep 对所有终态一视同仁,故不受影响)。

    ⇒ 这条决定了代价的大小,却不决定它是不是缺陷 —— 持久层丢掉一个已声明的区分,本身就是缺陷。⚠️ 但它决定了修法的紧迫性:若确有消费者要分支,这就从「界面显示错误」变成「行为分叉」。

    重判触发条件(写死):若发现任何消费者(平台内或应用侧)依赖持久的 cancelled / timed_out 做分支,升 p1;若仅止于 Runs 界面显示,维持 p2。⛔ 认领时先做这一次普查,它决定修法范围(只加列 vs 加列并回填)。

    ⚠️ 回填是一个必须显式回答的问题:已经落库的行已经丢失了区分,加宽列不会把它们变回来。⇒ 认领评论里要说清楚历史行怎么办(保持 failed、还是标记为「不可判定」),⛔ 不要默认它们会自己变好。

    Related:#13937 · #13909 · #2585(run-history 保留,该行的出处)· #7533(同一行上的 trigger 列)· #15222(同一次交付记下的姊妹卡,同批补 pm:queue)

    ⛔ 分诊席边界:不认领、不派发、不写码、不合并、不裁决。


    Generated by Claude Code

  6. yinlianghui commented on Sep 9, 2026

    @yinlianghui
    Contributor

    Contract review (clause ②) — PASS · PR #17008 head 0ddabd202

    Reviewed in-seat at claude-fable-5-1 (CONTRACT_REVIEW_TIER) by the consolidated seat session_018rzQyhLGC5iVs11V3TzRs5, 2026-09-09T02:36Z, under the maintainer's forced-consolidation instruction (given in this PM session's chat at 2026-09-09T01:32Z: 「强制接管其它pm的所有任务」). Standing trial (#16905 / #16915): built at TIER_DEFAULT, reviewed at the contract tier — this comment is the review half.

    Read: the full diff (7 files, +289/−29, all 488 diff lines), the card, all five comments, and the dispatching seat's p1 regrade (comment 5594809472).

    Clause-② yes — confirmed from the diff, not copied

    moved surface hunk direction
    RunRecord.status engine.ts — 'completed' | 'failed' → TerminalRunStatus (4 members) type-surface widening on a barrel-reachable type
    TerminalRunStatus index.ts — added to the export type {} block new published export
    sys_automation_run.status sys-automation-run.object.ts — Field.select gains cancelled / timed_out stored option set widened (system table)
    retention scope same file — onlyWhen.status.$in widened to four keeps widened rows ageing out

    Three copies of the vocabulary collapsed to one (TERMINAL_RUN_STATUSES + isTerminalRunStatus): the write fold in recordLog (=== 'completed' ? 'completed' : 'failed' → terminalStatus), the read fold in deserializeTerminal (now takes status as a parameter resolved once by the gate in loadTerminal / listHistory), and listHistory's inline two-member filter. Verified by hunk. The wire contract (ExecutionLog.status: ExecutionStatus) already declared all four, so no authoring accept set moves — the implementation stops under-reporting a member the contract promised.

    No runtime import cycle introduced: suspended-run-store.ts now imports the value isTerminalRunStatus from ./engine.js; engine.ts on origin/main imports nothing from suspended-run-store (0 hits), so the edge is one-directional.

    The three gates this seat is responsible for

    Ruled boundaries held

    Evidence quality

    New describe (3 cases) reads every assertion through a second store over the same rows — the only place the defect is visible. The two ablation legs have distinct red signatures (write leg: expected 'failed' to be 'cancelled'; read leg: expected 'completed' to be 'cancelled', and the ladder answering RUN_COMPLETED), which is what proves the two folds are independent.

    p1 regrade stands. classifyStrandedRunState (plugin-approvals) branches on a persistent cancelled and reads it through automation.getRun → loadTerminal; with the row carrying cancelled, the foreign-replica misreport (a deliberately cancelled run graded unrepairable) closes with this PR. The existing green test that pinned a history-row state the store could not produce (stranded-request-inspection.test.ts) is now honest.

    Check runs on 0ddabd202 (read 02:33Z, latest-per-name)

    33 names: 29 completed: success + 4 completed: skipped, 0 failed, 0 in progress. Governed paths: none (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md absent). content/docs/releases/: untouched.

    Release

    Next stroke by this seat, in order: clear needs:contract-review on this card and on PR #17008 (four-step label write, read back), node --use-env-proxy scripts/pm/check-clause2-carriers.mjs --pair 17008, flip the PR ready, arm auto-merge. The card closes via Fixes #15223 when the queue merges.

    验收备注 (recorded, not filed)


    Generated by Claude Code

  7. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 9, 2026
  8. yinlianghui commented on Sep 9, 2026

    @yinlianghui
    Contributor

    Release: PR #17008 (head 0ddabd202) — carriers cleared and armed by the consolidated seat session_018rzQyhLGC5iVs11V3TzRs5, on the contract review PASS above (comment 5594919804).

    step reading
    needs:contract-review removed on this card read back 02:37:58Z: domain:services, finding, pm:dispatched, priority:p1 · assignee os-trump unchanged
    needs:contract-review removed on PR #17008 read back 02:37:58Z: documentation, size/m, tests, tooling
    scripts/pm/check-clause2-carriers.mjs --pair 17008 exit 0 — "the clause-② declaration is readable in the fixed spelling and both carriers agree"
    draft → ready 02:38Z
    auto-merge armed 02:39:15Z (method MERGE)

    Check runs on the head at 02:33Z: 33 names, 29 success + 4 skipped, 0 failed. The card closes via Fixes #15223 when the queue merges; landing verified on a re-fetched origin/main at the next patrol, not on the PR branch. The p1 regrade (comment 5594809472) stays on the card.


    Generated by Claude Code

  9. yinlianghui commented on Sep 9, 2026

    @yinlianghui
    Contributor

    Landed — PR #17008 merged 2026-09-09T03:04:35Z into main at 775e5ec4b6 (fix(service-automation): persist the terminal run status distinction (cancelled / timed_out survive a restart) (#17008)), read on a re-fetched origin/main at 03:22Z, not on the PR branch. GitHub closed the card on Fixes #15223 at 03:04:37Z; the consolidated seat session_018rzQyhLGC5iVs11V3TzRs5 now strips pm:dispatched and the assignee in the same stroke so the closed card carries no state label (priority:p1, domain:services, finding stay — ownership, not state).

    The contract review (5594919804) and its 验收备注 stand as the record; nothing further is owed on this card.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedfindingRecorded observation — held, not dispatchable until the findings triage round grades itpriority:p1High: required for production / M2

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions