@tea-agent/loop-agent 0.9.0 → 0.10.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 (63) hide show
  1. package/CHANGELOG.md +42 -11
  2. package/README.md +20 -0
  3. package/dist/cli/command-definitions.js +7 -0
  4. package/dist/cli/program.js +6 -1
  5. package/dist/commands/dag-reconcile-run.js +118 -0
  6. package/dist/commands/init.js +12 -3
  7. package/dist/governance/manifest-types.js +4 -0
  8. package/dist/worker/cli.js +216 -0
  9. package/dist/worker/closeout/apply.js +73 -0
  10. package/dist/worker/closeout/preview.js +30 -0
  11. package/dist/worker/delivery/final-verification.js +158 -0
  12. package/dist/worker/delivery/git-transaction.js +354 -0
  13. package/dist/worker/delivery/package.js +449 -0
  14. package/dist/worker/feature/decision-loader.js +68 -0
  15. package/dist/worker/feature/discover.js +14 -0
  16. package/dist/worker/feature/next-action.js +74 -0
  17. package/dist/worker/feature/reducer.js +133 -0
  18. package/dist/worker/feature/review.js +502 -0
  19. package/dist/worker/feature/run.js +313 -0
  20. package/dist/worker/feature/types.js +1 -0
  21. package/dist/worker/follow-up/approve.js +270 -0
  22. package/dist/worker/follow-up/factory.js +234 -0
  23. package/dist/worker/follow-up/paths.js +25 -0
  24. package/dist/worker/follow-up/policy.js +26 -0
  25. package/dist/worker/follow-up/schema.js +93 -0
  26. package/dist/worker/follow-up/store.js +96 -0
  27. package/dist/worker/metrics/projector.js +139 -0
  28. package/dist/worker/observability/read-model.js +256 -15
  29. package/dist/worker/observe/paths.js +17 -5
  30. package/dist/worker/observe/static/app.js +443 -61
  31. package/dist/worker/observe/static/index.html +3 -1
  32. package/dist/worker/observe/static/styles.css +86 -19
  33. package/dist/worker/pool/run-store.js +14 -2
  34. package/dist/worker/pool/validation.js +59 -0
  35. package/dist/worker/report/morning-report.js +41 -6
  36. package/dist/worker/run-task/run-task.js +1 -1
  37. package/dist/worker/runner/run-ready.js +19 -5
  38. package/dist/workflows/dag/init-hybrid.js +3 -1
  39. package/dist/workflows/dag/lifecycle.js +146 -0
  40. package/dist/workflows/dag/node-execution.js +3 -0
  41. package/dist/workflows/dag/prompt.js +16 -0
  42. package/dist/workflows/dag/report.js +2 -0
  43. package/dist/workflows/dag/runner.js +133 -104
  44. package/dist/workflows/dag/types.js +3 -0
  45. package/docs/README.md +17 -0
  46. package/docs/agent-dag-recovery-playbook.md +1 -1
  47. package/docs/architecture/runtime-boundaries.md +3 -2
  48. package/docs/design/README.md +11 -5
  49. package/docs/exec-plans/active/README.md +1 -1
  50. package/docs/exec-plans/completed/README.md +8 -1
  51. package/docs/loop-agent-harness.md +45 -2
  52. package/docs/progress/README.md +2 -0
  53. package/docs/reports/README.md +10 -0
  54. package/docs/templates/agent-dag-report.schema.json +5 -3
  55. package/docs/templates/harness.schema.json +7 -2
  56. package/docs/templates/init-evolution-review.md +4 -2
  57. package/docs/verification-matrix.md +7 -0
  58. package/harness.json +4 -3
  59. package/package.json +4 -2
  60. package/scripts/check-product-line-docs.sh +7 -3
  61. package/skills/init-capability-evolution/SKILL.md +1 -0
  62. package/skills/loop-agent/references/command-reference.md +21 -0
  63. package/skills/loop-agent/references/hybrid-dag.md +4 -3
