@tea-agent/loop-agent 0.24.11-beta.0 → 0.25.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/CHANGELOG.md +28 -5
- package/dist/commands/init.js +16 -2
- package/dist/executors/dag-pi-executor.js +74 -13
- package/dist/executors/pi-executor.js +25 -7
- package/dist/executors/pi-prompt-transport.js +198 -0
- package/dist/executors/pi-sdk-executor.js +1 -1
- package/dist/executors/process-tree.js +33 -0
- package/dist/executors/shell-executor.js +332 -24
- package/dist/executors/shell-verification.js +4 -2
- package/dist/infrastructure/harness/task-store.js +31 -0
- package/dist/shared/operator/capabilities.js +6 -0
- package/dist/verification/maven/cache.js +142 -0
- package/dist/verification/maven/index.js +120 -0
- package/dist/verification/maven/plan-commands.js +421 -0
- package/dist/verification/maven/pom-static.js +136 -0
- package/dist/verification/maven/scope-resolve.js +153 -0
- package/dist/verification/maven/stale.js +130 -0
- package/dist/verification/maven/types.js +23 -0
- package/dist/verification/maven/workspace-graph.js +322 -0
- package/dist/worker/cli.js +236 -21
- package/dist/worker/delivery/final-verification.js +12 -0
- package/dist/worker/delivery/git-transaction.js +32 -7
- package/dist/worker/delivery/package.js +9 -5
- package/dist/worker/delivery/verification-bundle.js +26 -5
- package/dist/worker/feature/advance.js +301 -0
- package/dist/worker/feature/doctor.js +223 -0
- package/dist/worker/feature/next-action.js +11 -3
- package/dist/worker/feature/scaffold.js +798 -0
- package/dist/worker/observe/dag-run-artifacts.js +90 -0
- package/dist/worker/observe/node-input.js +444 -0
- package/dist/worker/observe/routes.js +17 -0
- package/dist/worker/observe/static/api.js +9 -0
- package/dist/worker/observe/static/constants.js +9 -0
- package/dist/worker/observe/static/state.js +14 -0
- package/dist/worker/observe/static/styles.css +74 -0
- package/dist/worker/observe/static/views/dag-inspector.js +371 -15
- package/dist/worker/outcomes/projector.js +5 -1
- package/dist/worker/runner/run-ready.js +41 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +202 -9
- package/dist/workflows/dag/backend-test-result-contract.js +103 -0
- package/dist/workflows/dag/failure-category.js +5 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
- package/dist/workflows/dag/frontend-test-result-contract.js +106 -41
- package/dist/workflows/dag/init-hybrid.js +220 -32
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/reconcile-run.js +24 -0
- package/dist/workflows/dag/types.js +46 -0
- package/dist/workflows/dag/validate.js +19 -2
- package/docs/architecture/dag-execution.md +5 -1
- package/docs/architecture/runtime-boundaries.md +11 -1
- package/docs/architecture/worker-and-feature.md +2 -0
- package/docs/templates/backend-test-dag.json +3 -3
- package/docs/templates/product-line/README.md +17 -1
- package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
- package/docs/templates/product-line/scaffold-samples/README.md +36 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
- package/examples/l5-report-coms-process-definition.html +322 -0
- package/package.json +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +84 -0
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/loop-agent/SKILL.md +8 -8
- package/skills/loop-agent/references/command-reference.md +14 -2
- package/skills/loop-agent/references/harness-policy.md +22 -0
- package/skills/loop-agent/references/task-workflow.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,15 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## [0.25.0] - 2026-07-30
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
6
8
|
|
|
7
|
-
- Feature
|
|
9
|
+
- 新增确定性 Feature Packet 脚手架,支持后端、前端及含 API 前端模板,并具备原子批量写入与预检能力
|
|
10
|
+
- 新增 Feature 交付编排命令,自动串联最终验证、交付与收口流程
|
|
11
|
+
- 显著提升 DAG 执行稳定性,修复超时残留子进程、重复验证及耗时记录不完整等问题
|
|
12
|
+
- 新增任务级 Maven 验证规划,自动定位并仅针对相关模块执行验证
|
|
13
|
+
- 后端测试节点现可自动生成指标机器计算的自包含 L-5 可视化报告
|
|
14
|
+
|
|
15
|
+
### 新增
|
|
8
16
|
|
|
9
|
-
|
|
17
|
+
- 提供 agent-worker Feature Packet 脚手架,内置 backend-only、frontend-only 和 fe-with-api 模板,支持原子批量写入与 validate-feature 预检
|
|
18
|
+
- 新增 agent-worker feature advance 命令,编排 verify-final、delivery 到 closeout 的完整交付路径
|
|
19
|
+
- 新增 agent-worker feature doctor 命令,提供只读的交付就绪诊断(覆盖证据、Pool 状态与工作区改动)
|
|
20
|
+
- Maven 验证现支持任务级范围规划,根据任务涉及的目录自动定位并验证正确的 POM 模块
|
|
21
|
+
- 后端测试 DAG 节点 7 现可确定性生成美观的自包含 L-5 可视化报告,指标完全由机器计算
|
|
10
22
|
|
|
11
|
-
|
|
12
|
-
|
|
23
|
+
### 改进
|
|
24
|
+
|
|
25
|
+
- Feature Delivery 在省略特定参数时,默认使用 feature verify-final 写入的标准证据路径
|
|
26
|
+
- TaskConfig 持久化入口在写入前执行 schema 校验,拒绝不符合规范的数据格式
|
|
27
|
+
- Dogfood 前端测试服务器在检测到旧缓存导致接口数据异常时,现会自动强制重启恢复
|
|
28
|
+
|
|
29
|
+
### 修复
|
|
13
30
|
|
|
31
|
+
- 显著加固 DAG 执行可靠性,修复超时或中断后子进程残留问题,降低文件占用与测试干扰概率
|
|
32
|
+
- 修复受监督流程可能重复执行完整验证的问题,避免高成本检查在不同阶段反复运行
|
|
33
|
+
- 修复 DAG 被放弃或替换时,运行中节点实际耗时记录不完整的问题
|
|
34
|
+
- 修复 Windows 环境下因提示内容过长导致 DAG 无法启动的问题,现安全采用受控临时文件传输
|
|
35
|
+
- 修复前端实现 DAG 在未确认项目 Mock 能力时误报缺失 Mock 文件的问题
|
|
36
|
+
- 修复结构化任务配置中数组类型校验不严格的问题
|
|
14
37
|
|
|
15
38
|
## [0.24.10] - 2026-07-29
|
|
16
39
|
|
package/dist/commands/init.js
CHANGED
|
@@ -415,6 +415,18 @@ check_skill "loop-agent" \\
|
|
|
415
415
|
"references/command-reference.md"
|
|
416
416
|
check_skill "agent-worker" "references/agent-worker-operator.md"
|
|
417
417
|
|
|
418
|
+
# loop-agent description is a YAML folded block (>-); grep ^description: only
|
|
419
|
+
# captures the indicator line. Validate the general-demand trigger terms
|
|
420
|
+
# against the whole skill file so host auto-discovery phrasing cannot drift.
|
|
421
|
+
loop_agent_skill=".agents/skills/loop-agent/SKILL.md"
|
|
422
|
+
loop_agent_text="$(tr '[:upper:]' '[:lower:]' < "\${loop_agent_skill}")"
|
|
423
|
+
for term in "loop-agent 帮我完成" "帮我实现" "帮我修复" "帮我开发" "agent dag" "new-task" "dag run-task" "dag validate" "run-dag"; do
|
|
424
|
+
if [[ "\${loop_agent_text}" != *"\${term}"* ]]; then
|
|
425
|
+
echo "skill entry 检查失败(loop-agent):description 缺少触发/路由词 \${term}" >&2
|
|
426
|
+
exit 1
|
|
427
|
+
fi
|
|
428
|
+
done
|
|
429
|
+
|
|
418
430
|
description_line="$(grep -E '^description:' .agents/skills/agent-worker/SKILL.md | head -n 1 | tr '[:upper:]' '[:lower:]')"
|
|
419
431
|
for term in "agent-worker" "feature packet" "taskspec" "task pool" "self-host" "candidate" "loop-agent"; do
|
|
420
432
|
if [[ "\${description_line}" != *"\${term}"* ]]; then
|
|
@@ -802,6 +814,7 @@ function buildManagedAgentsBlock(input) {
|
|
|
802
814
|
"| 初始化更新校验 / 检查初始化更新 / loop-agent 初始化更新校验 / loop agent初始化更新校验 | 更新校验 | 只读报告,不写入 |",
|
|
803
815
|
"| 初始化对齐 / 升级后对齐 / reconcile 初始化 / loop-agent 初始化对齐 | 升级对齐 | 自动应用确定性安全动作;活跃 DAG/Worker 或人工决策时零写入 |",
|
|
804
816
|
"| 初始化安全更新 / 应用初始化更新 / loop-agent 初始化安全更新 / loop agent初始化安全更新 | 安全更新 | 先 check-update,再只执行确定性安全动作 |",
|
|
817
|
+
"| loop-agent 帮我完成 / 帮我实现 / 帮我修复 / 帮我开发 <需求>;使用 loop-agent 完成 <X>;按 loop-agent 流程处理 <X> | 通用需求实现 | 先建任务源与 `task.json.allowedPaths` / `forbiddenPaths`,再 `new-task` → `dag run-task` → `dag validate` → `run-dag`;主会话不得直接修改业务实现 |",
|
|
805
818
|
"",
|
|
806
819
|
"**更新校验(只读)**:只读执行下面命令;**不得自动**执行 `apply-safe` 或模型合并。",
|
|
807
820
|
"",
|
|
@@ -861,10 +874,11 @@ function buildManagedAgentsBlock(input) {
|
|
|
861
874
|
"### 运行看板(只读)",
|
|
862
875
|
"",
|
|
863
876
|
"```bash",
|
|
864
|
-
"agent-worker console
|
|
877
|
+
"agent-worker console # 默认 repo=当前目录,port=8790",
|
|
878
|
+
"agent-worker console serve --repo . --port 8790 # 兼容入口,等价于上面裸入口",
|
|
865
879
|
"```",
|
|
866
880
|
"",
|
|
867
|
-
"浏览器打开 `http://127.0.0.1:8790/`;检视面为 `http://127.0.0.1:8790/inspect/`。默认绑定本机 `127.0.0.1
|
|
881
|
+
"浏览器打开 `http://127.0.0.1:8790/`;检视面为 `http://127.0.0.1:8790/inspect/`。默认绑定本机 `127.0.0.1`;不要直接暴露到公开网络。端口被占用时不会自动更换,请用 `--port <port>` 显式指定。`agent-worker observe serve` 仅为兼容入口。",
|
|
868
882
|
"",
|
|
869
883
|
"### DAG 诊断与收口",
|
|
870
884
|
"",
|
|
@@ -130,17 +130,23 @@ export function buildDagPiUserMessage(task, persona, step) {
|
|
|
130
130
|
const role = task.role ?? "unspecified";
|
|
131
131
|
const writePolicy = task.writePolicy ?? "read-only (default)";
|
|
132
132
|
if (isDagPiWriteTask(task)) {
|
|
133
|
+
const outcomeInstruction = task.writerOutcomePolicy
|
|
134
|
+
? "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked. Use changed only after producing a non-empty bounded diff; use already-satisfied only when the contract is already met and no file changed; use blocked when implementation cannot proceed."
|
|
135
|
+
: undefined;
|
|
133
136
|
return [
|
|
134
137
|
`You are executing hybrid DAG node "${task.id}" (role=${role}, piStep=${step}, writePolicy=${writePolicy}).`,
|
|
135
138
|
"The system prompt contains the full DAG envelope: objective, constraints, upstream context, and task.",
|
|
136
139
|
"Use write tools only for the bounded implementation requested by this node.",
|
|
140
|
+
outcomeInstruction,
|
|
137
141
|
`Allowed paths: ${(task.allowedPaths ?? []).join(", ") || "(none declared)"}.`,
|
|
138
142
|
`Write set: ${(task.writeSet ?? []).join(", ") || "(none declared)"}.`,
|
|
139
143
|
`Forbidden paths: ${(task.forbiddenPaths ?? []).join(", ") || "(none declared)"}.`,
|
|
140
144
|
"Preserve unrelated files. Do not edit outside allowedPaths/writeSet. Never write forbiddenPaths, .harness/dag-runs/**, or undeclared root artifacts/**.",
|
|
141
145
|
"Return concise Markdown with changed files, verification performed or still required, and residual risks.",
|
|
142
146
|
"Do not wrap the output in code fences and do not add conversational preamble.",
|
|
143
|
-
]
|
|
147
|
+
]
|
|
148
|
+
.filter((value) => Boolean(value))
|
|
149
|
+
.join(" ");
|
|
144
150
|
}
|
|
145
151
|
// Decision Gate nodes must emit a mandatory DECISION_ENVELOPE_JSON fenced block;
|
|
146
152
|
// do not inject the global no-fence instruction that would conflict with outputContract.
|
|
@@ -344,21 +350,39 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
344
350
|
];
|
|
345
351
|
}
|
|
346
352
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
353
|
+
let writerOutcomeViolation;
|
|
354
|
+
if (mapped.ok && input.task.writerOutcomePolicy) {
|
|
355
|
+
if (changeManifestChangedFiles === undefined) {
|
|
356
|
+
writerOutcomeViolation =
|
|
357
|
+
"writer outcome validation failed: actual diff is unavailable";
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const outcomeValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles);
|
|
361
|
+
if (!outcomeValidation.ok) {
|
|
362
|
+
writerOutcomeViolation = outcomeValidation.reason;
|
|
363
|
+
}
|
|
357
364
|
}
|
|
365
|
+
}
|
|
366
|
+
if (writeGuardOk && beforeStatus !== undefined && changeManifestChangedFiles !== undefined) {
|
|
367
|
+
await persistWriterChangeManifest({
|
|
368
|
+
runDir: meta.runDir,
|
|
369
|
+
nodeId: input.task.id,
|
|
370
|
+
writerNodeId: input.task.id,
|
|
371
|
+
changedFiles: changeManifestChangedFiles,
|
|
372
|
+
beforeStatus,
|
|
373
|
+
afterStatus: changeManifestAfterStatus ?? "",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (writeGuardOk && !writerOutcomeViolation) {
|
|
358
377
|
return mapped;
|
|
359
378
|
}
|
|
360
379
|
const stderrParts = [mapped.stderr];
|
|
361
|
-
|
|
380
|
+
if (!writeGuardOk) {
|
|
381
|
+
stderrParts.push(`write guard failed: ${writeGuardViolations.join(", ")}`);
|
|
382
|
+
}
|
|
383
|
+
if (writerOutcomeViolation) {
|
|
384
|
+
stderrParts.push(writerOutcomeViolation);
|
|
385
|
+
}
|
|
362
386
|
if (meta.writeGuardAttribution === "best-effort") {
|
|
363
387
|
stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
|
|
364
388
|
}
|
|
@@ -366,10 +390,47 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
366
390
|
...mapped,
|
|
367
391
|
ok: false,
|
|
368
392
|
stderr: stderrParts.filter(Boolean).join("\n\n"),
|
|
369
|
-
failureCategory: mapped.ok
|
|
393
|
+
failureCategory: mapped.ok
|
|
394
|
+
? writeGuardOk
|
|
395
|
+
? "invalid-output"
|
|
396
|
+
: "write-guard"
|
|
397
|
+
: mapped.failureCategory,
|
|
370
398
|
durationMs: mapped.durationMs || Date.now() - started,
|
|
371
399
|
};
|
|
372
400
|
}
|
|
401
|
+
export function validateWriterImplementationOutcome(text, changedFiles) {
|
|
402
|
+
const firstLine = text
|
|
403
|
+
.split(/\r?\n/)
|
|
404
|
+
.map((line) => line.trim())
|
|
405
|
+
.find(Boolean);
|
|
406
|
+
const match = firstLine?.match(/^IMPLEMENTATION_OUTCOME: (changed|already-satisfied|blocked)$/);
|
|
407
|
+
if (!match) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
reason: "writer outcome validation failed: missing or invalid first line; expected IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const outcome = match[1];
|
|
414
|
+
if (outcome === "blocked") {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
reason: "writer outcome validation failed: IMPLEMENTATION_OUTCOME: blocked cannot complete successfully",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (outcome === "changed" && changedFiles.length === 0) {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
reason: "writer outcome validation failed: changed outcome has an empty diff",
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (outcome === "already-satisfied" && changedFiles.length > 0) {
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
reason: "writer outcome validation failed: already-satisfied outcome has a non-empty diff",
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return { ok: true, outcome };
|
|
433
|
+
}
|
|
373
434
|
export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
|
|
374
435
|
const assistantText = canonicalizeProtocolFirstLine(result.assistantText, firstProtocolLine);
|
|
375
436
|
return {
|
|
@@ -3,6 +3,8 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { PI_REUSE_RUNTIME_ENV, isPiReuseRuntimeEffective, resolvePiReuseRuntimeMode as resolveConfiguredPiReuseRuntimeMode, } from './pi-runtime-reuse.js';
|
|
6
|
+
import { cleanupPiPromptTransport, createPiPromptTransport, } from './pi-prompt-transport.js';
|
|
7
|
+
import { processTreeSpawnOptions, terminateProcessTree } from './process-tree.js';
|
|
6
8
|
export function extractSubagentStats(stdout) {
|
|
7
9
|
let totalCalls = 0;
|
|
8
10
|
let failedCalls = 0;
|
|
@@ -507,12 +509,24 @@ function withCliBackend(result) {
|
|
|
507
509
|
}
|
|
508
510
|
/** CLI-only orchestration: primary attempt + optional fallback model retry. */
|
|
509
511
|
async function runCliOrchestration(options, meta) {
|
|
512
|
+
const promptTransport = createPiPromptTransport(options.prompt);
|
|
513
|
+
try {
|
|
514
|
+
return await runCliOrchestrationWithPromptFile(options, promptTransport.filePath, meta);
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
const cleanup = cleanupPiPromptTransport(promptTransport);
|
|
518
|
+
for (const warning of cleanup.warnings) {
|
|
519
|
+
process.stderr.write(`${warning}\n`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async function runCliOrchestrationWithPromptFile(options, promptFilePath, meta) {
|
|
510
524
|
const primary = options.modelConfig ?? {};
|
|
511
525
|
const attemptedModels = [];
|
|
512
526
|
if (meta?.prependAttemptedModel) {
|
|
513
527
|
attemptedModels.push(meta.prependAttemptedModel);
|
|
514
528
|
}
|
|
515
|
-
const primaryResult = await executeSingleCliAttempt(options, primary);
|
|
529
|
+
const primaryResult = await executeSingleCliAttempt(options, primary, promptFilePath);
|
|
516
530
|
attemptedModels.push(primaryResult.modelDisplay);
|
|
517
531
|
let primaryIssues = [];
|
|
518
532
|
if (primaryResult.ok && options.validateOutput) {
|
|
@@ -525,7 +539,7 @@ async function runCliOrchestration(options, meta) {
|
|
|
525
539
|
&& shouldRetryWithFallback(primaryResult.stderr, primaryResult.stdout))
|
|
526
540
|
|| primaryInvalidOutput);
|
|
527
541
|
if (shouldFallback) {
|
|
528
|
-
const fallbackResult = await executeSingleCliAttempt(options, primary.fallback);
|
|
542
|
+
const fallbackResult = await executeSingleCliAttempt(options, primary.fallback, promptFilePath);
|
|
529
543
|
attemptedModels.push(fallbackResult.modelDisplay);
|
|
530
544
|
const combinedStderr = [primaryResult.stderr, fallbackResult.stderr].filter(Boolean).join('\n--- fallback ---\n');
|
|
531
545
|
const combinedStdout = [primaryResult.stdout, fallbackResult.stdout].filter(Boolean).join('\n--- fallback ---\n');
|
|
@@ -659,7 +673,7 @@ export async function executePiStep(options) {
|
|
|
659
673
|
});
|
|
660
674
|
return { ...cliResult, reuseRuntimeActive };
|
|
661
675
|
}
|
|
662
|
-
async function executeSingleCliAttempt(options, modelConfig) {
|
|
676
|
+
async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
|
|
663
677
|
const modelDisplay = modelConfig.provider && modelConfig.model
|
|
664
678
|
? `${modelConfig.provider}/${modelConfig.model}`
|
|
665
679
|
: modelConfig.model ?? 'default';
|
|
@@ -687,13 +701,17 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
687
701
|
piArgs.push('--thinking', modelConfig.thinking);
|
|
688
702
|
}
|
|
689
703
|
piArgs.push('--tools', options.toolNames.join(','));
|
|
690
|
-
piArgs.push('--append-system-prompt',
|
|
704
|
+
piArgs.push('--append-system-prompt', promptFilePath);
|
|
691
705
|
piArgs.push(...options.attachedFiles.map((file) => `@${file}`));
|
|
692
706
|
piArgs.push(options.userMessage);
|
|
707
|
+
const recordedPiArgs = piArgs.map((arg, index) => index > 0 && piArgs[index - 1] === '--append-system-prompt'
|
|
708
|
+
? '<system-prompt-file>'
|
|
709
|
+
: arg);
|
|
693
710
|
const startedAt = Date.now();
|
|
694
711
|
return await new Promise((resolve, reject) => {
|
|
695
712
|
const child = spawn(process.execPath, [getPiCliPath(), ...piArgs], {
|
|
696
713
|
cwd: options.repoRoot,
|
|
714
|
+
...processTreeSpawnOptions(),
|
|
697
715
|
env: {
|
|
698
716
|
...process.env,
|
|
699
717
|
// Explicitly propagate API keys that pi needs to recognise providers.
|
|
@@ -748,7 +766,7 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
748
766
|
});
|
|
749
767
|
resolve({
|
|
750
768
|
assistantText: collected.assistantText,
|
|
751
|
-
command: ['pi', ...
|
|
769
|
+
command: ['pi', ...recordedPiArgs],
|
|
752
770
|
durationMs,
|
|
753
771
|
exitCode: code,
|
|
754
772
|
failureCategory,
|
|
@@ -771,7 +789,7 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
771
789
|
return;
|
|
772
790
|
timedOut = true;
|
|
773
791
|
supervisionStderr = reason;
|
|
774
|
-
child
|
|
792
|
+
terminateProcessTree(child, 'SIGTERM');
|
|
775
793
|
sigkillHandle = setTimeout(() => {
|
|
776
794
|
if (settled)
|
|
777
795
|
return;
|
|
@@ -779,7 +797,7 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
779
797
|
finish(child.exitCode);
|
|
780
798
|
return;
|
|
781
799
|
}
|
|
782
|
-
child
|
|
800
|
+
terminateProcessTree(child, 'SIGKILL');
|
|
783
801
|
exitConfirmationHandle = setTimeout(() => {
|
|
784
802
|
if (settled)
|
|
785
803
|
return;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { closeSync, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdtempSync, openSync, realpathSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const PROMPT_DIRECTORY_PREFIX = "loop-agent-pi-prompt-";
|
|
5
|
+
const PROMPT_FILE_NAME = "system-prompt.md";
|
|
6
|
+
function identityOf(stats) {
|
|
7
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
8
|
+
}
|
|
9
|
+
function hasIdentity(stats, identity) {
|
|
10
|
+
return stats.dev === identity.dev && stats.ino === identity.ino;
|
|
11
|
+
}
|
|
12
|
+
function normalizedPath(value) {
|
|
13
|
+
const resolved = path.resolve(value);
|
|
14
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
15
|
+
}
|
|
16
|
+
function assertOwnedPromptPaths(transport) {
|
|
17
|
+
const tempRoot = normalizedPath(transport.tempRoot);
|
|
18
|
+
const directoryPath = normalizedPath(transport.directoryPath);
|
|
19
|
+
const filePath = normalizedPath(transport.filePath);
|
|
20
|
+
const tempRootStats = lstatSync(transport.tempRoot, { bigint: true });
|
|
21
|
+
if (!tempRootStats.isDirectory() ||
|
|
22
|
+
tempRootStats.isSymbolicLink() ||
|
|
23
|
+
!hasIdentity(tempRootStats, transport.tempRootIdentity)) {
|
|
24
|
+
throw new Error("Pi prompt cleanup refused: temporary root identity changed");
|
|
25
|
+
}
|
|
26
|
+
if (normalizedPath(path.dirname(transport.directoryPath)) !== tempRoot) {
|
|
27
|
+
throw new Error("Pi prompt cleanup refused: temporary directory escaped its recorded root");
|
|
28
|
+
}
|
|
29
|
+
if (!path.basename(directoryPath).startsWith(PROMPT_DIRECTORY_PREFIX)) {
|
|
30
|
+
throw new Error("Pi prompt cleanup refused: unexpected temporary directory name");
|
|
31
|
+
}
|
|
32
|
+
if (normalizedPath(path.dirname(transport.filePath)) !== directoryPath) {
|
|
33
|
+
throw new Error("Pi prompt cleanup refused: prompt file escaped its recorded directory");
|
|
34
|
+
}
|
|
35
|
+
if (path.basename(filePath) !== PROMPT_FILE_NAME) {
|
|
36
|
+
throw new Error("Pi prompt cleanup refused: unexpected prompt file name");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function createPiPromptTransport(prompt) {
|
|
40
|
+
const tempRoot = realpathSync.native(tmpdir());
|
|
41
|
+
const tempRootStats = lstatSync(tempRoot, { bigint: true });
|
|
42
|
+
if (!tempRootStats.isDirectory() || tempRootStats.isSymbolicLink()) {
|
|
43
|
+
throw new Error("Pi prompt transport requires a real temporary directory");
|
|
44
|
+
}
|
|
45
|
+
const tempRootIdentity = identityOf(tempRootStats);
|
|
46
|
+
const directoryPath = mkdtempSync(path.join(tempRoot, PROMPT_DIRECTORY_PREFIX));
|
|
47
|
+
const currentTempRootStats = lstatSync(tempRoot, { bigint: true });
|
|
48
|
+
if (!hasIdentity(currentTempRootStats, tempRootIdentity)) {
|
|
49
|
+
throw new Error("Pi prompt transport temporary root changed during creation");
|
|
50
|
+
}
|
|
51
|
+
const directoryStats = lstatSync(directoryPath, { bigint: true });
|
|
52
|
+
if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
|
|
53
|
+
throw new Error("Pi prompt transport did not create a private directory");
|
|
54
|
+
}
|
|
55
|
+
const filePath = path.join(directoryPath, PROMPT_FILE_NAME);
|
|
56
|
+
let fileDescriptor;
|
|
57
|
+
let fileIdentity;
|
|
58
|
+
try {
|
|
59
|
+
fileDescriptor = openSync(filePath, "wx", 0o600);
|
|
60
|
+
fileIdentity = identityOf(fstatSync(fileDescriptor, { bigint: true }));
|
|
61
|
+
writeFileSync(fileDescriptor, prompt, { encoding: "utf-8" });
|
|
62
|
+
fsyncSync(fileDescriptor);
|
|
63
|
+
return {
|
|
64
|
+
directoryPath,
|
|
65
|
+
filePath,
|
|
66
|
+
fileDescriptor,
|
|
67
|
+
tempRoot,
|
|
68
|
+
tempRootIdentity,
|
|
69
|
+
directoryIdentity: identityOf(directoryStats),
|
|
70
|
+
fileIdentity,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (fileDescriptor !== undefined && fileIdentity !== undefined) {
|
|
75
|
+
try {
|
|
76
|
+
cleanupPiPromptTransport({
|
|
77
|
+
directoryPath,
|
|
78
|
+
filePath,
|
|
79
|
+
fileDescriptor,
|
|
80
|
+
tempRoot,
|
|
81
|
+
tempRootIdentity,
|
|
82
|
+
directoryIdentity: identityOf(directoryStats),
|
|
83
|
+
fileIdentity,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Creation failed; the original error remains authoritative.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
try {
|
|
92
|
+
if (fileDescriptor !== undefined)
|
|
93
|
+
closeSync(fileDescriptor);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Best effort before rethrowing the creation error.
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const currentDirectory = lstatSync(directoryPath, { bigint: true });
|
|
100
|
+
if (hasIdentity(currentDirectory, identityOf(directoryStats))) {
|
|
101
|
+
rmdirSync(directoryPath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Never recursively remove a transport directory.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Clear and remove only the exact prompt file created by this transport.
|
|
113
|
+
* The directory is removed only when it is still the same empty directory.
|
|
114
|
+
*/
|
|
115
|
+
export function cleanupPiPromptTransport(transport) {
|
|
116
|
+
const warnings = [];
|
|
117
|
+
let contentCleared = false;
|
|
118
|
+
let fileRemoved = false;
|
|
119
|
+
let directoryRemoved = false;
|
|
120
|
+
let truncateError;
|
|
121
|
+
try {
|
|
122
|
+
ftruncateSync(transport.fileDescriptor, 0);
|
|
123
|
+
fsyncSync(transport.fileDescriptor);
|
|
124
|
+
contentCleared = true;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
truncateError = error;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
try {
|
|
131
|
+
closeSync(transport.fileDescriptor);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// A closed descriptor is acceptable; identity checks still guard deletion.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let pathsOwned = false;
|
|
138
|
+
try {
|
|
139
|
+
assertOwnedPromptPaths(transport);
|
|
140
|
+
const directoryStats = lstatSync(transport.directoryPath, { bigint: true });
|
|
141
|
+
const fileStats = lstatSync(transport.filePath, { bigint: true });
|
|
142
|
+
pathsOwned =
|
|
143
|
+
directoryStats.isDirectory() &&
|
|
144
|
+
!directoryStats.isSymbolicLink() &&
|
|
145
|
+
hasIdentity(directoryStats, transport.directoryIdentity) &&
|
|
146
|
+
fileStats.isFile() &&
|
|
147
|
+
!fileStats.isSymbolicLink() &&
|
|
148
|
+
hasIdentity(fileStats, transport.fileIdentity);
|
|
149
|
+
if (!pathsOwned) {
|
|
150
|
+
warnings.push("Pi prompt cleanup refused deletion because path identity changed");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const code = error.code;
|
|
155
|
+
if (code === "ENOENT") {
|
|
156
|
+
fileRemoved = true;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
warnings.push(`Pi prompt cleanup could not verify the exact prompt path: ${error instanceof Error ? error.message : String(error)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (pathsOwned) {
|
|
163
|
+
try {
|
|
164
|
+
unlinkSync(transport.filePath);
|
|
165
|
+
fileRemoved = true;
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
warnings.push(`Pi prompt cleanup could not unlink the exact prompt file: ${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!contentCleared && !fileRemoved) {
|
|
172
|
+
throw new Error(`Pi prompt cleanup could not prove prompt removal: ${truncateError instanceof Error ? truncateError.message : String(truncateError ?? "unknown truncate failure")}`);
|
|
173
|
+
}
|
|
174
|
+
if (fileRemoved) {
|
|
175
|
+
try {
|
|
176
|
+
const directoryStats = lstatSync(transport.directoryPath, { bigint: true });
|
|
177
|
+
if (directoryStats.isDirectory() &&
|
|
178
|
+
!directoryStats.isSymbolicLink() &&
|
|
179
|
+
hasIdentity(directoryStats, transport.directoryIdentity)) {
|
|
180
|
+
rmdirSync(transport.directoryPath);
|
|
181
|
+
directoryRemoved = true;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
warnings.push("Pi prompt cleanup refused directory removal because identity changed");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
const code = error.code;
|
|
189
|
+
if (code === "ENOENT") {
|
|
190
|
+
directoryRemoved = true;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
warnings.push(`Pi prompt cleanup left an empty or non-empty transport directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { contentCleared, fileRemoved, directoryRemoved, warnings };
|
|
198
|
+
}
|
|
@@ -296,7 +296,7 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
296
296
|
'--model', modelConfig.model ?? '(default)',
|
|
297
297
|
...(modelConfig.thinking ? ['--thinking', modelConfig.thinking] : []),
|
|
298
298
|
'--tools', options.toolNames.join(','),
|
|
299
|
-
'--append-system-prompt',
|
|
299
|
+
'--append-system-prompt', '<in-memory-system-prompt>',
|
|
300
300
|
...options.attachedFiles.map((file) => `@${file}`),
|
|
301
301
|
options.userMessage,
|
|
302
302
|
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** Terminate a command and all descendants that it started. */
|
|
3
|
+
export function terminateProcessTree(child, signal, dependencies = {}) {
|
|
4
|
+
if (child.exitCode !== null && child.exitCode !== undefined)
|
|
5
|
+
return true;
|
|
6
|
+
const pid = child.pid;
|
|
7
|
+
if (!pid)
|
|
8
|
+
return child.kill(signal);
|
|
9
|
+
const platform = dependencies.platform ?? process.platform;
|
|
10
|
+
if (platform === "win32") {
|
|
11
|
+
const runTaskkill = dependencies.runTaskkill ?? ((targetPid) => {
|
|
12
|
+
const result = spawnSync("taskkill.exe", ["/pid", String(targetPid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
|
|
13
|
+
return !result.error && result.status === 0;
|
|
14
|
+
});
|
|
15
|
+
return runTaskkill(pid) || child.kill(signal);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const killProcessGroup = dependencies.killProcessGroup ?? ((targetPid, targetSignal) => {
|
|
19
|
+
process.kill(-targetPid, targetSignal);
|
|
20
|
+
});
|
|
21
|
+
killProcessGroup(pid, signal);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return child.kill(signal);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function processTreeSpawnOptions() {
|
|
29
|
+
return {
|
|
30
|
+
detached: process.platform !== "win32",
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
};
|
|
33
|
+
}
|