@tea-agent/loop-agent 0.28.12 → 0.29.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.
Files changed (33) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +61 -15
  3. package/dist/commands/client-recovery.js +56 -1
  4. package/dist/commands/init-upgrade.js +186 -21
  5. package/dist/executors/dag-pi-executor.js +49 -2
  6. package/dist/executors/shell-executor.js +135 -0
  7. package/dist/worker/console/app-data.js +132 -11
  8. package/dist/worker/console/chat/pi-runtime.js +24 -42
  9. package/dist/worker/console/chat/resource-loader.js +11 -20
  10. package/dist/worker/console/chat/routes.js +7 -8
  11. package/dist/worker/console/chat/runtime-context.js +1 -1
  12. package/dist/worker/console/chat/tools.js +67 -54
  13. package/dist/worker/console/operation-runner.js +15 -1
  14. package/dist/worker/console/operation-store.js +70 -49
  15. package/dist/worker/console/operator-actions.js +57 -1
  16. package/dist/worker/console/static/assets/index-Cwx-ZVEQ.js +29 -0
  17. package/dist/worker/console/static/favicon.svg +37 -0
  18. package/dist/worker/console/static/index.html +2 -1
  19. package/dist/workflows/dag/backend-test-scenario-param.js +846 -0
  20. package/dist/workflows/dag/backend-test-writer-completeness.js +418 -0
  21. package/dist/workflows/dag/init-hybrid.js +20 -6
  22. package/dist/workflows/dag/node-execution.js +31 -2
  23. package/dist/workflows/dag/retry-policy.js +55 -18
  24. package/dist/workflows/dag/types.js +1 -0
  25. package/dist/workflows/dag/validate.js +38 -3
  26. package/docs/operations/README.md +1 -1
  27. package/docs/templates/README.md +1 -1
  28. package/docs/templates/agent-dag.schema.json +3 -3
  29. package/docs/templates/backend-test-dag.json +36 -11
  30. package/docs/templates/init-managed-agents.md +1 -1
  31. package/harness.json +2 -2
  32. package/package.json +2 -1
  33. package/dist/worker/console/static/assets/index-BfRgtLF4.js +0 -29
package/AGENTS.md CHANGED
@@ -49,7 +49,7 @@
49
49
 
50
50
  启动 loop-agent DAG 流程后,主 agent 应自主推进到整个流程结束,不要中途请求无谓的人工确认(如“要我现在执行 DAG 吗?”)。只要 dry-run / validate / writeSet 审查通过就应直接执行并跑完全部 ranks;遇到真问题(契约不一致、writeSet 越界、verify 失败且超出 maxFixLoops、用户明显未授权的高风险动作)才停。即便 profile 是 supervised/reviewed,也不默认在 review-gate 主动停下等人——控制器会在需要人工 approve 时自行提示,主 agent 的职责是推动流程跑完。
51
51
 
52
- DAG 执行期间主 agent 必须持续轮询监视直到 run 结束(FINISHED / FAILED / 需要 approve),不能轮询一次就停下等用户;轮询间隔应合理(避免频繁唤醒浪费 token,也不得住一个节点上赌一次就走)。判活必须用可靠方式:看 `state.json` 的 `runner.heartbeatAt` 是否持续刷新 + `session-events.jsonl` 是否在增长 + `dag doctor` 的 `liveness` 字段,不要只用 `tasklist /FI "PID eq X"` 这类过滤语法在 Git Bash 下会误报 DEAD,从而错判一个正常工作的 run 为 orphaned。遇到疑似 stall 先按 `docs/runtime/agent-dag-runner.md` 查 `lastMeaningfulProgressAt` / provider 活动,有真实进展就继续等,绝不盲目 supersede
52
+ DAG 执行期间主 agent 必须持续轮询监视直到 run 结束(FINISHED / FAILED / 需要 approve),不能轮询一次就停下等用户;轮询间隔应合理(避免频繁唤醒浪费 token,也不得住一个节点上赌一次就走)。判活必须用可靠方式:看 `state.json` 的 `runner.heartbeatAt` 是否持续刷新 + `session-events.jsonl` 是否在增长 + `dag doctor` 的 `liveness` 字段,不要只用 `tasklist /FI "PID eq X"` 这类过滤语法在 Git Bash 下会误报 DEAD,从而错判一个正常工作的 run 为 orphaned。遇到疑似 stall 先按 `docs/runtime/agent-dag-runner.md` 查 `lastMeaningfulProgressAt` / provider 活动,有真实进展就继续等,绝不盲目 supersede。持续监视过程中,主 agent 可在合适节点(例如 rank/节点状态变化、进入 verify/closeout、出现 stall 嫌疑或需要 approve 时)向用户做简短进度汇报(当前节点、状态、是否有风险),避免长时间静默;汇报是告知,不是请求确认,不得因此停下流程。
53
53
 
