@zhushanwen/pi-subagent-workflow 0.1.0 → 0.3.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 +56 -0
- package/agents/context-builder.md +1 -3
- package/agents/explorer.md +27 -0
- package/agents/oracle.md +2 -2
- package/agents/orchestrator.md +48 -0
- package/agents/planner.md +1 -3
- package/agents/researcher.md +0 -2
- package/agents/reviewer.md +2 -2
- package/agents/worker.md +0 -2
- package/package.json +5 -3
- package/skills/workflow-script-format/SKILL.md +6 -6
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +41 -9
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/sdk-contract.test.ts +5 -2
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
- package/src/execution/__tests__/tool-action.test.ts +27 -5
- 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 +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/concurrency-pool.ts +38 -6
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +21 -4
- package/src/execution/execution-record.ts +5 -0
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +110 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-reconstructor.ts +11 -0
- package/src/execution/session-runner.ts +228 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/stream-sink.ts +83 -0
- package/src/execution/subagent-service.ts +230 -235
- package/src/execution/subprocess-agent-runner.ts +16 -4
- package/src/execution/types.ts +23 -3
- 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 +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +101 -4
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
- package/src/interface/command-actions.ts +77 -0
- package/src/interface/commands.ts +40 -4
- package/src/interface/format.ts +2 -0
- package/src/interface/gui-mappers.ts +83 -0
- package/src/interface/helpers.ts +52 -9
- package/src/interface/list-component.ts +3 -1
- package/src/interface/subagent-actions.ts +44 -24
- package/src/interface/subagent-tool.ts +56 -24
- package/src/interface/subagents.ts +45 -5
- package/src/interface/tool-render.ts +16 -5
- package/src/interface/tool-workflow-script.ts +113 -15
- package/src/interface/tool-workflow.ts +92 -34
- package/src/interface/views/WorkflowsView.ts +13 -4
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
- package/src/interface/views/detail-content.ts +20 -0
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
- package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
- package/src/orchestration/agent-opts-resolver.ts +11 -2
- package/src/orchestration/error-recovery.ts +131 -23
- package/src/orchestration/execute-agent-call.ts +12 -3
- package/src/orchestration/jsonl-run-store.ts +10 -0
- package/src/orchestration/lifecycle.ts +1 -1
- package/src/orchestration/models/agent-call.ts +7 -0
- package/src/orchestration/models/ports.ts +15 -2
- package/src/orchestration/models/run-spec.ts +6 -0
- package/src/orchestration/models/trace.ts +1 -0
- package/src/orchestration/models/types.ts +19 -0
- package/src/orchestration/node-ops.ts +2 -0
- package/src/orchestration/worker-script-builder.ts +1 -0
- package/workflows/README.md +58 -0
- package/workflows/chain.js +107 -0
- package/workflows/map-reduce.js +142 -0
- package/workflows/parallel.js +131 -0
- package/workflows/scatter-gather.js +146 -0
- package/agents/scout.md +0 -17
- package/examples/README.md +0 -43
- package/examples/chain.example.js +0 -92
- package/examples/map-reduce.example.js +0 -99
- package/examples/parallel.example.js +0 -82
- package/examples/scatter-gather.example.js +0 -106
- package/src/interface/gui-adapter.ts +0 -136
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// src/execution/finalize-record.ts
|
|
2
|
+
//
|
|
3
|
+
// 时序收尾逻辑(从 subagent-service.ts 提取,降低主文件行数 < 1000 上限)。
|
|
4
|
+
//
|
|
5
|
+
// D-017 时序:collectPatch → completeRecord → archive → cleanup(finalized+worktree+
|
|
6
|
+
// aliveMarker+pending注销) → manifest(最后 best-effort)。
|
|
7
|
+
//
|
|
8
|
+
// [Critical #1 / PR #85] cleanup 全部在 manifest 写之前执行——manifest 是诊断辅助
|
|
9
|
+
//(orphan recovery),写失败仅记录不阻断。旧实现 Step 2.5 throw 会跳过 Step 3 cleanup,
|
|
10
|
+
// 导致磁盘满/权限错时 worktree 泄漏 + finalized marker 不写 + alive marker 残留 +
|
|
11
|
+
// pending 记账错乱。现 manifest 写移到 Step 4(最后),best-effort(console.error +
|
|
12
|
+
// appendEntry,不 throw)。
|
|
13
|
+
//
|
|
14
|
+
// B9 兜底:completeRecord/archive 抛错→后续 cleanup/manifest 仍执行。
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { removeAliveMarker } from "./alive-store.ts";
|
|
20
|
+
import { bestEffort } from "./best-effort.ts";
|
|
21
|
+
import { completeRecord } from "./execution-record.ts";
|
|
22
|
+
import { writeFinalized } from "./finalized-marker.ts";
|
|
23
|
+
import type { ManifestStore } from "./manifest-store.ts";
|
|
24
|
+
import type { ModelConfigService } from "./model-config-service.ts";
|
|
25
|
+
import { getSubagentSessionDir } from "./path-encoding.ts";
|
|
26
|
+
import type { RecordStore } from "./record-store.ts";
|
|
27
|
+
import { writeCancelledTombstone } from "./tombstone-store.ts";
|
|
28
|
+
import type { AgentResult, ExecutionRecord } from "./types.ts";
|
|
29
|
+
import type { WorktreeManager } from "./worktree-manager.ts";
|
|
30
|
+
|
|
31
|
+
/** doFinalizeRecord 的依赖(从 SubagentService 注入,避免 this 绑定 + 解耦可测试)。 */
|
|
32
|
+
export interface FinalizeDeps {
|
|
33
|
+
manifestStore: ManifestStore;
|
|
34
|
+
worktreeManager: WorktreeManager;
|
|
35
|
+
store: RecordStore;
|
|
36
|
+
modelService: ModelConfigService;
|
|
37
|
+
/** Pi ExtensionAPI(仅用 appendEntry 记录 manifest 写失败事件)。null 在 dispose 后。 */
|
|
38
|
+
pi: { appendEntry?: (type: string, data: unknown) => void } | null;
|
|
39
|
+
/** 清节流状态(防 trailing timer 在 record 归档后误发陈旧 onUpdate)。 */
|
|
40
|
+
clearThrottle(id: string): void;
|
|
41
|
+
/** pending-notifications 终态注销(绑定 pi.events.emit,由调用方闭包提供)。 */
|
|
42
|
+
emitUnregister(id: string, status: string): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 时序收尾(D-017)。步骤 0→4 全部 best-effort 互不阻断(除 manifest 外都幂等)。
|
|
47
|
+
*
|
|
48
|
+
* [Critical #1] Step 3 cleanup 全部在 Step 4 manifest 之前——manifest 写失败仅 console.error +
|
|
49
|
+
* appendEntry,不 throw 不跳过 cleanup。task/slug/model 从 ExecutionRecord 抓取(配合 ManifestRecord
|
|
50
|
+
* 补字段),manifestToSubagent 投影真实值而非硬编码空串。
|
|
51
|
+
*/
|
|
52
|
+
export async function doFinalizeRecord(
|
|
53
|
+
deps: FinalizeDeps,
|
|
54
|
+
record: ExecutionRecord,
|
|
55
|
+
result: AgentResult,
|
|
56
|
+
status: "done" | "failed" | "cancelled",
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
// 终态清节流状态:防 trailing timer 在 record 归档后误发陈旧 onUpdate
|
|
59
|
+
deps.clearThrottle(record.id);
|
|
60
|
+
|
|
61
|
+
// ── Step 0: collectPatch(best-effort)──
|
|
62
|
+
// [MF#3] patchFile 写到 worktree 之外(sessionsDir/<branch>.patch),避免被 cleanup 删除;
|
|
63
|
+
// 路径回填 record.patchFile,供调用方(tool result / /subagents list)应用。
|
|
64
|
+
if (record.worktreeHandle) {
|
|
65
|
+
try {
|
|
66
|
+
const sessionsDir = getSubagentSessionDir(
|
|
67
|
+
deps.modelService.getAgentDir(),
|
|
68
|
+
record.worktreeHandle.mainCwd,
|
|
69
|
+
);
|
|
70
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
71
|
+
const patchFile = path.join(sessionsDir, `${record.worktreeHandle.branch}.patch`);
|
|
72
|
+
const patch = deps.worktreeManager.collectPatch(record.worktreeHandle, patchFile);
|
|
73
|
+
if (patch.written) record.patchFile = patchFile;
|
|
74
|
+
} catch (pe: unknown) {
|
|
75
|
+
bestEffort(pe, "collectPatch (finalizeRecord Step0)");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Step 1: completeRecord(B9: 抛错→后续仍执行)──
|
|
80
|
+
try {
|
|
81
|
+
completeRecord(record, result, status);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
bestEffort(err, "completeRecord (finalizeRecord B9)", "error");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Step 2: archive(B9: 抛错→后续仍执行)──
|
|
87
|
+
try {
|
|
88
|
+
deps.store.archive(record);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
bestEffort(err, "store.archive (finalizeRecord B9)", "error");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Step 3: finalized + cleanup + aliveMarker + pending注销(全部先执行,幂等)──
|
|
94
|
+
// [Critical] 清理必须在 manifest 写入之前:worktree cleanup / finalized marker / aliveMarker
|
|
95
|
+
// 都是幂等且不可跳过的副作用。绝不能因 manifest 写失败而跳过 worktree cleanup
|
|
96
|
+
// (否则 worktree 泄漏)。各件独立 try/catch,互不阻断。
|
|
97
|
+
if (record.sessionFile) {
|
|
98
|
+
try {
|
|
99
|
+
// MF-1 fix: cancelled 状态写 tombstone 而非 finalized,防重建丢失 cancelled
|
|
100
|
+
if (status === "cancelled") {
|
|
101
|
+
writeCancelledTombstone(record.sessionFile, {
|
|
102
|
+
id: record.id,
|
|
103
|
+
status: "cancelled",
|
|
104
|
+
agent: record.agent,
|
|
105
|
+
startedAt: record.startedAt,
|
|
106
|
+
endedAt: record.endedAt ?? Date.now(),
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
writeFinalized(record.sessionFile);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
bestEffort(err, "writeFinalized/tombstone (finalizeRecord Step3)");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (record.worktreeHandle) {
|
|
116
|
+
try {
|
|
117
|
+
deps.worktreeManager.cleanup(record.worktreeHandle);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
bestEffort(err, "worktree cleanup (finalizeRecord Step3)");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (record.sessionFile) {
|
|
123
|
+
try {
|
|
124
|
+
removeAliveMarker(record.sessionFile);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
bestEffort(err, "removeAliveMarker (finalizeRecord Step3)");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// pending-notifications:终态注销(只记 registry 状态,通知由 BgNotifier 发)
|
|
131
|
+
deps.emitUnregister(record.id, status);
|
|
132
|
+
|
|
133
|
+
// ── Step 4 (last): manifest 持久化(best-effort,不阻断、不 throw)──
|
|
134
|
+
// [Critical #1] manifest 是诊断辅助(orphan recovery),不是正确性依赖。写失败时
|
|
135
|
+
// 仅记录(console.error + appendEntry),绝不让 manifest 写失败跳过上面的 worktree
|
|
136
|
+
// cleanup 或抛出打断 finalize 链。旧实现 Step 2.5 throw 会跳过 Step 3 cleanup。
|
|
137
|
+
// task/slug/model 从 ExecutionRecord 抓取(配合 ManifestRecord 补字段),
|
|
138
|
+
// manifestToSubagent 投影时用真实值而非硬编码空串。
|
|
139
|
+
try {
|
|
140
|
+
await deps.manifestStore.writeManifest({
|
|
141
|
+
id: record.id,
|
|
142
|
+
rootSessionId: record.rootSessionId ?? "",
|
|
143
|
+
agentName: record.agent,
|
|
144
|
+
status: status === "done" ? "completed" : status,
|
|
145
|
+
createdAt: record.startedAt,
|
|
146
|
+
completedAt: record.endedAt ?? Date.now(),
|
|
147
|
+
sessionFile: record.sessionFile,
|
|
148
|
+
task: record.task,
|
|
149
|
+
slug: record.slug,
|
|
150
|
+
model: record.model,
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
+
console.error(`[subagent] manifest 写入失败 (record=${record.id}): ${msg}`);
|
|
155
|
+
deps.pi?.appendEntry?.("subagent:manifest-write-failed", {
|
|
156
|
+
id: record.id,
|
|
157
|
+
error: msg,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// src/execution/get-state-handshake.ts
|
|
2
|
+
//
|
|
3
|
+
// FR-4: get_state RPC 握手逻辑。
|
|
4
|
+
//
|
|
5
|
+
// 从 session-runner.ts 提取(保持文件 < 1000 行)。职责单一:通过 get_state RPC
|
|
6
|
+
// 查询子进程 sessionFile/sessionId,带超时重试。session-runner spawn 后无条件调用。
|
|
7
|
+
//
|
|
8
|
+
// 设计要点:
|
|
9
|
+
// - 重试节奏:单次超时 GET_STATE_TIMEOUT_MS(2s)后,等 GET_STATE_RETRY_INTERVAL_MS(500ms)
|
|
10
|
+
// 再发起下一次 get_state,最多 GET_STATE_MAX_RETRIES(3)次。修复点:旧实现超时后
|
|
11
|
+
// 立即递归 tryOnce(),GET_STATE_RETRY_INTERVAL_MS 声明了却从未使用(eslint error 阻断),
|
|
12
|
+
// 现在让常量名与行为一致——重试前真的等间隔。
|
|
13
|
+
// - 加速路径:sessionFile 一旦拿到立即 resolve(不等剩余重试)。
|
|
14
|
+
// - 全部超时:resolve 空对象(调用方走兜底查找)。
|
|
15
|
+
|
|
16
|
+
import type { ChildProcess } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
import { sendGetStateCommand } from "./stdin-writer.ts";
|
|
19
|
+
|
|
20
|
+
/** FR-4: get_state RPC 握手最大重试次数。 */
|
|
21
|
+
const GET_STATE_MAX_RETRIES = 3;
|
|
22
|
+
/** FR-4: get_state RPC 握手重试间隔(ms)——单次超时后等待此间隔再重试。 */
|
|
23
|
+
const GET_STATE_RETRY_INTERVAL_MS = 500;
|
|
24
|
+
/** FR-4: get_state RPC 握手单次超时(ms)。 */
|
|
25
|
+
const GET_STATE_TIMEOUT_MS = 2000;
|
|
26
|
+
|
|
27
|
+
/** get_state 握手结果。 */
|
|
28
|
+
export interface GetStateResult {
|
|
29
|
+
sessionFile?: string;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* FR-4: 通过 get_state RPC 查询子进程获取 sessionFile/sessionId。
|
|
35
|
+
*
|
|
36
|
+
* 当 stdout header 未获取到 sessionFile 时,尝试通过 get_state RPC 查询。
|
|
37
|
+
* 最多重试 GET_STATE_MAX_RETRIES 次,单次超时 GET_STATE_TIMEOUT_MS 后等待
|
|
38
|
+
* GET_STATE_RETRY_INTERVAL_MS 再发起下一次重试。
|
|
39
|
+
*
|
|
40
|
+
* @param child 子进程(stdin 写入 get_state 命令)
|
|
41
|
+
* @param addResponseListener 注册 response 监听器的函数(stdout pump 中调用)
|
|
42
|
+
* @returns 握手结果(可能为空——所有重试均超时/失败)
|
|
43
|
+
*/
|
|
44
|
+
export function performGetStateHandshake(
|
|
45
|
+
child: ChildProcess,
|
|
46
|
+
addResponseListener: (id: string, resolver: (data: unknown) => void) => void,
|
|
47
|
+
): Promise<GetStateResult> {
|
|
48
|
+
return new Promise<GetStateResult>((resolve) => {
|
|
49
|
+
const collected: GetStateResult = {};
|
|
50
|
+
let attempts = 0;
|
|
51
|
+
let resolved = false;
|
|
52
|
+
|
|
53
|
+
function tryOnce(): void {
|
|
54
|
+
if (resolved) return;
|
|
55
|
+
attempts++;
|
|
56
|
+
const reqId = sendGetStateCommand(child);
|
|
57
|
+
|
|
58
|
+
// [#15] 本次 tryOnce 私有的 timer(2s 超时 + 超时后派生的 retry)。
|
|
59
|
+
// 关键:response 回调通过闭包引用的是"本次 tryOnce 对应的 timer",而非某个
|
|
60
|
+
// 外层共享变量——即便后续 tryOnce(#2) 重新发起请求,旧 reqId 的迟到 response 回调
|
|
61
|
+
// 闭包仍指向它自己那次 tryOnce 的 timer,不会误清新 reqId 的 timer。retry 句柄也
|
|
62
|
+
// 一并捕获,response 到达时同步取消"已在排队但尚未触发的下一次重试"。
|
|
63
|
+
let pendingRetry: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
const timer: ReturnType<typeof setTimeout> = setTimeout(() => {
|
|
65
|
+
pendingRetry = undefined;
|
|
66
|
+
// 单次超时:等待间隔后重试,或放弃
|
|
67
|
+
if (attempts < GET_STATE_MAX_RETRIES && !resolved) {
|
|
68
|
+
// [Bug fix] 旧实现直接 tryOnce() 立即重试,GET_STATE_RETRY_INTERVAL_MS 声明却
|
|
69
|
+
// 从未使用(eslint error 阻断 commit)。现在重试前等待间隔,让常量名与行为一致。
|
|
70
|
+
pendingRetry = setTimeout(() => tryOnce(), GET_STATE_RETRY_INTERVAL_MS);
|
|
71
|
+
pendingRetry.unref();
|
|
72
|
+
} else if (!resolved) {
|
|
73
|
+
resolved = true;
|
|
74
|
+
resolve(collected);
|
|
75
|
+
}
|
|
76
|
+
}, GET_STATE_TIMEOUT_MS);
|
|
77
|
+
timer.unref();
|
|
78
|
+
|
|
79
|
+
addResponseListener(reqId, (data: unknown) => {
|
|
80
|
+
if (resolved) return;
|
|
81
|
+
// [#15] 闭包清理本次 tryOnce 的 timer(2s 超时 + 排队中的 retry),不碰其他 reqId 的 timer。
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
if (pendingRetry) clearTimeout(pendingRetry);
|
|
84
|
+
if (data && typeof data === "object") {
|
|
85
|
+
const d = data as Record<string, unknown>;
|
|
86
|
+
if (typeof d.sessionFile === "string" && d.sessionFile.length > 0) {
|
|
87
|
+
collected.sessionFile = d.sessionFile;
|
|
88
|
+
}
|
|
89
|
+
if (typeof d.sessionId === "string" && d.sessionId.length > 0) {
|
|
90
|
+
collected.sessionId = d.sessionId;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// sessionFile 已获取——立即 resolve(无需更多重试)
|
|
94
|
+
if (collected.sessionFile) {
|
|
95
|
+
resolved = true;
|
|
96
|
+
resolve(collected);
|
|
97
|
+
}
|
|
98
|
+
// 否则等待超时重试
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
tryOnce();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/execution/host-mode.ts
|
|
2
|
+
//
|
|
3
|
+
// 主进程运行模式分类工具。
|
|
4
|
+
//
|
|
5
|
+
// 将 Pi 的 ExtensionMode("tui"|"rpc"|"json"|"print")聚合为业务语义的
|
|
6
|
+
// HostMode("tui"|"gui"|"headless"),供 session-runner 的 W4 提示词守卫、
|
|
7
|
+
// handler 工厂分流、stdio 选择等消费点统一调用。
|
|
8
|
+
//
|
|
9
|
+
// 判定依据见 AGENTS.md「运行时环境区分」章节 +
|
|
10
|
+
// docs/pi-tui-development-guide.md 第四部分第 8 节。
|
|
11
|
+
// ExtensionMode 来自 Pi 源码 packages/coding-agent/src/core/extensions/types.ts:299
|
|
12
|
+
// (dist 中 core/extensions/types.d.ts:207)。
|
|
13
|
+
//
|
|
14
|
+
// 设计动机(.fix-plans/00-master-summary.md §一冲突 4):
|
|
15
|
+
// - 把散落多处的 `ctx.mode === "tui" || ctx.mode === "rpc"` 字面量比较
|
|
16
|
+
// 集中到单一修改点,未来 Pi 新增 mode 值时只改本文件。
|
|
17
|
+
// - 业务语义命名("gui"/"headless")比原始枚举值更清晰表达意图。
|
|
18
|
+
|
|
19
|
+
import type { ExtensionMode } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
/** 主进程运行模式分类。基于 ExtensionMode 聚合为业务语义。
|
|
22
|
+
* - "tui":纯 Pi TUI,ctx.ui.custom 可用,用户在终端交互
|
|
23
|
+
* - "gui":xyz-agent GUI,通过 rpc sidecar 通道与前端 Vue 组件交互
|
|
24
|
+
* - "headless":无交互 UI 通道(json/print 输出模式,或 mode 未穿透) */
|
|
25
|
+
export type HostMode = "tui" | "gui" | "headless";
|
|
26
|
+
|
|
27
|
+
/** 从 ExtensionContext.mode 解析主进程模式分类。
|
|
28
|
+
* - "tui" → tui(纯 Pi TUI,ctx.ui.custom 可用)
|
|
29
|
+
* - "rpc" → gui(xyz-agent GUI,sidecar 通道可用)
|
|
30
|
+
* - "json"/"print"/undefined → headless(无交互通道)
|
|
31
|
+
*
|
|
32
|
+
* undefined 归入 headless 是向后兼容:mode 未穿透 SessionRunnerContext 时
|
|
33
|
+
* 按「无 UI」保守处理,避免误注入依赖 UI 的逻辑。 */
|
|
34
|
+
export function resolveHostMode(mode: ExtensionMode | undefined): HostMode {
|
|
35
|
+
if (mode === "tui") return "tui";
|
|
36
|
+
if (mode === "rpc") return "gui";
|
|
37
|
+
return "headless"; // json/print/undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 主进程是否会响应子进程的 ask_user(UI 透传)。
|
|
41
|
+
* tui + gui 都会(冲突 3 裁决:TUI 必须注入 handler;GUI 透传所有 UI),
|
|
42
|
+
* headless 不会(无 UI 通道,注入 W4 提示词会误导 LLM)。 */
|
|
43
|
+
export function willRespondToAskUser(mode: ExtensionMode | undefined): boolean {
|
|
44
|
+
const host = resolveHostMode(mode);
|
|
45
|
+
return host === "tui" || host === "gui";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 主进程是否有交互 UI 通道(TUI 组件 / GUI sidecar)。
|
|
49
|
+
* 非 headless 即有。用于 stdio 选择等不区分 tui/gui 的决策点。 */
|
|
50
|
+
export function hasInteractiveUI(mode: ExtensionMode | undefined): boolean {
|
|
51
|
+
return resolveHostMode(mode) !== "headless";
|
|
52
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as fsPromises from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { bestEffort } from "./best-effort.ts";
|
|
6
|
+
|
|
7
|
+
export interface ManifestRecord {
|
|
8
|
+
id: string;
|
|
9
|
+
rootSessionId: string;
|
|
10
|
+
agentName: string;
|
|
11
|
+
/**
|
|
12
|
+
* 终态枚举:finalizeRecord 写 running/completed/failed/cancelled 四态;cancelled 不再
|
|
13
|
+
* 归并 failed。crashed 不进 manifest——crashed 是重启重建时靠 sidecar 四分支推断的派生态
|
|
14
|
+
* (见 record-store.ts reconstructAll),持久化会与 sidecar source of truth 形成双源;
|
|
15
|
+
* manifest 职责保持纯粹,只记录 finalize 明确产出的终态。
|
|
16
|
+
* 历史 "error" 值已移除——读侧 isValidManifest 守卫拒绝,mapManifestStatus 越界返回 null。
|
|
17
|
+
*/
|
|
18
|
+
status: "running" | "completed" | "failed" | "cancelled";
|
|
19
|
+
createdAt: number;
|
|
20
|
+
completedAt?: number;
|
|
21
|
+
sessionFile?: string;
|
|
22
|
+
/** FR-7 补字段:manifest 写入时从 ExecutionRecord 抓取,供 manifestToSubagent 投影真实值。 */
|
|
23
|
+
task?: string;
|
|
24
|
+
slug?: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 合法 manifest status 集合(4 态;运行时守卫用,磁盘文件可能陈旧/损坏)。crashed 不在其中。 */
|
|
29
|
+
const VALID_MANIFEST_STATUSES: ReadonlySet<string> = new Set([
|
|
30
|
+
"running",
|
|
31
|
+
"completed",
|
|
32
|
+
"failed",
|
|
33
|
+
"cancelled",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 校验 JSON.parse 产物是否为合法 ManifestRecord。
|
|
38
|
+
* 关键字段类型检查——不合法返回 false,调用方据此过滤(防损坏/陈旧文件污染投影)。
|
|
39
|
+
*/
|
|
40
|
+
function isValidManifest(value: unknown): value is ManifestRecord {
|
|
41
|
+
if (typeof value !== "object" || value === null) return false;
|
|
42
|
+
const v = value as Record<string, unknown>;
|
|
43
|
+
return (
|
|
44
|
+
typeof v.id === "string" &&
|
|
45
|
+
typeof v.rootSessionId === "string" &&
|
|
46
|
+
typeof v.agentName === "string" &&
|
|
47
|
+
typeof v.createdAt === "number" &&
|
|
48
|
+
typeof v.status === "string" &&
|
|
49
|
+
VALID_MANIFEST_STATUSES.has(v.status)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ManifestStore {
|
|
54
|
+
private readonly dir: string;
|
|
55
|
+
|
|
56
|
+
constructor(dir: string) {
|
|
57
|
+
this.dir = dir;
|
|
58
|
+
if (!fs.existsSync(dir)) {
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 原子写:tmp → fsync → rename → fsync dir。真异步(fs.promises,不阻塞 event loop)。
|
|
65
|
+
*
|
|
66
|
+
* rename 失败时 best-effort 清理残留 tmp(用 renamed 标志在 catch 中决定是否 unlink),
|
|
67
|
+
* 不掩盖原错误。失败向上抛——调用方(finalizeRecord)决定降级策略。
|
|
68
|
+
*/
|
|
69
|
+
async writeManifest(record: ManifestRecord): Promise<void> {
|
|
70
|
+
const filePath = path.join(this.dir, `${record.id}.json`);
|
|
71
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
72
|
+
const content = JSON.stringify(record, null, 2);
|
|
73
|
+
|
|
74
|
+
let renamed = false;
|
|
75
|
+
try {
|
|
76
|
+
// 1. 写 tmp → fsync 文件
|
|
77
|
+
const fh = await fsPromises.open(tmpPath, "w");
|
|
78
|
+
try {
|
|
79
|
+
await fh.writeFile(content, "utf-8");
|
|
80
|
+
await fh.sync();
|
|
81
|
+
} finally {
|
|
82
|
+
await fh.close();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2. rename tmp → final(放入 try:失败时 catch 清理 tmp)
|
|
86
|
+
await fsPromises.rename(tmpPath, filePath);
|
|
87
|
+
renamed = true;
|
|
88
|
+
|
|
89
|
+
// 3. fsync 目录(best-effort:POSIX 不要求,失败不否定已成功的 rename)
|
|
90
|
+
try {
|
|
91
|
+
const dirFh = await fsPromises.open(this.dir, "r");
|
|
92
|
+
try {
|
|
93
|
+
await dirFh.sync();
|
|
94
|
+
} finally {
|
|
95
|
+
await dirFh.close();
|
|
96
|
+
}
|
|
97
|
+
} catch (dirSyncErr) {
|
|
98
|
+
bestEffort(dirSyncErr, "fsync dir (writeManifest)");
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// rename 未成功 → 清理残留 tmp(best-effort,不掩盖原错误)
|
|
102
|
+
if (!renamed) {
|
|
103
|
+
try {
|
|
104
|
+
await fsPromises.unlink(tmpPath);
|
|
105
|
+
} catch (cleanupErr) {
|
|
106
|
+
// best-effort:tmp 可能已被 rename 消费或从未创建。不影响主错误(下面 re-throw err)
|
|
107
|
+
bestEffort(cleanupErr, "unlink tmp (writeManifest)");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 按 id 读 manifest。文件不存在/JSON 损坏/schema 不合法均返回 null。
|
|
116
|
+
* 调用方需处理 null。
|
|
117
|
+
*/
|
|
118
|
+
async readManifest(id: string): Promise<ManifestRecord | null> {
|
|
119
|
+
const filePath = path.join(this.dir, `${id}.json`);
|
|
120
|
+
try {
|
|
121
|
+
const content = await fsPromises.readFile(filePath, "utf-8");
|
|
122
|
+
const parsed: unknown = JSON.parse(content);
|
|
123
|
+
return isValidManifest(parsed) ? parsed : null;
|
|
124
|
+
} catch {
|
|
125
|
+
// 文件缺失(ENOENT)或 JSON 损坏(SyntaxError)均降级为 null
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 同步读取所有 manifest 记录(best-effort,损坏/非法文件跳过)。
|
|
132
|
+
* 供 RecordStore.collectRecords 投影 orphan 记录使用——替代对私有 dir 的反射访问。
|
|
133
|
+
* 仅返回通过 isValidManifest 校验的记录。
|
|
134
|
+
*/
|
|
135
|
+
listAllSync(): readonly ManifestRecord[] {
|
|
136
|
+
let files: string[];
|
|
137
|
+
try {
|
|
138
|
+
files = fs.readdirSync(this.dir);
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const results: ManifestRecord[] = [];
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
if (!file.endsWith(".json") || file.includes(".tmp.")) continue;
|
|
145
|
+
try {
|
|
146
|
+
const content = fs.readFileSync(path.join(this.dir, file), "utf-8");
|
|
147
|
+
const parsed: unknown = JSON.parse(content);
|
|
148
|
+
if (isValidManifest(parsed)) {
|
|
149
|
+
results.push(parsed);
|
|
150
|
+
}
|
|
151
|
+
} catch (fileErr) {
|
|
152
|
+
// best-effort:损坏/非法文件跳过(debug 记录便于排查)
|
|
153
|
+
bestEffort(fileErr, `read manifest ${file} (listAllSync)`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return results;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 启动时恢复 tmp 文件。
|
|
161
|
+
* 3 分支逻辑:
|
|
162
|
+
* 1. manifest 已存在 → 删 tmp(陈旧)
|
|
163
|
+
* 2. tmp 合法 + manifest 缺失 → rename tmp 为 manifest
|
|
164
|
+
* 3. tmp 非法 + manifest 缺失 → 删 tmp
|
|
165
|
+
*/
|
|
166
|
+
async recoverTmpFiles(): Promise<{ deleted: number; recovered: number }> {
|
|
167
|
+
let deleted = 0;
|
|
168
|
+
let recovered = 0;
|
|
169
|
+
|
|
170
|
+
const files = fs.readdirSync(this.dir);
|
|
171
|
+
const tmpFiles = files.filter((f) => f.includes(".json.tmp."));
|
|
172
|
+
|
|
173
|
+
for (const tmpFile of tmpFiles) {
|
|
174
|
+
const tmpPath = path.join(this.dir, tmpFile);
|
|
175
|
+
const manifestId = tmpFile.split(".json.tmp.")[0];
|
|
176
|
+
const manifestPath = path.join(this.dir, `${manifestId}.json`);
|
|
177
|
+
|
|
178
|
+
if (fs.existsSync(manifestPath)) {
|
|
179
|
+
// 分支 1: manifest 已存在,删 tmp
|
|
180
|
+
fs.unlinkSync(tmpPath);
|
|
181
|
+
deleted++;
|
|
182
|
+
} else {
|
|
183
|
+
// 试解析 tmp
|
|
184
|
+
try {
|
|
185
|
+
const content = fs.readFileSync(tmpPath, "utf-8");
|
|
186
|
+
const parsed: unknown = JSON.parse(content);
|
|
187
|
+
if (isValidManifest(parsed)) {
|
|
188
|
+
// 分支 2: tmp 是合法 manifest,rename 为正式文件
|
|
189
|
+
fs.renameSync(tmpPath, manifestPath);
|
|
190
|
+
recovered++;
|
|
191
|
+
} else {
|
|
192
|
+
// 分支 3b: 合法 JSON 但非合法 manifest(缺必填字段),删
|
|
193
|
+
fs.unlinkSync(tmpPath);
|
|
194
|
+
deleted++;
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// 分支 3a: JSON.parse 失败,删
|
|
198
|
+
fs.unlinkSync(tmpPath);
|
|
199
|
+
deleted++;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { deleted, recovered };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -129,7 +129,11 @@ export class BgNotifier {
|
|
|
129
129
|
content,
|
|
130
130
|
display: true,
|
|
131
131
|
details,
|
|
132
|
-
|
|
132
|
+
// [W2 修复] followUp → steer:subagent 完成通知需立即抢占主 agent 下一个 turn,
|
|
133
|
+
// 即使主 agent 处于轮询 subagent_list 的 processing 状态(followUp 永远排不上)。
|
|
134
|
+
// 与 workflow helpers.ts:151 同语义对齐(commit d214d0d83 验证 steer 能避免
|
|
135
|
+
// 'Agent is already processing' 错误)。
|
|
136
|
+
}, { triggerTurn: true, deliverAs: "steer" });
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
private buildLlmContent(record: BgNotifyRecord): string {
|
|
@@ -32,3 +32,21 @@ export function getSubagentSessionDir(agentDir: string, mainCwd: string): string
|
|
|
32
32
|
// 本分支未发布,回退到既有布局即无需迁移、无数据丢失。
|
|
33
33
|
return path.join(agentDir, "subagents", encodeCwd(mainCwd), "sessions");
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 获取 subagent records(manifest)持久化目录路径。
|
|
38
|
+
*
|
|
39
|
+
* 与 getSubagentSessionDir 同用 encodeCwd(mainCwd),保证 records 与 sessions 在同一
|
|
40
|
+
* <enc> 段下物理相邻——worktree 场景三者恒等(init.cwd /
|
|
41
|
+
* buildSessionRunnerContext.mainCwd / record.worktreeHandle.mainCwd 指向同一主 cwd)。
|
|
42
|
+
*
|
|
43
|
+
* D-004 同源:用主 cwd 编码做物理隔离,使 session-file-gc 按 <enc>/records/ 子目录
|
|
44
|
+
* 匹配 manifest .json 时天然限定在当前 cwd 范围内,不会越界清理其他 cwd 的 manifest。
|
|
45
|
+
*
|
|
46
|
+
* @param agentDir agent 配置目录(如 ~/.pi/agent)
|
|
47
|
+
* @param mainCwd 主 agent 的工作目录(非 subagent 的 effectiveCwd)
|
|
48
|
+
* @returns records 持久化目录绝对路径
|
|
49
|
+
*/
|
|
50
|
+
export function getSubagentRecordsDir(agentDir: string, mainCwd: string): string {
|
|
51
|
+
return path.join(agentDir, "subagents", encodeCwd(mainCwd), "records");
|
|
52
|
+
}
|
|
@@ -42,7 +42,7 @@ function isGenericRuntime(execPath: string): boolean {
|
|
|
42
42
|
/**
|
|
43
43
|
* 组装 pi 子进程的 spawn 调用。
|
|
44
44
|
*
|
|
45
|
-
* @param userArgs pi CLI 参数(如 ["--mode", "
|
|
45
|
+
* @param userArgs pi CLI 参数(如 ["--mode", "rpc", "--session-dir", "..."])
|
|
46
46
|
* @returns spawn 描述符(command + 完整 args)
|
|
47
47
|
*
|
|
48
48
|
* 决策链(按优先级):
|