@zhushanwen/pi-subagent-workflow 8.0.0 → 8.1.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/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/src/execution/__tests__/finalize-record.test.ts +33 -7
- package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
- package/src/execution/__tests__/record-store.test.ts +284 -2
- package/src/execution/__tests__/spawn-args.test.ts +5 -5
- package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
- package/src/execution/__tests__/worktree-manager.test.ts +9 -2
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +1 -1
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/finalize-record.ts +16 -7
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +9 -2
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +290 -1
- package/src/execution/session-pending.ts +5 -4
- package/src/execution/session-runner.ts +484 -289
- package/src/execution/subagent-service.ts +87 -37
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/types.ts +20 -2
- package/src/execution/worktree-manager.ts +325 -14
- package/src/execution/worktree-registry.ts +90 -33
- package/src/index.ts +42 -2
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- package/src/interface/format.ts +10 -4
- package/src/interface/tool-render.ts +10 -3
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +47 -65
- package/src/interface/views/detail-content.ts +1 -1
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
- package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/test-mocks.ts +9 -3
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
- package/src/orchestration/error-recovery.ts +4 -1
- package/src/orchestration/execute-agent-call.ts +13 -3
- package/src/orchestration/jsonl-run-store.ts +139 -60
- package/src/orchestration/lifecycle.ts +10 -1
- package/src/orchestration/worker-script-builder.ts +9 -1
- package/src/shared/__tests__/resource-discovery.test.ts +24 -0
- package/src/shared/agent-ref.ts +6 -1
- package/src/shared/resource-discovery.ts +15 -1
- package/src/shared/schema-jsonify.ts +4 -1
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -211,8 +211,9 @@ export function registerWorkflowScriptTool(
|
|
|
211
211
|
result = await actionList(registry);
|
|
212
212
|
break;
|
|
213
213
|
default:
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
// 防御性(schema StringEnum 先拦):throw(W4b)——pi 只对 execute throw 置
|
|
215
|
+
// isError:true,返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483)。
|
|
216
|
+
throw new Error(`Unknown action: ${String(params.action)}`);
|
|
216
217
|
}
|
|
217
218
|
// GUI 协议:RPC 模式下附加 __gui__ 到 details
|
|
218
219
|
return withScriptGui(result, toGuiCtx(ctx));
|
|
@@ -241,28 +242,27 @@ export function registerWorkflowScriptTool(
|
|
|
241
242
|
|
|
242
243
|
export function actionGenerate(params: ScriptParams, signal: AbortSignal | undefined): TextContent {
|
|
243
244
|
if (signal?.aborted) {
|
|
244
|
-
|
|
245
|
+
// throw(W4b):pi 只对 execute throw 置 isError:true(返回值 isError 被丢弃)
|
|
246
|
+
throw new Error("Operation aborted before start");
|
|
245
247
|
}
|
|
246
248
|
const name = params.name;
|
|
247
249
|
const script = params.script;
|
|
248
250
|
if (!name || !script) {
|
|
249
|
-
|
|
251
|
+
throw new Error("generate requires 'name' and 'script' parameters");
|
|
250
252
|
}
|
|
251
253
|
|
|
252
254
|
// 1. Reject ESM syntax (Worker runs CJS); 'export const meta' 例外
|
|
253
255
|
const stripped = script.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
254
256
|
if (/\bimport\s+(?:type\s+)?[\w{*]/.test(stripped)) {
|
|
255
|
-
|
|
257
|
+
throw new Error(
|
|
256
258
|
"Script uses ESM 'import' syntax. Workflow scripts run in a CJS Worker — use require() instead.",
|
|
257
|
-
true,
|
|
258
259
|
);
|
|
259
260
|
}
|
|
260
261
|
const hasExportMeta = /\bexport\s+const\s+meta\s*=/.test(stripped);
|
|
261
262
|
const otherExports = stripped.match(/\bexport\s+(?:const|let|var|function|default|\{)/g);
|
|
262
263
|
if (otherExports && !hasExportMeta) {
|
|
263
|
-
|
|
264
|
+
throw new Error(
|
|
264
265
|
"Script uses ESM 'export' (non-meta). Use 'const meta = {...}' at top level instead.",
|
|
265
|
-
true,
|
|
266
266
|
);
|
|
267
267
|
}
|
|
268
268
|
|
|
@@ -270,17 +270,15 @@ export function actionGenerate(params: ScriptParams, signal: AbortSignal | undef
|
|
|
270
270
|
const hasPiMeta = /\/\*\s*@pi-meta\s*\n/.test(script);
|
|
271
271
|
const hasLegacyMeta = script.includes("const meta") || script.includes("export const meta");
|
|
272
272
|
if (!hasPiMeta && !hasLegacyMeta) {
|
|
273
|
-
|
|
273
|
+
throw new Error(
|
|
274
274
|
"Script must contain a meta declaration: a /* @pi-meta */ YAML block comment (preferred) or legacy const meta = { ... }. The block has the form: a block comment starting with /* @pi-meta followed by YAML (name/description/phases/parameters?/usage?), closed by */ on its own line.",
|
|
275
|
-
true,
|
|
276
275
|
);
|
|
277
276
|
}
|
|
278
277
|
|
|
279
278
|
// 3. Check agent usage
|
|
280
279
|
if (!/\bagent\s*\(/.test(stripped)) {
|
|
281
|
-
|
|
280
|
+
throw new Error(
|
|
282
281
|
"Script does not contain any agent() calls. A workflow must call agent() at least once.",
|
|
283
|
-
true,
|
|
284
282
|
);
|
|
285
283
|
}
|
|
286
284
|
|
|
@@ -290,7 +288,7 @@ export function actionGenerate(params: ScriptParams, signal: AbortSignal | undef
|
|
|
290
288
|
new Function(`(async () => { ${cjsScript} })();`);
|
|
291
289
|
} catch (err: unknown) {
|
|
292
290
|
const msg = err instanceof Error ? err.message : String(err);
|
|
293
|
-
|
|
291
|
+
throw new Error(`Syntax error in script: ${msg}`);
|
|
294
292
|
}
|
|
295
293
|
|
|
296
294
|
// 4b. Round-trip: validate /* @pi-meta */ YAML before writing (v5 §4.7 / ERR4 — report linePos, don't write bad files)
|
|
@@ -300,9 +298,8 @@ export function actionGenerate(params: ScriptParams, signal: AbortSignal | undef
|
|
|
300
298
|
const loc = "linePos" in detailed && detailed.linePos
|
|
301
299
|
? ` (line ${detailed.linePos.line}, col ${detailed.linePos.col})`
|
|
302
300
|
: "";
|
|
303
|
-
|
|
301
|
+
throw new Error(
|
|
304
302
|
`Generated /* @pi-meta */ YAML cannot be parsed${loc}: ${detailed.error}. Common causes: YAML indent errors, patternProperties regex must use double backslash (\\d not \d), or a stray star-slash inside the YAML body. Fix the meta block and retry.`,
|
|
305
|
-
true,
|
|
306
303
|
);
|
|
307
304
|
}
|
|
308
305
|
}
|
|
@@ -332,7 +329,7 @@ async function actionLint(
|
|
|
332
329
|
): Promise<TextContent> {
|
|
333
330
|
const name = params.name;
|
|
334
331
|
if (!name) {
|
|
335
|
-
|
|
332
|
+
throw new Error("lint requires 'name' parameter");
|
|
336
333
|
}
|
|
337
334
|
const source = await loadScriptSource(name, registry);
|
|
338
335
|
if (!source) {
|
|
@@ -341,9 +338,8 @@ async function actionLint(
|
|
|
341
338
|
const suggestions = available
|
|
342
339
|
.map((wf) => ` - ${wf.name}: ${wf.meta.description || "(no description)"}`)
|
|
343
340
|
.join("\n");
|
|
344
|
-
|
|
341
|
+
throw new Error(
|
|
345
342
|
`Workflow '${name}' not found or not available.\nAvailable:\n${suggestions || " (none)"}`,
|
|
346
|
-
true,
|
|
347
343
|
);
|
|
348
344
|
}
|
|
349
345
|
|
|
@@ -386,7 +382,7 @@ async function loadScriptSource(
|
|
|
386
382
|
async function actionSave(params: ScriptParams): Promise<TextContent> {
|
|
387
383
|
const name = params.name;
|
|
388
384
|
if (!name) {
|
|
389
|
-
|
|
385
|
+
throw new Error("save requires 'name' parameter (tmp script name)");
|
|
390
386
|
}
|
|
391
387
|
try {
|
|
392
388
|
const result = await saveWorkflow(name, params.newName);
|
|
@@ -395,12 +391,10 @@ async function actionSave(params: ScriptParams): Promise<TextContent> {
|
|
|
395
391
|
details: { action: "save", name, ok: true },
|
|
396
392
|
};
|
|
397
393
|
} catch (err: unknown) {
|
|
394
|
+
// throw(W4):pi 只对 execute throw 置 isError:true(返回值里的 isError 被
|
|
395
|
+
// agent-loop 丢弃,agent-loop.js:453-483)——文案原样进 toolResult。
|
|
398
396
|
const msg = err instanceof Error ? err.message : String(err);
|
|
399
|
-
|
|
400
|
-
content: [{ type: "text", text: `Save failed: ${msg}` }],
|
|
401
|
-
details: { action: "save", name, ok: false },
|
|
402
|
-
isError: true,
|
|
403
|
-
};
|
|
397
|
+
throw new Error(`Save failed: ${msg}`);
|
|
404
398
|
}
|
|
405
399
|
}
|
|
406
400
|
|
|
@@ -413,7 +407,7 @@ function actionDelete(
|
|
|
413
407
|
): TextContent {
|
|
414
408
|
const name = params.name;
|
|
415
409
|
if (!name) {
|
|
416
|
-
|
|
410
|
+
throw new Error("delete requires 'name' parameter");
|
|
417
411
|
}
|
|
418
412
|
// deleteWorkflow 内部检查 isRunning(防止删运行中脚本)
|
|
419
413
|
try {
|
|
@@ -425,12 +419,9 @@ function actionDelete(
|
|
|
425
419
|
details: { action: "delete", name, ok: true },
|
|
426
420
|
};
|
|
427
421
|
} catch (err: unknown) {
|
|
422
|
+
// throw(W4):同 save——pi 契约只有 throw 才置 isError:true。
|
|
428
423
|
const msg = err instanceof Error ? err.message : String(err);
|
|
429
|
-
|
|
430
|
-
content: [{ type: "text", text: `Delete failed: ${msg}` }],
|
|
431
|
-
details: { action: "delete", name, ok: false },
|
|
432
|
-
isError: true,
|
|
433
|
-
};
|
|
424
|
+
throw new Error(`Delete failed: ${msg}`);
|
|
434
425
|
}
|
|
435
426
|
}
|
|
436
427
|
|
|
@@ -451,17 +442,22 @@ async function actionList(registry: WorkflowScriptRegistry): Promise<TextContent
|
|
|
451
442
|
details: { action: "list", count: available.length },
|
|
452
443
|
};
|
|
453
444
|
} catch (err: unknown) {
|
|
445
|
+
// throw(W4b):list 失败改 throw(原 return isError 被 pi 丢弃),文案保持
|
|
454
446
|
const msg = err instanceof Error ? err.message : String(err);
|
|
455
|
-
|
|
447
|
+
throw new Error(`List failed: ${msg}`);
|
|
456
448
|
}
|
|
457
449
|
}
|
|
458
450
|
|
|
459
451
|
// ── helper ───────────────────────────────────────────────────
|
|
460
452
|
|
|
461
|
-
|
|
453
|
+
/**
|
|
454
|
+
* 构造纯文本非错误结果(W4b:isError 参数已删除——pi 只对 execute throw 置
|
|
455
|
+
* isError:true,返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483),
|
|
456
|
+
* 错误一律 throw,编译器兜底防回潮)。
|
|
457
|
+
*/
|
|
458
|
+
function textResult(text: string): TextContent {
|
|
462
459
|
return {
|
|
463
460
|
content: [{ type: "text", text }],
|
|
464
461
|
details: undefined,
|
|
465
|
-
isError: isError || undefined,
|
|
466
462
|
};
|
|
467
463
|
}
|
|
@@ -34,7 +34,6 @@ import { type Static, Type } from "typebox";
|
|
|
34
34
|
import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
|
|
35
35
|
import { THINKING_ORDER } from "../execution/model-resolver.ts";
|
|
36
36
|
import type { LauncherDeps } from "../orchestration/launcher.ts";
|
|
37
|
-
import { ArgsValidationError } from "../orchestration/args-validator.ts";
|
|
38
37
|
import { abortRun, runWorkflow } from "../orchestration/lifecycle.ts";
|
|
39
38
|
import type { RunStore } from "../orchestration/models/ports.ts";
|
|
40
39
|
import type { WorkflowRun } from "../orchestration/models/workflow-run.ts";
|
|
@@ -250,8 +249,9 @@ function withGui(
|
|
|
250
249
|
/** 按 WorkflowToolDetails 构造对应的 GuiComponent。 */
|
|
251
250
|
export function buildWorkflowGui(details: WorkflowToolDetails) {
|
|
252
251
|
if (details.action === "run") {
|
|
253
|
-
// not_found
|
|
254
|
-
//
|
|
252
|
+
// not_found 曾是「isError:true + not_found details」的错误形态(W4 前返回值 isError 被
|
|
253
|
+
// pi 丢弃);W4 后该错误改为 throw(details 不再产出此形态),本分支保留消费历史
|
|
254
|
+
// session entry / 防御性渲染,不能走通用 mapper 的 done/check 成功映射。
|
|
255
255
|
if (details.status === "not_found") {
|
|
256
256
|
return guiComponent("stats-line", {
|
|
257
257
|
items: [{ label: "run", value: "not found", severity: "danger" as const }],
|
|
@@ -346,11 +346,13 @@ export function registerWorkflowTool(
|
|
|
346
346
|
): Promise<ToolResult> {
|
|
347
347
|
// P1-2: Honor abort signal up-front
|
|
348
348
|
if (signal?.aborted) {
|
|
349
|
-
|
|
349
|
+
// throw(W4b):pi 只对 execute throw 置 isError:true,返回值里的 isError
|
|
350
|
+
// 被 agent-loop 丢弃(agent-loop.js:453-483)——文案原样进 toolResult。
|
|
351
|
+
throw new Error("Operation aborted before start");
|
|
350
352
|
}
|
|
351
|
-
// P1-6: Reentry guard
|
|
353
|
+
// P1-6: Reentry guard(acquire 失败时尚未持有 guard,throw 前无需 release)
|
|
352
354
|
if (!acquireReentryGuard(reentryRef)) {
|
|
353
|
-
|
|
355
|
+
throw new Error(REENTRY_BUSY_MESSAGE);
|
|
354
356
|
}
|
|
355
357
|
try {
|
|
356
358
|
let result: ToolResult;
|
|
@@ -370,7 +372,7 @@ export function registerWorkflowTool(
|
|
|
370
372
|
default: {
|
|
371
373
|
// Exhaustiveness check — 新增 WorkflowAction 成员时未补 case,tsc 在此报错。
|
|
372
374
|
const _exhaustive: never = action;
|
|
373
|
-
|
|
375
|
+
throw new Error(`Unknown action: ${String(_exhaustive)}`);
|
|
374
376
|
}
|
|
375
377
|
}
|
|
376
378
|
// GUI 协议:RPC 模式下附加 __gui__ 到 details
|
|
@@ -417,30 +419,27 @@ export async function actionRun(
|
|
|
417
419
|
): Promise<ToolResult> {
|
|
418
420
|
const name = params.name;
|
|
419
421
|
if (!name) {
|
|
420
|
-
|
|
422
|
+
throw new Error("run requires 'name' parameter (absolute .js path from <available_workflows> <location>). Correct: {\"action\":\"run\",\"name\":\"<ref>\",\"args\":{...}}");
|
|
421
423
|
}
|
|
422
424
|
// 弱模型常见误用(P0 静默失败):把 task/items 等 args 子字段平铺到 workflow params
|
|
423
425
|
// 顶层(缺 args 嵌套)。args ?? {} 会静默 args={},启动缺参 run 不报错——比 subagent
|
|
424
426
|
// 平铺事故更严重。m6:先 registry.getPath(动态参数集来源——schema 即 SSOT),
|
|
425
427
|
// not_found 优先返回;平铺检测报错带 Correct 正例纠正。
|
|
426
428
|
const script = await deps.registry.getPath(name);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
+
// W4c:config-loader 的 toCachedMeta 对不可读/不存在文件返回 available:false 的
|
|
430
|
+
// stub(非 undefined),仅判 !script 会绕过 not_found → 空 sourceCode 假启动
|
|
431
|
+
//(W4b verifier 探针实测复现)。与下方 suggestions 分支的 wf.available 过滤口径对齐。
|
|
432
|
+
if (!script || !script.available) {
|
|
433
|
+
// 模糊匹配建议。throw(W4):pi 只对 execute throw 置 isError:true,
|
|
434
|
+
// 返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483)——文案原样进 toolResult。
|
|
429
435
|
const all = await deps.registry.loadAll();
|
|
430
436
|
const available = all.filter((wf) => wf.available);
|
|
431
437
|
const suggestions = available
|
|
432
438
|
.map((wf) => ` - ${wf.name}: ${wf.meta.description || "(no description)"}`)
|
|
433
439
|
.join("\n");
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
type: "text",
|
|
438
|
-
text: `Workflow '${name}' not found. Available:\n${suggestions || " (none)"}\nUse <location> from <available_workflows> for the absolute .js path.`,
|
|
439
|
-
},
|
|
440
|
-
],
|
|
441
|
-
details: { action: "run", runId: "", status: "not_found", name },
|
|
442
|
-
isError: true,
|
|
443
|
-
};
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Workflow '${name}' not found. Available:\n${suggestions || " (none)"}\nUse <location> from <available_workflows> for the absolute .js path.`,
|
|
442
|
+
);
|
|
444
443
|
}
|
|
445
444
|
|
|
446
445
|
// m6:动态参数集(schema 即 SSOT)→ 平铺检测;无 parameters → 单次 warn + 跳过
|
|
@@ -455,17 +454,15 @@ export async function actionRun(
|
|
|
455
454
|
}
|
|
456
455
|
const flattened = findFlattenedArgKeys(params, knownKeys, knownPatterns);
|
|
457
456
|
if (flattened.length > 0) {
|
|
458
|
-
|
|
457
|
+
throw new Error(
|
|
459
458
|
`Detected ${flattened.join(", ")} at top level — they belong inside 'args'. ` +
|
|
460
459
|
`Correct: {"action":"run","name":"${name}","args":{${flattened.map((k) => `"${k}": "<value>"`).join(", ")}}}`,
|
|
461
|
-
true,
|
|
462
460
|
);
|
|
463
461
|
}
|
|
464
462
|
// slug 运行时护栏(与 subagent startHandler 对称的纵深防御;schema maxLength 是第一道关卡)
|
|
465
463
|
if (params.slug !== undefined && params.slug.length > SLUG_MAX_LENGTH) {
|
|
466
|
-
|
|
464
|
+
throw new Error(
|
|
467
465
|
`slug exceeds ${SLUG_MAX_LENGTH} chars (got ${params.slug.length}). Shorten to a kebab-case label, e.g. "fix-login", "extract-urls".`,
|
|
468
|
-
true,
|
|
469
466
|
);
|
|
470
467
|
}
|
|
471
468
|
const args = params.args ?? {};
|
|
@@ -473,36 +470,25 @@ export async function actionRun(
|
|
|
473
470
|
const time = params.time;
|
|
474
471
|
|
|
475
472
|
// 构建 RunSpec + 启动(m3:parameters 从 script.meta 拷贝——chokepoint 校验用;
|
|
476
|
-
// 校验失败 →
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
);
|
|
496
|
-
} catch (err) {
|
|
497
|
-
if (err instanceof ArgsValidationError) {
|
|
498
|
-
return {
|
|
499
|
-
content: [{ type: "text", text: err.message }],
|
|
500
|
-
details: { action: "run", runId: "", status: "invalid_args", name: script.name },
|
|
501
|
-
isError: true,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
throw err;
|
|
505
|
-
}
|
|
473
|
+
// 校验失败 → ArgsValidationError 直接 throw 给 pi(W4:err.message 含 §5.3 指引,
|
|
474
|
+
// pi catch 后原文案进 toolResult content 并置 isError:true),其他错误保持传播)
|
|
475
|
+
const runId = await runWorkflow(
|
|
476
|
+
{
|
|
477
|
+
scriptSource: script.toExecutable(),
|
|
478
|
+
args,
|
|
479
|
+
budgetTokens: tokens,
|
|
480
|
+
budgetTimeMs: time,
|
|
481
|
+
scriptName: script.name,
|
|
482
|
+
slug: params.slug,
|
|
483
|
+
scriptPath: script.path,
|
|
484
|
+
description: script.meta.description,
|
|
485
|
+
parameters: script.meta.parameters,
|
|
486
|
+
model: params.model,
|
|
487
|
+
thinkingLevel: params.thinkingLevel,
|
|
488
|
+
},
|
|
489
|
+
deps,
|
|
490
|
+
signal,
|
|
491
|
+
);
|
|
506
492
|
|
|
507
493
|
return {
|
|
508
494
|
content: [
|
|
@@ -551,13 +537,12 @@ async function actionLifecycle(
|
|
|
551
537
|
): Promise<ToolResult> {
|
|
552
538
|
const runId = params.runId;
|
|
553
539
|
if (!runId) {
|
|
554
|
-
|
|
540
|
+
throw new Error(`'runId' is required for ${action}. Correct: {"action":"${action}","runId":"<id>"} (use action:"status" to find runId)`);
|
|
555
541
|
}
|
|
556
542
|
const run = deps.runs.get(runId);
|
|
557
543
|
if (!run) {
|
|
558
|
-
|
|
544
|
+
throw new Error(
|
|
559
545
|
`Workflow '${runId}' not found. Use action:status to list active runs and their runIds.`,
|
|
560
|
-
true,
|
|
561
546
|
);
|
|
562
547
|
}
|
|
563
548
|
try {
|
|
@@ -575,8 +560,9 @@ async function actionLifecycle(
|
|
|
575
560
|
details: { action, runId, status: newStatus, reason: run.state.reason },
|
|
576
561
|
};
|
|
577
562
|
} catch (err) {
|
|
563
|
+
// throw(W4b):abortRun 失败改 throw(原 return isError 被 pi 丢弃),"Error: " 前缀保持
|
|
578
564
|
const msg = err instanceof Error ? err.message : String(err);
|
|
579
|
-
|
|
565
|
+
throw new Error(`Error: ${msg}`);
|
|
580
566
|
}
|
|
581
567
|
}
|
|
582
568
|
|
|
@@ -597,10 +583,6 @@ function toRunSummary(run: WorkflowRun, store: RunStore): RunSummary {
|
|
|
597
583
|
};
|
|
598
584
|
}
|
|
599
585
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
details: undefined,
|
|
604
|
-
isError: isError || undefined,
|
|
605
|
-
};
|
|
606
|
-
}
|
|
586
|
+
// W4b:原 textResult(text, isError) helper 已删除——23 处错误路径全部改 throw
|
|
587
|
+
// (pi 只对 execute throw 置 isError:true,返回值里的 isError 被 agent-loop 丢弃,
|
|
588
|
+
// agent-loop.js:453-483),且本文件无非错误纯文本结果用途,无残留调用方。
|
|
@@ -272,7 +272,7 @@ function renderOutcomeSection(
|
|
|
272
272
|
if (node.status === "running" && node.live) {
|
|
273
273
|
// 运行中:显示实时指标(elapsed/tokens/turns)替代空荡的 "Still running..."
|
|
274
274
|
const live = projectLiveProgress(node.live);
|
|
275
|
-
const tokK = live.totalTokens > 0 ? `${Math.round(live.totalTokens /
|
|
275
|
+
const tokK = live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "0 tok";
|
|
276
276
|
rightLines.push(theme.fg("dim", ` Running · ${formatElapsedSeconds(live.elapsedSeconds)} · ${tokK} · ${live.turns} turn${live.turns !== 1 ? "s" : ""}`));
|
|
277
277
|
if (live.lastError) {
|
|
278
278
|
rightLines.push(theme.fg("warning", ` ⚠ ${live.lastError.slice(0, mainWidth - BOX_BORDER_CHARS)}`));
|
|
@@ -100,7 +100,9 @@ function _safePost(msg, context) {
|
|
|
100
100
|
// 不丢失。失败 resolve 为空字符串是既定容错策略。
|
|
101
101
|
// parsedOutput: validated data object from structured-output execute().
|
|
102
102
|
// Fallback to content (raw text) when no schema was requested or on error.
|
|
103
|
-
// W2 改动 9(b):returnMeta===true 时 resolve {value,sessionFile,worktreePath,error}
|
|
103
|
+
// W2 改动 9(b):returnMeta===true 时 resolve {value,sessionFile,worktreePath,error,usage,durationMs,sessionId}
|
|
104
|
+
// (对称缓存重放 9c);否则单值。usage/durationMs/sessionId 为 rfl 仪表透传(tier-1 §7.1,
|
|
105
|
+
// AgentResult 已含、此处原样转发,缺省 undefined 兼容旧主线程)。
|
|
104
106
|
const _value = msg.result.parsedOutput ?? msg.result.content;
|
|
105
107
|
if (pending.returnMeta) {
|
|
106
108
|
pending.resolve({
|
|
@@ -108,6 +110,9 @@ function _safePost(msg, context) {
|
|
|
108
110
|
sessionFile: msg.result.sessionFile,
|
|
109
111
|
worktreePath: msg.result.worktreePath,
|
|
110
112
|
error: msg.result.error,
|
|
113
|
+
usage: msg.result.usage,
|
|
114
|
+
durationMs: msg.result.durationMs,
|
|
115
|
+
sessionId: msg.result.sessionId,
|
|
111
116
|
});
|
|
112
117
|
} else {
|
|
113
118
|
pending.resolve(_value);
|
|
@@ -204,6 +209,9 @@ function _safePost(msg, context) {
|
|
|
204
209
|
sessionFile: cached.sessionFile,
|
|
205
210
|
worktreePath: cached.worktreePath,
|
|
206
211
|
error: cached.error,
|
|
212
|
+
usage: cached.usage,
|
|
213
|
+
durationMs: cached.durationMs,
|
|
214
|
+
sessionId: cached.sessionId,
|
|
207
215
|
};
|
|
208
216
|
}
|
|
209
217
|
return _cachedValue;
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, it, vi } from "vitest";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
executeAgentCall,
|
|
10
|
+
isStaleContextErrorMsg,
|
|
11
|
+
STALE_CONTEXT_PATTERNS,
|
|
12
|
+
} from "../execute-agent-call.ts";
|
|
9
13
|
import { AgentCall } from "../models/agent-call.ts";
|
|
10
14
|
import { Budget } from "../models/budget.ts";
|
|
11
15
|
import type { AgentRunner } from "../models/ports.ts";
|
|
@@ -264,3 +268,47 @@ describe("isOrphaned 守卫", () => {
|
|
|
264
268
|
}
|
|
265
269
|
});
|
|
266
270
|
});
|
|
271
|
+
|
|
272
|
+
// ── W4b: STALE_CONTEXT_PATTERNS 对齐 pi 真实文案(stale 分诊) ──
|
|
273
|
+
//
|
|
274
|
+
// pi 0.84.x 真实 stale 文案(extensions/runner.ts:531,dist runner.js:567):
|
|
275
|
+
// "This extension ctx is stale after session replacement or reload. Do not use
|
|
276
|
+
// a captured pi or command ctx after ctx.newSession(), ..."
|
|
277
|
+
// 旧 patterns("stale context"/"stalecontext")与该文案零匹配(词序相反),
|
|
278
|
+
// stale 错误曾退化为普通错误照常重试 3 次——W4b 词序修正 + 对齐 scheduler
|
|
279
|
+
// 已验证 marker 'stale after session replacement'(runtime.ts STALE_CTX_MARKER)。
|
|
280
|
+
|
|
281
|
+
/** pi 真实 stale 文案前半(含 marker 全文前缀,锚定真实串而非自造缩写)。 */
|
|
282
|
+
const PI_REAL_STALE_MESSAGE =
|
|
283
|
+
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload().";
|
|
284
|
+
|
|
285
|
+
describe("W4b: stale 分诊对齐 pi 真实文案", () => {
|
|
286
|
+
it("真实文案全文 → isStaleContextErrorMsg true('ctx is stale' 词序 + scheduler marker 双命中)", () => {
|
|
287
|
+
expect(isStaleContextErrorMsg(PI_REAL_STALE_MESSAGE)).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("patterns 含 scheduler 已验证 marker 'stale after session replacement'", () => {
|
|
291
|
+
expect(STALE_CONTEXT_PATTERNS).toContain("stale after session replacement");
|
|
292
|
+
expect(STALE_CONTEXT_PATTERNS).toContain("ctx is stale");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("真实文案 → executeAgentCall 不重试(runner.run 恰 1 次)+ markDone failed", async () => {
|
|
296
|
+
const { call, trace } = makeAgentCallAndTrace();
|
|
297
|
+
const runner = createMockRunner(
|
|
298
|
+
vi.fn().mockResolvedValue(makeMockResult({ error: PI_REAL_STALE_MESSAGE })),
|
|
299
|
+
);
|
|
300
|
+
const budget = new Budget();
|
|
301
|
+
|
|
302
|
+
await executeAgentCall(call, runner, budget, new AbortController().signal, trace);
|
|
303
|
+
|
|
304
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(call.status).toBe("done");
|
|
306
|
+
expect(call.result?.error).toBe(PI_REAL_STALE_MESSAGE);
|
|
307
|
+
expect(trace.find(0)?.status).toBe("failed");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("普通 transient 错误 → 不命中 stale(照常进入重试路径,现状回归)", () => {
|
|
311
|
+
expect(isStaleContextErrorMsg("transient network error")).toBe(false);
|
|
312
|
+
expect(isStaleContextErrorMsg(undefined)).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts
|
|
2
|
+
//
|
|
3
|
+
// loadAll entry 源扫描(collectEntrySources + loadRunFromStateFile)定向用例(R2-TC S4)。
|
|
4
|
+
//
|
|
5
|
+
// 防的 bug:loadAll 的多 run 重建 / 损坏 state 文件降级。W17 已覆盖单 run 的 entry 重建、
|
|
6
|
+
// link 兼容与读序优先级;本文件补齐三块未定向覆盖的分支:
|
|
7
|
+
// 1. 多 run 并存(多个 workflow-record entry run + link run 同批重建,无丢失)
|
|
8
|
+
// 2. 损坏 state 文件降级(末行非 JSON / 空文件 / link 指向不存在路径 / link data 缺 runId)
|
|
9
|
+
// ——单文件失败返回 null 跳过,不崩 loadAll、不阻断同批其余 run 重建
|
|
10
|
+
// 3. 同 runId 多条 record entry 末条胜出在多 run 场景下不串扰
|
|
11
|
+
//
|
|
12
|
+
// 不 mock collectEntrySources:走 loadAll 真实通路(ctx.sessionManager.getEntries 返回
|
|
13
|
+
// seed entries,loadRunFromStateFile 读真实临时文件)。record entry 一律经真实 save 产出
|
|
14
|
+
//(deserializeRun 的快照 schema 以真实 serializeRun 为准,手工拼快照会绕开 schema 契约)。
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
import type { CustomEntry } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
import { Budget } from "../models/budget.ts";
|
|
25
|
+
import { Trace } from "../models/trace.ts";
|
|
26
|
+
import type { ExecutionTraceNode } from "../models/types.ts";
|
|
27
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
28
|
+
import { WorkflowRun } from "../models/workflow-run.ts";
|
|
29
|
+
import { JsonlRunStore } from "../jsonl-run-store.ts";
|
|
30
|
+
import { mkCtx, mkPi } from "./test-mocks.ts";
|
|
31
|
+
|
|
32
|
+
function makeSpec(): RunSpec {
|
|
33
|
+
return {
|
|
34
|
+
scriptSource: "module.exports = async () => {};",
|
|
35
|
+
args: {},
|
|
36
|
+
scriptName: "test-script",
|
|
37
|
+
scriptPath: "/tmp/test.js",
|
|
38
|
+
description: "test",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeTraceNode(stepIndex: number): ExecutionTraceNode {
|
|
43
|
+
return { stepIndex, agent: "worker", task: "do thing", model: "default", status: "pending" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** running → done 的完整生命周期 save(终态快照可被 deserializeRun 重建)。 */
|
|
47
|
+
async function saveDoneRun(
|
|
48
|
+
sessionDir: string,
|
|
49
|
+
entries: CustomEntry[],
|
|
50
|
+
runId: string,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const store = new JsonlRunStore({ sessionDir, pi: mkPi(entries) });
|
|
53
|
+
const run = makeRunningRun(runId);
|
|
54
|
+
await store.save(run);
|
|
55
|
+
run.transition("done", "completed");
|
|
56
|
+
await store.save(run);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeRunningRun(runId: string): WorkflowRun {
|
|
60
|
+
const trace = new Trace();
|
|
61
|
+
trace.append(makeTraceNode(0));
|
|
62
|
+
return WorkflowRun.reconstruct(runId, makeSpec(), {
|
|
63
|
+
status: "running",
|
|
64
|
+
budget: new Budget(),
|
|
65
|
+
calls: new Map(),
|
|
66
|
+
trace,
|
|
67
|
+
errorLogs: [],
|
|
68
|
+
}, { startedAt: new Date().toISOString() });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 手工构造旧 workflow-state-link 指针 entry(loadAll 的 link 兼容输入)。 */
|
|
72
|
+
function linkEntry(runId: string, statePath: string): CustomEntry {
|
|
73
|
+
return {
|
|
74
|
+
type: "custom",
|
|
75
|
+
customType: "workflow-state-link",
|
|
76
|
+
data: { runId, path: statePath },
|
|
77
|
+
id: `seed-link-${runId}`,
|
|
78
|
+
parentId: null,
|
|
79
|
+
timestamp: new Date().toISOString(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("loadAll entry 源扫描:多 run 重建与损坏 state 降级(R2-TC S4)", () => {
|
|
84
|
+
let tmpDir: string;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-loadall-src-"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("多 run 并存:2 个 entry run + 1 个 link run 同批全部重建(无丢失)", async () => {
|
|
95
|
+
// run-a/run-b 经真实 save 通路产出终态 entry(快照形态可信)
|
|
96
|
+
const entries: CustomEntry[] = [];
|
|
97
|
+
await saveDoneRun(tmpDir, entries, "run-a");
|
|
98
|
+
await saveDoneRun(tmpDir, entries, "run-b");
|
|
99
|
+
|
|
100
|
+
// run-c:旧 link 形态(state 文件完好,无 record entry)
|
|
101
|
+
const storeC = new JsonlRunStore({ sessionDir: tmpDir });
|
|
102
|
+
const runC = makeRunningRun("run-c");
|
|
103
|
+
await storeC.save(runC);
|
|
104
|
+
runC.transition("done", "completed");
|
|
105
|
+
await storeC.save(runC);
|
|
106
|
+
const linkPath = path.join(tmpDir, "workflow-state", "run-c.jsonl");
|
|
107
|
+
expect(fs.existsSync(linkPath)).toBe(true);
|
|
108
|
+
const seedEntries: CustomEntry[] = [...entries, linkEntry("run-c", linkPath)];
|
|
109
|
+
|
|
110
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(seedEntries) });
|
|
111
|
+
const loaded = await store.loadAll();
|
|
112
|
+
expect(loaded.map((r) => r.runId).sort()).toEqual(["run-a", "run-b", "run-c"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("损坏 state 文件(末行非 JSON)→ 该 run 跳过不崩,同批其余 run(entry run)正常返回", async () => {
|
|
116
|
+
const entries: CustomEntry[] = [];
|
|
117
|
+
await saveDoneRun(tmpDir, entries, "run-good");
|
|
118
|
+
|
|
119
|
+
const corruptPath = path.join(tmpDir, "workflow-state", "run-corrupt.jsonl");
|
|
120
|
+
fs.mkdirSync(path.dirname(corruptPath), { recursive: true });
|
|
121
|
+
fs.writeFileSync(corruptPath, "{truncated-not-json\n", "utf8");
|
|
122
|
+
|
|
123
|
+
const seedEntries: CustomEntry[] = [...entries, linkEntry("run-corrupt", corruptPath)];
|
|
124
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(seedEntries) });
|
|
125
|
+
const loaded = await store.loadAll();
|
|
126
|
+
expect(loaded.map((r) => r.runId)).toEqual(["run-good"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("损坏形态族:空文件 / link 指向不存在路径 / link data 缺 runId → 均降级跳过,loadAll 不抛", async () => {
|
|
130
|
+
const emptyPath = path.join(tmpDir, "workflow-state", "run-empty.jsonl");
|
|
131
|
+
fs.mkdirSync(path.dirname(emptyPath), { recursive: true });
|
|
132
|
+
fs.writeFileSync(emptyPath, "", "utf8");
|
|
133
|
+
const missingPath = path.join(tmpDir, "workflow-state", "run-missing.jsonl");
|
|
134
|
+
|
|
135
|
+
const malformedLink = linkEntry("run-bad", emptyPath);
|
|
136
|
+
(malformedLink.data as Record<string, unknown>).runId = undefined; // 缺 runId 的坏指针
|
|
137
|
+
|
|
138
|
+
// 对照组:一条合法 record entry run 应正常重建(真实 save 产出)
|
|
139
|
+
const entries: CustomEntry[] = [];
|
|
140
|
+
await saveDoneRun(tmpDir, entries, "run-seeded");
|
|
141
|
+
|
|
142
|
+
const seedEntries: CustomEntry[] = [
|
|
143
|
+
...entries,
|
|
144
|
+
linkEntry("run-empty", emptyPath),
|
|
145
|
+
linkEntry("run-missing", missingPath),
|
|
146
|
+
malformedLink,
|
|
147
|
+
];
|
|
148
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(seedEntries) });
|
|
149
|
+
const loaded = await store.loadAll();
|
|
150
|
+
expect(loaded.map((r) => r.runId)).toEqual(["run-seeded"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("同 runId 多条 record entry 末条胜出(running→done 收敛)+ 另一 run 不受影响(混合批不串扰)", async () => {
|
|
154
|
+
const entries: CustomEntry[] = [];
|
|
155
|
+
// run-x:两条 entry(running 中间态 + done 终态),末条胜出
|
|
156
|
+
const storeX = new JsonlRunStore({ sessionDir: tmpDir, pi: mkPi(entries) });
|
|
157
|
+
const runX = makeRunningRun("run-x");
|
|
158
|
+
await storeX.save(runX);
|
|
159
|
+
runX.transition("done", "completed");
|
|
160
|
+
await storeX.save(runX);
|
|
161
|
+
// run-y:done 终态
|
|
162
|
+
await saveDoneRun(tmpDir, entries, "run-y");
|
|
163
|
+
|
|
164
|
+
const store = new JsonlRunStore({ sessionDir: tmpDir, ctx: mkCtx(entries) });
|
|
165
|
+
const loaded = await store.loadAll();
|
|
166
|
+
const byId = new Map(loaded.map((r) => [r.runId, r]));
|
|
167
|
+
expect(loaded).toHaveLength(2);
|
|
168
|
+
expect(byId.get("run-x")?.state.status).toBe("done");
|
|
169
|
+
expect(byId.get("run-y")?.state.status).toBe("done");
|
|
170
|
+
});
|
|
171
|
+
});
|