54
54
  DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件(包括 AGENTS.md、docs、根配置等)。bounded writer 节点的 write guard 用「节点运行期间的工作区 diff」作为越界证据,不区分改动来自 pi 还是主会话——主会话在 pi 节点跑的时候改了 writeSet 外文件,会让 write guard 把账算到 pi 头上、判节点 ERROR、下游 cascade skip。需要改文档/约束时,要么在 DAG 启动前改完,要么等 run 结束后改;务必与 DAG 写入节点在时间上互斥。排查任务路由问题时优先用 `resolveTaskDagTemplateSelection` / `classifyTaskDemand` 等纯函数探针或 `--output` 指定临时路径,不要反复 `dag execute --dry-run` / 无必要的 lifecycle 探测。dry-run 不执行节点;若确需预演,检查命令返回的 `.harness/dag-runs/dry-run/<runId>/` `runDir`,它不进入 active overview,也不能作为 `dag resume` 目标。优先用纯函数探针或 `task status`。
55
55
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,54 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.29.0] - 2026-08-07
6
+
7
+ ### 重点更新
8
+
9
+ - 新增 Automatic SemVer 自动化发布链,基于 Conventional Commit 增量自动决定版本号,且绝不自动发布破坏性 major 版本
10
+ - Operator Chat 默认开放完整的 Pi 工具集(read/write/edit/bash 等),在保持安全硬边界的前提下提升操作便捷性
11
+ - 后端测试在 16K 最大输出限制下引入分文件写入纪律与完整性恢复机制,彻底避免残缺资产被误判为成功
12
+ - 控制台新增横向莫比乌斯环页签图标,以暖黑双环表达持续运行,并用克制橙色交接面标记受控切换
13
+
14
+ ### 新增
15
+
16
+ - 新增 Automatic SemVer 每日发布入口:严格依据最新稳定 tag 到 main HEAD 的增量决定版本,feat 触发 minor,其余触发 patch,破坏性标记会安全停止
17
+ - Operator Chat 默认注册并开放完整的 Pi 工具(read/write/edit/bash/grep/find/ls),Capabilities 与 system prompt 对齐 ADR 0011
18
+ - Loop Operator Console 新增横向暖黑莫比乌斯环页签图标,并覆盖静态资源与 UI 边界检查
19
+ - 客户端恢复机制支持静默忽略瞬时中断错误,并写入详细的恢复日志供运维人员核查
20
+
21
+ ### 改进
22
+
23
+ - 后端测试在 pytest 执行前增加场景参数一致性检查,支持确定性有界自修,并在执行后生成结构化的《测试失败用例分析报告》
24
+ - 测试框架不再硬编码模型 ID,改为动态解析真实的 Pi 模型矩阵,提升跨环境兼容性
25
+ - 初始化升级流程在控制器基线合并中增加验证权限守卫,提升操作安全性
26
+ - AGENTS 及初始化托管块现允许在监控 DAG 时进行简短的进度更新
27
+
28
+ ### 修复
29
+
30
+ - 修复控制台在用户主动切换标签页时,任务失败错误信息残留并跨标签页持续显示的问题
31
+ - 修复并发操作更新导致 JSON 路径产生竞态的问题,现对操作写入进行串行化处理
32
+ - 修复 Windows 环境下文件原子替换遇到短暂占用时导致操作卡死的问题,现有界重试并清理临时文件
33
+ - 修复后端测试 Markdown 生成器在已确认变更但无 diff 时误判的问题,现会进行严格的有界重试
34
+ - 改进 Windows 环境下临时目录清理机制,在遇到目录非空或繁忙(ENOTEMPTY/EBUSY)错误时自动重试
35
+
36
+ ## [0.28.13] - 2026-08-06
37
+
38
+ ### 重点更新
39
+
40
+ - 显著缩短 CI 与发布流程的测试耗时,优化 2 vCPU runner 上的并发策略
41
+ - 修复集成测试在多核环境下因工作区指纹竞态导致的不稳定问题
42
+
43
+ ### 改进
44
+
45
+ - 优化 CI/发布环境的测试执行效率:固定 fast 测试池为 2 个 worker,移除重复的 pre-prepare 类型检查,并将 npm dist-tag 收敛轮询改为前快后慢的退避策略
46
+ - 默认 fast 测试池现按可用 CPU 核数(上限 4)分配,避免在 2 核环境下意外退化为串行执行
47
+
48
+ ### 修复
49
+
50
+ - 修复集成测试在多 worker 下因读取工作区指纹竞态导致的包校验不稳定问题,现固定集成测试为串行执行
51
+ - 修复 pre-push 验证收据漂移测试受 CI 环境固定 worker 数量影响的问题,确保使用不同值以正确验证指纹失效场景
52
+
5
53
  ## [0.28.12] - 2026-08-06
6
54
 
7
55
  ### 重点更新
@@ -157,26 +205,24 @@
157
205
 
158
206
  ## [0.28.1] - 2026-08-05
159
207
 
208
+ ### 重点更新
209
+
210
+ - 提升前端/后端任务执行稳定性,减少“看起来成功、实际未写入”的误判
211
+ - 缩短默认测试耗时,改善本地与发布验证体验
212
+
160
213
  ### 改进
161
214
 
