@tea-agent/loop-agent 0.20.1 → 0.21.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 (31) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/commands/init.js +7 -0
  3. package/dist/executors/dag-pi-executor.js +24 -0
  4. package/dist/executors/pi-executor.js +111 -36
  5. package/dist/executors/pi-sdk-executor.js +105 -29
  6. package/dist/executors/shell-executor.js +54 -11
  7. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  8. package/dist/worker/observability/read-model.js +7 -1
  9. package/dist/worker/observe/static/constants.js +5 -0
  10. package/dist/worker/observe/static/format-pool.js +22 -3
  11. package/dist/worker/observe/static/styles.css +32 -3
  12. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  13. package/dist/worker/run-task/run-task.js +16 -6
  14. package/dist/workflows/dag/backend-test-markdown-workflow.js +291 -97
  15. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  16. package/dist/workflows/dag/init-hybrid.js +27 -16
  17. package/dist/workflows/dag/lifecycle.js +60 -4
  18. package/dist/workflows/dag/liveness-policy.js +250 -0
  19. package/dist/workflows/dag/node-execution.js +49 -0
  20. package/dist/workflows/dag/runner.js +21 -1
  21. package/dist/workflows/dag/types.js +5 -0
  22. package/docs/README.md +5 -6
  23. package/docs/architecture/dag-execution.md +11 -0
  24. package/docs/architecture/facts-and-state.md +1 -0
  25. package/docs/architecture/worker-and-feature.md +10 -0
  26. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  27. package/docs/templates/backend-test-dag.json +15 -15
  28. package/harness.json +1 -1
  29. package/package.json +1 -1
  30. package/skills/loop-agent/references/command-reference.md +2 -0
  31. package/skills/loop-agent/references/hybrid-dag.md +2 -2
