@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.
Files changed (45) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +1 -1
  4. package/bin/loop-agent.js +37 -1
  5. package/dist/build-stamp.json +6 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/executors/dag-pi-executor.js +44 -0
  8. package/dist/shared/package-metadata.js +42 -0
  9. package/dist/worker/console/chat/assistant-content.js +11 -0
  10. package/dist/worker/console/chat/pi-runtime.js +6 -2
  11. package/dist/worker/console/chat/turn-process.js +17 -9
  12. package/dist/worker/console/chat/workspace-landing.js +1 -1
  13. package/dist/worker/console/static/assets/index-DuVLjCIT.js +57 -0
  14. package/dist/worker/console/static/index.html +1 -1
  15. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
  16. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +15 -3
  17. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  18. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  19. package/dist/worker/console/static-src/operator-chat/useChatThread.js +1 -0
  20. package/dist/worker/loop-agent/loop-agent-client.js +17 -3
  21. package/dist/worker/observability/read-model.js +20 -0
  22. package/dist/worker/observe/spec-evidence.js +3 -8
  23. package/dist/worker/observe/static/views/dag-inspector.js +6 -71
  24. package/dist/worker/preflight.js +2 -1
  25. package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
  26. package/dist/workflows/dag/contract-output-registry.js +14 -0
  27. package/dist/workflows/dag/contract-validator-registrations.js +8 -0
  28. package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
  29. package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
  30. package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
  31. package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
  32. package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
  33. package/dist/workflows/dag/frontend-recovery-run.js +539 -0
  34. package/dist/workflows/dag/frontend-repair.js +219 -18
  35. package/dist/workflows/dag/frontend-verification-trace.js +47 -32
  36. package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
  37. package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
  38. package/dist/workflows/dag/init-hybrid.js +49 -24
  39. package/dist/workflows/dag/node-execution.js +89 -0
  40. package/dist/workflows/dag/recovery-recommendation.js +58 -0
  41. package/dist/workflows/dag/runner.js +245 -11
  42. package/dist/workflows/dag/scheduler.js +257 -3
  43. package/dist/workflows/dag/types.js +130 -2
  44. package/package.json +4 -3
  45. package/dist/worker/console/static/assets/index-gVHrlqI9.js +0 -56
@@ -6,7 +6,7 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <link rel="stylesheet" href="/inspect/operator-chrome.css" />
8
8
  <title>Loop 操作台 · Operator Console</title>
9
- <script type="module" crossorigin src="/assets/index-gVHrlqI9.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DuVLjCIT.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-DRqZiQ7J.css">
11
11
  </head>
12
12
  <body>
@@ -22,6 +22,7 @@ export function useRecoveryConsole(params) {
22
22
  const [recoveryNodesLoading, setRecoveryNodesLoading] = useState(false);
23
23
  const [recoveryRunStatus, setRecoveryRunStatus] = useState(null);
24
24
  const [recoveryFailureCategory, setRecoveryFailureCategory] = useState(null);
25
+ const [frontendRecovery, setFrontendRecovery] = useState(null);
25
26
  const [manualRunId, setManualRunId] = useState(false);
26
27
  const [manualNodeId, setManualNodeId] = useState(false);
27
28
  const [recoveryReport, setRecoveryReport] = useState(null);
@@ -124,6 +125,7 @@ export function useRecoveryConsole(params) {
124
125
  setRecoveryNodes([]);
125
126
  setRecoveryRunStatus(null);
126
127
  setRecoveryFailureCategory(null);
128
+ setFrontendRecovery(null);
127
129
  return;
128
130
  }
129
131
  setRecoveryNodesLoading(true);
@@ -167,11 +169,13 @@ export function useRecoveryConsole(params) {
167
169
  n.failureCategory &&
168
170
  n.failureCategory !== "success")?.failureCategory;
169
171
  setRecoveryFailureCategory(runFailure ?? nodeFailure ?? null);
172
+ setFrontendRecovery(body.frontendRecovery ?? null);
170
173
  }