162
- - cut npm test wall clock to ~264s and archive finished plans
163
- - avoid routing maintenance tasks to frontend dag
164
- - make openspec evidence non-blocking
165
- - disable frontend lint generation
166
- - tolerate prose before frontend contract json
167
- - fix frontend topology and verdict tests
168
- - reduce llm protocol validation failures
169
- - fix frontend prewrite contract normalization
215
+ - 默认测试流程更快,并归档已完成计划
216
+ - 维护类任务不再误入前端实现流程
217
+ - 部分证据与 lint 检查改为非阻断,降低无关噪声对交付的干扰
218
+ - 模型输出格式更宽松:说明文字、行内通过结论等常见写法更不易被误杀
170
219
 
171
220
  ### 修复
172
221
 
173
- - stabilize identity-sensitive tests
174
- - fail closed on backend writer no-op
175
- - validate frontend contract before writing
176
- - add structured frontend contract node
177
- - harden verification and JSON parsing
178
- - accept inline pass verdict
179
- - normalize empty model fields
222
+ - 后端生成任务在未实际写入文件时不再误判为成功
223
+ - 前端写入前补齐契约校验,避免无效契约进入实现阶段
224
+ - 修正失败分类误把路径限制提示当成鉴权失败的问题
225
+ - 加强验证与结果解析,减少畸形输出导致的卡死或误判
180
226
 
181
227
  ## [0.28.0] - 2026-08-05
182
228
 
@@ -8,6 +8,7 @@ export const CLIENT_RECOVERY_MODES = [
8
8
  "off",
9
9
  ];
10
10
  export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
11
+ export const OPENCODE_TRANSIENT_RETRY_LOG_PATH = ".opencode/plugins/loop-agent-transient-retry.log";
11
12
  export const OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH = ".opencode/plugins/loop-agent-context-overflow-compact.js";
12
13
  export const PI_CONTEXT_OVERFLOW_EXTENSION_PATH = ".pi/extensions/loop-agent-context-overflow.js";
13
14
  /** Pi project settings are part of the managed init surface; never default to home. */
@@ -511,12 +512,29 @@ export function buildOpenCodeTransientRetryPluginSource() {
511
512
  * Compensates transient UnknownError cases that OpenCode built-in APIError
512
513
  * retry does not cover. Never runs concurrently with session.status === "retry".
513
514
  * Permanent failures use interaction "${PERMANENT_ERROR_INTERACTION}".
515
+ * User aborts (MessageAbortedError / cancel words) are silently cleared.
516
+ * Best-effort file log: ${OPENCODE_TRANSIENT_RETRY_LOG_PATH}.
514
517
  */
518
+ import path from "node:path";
519
+ import { fileURLToPath } from "node:url";
520
+ import { appendFile, mkdir } from "node:fs/promises";
521
+
515
522
  const PLUGIN_PATH = ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH)};
523
+ const LOG_FILE_NAME = ${JSON.stringify(path.basename(OPENCODE_TRANSIENT_RETRY_LOG_PATH))};
524
+ const LOG_PATH = (() => {
525
+ try {
526
+ const dir =
527
+ import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
528
+ return path.join(dir, LOG_FILE_NAME);
529
+ } catch {
530
+ return ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_LOG_PATH)};
531
+ }
532
+ })();
516
533
  const PERMANENT_INTERACTION = ${JSON.stringify(PERMANENT_ERROR_INTERACTION)};
517
534
  const BACKOFF_MS = ${JSON.stringify([...BACKOFF_MS])};
518
535
  const MAX_RETRIES = ${MAX_SESSION_RETRIES};
519
536
  ${buildGeneratedContextOverflowPatternSource()}
537
+ void logToFile("load ok");
520
538
 
521
539
  const sessionState = new Map();
522
540
  const sessionStatus = new Map();
@@ -542,6 +560,26 @@ function textOf(error) {
542
560
  }
543
561
  }
544
562
 
