@tea-agent/loop-agent 0.20.1 → 0.22.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 +72 -0
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/commands/init.js +104 -0
- package/dist/executors/dag-pi-executor.js +26 -0
- package/dist/executors/pi-executor.js +111 -36
- package/dist/executors/pi-sdk-executor.js +105 -29
- package/dist/executors/shell-executor.js +215 -29
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/worker/loop-agent/loop-agent-client.js +43 -9
- package/dist/worker/observability/read-model.js +28 -2
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/constants.js +5 -0
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/format-pool.js +22 -3
- package/dist/worker/observe/static/styles.css +32 -3
- package/dist/worker/observe/static/views/dag-inspector.js +2 -2
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/worker/run-task/run-task.js +16 -6
- package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
- package/dist/workflows/dag/backend-test-result-contract.js +10 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +370 -79
- package/dist/workflows/dag/lifecycle.js +60 -4
- package/dist/workflows/dag/liveness-policy.js +250 -0
- package/dist/workflows/dag/node-execution.js +49 -0
- package/dist/workflows/dag/runner.js +21 -1
- package/dist/workflows/dag/types.js +67 -1
- package/docs/README.md +5 -6
- package/docs/architecture/dag-execution.md +11 -0
- package/docs/architecture/facts-and-state.md +1 -0
- package/docs/architecture/worker-and-feature.md +10 -0
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
- package/docs/templates/backend-test-dag.json +15 -15
- package/docs/templates/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
- package/skills/loop-agent/references/command-reference.md +2 -0
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.22.0] - 2026-07-24
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 前端规范读取范围扩展,新增 ai_workspace 作为并列来源,并独立展示各类规范证据
|
|
10
|
+
- 前端实现 DAG 引入 lint 债务基线机制,精准区分存量与新增代码问题,减少误阻断
|
|
11
|
+
- 前端预写门禁增强,强制校验真实的规范读取事件,避免遗漏关键上下文
|
|
12
|
+
- 后端测试报告结构优化,新增 Markdown 报告并改进 HTML 报告的分层展示
|
|
13
|
+
|
|
14
|
+
### 新增
|
|
15
|
+
|
|
16
|
+
- 前端规范读取将 ai_workspace 目录与 openspec 相关目录作为并列来源,即使缺少 package.json 也能发现本地规范候选
|
|
17
|
+
- Dashboard 规范证据 API 与界面独立展示知识库查询、openspec 检索和成功读取三类证据
|
|
18
|
+
- 后端测试在同目录下新增生成 backend-test.md 报告,便于查阅测试结论与明细
|
|
19
|
+
|
|
20
|
+
### 改进
|
|
21
|
+
|
|
22
|
+
- 前端实现 DAG 允许未修改文件中的已知 lint 诊断作为基线债务继续,但对修改文件的新问题严格阻断
|
|
23
|
+
- 后端测试 HTML 报告采用更清晰的分层展示,新增稳定结论图标与可折叠的质量校验明细,移除了原稳定性指标
|
|
24
|
+
- 前端测试用例的内容完整性与证据检查降级为建议性反馈,不再因个别物料缺失阻断最终复盘
|
|
25
|
+
- 设计文档与研发 DAG 拓扑说明同步更新,澄清了 profile/workflow/type 三套正交概念与当前实现状态
|
|
26
|
+
|
|
27
|
+
### 修复
|
|
28
|
+
|
|
29
|
+
- 修复前端技能指令可能超出预算限制的问题
|
|
30
|
+
- 修复 DAG 未正确注册 DeepSeek Pi 模型提供者的问题
|
|
31
|
+
- 修复前端目标项目验证路由不够健壮的问题
|
|
32
|
+
- 修复后端测试中无法跨环境便携解析 Python 运行时的问题
|
|
33
|
+
|
|
34
|
+
## [0.21.0] - 2026-07-24
|
|
35
|
+
|
|
36
|
+
### 重点更新
|
|
37
|
+
|
|
38
|
+
- DAG/Pi 自适应活跃度监管:精准区分传输活动与真实执行进展,受控处置静默卡死,避免误杀。
|
|
39
|
+
- 后端测试执行范围收窄:仅运行最终 Markdown 明确映射且通过安全校验的脚本,杜绝无关测试混入。
|
|
40
|
+
- 后端测试 HTML 报告全面升级:采用清晰的用例卡片,直观展示脱敏的请求与响应详情。
|
|
41
|
+
|
|
42
|
+
### 新增
|
|
43
|
+
|
|
44
|
+
- DAG/Pi 自适应 liveness 与非误杀监管:区分 runner lease、transport 活动与 Provider/tool/output 真实进展;节点可投影 active/quiet/suspected-stall/probing/needs-attention;静默 stall 会受控终止,退出无法确认时禁止自动重试;Pi absolute max 对齐 4h;Worker 默认不再给 `run-dag` 设置外层 wall-clock,显式 `worker.timeout_ms` 仍为 hard timeout。
|
|
45
|
+
|
|
46
|
+
### 改进
|
|
47
|
+
|
|
48
|
+
- backend-test 第 7 节点现在只执行最终 Markdown `自动化映射` 明确声明且安全校验通过的 pytest 脚本,避免历史或无关 `test_*.py` 混入本轮 JUnit/HTML;空映射、缺失脚本或越界路径会 fail-closed。
|
|
49
|
+
- backend-test HTML 报告升级为更清晰的用例卡片:保留测试结论、质量状态和失败概览,直接展示简洁测试场景、自动化用例名称、结果与耗时,并为通过/失败用例都展示同次执行中脱敏且有界的接口请求参数与响应结果;HTML 不再附带技术证据章节,审计路径与哈希继续保留在 facts/JUnit。
|
|
50
|
+
- frontend-test 第四节点不再扫描或阻断 pytest、原生 Playwright、`npx playwright`、`@playwright/test` 或 Node Playwright API 等可执行工具指令;该节点只保留 manifest、Case/AC/path、非生产 URL 和标准 `playwright-cli open` 前缀等结构与安全门禁,并强烈建议浏览器执行优先使用 `playwright-cli`。
|
|
51
|
+
- frontend-test 用例内容、Case ID、AC 关联与 evidence 完整性检查改为 advisory:缺失或损坏的执行物料不再被自动改写为 blocked,也不阻断最终复盘;finding 汇入结构化结果,并在 Markdown retrospective 后生成中文 HTML 建议报告。路径越界、跨用例 evidence 引用、writeSet、生产环境与凭据安全仍保持硬门禁。
|
|
52
|
+
- frontend-test checklist JavaScript 通过 Base64 参数交给 Node 解码执行,并修正变量声明顺序与重复语句,避免 Git Bash 将 `\s` / `\d`、反引号或引号破坏后再交给 Node。
|
|
53
|
+
- 新初始化项目的 harness 指南同步 adaptive liveness、4h absolute max、stall 状态与 `termination-unconfirmed` 处置;旧项目可由 `init check-update` 识别未修改的受管指南,并通过 `init update --apply-safe` 安全刷新。
|
|
54
|
+
|
|
5
55
|
## [0.20.1] - 2026-07-24
|
|
6
56
|
|
|
7
57
|
### 新增
|
|
@@ -17,6 +67,9 @@
|
|
|
17
67
|
|
|
18
68
|
### 改进
|
|
19
69
|
|
|
70
|
+
- backend-test 第 4 个 Markdown 校验节点只将前置条件、操作步骤和预期结果作为必需章节;需求来源引用有效性不再阻断后续测试。普通质量问题保持 advisory,但 secret-shaped 值会先生成脱敏安全报告再 fail-closed,避免进入 pytest、HTML/facts 和最终报告。
|
|
71
|
+
- backend-test 生成的真实 HTTP pytest 现在必须输出脱敏且有界的请求/响应日志,包含 method、URL/path、请求参数摘要、响应状态和响应 body 摘要;追溯节点会在 Markdown 映射脚本范围内把日志合同缺口记录为 advisory。
|
|
72
|
+
- backend-test 第 7 节点的 HTML/facts 按测试概览、质量校验、失败概览、用例执行明细和技术证据分层展示;第 8 节点使用固定简洁的中文收口结构,减少重复原文并优先展示结论和风险。
|
|
20
73
|
- Operator Console「恢复」面板自动拉取 DAG 运行与节点候选,优先展示失败项并以选择器填入,仍可手动输入
|
|
21
74
|
- 从节点重跑预检失败时展示真实安全资格阻断原因,不再误报为「无法获取 planHash」
|
|
22
75
|
- 恢复页长操作(完整重跑等)会回写终态成功/失败;不再只提示「已接受」后无下文
|
|
@@ -33,10 +86,28 @@
|
|
|
33
86
|
- frontend-test 环境预检:在生成用例前对冻结的非生产 `baseUrl` 做 curl 可达性探测;不可达或缺少 curl 时以 `frontend-base-url-unreachable` / `curl-unavailable` 阻断 generate/map,并写入 `environmentProbe` 事实。节点 5 manifest 物化改为 ruleId 失败分类、map 配额预检与 temp+rename 原子写入;生成/清单/执行强制 playwright-cli-only,U/D 场景要求当前用户数据归属或 create/Mock/blocked。
|
|
34
87
|
- 前端测试 DAG 的证据自检改为按用例降级:个别缺失、空白或格式错误的证据会如实标为 blocked 并继续生成复盘报告;只有全部用例都未产生任何证据内容或证据路径不安全时才失败。
|
|
35
88
|
|
|
89
|
+
### 修复
|
|
90
|
+
|
|
91
|
+
- Observe 总览 KPI 问号提示框宽度与窄卡片解耦:提示框使用稳定的 240–280px 视口安全宽度,并在五列、三列和两列布局的右侧列向内对齐,避免宽屏下被压缩成约 78px 的窄条;hover 与键盘焦点行为保持不变。
|
|
92
|
+
- 节点检查器的规范证据文件预览会渲染 Markdown 标题、列表、表格与代码块,不再把正文作为带语法标记的纯文本显示。
|
|
93
|
+
|
|
36
94
|
## [0.20.0] - 2026-07-23
|
|
37
95
|
|
|
38
96
|
### 新增
|
|
39
97
|
|
|
98
|
+
- 继续收紧前端实现证据门禁:禁止空 verification contract,校验验证目标与 requirement/UI state/授权路径绑定;新版 review context 缺失 writer baseline 时直接阻断,非 `not-needed` Mock 策略必须提供 endpoint 与 consumer 证据。
|
|
99
|
+
- 前端写入前重新校验 DAG 绑定源文件的 SHA-256;Mock endpoint 现在必须声明 fixture 与 consumer,且验证 trace 必须记录成功的 Mock 命令。
|
|
100
|
+
- 前端验证 bundle 现在冻结实际命令文本,并将实现契约 targets 限制在 writer writeSet 内,避免验证证据与可写范围漂移。
|
|
101
|
+
- 同步前端 prewrite/review context 的 DAG JSON Schema,避免生成 DAG 因 freshness、writeSet 或 baseline 字段漂移而被错误拒绝。
|
|
102
|
+
- 前端 prewrite 现在强制校验需求源新鲜度,验证 Schema 也要求声明实际命令文本,减少旧 DAG 和证据配置的隐式降级。
|
|
103
|
+
- 前端实现 DAG 的验证命令与侦察范围改为从目标项目自身的 package scripts、包管理器和允许路径推导,不再假设目标项目沿用 loop-agent 的目录与命令布局。
|
|
104
|
+
|
|
105
|
+
### 改进
|
|
106
|
+
|
|
107
|
+
- 前端实现 DAG 的验证命令与侦察范围改为从目标项目自身的 package scripts、包管理器和允许路径推导,不再假设目标项目沿用 loop-agent 的目录与命令布局。
|
|
108
|
+
- 后端测试用例默认生成中文 README 索引与模块用例卡片,步骤、预期、需求依据和自动化映射更便于测试、研发与评审人员阅读。
|
|
109
|
+
- 后端测试 HTML 主报告现在逐条展示用例编号、中文名称与场景、脚本、测试函数、结果、耗时和失败原因,完整技术详情可折叠查看。
|
|
110
|
+
|
|
40
111
|
- streamline implementation DAG (#61)
|
|
41
112
|
- improve human-readable artifacts
|
|
42
113
|
|
|
@@ -60,6 +131,7 @@
|
|
|
60
131
|
### 改进
|
|
61
132
|
|
|
62
133
|
- `frontend-implementation` 保留独立 contract/scout,并把 Mock 策略与 implementation contract 合入 plan,把写前授权、验证/失败评估、复验和 review context 分别收敛为组合 shell 节点。standard/high-risk 固定 15 个顶层节点、small-risk 13 个;绿色路径执行 11 个节点、7 次 Pi,同时保留唯一 prewrite 写入授权、同 writeSet repair、真实 diff review 与 fail-closed closeout。
|
|
134
|
+
- 加强前端实现节点的证据链:Trace 只绑定本次实际成功执行的验证命令,契约兼容转换不再补入占位文件或默认验证目标,Prewrite 要求上游节点明确完成,review context 校验 artifact 绑定,并以 writer 前工作区快照隔离既有用户改动。
|
|
63
135
|
- 优化操作与观测统一界面的导航与布局,合并确认与运行为「开始运行」,DAG 详情跳转路径更加准确
|
|
64
136
|
|
|
65
137
|
### 修复
|
package/bin/agent-worker.js
CHANGED
|
File without changes
|
|
@@ -63,6 +63,11 @@ function getVerifyCommands(repoRoot, options) {
|
|
|
63
63
|
if (manifestCommands) {
|
|
64
64
|
return applyQuota(manifestCommands, options);
|
|
65
65
|
}
|
|
66
|
+
if (options.taskConfig?.taskKind === 'frontend-implementation') {
|
|
67
|
+
const frontendCommands = getFrontendProjectVerifyCommands(repoRoot, options);
|
|
68
|
+
if (frontendCommands)
|
|
69
|
+
return frontendCommands;
|
|
70
|
+
}
|
|
66
71
|
const quickCommands = [
|
|
67
72
|
{
|
|
68
73
|
args: ['bash', 'scripts/check-repo.sh'],
|
|
@@ -91,6 +96,53 @@ function getVerifyCommands(repoRoot, options) {
|
|
|
91
96
|
: [...quickCommands, ...standardCommands, ...fullCommands];
|
|
92
97
|
return applyQuota(selectedCommands, options);
|
|
93
98
|
}
|
|
99
|
+
function getFrontendProjectVerifyCommands(repoRoot, options) {
|
|
100
|
+
let scripts;
|
|
101
|
+
try {
|
|
102
|
+
const packageJson = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
103
|
+
if (!packageJson.scripts || typeof packageJson.scripts !== 'object')
|
|
104
|
+
return undefined;
|
|
105
|
+
scripts = packageJson.scripts;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
let packageManager = 'npm';
|
|
111
|
+
for (const [lockfile, manager] of [
|
|
112
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
113
|
+
['yarn.lock', 'yarn'],
|
|
114
|
+
['bun.lockb', 'bun'],
|
|
115
|
+
['bun.lock', 'bun'],
|
|
116
|
+
]) {
|
|
117
|
+
try {
|
|
118
|
+
readFileSync(path.join(repoRoot, lockfile));
|
|
119
|
+
packageManager = manager;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Try the next package-manager marker.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const hasScript = (name) => typeof scripts[name] === 'string' && String(scripts[name]).trim().length > 0;
|
|
127
|
+
const commandFor = (name) => ({
|
|
128
|
+
args: name === 'test'
|
|
129
|
+
? [packageManager, 'test']
|
|
130
|
+
: [packageManager, 'run', name],
|
|
131
|
+
cwd: repoRoot,
|
|
132
|
+
label: `${packageManager} ${name === 'test' ? 'test' : `run ${name}`}`,
|
|
133
|
+
});
|
|
134
|
+
const collect = (names) => names.filter(hasScript).map(commandFor);
|
|
135
|
+
const staticCommands = collect(['typecheck', 'check-types', 'lint', 'check', 'build']);
|
|
136
|
+
const behaviorCommands = collect(['test:unit', 'test:frontend', 'test:component', 'test', 'test:e2e', 'e2e']);
|
|
137
|
+
if (staticCommands.length === 0 && behaviorCommands.length === 0)
|
|
138
|
+
return undefined;
|
|
139
|
+
const selected = options.phase === 'final'
|
|
140
|
+
? [...staticCommands, ...behaviorCommands]
|
|
141
|
+
: staticCommands;
|
|
142
|
+
if (selected.length === 0)
|
|
143
|
+
return behaviorCommands;
|
|
144
|
+
return applyQuota(selected, options);
|
|
145
|
+
}
|
|
94
146
|
function applyQuota(commands, options) {
|
|
95
147
|
if (options.phase === 'final' || !options.quota || options.quota === 'full') {
|
|
96
148
|
return commands;
|
package/dist/commands/init.js
CHANGED
|
@@ -522,10 +522,107 @@ has_npm_script() {
|
|
|
522
522
|
node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('package.json','utf8')); process.exit(p.scripts && p.scripts[process.argv[1]] ? 0 : 1)" "\${script}" >/dev/null 2>&1
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
dag_lint_assessment_allows_skip() {
|
|
526
|
+
[[ -n "\${HARNESS_DAG_RUN_DIR:-}" ]] || return 1
|
|
527
|
+
command -v node >/dev/null 2>&1 || return 1
|
|
528
|
+
node - "\${HARNESS_DAG_RUN_DIR}" <<'NODE'
|
|
529
|
+
const fs = require("fs");
|
|
530
|
+
const path = require("path");
|
|
531
|
+
const crypto = require("crypto");
|
|
532
|
+
const runDir = path.resolve(process.argv[2]);
|
|
533
|
+
const assessmentPath = path.join(runDir, "contracts", "frontend-lint-assessment.json");
|
|
534
|
+
const fail = () => process.exit(1);
|
|
535
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
536
|
+
const readContained = (relative) => {
|
|
537
|
+
if (typeof relative !== "string" || path.isAbsolute(relative)) fail();
|
|
538
|
+
const absolute = path.resolve(runDir, relative);
|
|
539
|
+
if (absolute !== runDir && !absolute.startsWith(runDir + path.sep)) fail();
|
|
540
|
+
return fs.readFileSync(absolute);
|
|
541
|
+
};
|
|
542
|
+
let assessment;
|
|
543
|
+
try {
|
|
544
|
+
assessment = JSON.parse(fs.readFileSync(assessmentPath, "utf8"));
|
|
545
|
+
} catch {
|
|
546
|
+
fail();
|
|
547
|
+
}
|
|
548
|
+
if (
|
|
549
|
+
assessment.schemaVersion !== 1 ||
|
|
550
|
+
assessment.schemaId !== "frontend-lint-assessment-v1" ||
|
|
551
|
+
!["passed", "baseline-debt"].includes(assessment.status) ||
|
|
552
|
+
!assessment.commandIdentity ||
|
|
553
|
+
!Array.isArray(assessment.commandIdentity.commands) ||
|
|
554
|
+
assessment.commandIdentity.commands.length === 0 ||
|
|
555
|
+
!Array.isArray(assessment.writerChangedFiles) ||
|
|
556
|
+
!Array.isArray(assessment.blockingDiagnostics) ||
|
|
557
|
+
assessment.blockingDiagnostics.length !== 0 ||
|
|
558
|
+
!Array.isArray(assessment.blockingReasons) ||
|
|
559
|
+
assessment.blockingReasons.length !== 0
|
|
560
|
+
) fail();
|
|
561
|
+
const commandHash = sha256(JSON.stringify(assessment.commandIdentity.commands));
|
|
562
|
+
if (commandHash !== assessment.commandIdentity.sha256) fail();
|
|
563
|
+
if (!assessment.commandIdentity.commands.every((command) =>
|
|
564
|
+
/(?:^|[\\s'"])npm(?:['"])?\\s+(?:['"])?run(?:['"])?\\s+(?:['"])?lint(?:['"])?(?:\\s|$)/.test(command)
|
|
565
|
+
)) fail();
|
|
566
|
+
if (
|
|
567
|
+
(assessment.status === "passed" && assessment.currentExitCode !== 0) ||
|
|
568
|
+
(assessment.status === "baseline-debt" && assessment.currentExitCode === 0)
|
|
569
|
+
) fail();
|
|
570
|
+
if (!assessment.baselineRef || assessment.baselineRef.nodeId !== "frontend-lint-baseline-shell") fail();
|
|
571
|
+
const baselineRaw = readContained(assessment.baselineRef.path);
|
|
572
|
+
if (sha256(baselineRaw) !== assessment.baselineRef.sha256) fail();
|
|
573
|
+
let baseline;
|
|
574
|
+
try {
|
|
575
|
+
baseline = JSON.parse(baselineRaw);
|
|
576
|
+
} catch {
|
|
577
|
+
fail();
|
|
578
|
+
}
|
|
579
|
+
if (
|
|
580
|
+
baseline.schemaVersion !== 1 ||
|
|
581
|
+
baseline.schemaId !== "frontend-lint-baseline-v1" ||
|
|
582
|
+
baseline.status !== "available" ||
|
|
583
|
+
baseline.commandIdentity?.sha256 !== assessment.commandIdentity.sha256
|
|
584
|
+
) fail();
|
|
585
|
+
if (assessment.status === "baseline-debt") {
|
|
586
|
+
if (
|
|
587
|
+
!Array.isArray(assessment.currentDiagnostics) ||
|
|
588
|
+
assessment.currentDiagnostics.length === 0 ||
|
|
589
|
+
assessment.currentDiagnostics.length !== assessment.toleratedDiagnosticCount ||
|
|
590
|
+
!Array.isArray(baseline.diagnostics)
|
|
591
|
+
) fail();
|
|
592
|
+
const key = (item) => JSON.stringify([
|
|
593
|
+
item.file, item.line, item.column, item.severity, item.message, item.ruleId ?? null,
|
|
594
|
+
]);
|
|
595
|
+
const baselineCounts = new Map();
|
|
596
|
+
for (const item of baseline.diagnostics) {
|
|
597
|
+
const value = key(item);
|
|
598
|
+
baselineCounts.set(value, (baselineCounts.get(value) || 0) + 1);
|
|
599
|
+
}
|
|
600
|
+
const changed = new Set(assessment.writerChangedFiles);
|
|
601
|
+
for (const item of assessment.currentDiagnostics) {
|
|
602
|
+
if (changed.has(item.file)) fail();
|
|
603
|
+
const value = key(item);
|
|
604
|
+
const count = baselineCounts.get(value) || 0;
|
|
605
|
+
if (count === 0) fail();
|
|
606
|
+
baselineCounts.set(value, count - 1);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (!Array.isArray(assessment.rawEvidenceRefs) || assessment.rawEvidenceRefs.length === 0) fail();
|
|
610
|
+
for (const ref of assessment.rawEvidenceRefs) {
|
|
611
|
+
if (sha256(readContained(ref.path)) !== ref.sha256) fail();
|
|
612
|
+
}
|
|
613
|
+
process.exit(0);
|
|
614
|
+
NODE
|
|
615
|
+
}
|
|
616
|
+
|
|
525
617
|
if [[ -f package.json ]]; then
|
|
526
618
|
if command -v npm >/dev/null 2>&1; then
|
|
527
619
|
for script in lint typecheck test build; do
|
|
528
620
|
if has_npm_script "\${script}"; then
|
|
621
|
+
if [[ "\${script}" == "lint" ]] && dag_lint_assessment_allows_skip; then
|
|
622
|
+
echo "==> lint handled by frontend DAG assessment (status: passed or baseline-debt)"
|
|
623
|
+
ran=1
|
|
624
|
+
continue
|
|
625
|
+
fi
|
|
529
626
|
run_cmd npm run "\${script}"
|
|
530
627
|
fi
|
|
531
628
|
done
|
|
@@ -2057,6 +2154,13 @@ function buildTargetLoopAgentHarness(input) {
|
|
|
2057
2154
|
"",
|
|
2058
2155
|
"Use `loop-agent new-task`, `loop-agent dag run-task`, `loop-agent dag validate`, and `loop-agent run-dag` for non-trivial implementation work.",
|
|
2059
2156
|
"",
|
|
2157
|
+
"## Adaptive Liveness",
|
|
2158
|
+
"",
|
|
2159
|
+
"- Healthy Pi nodes are no longer stopped by a fixed 30-minute deadline. The default 4h absolute max is a final safety bound and cannot be extended by synthetic heartbeats.",
|
|
2160
|
+
"- Runner heartbeat proves only the runner lease. Provider, tool, or output activity records meaningful progress; prolonged inactivity can surface as `quiet`, `suspected-stall`, `probing`, or `needs-attention` in `dag status`, `dag doctor`, and Observe.",
|
|
2161
|
+
"- A silent transport is aborted in a controlled way. `termination-unconfirmed` means the old attempt may still exist, so loop-agent fails closed and does not start an automatic retry; inspect the run before any operator recovery.",
|
|
2162
|
+
"- `agent-worker` does not set an outer `run-dag` wall-clock by default. An explicit `worker.timeout_ms` remains a hard timeout.",
|
|
2163
|
+
"",
|
|
2060
2164
|
"## Operator Recovery",
|
|
2061
2165
|
"",
|
|
2062
2166
|
"After a run, use `loop-agent dag report --run-id <run-id> --markdown` to read canonical run facts and `loop-agent dag doctor --run-id <run-id> --markdown` to diagnose failed or paused runs. Failed DAG runs should produce a failure handoff via `loop-agent dag closeout-draft --run-id <run-id>` instead of a successful closeout.",
|
|
@@ -19,6 +19,8 @@ export const DAG_PI_MODEL_PROVIDERS = {
|
|
|
19
19
|
"gpt-5.3-codex-spark": "wizard-local",
|
|
20
20
|
"gpt-5.5": "wizard-local",
|
|
21
21
|
"glm-5.2": "wizard-local",
|
|
22
|
+
"deepseek-v4-flash": "deepseek",
|
|
23
|
+
"deepseek-v4-pro": "deepseek",
|
|
22
24
|
};
|
|
23
25
|
const SAFE_PI_STEPS = new Set([
|
|
24
26
|
"analyze",
|
|
@@ -263,6 +265,17 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
263
265
|
beforePathFingerprints = undefined;
|
|
264
266
|
}
|
|
265
267
|
}
|
|
268
|
+
const reportActivity = input.reportActivity;
|
|
269
|
+
const attempt = input.attempt ?? 1;
|
|
270
|
+
const bridgeActivity = reportActivity
|
|
271
|
+
? (kind, at) => {
|
|
272
|
+
reportActivity({
|
|
273
|
+
attempt,
|
|
274
|
+
kind,
|
|
275
|
+
at: at ?? new Date().toISOString(),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
: undefined;
|
|
266
279
|
const result = await piStepFn({
|
|
267
280
|
attachedFiles: [],
|
|
268
281
|
modelConfig: resolveDagPiModelConfig(input.model),
|
|
@@ -272,6 +285,19 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
|
|
|
272
285
|
toolNames: resolveDagPiToolNames(input.task),
|
|
273
286
|
userMessage: buildDagPiUserMessage(input.task, persona, step),
|
|
274
287
|
sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
|
|
288
|
+
// Absolute max wall clock from adaptive liveness policy (defaults to 4h).
|
|
289
|
+
timeoutMs: input.timeoutMs,
|
|
290
|
+
stallTimeoutMs: input.stallTimeoutMs,
|
|
291
|
+
abortGraceMs: input.abortGraceMs,
|
|
292
|
+
onActivity: bridgeActivity
|
|
293
|
+
? (activity) => {
|
|
294
|
+
if (activity.kind === "lease"
|
|
295
|
+
|| activity.kind === "synthetic-heartbeat") {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
bridgeActivity(activity.kind, activity.at);
|
|
299
|
+
}
|
|
300
|
+
: undefined,
|
|
275
301
|
});
|
|
276
302
|
const context = {
|
|
277
303
|
channel: "dag",
|
|
@@ -47,8 +47,13 @@ export function extractSubagentStats(stdout) {
|
|
|
47
47
|
parts.push(`agents=[${Array.from(agents).sort().join(',')}]`);
|
|
48
48
|
return parts.join(' | ');
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
export const
|
|
50
|
+
/** Absolute max wall clock for a single Pi attempt (4h). Not renewed by empty heartbeats. */
|
|
51
|
+
export const DEFAULT_TIMEOUT_MS = 14_400_000;
|
|
52
|
+
export const MAX_ALLOWED_TIMEOUT_MS = 14_400_000;
|
|
53
|
+
/** Max provider/CLI transport silence before initiating a controlled abort (15m). */
|
|
54
|
+
export const DEFAULT_STALL_TIMEOUT_MS = 900_000;
|
|
55
|
+
/** Grace after SIGTERM before SIGKILL on absolute max. */
|
|
56
|
+
export const DEFAULT_ABORT_GRACE_MS = 30_000;
|
|
52
57
|
const PI_BACKEND_ENV = 'CODE_AGENT_PI_BACKEND';
|
|
53
58
|
const OUTPUT_PREVIEW_HEAD_CHARS = 64_000;
|
|
54
59
|
const OUTPUT_PREVIEW_TAIL_CHARS = 64_000;
|
|
@@ -515,7 +520,9 @@ async function runCliOrchestration(options, meta) {
|
|
|
515
520
|
}
|
|
516
521
|
const primaryHardFail = !primaryResult.ok;
|
|
517
522
|
const primaryInvalidOutput = primaryResult.ok && primaryIssues.length > 0;
|
|
518
|
-
const shouldFallback = primary.fallback && ((primaryHardFail
|
|
523
|
+
const shouldFallback = primary.fallback && ((primaryHardFail
|
|
524
|
+
&& primaryResult.failureCategory !== 'termination-unconfirmed'
|
|
525
|
+
&& shouldRetryWithFallback(primaryResult.stderr, primaryResult.stdout))
|
|
519
526
|
|| primaryInvalidOutput);
|
|
520
527
|
if (shouldFallback) {
|
|
521
528
|
const fallbackResult = await executeSingleCliAttempt(options, primary.fallback);
|
|
@@ -590,7 +597,9 @@ async function runCliOrchestration(options, meta) {
|
|
|
590
597
|
function shouldFallbackSdkToCli(sdkResult, validationIssues) {
|
|
591
598
|
if (sdkResult.ok && validationIssues.length > 0)
|
|
592
599
|
return true;
|
|
593
|
-
if (!sdkResult.ok && (sdkResult.failureCategory === 'auth'
|
|
600
|
+
if (!sdkResult.ok && (sdkResult.failureCategory === 'auth'
|
|
601
|
+
|| sdkResult.failureCategory === 'timeout'
|
|
602
|
+
|| sdkResult.failureCategory === 'termination-unconfirmed')) {
|
|
594
603
|
return false;
|
|
595
604
|
}
|
|
596
605
|
if (!sdkResult.ok && shouldRetryWithFallback(sdkResult.stderr, sdkResult.stdout))
|
|
@@ -655,9 +664,15 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
655
664
|
? `${modelConfig.provider}/${modelConfig.model}`
|
|
656
665
|
: modelConfig.model ?? 'default';
|
|
657
666
|
const timeoutMs = options.timeoutMs ?? modelConfig.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
667
|
+
const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
668
|
+
const abortGraceMs = options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
|
|
658
669
|
let timedOut = false;
|
|
670
|
+
let terminationUnconfirmed = false;
|
|
671
|
+
let supervisionStderr = '';
|
|
659
672
|
let timeoutHandle;
|
|
673
|
+
let stallHandle;
|
|
660
674
|
let sigkillHandle;
|
|
675
|
+
let exitConfirmationHandle;
|
|
661
676
|
// Build pi CLI arguments (same as before).
|
|
662
677
|
// We invoke pi via `node <pi-cli-path>` instead of relying on `#!/usr/bin/env node`
|
|
663
678
|
// to avoid provider resolution issues when spawned from tsx-managed processes.
|
|
@@ -698,39 +713,39 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
698
713
|
const stdoutPreview = new BoundedTextPreview('stdout');
|
|
699
714
|
const stderrPreview = new BoundedTextPreview('stderr', 16_000, 16_000);
|
|
700
715
|
const stdoutCollector = new PiJsonlStreamCollector();
|
|
716
|
+
let settled = false;
|
|
701
717
|
child.stdout.setEncoding('utf-8');
|
|
702
718
|
child.stderr.setEncoding('utf-8');
|
|
703
|
-
|
|
704
|
-
stdoutPreview.append(chunk);
|
|
705
|
-
stdoutCollector.append(chunk);
|
|
706
|
-
});
|
|
707
|
-
child.stderr.on('data', (chunk) => {
|
|
708
|
-
stderrPreview.append(chunk);
|
|
709
|
-
});
|
|
710
|
-
child.on('error', (err) => {
|
|
711
|
-
if (timeoutHandle)
|
|
712
|
-
clearTimeout(timeoutHandle);
|
|
713
|
-
if (sigkillHandle)
|
|
714
|
-
clearTimeout(sigkillHandle);
|
|
715
|
-
reject(err);
|
|
716
|
-
});
|
|
717
|
-
child.on('close', (code) => {
|
|
719
|
+
const clearSupervisionTimers = () => {
|
|
718
720
|
if (timeoutHandle)
|
|
719
721
|
clearTimeout(timeoutHandle);
|
|
722
|
+
if (stallHandle)
|
|
723
|
+
clearTimeout(stallHandle);
|
|
720
724
|
if (sigkillHandle)
|
|
721
725
|
clearTimeout(sigkillHandle);
|
|
726
|
+
if (exitConfirmationHandle)
|
|
727
|
+
clearTimeout(exitConfirmationHandle);
|
|
728
|
+
};
|
|
729
|
+
const finish = (code) => {
|
|
730
|
+
if (settled)
|
|
731
|
+
return;
|
|
732
|
+
settled = true;
|
|
733
|
+
clearSupervisionTimers();
|
|
722
734
|
const durationMs = Date.now() - startedAt;
|
|
723
735
|
const stdout = stdoutPreview.text();
|
|
724
|
-
const
|
|
736
|
+
const capturedStderr = stderrPreview.text();
|
|
737
|
+
const stderr = [supervisionStderr, capturedStderr].filter(Boolean).join('\n');
|
|
725
738
|
const collected = stdoutCollector.finish();
|
|
726
|
-
const failureCategory =
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
739
|
+
const failureCategory = terminationUnconfirmed
|
|
740
|
+
? 'termination-unconfirmed'
|
|
741
|
+
: classifyPiFailure({
|
|
742
|
+
assistantText: collected.assistantText,
|
|
743
|
+
exitCode: code,
|
|
744
|
+
stderr,
|
|
745
|
+
stdout,
|
|
746
|
+
timedOut,
|
|
747
|
+
outputTooLarge: collected.outputTooLarge,
|
|
748
|
+
});
|
|
734
749
|
resolve({
|
|
735
750
|
assistantText: collected.assistantText,
|
|
736
751
|
command: ['pi', ...piArgs],
|
|
@@ -738,7 +753,7 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
738
753
|
exitCode: code,
|
|
739
754
|
failureCategory,
|
|
740
755
|
modelDisplay,
|
|
741
|
-
ok: code === 0 && collected.assistantText.length > 0 && !collected.outputTooLarge,
|
|
756
|
+
ok: !timedOut && code === 0 && collected.assistantText.length > 0 && !collected.outputTooLarge,
|
|
742
757
|
parsedEvents: collected.parsedEvents,
|
|
743
758
|
stderr,
|
|
744
759
|
stdout,
|
|
@@ -749,16 +764,76 @@ async function executeSingleCliAttempt(options, modelConfig) {
|
|
|
749
764
|
tokensUsed: collected.tokensUsed,
|
|
750
765
|
subagentStats: collected.subagentStats,
|
|
751
766
|
});
|
|
767
|
+
};
|
|
768
|
+
const hasConfirmedExitCode = () => child.exitCode !== null && child.exitCode !== undefined;
|
|
769
|
+
const beginControlledTermination = (reason) => {
|
|
770
|
+
if (timedOut || settled)
|
|
771
|
+
return;
|
|
772
|
+
timedOut = true;
|
|
773
|
+
supervisionStderr = reason;
|
|
774
|
+
child.kill('SIGTERM');
|
|
775
|
+
sigkillHandle = setTimeout(() => {
|
|
776
|
+
if (settled)
|
|
777
|
+
return;
|
|
778
|
+
if (hasConfirmedExitCode()) {
|
|
779
|
+
finish(child.exitCode);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
child.kill('SIGKILL');
|
|
783
|
+
exitConfirmationHandle = setTimeout(() => {
|
|
784
|
+
if (settled)
|
|
785
|
+
return;
|
|
786
|
+
if (hasConfirmedExitCode()) {
|
|
787
|
+
finish(child.exitCode);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
terminationUnconfirmed = true;
|
|
791
|
+
supervisionStderr += `\npi CLI exit was not confirmed within ${abortGraceMs}ms after SIGKILL`;
|
|
792
|
+
finish(null);
|
|
793
|
+
}, abortGraceMs);
|
|
794
|
+
}, abortGraceMs);
|
|
795
|
+
};
|
|
796
|
+
const armStallWatchdog = () => {
|
|
797
|
+
if (stallTimeoutMs <= 0 || timedOut || settled)
|
|
798
|
+
return;
|
|
799
|
+
if (stallHandle)
|
|
800
|
+
clearTimeout(stallHandle);
|
|
801
|
+
stallHandle = setTimeout(() => {
|
|
802
|
+
beginControlledTermination(`pi CLI step stalled after ${stallTimeoutMs}ms with no stdout/stderr activity`);
|
|
803
|
+
}, stallTimeoutMs);
|
|
804
|
+
};
|
|
805
|
+
const reportOutputActivity = () => {
|
|
806
|
+
armStallWatchdog();
|
|
807
|
+
try {
|
|
808
|
+
options.onActivity?.({ kind: 'output', at: new Date().toISOString() });
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
// best-effort: never fail the pi step
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
child.stdout.on('data', (chunk) => {
|
|
815
|
+
stdoutPreview.append(chunk);
|
|
816
|
+
stdoutCollector.append(chunk);
|
|
817
|
+
if (chunk.length > 0)
|
|
818
|
+
reportOutputActivity();
|
|
819
|
+
});
|
|
820
|
+
child.stderr.on('data', (chunk) => {
|
|
821
|
+
stderrPreview.append(chunk);
|
|
822
|
+
if (chunk.length > 0)
|
|
823
|
+
reportOutputActivity();
|
|
752
824
|
});
|
|
825
|
+
child.on('error', (err) => {
|
|
826
|
+
clearSupervisionTimers();
|
|
827
|
+
settled = true;
|
|
828
|
+
reject(err);
|
|
829
|
+
});
|
|
830
|
+
child.on('close', (code) => {
|
|
831
|
+
finish(code);
|
|
832
|
+
});
|
|
833
|
+
armStallWatchdog();
|
|
753
834
|
if (timeoutMs > 0) {
|
|
754
835
|
timeoutHandle = setTimeout(() => {
|
|
755
|
-
|
|
756
|
-
child.kill('SIGTERM');
|
|
757
|
-
sigkillHandle = setTimeout(() => {
|
|
758
|
-
if (child.exitCode === null) {
|
|
759
|
-
child.kill('SIGKILL');
|
|
760
|
-
}
|
|
761
|
-
}, 5_000);
|
|
836
|
+
beginControlledTermination(`pi CLI step timed out after ${timeoutMs}ms`);
|
|
762
837
|
}, timeoutMs);
|
|
763
838
|
}
|
|
764
839
|
});
|