@tea-agent/loop-agent 0.20.1 → 0.22.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 (61) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/bin/agent-worker.js +0 -0
  3. package/dist/adapters/loop-agent.js +52 -0
  4. package/dist/commands/init.js +104 -0
  5. package/dist/executors/dag-pi-executor.js +26 -0
  6. package/dist/executors/pi-executor.js +111 -36
  7. package/dist/executors/pi-sdk-executor.js +105 -29
  8. package/dist/executors/shell-executor.js +215 -29
  9. package/dist/shared/openspec-spec.js +49 -0
  10. package/dist/worker/loop-agent/loop-agent-client.js +43 -9
  11. package/dist/worker/observability/read-model.js +28 -2
  12. package/dist/worker/observe/spec-evidence.js +12 -15
  13. package/dist/worker/observe/static/constants.js +5 -0
  14. package/dist/worker/observe/static/dag-helpers.js +22 -0
  15. package/dist/worker/observe/static/format-pool.js +22 -3
  16. package/dist/worker/observe/static/styles.css +32 -3
  17. package/dist/worker/observe/static/views/dag-inspector.js +2 -2
  18. package/dist/worker/observe/static/views/dag.js +5 -0
  19. package/dist/worker/run-task/run-task.js +16 -6
  20. package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
  21. package/dist/workflows/dag/backend-test-result-contract.js +10 -4
  22. package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
  23. package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
  24. package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
  25. package/dist/workflows/dag/frontend-project-capability.js +11 -8
  26. package/dist/workflows/dag/frontend-repair.js +6 -4
  27. package/dist/workflows/dag/frontend-review-context.js +67 -0
  28. package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
  29. package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
  30. package/dist/workflows/dag/frontend-verification-trace.js +31 -1
  31. package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
  32. package/dist/workflows/dag/init-hybrid.js +370 -79
  33. package/dist/workflows/dag/lifecycle.js +60 -4
  34. package/dist/workflows/dag/liveness-policy.js +250 -0
  35. package/dist/workflows/dag/node-execution.js +49 -0
  36. package/dist/workflows/dag/runner.js +21 -1
  37. package/dist/workflows/dag/types.js +67 -1
  38. package/docs/README.md +5 -6
  39. package/docs/architecture/dag-execution.md +11 -0
  40. package/docs/architecture/facts-and-state.md +1 -0
  41. package/docs/architecture/worker-and-feature.md +10 -0
  42. package/docs/templates/agent-dag.schema.json +15 -5
  43. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
  44. package/docs/templates/backend-test-dag.json +15 -15
  45. package/docs/templates/frontend-implementation-contract.schema.json +4 -3
  46. package/docs/templates/frontend-test-case-checklist.md +6 -2
  47. package/docs/templates/frontend-test-dag.json +2 -2
  48. package/harness.json +1 -1
  49. package/package.json +1 -1
  50. package/skills/frontend-design-review/SKILL.md +12 -10
  51. package/skills/frontend-design-review/references/review-checklist.md +4 -4
  52. package/skills/frontend-implementation/SKILL.md +2 -2
  53. package/skills/frontend-implementation/references/code-standards.md +4 -3
  54. package/skills/frontend-implementation/references/design-spec.md +19 -14
  55. package/skills/frontend-implementation/references/node-contracts.md +2 -2
  56. package/skills/frontend-review/SKILL.md +15 -28
  57. package/skills/frontend-review/references/review-findings.md +16 -18
  58. package/skills/frontend-verification/SKILL.md +16 -13
  59. package/skills/frontend-verification/references/verification-checklist.md +18 -30
  60. package/skills/loop-agent/references/command-reference.md +2 -0
  61. package/skills/loop-agent/references/hybrid-dag.md +2 -2
@@ -187,10 +187,38 @@ export function assessDagRunLiveness(input) {
187
187
  return { status: "stale", runnerAlive: true };
188
188
  }
189
189
  const activeNode = Object.values(input.state.nodes).find((node) => node.status === "RUNNING");
190
- const nodeActivityMs = Date.parse(activeNode?.lastActivityAt ?? activeNode?.startedAt ?? "");
191
- if (!Number.isNaN(nodeActivityMs) && nowMs - nodeActivityMs > (input.nodeQuietThresholdMs ?? 300_000)) {
190
+ if (!activeNode)
191
+ return { status: "active", runnerAlive: true };
192
+ // Prefer persisted adaptive projection when present.
193
+ if (activeNode.livenessStatus === "needs-attention") {
194
+ return { status: "needs-attention", runnerAlive: true };
195
+ }
196
+ if (activeNode.livenessStatus === "suspected-stall"
197
+ || activeNode.livenessStatus === "probing") {
198
+ return { status: "suspected-stall", runnerAlive: true };
199
+ }
200
+ if (activeNode.livenessStatus === "quiet") {
192
201
  return { status: "node-quiet", runnerAlive: true };
193
202
  }
203
+ // Fall back to meaningful progress clocks (never use runner lease as progress).
204
+ const meaningfulAt = activeNode.lastMeaningfulProgressAt
205
+ ?? activeNode.lastProviderActivityAt
206
+ ?? activeNode.lastToolActivityAt
207
+ ?? activeNode.lastOutputActivityAt
208
+ ?? activeNode.lastActivityAt
209
+ ?? activeNode.startedAt;
210
+ const nodeActivityMs = Date.parse(meaningfulAt ?? "");
211
+ if (!Number.isNaN(nodeActivityMs)) {
212
+ const idleMs = nowMs - nodeActivityMs;
213
+ const stallMs = input.nodeStallThresholdMs ?? 900_000;
214
+ const quietMs = input.nodeQuietThresholdMs ?? 300_000;
215
+ if (idleMs > stallMs) {
216
+ return { status: "suspected-stall", runnerAlive: true };
217
+ }
218
+ if (idleMs > quietMs) {
219
+ return { status: "node-quiet", runnerAlive: true };
220
+ }
221
+ }
194
222
  return { status: "active", runnerAlive: true };
195
223
  }
