@tea-agent/loop-agent 0.31.1 → 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 +32 -0
- package/README.md +1 -1
- 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/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/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,3 +1,4 @@
|
|
|
1
|
+
import { resolveWorkerRecoveryDecision } from "../pool/recovery-decision.js";
|
|
1
2
|
export function projectNextAction(input, status) {
|
|
2
3
|
const featureArgs = `--feature-dir ${quote(input.featureDir)} --repo ${quote(input.repoRoot)}`;
|
|
3
4
|
const resolvedFailures = new Set(input.resolvedFailureTaskIds ?? []);
|
|
@@ -31,20 +32,59 @@ export function projectNextAction(input, status) {
|
|
|
31
32
|
}
|
|
32
33
|
const failed = input.tasks.find((task) => task.status === "Failed" && !resolvedFailures.has(task.taskId));
|
|
33
34
|
if (failed) {
|
|
34
|
-
|
|
35
|
+
const decision = resolveWorkerRecoveryDecision({
|
|
36
|
+
state: {
|
|
37
|
+
status: failed.status ?? "Failed",
|
|
38
|
+
...(failed.workerRunId ? { workerRunId: failed.workerRunId } : {}),
|
|
39
|
+
...(failed.failureCategory
|
|
40
|
+
? {
|
|
41
|
+
failure: {
|
|
42
|
+
category: failed.failureCategory,
|
|
43
|
+
recommendedFollowUpKind: "feature-review",
|
|
44
|
+
derivedFollowUpTaskId: `${failed.taskId}-review`,
|
|
45
|
+
source: "fallback",
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (decision.recommendedAction === "retry") {
|
|
35
52
|
return {
|
|
36
53
|
kind: "retry_task",
|
|
37
|
-
label:
|
|
54
|
+
label: `重试失败任务 ${failed.taskId}`,
|
|
38
55
|
command: `agent-worker task retry ${quote(failed.taskId)} --feature-id ${quote(input.featureId)} --repo ${quote(input.repoRoot)}`,
|
|
39
56
|
};
|
|
40
57
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
if (decision.recommendedAction === "decide") {
|
|
59
|
+
return {
|
|
60
|
+
kind: "handle_failure",
|
|
61
|
+
label: `根据失败证据处理 ${failed.taskId};ProductBug 可生成并人工批准 Follow-up`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
45
64
|
}
|
|
46
65
|
const blocked = input.tasks.find((task) => ["Blocked", "HumanReview", "Abandoned"].includes(task.status ?? ""));
|
|
47
66
|
if (blocked) {
|
|
67
|
+
const decision = resolveWorkerRecoveryDecision({
|
|
68
|
+
state: {
|
|
69
|
+
status: blocked.status ?? "Blocked",
|
|
70
|
+
...(blocked.failureCategory
|
|
71
|
+
? {
|
|
72
|
+
failure: {
|
|
73
|
+
category: blocked.failureCategory,
|
|
74
|
+
recommendedFollowUpKind: "feature-review",
|
|
75
|
+
derivedFollowUpTaskId: `${blocked.taskId}-review`,
|
|
76
|
+
source: "fallback",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
if (decision.recommendedAction === "revise-contract") {
|
|
83
|
+
return {
|
|
84
|
+
kind: "revise_contract",
|
|
85
|
+
label: `修订 ${blocked.taskId} 的 contract/spec 后再 materialize`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
48
88
|
return {
|
|
49
89
|
kind: "resolve_human_gate",
|
|
50
90
|
label: `处理 ${blocked.taskId} 的人工 Gate 或阻塞原因`,
|
|
@@ -65,6 +105,16 @@ export function projectNextAction(input, status) {
|
|
|
65
105
|
command: `agent-worker batch run-ready ${featureArgs}`,
|
|
66
106
|
};
|
|
67
107
|
}
|
|
108
|
+
if (status === "awaiting_qa" || status === "deliverable") {
|
|
109
|
+
const gap = input.acceptanceGaps?.find((entry) => entry.nextCommand);
|
|
110
|
+
if (gap?.nextCommand) {
|
|
111
|
+
return {
|
|
112
|
+
kind: status === "deliverable" ? "advance_delivery" : "complete_qa",
|
|
113
|
+
label: `补齐 ${gap.acId} 验收缺口(${gap.status})`,
|
|
114
|
+
command: gap.nextCommand,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
68
118
|
if (status === "awaiting_qa") {
|
|
69
119
|
const finalish = input.tasks.find((task) => /(final[-_]?(verify|verification))|closeout/i.test(task.taskId));
|
|
70
120
|
if (finalish && (finalish.status === "Done" || finalish.status === "Ready")) {
|
|
@@ -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
|
+
}
|