@tea-agent/loop-agent 0.35.2 → 0.35.4-beta.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 +2 -0
- package/CHANGELOG.md +43 -1
- package/README.md +1 -1
- package/bin/loop-agent.js +37 -1
- package/dist/build-stamp.json +6 -0
- package/dist/cli/program.js +2 -2
- package/dist/executors/dag-pi-executor.js +44 -0
- package/dist/shared/package-metadata.js +42 -0
- package/dist/worker/console/chat/assistant-content.js +11 -0
- package/dist/worker/console/chat/pi-runtime.js +6 -2
- package/dist/worker/console/chat/turn-process.js +17 -9
- package/dist/worker/console/chat/workspace-landing.js +1 -1
- package/dist/worker/console/static/assets/index-DuVLjCIT.js +57 -0
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +15 -3
- package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +1 -0
- package/dist/worker/loop-agent/loop-agent-client.js +17 -3
- package/dist/worker/observability/read-model.js +20 -0
- package/dist/worker/observe/spec-evidence.js +3 -8
- package/dist/worker/observe/static/views/dag-inspector.js +6 -71
- package/dist/worker/preflight.js +2 -1
- package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
- package/dist/workflows/dag/contract-output-registry.js +14 -0
- package/dist/workflows/dag/contract-validator-registrations.js +8 -0
- package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
- package/dist/workflows/dag/failure-routing.js +9 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
- package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
- package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
- package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
- package/dist/workflows/dag/frontend-recovery-run.js +539 -0
- package/dist/workflows/dag/frontend-repair.js +219 -18
- package/dist/workflows/dag/frontend-verification-trace.js +47 -32
- package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
- package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
- package/dist/workflows/dag/init-hybrid.js +49 -24
- package/dist/workflows/dag/lifecycle.js +4 -0
- package/dist/workflows/dag/node-execution.js +89 -0
- package/dist/workflows/dag/recovery-recommendation.js +58 -0
- package/dist/workflows/dag/report.js +6 -0
- package/dist/workflows/dag/runner.js +245 -11
- package/dist/workflows/dag/scheduler.js +257 -3
- package/dist/workflows/dag/types.js +130 -2
- package/docs/templates/frontend-task-constraints.md +13 -7
- package/package.json +4 -3
- package/dist/worker/console/static/assets/index-gVHrlqI9.js +0 -56
|
@@ -1,9 +1,125 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { isOpenspecSpecFilePath } from "../../shared/openspec-spec.js";
|
|
4
|
-
import {
|
|
5
|
+
import { analyzeFrontendImplementationContract, writeFrontendImplementationContractArtifact, writeDeterministicJsonArtifact, assertFrontendSourceBindingFresh, deterministicSha256, sha256Hex, FrontendContractFailure, frontendNormalizationActionSchema, } from "./frontend-implementation-contract.js";
|
|
5
6
|
import { captureFrontendWorktreeBaseline } from "./frontend-worktree-diff.js";
|
|
6
7
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
8
|
+
export const FRONTEND_PREWRITE_RESULT_SCHEMA_ID = "frontend-prewrite-result-v1";
|
|
9
|
+
export const FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME = "frontend-prewrite-result.json";
|
|
10
|
+
/**
|
|
11
|
+
* Machine-readable failure classification for blocked/retryable-invalid
|
|
12
|
+
* prewrite outcomes. Kept alongside the human-readable failureReason so
|
|
13
|
+
* downstream routing (repair prompting, dashboards, retry policies) can act on
|
|
14
|
+
* a stable code instead of parsing prose.
|
|
15
|
+
*/
|
|
16
|
+
export const frontendPrewriteFailureCodeSchema = z.enum([
|
|
17
|
+
"plan-text-unreadable",
|
|
18
|
+
"review-text-unreadable",
|
|
19
|
+
"verdict-not-pass",
|
|
20
|
+
"missing-requirement-ids",
|
|
21
|
+
"source-binding-not-fresh",
|
|
22
|
+
"contract-invalid",
|
|
23
|
+
"contract-blocked",
|
|
24
|
+
"mock-strategy-outside-allowed",
|
|
25
|
+
"mock-strategy-no-verification-commands",
|
|
26
|
+
"target-outside-write-set",
|
|
27
|
+
"verification-target-outside-write-set",
|
|
28
|
+
]);
|
|
29
|
+
export const frontendPrewriteResultV1Schema = z
|
|
30
|
+
.object({
|
|
31
|
+
schemaVersion: z.literal(1),
|
|
32
|
+
schemaId: z.literal(FRONTEND_PREWRITE_RESULT_SCHEMA_ID),
|
|
33
|
+
classification: z.enum([
|
|
34
|
+
"accepted",
|
|
35
|
+
"accepted-normalized",
|
|
36
|
+
"retryable-invalid",
|
|
37
|
+
"blocked",
|
|
38
|
+
]),
|
|
39
|
+
failureCode: frontendPrewriteFailureCodeSchema.nullable(),
|
|
40
|
+
normalizationActions: z.array(frontendNormalizationActionSchema),
|
|
41
|
+
candidateRawSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
42
|
+
candidateJsonSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
|
43
|
+
canonicalSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
|
44
|
+
selectedPlanNodeId: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
45
|
+
sourceBindingSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
|
46
|
+
writeSetDigest: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
|
47
|
+
retryCount: z.number().int().min(0).max(1),
|
|
48
|
+
maxRetryCount: z.literal(1),
|
|
49
|
+
failureReason: z.string().nullable(),
|
|
50
|
+
digestAlgorithm: z.literal("sha256"),
|
|
51
|
+
})
|
|
52
|
+
.strict()
|
|
53
|
+
.superRefine((value, ctx) => {
|
|
54
|
+
const acceptedLike = value.classification === "accepted" ||
|
|
55
|
+
value.classification === "accepted-normalized";
|
|
56
|
+
const failed = value.classification === "retryable-invalid" ||
|
|
57
|
+
value.classification === "blocked";
|
|
58
|
+
if (acceptedLike) {
|
|
59
|
+
if (value.failureReason !== null)
|
|
60
|
+
ctx.addIssue({
|
|
61
|
+
code: "custom",
|
|
62
|
+
message: "failureReason must be null for accepted/accepted-normalized",
|
|
63
|
+
path: ["failureReason"],
|
|
64
|
+
});
|
|
65
|
+
if (value.failureCode !== null)
|
|
66
|
+
ctx.addIssue({
|
|
67
|
+
code: "custom",
|
|
68
|
+
message: "failureCode must be null for accepted/accepted-normalized",
|
|
69
|
+
path: ["failureCode"],
|
|
70
|
+
});
|
|
71
|
+
if (!value.candidateJsonSha256 || !value.canonicalSha256)
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: "custom",
|
|
74
|
+
message: "candidateJsonSha256 and canonicalSha256 are required for accepted/accepted-normalized",
|
|
75
|
+
path: ["candidateJsonSha256"],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (value.classification === "accepted" && value.normalizationActions.length !== 0)
|
|
79
|
+
ctx.addIssue({
|
|
80
|
+
code: "custom",
|
|
81
|
+
message: "accepted must have empty normalizationActions",
|
|
82
|
+
path: ["normalizationActions"],
|
|
83
|
+
});
|
|
84
|
+
if (value.classification === "accepted-normalized" &&
|
|
85
|
+
value.normalizationActions.length === 0)
|
|
86
|
+
ctx.addIssue({
|
|
87
|
+
code: "custom",
|
|
88
|
+
message: "accepted-normalized must have non-empty normalizationActions",
|
|
89
|
+
path: ["normalizationActions"],
|
|
90
|
+
});
|
|
91
|
+
if (failed) {
|
|
92
|
+
if (value.normalizationActions.length !== 0)
|
|
93
|
+
ctx.addIssue({
|
|
94
|
+
code: "custom",
|
|
95
|
+
message: "retryable-invalid/blocked must have empty normalizationActions",
|
|
96
|
+
path: ["normalizationActions"],
|
|
97
|
+
});
|
|
98
|
+
if (!value.failureReason)
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: "custom",
|
|
101
|
+
message: "failureReason must be non-empty for retryable-invalid/blocked",
|
|
102
|
+
path: ["failureReason"],
|
|
103
|
+
});
|
|
104
|
+
if (value.failureCode === null)
|
|
105
|
+
ctx.addIssue({
|
|
106
|
+
code: "custom",
|
|
107
|
+
message: "failureCode must be non-null for retryable-invalid/blocked",
|
|
108
|
+
path: ["failureCode"],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
export const frontendWriterAdmissionSchema = z
|
|
113
|
+
.object({
|
|
114
|
+
schemaVersion: z.literal(1),
|
|
115
|
+
writerNodeId: z.enum(["frontend-implement-pi", "frontend-repair-pi"]),
|
|
116
|
+
decision: z.enum(["authorized", "denied"]),
|
|
117
|
+
sourceArtifact: z.string().min(1),
|
|
118
|
+
artifactHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
119
|
+
checkedAt: z.string().min(1),
|
|
120
|
+
reason: z.string().nullable(),
|
|
121
|
+
})
|
|
122
|
+
.strict();
|
|
7
123
|
async function selectNode(runDir, primary, fallbacks) {
|
|
8
124
|
const candidates = [primary, ...fallbacks.filter((id) => id !== primary)];
|
|
9
125
|
const rejections = [];
|
|
@@ -37,6 +153,10 @@ async function readNodeText(runDir, nodeId) {
|
|
|
37
153
|
throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
|
|
38
154
|
return text;
|
|
39
155
|
}
|
|
156
|
+
async function readNodeRawText(runDir, nodeId) {
|
|
157
|
+
const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
|
|
158
|
+
return record.assistantText ?? record.stdout ?? "";
|
|
159
|
+
}
|
|
40
160
|
function firstNonEmptyVerdictLine(text) {
|
|
41
161
|
const lines = text
|
|
42
162
|
.split(/\r?\n/)
|
|
@@ -132,16 +252,141 @@ async function checkOpenspecReadEvidence(input) {
|
|
|
132
252
|
}
|
|
133
253
|
return [...matched];
|
|
134
254
|
}
|
|
255
|
+
async function finalizePrewrite(input, pending) {
|
|
256
|
+
const sourceBindingSha256 = input.sourceBinding
|
|
257
|
+
? deterministicSha256(input.sourceBinding)
|
|
258
|
+
: null;
|
|
259
|
+
const writeSetDigest = input.config.implementationWriteSet
|
|
260
|
+
? deterministicSha256([...new Set(input.config.implementationWriteSet)].sort())
|
|
261
|
+
: null;
|
|
262
|
+
const prewriteResult = frontendPrewriteResultV1Schema.parse({
|
|
263
|
+
schemaVersion: 1,
|
|
264
|
+
schemaId: FRONTEND_PREWRITE_RESULT_SCHEMA_ID,
|
|
265
|
+
classification: pending.classification,
|
|
266
|
+
normalizationActions: pending.normalizationActions,
|
|
267
|
+
candidateRawSha256: pending.candidateRawSha256,
|
|
268
|
+
candidateJsonSha256: pending.candidateJsonSha256,
|
|
269
|
+
canonicalSha256: pending.canonicalSha256,
|
|
270
|
+
selectedPlanNodeId: pending.planNodeId,
|
|
271
|
+
sourceBindingSha256,
|
|
272
|
+
writeSetDigest,
|
|
273
|
+
retryCount: 0,
|
|
274
|
+
maxRetryCount: 1,
|
|
275
|
+
failureReason: pending.failureReason,
|
|
276
|
+
failureCode: pending.failureCode,
|
|
277
|
+
digestAlgorithm: "sha256",
|
|
278
|
+
});
|
|
279
|
+
const written = await writeDeterministicJsonArtifact(input.runDir, path.posix.join(input.config.outputDir, FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME), prewriteResult);
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
classification: pending.classification,
|
|
283
|
+
planNodeId: pending.planNodeId,
|
|
284
|
+
reviewNodeId: pending.reviewNodeId,
|
|
285
|
+
verdict: pending.verdict,
|
|
286
|
+
mockStrategy: pending.mockStrategy,
|
|
287
|
+
artifact: pending.artifact,
|
|
288
|
+
prewriteResult: {
|
|
289
|
+
path: written.path,
|
|
290
|
+
sha256: written.sha256,
|
|
291
|
+
schemaId: FRONTEND_PREWRITE_RESULT_SCHEMA_ID,
|
|
292
|
+
},
|
|
293
|
+
failureReason: pending.failureReason,
|
|
294
|
+
failureCode: pending.failureCode,
|
|
295
|
+
normalizationActions: pending.normalizationActions,
|
|
296
|
+
candidateRawSha256: pending.candidateRawSha256,
|
|
297
|
+
candidateJsonSha256: pending.candidateJsonSha256,
|
|
298
|
+
canonicalSha256: pending.canonicalSha256,
|
|
299
|
+
openspecReadPaths: pending.openspecReadPaths,
|
|
300
|
+
openspecCandidatePaths: pending.openspecCandidatePaths,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
135
303
|
export async function runFrontendPrewriteGate(input) {
|
|
136
304
|
const workspaceRoot = input.workspaceRoot ?? input.repoRoot;
|
|
305
|
+
// selectNode throws for a scheduling defect (no FINISHED plan/review). That
|
|
306
|
+
// remains an ERROR and is intentionally not classified into prewrite-result.
|
|
307
|
+
const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
|
|
308
|
+
const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
|
|
309
|
+
const candidateRawSha256 = sha256Hex(await readNodeRawText(input.runDir, planNodeId));
|
|
310
|
+
const basePending = {
|
|
311
|
+
planNodeId,
|
|
312
|
+
reviewNodeId,
|
|
313
|
+
verdict: "",
|
|
314
|
+
candidateRawSha256,
|
|
315
|
+
candidateJsonSha256: null,
|
|
316
|
+
canonicalSha256: null,
|
|
317
|
+
normalizationActions: [],
|
|
318
|
+
mockStrategy: undefined,
|
|
319
|
+
artifact: undefined,
|
|
320
|
+
failureReason: null,
|
|
321
|
+
failureCode: null,
|
|
322
|
+
classification: "accepted",
|
|
323
|
+
openspecReadPaths: [],
|
|
324
|
+
openspecCandidatePaths: input.config.openspecCandidatePaths ?? [],
|
|
325
|
+
};
|
|
326
|
+
let planText;
|
|
327
|
+
try {
|
|
328
|
+
planText = await readNodeText(input.runDir, planNodeId);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
return finalizePrewrite(input, {
|
|
332
|
+
...basePending,
|
|
333
|
+
classification: "retryable-invalid",
|
|
334
|
+
failureReason: error instanceof Error ? error.message : String(error),
|
|
335
|
+
failureCode: "plan-text-unreadable",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
let reviewText;
|
|
339
|
+
try {
|
|
340
|
+
reviewText = await readNodeText(input.runDir, reviewNodeId);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
return finalizePrewrite(input, {
|
|
344
|
+
...basePending,
|
|
345
|
+
classification: "blocked",
|
|
346
|
+
failureReason: `frontend prewrite gate blocked by ${reviewNodeId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
347
|
+
failureCode: "review-text-unreadable",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const verdict = firstNonEmptyVerdictLine(reviewText);
|
|
351
|
+
if (verdict !== "VERDICT: pass") {
|
|
352
|
+
return finalizePrewrite(input, {
|
|
353
|
+
...basePending,
|
|
354
|
+
verdict,
|
|
355
|
+
classification: "blocked",
|
|
356
|
+
failureReason: `frontend prewrite gate blocked by ${reviewNodeId}: ${verdict || "missing VERDICT"}`,
|
|
357
|
+
failureCode: "verdict-not-pass",
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
|
|
361
|
+
if (missingIds.length > 0) {
|
|
362
|
+
return finalizePrewrite(input, {
|
|
363
|
+
...basePending,
|
|
364
|
+
verdict,
|
|
365
|
+
classification: "blocked",
|
|
366
|
+
failureReason: `frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`,
|
|
367
|
+
failureCode: "missing-requirement-ids",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
137
370
|
if (workspaceRoot) {
|
|
138
371
|
if (input.config.requireSourceFreshness) {
|
|
139
|
-
if (!input.sourceBinding)
|
|
372
|
+
if (!input.sourceBinding) {
|
|
140
373
|
throw new Error("frontend prewrite gate requires sourceBinding for freshness check");
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
await assertFrontendSourceBindingFresh({
|
|
377
|
+
workspaceRoot,
|
|
378
|
+
binding: input.sourceBinding,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
return finalizePrewrite(input, {
|
|
383
|
+
...basePending,
|
|
384
|
+
verdict,
|
|
385
|
+
classification: "blocked",
|
|
386
|
+
failureReason: error instanceof Error ? error.message : String(error),
|
|
387
|
+
failureCode: "source-binding-not-fresh",
|
|
388
|
+
});
|
|
389
|
+
}
|
|
145
390
|
}
|
|
146
391
|
let hasGitMetadata = true;
|
|
147
392
|
try {
|
|
@@ -159,31 +404,34 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
159
404
|
workspaceRoot,
|
|
160
405
|
});
|
|
161
406
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
407
|
+
// Candidate -> canonical conversion. The gate consumes the selected
|
|
408
|
+
// plan/revision assistant output directly and is the only conversion point.
|
|
409
|
+
let analysis;
|
|
410
|
+
try {
|
|
411
|
+
analysis = await analyzeFrontendImplementationContract({
|
|
412
|
+
runDir: input.runDir,
|
|
413
|
+
fromNodeId: planNodeId,
|
|
414
|
+
sourceBinding: input.sourceBinding,
|
|
415
|
+
});
|
|
169
416
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
417
|
+
catch (error) {
|
|
418
|
+
if (error instanceof FrontendContractFailure) {
|
|
419
|
+
return finalizePrewrite(input, {
|
|
420
|
+
...basePending,
|
|
421
|
+
verdict,
|
|
422
|
+
classification: error.kind,
|
|
423
|
+
failureReason: error.message,
|
|
424
|
+
failureCode: error.kind === "retryable-invalid"
|
|
425
|
+
? "contract-invalid"
|
|
426
|
+
: "contract-blocked",
|
|
427
|
+
candidateJsonSha256: error.candidateJsonSha256,
|
|
428
|
+
normalizationActions: [],
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
173
432
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// FINISHED primary is present but invalid (see composite-shell invalid-primary).
|
|
177
|
-
const artifact = await materializeFrontendImplementationContract({
|
|
178
|
-
runDir: input.runDir,
|
|
179
|
-
fromNodeId: planNodeId,
|
|
180
|
-
artifactName: input.config.artifactName,
|
|
181
|
-
outputDir: input.config.outputDir,
|
|
182
|
-
sourceBinding: input.sourceBinding,
|
|
183
|
-
});
|
|
184
|
-
const raw = JSON.parse(await readFile(artifact.path, "utf8"));
|
|
185
|
-
const contract = frontendImplementationContractSchema.parse(raw);
|
|
186
|
-
if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
|
|
433
|
+
const mockStrategy = analysis.canonical.mockApi.strategy;
|
|
434
|
+
if (!input.config.allowedMockStrategies.includes(mockStrategy)) {
|
|
187
435
|
const quotedAllowed = input.config.allowedMockStrategies
|
|
188
436
|
.map((strategy) => JSON.stringify(strategy))
|
|
189
437
|
.join(", ");
|
|
@@ -192,30 +440,71 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
192
440
|
const reason = forcedNotNeeded
|
|
193
441
|
? "the task is auto/not-required or lacks authorized deterministic Mock verification commands"
|
|
194
442
|
: `allowed strategies are [${quotedAllowed}]`;
|
|
195
|
-
|
|
443
|
+
return finalizePrewrite(input, {
|
|
444
|
+
...basePending,
|
|
445
|
+
verdict,
|
|
446
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
447
|
+
normalizationActions: [],
|
|
448
|
+
mockStrategy,
|
|
449
|
+
classification: "blocked",
|
|
450
|
+
failureReason: `frontend contract mock strategy "${mockStrategy}" is incompatible with the generated allowed strategies [${quotedAllowed}]; ${reason}. Regenerate the DAG or set frontendMock.policy=required with authorized Mock verification commands.`,
|
|
451
|
+
failureCode: "mock-strategy-outside-allowed",
|
|
452
|
+
});
|
|
196
453
|
}
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
// commands" after implementation, so block before any write is authorized.
|
|
202
|
-
if (contract.mockApi.strategy !== "not-needed" &&
|
|
454
|
+
// Mock command binding (main): a non-not-needed strategy can only be proven
|
|
455
|
+
// by the DAG's frozen Mock verification commands. Block before any write is
|
|
456
|
+
// authorized when none were materialized at generation time.
|
|
457
|
+
if (mockStrategy !== "not-needed" &&
|
|
203
458
|
(input.config.mockCommandLabels?.length ?? 0) === 0) {
|
|
204
|
-
|
|
459
|
+
return finalizePrewrite(input, {
|
|
460
|
+
...basePending,
|
|
461
|
+
verdict,
|
|
462
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
463
|
+
normalizationActions: [],
|
|
464
|
+
mockStrategy,
|
|
465
|
+
classification: "blocked",
|
|
466
|
+
failureReason: `frontend prewrite gate blocked: contract selected Mock strategy ${mockStrategy} but the DAG has no authorized Mock verification commands; add frontendMock.verifyCommands or a project mock script, or select not-needed`,
|
|
467
|
+
failureCode: "mock-strategy-no-verification-commands",
|
|
468
|
+
});
|
|
205
469
|
}
|
|
206
470
|
if (input.config.implementationWriteSet) {
|
|
207
471
|
const writeSet = new Set(input.config.implementationWriteSet);
|
|
208
|
-
const contractTargets = new Set(
|
|
472
|
+
const contractTargets = new Set(analysis.canonical.targets.files);
|
|
209
473
|
const uncoveredTargets = [...contractTargets].filter((target) => ![...writeSet].some((pattern) => pathMatchesPattern(target, pattern) || target === pattern));
|
|
210
474
|
if (uncoveredTargets.length > 0) {
|
|
211
|
-
|
|
475
|
+
return finalizePrewrite(input, {
|
|
476
|
+
...basePending,
|
|
477
|
+
verdict,
|
|
478
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
479
|
+
normalizationActions: [],
|
|
480
|
+
mockStrategy,
|
|
481
|
+
classification: "blocked",
|
|
482
|
+
failureReason: `frontend prewrite gate contract target is outside implementation writeSet: ${uncoveredTargets.join(", ")}`,
|
|
483
|
+
failureCode: "target-outside-write-set",
|
|
484
|
+
});
|
|
212
485
|
}
|
|
213
|
-
const nonStaticVTs =
|
|
486
|
+
const nonStaticVTs = analysis.canonical.verificationTargets.filter((vt) => vt.type !== "static");
|
|
214
487
|
const uncoveredVTs = nonStaticVTs.filter((vt) => ![...writeSet].some((pattern) => pathMatchesPattern(vt.file, pattern) || vt.file === pattern));
|
|
215
488
|
if (uncoveredVTs.length > 0) {
|
|
216
|
-
|
|
489
|
+
return finalizePrewrite(input, {
|
|
490
|
+
...basePending,
|
|
491
|
+
verdict,
|
|
492
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
493
|
+
normalizationActions: [],
|
|
494
|
+
mockStrategy,
|
|
495
|
+
classification: "blocked",
|
|
496
|
+
failureReason: `frontend prewrite gate verification target is outside implementation writeSet: ${uncoveredVTs.map((vt) => vt.file).join(", ")}`,
|
|
497
|
+
failureCode: "verification-target-outside-write-set",
|
|
498
|
+
});
|
|
217
499
|
}
|
|
218
500
|
}
|
|
501
|
+
// Materialize the canonical contract only after every governance check passed.
|
|
502
|
+
const artifact = await writeFrontendImplementationContractArtifact({
|
|
503
|
+
runDir: input.runDir,
|
|
504
|
+
outputDir: input.config.outputDir,
|
|
505
|
+
artifactName: input.config.artifactName,
|
|
506
|
+
canonical: analysis.canonical,
|
|
507
|
+
});
|
|
219
508
|
const candidatePaths = input.config.openspecCandidatePaths ?? [];
|
|
220
509
|
const openspecReadPaths = await checkOpenspecReadEvidence({
|
|
221
510
|
runDir: input.runDir,
|
|
@@ -224,32 +513,46 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
224
513
|
reviewNodeId,
|
|
225
514
|
repoRoot: workspaceRoot ?? process.cwd(),
|
|
226
515
|
});
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// successful evidence for review context, while allowing unavailable
|
|
231
|
-
// evidence to be reported downstream.
|
|
232
|
-
return {
|
|
233
|
-
ok: true,
|
|
234
|
-
planNodeId,
|
|
235
|
-
reviewNodeId,
|
|
516
|
+
const classification = analysis.normalizationActions.length > 0 ? "accepted-normalized" : "accepted";
|
|
517
|
+
return finalizePrewrite(input, {
|
|
518
|
+
...basePending,
|
|
236
519
|
verdict,
|
|
237
|
-
|
|
520
|
+
classification,
|
|
521
|
+
failureReason: null,
|
|
522
|
+
candidateJsonSha256: analysis.candidateJsonSha256,
|
|
523
|
+
canonicalSha256: artifact.sha256,
|
|
524
|
+
normalizationActions: analysis.normalizationActions,
|
|
525
|
+
mockStrategy,
|
|
238
526
|
artifact,
|
|
239
527
|
openspecReadPaths,
|
|
240
528
|
openspecCandidatePaths: candidatePaths,
|
|
241
|
-
};
|
|
529
|
+
});
|
|
242
530
|
}
|
|
243
531
|
export function formatFrontendPrewriteGateStdout(result) {
|
|
244
|
-
const lines = [
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
532
|
+
const lines = [];
|
|
533
|
+
if (result.classification === "accepted" ||
|
|
534
|
+
result.classification === "accepted-normalized") {
|
|
535
|
+
lines.push("Frontend prewrite gate: pass");
|
|
536
|
+
}
|
|
537
|
+
else if (result.classification === "blocked") {
|
|
538
|
+
lines.push("Frontend prewrite gate: blocked");
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
lines.push("Frontend prewrite gate: retryable-invalid");
|
|
542
|
+
}
|
|
543
|
+
lines.push(`Classification: ${result.classification}`);
|
|
544
|
+
lines.push(`Plan: ${result.planNodeId}`);
|
|
545
|
+
lines.push(`Review: ${result.reviewNodeId}`);
|
|
546
|
+
if (result.mockStrategy)
|
|
547
|
+
lines.push(`Mock strategy: ${result.mockStrategy}`);
|
|
548
|
+
if (result.artifact) {
|
|
549
|
+
lines.push(`Structured artifact: ${result.artifact.path}`);
|
|
550
|
+
lines.push(`Schema: ${result.artifact.schemaId}`);
|
|
551
|
+
lines.push(`SHA-256: ${result.artifact.sha256}`);
|
|
552
|
+
}
|
|
553
|
+
lines.push(`Prewrite result: ${result.prewriteResult.path}`);
|
|
554
|
+
if (result.failureReason)
|
|
555
|
+
lines.push(`Failure reason: ${result.failureReason}`);
|
|
253
556
|
if (result.openspecReadPaths.length > 0) {
|
|
254
557
|
lines.push(`openspec read: ${result.openspecReadPaths.join(", ")}`);
|
|
255
558
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { computeResetClosure } from "./rerun-plan.js";
|
|
2
|
+
/**
|
|
3
|
+
* Frontend-only candidate-continuation recovery planning (AC-5).
|
|
4
|
+
*
|
|
5
|
+
* Distinct from the generic `dag rerun` reset closure: the failure source is
|
|
6
|
+
* derived from the prewrite-result's `selectedPlanNodeId` rather than an
|
|
7
|
+
* operator-selected node, and the closure starts at the specific frontend
|
|
8
|
+
* candidate producer that failed.
|
|
9
|
+
*/
|
|
10
|
+
/** Idempotency namespace for requestId-keyed recovery intents (D2). */
|
|
11
|
+
export const FRONTEND_RECOVERY_INTENT_REL_DIR = ".runtime/frontend-recovery";
|
|
12
|
+
/** Import manifest path inside the child run dir; hashed into the activation marker. */
|
|
13
|
+
export const FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH = ".runtime/import-manifest.json";
|
|
14
|
+
/** Staging directory prefix under `active/` for in-flight child materialization. */
|
|
15
|
+
export const FRONTEND_RECOVERY_STAGING_PREFIX = ".frontend-recovery-staging-";
|
|
16
|
+
export function deriveFrontendRecoveryFailureSource(result) {
|
|
17
|
+
if (result.selectedPlanNodeId === "frontend-plan-revision-pi") {
|
|
18
|
+
return "frontend-plan-revision-pi";
|
|
19
|
+
}
|
|
20
|
+
if (result.selectedPlanNodeId === "frontend-plan-pi") {
|
|
21
|
+
return "frontend-plan-pi";
|
|
22
|
+
}
|
|
23
|
+
return "frontend-prewrite-gate-shell";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compute the frontend candidate-continuation reset partition:
|
|
27
|
+
* - `frontend-plan-pi` failure → reset plan + design-review + prewrite (+ descendants);
|
|
28
|
+
* - `frontend-plan-revision-pi` failure → reset revision + final-review + prewrite (+ descendants);
|
|
29
|
+
* - prewrite-only materialization → reset prewrite (+ descendants), reuse verified plan/review.
|
|
30
|
+
*
|
|
31
|
+
* Everything upstream of the failure source is imported as verified parent facts.
|
|
32
|
+
*/
|
|
33
|
+
export function computeFrontendRecoveryPlan(input) {
|
|
34
|
+
const failureSource = deriveFrontendRecoveryFailureSource(input.result);
|
|
35
|
+
return computeFrontendRecoveryPlanForSource({
|
|
36
|
+
spec: input.spec,
|
|
37
|
+
parentRunId: input.parentRunId,
|
|
38
|
+
requestId: input.requestId,
|
|
39
|
+
failureSource,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Phase 5: writer transient partial-write recovery plan. The writer itself
|
|
44
|
+
* failed after some bounded writes, so the child re-runs `frontend-implement-pi`
|
|
45
|
+
* (and its descendants) while importing the verified contract/scout/plan/prewrite
|
|
46
|
+
* facts. Everything upstream of the writer is imported; the writer + descendants
|
|
47
|
+
* are reset.
|
|
48
|
+
*/
|
|
49
|
+
export function computeFrontendWriterRecoveryPlan(input) {
|
|
50
|
+
return computeFrontendRecoveryPlanForSource({
|
|
51
|
+
spec: input.spec,
|
|
52
|
+
parentRunId: input.parentRunId,
|
|
53
|
+
requestId: input.requestId,
|
|
54
|
+
failureSource: "frontend-implement-pi",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function computeFrontendRecoveryPlanForSource(input) {
|
|
58
|
+
const resetNodeIds = computeResetClosure(input.spec, input.failureSource);
|
|
59
|
+
const resetSet = new Set(resetNodeIds);
|
|
60
|
+
const importedNodeIds = input.spec.tasks
|
|
61
|
+
.map((task) => task.id)
|
|
62
|
+
.filter((nodeId) => !resetSet.has(nodeId))
|
|
63
|
+
.sort();
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
parentRunId: input.parentRunId,
|
|
67
|
+
requestId: input.requestId,
|
|
68
|
+
failureSource: input.failureSource,
|
|
69
|
+
resetNodeIds,
|
|
70
|
+
importedNodeIds,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export { computeFrontendRecoveryPlanForSource };
|