@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.
- package/CHANGELOG.md +72 -0
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/commands/init.js +104 -0
- package/dist/executors/dag-pi-executor.js +26 -0
- package/dist/executors/pi-executor.js +111 -36
- package/dist/executors/pi-sdk-executor.js +105 -29
- package/dist/executors/shell-executor.js +215 -29
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/worker/loop-agent/loop-agent-client.js +43 -9
- package/dist/worker/observability/read-model.js +28 -2
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/constants.js +5 -0
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/format-pool.js +22 -3
- package/dist/worker/observe/static/styles.css +32 -3
- package/dist/worker/observe/static/views/dag-inspector.js +2 -2
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/worker/run-task/run-task.js +16 -6
- package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
- package/dist/workflows/dag/backend-test-result-contract.js +10 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +370 -79
- package/dist/workflows/dag/lifecycle.js +60 -4
- package/dist/workflows/dag/liveness-policy.js +250 -0
- package/dist/workflows/dag/node-execution.js +49 -0
- package/dist/workflows/dag/runner.js +21 -1
- package/dist/workflows/dag/types.js +67 -1
- package/docs/README.md +5 -6
- package/docs/architecture/dag-execution.md +11 -0
- package/docs/architecture/facts-and-state.md +1 -0
- package/docs/architecture/worker-and-feature.md +10 -0
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
- package/docs/templates/backend-test-dag.json +15 -15
- package/docs/templates/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
- package/skills/loop-agent/references/command-reference.md +2 -0
- 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
|
-
|
|
191
|
-
|
|
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
|
-
&& [
|
|
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
|
|
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
|
-
},
|
|
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().
|
|
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-
|
|
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/
|
|
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
|
|