@tea-agent/loop-agent 0.20.1 → 0.22.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/CHANGELOG.md +72 -0
- package/bin/agent-worker.js +0 -0
- package/dist/adapters/loop-agent.js +52 -0
- package/dist/commands/init.js +104 -0
- package/dist/executors/dag-pi-executor.js +26 -0
- package/dist/executors/pi-executor.js +111 -36
- package/dist/executors/pi-sdk-executor.js +105 -29
- package/dist/executors/shell-executor.js +215 -29
- package/dist/shared/openspec-spec.js +49 -0
- package/dist/worker/loop-agent/loop-agent-client.js +43 -9
- package/dist/worker/observability/read-model.js +28 -2
- package/dist/worker/observe/spec-evidence.js +12 -15
- package/dist/worker/observe/static/constants.js +5 -0
- package/dist/worker/observe/static/dag-helpers.js +22 -0
- package/dist/worker/observe/static/format-pool.js +22 -3
- package/dist/worker/observe/static/styles.css +32 -3
- package/dist/worker/observe/static/views/dag-inspector.js +2 -2
- package/dist/worker/observe/static/views/dag.js +5 -0
- package/dist/worker/run-task/run-task.js +16 -6
- package/dist/workflows/dag/backend-test-markdown-workflow.js +328 -97
- package/dist/workflows/dag/backend-test-result-contract.js +10 -4
- package/dist/workflows/dag/frontend-implementation-contract.js +141 -32
- package/dist/workflows/dag/frontend-lint-baseline.js +471 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +79 -16
- package/dist/workflows/dag/frontend-project-capability.js +11 -8
- package/dist/workflows/dag/frontend-repair.js +6 -4
- package/dist/workflows/dag/frontend-review-context.js +67 -0
- package/dist/workflows/dag/frontend-test-case-quality.js +105 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +71 -66
- package/dist/workflows/dag/frontend-verification-trace.js +31 -1
- package/dist/workflows/dag/frontend-worktree-diff.js +81 -6
- package/dist/workflows/dag/init-hybrid.js +370 -79
- package/dist/workflows/dag/lifecycle.js +60 -4
- package/dist/workflows/dag/liveness-policy.js +250 -0
- package/dist/workflows/dag/node-execution.js +49 -0
- package/dist/workflows/dag/runner.js +21 -1
- package/dist/workflows/dag/types.js +67 -1
- package/docs/README.md +5 -6
- package/docs/architecture/dag-execution.md +11 -0
- package/docs/architecture/facts-and-state.md +1 -0
- package/docs/architecture/worker-and-feature.md +10 -0
- package/docs/templates/agent-dag.schema.json +15 -5
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -2
- package/docs/templates/backend-test-dag.json +15 -15
- package/docs/templates/frontend-implementation-contract.schema.json +4 -3
- package/docs/templates/frontend-test-case-checklist.md +6 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +12 -10
- package/skills/frontend-design-review/references/review-checklist.md +4 -4
- package/skills/frontend-implementation/SKILL.md +2 -2
- package/skills/frontend-implementation/references/code-standards.md +4 -3
- package/skills/frontend-implementation/references/design-spec.md +19 -14
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/frontend-review/SKILL.md +15 -28
- package/skills/frontend-review/references/review-findings.md +16 -18
- package/skills/frontend-verification/SKILL.md +16 -13
- package/skills/frontend-verification/references/verification-checklist.md +18 -30
- package/skills/loop-agent/references/command-reference.md +2 -0
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
|
|
3
|
+
import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
|
|
4
4
|
import { serializeSessionEvent } from './pi-event-serializer.js';
|
|
5
5
|
let sdkSessionFactoryOverride;
|
|
6
6
|
let sdkImportOverrideForTests;
|
|
@@ -169,6 +169,21 @@ export function shouldPersistSessionEvent(event) {
|
|
|
169
169
|
}
|
|
170
170
|
return true;
|
|
171
171
|
}
|
|
172
|
+
function classifySdkActivityKind(event) {
|
|
173
|
+
if (!event || typeof event !== 'object')
|
|
174
|
+
return 'provider';
|
|
175
|
+
const type = typeof event.type === 'string'
|
|
176
|
+
? String(event.type)
|
|
177
|
+
: '';
|
|
178
|
+
if (type === 'tool_start' || type === 'tool_end'
|
|
179
|
+
|| type === 'tool_execution_start' || type === 'tool_execution_end') {
|
|
180
|
+
return 'tool';
|
|
181
|
+
}
|
|
182
|
+
if (type === 'thinking_delta' || type === 'message_update') {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return 'provider';
|
|
186
|
+
}
|
|
172
187
|
function createSessionEventAppender(filePath, onSessionEvent) {
|
|
173
188
|
let chain = Promise.resolve();
|
|
174
189
|
let dirEnsured = false;
|
|
@@ -181,6 +196,9 @@ function createSessionEventAppender(filePath, onSessionEvent) {
|
|
|
181
196
|
dirEnsured = true;
|
|
182
197
|
}
|
|
183
198
|
await appendFile(filePath, `${line}\n`, 'utf-8');
|
|
199
|
+
// Only after successful persistence: surface the persisted session event.
|
|
200
|
+
// Transport activity is reported synchronously by the subscription so
|
|
201
|
+
// filtered deltas and slow disk writes cannot trip the stall watchdog.
|
|
184
202
|
onSessionEvent?.(line, event);
|
|
185
203
|
}
|
|
186
204
|
catch {
|
|
@@ -271,6 +289,8 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
271
289
|
? `${modelConfig.provider}/${modelConfig.model}`
|
|
272
290
|
: modelConfig.model ?? 'default';
|
|
273
291
|
const timeoutMs = options.timeoutMs ?? modelConfig.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
292
|
+
const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
293
|
+
const abortGraceMs = options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
|
|
274
294
|
const piSdkArgs = [
|
|
275
295
|
'--provider', modelConfig.provider ?? '(default)',
|
|
276
296
|
'--model', modelConfig.model ?? '(default)',
|
|
@@ -282,15 +302,46 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
282
302
|
];
|
|
283
303
|
const startedAt = Date.now();
|
|
284
304
|
let timedOut = false;
|
|
305
|
+
let terminationConfirmed = true;
|
|
285
306
|
let stderr = '';
|
|
286
307
|
const stdoutPreview = new BoundedTextPreview('stdout');
|
|
287
308
|
const stdoutCollector = createPiJsonlStreamCollector();
|
|
288
309
|
const usageSamples = [];
|
|
289
310
|
let session;
|
|
311
|
+
let unsubscribe;
|
|
290
312
|
let timeoutHandle;
|
|
313
|
+
let stallHandle;
|
|
314
|
+
let disposeAttempted = false;
|
|
291
315
|
const sessionEventAppender = options.sessionEventsPath
|
|
292
316
|
? createSessionEventAppender(options.sessionEventsPath, options.onSessionEvent)
|
|
293
317
|
: undefined;
|
|
318
|
+
const appendStderr = (message) => {
|
|
319
|
+
stderr = stderr ? `${stderr}\n${message}` : message;
|
|
320
|
+
};
|
|
321
|
+
const runSessionActionWithGrace = async (label, action) => {
|
|
322
|
+
let graceHandle;
|
|
323
|
+
const outcome = await Promise.race([
|
|
324
|
+
Promise.resolve()
|
|
325
|
+
.then(action)
|
|
326
|
+
.then(() => ({ ok: true }))
|
|
327
|
+
.catch((error) => ({ ok: false, error })),
|
|
328
|
+
new Promise((resolve) => {
|
|
329
|
+
graceHandle = setTimeout(() => resolve({ ok: false, timedOut: true }), abortGraceMs);
|
|
330
|
+
}),
|
|
331
|
+
]);
|
|
332
|
+
if (graceHandle)
|
|
333
|
+
clearTimeout(graceHandle);
|
|
334
|
+
if (outcome.ok)
|
|
335
|
+
return true;
|
|
336
|
+
if ('timedOut' in outcome) {
|
|
337
|
+
appendStderr(`pi SDK ${label} was not confirmed within ${abortGraceMs}ms`);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
|
|
341
|
+
appendStderr(`pi SDK ${label} failed: ${message}`);
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
};
|
|
294
345
|
try {
|
|
295
346
|
const createSession = await resolveSdkSessionFactory(options.reuseScope);
|
|
296
347
|
session = await createSession({
|
|
@@ -301,7 +352,32 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
301
352
|
model: modelConfig.model,
|
|
302
353
|
thinking: modelConfig.thinking,
|
|
303
354
|
});
|
|
304
|
-
|
|
355
|
+
let resolveStall;
|
|
356
|
+
const stallPromise = stallTimeoutMs > 0
|
|
357
|
+
? new Promise((resolve) => {
|
|
358
|
+
resolveStall = resolve;
|
|
359
|
+
})
|
|
360
|
+
: null;
|
|
361
|
+
const armStallWatchdog = () => {
|
|
362
|
+
if (!stallPromise || !resolveStall)
|
|
363
|
+
return;
|
|
364
|
+
if (stallHandle)
|
|
365
|
+
clearTimeout(stallHandle);
|
|
366
|
+
stallHandle = setTimeout(() => resolveStall?.('stall'), stallTimeoutMs);
|
|
367
|
+
};
|
|
368
|
+
unsubscribe = session.subscribe((event) => {
|
|
369
|
+
// Every real SDK event proves transport activity, including noisy deltas
|
|
370
|
+
// intentionally excluded from persisted JSONL and meaningful DAG progress.
|
|
371
|
+
armStallWatchdog();
|
|
372
|
+
const activityKind = classifySdkActivityKind(event);
|
|
373
|
+
if (activityKind) {
|
|
374
|
+
try {
|
|
375
|
+
options.onActivity?.({ kind: activityKind, at: new Date().toISOString() });
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// best-effort: activity must never change Pi result
|
|
379
|
+
}
|
|
380
|
+
}
|
|
305
381
|
const usageSample = extractSdkUsageSample(event);
|
|
306
382
|
if (usageSample)
|
|
307
383
|
usageSamples.push(usageSample);
|
|
@@ -317,44 +393,44 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
317
393
|
? `${filePrefix}\n${options.userMessage}`
|
|
318
394
|
: options.userMessage;
|
|
319
395
|
const promptPromise = session.prompt(promptMessage);
|
|
396
|
+
armStallWatchdog();
|
|
320
397
|
const timeoutPromise = timeoutMs > 0
|
|
321
398
|
? new Promise((resolve) => {
|
|
322
399
|
timeoutHandle = setTimeout(() => {
|
|
323
|
-
|
|
324
|
-
void session?.abort();
|
|
325
|
-
resolve('timeout');
|
|
400
|
+
resolve('absolute-timeout');
|
|
326
401
|
}, timeoutMs);
|
|
327
402
|
})
|
|
328
403
|
: null;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
404
|
+
const raced = await Promise.race([
|
|
405
|
+
promptPromise.then(() => 'done'),
|
|
406
|
+
...(timeoutPromise ? [timeoutPromise] : []),
|
|
407
|
+
...(stallPromise ? [stallPromise] : []),
|
|
408
|
+
]);
|
|
409
|
+
if (raced !== 'done') {
|
|
410
|
+
timedOut = true;
|
|
411
|
+
appendStderr(raced === 'stall'
|
|
412
|
+
? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
|
|
413
|
+
: `pi SDK step timed out after ${timeoutMs}ms`);
|
|
414
|
+
const abortConfirmed = await runSessionActionWithGrace('abort', () => session.abort());
|
|
415
|
+
disposeAttempted = true;
|
|
416
|
+
const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
|
|
417
|
+
terminationConfirmed = abortConfirmed && disposeConfirmed;
|
|
337
418
|
}
|
|
338
|
-
else {
|
|
339
|
-
await promptPromise;
|
|
340
|
-
}
|
|
341
|
-
unsubscribe();
|
|
342
419
|
}
|
|
343
420
|
catch (error) {
|
|
344
421
|
const message = error instanceof Error ? error.message : String(error);
|
|
345
|
-
|
|
422
|
+
appendStderr(message);
|
|
346
423
|
}
|
|
347
424
|
finally {
|
|
348
425
|
if (timeoutHandle)
|
|
349
426
|
clearTimeout(timeoutHandle);
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
427
|
+
if (stallHandle)
|
|
428
|
+
clearTimeout(stallHandle);
|
|
429
|
+
unsubscribe?.();
|
|
430
|
+
if (session && !disposeAttempted) {
|
|
431
|
+
disposeAttempted = true;
|
|
432
|
+
const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
|
|
433
|
+
terminationConfirmed = terminationConfirmed && disposeConfirmed;
|
|
358
434
|
}
|
|
359
435
|
if (sessionEventAppender) {
|
|
360
436
|
try {
|
|
@@ -378,14 +454,14 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
378
454
|
? collected.parsedEvents
|
|
379
455
|
: fallbackParsed?.parsedEvents ?? 0;
|
|
380
456
|
const tokensUsed = aggregateSdkTokenUsage(usageSamples);
|
|
381
|
-
const failureCategory = classifyPiFailure({
|
|
457
|
+
const failureCategory = terminationConfirmed ? classifyPiFailure({
|
|
382
458
|
assistantText,
|
|
383
459
|
exitCode: timedOut ? 1 : stderr ? 1 : 0,
|
|
384
460
|
outputTooLarge: collected.outputTooLarge,
|
|
385
461
|
stderr,
|
|
386
462
|
stdout,
|
|
387
463
|
timedOut,
|
|
388
|
-
});
|
|
464
|
+
}) : 'termination-unconfirmed';
|
|
389
465
|
return {
|
|
390
466
|
assistantText,
|
|
391
467
|
backend: 'sdk',
|
|
@@ -394,7 +470,7 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
394
470
|
exitCode: timedOut || stderr ? 1 : 0,
|
|
395
471
|
failureCategory,
|
|
396
472
|
modelDisplay,
|
|
397
|
-
ok: !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
|
|
473
|
+
ok: terminationConfirmed && !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
|
|
398
474
|
parsedEvents,
|
|
399
475
|
stderr,
|
|
400
476
|
stdout,
|
|
@@ -14,10 +14,11 @@ import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "
|
|
|
14
14
|
import { formatFrontendFailureAssessStdout, formatFrontendRepairContractStdout, runFrontendFailureAssessGate, runFrontendRepairContractGate, } from "../workflows/dag/frontend-repair.js";
|
|
15
15
|
import { formatFrontendPrewriteGateStdout, runFrontendPrewriteGate, } from "../workflows/dag/frontend-prewrite-gate.js";
|
|
16
16
|
import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from "../workflows/dag/frontend-review-context.js";
|
|
17
|
+
import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
|
|
17
18
|
import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
|
|
18
19
|
import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
|
|
19
20
|
import { materializeBackendTestResultFromRunDir, parseJunitXml } from "../workflows/dag/backend-test-result-contract.js";
|
|
20
|
-
import { collectBackendTestHumanCaseCatalog, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
21
|
+
import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
|
|
21
22
|
import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
|
|
22
23
|
import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
|
|
23
24
|
import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
@@ -304,6 +305,14 @@ async function runShellWriteGuard(input) {
|
|
|
304
305
|
forbiddenPaths: input.task.forbiddenPaths,
|
|
305
306
|
});
|
|
306
307
|
}
|
|
308
|
+
async function readRequiredRunReport(reportsDir, filename) {
|
|
309
|
+
try {
|
|
310
|
+
return await readFile(path.join(reportsDir, filename), "utf8");
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
throw new Error(`missing required upstream report: reports/${filename}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
307
316
|
async function executeBackendTestPipeline(input, meta) {
|
|
308
317
|
const pipeline = input.task.shell?.backendTestPipeline;
|
|
309
318
|
const started = Date.now();
|
|
@@ -332,22 +341,56 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
332
341
|
meta.spec.objective ?? "",
|
|
333
342
|
...(meta.spec.successCriteria ?? []),
|
|
334
343
|
]);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
344
|
+
let report;
|
|
345
|
+
try {
|
|
346
|
+
report = await validateBackendMarkdownCases({
|
|
347
|
+
workspaceRoot: input.cwd,
|
|
348
|
+
environmentMarkdown,
|
|
349
|
+
requiredRequirementIds,
|
|
350
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
report = `# Backend Markdown Case Validation\n\n## Status\n\nFAIL\n\n## Findings\n\n- Validator could not complete: ${error instanceof Error ? error.message : String(error)}\n`;
|
|
355
|
+
}
|
|
341
356
|
const reportPath = await writeRunReport(meta.runDir, "backend-md-case-validation.md", report);
|
|
342
357
|
outputs.push(`caseValidation=${reportPath}`, report);
|
|
358
|
+
if (hasBlockingBackendMarkdownSafetyFindings(report)) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
stdout: outputs.join("\n"),
|
|
362
|
+
stderr: "backend Markdown safety gate blocked: secret-shaped values detected",
|
|
363
|
+
failureCategory: "invalid-output",
|
|
364
|
+
durationMs: Date.now() - started,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
343
367
|
}
|
|
344
368
|
else if (pipeline === "markdown-traceability") {
|
|
345
|
-
|
|
369
|
+
let report;
|
|
370
|
+
try {
|
|
371
|
+
report = await validateBackendMarkdownTraceability(input.cwd);
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
report = `# Backend Test Traceability\n\n## Status\n\nFAIL\n\n## Findings\n\n- Traceability validation could not complete: ${error instanceof Error ? error.message : String(error)}\n`;
|
|
375
|
+
}
|
|
346
376
|
const reportPath = await writeRunReport(meta.runDir, "backend-test-traceability.md", report);
|
|
347
377
|
outputs.push(`traceability=${reportPath}`, report);
|
|
348
378
|
}
|
|
349
379
|
else if (pipeline === "markdown-execute-html") {
|
|
350
|
-
const
|
|
380
|
+
const mappedScripts = await collectBackendTestMappedPytestScripts(input.cwd);
|
|
381
|
+
const shellQuote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
382
|
+
const pytestTargets = mappedScripts.map(shellQuote).join(" ");
|
|
383
|
+
const pytestCommand = [
|
|
384
|
+
'mkdir -p "${HARNESS_DAG_RUN_DIR}/reports"',
|
|
385
|
+
'PYTHON_BIN="$(command -v python || command -v python3 || true)"',
|
|
386
|
+
'if [ -z "${PYTHON_BIN}" ]; then echo "python/python3 is required for backend-test execution" >&2; exit 127; fi',
|
|
387
|
+
`PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 "\${PYTHON_BIN}" -m pytest ${pytestTargets} -v -p no:cacheprovider -o junit_logging=all -o junit_log_passing_tests=true --junitxml="\${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml"`,
|
|
388
|
+
"STATUS=$?",
|
|
389
|
+
'printf "%s" "${STATUS}" > "${HARNESS_DAG_RUN_DIR}/reports/backend-test-pytest-exit.txt"',
|
|
390
|
+
'if { [ "${STATUS}" -eq 0 ] || [ "${STATUS}" -eq 1 ]; } && [ -s "${HARNESS_DAG_RUN_DIR}/reports/backend-test.junit.xml" ]; then exit 0; fi',
|
|
391
|
+
'exit "${STATUS}"',
|
|
392
|
+
].join("; ");
|
|
393
|
+
const results = await executePipelineCommands(input, meta, [pytestCommand]);
|
|
351
394
|
if (!results.every((result) => result.ok)) {
|
|
352
395
|
const failure = results.find((result) => !result.ok);
|
|
353
396
|
return { ok: false, stdout: results.map((result) => result.stdout).join("\n"), stderr: failure.stderr, failureCategory: failure.failureCategory, durationMs: Date.now() - started };
|
|
@@ -359,18 +402,22 @@ async function executeBackendTestPipeline(input, meta) {
|
|
|
359
402
|
throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
|
|
360
403
|
const parsed = parseJunitXml(junitContent);
|
|
361
404
|
const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
|
|
405
|
+
const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
|
|
406
|
+
const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
|
|
362
407
|
const htmlContent = renderBackendTestHtml({
|
|
363
408
|
title: meta.spec.title,
|
|
364
409
|
parsed,
|
|
365
410
|
cases,
|
|
366
411
|
environmentSummary: await readFile(path.join(reportsDir, "backend-test-environment.md"), "utf8"),
|
|
367
|
-
|
|
412
|
+
caseValidationSummary,
|
|
413
|
+
traceabilitySummary,
|
|
368
414
|
});
|
|
369
415
|
const htmlPath = await writeRunReport(meta.runDir, "backend-test.html", htmlContent);
|
|
370
|
-
const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent });
|
|
416
|
+
const facts = renderBackendTestFacts({ parsed, cases, pytestExitCode, junitRelativePath: "reports/backend-test.junit.xml", htmlRelativePath: "reports/backend-test.html", junitContent, htmlContent, caseValidationSummary, traceabilitySummary });
|
|
417
|
+
const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
|
|
371
418
|
const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
|
|
372
419
|
const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
|
|
373
|
-
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `facts=${factsPath}`, facts);
|
|
420
|
+
outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, facts);
|
|
374
421
|
}
|
|
375
422
|
else if (pipeline === "contracts") {
|
|
376
423
|
const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
|
|
@@ -618,13 +665,46 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
618
665
|
catch {
|
|
619
666
|
beforeStatus = undefined;
|
|
620
667
|
}
|
|
668
|
+
const lintResults = [];
|
|
669
|
+
for (const command of bundle.lintCommands ?? []) {
|
|
670
|
+
const commandNumber = results.length + 1;
|
|
671
|
+
const result = await executeShellCommand({
|
|
672
|
+
command,
|
|
673
|
+
cwd,
|
|
674
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
675
|
+
envAllowlist: shell.envAllowlist,
|
|
676
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
677
|
+
outputArtifacts: {
|
|
678
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
679
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
lintResults.push(result);
|
|
683
|
+
results.push(result);
|
|
684
|
+
}
|
|
685
|
+
let lintAssessment;
|
|
686
|
+
if ((bundle.lintCommands?.length ?? 0) > 0 &&
|
|
687
|
+
bundle.lintBaselineNodeId &&
|
|
688
|
+
(bundle.writerNodeIds?.length ?? 0) > 0) {
|
|
689
|
+
lintAssessment = await materializeFrontendLintAssessment({
|
|
690
|
+
runDir: meta.runDir,
|
|
691
|
+
workspaceRoot: input.cwd,
|
|
692
|
+
commands: bundle.lintCommands,
|
|
693
|
+
results: lintResults,
|
|
694
|
+
baselineNodeId: bundle.lintBaselineNodeId,
|
|
695
|
+
writerNodeIds: bundle.writerNodeIds,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const lintBlocked = lintAssessment?.status === "failed" ||
|
|
699
|
+
lintAssessment?.status === "unavailable";
|
|
621
700
|
const groups = [
|
|
622
|
-
{ name: "mock", commands: bundle.mockCommands },
|
|
623
|
-
{ name: "static", commands: bundle.staticCommands },
|
|
624
|
-
{ name: "behavior", commands: bundle.behaviorCommands },
|
|
701
|
+
{ name: "mock", commands: bundle.mockCommands, labels: bundle.mockEvidence?.commandLabels ?? [] },
|
|
702
|
+
{ name: "static", commands: bundle.staticCommands, labels: bundle.staticEvidence.commandLabels },
|
|
703
|
+
{ name: "behavior", commands: bundle.behaviorCommands, labels: bundle.behaviorEvidence.commandLabels },
|
|
625
704
|
];
|
|
626
|
-
|
|
627
|
-
|
|
705
|
+
const successfulLabels = new Map();
|
|
706
|
+
for (const group of lintBlocked ? [] : groups) {
|
|
707
|
+
for (const [index, command] of group.commands.entries()) {
|
|
628
708
|
const commandNumber = results.length + 1;
|
|
629
709
|
const result = await executeShellCommand({
|
|
630
710
|
command,
|
|
@@ -638,6 +718,11 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
638
718
|
},
|
|
639
719
|
});
|
|
640
720
|
results.push(result);
|
|
721
|
+
if (result.ok && group.labels[index]) {
|
|
722
|
+
const labels = successfulLabels.get(group.name) ?? [];
|
|
723
|
+
labels.push(group.labels[index]);
|
|
724
|
+
successfulLabels.set(group.name, labels);
|
|
725
|
+
}
|
|
641
726
|
if (!result.ok)
|
|
642
727
|
break;
|
|
643
728
|
}
|
|
@@ -670,20 +755,36 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
670
755
|
failureCategory: result.failureCategory,
|
|
671
756
|
command: result.command,
|
|
672
757
|
}));
|
|
673
|
-
const firstFailure =
|
|
758
|
+
const firstFailure = lintBlocked
|
|
759
|
+
? lintResults.find((result) => !result.ok)
|
|
760
|
+
: results
|
|
761
|
+
.filter((result) => !lintResults.includes(result))
|
|
762
|
+
.find((result) => !result.ok);
|
|
763
|
+
const lintSyntheticFailure = lintBlocked && !firstFailure
|
|
764
|
+
? {
|
|
765
|
+
failureCategory: "invalid-output",
|
|
766
|
+
stderr: lintAssessment?.blockingReasons.join("; ") ??
|
|
767
|
+
"frontend lint assessment failed",
|
|
768
|
+
}
|
|
769
|
+
: undefined;
|
|
674
770
|
let traceError;
|
|
675
771
|
try {
|
|
676
772
|
await runFrontendVerificationTraceGate({
|
|
677
773
|
runDir: meta.runDir,
|
|
678
774
|
workspaceRoot: input.cwd,
|
|
679
775
|
evidence: {
|
|
776
|
+
mock: {
|
|
777
|
+
nodeId: input.task.id,
|
|
778
|
+
commandLabels: successfulLabels.get("mock") ?? [],
|
|
779
|
+
commandTexts: bundle.mockCommands.slice(0, successfulLabels.get("mock")?.length ?? 0),
|
|
780
|
+
},
|
|
680
781
|
static: {
|
|
681
782
|
nodeId: input.task.id,
|
|
682
|
-
commandLabels:
|
|
783
|
+
commandLabels: successfulLabels.get("static") ?? [],
|
|
683
784
|
},
|
|
684
785
|
behavior: {
|
|
685
786
|
nodeId: input.task.id,
|
|
686
|
-
commandLabels:
|
|
787
|
+
commandLabels: successfulLabels.get("behavior") ?? [],
|
|
687
788
|
},
|
|
688
789
|
},
|
|
689
790
|
});
|
|
@@ -692,19 +793,26 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
692
793
|
traceError = error instanceof Error ? error : new Error(String(error));
|
|
693
794
|
}
|
|
694
795
|
if (bundle.mode === "repair") {
|
|
695
|
-
if (firstFailure || traceError) {
|
|
796
|
+
if (firstFailure || lintSyntheticFailure || traceError) {
|
|
696
797
|
return {
|
|
697
798
|
ok: false,
|
|
698
799
|
stdout: summarizeCommandResults(results).stdout,
|
|
699
|
-
stderr: firstFailure?.stderr ||
|
|
700
|
-
|
|
800
|
+
stderr: firstFailure?.stderr ||
|
|
801
|
+
lintSyntheticFailure?.stderr ||
|
|
802
|
+
traceError?.message ||
|
|
803
|
+
"frontend reverify failed",
|
|
804
|
+
failureCategory: firstFailure?.failureCategory ??
|
|
805
|
+
lintSyntheticFailure?.failureCategory ??
|
|
806
|
+
"invalid-output",
|
|
701
807
|
durationMs: Date.now() - started,
|
|
702
808
|
...{ commandResults },
|
|
703
809
|
};
|
|
704
810
|
}
|
|
705
811
|
return {
|
|
706
812
|
ok: true,
|
|
707
|
-
stdout:
|
|
813
|
+
stdout: lintAssessment?.status === "baseline-debt"
|
|
814
|
+
? "Frontend reverify bundle: pass with lint baseline-debt"
|
|
815
|
+
: "Frontend reverify bundle: pass",
|
|
708
816
|
stderr: "",
|
|
709
817
|
failureCategory: "success",
|
|
710
818
|
durationMs: Date.now() - started,
|
|
@@ -712,14 +820,22 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
712
820
|
};
|
|
713
821
|
}
|
|
714
822
|
const failureFacts = [];
|
|
715
|
-
if (firstFailure) {
|
|
823
|
+
if (firstFailure || lintSyntheticFailure) {
|
|
824
|
+
const failureStdout = firstFailure
|
|
825
|
+
? [firstFailure.command, firstFailure.stdout]
|
|
826
|
+
.filter(Boolean)
|
|
827
|
+
.join("\n")
|
|
828
|
+
: "";
|
|
716
829
|
failureFacts.push({
|
|
717
830
|
nodeId: input.task.id,
|
|
718
831
|
record: {
|
|
719
832
|
status: "FINISHED",
|
|
720
|
-
failureCategory: firstFailure
|
|
721
|
-
|
|
722
|
-
|
|
833
|
+
failureCategory: firstFailure?.failureCategory ??
|
|
834
|
+
lintSyntheticFailure?.failureCategory,
|
|
835
|
+
// Keep classification scoped to the failed command. Aggregate
|
|
836
|
+
// successful output may contain unrelated writeSet-like JSON.
|
|
837
|
+
stdout: failureStdout,
|
|
838
|
+
stderr: firstFailure?.stderr ?? lintSyntheticFailure?.stderr,
|
|
723
839
|
commandResults,
|
|
724
840
|
},
|
|
725
841
|
});
|
|
@@ -760,6 +876,68 @@ async function executeFrontendVerificationBundle(input, meta) {
|
|
|
760
876
|
};
|
|
761
877
|
}
|
|
762
878
|
}
|
|
879
|
+
async function executeFrontendLintBaseline(input, meta) {
|
|
880
|
+
const started = Date.now();
|
|
881
|
+
const shell = input.task.shell;
|
|
882
|
+
const baseline = shell.frontendLintBaseline;
|
|
883
|
+
const cwd = resolveShellCwd(input.cwd, shell.cwd);
|
|
884
|
+
let beforeStatus;
|
|
885
|
+
try {
|
|
886
|
+
beforeStatus = await readGitStatusPorcelain(input.cwd);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
beforeStatus = undefined;
|
|
890
|
+
}
|
|
891
|
+
const results = [];
|
|
892
|
+
for (const command of baseline.lintCommands) {
|
|
893
|
+
const commandNumber = results.length + 1;
|
|
894
|
+
results.push(await executeShellCommand({
|
|
895
|
+
command,
|
|
896
|
+
cwd,
|
|
897
|
+
timeoutMs: shell.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS,
|
|
898
|
+
envAllowlist: shell.envAllowlist,
|
|
899
|
+
dagRunMeta: { runDir: meta.runDir, runId: meta.runId },
|
|
900
|
+
outputArtifacts: {
|
|
901
|
+
stdoutPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stdout.txt`),
|
|
902
|
+
stderrPath: path.join(meta.runDir, input.task.id, "commands", `${commandNumber}.stderr.txt`),
|
|
903
|
+
},
|
|
904
|
+
}));
|
|
905
|
+
}
|
|
906
|
+
let afterStatus;
|
|
907
|
+
try {
|
|
908
|
+
afterStatus = await readGitStatusPorcelain(input.cwd);
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
afterStatus = undefined;
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const materialized = await materializeFrontendLintBaseline({
|
|
915
|
+
runDir: meta.runDir,
|
|
916
|
+
workspaceRoot: input.cwd,
|
|
917
|
+
commands: baseline.lintCommands,
|
|
918
|
+
results,
|
|
919
|
+
worktreeChanged: beforeStatus === undefined ||
|
|
920
|
+
afterStatus === undefined ||
|
|
921
|
+
beforeStatus !== afterStatus,
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
ok: true,
|
|
925
|
+
stdout: `Frontend lint baseline: ${materialized.artifact.status}\nArtifact: ${materialized.ref.path}\nDiagnostics: ${materialized.artifact.diagnostics.length}`,
|
|
926
|
+
stderr: materialized.artifact.reason ?? "",
|
|
927
|
+
failureCategory: "success",
|
|
928
|
+
durationMs: Date.now() - started,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
catch (error) {
|
|
932
|
+
return {
|
|
933
|
+
ok: false,
|
|
934
|
+
stdout: "",
|
|
935
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
936
|
+
failureCategory: "invalid-output",
|
|
937
|
+
durationMs: Date.now() - started,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
}
|
|
763
941
|
export async function executeDagShellNode(input, meta) {
|
|
764
942
|
const shell = input.task.shell;
|
|
765
943
|
if (shell?.frontendPrewriteGate) {
|
|
@@ -769,6 +947,7 @@ export async function executeDagShellNode(input, meta) {
|
|
|
769
947
|
runDir: meta.runDir,
|
|
770
948
|
config: shell.frontendPrewriteGate,
|
|
771
949
|
sourceBinding: meta.spec.sourceBinding,
|
|
950
|
+
workspaceRoot: input.cwd,
|
|
772
951
|
repoRoot: input.cwd,
|
|
773
952
|
});
|
|
774
953
|
return { ok: true, stdout: formatFrontendPrewriteGateStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
@@ -777,13 +956,20 @@ export async function executeDagShellNode(input, meta) {
|
|
|
777
956
|
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), failureCategory: "invalid-output", durationMs: Date.now() - started };
|
|
778
957
|
}
|
|
779
958
|
}
|
|
959
|
+
if (shell?.frontendLintBaseline) {
|
|
960
|
+
return executeFrontendLintBaseline(input, meta);
|
|
961
|
+
}
|
|
780
962
|
if (shell?.frontendVerificationBundle) {
|
|
781
963
|
return executeFrontendVerificationBundle(input, meta);
|
|
782
964
|
}
|
|
783
965
|
if (shell?.frontendReviewContext) {
|
|
784
966
|
const started = Date.now();
|
|
785
967
|
try {
|
|
786
|
-
const result = await runFrontendReviewContextGate({
|
|
968
|
+
const result = await runFrontendReviewContextGate({
|
|
969
|
+
runDir: meta.runDir,
|
|
970
|
+
workspaceRoot: input.cwd,
|
|
971
|
+
requireBaseline: shell.frontendReviewContext.requireBaseline,
|
|
972
|
+
});
|
|
787
973
|
return { ok: true, stdout: formatFrontendReviewContextStdout(result), stderr: "", failureCategory: "success", durationMs: Date.now() - started };
|
|
788
974
|
}
|
|
789
975
|
catch (error) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared openspec specification path predicates.
|
|
3
|
+
*
|
|
4
|
+
* Canonical frontend specification directories (case-sensitive) and extension
|
|
5
|
+
* allowlist.
|
|
6
|
+
* All consumers in candidate discovery, DAG schema, prewrite gate, and observe
|
|
7
|
+
* must use these predicates to ensure a single consistent scope definition.
|
|
8
|
+
*/
|
|
9
|
+
/** Canonical spec directories (case-sensitive, POSIX form). */
|
|
10
|
+
export const OPENSPEC_SPEC_DIRS = [
|
|
11
|
+
"openspec/schemas",
|
|
12
|
+
"openspec/project-specs",
|
|
13
|
+
"ai_workspace",
|
|
14
|
+
];
|
|
15
|
+
/** Allowed spec file extensions (case-insensitive). */
|
|
16
|
+
export const OPENSPEC_SPEC_EXT_RE = /\.(md|mdx|json|yaml|yml)$/i;
|
|
17
|
+
/**
|
|
18
|
+
* Returns `true` when `filePath` is inside one of the canonical frontend spec
|
|
19
|
+
* directories. `filePath` must be a repo-relative POSIX path (backslashes are
|
|
20
|
+
* normalised internally). Directory names are case-sensitive.
|
|
21
|
+
*/
|
|
22
|
+
export function isOpenspecSpecPath(filePath) {
|
|
23
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
24
|
+
for (const dir of OPENSPEC_SPEC_DIRS) {
|
|
25
|
+
if (normalized === dir || normalized.startsWith(dir + "/"))
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns `true` when `filename` (the basename only, not a full path) has an
|
|
32
|
+
* allowed frontend specification extension.
|
|
33
|
+
*/
|
|
34
|
+
export function isValidOpenspecExtension(filename) {
|
|
35
|
+
return OPENSPEC_SPEC_EXT_RE.test(filename);
|
|
36
|
+
}
|
|
37
|
+
/** Returns `true` for a supported file inside a canonical openspec spec dir. */
|
|
38
|
+
export function isOpenspecSpecFilePath(filePath) {
|
|
39
|
+
return (isOpenspecSpecPath(filePath) &&
|
|
40
|
+
isValidOpenspecExtension(filePath.replaceAll("\\", "/")));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns `true` when free-form search input references a canonical frontend
|
|
44
|
+
* specification directory with exact lowercase directory names.
|
|
45
|
+
*/
|
|
46
|
+
export function isOpenspecSpecSearchTarget(value) {
|
|
47
|
+
const normalized = value.replaceAll("\\", "/");
|
|
48
|
+
return /(?:^|[^A-Za-z0-9_.-])(?:openspec\/(?:schemas|project-specs)|ai_workspace)(?:\/|$)/.test(normalized);
|
|
49
|
+
}
|