@tea-agent/loop-agent 0.31.1 → 0.32.1

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 (85) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +46 -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/console/static/assets/index-Bpa2qrc-.js +29 -0
  12. package/dist/worker/console/static/assets/index-BqfFDdnG.css +1 -0
  13. package/dist/worker/console/static/index.html +2 -2
  14. package/dist/worker/console/static-src/app/console-types.js +140 -0
  15. package/dist/worker/console/static-src/app/useConsoleShell.js +101 -0
  16. package/dist/worker/console/static-src/app/useOperatorActions.js +304 -0
  17. package/dist/worker/console/static-src/app/usePrdImport.js +171 -0
  18. package/dist/worker/console/static-src/app/useRecoveryActions.js +257 -0
  19. package/dist/worker/console/static-src/app/useRecoveryConsole.js +334 -0
  20. package/dist/worker/console/static-src/app/useTaskWizard.js +229 -0
  21. package/dist/worker/console/static-src/chat-view-types.js +2 -0
  22. package/dist/worker/console/static-src/night/night-types.js +24 -0
  23. package/dist/worker/console/static-src/night/useNightBoard.js +125 -0
  24. package/dist/worker/console/static-src/night/useNightWizard.js +171 -0
  25. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +253 -0
  26. package/dist/worker/console/static-src/operator-chat/format.js +71 -0
  27. package/dist/worker/console/static-src/operator-chat/refs.js +47 -0
  28. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +77 -0
  29. package/dist/worker/console/static-src/operator-chat/types.js +1 -0
  30. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +320 -0
  31. package/dist/worker/console/static-src/operator-chat/useChatStream.js +209 -0
  32. package/dist/worker/console/static-src/operator-chat/useChatThread.js +218 -0
  33. package/dist/worker/console/static-src/operator-chat/useComposer.js +125 -0
  34. package/dist/worker/console/static-src/operator-chat/useInterview.js +108 -0
  35. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +123 -0
  36. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +207 -0
  37. package/dist/worker/feature/acceptance-policy.js +227 -0
  38. package/dist/worker/feature/decision-loader.js +1 -1
  39. package/dist/worker/feature/next-action.js +56 -6
  40. package/dist/worker/feature/profile-schema.js +3 -0
  41. package/dist/worker/feature/reducer.js +1 -0
  42. package/dist/worker/feature/review.js +72 -8
  43. package/dist/worker/feature/scaffold.js +14 -0
  44. package/dist/worker/loop-agent/controller-protocol.js +143 -0
  45. package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
  46. package/dist/worker/materialize/harness-task-lineage.js +220 -0
  47. package/dist/worker/materialize/harness-task-materializer.js +350 -80
  48. package/dist/worker/observability/progress-composite.js +1 -0
  49. package/dist/worker/observability/read-model.js +66 -19
  50. package/dist/worker/pool/attempt-identity.js +41 -0
  51. package/dist/worker/pool/attempt-lease.js +184 -0
  52. package/dist/worker/pool/attempt-transition.js +210 -0
  53. package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
  54. package/dist/worker/pool/begin-attempt.js +35 -0
  55. package/dist/worker/pool/failure-routing.js +49 -0
  56. package/dist/worker/pool/recovery-decision.js +163 -0
  57. package/dist/worker/pool/run-owner-store.js +126 -0
  58. package/dist/worker/pool/run-store.js +32 -46
  59. package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
  60. package/dist/worker/pool/state-projection.js +57 -0
  61. package/dist/worker/run-task/run-task.js +31 -4
  62. package/dist/worker/runner/run-ready.js +64 -14
  63. package/dist/worker/runner/single-task-attempt.js +42 -13
  64. package/dist/worker/task-graph/acceptance-schema.js +3 -0
  65. package/docs/README.md +3 -1
  66. package/docs/architecture/evolution.md +1 -1
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/README.md +1 -0
  69. package/docs/templates/agent-worker-production-readiness-checklist.md +47 -0
  70. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  71. package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
  72. package/docs/templates/init-managed-agents.md +1 -1
  73. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
  74. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
  75. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
  76. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
  77. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
  78. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
  79. package/package.json +5 -4
  80. package/skills/agent-worker/SKILL.md +1 -1
  81. package/skills/agent-worker/references/agent-worker-operator.md +1 -1
  82. package/skills/loop-agent/references/command-reference.md +2 -2
  83. package/skills/loop-agent/references/harness-policy.md +1 -1
  84. package/dist/worker/console/static/assets/index-D9gnJn_l.js +0 -29
  85. package/dist/worker/console/static/assets/index-rajoXwkM.css +0 -1
@@ -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
+ }
@@ -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
+ }