171
174
  catch {
172
175
  setRecoveryNodes([]);
173
176
  setRecoveryRunStatus(null);
174
177
  setRecoveryFailureCategory(null);
178
+ setFrontendRecovery(null);
175
179
  }
176
180
  finally {
177
181
  setRecoveryNodesLoading(false);
@@ -323,6 +327,7 @@ export function useRecoveryConsole(params) {
323
327
  recoveryRunsLoading,
324
328
  recoveryNodesLoading,
325
329
  recoveryRunStatus,
330
+ frontendRecovery,
326
331
  manualRunId,
327
332
  setManualRunId,
328
333
  manualNodeId,
@@ -214,12 +214,22 @@ export function createChatSseEventApplier(deps) {
214
214
  // a duplicate bubble.
215
215
  return;
216
216
  }
217
+ const sealedIds = refs.sealedAssistantIdsRef.current;
218
+ if (eventName === "message_update" &&
219
+ sealedIds.has(resolvedAssistantId)) {
220
+ const nextId = requestId("a");
221
+ if (turnId)
222
+ refs.turnAssistantIdsRef.current.set(turnId, nextId);
223
+ resolvedAssistantId = nextId;
224
+ setStreamingAssistantId(nextId);
225
+ }
217
226
  const text = typeof payload.text === "string" ? payload.text : "";
218
227
  const content = parseAssistantContent(payload.content);
228
+ const assistantIdForEvent = resolvedAssistantId;
219
229
  setMessages((m) => {
220
- const exists = m.some((msg) => msg.id === resolvedAssistantId);
230
+ const exists = m.some((msg) => msg.id === assistantIdForEvent);
221
231
  if (exists) {
222
- return m.map((msg) => msg.id === resolvedAssistantId
232
+ return m.map((msg) => msg.id === assistantIdForEvent
223
233
  ? {
224
234
  ...msg,
225
235
  text,
@@ -232,7 +242,7 @@ export function createChatSseEventApplier(deps) {
232
242
  return [
233
243
  ...m,
234
244
  {
235
- id: resolvedAssistantId,
245
+ id: assistantIdForEvent,
236
246
  role: "assistant",
237
247
  text,
238
248
  createdAt: Date.now(),
@@ -240,6 +250,8 @@ export function createChatSseEventApplier(deps) {
240
250
  },
241
251
  ];
242
252
  });
253
+ if (eventName === "message_end")
254
+ sealedIds.add(assistantIdForEvent);
243
255
  }
244
256
  else if (eventName === "tool_call") {
245
257
  const id = typeof payload.toolCallId === "string"
@@ -12,6 +12,7 @@ export function useChatRefs() {
12
12
  const settledTurnIdsRef = useRef(new Set());
13
13
  const seenEventIdsRef = useRef(new Set());
14
14
  const turnAssistantIdsRef = useRef(new Map());
15
+ const sealedAssistantIdsRef = useRef(new Set());
15
16
  const reconnectAbortRef = useRef(null);
16
17
  const activeTurnIdRef = useRef(null);
17
18
  const submitInFlightRef = useRef(false);
@@ -40,6 +41,7 @@ export function useChatRefs() {
40
41
  settledTurnIdsRef,
41
42
  seenEventIdsRef,
42
43
  turnAssistantIdsRef,
44
+ sealedAssistantIdsRef,
43
45
  reconnectAbortRef,
44
46
  activeTurnIdRef,
45
47
  submitInFlightRef,
@@ -66,6 +68,7 @@ export function useChatRefs() {
66
68
  settledTurnIdsRef,
67
69
  seenEventIdsRef,
68
70
  turnAssistantIdsRef,
71
+ sealedAssistantIdsRef,
69
72
  reconnectAbortRef,
70
73
  activeTurnIdRef,
71
74
  submitInFlightRef,
@@ -90,6 +90,7 @@ export function useChatSessions(params) {
90
90
  refs.lastEventIdRef.current = null;
91
91
  refs.seenEventIdsRef.current = new Set();
92
92
  refs.turnAssistantIdsRef.current = new Map();
93
+ refs.sealedAssistantIdsRef.current = new Set();
93
94
  refs.settledTurnIdsRef.current = new Set();
94
95
  refs.activeTurnIdRef.current = null;
95
96
  resetForNewSession();
@@ -199,6 +200,7 @@ export function useChatSessions(params) {
199
200
  refs.activeTurnIdRef.current = body.session.activeTurnId ?? null;
200
201
  refs.seenEventIdsRef.current = new Set();
201
202
  refs.turnAssistantIdsRef.current = new Map();
203
+ refs.sealedAssistantIdsRef.current = new Set();
202
204
  refs.settledTurnIdsRef.current = new Set();
203
205
  refs.lastEventIdRef.current = null;
204
206
  setSession(next);
@@ -296,6 +298,7 @@ export function useChatSessions(params) {
296
298
  refs.activeTurnIdRef.current = body.session.activeTurnId ?? null;
297
299
  refs.seenEventIdsRef.current = new Set();
298
300
  refs.turnAssistantIdsRef.current = new Map();
301
+ refs.sealedAssistantIdsRef.current = new Set();
299
302
  refs.settledTurnIdsRef.current = new Set();
300
303
  refs.lastEventIdRef.current = null;
301
304
  setSession(restored);
@@ -219,6 +219,7 @@ export function useChatThread(params) {
219
219
  refs.lastEventIdRef.current = body.lastEventId ?? null;
220
220
  refs.seenEventIdsRef.current = new Set();
221
221
  refs.turnAssistantIdsRef.current = new Map();
222
+ refs.sealedAssistantIdsRef.current = new Set();
222
223
  setMessages((body.session.messages ?? [])
223
224
  .filter((message) => message.role === "user" || message.role === "assistant")
224
225
  .map((message) => {
@@ -2,11 +2,15 @@ import { spawn } from "node:child_process";
2
2
  import { appendFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { computeBinarySha256, computePackageFingerprint, findPackageRoot, isJavaScriptScript, readPackageName, readPackageVersion, resolveBinPaths, resolveCmdShim, } from "../../shared/package-metadata.js";
5
+ import { computeBinarySha256, computePackageFingerprint, findPackageRoot, isJavaScriptScript, normalizeCliVersionLabel, readPackageName, readPackageVersion, resolveBinPaths, resolveCmdShim, } from "../../shared/package-metadata.js";
6
6
  import { parseCommandJson } from "./parse-json.js";
7
7
  export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
8
8
  export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
9
9
  export const LOOP_AGENT_PACKAGE_NAME = "@tea-agent/loop-agent";
10
+ /** Grace window between the SIGTERM and SIGKILL stages of a hard timeout, so a
11
+ * runner can persist terminal state (see executeDagCheckpoint) before being
12
+ * force-killed. */
13
+ export const HARD_TIMEOUT_KILL_GRACE_MS = 10_000;
10
14
  const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
11
15
  /**
12
16
  * Bind evidence supplied by an orchestration layer to the identity exposed by
@@ -178,10 +182,11 @@ export class LoopAgentClient {
178
182
  if (!this._identity) {
179
183
  throw new Error("controller identity: cannot record a reported version without a pinned identity");
180
184
  }
181
- if (reportedVersion !== this._identity.packageVersion) {
185
+ const normalized = normalizeCliVersionLabel(reportedVersion);
186
+ if (normalized !== this._identity.packageVersion) {
182
187
  throw new Error(`controller identity: reported version ${reportedVersion} does not match package version ${this._identity.packageVersion}`);
183
188
  }
184
- this._identity = { ...this._identity, reportedVersion };
189
+ this._identity = { ...this._identity, reportedVersion: normalized };
185
190
  return this._identity;
186
191
  }
187
192
  async run(args, options) {
@@ -381,10 +386,17 @@ function spawnCommand(input) {
381
386
  });
382
387
  };
383
388
  let hardTimeout;
389
+ let killGraceTimeout;
384
390
  if (input.hardTimeoutMs !== undefined && input.hardTimeoutMs > 0) {
385
391
  hardTimeout = setTimeout(() => {
386
392
  timedOut = true;
393
+ // Two-stage termination: SIGTERM first so the runner can persist a
394
+ // terminal state (see executeDagCheckpoint signal handler), then
395
+ // SIGKILL only after the grace window expires.
387
396
  child.kill("SIGTERM");
397
+ killGraceTimeout = setTimeout(() => {
398
+ child.kill("SIGKILL");
399
+ }, HARD_TIMEOUT_KILL_GRACE_MS);
388
400
  }, input.hardTimeoutMs);
389
401
  }
390
402
  let heartbeatTimer;
@@ -394,6 +406,8 @@ function spawnCommand(input) {
394
406
  const clearTimers = () => {
395
407
  if (hardTimeout !== undefined)
396
408
  clearTimeout(hardTimeout);
409
+ if (killGraceTimeout !== undefined)
410
+ clearTimeout(killGraceTimeout);
397
411
  if (heartbeatTimer !== undefined) {
398
412
  clearInterval(heartbeatTimer);
399
413
  heartbeatTimer = undefined;
@@ -1491,6 +1491,7 @@ function mergeDagRun(existing, incoming) {
1491
1491
  failureCategory: incoming.failureCategory ?? existing.failureCategory,
1492
1492
  stateConsistent: incoming.stateConsistent ?? existing.stateConsistent,
1493
1493
  recoveryEligibility: incoming.recoveryEligibility ?? existing.recoveryEligibility,
1494
+ frontendRecovery: incoming.frontendRecovery ?? existing.frontendRecovery,
1494
1495
  title: incoming.title ?? existing.title,
1495
1496
  startedAt,
1496
1497
  finishedAt,
@@ -1790,6 +1791,23 @@ async function loadFrontendLintProjection(runDir) {
1790
1791
  blockingReasons: parsed.data.blockingReasons,
1791
1792
  };
1792
1793
  }
1794
+ function projectFrontendRecovery(state) {
1795
+ const s = state.frontendRecoveryState;
1796
+ const r = state.frontendRecoveryResult;
1797
+ if (!s && !r)
1798
+ return undefined;
1799
+ return {
1800
+ ...(s?.recoveryRootRunId ? { recoveryRootRunId: s.recoveryRootRunId } : {}),
1801
+ ...(s?.parentRunId ? { parentRunId: s.parentRunId } : {}),
1802
+ ...(s?.childRunId ? { childRunId: s.childRunId } : {}),
1803
+ ...(s?.attemptIndex !== undefined ? { attemptIndex: s.attemptIndex } : {}),
1804
+ ...(s?.continuationCount !== undefined
1805
+ ? { continuationCount: s.continuationCount }
1806
+ : {}),
1807
+ ...(s?.phase ? { phase: s.phase } : {}),
1808
+ ...(r?.outcome ? { outcome: r.outcome } : {}),
1809
+ };
1810
+ }
1793
1811
  async function parseDagStateFile(statePath, now) {
1794
1812
  try {
1795
1813
  if (!existsSync(statePath))
@@ -1860,6 +1878,7 @@ async function parseDagStateFile(statePath, now) {
1860
1878
  loadBackendTestProjection(runDir, nodes),
1861
1879
  loadFrontendLintProjection(runDir),
1862
1880
  ]);
1881
+ const frontendRecovery = projectFrontendRecovery(state);
1863
1882
  const continuationRecord = state.continuation &&
1864
1883
  typeof state.continuation.parentRunId === "string" &&
1865
1884
  typeof state.continuation.effectiveFromNodeId === "string" &&
@@ -1918,6 +1937,7 @@ async function parseDagStateFile(statePath, now) {
1918
1937
  ...(continuation ? { continuation } : {}),
1919
1938
  ...(backendTest ? { backendTest } : {}),
1920
1939
  ...(frontendLint ? { frontendLint } : {}),
1940
+ ...(frontendRecovery ? { frontendRecovery } : {}),
1921
1941
  };
1922
1942
  }
1923
1943
  catch {
@@ -359,16 +359,11 @@ export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
359
359
  if (status === "no-evidence") {
360
360
  summaryLines.push("未观察到任何规范证据:无 skill 注入、无文件读取、无检索操作。");
361
361
  }
362
- // Separate openspec spec directory reads and searches for Dashboard display.
363
- // Knowledge base and openspec are parallel sources.
362
+ // Openspec directory reads/searches remain in the payload for API consumers
363
+ // (tests and downstream projections); the inspector no longer displays them
364
+ // separately from the generic spec reads/searches.
364
365
  const openspecReads = specReads.filter((r) => isOpenspecPath(r.path));
365
366
  const openspecSearches = searches.filter((s) => isOpenspecSearch(s.query, s.path));
366
- if (openspecReads.length > 0) {
367
- summaryLines.push(`openspec 已读取 ${openspecReads.length} 个文件:${openspecReads.map((r) => r.path).join("、")}`);
368
- }
369
- if (openspecSearches.length > 0) {
370
- summaryLines.push(`openspec 检索 ${openspecSearches.length} 次。`);
371
- }
372
367
  return {
373
368
  dagRunId,
374
369
  nodeId,
@@ -360,15 +360,9 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
360
360
  evidence.sourceBinding?.requirementIds,
361
361
  (id) => el("li", null, id),
362
362
  );
363
- const openspecReadPaths = new Set(
364
- (evidence.openspecReads ?? []).map((entry) => entry.path),
365
- );
366
- const otherSpecReads = (evidence.specReads ?? []).filter(
367
- (entry) => !openspecReadPaths.has(entry.path),
368
- );
369
363
  appendListSection(
370
- `其他已读取规范文件(${otherSpecReads.length})`,
371
- otherSpecReads,
364
+ `规范已读取(${evidence.specReads?.length ?? 0})`,
365
+ evidence.specReads,
372
366
  (read) => {
373
367
  const item = el("li", null);
374
368
  const button = document.createElement("button");
@@ -380,7 +374,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
380
374
  button.setAttribute("data-read-at", read.timestamp);
381
375
  }
382
376
  button.id = `spec-evidence-btn-read-${read.path}`;
383
- const icon = el("i", "ri-file-text-line");
377
+ const icon = el("i", "ri-book-open-line");
384
378
  icon.setAttribute("aria-hidden", "true");
385
379
  button.append(icon, el("code", null, read.path));
386
380
  if (read.timestamp) {
@@ -405,9 +399,10 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
405
399
  item.appendChild(button);
406
400
  return item;
407
401
  },
402
+ "unavailable:未观察到规范读取。",
408
403
  );
409
404
  appendListSection(
410
- `检索操作(${evidence.specSearches?.length ?? 0})`,
405
+ `规范检索(${evidence.specSearches?.length ?? 0})`,
411
406
  evidence.specSearches,
412
407
  (search) => {
413
408
  const item = el("li", null);
@@ -419,6 +414,7 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
419
414
  );
420
415
  return item;
421
416
  },
417
+ "unavailable:未观察到规范检索。",
422
418
  );
423
419
  appendListSection(
424
420
  `知识库查询(${evidence.knowledgeBaseQueries?.length ?? 0})`,
@@ -433,67 +429,6 @@ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
433
429
  "unavailable:未观察到知识库查询;connector 可能未配置或未调用。",
434
430
  );
435
431
 
436
- // openspec reads are displayed independently from skill/docs reads.
437
- appendListSection(
438
- `openspec 已读取(${evidence.openspecReads?.length ?? 0})`,
439
- evidence.openspecReads,
440
- (read) => {
441
- const item = el("li", null);
442
- const button = document.createElement("button");
443
- button.type = "button";
444
- button.className = "spec-evidence-file-btn";
445
- button.setAttribute("data-source", "read");
446
- button.setAttribute("data-path", read.path);
447
- if (read.timestamp) {
448
- button.setAttribute("data-read-at", read.timestamp);
449
- }
450
- button.id = `spec-evidence-btn-read-${read.path}`;
451
- const icon = el("i", "ri-book-open-line");
452
- icon.setAttribute("aria-hidden", "true");
453
- button.append(icon, el("code", null, read.path));
454
- if (read.timestamp) {
455
- button.appendChild(
456
- el(
457
- "span",
458
- "spec-evidence-time",
459
- formatSessionEventTime({ timestamp: read.timestamp }),
460
- ),
461
- );
462
- }
463
- button.addEventListener("click", () =>
464
- onSpecEvidenceFileClick(
465
- content,
466
- dagRunId,
467
- nodeId,
468
- "read",
469
- read.path,
470
- button,
471
- ),
472
- );
473
- item.appendChild(button);
474
- return item;
475
- },
476
- evidence.openspecSearches?.length
477
- ? "已检索但未观察到 openspec 成功读取;若生成期存在候选,预写门禁会阻断 writer。"
478
- : "unavailable:未观察到 openspec 成功读取。",
479
- );
480
-
481
- appendListSection(
482
- `openspec 检索(${evidence.openspecSearches?.length ?? 0})`,
483
- evidence.openspecSearches,
484
- (search) => {
485
- const item = el("li", null);
486
- const icon = el("i", "ri-search-line");
487
- icon.setAttribute("aria-hidden", "true");
488
- item.append(
489
- icon,
490
- document.createTextNode(` ${search.tool}: ${search.query}`),
491
- );
492
- return item;
493
- },
494
- "unavailable:未观察到 openspec 检索。",
495
- );
496
-
497
432
  if (
498
433
  evidence.status === "source-bound" ||
499
434
  evidence.status === "spec-injected" ||
@@ -1,5 +1,6 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { normalizeCliVersionLabel } from "../shared/package-metadata.js";
3
4
  import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from "./loop-agent/loop-agent-client.js";
4
5
  export const DEFAULT_CHECK_REPO_TIMEOUT_MS = 300_000;
5
6
  export async function preflightTargetRepo(input) {
@@ -47,7 +48,7 @@ export async function preflightTargetRepo(input) {
47
48
  controllerIdentity: identity,
48
49
  };
49
50
  }
50
- const reportedVersion = version.stdout.trim();
51
+ const reportedVersion = normalizeCliVersionLabel(version.stdout);
51
52
  let controllerIdentity = identity
52
53
  ? { ...identity, reportedVersion }
53
54
  : undefined;
@@ -724,30 +724,40 @@ export function deterministicRewriteScenarioParam(input) {
724
724
  return { ok: true, source: nextSource, detail: "rewritten" };
725
725
  }
726
726
  function validatePythonSyntaxWithoutExecution(source) {
727
- const result = spawnSync("python", ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
728
- input: source,
729
- encoding: "utf8",
730
- env: {
731
- ...process.env,
732
- PYTHONDONTWRITEBYTECODE: "1",
733
- PYTHONUTF8: "1",
734
- },
735
- timeout: 15_000,
736
- });
737
- if (result.error) {
738
- return {
739
- ok: false,
740
- detail: `python syntax validation unavailable: ${result.error.message}`,
741
- };
742
- }
743
- if (result.status !== 0) {
744
- const detail = String(result.stderr || result.stdout || "invalid Python syntax")
745
- .trim()
746
- .replace(/\s+/g, " ")
747
- .slice(0, 500);
748
- return { ok: false, detail: `python syntax validation failed: ${detail}` };
727
+ // Prefer `python3` (present on modern POSIX images and macOS); fall back to
728
+ // `python` for older or Windows layouts where only the unversioned shim exists.
729
+ // A missing interpreter must not silently roll back an otherwise-valid
730
+ // deterministic rewrite.
731
+ const candidates = ["python3", "python"];
732
+ let lastError;
733
+ for (const interpreter of candidates) {
734
+ const result = spawnSync(interpreter, ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
735
+ input: source,
736
+ encoding: "utf8",
737
+ env: {
738
+ ...process.env,
739
+ PYTHONDONTWRITEBYTECODE: "1",
740
+ PYTHONUTF8: "1",
741
+ },
742
+ timeout: 15_000,
743
+ });
744
+ if (result.error) {
745
+ lastError = result.error.message;
746
+ continue;
747
+ }
748
+ if (result.status !== 0) {
749
+ const detail = String(result.stderr || result.stdout || "invalid Python syntax")
750
+ .trim()
751
+ .replace(/\s+/g, " ")
752
+ .slice(0, 500);
753
+ return { ok: false, detail: `python syntax validation failed: ${detail}` };
754
+ }
755
+ return { ok: true, detail: "python ast.parse PASS" };
749
756
  }
750
- return { ok: true, detail: "python ast.parse PASS" };
757
+ return {
758
+ ok: false,
759
+ detail: `python syntax validation unavailable: ${lastError ?? "python/python3 not found"}`,
760
+ };
751
761
  }
752
762
  export async function applyDeterministicScenarioParamRepairs(input) {
753
763
  const repairable = input.facts.entries.filter((entry) => entry.status === "MISMATCH" && entry.repairability === "repairable");
@@ -0,0 +1,14 @@
1
+ import { structuredContractOutputSchemaIds, } from "./types.js";
2
+ const validators = new Map();
3
+ export function registerStructuredContractValidator(schemaId, validator) {
4
+ if (validators.has(schemaId)) {
5
+ throw new Error(`structured contract validator already registered for ${schemaId}`);
6
+ }
7
+ validators.set(schemaId, validator);
8
+ }
9
+ export function getStructuredContractValidator(schemaId) {
10
+ if (structuredContractOutputSchemaIds.includes(schemaId)) {
11
+ return validators.get(schemaId);
12
+ }
13
+ return undefined;
14
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Side-effect module: registers every structured contract validator against
3
+ * its schemaId in the contract output registry. Imported once by the node
4
+ * executor; keeps per-contract imports out of the executor.
5
+ */
6
+ import { validateFrontendContractNodeOutput } from "./frontend-implementation-contract.js";
7
+ import { registerStructuredContractValidator } from "./contract-output-registry.js";
8
+ registerStructuredContractValidator("frontend-implementation-contract-v1", validateFrontendContractNodeOutput);
@@ -31,7 +31,15 @@ export function getNodeOutputAsJson(state, nodeId) {
31
31
  export function normalizeVerdictCandidateLine(value) {
32
32
  const trimmed = String(value).trim();
33
33
  const emphasized = trimmed.match(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/);
34
- return (emphasized ? emphasized[1] : trimmed).trim();
34
+ const line = (emphasized ? emphasized[1] : trimmed).trim();
35
+ // Canonicalize the colon separator and trailing sentence punctuation so
36
+ // "VERDICT:pass", "VERDICT: pass", or "VERDICT: pass." match the declared
37
+ // enum lines without accepting free-text verdicts. Conflicting verdicts
38
+ // still canonicalize to distinct enum lines and remain fail-closed.
39
+ const verdictMatch = line.match(/^VERDICT:\s*(pass|request-revision)\s*[.!?。!?]*$/i);
40
+ if (verdictMatch)
41
+ return `VERDICT: ${verdictMatch[1].toLowerCase()}`;
42
+ return line;
35
43
  }
36
44
  /** First line matching /^VERDICT:/ from node assistantText/stdout (markdown emphasis OK). */
37
45
  export function extractFirstVerdictLineFromNodeText(text) {