@tea-agent/loop-agent 0.40.0-next.3 → 0.40.0-next.4

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 CHANGED
@@ -61,6 +61,7 @@
61
61
 
62
62
  ### 改进
63
63
 
64
+ - 检查器「提示词重启」只读态的提示词卡片会撑满剩余高度并在卡片内滚动,不再在面板底部留下大块空白;失败节点的可编辑布局不变。
64
65
  - DAG 节点表「备注」改为节点用途简介(是什么、做什么、目的),不再重复节点 ID;失败节点仍显示错误摘要。检查器标题与图节点悬浮提示同步展示同一句说明。
65
66
  - 实验「提示词重启」会为 Pi 节点的每次失败 attempt 保存独立、脱敏且 64 KiB 有界的提示词候选;Inspector 始终展示提示词版本选择框,新 run 会列出冻结原始提示词与全部失败尝试(含自动重试产生的恢复提示词),历史 run 未保存候选时明确说明,成功尝试不混入。提示词卡片右上角提供复制图标并以勾选图标反馈成功;已截断候选复制时明确标记为截断预览,并保持只读、禁止直接提交,避免用不完整提示词发起续跑。选择失败候选会载入「全文替换」编辑器;即使节点后续自动重试成功,历史失败候选仍可只读切换查看。
66
67
  - DAG 节点检查器「执行过程」按执行器分流:Pi 仍看 session-events;有命令的 shell 展示 `shell-command-receipt` 与 stdout/stderr;`frontendBrowserToolPreflight` 等内部处理器展示步骤回执(历史 run 可从 capability / attempt 产物合成)。空态不再把 shell/static 误说成「暂无过程日志」。节点输入补充 named handler 名称。脚本仍属于节点输入,不假装已执行。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.40.0-next.3",
4
- "gitSha": "1b04fc99ec8ef7ffad6549eeda272cc4e9cc8013",
5
- "builtAt": "2026-08-26T06:29:08.294Z"
3
+ "version": "0.40.0-next.4",
4
+ "gitSha": "84dabbbd0cdd880a29ed8cf22e11feb7f4550188",
5
+ "builtAt": "2026-08-26T13:58:53.067Z"
6
6
  }
@@ -9,6 +9,7 @@ import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND
9
9
  import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
10
10
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
11
11
  import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
12
+ import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
12
13
  import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
13
14
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
14
15
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
@@ -446,9 +447,28 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
446
447
  };
447
448
  }
448
449
  let beforeStatus;
450
+ let beforeWorkspaceSnapshot;
449
451
  let beforePathFingerprints;