563
+ async function logToFile(line) {
564
+ try {
565
+ await mkdir(path.dirname(LOG_PATH), { recursive: true });
566
+ await appendFile(
567
+ LOG_PATH,
568
+ new Date().toISOString() + " " + line + "\\n",
569
+ "utf-8",
570
+ );
571
+ } catch {
572
+ // Best-effort observability log; never throw (ENOENT/EISDIR tolerated).
573
+ }
574
+ }
575
+
576
+ function isUserAbort(error) {
577
+ if (error && typeof error === "object" && typeof error.name === "string") {
578
+ if (/^MessageAbortedError$/i.test(error.name)) return true;
579
+ }
580
+ return /aborted by user|user cancel|cancelled|canceled/i.test(textOf(error));
581
+ }
582
+
545
583
  function isPermanent(error) {
546
584
  const text = textOf(error);
547
585
  if (error && typeof error === "object" && typeof error.name === "string") {
@@ -624,6 +662,7 @@ function promptWasAccepted(response) {
624
662
  export default async function loopAgentTransientRetryPlugin({ client, $ }) {
625
663
  void $;
626
664
  void PLUGIN_PATH;
665
+ void logToFile("setup ok");
627
666
 
628
667
  async function drainPendingRecovery(sessionId) {
629
668
  const state = getState(sessionId);
@@ -662,9 +701,16 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
662
701
  throwOnError: true,
663
702
  });
664
703
  } catch {
704
+ void logToFile(
705
+ "prompt attempt=" + nextAttempt + " accepted=false session=" + sessionId,
706
+ );
665
707
  return;
666
708
  }
667
- if (!promptWasAccepted(response)) return;
709
+ const accepted = promptWasAccepted(response);
710
+ void logToFile(
711
+ "prompt attempt=" + nextAttempt + " accepted=" + accepted + " session=" + sessionId,
712
+ );
713
+ if (!accepted) return;
668
714
 
669
715
  state.attempt = nextAttempt;
670
716
  if (pendingErrors.get(sessionId) === pending) {
@@ -680,9 +726,11 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
680
726
  }
681
727
  state.locked = true;
682
728
  state.rerunRequested = false;
729
+ void logToFile("worker start session=" + sessionId);
683
730
  const worker = drainPendingRecovery(sessionId)
684
731
  .catch(() => {})
685
732
  .finally(() => {
733
+ void logToFile("worker end session=" + sessionId);
686
734
  recoveryWorkers.delete(sessionId);
687
735
  const rerun = state.rerunRequested;
688
736
  state.rerunRequested = false;
@@ -751,12 +799,19 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
751
799
  if (event.type === "session.error") {
752
800
  if (!sessionId) return;
753
801
  const error = properties.error || properties;
802
+ if (isUserAbort(error)) {
803
+ clearSession(sessionId);
804
+ void logToFile("classify abort session=" + sessionId);
805
+ return undefined;
806
+ }
754
807
  if (!isTransientUnknown(error)) {
755
808
  pendingErrors.delete(sessionId);
756
809
  getState(sessionId).rerunRequested = false;
810
+ void logToFile("classify permanent session=" + sessionId);
757
811
  return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
758
812
  }
759
813
  pendingErrors.set(sessionId, { error });
814
+ void logToFile("classify transient session=" + sessionId);
760
815
  startRecoveryWorker(sessionId);
761
816
  return { pending: true };
762
817
  }
@@ -1,5 +1,5 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { watch } from "node:fs";
2
+ import { lstatSync, watch } from "node:fs";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { lstat, mkdtemp, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
5
5
  import os from "node:os";
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { resolveAdapter } from "../adapters/index.js";
9
9
  import { createNpmUpdateClient } from "../cli/update/npm-client.js";
10
10
  import { auditDocs } from "../governance/checks.js";
11
+ import { harnessManifestSchema } from "../governance/manifest-types.js";
11
12
  import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
12
13
  import { MAX_OVERFLOW_RECOVERIES, MAX_SESSION_RETRIES, OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH, OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, PI_CONTEXT_OVERFLOW_EXTENSION_PATH, PI_PROJECT_SETTINGS_PATH, inspectProjectPiSettings, } from "./client-recovery.js";
13
14
  import { applyInitUpdate, checkInitUpdate, initializeLoopAgentProject, runInitDoctor, } from "./init.js";
@@ -338,6 +339,9 @@ function mergeAuthorityProjection(state, authority) {
338
339
  if (!sameJsonValue(state.mergeGuard.baselineWorkspace, authority.baselineWorkspace)) {
339
340
  throw new Error("init upgrade merge guard projection differs from controller baseline authority");
340
341
  }
342
+ if (!sameJsonValue(state.mergeGuard.verificationAuthority, authority.verificationAuthority)) {
343
+ throw new Error("init upgrade merge guard verification authority differs from controller baseline authority");
344
+ }
341
345
  if (state.mergeReceipts.length !== authority.priorMergeReceiptCount) {
342
346
  throw new Error("persisted pre-acceptance merge receipt count differs from controller authority");
343
347
  }
@@ -653,6 +657,12 @@ async function snapshotWorkspace(repoRoot) {
653
657
  const entries = await readdir(directory, { withFileTypes: true });
654
658
  entries.sort((left, right) => left.name.localeCompare(right.name));
655
659
  for (const entry of entries) {
660
+ // Git may expose the same temp repository through a short/long Windows
661
+ // path pair, making path.relative() lose the leading `.git` segment.
662
+ // Exclude the top-level Git administration directory by entry identity;
663
+ // init-upgrade protects its own controller anchors separately.
664
+ if (directory === repoRoot && entry.name === ".git")
665
+ continue;
656
666
  const absolute = path.join(directory, entry.name);
657
667
  const relative = toRepoPath(repoRoot, absolute);
658
668
  if (relative === ".git" || relative.startsWith(".git/"))
@@ -845,11 +855,8 @@ async function freezeVerificationSurface(repoRoot) {
845
855
  collected.add(await assertSafeRegularRepoFile(repoRoot, relativePath));
846
856
  };
847
857
  await add("harness.json");
848
- const harness = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
849
- const governanceRoot = typeof harness.governanceRoot === "string" ? harness.governanceRoot : "ai_workspace/loop-agent";
850
- const matrixPath = typeof harness.entrypoints?.verificationMatrix === "string"
851
- ? harness.entrypoints.verificationMatrix
852
- : `${governanceRoot}/verification-matrix.md`;
858
+ const harness = await readVerificationHarness(repoRoot);
859
+ const matrixPath = verificationMatrixPath(harness);
853
860
  await add(matrixPath);
854
861
  const commands = ["bash scripts/check-repo.sh", await discoverQuickVerification(repoRoot)];
855
862
  for (const command of commands) {
@@ -878,9 +885,68 @@ async function assertFrozenVerificationSurface(repoRoot, surface) {
878
885
  }
879
886
  }
880
887
  }
888
+ function frozenVerificationMatrixPath(surface) {
889
+ const paths = surface.files
890
+ .map((entry) => entry.path)
891
+ .filter((pathName) => pathName === "verification-matrix.md" ||
892
+ pathName.endsWith("/verification-matrix.md"));
893
+ if (paths.length !== 1) {
894
+ throw new Error("verification authority surface must contain exactly one verification-matrix.md");
895
+ }
896
+ return paths[0];
897
+ }
898
+ function isControllerAuthorizedVerificationMergeTask(task, surface) {
899
+ const taskPath = normalizeAllowedPath(task.path);
900
+ if (taskPath === "harness.json")
901
+ return true;
902
+ return ((taskPath === "verification-matrix.md" ||
903
+ taskPath.endsWith("/verification-matrix.md")) &&
904
+ Boolean(surface?.files.some((entry) => entry.path === taskPath)));
905
+ }
906
+ async function assertFrozenVerificationSurfaceExcept(repoRoot, surface, exemptPath) {
907
+ if (!isFrozenVerificationSurface(surface)) {
908
+ throw new Error("verification authority surface manifest is invalid");
909
+ }
910
+ const canonicalExemptPath = exemptPath
911
+ ? normalizeAllowedPath(exemptPath)
912
+ : undefined;
913
+ for (const entry of surface.files) {
914
+ if (entry.path === canonicalExemptPath)
915
+ continue;
916
+ const normalized = await assertSafeRegularRepoFile(repoRoot, entry.path);
917
+ if (normalized !== entry.path || sha256(await readFile(path.join(repoRoot, normalized))) !== entry.sha256) {
918
+ throw new Error(`verification authority surface changed: ${entry.path}`);
919
+ }
920
+ }
921
+ }
922
+ async function validateControllerAuthorizedVerificationMerge(input) {
923
+ const taskPath = normalizeAllowedPath(input.task.path);
924
+ if (!isControllerAuthorizedVerificationMergeTask(input.task, input.surface))
925
+ return;
926
+ const harness = await readVerificationHarness(input.repoRoot);
927
+ const matrixPath = normalizeAllowedPath(verificationMatrixPath(harness));
928
+ const frozenMatrixPath = frozenVerificationMatrixPath(input.surface);
929
+ if (matrixPath !== frozenMatrixPath ||
930
+ (taskPath !== "harness.json" && taskPath !== frozenMatrixPath)) {
931
+ throw new Error("verification authority merge must retain the controller-frozen verification matrix path");
932
+ }
933
+ if (taskPath === "harness.json") {
934
+ harnessManifestSchema.parse(harness);
935
+ const authority = input.guard?.verificationAuthority;
936
+ if (authority &&
937
+ (!sameJsonValue(harness.executors ?? null, authority.executors) ||
938
+ !sameJsonValue(harness.workflowPolicy ?? null, authority.workflowPolicy))) {
939
+ throw new Error("verification authority harness.json merge cannot change controller executor or workflow authority");
940
+ }
941
+ }
942
+ const command = await discoverQuickVerification(input.repoRoot);
943
+ tokenizeCommand(command);
944
+ await commandRepositoryInputs(input.repoRoot, command);
945
+ }
881
946
  function assertTaskOutsideVerificationSurface(task, surface) {
882
947
  const taskPath = normalizeAllowedPath(task.path);
883
- if (surface.files.some((entry) => entry.path === taskPath)) {
948
+ if (surface.files.some((entry) => entry.path === taskPath) &&
949
+ !isControllerAuthorizedVerificationMergeTask(task, surface)) {
884
950
  throw new Error(`init upgrade merge task targets authority-sensitive verification input: ${taskPath}`);
885
951
  }
886
952
  }
@@ -918,11 +984,24 @@ function validateTaskBoundary(task) {
918
984
  }
919
985
  return allowed;
920
986
  }
987
+ async function harnessVerificationAuthority(repoRoot, task) {
988
+ if (normalizeAllowedPath(task.path) !== "harness.json")
989
+ return undefined;
990
+ const harness = await readVerificationHarness(repoRoot);
991
+ return {
992
+ executors: cloneJson(harness.executors ?? null),
993
+ workflowPolicy: cloneJson(harness.workflowPolicy ?? null),
994
+ };
995
+ }
921
996
  async function createMergeGuard(repoRoot, task) {
922
997
  for (const allowedPath of validateTaskBoundary(task)) {
923
998
  await assertSafeMergePath(repoRoot, allowedPath);
924
999
  }
925
- return { task, baselineWorkspace: await snapshotWorkspace(repoRoot) };
1000
+ return {
1001
+ task,
1002
+ baselineWorkspace: await snapshotWorkspace(repoRoot),
1003
+ verificationAuthority: await harnessVerificationAuthority(repoRoot, task),
1004
+ };
926
1005
  }
927
1006
  function isMergeReceipt(value) {
928
1007
  if (!isRecord(value))
@@ -1203,6 +1282,7 @@ async function recordAcceptedMerge(input) {
1203
1282
  mergeAuthority: {
1204
1283
  task: input.task,
1205
1284
  baselineWorkspace: input.guard.baselineWorkspace,
1285
+ verificationAuthority: input.guard.verificationAuthority,
1206
1286
  priorMergeReceiptCount: input.state.mergeReceipts.length,
1207
1287
  priorMergeReceiptsSha256: sha256(serializeJson(input.state.mergeReceipts)),
1208
1288
  stateProjection: controllerStateProjection(input.state),
@@ -1237,11 +1317,14 @@ async function validateContinueMerge(directory, state, anchor) {
1237
1317
  }
1238
1318
  const authority = mergeAuthorityFromAnchor(anchor, state);
1239
1319
  const projection = mergeAuthorityProjection(state, authority);
1240
- await assertFrozenVerificationSurface(state.repoRoot, authority.verificationSurface);
1241
1320
  const task = authority.task;
1321
+ await assertFrozenVerificationSurfaceExcept(state.repoRoot, authority.verificationSurface, isControllerAuthorizedVerificationMergeTask(task, authority.verificationSurface)
1322
+ ? task.path
1323
+ : undefined);
1242
1324
  const authoritativeGuard = {
1243
1325
  task,
1244
1326
  baselineWorkspace: authority.baselineWorkspace,
1327
+ verificationAuthority: authority.verificationAuthority,
1245
1328
  };
1246
1329
  const allowed = new Set(validateTaskBoundary(task));
1247
1330
  for (const allowedPath of allowed) {
@@ -1283,6 +1366,12 @@ async function validateContinueMerge(directory, state, anchor) {
1283
1366
  return "no-change";
1284
1367
  }
1285
1368
  const currentSha256 = await verifyMergedFileInvariants(state.repoRoot, task);
1369
+ await validateControllerAuthorizedVerificationMerge({
1370
+ repoRoot: state.repoRoot,
1371
+ task,
1372
+ surface: authority.verificationSurface,
1373
+ guard: authoritativeGuard,
1374
+ });
1286
1375
  if (pending && pending.guard.currentSha256 !== currentSha256) {
1287
1376
  throw new Error("pending merge acceptance current hash is inconsistent");
1288
1377
  }
@@ -1483,13 +1572,14 @@ async function createVerificationWriteAudit(input) {
1483
1572
  const failures = [];
1484
1573
  const watchers = [];
1485
1574
  const watched = new Set();
1575
+ const canonicalRepoRoot = await realpath(input.repoRoot);
1486
1576
  const piHome = path.join(os.homedir(), ".pi");
1487
1577
  const anchor = await controllerAnchorPath(input.state);
1488
1578
  const note = (scope, file) => {
1489
1579
  events.push(`${scope}=${file.split(path.sep).join("/")}`);
1490
1580
  };
1491
1581
  const ignoredWorkspacePath = (candidate) => {
1492
- const relative = toRepoPath(input.repoRoot, candidate);
1582
+ const relative = toRepoPath(canonicalRepoRoot, candidate);
1493
1583
  return (relative === ".git" ||
1494
1584
  relative.startsWith(".git/") ||
1495
1585
  relative === "node_modules" ||
@@ -1510,9 +1600,21 @@ async function createVerificationWriteAudit(input) {
1510
1600
  : canonical;
1511
1601
  if (ignore?.(candidate))
1512
1602
  return;
1513
- note(scope, path.relative(directory, candidate) || ".");
1514
- // A parent rename can introduce a new directory. The event is already
1515
- // a failure; installing its watcher closes the delayed-descendant gap.
1603
+ let existingDirectory = false;
1604
+ try {
1605
+ existingDirectory = lstatSync(candidate).isDirectory();
1606
+ }
1607
+ catch {
1608
+ // A removed or transient path is still a write-boundary event.
1609
+ }
1610
+ // Windows can report a parent-directory notification when a read-only
1611
+ // traversal opens an existing child directory. Nested watchers own real
1612
+ // mutations below that child; persistent changes are also snapshot-checked.
1613
+ if (!existingDirectory) {
1614
+ note(scope, path.relative(directory, candidate) || ".");
1615
+ }
1616
+ // A parent rename can introduce a new directory. Installing its watcher
1617
+ // closes the delayed-descendant gap even when the container event is noise.
1516
1618
  void install(scope, candidate, ignore).catch((error) => {
1517
1619
  const code = error.code;
1518
1620
  if (code !== "ENOENT" && code !== "ENOTDIR") {
@@ -1536,7 +1638,7 @@ async function createVerificationWriteAudit(input) {
1536
1638
  await install(scope, child, ignore);
1537
1639
  }
1538
1640
  };
1539
- await install("workspace", input.repoRoot, ignoredWorkspacePath);
1641
+ await install("workspace", canonicalRepoRoot, ignoredWorkspacePath);
1540
1642
  await install("controller", runRoot(input.repoRoot));
1541
1643
  await install("controller-anchor", anchor.anchorRoot);
1542
1644
  const piHomeEntry = await lstat(piHome).catch((error) => {
@@ -1901,14 +2003,24 @@ async function runVerificationCommand(input) {
1901
2003
  }
1902
2004
  return result;
1903
2005
  }
1904
- async function discoverQuickVerification(repoRoot) {
1905
- const harness = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
2006
+ function verificationMatrixPath(harness) {
1906
2007
  const governanceRoot = typeof harness.governanceRoot === "string"
1907
2008
  ? harness.governanceRoot
1908
2009
  : "ai_workspace/loop-agent";
1909
- const matrixPath = typeof harness.entrypoints?.verificationMatrix === "string"
2010
+ return typeof harness.entrypoints?.verificationMatrix === "string"
1910
2011
  ? harness.entrypoints.verificationMatrix
1911
2012
  : `${governanceRoot}/verification-matrix.md`;
2013
+ }
2014
+ async function readVerificationHarness(repoRoot) {
2015
+ const parsed = JSON.parse(await readFile(path.join(repoRoot, "harness.json"), "utf-8"));
2016
+ if (!isRecord(parsed)) {
2017
+ throw new Error("verification authority harness.json must contain a JSON object");
2018
+ }
2019
+ return parsed;
2020
+ }
2021
+ async function discoverQuickVerification(repoRoot) {
2022
+ const harness = await readVerificationHarness(repoRoot);
2023
+ const matrixPath = verificationMatrixPath(harness);
1912
2024
  await assertSafeMergePath(repoRoot, matrixPath);
1913
2025
  const matrix = await readFile(path.join(repoRoot, matrixPath), "utf-8");
1914
2026
  const targetTests = matrix.match(/^\|\s*target project tests are valid\s*\|\s*`([^`]+)`/im)?.[1];
@@ -2046,6 +2158,44 @@ async function runFinalVerification(state, authoritativeHomeFingerprint, surface
2046
2158
  humanDecisionClean: state.humanDecisions.length === 0,
2047
2159
  };
2048
2160
  }
2161
+ function authoritativeVerificationFailureReason(verification) {
2162
+ const sources = [
2163
+ verification.quickVerification,
2164
+ verification.checkRepo,
2165
+ verification.inspect,
2166
+ verification.docsAudit,
2167
+ ];
2168
+ for (const source of sources) {
2169
+ if (!isRecord(source))
2170
+ continue;
2171
+ const candidates = [
2172
+ typeof source.error === "string" ? source.error : undefined,
2173
+ source.exitCode === null && typeof source.stderr === "string"
2174
+ ? source.stderr
2175
+ : undefined,
2176
+ ];
2177
+ for (const value of candidates) {
2178
+ if (!value)
2179
+ continue;
2180
+ for (const line of value.split(/\r?\n/)) {
2181
+ const reason = line.trim();
2182
+ if (reason.length <= 500 &&
2183
+ (reason ===
2184
+ "verification authority harness.json merge cannot change controller executor or workflow authority" ||
2185
+ reason ===
2186
+ "verification authority rejects direct node code evaluation" ||
2187
+ reason ===
2188
+ "verification authority rejects package command code evaluation" ||
2189
+ /^verification authority (?:surface changed|input is not a regular file): [A-Za-z0-9_./-]+$/.test(reason) ||
2190
+ reason ===
2191
+ "verification authority merge must retain the controller-frozen verification matrix path")) {
2192
+ return reason;
2193
+ }
2194
+ }
2195
+ }
2196
+ }
2197
+ return undefined;
2198
+ }
2049
2199
  function verificationPassed(verification) {
2050
2200
  const doctor = verification.doctor;
2051
2201
  const inspect = verification.inspect;
@@ -2160,9 +2310,15 @@ async function executeUpgrade(directory, state, input) {
2160
2310
  nextAction: "Controller identity drift detected. Re-run with the frozen controller; no target-project writes were made.",
2161
2311
  };
2162
2312
  }
2163
- state.phase = "VERSION_GATE";
2313
+ const recordedCurrentChoice = input.mode === "continue" &&
2314
+ input.versionChoice === undefined &&
2315
+ state.versionGate.choice === "current"
2316
+ ? "current"
2317
+ : undefined;
2318
+ if (!recordedCurrentChoice)
2319
+ state.phase = "VERSION_GATE";
2164
2320
  state.versionGate = await currentVersionGate(input, identity);
2165
- const choice = input.versionChoice;
2321
+ const choice = input.versionChoice ?? recordedCurrentChoice;
2166
2322
  if (state.versionGate.outcome === "newer-available" && !choice) {
2167
2323
  state.status = "needs-human-decision";
2168
2324
  state.nextAction =
@@ -2274,6 +2430,13 @@ async function executeUpgrade(directory, state, input) {
2274
2430
  const activeTask = post.modelMergeTasks[0];
2275
2431
  const verificationSurface = await freezeVerificationSurface(state.repoRoot);
2276
2432
  assertTaskOutsideVerificationSurface(activeTask, verificationSurface);
2433
+ if (verificationSurface.files.some((entry) => entry.path === normalizeAllowedPath(activeTask.path))) {
2434
+ await validateControllerAuthorizedVerificationMerge({
2435
+ repoRoot: state.repoRoot,
2436
+ task: activeTask,
2437
+ surface: verificationSurface,
2438
+ });
2439
+ }
2277
2440
  state.mergeTasks = [activeTask];
2278
2441
  state.mergeGuard = await createMergeGuard(state.repoRoot, activeTask);
2279
2442
  // Bind the Git-dir anchor to the state actually handed to the model.
@@ -2329,8 +2492,10 @@ async function executeUpgrade(directory, state, input) {
2329
2492
  }
2330
2493
  else {
2331
2494
  state.status = "failed";
2332
- state.nextAction =
2333
- "Verification failed. Inspect verification.json; repair only the controller-returned concrete merge path or address the reported external blocker, then use --continue.";
2495
+ const authorityReason = authoritativeVerificationFailureReason(verification);
2496
+ state.nextAction = authorityReason
2497
+ ? `Verification failed: ${authorityReason}. Inspect verification.json, repair only the controller-returned concrete merge path, then use --continue.`
2498
+ : "Verification failed. Inspect verification.json; repair only the controller-returned concrete merge path or address the reported external blocker, then use --continue.";
2334
2499
  }
2335
2500
  await saveState(directory, state);
2336
2501
  await writeReport(directory, state, verification);
@@ -8,6 +8,8 @@ import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND
8
8
  import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
9
9
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
10
10
  import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
11
+ import { isWriterEmptyDiffRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
12
+ import { assessBackendTestMdWriterCompleteness, assessBackendTestPytestWriterCompleteness, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
11
13
  import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
12
14
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
13
15
  /** Bounded writer tools: no unrestricted bash; edit/write are policy-wrapped via SDK customTools. */
@@ -680,7 +682,39 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
680
682
  afterStatus: changeManifestAfterStatus ?? "",
681
683
  });
682
684
  }
683
- if (writeGuardOk && !writerOutcomeViolation) {
685
+ let completenessFailure;
686
+ if (mapped.ok &&
687
+ writeGuardOk &&
688
+ !writerOutcomeViolation &&
689
+ isBackendTestCompletenessRetryCandidate(input.task)) {
690
+ try {
691
+ const progress = input.task.id === "generate-backend-pytest-pi"
692
+ ? await assessBackendTestPytestWriterCompleteness(input.cwd)
693
+ : await assessBackendTestMdWriterCompleteness(input.cwd);
694
+ await writeBackendTestWriterProgressArtifacts({
695
+ runDir: meta.runDir,
696
+ progress,
697
+ attempt: input.attempt ?? 1,
698
+ maxAttempts: input.task.retryPolicy?.maxAttempts ?? 3,
699
+ });
700
+ if (progress.status !== "PASS") {
701
+ const classified = classifyBackendTestWriterCompletenessFailure(progress);
702
+ completenessFailure = {
703
+ failureCategory: classified.recoverable
704
+ ? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
705
+ : "invalid-output",
706
+ detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
707
+ };
708
+ }
709
+ }
710
+ catch (error) {
711
+ completenessFailure = {
712
+ failureCategory: "invalid-output",
713
+ detail: `backend-test completeness gate crashed: ${error instanceof Error ? error.message : String(error)}`,
714
+ };
715
+ }
716
+ }
717
+ if (writeGuardOk && !writerOutcomeViolation && !completenessFailure) {
684
718
  return mapped;
685
719
  }
686
720
  const stderrParts = [mapped.stderr];
@@ -690,6 +724,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
690
724
  if (writerOutcomeViolation) {
691
725
  stderrParts.push(writerOutcomeViolation);
692
726
  }
727
+ if (completenessFailure) {
728
+ stderrParts.push(completenessFailure.detail);
729
+ }
693
730
  if (meta.writeGuardAttribution === "best-effort") {
694
731
  stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
695
732
  }
@@ -699,7 +736,13 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
699
736
  stderr: stderrParts.filter(Boolean).join("\n\n"),
700
737
  failureCategory: mapped.ok
701
738
  ? writeGuardOk
702
- ? "invalid-output"
739
+ ? completenessFailure
740
+ ? completenessFailure.failureCategory
741
+ : changeManifestChangedFiles?.length === 0 &&
742
+ isWriterEmptyDiffRetryCandidate(input.task) &&
743
+ isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
744
+ ? WRITER_EMPTY_DIFF_RETRY_CATEGORY
745
+ : "invalid-output"
703
746
  : "write-guard"
704
747
  : mapped.failureCategory,
705
748
  durationMs: mapped.durationMs || Date.now() - started,
@@ -776,6 +819,10 @@ function parseWriterImplementationOutcome(text) {
776
819
  function isWriterImplementationOutcome(value) {
777
820
  return (value === "changed" || value === "already-satisfied" || value === "blocked");
778
821
  }
822
+ function isChangedWriterImplementationOutcome(text) {
823
+ const parsed = parseWriterImplementationOutcome(text);
824
+ return parsed.kind === "valid" && parsed.outcome === "changed";
825
+ }
779
826
  function writerOutcomeDiagnostics(text, parsed, changedFiles) {
780
827
  const firstNonEmpty = text
781
828
  .split(/\r?\n/)