@huseynsnmz/pi-board 0.15.3 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -4
- package/CONTRIBUTING.md +7 -0
- package/README.md +53 -419
- package/SECURITY.md +19 -0
- package/docs/how-it-works.md +31 -0
- package/docs/reference.md +276 -0
- package/package.json +5 -11
- package/src/index.ts +1 -1
- package/src/runtime/service.mjs +12 -0
- package/src/ui/dashboard.ts +14 -8
- package/IMPLEMENTATION_PLAN.md +0 -920
- package/PRD.md +0 -484
- package/PROGRESS.md +0 -142
- package/VERIFY.md +0 -115
- package/docs/BATCH_SELECTION_READ_FLOW.md +0 -277
- package/docs/EXPLORATION.md +0 -187
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +0 -581
- package/docs/RELEASE.md +0 -148
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +0 -704
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +0 -499
- package/docs/superpowers/plans/2026-08-21-attach-coldstart-jiggle-rearm.md +0 -797
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +0 -366
- package/docs/superpowers/plans/2026-08-21-issue-14-needs-input-no-auto-done.md +0 -576
- package/docs/superpowers/plans/2026-08-22-jiggle-shrink-and-hold.md +0 -337
- package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +0 -916
- package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +0 -211
- package/docs/superpowers/plans/2026-08-22-rename-state-labels.md +0 -182
- package/docs/superpowers/plans/2026-08-22-stable-list-order.md +0 -51
- package/docs/superpowers/plans/2026-08-27-locks-acquirelock-spin.md +0 -614
- package/docs/superpowers/plans/2026-08-27-pty-runner-test-flaky.md +0 -32
- package/docs/superpowers/plans/2026-08-29-code-refs-badges.md +0 -223
- package/docs/superpowers/plans/2026-08-30-circular-navigation.md +0 -308
- package/docs/superpowers/plans/2026-08-30-post-exit-timing-fix.md +0 -37
- package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +0 -311
- package/docs/superpowers/plans/2026-08-30-readme-v2.md +0 -294
- package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +0 -284
- package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +0 -722
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +0 -551
- package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +0 -146
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +0 -209
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +0 -796
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +0 -114
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +0 -73
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +0 -143
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +0 -226
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +0 -87
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +0 -53
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +0 -731
- package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +0 -30
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +0 -68
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +0 -917
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +0 -125
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +0 -56
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +0 -96
- package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +0 -267
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +0 -603
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +0 -252
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +0 -115
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +0 -469
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +0 -92
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +0 -771
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +0 -299
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +0 -609
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +0 -145
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +0 -224
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +0 -341
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +0 -875
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +0 -116
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +0 -517
- package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +0 -25
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +0 -393
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +0 -51
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +0 -58
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +0 -698
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +0 -605
- package/docs/superpowers/plans/2026-09-29-issue-145-holding-semantic-state.md +0 -1183
- package/docs/superpowers/plans/2026-09-29-issue-145-user-verification.md +0 -66
- package/docs/superpowers/plans/2026-09-30-board-delete-key-x-y-confirm.md +0 -649
- package/docs/superpowers/plans/2026-09-30-issue-153-runner-stop-boot-window.md +0 -618
- package/docs/superpowers/plans/2026-09-30-issue-95-test-discipline.md +0 -555
- package/docs/superpowers/plans/2026-09-30-publish-dispatch-tag-check.md +0 -259
- package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +0 -751
- package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +0 -509
- package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +0 -346
- package/docs/superpowers/plans/2026-10-10-attach-delete-in-place.md +0 -832
- package/docs/superpowers/plans/2026-10-10-issue-174-selection-mirror-paste-source.md +0 -510
- package/docs/superpowers/plans/2026-10-14-issue-170-attach-reply-guard.md +0 -771
- package/docs/superpowers/plans/2026-10-14-issue-178-accept-registration-race.md +0 -1257
- package/docs/superpowers/plans/2026-10-14-issue-184-snapshot-window-contract.md +0 -657
- package/docs/superpowers/plans/2026-10-14-issue-185-attach-wheel-alt-screen.md +0 -47
- package/docs/superpowers/plans/2026-10-14-issue-187-motionless-drag.md +0 -931
- package/docs/superpowers/plans/2026-10-14-issue-189-mixed-input-segments.md +0 -823
- package/docs/superpowers/plans/2026-10-15-issue-188-x10-mouse-release.md +0 -526
- package/docs/superpowers/plans/2026-10-16-issue-198-attach-wheel-forward-fallback.md +0 -32
- package/docs/superpowers/plans/2026-10-18-attach-wheel-replay-pseudo-scrollback.md +0 -437
- package/docs/superpowers/plans/2026-10-19-issue-213-reply-guard-csi-key.md +0 -257
- package/docs/superpowers/plans/2026-10-19-issue-214-attach-forward-alt-modifier.md +0 -202
- package/docs/superpowers/plans/2026-10-21-issue-221-ask-user-question.md +0 -198
- package/docs/superpowers/plans/2026-10-23-issue-222-attach-ready.md +0 -290
- package/docs/superpowers/plans/2026-10-23-issue-225-monotonic-foreground-state.md +0 -450
- package/docs/superpowers/plans/2026-10-23-issue-229-vim-jkl.md +0 -449
- package/docs/superpowers/plans/2026-10-23-issue-232-attach-bg-only-cells.md +0 -153
- package/docs/superpowers/plans/2026-10-23-lease-heartbeat-staleness.md +0 -537
- package/docs/superpowers/plans/2026-10-23-selected-row-highlight.md +0 -405
- package/docs/superpowers/plans/2026-10-24-issue-217-windows-pid-reuse-stopping.md +0 -367
- package/docs/superpowers/plans/2026-10-24-issue-230-warm-host-background-runs.md +0 -806
- package/docs/superpowers/plans/2026-10-24-issue-238-windows-process-identity-token.md +0 -490
- package/docs/superpowers/plans/2026-10-24-issue-241-coordinator-root-exit.md +0 -461
- package/docs/superpowers/plans/2026-10-25-issue-239-coordinator-replacement-identity.md +0 -504
- package/docs/superpowers/plans/2026-10-26-issue-245-key-hints-doc-sync.md +0 -244
- package/docs/superpowers/plans/2026-10-27-issue-172-fullscreen-selection-clear.md +0 -344
- package/docs/superpowers/plans/2026-10-27-issue-252-select-key-hints.md +0 -209
- package/docs/superpowers/plans/2026-10-28-issue-255-coordinator-endpoint-zombie.md +0 -442
- package/docs/superpowers/plans/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup.md +0 -256
- package/docs/superpowers/plans/2026-11-03-cwd-favorites-full-pool.md +0 -266
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +0 -105
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +0 -142
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +0 -110
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +0 -59
- package/docs/superpowers/specs/2026-08-21-issue-14-needs-input-no-auto-done-design.md +0 -109
- package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +0 -131
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +0 -111
- package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +0 -114
- package/docs/superpowers/specs/2026-08-22-rename-state-labels-design.md +0 -80
- package/docs/superpowers/specs/2026-08-27-locks-acquirelock-spin-design.md +0 -97
- package/docs/superpowers/specs/2026-08-27-pty-runner-test-flaky-design.md +0 -35
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +0 -128
- package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +0 -47
- package/docs/superpowers/specs/2026-08-30-eprm-atomicwrite-race-design.md +0 -77
- package/docs/superpowers/specs/2026-08-30-post-exit-timing-fix-design.md +0 -80
- package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +0 -105
- package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +0 -115
- package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +0 -78
- package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +0 -120
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +0 -92
- package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +0 -99
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +0 -50
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +0 -106
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +0 -64
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +0 -48
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +0 -64
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +0 -71
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +0 -49
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +0 -76
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +0 -510
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +0 -130
- package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +0 -58
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +0 -52
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +0 -87
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +0 -56
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +0 -79
- package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +0 -147
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +0 -304
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +0 -114
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +0 -120
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +0 -146
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +0 -116
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +0 -84
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +0 -92
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +0 -123
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +0 -204
- package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +0 -43
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +0 -95
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +0 -63
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +0 -57
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +0 -98
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +0 -190
- package/docs/superpowers/specs/2026-09-29-issue-145-holding-semantic-state-design.md +0 -394
- package/docs/superpowers/specs/2026-09-30-board-delete-key-x-y-confirm-design.md +0 -86
- package/docs/superpowers/specs/2026-09-30-issue-153-runner-stop-boot-window-design.md +0 -75
- package/docs/superpowers/specs/2026-09-30-issue-95-test-discipline-design.md +0 -226
- package/docs/superpowers/specs/2026-09-30-publish-dispatch-tag-check-design.md +0 -129
- package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +0 -196
- package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +0 -125
- package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +0 -130
- package/docs/superpowers/specs/2026-10-10-attach-delete-in-place-design.md +0 -224
- package/docs/superpowers/specs/2026-10-10-issue-174-selection-mirror-paste-source-design.md +0 -154
- package/docs/superpowers/specs/2026-10-14-issue-170-attach-reply-guard-design.md +0 -195
- package/docs/superpowers/specs/2026-10-14-issue-178-accept-registration-race-design.md +0 -287
- package/docs/superpowers/specs/2026-10-14-issue-184-snapshot-window-contract-design.md +0 -288
- package/docs/superpowers/specs/2026-10-14-issue-185-attach-wheel-alt-screen-design.md +0 -126
- package/docs/superpowers/specs/2026-10-14-issue-187-motionless-drag-design.md +0 -269
- package/docs/superpowers/specs/2026-10-14-issue-189-mixed-input-segments-design.md +0 -369
- package/docs/superpowers/specs/2026-10-15-issue-188-x10-mouse-release-design.md +0 -252
- package/docs/superpowers/specs/2026-10-16-issue-198-attach-wheel-forward-fallback-design.md +0 -67
- package/docs/superpowers/specs/2026-10-18-attach-wheel-replay-pseudo-scrollback-design.md +0 -250
- package/docs/superpowers/specs/2026-10-19-issue-213-reply-guard-csi-key-design.md +0 -165
- package/docs/superpowers/specs/2026-10-19-issue-214-attach-forward-alt-modifier-design.md +0 -131
- package/docs/superpowers/specs/2026-10-20-issue-221-ask-user-question-design.md +0 -270
- package/docs/superpowers/specs/2026-10-22-issue-225-monotonic-foreground-state-design.md +0 -225
- package/docs/superpowers/specs/2026-10-23-issue-222-attach-ready-design.md +0 -204
- package/docs/superpowers/specs/2026-10-23-issue-229-vim-jkl-design.md +0 -90
- package/docs/superpowers/specs/2026-10-23-issue-232-attach-bg-only-cells-design.md +0 -146
- package/docs/superpowers/specs/2026-10-23-lease-heartbeat-staleness-design.md +0 -271
- package/docs/superpowers/specs/2026-10-23-selected-row-highlight-design.md +0 -191
- package/docs/superpowers/specs/2026-10-24-issue-217-windows-pid-reuse-stopping-design.md +0 -334
- package/docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md +0 -283
- package/docs/superpowers/specs/2026-10-24-issue-238-windows-process-identity-token-design.md +0 -286
- package/docs/superpowers/specs/2026-10-24-issue-241-coordinator-root-exit-design.md +0 -381
- package/docs/superpowers/specs/2026-10-25-issue-239-coordinator-replacement-identity-design.md +0 -331
- package/docs/superpowers/specs/2026-10-26-issue-245-key-hints-doc-sync-design.md +0 -72
- package/docs/superpowers/specs/2026-10-27-issue-172-fullscreen-selection-clear-design.md +0 -248
- package/docs/superpowers/specs/2026-10-27-issue-252-select-key-hints-design.md +0 -95
- package/docs/superpowers/specs/2026-10-28-issue-255-coordinator-endpoint-zombie-design.md +0 -380
- package/docs/superpowers/specs/2026-10-28-issue-257-legacy-pty-runner-endpoint-cleanup-design.md +0 -197
- package/docs/superpowers/specs/2026-11-03-cwd-favorites-full-pool-design.md +0 -106
- package/scripts/budget-audit.mjs +0 -135
- package/scripts/release.mjs +0 -446
- package/scripts/release_helper.mjs +0 -309
- package/scripts/run-perf-gate.mjs +0 -40
- package/scripts/run-tests.mjs +0 -375
|
@@ -1,806 +0,0 @@
|
|
|
1
|
-
# Warm-host background-run guard Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** 让 warm-host sweeper 不再驱逐正在跑后台 workflow 的宿主,并在驱逐确实发生时/宿主死亡后留下可读痕迹。
|
|
6
|
-
|
|
7
|
-
**Architecture:** 新增 `src/core/background-runs.mjs`(纯解析/判定 + 一层 IO shell,读 pi-dynamic-workflows 的 run head 文件);`selectIdleHostsToEvict` 接受可注入的 `isProtected` 谓词保持纯函数;`service.pruneWarmHosts` 两阶段选择(先无保护快跑,有候选才扫描 store)+ 停止前复查;`service.reconcile()` 新增独立 pre-pass,对已死宿主的 paused run 写行级提示(经 `patch_fields`)。
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Node ESM (`.mjs`) + `node:test` + `assert/strict`;无新依赖;JSDoc typedef(`checkJs: false`,类型只作注释)。
|
|
10
|
-
|
|
11
|
-
**Spec:** `docs/superpowers/specs/2026-10-24-issue-230-warm-host-background-runs-design.md`
|
|
12
|
-
|
|
13
|
-
## Global Constraints
|
|
14
|
-
|
|
15
|
-
- 缩进用 **Tab**;注释与 commit message 用 **英文**;commit subject 用 conventional 格式。
|
|
16
|
-
- 测试一律 `node:test` + `node:assert/strict`,文件放 `test/*.test.mjs`;涉及文件系统的用例必须用 `mkdtempSync(tmpdir())` 自建 fixture,**不得**读写宿主仓库或真实 `~/.pi/workflows`。
|
|
17
|
-
- 禁止修改 `main`;所有改动在本 worktree(`C:/Users/27499/proj/opensource/pi-agent-board/.pi/worktrees/issue-230-warm-host-background-runs`)内提交,`git add <file>` 逐文件,**禁用 `git add -A`**。
|
|
18
|
-
- 生产代码**不得**新增 `writeState` / `writeStatus` import(`test/architecture-writer-boundary.test.mjs` 静态检查)。
|
|
19
|
-
- 判定语义固定:保护 ⇔ `status === "running"` 且(`hostStartedAt == null` 或 `updatedAtMs == null` 或 `updatedAtMs >= hostStartedAt - CLOCK_EPSILON_MS`);`CLOCK_EPSILON_MS = 2000`。
|
|
20
|
-
- 环境变量名:`AGENT_BOARD_NO_BACKGROUND_RUN_GUARD`(`"1"` = 关闭保护)、`AGENT_BOARD_WORKFLOW_HOME`(覆盖 workflow home)。
|
|
21
|
-
- 诊断 code 固定:`warm_prune_probe_failed`、`warm_prune_during_background_run`、`background_run_interrupted`;三者都必须传 `appendDiagnosticOnce` 的 `opts.match`。
|
|
22
|
-
- 覆盖率门槛不变:lines 85 / funcs 80 / branches 70(`npm run verify`)。
|
|
23
|
-
|
|
24
|
-
## Review Focus
|
|
25
|
-
|
|
26
|
-
以下几类输入 spec 没有逐条写测试,但最容易在真实使用中出问题;每条都已在对应 Task 里钉了测试:
|
|
27
|
-
|
|
28
|
-
1. **同一 project 下多会话并发**:一个会话在跑 workflow、另一个空闲 → 只有前者被保护(按 `parentSessionFile` 精确匹配,不是按项目目录整体保护)。
|
|
29
|
-
2. **同一路径的不同写法**:盘符大小写、`\` 与 `/` 混用(上游在不同进程里 `path.join` 产物可能不同)→ 仍判同文件。
|
|
30
|
-
3. **上一次宿主遗留的 `running` 记录**(宿主崩溃后没来得及落终态)→ **不保护**,否则 host 泄漏回来了。
|
|
31
|
-
4. **半写坏掉的 head 文件**(原子写落 `.bak` 中途被读)→ 不抛异常、不改变其它记录的判定结果。
|
|
32
|
-
5. **`AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1`** → 行为与今天逐字节一致(长 run 仍会被驱逐),但 paused 提示链路照常工作(提示不受该开关控制;U2 实测覆盖这个组合)。
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
### Task 1: 纯判定核心 `src/core/background-runs.mjs`
|
|
37
|
-
|
|
38
|
-
**Files:**
|
|
39
|
-
- Create: `src/core/background-runs.mjs`
|
|
40
|
-
- Test: `test/background-runs.test.mjs`
|
|
41
|
-
|
|
42
|
-
**Interfaces:**
|
|
43
|
-
- Produces:
|
|
44
|
-
- `CLOCK_EPSILON_MS: number` = `2000`
|
|
45
|
-
- `resolveWorkflowHome(o?: { env?: Record<string,string|undefined>, home?: string }): string`
|
|
46
|
-
- `parseRunHead(raw: any): { runId: string, status: string, parentSessionFile: string|null, startedAtMs: number|null, updatedAtMs: number|null, hasCheckpoint: boolean } | null`
|
|
47
|
-
- `isSameSessionFile(a: string, b: string, o?: { platform?: string }): boolean`
|
|
48
|
-
- `classifyRunForRow(run: Object, o: { sessionFile: string, hostStartedAt: number|null }): "protecting"|"paused"|"irrelevant"`
|
|
49
|
-
- `classifyRunsForRow(runs: Array<Object>, o: same): { state: "protecting"|"paused"|"irrelevant", run: Object|null }`
|
|
50
|
-
|
|
51
|
-
- [ ] **Step 1: 先写失败的测试**
|
|
52
|
-
|
|
53
|
-
`test/background-runs.test.mjs`(完整文件):
|
|
54
|
-
|
|
55
|
-
```js
|
|
56
|
-
import assert from "node:assert/strict";
|
|
57
|
-
import { test } from "node:test";
|
|
58
|
-
import {
|
|
59
|
-
CLOCK_EPSILON_MS,
|
|
60
|
-
classifyRunForRow,
|
|
61
|
-
classifyRunsForRow,
|
|
62
|
-
isSameSessionFile,
|
|
63
|
-
parseRunHead,
|
|
64
|
-
resolveWorkflowHome,
|
|
65
|
-
} from "../src/core/background-runs.mjs";
|
|
66
|
-
|
|
67
|
-
const V2 = (o = {}) => ({
|
|
68
|
-
format: "pi-workflow-run-v2",
|
|
69
|
-
index: { runId: "run-1", status: "running", parentSessionFile: "C:\\r\\sessions\\view_a.jsonl", updatedAt: "2026-10-24T01:00:00.000Z", startedAt: "2026-10-24T00:50:00.000Z", ...o },
|
|
70
|
-
summary: { checkpoint: null },
|
|
71
|
-
keys: [],
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("A1: v2 head -> status/parentSessionFile/updatedAt", () => {
|
|
75
|
-
const h = parseRunHead(V2());
|
|
76
|
-
assert.equal(h.runId, "run-1");
|
|
77
|
-
assert.equal(h.status, "running");
|
|
78
|
-
assert.equal(h.parentSessionFile, "C:\\r\\sessions\\view_a.jsonl");
|
|
79
|
-
assert.equal(h.updatedAtMs, Date.parse("2026-10-24T01:00:00.000Z"));
|
|
80
|
-
assert.equal(h.hasCheckpoint, false);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("A1: v2 head without parentSessionFile -> null parent", () => {
|
|
84
|
-
const raw = V2();
|
|
85
|
-
delete raw.index.parentSessionFile;
|
|
86
|
-
assert.equal(parseRunHead(raw).parentSessionFile, null);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("A1: legacy flat record -> parseable but no parentSessionFile", () => {
|
|
90
|
-
const h = parseRunHead({ runId: "old-1", status: "completed", startedAt: "2026-08-30T00:00:00.000Z" });
|
|
91
|
-
assert.equal(h.runId, "old-1");
|
|
92
|
-
assert.equal(h.status, "completed");
|
|
93
|
-
assert.equal(h.parentSessionFile, null);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("A1: garbage never throws and yields null", () => {
|
|
97
|
-
for (const bad of [null, 42, "str", [], {}, { format: "something-else" }]) {
|
|
98
|
-
assert.equal(parseRunHead(bad), null);
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("A2: running + same session + updatedAt >= hostStartedAt -> protecting", () => {
|
|
103
|
-
const h = parseRunHead(V2());
|
|
104
|
-
const startedAt = Date.parse("2026-10-24T00:55:00.000Z");
|
|
105
|
-
assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: startedAt }), "protecting");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("A3: stale running (updatedAt < hostStartedAt) -> irrelevant", () => {
|
|
109
|
-
const h = parseRunHead(V2());
|
|
110
|
-
const startedAt = Date.parse("2026-10-24T02:00:00.000Z");
|
|
111
|
-
assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: startedAt }), "irrelevant");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("A3: stale running within CLOCK_EPSILON_MS still protects", () => {
|
|
115
|
-
const h = parseRunHead(V2());
|
|
116
|
-
const startedAt = Date.parse("2026-10-24T01:00:00.000Z") + CLOCK_EPSILON_MS - 1;
|
|
117
|
-
assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: startedAt }), "protecting");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("A3: missing hostStartedAt protects; missing updatedAt protects", () => {
|
|
121
|
-
const h = parseRunHead(V2());
|
|
122
|
-
assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }), "protecting");
|
|
123
|
-
const raw = V2();
|
|
124
|
-
delete raw.index.updatedAt;
|
|
125
|
-
assert.equal(classifyRunForRow(parseRunHead(raw), { sessionFile: raw.index.parentSessionFile, hostStartedAt: Date.now() }), "protecting");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("A4: non-running statuses never protect; paused is classified separately", () => {
|
|
129
|
-
const sessionFile = "C:\\r\\sessions\\view_a.jsonl";
|
|
130
|
-
for (const status of ["completed", "failed", "aborted", "pending", ""]) {
|
|
131
|
-
assert.equal(classifyRunForRow(parseRunHead(V2({ status })), { sessionFile, hostStartedAt: null }), "irrelevant", status);
|
|
132
|
-
}
|
|
133
|
-
assert.equal(classifyRunForRow(parseRunHead(V2({ status: "paused" })), { sessionFile, hostStartedAt: null }), "paused");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("A4: other session's run is irrelevant", () => {
|
|
137
|
-
const h = parseRunHead(V2());
|
|
138
|
-
assert.equal(classifyRunForRow(h, { sessionFile: "C:\\r\\sessions\\view_b.jsonl", hostStartedAt: null }), "irrelevant");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("A5: win32 path spelling differences compare equal (platform injected)", () => {
|
|
142
|
-
assert.equal(isSameSessionFile("C:\\r\\sessions\\view_a.jsonl", "c:/r/sessions/view_a.jsonl", { platform: "win32" }), true);
|
|
143
|
-
assert.equal(isSameSessionFile("c:/r/sessions/view_a.jsonl", "C:\\r\\sessions\\view_b.jsonl", { platform: "win32" }), false);
|
|
144
|
-
assert.equal(isSameSessionFile("/tmp/x/a.jsonl", "/tmp/x/./a.jsonl", { platform: "linux" }), true);
|
|
145
|
-
assert.equal(isSameSessionFile("/tmp/x/A.jsonl", "/tmp/x/a.jsonl", { platform: "linux" }), false);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("A5: missing operands -> false, never throws", () => {
|
|
149
|
-
assert.equal(isSameSessionFile(null, "/a", {}), false);
|
|
150
|
-
assert.equal(isSameSessionFile("/a", undefined, {}), false);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test("A16: workflow home precedence and tilde expansion", () => {
|
|
154
|
-
const home = "/home/u";
|
|
155
|
-
assert.equal(resolveWorkflowHome({ env: {}, home }), "/home/u/.pi/workflows");
|
|
156
|
-
assert.equal(resolveWorkflowHome({ env: { PI_CODING_AGENT_DIR: "/custom/agent" }, home }), "/custom/agent/workflows");
|
|
157
|
-
assert.equal(resolveWorkflowHome({ env: { PI_CODING_AGENT_DIR: "~/agent" }, home }), "/home/u/agent/workflows");
|
|
158
|
-
assert.equal(
|
|
159
|
-
resolveWorkflowHome({ env: { PI_CODING_AGENT_DIR: "/custom/agent", AGENT_BOARD_WORKFLOW_HOME: "/explicit/wf" }, home }),
|
|
160
|
-
"/explicit/wf",
|
|
161
|
-
);
|
|
162
|
-
assert.equal(resolveWorkflowHome({ env: { AGENT_BOARD_WORKFLOW_HOME: "~/wf" }, home }), "/home/u/wf");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("ReviewFocus 1: two runs in one project — only the matching session protects", () => {
|
|
166
|
-
const mine = parseRunHead(V2());
|
|
167
|
-
const other = parseRunHead(V2({ runId: "run-2", parentSessionFile: "C:\\r\\sessions\\view_b.jsonl" }));
|
|
168
|
-
const res = classifyRunsForRow([other, mine], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null });
|
|
169
|
-
assert.equal(res.state, "protecting");
|
|
170
|
-
assert.equal(res.run.runId, "run-1");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("ReviewFocus: protecting outranks paused in one scan", () => {
|
|
174
|
-
const paused = parseRunHead(V2({ runId: "p", status: "paused" }));
|
|
175
|
-
const running = parseRunHead(V2({ runId: "r", status: "running" }));
|
|
176
|
-
assert.equal(classifyRunsForRow([paused, running], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }).state, "protecting");
|
|
177
|
-
assert.equal(classifyRunsForRow([paused], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }).state, "paused");
|
|
178
|
-
assert.equal(classifyRunsForRow([], { sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null }).state, "irrelevant");
|
|
179
|
-
});
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
- [ ] **Step 2: 运行测试确认失败**
|
|
183
|
-
|
|
184
|
-
Run: `node --test test/background-runs.test.mjs`
|
|
185
|
-
Expected: FAIL — `Cannot find module '.../src/core/background-runs.mjs'`
|
|
186
|
-
|
|
187
|
-
- [ ] **Step 3: 实现 `src/core/background-runs.mjs`(纯函数部分)**
|
|
188
|
-
|
|
189
|
-
导出签名如上。实现要点(算法由 spec 固定,照写):
|
|
190
|
-
- `parseRunHead`:`raw.format === "pi-workflow-run-v2"` → 用 `raw.index`;否则顶层有 `string` 型 `runId` → 当 legacy flat(`parentSessionFile: null`);其它一律 `null`。`startedAtMs/updatedAtMs` 用 `Date.parse`,非有限值记 `null`。`hasCheckpoint = Boolean(source.summary?.checkpoint ?? source.checkpoint)`。
|
|
191
|
-
- `isSameSessionFile`:`const impl = platform === "win32" ? path.win32 : path.posix;` normalize 用 `impl.resolve`;win32 比较前 `toLowerCase()`。
|
|
192
|
-
- `classifyRunForRow`:顺序 —— 无 `run`/无 `parentSessionFile` → `irrelevant`;`!isSameSessionFile(...)` → `irrelevant`;`status === "paused"` → `paused`;`status !== "running"` → `irrelevant`;`hostStartedAt == null || updatedAtMs == null` → `protecting`;`updatedAtMs >= hostStartedAt - CLOCK_EPSILON_MS` → `protecting`;否则 `irrelevant`。
|
|
193
|
-
- `classifyRunsForRow`:遍历取最强(`protecting > paused > irrelevant`),返回 `{ state, run }`(`irrelevant` 时 `run: null`)。
|
|
194
|
-
- `resolveWorkflowHome`:`AGENT_BOARD_WORKFLOW_HOME` → `PI_CODING_AGENT_DIR/workflows` → `<home>/.pi/workflows`;`~` 前缀替换为 `home`。
|
|
195
|
-
- 文件头写英文注释块,说明这是 issue #230 的判定核心,并指向 spec。
|
|
196
|
-
|
|
197
|
-
- [ ] **Step 4: 运行测试确认通过**
|
|
198
|
-
|
|
199
|
-
Run: `node --test test/background-runs.test.mjs`
|
|
200
|
-
Expected: PASS(15 个 test)
|
|
201
|
-
|
|
202
|
-
- [ ] **Step 5: Commit**
|
|
203
|
-
|
|
204
|
-
```bash
|
|
205
|
-
git add src/core/background-runs.mjs test/background-runs.test.mjs
|
|
206
|
-
git commit -m "feat(core): classify background workflow runs for warm-host protection (issue #230)"
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
---
|
|
210
|
-
|
|
211
|
-
### Task 2: IO shell `scanBackgroundRuns` / `probeBackgroundRun`
|
|
212
|
-
|
|
213
|
-
**Files:**
|
|
214
|
-
- Modify: `src/core/background-runs.mjs`
|
|
215
|
-
- Test: `test/background-runs.test.mjs`
|
|
216
|
-
|
|
217
|
-
**Interfaces:**
|
|
218
|
-
- Consumes: Task 1 的 `parseRunHead` / `classifyRunsForRow` / `CLOCK_EPSILON_MS`
|
|
219
|
-
- Produces:
|
|
220
|
-
- `scanBackgroundRuns(o: { workflowHome: string, minMtimeMs?: number }): { ok: boolean, heads: Array<RunHeadWithPath>, error: string|null }`,其中 `RunHeadWithPath = RunHead & { path: string, mtimeMs: number }`
|
|
221
|
-
- `probeBackgroundRun(o: { sessionFile: string, hostStartedAt: number|null, workflowHome: string }): { ok: boolean, running: RunHeadWithPath|null, paused: RunHeadWithPath|null, error: string|null }`
|
|
222
|
-
|
|
223
|
-
- [ ] **Step 1: 先写失败的测试**(追加到 `test/background-runs.test.mjs`)
|
|
224
|
-
|
|
225
|
-
```js
|
|
226
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
227
|
-
import { tmpdir } from "node:os";
|
|
228
|
-
import { join } from "node:path";
|
|
229
|
-
import { probeBackgroundRun, scanBackgroundRuns } from "../src/core/background-runs.mjs";
|
|
230
|
-
|
|
231
|
-
function fixtureTree() {
|
|
232
|
-
const root = mkdtempSync(join(tmpdir(), "agentboard-wf-"));
|
|
233
|
-
const runsDir = join(root, "projects", "proj-abc123", "runs");
|
|
234
|
-
mkdirSync(runsDir, { recursive: true });
|
|
235
|
-
return { root, runsDir, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const writeHead = (runsDir, name, body) => writeFileSync(join(runsDir, name), JSON.stringify(body));
|
|
239
|
-
|
|
240
|
-
test("A6: scan enumerates v2 heads and skips .bak/.events.jsonl/.log/.result", () => {
|
|
241
|
-
const fx = fixtureTree();
|
|
242
|
-
try {
|
|
243
|
-
writeHead(fx.runsDir, "run-1.json", V2());
|
|
244
|
-
writeHead(fx.runsDir, "run-1.json.bak", V2());
|
|
245
|
-
writeFileSync(join(fx.runsDir, "run-1.json.events.jsonl"), "{}\n");
|
|
246
|
-
writeFileSync(join(fx.runsDir, "run-1.log"), "log\n");
|
|
247
|
-
writeFileSync(join(fx.runsDir, "run-1.json.result-deadbeef"), "{}");
|
|
248
|
-
const res = scanBackgroundRuns({ workflowHome: fx.root });
|
|
249
|
-
assert.equal(res.ok, true);
|
|
250
|
-
assert.equal(res.heads.length, 1);
|
|
251
|
-
assert.equal(res.heads[0].runId, "run-1");
|
|
252
|
-
assert.ok(typeof res.heads[0].mtimeMs === "number");
|
|
253
|
-
} finally {
|
|
254
|
-
fx.cleanup();
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("A6: minMtimeMs prefilter drops old heads", () => {
|
|
259
|
-
const fx = fixtureTree();
|
|
260
|
-
try {
|
|
261
|
-
writeHead(fx.runsDir, "run-1.json", V2());
|
|
262
|
-
const future = Date.now() + 60_000;
|
|
263
|
-
assert.deepEqual(scanBackgroundRuns({ workflowHome: fx.root, minMtimeMs: future }), { ok: true, heads: [], error: null });
|
|
264
|
-
} finally {
|
|
265
|
-
fx.cleanup();
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test("A6: corrupt JSON does not throw and does not change the verdict", () => {
|
|
270
|
-
const fx = fixtureTree();
|
|
271
|
-
try {
|
|
272
|
-
writeHead(fx.runsDir, "run-1.json", V2());
|
|
273
|
-
writeFileSync(join(fx.runsDir, "broken.json"), "{ not json");
|
|
274
|
-
const res = scanBackgroundRuns({ workflowHome: fx.root });
|
|
275
|
-
assert.equal(res.ok, true);
|
|
276
|
-
assert.equal(res.heads.length, 1);
|
|
277
|
-
assert.ok(res.error);
|
|
278
|
-
} finally {
|
|
279
|
-
fx.cleanup();
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test("A6: missing projects dir -> ok:true with empty heads", () => {
|
|
284
|
-
const root = mkdtempSync(join(tmpdir(), "agentboard-wf-none-"));
|
|
285
|
-
try {
|
|
286
|
-
assert.deepEqual(scanBackgroundRuns({ workflowHome: root }), { ok: true, heads: [], error: null });
|
|
287
|
-
} finally {
|
|
288
|
-
rmSync(root, { recursive: true, force: true });
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test("A10(setup): unreadable store -> ok:false (workflowHome is a file, not a dir)", () => {
|
|
293
|
-
const root = mkdtempSync(join(tmpdir(), "agentboard-wf-bad-"));
|
|
294
|
-
try {
|
|
295
|
-
const asFile = join(root, "not-a-dir");
|
|
296
|
-
writeFileSync(asFile, "x");
|
|
297
|
-
const res = scanBackgroundRuns({ workflowHome: asFile });
|
|
298
|
-
assert.equal(res.ok, false);
|
|
299
|
-
assert.ok(res.error);
|
|
300
|
-
assert.deepEqual(res.heads, []);
|
|
301
|
-
} finally {
|
|
302
|
-
rmSync(root, { recursive: true, force: true });
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test("A6/probe: probe splits running vs paused for the matching session", () => {
|
|
307
|
-
const fx = fixtureTree();
|
|
308
|
-
try {
|
|
309
|
-
writeHead(fx.runsDir, "run-2.json", V2({ runId: "run-2" }));
|
|
310
|
-
writeHead(fx.runsDir, "run-1.json", V2({ runId: "run-1", status: "paused" }));
|
|
311
|
-
const res = probeBackgroundRun({ sessionFile: "C:\\r\\sessions\\view_a.jsonl", hostStartedAt: null, workflowHome: fx.root });
|
|
312
|
-
assert.equal(res.ok, true);
|
|
313
|
-
assert.equal(res.paused.runId, "run-1");
|
|
314
|
-
assert.equal(res.running, null);
|
|
315
|
-
} finally {
|
|
316
|
-
fx.cleanup();
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
- [ ] **Step 2: 运行确认失败**
|
|
322
|
-
|
|
323
|
-
Run: `node --test test/background-runs.test.mjs`
|
|
324
|
-
Expected: FAIL — `scanBackgroundRuns is not a function`
|
|
325
|
-
|
|
326
|
-
- [ ] **Step 3: 实现 IO shell**
|
|
327
|
-
|
|
328
|
-
要点:
|
|
329
|
-
- `scanBackgroundRuns`:读 `<workflowHome>/projects`(`readdirSync`,`ENOENT` → `{ok:true, heads:[], error:null}`;其它错误 → `{ok:false, heads:[], error:message}`);每个 project 目录读 `runs`;只收 `endsWith(".json")` 的**文件**;`statSync().mtimeMs < minMtimeMs` 跳过;`JSON.parse` 与 `parseRunHead` 都包 `try/catch`,失败记首个 `error` 字符串并继续。
|
|
330
|
-
- `probeBackgroundRun`:`minMtimeMs = hostStartedAt == null ? 0 : hostStartedAt - CLOCK_EPSILON_MS`;`!res.ok` → 透传 `{ok:false, running:null, paused:null, error}`;否则 `classifyRunsForRow(heads, {sessionFile, hostStartedAt})` 映射成 `{ok:true, running, paused, error}`。
|
|
331
|
-
|
|
332
|
-
- [ ] **Step 4: 运行确认通过**
|
|
333
|
-
|
|
334
|
-
Run: `node --test test/background-runs.test.mjs`
|
|
335
|
-
Expected: PASS(21 个 test)
|
|
336
|
-
|
|
337
|
-
- [ ] **Step 5: Commit**
|
|
338
|
-
|
|
339
|
-
```bash
|
|
340
|
-
git add src/core/background-runs.mjs test/background-runs.test.mjs
|
|
341
|
-
git commit -m "feat(core): read workflow run heads from disk for background-run probes (issue #230)"
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
---
|
|
345
|
-
|
|
346
|
-
### Task 3: sweeper 纯谓词 `isProtected`
|
|
347
|
-
|
|
348
|
-
**Files:**
|
|
349
|
-
- Modify: `src/core/warm-host-sweeper.mjs:57-88`
|
|
350
|
-
- Test: `test/warm-host-sweeper.test.mjs`
|
|
351
|
-
|
|
352
|
-
**Interfaces:**
|
|
353
|
-
- Consumes: 无(谓词由调用方注入)
|
|
354
|
-
- Produces: `selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs?, keepViewId?, isProtected?: ((row) => boolean)|null })` —— 签名向后兼容
|
|
355
|
-
|
|
356
|
-
- [ ] **Step 1: 先写失败的测试**(追加到 `test/warm-host-sweeper.test.mjs`)
|
|
357
|
-
|
|
358
|
-
```js
|
|
359
|
-
test("A7: isProtected 跳过 ttl 与 maxWarm 两个分支", () => {
|
|
360
|
-
const now = 1_000_000;
|
|
361
|
-
const mk = (id, startedAt) => ({
|
|
362
|
-
meta: { id, updatedAt: now - 10_000 },
|
|
363
|
-
state: { semanticState: "idle", lastActivityAt: startedAt },
|
|
364
|
-
alive: true,
|
|
365
|
-
hostAlive: true,
|
|
366
|
-
host: { attachedClients: 0, startedAt },
|
|
367
|
-
});
|
|
368
|
-
const rows = [
|
|
369
|
-
mk("a", now - 500_000),
|
|
370
|
-
mk("b", now - 500_000),
|
|
371
|
-
mk("c", now - 500_000),
|
|
372
|
-
mk("d", now - 500_000),
|
|
373
|
-
mk("e", now - 500_000),
|
|
374
|
-
mk("f", now - 400_000),
|
|
375
|
-
];
|
|
376
|
-
const base = { now, maxWarm: 1, ttlMs: 100_000 };
|
|
377
|
-
|
|
378
|
-
assert.deepEqual(selectIdleHostsToEvict(rows, base).ttlEvicted, ["a", "b", "c", "d", "e", "f"]);
|
|
379
|
-
|
|
380
|
-
const protectedIds = new Set(["a", "b", "c", "d"]);
|
|
381
|
-
const withGuard = { ...base, isProtected: (row) => protectedIds.has(row.meta.id) };
|
|
382
|
-
assert.deepEqual(selectIdleHostsToEvict(rows, withGuard).ttlEvicted, ["e", "f"]);
|
|
383
|
-
|
|
384
|
-
// cap-only 分支:受保护行不进 survivors,也不占 maxWarm 名额
|
|
385
|
-
const capOnly = selectIdleHostsToEvict(rows, { now, maxWarm: 1, ttlMs: 0, isProtected: (row) => protectedIds.has(row.meta.id) });
|
|
386
|
-
assert.deepEqual(capOnly.ttlEvicted, []);
|
|
387
|
-
assert.deepEqual(capOnly.excessEvicted, ["e"]);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
test("A7: isProtected 默认不生效(null / undefined 与既有行为一致)", () => {
|
|
391
|
-
const now = 1_000_000;
|
|
392
|
-
const rows = [{ meta: { id: "a", updatedAt: now }, state: { semanticState: "idle", lastActivityAt: now - 500_000 }, alive: true, hostAlive: true, host: { attachedClients: 0, startedAt: now - 500_000 } }];
|
|
393
|
-
assert.deepEqual(selectIdleHostsToEvict(rows, { now, maxWarm: 4, ttlMs: 100_000, isProtected: undefined }), { ttlEvicted: ["a"], excessEvicted: [] });
|
|
394
|
-
});
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
- [ ] **Step 2: 运行确认失败**
|
|
398
|
-
|
|
399
|
-
Run: `node --test test/warm-host-sweeper.test.mjs`
|
|
400
|
-
Expected: FAIL — 第一个新用例的 `ttlEvicted` 仍是 5 个(谓词被忽略)
|
|
401
|
-
|
|
402
|
-
- [ ] **Step 3: 实现**
|
|
403
|
-
|
|
404
|
-
在 `selectIdleHostsToEvict` 的 JSDoc 参数里补 `isProtected?: ((row: import("./store.mjs").Row) => boolean)|null`,并在候选循环中 `if (isAgentBusy(row)) continue;` 之后加一行:
|
|
405
|
-
|
|
406
|
-
```js
|
|
407
|
-
// Background-run protection (issue #230): a host executing a background
|
|
408
|
-
// workflow still counts as busy even though the assistant turn ended.
|
|
409
|
-
if (isProtected && isProtected(row)) continue;
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
JSDoc 上方注释补一句:谓词由调用方注入(纯函数不读盘)。
|
|
413
|
-
|
|
414
|
-
- [ ] **Step 4: 运行确认通过**
|
|
415
|
-
|
|
416
|
-
Run: `node --test test/warm-host-sweeper.test.mjs`
|
|
417
|
-
Expected: PASS(23 个 test,既有 21 个全绿)
|
|
418
|
-
|
|
419
|
-
- [ ] **Step 5: Commit**
|
|
420
|
-
|
|
421
|
-
```bash
|
|
422
|
-
git add src/core/warm-host-sweeper.mjs test/warm-host-sweeper.test.mjs
|
|
423
|
-
git commit -m "feat(host): let the sweep decision exempt background-workflow hosts (issue #230)"
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
---
|
|
427
|
-
|
|
428
|
-
### Task 4: `pruneWarmHosts` 接线(两阶段选择 + 停止前复查 + 逃生门)
|
|
429
|
-
|
|
430
|
-
**Files:**
|
|
431
|
-
- Modify: `src/runtime/service.mjs:915-939`(`pruneWarmHosts`)、`src/runtime/service.mjs:136-176`(`createService` 注入项)、服务对外暴露区(`pruneWarmHosts` 委托处 `service.mjs:2510` 不改)
|
|
432
|
-
- Test: `test/warm-host-sweep.integration.test.mjs`
|
|
433
|
-
|
|
434
|
-
**Interfaces:**
|
|
435
|
-
- Consumes: Task 1-3 的 `scanBackgroundRuns` / `probeBackgroundRun` / `classifyRunsForRow` / `resolveWorkflowHome` / `CLOCK_EPSILON_MS` / `selectIdleHostsToEvict(..., { isProtected })`;既有 `appendDiagnosticOnce`(`src/core/diagnostics.mjs`)
|
|
436
|
-
- Produces: `createService({ ..., workflowHome?: string, backgroundRuns?: { scanBackgroundRuns, probeBackgroundRun } })`;`pruneWarmHosts` 行为契约见 spec §4
|
|
437
|
-
|
|
438
|
-
- [ ] **Step 1: 先写失败的测试**(追加到 `test/warm-host-sweep.integration.test.mjs`)
|
|
439
|
-
|
|
440
|
-
新增 helper 与 4 个用例(沿用本文件既有的 `freshRoot` / `makeService` / `idleView` / `captureHostSocket`):
|
|
441
|
-
|
|
442
|
-
```js
|
|
443
|
-
function wfFixture() {
|
|
444
|
-
const root = mkdtempSync(join(tmpdir(), "agentboard-wf-svc-"));
|
|
445
|
-
const runsDir = join(root, "projects", "proj-1", "runs");
|
|
446
|
-
mkdirSync(runsDir, { recursive: true });
|
|
447
|
-
return { root, runsDir, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function writeRunningRun(runsDir, root, viewId, { runId = "run-1", status = "running", updatedAt = Date.now() } = {}) {
|
|
451
|
-
writeFileSync(
|
|
452
|
-
join(runsDir, `${runId}.json`),
|
|
453
|
-
JSON.stringify({
|
|
454
|
-
format: "pi-workflow-run-v2",
|
|
455
|
-
index: { runId, status, parentSessionFile: P.sessionFilePath(root, viewId), updatedAt: new Date(updatedAt).toISOString() },
|
|
456
|
-
summary: { checkpoint: null },
|
|
457
|
-
}),
|
|
458
|
-
);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
test("A8: 有 running 后台 run 的 idle host 不被淘汰;无 run 的仍被淘汰", async () => {
|
|
462
|
-
const root = freshRoot();
|
|
463
|
-
const wf = wfFixture();
|
|
464
|
-
try {
|
|
465
|
-
const protectedId = "view_protected";
|
|
466
|
-
const plainId = "view_plain";
|
|
467
|
-
idleView(root, protectedId);
|
|
468
|
-
idleView(root, plainId);
|
|
469
|
-
writeRunningRun(wf.runsDir, root, protectedId);
|
|
470
|
-
const a = await captureHostSocket(root, protectedId);
|
|
471
|
-
const b = await captureHostSocket(root, plainId);
|
|
472
|
-
makeService(root, { workflowHome: wf.root }).pruneWarmHosts();
|
|
473
|
-
await waitFor(() => b.received.some((m) => m.type === "terminate"), 3000);
|
|
474
|
-
assert.equal(a.received.some((m) => m.type === "terminate"), false);
|
|
475
|
-
assert.equal(b.received.some((m) => m.type === "terminate"), true);
|
|
476
|
-
await closeServer(a.server);
|
|
477
|
-
await closeServer(b.server);
|
|
478
|
-
} finally {
|
|
479
|
-
wf.cleanup();
|
|
480
|
-
rmSync(root, { recursive: true, force: true });
|
|
481
|
-
}
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
test("A9: 选择后被跳过——复查阶段看到 running run 则不驱逐,并留 warm_prune_during_background_run", async () => {
|
|
485
|
-
const root = freshRoot();
|
|
486
|
-
const id = "view_race";
|
|
487
|
-
try {
|
|
488
|
-
idleView(root, id);
|
|
489
|
-
const sock = await captureHostSocket(root, id);
|
|
490
|
-
// 选择阶段扫不到任何 run;停前复查探针看到刚启动的 run(模拟快照→停止的窗口)
|
|
491
|
-
const service = makeService(root, {
|
|
492
|
-
backgroundRuns: {
|
|
493
|
-
scanBackgroundRuns: () => ({ ok: true, heads: [], error: null }),
|
|
494
|
-
probeBackgroundRun: () => ({ ok: true, running: { runId: "run-late" }, paused: null, error: null }),
|
|
495
|
-
},
|
|
496
|
-
});
|
|
497
|
-
service.pruneWarmHosts();
|
|
498
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
499
|
-
assert.equal(sock.received.some((m) => m.type === "terminate"), false);
|
|
500
|
-
const diags = readJsonl(P.diagnosticsPath(root, id));
|
|
501
|
-
assert.equal(diags.filter((d) => d.code === "warm_prune_during_background_run").length, 1);
|
|
502
|
-
assert.equal(diags.find((d) => d.code === "warm_prune_during_background_run").details.runId, "run-late");
|
|
503
|
-
await closeServer(sock.server);
|
|
504
|
-
} finally {
|
|
505
|
-
rmSync(root, { recursive: true, force: true });
|
|
506
|
-
}
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
test("A10: 探测失败 fail-open(照常 terminate)+ 去重 warn 诊断", async () => {
|
|
510
|
-
const root = freshRoot();
|
|
511
|
-
const id = "view_probe_fail";
|
|
512
|
-
try {
|
|
513
|
-
idleView(root, id);
|
|
514
|
-
const sock = await captureHostSocket(root, id);
|
|
515
|
-
const failing = {
|
|
516
|
-
scanBackgroundRuns: () => ({ ok: false, heads: [], error: "EACCES" }),
|
|
517
|
-
probeBackgroundRun: () => ({ ok: false, running: null, paused: null, error: "EACCES" }),
|
|
518
|
-
};
|
|
519
|
-
const service = makeService(root, { backgroundRuns: failing });
|
|
520
|
-
service.pruneWarmHosts();
|
|
521
|
-
await waitFor(() => sock.received.some((m) => m.type === "terminate"), 3000);
|
|
522
|
-
assert.equal(sock.received.some((m) => m.type === "terminate"), true);
|
|
523
|
-
const diags = readJsonl(P.diagnosticsPath(root, id));
|
|
524
|
-
assert.equal(diags.filter((d) => d.code === "warm_prune_probe_failed").length, 1);
|
|
525
|
-
await closeServer(sock.server);
|
|
526
|
-
} finally {
|
|
527
|
-
rmSync(root, { recursive: true, force: true });
|
|
528
|
-
}
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
test("A11: AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1 回到今天的行为", async () => {
|
|
532
|
-
const root = freshRoot();
|
|
533
|
-
const wf = wfFixture();
|
|
534
|
-
const prev = process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD;
|
|
535
|
-
try {
|
|
536
|
-
process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD = "1";
|
|
537
|
-
const id = "view_guard_off";
|
|
538
|
-
idleView(root, id);
|
|
539
|
-
writeRunningRun(wf.runsDir, root, id);
|
|
540
|
-
const sock = await captureHostSocket(root, id);
|
|
541
|
-
makeService(root, { workflowHome: wf.root }).pruneWarmHosts();
|
|
542
|
-
await waitFor(() => sock.received.some((m) => m.type === "terminate"), 3000);
|
|
543
|
-
assert.equal(sock.received.some((m) => m.type === "terminate"), true);
|
|
544
|
-
await closeServer(sock.server);
|
|
545
|
-
} finally {
|
|
546
|
-
if (prev === undefined) delete process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD;
|
|
547
|
-
else process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD = prev;
|
|
548
|
-
wf.cleanup();
|
|
549
|
-
rmSync(root, { recursive: true, force: true });
|
|
550
|
-
}
|
|
551
|
-
});
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
同时给既有 `makeService(root)` 加第二参数(默认空对象)并透传注入项:
|
|
555
|
-
|
|
556
|
-
```js
|
|
557
|
-
function makeService(root, extra = {}) {
|
|
558
|
-
return createService({
|
|
559
|
-
root,
|
|
560
|
-
/* ...既有参数保持不变... */
|
|
561
|
-
...extra,
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
```
|
|
565
|
-
|
|
566
|
-
需要的 import 增补:`mkdirSync, writeFileSync`(`node:fs`);`readJsonl` 从 `../src/core/atomic.mjs`。
|
|
567
|
-
|
|
568
|
-
- [ ] **Step 2: 运行确认失败**
|
|
569
|
-
|
|
570
|
-
Run: `node --test test/warm-host-sweep.integration.test.mjs`
|
|
571
|
-
Expected: FAIL — A8 里受保护 host 也收到了 terminate(保护未实现)
|
|
572
|
-
|
|
573
|
-
- [ ] **Step 3: 实现 service 侧**
|
|
574
|
-
|
|
575
|
-
1. `createService` 顶部注入(与既有 `opts.X ?? default` 同款):
|
|
576
|
-
|
|
577
|
-
```js
|
|
578
|
-
const backgroundRunsImpl = opts.backgroundRuns ?? { scanBackgroundRuns, probeBackgroundRun };
|
|
579
|
-
const workflowHomeImpl = opts.workflowHome ?? resolveWorkflowHome();
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
2. `pruneWarmHosts` 重写为 spec §4 的流程:`dry` 快跑 → 无候选即返回 → `AGENT_BOARD_NO_BACKGROUND_RUN_GUARD !== "1"` 时做一次 `scanBackgroundRuns`(`minMtimeMs` = 候选行 `host.startedAt` 最小值减 `CLOCK_EPSILON_MS`;任一候选行缺 `startedAt` 时取 `0`)→ `classifyRunsForRow` 生成 `isProtected` → 带谓词再选 → 逐行复查(既有四项 + `isProtected` + 新鲜 `probeBackgroundRun`):`!ok` → `appendDiagnosticOnce(code:"warm_prune_probe_failed", {match:["error"]})` 后**继续**驱逐;`running` → `appendDiagnosticOnce(code:"warm_prune_during_background_run", {match:["runId"]})` 后 `continue`(跳过);否则 `stopHostRow(row, "warm_prune")`。
|
|
583
|
-
|
|
584
|
-
3. 诊断 message 文案固定:
|
|
585
|
-
- `warm_prune_probe_failed`: `"Could not read the workflow run store; warm-host eviction proceeded without background-run protection"`
|
|
586
|
-
- `warm_prune_during_background_run`: `` `Skipped warm-host eviction: background workflow ${runId} started after selection` ``
|
|
587
|
-
|
|
588
|
-
- [ ] **Step 4: 运行确认通过**
|
|
589
|
-
|
|
590
|
-
Run: `node --test test/warm-host-sweep.integration.test.mjs`
|
|
591
|
-
Expected: PASS(7 个 test:既有 3 + 新 4)
|
|
592
|
-
|
|
593
|
-
- [ ] **Step 5: 回归既有 sweep 单测**
|
|
594
|
-
|
|
595
|
-
Run: `node --test test/warm-host-sweeper.test.mjs test/host-recovery.test.mjs`
|
|
596
|
-
Expected: PASS(既有用例零改动)
|
|
597
|
-
|
|
598
|
-
- [ ] **Step 6: Commit**
|
|
599
|
-
|
|
600
|
-
```bash
|
|
601
|
-
git add src/runtime/service.mjs test/warm-host-sweep.integration.test.mjs
|
|
602
|
-
git commit -m "fix(host): keep warm hosts alive while a background workflow run is active (issue #230)"
|
|
603
|
-
```
|
|
604
|
-
|
|
605
|
-
---
|
|
606
|
-
|
|
607
|
-
### Task 5: 驱逐/宿主死亡的留痕(reconcile pre-pass + `patch_fields` 白名单)
|
|
608
|
-
|
|
609
|
-
**Files:**
|
|
610
|
-
- Modify: `src/core/state-commands.mjs:113`(`PATCHABLE_FIELDS["service"]`)、`:108-112` 注释
|
|
611
|
-
- Modify: `src/runtime/service.mjs:2269-2275`(`reconcile()` 开头加 pre-pass)与 `createService` 闭包内新增 `noticeProbeMemo` / `noticeInterruptedBackgroundRuns`
|
|
612
|
-
- Modify: `test/state-commands.test.mjs:118`(白名单断言)
|
|
613
|
-
- Modify: `test/architecture-writer-boundary.test.mjs:39`(豁免理由字符串「三处」→「四处」)
|
|
614
|
-
- Test: `test/background-run-notice.test.mjs`
|
|
615
|
-
|
|
616
|
-
**Interfaces:**
|
|
617
|
-
- Consumes: Task 1-4 的 probe 注入项与被测信息;既有 `sendLifecycleCommand` / `readState` / `writeState` / `appendDiagnosticOnce`
|
|
618
|
-
- Produces: `reconcile()` 新增 pre-pass 行为(spec §4 第 0 步);`PATCHABLE_FIELDS["service"].state` 含 `"summary"`
|
|
619
|
-
|
|
620
|
-
- [ ] **Step 1: 先写失败的测试**
|
|
621
|
-
|
|
622
|
-
`test/background-run-notice.test.mjs`(完整文件;沿用 integration 测试里的 root/fixture 构造方式):
|
|
623
|
-
|
|
624
|
-
```js
|
|
625
|
-
import assert from "node:assert/strict";
|
|
626
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
627
|
-
import { tmpdir } from "node:os";
|
|
628
|
-
import { join } from "node:path";
|
|
629
|
-
import { test } from "node:test";
|
|
630
|
-
import { createService } from "../src/runtime/service.mjs";
|
|
631
|
-
import { createView, readState, writeHost } from "../src/core/store.mjs";
|
|
632
|
-
import { readJsonl } from "../src/core/atomic.mjs";
|
|
633
|
-
import * as P from "../src/core/paths.mjs";
|
|
634
|
-
|
|
635
|
-
const makeService = (root, extra = {}) =>
|
|
636
|
-
createService({ root, runnerScript: "/no/runner.mjs", piCommand: "pi", piArgsPrefix: [], defaultCwd: process.cwd(), ...extra });
|
|
637
|
-
|
|
638
|
-
function deadHostRow(root, id, { paused = true } = {}) {
|
|
639
|
-
const wf = mkdtempSync(join(tmpdir(), "agentboard-notice-"));
|
|
640
|
-
const runsDir = join(wf, "projects", "p", "runs");
|
|
641
|
-
mkdirSync(runsDir, { recursive: true });
|
|
642
|
-
createView(root, { id, name: id, cwd: "/r" });
|
|
643
|
-
const s = readState(root, id);
|
|
644
|
-
// 生产真实态:turn 已结束、run 已终态,宿主随后死亡
|
|
645
|
-
s.semanticState = "idle";
|
|
646
|
-
s.processState = "exited";
|
|
647
|
-
s.currentRunId = null;
|
|
648
|
-
s.summary = "Turn finished";
|
|
649
|
-
writeState(root, s);
|
|
650
|
-
writeHost(root, { viewId: id, mode: "pty", state: "exited", runnerPid: null, startedAt: Date.now() - 3_600_000, endedAt: Date.now() - 60_000, attachedClients: 0, lastSeenAt: Date.now() });
|
|
651
|
-
writeFileSync(
|
|
652
|
-
join(runsDir, "run-9.json"),
|
|
653
|
-
JSON.stringify({
|
|
654
|
-
format: "pi-workflow-run-v2",
|
|
655
|
-
index: {
|
|
656
|
-
runId: "run-9",
|
|
657
|
-
status: paused ? "paused" : "completed",
|
|
658
|
-
parentSessionFile: P.sessionFilePath(root, id),
|
|
659
|
-
updatedAt: new Date(Date.now() - 90_000).toISOString(),
|
|
660
|
-
},
|
|
661
|
-
summary: { checkpoint: { checkpointId: "cp1", kind: "manual", status: "pending" } },
|
|
662
|
-
}),
|
|
663
|
-
);
|
|
664
|
-
return { wf, cleanup: () => rmSync(wf, { recursive: true, force: true }) };
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
test("A12: 生产真实态的死宿主 + paused run -> summary 写入提示,诊断恰一条", async () => {
|
|
668
|
-
const root = mkdtempSync(join(tmpdir(), "agentboard-notice-root-"));
|
|
669
|
-
const fx = deadHostRow(root, "view_notice");
|
|
670
|
-
try {
|
|
671
|
-
const service = makeService(root, { workflowHome: fx.wf, sendStateCommand: async () => ({ status: "rejected", reason: "coordinator_disabled" }) });
|
|
672
|
-
await service.reconcile();
|
|
673
|
-
const s = readState(root, "view_notice");
|
|
674
|
-
assert.match(s.summary, /Background workflow run-9 was interrupted/);
|
|
675
|
-
assert.match(s.summary, /resume/);
|
|
676
|
-
const diags = readJsonl(P.diagnosticsPath(root, "view_notice"));
|
|
677
|
-
assert.equal(diags.filter((d) => d.code === "background_run_interrupted").length, 1);
|
|
678
|
-
await service.reconcile();
|
|
679
|
-
assert.equal(readState(root, "view_notice").summary, s.summary);
|
|
680
|
-
assert.equal(readJsonl(P.diagnosticsPath(root, "view_notice")).filter((d) => d.code === "background_run_interrupted").length, 1);
|
|
681
|
-
} finally {
|
|
682
|
-
fx.cleanup();
|
|
683
|
-
rmSync(root, { recursive: true, force: true });
|
|
684
|
-
}
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
test("A12: completed run(非 paused)不写提示", async () => {
|
|
688
|
-
const root = mkdtempSync(join(tmpdir(), "agentboard-notice-root2-"));
|
|
689
|
-
const fx = deadHostRow(root, "view_done", { paused: false });
|
|
690
|
-
try {
|
|
691
|
-
const service = makeService(root, { workflowHome: fx.wf, sendStateCommand: async () => ({ status: "rejected", reason: "coordinator_disabled" }) });
|
|
692
|
-
await service.reconcile();
|
|
693
|
-
assert.equal(readState(root, "view_done").summary, "Turn finished");
|
|
694
|
-
} finally {
|
|
695
|
-
fx.cleanup();
|
|
696
|
-
rmSync(root, { recursive: true, force: true });
|
|
697
|
-
}
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
test("A12: 探针失败不记 memo —— 恢复后下一个 tick 仍能写提示", async () => {
|
|
701
|
-
const root = mkdtempSync(join(tmpdir(), "agentboard-notice-root3-"));
|
|
702
|
-
const fx = deadHostRow(root, "view_retry");
|
|
703
|
-
try {
|
|
704
|
-
let calls = 0;
|
|
705
|
-
const flaky = {
|
|
706
|
-
scanBackgroundRuns: () => ({ ok: true, heads: [], error: null }),
|
|
707
|
-
probeBackgroundRun: () => {
|
|
708
|
-
calls += 1;
|
|
709
|
-
if (calls === 1) return { ok: false, running: null, paused: null, error: "EACCES" };
|
|
710
|
-
return { ok: true, running: null, paused: { runId: "run-9" }, error: null };
|
|
711
|
-
},
|
|
712
|
-
};
|
|
713
|
-
const service = makeService(root, { backgroundRuns: flaky, sendStateCommand: async () => ({ status: "rejected", reason: "coordinator_disabled" }) });
|
|
714
|
-
await service.reconcile();
|
|
715
|
-
assert.equal(readState(root, "view_retry").summary, "Turn finished");
|
|
716
|
-
await service.reconcile();
|
|
717
|
-
assert.match(readState(root, "view_retry").summary, /run-9/);
|
|
718
|
-
} finally {
|
|
719
|
-
fx.cleanup();
|
|
720
|
-
rmSync(root, { recursive: true, force: true });
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
```
|
|
724
|
-
|
|
725
|
-
`test/state-commands.test.mjs`:把 `PATCHABLE_FIELDS["service"]` 断言(L118)改为 `{ state: ["lastVisitedAt", "summary"], status: [] }`。
|
|
726
|
-
|
|
727
|
-
- [ ] **Step 2: 运行确认失败**
|
|
728
|
-
|
|
729
|
-
Run: `node --test test/background-run-notice.test.mjs`
|
|
730
|
-
Expected: FAIL — summary 仍是 `Turn finished`(pre-pass 未实现)
|
|
731
|
-
|
|
732
|
-
- [ ] **Step 3: 实现**
|
|
733
|
-
|
|
734
|
-
1. `src/core/state-commands.mjs`:`"service"` 的 `state` 白名单加 `"summary"`,并把上方注释改为同时说明两条用途(`lastVisitedAt` = 访问时间;`summary` = 宿主死亡后的后台 run 中断提示,issue #230)。
|
|
735
|
-
2. `src/runtime/service.mjs`:
|
|
736
|
-
- `createService` 闭包内新增 `const noticeProbeMemo = new Set();`
|
|
737
|
-
- 新增 `async function noticeInterruptedBackgroundRuns(now)`,实现 spec §4 第 0 步(`host.state ∈ {exited, failed}` 且 `!row.hostAlive`;key = `` `${viewId}:${host.endedAt ?? 0}` ``;**probe 成功后才** `memo.add(key)`;`!probe.paused` → continue;summary 已以 `"Background workflow "` 开头则跳过写入;写入走 `sendLifecycleCommand({kind:"patch_fields", payload:{state:{summary}}})`,`reason === "coordinator_disabled"` 时 `const fresh = readState(root, id); fresh.summary = summary; fresh.updatedAt = now; writeState(root, fresh);`;随后 `appendDiagnosticOnce(..., {match:["runId"]})`)。
|
|
738
|
-
- `reconcile()` 开头(`const now = Date.now();` 之后、`let fixed = 0;` 之前)`await noticeInterruptedBackgroundRuns(now);`
|
|
739
|
-
3. `test/architecture-writer-boundary.test.mjs:39` 的豁免理由字符串改为「…以及 reconcile() 的四处 coordinator_disabled 直写回退(host 探测终态 / project 模式 / runner-exited / paused-run summary notice)」。
|
|
740
|
-
|
|
741
|
-
- [ ] **Step 4: 运行确认通过**
|
|
742
|
-
|
|
743
|
-
Run: `node --test test/background-run-notice.test.mjs test/state-commands.test.mjs test/architecture-writer-boundary.test.mjs`
|
|
744
|
-
Expected: PASS
|
|
745
|
-
|
|
746
|
-
- [ ] **Step 5: Commit**
|
|
747
|
-
|
|
748
|
-
```bash
|
|
749
|
-
git add src/core/state-commands.mjs src/runtime/service.mjs test/background-run-notice.test.mjs test/state-commands.test.mjs test/architecture-writer-boundary.test.mjs
|
|
750
|
-
git commit -m "feat(host): notice interrupted background runs after a warm host dies (issue #230)"
|
|
751
|
-
```
|
|
752
|
-
|
|
753
|
-
---
|
|
754
|
-
|
|
755
|
-
### Task 6: 全仓验收(A13 / A14)
|
|
756
|
-
|
|
757
|
-
**Files:** 无代码改动(仅验证)
|
|
758
|
-
|
|
759
|
-
**Interfaces:**
|
|
760
|
-
- Consumes: Task 1-5 全部
|
|
761
|
-
- Produces: 可合并的绿灯
|
|
762
|
-
|
|
763
|
-
- [ ] **Step 1: 类型与静态检查**
|
|
764
|
-
|
|
765
|
-
Run: `npm run typecheck`
|
|
766
|
-
Expected: 退出码 0
|
|
767
|
-
|
|
768
|
-
- [ ] **Step 2: 覆盖验收矩阵**
|
|
769
|
-
|
|
770
|
-
Run: `node --test test/background-runs.test.mjs test/warm-host-sweeper.test.mjs test/warm-host-sweep.integration.test.mjs test/background-run-notice.test.mjs test/state-commands.test.mjs test/architecture-writer-boundary.test.mjs`
|
|
771
|
-
Expected: PASS,对应 A1-A16 全绿
|
|
772
|
-
|
|
773
|
-
- [ ] **Step 3: 全仓门禁**
|
|
774
|
-
|
|
775
|
-
Run: `npm run verify`
|
|
776
|
-
Expected: 退出码 0(typecheck + perf gate + 全量测试 + c8 覆盖率门槛 + pack:dry)
|
|
777
|
-
|
|
778
|
-
- [ ] **Step 4: 提交(若前两步需要修 lint/覆盖率,逐个修复后)**
|
|
779
|
-
|
|
780
|
-
```bash
|
|
781
|
-
git add <changed files>
|
|
782
|
-
git commit -m "test: cover background-run guard end to end (issue #230)"
|
|
783
|
-
```
|
|
784
|
-
|
|
785
|
-
---
|
|
786
|
-
|
|
787
|
-
## Manual verification (post-implementation, U1 / U2)
|
|
788
|
-
|
|
789
|
-
部署前置与步骤见 spec §9(必须让 board 从 PR 分支加载,**不是**本工作副本)。两项在 PR 描述里登记为 `pending` 直到实际执行。
|
|
790
|
-
|
|
791
|
-
| ID | 操作 | 通过标准 |
|
|
792
|
-
|---|---|---|
|
|
793
|
-
| U1 | 压缩 TTL(`AGENT_BOARD_WARM_HOST_TTL_MS=60000`)后跑一个 ≥3 min 的单 step background workflow,关闭 attach 等 2×TTL | `host.json.state` 保持 `alive`;run 保持 `running`;无 `skipped/interrupted` |
|
|
794
|
-
| U2 | `AGENT_BOARD_NO_BACKGROUND_RUN_GUARD=1` 让驱逐发生,再打开 dashboard | 行 summary 含 runId 与 "resume";诊断含 `background_run_interrupted`;resume 后 run 完成 |
|
|
795
|
-
|
|
796
|
-
## 验收 → Task 追溯
|
|
797
|
-
|
|
798
|
-
| 验收 ID | Task |
|
|
799
|
-
|---|---|
|
|
800
|
-
| A1, A2, A3, A4, A5, A16 | 1 |
|
|
801
|
-
| A6 | 2 |
|
|
802
|
-
| A7 | 3 |
|
|
803
|
-
| A8, A9, A10, A11 | 4 |
|
|
804
|
-
| A12, A15 | 5 |
|
|
805
|
-
| A13, A14 | 6 |
|
|
806
|
-
| U1, U2 | Manual verification |
|