@@ -4,6 +4,9 @@ import path from "node:path";
4
4
  import { truncateUtf8Preview } from "../../shared/preview.js";
5
5
  import { parseWorkerEventLine } from "./events.js";
6
6
  import { getTaskPoolRoot } from "../pool/run-store.js";
7
+ import { assessDagRunLiveness, assessDagRunRecoveryEligibility, deriveDagRunEffectiveStatus, } from "../../workflows/dag/lifecycle.js";
8
+ import { parseDagSpec, resolveModelForTask, } from "../../workflows/dag/types.js";
9
+ import { loadFeatureDecisionModels } from "../feature/decision-loader.js";
7
10
  const ACTIVE_TASK_STATUSES = new Set(["running", "pending"]);
8
11
  const ACTIVE_BATCH_STATUSES = new Set(["running", "pending"]);
9
12
  export async function buildGlobalSnapshot(options) {
@@ -18,7 +21,7 @@ export async function buildGlobalSnapshot(options) {
18
21
  loadLedgerBatches(repoRoot),
19
22
  loadStateRecords(repoRoot),
20
23
  loadFailureHandoffs(repoRoot),
21
- loadDagRuns(repoRoot),
24
+ loadDagRuns(repoRoot, now()),
22
25
  ]);
23
26
  const taskMap = new Map();
24
27
  for (const state of states) {
@@ -41,7 +44,8 @@ export async function buildGlobalSnapshot(options) {
41
44
  ...batch,
42
45
  tasks: batch.tasks.map((task) => normalizeTaskArtifactRefs(task, repoRoot)),
43
46
  }));
44
- const health = computeHealth(batches, tasks);
47
+ const health = computeHealth(batches, tasks, dagRuns);
48
+ const { features, projectionWarnings } = await loadFeatureDecisionModels(repoRoot);
45
49
  return {
46
50
  schemaVersion: 1,
47
51
  generatedAt: now().toISOString(),
@@ -49,12 +53,31 @@ export async function buildGlobalSnapshot(options) {
49
53
  batches,
50
54
  tasks,
51
55
  dagRuns,
56
+ features,
57
+ projectionWarnings,
52
58
  health,
53
59
  };
54
60
  }
55
- catch {
61
+ catch (error) {
56
62
  const now = options.now ?? (() => new Date());
57
- return emptySnapshot(path.resolve(options.repoRoot), now);
63
+ const safeNow = () => {
64
+ try {
65
+ return now();
66
+ }
67
+ catch {
68
+ return new Date();
69
+ }
70
+ };
71
+ let repoRoot;
72
+ try {
73
+ repoRoot = path.resolve(options.repoRoot);
74
+ }
75
+ catch {
76
+ repoRoot = String(options.repoRoot ?? "");
77
+ }
78
+ const snapshot = emptySnapshot(repoRoot, safeNow);
79
+ snapshot.projectionError = sanitizeProjectionError(error);
80
+ return snapshot;
58
81
  }
59
82
  }
60
83
  function emptySnapshot(repoRoot, now) {
@@ -72,6 +95,25 @@ function emptySnapshot(repoRoot, now) {
72
95
  staleCount: 0,
73
96
  timeoutRiskCount: 0,
74
97
  failuresCount: 0,
98
+ dag: {
99
+ activeRuns: 0,
100
+ runningNodes: 0,
101
+ pendingNodes: 0,
102
+ pausedRuns: 0,
103
+ failedRuns: 0,
104
+ staleRuns: 0,
105
+ interruptedRuns: 0,
106
+ inconsistentRuns: 0,
107
+ attentionRuns: 0,
108
+ },
109
+ worker: {
110
+ activeTasks: 0,
111
+ activeBatches: 0,
112
+ quietCount: 0,
113
+ staleCount: 0,
114
+ timeoutRiskCount: 0,
115
+ failuresCount: 0,
116
+ },
75
117
  },
76
118
  };
77
119
  }
@@ -369,15 +411,168 @@ function summarizeTasks(tasks) {
369
411
  }
