@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.
Files changed (57) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +1 -1
  4. package/dist/shared/operator/capabilities.js +22 -1
  5. package/dist/shared/operator/command-lifecycle.js +94 -0
  6. package/dist/shared/operator/index.js +1 -0
  7. package/dist/worker/cli.js +10 -24
  8. package/dist/worker/console/doctor.js +11 -4
  9. package/dist/worker/console/observe-health-match.js +18 -15
  10. package/dist/worker/console/operator-actions.js +2 -1
  11. package/dist/worker/feature/acceptance-policy.js +227 -0
  12. package/dist/worker/feature/decision-loader.js +1 -1
  13. package/dist/worker/feature/next-action.js +56 -6
  14. package/dist/worker/feature/profile-schema.js +3 -0
  15. package/dist/worker/feature/reducer.js +1 -0
  16. package/dist/worker/feature/review.js +72 -8
  17. package/dist/worker/feature/scaffold.js +14 -0
  18. package/dist/worker/loop-agent/controller-protocol.js +143 -0
  19. package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
  20. package/dist/worker/materialize/harness-task-lineage.js +220 -0
  21. package/dist/worker/materialize/harness-task-materializer.js +350 -80
  22. package/dist/worker/observability/progress-composite.js +1 -0
  23. package/dist/worker/observability/read-model.js +66 -19
  24. package/dist/worker/pool/attempt-identity.js +41 -0
  25. package/dist/worker/pool/attempt-lease.js +184 -0
  26. package/dist/worker/pool/attempt-transition.js +210 -0
  27. package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
  28. package/dist/worker/pool/begin-attempt.js +35 -0
  29. package/dist/worker/pool/failure-routing.js +49 -0
  30. package/dist/worker/pool/recovery-decision.js +163 -0
  31. package/dist/worker/pool/run-owner-store.js +126 -0
  32. package/dist/worker/pool/run-store.js +32 -46
  33. package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
  34. package/dist/worker/pool/state-projection.js +57 -0
  35. package/dist/worker/run-task/run-task.js +31 -4
  36. package/dist/worker/runner/run-ready.js +64 -14
  37. package/dist/worker/runner/single-task-attempt.js +42 -13
  38. package/dist/worker/task-graph/acceptance-schema.js +3 -0
  39. package/docs/README.md +3 -1
  40. package/docs/architecture/evolution.md +1 -1
  41. package/docs/operations/README.md +1 -0
  42. package/docs/templates/README.md +1 -0
  43. package/docs/templates/agent-worker-production-readiness-checklist.md +45 -0
  44. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  45. package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
  46. package/docs/templates/init-managed-agents.md +1 -1
  47. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
  48. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
  49. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
  50. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
  51. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
  52. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
  53. package/package.json +1 -1
  54. package/skills/agent-worker/SKILL.md +1 -1
  55. package/skills/agent-worker/references/agent-worker-operator.md +1 -1
  56. package/skills/loop-agent/references/command-reference.md +2 -2
  57. 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
- if (failed.failureCategory === "EnvFailure") {
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: `重试环境失败任务 ${failed.taskId}`,
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
- return {
42
- kind: "handle_failure",
43
- label: `根据失败证据处理 ${failed.taskId};ProductBug 可生成并人工批准 Follow-up`,
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
- acceptance = acceptanceSpecSchema.safeParse(YAML.parse(await readFile(path.join(featureDir, "acceptance.yaml"), "utf-8")));
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 state = rawState?.workerRunId && !stateRun
195
- ? undefined
196
- : stateRun && stateRun.featureId !== validation.featureId
197
- ? undefined
198
- : rawState;
199
- if (rawState && !state) {
200
- projectionWarnings.push(`Task Pool state for ${node.id} has missing or cross-Feature run ownership`);
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
+ }