@tea-agent/loop-agent 0.31.0 → 0.32.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/AGENTS.md +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +1 -1
- package/dist/executors/dag-pi-executor.js +69 -2
- package/dist/executors/pi-sdk-executor.js +61 -0
- package/dist/executors/shell-executor.js +136 -79
- package/dist/shared/operator/capabilities.js +22 -1
- package/dist/shared/operator/command-lifecycle.js +94 -0
- package/dist/shared/operator/index.js +1 -0
- package/dist/worker/cli.js +10 -24
- package/dist/worker/console/doctor.js +11 -4
- package/dist/worker/console/observe-health-match.js +18 -15
- package/dist/worker/console/operator-actions.js +2 -1
- package/dist/worker/feature/acceptance-policy.js +227 -0
- package/dist/worker/feature/decision-loader.js +1 -1
- package/dist/worker/feature/next-action.js +56 -6
- package/dist/worker/feature/profile-schema.js +3 -0
- package/dist/worker/feature/reducer.js +1 -0
- package/dist/worker/feature/review.js +72 -8
- package/dist/worker/feature/scaffold.js +14 -0
- package/dist/worker/loop-agent/controller-protocol.js +143 -0
- package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
- package/dist/worker/materialize/harness-task-lineage.js +220 -0
- package/dist/worker/materialize/harness-task-materializer.js +350 -80
- package/dist/worker/observability/progress-composite.js +1 -0
- package/dist/worker/observability/read-model.js +66 -19
- package/dist/worker/pool/attempt-identity.js +41 -0
- package/dist/worker/pool/attempt-lease.js +184 -0
- package/dist/worker/pool/attempt-transition.js +210 -0
- package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
- package/dist/worker/pool/begin-attempt.js +35 -0
- package/dist/worker/pool/failure-routing.js +49 -0
- package/dist/worker/pool/recovery-decision.js +163 -0
- package/dist/worker/pool/run-owner-store.js +126 -0
- package/dist/worker/pool/run-store.js +32 -46
- package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
- package/dist/worker/pool/state-projection.js +57 -0
- package/dist/worker/run-task/run-task.js +31 -4
- package/dist/worker/runner/run-ready.js +64 -14
- package/dist/worker/runner/single-task-attempt.js +42 -13
- package/dist/worker/task-graph/acceptance-schema.js +3 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +162 -7
- package/dist/workflows/dag/backend-test-result-contract.js +105 -67
- package/dist/workflows/dag/backend-test-scenario-param.js +92 -30
- package/dist/workflows/dag/backend-test-writer-completeness.js +55 -0
- package/dist/workflows/dag/init-hybrid.js +44 -47
- package/dist/workflows/dag/rerun-task.js +86 -0
- package/docs/README.md +3 -1
- package/docs/architecture/evolution.md +1 -1
- package/docs/operations/README.md +1 -0
- package/docs/templates/README.md +1 -0
- package/docs/templates/agent-worker-production-readiness-checklist.md +45 -0
- package/docs/templates/backend-test-dag.json +40 -60
- package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
- package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
- package/docs/templates/init-managed-agents.md +1 -1
- package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
- package/package.json +1 -1
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +1 -1
- package/skills/loop-agent/references/command-reference.md +2 -2
- package/skills/loop-agent/references/harness-policy.md +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { acceptancePolicySchema } from "./acceptance-policy.js";
|
|
2
3
|
/**
|
|
3
4
|
* Feature-level profile for a Feature Packet (M4 `fullstack-v1`).
|
|
4
5
|
*
|
|
@@ -36,6 +37,8 @@ export const featureProfileSchema = z
|
|
|
36
37
|
})
|
|
37
38
|
.strict()
|
|
38
39
|
.optional(),
|
|
40
|
+
/** Phase 6 ADR 0007 acceptance / verify-final / delivery policy. */
|
|
41
|
+
acceptance_policy: acceptancePolicySchema.optional(),
|
|
39
42
|
})
|
|
40
43
|
.strict();
|
|
41
44
|
/** Whether a parsed profile activates the fullstack-v1 gate set. */
|
|
@@ -81,6 +81,7 @@ export function reduceFeature(input) {
|
|
|
81
81
|
status: task.status ?? (isReady(task.taskId, input) ? "Ready" : "Draft"),
|
|
82
82
|
})),
|
|
83
83
|
acceptanceCoverage: input.acceptance,
|
|
84
|
+
acceptanceGaps: input.acceptanceGaps ?? [],
|
|
84
85
|
nextAction: projectNextAction(input, status),
|
|
85
86
|
alternativeActions: [],
|
|
86
87
|
evidence: {
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { getRunsJsonlPath, getTaskPoolRoot, readFeatureTaskPoolStates, readJsonlFile } from "../pool/run-store.js";
|
|
7
|
+
import { readRunOwner } from "../pool/run-owner-store.js";
|
|
7
8
|
import { artifactMatchesEvidenceToken, isShellVerificationToken, } from "../outcomes/evidence-tokens.js";
|
|
8
9
|
import { readVerifiedOutcome } from "../outcomes/store.js";
|
|
9
10
|
import { acceptanceSpecSchema } from "../task-graph/acceptance-schema.js";
|
|
@@ -14,6 +15,7 @@ import { followUpActionCardSchema, followUpApprovalSchema, followUpDraftSchema,
|
|
|
14
15
|
import { readFollowUpIndex, resolveRepoFile, sha256File as sha256FollowUpFile } from "../follow-up/store.js";
|
|
15
16
|
import { projectReadyPlan } from "./ready-plan-projection.js";
|
|
16
17
|
import { reduceFeature } from "./reducer.js";
|
|
18
|
+
import { buildAcceptanceGaps, indexRawAcceptanceItems, parseAcceptancePolicy, } from "./acceptance-policy.js";
|
|
17
19
|
export async function reviewFeature(input) {
|
|
18
20
|
const projection = await loadFeatureProjection(input);
|
|
19
21
|
return reduceFeature(projection);
|
|
@@ -61,14 +63,27 @@ async function loadFeatureProjection(input) {
|
|
|
61
63
|
});
|
|
62
64
|
}
|
|
63
65
|
let acceptance = acceptanceSpecSchema.safeParse({});
|
|
66
|
+
let acceptanceRaw = {};
|
|
64
67
|
let graph = taskGraphSpecSchema.safeParse({});
|
|
68
|
+
let featurePacketRaw = {};
|
|
65
69
|
try {
|
|
66
|
-
|
|
70
|
+
acceptanceRaw = YAML.parse(await readFile(path.join(featureDir, "acceptance.yaml"), "utf-8"));
|
|
71
|
+
acceptance = acceptanceSpecSchema.safeParse(acceptanceRaw);
|
|
67
72
|
graph = taskGraphSpecSchema.safeParse(YAML.parse(await readFile(path.join(featureDir, "tasks", "task-graph.yaml"), "utf-8")));
|
|
68
73
|
}
|
|
69
74
|
catch (error) {
|
|
70
75
|
projectionWarnings.push(`cannot read Feature Packet: ${message(error)}`);
|
|
71
76
|
}
|
|
77
|
+
try {
|
|
78
|
+
const featureYamlPath = path.join(featureDir, "feature.yaml");
|
|
79
|
+
if (await exists(featureYamlPath)) {
|
|
80
|
+
featurePacketRaw = YAML.parse(await readFile(featureYamlPath, "utf-8"));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
projectionWarnings.push(`cannot read feature.yaml: ${message(error)}`);
|
|
85
|
+
}
|
|
86
|
+
const acceptancePolicy = parseAcceptancePolicy(featurePacketRaw);
|
|
72
87
|
if (!acceptance.success)
|
|
73
88
|
projectionWarnings.push("acceptance.yaml is invalid");
|
|
74
89
|
if (!graph.success)
|
|
@@ -186,18 +201,49 @@ async function loadFeatureProjection(input) {
|
|
|
186
201
|
catch (error) {
|
|
187
202
|
projectionWarnings.push(`follow-up index is corrupt: ${message(error)}`);
|
|
188
203
|
}
|
|
204
|
+
const ownerByWorkerRunId = new Map();
|
|
205
|
+
await Promise.all(Object.values(states).map(async (state) => {
|
|
206
|
+
if (!state.workerRunId)
|
|
207
|
+
return;
|
|
208
|
+
const owner = await readRunOwner(repoRoot, state.workerRunId).catch(() => undefined);
|
|
209
|
+
if (owner)
|
|
210
|
+
ownerByWorkerRunId.set(state.workerRunId, owner);
|
|
211
|
+
}));
|
|
189
212
|
const tasks = graphNodes.map((node) => {
|
|
190
213
|
const rawState = states[node.id];
|
|
191
214
|
const stateRun = rawState?.workerRunId
|
|
192
215
|
? runs.find((run) => run.workerRunId === rawState.workerRunId)
|
|
193
216
|
: undefined;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
const live = rawState?.status === "Running" ||
|
|
218
|
+
rawState?.status === "Queued" ||
|
|
219
|
+
rawState?.status === "AgentCompleted" ||
|
|
220
|
+
rawState?.status === "VerificationRunning" ||
|
|
221
|
+
rawState?.status === "HumanReview";
|
|
222
|
+
const owner = rawState?.workerRunId
|
|
223
|
+
? ownerByWorkerRunId.get(rawState.workerRunId)
|
|
224
|
+
: undefined;
|
|
225
|
+
let state = rawState;
|
|
226
|
+
if (rawState?.workerRunId) {
|
|
227
|
+
if (stateRun && stateRun.featureId !== validation.featureId) {
|
|
228
|
+
state = undefined;
|
|
229
|
+
}
|
|
230
|
+
else if (owner && owner.featureId !== validation.featureId) {
|
|
231
|
+
state = undefined;
|
|
232
|
+
}
|
|
233
|
+
else if (!stateRun && !live) {
|
|
234
|
+
state = undefined;
|
|
235
|
+
}
|
|
236
|
+
else if (!stateRun && live) {
|
|
237
|
+
const stateFeatureOk = !rawState.featureId || rawState.featureId === validation.featureId;
|
|
238
|
+
const ownerOk = !owner || owner.featureId === validation.featureId;
|
|
239
|
+
state = stateFeatureOk && ownerOk ? rawState : undefined;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
state = rawState;
|
|
243
|
+
}
|
|
244
|
+
if (rawState && !state) {
|
|
245
|
+
projectionWarnings.push(`Task Pool state for ${node.id} has missing or cross-Feature run ownership`);
|
|
246
|
+
}
|
|
201
247
|
}
|
|
202
248
|
const latest = [...runs].reverse().find((run) => run.featureId === validation.featureId && run.taskId === node.id);
|
|
203
249
|
return {
|
|
@@ -241,6 +287,17 @@ async function loadFeatureProjection(input) {
|
|
|
241
287
|
};
|
|
242
288
|
})
|
|
243
289
|
: [];
|
|
290
|
+
const acceptanceGaps = acceptance.success
|
|
291
|
+
? buildAcceptanceGaps({
|
|
292
|
+
policy: acceptancePolicy,
|
|
293
|
+
acceptanceItems: acceptance.data.acceptance,
|
|
294
|
+
rawByAcId: indexRawAcceptanceItems(acceptanceRaw),
|
|
295
|
+
coverage,
|
|
296
|
+
tasks,
|
|
297
|
+
featureDir,
|
|
298
|
+
repoRoot,
|
|
299
|
+
})
|
|
300
|
+
: [];
|
|
244
301
|
const deliveryPath = path.join(deliveryRoot, "delivery-manifest.json");
|
|
245
302
|
const morningReportPath = path.join(getTaskPoolRoot(repoRoot), "reports", "morning-report.md");
|
|
246
303
|
const observeSnapshotPath = path.join(getTaskPoolRoot(repoRoot), "observability", "snapshot.json");
|
|
@@ -321,6 +378,13 @@ async function loadFeatureProjection(input) {
|
|
|
321
378
|
validationErrors: validation.errors.map((error) => `${error.code}: ${error.message}`),
|
|
322
379
|
tasks,
|
|
323
380
|
acceptance: coverage,
|
|
381
|
+
acceptanceGaps,
|
|
382
|
+
acceptancePolicy: {
|
|
383
|
+
authority: acceptancePolicy.authority ?? "feature-verify-final",
|
|
384
|
+
coverageWriter: acceptancePolicy.coverage_writer ?? "feature-verify-final",
|
|
385
|
+
verifyFinalCommand: acceptancePolicy.final_verification?.command ??
|
|
386
|
+
"agent-worker feature verify-final",
|
|
387
|
+
},
|
|
324
388
|
...(closeout ? { closeout } : {}),
|
|
325
389
|
...(delivery ? { delivery } : {}),
|
|
326
390
|
pendingFollowUps,
|
|
@@ -228,6 +228,16 @@ export function renderWritePlan(input, repoRoot) {
|
|
|
228
228
|
schema_version: 1,
|
|
229
229
|
feature_id: featureId,
|
|
230
230
|
profile: "generic",
|
|
231
|
+
acceptance_policy: {
|
|
232
|
+
schema_version: 1,
|
|
233
|
+
authority: "feature-verify-final",
|
|
234
|
+
independent_qa_required: true,
|
|
235
|
+
coverage_writer: "feature-verify-final",
|
|
236
|
+
final_verification: {
|
|
237
|
+
command: "agent-worker feature verify-final",
|
|
238
|
+
},
|
|
239
|
+
delivery_sequence: ["verify-final", "delivery", "closeout"],
|
|
240
|
+
},
|
|
231
241
|
}),
|
|
232
242
|
});
|
|
233
243
|
files.push({
|
|
@@ -325,6 +335,8 @@ function buildAcceptance(input) {
|
|
|
325
335
|
then: item.then,
|
|
326
336
|
verification: {
|
|
327
337
|
expected_task_refs: taskRefs,
|
|
338
|
+
authority: "feature-verify-final",
|
|
339
|
+
required_evidence_kinds: ["canonical-qa-evidence"],
|
|
328
340
|
},
|
|
329
341
|
};
|
|
330
342
|
});
|
|
@@ -341,6 +353,8 @@ function buildAcceptance(input) {
|
|
|
341
353
|
then: "验收目标达成且 verify.commands 通过",
|
|
342
354
|
verification: {
|
|
343
355
|
expected_task_refs: taskRefs,
|
|
356
|
+
authority: "feature-verify-final",
|
|
357
|
+
required_evidence_kinds: ["canonical-qa-evidence"],
|
|
344
358
|
},
|
|
345
359
|
},
|
|
346
360
|
];
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker ↔ Controller protocol envelope (Phase 5).
|
|
3
|
+
* Identity proves who; this envelope proves semantic compatibility.
|
|
4
|
+
*/
|
|
5
|
+
export const WORKER_ADAPTER_PROTOCOL_RANGE = ">=1 <2";
|
|
6
|
+
export const WORKER_CONTROLLER_REQUIREMENTS_V1 = {
|
|
7
|
+
schemaVersion: 1,
|
|
8
|
+
protocolRange: WORKER_ADAPTER_PROTOCOL_RANGE,
|
|
9
|
+
requiredCapabilities: [
|
|
10
|
+
"task-advance-managed-contract-v2",
|
|
11
|
+
"worker-association-v1",
|
|
12
|
+
"events-jsonl-v1",
|
|
13
|
+
],
|
|
14
|
+
optionalCapabilities: [
|
|
15
|
+
"document-index-closure-v1",
|
|
16
|
+
"worker-attempt-owner-v1",
|
|
17
|
+
"worker-attempt-revision-v1",
|
|
18
|
+
"recovery-decision-v2",
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
export function evaluateControllerCompatibility(input) {
|
|
22
|
+
const requirements = input.requirements ?? WORKER_CONTROLLER_REQUIREMENTS_V1;
|
|
23
|
+
const envelope = input.envelope;
|
|
24
|
+
if (!envelope || envelope.schemaVersion !== 1) {
|
|
25
|
+
return {
|
|
26
|
+
status: "invalid-envelope",
|
|
27
|
+
evidence: { reason: "missing or invalid ControllerProtocolEnvelopeV1" },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isFinite(envelope.controllerProtocolVersion)) {
|
|
31
|
+
return {
|
|
32
|
+
status: "invalid-envelope",
|
|
33
|
+
evidence: { reason: "controllerProtocolVersion missing" },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (!protocolRangeIncludes(requirements.protocolRange, envelope.controllerProtocolVersion)) {
|
|
37
|
+
return {
|
|
38
|
+
status: "unsupported",
|
|
39
|
+
missing: [`protocol ${requirements.protocolRange}`],
|
|
40
|
+
evidence: {
|
|
41
|
+
protocolVersion: envelope.controllerProtocolVersion,
|
|
42
|
+
availableCapabilities: envelope.capabilities,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const available = new Set(envelope.capabilities);
|
|
47
|
+
const missing = requirements.requiredCapabilities.filter((cap) => !available.has(cap));
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
return {
|
|
50
|
+
status: "unsupported",
|
|
51
|
+
missing,
|
|
52
|
+
evidence: {
|
|
53
|
+
protocolVersion: envelope.controllerProtocolVersion,
|
|
54
|
+
availableCapabilities: envelope.capabilities,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const missingOptional = requirements.optionalCapabilities.filter((cap) => !available.has(cap));
|
|
59
|
+
if (missingOptional.length > 0) {
|
|
60
|
+
return {
|
|
61
|
+
status: "upgrade-recommended",
|
|
62
|
+
evidence: {
|
|
63
|
+
matchedCapabilities: requirements.requiredCapabilities,
|
|
64
|
+
missingOptional,
|
|
65
|
+
protocolVersion: envelope.controllerProtocolVersion,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
status: "compatible",
|
|
71
|
+
evidence: {
|
|
72
|
+
matchedCapabilities: requirements.requiredCapabilities,
|
|
73
|
+
protocolVersion: envelope.controllerProtocolVersion,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function protocolRangeIncludes(range, version) {
|
|
78
|
+
// Minimal parser for ">=N <M" used by Worker requirements.
|
|
79
|
+
const match = range.match(/^>=\s*(\d+)\s*<\s*(\d+)$/);
|
|
80
|
+
if (!match)
|
|
81
|
+
return false;
|
|
82
|
+
const min = Number(match[1]);
|
|
83
|
+
const max = Number(match[2]);
|
|
84
|
+
return version >= min && version < max;
|
|
85
|
+
}
|
|
86
|
+
export function assertCompatibleBeforeWrite(result) {
|
|
87
|
+
if (result.status === "compatible" || result.status === "upgrade-recommended") {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const detail = result.status === "unsupported"
|
|
91
|
+
? `missing=${result.missing.join(",")}`
|
|
92
|
+
: result.evidence.reason;
|
|
93
|
+
throw new Error(`controller incompatible before canonical write: ${result.status} (${detail})`);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Envelope advertised by this package's published controller surface.
|
|
97
|
+
* Identity pin still proves "who"; this proves semantic capabilities.
|
|
98
|
+
*/
|
|
99
|
+
export function buildPublishedControllerProtocolEnvelope(packageVersion) {
|
|
100
|
+
return {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
controllerProtocolVersion: 1,
|
|
103
|
+
workerAdapterProtocolRange: WORKER_ADAPTER_PROTOCOL_RANGE,
|
|
104
|
+
capabilities: [
|
|
105
|
+
...WORKER_CONTROLLER_REQUIREMENTS_V1.requiredCapabilities,
|
|
106
|
+
...WORKER_CONTROLLER_REQUIREMENTS_V1.optionalCapabilities,
|
|
107
|
+
],
|
|
108
|
+
packageVersion,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Extract protocol envelope from operator capabilities JSON when present. */
|
|
112
|
+
export function extractControllerProtocolEnvelope(capabilities) {
|
|
113
|
+
if (!capabilities || typeof capabilities !== "object")
|
|
114
|
+
return undefined;
|
|
115
|
+
const raw = capabilities.controllerProtocol;
|
|
116
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
117
|
+
return undefined;
|
|
118
|
+
const record = raw;
|
|
119
|
+
if (record.schemaVersion !== 1)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (typeof record.controllerProtocolVersion !== "number")
|
|
122
|
+
return undefined;
|
|
123
|
+
if (typeof record.workerAdapterProtocolRange !== "string")
|
|
124
|
+
return undefined;
|
|
125
|
+
if (!Array.isArray(record.capabilities))
|
|
126
|
+
return undefined;
|
|
127
|
+
if (typeof record.packageVersion !== "string")
|
|
128
|
+
return undefined;
|
|
129
|
+
const capabilitiesList = record.capabilities.filter((entry) => typeof entry === "string");
|
|
130
|
+
const deprecated = Array.isArray(record.deprecatedCapabilities)
|
|
131
|
+
? record.deprecatedCapabilities.filter((entry) => typeof entry === "string")
|
|
132
|
+
: undefined;
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
controllerProtocolVersion: record.controllerProtocolVersion,
|
|
136
|
+
workerAdapterProtocolRange: record.workerAdapterProtocolRange,
|
|
137
|
+
capabilities: capabilitiesList,
|
|
138
|
+
packageVersion: record.packageVersion,
|
|
139
|
+
...(deprecated && deprecated.length > 0
|
|
140
|
+
? { deprecatedCapabilities: deprecated }
|
|
141
|
+
: {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-local harness Task lifecycle probe.
|
|
3
|
+
* Uses workflows/DAG facts only — must not import src/application/**.
|
|
4
|
+
*/
|
|
5
|
+
import { assessDagRunLiveness, assessDagRunRecoveryEligibility, listAllDagRunEntries, readDagRunSpec, readDagRunState, } from "../../workflows/dag/lifecycle.js";
|
|
6
|
+
import { resolveWorkerRecoveryDecision } from "../pool/recovery-decision.js";
|
|
7
|
+
export async function listWorkerAssociatedDagRuns(repoRoot, harnessTaskId) {
|
|
8
|
+
const entries = await listAllDagRunEntries(repoRoot);
|
|
9
|
+
const related = [];
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
let matched = false;
|
|
12
|
+
try {
|
|
13
|
+
const state = await readDagRunState(entry.runDir);
|
|
14
|
+
if (state.workerAssociation?.taskId === harnessTaskId) {
|
|
15
|
+
matched = true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// state unreadable; may still match via binding
|
|
20
|
+
}
|
|
21
|
+
if (!matched) {
|
|
22
|
+
try {
|
|
23
|
+
const spec = await readDagRunSpec(entry.runDir);
|
|
24
|
+
if (spec.taskContractBinding?.taskId === harnessTaskId ||
|
|
25
|
+
spec.sourceBinding?.taskId === harnessTaskId) {
|
|
26
|
+
matched = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// ignore
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!matched)
|
|
34
|
+
continue;
|
|
35
|
+
related.push({
|
|
36
|
+
runId: entry.runId,
|
|
37
|
+
lifecycle: entry.lifecycle,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return related.sort((a, b) => a.runId.localeCompare(b.runId));
|
|
41
|
+
}
|
|
42
|
+
export async function detectPausedAssociatedRun(repoRoot, harnessTaskId) {
|
|
43
|
+
const runs = await listWorkerAssociatedDagRuns(repoRoot, harnessTaskId);
|
|
44
|
+
const paused = runs.find((run) => run.lifecycle === "paused");
|
|
45
|
+
return paused ? { runId: paused.runId } : undefined;
|
|
46
|
+
}
|
|
47
|
+
export async function detectRecoveryRequired(repoRoot, harnessTaskId, _revisionId) {
|
|
48
|
+
const entries = await listAllDagRunEntries(repoRoot);
|
|
49
|
+
let hasActiveOrRecoverableDag = false;
|
|
50
|
+
let dagCanResume = false;
|
|
51
|
+
let factsIncomplete = false;
|
|
52
|
+
let runFailed = false;
|
|
53
|
+
let needsAttention = false;
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
let associated = false;
|
|
56
|
+
try {
|
|
57
|
+
const state = await readDagRunState(entry.runDir);
|
|
58
|
+
if (state.workerAssociation?.taskId === harnessTaskId) {
|
|
59
|
+
associated = true;
|
|
60
|
+
}
|
|
61
|
+
if (!associated)
|
|
62
|
+
continue;
|
|
63
|
+
const liveness = assessDagRunLiveness({ state });
|
|
64
|
+
const recovery = assessDagRunRecoveryEligibility({
|
|
65
|
+
lifecycle: entry.lifecycle,
|
|
66
|
+
state,
|
|
67
|
+
liveness: liveness.status,
|
|
68
|
+
});
|
|
69
|
+
if (entry.lifecycle === "active" ||
|
|
70
|
+
entry.lifecycle === "paused" ||
|
|
71
|
+
liveness.status === "active" ||
|
|
72
|
+
liveness.status === "node-quiet") {
|
|
73
|
+
hasActiveOrRecoverableDag = true;
|
|
74
|
+
}
|
|
75
|
+
if (entry.lifecycle === "paused" || recovery.canResume) {
|
|
76
|
+
dagCanResume = true;
|
|
77
|
+
}
|
|
78
|
+
if (liveness.status === "orphaned" ||
|
|
79
|
+
liveness.status === "stale" ||
|
|
80
|
+
liveness.status === "needs-attention" ||
|
|
81
|
+
liveness.status === "suspected-stall") {
|
|
82
|
+
needsAttention = true;
|
|
83
|
+
hasActiveOrRecoverableDag = true;
|
|
84
|
+
}
|
|
85
|
+
if (entry.lifecycle === "completed" && state.status !== "finished") {
|
|
86
|
+
runFailed = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
try {
|
|
91
|
+
const spec = await readDagRunSpec(entry.runDir);
|
|
92
|
+
if (spec.taskContractBinding?.taskId === harnessTaskId ||
|
|
93
|
+
spec.sourceBinding?.taskId === harnessTaskId) {
|
|
94
|
+
factsIncomplete = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// ignore
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (factsIncomplete) {
|
|
103
|
+
return resolveWorkerRecoveryDecision({ factsIncomplete: true });
|
|
104
|
+
}
|
|
105
|
+
if (needsAttention || dagCanResume) {
|
|
106
|
+
const decision = resolveWorkerRecoveryDecision({
|
|
107
|
+
dagCanResume,
|
|
108
|
+
hasActiveOrRecoverableDag,
|
|
109
|
+
});
|
|
110
|
+
if (decision.recommendedAction === "reconcile" ||
|
|
111
|
+
decision.recommendedAction === "decide" ||
|
|
112
|
+
decision.recommendedAction === "revise-contract") {
|
|
113
|
+
return decision;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (runFailed) {
|
|
117
|
+
const decision = resolveWorkerRecoveryDecision({
|
|
118
|
+
dagCanResume: false,
|
|
119
|
+
hasActiveOrRecoverableDag: false,
|
|
120
|
+
});
|
|
121
|
+
if (decision.recommendedAction === "revise-contract") {
|
|
122
|
+
return decision;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { sha256OfCanonicalJson } from "../../task/contract/hash.js";
|
|
5
|
+
import { resolveLoopAgentProfile } from "../profile-mapping.js";
|
|
6
|
+
import { mapRiskLevelToComplexity } from "../task-spec/complexity-mapping.js";
|
|
7
|
+
import { resolveWorkflow, } from "../task-spec/workflow-routing.js";
|
|
8
|
+
export const HARNESS_TASK_LINEAGE_MATERIALIZATION_VERSION = 1;
|
|
9
|
+
export const MAX_HARNESS_TASK_ID_LENGTH = 96;
|
|
10
|
+
export function slugifyIdentity(value) {
|
|
11
|
+
return value
|
|
12
|
+
.normalize("NFKD")
|
|
13
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
16
|
+
.replace(/^-+|-+$/g, "")
|
|
17
|
+
.replace(/-{2,}/g, "-");
|
|
18
|
+
}
|
|
19
|
+
export function buildHarnessTaskLineage(taskSpec) {
|
|
20
|
+
const lineageId = slugifyIdentity(`${taskSpec.feature_id}-${taskSpec.id}`);
|
|
21
|
+
return {
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
featureId: taskSpec.feature_id,
|
|
24
|
+
taskId: taskSpec.id,
|
|
25
|
+
lineageId,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function isLegacyReadableHarnessTaskId(taskId) {
|
|
29
|
+
return /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(-[a-z0-9]+)*$/.test(taskId);
|
|
30
|
+
}
|
|
31
|
+
export function buildHarnessTaskId(lineageId, revisionId) {
|
|
32
|
+
const revisionShort = revisionId.slice(0, 8);
|
|
33
|
+
let base = `${lineageId}--${revisionShort}`;
|
|
34
|
+
if (base.length <= MAX_HARNESS_TASK_ID_LENGTH) {
|
|
35
|
+
return base;
|
|
36
|
+
}
|
|
37
|
+
const hash = createHash("sha256")
|
|
38
|
+
.update(`${lineageId}:${revisionId}`)
|
|
39
|
+
.digest("hex")
|
|
40
|
+
.slice(0, 8);
|
|
41
|
+
const prefixBudget = MAX_HARNESS_TASK_ID_LENGTH - hash.length - 2;
|
|
42
|
+
const prefix = lineageId.slice(0, Math.max(8, prefixBudget)).replace(/-+$/g, "");
|
|
43
|
+
base = `${prefix}--${hash}`;
|
|
44
|
+
if (base.length > MAX_HARNESS_TASK_ID_LENGTH) {
|
|
45
|
+
throw new Error(`harness task id exceeds bounded length (${base.length} > ${MAX_HARNESS_TASK_ID_LENGTH})`);
|
|
46
|
+
}
|
|
47
|
+
return base;
|
|
48
|
+
}
|
|
49
|
+
/** Legacy date+title slug ids remain readable; new ids never depend on title. */
|
|
50
|
+
export function buildLegacyReadableHarnessTaskId(taskSpec, now) {
|
|
51
|
+
const date = now.toISOString().slice(0, 10);
|
|
52
|
+
const slug = slugifyIdentity(`${taskSpec.id}-${taskSpec.title}`);
|
|
53
|
+
return `${date}-${slug}`;
|
|
54
|
+
}
|
|
55
|
+
export async function computeSourceRefHashes(input) {
|
|
56
|
+
const taskSpecDir = path.dirname(path.resolve(input.taskSpecPath));
|
|
57
|
+
const hashes = {};
|
|
58
|
+
for (const [key, relativePath] of Object.entries(input.taskSpec.source_docs)) {
|
|
59
|
+
if (!relativePath)
|
|
60
|
+
continue;
|
|
61
|
+
const sourcePath = path.resolve(taskSpecDir, relativePath);
|
|
62
|
+
const content = await readFile(sourcePath);
|
|
63
|
+
hashes[key] = createHash("sha256").update(content).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
return hashes;
|
|
66
|
+
}
|
|
67
|
+
export function computeRevisionDigests(input) {
|
|
68
|
+
const sourcePayload = {
|
|
69
|
+
materializationVersion: HARNESS_TASK_LINEAGE_MATERIALIZATION_VERSION,
|
|
70
|
+
source_docs: input.taskSpec.source_docs,
|
|
71
|
+
source_ref_hashes: input.sourceRefHashes,
|
|
72
|
+
};
|
|
73
|
+
const sourceDigest = sha256OfCanonicalJson(sourcePayload);
|
|
74
|
+
const contractPayload = {
|
|
75
|
+
materializationVersion: HARNESS_TASK_LINEAGE_MATERIALIZATION_VERSION,
|
|
76
|
+
id: input.taskSpec.id,
|
|
77
|
+
feature_id: input.taskSpec.feature_id,
|
|
78
|
+
repo: input.taskSpec.repo,
|
|
79
|
+
type: input.taskSpec.type,
|
|
80
|
+
priority: input.taskSpec.priority,
|
|
81
|
+
risk_level: input.taskSpec.risk_level,
|
|
82
|
+
capabilities: input.taskSpec.capabilities ?? [],
|
|
83
|
+
depends_on: input.taskSpec.depends_on,
|
|
84
|
+
acceptance_refs: input.taskSpec.acceptance_refs,
|
|
85
|
+
scope: input.taskSpec.scope,
|
|
86
|
+
constraints: input.taskSpec.constraints,
|
|
87
|
+
verify: input.taskSpec.verify,
|
|
88
|
+
worker: input.taskSpec.worker,
|
|
89
|
+
execution: input.taskSpec.execution,
|
|
90
|
+
loop_agent: input.taskSpec.loop_agent,
|
|
91
|
+
outputs: input.taskSpec.outputs,
|
|
92
|
+
failure_policy: input.taskSpec.failure_policy,
|
|
93
|
+
workflow: input.workflow,
|
|
94
|
+
taskKind: input.taskKind,
|
|
95
|
+
loopAgentProfile: input.loopAgentProfile,
|
|
96
|
+
allowedPaths: [...input.allowedPaths].sort(),
|
|
97
|
+
complexity: mapRiskLevelToComplexity(input.taskSpec.risk_level),
|
|
98
|
+
};
|
|
99
|
+
const contractDigest = sha256OfCanonicalJson(contractPayload);
|
|
100
|
+
const revisionId = sha256OfCanonicalJson({
|
|
101
|
+
sourceDigest,
|
|
102
|
+
contractDigest,
|
|
103
|
+
});
|
|
104
|
+
return { sourceDigest, contractDigest, revisionId };
|
|
105
|
+
}
|
|
106
|
+
export async function resolveRevisionDigestsForTaskSpec(input) {
|
|
107
|
+
const resolvedWorkflow = resolveWorkflow(input.taskSpec);
|
|
108
|
+
const profileMapping = resolveLoopAgentProfile(input.taskSpec);
|
|
109
|
+
const allowedPaths = expandAllowedPathsForWorkflow(resolvedWorkflow.workflow, input.taskSpec.constraints.allowed_paths);
|
|
110
|
+
const sourceRefHashes = await computeSourceRefHashes({
|
|
111
|
+
taskSpec: input.taskSpec,
|
|
112
|
+
taskSpecPath: input.taskSpecPath,
|
|
113
|
+
});
|
|
114
|
+
const digests = computeRevisionDigests({
|
|
115
|
+
taskSpec: input.taskSpec,
|
|
116
|
+
workflow: resolvedWorkflow.workflow,
|
|
117
|
+
taskKind: resolvedWorkflow.taskKind,
|
|
118
|
+
loopAgentProfile: profileMapping.loopAgentProfile,
|
|
119
|
+
allowedPaths,
|
|
120
|
+
sourceRefHashes,
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
lineage: buildHarnessTaskLineage(input.taskSpec),
|
|
124
|
+
digests,
|
|
125
|
+
workflow: resolvedWorkflow.workflow,
|
|
126
|
+
taskKind: resolvedWorkflow.taskKind,
|
|
127
|
+
loopAgentProfile: profileMapping.loopAgentProfile,
|
|
128
|
+
allowedPaths,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Union TaskSpec allowed_paths with workflow-owned write roots. */
|
|
132
|
+
export function expandAllowedPathsForWorkflow(workflow, allowedPaths) {
|
|
133
|
+
const extras = workflow === "backend-test"
|
|
134
|
+
? ["testcase/**", "docs/test-reports/**"]
|
|
135
|
+
: workflow === "frontend-test"
|
|
136
|
+
? ["testcase/frontend/**", "docs/test-reports/**"]
|
|
137
|
+
: [];
|
|
138
|
+
return Array.from(new Set([...allowedPaths, ...extras]));
|
|
139
|
+
}
|
|
140
|
+
export function parseLineageRevisionManifest(value) {
|
|
141
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
142
|
+
return undefined;
|
|
143
|
+
const record = value;
|
|
144
|
+
if (record.schemaVersion !== 1)
|
|
145
|
+
return undefined;
|
|
146
|
+
const lineage = record.lineage;
|
|
147
|
+
const revision = record.revision;
|
|
148
|
+
if (!lineage || typeof lineage !== "object" || !revision || typeof revision !== "object") {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const lineageRecord = lineage;
|
|
152
|
+
const revisionRecord = revision;
|
|
153
|
+
if (lineageRecord.schemaVersion !== 1 ||
|
|
154
|
+
revisionRecord.schemaVersion !== 1 ||
|
|
155
|
+
typeof record.harnessTaskId !== "string" ||
|
|
156
|
+
typeof lineageRecord.lineageId !== "string" ||
|
|
157
|
+
typeof revisionRecord.revisionId !== "string" ||
|
|
158
|
+
typeof revisionRecord.sourceDigest !== "string" ||
|
|
159
|
+
typeof revisionRecord.contractDigest !== "string" ||
|
|
160
|
+
typeof revisionRecord.createdAt !== "string") {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
lineage: {
|
|
166
|
+
schemaVersion: 1,
|
|
167
|
+
featureId: String(lineageRecord.featureId),
|
|
168
|
+
taskId: String(lineageRecord.taskId),
|
|
169
|
+
lineageId: String(lineageRecord.lineageId),
|
|
170
|
+
},
|
|
171
|
+
revision: {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
lineageId: String(revisionRecord.lineageId),
|
|
174
|
+
revisionId: String(revisionRecord.revisionId),
|
|
175
|
+
sourceDigest: String(revisionRecord.sourceDigest),
|
|
176
|
+
contractDigest: String(revisionRecord.contractDigest),
|
|
177
|
+
createdAt: String(revisionRecord.createdAt),
|
|
178
|
+
},
|
|
179
|
+
harnessTaskId: record.harnessTaskId,
|
|
180
|
+
...(typeof record.previousRevisionId === "string"
|
|
181
|
+
? { previousRevisionId: record.previousRevisionId }
|
|
182
|
+
: {}),
|
|
183
|
+
...(typeof record.legacyReadableId === "string"
|
|
184
|
+
? { legacyReadableId: record.legacyReadableId }
|
|
185
|
+
: {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function parseLineageIndex(value) {
|
|
189
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
190
|
+
return undefined;
|
|
191
|
+
const record = value;
|
|
192
|
+
if (record.schemaVersion !== 1 ||
|
|
193
|
+
typeof record.lineageId !== "string" ||
|
|
194
|
+
typeof record.featureId !== "string" ||
|
|
195
|
+
typeof record.taskId !== "string" ||
|
|
196
|
+
typeof record.currentRevisionId !== "string" ||
|
|
197
|
+
!Array.isArray(record.revisions)) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
const revisions = record.revisions
|
|
201
|
+
.filter((entry) => entry && typeof entry === "object")
|
|
202
|
+
.map((entry) => {
|
|
203
|
+
const revision = entry;
|
|
204
|
+
return {
|
|
205
|
+
revisionId: String(revision.revisionId),
|
|
206
|
+
harnessTaskId: String(revision.harnessTaskId),
|
|
207
|
+
sourceDigest: String(revision.sourceDigest),
|
|
208
|
+
contractDigest: String(revision.contractDigest),
|
|
209
|
+
createdAt: String(revision.createdAt),
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
schemaVersion: 1,
|
|
214
|
+
lineageId: record.lineageId,
|
|
215
|
+
featureId: record.featureId,
|
|
216
|
+
taskId: record.taskId,
|
|
217
|
+
currentRevisionId: record.currentRevisionId,
|
|
218
|
+
revisions,
|
|
219
|
+
};
|
|
220
|
+
}
|