450
- /** tools-only: keep SDK path sandbox; skip git baseline + post-diff write-guard hard fail. */
451
- const skipGitWriteGuard = isWriteTask && input.task.writeGuardPolicy === "tools-only";
452
+ /** tools-only and filesystem-only keep the tool sandbox but skip Git baseline. */
453
+ const filesystemOnly = isWriteTask && meta.spec.backendTestWorkspaceControl === "filesystem-only";
454
+ const skipGitWriteGuard = isWriteTask && (input.task.writeGuardPolicy === "tools-only" || filesystemOnly);
455
+ if (isWriteTask && filesystemOnly) {
456
+ try {
457
+ beforeWorkspaceSnapshot = await captureWorkspaceWriteSnapshot({
458
+ rootCwd: input.cwd,
459
+ paths: input.task.writeSet ?? input.task.allowedPaths,
460
+ });
461
+ }
462
+ catch (error) {
463
+ return {
464
+ ok: false,
465
+ stdout: "",
466
+ stderr: `workspace filesystem baseline unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
467
+ failureCategory: "write-guard",
468
+ durationMs: Date.now() - started,
469
+ };
470
+ }
471
+ }
452
472
  if (isWriteTask && !skipGitWriteGuard) {
453
473
  try {
454
474
  beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
@@ -678,6 +698,27 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
678
698
  let writeGuardViolations = [];
679
699
  let changeManifestAfterStatus;
680
700
  let changeManifestChangedFiles;
701
+ let afterWorkspaceSnapshot;
702
+ if (filesystemOnly && beforeWorkspaceSnapshot) {
703
+ try {
704
+ afterWorkspaceSnapshot = await captureWorkspaceWriteSnapshot({
705
+ rootCwd: input.cwd,
706
+ paths: input.task.writeSet ?? input.task.allowedPaths,
707
+ });
708
+ changeManifestChangedFiles = diffWorkspaceWriteSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot);
709
+ const guard = validateShellWriteGuardFromDiff({
710
+ changedFiles: changeManifestChangedFiles,
711
+ task: input.task,
712
+ concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
713
+ });
714
+ writeGuardOk = guard.ok;
715
+ writeGuardViolations = guard.violations;
716
+ }
717
+ catch (error) {
718
+ writeGuardOk = false;
719
+ writeGuardViolations = [`workspace snapshot unavailable: ${error instanceof Error ? error.message : String(error)}`];
720
+ }
721
+ }
681
722
  if (beforeStatus !== undefined) {
682
723
  let recoveryEvidence;
683
724
  let removalPendingRecheck;
@@ -850,7 +891,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
850
891
  }
851
892
  }
852
893
  if (writeGuardOk &&
853
- beforeStatus !== undefined &&
894
+ (beforeStatus !== undefined || beforeWorkspaceSnapshot !== undefined) &&
854
895
  changeManifestChangedFiles !== undefined) {
855
896
  await persistWriterChangeManifest({
856
897
  runDir: meta.runDir,
@@ -860,8 +901,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
860
901
  approvalSourceNodeId: input.task.resolvedFinalWriteSetApproval?.approvalSourceNodeId,
861
902
  approvalDigest: input.task.resolvedFinalWriteSetApproval?.approvalDigest,
862
903
  changedFiles: changeManifestChangedFiles,
863
- beforeStatus,
864
- afterStatus: changeManifestAfterStatus ?? "",
904
+ beforeStatus: beforeStatus ?? JSON.stringify(beforeWorkspaceSnapshot),
905
+ afterStatus: changeManifestAfterStatus ?? JSON.stringify(afterWorkspaceSnapshot),
865
906
  });
866
907
  }
867
908
  let completenessFailure;
@@ -41,6 +41,7 @@ import { computeL5ReportMetrics } from "../workflows/dag/l5-report-metrics.js";
41
41
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
42
42
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
43
43
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
44
+ import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
44
45
  import { parseAndValidateFinalWriteSetApproval, } from "../workflows/dag/node-execution.js";
45
46
  import { buildShellProcessEnv } from "./shell-verification.js";
46
47
  import { readRunState } from "../workflows/dag/run-store.js";
@@ -1719,6 +1720,33 @@ async function executeBackendTestPipeline(input, meta) {
1719
1720
  }
1720
1721
  }
1721
1722
  async function executeBackendTestPipelineWithWriteGuard(input, meta) {
1723
+ if (meta.spec.backendTestWorkspaceControl === "filesystem-only") {
1724
+ const paths = input.task.writeSet ?? input.task.allowedPaths;
1725
+ let beforeSnapshot;
1726
+ try {
1727
+ beforeSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
1728
+ }
1729
+ catch (error) {
1730
+ return { ok: false, stdout: "", stderr: `workspace filesystem baseline unavailable: ${error instanceof Error ? error.message : String(error)}`, failureCategory: "write-guard", durationMs: 0 };
1731
+ }
1732
+ const result = await executeBackendTestPipeline(input, meta);
1733
+ try {
1734
+ const afterSnapshot = await captureWorkspaceWriteSnapshot({ rootCwd: input.cwd, paths });
1735
+ const guard = validateShellWriteGuard({
1736
+ changedPaths: diffWorkspaceWriteSnapshots(beforeSnapshot, afterSnapshot),
1737
+ writePolicy: input.task.writePolicy,
1738
+ writeSet: input.task.writeSet,
1739
+ allowedPaths: input.task.allowedPaths,
1740
+ forbiddenPaths: input.task.forbiddenPaths,
1741
+ });
1742
+ if (!guard.ok)
1743
+ return { ...result, ok: false, stderr: [result.stderr, `filesystem write guard failed: ${guard.violations.join(", ")}`].filter(Boolean).join("\n\n"), failureCategory: "write-guard" };
1744
+ return result;
1745
+ }
1746
+ catch (error) {
1747
+ return { ...result, ok: false, stderr: [result.stderr, `filesystem write guard failed: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join("\n\n"), failureCategory: "write-guard" };
1748
+ }
1749
+ }
1722
1750
  let beforeStatus;
