@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
@@ -1,17 +1,25 @@
1
- import { createHash } from "node:crypto";
2
1
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import YAML from "yaml";
5
4
  import { writeTaskConfig, writeTaskArtifactFile, } from "../../infrastructure/harness/task-store.js";
5
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
6
6
  import { getTaskPaths, loadTaskConfig } from "../../task/runtime.js";
7
7
  import { adoptTaskContract } from "../../task/contract/adopt.js";
8
8
  import { observeTaskContractState } from "../../task/contract/observe.js";
9
9
  import { sha256Utf8 } from "../../task/contract/hash.js";
10
10
  import { resolveLoopAgentProfile, } from "../profile-mapping.js";
11
- import { mapRiskLevelToComplexity } from "../task-spec/complexity-mapping.js";
12
11
  import { resolveWorkflow } from "../task-spec/workflow-routing.js";
13
12
  import { validateTaskSpec } from "../task-spec/validate.js";
13
+ import { mapRiskLevelToComplexity } from "../task-spec/complexity-mapping.js";
14
+ import { detectPausedAssociatedRun, detectRecoveryRequired, } from "./harness-task-lifecycle-probe.js";
15
+ import { buildHarnessTaskId as buildHarnessTaskLocatorId, buildHarnessTaskLineage, computeRevisionDigests, expandAllowedPathsForWorkflow, parseLineageIndex, parseLineageRevisionManifest, resolveRevisionDigestsForTaskSpec, } from "./harness-task-lineage.js";
16
+ const LINEAGE_MANIFEST_NAME = "lineage-revision-manifest.json";
17
+ const LINEAGE_INDEX_DIR = "_lineage-index";
14
18
  export async function materializeTaskSpec(options) {
19
+ const result = await materializeTaskSpecV2(options);
20
+ return result.manifest;
21
+ }
22
+ export async function materializeTaskSpecV2(options) {
15
23
  const now = options.now ?? new Date();
16
24
  const validation = await validateTaskSpec(options.taskSpec, {
17
25
  taskSpecPath: options.taskSpecPath,
@@ -20,43 +28,179 @@ export async function materializeTaskSpec(options) {
20
28
  const codes = validation.errors.map((error) => error.code).join(", ");
21
29
  throw new Error(`TaskSpec validation failed: ${codes}`);
22
30
  }
23
- const harnessTaskId = buildHarnessTaskId(options.taskSpec, now);
24
- const resolvedWorkflow = resolveWorkflow(options.taskSpec);
31
+ const resolved = await resolveRevisionDigestsForTaskSpec({
32
+ taskSpec: options.taskSpec,
33
+ taskSpecPath: options.taskSpecPath,
34
+ });
35
+ const lineage = resolved.lineage;
36
+ const digests = resolved.digests;
37
+ const harnessTaskId = buildHarnessTaskLocatorId(lineage.lineageId, digests.revisionId);
38
+ const lineageIndex = await readLineageIndex(options.repoRoot, lineage.lineageId);
39
+ const existingManifest = await readLineageRevisionManifest(options.repoRoot, harnessTaskId);
40
+ assertManifestDigestConsistency(existingManifest, digests, harnessTaskId);
41
+ const indexEntry = lineageIndex?.revisions.find((entry) => entry.revisionId === digests.revisionId);
42
+ if (indexEntry &&
43
+ indexEntry.harnessTaskId !== harnessTaskId &&
44
+ indexEntry.sourceDigest === digests.sourceDigest &&
45
+ indexEntry.contractDigest === digests.contractDigest) {
46
+ throw new Error(`lineage index collision for ${lineage.lineageId}: revision ${digests.revisionId} maps to ${indexEntry.harnessTaskId}, expected ${harnessTaskId}`);
47
+ }
25
48
  const existingConfig = await loadExistingTaskConfig(options.repoRoot, harnessTaskId);
26
- if (!existingConfig) {
27
- // Prefer task advance create path (new-task is being purged from the public surface).
28
- const newTaskResult = await options.client.run(["task", "advance", harnessTaskId, options.taskSpec.title, "--json"], {
29
- cwd: options.repoRoot,
30
- artifactName: `task-advance-create-${harnessTaskId}`,
49
+ const pausedRun = existingConfig
50
+ ? await detectPausedAssociatedRun(options.repoRoot, harnessTaskId)
51
+ : undefined;
52
+ if (pausedRun) {
53
+ const manifest = await buildManifestFromExisting({
54
+ repoRoot: options.repoRoot,
55
+ harnessTaskId,
56
+ taskSpec: options.taskSpec,
57
+ lineage,
58
+ digests,
59
+ workflow: resolved.workflow,
60
+ loopAgentProfile: resolved.loopAgentProfile,
61
+ materializeOutcome: "resume-existing",
31
62
  });
32
- if (!newTaskResult.ok) {
33
- const racedConfig = await loadExistingTaskConfig(options.repoRoot, harnessTaskId);
34
- if (!racedConfig) {
35
- throw new Error(`loop-agent task advance (create) failed with exit ${newTaskResult.exitCode}: ${newTaskResult.stderr}`);
36
- }
63
+ return {
64
+ outcome: {
65
+ type: "resume-existing",
66
+ revisionId: digests.revisionId,
67
+ runId: pausedRun.runId,
68
+ },
69
+ manifest,
70
+ };
71
+ }
72
+ if (existingConfig && existingManifest) {
73
+ const recovery = await detectRecoveryRequired(options.repoRoot, harnessTaskId, digests.revisionId);
74
+ if (recovery) {
75
+ return {
76
+ outcome: {
77
+ type: "recovery-required",
78
+ revisionId: digests.revisionId,
79
+ recovery,
80
+ },
81
+ manifest: await buildManifestFromExisting({
82
+ repoRoot: options.repoRoot,
83
+ harnessTaskId,
84
+ taskSpec: options.taskSpec,
85
+ lineage,
86
+ digests,
87
+ workflow: resolved.workflow,
88
+ loopAgentProfile: resolved.loopAgentProfile,
89
+ materializeOutcome: "recovery-required",
90
+ }),
91
+ };
37
92
  }
38
93
  }
39
- const paths = getTaskPaths(options.repoRoot, harnessTaskId);
40
- const baseConfig = await loadTaskConfig(options.repoRoot, harnessTaskId);
41
- // backend-test hybrid DAG writes under testcase/** (pytest + md cases + helpers).
42
- // TaskSpecs often list legacy tests/api/** surfaces; union runtime write roots so
43
- // git checkpoint / path guard can promote green BE-TEST runs.
44
- const allowedPaths = expandAllowedPathsForWorkflow(resolvedWorkflow.workflow, options.taskSpec.constraints.allowed_paths);
94
+ let outcome;
95
+ let previousRevisionId;
96
+ if (existingConfig && existingManifest) {
97
+ outcome = {
98
+ type: "idempotent-reuse",
99
+ lineageId: lineage.lineageId,
100
+ revisionId: digests.revisionId,
101
+ harnessTaskId,
102
+ };
103
+ }
104
+ else if (lineageIndex && lineageIndex.revisions.length > 0) {
105
+ previousRevisionId = lineageIndex.currentRevisionId;
106
+ outcome = {
107
+ type: "new-revision",
108
+ lineageId: lineage.lineageId,
109
+ previousRevisionId,
110
+ revisionId: digests.revisionId,
111
+ harnessTaskId,
112
+ };
113
+ }
114
+ else {
115
+ outcome = {
116
+ type: "created",
117
+ lineageId: lineage.lineageId,
118
+ revisionId: digests.revisionId,
119
+ harnessTaskId,
120
+ };
121
+ }
122
+ const skipContractRewrite = outcome.type === "idempotent-reuse";
123
+ if (!existingConfig) {
124
+ await ensureHarnessTaskCreated({
125
+ repoRoot: options.repoRoot,
126
+ harnessTaskId,
127
+ title: options.taskSpec.title,
128
+ client: options.client,
129
+ });
130
+ }
131
+ if (!skipContractRewrite) {
132
+ await writeHarnessTaskMaterials({
133
+ repoRoot: options.repoRoot,
134
+ harnessTaskId,
135
+ taskSpec: options.taskSpec,
136
+ taskSpecPath: options.taskSpecPath,
137
+ workflow: resolved.workflow,
138
+ taskKind: resolved.taskKind,
139
+ allowedPaths: resolved.allowedPaths,
140
+ now,
141
+ });
142
+ }
143
+ else {
144
+ await touchIdempotentReuseTask({
145
+ repoRoot: options.repoRoot,
146
+ harnessTaskId,
147
+ title: options.taskSpec.title,
148
+ });
149
+ }
150
+ const revision = {
151
+ schemaVersion: 1,
152
+ lineageId: lineage.lineageId,
153
+ revisionId: digests.revisionId,
154
+ sourceDigest: digests.sourceDigest,
155
+ contractDigest: digests.contractDigest,
156
+ createdAt: existingManifest?.revision.createdAt ?? now.toISOString(),
157
+ };
158
+ const lineageManifest = {
159
+ schemaVersion: 1,
160
+ lineage,
161
+ revision,
162
+ harnessTaskId,
163
+ ...(previousRevisionId ? { previousRevisionId } : {}),
164
+ };
165
+ await writeLineageRevisionManifest(options.repoRoot, harnessTaskId, lineageManifest);
166
+ await upsertLineageIndex({
167
+ repoRoot: options.repoRoot,
168
+ lineage,
169
+ digests,
170
+ harnessTaskId,
171
+ createdAt: revision.createdAt,
172
+ });
173
+ const manifest = await buildMaterializeManifest({
174
+ repoRoot: options.repoRoot,
175
+ harnessTaskId,
176
+ taskSpec: options.taskSpec,
177
+ lineage,
178
+ digests,
179
+ workflow: resolved.workflow,
180
+ loopAgentProfile: resolved.loopAgentProfile,
181
+ materializeOutcome: outcome.type,
182
+ });
183
+ await writeTaskArtifactFile(options.repoRoot, harnessTaskId, "materialize-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
184
+ return { outcome, manifest };
185
+ }
186
+ async function writeHarnessTaskMaterials(input) {
187
+ const paths = getTaskPaths(input.repoRoot, input.harnessTaskId);
188
+ const baseConfig = await loadTaskConfig(input.repoRoot, input.harnessTaskId);
45
189
  const taskConfig = {
46
190
  ...baseConfig,
47
- taskId: harnessTaskId,
48
- title: options.taskSpec.title,
49
- taskKind: resolvedWorkflow.taskKind,
50
- featureId: options.taskSpec.feature_id,
51
- allowedPaths,
52
- forbiddenPaths: options.taskSpec.constraints.forbidden_paths,
53
- hardConstraints: options.taskSpec.constraints.hard_constraints,
54
- complexity: mapRiskLevelToComplexity(options.taskSpec.risk_level),
55
- capabilities: options.taskSpec.capabilities ?? [],
56
- verifyMode: options.taskSpec.verify.mode,
57
- verifyPreset: options.taskSpec.verify.preset,
58
- verifyQuota: options.taskSpec.verify.quota,
59
- verifyCommands: options.taskSpec.verify.commands
191
+ taskId: input.harnessTaskId,
192
+ title: input.taskSpec.title,
193
+ taskKind: input.taskKind,
194
+ featureId: input.taskSpec.feature_id,
195
+ allowedPaths: input.allowedPaths,
196
+ forbiddenPaths: input.taskSpec.constraints.forbidden_paths,
197
+ hardConstraints: input.taskSpec.constraints.hard_constraints,
198
+ complexity: mapRiskLevelToComplexity(input.taskSpec.risk_level),
199
+ capabilities: input.taskSpec.capabilities ?? [],
200
+ verifyMode: input.taskSpec.verify.mode,
201
+ verifyPreset: input.taskSpec.verify.preset,
202
+ verifyQuota: input.taskSpec.verify.quota,
203
+ verifyCommands: input.taskSpec.verify.commands
60
204
  .filter((command) => command.required)
61
205
  .map((command) => ({
62
206
  label: command.label,
@@ -65,7 +209,7 @@ export async function materializeTaskSpec(options) {
65
209
  })),
66
210
  autoCommitAfterVerify: false,
67
211
  };
68
- await writeTaskConfig(options.repoRoot, harnessTaskId, taskConfig);
212
+ await writeTaskConfig(input.repoRoot, input.harnessTaskId, taskConfig);
69
213
  const sourcePaths = {
70
214
  requirementPath: path.join(paths.sourceDir, "需求.md"),
71
215
  constraintsPath: path.join(paths.sourceDir, "执行约束.md"),
@@ -73,48 +217,170 @@ export async function materializeTaskSpec(options) {
73
217
  };
74
218
  await mkdir(paths.sourceDir, { recursive: true });
75
219
  const referencePaths = await materializeSourceDocs({
76
- taskSpec: options.taskSpec,
77
- taskSpecPath: options.taskSpecPath,
220
+ taskSpec: input.taskSpec,
221
+ taskSpecPath: input.taskSpecPath,
78
222
  sourceDir: paths.sourceDir,
79
223
  });
80
224
  const sourceDocTrace = await buildSourceDocTrace({
81
- taskSpec: options.taskSpec,
82
- taskSpecPath: options.taskSpecPath,
225
+ taskSpec: input.taskSpec,
226
+ taskSpecPath: input.taskSpecPath,
83
227
  referencePaths,
84
228
  });
85
229
  const acceptanceSummaries = await buildAcceptanceSummaries({
86
- taskSpec: options.taskSpec,
87
- taskSpecPath: options.taskSpecPath,
230
+ taskSpec: input.taskSpec,
231
+ taskSpecPath: input.taskSpecPath,
88
232
  referencePaths,
89
233
  });
90
- await writeFile(sourcePaths.requirementPath, renderRequirementMarkdown(options.taskSpec, {
234
+ await writeFile(sourcePaths.requirementPath, renderRequirementMarkdown(input.taskSpec, {
91
235
  sourceDocTrace,
92
236
  acceptanceSummaries,
93
237
  }), "utf-8");
94
- await writeFile(sourcePaths.constraintsPath, renderConstraintsMarkdown(options.taskSpec), "utf-8");
95
- await writeFile(sourcePaths.taskYamlPath, YAML.stringify(options.taskSpec), "utf-8");
238
+ await writeFile(sourcePaths.constraintsPath, renderConstraintsMarkdown(input.taskSpec), "utf-8");
239
+ await writeFile(sourcePaths.taskYamlPath, YAML.stringify(input.taskSpec), "utf-8");
96
240
  await ensureMaterializedTaskContractManaged({
97
- repoRoot: options.repoRoot,
98
- taskId: harnessTaskId,
99
- taskSpecPath: options.taskSpecPath,
241
+ repoRoot: input.repoRoot,
242
+ taskId: input.harnessTaskId,
243
+ taskSpecPath: input.taskSpecPath,
244
+ });
245
+ }
246
+ async function touchIdempotentReuseTask(input) {
247
+ const baseConfig = await loadTaskConfig(input.repoRoot, input.harnessTaskId);
248
+ if (baseConfig.title === input.title)
249
+ return;
250
+ await writeTaskConfig(input.repoRoot, input.harnessTaskId, {
251
+ ...baseConfig,
252
+ title: input.title,
100
253
  });
101
- const profileMapping = resolveLoopAgentProfile(options.taskSpec);
102
- const manifest = {
254
+ }
255
+ async function ensureHarnessTaskCreated(input) {
256
+ const newTaskResult = await input.client.run(["task", "advance", input.harnessTaskId, input.title, "--json"], {
257
+ cwd: input.repoRoot,
258
+ artifactName: `task-advance-create-${input.harnessTaskId}`,
259
+ });
260
+ if (!newTaskResult.ok) {
261
+ const racedConfig = await loadExistingTaskConfig(input.repoRoot, input.harnessTaskId);
262
+ if (!racedConfig) {
263
+ throw new Error(`loop-agent task advance (create) failed with exit ${newTaskResult.exitCode}: ${newTaskResult.stderr}`);
264
+ }
265
+ }
266
+ }
267
+ function assertManifestDigestConsistency(existingManifest, digests, harnessTaskId) {
268
+ if (!existingManifest)
269
+ return;
270
+ if (existingManifest.revision.sourceDigest !== digests.sourceDigest ||
271
+ existingManifest.revision.contractDigest !== digests.contractDigest) {
272
+ throw new Error(`lineage manifest digest mismatch for ${harnessTaskId}: stored revision does not match canonical TaskSpec digest (fail-closed)`);
273
+ }
274
+ if (existingManifest.revision.revisionId !== digests.revisionId) {
275
+ throw new Error(`lineage manifest revisionId mismatch for ${harnessTaskId}: stored ${existingManifest.revision.revisionId} vs canonical ${digests.revisionId}`);
276
+ }
277
+ }
278
+ function lineageIndexPath(repoRoot, lineageId) {
279
+ return path.join(repoRoot, ".harness", "tasks", LINEAGE_INDEX_DIR, `${lineageId}.json`);
280
+ }
281
+ function lineageManifestPath(repoRoot, harnessTaskId) {
282
+ return path.join(repoRoot, ".harness", "tasks", harnessTaskId, "artifacts", LINEAGE_MANIFEST_NAME);
283
+ }
284
+ async function readLineageRevisionManifest(repoRoot, harnessTaskId) {
285
+ try {
286
+ const raw = await readFile(lineageManifestPath(repoRoot, harnessTaskId), "utf-8");
287
+ return parseLineageRevisionManifest(JSON.parse(raw));
288
+ }
289
+ catch (error) {
290
+ if (isNotFound(error))
291
+ return undefined;
292
+ throw error;
293
+ }
294
+ }
295
+ async function writeLineageRevisionManifest(repoRoot, harnessTaskId, manifest) {
296
+ await writeTaskArtifactFile(repoRoot, harnessTaskId, LINEAGE_MANIFEST_NAME, `${JSON.stringify(manifest, null, 2)}\n`);
297
+ }
298
+ async function readLineageIndex(repoRoot, lineageId) {
299
+ try {
300
+ const raw = await readFile(lineageIndexPath(repoRoot, lineageId), "utf-8");
301
+ return parseLineageIndex(JSON.parse(raw));
302
+ }
303
+ catch (error) {
304
+ if (isNotFound(error))
305
+ return undefined;
306
+ throw error;
307
+ }
308
+ }
309
+ async function upsertLineageIndex(input) {
310
+ const indexPath = lineageIndexPath(input.repoRoot, input.lineage.lineageId);
311
+ await mkdir(path.dirname(indexPath), { recursive: true });
312
+ const existing = await readLineageIndex(input.repoRoot, input.lineage.lineageId);
313
+ const revisions = existing?.revisions ?? [];
314
+ const withoutCurrent = revisions.filter((entry) => entry.revisionId !== input.digests.revisionId);
315
+ const next = {
103
316
  schemaVersion: 1,
104
- businessId: options.taskSpec.id,
105
- harnessTaskId,
106
- featureId: options.taskSpec.feature_id,
107
- workflow: resolvedWorkflow.workflow,
108
- loopAgentProfile: profileMapping.loopAgentProfile,
317
+ lineageId: input.lineage.lineageId,
318
+ featureId: input.lineage.featureId,
319
+ taskId: input.lineage.taskId,
320
+ currentRevisionId: input.digests.revisionId,
321
+ revisions: [
322
+ ...withoutCurrent,
323
+ {
324
+ revisionId: input.digests.revisionId,
325
+ harnessTaskId: input.harnessTaskId,
326
+ sourceDigest: input.digests.sourceDigest,
327
+ contractDigest: input.digests.contractDigest,
328
+ createdAt: input.createdAt,
329
+ },
330
+ ].sort((left, right) => left.revisionId.localeCompare(right.revisionId)),
331
+ };
332
+ await writeJsonAtomic(indexPath, next, { repoRoot: input.repoRoot });
333
+ }
334
+ async function buildManifestFromExisting(input) {
335
+ return buildMaterializeManifest(input);
336
+ }
337
+ async function buildMaterializeManifest(input) {
338
+ const paths = getTaskPaths(input.repoRoot, input.harnessTaskId);
339
+ const referencePaths = await readExistingReferencePaths(paths.sourceDir, input.taskSpec);
340
+ return {
341
+ schemaVersion: 1,
342
+ businessId: input.taskSpec.id,
343
+ harnessTaskId: input.harnessTaskId,
344
+ featureId: input.taskSpec.feature_id,
345
+ lineageId: input.lineage.lineageId,
346
+ revisionId: input.digests.revisionId,
347
+ sourceDigest: input.digests.sourceDigest,
348
+ contractDigest: input.digests.contractDigest,
349
+ materializeOutcome: input.materializeOutcome,
350
+ workflow: input.workflow,
351
+ loopAgentProfile: input.loopAgentProfile,
109
352
  taskConfigPath: paths.taskConfigPath,
110
353
  source: {
111
- ...sourcePaths,
112
- // Reference copies make the upstream requirement/design/test cases concrete.
354
+ requirementPath: path.join(paths.sourceDir, "需求.md"),
355
+ constraintsPath: path.join(paths.sourceDir, "执行约束.md"),
356
+ taskYamlPath: path.join(paths.sourceDir, "task.yaml"),
113
357
  referencePaths,
114
358
  },
115
359
  };
116
- await writeTaskArtifactFile(options.repoRoot, harnessTaskId, "materialize-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
117
- return manifest;
360
+ }
361
+ // Re-export lineage helpers used by tests and callers.
362
+ export { expandAllowedPathsForWorkflow } from "./harness-task-lineage.js";
363
+ async function readExistingReferencePaths(sourceDir, taskSpec) {
364
+ const referenceDir = path.join(sourceDir, "references");
365
+ const referencePaths = {};
366
+ for (const key of Object.keys(taskSpec.source_docs)) {
367
+ const candidates = [
368
+ path.join(referenceDir, `${key}.md`),
369
+ path.join(referenceDir, `${key}.yaml`),
370
+ path.join(referenceDir, `${key}.yml`),
371
+ ];
372
+ for (const candidate of candidates) {
373
+ try {
374
+ await readFile(candidate);
375
+ referencePaths[key] = candidate;
376
+ break;
377
+ }
378
+ catch {
379
+ // continue
380
+ }
381
+ }
382
+ }
383
+ return referencePaths;
118
384
  }
119
385
  async function materializeSourceDocs(input) {
120
386
  const referenceDir = path.join(input.sourceDir, "references");
@@ -132,20 +398,6 @@ async function materializeSourceDocs(input) {
132
398
  }
133
399
  return referencePaths;
134
400
  }
135
- export function buildHarnessTaskId(taskSpec, now) {
136
- const date = now.toISOString().slice(0, 10);
137
- const slug = slugify(`${taskSpec.id}-${taskSpec.title}`);
138
- return `${date}-${slug}`;
139
- }
140
- function slugify(value) {
141
- return value
142
- .normalize("NFKD")
143
- .replace(/[\u0300-\u036f]/g, "")
144
- .toLowerCase()
145
- .replace(/[^a-z0-9]+/g, "-")
146
- .replace(/^-+|-+$/g, "")
147
- .replace(/-{2,}/g, "-");
148
- }
149
401
  /** Exported for unit tests: derived contract must point back to immutable references. */
150
402
  async function ensureMaterializedTaskContractManaged(input) {
151
403
  const state = await observeTaskContractState(input.repoRoot, input.taskId);
@@ -217,6 +469,7 @@ export function renderRequirementMarkdown(taskSpec, options = {}) {
217
469
  return `${lines.join("\n").trimEnd()}\n`;
218
470
  }
219
471
  async function buildSourceDocTrace(input) {
472
+ const { createHash } = await import("node:crypto");
220
473
  const entries = [];
221
474
  for (const [key, relativePath] of Object.entries(input.taskSpec.source_docs)) {
222
475
  if (!relativePath)
@@ -351,15 +604,6 @@ function bulletLines(values) {
351
604
  return ["- None"];
352
605
  return values.map((value) => `- ${value}`);
353
606
  }
354
- /** Union TaskSpec allowed_paths with workflow-owned write roots. */
355
- export function expandAllowedPathsForWorkflow(workflow, allowedPaths) {
356
- const extras = workflow === "backend-test"
357
- ? ["testcase/**", "docs/test-reports/**"]
358
- : workflow === "frontend-test"
359
- ? ["testcase/frontend/**", "docs/test-reports/**"]
360
- : [];
361
- return Array.from(new Set([...allowedPaths, ...extras]));
362
- }
363
607
  async function loadExistingTaskConfig(repoRoot, taskId) {
364
608
  try {
365
609
  return await loadTaskConfig(repoRoot, taskId);
@@ -376,3 +620,29 @@ function isNotFound(error) {
376
620
  "code" in error &&
377
621
  error.code === "ENOENT");
378
622
  }
623
+ /**
624
+ * Resolve the bounded harness task locator for a TaskSpec.
625
+ * `now` is accepted for legacy callers but does not affect identity.
626
+ */
627
+ export async function buildHarnessTaskId(taskSpec, _now, taskSpecPath) {
628
+ if (taskSpecPath) {
629
+ const resolved = await resolveRevisionDigestsForTaskSpec({
630
+ taskSpec,
631
+ taskSpecPath,
632
+ });
633
+ return buildHarnessTaskLocatorId(resolved.lineage.lineageId, resolved.digests.revisionId);
634
+ }
635
+ const lineage = buildHarnessTaskLineage(taskSpec);
636
+ const resolvedWorkflow = resolveWorkflow(taskSpec);
637
+ const profileMapping = resolveLoopAgentProfile(taskSpec);
638
+ const allowedPaths = expandAllowedPathsForWorkflow(resolvedWorkflow.workflow, taskSpec.constraints.allowed_paths);
639
+ const { revisionId } = computeRevisionDigests({
640
+ taskSpec,
641
+ workflow: resolvedWorkflow.workflow,
642
+ taskKind: resolvedWorkflow.taskKind,
643
+ loopAgentProfile: profileMapping.loopAgentProfile,
644
+ allowedPaths,
645
+ sourceRefHashes: {},
646
+ });
647
+ return buildHarnessTaskLocatorId(lineage.lineageId, revisionId);
648
+ }
@@ -21,6 +21,7 @@ export function createCompositeProgressReporter(options) {
21
21
  batchRunId: input.batchRunId ?? context.batchRunId,
22
22
  workerRunId: input.workerRunId ?? context.workerRunId,
23
23
  taskId: input.taskId ?? context.taskId,
24
+ featureId: input.featureId ?? context.featureId,
24
25
  }, { now });
25
26
  await eventStore.append(event);
26
27
  }