@tea-agent/loop-agent 0.35.0 → 0.35.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +20 -0
- package/bin/loop-agent.js +37 -1
- package/dist/build-stamp.json +6 -0
- package/dist/cli/program.js +2 -2
- package/dist/shared/package-metadata.js +32 -0
- package/dist/worker/loop-agent/loop-agent-client.js +13 -0
- package/dist/workflows/dag/contract-output-registry.js +14 -0
- package/dist/workflows/dag/contract-validator-registrations.js +8 -0
- package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
- package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
- package/dist/workflows/dag/frontend-repair.js +219 -18
- package/dist/workflows/dag/frontend-verification-trace.js +47 -32
- package/dist/workflows/dag/init-hybrid.js +41 -24
- package/dist/workflows/dag/node-execution.js +89 -0
- package/dist/workflows/dag/runner.js +52 -3
- package/dist/workflows/dag/scheduler.js +98 -3
- package/dist/workflows/dag/types.js +23 -2
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -94,6 +94,8 @@ npm run build
|
|
|
94
94
|
node bin/loop-agent.js --help
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
- 改 `src/**` 后必须先 `npm run build` 再用 `bin/loop-agent.js`:bin 内置 stale-dist 守卫(src 比 dist 新即硬失败,`--ignore-stale-dist` 逃生),`--version` 会显示构建 git SHA,发布/自测前用它确认跑的是目标改动。
|
|
98
|
+
|
|
97
99
|
文档站变更:`npm run docs:build`。init / architecture / skill entry / pack 定向验证见 verification-matrix。
|
|
98
100
|
|
|
99
101
|
## 交接
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.35.1-beta.1] - 2026-08-14
|
|
6
|
+
|
|
7
|
+
### 改进
|
|
8
|
+
|
|
9
|
+
- DAG runner 捕获 SIGTERM/SIGINT:外层硬超时或监督层杀进程时,先把 run 写成 `failed` 终态(含 `terminalReason` 与 `finishedAt`)再退出,不再留 orphaned RUNNING;agent-worker 硬超时改为 SIGTERM→宽限 10s→SIGKILL 两级,给终态落盘留窗口
|
|
10
|
+
- prewrite gate 失败结果新增机器可读 `failureCode`(如 `mock-strategy-outside-allowed`、`verdict-not-pass`、`contract-invalid` 等),失败场景强制非空,下游路由不再解析散文
|
|
11
|
+
- plan 节点的契约自校验改为 `schemaId → validator` 注册表(`contract-output-registry`),新增契约无需改 executor;契约 schema 层新增深度 null 归一(`z.preprocess` 递归删 null),模型对可选字段吐 null 不再炸 strict schema,必填字段 null 仍以清晰 Required 报错
|
|
12
|
+
- VERDICT 归一更宽容:`VERDICT:pass`(缺空格)、尾部句号/感叹号等漂移在节点级即归一,减少无谓的 protocol-invalid 重试
|
|
13
|
+
- 构建写入 `dist/build-stamp.json`(git SHA + 时间),`loop-agent --version` 显示构建来源;`bin/loop-agent.js` 在源码树内运行且 dist 落后于 src 时硬失败(`--ignore-stale-dist` 逃生),杜绝「改了没生效」的陈旧产物事故
|
|
14
|
+
|
|
15
|
+
- `frontend-implementation` DAG 删除冗余的 `frontend-contract-json-pi` 与 `frontend-contract-json-validate-shell` 两个节点,`frontend-prewrite-gate-shell` 直接消费 plan/revision 的 candidate JSON,成为 candidate → canonical 的唯一转换点(standard 17→15、small 15→13、green path 13→11 节点 / 8→7 Pi)
|
|
16
|
+
- prewrite gate 新增 `frontend-prewrite-result-v1` 分类 artifact(accepted / accepted-normalized / retryable-invalid / blocked),blocked/retryable-invalid 先落 artifact 再以结构化结果返回
|
|
17
|
+
- 新增两层 writer 前置门禁:scheduler admission + executeDagNode 运行前 guard,仅在 classification 为 accepted/accepted-normalized 时放行 writer,否则 SKIPPED + `frontend-prewrite-not-authorized`,writer provider 调用次数为 0
|
|
18
|
+
- 终态聚合对 candidate-contract-invalid / prewrite-blocked 强制 `failed`,防止「prewrite FINISHED + writer SKIPPED」误判 partial_failed
|
|
19
|
+
- 删除 contract-json 节点后,frozen command label 提醒与 requirement coverage 指令迁移进 `frontend-plan-pi` / `frontend-plan-revision-pi` 提示,模型侧引导不丢失
|
|
20
|
+
- `frontend-repair.ts` 的失败分类从 nodeId/stdout/stderr 字符串启发式改为结构化证据(verificationTargetId / commandLabel → commandCategory / exitCode / traceRef / changedPaths),字符串匹配降级为兜底
|
|
21
|
+
- `review` 失败不再默认可自动修复:只有 finding 同时具备明确文件、行或符号、requirement/AC 且修复范围 ⊆ writeSet、不需重新规划 contract 时才进入自动 repair,否则转人工
|
|
22
|
+
- `frontendRepairAssessment` 扩展结构化证据字段(verificationTargetId / commandLabel / exitCode / commandCategory / requirementIds / acceptanceCriteriaIds / fixScope / changedPaths / traceRef),缺必要证据时不再判定 repairable
|
|
23
|
+
- `frontend-implement-pi` 与 `frontend-repair-pi` 共享同一 prompt builder / contract loader / writeSet guard,仅 phase 与 baseline 不同
|
|
24
|
+
|
|
5
25
|
## [0.35.0] - 2026-08-13
|
|
6
26
|
|
|
7
27
|
### 重点更新
|
package/bin/loop-agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
|
|
@@ -13,6 +13,42 @@ if (!existsSync(cliEntry)) {
|
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Stale-dist guard: when running from a source checkout (src/ present), refuse
|
|
18
|
+
* to silently execute a dist/ that predates the latest source edit. The
|
|
19
|
+
* classic failure mode is editing src/** and running the bin without
|
|
20
|
+
* `npm run build`, which executes the previous build. Suppress with
|
|
21
|
+
* --ignore-stale-dist when intentionally running a stale build.
|
|
22
|
+
*/
|
|
23
|
+
const srcDir = join(packageRoot, "src");
|
|
24
|
+
const ignoreStale = process.argv.includes("--ignore-stale-dist");
|
|
25
|
+
if (ignoreStale) {
|
|
26
|
+
process.argv = process.argv.filter((arg) => arg !== "--ignore-stale-dist");
|
|
27
|
+
}
|
|
28
|
+
if (!ignoreStale && existsSync(srcDir)) {
|
|
29
|
+
const distCliMtime = statSync(cliEntry).mtimeMs;
|
|
30
|
+
let newestSrcMtime = 0;
|
|
31
|
+
const walk = (dir) => {
|
|
32
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
33
|
+
const full = join(dir, entry.name);
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
walk(full);
|
|
36
|
+
} else {
|
|
37
|
+
const mtime = statSync(full).mtimeMs;
|
|
38
|
+
if (mtime > newestSrcMtime) newestSrcMtime = mtime;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
walk(srcDir);
|
|
43
|
+
if (newestSrcMtime > distCliMtime) {
|
|
44
|
+
console.error(
|
|
45
|
+
`loop-agent: dist/ is stale relative to src/ (a source file is newer than ${cliEntry}).\n` +
|
|
46
|
+
` Run \`npm run build\` before using the bin, or pass --ignore-stale-dist to run the stale build intentionally.`,
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
16
52
|
try {
|
|
17
53
|
await import(pathToFileURL(cliEntry).href);
|
|
18
54
|
} catch (error) {
|
package/dist/cli/program.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import {
|
|
4
|
+
import { readCliVersionLabel } from "../shared/package-metadata.js";
|
|
5
5
|
import { resolveAdapter } from "../adapters/index.js";
|
|
6
6
|
import { runCoverageAudit } from "../commands/coverage-audit.js";
|
|
7
7
|
import { runCoverageReport } from "../commands/coverage-report.js";
|
|
@@ -547,7 +547,7 @@ export function buildLoopAgentProgram(options) {
|
|
|
547
547
|
program
|
|
548
548
|
.name("loop-agent")
|
|
549
549
|
.description("Loop Agent task and DAG automation CLI")
|
|
550
|
-
.version(
|
|
550
|
+
.version(readCliVersionLabel(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))) ?? "0.0.0", "-V, --version", "display version")
|
|
551
551
|
.showHelpAfterError()
|
|
552
552
|
.option("--repo-root <path>", "repository root")
|
|
553
553
|
.option("--adapter <name>", "harness adapter name")
|
|
@@ -45,6 +45,38 @@ export function readPackageName(packageDir) {
|
|
|
45
45
|
}
|
|
46
46
|
return undefined;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Read the build stamp (git SHA + build time) written by the build pipeline
|
|
50
|
+
* into `<packageDir>/dist/build-stamp.json`. `undefined` when absent (e.g.
|
|
51
|
+
* running from source via tsx, or a hand-assembled package), which callers
|
|
52
|
+
* treat as "no stamp available".
|
|
53
|
+
*/
|
|
54
|
+
export function readBuildStamp(packageDir) {
|
|
55
|
+
try {
|
|
56
|
+
const raw = readFileSync(path.join(packageDir, "dist", "build-stamp.json"), "utf-8");
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
if (parsed.schemaVersion !== 1 ||
|
|
59
|
+
typeof parsed.version !== "string" ||
|
|
60
|
+
typeof parsed.builtAt !== "string")
|
|
61
|
+
return undefined;
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Human-readable CLI version label: plain version, enriched with the build
|
|
70
|
+
* stamp (short git SHA + build date) when available so a stale dist/ that was
|
|
71
|
+
* built from an old commit is immediately recognizable in `--version`.
|
|
72
|
+
*/
|
|
73
|
+
export function readCliVersionLabel(packageDir) {
|
|
74
|
+
const version = readPackageVersion(packageDir) ?? "0.0.0";
|
|
75
|
+
const stamp = readBuildStamp(packageDir);
|
|
76
|
+
if (!stamp?.gitSha)
|
|
77
|
+
return version;
|
|
78
|
+
return `${version} (build ${stamp.gitSha.slice(0, 8)} ${stamp.builtAt.slice(0, 10)})`;
|
|
79
|
+
}
|
|
48
80
|
/**
|
|
49
81
|
* Compute the SHA-256 hash of a file on disk.
|
|
50
82
|
*
|
|
@@ -7,6 +7,10 @@ import { parseCommandJson } from "./parse-json.js";
|
|
|
7
7
|
export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
|
|
8
8
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
9
9
|
export const LOOP_AGENT_PACKAGE_NAME = "@tea-agent/loop-agent";
|
|
10
|
+
/** Grace window between the SIGTERM and SIGKILL stages of a hard timeout, so a
|
|
11
|
+
* runner can persist terminal state (see executeDagCheckpoint) before being
|
|
12
|
+
* force-killed. */
|
|
13
|
+
export const HARD_TIMEOUT_KILL_GRACE_MS = 10_000;
|
|
10
14
|
const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
|
|
11
15
|
/**
|
|
12
16
|
* Bind evidence supplied by an orchestration layer to the identity exposed by
|
|
@@ -381,10 +385,17 @@ function spawnCommand(input) {
|
|
|
381
385
|
});
|
|
382
386
|
};
|
|
383
387
|
let hardTimeout;
|
|
388
|
+
let killGraceTimeout;
|
|
384
389
|
if (input.hardTimeoutMs !== undefined && input.hardTimeoutMs > 0) {
|
|
385
390
|
hardTimeout = setTimeout(() => {
|
|
386
391
|
timedOut = true;
|
|
392
|
+
// Two-stage termination: SIGTERM first so the runner can persist a
|
|
393
|
+
// terminal state (see executeDagCheckpoint signal handler), then
|
|
394
|
+
// SIGKILL only after the grace window expires.
|
|
387
395
|
child.kill("SIGTERM");
|
|
396
|
+
killGraceTimeout = setTimeout(() => {
|
|
397
|
+
child.kill("SIGKILL");
|
|
398
|
+
}, HARD_TIMEOUT_KILL_GRACE_MS);
|
|
388
399
|
}, input.hardTimeoutMs);
|
|
389
400
|
}
|
|
390
401
|
let heartbeatTimer;
|
|
@@ -394,6 +405,8 @@ function spawnCommand(input) {
|
|
|
394
405
|
const clearTimers = () => {
|
|
395
406
|
if (hardTimeout !== undefined)
|
|
396
407
|
clearTimeout(hardTimeout);
|
|
408
|
+
if (killGraceTimeout !== undefined)
|
|
409
|
+
clearTimeout(killGraceTimeout);
|
|
397
410
|
if (heartbeatTimer !== undefined) {
|
|
398
411
|
clearInterval(heartbeatTimer);
|
|
399
412
|
heartbeatTimer = undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { structuredContractOutputSchemaIds, } from "./types.js";
|
|
2
|
+
const validators = new Map();
|
|
3
|
+
export function registerStructuredContractValidator(schemaId, validator) {
|
|
4
|
+
if (validators.has(schemaId)) {
|
|
5
|
+
throw new Error(`structured contract validator already registered for ${schemaId}`);
|
|
6
|
+
}
|
|
7
|
+
validators.set(schemaId, validator);
|
|
8
|
+
}
|
|
9
|
+
export function getStructuredContractValidator(schemaId) {
|
|
10
|
+
if (structuredContractOutputSchemaIds.includes(schemaId)) {
|
|
11
|
+
return validators.get(schemaId);
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-effect module: registers every structured contract validator against
|
|
3
|
+
* its schemaId in the contract output registry. Imported once by the node
|
|
4
|
+
* executor; keeps per-contract imports out of the executor.
|
|
5
|
+
*/
|
|
6
|
+
import { validateFrontendContractNodeOutput } from "./frontend-implementation-contract.js";
|
|
7
|
+
import { registerStructuredContractValidator } from "./contract-output-registry.js";
|
|
8
|
+
registerStructuredContractValidator("frontend-implementation-contract-v1", validateFrontendContractNodeOutput);
|
|
@@ -31,7 +31,15 @@ export function getNodeOutputAsJson(state, nodeId) {
|
|
|
31
31
|
export function normalizeVerdictCandidateLine(value) {
|
|
32
32
|
const trimmed = String(value).trim();
|
|
33
33
|
const emphasized = trimmed.match(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/);
|
|
34
|
-
|
|
34
|
+
const line = (emphasized ? emphasized[1] : trimmed).trim();
|
|
35
|
+
// Canonicalize the colon separator and trailing sentence punctuation so
|
|
36
|
+
// "VERDICT:pass", "VERDICT: pass", or "VERDICT: pass." match the declared
|
|
37
|
+
// enum lines without accepting free-text verdicts. Conflicting verdicts
|
|
38
|
+
// still canonicalize to distinct enum lines and remain fail-closed.
|
|
39
|
+
const verdictMatch = line.match(/^VERDICT:\s*(pass|request-revision)\s*[.!?。!?]*$/i);
|
|
40
|
+
if (verdictMatch)
|
|
41
|
+
return `VERDICT: ${verdictMatch[1].toLowerCase()}`;
|
|
42
|
+
return line;
|
|
35
43
|
}
|
|
36
44
|
/** First line matching /^VERDICT:/ from node assistantText/stdout (markdown emphasis OK). */
|
|
37
45
|
export function extractFirstVerdictLineFromNodeText(text) {
|