1723
1751
  try {
1724
1752
  beforeStatus = await readGitStatusPorcelain(input.cwd);
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathMatchesPattern } from "../shared/git-progress.js";
5
+ const normalize = (value) => value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
6
+ function rootBeforeWildcard(pattern) {
7
+ const value = normalize(pattern);
8
+ const wildcard = value.search(/[?*]/);
9
+ if (wildcard < 0)
10
+ return path.posix.dirname(value);
11
+ const prefix = value.slice(0, wildcard);
12
+ return prefix.slice(0, prefix.lastIndexOf("/")) || ".";
13
+ }
14
+ async function walk(rootCwd, relative, files) {
15
+ let entries;
16
+ try {
17
+ entries = await readdir(path.resolve(rootCwd, relative), { withFileTypes: true });
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ for (const entry of entries) {
23
+ if ([".git", ".harness", "node_modules"].includes(entry.name))
24
+ continue;
25
+ const child = normalize(path.posix.join(relative, entry.name));
26
+ if (entry.isDirectory())
27
+ await walk(rootCwd, child, files);
28
+ else if (entry.isFile())
29
+ files.add(child);
30
+ }
31
+ }
32
+ export async function captureWorkspaceWriteSnapshot(input) {
33
+ const candidates = new Set();
34
+ for (const pattern of input.paths) {
35
+ await walk(input.rootCwd, rootBeforeWildcard(pattern), candidates);
36
+ if (!/[?*]/.test(pattern)) {
37
+ try {
38
+ if ((await lstat(path.resolve(input.rootCwd, pattern))).isFile())
39
+ candidates.add(normalize(pattern));
40
+ }
41
+ catch { /* absent target */ }
42
+ }
43
+ }
44
+ const files = {};
45
+ let totalBytes = 0;
46
+ for (const relative of [...candidates].filter((file) => input.paths.some((pattern) => pathMatchesPattern(file, pattern))).sort()) {
47
+ if (Object.keys(files).length >= 20_000)
48
+ throw new Error("workspace snapshot file limit exceeded");
49
+ const absolute = path.resolve(input.rootCwd, relative);
50
+ const stat = await lstat(absolute);
51
+ if (!stat.isFile())
52
+ throw new Error(`workspace snapshot encountered non-regular file: ${relative}`);
53
+ totalBytes += stat.size;
54
+ if (totalBytes > 64 * 1024 * 1024)
55
+ throw new Error("workspace snapshot byte limit exceeded");
56
+ files[relative] = { size: stat.size, sha256: createHash("sha256").update(await readFile(absolute)).digest("hex") };
57
+ }
58
+ const body = JSON.stringify(files);
59
+ return { schemaVersion: 1, controlMode: "filesystem-only", files, sha256: createHash("sha256").update(body).digest("hex") };
60
+ }
61
+ export function diffWorkspaceWriteSnapshots(before, after) {
62
+ const paths = new Set([...Object.keys(before.files), ...Object.keys(after.files)]);
63
+ return [...paths].sort().filter((file) => {
64
+ const left = before.files[file];
65
+ const right = after.files[file];
66
+ return !left || !right || left.size !== right.size || left.sha256 !== right.sha256;
67
+ });
68
+ }
@@ -147,6 +147,8 @@ export const backendTestConfigSchema = z
147
147
  gapDoc: z.string().min(1).optional(),
148
148
  /** gap-fill pytest execution scope: only affected items (default) or all eligible. */
149
149
  executeScope: z.enum(["affected", "all"]).optional(),
150
+ /** Backend-test-only workspace mutation control; omitted means Git guard. */
151
+ workspaceControl: z.enum(["git", "filesystem-only"]).optional(),
150
152
  })