@@ -206,11 +206,12 @@ export class LoopAgentClient {
206
206
  const stdoutPath = path.join(artifactDir, "stdout.txt");
207
207
  const stderrPath = path.join(artifactDir, "stderr.txt");
208
208
  const resultPath = path.join(artifactDir, "result.json");
209
+ const configuredTimeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
209
210
  const { stdout, stdoutBytes, stdoutTruncated, stderr, stderrBytes, stderrTruncated, exitCode, timedOut, } = await spawnCommand({
210
211
  command,
211
212
  args: commandArgs,
212
213
  cwd: options.cwd,
213
- timeoutMs: options.timeoutMs ?? this.defaultTimeoutMs,
214
+ hardTimeoutMs: configuredTimeoutMs > 0 ? configuredTimeoutMs : undefined,
214
215
  env: { ...process.env, ...this.env, ...options.env },
215
216
  stdoutPath, stderrPath, resultPath,
216
217
  heartbeatIntervalMs: this.heartbeatIntervalMs,
@@ -362,23 +363,56 @@ function spawnCommand(input) {
362
363
  let stdout = createBoundedOutput();
363
364
  let stderr = createBoundedOutput();
364
365
  let timedOut = false;
365
- const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, input.timeoutMs);
366
+ let settled = false;
367
+ const settle = (payload) => {
368
+ if (settled)
369
+ return;
370
+ settled = true;
371
+ clearTimers();
372
+ resolve({
373
+ stdout: formatBoundedOutput(stdout),
374
+ stdoutBytes: stdout.bytes,
375
+ stdoutTruncated: stdout.truncated,
376
+ stderr: formatBoundedOutput(stderr),
377
+ stderrBytes: stderr.bytes,
378
+ stderrTruncated: stderr.truncated,
379
+ exitCode: payload.exitCode,
380
+ timedOut: payload.timedOut,
381
+ });
382
+ };
383
+ let hardTimeout;
384
+ if (input.hardTimeoutMs !== undefined && input.hardTimeoutMs > 0) {
385
+ hardTimeout = setTimeout(() => {
386
+ timedOut = true;
387
+ child.kill("SIGTERM");
388
+ }, input.hardTimeoutMs);
389
+ }
366
390
  let heartbeatTimer;
367
391
  if (input.heartbeatIntervalMs > 0) {
368
392
  heartbeatTimer = setInterval(() => { invokeHeartbeatCallback(input.onHeartbeat, startedAtMs); }, input.heartbeatIntervalMs);
369
393
  }
370
- const clearTimers = () => { clearTimeout(timeout); if (heartbeatTimer !== undefined) {
371
- clearInterval(heartbeatTimer);
372
- heartbeatTimer = undefined;
373
- } };
394
+ const clearTimers = () => {
395
+ if (hardTimeout !== undefined)
396
+ clearTimeout(hardTimeout);
397
+ if (heartbeatTimer !== undefined) {
398
+ clearInterval(heartbeatTimer);
399
+ heartbeatTimer = undefined;
400
+ }
401
+ };
374
402
  child.stdout.setEncoding("utf8");
375
403
  child.stderr.setEncoding("utf8");
376
404
  child.stdout.on("data", (chunk) => { stdout = appendBoundedOutput(stdout, chunk); appendChunkBestEffort(input.stdoutPath, chunk); invokeChunkCallback(input.onStdout, chunk); });
377
405
  child.stderr.on("data", (chunk) => { stderr = appendBoundedOutput(stderr, chunk); appendChunkBestEffort(input.stderrPath, chunk); invokeChunkCallback(input.onStderr, chunk); });
378
- child.on("error", (error) => { clearTimers(); reject(error); });
379
- child.on("close", (exitCode) => {
406
+ child.on("error", (error) => { if (!settled) {
407
+ settled = true;
380
408
  clearTimers();
381
- resolve({ stdout: formatBoundedOutput(stdout), stdoutBytes: stdout.bytes, stdoutTruncated: stdout.truncated, stderr: formatBoundedOutput(stderr), stderrBytes: stderr.bytes, stderrTruncated: stderr.truncated, exitCode, timedOut });
409
+ reject(error);
410
+ } });
411
+ child.on("close", (exitCode) => {
412
+ settle({
413
+ exitCode,
414
+ timedOut,
415
+ });
382
416
  });
383
417
  });
384
418
  }
@@ -717,6 +717,8 @@ function computeHealth(batches, tasks, dagRuns) {
717
717
  const ACTIVE_EFFECTIVE_STATUSES = new Set([
718
718
  "running",
719
719
  "running-quiet",
720
+ "running-suspected-stall",
721
+ "needs-attention",
720
722
  "remote-unknown",
721
723
  "pending",
722
724
  ]);
@@ -796,8 +798,12 @@ function computeDagHealth(dagRuns) {
796
798
  pausedRuns++;
797
799
  if (dag.effectiveStatus === "failed")
798
800
  failedRuns++;
799
- if (liveness === "stale" || liveness === "orphaned")
801
+ if (liveness === "stale"
802
+ || liveness === "orphaned"
803
+ || liveness === "suspected-stall"
804
+ || liveness === "needs-attention") {
800
805
  staleRuns++;
806
+ }
801
807
  if (dag.effectiveStatus === "interrupted")
802
808
  interruptedRuns++;
803
809
  if (dag.stateConsistent === false)
@@ -75,6 +75,9 @@ export const LIVENESS_LABELS = {
75
75
  active: "执行器正常",
76
76
  "node-quiet": "执行器正常,节点长时间无活动",
77
77
  quiet: "无输出(存活)",
78
+ "suspected-stall": "疑似卡死",
79
+ "needs-attention": "需人工关注",
80
+ "needs-reconcile": "需对账",
78
81
  stale: "执行器心跳中断",
79
82
  "timeout-risk": "即将超时",
80
83
  orphaned: "执行器已退出",
@@ -86,6 +89,8 @@ export const DAG_EFFECTIVE_STATUS_LABELS = {
86
89
  pending: "等待执行",
87
90
  running: "正在执行",
88
91
  "running-quiet": "运行可疑",
92
+ "running-suspected-stall": "疑似卡死",
93
+ "needs-attention": "需人工关注",
89
94
  paused: "已暂停",
90
95
  interrupted: "执行已中断",
91
96
  "remote-unknown": "远端状态未知",
@@ -71,7 +71,18 @@ export function isPoolAttentionTask(task) {
71
71
  const st = (task?.status ?? "").toLowerCase();
72
72
  const live = (task?.liveness ?? "").toLowerCase();
73
73
  if (["failed", "error", "blocked", "stale"].includes(st)) return true;
74
- if (["stale", "orphaned", "timeout-risk"].includes(live)) return true;
74
+ if (
75
+ [
76
+ "stale",
77
+ "orphaned",
78
+ "timeout-risk",
79
+ "suspected-stall",
80
+ "needs-attention",
81
+ "needs-reconcile",
82
+ ].includes(live)
83
+ ) {
84
+ return true;
85
+ }
75
86
  return false;
76
87
  }
77
88
 
@@ -79,8 +90,16 @@ export function attentionRank(task) {
79
90
  const st = (task?.status ?? "").toLowerCase();
80
91
  const live = (task?.liveness ?? "").toLowerCase();
81
92
  if (st === "failed" || st === "error") return 0;
82
- if (live === "timeout-risk") return 1;
83
- if (st === "stale" || live === "stale" || live === "orphaned") return 2;
93
+ if (live === "timeout-risk" || live === "needs-attention") return 1;
94
+ if (
95
+ st === "stale"
96
+ || live === "stale"
97
+ || live === "orphaned"
98
+ || live === "suspected-stall"
99
+ || live === "needs-reconcile"
100
+ ) {
101
+ return 2;
102
+ }
84
103
  if (st === "blocked") return 3;
85
104
  return 9;
86
105
  }
@@ -845,10 +845,14 @@ td:first-child,
845
845
  .kpi-tip {
846
846
  position: absolute;
847
847
  left: 12px;
848
- right: 12px;
848
+ right: auto;
849
849
  top: calc(100% - 8px);
850
850
  z-index: 10;
851
- max-width: 280px;
851
+ /* Stable readable width independent of narrow KPI cards; viewport-safe cap. */
852
+ width: min(280px, calc(100vw - 24px));
853
+ min-width: min(240px, calc(100vw - 24px));
854
+ max-width: min(280px, calc(100vw - 24px));
855
+ box-sizing: border-box;
852
856
  padding: 8px 10px;
853
857
  border: 1px solid var(--hairline-strong);
854
858
  border-radius: var(--radius-md);
@@ -863,6 +867,12 @@ td:first-child,
863
867
  pointer-events: none;
864
868
  transition: opacity var(--transition-fast);
865
869
  }
870
+ /* Five-column default: right two columns open inward (left) to avoid viewport overflow. */
871
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n-1) .kpi-tip,
872
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n) .kpi-tip {
873
+ left: auto;
874
+ right: 12px;
875
+ }
866
876
  .kpi-help:hover + .kpi-tip,
867
877
  .kpi-help:focus-visible + .kpi-tip {
868
878
  opacity: 1;
@@ -2015,7 +2025,7 @@ body.is-resizing-dag-graph {
2015
2025
  .spec-evidence-back { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid var(--hairline); border-radius: var(--radius-pill); background: var(--surface); color: var(--ink); font-size: 12px; font-weight: 650; cursor: pointer; }
2016
2026
  .spec-evidence-back:hover, .spec-evidence-back:focus-visible { background: var(--orange-soft); border-color: var(--orange); color: var(--orange-active); outline: none; }
2017
2027
  .spec-evidence-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--body); }
2018
- .spec-evidence-detail-content { min-height: 0; max-width: 100%; margin: 0; padding: var(--space-3); border: 1px solid var(--hairline); border-radius: var(--radius-md); background: var(--canvas-soft); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; }
2028
+ .spec-evidence-detail-content { min-height: 0; max-width: 100%; margin: 0; padding: var(--space-3); border: 1px solid var(--hairline); border-radius: var(--radius-md); background: var(--canvas-soft); overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; }
2019
2029
  .spec-evidence-detail-error { margin: 0; color: var(--red, #c00); font-size: 12px; }
2020
2030
  .spec-evidence-detail-truncated { margin: 0; color: var(--muted); font-size: 11px; }
2021
2031
 
@@ -2289,6 +2299,16 @@ body.is-resizing-dag-graph {
2289
2299
  .kpi-grid.pool-health-grid .kpi-card:first-child {
2290
2300
  border-left: 0;
2291
2301
  }
2302
+ /* Three-column: reset five-column tip edges, then rightmost column opens inward. */
2303
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n-1) .kpi-tip,
2304
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(5n) .kpi-tip {
2305
+ left: 12px;
2306
+ right: auto;
2307
+ }
2308
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(3n) .kpi-tip {
2309
+ left: auto;
2310
+ right: 12px;
2311
+ }
2292
2312
  .panel-risk {
2293
2313
  position: static;
2294
2314
  }
@@ -2378,6 +2398,15 @@ body.is-resizing-dag-graph {
2378
2398
  .kpi-grid.pool-health-grid .kpi-card:first-child {
2379
2399
  border-left: 0;
2380
2400
  }
2401
+ /* Two-column: reset three-column tip edges, then right column opens inward. */
2402
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(3n) .kpi-tip {
2403
+ left: 12px;
2404
+ right: auto;
2405
+ }
2406
+ .kpi-grid:not(.pool-health-grid) > .kpi-card:nth-child(2n) .kpi-tip {
2407
+ left: auto;
2408
+ right: 12px;
2409
+ }
2381
2410
  .dag-inspector {
2382
2411
  top: 12px;
2383
2412
  right: 12px;
@@ -515,8 +515,8 @@ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
515
515
  error.setAttribute("role", "alert");
516
516
  wrapper.appendChild(error);
517
517
  } else if (detail.data) {
518
- const body = el("pre", "spec-evidence-detail-content");
519
- body.textContent = detail.data.content ?? "";
518
+ const body = el("div", "spec-evidence-detail-content");
519
+ body.appendChild(renderMarkdown(detail.data.content ?? ""));
520
520
  wrapper.appendChild(body);
521
521
  if (detail.data.truncated) {
522
522
  const note = el(
@@ -10,7 +10,8 @@ import { getTaskPoolRoot } from "../pool/run-store.js";
10
10
  import { checkRequiredOutputs } from "../outcomes/gate.js";
11
11
  import { projectOutcome } from "../outcomes/projector.js";
12
12
  import { writeOutcome } from "../outcomes/store.js";
13
- export const DEFAULT_RUN_DAG_TIMEOUT_MS = 1_800_000;
13
+ /** No outer wall-clock by default; the DAG kernel owns liveness supervision. */
14
+ export const DEFAULT_RUN_DAG_TIMEOUT_MS = 0;
14
15
  export const MAX_WORKER_TIMEOUT_MS = 7_200_000;
15
16
  export async function runTaskSpec(options) {
16
17
  let controllerIdentity = resolveControllerIdentity(options.client, options.controllerIdentity);
@@ -346,6 +347,11 @@ async function runRequiredCommand(repoRoot, client, artifactName, args, expectJs
346
347
  * not know the flag; their parser rejects before any DAG node can execute, so
347
348
  * retrying the exact command without it is safe and preserves the same run id.
348
349
  */
350
+ function assertRunDagCommandOk(result, args) {
351
+ if (!result.ok) {
352
+ throw new Error(`loop-agent command failed: ${args.join(" ")}`);
353
+ }
354
+ }
349
355
  async function runDagWithEventsFallback(repoRoot, client, args, timeoutMs, eventCtx) {
350
356
  const result = await client.run(args, {
351
357
  cwd: repoRoot,
@@ -356,7 +362,7 @@ async function runDagWithEventsFallback(repoRoot, client, args, timeoutMs, event
356
362
  if (result.ok)
357
363
  return result;
358
364
  if (!rejectsEventsJsonl(result) || !args.includes("--events-jsonl")) {
359
- throw new Error(`loop-agent command failed: ${args.join(" ")}`);
365
+ assertRunDagCommandOk(result, args);
360
366
  }
361
367
  const fallbackArgs = withoutFlagAndValue(args, "--events-jsonl");
362
368
  eventCtx.progress.note("run-dag controller does not support --events-jsonl; retrying without DAG event stream");
@@ -366,9 +372,7 @@ async function runDagWithEventsFallback(repoRoot, client, args, timeoutMs, event
366
372
  expectJson: true,
367
373
  timeoutMs,
368
374
  });
369
- if (!fallback.ok) {
370
- throw new Error(`loop-agent command failed: ${fallbackArgs.join(" ")}`);
371
- }
375
+ assertRunDagCommandOk(fallback, fallbackArgs);
372
376
  return fallback;
373
377
  }
374
378
  function rejectsEventsJsonl(result) {
@@ -424,8 +428,14 @@ async function runObservedStep(ctx, stepName, run, options) {
424
428
  throw error;
425
429
  }
426
430
  }
431
+ /**
432
+ * Explicit worker.timeout_ms remains an operator hard limit. Omission disables
433
+ * the Worker wall clock so the DAG kernel can supervise active nodes.
434
+ */
427
435
  function resolveRunDagTimeoutMs(taskSpec) {
428
- return Math.min(taskSpec.worker.timeout_ms ?? DEFAULT_RUN_DAG_TIMEOUT_MS, MAX_WORKER_TIMEOUT_MS);
436
+ return taskSpec.worker.timeout_ms === undefined
437
+ ? DEFAULT_RUN_DAG_TIMEOUT_MS
438
+ : Math.min(taskSpec.worker.timeout_ms, MAX_WORKER_TIMEOUT_MS);
429
439
  }
430
440
  async function collectFailureArtifacts(input) {
431
441
  const doctor = await runObservedStep(input.eventCtx, "dag-doctor", () => input.client.run(["dag", "doctor", "--run-id", input.workerRunId, "--markdown"], {