@zhushanwen/subagent-core 0.2.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 +114 -0
- package/dist/chunk-3VOERJPJ.js +22 -0
- package/dist/chunk-A4IVZWVX.js +31 -0
- package/dist/execution/engine/engines/zcode/constants.cjs +54 -0
- package/dist/execution/engine/engines/zcode/constants.d.cts +31 -0
- package/dist/execution/engine/engines/zcode/constants.d.ts +31 -0
- package/dist/execution/engine/engines/zcode/constants.js +22 -0
- package/dist/execution/engine/engines/zcode/reader.cjs +276 -0
- package/dist/execution/engine/engines/zcode/reader.d.cts +20 -0
- package/dist/execution/engine/engines/zcode/reader.d.ts +20 -0
- package/dist/execution/engine/engines/zcode/reader.js +239 -0
- package/dist/execution/engine/paths.cjs +55 -0
- package/dist/execution/engine/paths.d.cts +8 -0
- package/dist/execution/engine/paths.d.ts +8 -0
- package/dist/execution/engine/paths.js +26 -0
- package/dist/execution/relay-env.cjs +62 -0
- package/dist/execution/relay-env.d.cts +33 -0
- package/dist/execution/relay-env.d.ts +33 -0
- package/dist/execution/relay-env.js +20 -0
- package/dist/index.cjs +1852 -0
- package/dist/index.d.cts +1184 -0
- package/dist/index.d.ts +1184 -0
- package/dist/index.js +1810 -0
- package/dist/types-BxyAidGf.d.cts +875 -0
- package/dist/types-BxyAidGf.d.ts +875 -0
- package/package.json +99 -0
- package/src/__tests__/agent-opts-resolver-schema-prompt.test.ts +142 -0
- package/src/__tests__/append-system-prompt-assembly.test.ts +242 -0
- package/src/__tests__/builtin-workflows-structure.test.ts +180 -0
- package/src/__tests__/derive-closed-display-parity.test.ts +93 -0
- package/src/__tests__/fr4-get-state-handshake.test.ts +125 -0
- package/src/__tests__/fr8-orphan-recovery.test.ts +259 -0
- package/src/__tests__/m2-append-content-probe.test.ts +73 -0
- package/src/__tests__/manifest-store.test.ts +368 -0
- package/src/__tests__/record-store-cache.test.ts +335 -0
- package/src/__tests__/record-store-index.test.ts +560 -0
- package/src/__tests__/record-store-last-line.test.ts +129 -0
- package/src/__tests__/review-fix-loop-script.test.ts +288 -0
- package/src/__tests__/review-fix-loop-utils.test.ts +2377 -0
- package/src/__tests__/robustness-low-batch1.test.ts +286 -0
- package/src/__tests__/robustness-medium-batch1.test.ts +69 -0
- package/src/__tests__/robustness-medium-batch2.test.ts +65 -0
- package/src/__tests__/robustness-medium-batch3.test.ts +30 -0
- package/src/__tests__/robustness-medium-batch4.test.ts +358 -0
- package/src/__tests__/session-runner.test.ts +69 -0
- package/src/__tests__/smoke.test.ts +9 -0
- package/src/__tests__/stream-sink.test.ts +203 -0
- package/src/core/__tests__/host-services.test.ts +144 -0
- package/src/core/__tests__/logger.test.ts +78 -0
- package/src/core/__tests__/notify-ports.test.ts +104 -0
- package/src/core/host-services.ts +97 -0
- package/src/core/logger.ts +47 -0
- package/src/core/notify-ports.ts +126 -0
- package/src/execution/__tests__/__fixtures__/notifier-golden-snapshots.json +53 -0
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +150 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +490 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +242 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +610 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +250 -0
- package/src/execution/__tests__/config.test.ts +302 -0
- package/src/execution/__tests__/delivery-methods.test.ts +424 -0
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +243 -0
- package/src/execution/__tests__/execute-nesting.test.ts +323 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +200 -0
- package/src/execution/__tests__/execution-record.test.ts +1394 -0
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +171 -0
- package/src/execution/__tests__/finalize-record.test.ts +367 -0
- package/src/execution/__tests__/finalized-marker.test.ts +104 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +169 -0
- 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/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +242 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +383 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/manifest-parentid.test.ts +117 -0
- package/src/execution/__tests__/model-resolver.test.ts +465 -0
- 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__/output-collector.test.ts +358 -0
- package/src/execution/__tests__/path-encoding.test.ts +104 -0
- package/src/execution/__tests__/pi-invocation.test.ts +134 -0
- package/src/execution/__tests__/record-store.test.ts +1057 -0
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +339 -0
- package/src/execution/__tests__/recursive-visibility-env.test.ts +273 -0
- package/src/execution/__tests__/relay-env.test.ts +42 -0
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -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 +687 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +933 -0
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +196 -0
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +199 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +293 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +379 -0
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +350 -0
- package/src/execution/__tests__/spawn-args.test.ts +445 -0
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +207 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/start-sync-model-guard.test.ts +150 -0
- package/src/execution/__tests__/stdin-writer.test.ts +462 -0
- package/src/execution/__tests__/stream-sink-retirement.test.ts +261 -0
- package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +788 -0
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +310 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +612 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +615 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +369 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +113 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +633 -0
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +229 -0
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +199 -0
- package/src/execution/agent-registry.ts +199 -0
- package/src/execution/agent-result-mapper.ts +90 -0
- package/src/execution/alive-store.ts +119 -0
- package/src/execution/argv-mirror.ts +113 -0
- package/src/execution/best-effort.ts +38 -0
- package/src/execution/channel-registry-access.ts +144 -0
- package/src/execution/concurrency-pool.ts +116 -0
- package/src/execution/config.ts +165 -0
- package/src/execution/dialog-queue.ts +330 -0
- package/src/execution/engine/__tests__/common/data-dir.test.ts +78 -0
- package/src/execution/engine/__tests__/common/errors.test.ts +132 -0
- package/src/execution/engine/__tests__/common/event-journal.test.ts +177 -0
- package/src/execution/engine/__tests__/common/kill-chain.test.ts +192 -0
- package/src/execution/engine/__tests__/common/nesting-guard.test.ts +81 -0
- package/src/execution/engine/__tests__/common/persona-router.test.ts +123 -0
- package/src/execution/engine/__tests__/common/pool-manager.test.ts +154 -0
- package/src/execution/engine/__tests__/common/schema-emulation.test.ts +128 -0
- package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +28 -0
- package/src/execution/engine/__tests__/conformance/agent-event-invariants.ts +141 -0
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +109 -0
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +101 -0
- package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +77 -0
- package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +104 -0
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +201 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +76 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +79 -0
- package/src/execution/engine/__tests__/engine-discovery.test.ts +87 -0
- package/src/execution/engine/__tests__/model-prompt.test.ts +197 -0
- package/src/execution/engine/__tests__/paths.test.ts +39 -0
- package/src/execution/engine/__tests__/registry.test.ts +120 -0
- package/src/execution/engine/__tests__/routing.test.ts +231 -0
- package/src/execution/engine/common/data-dir.ts +66 -0
- package/src/execution/engine/common/errors.ts +183 -0
- package/src/execution/engine/common/event-journal.ts +254 -0
- package/src/execution/engine/common/journal-replay.ts +62 -0
- package/src/execution/engine/common/kill-chain.ts +221 -0
- package/src/execution/engine/common/nesting-guard.ts +50 -0
- package/src/execution/engine/common/persona-router.ts +108 -0
- package/src/execution/engine/common/pool-manager.ts +226 -0
- package/src/execution/engine/common/schema-emulation.ts +189 -0
- package/src/execution/engine/common/session-view-projection.ts +51 -0
- package/src/execution/engine/engine-discovery.ts +65 -0
- package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +469 -0
- package/src/execution/engine/engines/pi/__tests__/reader.test.ts +155 -0
- package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +164 -0
- package/src/execution/engine/engines/pi/pi-engine.ts +415 -0
- package/src/execution/engine/engines/pi/reader.ts +48 -0
- package/src/execution/engine/engines/pi/registration.ts +35 -0
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +100 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +39 -0
- package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +150 -0
- package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +246 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +228 -0
- package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +210 -0
- package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +71 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +127 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +580 -0
- package/src/execution/engine/engines/zcode/constants.ts +43 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +39 -0
- package/src/execution/engine/engines/zcode/launcher.ts +161 -0
- package/src/execution/engine/engines/zcode/parser.ts +436 -0
- package/src/execution/engine/engines/zcode/preparer.ts +363 -0
- package/src/execution/engine/engines/zcode/reader.ts +381 -0
- package/src/execution/engine/engines/zcode/registration.ts +37 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +658 -0
- package/src/execution/engine/host-task-spec.ts +47 -0
- package/src/execution/engine/model-prompt.ts +158 -0
- package/src/execution/engine/paths.ts +42 -0
- package/src/execution/engine/port.ts +153 -0
- package/src/execution/engine/registry.ts +133 -0
- package/src/execution/engine/routing.ts +218 -0
- package/src/execution/engine/types.ts +309 -0
- package/src/execution/execute-options-mapper.ts +115 -0
- package/src/execution/execution-record.ts +984 -0
- package/src/execution/finalize-record.ts +254 -0
- package/src/execution/finalized-marker.ts +70 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +56 -0
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +513 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +256 -0
- package/src/execution/model-config-service.ts +250 -0
- package/src/execution/model-resolver.ts +248 -0
- package/src/execution/notifier.ts +372 -0
- package/src/execution/notify-ledger.ts +580 -0
- package/src/execution/output-collector.ts +228 -0
- package/src/execution/path-encoding.ts +60 -0
- package/src/execution/pi-invocation.ts +120 -0
- package/src/execution/record-entry.ts +132 -0
- package/src/execution/record-store.ts +1265 -0
- package/src/execution/relay-env.ts +37 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +120 -0
- package/src/execution/session-pending.ts +170 -0
- package/src/execution/session-reconstructor.ts +678 -0
- package/src/execution/session-runner.ts +1789 -0
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/spawn-event-adapter.ts +363 -0
- package/src/execution/stdin-writer.ts +198 -0
- package/src/execution/stream-sink.ts +126 -0
- package/src/execution/subagent-service.ts +2268 -0
- package/src/execution/subprocess-agent-runner.ts +317 -0
- package/src/execution/temp-prompt.ts +62 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +102 -0
- package/src/execution/types.ts +1023 -0
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +243 -0
- package/src/execution/ui-request-observability.ts +81 -0
- package/src/execution/ui-request-queue.ts +184 -0
- package/src/execution/worktree-manager.ts +688 -0
- package/src/execution/worktree-registry.ts +279 -0
- package/src/index.ts +87 -0
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +349 -0
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +202 -0
- package/src/orchestration/__tests__/agent-call-stream.test.ts +152 -0
- package/src/orchestration/__tests__/args-validator.test.ts +143 -0
- package/src/orchestration/__tests__/config-loader.test.ts +503 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +809 -0
- package/src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts +404 -0
- package/src/orchestration/__tests__/error-recovery-serialize-failed-result.test.ts +56 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +451 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +600 -0
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +659 -0
- package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +95 -0
- package/src/orchestration/__tests__/review-fix-loop-scriptpath-failfast.test.ts +183 -0
- package/src/orchestration/__tests__/script-lint.test.ts +831 -0
- package/src/orchestration/__tests__/skill-discovery.test.ts +210 -0
- package/src/orchestration/__tests__/test-mocks.ts +197 -0
- package/src/orchestration/__tests__/worker-exit-without-result.test.ts +368 -0
- package/src/orchestration/__tests__/worker-host.test.ts +120 -0
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +563 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +309 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +129 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +317 -0
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/agent-opts-resolver.ts +174 -0
- package/src/orchestration/args-validator.ts +127 -0
- package/src/orchestration/config-loader.ts +316 -0
- package/src/orchestration/error-recovery.ts +1018 -0
- package/src/orchestration/execute-agent-call.ts +256 -0
- package/src/orchestration/launcher.ts +455 -0
- package/src/orchestration/lifecycle.ts +399 -0
- package/src/orchestration/models/__tests__/budget.test.ts +307 -0
- package/src/orchestration/models/agent-call.ts +83 -0
- package/src/orchestration/models/budget.ts +118 -0
- package/src/orchestration/models/ports.ts +164 -0
- package/src/orchestration/models/run-runtime.ts +104 -0
- package/src/orchestration/models/run-spec.ts +83 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +183 -0
- package/src/orchestration/models/types.ts +301 -0
- package/src/orchestration/models/workflow-run.ts +254 -0
- package/src/orchestration/models/workflow-script-registry.ts +35 -0
- package/src/orchestration/models/workflow-script.ts +117 -0
- package/src/orchestration/script-lint.ts +766 -0
- package/src/orchestration/skill-discovery.ts +135 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +98 -0
- package/src/orchestration/worker-script-builder.ts +421 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +133 -0
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/meta-parser.test.ts +304 -0
- package/src/shared/__tests__/model-ref.test.ts +306 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +288 -0
- package/src/shared/__tests__/resource-discovery.test.ts +749 -0
- package/src/shared/__tests__/resource-meta.test.ts +51 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/__tests__/timer-delay.test.ts +61 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/agent-ref.ts +57 -0
- package/src/shared/meta-parser.ts +261 -0
- package/src/shared/model-ref.ts +286 -0
- package/src/shared/resource-discovery.ts +835 -0
- package/src/shared/resource-meta.ts +65 -0
- package/src/shared/schema-env.ts +44 -0
- package/src/shared/schema-jsonify.ts +58 -0
- package/src/shared/timer-delay.ts +54 -0
- package/src/shared/xml-injection.ts +35 -0
- package/workflows/README.md +81 -0
- package/workflows/_shared/agent-refs.cjs +40 -0
- package/workflows/chain.js +137 -0
- package/workflows/map-reduce.js +180 -0
- package/workflows/parallel.js +154 -0
- package/workflows/review-fix-loop-utils.cjs +1542 -0
- package/workflows/review-fix-loop.js +1605 -0
- package/workflows/scatter-gather.js +184 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
// src/runtime/execution/record-store.ts
|
|
2
|
+
//
|
|
3
|
+
// Record 的统一容器。内存只留 running record;终态从 session.jsonl 重建。
|
|
4
|
+
//
|
|
5
|
+
// 职责:
|
|
6
|
+
// - 持有 running record(终态 record 在 archive 时立即从内存移除)
|
|
7
|
+
// - onChange 订阅(TUI widget/list 据此重渲)
|
|
8
|
+
// - collectRecords:内存(running) + 磁盘(sessions/*.jsonl 重建) 合并
|
|
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。
|
|
24
|
+
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
|
|
28
|
+
import { getLogger } from "../core/logger.ts";
|
|
29
|
+
|
|
30
|
+
import { getCurrentActivity, getDisplayItems, getEventLog, markReconstructedStatus, snapshot as toSnapshot } from "./execution-record.ts";
|
|
31
|
+
import { readFinalizedReason, writeFinalized } from "./finalized-marker.ts";
|
|
32
|
+
import { toSubagentRecordEntry, SUBAGENT_RECORD_CUSTOM_TYPE } from "./record-entry.ts";
|
|
33
|
+
import type { ManifestRecord, ManifestStore } from "./manifest-store.ts";
|
|
34
|
+
import { INDEX_WRITE_MIN_INTERVAL_MS, loadIndex, saveIndex } from "./sessions-index.ts";
|
|
35
|
+
import type { SessionsIndexEntry, SessionsIndexNegativeEntry } from "./sessions-index.ts";
|
|
36
|
+
import {
|
|
37
|
+
IDENTITY_HEAD_BYTES,
|
|
38
|
+
type IdentityHeaderRecon,
|
|
39
|
+
type ReconstructedRecord,
|
|
40
|
+
readIdentityAnywhere,
|
|
41
|
+
readIdentityHeader,
|
|
42
|
+
readIdentityTail,
|
|
43
|
+
reconstructFromFile,
|
|
44
|
+
} from "./session-reconstructor.ts";
|
|
45
|
+
import type {
|
|
46
|
+
AliveMarker,
|
|
47
|
+
ClosedReason,
|
|
48
|
+
ExecutionRecord,
|
|
49
|
+
ExecutionStatus,
|
|
50
|
+
RecordSnapshot,
|
|
51
|
+
SubagentRecord,
|
|
52
|
+
} from "./types.ts";
|
|
53
|
+
import type { CancelledTombstone } from "./tombstone-store.ts";
|
|
54
|
+
import { CLOSED_REASONS as CLOSED_REASON_LIST } from "./types.ts";
|
|
55
|
+
import { isProcessAlive, readAliveMarker, ALIVE_SOFT_TIMEOUT_MS } from "./alive-store.ts";
|
|
56
|
+
import { readCancelledTombstone } from "./tombstone-store.ts";
|
|
57
|
+
|
|
58
|
+
const logger = getLogger("subagents");
|
|
59
|
+
|
|
60
|
+
// ============================================================
|
|
61
|
+
// 常量
|
|
62
|
+
// ============================================================
|
|
63
|
+
|
|
64
|
+
/** status → 排序优先级(值小排前):running < closed。
|
|
65
|
+
* v4 B-1:idle/cancelled 折入 running/closed,两态收敛。closed = 统一终态
|
|
66
|
+
* (done/failed/crashed/cancelled 合并),按 closedReason 派生对外语义。 */
|
|
67
|
+
const STATUS_PRIORITY: Record<ExecutionStatus, number> = {
|
|
68
|
+
running: 0,
|
|
69
|
+
closed: 3,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// .alive 软超时常量已迁移至 alive-store.ts(v8.5 D 透明重生探针共用同一判据 SSOT)。
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* manifest status → ExecutionStatus 运行时守卫映射。
|
|
76
|
+
*
|
|
77
|
+
* manifest 写 running/closed/cancelled 三态(ManifestRecord.status union),但磁盘
|
|
78
|
+
* 文件可能陈旧(含历史 "completed"/"failed"/"error" 值、被外部篡改)。越界值返回 null——
|
|
79
|
+
* manifestToSubagent 据此返回 null,collectRecords 跳过损坏 record 并 console.warn,不因单个
|
|
80
|
+
* 坏文件崩溃,也不把损坏 record 错误降级为 closed(closed 触发告警,是误报)。
|
|
81
|
+
*
|
|
82
|
+
* 提取为纯函数:同时解决 PR#85 反射问题(三元 + `as ExecutionStatus` cast)。
|
|
83
|
+
* [HISTORICAL] SP-1 重构:旧 "completed" → closed,旧 "failed" → closed(L1 统一终态)。
|
|
84
|
+
*/
|
|
85
|
+
function mapManifestStatus(s: string): ExecutionStatus | null {
|
|
86
|
+
if (s === "closed") return "closed";
|
|
87
|
+
if (s === "completed") return "closed"; // 向后兼容旧 manifest 数据
|
|
88
|
+
if (s === "failed") return "closed"; // 向后兼容旧 manifest 数据
|
|
89
|
+
if (s === "running") return "running";
|
|
90
|
+
if (s === "cancelled") return "closed"; // v4 B-1: manifest cancelled 折入 closed(closedReason 信息丢失,manifest 仅诊断辅助)
|
|
91
|
+
return null; // 越界=数据损坏(含历史 "error" 值),返回 null 让调用方跳过
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** store 变更监听器(返回取消订阅函数)。 */
|
|
95
|
+
export type ChangeListener = () => void;
|
|
96
|
+
|
|
97
|
+
/** status 过滤模式(collectRecords 的核心能力参数)。 */
|
|
98
|
+
export type StatusFilter = "running" | "all";
|
|
99
|
+
|
|
100
|
+
/** 缓存校验戳(mtime+size 双因子——append-only jsonl 必变 size;sidecar 覆盖写必变 mtime)。 */
|
|
101
|
+
interface Stamp {
|
|
102
|
+
mtimeMs: number;
|
|
103
|
+
size: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** sidecar 状态矩阵输入(buildLightRecord / getFullRecord 共享)。 */
|
|
107
|
+
interface SidecarMatrix {
|
|
108
|
+
tomb: CancelledTombstone | undefined;
|
|
109
|
+
finalized: boolean;
|
|
110
|
+
/** [v8.5 A2] `.finalized` sidecar 内容携带的关闭原因(readFinalizedReason 读出)。
|
|
111
|
+
* 旧格式空文件 → 空串(死因不可考);sidecar 不存在 → undefined。 */
|
|
112
|
+
finalizedReason: string | undefined;
|
|
113
|
+
alive: AliveMarker | undefined;
|
|
114
|
+
/** jsonl mtime(light 分支 2 的 endedAt 近似——finalize 后文件不再变化)。 */
|
|
115
|
+
jsonlMtimeMs: number;
|
|
116
|
+
/** 全量重建可得的精确结束时间(最后 entry ts);light 传 undefined 回落 mtime。 */
|
|
117
|
+
fullEndedAt?: number;
|
|
118
|
+
now: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* per-file 缓存条目([perf] 两级设计)。
|
|
123
|
+
*
|
|
124
|
+
* light:头部 identity + sidecar 状态矩阵(列表扫描产出,详情字段缺省)
|
|
125
|
+
* full :懒加载的完整重建(getFullRecord 按需补 turns/eventLog/result)。
|
|
126
|
+
* full === light 是哨兵(「已尝试但无详情可补」,如无 assistant message
|
|
127
|
+
* 的文件),避免重复全文重读;stat 戳变化时随 light 一起重置重试。
|
|
128
|
+
*
|
|
129
|
+
* 校验:jsonl + 3 sidecar 的 stat 戳对比。任何写操作至少改变一个戳 →
|
|
130
|
+
* 只重建该文件,其余 N-1 个复用缓存(statSync 毫秒级,取代旧的整体失效重扫)。
|
|
131
|
+
*/
|
|
132
|
+
interface FileCacheEntry {
|
|
133
|
+
/** tagged union 判别(负缓存条目为 true)。显式声明 false 供 TS narrowing。 */
|
|
134
|
+
negative?: false;
|
|
135
|
+
light: SubagentRecord;
|
|
136
|
+
full: SubagentRecord | undefined;
|
|
137
|
+
jsonl: Stamp;
|
|
138
|
+
cancelled: Stamp | null;
|
|
139
|
+
finalized: Stamp | null;
|
|
140
|
+
alive: Stamp | null;
|
|
141
|
+
/** 最近一次重建时读到的 sidecar 原始内容(校验命中路径复用,不重读文件)。 */
|
|
142
|
+
tomb: CancelledTombstone | undefined;
|
|
143
|
+
aliveData: AliveMarker | undefined;
|
|
144
|
+
/** [v8.5 A2] 最近一次重建时读到的 `.finalized` 内容 reason(同上,校验命中复用)。
|
|
145
|
+
* sidecar 不存在时恒 undefined(与「存在但空串」区分,后者是旧格式空文件)。 */
|
|
146
|
+
finalReason: string | undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 负缓存条目:确认无 identity 的文件(损坏/异构)。缓存「没有」这一事实,
|
|
150
|
+
* 避免每轮扫描都全文 fallback 重读(全文读是 fallback 的成本主体)。 */
|
|
151
|
+
interface NegativeFileEntry {
|
|
152
|
+
negative: true;
|
|
153
|
+
jsonl: Stamp;
|
|
154
|
+
cancelled: Stamp | null;
|
|
155
|
+
finalized: Stamp | null;
|
|
156
|
+
alive: Stamp | null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** fileCache 值类型:正常条目或负缓存条目。 */
|
|
160
|
+
type FileCacheValue = FileCacheEntry | NegativeFileEntry;
|
|
161
|
+
|
|
162
|
+
/** 孤儿判定的末行读取初始窗口(常规 entry 远小于此,避免全文件读)。 */
|
|
163
|
+
// eslint-disable-next-line no-magic-numbers -- 64KB = 64 * 1024 bytes 字节换算常数
|
|
164
|
+
const LAST_LINE_WINDOW_BYTES = 64 * 1024;
|
|
165
|
+
/** 末行超出初始窗口时的扩窗倍数(64KB → 256KB → 1MB → …,上限=文件头)。
|
|
166
|
+
* [HISTORICAL] 曾断言「64KB 足以容纳任何单行 entry(task 上限 ~62KB 实测)」并被
|
|
167
|
+
* V1 探针推翻:真实库 4822 个子文件中 28 个末行为 65KB-776KB 的完整 entry
|
|
168
|
+
* (subagent-identity 的 task 内嵌大 payload)——固定尾窗会把超长末行从中间切开
|
|
169
|
+
* 误判截断,孤儿恢复错落 error。 */
|
|
170
|
+
const WINDOW_GROWTH_FACTOR = 4;
|
|
171
|
+
/** 窗口内 ≥2 个非空段 = 末行之前存在换行边界(首段可能被窗口切开,末段完整到 EOF)。 */
|
|
172
|
+
const MIN_SEGMENTS_WITH_BOUNDARY = 2;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 读 JSONL 文件的最后一个非空行(孤儿终态判定用,residual-fixes §5.2)。
|
|
176
|
+
* 读文件尾部窗口起步;窗口内只有单个非空段且未到文件头时,该段可能是被窗口切开的
|
|
177
|
+
* 超长末行,按 WINDOW_GROWTH_FACTOR 扩窗直到看见末行前的换行边界或抵达文件头。
|
|
178
|
+
* 返回 ok=false 表示 IO 错误(open/read 阶段抛出,含权限/磁盘故障——可能是暂时
|
|
179
|
+
* 状态,调用方按保守方向处理)。
|
|
180
|
+
*/
|
|
181
|
+
function readLastJsonlLine(sessionFile: string): { ok: true; line: string } | { ok: false } {
|
|
182
|
+
let fd: number | undefined;
|
|
183
|
+
try {
|
|
184
|
+
fd = fs.openSync(sessionFile, "r");
|
|
185
|
+
const size = fs.fstatSync(fd).size;
|
|
186
|
+
let windowBytes = LAST_LINE_WINDOW_BYTES;
|
|
187
|
+
let windowStart = Math.max(0, size - windowBytes);
|
|
188
|
+
let lines: string[] = [];
|
|
189
|
+
while (true) {
|
|
190
|
+
const buf = Buffer.alloc(size - windowStart);
|
|
191
|
+
fs.readSync(fd, buf, 0, buf.length, windowStart);
|
|
192
|
+
lines = buf.toString("utf-8").split("\n").filter((l) => l.length > 0);
|
|
193
|
+
// 末行完整可提取 = 窗口内末行之前还有换行(≥2 个非空段)或已覆盖到文件头。
|
|
194
|
+
if (windowStart === 0 || lines.length >= MIN_SEGMENTS_WITH_BOUNDARY) break;
|
|
195
|
+
windowBytes *= WINDOW_GROWTH_FACTOR;
|
|
196
|
+
const nextStart = Math.max(0, size - windowBytes);
|
|
197
|
+
if (nextStart === windowStart) break;
|
|
198
|
+
windowStart = nextStart;
|
|
199
|
+
}
|
|
200
|
+
// 窗口仍可能从行中间开始(首段被切开)——丢掉第一段;到文件头则首段是完整首行。
|
|
201
|
+
const candidates = windowStart > 0 && lines.length > 0 ? lines.slice(1) : lines;
|
|
202
|
+
const last = candidates.length > 0 ? candidates[candidates.length - 1] : lines[lines.length - 1];
|
|
203
|
+
if (last === undefined) return { ok: true, line: "" }; // 空文件:视为不可判终态的截断形态由 parse 失败兜住
|
|
204
|
+
return { ok: true, line: last };
|
|
205
|
+
} catch {
|
|
206
|
+
return { ok: false };
|
|
207
|
+
} finally {
|
|
208
|
+
if (fd !== undefined) {
|
|
209
|
+
try {
|
|
210
|
+
fs.closeSync(fd);
|
|
211
|
+
} catch (_e) {
|
|
212
|
+
void _e; // 关闭失败不影响已读结果(对齐 finalized-marker best-effort 模式)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** unknown → subagent-record entry 的运行时守卫(taste/no-unsafe-cast:断言前收窄)。 */
|
|
219
|
+
function asSubagentRecordEntry(o: unknown): { id: string; data: Record<string, unknown> } | null {
|
|
220
|
+
if (typeof o !== "object" || o === null) return null;
|
|
221
|
+
const obj = o as Record<string, unknown>;
|
|
222
|
+
if (obj.type !== "custom" || obj.customType !== SUBAGENT_RECORD_CUSTOM_TYPE) return null;
|
|
223
|
+
if (typeof obj.data !== "object" || obj.data === null) return null;
|
|
224
|
+
const data = obj.data as Record<string, unknown>;
|
|
225
|
+
return typeof data.id === "string" ? { id: data.id, data } : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** recoverEntryOnlyOrphans 用的 entry 扫描:主 session 全文 → 每 id 末条 record data。 */
|
|
229
|
+
function collectLastRecordEntries(content: string): Map<string, Record<string, unknown>> {
|
|
230
|
+
const lastById = new Map<string, Record<string, unknown>>();
|
|
231
|
+
for (const line of content.split("\n")) {
|
|
232
|
+
if (!line.includes(SUBAGENT_RECORD_CUSTOM_TYPE)) continue; // 快过滤:绝大多数行不是本类型
|
|
233
|
+
let entry: { id: string; data: Record<string, unknown> } | null = null;
|
|
234
|
+
try {
|
|
235
|
+
entry = asSubagentRecordEntry(JSON.parse(line));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// 截断/异构行跳过(主文件末行可能正被写入)——行级 best-effort,debug 留痕
|
|
238
|
+
logger.debug("[subagents] entry-only orphan scan: skip unparsable line", {
|
|
239
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (entry !== null) lastById.set(entry.id, entry.data);
|
|
243
|
+
}
|
|
244
|
+
return lastById;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** entry data 即 SubagentRecord v1 快照——带运行时 guard 重建(taste/no-unsafe-cast)。
|
|
248
|
+
* 损坏 entry(agent/task/startedAt 任一缺失)返回 null,由调用方跳过。 */
|
|
249
|
+
function rebuildEntryRecord(id: string, d: Record<string, unknown>): SubagentRecord | null {
|
|
250
|
+
const str = (k: string): string | undefined => (typeof d[k] === "string" ? (d[k] as string) : undefined);
|
|
251
|
+
const num = (k: string): number | undefined => (typeof d[k] === "number" ? (d[k] as number) : undefined);
|
|
252
|
+
const agent = str("agent");
|
|
253
|
+
const task = str("task");
|
|
254
|
+
const startedAt = num("startedAt");
|
|
255
|
+
if (agent === undefined || task === undefined || startedAt === undefined) return null; // 损坏 entry:跳过
|
|
256
|
+
return {
|
|
257
|
+
id,
|
|
258
|
+
agent,
|
|
259
|
+
task,
|
|
260
|
+
slug: str("slug") ?? "",
|
|
261
|
+
status: "running",
|
|
262
|
+
mode: "background",
|
|
263
|
+
startedAt,
|
|
264
|
+
rootSessionId: str("rootSessionId"),
|
|
265
|
+
parentRecordId: str("parentRecordId"),
|
|
266
|
+
depth: num("depth") ?? 0,
|
|
267
|
+
endedAt: undefined,
|
|
268
|
+
turns: num("turns") ?? 0,
|
|
269
|
+
totalTokens: num("totalTokens") ?? 0,
|
|
270
|
+
model: str("model") ?? "",
|
|
271
|
+
thinkingLevel: str("thinkingLevel"),
|
|
272
|
+
eventLog: [],
|
|
273
|
+
displayItems: [],
|
|
274
|
+
sessionFile: undefined,
|
|
275
|
+
chatMode: d.chatMode === true,
|
|
276
|
+
round: num("round"),
|
|
277
|
+
engine: str("engine"),
|
|
278
|
+
engineFallback:
|
|
279
|
+
isEngineFallbackShape(d.engineFallback) ? d.engineFallback : undefined,
|
|
280
|
+
engineHandle: isEngineHandleShape(d.engineHandle) ? d.engineHandle : undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** engineFallback entry 值的运行时 guard(未知 JSON 不裸收)。 */
|
|
285
|
+
function isEngineFallbackShape(v: unknown): v is { from: string; reason: string } {
|
|
286
|
+
if (typeof v !== "object" || v === null) return false;
|
|
287
|
+
const r = v as Record<string, unknown>;
|
|
288
|
+
return typeof r.from === "string" && typeof r.reason === "string";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** engineHandle entry 值的运行时 guard(未知 JSON 不裸收;形状与 runtime 读侧
|
|
292
|
+
* subagent-engine-history 的 extractRecordEngineHandle 守卫语义对齐:poolKey
|
|
293
|
+
* 必有非空 string + sessionRef 值全 string 才收,journalPath 可选 string)。 */
|
|
294
|
+
function isEngineHandleShape(
|
|
295
|
+
v: unknown,
|
|
296
|
+
): v is { sessionRef: Record<string, string>; journalPath?: string; poolKey: string } {
|
|
297
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
298
|
+
const h = v as Record<string, unknown>;
|
|
299
|
+
if (typeof h.poolKey !== "string" || h.poolKey.length === 0) return false;
|
|
300
|
+
if (typeof h.sessionRef !== "object" || h.sessionRef === null || Array.isArray(h.sessionRef)) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
for (const value of Object.values(h.sessionRef as Record<string, unknown>)) {
|
|
304
|
+
if (typeof value !== "string") return false;
|
|
305
|
+
}
|
|
306
|
+
if (h.journalPath !== undefined && typeof h.journalPath !== "string") return false;
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** stat 戳(不存在 → null)。 */
|
|
311
|
+
function statStamp(p: string): Stamp | null {
|
|
312
|
+
try {
|
|
313
|
+
const s = fs.statSync(p);
|
|
314
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function sameStamp(a: Stamp, b: Stamp): boolean {
|
|
321
|
+
return a.mtimeMs === b.mtimeMs && a.size === b.size;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** ClosedReason 合法值集合(sidecar 内容校验用:外部损坏/手写垃圾内容 → disconnected)。
|
|
325
|
+
* SSOT = types.ts CLOSED_REASONS 全枚举,此处仅建 Set 索引(避免第二份字面量清单漂移)。 */
|
|
326
|
+
const CLOSED_REASONS: ReadonlySet<string> = new Set(CLOSED_REASON_LIST);
|
|
327
|
+
|
|
328
|
+
/** sidecar 内容是否为合法的 ClosedReason 字面量(disconnected 只作兜底产出,不接受写入)。 */
|
|
329
|
+
function isValidClosedReason(value: string | undefined): value is ClosedReason {
|
|
330
|
+
return value !== undefined && CLOSED_REASONS.has(value);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function sameNullableStamp(a: Stamp | null, b: Stamp | null): boolean {
|
|
334
|
+
if (a === null || b === null) return a === b;
|
|
335
|
+
return sameStamp(a, b);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Pi ExtensionAPI 的最小子集(仅 collectRecords 跳过损坏 manifest 时上报用)。
|
|
339
|
+
* 解构为局部类型,避免与 subagent-service 的 PiLike 循环依赖。 */
|
|
340
|
+
export type RecordStorePi = {
|
|
341
|
+
appendEntry?: (customType: string, data: unknown) => void;
|
|
342
|
+
} | null | undefined;
|
|
343
|
+
|
|
344
|
+
// ============================================================
|
|
345
|
+
// RecordStore
|
|
346
|
+
// ============================================================
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Record 容器。进程单例(随 SubagentService 重建)。
|
|
350
|
+
*
|
|
351
|
+
* 内存只留 running record——终态 record 在 archive 时立即移除,collectRecords
|
|
352
|
+
* 读时从 sessions/*.jsonl 重建([perf] light 头部扫描 + per-file 缓存)。
|
|
353
|
+
*
|
|
354
|
+
* 任何 mutate → notifyChange()(仅通知监听器;磁盘缓存靠 stat 戳自校验,不清空)。
|
|
355
|
+
*/
|
|
356
|
+
export class RecordStore {
|
|
357
|
+
private readonly records = new Map<string, ExecutionRecord>();
|
|
358
|
+
private readonly listeners = new Set<ChangeListener>();
|
|
359
|
+
private _disposed = false;
|
|
360
|
+
/** 孤儿终态恢复的已判定缓存(residual-fixes):resumable 形态无 sidecar 锚,同进程重复调用跳过。 */
|
|
361
|
+
private orphanJudged = new Set<string>();
|
|
362
|
+
/** Pi handle(用于 appendEntry 上报损坏 manifest)。构造时可空,setPi() 后续注入。
|
|
363
|
+
* 显式存为字段而非构造参数 readonly:setPi 需要写权限。 */
|
|
364
|
+
private pi: RecordStorePi = null;
|
|
365
|
+
|
|
366
|
+
/** [perf] per-file 缓存(key = sessionFile 绝对路径)。不再整体失效——stat 戳精准校验。
|
|
367
|
+
* 值含负缓存(确认无 identity 的文件),防每轮全文 fallback 重读。 */
|
|
368
|
+
private readonly fileCache = new Map<string, FileCacheValue>();
|
|
369
|
+
/** record id → sessionFile 索引(getFullRecord 按 id 定位文件)。随 fileCache 同步维护。 */
|
|
370
|
+
private readonly idToFile = new Map<string, string>();
|
|
371
|
+
/** [perf] sessionsDir 最近一次全量扫描的 mtime(快路径判变,见 reconstructAll)。
|
|
372
|
+
* null = 未扫过 / 已 dispose。 */
|
|
373
|
+
private dirStamp: { mtimeMs: number } | null = null;
|
|
374
|
+
|
|
375
|
+
/** [perf L-1] 首扫惰性装载的磁盘索引只读映像(key = jsonl basename)。
|
|
376
|
+
* 扫描尾(flushIndexAfterScan)与 readdir 失败路径释放——运行期索引不再被读(L1 接管)。 */
|
|
377
|
+
private indexEntries: Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> | null = null;
|
|
378
|
+
/** [perf L-1] 本轮起未落盘的探测标志:scanFile 走过探测分支即置位。发起写时消费
|
|
379
|
+
* (置 false)、写失败恢复;未写路径不清位——未落盘的探测成果跨轮携带直至真正写入。 */
|
|
380
|
+
private indexDirty = false;
|
|
381
|
+
/** [perf L-1] 上次成功落盘墙钟(节流基准)。0 = 从未写过 → 首扫 dirty 必写;
|
|
382
|
+
* 仅成功分支推进(写失败不推进节流窗,下轮过窗重试)。 */
|
|
383
|
+
private lastIndexWriteAt = 0;
|
|
384
|
+
/** [perf L-1] loadIndex 高版本标志的进程级持久态:true 时本进程所有后续扫描均不
|
|
385
|
+
* 落盘(防 v1/v2 last-writer-wins 覆盖振荡),直至下次 loadIndex 重新评估。 */
|
|
386
|
+
private indexHigherVersion = false;
|
|
387
|
+
|
|
388
|
+
constructor(
|
|
389
|
+
private readonly sessionsDir: string,
|
|
390
|
+
private readonly manifestStore?: ManifestStore,
|
|
391
|
+
/** Pi 入口(注入 appendEntry 用于上报损坏 manifest)。
|
|
392
|
+
* SubagentService 构造时 this.pi 尚未注入(session_start 之前),传 undefined 兜底;
|
|
393
|
+
* 后续通过 setPi() 注入(见下)。允许 null = 兼容 PiLike 字段类型。 */
|
|
394
|
+
pi?: RecordStorePi,
|
|
395
|
+
) {
|
|
396
|
+
this.pi = pi ?? null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** session_start 后由 SubagentService.initSession 调,注入真实 Pi handle。
|
|
400
|
+
* 设计为独立方法而非要求构造时必传——RecordStore 在 SubagentService 构造时即建
|
|
401
|
+
* (与 sessionsDir/manifestStore 一同初始化),但 this.pi 此时尚未注入。
|
|
402
|
+
* 后续构造期外的 appendEntry 上报才有意义。 */
|
|
403
|
+
setPi(pi: RecordStorePi): void {
|
|
404
|
+
this.pi = pi ?? null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 注册新 record。触发 onChange。
|
|
408
|
+
* W16 [D4]:record 诞生(→ running)即 append 自描述快照 entry——pi 文件是
|
|
409
|
+
* 扩展数据持久化权威,custom entry 不进 LLM context。 */
|
|
410
|
+
register(record: ExecutionRecord): void {
|
|
411
|
+
this.records.set(record.id, record);
|
|
412
|
+
this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
|
|
413
|
+
this.notifyChange();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 归档:record 已被 completeRecord 设置了终态 status。
|
|
418
|
+
* 立即从内存移除(终态 record 下次读时从 session.jsonl 重建)。
|
|
419
|
+
* cancelled record 由调用方先写 tombstone(cancel 路径),此处只负责移除。
|
|
420
|
+
*
|
|
421
|
+
* W16 [D4]:终态冻结字段(result/endedAt/closedReason)在 completeRecord 已就绪,
|
|
422
|
+
* 此处 append 的快照即完整终态记录(所有终态路径的必经锚点)。
|
|
423
|
+
*/
|
|
424
|
+
archive(record: ExecutionRecord): void {
|
|
425
|
+
this.records.delete(record.id);
|
|
426
|
+
this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
|
|
427
|
+
this.notifyChange();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* W16 [D4]:类外状态写点上报(record-store 内的迁移点 register/archive 已内置)。
|
|
432
|
+
*
|
|
433
|
+
* 供 service 层直接改 record.status 的恢复写点调用(chatMode 续轮 idle→running
|
|
434
|
+
* 冷路径 resumeRound、轮终 finalizeRoundToIdle 回 running-resumable)——这些
|
|
435
|
+
* 写点绕过 register/archive,若不显式上报,pi 文件缺失该次迁移、重建源滞后。
|
|
436
|
+
* pi 未注入(session_start 前)时可选链静默降级,不阻断主流程。
|
|
437
|
+
*/
|
|
438
|
+
reportRecordTransition(record: ExecutionRecord): void {
|
|
439
|
+
this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(RecordStore.recordToSubagent(record)));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** 按 id 查找。返回可变 record(仅 runtime 内部用)。 */
|
|
443
|
+
getMutable(id: string): ExecutionRecord | undefined {
|
|
444
|
+
return this.records.get(id);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* abort 所有 running record 的 controller(background 子进程 SIGTERM)。
|
|
449
|
+
*
|
|
450
|
+
* 仅在 SubagentService.dispose(进程退出路径)调用。不做 CAS/tombstone——dispose
|
|
451
|
+
* 是终局,状态机收尾无意义;目的是让 background 子进程的 AbortSignal 触发 →
|
|
452
|
+
* runSpawn 的 signal listener → child.kill("SIGTERM"),防止主进程退出后子进程成孤儿。
|
|
453
|
+
*
|
|
454
|
+
* sync record 无 controller(undefined),跳过——sync 是阻塞调用,主进程不会先于
|
|
455
|
+
* sync subagent 退出(除非 SIGKILL/崩溃,此时任何清理都无效)。
|
|
456
|
+
*
|
|
457
|
+
* 返回被 abort 的 record 数(诊断用)。
|
|
458
|
+
*/
|
|
459
|
+
abortRunningControllers(): number {
|
|
460
|
+
let n = 0;
|
|
461
|
+
for (const r of this.records.values()) {
|
|
462
|
+
if (r.status === "running" && r.controller) {
|
|
463
|
+
r.controller.abort();
|
|
464
|
+
n++;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return n;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 列出所有 running record 的只读快照(widget 计数、诊断用)。 */
|
|
471
|
+
listRunning(): RecordSnapshot[] {
|
|
472
|
+
return [...this.records.values()]
|
|
473
|
+
.filter((r) => r.status === "running")
|
|
474
|
+
.map((r) => toSnapshot(r));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** SP-4: 列出所有活跃 record(running + idle)的可变引用。
|
|
478
|
+
* 供 SubagentService.disposeAllRecords 做级联关闭。 */
|
|
479
|
+
listAllActive(): ExecutionRecord[] {
|
|
480
|
+
return [...this.records.values()]
|
|
481
|
+
.filter((r) => r.status === "running");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 合并内存(running) + 磁盘(sessions/*.jsonl 重建) → SubagentRecord[]。
|
|
486
|
+
*
|
|
487
|
+
* ╔══════════════════════════════════════════════════════════════════╗
|
|
488
|
+
* ║ 1. 磁盘源:扫 sessionsDir 的 .jsonl,逐个 scanFile([perf] 头部 ║
|
|
489
|
+
* ║ identity 轻量重建 + stat 戳缓存命中零读取)。cancelled ║
|
|
490
|
+
* ║ tombstone override status。详情字段(eventLog/result/turns) ║
|
|
491
|
+
* ║ 缺省,由 getFullRecord(id) 懒加载 ║
|
|
492
|
+
* ║ 2. 内存源覆盖(同 id 内存优先——running record 更新鲜) ║
|
|
493
|
+
* ║ 3. session 过滤:只留 rootSessionId === rootSessionFilter 的 ║
|
|
494
|
+
* ║ record。rootSessionId 缺失(旧文件)的 record 一律排除 ║
|
|
495
|
+
* ║ (无法判定归属,隔离优先)。rootSessionFilter 为 undefined ║
|
|
496
|
+
* ║ 时不过滤(向后兼容)。 ║
|
|
497
|
+
* ║ 4. statusFilter:"running" → 只留 running(内存源); ║
|
|
498
|
+
* ║ "all"(默认)→ 内存 + 磁盘 ║
|
|
499
|
+
* ║ 5. 排序:STATUS_PRIORITY + startedAt desc ║
|
|
500
|
+
* ║ 6. slice(limit) ║
|
|
501
|
+
* ╚══════════════════════════════════════════════════════════════════╝
|
|
502
|
+
*
|
|
503
|
+
* statusFilter="running" 时仍先取够多再过滤(防 limit 截断把 running 滤没),
|
|
504
|
+
* 与旧 listHandler 的防截断逻辑一致,下沉到此。
|
|
505
|
+
*
|
|
506
|
+
* session 隔离:同一 cwd 下多个 Pi session 共享 sessionsDir,靠 rootSessionId
|
|
507
|
+
* 区分。内存与磁盘源都按 rootSessionFilter 过滤后再 merge/sort/slice。
|
|
508
|
+
*/
|
|
509
|
+
collectRecords(
|
|
510
|
+
limit: number,
|
|
511
|
+
statusFilter: StatusFilter = "all",
|
|
512
|
+
rootSessionFilter?: string,
|
|
513
|
+
): SubagentRecord[] {
|
|
514
|
+
const byId = new Map<string, SubagentRecord>();
|
|
515
|
+
|
|
516
|
+
// 1. 磁盘源(重建终态 record)。 reconstructAll 已按 rootSessionFilter 过滤。
|
|
517
|
+
for (const rec of this.reconstructAll(rootSessionFilter)) {
|
|
518
|
+
byId.set(rec.id, rec);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 1.5 FR-8: manifest 源补充 orphan 记录。
|
|
522
|
+
// 优先级:内存 > 磁盘重建 > manifest。manifest 仅补充 session.jsonl 重建失败的记录。
|
|
523
|
+
if (this.manifestStore) {
|
|
524
|
+
for (const manifest of this.readManifestsSync()) {
|
|
525
|
+
if (byId.has(manifest.id)) continue; // 已被磁盘/内存源覆盖
|
|
526
|
+
if (rootSessionFilter !== undefined && manifest.rootSessionId !== rootSessionFilter) continue;
|
|
527
|
+
const rec = RecordStore.manifestToSubagent(manifest);
|
|
528
|
+
if (!rec) {
|
|
529
|
+
// manifest status 越界=数据损坏(含历史 "error"、意外 crashed 值):跳过而非降级 failed,
|
|
530
|
+
// 避免损坏 record 被误显示为 failed(触发错误重试/告警)。
|
|
531
|
+
// 双通道上报:logger.warn 给开发者(事后排查,appendEntry 持久化,不显 TUI);
|
|
532
|
+
// pi.appendEntry 给用户(session 内可见,即使退出后也能从 session.jsonl 复盘事故原因)。
|
|
533
|
+
// SubagentService 构造时 pi 未注入(session_start 之前),appendEntry 走可选链安全降级。
|
|
534
|
+
logger.warn("[subagents] skip manifest with invalid status", {
|
|
535
|
+
detail: { id: manifest.id, status: manifest.status },
|
|
536
|
+
});
|
|
537
|
+
this.pi?.appendEntry?.("subagent:manifest-invalid-status", {
|
|
538
|
+
id: manifest.id,
|
|
539
|
+
status: manifest.status,
|
|
540
|
+
rootSessionId: manifest.rootSessionId,
|
|
541
|
+
agentName: manifest.agentName,
|
|
542
|
+
});
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
byId.set(rec.id, rec);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 2. 内存源覆盖(running record 优先——它是活态,比磁盘重建更新鲜)。同样按 session 过滤。
|
|
550
|
+
for (const r of this.records.values()) {
|
|
551
|
+
if (rootSessionFilter !== undefined && r.rootSessionId !== rootSessionFilter) continue;
|
|
552
|
+
byId.set(r.id, RecordStore.recordToSubagent(r));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 3. statusFilter。
|
|
556
|
+
let result = [...byId.values()];
|
|
557
|
+
if (statusFilter === "running") {
|
|
558
|
+
result = result.filter((r) => r.status === "running");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 4-5. 排序 + slice。
|
|
562
|
+
return result
|
|
563
|
+
.sort(RecordStore.compareRecords)
|
|
564
|
+
.slice(0, limit);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── 孤儿终态恢复(residual-fixes 设计 §6.1.2)──────────────────
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* 重建 SubagentRecord 的自描述 entry 落盘入口(签名适配:reportRecordTransition 收
|
|
571
|
+
* ExecutionRecord,重建孤儿的数据源是 SubagentRecord——直接经 toSubagentRecordEntry
|
|
572
|
+
* 投影 appendEntry,绕过 recordToSubagent)。pi 未注入时可选链静默。
|
|
573
|
+
*/
|
|
574
|
+
reportSubagentRecord(record: SubagentRecord): void {
|
|
575
|
+
this.pi?.appendEntry?.("subagent-record", toSubagentRecordEntry(record));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* 孤儿终态恢复:对重建矩阵分支 4 兜底(running 且无 externalInstance)的 record
|
|
580
|
+
* 判定真实终态并落 entry,消除「父扩展死后再无人写终态 → 侧栏永久 running」。
|
|
581
|
+
*
|
|
582
|
+
* 判定(residual-fixes §5.2 三判据 + chat 分流):
|
|
583
|
+
* - chatMode = true → 不终态化(跨重启可续聊是产品语义,v4 B-1),落 resumable
|
|
584
|
+
* entry 供侧栏 waiting 细分;
|
|
585
|
+
* - 子 JSONL 末行完整 JSON.parse → closed(closedReason=gc,与分支 2 重建映射一致;
|
|
586
|
+
* done/failed 细分由 error 字段经 deriveClosedDisplay 派生)+ 写 .finalized sidecar
|
|
587
|
+
* (防重锚——下次重建走分支 2 不再进判定);
|
|
588
|
+
* - 末行截断 → closed + error(保守,错误方向安全)+ sidecar;
|
|
589
|
+
* - 文件不可读(IO 错误,可能暂时)→ 不判终态,落 resumable entry(防御性路径,
|
|
590
|
+
* IO 恢复后重开可重判)。
|
|
591
|
+
*
|
|
592
|
+
* 防重:orphanJudged 实例级缓存(resumable 形态无 sidecar 锚,同进程重复调用跳过;
|
|
593
|
+
* 终态形态双重防护 = sidecar + 缓存)。调用方:index.ts session_start 恢复段(一次)。
|
|
594
|
+
*/
|
|
595
|
+
recoverOrphanRecords(rootSessionFilter?: string): void {
|
|
596
|
+
for (const rec of this.reconstructAll(rootSessionFilter)) {
|
|
597
|
+
// 分支 4 命中集 = running 且无活进程实例(分支 3 带 externalInstance,分支 1/2 已 closed)。
|
|
598
|
+
if (rec.status !== "running" || rec.externalInstance !== undefined) continue;
|
|
599
|
+
if (this.orphanJudged.has(rec.id)) continue;
|
|
600
|
+
this.orphanJudged.add(rec.id);
|
|
601
|
+
this.finalizeOrphanRecord(rec);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 单孤儿 record 的终态判定与落 entry(residual-fixes §5.2 三判据 + chat 分流)。
|
|
607
|
+
* 防重锚(orphanJudged 标记)已由调用方完成。
|
|
608
|
+
*/
|
|
609
|
+
private finalizeOrphanRecord(rec: SubagentRecord): void {
|
|
610
|
+
if (rec.chatMode === true) {
|
|
611
|
+
// chat 会话跨重启等续聊:保留 running(可续聊),仅落执行态信号。
|
|
612
|
+
this.reportSubagentRecord({ ...rec, resumable: true });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const sessionFile = rec.sessionFile;
|
|
616
|
+
if (sessionFile === undefined) return; // 无子文件锚(目录扫描源不可达,防御)
|
|
617
|
+
const lastLine = readLastJsonlLine(sessionFile);
|
|
618
|
+
if (!lastLine.ok) {
|
|
619
|
+
// IO 错误可能暂时——判终态不可逆,保守落 resumable 等重开重判。
|
|
620
|
+
this.reportSubagentRecord({ ...rec, resumable: true });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
let parseOk = false;
|
|
624
|
+
try {
|
|
625
|
+
JSON.parse(lastLine.line);
|
|
626
|
+
parseOk = true;
|
|
627
|
+
} catch {
|
|
628
|
+
parseOk = false;
|
|
629
|
+
}
|
|
630
|
+
// .finalized sidecar:防重锚(同 doFinalizeRecord 终态路径的收尾标记)。
|
|
631
|
+
// 显式携 reason="gc":孤儿判定「末行完整 = 自然完成」,与 doFinalizeRecord 写入的
|
|
632
|
+
// 真实 reason 同层——否则无 reason sidecar 在磁盘重建时被兜底为 disconnected,
|
|
633
|
+
// 把正常完成的记录误标成断联。
|
|
634
|
+
writeFinalized(sessionFile, "gc");
|
|
635
|
+
this.reportSubagentRecord({
|
|
636
|
+
...rec,
|
|
637
|
+
status: "closed",
|
|
638
|
+
closedReason: "gc",
|
|
639
|
+
endedAt: Date.now(),
|
|
640
|
+
...(parseOk ? {} : { error: "orphan recovery: subagent session ended abnormally (truncated last line)" }),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* [E2E 实测缺口] entry-born 孤儿恢复:register entry 已落主 session、但子 session 文件
|
|
646
|
+
* 从未创建(父进程死在 spawn 窗口期——register 写点与子进程首笔写入之间的窗口;外部
|
|
647
|
+
* 删除子文件的已知边界同形)。目录扫描(reconstructAll)看不见这类 record(无文件即
|
|
648
|
+
* 无扫描集),recoverOrphanRecords 判不到,侧栏(runtime entry 扫描源)永久 spinner。
|
|
649
|
+
*
|
|
650
|
+
* 判定:读主 session 的 subagent-record entry,取每 id 末条;末条 status=running 且
|
|
651
|
+
* 无子文件锚(不在 reconstructAll 结果中)且不在内存活 record(防误杀刚 register 的
|
|
652
|
+
* 在途 spawn)→ 按无文件判据收敛:chatMode=true → resumable(分流语义一致);否则
|
|
653
|
+
* closed+gc+error(子文件由子进程创建,无文件 = 子进程从未开跑,error 方向安全)。
|
|
654
|
+
* 调用点:initSession 的 recoverOrphanRecords 之后(session_start,内存恒空)。
|
|
655
|
+
*/
|
|
656
|
+
recoverEntryOnlyOrphans(mainSessionFile: string | undefined, rootSessionFilter?: string): void {
|
|
657
|
+
if (mainSessionFile === undefined) return;
|
|
658
|
+
let content: string;
|
|
659
|
+
try {
|
|
660
|
+
content = fs.readFileSync(mainSessionFile, "utf-8");
|
|
661
|
+
} catch {
|
|
662
|
+
return; // 主文件不可读(含新 session 未 flush 的 ENOENT):静默跳过(best-effort 恢复)
|
|
663
|
+
}
|
|
664
|
+
const lastById = collectLastRecordEntries(content);
|
|
665
|
+
if (lastById.size === 0) return;
|
|
666
|
+
const anchoredIds = new Set(this.reconstructAll(rootSessionFilter).map((r) => r.id));
|
|
667
|
+
for (const [id, d] of lastById) {
|
|
668
|
+
if (!this.isEntryOrphanCandidate(id, d, rootSessionFilter, anchoredIds)) continue;
|
|
669
|
+
this.orphanJudged.add(id);
|
|
670
|
+
const rec = rebuildEntryRecord(id, d);
|
|
671
|
+
if (rec === null) continue; // 损坏 entry:跳过(orphanJudged 已标记,不重判)
|
|
672
|
+
this.finalizeEntryOnlyOrphan(rec, d.chatMode === true);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* entry-born 孤儿候选判定(recoverEntryOnlyOrphans 的守卫链拆出):末条 running、
|
|
678
|
+
* root session 匹配、无子文件锚、不在内存活 record(防误杀在途 spawn)、未判过。
|
|
679
|
+
*/
|
|
680
|
+
private isEntryOrphanCandidate(
|
|
681
|
+
id: string,
|
|
682
|
+
d: Record<string, unknown>,
|
|
683
|
+
rootSessionFilter: string | undefined,
|
|
684
|
+
anchoredIds: Set<string>,
|
|
685
|
+
): boolean {
|
|
686
|
+
if (d.status !== "running") return false; // 末条已终态/轮终:entry 自洽,无需恢复
|
|
687
|
+
if (rootSessionFilter !== undefined && d.rootSessionId !== rootSessionFilter) return false;
|
|
688
|
+
if (anchoredIds.has(id)) return false; // 有子文件锚:主循环已判(或 sidecar 已终态)
|
|
689
|
+
if (this.records.has(id)) return false; // 内存活 record:在途 spawn,不得误杀
|
|
690
|
+
return !this.orphanJudged.has(id);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** entry-born 孤儿按无文件判据收敛落 entry:chatMode → resumable(分流语义一致);
|
|
694
|
+
* 否则 closed+gc+error(子文件由子进程创建,无文件 = 子进程从未开跑,error 方向安全)。 */
|
|
695
|
+
private finalizeEntryOnlyOrphan(rec: SubagentRecord, chatMode: boolean): void {
|
|
696
|
+
this.reportSubagentRecord({
|
|
697
|
+
...rec,
|
|
698
|
+
...(chatMode
|
|
699
|
+
? { resumable: true }
|
|
700
|
+
: {
|
|
701
|
+
status: "closed" as const,
|
|
702
|
+
closedReason: "gc" as const,
|
|
703
|
+
endedAt: Date.now(),
|
|
704
|
+
error: "orphan recovery: no child session file (spawn interrupted or file removed externally)",
|
|
705
|
+
}),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** 订阅变更。返回取消订阅函数。 */
|
|
710
|
+
onChange(listener: ChangeListener): () => void {
|
|
711
|
+
this.listeners.add(listener);
|
|
712
|
+
return () => {
|
|
713
|
+
this.listeners.delete(listener);
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。
|
|
718
|
+
* [perf] 不清空磁盘缓存:per-file stat 戳自校验(任何磁盘写入改变戳 → 单文件重建),
|
|
719
|
+
* 内存事件(register/archive)不改变磁盘文件——旧实现整体失效是全量重扫的根因。 */
|
|
720
|
+
notifyChange(): void {
|
|
721
|
+
if (this._disposed) return;
|
|
722
|
+
for (const listener of this.listeners) {
|
|
723
|
+
listener();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** session 结束清理。 */
|
|
728
|
+
dispose(): void {
|
|
729
|
+
this._disposed = true;
|
|
730
|
+
this.listeners.clear();
|
|
731
|
+
this.fileCache.clear();
|
|
732
|
+
this.idToFile.clear();
|
|
733
|
+
this.dirStamp = null;
|
|
734
|
+
this.orphanJudged.clear();
|
|
735
|
+
// [perf L-1] 索引状态重置为初始值(「清内存」语义完备)。不取消挂起的 fire-and-forget
|
|
736
|
+
// 写——in-flight 写的丢失显式接受(revive 后 dirStamp===null 重扫会重新装载/重写)。
|
|
737
|
+
this.indexEntries = null;
|
|
738
|
+
this.indexDirty = false;
|
|
739
|
+
this.lastIndexWriteAt = 0;
|
|
740
|
+
this.indexHigherVersion = false;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** /resume /fork /new 后复活(dispose 的逆操作)。 */
|
|
744
|
+
revive(): void {
|
|
745
|
+
this._disposed = false;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ── 内部 ──────────────────────────────────────────────────
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* 四分支 sidecar 矩阵重建([perf] light 版)。
|
|
752
|
+
*
|
|
753
|
+
* 优先级:
|
|
754
|
+
* 1. .cancelled → closed(closedReason=cancelled)
|
|
755
|
+
* 2. .finalized → closed(closedReason=sidecar 内容 reason;空/旧格式 → disconnected)
|
|
756
|
+
* 3. .alive + pid 存活 + 未超软超时 → running, externalInstance=true
|
|
757
|
+
* 4. 兜底(无 marker、pid 死、超时)→ running(v4 B-1 可续聊语义)
|
|
758
|
+
*
|
|
759
|
+
* [perf]:逐文件 scanFile(stat 戳校验 + 头部 identity 轻量重建)。命中缓存的
|
|
760
|
+
* 文件零文件读取;变化的文件只重建自身,其余 N-1 个复用缓存。
|
|
761
|
+
*
|
|
762
|
+
* session 隔离:rootSessionFilter 非空时,只保留 rootSessionId 匹配的 record。
|
|
763
|
+
* rootSessionId 缺失(旧文件,未带身份字段)一律排除(无法判定归属)。
|
|
764
|
+
*/
|
|
765
|
+
private reconstructAll(rootSessionFilter?: string): SubagentRecord[] {
|
|
766
|
+
// [perf] 目录 mtime 快路径:sessionsDir mtime 未变 ⇒ 文件集合与 sidecar 集合都未变
|
|
767
|
+
//(任何文件新建/删除/重命名都改目录 mtime),且 jsonl append 不影响 light 态
|
|
768
|
+
//(identity/status 由首行与 sidecar 决定,进度重数据走 getFullRecord 的独立 stat
|
|
769
|
+
//校验)→ 跳过 readdir + N×4 statSync,直接复用缓存 light。/subagents overlay 打开
|
|
770
|
+
//期间 250ms 动画 timer + 120ms debounce 双驱动高频扫描,快路径把 ~N×4 stat 降到
|
|
771
|
+
// 1 次(目录本身)+ 少量 pid 探活(refreshAlive,内存无 IO)。
|
|
772
|
+
// 已知局限(与 mtime 缓存同族):目录 mtime 粒度粗糙的文件系统(NFS/2s FAT)
|
|
773
|
+
// 可能漏判——APFS 微秒级可靠。invalidate 语义由文件写入侧保证,但**仅限 sidecar
|
|
774
|
+
// 新建/删除/重命名**(这些操作必改目录 mtime);覆盖写已存在的 sidecar 不改目录
|
|
775
|
+
// mtime——`.alive` 覆盖写(resume spawn 后 pid 变化重写 marker)后快路径会复用旧
|
|
776
|
+
// aliveData(旧 pid/旧 startedAt),refreshAlive 探活与 1h 软超时判定可能滞后一拍
|
|
777
|
+
//(status 判定不受影响:分支 3 探活失败只清 externalInstance,不改 status)。
|
|
778
|
+
let dirMtimeMs: number;
|
|
779
|
+
try {
|
|
780
|
+
dirMtimeMs = fs.statSync(this.sessionsDir).mtimeMs;
|
|
781
|
+
} catch {
|
|
782
|
+
return [];
|
|
783
|
+
}
|
|
784
|
+
// [perf L-1] 首扫(dirStamp===null)惰性装载磁盘索引。必须位于 statSync 之后:
|
|
785
|
+
// sessionsDir 不存在的 early-return 不装载映像(防解析产物滞留内存);首扫时
|
|
786
|
+
// 下方快路径条件必不成立,插在快路径 if 前后等价。
|
|
787
|
+
if (this.dirStamp === null) {
|
|
788
|
+
const loaded = loadIndex(path.dirname(this.sessionsDir));
|
|
789
|
+
this.indexEntries = loaded.entries;
|
|
790
|
+
this.indexHigherVersion = loaded.higherVersion;
|
|
791
|
+
}
|
|
792
|
+
if (this.dirStamp !== null && this.dirStamp.mtimeMs === dirMtimeMs) {
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
const out: SubagentRecord[] = [];
|
|
795
|
+
for (const entry of this.fileCache.values()) {
|
|
796
|
+
if (entry.negative) continue;
|
|
797
|
+
RecordStore.refreshAlive(entry, now);
|
|
798
|
+
out.push(entry.light);
|
|
799
|
+
}
|
|
800
|
+
return rootSessionFilter === undefined
|
|
801
|
+
? out
|
|
802
|
+
: out.filter((r) => r.rootSessionId === rootSessionFilter);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
let files: string[];
|
|
806
|
+
try {
|
|
807
|
+
files = fs.readdirSync(this.sessionsDir)
|
|
808
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
809
|
+
.map((f) => path.join(this.sessionsDir, f));
|
|
810
|
+
} catch {
|
|
811
|
+
this.indexEntries = null; // [perf L-1] 该 early-return 路径同样释放映像(内存卫生)
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// 修剪:磁盘上已消失的文件(GC/手动删)同步移出缓存与索引。删了条目必须置
|
|
816
|
+
// indexDirty——否则纯修剪轮(无其他探测)flush 第一道门 !dirty return,磁盘索引的
|
|
817
|
+
// 陈旧条目永不清除(fileCache 已删仅影响内存投影,落盘快照不会自发跟随)。
|
|
818
|
+
const disk = new Set(files);
|
|
819
|
+
for (const [file, entry] of this.fileCache) {
|
|
820
|
+
if (!disk.has(file)) {
|
|
821
|
+
this.fileCache.delete(file);
|
|
822
|
+
if (!entry.negative) this.idToFile.delete(entry.light.id);
|
|
823
|
+
this.indexDirty = true;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
const out: SubagentRecord[] = [];
|
|
829
|
+
for (const file of files) {
|
|
830
|
+
const entry = this.scanFile(file, now);
|
|
831
|
+
if (entry) out.push(entry.light);
|
|
832
|
+
}
|
|
833
|
+
this.dirStamp = { mtimeMs: dirMtimeMs };
|
|
834
|
+
this.flushIndexAfterScan();
|
|
835
|
+
if (rootSessionFilter === undefined) return out;
|
|
836
|
+
return out.filter((r) => r.rootSessionId === rootSessionFilter);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* 扫描单文件:stat 戳(jsonl + 3 sidecar)校验,全同 → 复用缓存(零文件读取,
|
|
841
|
+
* 含负缓存直接返回 null);否则重建 light。
|
|
842
|
+
* identity 定位两级:头部 64KB(首轮会话)→ 全文 fallback(续聊场景 identity
|
|
843
|
+
* append 在尾部);两级都找不到 → 写负缓存(防每轮全文重读)。
|
|
844
|
+
* 返回 null:文件消失/读失败/无 identity → 跳过。
|
|
845
|
+
*/
|
|
846
|
+
private scanFile(file: string, now: number): FileCacheEntry | null {
|
|
847
|
+
const jsonl = statStamp(file);
|
|
848
|
+
if (!jsonl) {
|
|
849
|
+
this.fileCache.delete(file);
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
const cancelled = statStamp(`${file}.cancelled`);
|
|
853
|
+
const finalized = statStamp(`${file}.finalized`);
|
|
854
|
+
const alive = statStamp(`${file}.alive`);
|
|
855
|
+
|
|
856
|
+
const cached = this.fileCache.get(file);
|
|
857
|
+
if (
|
|
858
|
+
cached !== undefined &&
|
|
859
|
+
sameStamp(cached.jsonl, jsonl) &&
|
|
860
|
+
sameNullableStamp(cached.cancelled, cancelled) &&
|
|
861
|
+
sameNullableStamp(cached.finalized, finalized) &&
|
|
862
|
+
sameNullableStamp(cached.alive, alive)
|
|
863
|
+
) {
|
|
864
|
+
if (cached.negative) return null; // 负缓存命中:确认无 identity,零读取跳过
|
|
865
|
+
// pid 探活结果不落盘(进程死亡无 IO)——分支 3 的 running 项每扫重查,
|
|
866
|
+
// 保留原语义(旧实现每次 collectRecords 都重新 isProcessAlive)。
|
|
867
|
+
RecordStore.refreshAlive(cached, now);
|
|
868
|
+
return cached;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// [perf L-1] 磁盘索引查询(首扫惰性装载,miss/空索引时 get 恒 undefined = 无索引)。
|
|
872
|
+
// 条目戳匹配 jsonl 当前 stat → 零内容读取构造缓存条目。sidecar payload(tombstone/
|
|
873
|
+
// alive)是活态数据,沿用探测分支的每轮重读语义;finalized reason 静态数据仅在
|
|
874
|
+
// sidecar 存在时读一次(文件小,成本可忽略)。
|
|
875
|
+
if (this.indexEntries !== null) {
|
|
876
|
+
const hit = this.indexEntries.get(path.basename(file));
|
|
877
|
+
if (hit !== undefined && hit.mtimeMs === jsonl.mtimeMs && hit.size === jsonl.size) {
|
|
878
|
+
if (hit.negative === true) {
|
|
879
|
+
// 负条目命中:「确认无 identity」跨实例持久,零探测跳过(与下方负缓存同款形态)。
|
|
880
|
+
this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
|
|
884
|
+
const aliveData = alive !== null ? readAliveMarker(file) : undefined;
|
|
885
|
+
const finalReason = finalized !== null ? readFinalizedReason(file) : undefined;
|
|
886
|
+
const entry: FileCacheEntry = {
|
|
887
|
+
light: RecordStore.buildRecord(
|
|
888
|
+
{ ...hit, forkDepth: undefined, sessionFile: file },
|
|
889
|
+
{ tomb, finalized: finalized !== null, finalizedReason: finalReason, alive: aliveData, jsonlMtimeMs: jsonl.mtimeMs, now },
|
|
890
|
+
),
|
|
891
|
+
full: undefined,
|
|
892
|
+
jsonl,
|
|
893
|
+
cancelled,
|
|
894
|
+
finalized,
|
|
895
|
+
alive,
|
|
896
|
+
tomb,
|
|
897
|
+
aliveData,
|
|
898
|
+
finalReason,
|
|
899
|
+
};
|
|
900
|
+
this.fileCache.set(file, entry);
|
|
901
|
+
this.idToFile.set(hit.id, file);
|
|
902
|
+
return entry;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// [perf L-1] 索引 miss/戳不匹配落到原三级探测:本轮探测结果必须进索引(含负探测)。
|
|
907
|
+
// 覆盖两种形态:首扫(映像已装载但 miss/不匹配)与后续轮次(映像已释放,凡进重建分支必是戳变化)。
|
|
908
|
+
this.indexDirty = true;
|
|
909
|
+
|
|
910
|
+
// 重建:identity 三级定位——头部 64KB(首轮会话,~34%)→ 尾部 64KB(续聊场景
|
|
911
|
+
// 最后一轮 session_start 追加,~65%)→ 全文 fallback(~0.2%)。均不解析 message entries。
|
|
912
|
+
// size ≤ 头部读取上限时 head 读到的即全文,tail/anywhere 只会重复读同一份内容——
|
|
913
|
+
// 直接判负(head miss = 全文无 identity),省去同内容两连读。
|
|
914
|
+
const header =
|
|
915
|
+
jsonl.size <= IDENTITY_HEAD_BYTES
|
|
916
|
+
? readIdentityHeader(file)
|
|
917
|
+
: readIdentityHeader(file) ?? readIdentityTail(file) ?? readIdentityAnywhere(file);
|
|
918
|
+
if (!header) {
|
|
919
|
+
// 负缓存:确认无 identity。后续扫描 stat 命中直接跳过;戳变化(文件补写)自动重试。
|
|
920
|
+
this.fileCache.set(file, { negative: true, jsonl, cancelled, finalized, alive });
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
const tomb = cancelled !== null ? readCancelledTombstone(file) : undefined;
|
|
924
|
+
const aliveData = alive !== null ? readAliveMarker(file) : undefined;
|
|
925
|
+
const finalReason = finalized !== null ? readFinalizedReason(file) : undefined;
|
|
926
|
+
const entry: FileCacheEntry = {
|
|
927
|
+
light: RecordStore.buildRecord(header, {
|
|
928
|
+
tomb,
|
|
929
|
+
finalized: finalized !== null,
|
|
930
|
+
finalizedReason: finalReason,
|
|
931
|
+
alive: aliveData,
|
|
932
|
+
jsonlMtimeMs: jsonl.mtimeMs,
|
|
933
|
+
now,
|
|
934
|
+
}),
|
|
935
|
+
full: undefined,
|
|
936
|
+
jsonl,
|
|
937
|
+
cancelled,
|
|
938
|
+
finalized,
|
|
939
|
+
alive,
|
|
940
|
+
tomb,
|
|
941
|
+
aliveData,
|
|
942
|
+
finalReason,
|
|
943
|
+
};
|
|
944
|
+
this.fileCache.set(file, entry);
|
|
945
|
+
this.idToFile.set(header.id, file);
|
|
946
|
+
return entry;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* [perf L-1] 扫描尾索引落盘(节流):释放映像 → dirty/高版本/60s 节流窗三重门 →
|
|
951
|
+
* fire-and-forget saveIndex(fileCache 全量投影)。写决策与发起在同步栈(collectRecords
|
|
952
|
+
* 返回后不会再有本轮写);仅写完成的回调(推进节流窗)是异步的。所有 return 路径均
|
|
953
|
+
* 不清 dirty——未落盘的探测成果跨轮携带,直至真正写入。
|
|
954
|
+
*
|
|
955
|
+
* 并发安全:节流基准只在写成功后推进,W1 在途时新一轮过窗扫描可再 dispatch W2(不做
|
|
956
|
+
* 进程内排队——fire-and-forget 语义保持)。安全性由 saveIndex 的 tmp 唯一性
|
|
957
|
+
* (pid+单调序号)保证:交错 rename 的终态必为某一次的完整快照(last-writer-wins,
|
|
958
|
+
* 陈旧快照胜出时下轮戳不匹配自愈),不依赖本方法串行化。
|
|
959
|
+
*/
|
|
960
|
+
private flushIndexAfterScan(): void {
|
|
961
|
+
this.indexEntries = null; // 释放映像:运行期索引不再被读(L1 接管)
|
|
962
|
+
if (!this.indexDirty) return; // 纯命中轮零探测,不写
|
|
963
|
+
if (this.indexHigherVersion) return; // 磁盘是更高版本:只忽略不重写(防 v1/v2 互相覆盖)
|
|
964
|
+
if (Date.now() - this.lastIndexWriteAt < INDEX_WRITE_MIN_INTERVAL_MS) return; // 60s 节流窗内
|
|
965
|
+
const entries = this.projectIndexEntries();
|
|
966
|
+
this.indexDirty = false; // 发起时消费(写失败在 .catch 恢复)
|
|
967
|
+
const encDir = path.dirname(this.sessionsDir);
|
|
968
|
+
saveIndex(encDir, { entries })
|
|
969
|
+
.then(() => {
|
|
970
|
+
this.lastIndexWriteAt = Date.now(); // 仅成功分支推进节流窗
|
|
971
|
+
})
|
|
972
|
+
.catch((err: unknown) => {
|
|
973
|
+
this.indexDirty = true; // 失败恢复 dirty,下轮过窗重试
|
|
974
|
+
logger.debug("[subagents] sessions-index write failed", {
|
|
975
|
+
detail: { dir: encDir, error: err instanceof Error ? err.message : String(err) },
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* [perf L-1] fileCache 全量投影 → 索引快照(basename → 正/负条目)。
|
|
982
|
+
* 投影式单一 SSOT:不维护第二份可变索引映像(防双轨漂移);fileCache 已被
|
|
983
|
+
* reconstructAll 修剪掉消失文件(修剪时置 indexDirty),下次过窗写时快照清除
|
|
984
|
+
* 磁盘上的陈旧条目。
|
|
985
|
+
*/
|
|
986
|
+
private projectIndexEntries(): Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry> {
|
|
987
|
+
const entries = new Map<string, SessionsIndexEntry | SessionsIndexNegativeEntry>();
|
|
988
|
+
for (const [file, cached] of this.fileCache) {
|
|
989
|
+
const base = path.basename(file);
|
|
990
|
+
if (cached.negative) {
|
|
991
|
+
entries.set(base, { negative: true, mtimeMs: cached.jsonl.mtimeMs, size: cached.jsonl.size });
|
|
992
|
+
} else {
|
|
993
|
+
entries.set(base, {
|
|
994
|
+
mtimeMs: cached.jsonl.mtimeMs,
|
|
995
|
+
size: cached.jsonl.size,
|
|
996
|
+
id: cached.light.id,
|
|
997
|
+
agent: cached.light.agent,
|
|
998
|
+
mode: cached.light.mode,
|
|
999
|
+
task: cached.light.task,
|
|
1000
|
+
slug: cached.light.slug,
|
|
1001
|
+
startedAt: cached.light.startedAt,
|
|
1002
|
+
rootSessionId: cached.light.rootSessionId,
|
|
1003
|
+
parentRecordId: cached.light.parentRecordId,
|
|
1004
|
+
depth: cached.light.depth,
|
|
1005
|
+
model: cached.light.model,
|
|
1006
|
+
thinkingLevel: cached.light.thinkingLevel,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return entries;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* [perf] byId 索引直查 light record(单文件 stat 校验,不触发 getFullRecord 的
|
|
1015
|
+
* 全量重建)。idToFile 未热(进程重启后尚未扫描过)时返回 undefined,调用方
|
|
1016
|
+
* 自行兜底全目录扫描——用于把「跨重启后每条 message 一次 collectRecords 全扫」
|
|
1017
|
+
* 降为 O(1) 索引命中。
|
|
1018
|
+
*/
|
|
1019
|
+
findLightById(id: string): SubagentRecord | undefined {
|
|
1020
|
+
const file = this.idToFile.get(id);
|
|
1021
|
+
if (!file) return undefined;
|
|
1022
|
+
return this.scanFile(file, Date.now())?.light;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* [perf] 单 record 详情懒加载:内存 running record 投影全量;磁盘 record 全量重建
|
|
1027
|
+
* (reconstructFromFile)并套用同一 sidecar 状态矩阵。结果缓存在 FileCacheEntry.full,
|
|
1028
|
+
* stat 戳变化时随 light 一起失效。列表 collectRecords 返回 light(无 eventLog/
|
|
1029
|
+
* result/turns 等重数据),详情面板/工具 list 按需调本方法补齐。
|
|
1030
|
+
*
|
|
1031
|
+
* 返回 undefined:id 不存在(内存与磁盘均无)。reconstructFromFile 失败(无
|
|
1032
|
+
* assistant message 等)→ 返回 light(无详情可补,缓存哨兵防重复全文重读)。
|
|
1033
|
+
*/
|
|
1034
|
+
getFullRecord(id: string): SubagentRecord | undefined {
|
|
1035
|
+
// 内存 running record 天然全量(recordToSubagent 投影完整活态数据)。
|
|
1036
|
+
const mem = this.records.get(id);
|
|
1037
|
+
if (mem) return RecordStore.recordToSubagent(mem);
|
|
1038
|
+
|
|
1039
|
+
const file = this.idToFile.get(id);
|
|
1040
|
+
if (!file) return undefined;
|
|
1041
|
+
const entry = this.scanFile(file, Date.now());
|
|
1042
|
+
if (!entry) return undefined;
|
|
1043
|
+
if (entry.full === undefined) {
|
|
1044
|
+
const recon = reconstructFromFile(file);
|
|
1045
|
+
if (recon) {
|
|
1046
|
+
entry.full = RecordStore.buildRecord(recon, {
|
|
1047
|
+
tomb: entry.tomb,
|
|
1048
|
+
finalized: entry.finalized !== null,
|
|
1049
|
+
finalizedReason: entry.finalized !== null ? (entry.finalReason ?? "") : undefined,
|
|
1050
|
+
alive: entry.aliveData,
|
|
1051
|
+
jsonlMtimeMs: entry.jsonl.mtimeMs,
|
|
1052
|
+
fullEndedAt: recon.endedAt,
|
|
1053
|
+
now: Date.now(),
|
|
1054
|
+
});
|
|
1055
|
+
} else {
|
|
1056
|
+
entry.full = entry.light; // 哨兵:无详情可补,后续直取 light(戳变化时重置重试)
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return entry.full;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** alive 探活刷新(scanFile 缓存命中与 reconstructAll 快路径共用):
|
|
1063
|
+
* 分支 3 的 running + alive 条目每扫重查 pid(结果不落盘,进程死亡无 IO),
|
|
1064
|
+
* 保留旧实现「每次 collectRecords 重新 isProcessAlive」的语义。 */
|
|
1065
|
+
private static refreshAlive(entry: FileCacheEntry, now: number): void {
|
|
1066
|
+
if (entry.alive === null || entry.light.status !== "running") return;
|
|
1067
|
+
const marker = entry.aliveData;
|
|
1068
|
+
if (!marker) return;
|
|
1069
|
+
const live = isProcessAlive(marker.pid) && now - marker.startedAt < ALIVE_SOFT_TIMEOUT_MS;
|
|
1070
|
+
entry.light.externalInstance = live ? marker : undefined;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** identity 基底(头部 light 或全量 recon)+ 四分支 sidecar 状态矩阵 → SubagentRecord。 */
|
|
1074
|
+
private static buildRecord(
|
|
1075
|
+
base: IdentityHeaderRecon | ReconstructedRecord,
|
|
1076
|
+
m: SidecarMatrix,
|
|
1077
|
+
): SubagentRecord {
|
|
1078
|
+
let rec: SubagentRecord;
|
|
1079
|
+
if ("turns" in base) {
|
|
1080
|
+
// 全量:turnCount/totalTokens/model/eventLog/displayItems/result/error 齐全。
|
|
1081
|
+
rec = {
|
|
1082
|
+
id: base.id,
|
|
1083
|
+
agent: base.agent,
|
|
1084
|
+
slug: base.slug,
|
|
1085
|
+
status: "running", // 占位,下方矩阵覆盖
|
|
1086
|
+
mode: base.mode,
|
|
1087
|
+
startedAt: base.startedAt,
|
|
1088
|
+
rootSessionId: base.rootSessionId,
|
|
1089
|
+
parentRecordId: base.parentRecordId,
|
|
1090
|
+
depth: base.depth,
|
|
1091
|
+
endedAt: undefined,
|
|
1092
|
+
turns: base.turnCount,
|
|
1093
|
+
totalTokens: base.totalTokens,
|
|
1094
|
+
model: base.model,
|
|
1095
|
+
thinkingLevel: base.thinkingLevel,
|
|
1096
|
+
task: base.task,
|
|
1097
|
+
currentActivity: undefined,
|
|
1098
|
+
eventLog: base.eventLog,
|
|
1099
|
+
displayItems: getDisplayItems(base),
|
|
1100
|
+
result: base.result,
|
|
1101
|
+
error: base.error,
|
|
1102
|
+
sessionFile: base.sessionFile,
|
|
1103
|
+
chatMode: base.chatMode,
|
|
1104
|
+
worktree: base.worktree,
|
|
1105
|
+
};
|
|
1106
|
+
} else {
|
|
1107
|
+
// light:详情字段缺省(turns=0/eventLog=[]/result=undefined),getFullRecord 懒补。
|
|
1108
|
+
rec = {
|
|
1109
|
+
id: base.id,
|
|
1110
|
+
agent: base.agent,
|
|
1111
|
+
slug: base.slug,
|
|
1112
|
+
status: "running", // 占位,下方矩阵覆盖
|
|
1113
|
+
mode: base.mode,
|
|
1114
|
+
startedAt: base.startedAt,
|
|
1115
|
+
rootSessionId: base.rootSessionId,
|
|
1116
|
+
parentRecordId: base.parentRecordId,
|
|
1117
|
+
depth: base.depth,
|
|
1118
|
+
endedAt: undefined,
|
|
1119
|
+
turns: 0,
|
|
1120
|
+
totalTokens: 0,
|
|
1121
|
+
model: base.model,
|
|
1122
|
+
thinkingLevel: base.thinkingLevel,
|
|
1123
|
+
task: base.task,
|
|
1124
|
+
currentActivity: undefined,
|
|
1125
|
+
eventLog: [],
|
|
1126
|
+
displayItems: [],
|
|
1127
|
+
result: undefined,
|
|
1128
|
+
error: undefined,
|
|
1129
|
+
sessionFile: base.sessionFile,
|
|
1130
|
+
chatMode: base.chatMode,
|
|
1131
|
+
worktree: base.worktree,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ── 分支 1: .cancelled ──
|
|
1136
|
+
// v4 B-1: cancelled 折入 closed(closedReason='cancelled' 保留 L2 区分)。
|
|
1137
|
+
if (m.tomb) {
|
|
1138
|
+
markReconstructedStatus(rec, "closed");
|
|
1139
|
+
rec.closedReason = "cancelled";
|
|
1140
|
+
rec.error = "cancelled by user";
|
|
1141
|
+
rec.endedAt = m.tomb.endedAt;
|
|
1142
|
+
}
|
|
1143
|
+
// ── 分支 2: .finalized ──
|
|
1144
|
+
else if (m.finalized) {
|
|
1145
|
+
// closed 统一终态:done/failed/crashed 合并为 closed。closedReason 优先用
|
|
1146
|
+
// sidecar 内容携带的真实原因([v8.5 A2] doFinalizeRecord Step3 写入)。
|
|
1147
|
+
// 空内容(旧格式空文件 / 未携 reason 的外部写入)→ disconnected 兜底:死因
|
|
1148
|
+
// 不可考,但「正常结束过」信号仍在——替代旧的误导性 gc 兜底(自然完成 vs
|
|
1149
|
+
// 断联不分)。非枚举值(外部损坏/手写垃圾内容)同 treated as unknown → disconnected。
|
|
1150
|
+
markReconstructedStatus(rec, "closed");
|
|
1151
|
+
const reason = m.finalizedReason?.trim();
|
|
1152
|
+
rec.closedReason = isValidClosedReason(reason) ? (reason as ClosedReason) : "disconnected";
|
|
1153
|
+
// 全量路径用最后 entry ts(精确);light 路径用 jsonl mtime 近似(finalize 后
|
|
1154
|
+
// 文件不再变化,误差 <1s),避免重建后耗时随墙钟无限增长。
|
|
1155
|
+
rec.endedAt = m.fullEndedAt ?? m.jsonlMtimeMs;
|
|
1156
|
+
}
|
|
1157
|
+
// ── 分支 3: .alive + pid 存活 + 未超软超时 ──
|
|
1158
|
+
else if (
|
|
1159
|
+
m.alive !== undefined &&
|
|
1160
|
+
isProcessAlive(m.alive.pid) &&
|
|
1161
|
+
m.now - m.alive.startedAt < ALIVE_SOFT_TIMEOUT_MS
|
|
1162
|
+
) {
|
|
1163
|
+
markReconstructedStatus(rec, "running");
|
|
1164
|
+
rec.externalInstance = m.alive;
|
|
1165
|
+
}
|
|
1166
|
+
// ── 分支 4: 兜底(都无 / .alive 但 pid 死 / 超时)──
|
|
1167
|
+
// v4 B-1:跨重启可续聊态落点 = running。endedAt 保持 undefined(非终态)。
|
|
1168
|
+
else {
|
|
1169
|
+
markReconstructedStatus(rec, "running");
|
|
1170
|
+
}
|
|
1171
|
+
return rec;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** 从缓存与索引移除某文件(文件删除时;负缓存条目无 id,仅删缓存项)。 */
|
|
1175
|
+
private dropFileCache(file: string): void {
|
|
1176
|
+
const entry = this.fileCache.get(file);
|
|
1177
|
+
if (entry) {
|
|
1178
|
+
if (!entry.negative) this.idToFile.delete(entry.light.id);
|
|
1179
|
+
this.fileCache.delete(file);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** 排序比较器:status priority(running<failed<cancelled<done)+ startedAt desc。 */
|
|
1184
|
+
private static compareRecords(a: SubagentRecord, b: SubagentRecord): number {
|
|
1185
|
+
const pdiff = STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status];
|
|
1186
|
+
if (pdiff !== 0) return pdiff;
|
|
1187
|
+
return b.startedAt - a.startedAt; // 新→旧
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** FR-8: 同步读取所有 manifest 记录(封装 ManifestStore.listAllSync,消除反射访问)。 */
|
|
1191
|
+
private readManifestsSync(): readonly ManifestRecord[] {
|
|
1192
|
+
return this.manifestStore?.listAllSync() ?? [];
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** FR-8: ManifestRecord → SubagentRecord(manifest 源投影)。
|
|
1196
|
+
* task/slug/model 从 manifest 真实值投影(配合 writeManifest 补字段),缺失兜底空串。
|
|
1197
|
+
* status 越界(mapManifestStatus 返回 null)时返回 null,由 collectRecords 跳过。 */
|
|
1198
|
+
private static manifestToSubagent(m: ManifestRecord): SubagentRecord | null {
|
|
1199
|
+
const status = mapManifestStatus(m.status);
|
|
1200
|
+
if (status === null) return null;
|
|
1201
|
+
return {
|
|
1202
|
+
id: m.id,
|
|
1203
|
+
agent: m.agentName,
|
|
1204
|
+
task: m.task ?? "",
|
|
1205
|
+
slug: m.slug ?? "",
|
|
1206
|
+
status,
|
|
1207
|
+
mode: "background" as const,
|
|
1208
|
+
startedAt: m.createdAt,
|
|
1209
|
+
rootSessionId: m.rootSessionId || undefined,
|
|
1210
|
+
parentRecordId: undefined,
|
|
1211
|
+
depth: 0,
|
|
1212
|
+
endedAt: m.completedAt,
|
|
1213
|
+
turns: 0,
|
|
1214
|
+
totalTokens: 0,
|
|
1215
|
+
model: m.model ?? "",
|
|
1216
|
+
thinkingLevel: undefined,
|
|
1217
|
+
eventLog: [],
|
|
1218
|
+
displayItems: [],
|
|
1219
|
+
result: undefined,
|
|
1220
|
+
error: undefined, // closed 统一终态,不再按 status 区分 error 字段
|
|
1221
|
+
sessionFile: m.sessionFile,
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** ExecutionRecord → SubagentRecord(内存源投影)。 */
|
|
1226
|
+
private static recordToSubagent(r: ExecutionRecord): SubagentRecord {
|
|
1227
|
+
return {
|
|
1228
|
+
id: r.id,
|
|
1229
|
+
agent: r.agent,
|
|
1230
|
+
status: r.status,
|
|
1231
|
+
closedReason: r.closedReason,
|
|
1232
|
+
mode: r.mode,
|
|
1233
|
+
slug: r.slug,
|
|
1234
|
+
startedAt: r.startedAt,
|
|
1235
|
+
rootSessionId: r.rootSessionId,
|
|
1236
|
+
parentRecordId: r.parentRecordId,
|
|
1237
|
+
depth: r.depth,
|
|
1238
|
+
endedAt: r.endedAt,
|
|
1239
|
+
turns: r.turnCount,
|
|
1240
|
+
totalTokens: r.totalTokens,
|
|
1241
|
+
model: r.model,
|
|
1242
|
+
thinkingLevel: r.thinkingLevel,
|
|
1243
|
+
task: r.task,
|
|
1244
|
+
currentActivity: getCurrentActivity(r),
|
|
1245
|
+
eventLog: getEventLog(r),
|
|
1246
|
+
displayItems: getDisplayItems(r),
|
|
1247
|
+
result: r.result,
|
|
1248
|
+
error: r.error,
|
|
1249
|
+
sessionFile: r.sessionFile,
|
|
1250
|
+
round: r.round,
|
|
1251
|
+
// [E2E 实测抓漏] 缺这两行时 chatMode/resumable 在 recordToSubagent 处被丢弃,
|
|
1252
|
+
// entry 序列化后无此字段 → renderer isDone(需显式 chatMode===false)恒不成立,
|
|
1253
|
+
// 完成态 one-shot 永远显示 waiting。单测 schema 断言曾因内存对象保留 undefined
|
|
1254
|
+
// 键名而未拦截(真实 JSONL 丢 undefined 值),故 schema 测试改为序列化后断言。
|
|
1255
|
+
chatMode: r.chatMode,
|
|
1256
|
+
resumable: r.resumable,
|
|
1257
|
+
// [review round2] worktree 隔离标志:内存源有 handle 或跨重启重建带 hadWorktree 均为 true。
|
|
1258
|
+
worktree: r.worktreeHandle !== undefined || r.hadWorktree === true,
|
|
1259
|
+
engine: r.engine,
|
|
1260
|
+
engineFallback: r.engineFallback,
|
|
1261
|
+
// U2:engineHandle 经 entry 持久化(register/archive 双写点均经本投影),无则 undefined 自然省略
|
|
1262
|
+
engineHandle: r.engineHandle,
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
}
|