151
153
  .strict()
152
154
  .optional();
@@ -237,5 +239,13 @@ export const taskConfigSchema = z.preprocess((raw) => {
237
239
  if ("executor" in obj || "cursorModel" in obj) {
238
240
  throw new Error(CURSOR_TASK_FIELD_REMOVED_ERROR);
239
241
  }
242
+ const backendTest = obj.backendTest;
243
+ if (backendTest &&
244
+ typeof backendTest === "object" &&
245
+ !Array.isArray(backendTest) &&
246
+ "workspaceControl" in backendTest &&
247
+ obj.taskKind !== "backend-test") {
248
+ throw new Error("backendTest.workspaceControl is only valid when taskKind is backend-test");
249
+ }
240
250
  return raw;
241
251
  }, taskConfigObjectSchema);
@@ -3580,6 +3580,10 @@ button.copy-command:focus-visible {
3580
3580
  #dag-inspector-panel-prompt-restart {
3581
3581
  display: flex;
3582
3582
  flex-direction: column;
3583
+ min-height: 0;
3584
+ }
3585
+ #dag-inspector-panel-prompt-restart:has(.prompt-restart-panel.is-readonly) {
3586
+ overflow: hidden;
3583
3587
  }
3584
3588
  .prompt-restart-panel,
3585
3589
  .prompt-restart-box {
@@ -3589,6 +3593,14 @@ button.copy-command:focus-visible {
3589
3593
  min-width: 0;
3590
3594
  flex: 1;
3591
3595
  }
3596
+ .prompt-restart-panel.is-readonly {
3597
+ min-height: 0;
3598
+ overflow: hidden;
3599
+ }
3600
+ .prompt-restart-panel.is-readonly .prompt-restart-prompt-card {
3601
+ flex: 1 1 auto;
3602
+ min-height: 12rem;
3603
+ }
3592
3604
  .prompt-restart-toolbar {
3593
3605
  display: grid;
3594
3606
  grid-template-columns: minmax(0, 1fr) auto;
@@ -3814,7 +3826,6 @@ button.copy-command:focus-visible {
3814
3826
  .prompt-restart-readonly {
3815
3827
  margin: 0;
3816
3828
  padding: 14px 16px;
3817
- max-height: min(36vh, 18rem);
3818
3829
  overflow: auto;
3819
3830
  background: var(--canvas-soft);
3820
3831
  color: var(--ink);
@@ -3825,6 +3836,14 @@ button.copy-command:focus-visible {
3825
3836
  overflow-wrap: anywhere;
3826
3837
  tab-size: 2;
3827
3838
  }
3839
+ .prompt-restart-panel.is-readonly .prompt-restart-readonly {
3840
+ flex: 1 1 auto;
3841
+ min-height: 0;
3842
+ max-height: none;
3843
+ }
3844
+ .prompt-restart-panel.is-edit .prompt-restart-readonly {
3845
+ max-height: min(36vh, 18rem);
3846
+ }
3828
3847
  .prompt-restart-textarea {
3829
3848
  width: 100%;
3830
3849
  min-height: 8rem;
@@ -1404,7 +1404,7 @@ async function renderPromptRestart(content, dagRunId, node) {
1404
1404
 
1405
1405
  if (node.status !== "ERROR") {
1406
1406
  // Non-failed nodes still show their frozen prompt — read-only.
1407
- const panel = el("div", "prompt-restart-panel");
1407
+ const panel = el("div", "prompt-restart-panel is-readonly");
1408
1408
  panel.appendChild(
1409
1409
  createPromptRestartBanner({
1410
1410
  tone: "readonly",
@@ -4412,6 +4412,7 @@ async function buildBackendTestGapFillDag(sources) {
4412
4412
  ],
4413
4413
  };
4414
4414
  spec.backendTestLayout = layout;
4415
+ applyBackendTestWorkspaceControl(spec, taskConfig.backendTest?.workspaceControl ?? "git");
4415
4416
  if (sharedSetup) {
4416
4417
  spec.backendTestSharedSetup = { ...sharedSetup };
4417
4418
  }
@@ -4868,6 +4869,7 @@ async function buildBackendTestHybridDag(sources) {
4868
4869
  // against the resolved layout. The default layout is identity, so the
4869
4870
  // packaged template stays byte-identical with the historical contract.
4870
4871
  applyBackendTestLayoutToDagSpec(spec, layout);
4872
+ applyBackendTestWorkspaceControl(spec, taskConfig.backendTest?.workspaceControl ?? "git");
4871
4873
  // Plan B: carry the bound shared-setup document on the spec so runtime
4872
4874
  // validators (N6 markdown case validation) see the same binding as prompts.
4873
4875
  if (intake.sharedSetup) {
@@ -4878,6 +4880,23 @@ async function buildBackendTestHybridDag(sources) {
4878
4880
  assertValidDagSpec(spec);
4879
4881
  return spec;
4880
4882
  }
4883
+ function applyBackendTestWorkspaceControl(spec, control) {
4884
+ if (control !== "filesystem-only") {
4885
+ delete spec.backendTestWorkspaceControl;
4886
+ return;
4887
+ }
4888
+ spec.backendTestWorkspaceControl = control;
4889
+ for (const task of spec.tasks) {
4890
+ if ((task.executor === "pi" && task.toolProfile === "write") ||
4891
+ task.shell?.backendTestPipeline) {
4892
+ task.writeGuardPolicy = "filesystem-only";
4893
+ }
4894
+ const child = task.dynamicExpansion?.childTask;
4895
+ if (child?.executor === "pi" && child.toolProfile === "write") {
4896
+ child.writeGuardPolicy = "filesystem-only";
4897
+ }
4898
+ }
4899
+ }
4881
4900
  /** Rewrite layout-dependent strings across a compiled backend-test DAG spec (plan A). */
4882
4901
  function applyBackendTestLayoutToDagSpec(spec, layout) {
4883
4902
  if (layout.isDefault) {
@@ -382,7 +382,7 @@ export const dagWritePolicySchema = z.enum([
382
382
  "none",
383
383
  ]);
384
384
  /** Git/post-diff write guard intensity for exclusive Pi writers. */
385
- export const dagWriteGuardPolicySchema = z.enum(["full", "tools-only"]);
385
+ export const dagWriteGuardPolicySchema = z.enum(["full", "tools-only", "filesystem-only"]);
386
386
  /**
387
387
  * Fail-closed outcome/diff consistency contract for bounded Pi writers.
388
388
  * The writer must begin with IMPLEMENTATION_OUTCOME: changed,
@@ -997,6 +997,8 @@ export const dagSpecSchema = z
997
997
  taskContractBinding: dagTaskContractBindingSchema.optional(),
998
998
  /** Optional unresolved parent-run feedback for a full task rerun. */
999
999
  rerunFeedback: dagRerunFeedbackSchema.optional(),
1000
+ /** Backend-test-only workspace mutation control; absent means Git. */
1001
+ backendTestWorkspaceControl: z.enum(["git", "filesystem-only"]).optional(),
1000
1002
  /** Frozen backend-test artifact layout (plan A); absent = historical default testcase/ layout. */
1001
1003
  backendTestLayout: z
1002
1004
  .object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.40.0-next.3",
3
+ "version": "0.40.0-next.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",