@tea-agent/loop-agent 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +1 -1
- package/dist/executors/dag-pi-executor.js +69 -2
- package/dist/executors/pi-sdk-executor.js +61 -0
- package/dist/executors/shell-executor.js +136 -79
- package/dist/shared/operator/capabilities.js +22 -1
- package/dist/shared/operator/command-lifecycle.js +94 -0
- package/dist/shared/operator/index.js +1 -0
- package/dist/worker/cli.js +10 -24
- package/dist/worker/console/doctor.js +11 -4
- package/dist/worker/console/observe-health-match.js +18 -15
- package/dist/worker/console/operator-actions.js +2 -1
- package/dist/worker/feature/acceptance-policy.js +227 -0
- package/dist/worker/feature/decision-loader.js +1 -1
- package/dist/worker/feature/next-action.js +56 -6
- package/dist/worker/feature/profile-schema.js +3 -0
- package/dist/worker/feature/reducer.js +1 -0
- package/dist/worker/feature/review.js +72 -8
- package/dist/worker/feature/scaffold.js +14 -0
- package/dist/worker/loop-agent/controller-protocol.js +143 -0
- package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
- package/dist/worker/materialize/harness-task-lineage.js +220 -0
- package/dist/worker/materialize/harness-task-materializer.js +350 -80
- package/dist/worker/observability/progress-composite.js +1 -0
- package/dist/worker/observability/read-model.js +66 -19
- package/dist/worker/pool/attempt-identity.js +41 -0
- package/dist/worker/pool/attempt-lease.js +184 -0
- package/dist/worker/pool/attempt-transition.js +210 -0
- package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
- package/dist/worker/pool/begin-attempt.js +35 -0
- package/dist/worker/pool/failure-routing.js +49 -0
- package/dist/worker/pool/recovery-decision.js +163 -0
- package/dist/worker/pool/run-owner-store.js +126 -0
- package/dist/worker/pool/run-store.js +32 -46
- package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
- package/dist/worker/pool/state-projection.js +57 -0
- package/dist/worker/run-task/run-task.js +31 -4
- package/dist/worker/runner/run-ready.js +64 -14
- package/dist/worker/runner/single-task-attempt.js +42 -13
- package/dist/worker/task-graph/acceptance-schema.js +3 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +162 -7
- package/dist/workflows/dag/backend-test-result-contract.js +105 -67
- package/dist/workflows/dag/backend-test-scenario-param.js +92 -30
- package/dist/workflows/dag/backend-test-writer-completeness.js +55 -0
- package/dist/workflows/dag/init-hybrid.js +44 -47
- package/dist/workflows/dag/rerun-task.js +86 -0
- package/docs/README.md +3 -1
- package/docs/architecture/evolution.md +1 -1
- package/docs/operations/README.md +1 -0
- package/docs/templates/README.md +1 -0
- package/docs/templates/agent-worker-production-readiness-checklist.md +45 -0
- package/docs/templates/backend-test-dag.json +40 -60
- package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
- package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
- package/docs/templates/init-managed-agents.md +1 -1
- package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
- package/package.json +1 -1
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +1 -1
- package/skills/loop-agent/references/command-reference.md +2 -2
- package/skills/loop-agent/references/harness-policy.md +1 -1
|
@@ -6,7 +6,7 @@ import { getTaskPaths } from "../../task/runtime.js";
|
|
|
6
6
|
import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
|
|
7
7
|
import { redactForPreview, truncatePreview } from "../observability/events.js";
|
|
8
8
|
import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
|
|
9
|
-
import {
|
|
9
|
+
import { materializeTaskSpecV2, } from "../materialize/harness-task-materializer.js";
|
|
10
10
|
import { preflightTargetRepo } from "../preflight.js";
|
|
11
11
|
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
12
12
|
import { checkRequiredOutputs } from "../outcomes/gate.js";
|
|
@@ -26,7 +26,12 @@ export async function runTaskSpec(options) {
|
|
|
26
26
|
const commands = [];
|
|
27
27
|
const progress = options.progress ?? noopProgressReporter;
|
|
28
28
|
const taskId = options.taskSpec.id;
|
|
29
|
-
const eventCtx = {
|
|
29
|
+
const eventCtx = {
|
|
30
|
+
progress,
|
|
31
|
+
workerRunId,
|
|
32
|
+
taskId,
|
|
33
|
+
featureId: options.taskSpec.feature_id,
|
|
34
|
+
};
|
|
30
35
|
const recordingClient = new RecordingRunTaskClient(options.client, commands);
|
|
31
36
|
const client = new ObservedRunTaskClient(recordingClient, eventCtx);
|
|
32
37
|
if (options.preflight !== false) {
|
|
@@ -57,9 +62,9 @@ export async function runTaskSpec(options) {
|
|
|
57
62
|
}
|
|
58
63
|
const materializeStep = "materialize";
|
|
59
64
|
const materializeStartedAt = Date.now();
|
|
60
|
-
const
|
|
65
|
+
const materializeResult = await runObservedStep(eventCtx, materializeStep, async () => {
|
|
61
66
|
progress.step("materialize harness task");
|
|
62
|
-
return
|
|
67
|
+
return materializeTaskSpecV2({
|
|
63
68
|
repoRoot: options.repoRoot,
|
|
64
69
|
taskSpec: options.taskSpec,
|
|
65
70
|
taskSpecPath: options.taskSpecPath,
|
|
@@ -67,6 +72,14 @@ export async function runTaskSpec(options) {
|
|
|
67
72
|
now,
|
|
68
73
|
});
|
|
69
74
|
});
|
|
75
|
+
const materializeManifest = materializeResult.manifest;
|
|
76
|
+
const materializeOutcome = materializeResult.outcome;
|
|
77
|
+
if (materializeOutcome.type === "recovery-required") {
|
|
78
|
+
throw new Error(`materialize recovery required (${materializeOutcome.recovery.recommendedAction}): ${materializeOutcome.recovery.reason}`);
|
|
79
|
+
}
|
|
80
|
+
if (materializeOutcome.type === "resume-existing") {
|
|
81
|
+
progress.note(`materialize resume-existing: revision ${materializeOutcome.revisionId}, run ${materializeOutcome.runId}`);
|
|
82
|
+
}
|
|
70
83
|
progress.step(`materialized ${materializeManifest.harnessTaskId} in ${formatDuration(Date.now() - materializeStartedAt)}`);
|
|
71
84
|
const taskArtifactsDir = path.join(options.repoRoot, ".harness", "tasks", materializeManifest.harnessTaskId, "artifacts");
|
|
72
85
|
await mkdir(taskArtifactsDir, { recursive: true });
|
|
@@ -230,6 +243,9 @@ export async function runTaskSpec(options) {
|
|
|
230
243
|
dagPath,
|
|
231
244
|
runRecordPath,
|
|
232
245
|
materializeManifest,
|
|
246
|
+
materializeOutcome,
|
|
247
|
+
lineageId: materializeManifest.lineageId,
|
|
248
|
+
revisionId: materializeManifest.revisionId,
|
|
233
249
|
reportDecision,
|
|
234
250
|
commands,
|
|
235
251
|
...(declaredArtifacts ? { declaredArtifacts } : {}),
|
|
@@ -317,6 +333,9 @@ export async function runTaskSpec(options) {
|
|
|
317
333
|
dagPath,
|
|
318
334
|
runRecordPath,
|
|
319
335
|
materializeManifest,
|
|
336
|
+
materializeOutcome,
|
|
337
|
+
lineageId: materializeManifest.lineageId,
|
|
338
|
+
revisionId: materializeManifest.revisionId,
|
|
320
339
|
reportDecision,
|
|
321
340
|
commands,
|
|
322
341
|
...(declaredArtifacts ? { declaredArtifacts } : {}),
|
|
@@ -329,6 +348,7 @@ export async function runTaskSpec(options) {
|
|
|
329
348
|
source: "artifact",
|
|
330
349
|
label: "worker-run-record",
|
|
331
350
|
workerRunId,
|
|
351
|
+
featureId: options.taskSpec.feature_id,
|
|
332
352
|
taskId,
|
|
333
353
|
artifactRefs: {
|
|
334
354
|
runRecordPath,
|
|
@@ -496,6 +516,7 @@ function emitStepStarted(ctx, stepName) {
|
|
|
496
516
|
spanId: `step:${ctx.workerRunId}:${stepName}`,
|
|
497
517
|
parentSpanId: ctx.workerRunId,
|
|
498
518
|
workerRunId: ctx.workerRunId,
|
|
519
|
+
featureId: ctx.featureId,
|
|
499
520
|
taskId: ctx.taskId,
|
|
500
521
|
status: "running",
|
|
501
522
|
});
|
|
@@ -508,6 +529,7 @@ function emitStepFinished(ctx, stepName, status, durationMs) {
|
|
|
508
529
|
spanId: `step:${ctx.workerRunId}:${stepName}`,
|
|
509
530
|
parentSpanId: ctx.workerRunId,
|
|
510
531
|
workerRunId: ctx.workerRunId,
|
|
532
|
+
featureId: ctx.featureId,
|
|
511
533
|
taskId: ctx.taskId,
|
|
512
534
|
status,
|
|
513
535
|
durationMs,
|
|
@@ -760,6 +782,7 @@ class ObservedRunTaskClient {
|
|
|
760
782
|
source: "loop-agent-command",
|
|
761
783
|
label: options.artifactName,
|
|
762
784
|
workerRunId: this.eventCtx.workerRunId,
|
|
785
|
+
featureId: this.eventCtx.featureId,
|
|
763
786
|
taskId: this.eventCtx.taskId,
|
|
764
787
|
pid: info.pid,
|
|
765
788
|
timeoutMs: options.timeoutMs,
|
|
@@ -783,6 +806,7 @@ class ObservedRunTaskClient {
|
|
|
783
806
|
source: "loop-agent-command",
|
|
784
807
|
label: options.artifactName,
|
|
785
808
|
workerRunId: this.eventCtx.workerRunId,
|
|
809
|
+
featureId: this.eventCtx.featureId,
|
|
786
810
|
taskId: this.eventCtx.taskId,
|
|
787
811
|
message: `elapsed ${info.elapsedMs}ms`,
|
|
788
812
|
});
|
|
@@ -795,6 +819,7 @@ class ObservedRunTaskClient {
|
|
|
795
819
|
source: "loop-agent-command",
|
|
796
820
|
label: options.artifactName,
|
|
797
821
|
workerRunId: this.eventCtx.workerRunId,
|
|
822
|
+
featureId: this.eventCtx.featureId,
|
|
798
823
|
taskId: this.eventCtx.taskId,
|
|
799
824
|
exitCode: result.exitCode,
|
|
800
825
|
timedOut: result.timedOut,
|
|
@@ -810,6 +835,7 @@ class ObservedRunTaskClient {
|
|
|
810
835
|
source: "loop-agent-command",
|
|
811
836
|
label: options.artifactName,
|
|
812
837
|
workerRunId: this.eventCtx.workerRunId,
|
|
838
|
+
featureId: this.eventCtx.featureId,
|
|
813
839
|
taskId: this.eventCtx.taskId,
|
|
814
840
|
status: "failed",
|
|
815
841
|
message: errorMessage(error),
|
|
@@ -823,6 +849,7 @@ class ObservedRunTaskClient {
|
|
|
823
849
|
source: "loop-agent-command",
|
|
824
850
|
label,
|
|
825
851
|
workerRunId: this.eventCtx.workerRunId,
|
|
852
|
+
featureId: this.eventCtx.featureId,
|
|
826
853
|
taskId: this.eventCtx.taskId,
|
|
827
854
|
outputPreview: redactForPreview(truncatePreview(chunk)),
|
|
828
855
|
});
|
|
@@ -2,9 +2,13 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import YAML from "yaml";
|
|
5
|
+
import { controllerIdentityAnchor } from "../../shared/package-metadata.js";
|
|
5
6
|
import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
|
|
7
|
+
import { buildPublishedControllerProtocolEnvelope } from "../loop-agent/controller-protocol.js";
|
|
6
8
|
import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
|
|
7
9
|
import { findRunByWorkerRunId, getTaskPoolRoot, readFeatureTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
|
|
10
|
+
import { releaseAttemptLease } from "../pool/attempt-lease.js";
|
|
11
|
+
import { beginWorkerAttemptWithLease } from "../pool/begin-attempt-with-lease.js";
|
|
8
12
|
import { readVerifiedOutcome } from "../outcomes/store.js";
|
|
9
13
|
import { runTaskSpec, } from "../run-task/run-task.js";
|
|
10
14
|
import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
|
|
@@ -100,6 +104,7 @@ export async function runReadyTasks(options) {
|
|
|
100
104
|
source: "worker",
|
|
101
105
|
label: `task ${taskId}: reuse existing run ${workerRunId}`,
|
|
102
106
|
batchRunId,
|
|
107
|
+
featureId,
|
|
103
108
|
taskId,
|
|
104
109
|
workerRunId,
|
|
105
110
|
status: "reused",
|
|
@@ -116,32 +121,44 @@ export async function runReadyTasks(options) {
|
|
|
116
121
|
}
|
|
117
122
|
const taskStartedAt = Date.now();
|
|
118
123
|
progress.task(`task ${index}/${total} ${taskId} "${taskSpec.title}" (${workerRunId})`);
|
|
119
|
-
emit(progress, {
|
|
120
|
-
type: "task.started",
|
|
121
|
-
source: "worker",
|
|
122
|
-
label: `task ${index}/${total} ${taskId}`,
|
|
123
|
-
batchRunId,
|
|
124
|
-
taskId,
|
|
125
|
-
workerRunId,
|
|
126
|
-
status: "running",
|
|
127
|
-
});
|
|
128
124
|
progress.step(`materialize → task advance → report`);
|
|
129
125
|
let result;
|
|
126
|
+
let leaseToken;
|
|
130
127
|
try {
|
|
131
|
-
await
|
|
132
|
-
|
|
128
|
+
const begun = await beginWorkerAttemptWithLease({
|
|
129
|
+
repoRoot: options.repoRoot,
|
|
130
|
+
featureId,
|
|
131
|
+
taskId,
|
|
132
|
+
workerRunId,
|
|
133
|
+
batchRunId,
|
|
134
|
+
now,
|
|
135
|
+
...(controllerIdentity
|
|
136
|
+
? {
|
|
137
|
+
controllerAnchor: controllerIdentityAnchor(controllerIdentity),
|
|
138
|
+
protocolEnvelope: buildPublishedControllerProtocolEnvelope(controllerIdentity.packageVersion),
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
stateExtras: {
|
|
142
|
+
...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
leaseToken = begun.leaseRegistration.lease.token;
|
|
146
|
+
emit(progress, {
|
|
147
|
+
type: "task.started",
|
|
148
|
+
source: "worker",
|
|
149
|
+
label: `task ${index}/${total} ${taskId}`,
|
|
150
|
+
batchRunId,
|
|
133
151
|
featureId,
|
|
134
152
|
taskId,
|
|
135
|
-
status: "Running",
|
|
136
|
-
updatedAt: new Date().toISOString(),
|
|
137
153
|
workerRunId,
|
|
138
|
-
|
|
154
|
+
status: "running",
|
|
139
155
|
});
|
|
140
156
|
emit(progress, {
|
|
141
157
|
type: "state.updated",
|
|
142
158
|
source: "worker",
|
|
143
159
|
label: `task pool state: ${taskId} → Running`,
|
|
144
160
|
batchRunId,
|
|
161
|
+
featureId,
|
|
145
162
|
taskId,
|
|
146
163
|
workerRunId,
|
|
147
164
|
status: "running",
|
|
@@ -178,6 +195,7 @@ export async function runReadyTasks(options) {
|
|
|
178
195
|
source: "worker",
|
|
179
196
|
label: `task ${taskId} run-error`,
|
|
180
197
|
batchRunId,
|
|
198
|
+
featureId,
|
|
181
199
|
taskId,
|
|
182
200
|
workerRunId,
|
|
183
201
|
status: "failed",
|
|
@@ -215,6 +233,16 @@ export async function runReadyTasks(options) {
|
|
|
215
233
|
}
|
|
216
234
|
if (recorded)
|
|
217
235
|
await options.onTaskFinalized?.({ status: "run-error", taskSpec, workerRunId });
|
|
236
|
+
if (leaseToken) {
|
|
237
|
+
await releaseAttemptLease({
|
|
238
|
+
repoRoot: options.repoRoot,
|
|
239
|
+
featureId,
|
|
240
|
+
taskId,
|
|
241
|
+
token: leaseToken,
|
|
242
|
+
workerRunId,
|
|
243
|
+
now,
|
|
244
|
+
}).catch(() => { });
|
|
245
|
+
}
|
|
218
246
|
tasks.push({
|
|
219
247
|
taskId,
|
|
220
248
|
workerRunId,
|
|
@@ -236,6 +264,7 @@ export async function runReadyTasks(options) {
|
|
|
236
264
|
source: "worker",
|
|
237
265
|
label: `task ${taskId} ${result.status}`,
|
|
238
266
|
batchRunId,
|
|
267
|
+
featureId,
|
|
239
268
|
taskId,
|
|
240
269
|
workerRunId: result.workerRunId,
|
|
241
270
|
status: result.status,
|
|
@@ -247,6 +276,7 @@ export async function runReadyTasks(options) {
|
|
|
247
276
|
source: "worker",
|
|
248
277
|
label: `failure routed: ${failure.category}`,
|
|
249
278
|
batchRunId,
|
|
279
|
+
featureId,
|
|
250
280
|
taskId,
|
|
251
281
|
workerRunId: result.workerRunId,
|
|
252
282
|
failureCategory: failure.category,
|
|
@@ -299,6 +329,16 @@ export async function runReadyTasks(options) {
|
|
|
299
329
|
status: result.status,
|
|
300
330
|
runRecordPath: result.runRecordPath,
|
|
301
331
|
});
|
|
332
|
+
if (leaseToken) {
|
|
333
|
+
await releaseAttemptLease({
|
|
334
|
+
repoRoot: options.repoRoot,
|
|
335
|
+
featureId,
|
|
336
|
+
taskId,
|
|
337
|
+
token: leaseToken,
|
|
338
|
+
workerRunId: result.workerRunId,
|
|
339
|
+
now,
|
|
340
|
+
}).catch(() => { });
|
|
341
|
+
}
|
|
302
342
|
}
|
|
303
343
|
catch (error) {
|
|
304
344
|
if (result.status !== "succeeded") {
|
|
@@ -344,6 +384,16 @@ export async function runReadyTasks(options) {
|
|
|
344
384
|
catch {
|
|
345
385
|
// Best-effort; batch still reports record-error.
|
|
346
386
|
}
|
|
387
|
+
if (leaseToken) {
|
|
388
|
+
await releaseAttemptLease({
|
|
389
|
+
repoRoot: options.repoRoot,
|
|
390
|
+
featureId,
|
|
391
|
+
taskId,
|
|
392
|
+
token: leaseToken,
|
|
393
|
+
workerRunId: result.workerRunId,
|
|
394
|
+
now,
|
|
395
|
+
}).catch(() => { });
|
|
396
|
+
}
|
|
347
397
|
tasks.push({
|
|
348
398
|
taskId,
|
|
349
399
|
workerRunId: result.workerRunId,
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { controllerIdentityAnchor } from "../../shared/package-metadata.js";
|
|
2
|
+
import { buildPublishedControllerProtocolEnvelope } from "../loop-agent/controller-protocol.js";
|
|
3
|
+
import { releaseAttemptLease } from "../pool/attempt-lease.js";
|
|
1
4
|
import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
|
|
5
|
+
import { beginWorkerAttemptWithLease } from "../pool/begin-attempt-with-lease.js";
|
|
2
6
|
import { recordTaskPoolRun, writeTaskPoolState } from "../pool/run-store.js";
|
|
3
7
|
/**
|
|
4
8
|
* Shared Worker attempt used by batch run-ready and Night Scheduler.
|
|
@@ -7,24 +11,33 @@ import { recordTaskPoolRun, writeTaskPoolState } from "../pool/run-store.js";
|
|
|
7
11
|
export async function runSingleTaskAttempt(input) {
|
|
8
12
|
const now = input.ctx.now ?? new Date();
|
|
9
13
|
const { ctx } = input;
|
|
10
|
-
await
|
|
11
|
-
|
|
14
|
+
const begun = await beginWorkerAttemptWithLease({
|
|
15
|
+
repoRoot: ctx.controlRepoRoot,
|
|
12
16
|
featureId: ctx.featureId,
|
|
13
17
|
taskId: ctx.taskId,
|
|
14
|
-
status: "Running",
|
|
15
|
-
updatedAt: now.toISOString(),
|
|
16
18
|
workerRunId: ctx.workerRunId,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
? { nightWorktreePath: ctx.night.nightWorktreePath }
|
|
19
|
+
batchRunId: ctx.batchRunId,
|
|
20
|
+
now,
|
|
21
|
+
...(ctx.controllerIdentity
|
|
22
|
+
? {
|
|
23
|
+
controllerAnchor: controllerIdentityAnchor(ctx.controllerIdentity),
|
|
24
|
+
protocolEnvelope: buildPublishedControllerProtocolEnvelope(ctx.controllerIdentity.packageVersion),
|
|
25
|
+
}
|
|
25
26
|
: {}),
|
|
26
|
-
|
|
27
|
+
stateExtras: {
|
|
28
|
+
...(ctx.night?.nightScheduleId
|
|
29
|
+
? { nightScheduleId: ctx.night.nightScheduleId }
|
|
30
|
+
: {}),
|
|
31
|
+
...(ctx.night?.admissionPath
|
|
32
|
+
? { admissionPath: ctx.night.admissionPath }
|
|
33
|
+
: {}),
|
|
34
|
+
...(ctx.night?.nightWorktreePath
|
|
35
|
+
? { nightWorktreePath: ctx.night.nightWorktreePath }
|
|
36
|
+
: {}),
|
|
37
|
+
...(ctx.night?.nightBranch ? { nightBranch: ctx.night.nightBranch } : {}),
|
|
38
|
+
},
|
|
27
39
|
});
|
|
40
|
+
const leaseToken = begun.leaseRegistration.lease.token;
|
|
28
41
|
let result;
|
|
29
42
|
try {
|
|
30
43
|
result = await input.execute(ctx);
|
|
@@ -70,6 +83,14 @@ export async function runSingleTaskAttempt(input) {
|
|
|
70
83
|
: {}),
|
|
71
84
|
...(ctx.night?.nightBranch ? { nightBranch: ctx.night.nightBranch } : {}),
|
|
72
85
|
});
|
|
86
|
+
await releaseAttemptLease({
|
|
87
|
+
repoRoot: ctx.controlRepoRoot,
|
|
88
|
+
featureId: ctx.featureId,
|
|
89
|
+
taskId: ctx.taskId,
|
|
90
|
+
token: leaseToken,
|
|
91
|
+
workerRunId: ctx.workerRunId,
|
|
92
|
+
now,
|
|
93
|
+
}).catch(() => { });
|
|
73
94
|
return {
|
|
74
95
|
status: "run-error",
|
|
75
96
|
workerRunId: ctx.workerRunId,
|
|
@@ -137,6 +158,14 @@ export async function runSingleTaskAttempt(input) {
|
|
|
137
158
|
...(ctx.night?.nightBranch ? { nightBranch: ctx.night.nightBranch } : {}),
|
|
138
159
|
};
|
|
139
160
|
await writeTaskPoolState(ctx.controlRepoRoot, poolState);
|
|
161
|
+
await releaseAttemptLease({
|
|
162
|
+
repoRoot: ctx.controlRepoRoot,
|
|
163
|
+
featureId: ctx.featureId,
|
|
164
|
+
taskId: ctx.taskId,
|
|
165
|
+
token: leaseToken,
|
|
166
|
+
workerRunId: ctx.workerRunId,
|
|
167
|
+
now,
|
|
168
|
+
}).catch(() => { });
|
|
140
169
|
return {
|
|
141
170
|
status: result.status,
|
|
142
171
|
workerRunId: result.workerRunId,
|
|
@@ -21,6 +21,9 @@ export const acceptanceItemSchema = z
|
|
|
21
21
|
implementation_task_refs: z.array(z.string().min(1)).optional(),
|
|
22
22
|
verification_task_refs: z.array(z.string().min(1)).optional(),
|
|
23
23
|
required_evidence: z.array(z.string().min(1)).optional(),
|
|
24
|
+
/** Phase 6: ADR 0007 authority / evidence kinds on required ACs. */
|
|
25
|
+
authority: z.string().min(1).optional(),
|
|
26
|
+
required_evidence_kinds: z.array(z.string().min(1)).optional(),
|
|
24
27
|
integration: z
|
|
25
28
|
.enum(["not-applicable", "mock-allowed", "real-required"])
|
|
26
29
|
.optional(),
|
|
@@ -11,12 +11,16 @@ export const backendPytestCollectionFindingSchema = z.object({
|
|
|
11
11
|
detail: z.string().min(1),
|
|
12
12
|
}).strict();
|
|
13
13
|
export const backendPytestCollectionFactsSchema = z.object({
|
|
14
|
-
schemaId: z.literal("backend-test-pytest-collection-
|
|
14
|
+
schemaId: z.literal("backend-test-pytest-collection-v3"),
|
|
15
15
|
phase: z.enum(["initial", "final", "effective"]),
|
|
16
16
|
status: z.enum(["PASS", "REPAIRABLE", "BLOCKED"]),
|
|
17
17
|
repairEligible: z.boolean(),
|
|
18
18
|
repairAttempt: z.number().int().min(0).max(1),
|
|
19
19
|
collectionAttempted: z.boolean(),
|
|
20
|
+
fixtureResolutionAttempted: z.boolean(),
|
|
21
|
+
fixtureResolutionStatus: z.enum(["NOT_RUN", "PASS", "REPAIRABLE", "BLOCKED"]),
|
|
22
|
+
fixtureResolutionExitCode: z.number().int().nullable(),
|
|
23
|
+
repairPaths: z.array(z.string()),
|
|
20
24
|
mappedScripts: z.array(z.string()).min(1),
|
|
21
25
|
existingMappedScripts: z.array(z.string()),
|
|
22
26
|
missingMappedScripts: z.array(z.string()),
|
|
@@ -28,17 +32,37 @@ export const backendPytestCollectionFactsSchema = z.object({
|
|
|
28
32
|
findings: z.array(backendPytestCollectionFindingSchema),
|
|
29
33
|
stdoutExcerpt: z.string(),
|
|
30
34
|
stderrExcerpt: z.string(),
|
|
35
|
+
fixtureStdoutExcerpt: z.string(),
|
|
36
|
+
fixtureStderrExcerpt: z.string(),
|
|
31
37
|
collectionSource: z.enum(["initial", "final"]).optional(),
|
|
32
38
|
}).strict().superRefine((facts, context) => {
|
|
33
39
|
if (facts.status === "PASS") {
|
|
34
|
-
if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
|
|
35
|
-
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires
|
|
40
|
+
if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || !facts.fixtureResolutionAttempted || facts.fixtureResolutionStatus !== "PASS" || facts.fixtureResolutionExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
|
|
41
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires collection and fixture-resolution PASS, complete mapped scripts and bound assets" });
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
if (!facts.collectionAttempted && facts.pytestExitCode !== null) {
|
|
39
45
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection without an attempt cannot have an exit code" });
|
|
40
46
|
}
|
|
47
|
+
if (!facts.fixtureResolutionAttempted && facts.fixtureResolutionExitCode !== null) {
|
|
48
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
|
|
49
|
+
}
|
|
41
50
|
});
|
|
51
|
+
export const backendTestExecutionReadinessSchema = z.object({
|
|
52
|
+
schemaId: z.literal("backend-test-execution-readiness-v1"),
|
|
53
|
+
status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
|
|
54
|
+
collectionStatus: z.literal("PASS"),
|
|
55
|
+
fixtureResolutionStatus: z.literal("PASS"),
|
|
56
|
+
scenarioParamStatus: z.enum(["PASS", "PARTIAL", "FAIL", "UNAVAILABLE"]),
|
|
57
|
+
repairAttempts: z.object({
|
|
58
|
+
collection: z.number().int().min(0).max(1),
|
|
59
|
+
scenarioParam: z.number().int().min(0).max(1),
|
|
60
|
+
}).strict(),
|
|
61
|
+
mappedScripts: z.array(z.string()).min(1),
|
|
62
|
+
collectedItemIds: z.array(z.string()),
|
|
63
|
+
fixtureIssues: z.array(z.string()),
|
|
64
|
+
assetHashes: z.record(z.string(), z.string().regex(SHA256)),
|
|
65
|
+
}).strict();
|
|
42
66
|
function repoRef(workspaceRoot, absolutePath) {
|
|
43
67
|
return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
|
|
44
68
|
}
|
|
@@ -106,6 +130,28 @@ function collectionItems(stdout) {
|
|
|
106
130
|
return /^testcase\//.test(line.replaceAll("\\", "/"));
|
|
107
131
|
}).map((line) => line.replaceAll("\\", "/")))];
|
|
108
132
|
}
|
|
133
|
+
function testcasePythonPaths(output) {
|
|
134
|
+
return [...new Set((output.replaceAll("\\", "/").match(/testcase\/[A-Za-z0-9_./-]+\.py/gi) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
|
|
135
|
+
}
|
|
136
|
+
function classifyFixtureResolutionFailure(output) {
|
|
137
|
+
const normalized = output.replaceAll("\\", "/");
|
|
138
|
+
const paths = testcasePythonPaths(normalized);
|
|
139
|
+
const generatedProviderPaths = paths.filter((item) => /testcase\/(?:helpers|factories)\//.test(item));
|
|
140
|
+
if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
|
|
141
|
+
return {
|
|
142
|
+
status: "REPAIRABLE",
|
|
143
|
+
kind: "missing-generated-fixture",
|
|
144
|
+
detail: "generated pytest fixture dependency or plugin registration is incomplete",
|
|
145
|
+
repairPaths: generatedProviderPaths,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
status: "BLOCKED",
|
|
150
|
+
kind: "unresolved-fixture-dependency",
|
|
151
|
+
detail: "fixture resolution failed without a safely attributable generated provider",
|
|
152
|
+
repairPaths: paths,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
109
155
|
function classifyCollectionFailure(output) {
|
|
110
156
|
const normalized = output.replaceAll("\\", "/");
|
|
111
157
|
const blockedPatterns = [
|
|
@@ -134,14 +180,19 @@ function classifyCollectionFailure(output) {
|
|
|
134
180
|
}
|
|
135
181
|
export function assessBackendPytestCollection(input) {
|
|
136
182
|
const items = collectionItems(input.stdout);
|
|
137
|
-
|
|
183
|
+
const fixtureResolution = input.fixtureResolution ?? { exitCode: 0, stdout: "fixture resolution assumed by direct assessor", stderr: "" };
|
|
184
|
+
if (input.exitCode === 0 && fixtureResolution.exitCode === 0) {
|
|
138
185
|
return backendPytestCollectionFactsSchema.parse({
|
|
139
|
-
schemaId: "backend-test-pytest-collection-
|
|
186
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
140
187
|
phase: input.phase,
|
|
141
188
|
status: "PASS",
|
|
142
189
|
repairEligible: false,
|
|
143
190
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
144
191
|
collectionAttempted: true,
|
|
192
|
+
fixtureResolutionAttempted: true,
|
|
193
|
+
fixtureResolutionStatus: "PASS",
|
|
194
|
+
fixtureResolutionExitCode: 0,
|
|
195
|
+
repairPaths: [],
|
|
145
196
|
mappedScripts: input.inventory.mappedScripts,
|
|
146
197
|
existingMappedScripts: input.inventory.mappedScripts,
|
|
147
198
|
missingMappedScripts: [],
|
|
@@ -153,16 +204,55 @@ export function assessBackendPytestCollection(input) {
|
|
|
153
204
|
findings: [],
|
|
154
205
|
stdoutExcerpt: bounded(input.stdout),
|
|
155
206
|
stderrExcerpt: bounded(input.stderr),
|
|
207
|
+
fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
|
|
208
|
+
fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (input.exitCode === 0) {
|
|
212
|
+
const classification = classifyFixtureResolutionFailure(`${fixtureResolution.stdout}\n${fixtureResolution.stderr}`);
|
|
213
|
+
return backendPytestCollectionFactsSchema.parse({
|
|
214
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
215
|
+
phase: input.phase,
|
|
216
|
+
status: classification.status,
|
|
217
|
+
repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
|
|
218
|
+
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
219
|
+
collectionAttempted: true,
|
|
220
|
+
fixtureResolutionAttempted: true,
|
|
221
|
+
fixtureResolutionStatus: classification.status,
|
|
222
|
+
fixtureResolutionExitCode: fixtureResolution.exitCode,
|
|
223
|
+
repairPaths: classification.repairPaths,
|
|
224
|
+
mappedScripts: input.inventory.mappedScripts,
|
|
225
|
+
existingMappedScripts: input.inventory.mappedScripts,
|
|
226
|
+
missingMappedScripts: [],
|
|
227
|
+
assetFiles: input.inventory.assetFiles,
|
|
228
|
+
inputHashes: input.inventory.inputHashes,
|
|
229
|
+
pytestExitCode: input.exitCode,
|
|
230
|
+
collectedItemCount: items.length,
|
|
231
|
+
collectedItemIds: items,
|
|
232
|
+
findings: [{
|
|
233
|
+
kind: classification.kind,
|
|
234
|
+
classification: "test-asset-defect",
|
|
235
|
+
repairability: classification.status === "REPAIRABLE" ? "repairable" : "blocked",
|
|
236
|
+
detail: classification.detail,
|
|
237
|
+
}],
|
|
238
|
+
stdoutExcerpt: bounded(input.stdout),
|
|
239
|
+
stderrExcerpt: bounded(input.stderr),
|
|
240
|
+
fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
|
|
241
|
+
fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
|
|
156
242
|
});
|
|
157
243
|
}
|
|
158
244
|
const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
|
|
159
245
|
return backendPytestCollectionFactsSchema.parse({
|
|
160
|
-
schemaId: "backend-test-pytest-collection-
|
|
246
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
161
247
|
phase: input.phase,
|
|
162
248
|
status: classification.status,
|
|
163
249
|
repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
|
|
164
250
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
165
251
|
collectionAttempted: true,
|
|
252
|
+
fixtureResolutionAttempted: false,
|
|
253
|
+
fixtureResolutionStatus: "NOT_RUN",
|
|
254
|
+
fixtureResolutionExitCode: null,
|
|
255
|
+
repairPaths: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
|
|
166
256
|
mappedScripts: input.inventory.mappedScripts,
|
|
167
257
|
existingMappedScripts: input.inventory.mappedScripts,
|
|
168
258
|
missingMappedScripts: [],
|
|
@@ -179,16 +269,22 @@ export function assessBackendPytestCollection(input) {
|
|
|
179
269
|
}],
|
|
180
270
|
stdoutExcerpt: bounded(input.stdout),
|
|
181
271
|
stderrExcerpt: bounded(input.stderr),
|
|
272
|
+
fixtureStdoutExcerpt: "",
|
|
273
|
+
fixtureStderrExcerpt: "",
|
|
182
274
|
});
|
|
183
275
|
}
|
|
184
276
|
export function assessMissingBackendPytestScripts(input) {
|
|
185
277
|
return backendPytestCollectionFactsSchema.parse({
|
|
186
|
-
schemaId: "backend-test-pytest-collection-
|
|
278
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
187
279
|
phase: "initial",
|
|
188
280
|
status: "REPAIRABLE",
|
|
189
281
|
repairEligible: true,
|
|
190
282
|
repairAttempt: 0,
|
|
191
283
|
collectionAttempted: false,
|
|
284
|
+
fixtureResolutionAttempted: false,
|
|
285
|
+
fixtureResolutionStatus: "NOT_RUN",
|
|
286
|
+
fixtureResolutionExitCode: null,
|
|
287
|
+
repairPaths: [...input.missingMappedScripts],
|
|
192
288
|
mappedScripts: [...input.mappedScripts],
|
|
193
289
|
existingMappedScripts: [...input.existingMappedScripts],
|
|
194
290
|
missingMappedScripts: [...input.missingMappedScripts],
|
|
@@ -205,6 +301,8 @@ export function assessMissingBackendPytestScripts(input) {
|
|
|
205
301
|
})),
|
|
206
302
|
stdoutExcerpt: "",
|
|
207
303
|
stderrExcerpt: "",
|
|
304
|
+
fixtureStdoutExcerpt: "",
|
|
305
|
+
fixtureStderrExcerpt: "",
|
|
208
306
|
});
|
|
209
307
|
}
|
|
210
308
|
export function renderBackendPytestCollectionReport(facts) {
|
|
@@ -219,6 +317,10 @@ export function renderBackendPytestCollectionReport(facts) {
|
|
|
219
317
|
`- Repair attempt: ${facts.repairAttempt}`,
|
|
220
318
|
`- Collection attempted: ${facts.collectionAttempted}`,
|
|
221
319
|
`- Pytest exit code: ${facts.pytestExitCode ?? "not-run"}`,
|
|
320
|
+
`- Fixture resolution attempted: ${facts.fixtureResolutionAttempted}`,
|
|
321
|
+
`- Fixture resolution status: ${facts.fixtureResolutionStatus}`,
|
|
322
|
+
`- Fixture resolution exit code: ${facts.fixtureResolutionExitCode ?? "not-run"}`,
|
|
323
|
+
`- Repair paths: ${facts.repairPaths.join(", ") || "none"}`,
|
|
222
324
|
`- Mapped scripts: ${facts.mappedScripts.length}`,
|
|
223
325
|
`- Existing mapped scripts: ${facts.existingMappedScripts.length}`,
|
|
224
326
|
`- Missing mapped scripts: ${facts.missingMappedScripts.length}`,
|
|
@@ -245,6 +347,18 @@ export function renderBackendPytestCollectionReport(facts) {
|
|
|
245
347
|
facts.stderrExcerpt,
|
|
246
348
|
"```",
|
|
247
349
|
"",
|
|
350
|
+
"## Fixture Resolution stdout",
|
|
351
|
+
"",
|
|
352
|
+
"```text",
|
|
353
|
+
facts.fixtureStdoutExcerpt,
|
|
354
|
+
"```",
|
|
355
|
+
"",
|
|
356
|
+
"## Fixture Resolution stderr",
|
|
357
|
+
"",
|
|
358
|
+
"```text",
|
|
359
|
+
facts.fixtureStderrExcerpt,
|
|
360
|
+
"```",
|
|
361
|
+
"",
|
|
248
362
|
].join("\n");
|
|
249
363
|
}
|
|
250
364
|
export async function writeBackendPytestCollectionArtifacts(input) {
|
|
@@ -289,6 +403,47 @@ function assertSameInventory(expected, actual) {
|
|
|
289
403
|
throw new Error(`backend pytest collection hash drift: ${file}`);
|
|
290
404
|
}
|
|
291
405
|
}
|
|
406
|
+
export async function materializeBackendTestExecutionReadiness(input) {
|
|
407
|
+
if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
|
|
408
|
+
throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
|
|
409
|
+
}
|
|
410
|
+
const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
|
|
411
|
+
assertSameInventory(input.effective, current);
|
|
412
|
+
const status = input.scenarioParamStatus === "FAIL"
|
|
413
|
+
? "BLOCKED"
|
|
414
|
+
: input.scenarioParamStatus === "PASS"
|
|
415
|
+
? "PASS"
|
|
416
|
+
: "PARTIAL";
|
|
417
|
+
const readiness = backendTestExecutionReadinessSchema.parse({
|
|
418
|
+
schemaId: "backend-test-execution-readiness-v1",
|
|
419
|
+
status,
|
|
420
|
+
collectionStatus: "PASS",
|
|
421
|
+
fixtureResolutionStatus: "PASS",
|
|
422
|
+
scenarioParamStatus: input.scenarioParamStatus,
|
|
423
|
+
repairAttempts: {
|
|
424
|
+
collection: input.effective.repairAttempt,
|
|
425
|
+
scenarioParam: input.scenarioParamRepairAttempt,
|
|
426
|
+
},
|
|
427
|
+
mappedScripts: input.effective.mappedScripts,
|
|
428
|
+
collectedItemIds: input.effective.collectedItemIds,
|
|
429
|
+
fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
|
|
430
|
+
assetHashes: input.effective.inputHashes,
|
|
431
|
+
});
|
|
432
|
+
const contractsDir = path.join(input.runDir, "contracts");
|
|
433
|
+
await mkdir(contractsDir, { recursive: true });
|
|
434
|
+
await writeFile(path.join(contractsDir, "backend-test-execution-readiness.json"), `${JSON.stringify(readiness, null, 2)}\n`, "utf8");
|
|
435
|
+
return readiness;
|
|
436
|
+
}
|
|
437
|
+
export async function readBackendTestExecutionReadiness(filePath) {
|
|
438
|
+
return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
|
|
439
|
+
}
|
|
440
|
+
export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
|
|
441
|
+
if (!["PASS", "PARTIAL"].includes(readiness.status)) {
|
|
442
|
+
throw new Error(`backend pytest execution readiness is ${readiness.status}`);
|
|
443
|
+
}
|
|
444
|
+
const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
|
|
445
|
+
assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
|
|
446
|
+
}
|
|
292
447
|
export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
|
|
293
448
|
if (effective.phase !== "effective" || effective.status !== "PASS") {
|
|
294
449
|
throw new Error("backend pytest execution requires effective collection PASS facts");
|