@tea-agent/loop-agent 0.35.2 → 0.35.3
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 +31 -0
- 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/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/node-execution.js +89 -0
- package/dist/workflows/dag/recovery-recommendation.js +58 -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/package.json +4 -3
- package/dist/worker/console/static/assets/index-gVHrlqI9.js +0 -56
|
@@ -20,6 +20,14 @@ export const frontendRepairFailureClassSchema = z.enum([
|
|
|
20
20
|
"spec-unclear",
|
|
21
21
|
"unknown",
|
|
22
22
|
]);
|
|
23
|
+
export const frontendCommandCategorySchema = z.enum([
|
|
24
|
+
"typecheck",
|
|
25
|
+
"build",
|
|
26
|
+
"lint",
|
|
27
|
+
"component-test",
|
|
28
|
+
"unit-test",
|
|
29
|
+
"trace",
|
|
30
|
+
]);
|
|
23
31
|
const REPAIRABLE = new Set([
|
|
24
32
|
"typecheck",
|
|
25
33
|
"build",
|
|
@@ -27,12 +35,72 @@ const REPAIRABLE = new Set([
|
|
|
27
35
|
"component-test",
|
|
28
36
|
"unit-test",
|
|
29
37
|
"trace",
|
|
30
|
-
"review",
|
|
31
38
|
]);
|
|
32
39
|
export function isFrontendRepairable(failureClass) {
|
|
33
40
|
return REPAIRABLE.has(failureClass);
|
|
34
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Deterministic commandLabel → commandCategory mapping (AC-3).
|
|
44
|
+
* `targetType` comes from the contract verificationTargets[].type
|
|
45
|
+
* (static | unit | component | integration | mock); the literal commandLabel
|
|
46
|
+
* wins over type so a `static` typecheck target is never misread as a test.
|
|
47
|
+
*/
|
|
48
|
+
export function mapCommandLabelToCategory(commandLabel, targetType) {
|
|
49
|
+
const label = commandLabel.toLowerCase();
|
|
50
|
+
if (/(?:typecheck|check-types|type-check|\btsc\b)/.test(label))
|
|
51
|
+
return "typecheck";
|
|
52
|
+
if (/(?:vite\s+build|next\s+build|webpack|rollup|\bbuild\b)/.test(label))
|
|
53
|
+
return "build";
|
|
54
|
+
if (/(?:eslint|\blint\b)/.test(label))
|
|
55
|
+
return "lint";
|
|
56
|
+
if (targetType === "component")
|
|
57
|
+
return "component-test";
|
|
58
|
+
if (targetType === "unit")
|
|
59
|
+
return "unit-test";
|
|
60
|
+
if (/(?:component|testing-library|render\()/.test(label))
|
|
61
|
+
return "component-test";
|
|
62
|
+
return "unit-test";
|
|
63
|
+
}
|
|
64
|
+
export function isReviewRepairable(finding, implementWriteSet) {
|
|
65
|
+
const hasFile = Boolean(finding.file && finding.file.trim().length > 0);
|
|
66
|
+
const hasLocation = Boolean((finding.line !== undefined && finding.line > 0) ||
|
|
67
|
+
(finding.symbol && finding.symbol.trim().length > 0));
|
|
68
|
+
const hasRequirement = Boolean((finding.requirementId && finding.requirementId.trim().length > 0) ||
|
|
69
|
+
(finding.acceptanceCriteriaId &&
|
|
70
|
+
finding.acceptanceCriteriaId.trim().length > 0));
|
|
71
|
+
const fixScope = finding.fixScope ?? (hasFile ? [finding.file] : []);
|
|
72
|
+
const fixScopeSubset = fixScope.length > 0 &&
|
|
73
|
+
fixScope.every((entry) => isPathWithinWriteSet(entry, implementWriteSet));
|
|
74
|
+
return (hasFile &&
|
|
75
|
+
hasLocation &&
|
|
76
|
+
hasRequirement &&
|
|
77
|
+
fixScopeSubset &&
|
|
78
|
+
finding.requiresContractReplan !== true);
|
|
79
|
+
}
|
|
35
80
|
export function classifyFrontendFailure(input) {
|
|
81
|
+
// AC-2: structured evidence takes priority over string-blob heuristics.
|
|
82
|
+
if (input.failureCategory === "write-guard") {
|
|
83
|
+
return "path";
|
|
84
|
+
}
|
|
85
|
+
if (input.traceTarget || input.commandCategory === "trace") {
|
|
86
|
+
return "trace";
|
|
87
|
+
}
|
|
88
|
+
if (input.commandCategory) {
|
|
89
|
+
switch (input.commandCategory) {
|
|
90
|
+
case "typecheck":
|
|
91
|
+
return "typecheck";
|
|
92
|
+
case "build":
|
|
93
|
+
return "build";
|
|
94
|
+
case "lint":
|
|
95
|
+
return "lint";
|
|
96
|
+
case "component-test":
|
|
97
|
+
return "component-test";
|
|
98
|
+
case "unit-test":
|
|
99
|
+
return "unit-test";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Fallback: string-blob heuristic, preserved in its original branch order so
|
|
103
|
+
// legacy assertions (error TS2304 → typecheck, write-guard → path, etc.) stay green.
|
|
36
104
|
const blob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stdout ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
|
|
37
105
|
if (input.nodeId.includes("contract") ||
|
|
38
106
|
blob.includes("contract mismatch") ||
|
|
@@ -120,6 +188,23 @@ export const frontendRepairAssessmentSchema = z
|
|
|
120
188
|
allowedRepairWriteSet: z.array(z.string().min(1)),
|
|
121
189
|
evidenceRefs: z.array(z.string().min(1)),
|
|
122
190
|
browserStatus: z.literal("not-run"),
|
|
191
|
+
// AC-5 structured evidence fields (all optional so existing minimal
|
|
192
|
+
// fixtures and frontend-review-context safeParse stay compatible).
|
|
193
|
+
verificationTargetId: z.string().min(1).optional(),
|
|
194
|
+
commandLabel: z.string().min(1).optional(),
|
|
195
|
+
exitCode: z.number().int().optional(),
|
|
196
|
+
commandCategory: frontendCommandCategorySchema.optional(),
|
|
197
|
+
requirementIds: z.array(z.string().min(1)).optional(),
|
|
198
|
+
acceptanceCriteriaIds: z.array(z.string().min(1)).optional(),
|
|
199
|
+
fixScope: z.array(z.string().min(1)).optional(),
|
|
200
|
+
changedPaths: z.array(z.string().min(1)).optional(),
|
|
201
|
+
traceRef: z
|
|
202
|
+
.object({
|
|
203
|
+
relativePath: z.string().min(1),
|
|
204
|
+
schemaId: z.string().min(1),
|
|
205
|
+
})
|
|
206
|
+
.strict()
|
|
207
|
+
.optional(),
|
|
123
208
|
})
|
|
124
209
|
.strict();
|
|
125
210
|
function nodeHadFailure(record) {
|
|
@@ -135,19 +220,24 @@ function nodeHadFailure(record) {
|
|
|
135
220
|
function normalize(value) {
|
|
136
221
|
return value.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
137
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Whether a concrete path or glob entry is contained by the implement writeSet.
|
|
225
|
+
* Exact glob equality is the norm (repair writeSet === implement writeSet);
|
|
226
|
+
* concrete (non-glob) paths may also be covered by an implement glob.
|
|
227
|
+
*/
|
|
228
|
+
export function isPathWithinWriteSet(entry, implementWriteSet) {
|
|
229
|
+
const normalizedEntry = normalize(entry);
|
|
230
|
+
const normalizedImplement = implementWriteSet.map(normalize);
|
|
231
|
+
if (normalizedImplement.includes(normalizedEntry))
|
|
232
|
+
return true;
|
|
233
|
+
if (!normalizedEntry.includes("*")) {
|
|
234
|
+
return implementWriteSet.some((pattern) => pathMatchesPattern(normalizedEntry, pattern));
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
138
238
|
export function assertWriteSetSubset(repairWriteSet, implementWriteSet) {
|
|
139
239
|
for (const entry of repairWriteSet) {
|
|
140
|
-
|
|
141
|
-
pathMatchesPattern(normalize(entry), pattern) ||
|
|
142
|
-
pathMatchesPattern(normalize(pattern), entry));
|
|
143
|
-
// exact equality preferred for generation-time copy
|
|
144
|
-
if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
|
|
145
|
-
// allow identical globs only
|
|
146
|
-
if (!ok || normalize(entry) !== normalize(entry)) {
|
|
147
|
-
// require exact membership for exclusive writeSet entries
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
|
|
240
|
+
if (!isPathWithinWriteSet(entry, implementWriteSet)) {
|
|
151
241
|
throw new Error(`repair writeSet entry "${entry}" is not ⊆ implement writeSet`);
|
|
152
242
|
}
|
|
153
243
|
}
|
|
@@ -170,6 +260,63 @@ async function loadImplementWriteSet(runDir, fallback) {
|
|
|
170
260
|
}
|
|
171
261
|
return fallback;
|
|
172
262
|
}
|
|
263
|
+
function matchVerificationTarget(contract, commandLabel, command) {
|
|
264
|
+
for (const target of contract.verificationTargets) {
|
|
265
|
+
if (commandLabel && target.commandLabel === commandLabel)
|
|
266
|
+
return target;
|
|
267
|
+
if (command &&
|
|
268
|
+
(command.includes(target.commandLabel) ||
|
|
269
|
+
target.commandLabel.includes(command))) {
|
|
270
|
+
return target;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
function deriveFailureEvidence(input) {
|
|
276
|
+
const evidence = {};
|
|
277
|
+
for (const { record } of input.failed) {
|
|
278
|
+
const results = record.commandResults ?? [];
|
|
279
|
+
const failedResult = results.find((item) => item.ok === false) ?? results[0];
|
|
280
|
+
if (failedResult &&
|
|
281
|
+
evidence.exitCode === undefined &&
|
|
282
|
+
typeof failedResult.exitCode === "number") {
|
|
283
|
+
evidence.exitCode = failedResult.exitCode;
|
|
284
|
+
}
|
|
285
|
+
if (failedResult?.commandLabel && !evidence.commandLabel) {
|
|
286
|
+
evidence.commandLabel = failedResult.commandLabel;
|
|
287
|
+
}
|
|
288
|
+
// Structured trace facts passed by the verification bundle / trace gate.
|
|
289
|
+
const failedTraceTarget = (record.traceTargets ?? []).find((target) => target.status === "failed");
|
|
290
|
+
if (failedTraceTarget && !evidence.verificationTargetId) {
|
|
291
|
+
evidence.verificationTargetId = failedTraceTarget.id;
|
|
292
|
+
if (!evidence.commandLabel)
|
|
293
|
+
evidence.commandLabel = failedTraceTarget.commandLabel;
|
|
294
|
+
evidence.traceTarget = failedTraceTarget;
|
|
295
|
+
evidence.traceRef = {
|
|
296
|
+
relativePath: "contracts/frontend-verification-trace.json",
|
|
297
|
+
schemaId: "frontend-verification-trace-v1",
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (record.changedPaths?.length && !evidence.changedPaths) {
|
|
301
|
+
evidence.changedPaths = [...record.changedPaths];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Resolve commandLabel → contract verification target → category + scope.
|
|
305
|
+
const target = matchVerificationTarget(input.contract, evidence.commandLabel);
|
|
306
|
+
if (target) {
|
|
307
|
+
if (!evidence.verificationTargetId)
|
|
308
|
+
evidence.verificationTargetId = target.id;
|
|
309
|
+
if (!evidence.commandLabel)
|
|
310
|
+
evidence.commandLabel = target.commandLabel;
|
|
311
|
+
evidence.requirementIds ??= [...target.requirementIds];
|
|
312
|
+
evidence.acceptanceCriteriaIds ??= [...target.requirementIds];
|
|
313
|
+
evidence.fixScope ??= [target.file];
|
|
314
|
+
}
|
|
315
|
+
if (evidence.commandLabel) {
|
|
316
|
+
evidence.commandCategory ??= mapCommandLabelToCategory(evidence.commandLabel, target?.type);
|
|
317
|
+
}
|
|
318
|
+
return evidence;
|
|
319
|
+
}
|
|
173
320
|
export async function runFrontendFailureAssessGate(input) {
|
|
174
321
|
const attempt = input.attempt ?? 1;
|
|
175
322
|
const implementWriteSet = await loadImplementWriteSet(input.runDir, input.implementWriteSet ?? []);
|
|
@@ -179,12 +326,14 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
179
326
|
const contractRel = "contracts/frontend-implementation-contract.json";
|
|
180
327
|
const contractPath = path.join(input.runDir, contractRel);
|
|
181
328
|
let contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
|
|
329
|
+
let contract;
|
|
182
330
|
try {
|
|
183
331
|
const raw = JSON.parse(await readFile(contractPath, "utf8"));
|
|
184
332
|
const parsed = frontendImplementationContractSchema.safeParse(raw);
|
|
185
333
|
if (!parsed.success) {
|
|
186
334
|
throw new Error("invalid frontend implementation contract");
|
|
187
335
|
}
|
|
336
|
+
contract = parsed.data;
|
|
188
337
|
contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
|
|
189
338
|
}
|
|
190
339
|
catch {
|
|
@@ -218,7 +367,6 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
218
367
|
// missing optional nodes ok
|
|
219
368
|
}
|
|
220
369
|
}
|
|
221
|
-
assertWriteSetSubset(implementWriteSet, implementWriteSet);
|
|
222
370
|
if (failed.length === 0) {
|
|
223
371
|
const assessment = {
|
|
224
372
|
schemaVersion: 1,
|
|
@@ -241,16 +389,49 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
241
389
|
return assessment;
|
|
242
390
|
}
|
|
243
391
|
const primary = failed[0];
|
|
392
|
+
const evidence = deriveFailureEvidence({ contract, failed });
|
|
244
393
|
const failureClass = classifyFrontendFailure({
|
|
245
394
|
nodeId: primary.nodeId,
|
|
246
395
|
failureCategory: primary.record.failureCategory,
|
|
247
396
|
stdout: primary.record.stdout,
|
|
248
397
|
stderr: primary.record.stderr,
|
|
398
|
+
commandLabel: evidence.commandLabel,
|
|
399
|
+
commandCategory: evidence.commandCategory,
|
|
400
|
+
exitCode: evidence.exitCode,
|
|
401
|
+
traceTarget: evidence.traceTarget ?? null,
|
|
402
|
+
changedPaths: evidence.changedPaths,
|
|
249
403
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
404
|
+
// AC-5: repairable=true requires structured evidence (a resolved exitCode,
|
|
405
|
+
// commandCategory, or trace-bound verification target). review has its own
|
|
406
|
+
// stricter gate (AC-4) and is never unconditionally repairable.
|
|
407
|
+
const baseEligible = isFrontendRepairable(failureClass) && attempt <= 1;
|
|
408
|
+
let eligible;
|
|
409
|
+
let reason;
|
|
410
|
+
if (failureClass === "review") {
|
|
411
|
+
const finding = primary.record.reviewFinding ?? {};
|
|
412
|
+
eligible = attempt <= 1 && isReviewRepairable(finding, implementWriteSet);
|
|
413
|
+
reason = eligible
|
|
414
|
+
? `repairable review finding on ${primary.nodeId}`
|
|
415
|
+
: `review finding lacks file/line-symbol/requirement evidence or is outside writeSet on ${primary.nodeId}`;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const hasStructuredEvidence = evidence.exitCode !== undefined ||
|
|
419
|
+
evidence.commandCategory !== undefined ||
|
|
420
|
+
Boolean(evidence.verificationTargetId);
|
|
421
|
+
eligible = baseEligible && hasStructuredEvidence;
|
|
422
|
+
reason = eligible
|
|
423
|
+
? `repairable failureClass=${failureClass} on ${primary.nodeId}`
|
|
424
|
+
: !baseEligible
|
|
425
|
+
? `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`
|
|
426
|
+
: `missing structured evidence for failureClass=${failureClass} on ${primary.nodeId}`;
|
|
427
|
+
}
|
|
428
|
+
// fixScope and changedPaths must stay inside the implement writeSet.
|
|
429
|
+
if (evidence.fixScope?.length) {
|
|
430
|
+
assertWriteSetSubset(evidence.fixScope, implementWriteSet);
|
|
431
|
+
}
|
|
432
|
+
if (evidence.changedPaths?.length) {
|
|
433
|
+
assertWriteSetSubset(evidence.changedPaths, implementWriteSet);
|
|
434
|
+
}
|
|
254
435
|
const assessment = {
|
|
255
436
|
schemaVersion: 1,
|
|
256
437
|
schemaId: FRONTEND_REPAIR_ASSESSMENT_SCHEMA_ID,
|
|
@@ -267,6 +448,25 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
267
448
|
allowedRepairWriteSet: [...implementWriteSet],
|
|
268
449
|
evidenceRefs: [contractRel, ...failed.map((item) => `${item.nodeId}.json`)],
|
|
269
450
|
browserStatus: "not-run",
|
|
451
|
+
...(evidence.verificationTargetId
|
|
452
|
+
? { verificationTargetId: evidence.verificationTargetId }
|
|
453
|
+
: {}),
|
|
454
|
+
...(evidence.commandLabel ? { commandLabel: evidence.commandLabel } : {}),
|
|
455
|
+
...(evidence.exitCode !== undefined ? { exitCode: evidence.exitCode } : {}),
|
|
456
|
+
...(evidence.commandCategory
|
|
457
|
+
? { commandCategory: evidence.commandCategory }
|
|
458
|
+
: {}),
|
|
459
|
+
...(evidence.requirementIds?.length
|
|
460
|
+
? { requirementIds: evidence.requirementIds }
|
|
461
|
+
: {}),
|
|
462
|
+
...(evidence.acceptanceCriteriaIds?.length
|
|
463
|
+
? { acceptanceCriteriaIds: evidence.acceptanceCriteriaIds }
|
|
464
|
+
: {}),
|
|
465
|
+
...(evidence.fixScope?.length ? { fixScope: evidence.fixScope } : {}),
|
|
466
|
+
...(evidence.changedPaths?.length
|
|
467
|
+
? { changedPaths: evidence.changedPaths }
|
|
468
|
+
: {}),
|
|
469
|
+
...(evidence.traceRef ? { traceRef: evidence.traceRef } : {}),
|
|
270
470
|
};
|
|
271
471
|
await writeAssessment(input.runDir, assessment);
|
|
272
472
|
return assessment;
|
|
@@ -322,7 +522,8 @@ export async function runFrontendRepairContractGate(input) {
|
|
|
322
522
|
if (!assessment.eligible) {
|
|
323
523
|
throw new Error(`repair-contract: non-repairable failure (${assessment.failureClass}): ${assessment.reason}`);
|
|
324
524
|
}
|
|
325
|
-
if (!isFrontendRepairable(assessment.failureClass)
|
|
525
|
+
if (!isFrontendRepairable(assessment.failureClass) &&
|
|
526
|
+
assessment.failureClass !== "review") {
|
|
326
527
|
throw new Error(`repair-contract: failureClass ${assessment.failureClass} is not repairable`);
|
|
327
528
|
}
|
|
328
529
|
return { ok: true, assessment };
|
|
@@ -40,6 +40,48 @@ function symbolEvidenceCandidates(symbol) {
|
|
|
40
40
|
function isConfigurationVerificationFile(file) {
|
|
41
41
|
return /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|(?:vite|webpack|rollup|docusaurus)\.config\.[cm]?[jt]s)$/.test(file.replace(/\\/g, "/"));
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Pure verification-target evaluation shared by the trace gate and the repair
|
|
45
|
+
* assess gate. It derives, for each contract verification target, whether the
|
|
46
|
+
* target bound to frozen evidence (commandLabel owners), and whether the file
|
|
47
|
+
* and optional symbol exist in the workspace. It performs no writes and throws
|
|
48
|
+
* nothing: failures are returned as `hardIssues` plus per-target `status`.
|
|
49
|
+
*/
|
|
50
|
+
export async function evaluateVerificationTargets(input) {
|
|
51
|
+
const targets = [];
|
|
52
|
+
const hardIssues = [];
|
|
53
|
+
for (const target of input.contract.verificationTargets) {
|
|
54
|
+
const issues = [];
|
|
55
|
+
const owners = input.labelOwners.get(target.commandLabel);
|
|
56
|
+
if (!owners?.length) {
|
|
57
|
+
issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
|
|
58
|
+
}
|
|
59
|
+
const fileIssues = await assertFileAndSymbol({
|
|
60
|
+
workspaceRoot: input.workspaceRoot,
|
|
61
|
+
file: target.file,
|
|
62
|
+
// Configuration files prove that the command's entrypoint exists;
|
|
63
|
+
// they do not expose source symbols. LLMs sometimes derive a
|
|
64
|
+
// filename fragment such as "build" from tsconfig.build.json.
|
|
65
|
+
symbol: isConfigurationVerificationFile(target.file)
|
|
66
|
+
? undefined
|
|
67
|
+
: target.symbol,
|
|
68
|
+
});
|
|
69
|
+
issues.push(...fileIssues);
|
|
70
|
+
const status = issues.length ? "failed" : "ok";
|
|
71
|
+
if (issues.length)
|
|
72
|
+
hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
|
|
73
|
+
targets.push({
|
|
74
|
+
id: target.id,
|
|
75
|
+
commandLabel: target.commandLabel,
|
|
76
|
+
file: target.file,
|
|
77
|
+
symbol: target.symbol,
|
|
78
|
+
status,
|
|
79
|
+
matchedNodeIds: owners ?? [],
|
|
80
|
+
issues,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return { targets, hardIssues };
|
|
84
|
+
}
|
|
43
85
|
async function assertFileAndSymbol(input) {
|
|
44
86
|
const issues = [];
|
|
45
87
|
const absolute = path.resolve(input.workspaceRoot, input.file);
|
|
@@ -175,38 +217,11 @@ export async function runFrontendVerificationTraceGate(input) {
|
|
|
175
217
|
}
|
|
176
218
|
}
|
|
177
219
|
}
|
|
178
|
-
const targets =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!owners?.length) {
|
|
184
|
-
issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
|
|
185
|
-
}
|
|
186
|
-
const fileIssues = await assertFileAndSymbol({
|
|
187
|
-
workspaceRoot: input.workspaceRoot,
|
|
188
|
-
file: target.file,
|
|
189
|
-
// Configuration files prove that the command's entrypoint exists;
|
|
190
|
-
// they do not expose source symbols. LLMs sometimes derive a
|
|
191
|
-
// filename fragment such as "build" from tsconfig.build.json.
|
|
192
|
-
symbol: isConfigurationVerificationFile(target.file)
|
|
193
|
-
? undefined
|
|
194
|
-
: target.symbol,
|
|
195
|
-
});
|
|
196
|
-
issues.push(...fileIssues);
|
|
197
|
-
const status = issues.length ? "failed" : "ok";
|
|
198
|
-
if (issues.length)
|
|
199
|
-
hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
|
|
200
|
-
targets.push({
|
|
201
|
-
id: target.id,
|
|
202
|
-
commandLabel: target.commandLabel,
|
|
203
|
-
file: target.file,
|
|
204
|
-
symbol: target.symbol,
|
|
205
|
-
status,
|
|
206
|
-
matchedNodeIds: owners ?? [],
|
|
207
|
-
issues,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
220
|
+
const { targets, hardIssues } = await evaluateVerificationTargets({
|
|
221
|
+
contract,
|
|
222
|
+
labelOwners,
|
|
223
|
+
workspaceRoot: input.workspaceRoot,
|
|
224
|
+
});
|
|
210
225
|
if (hardIssues.length) {
|
|
211
226
|
throw new Error(`trace: ${hardIssues.join("; ")}`);
|
|
212
227
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { getDagRunDir } from "./lifecycle.js";
|
|
4
|
+
import { captureWriterBaseline, readJournalFile, restoreJournal, sha256HexBytes, writeJournalFile, } from "./frontend-writer-rollback.js";
|
|
5
|
+
/**
|
|
6
|
+
* Phase 5: writer transient partial-write recovery — compose the phase-4 rollback
|
|
7
|
+
* journal with the phase-3 staged child handoff. A frontend writer attempt is
|
|
8
|
+
* snapshotted before the provider call so a transient partial write can be rolled
|
|
9
|
+
* back to the pre-attempt baseline, then recovered by a staged child that re-runs
|
|
10
|
+
* `frontend-implement-pi`.
|
|
11
|
+
*
|
|
12
|
+
* The runner remains the single-writer coordinator: it captures the baseline via
|
|
13
|
+
* {@link captureFrontendWriterAttemptIntent} before the writer provider call, and
|
|
14
|
+
* on a transient partial write runs {@link rollbackFrontendWriter} before writing
|
|
15
|
+
* the recovery intent and staging the child.
|
|
16
|
+
*/
|
|
17
|
+
/** Rollback intent path inside a run dir (written before the writer provider call). */
|
|
18
|
+
export const FRONTEND_WRITER_ROLLBACK_INTENT_REL = ".runtime/frontend-writer-rollback.json";
|
|
19
|
+
export function frontendWriterRollbackIntentAbsPath(runDir) {
|
|
20
|
+
return path.join(runDir, FRONTEND_WRITER_ROLLBACK_INTENT_REL);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* AC/P1-9: capture the writeSet baseline and persist the attempt intent BEFORE
|
|
24
|
+
* the writer provider call. Returns the intent path. Callers must fail the
|
|
25
|
+
* attempt closed if this throws (no baseline → no rollback → no recovery).
|
|
26
|
+
*/
|
|
27
|
+
export async function captureFrontendWriterAttemptIntent(input) {
|
|
28
|
+
const baseline = await captureWriterBaseline(input.cwd, input.writeSet);
|
|
29
|
+
const intentPath = frontendWriterRollbackIntentAbsPath(input.runDir);
|
|
30
|
+
await writeJournalFile(intentPath, baseline);
|
|
31
|
+
return intentPath;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Record the writer attempt's changed paths and per-path attempt hashes into the
|
|
35
|
+
* rollback intent AFTER the provider call. This is what makes restoreJournal able
|
|
36
|
+
* to CAS-restore: it compares each changed path's current hash against the
|
|
37
|
+
* recorded attempt hash before writing back the baseline.
|
|
38
|
+
*/
|
|
39
|
+
export async function recordFrontendWriterAttempt(input) {
|
|
40
|
+
const intentPath = frontendWriterRollbackIntentAbsPath(input.runDir);
|
|
41
|
+
const snapshot = await readJournalFile(intentPath);
|
|
42
|
+
if (!snapshot)
|
|
43
|
+
return; // no baseline was captured; nothing to record
|
|
44
|
+
const changed = [...new Set(input.changedPaths)].sort((a, b) => a.localeCompare(b));
|
|
45
|
+
const fileHashes = {};
|
|
46
|
+
for (const rel of changed) {
|
|
47
|
+
try {
|
|
48
|
+
const bytes = await readFile(path.join(input.cwd, rel));
|
|
49
|
+
fileHashes[rel] = sha256HexBytes(bytes);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
fileHashes[rel] = "absent";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
snapshot.attemptChangedPaths = changed;
|
|
56
|
+
snapshot.attemptFileHashes = fileHashes;
|
|
57
|
+
await writeJournalFile(intentPath, snapshot);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Restore the parent workspace to its pre-attempt baseline using the phase-4 CAS
|
|
61
|
+
* journal. This must run BEFORE the recovery intent is written and the child is
|
|
62
|
+
* staged (rollback commit point): a blocked rollback short-circuits to
|
|
63
|
+
* `auto-recovery-blocked` and never stages a child.
|
|
64
|
+
*/
|
|
65
|
+
export async function rollbackFrontendWriter(input) {
|
|
66
|
+
const parentRunDir = getDagRunDir(input.cwd, "active", input.parentRunId);
|
|
67
|
+
const snapshot = await readJournalFile(frontendWriterRollbackIntentAbsPath(parentRunDir));
|
|
68
|
+
if (!snapshot) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
state: "missing-intent",
|
|
72
|
+
reason: "auto-recovery-blocked: writer rollback intent missing (baseline was not captured before the provider call)",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const result = await restoreJournal(snapshot, { repoRoot: input.cwd });
|
|
76
|
+
if (result.state !== "completed") {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
state: "blocked",
|
|
80
|
+
reason: result.blockedReason ?? "auto-recovery-blocked",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
state: "completed",
|
|
86
|
+
restoredPaths: result.restoredPaths,
|
|
87
|
+
snapshot,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Transient failure categories that indicate a writer may have partially written
|
|
92
|
+
* before the attempt was interrupted. Clean-timeout (zero writes) is handled by
|
|
93
|
+
* the retry policy and never reaches terminal aggregation as a recovery trigger.
|
|
94
|
+
*/
|
|
95
|
+
const TRANSIENT_WRITER_FAILURE_CATEGORIES = new Set([
|
|
96
|
+
"timeout",
|
|
97
|
+
"network",
|
|
98
|
+
"rate-limit",
|
|
99
|
+
"unavailable",
|
|
100
|
+
"terminated",
|
|
101
|
+
"disconnect",
|
|
102
|
+
"provider-error",
|
|
103
|
+
]);
|
|
104
|
+
export function isFrontendWriterTransientPartialWrite(input) {
|
|
105
|
+
return TRANSIENT_WRITER_FAILURE_CATEGORIES.has(input.failureCategory ?? "");
|
|
106
|
+
}
|