370
412
  return { total: tasks.length, succeeded, failed, reused };
371
413
  }
372
- function computeHealth(batches, tasks) {
373
- return {
374
- activeBatches: batches.filter((b) => ACTIVE_BATCH_STATUSES.has(b.status)).length,
414
+ function computeHealth(batches, tasks, dagRuns) {
415
+ const worker = {
375
416
  activeTasks: tasks.filter((t) => ACTIVE_TASK_STATUSES.has(t.status)).length,
417
+ activeBatches: batches.filter((b) => ACTIVE_BATCH_STATUSES.has(b.status)).length,
376
418
  quietCount: tasks.filter((t) => t.liveness === "quiet").length,
377
419
  staleCount: tasks.filter((t) => t.status === "stale").length,
378
420
  timeoutRiskCount: tasks.filter((t) => t.liveness === "timeout-risk").length,
379
421
  failuresCount: tasks.filter((t) => t.status === "failed").length,
380
422
  };
423
+ const dag = computeDagHealth(dagRuns);
424
+ return {
425
+ // Flat aliases retained for back-compat; mirror worker.* plus activeBatches.
426
+ activeBatches: worker.activeBatches,
427
+ activeTasks: worker.activeTasks,
428
+ quietCount: worker.quietCount,
429
+ staleCount: worker.staleCount,
430
+ timeoutRiskCount: worker.timeoutRiskCount,
431
+ failuresCount: worker.failuresCount,
432
+ dag,
433
+ worker,
434
+ };
435
+ }
436
+ const ACTIVE_EFFECTIVE_STATUSES = new Set([
437
+ "running",
438
+ "running-quiet",
439
+ "remote-unknown",
440
+ "pending",
441
+ ]);
442
+ const TERMINAL_RUN_STATUSES = new Set([
443
+ "finished",
444
+ "failed",
445
+ "partial_failed",
446
+ "superseded",
447
+ "abandoned",
448
+ ]);
449
+ /**
450
+ * Server-side mirror of app.js isDagRunActive. Derives DAG-run activeness from
451
+ * already-loaded DagRunSummary facts without touching app.js or new files.
452
+ */
453
+ function isDagRunActiveForHealth(dag) {
454
+ if (dag.effectiveStatus && ACTIVE_EFFECTIVE_STATUSES.has(dag.effectiveStatus)) {
455
+ return true;
456
+ }
457
+ // Only "unknown" (and absent) means liveness evidence is insufficient;
458
+ // it must NOT override raw lifecycle/status/node facts. Every other concrete
459
+ // effectiveStatus is authoritative-non-active and short-circuits to false.
460
+ if (dag.effectiveStatus && dag.effectiveStatus !== "unknown")
461
+ return false;
462
+ const status = (dag.status ?? "").toLowerCase();
463
+ if ((dag.lifecycle ?? "").toLowerCase() === "paused")
464
+ return false;
465
+ if (["orphaned", "stale"].includes((dag.liveness ?? "").toLowerCase()))
466
+ return false;
467
+ if (TERMINAL_RUN_STATUSES.has(status))
468
+ return false;
469
+ if (["running", "pending", "started"].includes(status))
470
+ return true;
471
+ return (dag.nodes ?? []).some((node) => isNodeStatusActive(node.status));
472
+ }
473
+ function isNodeStatusActive(status) {
474
+ const normalized = (status ?? "").toLowerCase();
475
+ if (["finished", "completed", "succeeded", "done", "error", "failed", "skipped", "partial_failed"].includes(normalized)) {
476
+ return false;
477
+ }
478
+ return true;
479
+ }
480
+ function computeDagHealth(dagRuns) {
481
+ let activeRuns = 0;
482
+ let runningNodes = 0;
483
+ let pendingNodes = 0;
484
+ let pausedRuns = 0;
485
+ let failedRuns = 0;
486
+ let staleRuns = 0;
487
+ let interruptedRuns = 0;
488
+ let inconsistentRuns = 0;
489
+ let attentionRuns = 0;
490
+ for (const dag of dagRuns) {
491
+ const active = isDagRunActiveForHealth(dag);
492
+ if (active) {
493
+ activeRuns++;
494
+ for (const node of dag.nodes ?? []) {
495
+ const status = (node.status ?? "").toLowerCase();
496
+ if (status === "running" || status === "started")
497
+ runningNodes++;
498
+ if (status === "pending" || status === "queued")
499
+ pendingNodes++;
500
+ }
501
+ }
502
+ const lifecycle = (dag.lifecycle ?? "").toLowerCase();
503
+ const liveness = (dag.liveness ?? "").toLowerCase();
504
+ if (lifecycle === "paused")
505
+ pausedRuns++;
506
+ if (dag.effectiveStatus === "failed")
507
+ failedRuns++;
508
+ if (liveness === "stale" || liveness === "orphaned")
509
+ staleRuns++;
510
+ if (dag.effectiveStatus === "interrupted")
511
+ interruptedRuns++;
512
+ if (dag.stateConsistent === false)
513
+ inconsistentRuns++;
514
+ if (lifecycle !== "completed"
515
+ && (lifecycle === "paused"
516
+ || liveness === "stale"
517
+ || liveness === "orphaned"
518
+ || dag.effectiveStatus === "interrupted"
519
+ || dag.effectiveStatus === "remote-unknown"
520
+ || dag.stateConsistent === false)) {
521
+ attentionRuns++;
522
+ }
523
+ }
524
+ return {
525
+ activeRuns,
526
+ runningNodes,
527
+ pendingNodes,
528
+ pausedRuns,
529
+ failedRuns,
530
+ staleRuns,
531
+ interruptedRuns,
532
+ inconsistentRuns,
533
+ attentionRuns,
534
+ };
535
+ }
536
+ /**
537
+ * Whitelist a short safe projection-failure message. Never expose stack frames,
538
+ * absolute paths, env values, or secrets. Mirrors the bare catch that previously
539
+ * silently returned a zero-shape snapshot.
540
+ */
541
+ function sanitizeProjectionError(error) {
542
+ const fallback = "投影失败 (projection failed)";
543
+ const at = new Date().toISOString();
544
+ if (!error || typeof error !== "object") {
545
+ return { message: fallback, at };
546
+ }
547
+ let name;
548
+ let message;
549
+ try {
550
+ name = typeof error.name === "string"
551
+ ? error.name
552
+ : undefined;
553
+ message = typeof error.message === "string"
554
+ ? error.message
555
+ : undefined;
556
+ }
557
+ catch {
558
+ return { message: fallback, at };
559
+ }
560
+ const candidate = message ?? name;
561
+ if (!candidate) {
562
+ return { message: fallback, at };
563
+ }
564
+ // Reject anything that looks like a stack frame, an absolute path, or a secret.
565
+ const raw = String(candidate);
566
+ if (raw.includes("\n")
567
+ || /\bat \S+/i.test(raw)
568
+ || /[A-Za-z]:\\/.test(raw)
569
+ || raw.includes("/Users/")
570
+ || raw.includes("/home/")
571
+ || /SECRET|TOKEN|PASSWORD|API_KEY/i.test(raw)) {
572
+ return { message: name ? `${name}: 投影失败` : fallback, at };
573
+ }
574
+ const trimmed = raw.trim().slice(0, 200);
575
+ return { message: trimmed || fallback, at };
381
576
  }
382
577
  function mergeArtifactRefs(existing, incoming) {
383
578
  if (!incoming)
@@ -643,11 +838,11 @@ async function loadFailureHandoffs(repoRoot) {
643
838
  }
644
839
  return handoffs;
645
840
  }
646
- async function loadDagRuns(repoRoot) {
841
+ async function loadDagRuns(repoRoot, now) {
647
842
  const byId = new Map();
648
843
  const statePaths = await findStateJsonFiles(path.join(repoRoot, ".harness", "dag-runs"));
649
844
  for (const statePath of statePaths) {
650
- const summary = await parseDagStateFile(statePath);
845
+ const summary = await parseDagStateFile(statePath, now);
651
846
  if (summary)
652
847
  byId.set(summary.dagRunId, mergeDagRun(byId.get(summary.dagRunId), summary));
653
848
  }
@@ -670,6 +865,11 @@ function mergeDagRun(existing, incoming) {
670
865
  return {
671
866
  dagRunId: incoming.dagRunId,
672
867
  status: incoming.status ?? existing.status,
868
+ lifecycle: incoming.lifecycle ?? existing.lifecycle,
869
+ liveness: incoming.liveness ?? existing.liveness,
870
+ effectiveStatus: incoming.effectiveStatus ?? existing.effectiveStatus,
871
+ stateConsistent: incoming.stateConsistent ?? existing.stateConsistent,
872
+ recoveryEligibility: incoming.recoveryEligibility ?? existing.recoveryEligibility,
673
873
  title: incoming.title ?? existing.title,
674
874
  startedAt,
675
875
  finishedAt,
@@ -849,7 +1049,7 @@ async function walkForStateJson(dir, results) {
849
1049
  // skip
850
1050
  }
851
1051
  }
852
- async function parseDagStateFile(statePath) {
1052
+ async function parseDagStateFile(statePath, now) {
853
1053
  try {
854
1054
  if (!existsSync(statePath))
855
1055
  return undefined;
@@ -858,6 +1058,10 @@ async function parseDagStateFile(statePath) {
858
1058
  if (!parsed)
859
1059
  return undefined;
860
1060
  const runDir = path.dirname(statePath);
1061
+ const lifecycleName = path.basename(path.dirname(runDir));
1062
+ const lifecycle = lifecycleName === "active" || lifecycleName === "paused" || lifecycleName === "completed"
1063
+ ? lifecycleName
1064
+ : undefined;
861
1065
  const dagRunId = readString(parsed, "runId") ?? path.basename(runDir);
862
1066
  const status = readString(parsed, "status");
863
1067
  const title = readString(parsed, "title");
@@ -866,11 +1070,30 @@ async function parseDagStateFile(statePath) {
866
1070
  const durationMs = computeDurationMs(startedAt, finishedAt);
867
1071
  const ranks = readStringMatrix(parsed, "ranks");
868
1072
  const rankByNode = buildRankIndex(ranks);
869
- const nodes = parseDagNodes(parsed, rankByNode);
870
- const edges = await parseDagEdges(path.join(runDir, "run.json"), nodes);
1073
+ const runSpecPath = path.join(runDir, "run.json");
1074
+ const modelByNode = await loadDagNodeModels(runSpecPath);
1075
+ const nodes = parseDagNodes(parsed, rankByNode, modelByNode);
1076
+ const edges = await parseDagEdges(runSpecPath, nodes);
1077
+ const state = parsed;
1078
+ const liveness = assessDagRunLiveness({ state, now });
1079
+ const effectiveStatus = lifecycle
1080
+ ? deriveDagRunEffectiveStatus({ lifecycle, state, liveness: liveness.status })
1081
+ : "unknown";
1082
+ const stateConsistent = lifecycle === undefined
1083
+ ? false
1084
+ : !((lifecycle === "paused" && state.status !== "paused")
1085
+ || (lifecycle === "completed" && !["finished", "failed", "partial_failed", "superseded", "abandoned"].includes(state.status)));
1086
+ const recoveryEligibility = lifecycle
1087
+ ? assessDagRunRecoveryEligibility({ lifecycle, state, liveness: liveness.status })
1088
+ : undefined;
871
1089
  return {
872
1090
  dagRunId,
873
1091
  status,
1092
+ ...(lifecycle ? { lifecycle } : {}),
1093
+ liveness: liveness.status,
1094
+ effectiveStatus,
1095
+ stateConsistent,
1096
+ ...(recoveryEligibility ? { recoveryEligibility } : {}),
874
1097
  ...(title ? { title } : {}),
875
1098
  ...(startedAt ? { startedAt } : {}),
876
1099
  ...(finishedAt ? { finishedAt } : {}),
@@ -924,7 +1147,25 @@ function buildRankIndex(ranks) {
924
1147
  }
925
1148
  return index;
926
1149
  }
927
- function parseDagNodes(parsed, rankByNode) {
1150
+ async function loadDagNodeModels(runPath) {
1151
+ const models = new Map();
1152
+ const raw = await safeReadJson(runPath);
1153
+ if (!raw)
1154
+ return models;
1155
+ try {
1156
+ const spec = parseDagSpec(raw);
1157
+ for (const task of spec.tasks) {
1158
+ const model = resolveModelForTask(task, spec.executorModels);
1159
+ if (model)
1160
+ models.set(task.id, model);
1161
+ }
1162
+ }
1163
+ catch {
1164
+ // Historical specs may predate the current schema; state facts remain readable.
1165
+ }
1166
+ return models;
1167
+ }
1168
+ function parseDagNodes(parsed, rankByNode, modelByNode) {
928
1169
  const nodes = [];
929
1170
  const nodesValue = parsed.nodes;
930
1171
  if (nodesValue && typeof nodesValue === "object" && !Array.isArray(nodesValue)) {
@@ -940,7 +1181,7 @@ function parseDagNodes(parsed, rankByNode) {
940
1181
  nodeId: resolvedId,
941
1182
  rank: rankByNode.get(nodeId) ?? rankByNode.get(resolvedId),
942
1183
  executor: readString(node, "executor"),
943
- model: readString(node, "model"),
1184
+ model: readString(node, "model") ?? modelByNode.get(resolvedId),
944
1185
  status: nodeStatus,
945
1186
  label: readString(node, "label") ?? readString(node, "title") ?? nodeId,
946
1187
  durationMs: readNumber(node, "durationMs"),
@@ -962,7 +1203,7 @@ function parseDagNodes(parsed, rankByNode) {
962
1203
  nodeId,
963
1204
  rank: rankByNode.get(nodeId),
964
1205
  executor: readString(node, "executor"),
965
- model: readString(node, "model"),
1206
+ model: readString(node, "model") ?? modelByNode.get(nodeId),
966
1207
  status: nodeStatus,
967
1208
  label: readString(node, "label") ?? readString(node, "title") ?? nodeId,
968
1209
  durationMs: readNumber(node, "durationMs"),
@@ -1,10 +1,7 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
- export function resolveArtifactPath(repoRoot, relative) {
4
- if (!relative || path.isAbsolute(relative)) {
5
- return null;
6
- }
7
- if (!isAllowedArtifactRelativePath(relative)) {
3
+ export function resolveArtifactPath(repoRoot, artifactPath) {
4
+ if (!artifactPath) {
8
5
  return null;
9
6
  }
10
7
  const resolvedRepoRoot = path.resolve(repoRoot);
@@ -15,6 +12,21 @@ export function resolveArtifactPath(repoRoot, relative) {
15
12
  catch {
16
13
  realRepoRoot = resolvedRepoRoot;
17
14
  }
15
+ let absoluteArtifact = path.resolve(artifactPath);
16
+ if (path.isAbsolute(artifactPath) && existsSync(absoluteArtifact)) {
17
+ try {
18
+ absoluteArtifact = realpathSync(absoluteArtifact);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ const relative = path.isAbsolute(artifactPath)
25
+ ? path.relative(realRepoRoot, absoluteArtifact)
26
+ : artifactPath;
27
+ if (!relative || path.isAbsolute(relative) || !isAllowedArtifactRelativePath(relative)) {
28
+ return null;
29
+ }
18
30
  const candidate = path.resolve(realRepoRoot, relative);
19
31
  if (!isPathInside(realRepoRoot, candidate)) {
20
32
  return null;