@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
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
// - onChange 订阅(TUI widget/list 据此重渲)
|
|
8
8
|
// - collectRecords:内存(running) + 磁盘(sessions/*.jsonl 重建) 合并
|
|
9
9
|
// - 提供 snapshot() 只读视图给 TUI(永不返回可变引用)
|
|
10
|
+
//
|
|
11
|
+
// [perf] 两级读写设计(修复 /subagents 打开慢):
|
|
12
|
+
// 1. 列表扫描 = light:只读文件头部 identity(readIdentityHeader,64KB)+ sidecar
|
|
13
|
+
// 状态矩阵,不解析 message entries。列表/补全/hasRunning 只需身份与状态,
|
|
14
|
+
// 586MB 级 sessions 目录的全量 JSON.parse(秒级)从列表路径上消失。
|
|
15
|
+
// 2. 详情 = getFullRecord(id) 懒加载:选中/单独查询时才对该文件全量重建
|
|
16
|
+
// (reconstructFromFile),turns/eventLog/result 等重数据仅按需解析。
|
|
17
|
+
// 3. per-file 缓存 + stat 戳校验(mtime+size):register/archive 等内存事件
|
|
18
|
+
// 不再整体失效缓存;任何磁盘写入(jsonl append / sidecar 覆盖)只重建对应
|
|
19
|
+
// 单文件,其余 N-1 个文件复用缓存(stat 校验毫秒级)。
|
|
20
|
+
// 4. [perf L-1] sessions-index.json(sessionsDir 兄弟位置):identity 探测结果的
|
|
21
|
+
// 磁盘种子——冷启动首扫读一次(惰性装载),dirty 扫描后按 60s 节流落盘
|
|
22
|
+
// (fileCache 投影,fire-and-forget);运行期 L0/L1 语义不变,损坏/低版本
|
|
23
|
+
// 静默回退全扫,高版本忽略不重写。见 sessions-index.ts。
|
|
10
24
|
|
|
11
25
|
import * as fs from "node:fs";
|
|
12
26
|
import * as path from "node:path";
|
|
@@ -15,15 +29,26 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
|
15
29
|
|
|
16
30
|
import { getCurrentActivity, getDisplayItems, getEventLog, markReconstructedStatus, snapshot as toSnapshot } from "./execution-record.ts";
|
|
17
31
|
import type { ManifestRecord, ManifestStore } from "./manifest-store.ts";
|
|
18
|
-
import {
|
|
32
|
+
import { INDEX_WRITE_MIN_INTERVAL_MS, loadIndex, saveIndex } from "./sessions-index.ts";
|
|
33
|
+
import type { SessionsIndexEntry, SessionsIndexNegativeEntry } from "./sessions-index.ts";
|
|
34
|
+
import {
|
|
35
|
+
IDENTITY_HEAD_BYTES,
|
|
36
|
+
type IdentityHeaderRecon,
|
|
37
|
+
type ReconstructedRecord,
|
|
38
|
+
readIdentityAnywhere,
|
|
39
|
+
readIdentityHeader,
|
|
40
|
+
readIdentityTail,
|
|
41
|
+
reconstructFromFile,
|
|
42
|
+
} from "./session-reconstructor.ts";
|
|
19
43
|
import type {
|
|
44
|
+
AliveMarker,
|
|
20
45
|
ExecutionRecord,
|
|
21
46
|
ExecutionStatus,
|
|
22
47
|
RecordSnapshot,
|
|
23
48
|
SubagentRecord,
|
|
24
49
|
} from "./types.ts";
|
|
50
|
+
import type { CancelledTombstone } from "./tombstone-store.ts";
|
|
25
51
|
import { isProcessAlive, readAliveMarker } from "./alive-store.ts";
|
|
26
|
-
import { readFinalized } from "./finalized-marker.ts";
|
|
27
52
|
import { readCancelledTombstone } from "./tombstone-store.ts";
|
|
28
53
|
|
|
29
54
|
const logger = getLogger("subagents");
|
|
@@ -32,13 +57,12 @@ const logger = getLogger("subagents");
|
|
|
32
57
|
// 常量
|
|
33
58
|
// ============================================================
|
|
34
59
|
|
|
35
|
-
/** status → 排序优先级(值小排前):running <
|
|
60
|
+
/** status → 排序优先级(值小排前):running < closed。
|
|
61
|
+
* v4 B-1:idle/cancelled 折入 running/closed,两态收敛。closed = 统一终态
|
|
62
|
+
* (done/failed/crashed/cancelled 合并),按 closedReason 派生对外语义。 */
|
|
36
63
|
const STATUS_PRIORITY: Record<ExecutionStatus, number> = {
|
|
37
64
|
running: 0,
|
|
38
|
-
|
|
39
|
-
crashed: 1,
|
|
40
|
-
cancelled: 2,
|
|
41
|
-
done: 3,
|
|
65
|
+
closed: 3,
|
|
42
66
|
};
|
|
43
67
|
|
|
44
68
|
/** .alive sidecar 的 24 小时软超时(超过此时间即使 pid 存活也判 crashed)。 */
|
|
@@ -47,19 +71,21 @@ const ALIVE_SOFT_TIMEOUT_MS = 3_600_000; // 1h in ms (reduced from 24h to minimi
|
|
|
47
71
|
/**
|
|
48
72
|
* manifest status → ExecutionStatus 运行时守卫映射。
|
|
49
73
|
*
|
|
50
|
-
* manifest 写 running/
|
|
51
|
-
* 文件可能陈旧(含历史 "error"
|
|
74
|
+
* manifest 写 running/closed/cancelled 三态(ManifestRecord.status union),但磁盘
|
|
75
|
+
* 文件可能陈旧(含历史 "completed"/"failed"/"error" 值、被外部篡改)。越界值返回 null——
|
|
52
76
|
* manifestToSubagent 据此返回 null,collectRecords 跳过损坏 record 并 console.warn,不因单个
|
|
53
|
-
* 坏文件崩溃,也不把损坏 record 错误降级为
|
|
77
|
+
* 坏文件崩溃,也不把损坏 record 错误降级为 closed(closed 触发告警,是误报)。
|
|
54
78
|
*
|
|
55
79
|
* 提取为纯函数:同时解决 PR#85 反射问题(三元 + `as ExecutionStatus` cast)。
|
|
80
|
+
* [HISTORICAL] SP-1 重构:旧 "completed" → closed,旧 "failed" → closed(L1 统一终态)。
|
|
56
81
|
*/
|
|
57
82
|
function mapManifestStatus(s: string): ExecutionStatus | null {
|
|
58
|
-
if (s === "
|
|
59
|
-
if (s === "
|
|
83
|
+
if (s === "closed") return "closed";
|
|
84
|
+
if (s === "completed") return "closed"; // 向后兼容旧 manifest 数据
|
|
85
|
+
if (s === "failed") return "closed"; // 向后兼容旧 manifest 数据
|
|
60
86
|
if (s === "running") return "running";
|
|
61
|
-
if (s === "cancelled") return "
|
|
62
|
-
return null; // 越界=数据损坏(含历史 "error"
|
|
87
|
+
if (s === "cancelled") return "closed"; // v4 B-1: manifest cancelled 折入 closed(closedReason 信息丢失,manifest 仅诊断辅助)
|
|
88
|
+
return null; // 越界=数据损坏(含历史 "error" 值),返回 null 让调用方跳过
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
/** store 变更监听器(返回取消订阅函数)。 */
|
|
@@ -68,6 +94,81 @@ export type ChangeListener = () => void;
|
|
|
68
94
|
/** status 过滤模式(collectRecords 的核心能力参数)。 */
|
|
69
95
|
export type StatusFilter = "running" | "all";
|
|
70
96
|
|
|
97
|
+
/** 缓存校验戳(mtime+size 双因子——append-only jsonl 必变 size;sidecar 覆盖写必变 mtime)。 */
|
|
98
|
+
interface Stamp {
|
|
99
|
+
mtimeMs: number;
|
|
100
|
+
size: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** sidecar 状态矩阵输入(buildLightRecord / getFullRecord 共享)。 */
|
|
104
|
+
interface SidecarMatrix {
|
|
105
|
+
tomb: CancelledTombstone | undefined;
|
|
106
|
+
finalized: boolean;
|
|
107
|
+
alive: AliveMarker | undefined;
|
|
108
|
+
/** jsonl mtime(light 分支 2 的 endedAt 近似——finalize 后文件不再变化)。 */
|
|
109
|
+
jsonlMtimeMs: number;
|
|
110
|
+
/** 全量重建可得的精确结束时间(最后 entry ts);light 传 undefined 回落 mtime。 */
|
|
111
|
+
fullEndedAt?: number;
|
|
112
|
+
now: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* per-file 缓存条目([perf] 两级设计)。
|
|
117
|
+
*
|
|
118
|
+
* light:头部 identity + sidecar 状态矩阵(列表扫描产出,详情字段缺省)
|
|
119
|
+
* full :懒加载的完整重建(getFullRecord 按需补 turns/eventLog/result)。
|
|
120
|
+
* full === light 是哨兵(「已尝试但无详情可补」,如无 assistant message
|
|
121
|
+
* 的文件),避免重复全文重读;stat 戳变化时随 light 一起重置重试。
|
|
122
|
+
*
|
|
123
|
+
* 校验:jsonl + 3 sidecar 的 stat 戳对比。任何写操作至少改变一个戳 →
|
|
124
|
+
* 只重建该文件,其余 N-1 个复用缓存(statSync 毫秒级,取代旧的整体失效重扫)。
|
|
125
|
+
*/
|
|
126
|
+
interface FileCacheEntry {
|
|
127
|
+
/** tagged union 判别(负缓存条目为 true)。显式声明 false 供 TS narrowing。 */
|
|
128
|
+
negative?: false;
|
|
129
|
+
light: SubagentRecord;
|
|
130
|
+
full: SubagentRecord | undefined;
|
|
131
|
+
jsonl: Stamp;
|
|
132
|
+
cancelled: Stamp | null;
|
|
133
|
+
finalized: Stamp | null;
|
|
134
|
+
alive: Stamp | null;
|
|
135
|
+
/** 最近一次重建时读到的 sidecar 原始内容(校验命中路径复用,不重读文件)。 */
|
|
136
|
+
tomb: CancelledTombstone | undefined;
|
|
137
|
+
aliveData: AliveMarker | undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 负缓存条目:确认无 identity 的文件(损坏/异构)。缓存「没有」这一事实,
|
|
141
|
+
* 避免每轮扫描都全文 fallback 重读(全文读是 fallback 的成本主体)。 */
|
|
142
|
+
interface NegativeFileEntry {
|
|
143
|
+
negative: true;
|
|
144
|
+
jsonl: Stamp;
|
|
145
|
+
cancelled: Stamp | null;
|
|
146
|
+
finalized: Stamp | null;
|
|
147
|
+
alive: Stamp | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** fileCache 值类型:正常条目或负缓存条目。 */
|
|
151
|
+
type FileCacheValue = FileCacheEntry | NegativeFileEntry;
|
|
152
|
+
|
|
153
|
+
/** stat 戳(不存在 → null)。 */
|
|
154
|
+
function statStamp(p: string): Stamp | null {
|
|
155
|
+
try {
|
|
156
|
+
const s = fs.statSync(p);
|
|
157
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sameStamp(a: Stamp, b: Stamp): boolean {
|
|
164
|
+
return a.mtimeMs === b.mtimeMs && a.size === b.size;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sameNullableStamp(a: Stamp | null, b: Stamp | null): boolean {
|
|
168
|
+
if (a === null || b === null) return a === b;
|
|
169
|
+
return sameStamp(a, b);
|
|
170
|
+
}
|
|
171
|
+
|
|
71
172
|
/** Pi ExtensionAPI 的最小子集(仅 collectRecords 跳过损坏 manifest 时上报用)。
|
|
72
173
|
* 解构为局部类型,避免与 subagent-service 的 PiLike 循环依赖。 */
|
|
73
174
|
export type RecordStorePi = {
|
|
@@ -82,10 +183,9 @@ export type RecordStorePi = {
|
|
|
82
183
|
* Record 容器。进程单例(随 SubagentService 重建)。
|
|
83
184
|
*
|
|
84
185
|
* 内存只留 running record——终态 record 在 archive 时立即移除,collectRecords
|
|
85
|
-
* 读时从 sessions/*.jsonl 重建(
|
|
86
|
-
* notifyChange 时失效(终态 record 不再变化,但新 finalize 触发重扫)。
|
|
186
|
+
* 读时从 sessions/*.jsonl 重建([perf] light 头部扫描 + per-file 缓存)。
|
|
87
187
|
*
|
|
88
|
-
* 任何 mutate → notifyChange()
|
|
188
|
+
* 任何 mutate → notifyChange()(仅通知监听器;磁盘缓存靠 stat 戳自校验,不清空)。
|
|
89
189
|
*/
|
|
90
190
|
export class RecordStore {
|
|
91
191
|
private readonly records = new Map<string, ExecutionRecord>();
|
|
@@ -95,8 +195,27 @@ export class RecordStore {
|
|
|
95
195
|
* 显式存为字段而非构造参数 readonly:setPi 需要写权限。 */
|
|
96
196
|
private pi: RecordStorePi = null;
|
|
97
197
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
198
|
+
/** [perf] per-file 缓存(key = sessionFile 绝对路径)。不再整体失效——stat 戳精准校验。
|
|
199
|
+
* 值含负缓存(确认无 identity 的文件),防每轮全文 fallback 重读。 */
|
|
200
|
+
private readonly fileCache = new Map<string, FileCacheValue>();
|
|
201
|
+
/** record id → sessionFile 索引(getFullRecord 按 id 定位文件)。随 fileCache 同步维护。 */
|
|
202
|
+
private readonly idToFile = new Map<string, string>();
|
|
203
|
+
/** [perf] sessionsDir 最近一次全量扫描的 mtime(快路径判变,见 reconstructAll)。
|
|
204
|
+
* null = 未扫过 / 已 dispose。 */
|
|
205
|
+
private dirStamp: { mtimeMs: number } | null = null;
|
|
206
|
+
|
|
207
|
+
/** [perf L-1] 首扫惰性装载的磁盘索引只读映像(key = jsonl basename)。
|
|
208
|
+
* 扫描尾(flushIndexAfterScan)与 readdir 失败路径释放——运行期索引不再被读(L1 接管)。 */
|
|
209
|
+
private indexEntries: Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> | null = null;
|
|
210
|
+
/** [perf L-1] 本轮起未落盘的探测标志:scanFile 走过探测分支即置位。发起写时消费
|
|
211
|
+
* (置 false)、写失败恢复;未写路径不清位——未落盘的探测成果跨轮携带直至真正写入。 */
|
|
212
|
+
private indexDirty = false;
|
|
213
|
+
/** [perf L-1] 上次成功落盘墙钟(节流基准)。0 = 从未写过 → 首扫 dirty 必写;
|
|
214
|
+
* 仅成功分支推进(写失败不推进节流窗,下轮过窗重试)。 */
|
|
215
|
+
private lastIndexWriteAt = 0;
|
|
216
|
+
/** [perf L-1] loadIndex 高版本标志的进程级持久态:true 时本进程所有后续扫描均不
|
|
217
|
+
* 落盘(防 v1/v2 last-writer-wins 覆盖振荡),直至下次 loadIndex 重新评估。 */
|
|
218
|
+
private indexHigherVersion = false;
|
|
100
219
|
|
|
101
220
|
constructor(
|
|
102
221
|
private readonly sessionsDir: string,
|
|
@@ -168,12 +287,21 @@ export class RecordStore {
|
|
|
168
287
|
.map((r) => toSnapshot(r));
|
|
169
288
|
}
|
|
170
289
|
|
|
290
|
+
/** SP-4: 列出所有活跃 record(running + idle)的可变引用。
|
|
291
|
+
* 供 SubagentService.disposeAllRecords 做级联关闭。 */
|
|
292
|
+
listAllActive(): ExecutionRecord[] {
|
|
293
|
+
return [...this.records.values()]
|
|
294
|
+
.filter((r) => r.status === "running");
|
|
295
|
+
}
|
|
296
|
+
|
|
171
297
|
/**
|
|
172
298
|
* 合并内存(running) + 磁盘(sessions/*.jsonl 重建) → SubagentRecord[]。
|
|
173
299
|
*
|
|
174
300
|
* ╔══════════════════════════════════════════════════════════════════╗
|
|
175
|
-
* ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个
|
|
176
|
-
* ║
|
|
301
|
+
* ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个 scanFile([perf] 头部 ║
|
|
302
|
+
* ║ identity 轻量重建 + stat 戳缓存命中零读取)。cancelled ║
|
|
303
|
+
* ║ tombstone override status。详情字段(eventLog/result/turns) ║
|
|
304
|
+
* ║ 缺省,由 getFullRecord(id) 懒加载 ║
|
|
177
305
|
* ║ 2. 内存源覆盖(同 id 内存优先——running record 更新鲜) ║
|
|
178
306
|
* ║ 3. session 过滤:只留 rootSessionId === rootSessionFilter 的 ║
|
|
179
307
|
* ║ record。rootSessionId 缺失(旧文件)的 record 一律排除 ║
|
|
@@ -257,10 +385,11 @@ export class RecordStore {
|
|
|
257
385
|
};
|
|
258
386
|
}
|
|
259
387
|
|
|
260
|
-
/** 触发所有监听器(TUI widget/list requestRender)。dispose
|
|
388
|
+
/** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。
|
|
389
|
+
* [perf] 不清空磁盘缓存:per-file stat 戳自校验(任何磁盘写入改变戳 → 单文件重建),
|
|
390
|
+
* 内存事件(register/archive)不改变磁盘文件——旧实现整体失效是全量重扫的根因。 */
|
|
261
391
|
notifyChange(): void {
|
|
262
392
|
if (this._disposed) return;
|
|
263
|
-
this.reconCache = undefined; // 失效缓存(新 finalize 可能产出新 session.jsonl)。
|
|
264
393
|
for (const listener of this.listeners) {
|
|
265
394
|
listener();
|
|
266
395
|
}
|
|
@@ -270,6 +399,15 @@ export class RecordStore {
|
|
|
270
399
|
dispose(): void {
|
|
271
400
|
this._disposed = true;
|
|
272
401
|
this.listeners.clear();
|
|
402
|
+
this.fileCache.clear();
|
|
403
|
+
this.idToFile.clear();
|
|
404
|
+
this.dirStamp = null;
|
|
405
|
+
// [perf L-1] 索引状态重置为初始值(「清内存」语义完备)。不取消挂起的 fire-and-forget
|
|
406
|
+
// 写——in-flight 写的丢失显式接受(revive 后 dirStamp===null 重扫会重新装载/重写)。
|
|
407
|
+
this.indexEntries = null;
|
|
408
|
+
this.indexDirty = false;
|
|
409
|
+
this.lastIndexWriteAt = 0;
|
|
410
|
+
this.indexHigherVersion = false;
|
|
273
411
|
}
|
|
274
412
|
|
|
275
413
|
/** /resume /fork /new 后复活(dispose 的逆操作)。 */
|
|
@@ -280,117 +418,422 @@ export class RecordStore {
|
|
|
280
418
|
// ── 内部 ──────────────────────────────────────────────────
|
|
281
419
|
|
|
282
420
|
/**
|
|
283
|
-
* 四分支 sidecar
|
|
421
|
+
* 四分支 sidecar 矩阵重建([perf] light 版)。
|
|
284
422
|
*
|
|
285
423
|
* 优先级:
|
|
286
|
-
* 1. .cancelled → cancelled
|
|
287
|
-
* 2. .finalized →
|
|
288
|
-
* 3. .alive + pid 存活 +
|
|
289
|
-
* 4.
|
|
424
|
+
* 1. .cancelled → closed(closedReason=cancelled)
|
|
425
|
+
* 2. .finalized → closed(closedReason=gc)
|
|
426
|
+
* 3. .alive + pid 存活 + 未超软超时 → running, externalInstance=true
|
|
427
|
+
* 4. 兜底(无 marker、pid 死、超时)→ running(v4 B-1 可续聊语义)
|
|
290
428
|
*
|
|
291
|
-
*
|
|
429
|
+
* [perf]:逐文件 scanFile(stat 戳校验 + 头部 identity 轻量重建)。命中缓存的
|
|
430
|
+
* 文件零文件读取;变化的文件只重建自身,其余 N-1 个复用缓存。
|
|
292
431
|
*
|
|
293
432
|
* session 隔离:rootSessionFilter 非空时,只保留 rootSessionId 匹配的 record。
|
|
294
433
|
* rootSessionId 缺失(旧文件,未带身份字段)一律排除(无法判定归属)。
|
|
295
|
-
* 缓存以 undefined 过滤结果为基底,带 filter 时在基底上再筛(避免缓存碎片化)。
|
|
296
434
|
*/
|
|
297
435
|
private reconstructAll(rootSessionFilter?: string): SubagentRecord[] {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
436
|
+
// [perf] 目录 mtime 快路径:sessionsDir mtime 未变 ⇒ 文件集合与 sidecar 集合都未变
|
|
437
|
+
//(任何文件新建/删除/重命名都改目录 mtime),且 jsonl append 不影响 light 态
|
|
438
|
+
//(identity/status 由首行与 sidecar 决定,进度重数据走 getFullRecord 的独立 stat
|
|
439
|
+
//校验)→ 跳过 readdir + N×4 statSync,直接复用缓存 light。/subagents overlay 打开
|
|
440
|
+
//期间 250ms 动画 timer + 120ms debounce 双驱动高频扫描,快路径把 ~N×4 stat 降到
|
|
441
|
+
// 1 次(目录本身)+ 少量 pid 探活(refreshAlive,内存无 IO)。
|
|
442
|
+
// 已知局限(与 mtime 缓存同族):目录 mtime 粒度粗糙的文件系统(NFS/2s FAT)
|
|
443
|
+
// 可能漏判——APFS 微秒级可靠。invalidate 语义由文件写入侧保证,但**仅限 sidecar
|
|
444
|
+
// 新建/删除/重命名**(这些操作必改目录 mtime);覆盖写已存在的 sidecar 不改目录
|
|
445
|
+
// mtime——`.alive` 覆盖写(resume spawn 后 pid 变化重写 marker)后快路径会复用旧
|
|
446
|
+
// aliveData(旧 pid/旧 startedAt),refreshAlive 探活与 1h 软超时判定可能滞后一拍
|
|
447
|
+
//(status 判定不受影响:分支 3 探活失败只清 externalInstance,不改 status)。
|
|
448
|
+
let dirMtimeMs: number;
|
|
449
|
+
try {
|
|
450
|
+
dirMtimeMs = fs.statSync(this.sessionsDir).mtimeMs;
|
|
451
|
+
} catch {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
// [perf L-1] 首扫(dirStamp===null)惰性装载磁盘索引。必须位于 statSync 之后:
|
|
455
|
+
// sessionsDir 不存在的 early-return 不装载映像(防解析产物滞留内存);首扫时
|
|
456
|
+
// 下方快路径条件必不成立,插在快路径 if 前后等价。
|
|
457
|
+
if (this.dirStamp === null) {
|
|
458
|
+
const loaded = loadIndex(path.dirname(this.sessionsDir));
|
|
459
|
+
this.indexEntries = loaded.entries;
|
|
460
|
+
this.indexHigherVersion = loaded.higherVersion;
|
|
461
|
+
}
|
|
462
|
+
if (this.dirStamp !== null && this.dirStamp.mtimeMs === dirMtimeMs) {
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
const out: SubagentRecord[] = [];
|
|
465
|
+
for (const entry of this.fileCache.values()) {
|
|
466
|
+
if (entry.negative) continue;
|
|
467
|
+
RecordStore.refreshAlive(entry, now);
|
|
468
|
+
out.push(entry.light);
|
|
469
|
+
}
|
|
470
|
+
return rootSessionFilter === undefined
|
|
471
|
+
? out
|
|
472
|
+
: out.filter((r) => r.rootSessionId === rootSessionFilter);
|
|
302
473
|
}
|
|
303
474
|
|
|
304
|
-
const cache = new Map<string, SubagentRecord>();
|
|
305
475
|
let files: string[];
|
|
306
476
|
try {
|
|
307
477
|
files = fs.readdirSync(this.sessionsDir)
|
|
308
478
|
.filter((f) => f.endsWith(".jsonl"))
|
|
309
479
|
.map((f) => path.join(this.sessionsDir, f));
|
|
310
480
|
} catch {
|
|
311
|
-
this.
|
|
481
|
+
this.indexEntries = null; // [perf L-1] 该 early-return 路径同样释放映像(内存卫生)
|
|
312
482
|
return [];
|
|
313
483
|
}
|
|
314
484
|
|
|
315
|
-
|
|
485
|
+
// 修剪:磁盘上已消失的文件(GC/手动删)同步移出缓存与索引。删了条目必须置
|
|
486
|
+
// indexDirty——否则纯修剪轮(无其他探测)flush 第一道门 !dirty return,磁盘索引的
|
|
487
|
+
// 陈旧条目永不清除(fileCache 已删仅影响内存投影,落盘快照不会自发跟随)。
|
|
488
|
+
const disk = new Set(files);
|
|
489
|
+
for (const [file, entry] of this.fileCache) {
|
|
490
|
+
if (!disk.has(file)) {
|
|
491
|
+
this.fileCache.delete(file);
|
|
492
|
+
if (!entry.negative) this.idToFile.delete(entry.light.id);
|
|
493
|
+
this.indexDirty = true;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
316
496
|
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
const out: SubagentRecord[] = [];
|
|
317
499
|
for (const file of files) {
|
|
318
|
-
const
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
// 构造 base record(status/error/endedAt/externalInstance 后续按分支覆盖)。
|
|
327
|
-
const rec: SubagentRecord = {
|
|
328
|
-
id: recon.id,
|
|
329
|
-
agent: recon.agent,
|
|
330
|
-
slug: recon.slug,
|
|
331
|
-
status: recon.status, // 临时值,各分支覆盖
|
|
332
|
-
mode: recon.mode,
|
|
333
|
-
startedAt: recon.startedAt,
|
|
334
|
-
rootSessionId: recon.rootSessionId,
|
|
335
|
-
parentRecordId: recon.parentRecordId,
|
|
336
|
-
depth: recon.depth,
|
|
337
|
-
endedAt: undefined,
|
|
338
|
-
turns: recon.turnCount,
|
|
339
|
-
totalTokens: recon.totalTokens,
|
|
340
|
-
model: recon.model,
|
|
341
|
-
thinkingLevel: recon.thinkingLevel,
|
|
342
|
-
task: recon.task,
|
|
343
|
-
// 磁盘重建是离线快照,无实时活动状态。
|
|
344
|
-
currentActivity: undefined,
|
|
345
|
-
// worktreeHandle 不从磁盘重建(session.jsonl 未持久化路径/分支)。
|
|
346
|
-
// 已结束的 worktree record 的 checkout 已被 cleanup 回收,重建句柄无意义。
|
|
347
|
-
// forkDepth 从 identity 重建(用于 TUI 深度标记),worktree 信息仅内存 running 时可见。
|
|
348
|
-
eventLog: recon.eventLog,
|
|
349
|
-
// [STEP3] displayItems 从重建的 turns[] 派生(getDisplayItems 参数放宽为
|
|
350
|
-
// { turns },ReconstructedRecord 满足)。终态 record 详情可看完整 text 输出。
|
|
351
|
-
displayItems: getDisplayItems(recon),
|
|
352
|
-
result: recon.result,
|
|
353
|
-
error: recon.error,
|
|
354
|
-
sessionFile: recon.sessionFile,
|
|
355
|
-
};
|
|
500
|
+
const entry = this.scanFile(file, now);
|
|
501
|
+
if (entry) out.push(entry.light);
|
|
502
|
+
}
|
|
503
|
+
this.dirStamp = { mtimeMs: dirMtimeMs };
|
|
504
|
+
this.flushIndexAfterScan();
|
|
505
|
+
if (rootSessionFilter === undefined) return out;
|
|
506
|
+
return out.filter((r) => r.rootSessionId === rootSessionFilter);
|
|
507
|
+
}
|
|
356
508
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
509
|
+
/**
|
|
510
|
+
* 扫描单文件:stat 戳(jsonl + 3 sidecar)校验,全同 → 复用缓存(零文件读取,
|
|
511
|
+
* 含负缓存直接返回 null);否则重建 light。
|
|
512
|
+
* identity 定位两级:头部 64KB(首轮会话)→ 全文 fallback(续聊场景 identity
|
|
513
|
+
* append 在尾部);两级都找不到 → 写负缓存(防每轮全文重读)。
|
|
514
|
+
* 返回 null:文件消失/读失败/无 identity → 跳过。
|
|
515
|
+
*/
|
|
516
|
+
private scanFile(file: string, now: number): FileCacheEntry | null {
|
|
517
|
+
const jsonl = statStamp(file);
|
|
518
|
+
if (!jsonl) {
|
|
519
|
+
this.fileCache.delete(file);
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const cancelled = statStamp(`${file}.cancelled`);
|
|
523
|
+
const finalized = statStamp(`${file}.finalized`);
|
|
524
|
+
const alive = statStamp(`${file}.alive`);
|
|
525
|
+
|
|
526
|
+
const cached = this.fileCache.get(file);
|
|
527
|
+
if (
|
|
528
|
+
cached !== undefined &&
|
|
529
|
+
sameStamp(cached.jsonl, jsonl) &&
|
|
530
|
+
sameNullableStamp(cached.cancelled, cancelled) &&
|
|
531
|
+
sameNullableStamp(cached.finalized, finalized) &&
|
|
532
|
+
sameNullableStamp(cached.alive, alive)
|
|
533
|
+
) {
|
|
534
|
+
if (cached.negative) return null; // 负缓存命中:确认无 identity,零读取跳过
|
|
535
|
+
// pid 探活结果不落盘(进程死亡无 IO)——分支 3 的 running 项每扫重查,
|
|
536
|
+
// 保留原语义(旧实现每次 collectRecords 都重新 isProcessAlive)。
|
|
537
|
+
RecordStore.refreshAlive(cached, now);
|
|
538
|
+
return cached;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// [perf L-1] 磁盘索引查询(首扫惰性装载,miss/空索引时 get 恒 undefined = 无索引)。
|
|
542
|
+
// 条目戳匹配 jsonl 当前 stat → 零内容读取构造缓存条目。sidecar payload(tombstone/
|
|
543
|
+
// alive)是活态数据,沿用探测分支的每轮重读语义。
|
|
544
|
+
if (this.indexEntries !== null) {
|
|
545
|
+
const hit = this.indexEntries.get(path.basename(file));
|
|
546
|
+
if (hit !== undefined && hit.mtimeMs === jsonl.mtimeMs && hit.size === jsonl.size) {
|
|
547
|
+
if (hit.negative === true) {
|
|
548
|
+
// 负条目命中:「确认无 identity」跨实例持久,零探测跳过(与下方负缓存同款形态)。
|
|
549
|
+
this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
|
|
553
|
+
const aliveData = alive !== null ? readAliveMarker(file) : undefined;
|
|
554
|
+
const entry: FileCacheEntry = {
|
|
555
|
+
light: RecordStore.buildRecord(
|
|
556
|
+
{ ...hit, forkDepth: undefined, sessionFile: file },
|
|
557
|
+
{ tomb, finalized: finalized !== null, alive: aliveData, jsonlMtimeMs: jsonl.mtimeMs, now },
|
|
558
|
+
),
|
|
559
|
+
full: undefined,
|
|
560
|
+
jsonl,
|
|
561
|
+
cancelled,
|
|
562
|
+
finalized,
|
|
563
|
+
alive,
|
|
564
|
+
tomb,
|
|
565
|
+
aliveData,
|
|
566
|
+
};
|
|
567
|
+
this.fileCache.set(file, entry);
|
|
568
|
+
this.idToFile.set(hit.id, file);
|
|
569
|
+
return entry;
|
|
370
570
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// [perf L-1] 索引 miss/戳不匹配落到原三级探测:本轮探测结果必须进索引(含负探测)。
|
|
574
|
+
// 覆盖两种形态:首扫(映像已装载但 miss/不匹配)与后续轮次(映像已释放,凡进重建分支必是戳变化)。
|
|
575
|
+
this.indexDirty = true;
|
|
576
|
+
|
|
577
|
+
// 重建:identity 三级定位——头部 64KB(首轮会话,~34%)→ 尾部 64KB(续聊场景
|
|
578
|
+
// 最后一轮 session_start 追加,~65%)→ 全文 fallback(~0.2%)。均不解析 message entries。
|
|
579
|
+
// size ≤ 头部读取上限时 head 读到的即全文,tail/anywhere 只会重复读同一份内容——
|
|
580
|
+
// 直接判负(head miss = 全文无 identity),省去同内容两连读。
|
|
581
|
+
const header =
|
|
582
|
+
jsonl.size <= IDENTITY_HEAD_BYTES
|
|
583
|
+
? readIdentityHeader(file)
|
|
584
|
+
: readIdentityHeader(file) ?? readIdentityTail(file) ?? readIdentityAnywhere(file);
|
|
585
|
+
if (!header) {
|
|
586
|
+
// 负缓存:确认无 identity。后续扫描 stat 命中直接跳过;戳变化(文件补写)自动重试。
|
|
587
|
+
this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
|
|
591
|
+
const aliveData = alive !== null ? readAliveMarker(file) : undefined;
|
|
592
|
+
const entry: FileCacheEntry = {
|
|
593
|
+
light: RecordStore.buildRecord(header, {
|
|
594
|
+
tomb,
|
|
595
|
+
finalized: finalized !== null,
|
|
596
|
+
alive: aliveData,
|
|
597
|
+
jsonlMtimeMs: jsonl.mtimeMs,
|
|
598
|
+
now,
|
|
599
|
+
}),
|
|
600
|
+
full: undefined,
|
|
601
|
+
jsonl,
|
|
602
|
+
cancelled,
|
|
603
|
+
finalized,
|
|
604
|
+
alive,
|
|
605
|
+
tomb,
|
|
606
|
+
aliveData,
|
|
607
|
+
};
|
|
608
|
+
this.fileCache.set(file, entry);
|
|
609
|
+
this.idToFile.set(header.id, file);
|
|
610
|
+
return entry;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* [perf L-1] 扫描尾索引落盘(节流):释放映像 → dirty/高版本/60s 节流窗三重门 →
|
|
615
|
+
* fire-and-forget saveIndex(fileCache 全量投影)。写决策与发起在同步栈(collectRecords
|
|
616
|
+
* 返回后不会再有本轮写);仅写完成的回调(推进节流窗)是异步的。所有 return 路径均
|
|
617
|
+
* 不清 dirty——未落盘的探测成果跨轮携带,直至真正写入。
|
|
618
|
+
*
|
|
619
|
+
* 并发安全:节流基准只在写成功后推进,W1 在途时新一轮过窗扫描可再 dispatch W2(不做
|
|
620
|
+
* 进程内排队——fire-and-forget 语义保持)。安全性由 saveIndex 的 tmp 唯一性
|
|
621
|
+
* (pid+单调序号)保证:交错 rename 的终态必为某一次的完整快照(last-writer-wins,
|
|
622
|
+
* 陈旧快照胜出时下轮戳不匹配自愈),不依赖本方法串行化。
|
|
623
|
+
*/
|
|
624
|
+
private flushIndexAfterScan(): void {
|
|
625
|
+
this.indexEntries = null; // 释放映像:运行期索引不再被读(L1 接管)
|
|
626
|
+
if (!this.indexDirty) return; // 纯命中轮零探测,不写
|
|
627
|
+
if (this.indexHigherVersion) return; // 磁盘是更高版本:只忽略不重写(防 v1/v2 互相覆盖)
|
|
628
|
+
if (Date.now() - this.lastIndexWriteAt < INDEX_WRITE_MIN_INTERVAL_MS) return; // 60s 节流窗内
|
|
629
|
+
const entries = this.projectIndexEntries();
|
|
630
|
+
this.indexDirty = false; // 发起时消费(写失败在 .catch 恢复)
|
|
631
|
+
const encDir = path.dirname(this.sessionsDir);
|
|
632
|
+
saveIndex(encDir, { entries })
|
|
633
|
+
.then(() => {
|
|
634
|
+
this.lastIndexWriteAt = Date.now(); // 仅成功分支推进节流窗
|
|
635
|
+
})
|
|
636
|
+
.catch((err: unknown) => {
|
|
637
|
+
this.indexDirty = true; // 失败恢复 dirty,下轮过窗重试
|
|
638
|
+
logger.debug("[subagents] sessions-index write failed", {
|
|
639
|
+
detail: { dir: encDir, error: err instanceof Error ? err.message : String(err) },
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* [perf L-1] fileCache 全量投影 → 索引快照(basename → 正/负条目)。
|
|
646
|
+
* 投影式单一 SSOT:不维护第二份可变索引映像(防双轨漂移);fileCache 已被
|
|
647
|
+
* reconstructAll 修剪掉消失文件(修剪时置 indexDirty),下次过窗写时快照清除
|
|
648
|
+
* 磁盘上的陈旧条目。
|
|
649
|
+
*/
|
|
650
|
+
private projectIndexEntries(): Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> {
|
|
651
|
+
const entries = new Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry>();
|
|
652
|
+
for (const [file, cached] of this.fileCache) {
|
|
653
|
+
const base = path.basename(file);
|
|
654
|
+
if (cached.negative) {
|
|
655
|
+
entries.set(base, { negative: true, mtimeMs: cached.jsonl.mtimeMs, size: cached.jsonl.size });
|
|
656
|
+
} else {
|
|
657
|
+
entries.set(base, {
|
|
658
|
+
mtimeMs: cached.jsonl.mtimeMs,
|
|
659
|
+
size: cached.jsonl.size,
|
|
660
|
+
id: cached.light.id,
|
|
661
|
+
agent: cached.light.agent,
|
|
662
|
+
mode: cached.light.mode,
|
|
663
|
+
task: cached.light.task,
|
|
664
|
+
slug: cached.light.slug,
|
|
665
|
+
startedAt: cached.light.startedAt,
|
|
666
|
+
rootSessionId: cached.light.rootSessionId,
|
|
667
|
+
parentRecordId: cached.light.parentRecordId,
|
|
668
|
+
depth: cached.light.depth,
|
|
669
|
+
model: cached.light.model,
|
|
670
|
+
thinkingLevel: cached.light.thinkingLevel,
|
|
671
|
+
});
|
|
379
672
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
673
|
+
}
|
|
674
|
+
return entries;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* [perf] byId 索引直查 light record(单文件 stat 校验,不触发 getFullRecord 的
|
|
679
|
+
* 全量重建)。idToFile 未热(进程重启后尚未扫描过)时返回 undefined,调用方
|
|
680
|
+
* 自行兜底全目录扫描——用于把「跨重启后每条 message 一次 collectRecords 全扫」
|
|
681
|
+
* 降为 O(1) 索引命中。
|
|
682
|
+
*/
|
|
683
|
+
findLightById(id: string): SubagentRecord | undefined {
|
|
684
|
+
const file = this.idToFile.get(id);
|
|
685
|
+
if (!file) return undefined;
|
|
686
|
+
return this.scanFile(file, Date.now())?.light;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* [perf] 单 record 详情懒加载:内存 running record 投影全量;磁盘 record 全量重建
|
|
691
|
+
* (reconstructFromFile)并套用同一 sidecar 状态矩阵。结果缓存在 FileCacheEntry.full,
|
|
692
|
+
* stat 戳变化时随 light 一起失效。列表 collectRecords 返回 light(无 eventLog/
|
|
693
|
+
* result/turns 等重数据),详情面板/工具 list 按需调本方法补齐。
|
|
694
|
+
*
|
|
695
|
+
* 返回 undefined:id 不存在(内存与磁盘均无)。reconstructFromFile 失败(无
|
|
696
|
+
* assistant message 等)→ 返回 light(无详情可补,缓存哨兵防重复全文重读)。
|
|
697
|
+
*/
|
|
698
|
+
getFullRecord(id: string): SubagentRecord | undefined {
|
|
699
|
+
// 内存 running record 天然全量(recordToSubagent 投影完整活态数据)。
|
|
700
|
+
const mem = this.records.get(id);
|
|
701
|
+
if (mem) return RecordStore.recordToSubagent(mem);
|
|
702
|
+
|
|
703
|
+
const file = this.idToFile.get(id);
|
|
704
|
+
if (!file) return undefined;
|
|
705
|
+
const entry = this.scanFile(file, Date.now());
|
|
706
|
+
if (!entry) return undefined;
|
|
707
|
+
if (entry.full === undefined) {
|
|
708
|
+
const recon = reconstructFromFile(file);
|
|
709
|
+
if (recon) {
|
|
710
|
+
entry.full = RecordStore.buildRecord(recon, {
|
|
711
|
+
tomb: entry.tomb,
|
|
712
|
+
finalized: entry.finalized !== null,
|
|
713
|
+
alive: entry.aliveData,
|
|
714
|
+
jsonlMtimeMs: entry.jsonl.mtimeMs,
|
|
715
|
+
fullEndedAt: recon.endedAt,
|
|
716
|
+
now: Date.now(),
|
|
717
|
+
});
|
|
718
|
+
} else {
|
|
719
|
+
entry.full = entry.light; // 哨兵:无详情可补,后续直取 light(戳变化时重置重试)
|
|
385
720
|
}
|
|
721
|
+
}
|
|
722
|
+
return entry.full;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** alive 探活刷新(scanFile 缓存命中与 reconstructAll 快路径共用):
|
|
726
|
+
* 分支 3 的 running + alive 条目每扫重查 pid(结果不落盘,进程死亡无 IO),
|
|
727
|
+
* 保留旧实现「每次 collectRecords 重新 isProcessAlive」的语义。 */
|
|
728
|
+
private static refreshAlive(entry: FileCacheEntry, now: number): void {
|
|
729
|
+
if (entry.alive === null || entry.light.status !== "running") return;
|
|
730
|
+
const marker = entry.aliveData;
|
|
731
|
+
if (!marker) return;
|
|
732
|
+
const live = isProcessAlive(marker.pid) && now - marker.startedAt < ALIVE_SOFT_TIMEOUT_MS;
|
|
733
|
+
entry.light.externalInstance = live ? marker : undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** identity 基底(头部 light 或全量 recon)+ 四分支 sidecar 状态矩阵 → SubagentRecord。 */
|
|
737
|
+
private static buildRecord(
|
|
738
|
+
base: IdentityHeaderRecon | ReconstructedRecord,
|
|
739
|
+
m: SidecarMatrix,
|
|
740
|
+
): SubagentRecord {
|
|
741
|
+
let rec: SubagentRecord;
|
|
742
|
+
if ("turns" in base) {
|
|
743
|
+
// 全量:turnCount/totalTokens/model/eventLog/displayItems/result/error 齐全。
|
|
744
|
+
rec = {
|
|
745
|
+
id: base.id,
|
|
746
|
+
agent: base.agent,
|
|
747
|
+
slug: base.slug,
|
|
748
|
+
status: "running", // 占位,下方矩阵覆盖
|
|
749
|
+
mode: base.mode,
|
|
750
|
+
startedAt: base.startedAt,
|
|
751
|
+
rootSessionId: base.rootSessionId,
|
|
752
|
+
parentRecordId: base.parentRecordId,
|
|
753
|
+
depth: base.depth,
|
|
754
|
+
endedAt: undefined,
|
|
755
|
+
turns: base.turnCount,
|
|
756
|
+
totalTokens: base.totalTokens,
|
|
757
|
+
model: base.model,
|
|
758
|
+
thinkingLevel: base.thinkingLevel,
|
|
759
|
+
task: base.task,
|
|
760
|
+
currentActivity: undefined,
|
|
761
|
+
eventLog: base.eventLog,
|
|
762
|
+
displayItems: getDisplayItems(base),
|
|
763
|
+
result: base.result,
|
|
764
|
+
error: base.error,
|
|
765
|
+
sessionFile: base.sessionFile,
|
|
766
|
+
worktree: base.worktree,
|
|
767
|
+
};
|
|
768
|
+
} else {
|
|
769
|
+
// light:详情字段缺省(turns=0/eventLog=[]/result=undefined),getFullRecord 懒补。
|
|
770
|
+
rec = {
|
|
771
|
+
id: base.id,
|
|
772
|
+
agent: base.agent,
|
|
773
|
+
slug: base.slug,
|
|
774
|
+
status: "running", // 占位,下方矩阵覆盖
|
|
775
|
+
mode: base.mode,
|
|
776
|
+
startedAt: base.startedAt,
|
|
777
|
+
rootSessionId: base.rootSessionId,
|
|
778
|
+
parentRecordId: base.parentRecordId,
|
|
779
|
+
depth: base.depth,
|
|
780
|
+
endedAt: undefined,
|
|
781
|
+
turns: 0,
|
|
782
|
+
totalTokens: 0,
|
|
783
|
+
model: base.model,
|
|
784
|
+
thinkingLevel: base.thinkingLevel,
|
|
785
|
+
task: base.task,
|
|
786
|
+
currentActivity: undefined,
|
|
787
|
+
eventLog: [],
|
|
788
|
+
displayItems: [],
|
|
789
|
+
result: undefined,
|
|
790
|
+
error: undefined,
|
|
791
|
+
sessionFile: base.sessionFile,
|
|
792
|
+
worktree: base.worktree,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
386
795
|
|
|
387
|
-
|
|
796
|
+
// ── 分支 1: .cancelled ──
|
|
797
|
+
// v4 B-1: cancelled 折入 closed(closedReason='cancelled' 保留 L2 区分)。
|
|
798
|
+
if (m.tomb) {
|
|
799
|
+
markReconstructedStatus(rec, "closed");
|
|
800
|
+
rec.closedReason = "cancelled";
|
|
801
|
+
rec.error = "cancelled by user";
|
|
802
|
+
rec.endedAt = m.tomb.endedAt;
|
|
388
803
|
}
|
|
804
|
+
// ── 分支 2: .finalized ──
|
|
805
|
+
else if (m.finalized) {
|
|
806
|
+
// closed 统一终态:done/failed/crashed 合并为 closed + closedReason=gc。
|
|
807
|
+
markReconstructedStatus(rec, "closed");
|
|
808
|
+
rec.closedReason = "gc";
|
|
809
|
+
// 全量路径用最后 entry ts(精确);light 路径用 jsonl mtime 近似(finalize 后
|
|
810
|
+
// 文件不再变化,误差 <1s),避免重建后耗时随墙钟无限增长。
|
|
811
|
+
rec.endedAt = m.fullEndedAt ?? m.jsonlMtimeMs;
|
|
812
|
+
}
|
|
813
|
+
// ── 分支 3: .alive + pid 存活 + 未超软超时 ──
|
|
814
|
+
else if (
|
|
815
|
+
m.alive !== undefined &&
|
|
816
|
+
isProcessAlive(m.alive.pid) &&
|
|
817
|
+
m.now - m.alive.startedAt < ALIVE_SOFT_TIMEOUT_MS
|
|
818
|
+
) {
|
|
819
|
+
markReconstructedStatus(rec, "running");
|
|
820
|
+
rec.externalInstance = m.alive;
|
|
821
|
+
}
|
|
822
|
+
// ── 分支 4: 兜底(都无 / .alive 但 pid 死 / 超时)──
|
|
823
|
+
// v4 B-1:跨重启可续聊态落点 = running。endedAt 保持 undefined(非终态)。
|
|
824
|
+
else {
|
|
825
|
+
markReconstructedStatus(rec, "running");
|
|
826
|
+
}
|
|
827
|
+
return rec;
|
|
828
|
+
}
|
|
389
829
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
830
|
+
/** 从缓存与索引移除某文件(文件删除时;负缓存条目无 id,仅删缓存项)。 */
|
|
831
|
+
private dropFileCache(file: string): void {
|
|
832
|
+
const entry = this.fileCache.get(file);
|
|
833
|
+
if (entry) {
|
|
834
|
+
if (!entry.negative) this.idToFile.delete(entry.light.id);
|
|
835
|
+
this.fileCache.delete(file);
|
|
836
|
+
}
|
|
394
837
|
}
|
|
395
838
|
|
|
396
839
|
/** 排序比较器:status priority(running<failed<cancelled<done)+ startedAt desc。 */
|
|
@@ -430,7 +873,7 @@ export class RecordStore {
|
|
|
430
873
|
eventLog: [],
|
|
431
874
|
displayItems: [],
|
|
432
875
|
result: undefined,
|
|
433
|
-
error:
|
|
876
|
+
error: undefined, // closed 统一终态,不再按 status 区分 error 字段
|
|
434
877
|
sessionFile: m.sessionFile,
|
|
435
878
|
};
|
|
436
879
|
}
|
|
@@ -441,6 +884,7 @@ export class RecordStore {
|
|
|
441
884
|
id: r.id,
|
|
442
885
|
agent: r.agent,
|
|
443
886
|
status: r.status,
|
|
887
|
+
closedReason: r.closedReason,
|
|
444
888
|
mode: r.mode,
|
|
445
889
|
slug: r.slug,
|
|
446
890
|
startedAt: r.startedAt,
|
|
@@ -459,6 +903,9 @@ export class RecordStore {
|
|
|
459
903
|
result: r.result,
|
|
460
904
|
error: r.error,
|
|
461
905
|
sessionFile: r.sessionFile,
|
|
906
|
+
round: r.round,
|
|
907
|
+
// [review round2] worktree 隔离标志:内存源有 handle 或跨重启重建带 hadWorktree 均为 true。
|
|
908
|
+
worktree: r.worktreeHandle !== undefined || r.hadWorktree === true,
|
|
462
909
|
};
|
|
463
910
|
}
|
|
464
911
|
}
|