@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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/README.md +39 -12
- package/agents/analyst.md +61 -0
- package/agents/coder.md +70 -0
- package/agents/debugger.md +67 -0
- package/agents/doc-reviewer.md +3 -3
- package/agents/explorer.md +50 -18
- package/agents/general-purpose.md +19 -8
- package/agents/orchestrator.md +37 -32
- package/agents/planner.md +45 -11
- package/agents/researcher.md +53 -11
- package/agents/reviewer.md +74 -0
- package/package.json +1 -1
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-registry.test.ts +13 -11
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +170 -14
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +158 -52
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +292 -89
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +90 -13
- package/src/execution/host-mode.ts +1 -1
- package/src/execution/lifecycle-manager.ts +484 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-store.ts +554 -107
- package/src/execution/session-pending.ts +116 -45
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +290 -75
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +867 -138
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +204 -22
- package/src/execution/worktree-manager.ts +128 -49
- package/src/execution/worktree-registry.ts +13 -2
- package/src/index.ts +277 -19
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
- package/src/interface/__tests__/tool-render.test.ts +15 -13
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +44 -17
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +82 -17
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +11 -24
- package/src/interface/tool-workflow.ts +20 -35
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
- package/src/orchestration/__tests__/test-mocks.ts +191 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +133 -29
- package/src/orchestration/execute-agent-call.ts +31 -7
- package/src/orchestration/jsonl-run-store.ts +287 -40
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +135 -132
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +29 -10
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +55 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +16 -0
- package/src/shared/resource-discovery.ts +147 -58
- package/src/shared/schema-jsonify.ts +53 -0
- package/workflows/README.md +4 -4
- package/agents/code-reviewer.md +0 -47
- package/agents/context-builder.md +0 -21
- package/agents/oracle.md +0 -34
- package/agents/worker.md +0 -20
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
// src/execution/lifecycle-manager.ts
|
|
2
|
+
//
|
|
3
|
+
// Subagent 持续对话 V2 — 进程生命周期管理(§5.2 模块 1)。
|
|
4
|
+
//
|
|
5
|
+
// 本模块是 V2 五项生命周期职责的中心调度器,以**模块级单例**持有全局进程
|
|
6
|
+
// 状态(活进程集合、per-record idle timer、activate 串行化锁)。它**不直接持有
|
|
7
|
+
// ChildProcess 句柄**——句柄仍在 session-runner 的 spawnedChildren Map——因此
|
|
8
|
+
// 所有「副作用」能力(kill / 探活 / 超时回调)都由调用方经回调/参数注入,本模块
|
|
9
|
+
// 只管状态记账 + 调度顺序。这让模块可独立编译 + 单测,无需拉起真实子进程。
|
|
10
|
+
//
|
|
11
|
+
// 五项职责(对应 V2 决策 1/4/7),接线现状(防止「已防护」错觉,详见各节 [状态] 注释):
|
|
12
|
+
// 1. idle timer —— agent_settled arm / 新 turn disarm / 超时触发 onTimeout(决策 4)
|
|
13
|
+
// 【已接线:session-runner.ts agent_settled arm / subagent-service 新 turn disarm】
|
|
14
|
+
// 2. 全局 ceiling —— 活进程上限,超限时按 LRU 挤出最久空闲(决策 4)【未接线,deferred】
|
|
15
|
+
// 3. shutdown 收割 —— 父进程 shutdown 时显式 SIGTERM 全部 activation(决策 7 防线 i)
|
|
16
|
+
// 【未接线:index.ts reapSpawnedChildrenOnShutdown + killAllSpawnedChildren 已等价覆盖】
|
|
17
|
+
// 4. 孤儿扫描 —— 父进程启动时按持久化 PID 扫收上次崩溃遗留的孤儿(决策 7 防线 ii)【deferred】
|
|
18
|
+
// 5. activate 互斥 —— 同 recordId 的并发 activate 串行化(决策 7 防线 iii,防双写者)
|
|
19
|
+
// 【已接线:subagent-service.ts 冷路径 resume 前 acquireActivateLock】
|
|
20
|
+
//
|
|
21
|
+
// 触发点(谁在何时调 arm/disarm/reap)散落在 session-runner / subagent-service /
|
|
22
|
+
// index.ts,由后续步骤接入;本模块不 import 它们,避免循环依赖。
|
|
23
|
+
//
|
|
24
|
+
// 设计参考:session-runner 的 MF-3/MF-4 setTimeout→SIGTERM 骨架(复用 timer 形态,
|
|
25
|
+
// 触发条件重构);spawnedChildren Map 的模块级单例模式。
|
|
26
|
+
|
|
27
|
+
// ============================================================
|
|
28
|
+
// 默认常量
|
|
29
|
+
// ============================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 默认 idle 超时(per-record)。
|
|
33
|
+
*
|
|
34
|
+
* V2 §5.4 / 决策 4:初拟 ≤ prompt cache TTL(~5min)——超出 cacheTTL 的活进程白占
|
|
35
|
+
* 内存(续聊仍 cache miss),小于则 kill 丢热 cache。实测定(P-timeout)。
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 从环境变量 XYZ_SUBAGENT_IDLE_TIMEOUT_MS 读取全局默认超时。
|
|
41
|
+
* 返回 undefined 表示 env 未设置或非法(调用方回落 DEFAULT_IDLE_TIMEOUT_MS)。
|
|
42
|
+
*
|
|
43
|
+
* [review 修复] 原 PI_ 前缀(PI_SUBAGENT_IDLE_TIMEOUT_MS)不在 ENV_WHITELIST_PREFIXES
|
|
44
|
+
* 白名单(packages/shared/src/constants.ts 只有 XYZ_ 等,无 PI_),xyz-agent 桌面
|
|
45
|
+
* spawn 链(safe-env 过滤)会丢弃该 env,配置口在桌面场景静默失效——已改名 XYZ_
|
|
46
|
+
* 前缀以透传。本地 pi CLI 直跑 env 全量继承,两种前缀都有效。注意与其他
|
|
47
|
+
* PI_SUBAGENT_* env 区分:那些是 extension 在 pi 进程内 spawn 子进程时直接注入的
|
|
48
|
+
* childEnv,不经过 safe-env 白名单过滤,PI_ 前缀无此问题。
|
|
49
|
+
*/
|
|
50
|
+
function getEnvIdleTimeoutMs(): number | undefined {
|
|
51
|
+
const raw = process.env.XYZ_SUBAGENT_IDLE_TIMEOUT_MS;
|
|
52
|
+
if (!raw) return undefined;
|
|
53
|
+
const parsed = Number(raw);
|
|
54
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 默认全局活进程上限(ceiling)。
|
|
60
|
+
*
|
|
61
|
+
* V2 §5.4 / 决策 4:防「N 个 subagent 于 timeout 窗口内高频复用」场景的内存高水位
|
|
62
|
+
* 无上界。候选 8-10,实测定(P-ceiling)。
|
|
63
|
+
*
|
|
64
|
+
* 注意:挤出候选**仅限空闲进程**(busy 进程不做候选,V2 决策 4 硬约束)。本模块不
|
|
65
|
+
* 感知 busy(进程句柄在 session-runner),调用方接入 evictIfOverCeiling 前应确保
|
|
66
|
+
* 已把 busy 进程移出活进程集合(unregisterActiveProcess),或由后续步骤扩展注入
|
|
67
|
+
* isBusy 谓词。
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_MAX_ALIVE_PROCESSES = 8;
|
|
70
|
+
|
|
71
|
+
// ============================================================
|
|
72
|
+
// 职责 1:idle timer(per-record)
|
|
73
|
+
// ============================================================
|
|
74
|
+
|
|
75
|
+
interface IdleTimerEntry {
|
|
76
|
+
/** Node setTimeout 句柄,disarm/刷新时 clearTimeout。 */
|
|
77
|
+
readonly timer: NodeJS.Timeout;
|
|
78
|
+
/** 本次 arm 使用的超时时长(诊断/刷新对齐用)。 */
|
|
79
|
+
readonly timeoutMs: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** recordId → armed idle timer。仅在空闲态 armed(V2 决策 4:禁止 timer 常驻)。 */
|
|
83
|
+
const idleTimers = new Map<string, IdleTimerEntry>();
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Arm(或刷新)某 record 的 idle timer。
|
|
87
|
+
*
|
|
88
|
+
* - 若该 record 已有 armed timer,先 clearTimeout 旧的再设新的(重复 arm = 刷新计时,
|
|
89
|
+
* 对齐 V2「续聊 disarm 后再次 agent_settled 重新 arm」语义)。
|
|
90
|
+
* - 超时触发时先从 Map 移除自身,再回调 onTimeout(onTimeout 内若重新 arm 不会被误删)。
|
|
91
|
+
*
|
|
92
|
+
* 调用时机(接入时由 session-runner 编排):`agent_settled`(支柱四,真空闲边界)→ arm。
|
|
93
|
+
*
|
|
94
|
+
* @param recordId subagent record id(sa-<uuid>)
|
|
95
|
+
* @param onTimeout 超时回调(调用方注入:SIGTERM 回收进程)
|
|
96
|
+
* @param timeoutMs 可选。SP-6 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > DEFAULT_IDLE_TIMEOUT_MS。
|
|
97
|
+
*/
|
|
98
|
+
export function armIdleTimer(
|
|
99
|
+
recordId: string,
|
|
100
|
+
onTimeout: () => void,
|
|
101
|
+
timeoutMs?: number,
|
|
102
|
+
): void {
|
|
103
|
+
// SP-6 优先级:参数 > env XYZ_SUBAGENT_IDLE_TIMEOUT_MS > 默认 300000ms (5min)。
|
|
104
|
+
const resolved = timeoutMs ?? getEnvIdleTimeoutMs() ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
105
|
+
|
|
106
|
+
// 刷新:先清旧 timer,避免同一 record 叠加多个 armed timer。
|
|
107
|
+
disarmIdleTimer(recordId);
|
|
108
|
+
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
idleTimers.delete(recordId);
|
|
111
|
+
onTimeout();
|
|
112
|
+
}, resolved);
|
|
113
|
+
// node 默认 setTimeout 返回的 timer 会被事件循环 keep-alive;unref 让它不阻塞
|
|
114
|
+
// 进程退出(进程退出由 shutdown hook 显式收割兜底,不靠 timer 拖延)。
|
|
115
|
+
timer.unref?.();
|
|
116
|
+
|
|
117
|
+
idleTimers.set(recordId, { timer, timeoutMs: resolved });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Disarm 某 record 的 idle timer(新 turn 开始时调)。
|
|
122
|
+
*
|
|
123
|
+
* 不存在 armed timer 时 no-op。V2 决策 4:新 turn 开始(投递导致 isStreaming 转
|
|
124
|
+
* true)必须 disarm——turn 期间进程由 busy 状态保护,绝不能被 idle timer 误杀。
|
|
125
|
+
*/
|
|
126
|
+
export function disarmIdleTimer(recordId: string): void {
|
|
127
|
+
const entry = idleTimers.get(recordId);
|
|
128
|
+
if (!entry) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
clearTimeout(entry.timer);
|
|
132
|
+
idleTimers.delete(recordId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 查询某 record 是否有 armed idle timer(诊断/接入期断言用)。
|
|
137
|
+
*/
|
|
138
|
+
export function hasIdleTimer(recordId: string): boolean {
|
|
139
|
+
return idleTimers.has(recordId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================
|
|
143
|
+
// 职责 2:全局 ceiling(活进程上限 + LRU)
|
|
144
|
+
//
|
|
145
|
+
// [状态:未接线(deferred)] register/touch/unregister/evictIfOverCeiling 均无生产
|
|
146
|
+
// 调用方(仅本模块单测引用)——「N 个 chatMode 长驻进程内存高水位」防护当前**未生效**。
|
|
147
|
+
// 接线条件:spawn/activate 完成处 registerActiveProcess + 事件驱动或周期调
|
|
148
|
+
// evictIfOverCeiling(注入 SIGTERM 回调),busy 进程按 evictIfOverCeiling docstring
|
|
149
|
+
// 约束移出集合。保留理由:activeProcesses 记账同时是职责 4 孤儿判定(scanOrphanProcesses
|
|
150
|
+
// 的「不在当前活进程集合」检查)的数据通路,与职责 4 一并待接入,不宜单删。
|
|
151
|
+
// ============================================================
|
|
152
|
+
|
|
153
|
+
interface ActiveProcessEntry {
|
|
154
|
+
/** 最近活动时间戳(Date.now())。LRU 挤出时取最小者。 */
|
|
155
|
+
lastTouched: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* recordId → 活进程记账项。register 时加入,unregister/挤出/reap 时移除。
|
|
160
|
+
*
|
|
161
|
+
* 该集合**不是** ChildProcess 句柄表(那在 spawnedChildren),只是 lifecycle-manager
|
|
162
|
+
* 用于 ceiling 判定 + orphan 判定 + reap 枚举的记账结构。
|
|
163
|
+
*/
|
|
164
|
+
const activeProcesses = new Map<string, ActiveProcessEntry>();
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 登记一个活进程(activate/spawn 完成时调)。
|
|
168
|
+
*
|
|
169
|
+
* 重复 register 同一 recordId 视为刷新(更新 lastTouched),不叠加。
|
|
170
|
+
*/
|
|
171
|
+
export function registerActiveProcess(recordId: string): void {
|
|
172
|
+
activeProcesses.set(recordId, { lastTouched: Date.now() });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 续聊/新 turn 时 touch 某 record,更新 LRU 时间戳(让它不被 LRU 挤出)。
|
|
177
|
+
*
|
|
178
|
+
* 不存在时 no-op(保守:避免隐式创建掩盖状态不一致;调用方应先 register)。
|
|
179
|
+
*/
|
|
180
|
+
export function touchActiveProcess(recordId: string): void {
|
|
181
|
+
const entry = activeProcesses.get(recordId);
|
|
182
|
+
if (!entry) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
entry.lastTouched = Date.now();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 注销一个活进程(进程退出/终态化/已被 reap 时调)。
|
|
190
|
+
*
|
|
191
|
+
* cascade:同时 disarm 该 record 的 idle timer——进程都没了,残留 timer 触发只会
|
|
192
|
+
* 操作已清理的 record(V2 决策 4 一致性)。不存在时 no-op。
|
|
193
|
+
*/
|
|
194
|
+
export function unregisterActiveProcess(recordId: string): void {
|
|
195
|
+
activeProcesses.delete(recordId);
|
|
196
|
+
disarmIdleTimer(recordId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 超过 ceiling 时按 LRU 挤出最久空闲的进程,直到活进程数 ≤ ceiling。
|
|
201
|
+
*
|
|
202
|
+
* 挤出语义(V2 决策 4 passivate):对每个被挤出者调 onEvict(调用方注入:SIGTERM
|
|
203
|
+
* 回收),并从活进程集合移除 + disarm 其 idle timer(passivate 后进程死,timer 无意义)。
|
|
204
|
+
*
|
|
205
|
+
* **busy 不做挤出候选**(V2 决策 4 硬约束):本模块不感知 busy,调用方接入前负责把
|
|
206
|
+
* busy 进程 unregister 出集合(或后续步骤注入 isBusy 谓词),否则 busy 进程可能被误杀。
|
|
207
|
+
*
|
|
208
|
+
* @param onEvict 挤出回调(recordId)——调用方 SIGTERM 该进程
|
|
209
|
+
* @param maxAlive 可选上限覆盖(默认 DEFAULT_MAX_ALIVE_PROCESSES);注入便于测试
|
|
210
|
+
*/
|
|
211
|
+
export function evictIfOverCeiling(
|
|
212
|
+
onEvict: (recordId: string) => void,
|
|
213
|
+
maxAlive: number = DEFAULT_MAX_ALIVE_PROCESSES,
|
|
214
|
+
): void {
|
|
215
|
+
while (activeProcesses.size > maxAlive) {
|
|
216
|
+
// 找 lastTouched 最小的(最久未活动)。Map 迭代顺序 = 插入顺序,最小值扫描稳定。
|
|
217
|
+
let oldestId: string | undefined;
|
|
218
|
+
let oldestTouched = Infinity;
|
|
219
|
+
for (const [id, entry] of activeProcesses) {
|
|
220
|
+
if (entry.lastTouched < oldestTouched) {
|
|
221
|
+
oldestTouched = entry.lastTouched;
|
|
222
|
+
oldestId = id;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (oldestId === undefined) {
|
|
226
|
+
break; // 防御:集合为空但 size 判定异常时退出
|
|
227
|
+
}
|
|
228
|
+
// 先移除 + disarm,再回调。onEvict 是 passivate 语义(SIGTERM 回收),
|
|
229
|
+
// 不应在其中重新 register 同一 record——否则 size 不减会导致 while 死循环。
|
|
230
|
+
activeProcesses.delete(oldestId);
|
|
231
|
+
disarmIdleTimer(oldestId);
|
|
232
|
+
onEvict(oldestId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 查询当前活进程数(诊断/接入期断言用)。
|
|
238
|
+
*/
|
|
239
|
+
export function getActiveProcessCount(): number {
|
|
240
|
+
return activeProcesses.size;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============================================================
|
|
244
|
+
// 职责 3:shutdown 收割(防线 i)
|
|
245
|
+
//
|
|
246
|
+
// [状态:未接线,已有等价实现] reapAllAliveProcesses 无生产调用方——shutdown 收割
|
|
247
|
+
// 已由 index.ts 的 reapSpawnedChildrenOnShutdown(process hook → killAllSpawnedChildren
|
|
248
|
+
// 发 SIGTERM,覆盖 SIGTERM/SIGINT/beforeExit 挂点)等价覆盖;后者直接遍历
|
|
249
|
+
// spawnedChildren 句柄表,不依赖本模块 activeProcesses 记账的 register 时机,覆盖面
|
|
250
|
+
// 更可靠。本函数保留作跨模块等价实现;若未来接入须先保证 register/unregister 记账
|
|
251
|
+
// 完整,否则收割列表不全。
|
|
252
|
+
// ============================================================
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 收割当前全部活进程(父进程 shutdown 时调)。
|
|
256
|
+
*
|
|
257
|
+
* 遍历活进程集合,对每个调 killFn(调用方注入:child.kill("SIGTERM")),并 disarm 对应
|
|
258
|
+
* idle timer 避免 timer 泄漏(V2 决策 7 防线 i:shutdown hook 显式收割)。
|
|
259
|
+
*
|
|
260
|
+
* @param killFn 收割回调(recordId)——调用方对 spawnedChildren 中的句柄发 SIGTERM
|
|
261
|
+
* @returns 被收割的 recordId 列表(按集合迭代序)
|
|
262
|
+
*/
|
|
263
|
+
export function reapAllAliveProcesses(killFn: (recordId: string) => void): string[] {
|
|
264
|
+
const reaped: string[] = [];
|
|
265
|
+
// 拷贝 keys 再遍历——killFn 的副作用可能间接触发 unregister,避免迭代中改集合。
|
|
266
|
+
for (const recordId of [...activeProcesses.keys()]) {
|
|
267
|
+
killFn(recordId);
|
|
268
|
+
reaped.push(recordId);
|
|
269
|
+
}
|
|
270
|
+
// 全部收割后清空记账 + disarm 所有 timer。
|
|
271
|
+
for (const entry of idleTimers.values()) {
|
|
272
|
+
clearTimeout(entry.timer);
|
|
273
|
+
}
|
|
274
|
+
idleTimers.clear();
|
|
275
|
+
activeProcesses.clear();
|
|
276
|
+
return reaped;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ============================================================
|
|
280
|
+
// 职责 4:孤儿扫描(防线 ii)
|
|
281
|
+
// ============================================================
|
|
282
|
+
|
|
283
|
+
/** 孤儿扫描候选:来自持久化 record 的 pid 信息。 */
|
|
284
|
+
export interface OrphanCandidate {
|
|
285
|
+
readonly id: string;
|
|
286
|
+
/** 持久化的进程 pid(缺失表示上次未持久化,无法判定)。 */
|
|
287
|
+
readonly pid?: number;
|
|
288
|
+
/** session 文件路径(调用方二次校验命令行时可用,本函数不消费)。 */
|
|
289
|
+
readonly sessionFile?: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 扫描孤儿进程候选(父进程启动时调)。
|
|
294
|
+
*
|
|
295
|
+
* 判定:pid 存在 && isProcessAlive(pid) && 该 record **不在**当前活进程集合 =
|
|
296
|
+
* 上次崩溃/异常退出遗留的孤儿(V2 决策 7 防线 ii)。
|
|
297
|
+
*
|
|
298
|
+
* **PID 复用风险**(V2 §5.4):仅靠 pid 存活不够——pid 可能被 OS 复用给无关进程。
|
|
299
|
+
* 本函数只按 pid 判定并返回候选列表,**调用方收割前必须二次校验**进程命令行含
|
|
300
|
+
* `pi --mode rpc`(确保是 subagent 残留而非被复用的无关进程)。该校验在调用方,不在本函数。
|
|
301
|
+
*
|
|
302
|
+
* 本函数只读,不改任何状态(孤儿收割由调用方对 killFn 执行)。
|
|
303
|
+
*
|
|
304
|
+
* @param records 持久化的 record 列表(含 pid)
|
|
305
|
+
* @param isProcessAlive pid 探活谓词(调用方注入,对齐 alive-store.isProcessAlive)
|
|
306
|
+
* @returns 孤儿候选 recordId 列表
|
|
307
|
+
*
|
|
308
|
+
* **状态:deferred(defense-in-depth,当前 spawn 配置下不触发)**。本函数骨架 + 单测已就绪,
|
|
309
|
+
* 数据通路(`.alive` sidecar 写 pid / `reconstructAll` 分支 3 读 alive.pid)也已落地,但
|
|
310
|
+
* **不接入 session_start**。理由 + 接入设计草图见
|
|
311
|
+
* `docs/design/v2-defense-ii-iii-resolution.md`(防线 ii 章节)。要点:当前 spawn 用 piped
|
|
312
|
+
* stdio(非 detach),父进程死亡 → stdin EOF → 子进程自杀(F10)覆盖全部正常崩溃路径,
|
|
313
|
+
* 孤儿近乎不可能泄漏;安全收割需 PID 复用校验(跨平台进程命令行读取,防 OS 复用 pid 误杀),
|
|
314
|
+
* 属低频场景的中等复杂度工作,待 spawn 改 detach 时再接入。
|
|
315
|
+
*/
|
|
316
|
+
export function scanOrphanProcesses(
|
|
317
|
+
records: OrphanCandidate[],
|
|
318
|
+
isProcessAlive: (pid: number) => boolean,
|
|
319
|
+
): string[] {
|
|
320
|
+
const orphans: string[] = [];
|
|
321
|
+
for (const record of records) {
|
|
322
|
+
if (record.pid === undefined) {
|
|
323
|
+
continue; // 无 pid 无法判定,不视为孤儿
|
|
324
|
+
}
|
|
325
|
+
if (activeProcesses.has(record.id)) {
|
|
326
|
+
continue; // 已在本进程 activation 集合内,非孤儿
|
|
327
|
+
}
|
|
328
|
+
if (isProcessAlive(record.pid)) {
|
|
329
|
+
orphans.push(record.id);
|
|
330
|
+
}
|
|
331
|
+
// pid 不存活 → 进程已退出,非孤儿
|
|
332
|
+
}
|
|
333
|
+
return orphans;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ============================================================
|
|
337
|
+
// 职责 5:activate 互斥(防线 iii,防双写者)
|
|
338
|
+
// ============================================================
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* recordId → 该 record 当前 activate 链尾的 Promise。
|
|
342
|
+
*
|
|
343
|
+
* 同一 recordId 的第二次 acquireActivateLock 会 await 链尾,直到前者 release 才 resolve,
|
|
344
|
+
* 从而串行化并发 activate,保证「同一 recordId 全局最多一个活进程」不变量(V2 决策 7
|
|
345
|
+
* 防线 iii:双写者交错 append 会写坏整个 session 文件,比脏 entry 致命一个量级)。
|
|
346
|
+
*
|
|
347
|
+
* **tail-identity 自清**(防长进程 recordId 条目泄漏):release 后经 queueMicrotask
|
|
348
|
+
* 异步检查——Map 链尾仍是本链尾(identity 匹配)时 delete 回收;有 waiter 排队时其
|
|
349
|
+
* acquire 已用新链尾覆盖 Map(identity 不匹配)→ 不删,条目由最后释放者回收。waiter
|
|
350
|
+
* 持 acquire 时刻捕获的 prev Promise 引用而非 Map 查询,delete 不影响其等待。30s
|
|
351
|
+
* 超时兜底与 _resetLifecycleState(全量 clear)语义不变。
|
|
352
|
+
*/
|
|
353
|
+
const activateLockTails = new Map<string, Promise<void>>();
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 获取某 record 的 activate 串行锁。
|
|
357
|
+
*
|
|
358
|
+
* - 同 recordId 首次 acquire:立即 resolve,返回 release 函数。
|
|
359
|
+
* - 同 recordId 第二次 acquire(前者未 release):pending,直到前者 release 才 resolve。
|
|
360
|
+
* - 不同 recordId 互不阻塞(各自独立链)。
|
|
361
|
+
*
|
|
362
|
+
* @returns release 函数——获得锁后**必须**调用它释放(finally 块),否则同 recordId
|
|
363
|
+
* 的后续 acquire 永久挂起。
|
|
364
|
+
*
|
|
365
|
+
* **状态:已接入(双保险,D3)**。`subagent-service.ts:894` 冷路径 resume 前调
|
|
366
|
+
* `acquireActivateLock(record.id)`,作为 idle CAS 守卫之外的结构化防护层:idle CAS
|
|
367
|
+
*(`status !== "idle"` 检查与 `status = "running"` 翻转间无 await)仍是一级守卫,
|
|
368
|
+
* reject 并发 message;锁把冷路径 resume spawn 的双写者交错升级为串行排队,防坏 session。
|
|
369
|
+
* 超时兜底见下方 `ACTIVATE_LOCK_TIMEOUT_MS`(V3 D3 / v4-lifecycle-convergence.md A-2)。
|
|
370
|
+
*/
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* acquireActivateLock 等待前序锁释放的超时(v4 A-2)。
|
|
374
|
+
*
|
|
375
|
+
* 前 holder 崩溃/死锁导致 release 永不触发时,waiter 不无限挂起;30s 超时后抛含恢复指引
|
|
376
|
+
* 的错误(调用方可用 message action 重试)。30s 远超正常冷路径 resume spawn 耗时(~ms 级),
|
|
377
|
+
* 留足异常恢复余量而不误伤正常排队。
|
|
378
|
+
*/
|
|
379
|
+
const ACTIVATE_LOCK_TIMEOUT_MS = 30 * 1000;
|
|
380
|
+
|
|
381
|
+
export function acquireActivateLock(recordId: string): Promise<() => void> {
|
|
382
|
+
const prev = activateLockTails.get(recordId) ?? Promise.resolve();
|
|
383
|
+
let releaseFn!: () => void;
|
|
384
|
+
// [review 修复] 超时放行句柄:超时者从未持有锁(race 已 reject),releaseFn 不会被
|
|
385
|
+
// 调用方触发,current 将永久 pending → tail(= prev.then(() => current))永久
|
|
386
|
+
// pending → 后续同 recordId 的 acquire 只能靠 30s 超时出队(锁链瘫痪,只能重启
|
|
387
|
+
// 恢复)。超时回调显式 resolve current 放行链尾(见下方 timeoutPromise)。
|
|
388
|
+
let settleCurrent!: () => void;
|
|
389
|
+
const current = new Promise<void>((resolve) => {
|
|
390
|
+
settleCurrent = resolve;
|
|
391
|
+
// tail-identity 自清(ES7/LOCK_TAIL_GC_RACE):resolve 后经 microtask 异步回收——
|
|
392
|
+
// 仅当 Map 链尾仍是本链尾(tail)时 delete。自清检查只读 Map 引用比较、不依赖
|
|
393
|
+
// 链尾 Promise 是否已 settle——release() 同步 resolve 后 microtask 执行时,无论
|
|
394
|
+
// then 链推进到哪一步,identity 比较结果一致;同 recordId 快速 acquire→release→
|
|
395
|
+
// acquire 序下,后继 acquire 的 set 已覆盖 Map,先行 release 的自清检查
|
|
396
|
+
// get !== 旧 tail → 不删(正确保留新链)。
|
|
397
|
+
releaseFn = () => {
|
|
398
|
+
resolve();
|
|
399
|
+
queueMicrotask(() => {
|
|
400
|
+
if (activateLockTails.get(recordId) === tail) {
|
|
401
|
+
activateLockTails.delete(recordId);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
// 链尾 = 等 prev 完成后挂 current;current 在 releaseFn 调用前保持 pending,
|
|
407
|
+
// 让下一次 acquire 的 prev 等到本次 release。tail 引用同时作为自清的 identity key。
|
|
408
|
+
const tail = prev.then(() => current);
|
|
409
|
+
activateLockTails.set(recordId, tail);
|
|
410
|
+
|
|
411
|
+
// 30s 超时兜底(v4 A-2):前序 holder 长期不 release(崩溃/死锁)时,waiter 不无限挂起,
|
|
412
|
+
// 超时抛含恢复指引的错误(调用方可用 message action 重试)。正常拿到锁时 clearTimeout
|
|
413
|
+
// 取消未触发的 timer 防泄漏。超时只 reject 本次 acquire 的返回 promise,不改 release 语义——
|
|
414
|
+
// 原 holder release 仍 resolve current,链尾照常推进,后续 waiter 各受同样 30s 保护。
|
|
415
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
416
|
+
// acquire 已赢标记:prev 的 then 回调(微任务)恒先于 timer 回调(宏任务)执行,
|
|
417
|
+
// 标记位防「prev 恰在 30s 边界 settle、clearTimeout 未赶上已入队 timer」的窗口——
|
|
418
|
+
// 那时调用方已(将)持有锁,若再放行链尾会让后续 waiter 提前获锁形成双写者。
|
|
419
|
+
let acquired = false;
|
|
420
|
+
const acquirePromise = prev.then(() => {
|
|
421
|
+
acquired = true;
|
|
422
|
+
clearTimeout(timeoutId);
|
|
423
|
+
return releaseFn;
|
|
424
|
+
});
|
|
425
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
426
|
+
timeoutId = setTimeout(() => {
|
|
427
|
+
if (acquired) return;
|
|
428
|
+
// [review 修复] 超时放行链尾:resolve 自身 current,保证前序 holder release 后
|
|
429
|
+
// tail 可 settle(不绕过前序等待——prev 未 settle 时 tail 仍等 prev,互斥保持),
|
|
430
|
+
// 后续 acquire 不被本超时者的永久 pending tail 卡死。
|
|
431
|
+
settleCurrent();
|
|
432
|
+
// [review 修复] 超时者对称自清(round2 INFO 残留):releaseFn 永不被调用(race
|
|
433
|
+
// 已 reject),tail-identity 自清 microtask 不排队,条目滞留 Map 直到下次同
|
|
434
|
+
// recordId acquire 覆盖。此处挂 tail 尾部做同款 identity 自清——只有 tail
|
|
435
|
+
// 真正 settle(prev 也已 release)才回收:不能直接 queueMicrotask 删除——前序
|
|
436
|
+
// 仍持锁(tail pending)时删条目会让后续 acquire 不再排队等前序 release,
|
|
437
|
+
// 破坏互斥(超时放行的是链尾 settle,不是锁获取)。tail 永不 settle(前序
|
|
438
|
+
// 崩溃)时回调不执行,条目与现状一致由 _resetLifecycleState 兜底清理。
|
|
439
|
+
tail.then(() => {
|
|
440
|
+
if (activateLockTails.get(recordId) === tail) {
|
|
441
|
+
activateLockTails.delete(recordId);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
reject(
|
|
445
|
+
new Error(
|
|
446
|
+
`subagent ${recordId} activation timed out; retry action: message`,
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
}, ACTIVATE_LOCK_TIMEOUT_MS);
|
|
450
|
+
});
|
|
451
|
+
return Promise.race([acquirePromise, timeoutPromise]);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ============================================================
|
|
455
|
+
// 测试钩子(模块级单例状态隔离)
|
|
456
|
+
// ============================================================
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 清空全部模块级状态(idle timer / 活进程集合 / activate 锁链尾)。
|
|
460
|
+
*
|
|
461
|
+
* 仅用于单测的 beforeEach 隔离——clearTimeout 所有 armed timer 防止跨用例泄漏。
|
|
462
|
+
*
|
|
463
|
+
* 注意:不会 resolve 已 acquire 但未 release 的锁 Promise(那些 pending holder 由测试
|
|
464
|
+
* 自律 release;reset 后它们的链尾引用被 Map 丢弃,不再阻塞后续 acquire)。
|
|
465
|
+
*/
|
|
466
|
+
export function _resetLifecycleState(): void {
|
|
467
|
+
for (const entry of idleTimers.values()) {
|
|
468
|
+
clearTimeout(entry.timer);
|
|
469
|
+
}
|
|
470
|
+
idleTimers.clear();
|
|
471
|
+
activeProcesses.clear();
|
|
472
|
+
activateLockTails.clear();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* 测试钩子:返回 activateLockTails 当前条目数。
|
|
477
|
+
*
|
|
478
|
+
* activateLockTails 是模块私有 const,自清语义(tail-identity 回收)的断言需要
|
|
479
|
+
* 观察点——本导出仅测试使用,命名对齐 _resetLifecycleState(本文件)与
|
|
480
|
+
* _resetProcessShutdownGuardForTest(index.ts)先例。
|
|
481
|
+
*/
|
|
482
|
+
export function _getActivateLockTailCountForTest(): number {
|
|
483
|
+
return activateLockTails.size;
|
|
484
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/execution/lifecycle-predicates.ts
|
|
2
|
+
//
|
|
3
|
+
// v4 B-1 ExecutionStatus 两态收敛后的派生谓词。
|
|
4
|
+
//
|
|
5
|
+
// 旧三态(running/idle/cancelled)折为两态(running/closed)后,「对话模式等待续聊」
|
|
6
|
+
//(旧 idle)和「正在执行」都是 status="running",需派生谓词区分。谓词复用已有
|
|
7
|
+
// lifecycle-manager.hasIdleTimer(idle timer 是否 armed)与 session-runner.getChildByRecord
|
|
8
|
+
//(活进程句柄是否存在),不新增状态记账。
|
|
9
|
+
//
|
|
10
|
+
// 两种 running 子态(v4 B-1):
|
|
11
|
+
// - 等待续聊(旧 idle):isIdle=true(timer armed)。又分两路:
|
|
12
|
+
// A. 进程保活(agent_settled arm timer,未超时)→ isResumable=false(有活进程),
|
|
13
|
+
// 续聊走 deliverMessage 热路径 prompt。
|
|
14
|
+
// B. 进程已回收(doFinalizeRoundToIdle / 跨重启重建)→ isResumable=true(无活进程),
|
|
15
|
+
// 续聊走 deliverMessage 冷路径 resume spawn。
|
|
16
|
+
// - 正在执行:isIdle=false、isResumable=false(有活进程)。
|
|
17
|
+
//
|
|
18
|
+
// 时序保证(session-runner.ts:670-686):agent_settled handler 内 armIdleTimer 在
|
|
19
|
+
// onRoundSettled(notify)与 resolveRun(runAndFinalize early-return 检查)之前执行,
|
|
20
|
+
// 故 isIdle 在这两个检查点恒为 true——notify 守卫与 early-return 判据可靠。
|
|
21
|
+
|
|
22
|
+
import { hasIdleTimer } from "./lifecycle-manager.ts";
|
|
23
|
+
import { getChildByRecord } from "./session-runner.ts";
|
|
24
|
+
import type { ExecutionRecord } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 活进程句柄是否存在(isResumable 子判据)。
|
|
28
|
+
*
|
|
29
|
+
* 复用 session-runner.spawnedChildren 的 getChildByRecord 查询。child 存在且未 kill
|
|
30
|
+
* = 有活进程句柄(正在执行 / Path A 保活)。进程 close 后 spawnedChildren 已 delete,
|
|
31
|
+
* 返回 undefined = 无活进程(Path B / 跨重启)。
|
|
32
|
+
*/
|
|
33
|
+
export function hasLiveProcessHandle(recordId: string): boolean {
|
|
34
|
+
const child = getChildByRecord(recordId);
|
|
35
|
+
return child !== undefined && !child.killed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 对话模式等待续聊态(旧 idle 收敛后的派生谓词)。
|
|
40
|
+
*
|
|
41
|
+
* 判据:该 record 有 armed idle timer(lifecycle-manager.hasIdleTimer)。agent_settled
|
|
42
|
+
* 时 session-runner arm timer(V2 决策 4);新 turn / 终态化时 disarm。
|
|
43
|
+
*
|
|
44
|
+
* 注意:isIdle=true 不区分进程保活(Path A)与已回收(Path B)——前者续聊走热路径,
|
|
45
|
+
* 后者走冷路径。进程死活由 {@link isResumable} 区分。
|
|
46
|
+
*/
|
|
47
|
+
export function isIdle(record: ExecutionRecord): boolean {
|
|
48
|
+
return hasIdleTimer(record.id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 可冷路径 resume(running 且无活进程句柄)。
|
|
53
|
+
*
|
|
54
|
+
* 旧 idle 中进程已回收的子态(Path B / 跨重启重建)。deliverMessage 冷路径、GC、
|
|
55
|
+
* close action 的「无活进程立即终态化」分支据此判定。
|
|
56
|
+
*
|
|
57
|
+
* 与 {@link isIdle} 的关系:isResumable=true 蕴含 isIdle 可能为 false(Path B 进程
|
|
58
|
+
* 已退出,timer 未 armed);isIdle=true(Path A)则 isResumable=false(进程保活)。
|
|
59
|
+
*
|
|
60
|
+
* [v4 A-6] 签名泛化为 Pick<"id"|"status">:ExecutionRecord(活态)与 SubagentRecord
|
|
61
|
+
* (list 快照)均结构兼容——recordToListItem 据此为 list 输出派生 resumable 字段。
|
|
62
|
+
*/
|
|
63
|
+
export function isResumable(record: Pick<ExecutionRecord, "id" | "status">): boolean {
|
|
64
|
+
return record.status === "running" && !hasLiveProcessHandle(record.id);
|
|
65
|
+
}
|
|
@@ -11,13 +11,13 @@ export interface ManifestRecord {
|
|
|
11
11
|
parentRecordId?: string;
|
|
12
12
|
agentName: string;
|
|
13
13
|
/**
|
|
14
|
-
* 终态枚举:finalizeRecord 写 running/
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* 历史 "error"
|
|
14
|
+
* 终态枚举:finalizeRecord 写 running/closed/cancelled 三态。
|
|
15
|
+
* SP-1 重构:旧 completed/failed 合并为 closed(L1 统一终态)。
|
|
16
|
+
* cancelled 保持独立(用户取消语义)。crashed 不进 manifest——
|
|
17
|
+
* crashed 是重启重建时靠 sidecar 四分支推断的派生态(见 record-store.ts reconstructAll)。
|
|
18
|
+
* 历史 "error"/"completed"/"failed" 值由读侧 mapManifestStatus 向后兼容映射。
|
|
19
19
|
*/
|
|
20
|
-
status: "running" | "
|
|
20
|
+
status: "running" | "closed" | "cancelled";
|
|
21
21
|
createdAt: number;
|
|
22
22
|
completedAt?: number;
|
|
23
23
|
sessionFile?: string;
|
|
@@ -30,12 +30,30 @@ export interface ManifestRecord {
|
|
|
30
30
|
/** JSON.stringify 缩进空格数(no-magic-numbers 合规)。 */
|
|
31
31
|
const MANIFEST_INDENT_SPACES = 2;
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/** [perf] 缓存校验戳(与 record-store.ts Stamp 同构;manifest 是小文件,mtime+size 足够)。 */
|
|
34
|
+
interface Stamp {
|
|
35
|
+
mtimeMs: number;
|
|
36
|
+
size: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function statStamp(p: string): Stamp | null {
|
|
40
|
+
try {
|
|
41
|
+
const s = fs.statSync(p);
|
|
42
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 合法 manifest status 集合(3 态;运行时守卫用,磁盘文件可能陈旧/损坏)。
|
|
49
|
+
* SP-1:completed/failed 合并为 closed。读侧 mapManifestStatus 向后兼容旧值。
|
|
50
|
+
* crashed 不在其中。 */
|
|
34
51
|
const VALID_MANIFEST_STATUSES: ReadonlySet<string> = new Set([
|
|
35
52
|
"running",
|
|
36
|
-
"
|
|
37
|
-
"failed",
|
|
53
|
+
"closed",
|
|
38
54
|
"cancelled",
|
|
55
|
+
"completed", // 向后兼容旧 manifest 数据
|
|
56
|
+
"failed", // 向后兼容旧 manifest 数据
|
|
39
57
|
]);
|
|
40
58
|
|
|
41
59
|
/**
|
|
@@ -58,6 +76,11 @@ function isValidManifest(value: unknown): value is ManifestRecord {
|
|
|
58
76
|
export class ManifestStore {
|
|
59
77
|
private readonly dir: string;
|
|
60
78
|
|
|
79
|
+
/** [perf] per-file 缓存:file → { stamp, record }。record=null 表示「已解析但非法」(缓存
|
|
80
|
+
* 负结果避免反复 parse 损坏文件)。stat 戳变化(writeManifest tmp→rename 后 mtime/size 变)
|
|
81
|
+
* 自动失效;删除的文件在下次扫描时修剪。 */
|
|
82
|
+
private readonly cache = new Map<string, { stamp: Stamp; record: ManifestRecord | null }>();
|
|
83
|
+
|
|
61
84
|
constructor(dir: string) {
|
|
62
85
|
this.dir = dir;
|
|
63
86
|
if (!fs.existsSync(dir)) {
|
|
@@ -136,6 +159,9 @@ export class ManifestStore {
|
|
|
136
159
|
* 同步读取所有 manifest 记录(best-effort,损坏/非法文件跳过)。
|
|
137
160
|
* 供 RecordStore.collectRecords 投影 orphan 记录使用——替代对私有 dir 的反射访问。
|
|
138
161
|
* 仅返回通过 isValidManifest 校验的记录。
|
|
162
|
+
*
|
|
163
|
+
* [perf] per-file 缓存 + stat 戳校验:collectRecords 每次渲染都调本方法,旧实现每次
|
|
164
|
+
* 全量 readFileSync + JSON.parse 千级 manifest(实测 ~300ms/次)。命中缓存的文件零读取。
|
|
139
165
|
*/
|
|
140
166
|
listAllSync(): readonly ManifestRecord[] {
|
|
141
167
|
let files: string[];
|
|
@@ -144,17 +170,36 @@ export class ManifestStore {
|
|
|
144
170
|
} catch {
|
|
145
171
|
return [];
|
|
146
172
|
}
|
|
173
|
+
const names = files.filter((f) => f.endsWith(".json") && !f.includes(".tmp."));
|
|
174
|
+
const disk = new Set(names);
|
|
175
|
+
|
|
176
|
+
// 修剪已删除文件
|
|
177
|
+
for (const f of this.cache.keys()) {
|
|
178
|
+
if (!disk.has(f)) this.cache.delete(f);
|
|
179
|
+
}
|
|
180
|
+
|
|
147
181
|
const results: ManifestRecord[] = [];
|
|
148
|
-
for (const file of
|
|
149
|
-
|
|
182
|
+
for (const file of names) {
|
|
183
|
+
const filePath = path.join(this.dir, file);
|
|
184
|
+
const stamp = statStamp(filePath);
|
|
185
|
+
if (!stamp) {
|
|
186
|
+
this.cache.delete(file);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const cached = this.cache.get(file);
|
|
190
|
+
if (cached && cached.stamp.mtimeMs === stamp.mtimeMs && cached.stamp.size === stamp.size) {
|
|
191
|
+
if (cached.record) results.push(cached.record);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
150
194
|
try {
|
|
151
|
-
const content = fs.readFileSync(
|
|
195
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
152
196
|
const parsed: unknown = JSON.parse(content);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
197
|
+
const record = isValidManifest(parsed) ? parsed : null;
|
|
198
|
+
this.cache.set(file, { stamp, record });
|
|
199
|
+
if (record) results.push(record);
|
|
156
200
|
} catch (fileErr) {
|
|
157
|
-
// best-effort:损坏/非法文件跳过(debug
|
|
201
|
+
// best-effort:损坏/非法文件跳过(debug 记录便于排查)。缓存负结果防反复 parse。
|
|
202
|
+
this.cache.set(file, { stamp, record: null });
|
|
158
203
|
bestEffort(fileErr, `read manifest ${file} (listAllSync)`);
|
|
159
204
|
}
|
|
160
205
|
}
|