@zhushanwen/pi-subagent-workflow 0.1.0 → 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/agents/context-builder.md +1 -3
- package/agents/oracle.md +2 -2
- package/agents/planner.md +1 -3
- package/agents/researcher.md +0 -2
- package/agents/reviewer.md +2 -2
- package/agents/scout.md +13 -3
- 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-result-mapper.test.ts +24 -2
- package/src/execution/__tests__/concurrency-pool.test.ts +33 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +40 -8
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +60 -0
- package/src/execution/__tests__/sdk-contract.test.ts +5 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +20 -0
- package/src/execution/__tests__/subagent-service-abort.test.ts +60 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
- package/src/execution/__tests__/tool-action.test.ts +26 -4
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/concurrency-pool.ts +38 -6
- package/src/execution/execute-options-mapper.ts +21 -4
- package/src/execution/execution-record.ts +5 -0
- package/src/execution/record-store.ts +2 -0
- package/src/execution/session-reconstructor.ts +11 -0
- package/src/execution/session-runner.ts +12 -0
- package/src/execution/stream-sink.ts +83 -0
- package/src/execution/subagent-service.ts +68 -43
- package/src/execution/subprocess-agent-runner.ts +16 -4
- package/src/execution/types.ts +23 -3
- package/src/index.ts +15 -2
- 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/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 +35 -22
- package/src/interface/subagent-tool.ts +54 -23
- 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/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,177 @@
|
|
|
1
|
+
// src/orchestration/__tests__/jsonl-run-store-session-file.test.ts
|
|
2
|
+
//
|
|
3
|
+
// W1: jsonl-run-store 序列化/反序列化 sessionFile round-trip 测试
|
|
4
|
+
//
|
|
5
|
+
// 防的 bug:sessionFile 加入 AgentCall + ExecutionTraceNode 后,序列化时必须写入快照,
|
|
6
|
+
// 反序列化时必须恢复——否则 pause/resume 或跨 session 重水合后 agent 的 session jsonl
|
|
7
|
+
// 路径丢失,overlay 无法定位。
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { AgentCall } from "../models/agent-call.ts";
|
|
16
|
+
import { Budget } from "../models/budget.ts";
|
|
17
|
+
import { Trace } from "../models/trace.ts";
|
|
18
|
+
import type { ExecutionTraceNode } from "../models/types.ts";
|
|
19
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
20
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
21
|
+
import { JsonlRunStore } from "../jsonl-run-store.ts";
|
|
22
|
+
|
|
23
|
+
function makeSpec(): RunSpec {
|
|
24
|
+
return {
|
|
25
|
+
scriptSource: "module.exports = async () => {};",
|
|
26
|
+
args: {},
|
|
27
|
+
scriptName: "test-script",
|
|
28
|
+
scriptPath: "/tmp/test.js",
|
|
29
|
+
description: "test",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeTraceNode(stepIndex: number): ExecutionTraceNode {
|
|
34
|
+
return {
|
|
35
|
+
stepIndex,
|
|
36
|
+
agent: "worker",
|
|
37
|
+
task: "do thing",
|
|
38
|
+
model: "default",
|
|
39
|
+
status: "pending",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeRunWithDoneCall(): WorkflowRun {
|
|
44
|
+
const trace = new Trace();
|
|
45
|
+
const node = makeTraceNode(0);
|
|
46
|
+
trace.append(node);
|
|
47
|
+
const call = new AgentCall(0, {
|
|
48
|
+
prompt: "task",
|
|
49
|
+
agent: "worker",
|
|
50
|
+
cwd: "/tmp",
|
|
51
|
+
} as never, node);
|
|
52
|
+
// 模拟已完成 agent call:带 sessionId + sessionFile
|
|
53
|
+
call.markRunning();
|
|
54
|
+
call.markDone({
|
|
55
|
+
content: "done",
|
|
56
|
+
sessionId: "session-abc",
|
|
57
|
+
sessionFile: "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
|
|
58
|
+
});
|
|
59
|
+
call.setSessionId("session-abc");
|
|
60
|
+
call.setSessionFile("/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl");
|
|
61
|
+
trace.update(0, {
|
|
62
|
+
status: "completed",
|
|
63
|
+
result: call.result,
|
|
64
|
+
completedAt: new Date().toISOString(),
|
|
65
|
+
sessionId: "session-abc",
|
|
66
|
+
sessionFile: "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return new WorkflowRun(
|
|
70
|
+
"run-test-001",
|
|
71
|
+
makeSpec(),
|
|
72
|
+
{
|
|
73
|
+
status: "done",
|
|
74
|
+
reason: "completed",
|
|
75
|
+
budget: new Budget(),
|
|
76
|
+
calls: new Map([[0, call]]),
|
|
77
|
+
trace,
|
|
78
|
+
errorLogs: [],
|
|
79
|
+
},
|
|
80
|
+
{ startedAt: new Date().toISOString(), completedAt: new Date().toISOString() },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("W1: JsonlRunStore sessionFile 序列化 round-trip", () => {
|
|
85
|
+
let tmpDir: string;
|
|
86
|
+
let store: JsonlRunStore;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-test-"));
|
|
90
|
+
store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("save + loadAll round-trip: AgentCall.sessionFile 保留", async () => {
|
|
98
|
+
const run = makeRunWithDoneCall();
|
|
99
|
+
await store.save(run);
|
|
100
|
+
|
|
101
|
+
// 从磁盘直接读快照验证 sessionFile 写入了序列化
|
|
102
|
+
const stateDir = path.join(tmpDir, "workflow-state");
|
|
103
|
+
const files = fs.readdirSync(stateDir).filter((f) => f.endsWith(".jsonl"));
|
|
104
|
+
expect(files).toHaveLength(1);
|
|
105
|
+
const raw = fs.readFileSync(path.join(stateDir, files[0]!), "utf8");
|
|
106
|
+
const snapshot = JSON.parse(raw.trim());
|
|
107
|
+
const serializedCall = snapshot.state.calls[0];
|
|
108
|
+
expect(serializedCall.sessionFile).toBe(
|
|
109
|
+
"/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("save + loadAll round-trip: ExecutionTraceNode.sessionFile 保留", async () => {
|
|
114
|
+
const run = makeRunWithDoneCall();
|
|
115
|
+
await store.save(run);
|
|
116
|
+
|
|
117
|
+
const raw = fs.readFileSync(
|
|
118
|
+
path.join(tmpDir, "workflow-state", "run-test-001.jsonl"),
|
|
119
|
+
"utf8",
|
|
120
|
+
);
|
|
121
|
+
const snapshot = JSON.parse(raw.trim());
|
|
122
|
+
const traceNode = snapshot.state.trace[0];
|
|
123
|
+
expect(traceNode.sessionFile).toBe(
|
|
124
|
+
"/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("save → loadAll 完整 round-trip: 反序列化后 AgentCall.sessionFile 可读", async () => {
|
|
129
|
+
// 闭环测试:serialize(save)→ deserialize(loadAll)→ 验证 run.state.calls 的 AgentCall.sessionFile
|
|
130
|
+
const sessionFilePath = "/abs/.pi/agent/subagents/enc/sessions/2026-07-15T_session-abc.jsonl";
|
|
131
|
+
|
|
132
|
+
// mock pi + ctx:save 写 pointer entry,loadAll 读同一组 entries
|
|
133
|
+
const entries: Array<{ type: string; customType?: string; data?: unknown }> = [];
|
|
134
|
+
const mockPi = {
|
|
135
|
+
appendEntry: vi.fn((type: string, data: unknown) => {
|
|
136
|
+
entries.push({ type: "custom", customType: type, data });
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
const mockCtx = {
|
|
140
|
+
sessionManager: { getEntries: () => entries },
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const storeWithCtx = new JsonlRunStore({
|
|
144
|
+
sessionDir: tmpDir,
|
|
145
|
+
pi: mockPi as never,
|
|
146
|
+
ctx: mockCtx as never,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const run = makeRunWithDoneCall();
|
|
150
|
+
await storeWithCtx.save(run);
|
|
151
|
+
|
|
152
|
+
const loaded = await storeWithCtx.loadAll();
|
|
153
|
+
expect(loaded).toHaveLength(1);
|
|
154
|
+
const restoredCall = loaded[0]!.state.calls.get(0);
|
|
155
|
+
expect(restoredCall).toBeDefined();
|
|
156
|
+
expect(restoredCall!.sessionFile).toBe(sessionFilePath);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("W2: RunStore.stateFilePath 暴露 run 状态文件路径", () => {
|
|
161
|
+
let tmpDir: string;
|
|
162
|
+
let store: JsonlRunStore;
|
|
163
|
+
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-test-"));
|
|
166
|
+
store = new JsonlRunStore({ sessionDir: tmpDir });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("stateFilePath(runId) 返回 <sessionDir>/workflow-state/<runId>.jsonl", () => {
|
|
174
|
+
const result = store.stateFilePath("run-xyz");
|
|
175
|
+
expect(result).toBe(path.join(tmpDir, "workflow-state", "run-xyz.jsonl"));
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -40,3 +40,18 @@ describe("buildWorkerScript — workflow() global injection", () => {
|
|
|
40
40
|
);
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
// ── H3: agent() task/agent 分支 skill 字段传递 ──
|
|
45
|
+
|
|
46
|
+
describe("buildWorkerScript — agent() skill field in task/agent branch", () => {
|
|
47
|
+
const script = buildWorkerScript("// noop user script");
|
|
48
|
+
|
|
49
|
+
it("task/agent branch includes skill in opts whitelist", () => {
|
|
50
|
+
// H3: agent({task, agent, skill}) 的 skill 在 task/agent 分支被丢弃。
|
|
51
|
+
// 验证生成的 worker 源码中,task/agent 分支的 opts 构造含 skill 字段。
|
|
52
|
+
// 找到 task/agent 分支的 opts 构造代码(含 firstArg.task || firstArg.agent)
|
|
53
|
+
const taskAgentBranch = script.match(/firstArg\.task \|\| firstArg\.agent[\s\S]*?\};/);
|
|
54
|
+
expect(taskAgentBranch).toBeTruthy();
|
|
55
|
+
expect(taskAgentBranch![0]).toContain("skill: firstArg.skill");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -50,7 +50,10 @@ export function resolveAgentOpts(
|
|
|
50
50
|
// Resolve agent system prompt
|
|
51
51
|
if (opts.agent) {
|
|
52
52
|
const discovered = agentRegistry.get(opts.agent); // 新 API: get() 替代 resolve(),返回 AgentConfig(含 systemPrompt+model)
|
|
53
|
-
if (!discovered)
|
|
53
|
+
if (!discovered) {
|
|
54
|
+
const available = agentRegistry.list().join(", ");
|
|
55
|
+
return { opts, error: `Agent not found: ${opts.agent}. Available: ${available || "(none)"}` };
|
|
56
|
+
}
|
|
54
57
|
|
|
55
58
|
const hasSystemPrompt = discovered.systemPrompt.trim().length > 0;
|
|
56
59
|
if (hasSystemPrompt) {
|
|
@@ -67,7 +70,13 @@ export function resolveAgentOpts(
|
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
// M3: 用 === undefined 而非 ||,避免空串被当 falsy 替换成 frontmatter model
|
|
74
|
+
opts = {
|
|
75
|
+
...opts,
|
|
76
|
+
model: opts.model === undefined ? discovered.model : opts.model,
|
|
77
|
+
// M2: 传播 agent .md frontmatter 的 thinkingLevel(之前 AgentCallOpts 无此字段导致丢失)
|
|
78
|
+
thinkingLevel: opts.thinkingLevel ?? discovered.thinkingLevel,
|
|
79
|
+
};
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
// Resolve skill name to SKILL.md path
|
|
@@ -25,28 +25,40 @@
|
|
|
25
25
|
* 参考:domain-models.md §失败处理矩阵。
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
|
|
29
|
+
import { createRecord, updateFromEvent } from "../execution/execution-record.ts";
|
|
30
|
+
import { SubagentStream } from "../execution/stream-sink.ts";
|
|
28
31
|
import type { AgentEvent } from "../shared/agent-event.ts";
|
|
29
32
|
import { resolveAgentOpts } from "./agent-opts-resolver.ts";
|
|
30
33
|
import { ConcurrencyGate, DEFAULT_CONCURRENCY } from "./concurrency-gate.ts";
|
|
31
|
-
import type { WorkerHandle } from "./worker-handle.ts";
|
|
32
34
|
import { executeAgentCall } from "./execute-agent-call.ts";
|
|
33
|
-
import { createRecord, updateFromEvent } from "../execution/execution-record.ts";
|
|
34
35
|
import { AgentCall } from "./models/agent-call.ts";
|
|
35
36
|
import type { LifecycleDeps, WorkerHandlers } from "./models/ports.ts";
|
|
36
37
|
import { RunRuntime } from "./models/run-runtime.ts";
|
|
37
38
|
import type { WorkerLogEntry } from "./models/types.ts";
|
|
38
39
|
import type { AgentCallOpts, AgentResult, ExecutionTraceNode } from "./models/types.ts";
|
|
39
40
|
import type { WorkflowRun } from "./models/workflow-run.ts";
|
|
41
|
+
import type { WorkerHandle } from "./worker-handle.ts";
|
|
40
42
|
|
|
41
43
|
// ── 常量 ─────────────────────────────────────────────────────
|
|
42
44
|
|
|
43
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* 单类错误最大重试次数(domain-models.md §失败处理矩阵)。
|
|
47
|
+
*
|
|
48
|
+
* 注意:workerErrorCount 和 scriptErrorCount 是两个独立计数器,各自上限 MAX_WORKER_RETRIES。
|
|
49
|
+
* 最坏情况(先连续 worker error 3 次 + 再连续 script error 3 次)= 6 次 rebuild。
|
|
50
|
+
* 这是有意设计——两类错误的根因不同(worker 崩溃 vs 脚本逻辑),合并计数会导致
|
|
51
|
+
* 不同根因的失败被过早判 failed。scheduleRebuild 的 retryIndex 取 max(两计数)。
|
|
52
|
+
*/
|
|
44
53
|
const MAX_WORKER_RETRIES = 3;
|
|
45
54
|
|
|
46
55
|
/** 指数退避基数(ms)。 */
|
|
47
56
|
const RETRY_BACKOFF_BASE_MS = 1000;
|
|
48
57
|
const EXPONENTIAL_BACKOFF_BASE = 2;
|
|
49
58
|
|
|
59
|
+
/** errorLogs 最大保留条数(防止超长 session 中日志无界增长)。 */
|
|
60
|
+
const MAX_ERROR_LOGS = 500;
|
|
61
|
+
|
|
50
62
|
// ── Worker 消息类型(与 infra/worker-script-builder.ts WorkerInMsg 对齐) ──
|
|
51
63
|
|
|
52
64
|
interface AgentCallMsg {
|
|
@@ -99,7 +111,10 @@ function backoffDelay(retryIndex: number): number {
|
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
function delay(ms: number): Promise<void> {
|
|
102
|
-
return new Promise((resolve) =>
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const timer = setTimeout(resolve, ms);
|
|
116
|
+
timer.unref();
|
|
117
|
+
});
|
|
103
118
|
}
|
|
104
119
|
|
|
105
120
|
// ── rebuildRuntime(G3-001 整重建) ─────────────────────────
|
|
@@ -158,6 +173,8 @@ export async function handleWorkerMessage(
|
|
|
158
173
|
// 终态/paused 状态丢弃 stale 消息(P0-1)
|
|
159
174
|
if (isTerminal(run) || run.state.status === "paused") return;
|
|
160
175
|
|
|
176
|
+
// M7: 形状校验——防畸形 IPC 消息(worker 崩溃/发非对象)导致下游 TypeError
|
|
177
|
+
if (typeof raw !== "object" || raw === null) return;
|
|
161
178
|
const msg = raw as WorkerMsg;
|
|
162
179
|
switch (msg.type) {
|
|
163
180
|
case "agent-call":
|
|
@@ -202,6 +219,16 @@ function dispatchAgentCall(
|
|
|
202
219
|
msg: AgentCallMsg,
|
|
203
220
|
deps: LifecycleDeps,
|
|
204
221
|
): void {
|
|
222
|
+
// M4: IPC 字段校验——畸形 agent-call 消息(opts 非对象/缺失、callId 非数字、prompt 缺失)
|
|
223
|
+
// 不写 trace / 不 postAgentResult——这类消息通常意味着 worker 模块版本不匹配或内存损坏,
|
|
224
|
+
// 回发结果给 worker 也没意义(worker 可能已崩)。仅记日志,让 worker timeout/exit 路径接管。
|
|
225
|
+
if (typeof msg.callId !== "number" || !Number.isFinite(msg.callId) ||
|
|
226
|
+
typeof msg.opts !== "object" || msg.opts === null ||
|
|
227
|
+
typeof msg.opts.prompt !== "string") {
|
|
228
|
+
console.error(`[workflow] malformed agent-call message: callId=${JSON.stringify(msg.callId)}, opts=${JSON.stringify(msg.opts)?.slice(0, 200)}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
205
232
|
// 已缓存的调用直接 replay(跨 pause/resume)
|
|
206
233
|
const cached = run.state.calls.get(msg.callId);
|
|
207
234
|
if (cached && cached.status === "done") {
|
|
@@ -209,8 +236,10 @@ function dispatchAgentCall(
|
|
|
209
236
|
return;
|
|
210
237
|
}
|
|
211
238
|
|
|
212
|
-
|
|
239
|
+
// 构建 trace 节点 + live record(TUI 实时进度)
|
|
213
240
|
const agentName = msg.opts.description ?? msg.opts.agent ?? "unknown";
|
|
241
|
+
// slug 复用 agentName(超长截断),live record 的 slug 仅用于 TUI 展示。
|
|
242
|
+
const liveSlug = agentName.length > SLUG_MAX_LENGTH ? agentName.slice(0, SLUG_MAX_LENGTH) : agentName;
|
|
214
243
|
const now = new Date().toISOString();
|
|
215
244
|
// live record:收口 agent 执行过程中的 text/thinking/toolCalls/usage,
|
|
216
245
|
// 供 TUI 在 agent 运行期间显示进度(getEventLog/getCurrentActivity)。
|
|
@@ -220,6 +249,7 @@ function dispatchAgentCall(
|
|
|
220
249
|
model: msg.opts.model ?? "default",
|
|
221
250
|
mode: "background",
|
|
222
251
|
task: msg.opts.prompt,
|
|
252
|
+
slug: liveSlug,
|
|
223
253
|
startedAt: Date.now(),
|
|
224
254
|
});
|
|
225
255
|
const node: ExecutionTraceNode = {
|
|
@@ -268,7 +298,9 @@ function dispatchAgentCall(
|
|
|
268
298
|
completedAt: new Date().toISOString(),
|
|
269
299
|
});
|
|
270
300
|
postAgentResult(run, msg.callId, errorResult, false);
|
|
271
|
-
|
|
301
|
+
deps.store.save(run).catch((e: unknown) => {
|
|
302
|
+
console.error(`[workflow] store.save failed (resolveAgentOpts): ${e instanceof Error ? e.message : String(e)}`);
|
|
303
|
+
});
|
|
272
304
|
return;
|
|
273
305
|
}
|
|
274
306
|
|
|
@@ -290,19 +322,38 @@ function dispatchAgentCall(
|
|
|
290
322
|
const onEvent = (event: AgentEvent): void => {
|
|
291
323
|
updateFromEvent(liveRecord, event);
|
|
292
324
|
};
|
|
325
|
+
// 创建 streaming sink:widgetKey = subagent-stream-<runId>-<stepIndex>。
|
|
326
|
+
// 复用 background subagent 的 SubagentStream → setWidget → RPC 链路(agent-call-streaming-extension.md)。
|
|
327
|
+
// streamSink 缺失(无 UI 模式)时 stream=undefined,executeAgentCall 正常执行不 streaming。
|
|
328
|
+
const stream = deps.streamSink
|
|
329
|
+
? new SubagentStream(`${run.runId}-${msg.callId}`, deps.streamSink)
|
|
330
|
+
: undefined;
|
|
293
331
|
void runtime.gate
|
|
294
|
-
.withSlot(
|
|
332
|
+
.withSlot(
|
|
333
|
+
async () => {
|
|
334
|
+
try {
|
|
335
|
+
await executeAgentCall(call, deps.runner, run.state.budget, signal, run.state.trace, onEvent, stream);
|
|
336
|
+
} finally {
|
|
337
|
+
stream?.dispose();
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
signal,
|
|
341
|
+
)
|
|
295
342
|
.then(() => {
|
|
343
|
+
// 清除 live record:终态已由 executeAgentCall → finalizeCall 写入 node.result,
|
|
344
|
+
// live 不再需要(且含可变状态,不保留)。无论 stale 与否都清,避免内存泄漏。
|
|
345
|
+
// M4: 必须在 stale guard 之前清,否则 pause/resume 循环下 live record 累积。
|
|
346
|
+
node.live = undefined;
|
|
296
347
|
// pause/abort 后到达的 stale completion 不写 state(pause 是干净快照)
|
|
297
348
|
if (run.state.status !== "running") return;
|
|
298
|
-
// 清除 live record:终态已由 executeAgentCall → finalizeCall 写入 node.result,
|
|
299
|
-
// live 不再需要(且含可变状态,不保留)。无论 stale 与否都清,避免内存泄漏。
|
|
300
|
-
node.live = undefined;
|
|
301
349
|
if (call.result) postAgentResult(run, msg.callId, call.result, false);
|
|
302
350
|
// D-12 regression fix (round-2 #1):executeAgentCall 内 consume/incrementCallCount
|
|
303
351
|
// 后同步 worker $BUDGET(否则 $BUDGET.spent()/remaining() 恒为 0)
|
|
304
352
|
postBudgetUpdate(run);
|
|
305
|
-
|
|
353
|
+
deps.store.save(run).catch((e: unknown) => {
|
|
354
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
355
|
+
console.error(`[workflow] store.save failed (agent call ${msg.callId}): ${m}`);
|
|
356
|
+
});
|
|
306
357
|
|
|
307
358
|
// C-2:budget 超限 → 终止整个 run(避免继续 spawn 烧预算)
|
|
308
359
|
// 内联 terminate(不调 lifecycle.abortRun 避免 engine 内循环依赖):
|
|
@@ -311,27 +362,68 @@ function dispatchAgentCall(
|
|
|
311
362
|
if (run.state.budget.isExceeded()) {
|
|
312
363
|
run.state.error = run.state.error ?? "Budget exceeded";
|
|
313
364
|
deps.log?.("debug", "workflow:error-recovery", "budget exceeded, transition done", { runId: run.runId });
|
|
365
|
+
// M12: transition 单独 try——并发 abort 导致 illegal-transition 是预期的,可忽略
|
|
366
|
+
let transitioned = false;
|
|
314
367
|
try {
|
|
315
368
|
run.transition("done", "budget_limited");
|
|
316
|
-
|
|
369
|
+
transitioned = true;
|
|
370
|
+
} catch (te: unknown) {
|
|
371
|
+
// run 可能在 budget 检查后、transition 前被并发 abort——预期,不记错
|
|
372
|
+
void te;
|
|
373
|
+
}
|
|
374
|
+
if (transitioned) {
|
|
375
|
+
deps.store.save(run).catch((e: unknown) => {
|
|
376
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
377
|
+
console.error(`[workflow] store.save failed (budget done): ${m}`);
|
|
378
|
+
});
|
|
317
379
|
deps.log?.("debug", "workflow:error-recovery", "run saved after budget done", { runId: run.runId, reason: run.state.reason });
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
380
|
+
// M12: onRunDone/emit 单独 try——这些是真实副作用,错误不应被静默吞掉
|
|
381
|
+
try {
|
|
382
|
+
deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister", { runId: run.runId, reason: run.state.reason });
|
|
383
|
+
deps.eventBus?.emit("pending:unregister", { id: run.runId, reason: run.state.reason ?? "completed" });
|
|
384
|
+
deps.log?.("debug", "workflow:error-recovery", "emit pending:unregister done", { runId: run.runId });
|
|
385
|
+
deps.onRunDone?.(run);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
388
|
+
console.error(`[workflow] onRunDone/emit failed (budget done): ${m}`);
|
|
389
|
+
}
|
|
326
390
|
}
|
|
327
391
|
}
|
|
328
392
|
})
|
|
329
393
|
.catch((err: unknown) => {
|
|
330
394
|
// withSlot 在 queued + signal-aborted 时 reject AbortError——预期,不记错。
|
|
331
|
-
// executeAgentCall 本身不 reject(runner.run 不 reject)。
|
|
332
395
|
if (err instanceof Error && err.name === "AbortError") return;
|
|
333
396
|
const message = err instanceof Error ? err.message : String(err);
|
|
334
397
|
console.error(`[workflow] agent call ${msg.callId} failed: ${message}`);
|
|
398
|
+
// 兜底回发:executeAgentCall 抛非 Abort 异常时(如 runner undefined 的 TypeError、
|
|
399
|
+
// gate.withSlot 内部 bug)原 catch 仅 console.error,worker 内对 callId 的 pending
|
|
400
|
+
// Promise 永不 resolve → agent() 永久 await → worker 脚本挂死。构造 failed AgentResult
|
|
401
|
+
//(与 resolveAgentOpts 失败路径 L262-275 一致的模式)postAgentResult 回 worker,
|
|
402
|
+
// 让 pending Promise resolve(结果为 error),脚本可继续或失败退出。
|
|
403
|
+
const errorResult: AgentResult = { content: "", error: message };
|
|
404
|
+
// call 已 done(executeAgentCall 内 finalizeCall 已 markDone)时跳过,避免重复 markDone。
|
|
405
|
+
// status 理论上必为 running(executeAgentCall L130 markRunning 先于 reject),pending
|
|
406
|
+
// 分支为防御性保护。非 running/done 意外态:跳过 markDone(markDone 要求 running)。
|
|
407
|
+
if (call.status !== "done") {
|
|
408
|
+
if (call.status === "pending") call.markRunning();
|
|
409
|
+
call.markDone(errorResult);
|
|
410
|
+
}
|
|
411
|
+
// state 一致性三件套(与 resolveAgentOpts 失败 L268-276 / .then L319-325 对等):
|
|
412
|
+
// trace 标 failed + 清 live record(防泄漏)+ 持久化(catch 恰是最需留证的场景)。
|
|
413
|
+
// stale 终态(run 已 paused/done)时 run.runtime 为 undefined,postAgentResult 用
|
|
414
|
+
// optional chaining 跳过 worker 回发;trace/state 写入仍执行(无害,pause 快照已存)。
|
|
415
|
+
node.live = undefined;
|
|
416
|
+
run.state.trace.update(msg.callId, {
|
|
417
|
+
status: "failed",
|
|
418
|
+
result: errorResult,
|
|
419
|
+
completedAt: new Date().toISOString(),
|
|
420
|
+
});
|
|
421
|
+
postAgentResult(run, msg.callId, errorResult, false);
|
|
422
|
+
// S2: 与 .then 对称——catch 路径也同步 worker $BUDGET(幂等)
|
|
423
|
+
postBudgetUpdate(run);
|
|
424
|
+
deps.store.save(run).catch((e: unknown) => {
|
|
425
|
+
console.error(`[workflow] store.save failed (catch fallback): ${e instanceof Error ? e.message : String(e)}`);
|
|
426
|
+
});
|
|
335
427
|
});
|
|
336
428
|
}
|
|
337
429
|
|
|
@@ -347,6 +439,14 @@ function dispatchWorkflowCall(
|
|
|
347
439
|
msg: WorkflowCallMsg,
|
|
348
440
|
deps: LifecycleDeps,
|
|
349
441
|
): void {
|
|
442
|
+
// M4: IPC 字段校验——畸形 workflow-call 消息
|
|
443
|
+
if (typeof msg.callId !== "number" || !Number.isFinite(msg.callId) ||
|
|
444
|
+
typeof msg.name !== "string" ||
|
|
445
|
+
typeof msg.args !== "object" || msg.args === null) {
|
|
446
|
+
console.error(`[workflow] malformed workflow-call message: callId=${JSON.stringify(msg.callId)}, name=${JSON.stringify(msg.name)}`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
350
450
|
const postResult = (result: unknown): void => {
|
|
351
451
|
if (run.state.status !== "running") return;
|
|
352
452
|
run.runtime?.worker.postMessage({
|
|
@@ -415,8 +515,12 @@ async function handleReturn(
|
|
|
415
515
|
): Promise<void> {
|
|
416
516
|
deps.log?.("debug", "workflow:error-recovery", "handleReturn", { runId: run.runId, status: run.state.status });
|
|
417
517
|
// 捕获 worker 诊断日志(P2-2)
|
|
518
|
+
// L9: 追加而非覆盖——保留重试历史的诊断日志(各 worker 实例的 console 输出)
|
|
418
519
|
if (msg.workerLogs && msg.workerLogs.length > 0) {
|
|
419
|
-
run.state.errorLogs
|
|
520
|
+
run.state.errorLogs.push(...msg.workerLogs);
|
|
521
|
+
if (run.state.errorLogs.length > MAX_ERROR_LOGS) {
|
|
522
|
+
run.state.errorLogs = run.state.errorLogs.slice(-MAX_ERROR_LOGS);
|
|
523
|
+
}
|
|
420
524
|
}
|
|
421
525
|
run.state.scriptResult = msg.result;
|
|
422
526
|
run.transition("done", "completed");
|
|
@@ -526,8 +630,12 @@ export async function handleScriptError(
|
|
|
526
630
|
if (isTerminal(run) || run.state.status === "paused") return;
|
|
527
631
|
|
|
528
632
|
// P2-2: 捕获 worker 诊断日志
|
|
633
|
+
// L9: 追加而非覆盖
|
|
529
634
|
if (workerLogs.length > 0) {
|
|
530
|
-
run.state.errorLogs
|
|
635
|
+
run.state.errorLogs.push(...workerLogs);
|
|
636
|
+
if (run.state.errorLogs.length > MAX_ERROR_LOGS) {
|
|
637
|
+
run.state.errorLogs = run.state.errorLogs.slice(-MAX_ERROR_LOGS);
|
|
638
|
+
}
|
|
531
639
|
}
|
|
532
640
|
|
|
533
641
|
const count = (run.meta.scriptErrorCount ?? 0) + 1;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* 参考:domain-models.md §5 + §失败处理矩阵。
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import type { SubagentStream } from "../execution/stream-sink.ts";
|
|
26
27
|
import type { AgentEvent } from "../shared/agent-event.ts";
|
|
27
28
|
import type { AgentCall } from "./models/agent-call.ts";
|
|
28
29
|
import type { Budget } from "./models/budget.ts";
|
|
@@ -81,11 +82,15 @@ function backoffDelay(retryIndex: number): number {
|
|
|
81
82
|
function finalizeCall(call: AgentCall, result: AgentResult, trace: Trace): void {
|
|
82
83
|
call.markDone(result);
|
|
83
84
|
const status = result.error === undefined ? "completed" : "failed";
|
|
85
|
+
// 同步 AgentCall 的 sessionId/sessionFile(对齐 trace 节点,持久化 + reset 用)
|
|
86
|
+
if (result.sessionId !== undefined) call.setSessionId(result.sessionId);
|
|
87
|
+
if (result.sessionFile !== undefined) call.setSessionFile(result.sessionFile);
|
|
84
88
|
trace.update(call.id, {
|
|
85
89
|
status,
|
|
86
90
|
result,
|
|
87
91
|
completedAt: new Date().toISOString(),
|
|
88
92
|
sessionId: result.sessionId,
|
|
93
|
+
sessionFile: result.sessionFile,
|
|
89
94
|
});
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -93,7 +98,10 @@ function finalizeCall(call: AgentCall, result: AgentResult, trace: Trace): void
|
|
|
93
98
|
* 延迟工具(testable —— 测试可通过 fake timers 推进)。
|
|
94
99
|
*/
|
|
95
100
|
function delay(ms: number): Promise<void> {
|
|
96
|
-
return new Promise((resolve) =>
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const timer = setTimeout(resolve, ms);
|
|
103
|
+
timer.unref();
|
|
104
|
+
});
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
// ── executeAgentCall ─────────────────────────────────────────
|
|
@@ -124,10 +132,11 @@ export async function executeAgentCall(
|
|
|
124
132
|
signal: AbortSignal,
|
|
125
133
|
trace: Trace,
|
|
126
134
|
onEvent?: (event: AgentEvent) => void,
|
|
135
|
+
stream?: SubagentStream,
|
|
127
136
|
): Promise<void> {
|
|
128
137
|
call.markRunning();
|
|
129
138
|
|
|
130
|
-
const result = await runner.run(call.opts, signal, onEvent);
|
|
139
|
+
const result = await runner.run(call.opts, signal, onEvent, stream);
|
|
131
140
|
|
|
132
141
|
// 累加 usage(加权由 budget.consume 内部按权重常量处理,见 budget.ts)
|
|
133
142
|
if (result.usage) {
|
|
@@ -164,7 +173,7 @@ export async function executeAgentCall(
|
|
|
164
173
|
budget.incrementCallCount();
|
|
165
174
|
return;
|
|
166
175
|
}
|
|
167
|
-
await executeAgentCall(call, runner, budget, signal, trace, onEvent);
|
|
176
|
+
await executeAgentCall(call, runner, budget, signal, trace, onEvent, stream);
|
|
168
177
|
return;
|
|
169
178
|
}
|
|
170
179
|
|
|
@@ -75,6 +75,7 @@ interface RunSnapshot {
|
|
|
75
75
|
attempts: number;
|
|
76
76
|
result?: AgentResult;
|
|
77
77
|
sessionId?: string;
|
|
78
|
+
sessionFile?: string;
|
|
78
79
|
traceNode: ExecutionTraceNode;
|
|
79
80
|
}>;
|
|
80
81
|
trace: ExecutionTraceNode[];
|
|
@@ -119,6 +120,7 @@ function serializeRun(run: WorkflowRun): RunSnapshot {
|
|
|
119
120
|
attempts: c.attempts,
|
|
120
121
|
result: c.result,
|
|
121
122
|
sessionId: c.sessionId,
|
|
123
|
+
sessionFile: c.sessionFile,
|
|
122
124
|
traceNode: traceNodeRest,
|
|
123
125
|
};
|
|
124
126
|
}),
|
|
@@ -162,6 +164,9 @@ function deserializeRun(snapshot: RunSnapshot): WorkflowRun | null {
|
|
|
162
164
|
if (c.sessionId !== undefined) {
|
|
163
165
|
call.setSessionId(c.sessionId);
|
|
164
166
|
}
|
|
167
|
+
if (c.sessionFile !== undefined) {
|
|
168
|
+
call.setSessionFile(c.sessionFile);
|
|
169
|
+
}
|
|
165
170
|
calls.set(c.id, call);
|
|
166
171
|
}
|
|
167
172
|
|
|
@@ -224,6 +229,11 @@ export class JsonlRunStore {
|
|
|
224
229
|
return path.join(this.stateDir, `${runId}.jsonl`);
|
|
225
230
|
}
|
|
226
231
|
|
|
232
|
+
/** Public accessor: run 状态快照文件绝对路径(RunStore port 实现)。 */
|
|
233
|
+
stateFilePath(runId: string): string {
|
|
234
|
+
return this.filePathFor(runId);
|
|
235
|
+
}
|
|
236
|
+
|
|
227
237
|
/**
|
|
228
238
|
* Persist a single run: rewrite mode (overwrite) — file always contains the
|
|
229
239
|
* latest complete snapshot on a single line. Appends a workflow-state-link
|
|
@@ -208,7 +208,7 @@ export async function runWorkflow(
|
|
|
208
208
|
deps.eventBus?.emit("pending:register", {
|
|
209
209
|
id: runId,
|
|
210
210
|
type: "workflow",
|
|
211
|
-
name: spec.scriptName || runId,
|
|
211
|
+
name: spec.slug || spec.scriptName || runId,
|
|
212
212
|
});
|
|
213
213
|
deps.log?.("debug", "workflow:lifecycle", "emit pending:register done", { runId });
|
|
214
214
|
|
|
@@ -36,6 +36,8 @@ export class AgentCall {
|
|
|
36
36
|
result?: AgentResult;
|
|
37
37
|
/** Pi subprocess session ID(uuidv7,G-017 归此)。 */
|
|
38
38
|
sessionId?: string;
|
|
39
|
+
/** Session JSONL 绝对路径(finalizeCall 后从 result.sessionFile 填入,对齐 sessionId 模式)。 */
|
|
40
|
+
sessionFile?: string;
|
|
39
41
|
/** 与 Trace 共享的节点引用(D-10 单源)。AgentCall 不直接改其字段。 */
|
|
40
42
|
readonly traceNode: ExecutionTraceNode;
|
|
41
43
|
|
|
@@ -73,4 +75,9 @@ export class AgentCall {
|
|
|
73
75
|
setSessionId(sessionId: string): void {
|
|
74
76
|
this.sessionId = sessionId;
|
|
75
77
|
}
|
|
78
|
+
|
|
79
|
+
/** 记录 session JSONL 绝对路径(finalizeCall 后,对齐 setSessionId 模式)。 */
|
|
80
|
+
setSessionFile(sessionFile: string): void {
|
|
81
|
+
this.sessionFile = sessionFile;
|
|
82
|
+
}
|
|
76
83
|
}
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 层归属:Engine。零 infra 依赖(AC-1)。
|
|
12
12
|
*/
|
|
13
|
-
import type { AgentEvent } from "../../shared/agent-event.ts";
|
|
14
13
|
import type { AgentRegistry } from "../../execution/agent-registry.ts";
|
|
14
|
+
import type { StreamSink, SubagentStream } from "../../execution/stream-sink.ts";
|
|
15
|
+
import type { AgentEvent } from "../../shared/agent-event.ts";
|
|
15
16
|
import type { WorkerHandle } from "../worker-handle.ts";
|
|
16
17
|
import type { RunSpec } from "./run-spec.ts";
|
|
17
18
|
import type { AgentCallOpts, AgentResult } from "./types.ts";
|
|
@@ -32,7 +33,7 @@ import type { WorkflowRun } from "./workflow-run.ts";
|
|
|
32
33
|
* raw JSONL 中间层(executeAndAwait 直接出 AgentEvent,session-runner handleSdkEvent 出口)。
|
|
33
34
|
*/
|
|
34
35
|
export interface AgentRunner {
|
|
35
|
-
run(opts: AgentCallOpts, signal: AbortSignal, onEvent?: (event: AgentEvent) => void): Promise<AgentResult>;
|
|
36
|
+
run(opts: AgentCallOpts, signal: AbortSignal, onEvent?: (event: AgentEvent) => void, stream?: SubagentStream): Promise<AgentResult>;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
// ── Port 2: RunStore ──────────────────────────────────────────
|
|
@@ -42,10 +43,13 @@ export interface AgentRunner {
|
|
|
42
43
|
*
|
|
43
44
|
* save 在每次状态变更后持久化整个 WorkflowRun(聚合根);
|
|
44
45
|
* loadAll 在 session_start 时重水合(D-5:JSONL 不向后兼容旧 session,旧格式返回空)。
|
|
46
|
+
* stateFilePath 返回 run 状态文件的绝对路径(供 overlay/GUI 暴露给用户)。
|
|
45
47
|
*/
|
|
46
48
|
export interface RunStore {
|
|
47
49
|
save(run: WorkflowRun): Promise<void>;
|
|
48
50
|
loadAll(): Promise<WorkflowRun[]>;
|
|
51
|
+
/** 返回 run 状态快照文件的绝对路径:<sessionDir>/workflow-state/<runId>.jsonl */
|
|
52
|
+
stateFilePath(runId: string): string;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
// ── Port 3: WorkerHost ────────────────────────────────────────
|
|
@@ -162,4 +166,13 @@ export interface LifecycleDeps {
|
|
|
162
166
|
args: Record<string, unknown>,
|
|
163
167
|
parentRun: WorkflowRun,
|
|
164
168
|
) => Promise<unknown>;
|
|
169
|
+
/**
|
|
170
|
+
* UI streaming sink(ctx.ui.setWidget),workflow agent call 创建 SubagentStream 用。
|
|
171
|
+
*
|
|
172
|
+
* 由 Interface 层 makeDeps 注入(从 SubagentService.getStreamSink() 取)。
|
|
173
|
+
* dispatchAgentCall 用它创建 SubagentStream(widgetKey=subagent-stream-<runId>-<stepIndex>),
|
|
174
|
+
* 使 workflow agent call 的 text_delta 走与 background subagent 相同的 streaming 链路。
|
|
175
|
+
* 可选——无 UI 模式(TUI/RPC 无 setWidget)时为 undefined,dispatchAgentCall 不创建 stream。
|
|
176
|
+
*/
|
|
177
|
+
streamSink?: StreamSink;
|
|
165
178
|
}
|