196
224
  export function deriveDagRunEffectiveStatus(input) {
@@ -210,6 +238,10 @@ export function deriveDagRunEffectiveStatus(input) {
210
238
  }
211
239
  if (input.liveness === "orphaned" || input.liveness === "stale")
212
240
  return "interrupted";
241
+ if (input.liveness === "needs-attention")
242
+ return "needs-attention";
243
+ if (input.liveness === "suspected-stall")
244
+ return "running-suspected-stall";
213
245
  if (input.liveness === "node-quiet")
214
246
  return "running-quiet";
215
247
  if (input.liveness === "unknown-host")
@@ -232,7 +264,15 @@ export function assessDagRunRecoveryEligibility(input) {
232
264
  reasons.push("run-already-terminal");
233
265
  }
234
266
  if (input.lifecycle === "active"
235
- && ["active", "node-quiet", "stale", "unknown-host", "unknown"].includes(input.liveness)) {
267
+ && [
268
+ "active",
269
+ "node-quiet",
270
+ "suspected-stall",
271
+ "needs-attention",
272
+ "stale",
273
+ "unknown-host",
274
+ "unknown",
275
+ ].includes(input.liveness)) {
236
276
  canReconcile = false;
237
277
  reasons.push("runner-not-proven-dead-or-stopped");
238
278
  }
@@ -358,10 +398,26 @@ export async function detectDagRunHealthIssues(input) {
358
398
  issues.push({
359
399
  code: "node-activity-quiet",
360
400
  severity: "warning",
361
- message: "Runner heartbeat is fresh but the current RUNNING node has produced no state activity for more than 5 minutes",
401
+ message: "Runner heartbeat is fresh but the current RUNNING node has produced no meaningful activity for more than 5 minutes",
362
402
  advisoryAction: "Inspect the node session events and executor logs before deciding whether to wait or abort.",
363
403
  });
364
404
  }
405
+ else if (liveness.status === "suspected-stall") {
406
+ issues.push({
407
+ code: "node-activity-suspected-stall",
408
+ severity: "warning",
409
+ message: "Runner heartbeat is fresh but the current RUNNING node has no meaningful Provider/tool/output activity for more than 15 minutes",
410
+ advisoryAction: "Treat as suspected network/provider stall; only retry read-only nodes after the attempt is proven finished. Writers must fail closed to reconcile.",
411
+ });
412
+ }
413
+ else if (liveness.status === "needs-attention") {
414
+ issues.push({
415
+ code: "node-needs-attention",
416
+ severity: "error",
417
+ message: "Runner or node process identity cannot be proven healthy, or absolute max wall clock was exceeded",
418
+ advisoryAction: "Do not forge timed-out/cancelled. Inspect process identity and artifacts; reconcile only when exit is proven.",
419
+ });
420
+ }
365
421
  if (lifecycle === "active" &&
366
422
  state.status === "running" &&
367
423
  state.humanDecisionNodeId) {
@@ -0,0 +1,250 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Adaptive liveness policy for DAG/Pi supervision.
4
+ *
5
+ * Four clocks must not be mixed:
6
+ * - runner lease (synthetic heartbeat) — not meaningful progress
7
+ * - provider/transport activity
8
+ * - tool activity
9
+ * - output / meaningful progress
10
+ */
11
+ export const DEFAULT_LIVENESS_POLICY = {
12
+ heartbeatIntervalMs: 15_000,
13
+ runnerStaleMs: 90_000,
14
+ quietMs: 300_000,
15
+ stallProbeMs: 900_000,
16
+ abortGraceMs: 30_000,
17
+ /** 4h absolute max wall clock — cannot be renewed by empty heartbeats. */
18
+ absoluteMaxWallClockMs: 14_400_000,
19
+ };
20
+ export const dagLivenessPolicySchema = z
21
+ .object({
22
+ heartbeatIntervalMs: z.number().int().min(1_000).optional(),
23
+ runnerStaleMs: z.number().int().positive().optional(),
24
+ quietMs: z.number().int().positive().optional(),
25
+ stallProbeMs: z.number().int().positive().optional(),
26
+ abortGraceMs: z.number().int().positive().optional(),
27
+ absoluteMaxWallClockMs: z.number().int().positive().optional(),
28
+ })
29
+ .strict()
30
+ .superRefine((value, ctx) => {
31
+ const resolved = { ...DEFAULT_LIVENESS_POLICY, ...value };
32
+ if (resolved.runnerStaleMs <= resolved.heartbeatIntervalMs) {
33
+ ctx.addIssue({
34
+ code: z.ZodIssueCode.custom,
35
+ path: ["runnerStaleMs"],
36
+ message: "runnerStaleMs must be greater than heartbeatIntervalMs",
37
+ });
38
+ }
39
+ if (resolved.stallProbeMs <= resolved.quietMs) {
40
+ ctx.addIssue({
41
+ code: z.ZodIssueCode.custom,
42
+ path: ["stallProbeMs"],
43
+ message: "stallProbeMs must be greater than quietMs",
44
+ });
45
+ }
46
+ if (resolved.absoluteMaxWallClockMs <= resolved.stallProbeMs) {
47
+ ctx.addIssue({
48
+ code: z.ZodIssueCode.custom,
49
+ path: ["absoluteMaxWallClockMs"],
50
+ message: "absoluteMaxWallClockMs must be greater than stallProbeMs",
51
+ });
52
+ }
53
+ })
54
+ .optional();
55
+ /**
56
+ * Resolve policy from node/defaults partials. Missing fields use conservative defaults.
57
+ */
58
+ export function resolveLivenessPolicy(...sources) {
59
+ const merged = {};
60
+ for (const source of sources) {
61
+ if (!source)
62
+ continue;
63
+ for (const key of Object.keys(DEFAULT_LIVENESS_POLICY)) {
64
+ const value = source[key];
65
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
66
+ merged[key] = Math.trunc(value);
67
+ }
68
+ }
69
+ }
70
+ return {
71
+ heartbeatIntervalMs: merged.heartbeatIntervalMs ?? DEFAULT_LIVENESS_POLICY.heartbeatIntervalMs,
72
+ runnerStaleMs: merged.runnerStaleMs ?? DEFAULT_LIVENESS_POLICY.runnerStaleMs,
73
+ quietMs: merged.quietMs ?? DEFAULT_LIVENESS_POLICY.quietMs,
74
+ stallProbeMs: merged.stallProbeMs ?? DEFAULT_LIVENESS_POLICY.stallProbeMs,
75
+ abortGraceMs: merged.abortGraceMs ?? DEFAULT_LIVENESS_POLICY.abortGraceMs,
76
+ absoluteMaxWallClockMs: merged.absoluteMaxWallClockMs ??
77
+ DEFAULT_LIVENESS_POLICY.absoluteMaxWallClockMs,
78
+ };
79
+ }
80
+ /**
81
+ * Synthetic timer heartbeats never count as meaningful Pi progress.
82
+ */
83
+ export function isMeaningfulActivityKind(kind) {
84
+ return kind === "provider" || kind === "tool" || kind === "output";
85
+ }
86
+ /**
87
+ * Attempt fence: late activity from a previous attempt must not overwrite the current one.
88
+ */
89
+ export function shouldAcceptActivity(currentAttempt, activityAttempt) {
90
+ if (!Number.isInteger(currentAttempt) || currentAttempt < 1)
91
+ return false;
92
+ if (!Number.isInteger(activityAttempt) || activityAttempt < 1)
93
+ return false;
94
+ return activityAttempt === currentAttempt;
95
+ }
96
+ export function classifySessionEventActivity(event) {
97
+ if (!event || typeof event !== "object")
98
+ return "provider";
99
+ const type = typeof event.type === "string"
100
+ ? String(event.type)
101
+ : "";
102
+ if (type === "tool_start" ||
103
+ type === "tool_end" ||
104
+ type === "tool_execution_start" ||
105
+ type === "tool_execution_end") {
106
+ return "tool";
107
+ }
108
+ if (type === "thinking_delta" || type === "message_update") {
109
+ // Noise — do not treat as progress when callers filter via shouldPersistSessionEvent.
110
+ return null;
111
+ }
112
+ return "provider";
113
+ }
114
+ function parseMs(value) {
115
+ if (!value)
116
+ return NaN;
117
+ const ms = Date.parse(value);
118
+ return Number.isFinite(ms) ? ms : NaN;
119
+ }
120
+ function latestMeaningfulActivityMs(snapshot) {
121
+ const candidates = [
122
+ parseMs(snapshot.lastMeaningfulProgressAt),
123
+ parseMs(snapshot.lastProviderActivityAt),
124
+ parseMs(snapshot.lastToolActivityAt),
125
+ parseMs(snapshot.lastOutputActivityAt),
126
+ // Compatibility: lastActivityAt is treated as meaningful when richer clocks absent.
127
+ parseMs(snapshot.lastActivityAt),
128
+ parseMs(snapshot.startedAt),
129
+ ].filter((value) => !Number.isNaN(value));
130
+ if (candidates.length === 0)
131
+ return NaN;
132
+ return Math.max(...candidates);
133
+ }
134
+ /**
135
+ * Pure node liveness evaluator. No I/O.
136
+ *
137
+ * Transitions:
138
+ * - real provider/tool/output activity → active
139
+ * - no activity ≥ quietMs and runner lease fresh → quiet
140
+ * - no activity ≥ stallProbeMs → suspected-stall
141
+ * - probing flag → probing
142
+ * - identity mismatch / unprovable survival → needs-attention
143
+ * - wall clock ≥ absoluteMax → needs-attention (controlled abort path upstream)
144
+ */
145
+ export function evaluateNodeLiveness(input) {
146
+ const policy = resolveLivenessPolicy(input.policy);
147
+ const nowMs = typeof input.now === "number"
148
+ ? input.now
149
+ : (input.now ?? new Date()).getTime();
150
+ const startedMs = parseMs(input.node.startedAt);
151
+ const wallClockMs = !Number.isNaN(startedMs)
152
+ ? Math.max(0, nowMs - startedMs)
153
+ : 0;
154
+ const exceededAbsoluteMax = wallClockMs >= policy.absoluteMaxWallClockMs;
155
+ if (input.node.needsAttentionReason || exceededAbsoluteMax) {
156
+ return {
157
+ status: "needs-attention",
158
+ lastMeaningfulProgressAt: input.node.lastMeaningfulProgressAt ?? input.node.lastActivityAt,
159
+ idleMs: 0,
160
+ wallClockMs,
161
+ exceededAbsoluteMax,
162
+ };
163
+ }
164
+ if (input.node.probing) {
165
+ return {
166
+ status: "probing",
167
+ lastMeaningfulProgressAt: input.node.lastMeaningfulProgressAt ?? input.node.lastActivityAt,
168
+ idleMs: 0,
169
+ wallClockMs,
170
+ exceededAbsoluteMax,
171
+ };
172
+ }
173
+ const meaningfulMs = latestMeaningfulActivityMs(input.node);
174
+ const idleMs = !Number.isNaN(meaningfulMs)
175
+ ? Math.max(0, nowMs - meaningfulMs)
176
+ : wallClockMs;
177
+ const leaseFresh = input.runnerLeaseFresh ??
178
+ (() => {
179
+ const heartbeatMs = parseMs(input.runnerHeartbeatAt);
180
+ if (Number.isNaN(heartbeatMs))
181
+ return true;
182
+ return nowMs - heartbeatMs <= policy.runnerStaleMs;
183
+ })();
184
+ if (idleMs >= policy.stallProbeMs) {
185
+ return {
186
+ status: "suspected-stall",
187
+ lastMeaningfulProgressAt: input.node.lastMeaningfulProgressAt ?? input.node.lastActivityAt,
188
+ idleMs,
189
+ wallClockMs,
190
+ exceededAbsoluteMax,
191
+ };
192
+ }
193
+ if (idleMs >= policy.quietMs && leaseFresh) {
194
+ return {
195
+ status: "quiet",
196
+ lastMeaningfulProgressAt: input.node.lastMeaningfulProgressAt ?? input.node.lastActivityAt,
197
+ idleMs,
198
+ wallClockMs,
199
+ exceededAbsoluteMax,
200
+ };
201
+ }
202
+ // Active tool presence keeps us from escalating beyond quiet solely on idle output.
203
+ if ((input.node.activeToolCount ?? 0) > 0 && idleMs < policy.stallProbeMs) {
204
+ return {
205
+ status: idleMs >= policy.quietMs ? "quiet" : "active",
206
+ lastMeaningfulProgressAt: input.node.lastMeaningfulProgressAt ?? input.node.lastActivityAt,
207
+ idleMs,
208
+ wallClockMs,
209
+ exceededAbsoluteMax,
210
+ };
211
+ }
212
+ return {
213
+ status: "active",
214
+ lastMeaningfulProgressAt: input.node.lastMeaningfulProgressAt ?? input.node.lastActivityAt,
215
+ idleMs,
216
+ wallClockMs,
217
+ exceededAbsoluteMax,
218
+ };
219
+ }
220
+ /**
221
+ * Apply a fenced activity event onto a mutable node snapshot (pure field updates).
222
+ * Returns whether the write was accepted.
223
+ */
224
+ export function applyNodeActivity(input) {
225
+ if (!shouldAcceptActivity(input.currentAttempt, input.activityAttempt)) {
226
+ return false;
227
+ }
228
+ const at = input.at ?? new Date().toISOString();
229
+ if (input.kind === "lease" || input.kind === "synthetic-heartbeat") {
230
+ input.node.lastLeaseAt = at;
231
+ // Never treat synthetic lease as meaningful progress.
232
+ return true;
233
+ }
234
+ if (!isMeaningfulActivityKind(input.kind)) {
235
+ return false;
236
+ }
237
+ if (input.kind === "provider") {
238
+ input.node.lastProviderActivityAt = at;
239
+ }
240
+ else if (input.kind === "tool") {
241
+ input.node.lastToolActivityAt = at;
242
+ }
243
+ else if (input.kind === "output") {
244
+ input.node.lastOutputActivityAt = at;
245
+ }
246
+ input.node.lastMeaningfulProgressAt = at;
247
+ input.node.lastActivityAt = at;
248
+ input.node.livenessStatus = "active";
249
+ return true;
250
+ }
@@ -8,6 +8,7 @@ import { resolveContextPolicy } from "./context-policy.js";
8
8
  import { buildDagNodePromptEnvelope } from "./prompt.js";
9
9
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
10
10
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
11
+ import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
11
12
  import { buildProtocolRetryInstruction, validateOutputProtocol, } from "./output-protocol.js";
12
13
  import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
13
14
  import { buildProjectGovernanceContext, readCompletedWriterChangeManifests, writeProjectGovernanceContext, } from "./project-governance-context.js";
@@ -235,6 +236,9 @@ export async function executeDagNode(input) {
235
236
  node.status = "RUNNING";
236
237
  node.startedAt = new Date().toISOString();
237
238
  node.lastActivityAt = node.startedAt;
239
+ node.lastMeaningfulProgressAt = node.startedAt;
240
+ node.livenessStatus = "active";
241
+ node.currentAttempt = 1;
238
242
  if (task.shell?.verifyEvidence) {
239
243
  node.verifyEvidence = task.shell.verifyEvidence;
240
244
  }
@@ -310,12 +314,48 @@ export async function executeDagNode(input) {
310
314
  const maxAttempts = retryPolicy?.maxAttempts ?? 1;
311
315
  const attempts = [];
312
316
  let totalBackoffMs = 0;
317
+ const livenessPolicy = resolveLivenessPolicy(spec.defaults?.livenessPolicy, task.livenessPolicy);
318
+ /**
319
+ * Attempt-fenced, throttled activity sink. Late events from a previous
320
+ * attempt are no-ops. Persistence is best-effort and never throws to the
321
+ * executor path.
322
+ */
323
+ const ACTIVITY_PERSIST_MIN_INTERVAL_MS = 2_000;
324
+ let lastActivityPersistMs = 0;
325
+ const reportActivity = (activity) => {
326
+ const accepted = applyNodeActivity({
327
+ node,
328
+ currentAttempt: node.currentAttempt ?? 1,
329
+ activityAttempt: activity.attempt,
330
+ kind: activity.kind,
331
+ at: activity.at,
332
+ });
333
+ if (!accepted)
334
+ return;
335
+ const evaluation = evaluateNodeLiveness({
336
+ node,
337
+ policy: livenessPolicy,
338
+ runnerHeartbeatAt: state.runner?.heartbeatAt,
339
+ });
340
+ node.livenessStatus = evaluation.status;
341
+ const nowMs = Date.now();
342
+ if (nowMs - lastActivityPersistMs < ACTIVITY_PERSIST_MIN_INTERVAL_MS) {
343
+ return;
344
+ }
345
+ lastActivityPersistMs = nowMs;
346
+ // Canonical mid-call activity lives in active state.json. Queue through the
347
+ // runner's serialized state writer; per-node records remain terminal/attempt
348
+ // evidence so a late best-effort write cannot recreate an archived run dir.
349
+ void input.persistState().catch(() => { });
350
+ };
313
351
  let terminalResult;
314
352
  let previousFailureCategory;
315
353
  let previousProtocolReason;
316
354
  for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
317
355
  const attemptStartedAt = new Date().toISOString();
318
356
  const attemptStarted = Date.now();
357
+ node.currentAttempt = attemptNumber;
358
+ node.livenessStatus = "active";
319
359
  let result;
320
360
  try {
321
361
  validateRepairArtifactGateBeforeShell({
@@ -328,6 +368,11 @@ export async function executeDagNode(input) {
328
368
  cwd,
329
369
  model,
330
370
  prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason),
371
+ attempt: attemptNumber,
372
+ reportActivity,
373
+ timeoutMs: livenessPolicy.absoluteMaxWallClockMs,
374
+ stallTimeoutMs: livenessPolicy.stallProbeMs,
375
+ abortGraceMs: livenessPolicy.abortGraceMs,
331
376
  });
332
377
  }
333
378
  catch (error) {
@@ -405,6 +450,10 @@ export async function executeDagNode(input) {
405
450
  ? result.parsedEvents
406
451
  : sumAttemptMetric(attempts, (attempt) => attempt.parsedEvents);
407
452
  node.lastActivityAt = attemptFinishedAt;
453
+ if (result.failureCategory === "termination-unconfirmed") {
454
+ node.needsAttentionReason = "attempt-termination-unconfirmed";
455
+ node.livenessStatus = "needs-attention";
456
+ }
408
457
  if (retryPolicy !== undefined) {
409
458
  state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
410
459
  await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
@@ -8,6 +8,7 @@ import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/r
8
8
  import { assertFrozenBudget, initRunBudgetLedger, preflightBudgetOrBreach, recordFinishedNodeBudget, writeBudgetLedgerArtifacts, } from "./budget-enforcement.js";
9
9
  import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
10
10
  import { moveToCompletedRunDir, moveToPausedRunDir, prepareActiveRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
11
+ import { evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
11
12
  import { createDagNodeExecutor } from "./executor-registry.js";
12
13
  import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
13
14
  import { assertValidDagSpec } from "./validate.js";
@@ -406,12 +407,31 @@ async function executeDagCheckpoint(input) {
406
407
  stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, state, options));
407
408
  await stateWriteQueue;
408
409
  };
410
+ const runnerLivenessPolicy = resolveLivenessPolicy(spec.defaults?.livenessPolicy);
409
411
  const heartbeatTimer = setInterval(() => {
410
412
  if (!state.runner)
411
413
  return;
414
+ // Runner lease only — never counts as meaningful Pi progress.
412
415
  state.runner.heartbeatAt = new Date().toISOString();
416
+ const tasksByIdForPolicy = new Map(spec.tasks.map((task) => [task.id, task]));
417
+ for (const node of Object.values(state.nodes)) {
418
+ if (node.status !== "RUNNING")
419
+ continue;
420
+ // Lease clock is separate from meaningful activity.
421
+ node.lastLeaseAt = state.runner.heartbeatAt;
422
+ const task = tasksByIdForPolicy.get(node.id);
423
+ const policy = resolveLivenessPolicy(spec.defaults?.livenessPolicy, task?.livenessPolicy);
424
+ const evaluation = evaluateNodeLiveness({
425
+ node,
426
+ policy,
427
+ runnerHeartbeatAt: state.runner.heartbeatAt,
428
+ });
429
+ node.livenessStatus = evaluation.status;
430
+ // Intentionally do NOT refresh lastActivityAt / lastMeaningfulProgressAt
431
+ // from the runner lease timer.
432
+ }
413
433
  void persistState().catch(() => { });
414
- }, 15_000);
434
+ }, runnerLivenessPolicy.heartbeatIntervalMs);
415
435
  heartbeatTimer.unref();
416
436
  try {
417
437
  const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
@@ -1,7 +1,9 @@
1
1
  import { z } from "zod";
2
+ import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
2
3
  import { campaignBudgetSchema, } from "../../application/evaluation/budget.js";
3
4
  import { assertDagPromptSourceRule } from "./prompt-source.js";
4
5
  import { dagRetryPolicySchema } from "./retry-policy.js";
6
+ import { dagLivenessPolicySchema, } from "./liveness-policy.js";
5
7
  import { dagOutputProtocolSchema } from "./output-protocol.js";
6
8
  export const dagComplexitySchema = z.enum(["HIGH", "MED", "LOW"]);
7
9
  export const dagNodeExecutorSchema = z.enum(["pi", "shell", "static"]);
@@ -24,6 +26,7 @@ export const dagShellVerifyEvidenceSchema = z.object({
24
26
  commandSource: z.enum(["adapter", "inline"]),
25
27
  commandCount: z.number().int().nonnegative(),
26
28
  commandLabels: z.array(z.string()).default([]),
29
+ commandTexts: z.array(z.string()).default([]),
27
30
  finalFullRequired: z.boolean().optional(),
28
31
  });
29
32
  export const dagRepairArtifactGateSchema = z.object({
@@ -127,22 +130,80 @@ export const dagFrontendPrewriteGateSchema = z.object({
127
130
  allowedMockStrategies: z.array(z.enum(["native", "browser-intercept", "request-adapter", "not-needed"])).min(1),
128
131
  artifactName: z.string().regex(/^[a-z0-9][a-z0-9._-]*\.json$/),
129
132
  outputDir: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
133
+ requireSourceFreshness: z.literal(true),
134
+ implementationWriteSet: z.array(z.string().min(1)).min(1).optional(),
130
135
  openspecCandidatePaths: z
131
- .array(z.string().regex(/^openspec\/.+/, "openspec candidate must be repo-relative"))
136
+ .array(z.string().refine((candidate) => !candidate.includes("\\") &&
137
+ isOpenspecSpecFilePath(candidate), "openspec candidate must be a repo-relative supported file under openspec/schemas/ or openspec/project-specs/"))
132
138
  .default([]),
133
139
  });
140
+ export const dagFrontendLintBaselineSchema = z
141
+ .object({
142
+ schemaVersion: z.literal(1),
143
+ lintCommands: z.array(z.string().min(1)).min(1),
144
+ lintEvidence: dagShellVerifyEvidenceSchema,
145
+ })
146
+ .strict();
134
147
  export const dagFrontendVerificationBundleSchema = z.object({
135
148
  schemaVersion: z.literal(1),
136
149
  mockCommands: z.array(z.string()).default([]),
150
+ lintCommands: z.array(z.string().min(1)).optional(),
137
151
  staticCommands: z.array(z.string()).min(1),
138
152
  behaviorCommands: z.array(z.string()).min(1),
139
153
  mockEvidence: dagShellVerifyEvidenceSchema.optional(),
154
+ lintEvidence: dagShellVerifyEvidenceSchema.optional(),
140
155
  staticEvidence: dagShellVerifyEvidenceSchema,
141
156
  behaviorEvidence: dagShellVerifyEvidenceSchema,
157
+ lintBaselineNodeId: dagFrontendNodeIdSchema.optional(),
158
+ writerNodeIds: z.array(dagFrontendNodeIdSchema).optional(),
142
159
  mode: z.enum(["initial", "repair"]),
160
+ }).superRefine((bundle, context) => {
161
+ const groups = [
162
+ ["mock", bundle.mockCommands, bundle.mockEvidence],
163
+ ["lint", bundle.lintCommands ?? [], bundle.lintEvidence],
164
+ ["static", bundle.staticCommands, bundle.staticEvidence],
165
+ ["behavior", bundle.behaviorCommands, bundle.behaviorEvidence],
166
+ ];
167
+ for (const [name, commands, evidence] of groups) {
168
+ if ((name === "mock" || name === "lint") && commands.length === 0 && !evidence)
169
+ continue;
170
+ if (!evidence) {
171
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`], message: `${name} evidence is required when commands are configured` });
172
+ continue;
173
+ }
174
+ if (evidence.commandCount !== commands.length) {
175
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandCount"], message: `${name} commandCount must match commands` });
176
+ }
177
+ if (evidence.commandLabels.length !== commands.length) {
178
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandLabels"], message: `${name} commandLabels must match commands` });
179
+ }
180
+ if (evidence.commandTexts.length !== commands.length || evidence.commandTexts.some((command, index) => command !== commands[index])) {
181
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandTexts"], message: `${name} commandTexts must exactly match commands` });
182
+ }
183
+ if (new Set(evidence.commandLabels).size !== evidence.commandLabels.length || evidence.commandLabels.some((label) => !label.trim())) {
184
+ context.addIssue({ code: z.ZodIssueCode.custom, path: [`${name}Evidence`, "commandLabels"], message: `${name} commandLabels must be non-empty and unique` });
185
+ }
186
+ }
187
+ if ((bundle.lintCommands?.length ?? 0) > 0) {
188
+ if (!bundle.lintBaselineNodeId) {
189
+ context.addIssue({
190
+ code: z.ZodIssueCode.custom,
191
+ path: ["lintBaselineNodeId"],
192
+ message: "lintBaselineNodeId is required when lintCommands are present",
193
+ });
194
+ }
195
+ if (!bundle.writerNodeIds || bundle.writerNodeIds.length === 0) {
196
+ context.addIssue({
197
+ code: z.ZodIssueCode.custom,
198
+ path: ["writerNodeIds"],
199
+ message: "writerNodeIds are required when lintCommands are present",
200
+ });
201
+ }
202
+ }
143
203
  });
144
204
  export const dagFrontendReviewContextSchema = z.object({
145
205
  schemaVersion: z.literal(1),
206
+ requireBaseline: z.literal(true),
146
207
  });
147
208
  export const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
148
209
  export const dagVersionSchema = z
@@ -194,6 +255,8 @@ export const dagDefaultsSchema = z
194
255
  contextPolicyId: contextPolicyIdSchema.optional(),
195
256
  skills: z.array(z.string()).optional(),
196
257
  writePolicy: dagWritePolicySchema.optional(),
258
+ /** Adaptive liveness thresholds for Pi node supervision. */
259
+ livenessPolicy: dagLivenessPolicySchema,
197
260
  })
198
261
  .optional();
199
262
  export const dagNodeStatusSchema = z.enum([
@@ -223,6 +286,7 @@ export const dagShellConfigSchema = z.object({
223
286
  requirementCoverageGate: dagRequirementCoverageGateSchema.optional(),
224
287
  jsonArtifactGate: dagJsonArtifactGateSchema.optional(),
225
288
  frontendPrewriteGate: dagFrontendPrewriteGateSchema.optional(),
289
+ frontendLintBaseline: dagFrontendLintBaselineSchema.optional(),
226
290
  frontendVerificationBundle: dagFrontendVerificationBundleSchema.optional(),
227
291
  frontendReviewContext: dagFrontendReviewContextSchema.optional(),
228
292
  backendTestPipeline: dagBackendTestPipelineSchema.optional(),
@@ -400,6 +464,8 @@ export const dagTaskSchema = z.object({
400
464
  forbiddenPaths: z.array(z.string()).optional().default([]),
401
465
  decisionGate: dagDecisionGateSchema.optional(),
402
466
  retryPolicy: dagRetryPolicySchema.optional(),
467
+ /** Optional per-node adaptive liveness override (merged over defaults). */
468
+ livenessPolicy: dagLivenessPolicySchema,
403
469
  dynamicExpansion: dagDynamicExpansionSchema.optional(),
404
470
  dynamicReduction: dagDynamicReductionSchema.optional(),
405
471
  dynamicCondition: dagDynamicConditionSchema.optional(),
package/docs/README.md CHANGED
@@ -52,17 +52,14 @@
52
52
  完整索引与归档策略见 `design/README.md`。下列为当前高频活入口:
53
53
 
54
54
  - `design/backend-test-workflow.md` — backend-test Markdown-first 8 节点流程、单次 pytest、run-owned artifacts 与 L-5
55
- - `design/frontend-mock-data-workflow.md` — 前端 Mock 节点、触发条件、规范证据与失败路由
56
- - `design/frontend-implementation-workflow.md` — 前端实现 / 评审 / 验证工作流
55
+ - `design/frontend-implementation-workflow.md` — 前端实现 / 评审 / 验证工作流(含 Mock/API 策略、capability seed、verification bundle)
57
56
  - `design/dag-source-binding-and-recovery.md` — 新生成 DAG 的任务源绑定与中断恢复
58
57
  - `design/agent-worker-fullstack-workflow-integration.md` — workflow routing、Task Outcome、artifact-aware Ready、`fullstack-v1` 与 Verification Bundle
59
58
  - `design/fullstack-end-to-end-delivery-optimization-roadmap.md` — 全栈端到端优化收敛路线图(release train / Delivery / Final Verification)
60
- - `design/2026-07-22-console-observe-unified-operator-surface.md` — Observe 深度融合进 Console(Wave 1–3 已实现,目标 0.18.0
61
- - `design/2026-07-23-operator-task-rerun-and-node-retry.md` — Operator 自动节点重试、从节点重跑、standalone 完整任务重跑与 Worker Task 重排队(**Wave 1–2 已落地**;Wave 3–4 仍分波;优先解决 LLM/provider 不稳定;Inspect 只读 / Operate mutation)
62
- - `design/local-operator-console-from-pi-web.md` — Operator Console 设计输入;MVP 已随 `0.17.0`–`0.17.2` 发布;统一 surface 见上条
59
+ - `design/local-operator-console-from-pi-web.md` — Operator Console 设计输入;MVP 已随 `0.17.0`–`0.17.2` 发布;统一 surface 设计已归档(见下)
63
60
  - `design/taskspec-to-loop-agent-mapping.md` — TaskSpec → loop-agent task 兼容契约(文档镜像;runtime 真源在代码)
64
61
 
65
- 已实现且仅作历史说明的设计见 `design/archive/`(例如 `design/archive/2026-07-14-loop-agent-self-update-notifier.md`)。
62
+ 已实现且仅作历史说明的设计见 `design/archive/`(例如 `design/archive/2026-07-14-loop-agent-self-update-notifier.md`、`design/archive/2026-07-22-console-observe-unified-operator-surface.md`、`design/archive/2026-07-23-operator-task-rerun-and-node-retry.md`、`design/archive/frontend-mock-data-workflow.md`)。
66
63
 
67
64
  ## 进行中 / 近期完成
68
65
 
@@ -76,6 +73,8 @@
76
73
 
77
74
  完整 completed 列表与主题速览见 `exec-plans/completed/README.md`。近期高频归档:
78
75
 
76
+ - `exec-plans/completed/2026-07-24-backend-test-request-response-logs-report-format.md` — pytest 请求/响应脱敏日志与第 7/8 节点报告版式;真实 `my-webapp` Campaign R04 8/8、17 passed
77
+ - `exec-plans/completed/2026-07-23-backend-test-advisory-gates.md` — 第 4/6 节点改为非阻断 advisory:用例只强制前置条件/步骤/预期,第 6 节点只扫描 Markdown 映射脚本;真实 `my-webapp` Campaign R02 8/8、10 passed
79
78
  - `exec-plans/completed/2026-07-23-backend-test-human-readable-artifacts.md` — 中文 README/用例卡片、class-based pytest traceability 与逐条 self-contained HTML 报告;真实 `my-webapp` Campaign Round 04 8/8、16 passed
80
79
  - `exec-plans/completed/2026-07-22-backend-markdown-gate-fix.md` — backend-test Markdown gate、traceability、中文生成与真实 Campaign 收口
81
80
  - `exec-plans/completed/2026-07-22-backend-test-markdown-first-8-node.md` — backend-test Markdown-first 8 节点
@@ -101,6 +101,17 @@ snapshot 与 controller identity 是两个不同冻结层,详见 `runtime-boun
101
101
 
102
102
  decision envelope 中的 **model verdict**(`decision` / `riskLevel` 等解析自文本)是 `advisoryOnly: true` 派生视图,**不**是完成权威(`facts-and-state.md`)。
103
103
 
104
+ ## 自适应 liveness(节点活动 vs runner lease)
105
+
106
+ 实现见 `src/workflows/dag/liveness-policy.ts` 与设计文档 `docs/design/dag-adaptive-liveness-and-supervision.md`。
107
+
108
+ - **runner lease**(`runner.heartbeatAt`,约 15s)只证明 runner 进程事件循环;**不是** Pi meaningful progress。
109
+ - **真实活动**:SDK provider/tool event 与 CLI stdout|stderr 刷新 provider/tool/output;attempt-fenced,旧 attempt 晚到事件 no-op。SDK noisy delta 只续租 transport watchdog,不刷新 meaningful progress。
110
+ - **节点投影** `livenessStatus`:`active | quiet | suspected-stall | probing | needs-attention`。
111
+ - **absolute max** 默认 4h wall clock(不可被空心跳续期);历史 30 分钟不再是活跃 Pi 节点硬杀边界。
112
+ - `assessDagRunLiveness` 在 lease 新鲜但无 meaningful activity 时返回 `node-quiet` / `suspected-stall` / `needs-attention`;doctor codes 含 `node-activity-quiet`、`node-activity-suspected-stall`、`node-needs-attention`。
113
+ - Observe/read-model 仅为 derived/advisory。
114
+
104
115
  ## 生命周期:active / paused / completed
105
116
 
106
117
  `src/workflows/dag/lifecycle.ts` 定义三个目录:
@@ -23,6 +23,7 @@
23
23
  - **Task Pool 是 Worker 专用可选根**:只有使用 `agent-worker` 产品线时才存在;唯一根 `.harness/task-pool/`。
24
24
  - **Task Pool state identity(ADR 0004)**:canonical 键为 `{ featureId, taskId }`(`TaskPoolTaskRef`),不是裸 `taskId`。
25
25
  - **Observe snapshot 不是事实源**:`buildGlobalSnapshot` 投影失败返回安全错误摘要而非全零健康,不改变执行成败。
26
+ - **节点 liveness 投影是保守 canonical 字段**:`lastProviderActivityAt` / `lastToolActivityAt` / `lastOutputActivityAt` / `lastMeaningfulProgressAt` / `livenessStatus` 由 runner/executor 写入 active run facts;runner lease heartbeat **不得**刷新 meaningful progress。Observe badge(quiet/suspected-stall/needs-attention/needs-reconcile)仍是 derived。
26
27
 
27
28
  ### Task Pool state 布局与迁移
28
29