@tea-agent/loop-agent 0.35.1-beta.2 → 0.35.1

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 (48) hide show
  1. package/AGENTS.md +0 -2
  2. package/CHANGELOG.md +25 -24
  3. package/bin/loop-agent.js +1 -37
  4. package/dist/application/dag/generate-task-dag.js +4 -1
  5. package/dist/application/task-lifecycle/advance.js +14 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/commands/task-advance.js +1 -0
  8. package/dist/executors/dag-pi-executor.js +0 -44
  9. package/dist/shared/package-metadata.js +0 -42
  10. package/dist/task/config-types.js +2 -0
  11. package/dist/task/contract/project.js +3 -0
  12. package/dist/task/contract/schema.js +1 -0
  13. package/dist/task/source-prepare/build-draft.js +7 -0
  14. package/dist/task/source-prepare/semantic-intake.js +37 -10
  15. package/dist/task/task-demand-routing.js +10 -0
  16. package/dist/worker/console/operator-actions.js +72 -6
  17. package/dist/worker/console/prd-intake-bridge.js +10 -3
  18. package/dist/worker/console/prd-reference-discovery.js +124 -0
  19. package/dist/worker/console/static/assets/{index-hJqCPs_g.css → index-HX1pbOyl.css} +1 -1
  20. package/dist/worker/console/static/assets/{index-CvsQgALl.js → index-M0BLEBfh.js} +25 -25
  21. package/dist/worker/console/static/index.html +2 -2
  22. package/dist/worker/console/static-src/app/useOperatorActions.js +19 -1
  23. package/dist/worker/console/static-src/app/useRecoveryConsole.js +0 -5
  24. package/dist/worker/console/static-src/app/useTaskWizard.js +12 -0
  25. package/dist/worker/loop-agent/loop-agent-client.js +3 -17
  26. package/dist/worker/observability/read-model.js +0 -20
  27. package/dist/worker/preflight.js +1 -2
  28. package/dist/workflows/dag/backend-test-scenario-param.js +23 -33
  29. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
  30. package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
  31. package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
  32. package/dist/workflows/dag/frontend-repair.js +18 -219
  33. package/dist/workflows/dag/frontend-verification-trace.js +32 -47
  34. package/dist/workflows/dag/init-hybrid.js +26 -41
  35. package/dist/workflows/dag/node-execution.js +0 -89
  36. package/dist/workflows/dag/recovery-recommendation.js +0 -58
  37. package/dist/workflows/dag/runner.js +11 -245
  38. package/dist/workflows/dag/scheduler.js +3 -257
  39. package/dist/workflows/dag/types.js +2 -130
  40. package/package.json +2 -2
  41. package/dist/build-stamp.json +0 -6
  42. package/dist/workflows/dag/contract-output-registry.js +0 -14
  43. package/dist/workflows/dag/contract-validator-registrations.js +0 -8
  44. package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
  45. package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
  46. package/dist/workflows/dag/frontend-recovery-run.js +0 -539
  47. package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
  48. package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
@@ -269,61 +269,3 @@ export function planDagRecovery(input) {
269
269
  }
270
270
  return planByNormalizedCategory(input);
271
271
  }
272
- /**
273
- * Phase 6: route a frontend recovery outcome to a recovery CTA (read-only; never
274
- * mutates a run's status). The six outcomes map to the existing recovery actions
275
- * so the Console recovery page reuses the current CTA surface.
276
- */
277
- export function recommendFrontendRecoveryOutcome(outcome) {
278
- switch (outcome) {
279
- case "none":
280
- return {
281
- action: "none",
282
- summary: "未发生前端恢复;按普通终态展示。",
283
- reason: "该 frontend-implementation 根没有触发自动恢复。",
284
- humanRequired: false,
285
- autoRetryEligible: false,
286
- };
287
- case "recovered":
288
- return {
289
- action: "none",
290
- summary: "自动恢复成功;child 已重新实现并通过验证。",
291
- reason: "parent 的 transient 失败已由 child 恢复,全链按 root 计一次成功。",
292
- humanRequired: false,
293
- autoRetryEligible: false,
294
- };
295
- case "candidate-contract-invalid":
296
- return {
297
- action: "manual-review",
298
- summary: "方案输出无效:candidate JSON 无法解析/推导,重试用尽。",
299
- reason: "建议人工检查需求或重新生成方案后再跑。",
300
- humanRequired: true,
301
- autoRetryEligible: false,
302
- };
303
- case "prewrite-blocked":
304
- return {
305
- action: "manual-review",
306
- summary: "治理门禁阻断:source stale / writeSet 越界 / Mock policy / contract 违规。",
307
- reason: "需人工修正任务边界/事实后重跑,不自动重试。",
308
- humanRequired: true,
309
- autoRetryEligible: false,
310
- };
311
- case "repair-exhausted":
312
- return {
313
- action: "rerun-after-fix",
314
- summary: "自动修复未通过:verify 失败,1 次 repair 后 reverify 仍失败。",
315
- reason: "建议 `dag rerun` 或人工排查。",
316
- humanRequired: true,
317
- autoRetryEligible: false,
318
- commandHint: "loop-agent dag rerun --run-id <runId>",
319
- };
320
- case "auto-recovery-blocked":
321
- return {
322
- action: "manual-review",
323
- summary: "自动恢复被阻止:rollback 无法证明完整或存在外部/越界修改。",
324
- reason: "必须人工介入,禁止自动覆盖用户文件。",
325
- humanRequired: true,
326
- autoRetryEligible: false,
327
- };
328
- }
329
- }
@@ -1,5 +1,4 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
- import { randomUUID } from "node:crypto";
3
2
  import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
4
3
  import { hostname } from "node:os";
5
4
  import path from "node:path";
@@ -7,7 +6,7 @@ import { isHardBudgetBreached, resolveEffectiveMaxConcurrent, } from "../../appl
7
6
  import { readCandidateRecord } from "../../infrastructure/evaluation/candidate-store.js";
8
7
  import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/runtime.js";
9
8
  import { assertFrozenBudget, initRunBudgetLedger, preflightBudgetOrBreach, recordFinishedNodeBudget, writeBudgetLedgerArtifacts, } from "./budget-enforcement.js";
10
- import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readDagRunSpec, readDagRunState, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
9
+ import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, requireActiveDagRun, } from "./lifecycle.js";
11
10
  import { moveToCompletedRunDir, moveToPausedRunDir, prepareRunDir, writeRunSpec, writeRunState, } from "./run-store.js";
12
11
  import { evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
13
12
  import { createDagNodeExecutor } from "./executor-registry.js";
@@ -22,9 +21,8 @@ import { createSkillSnapshot, prepareSkillSnapshotForContinuation, writeSkillSna
22
21
  import { captureWorkspaceCheckpoint, WORKSPACE_CHECKPOINT_START_REL, WORKSPACE_CHECKPOINT_TERMINAL_REL, writeWorkspaceCheckpoint, } from "./workspace-checkpoint.js";
23
22
  import { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions, executeDagNode, } from "./node-execution.js";
24
23
  import { runConvergencePassController, } from "./convergence/controller.js";
25
- import { executeDagRanksOnce, FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, FRONTEND_WRITER_NODE_IDS, isConditionSkippedReason, readFrontendPrewriteResult, } from "./scheduler.js";
24
+ import { executeDagRanksOnce, isConditionSkippedReason } from "./scheduler.js";
26
25
  import { topoSortToRanks } from "./topo.js";
27
- import { isFrontendWriterTransientPartialWrite } from "./frontend-writer-recovery.js";
28
26
  import { parseDagSpec, resolveModelForTask, } from "./types.js";
29
27
  import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
30
28
  import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
@@ -450,39 +448,6 @@ async function executeDagCheckpoint(input) {
450
448
  void persistState().catch(() => { });
451
449
  }, runnerLivenessPolicy.heartbeatIntervalMs);
452
450
  heartbeatTimer.unref();
453
- // Graceful terminal persistence: an outer SIGTERM/SIGINT (operator hard
454
- // timeout, supervision layer, or shell wall-clock) must not leave the run
455
- // orphaned in RUNNING with a dead heartbeat. Persist a terminal failed
456
- // state with an explicit terminalReason before exiting so downstream
457
- // liveness/doctor tooling sees a diagnosable terminal instead of an orphan.
458
- let terminalSignal;
459
- const shutdownOnSignal = (signal) => {
460
- if (terminalSignal)
461
- return;
462
- terminalSignal = signal;
463
- clearInterval(heartbeatTimer);
464
- if (state.runner)
465
- state.runner.heartbeatAt = new Date().toISOString();
466
- state.status = "failed";
467
- state.finishedAt = new Date().toISOString();
468
- state.terminalReason = `runner terminated by ${signal} before run completion`;
469
- console.warn(`[run-dag] received ${signal}; persisting terminal state and exiting`);
470
- const exit = () => process.exit(signal === "SIGINT" ? 130 : 143);
471
- // Best-effort atomic persist with a hard exit deadline so a stuck write
472
- // queue cannot keep the process alive past the supervisor's kill window.
473
- const hardExit = setTimeout(exit, 5000);
474
- hardExit.unref();
475
- void persistState()
476
- .catch(() => { })
477
- .finally(() => {
478
- clearTimeout(hardExit);
479
- exit();
480
- });
481
- };
482
- const onSigTerm = () => shutdownOnSignal("SIGTERM");
483
- const onSigInt = () => shutdownOnSignal("SIGINT");
484
- process.once("SIGTERM", onSigTerm);
485
- process.once("SIGINT", onSigInt);
486
451
  try {
487
452
  const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
488
453
  const baseExecuteNode = input.executeNode ??
@@ -539,7 +504,6 @@ async function executeDagCheckpoint(input) {
539
504
  tasksById,
540
505
  maxConcurrent,
541
506
  persistState,
542
- runDir,
543
507
  abortSignal: input.abortSignal,
544
508
  createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
545
509
  baseExecuteNode,
@@ -631,54 +595,17 @@ async function executeDagCheckpoint(input) {
631
595
  runDir = await moveToPausedRunDir(runDir, pausedRunDir);
632
596
  }
633
597
  else {
634
- const { recoveryPending } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd);
598
+ finalizeTerminalRunStatus(state, spec.tasks.length);
635
599
  await persistState();
636
- if (recoveryPending) {
637
- // Recovery coordinator (phase 3c AC-2/AC-3): materialize the reserved
638
- // child and run it synchronously. Dynamic import avoids a static
639
- // runner ↔ frontend-recovery-run module cycle. stageRecoveryChild
640
- // locates the parent via the canonical active/ layout, so recovery
641
- // only fires when this run is still the canonical active run dir
642
- // (direct runDagContinuation callers using an arbitrary runDir keep
643
- // the previous scheduler-layer skip semantics).
644
- const canonicalActiveDir = getDagRunDir(cwd, "active", state.runId);
645
- if (path.resolve(runDir) === path.resolve(canonicalActiveDir)) {
646
- const { stageRecoveryChild } = await import("./frontend-recovery-run.js");
647
- const staged = await stageRecoveryChild({
648
- cwd,
649
- parentRunId: state.runId,
650
- });
651
- // stageRecoveryChild CAS-advanced the parent's on-disk lineage
652
- // (child-staging → child-running + childRunId). Refresh the in-memory
653
- // copy so the terminal persistState below does not regress the parent
654
- // back to stale child-staging.
655
- const parentOnDisk = await readDagRunState(runDir);
656
- state.frontendRecoveryState = parentOnDisk.frontendRecoveryState;
657
- const childState = await readDagRunState(staged.childRunDir);
658
- const childSpec = await readDagRunSpec(staged.childRunDir);
659
- await runDagContinuation({
660
- cwd,
661
- spec: childSpec,
662
- state: childState,
663
- runDir: staged.childRunDir,
664
- maxConcurrent,
665
- executeNode: input.executeNode,
666
- observer: input.observer,
667
- abortSignal: input.abortSignal,
668
- });
669
- }
670
- }
671
600
  await notifyRunObserver(input.observer, "onRunFinish", state);
672
- if (!recoveryPending) {
673
- try {
674
- const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
675
- await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
676
- }
677
- catch (error) {
678
- console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
679
- }
680
- runDir = await moveToCompletedRunDir(runDir, completedRunDir);
601
+ try {
602
+ const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
603
+ await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
681
604
  }
605
+ catch (error) {
606
+ console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
607
+ }
608
+ runDir = await moveToCompletedRunDir(runDir, completedRunDir);
682
609
  }
683
610
  await relocateRunArtifactPaths({
684
611
  runDir,
@@ -701,8 +628,6 @@ async function executeDagCheckpoint(input) {
701
628
  }
702
629
  finally {
703
630
  clearInterval(heartbeatTimer);
704
- process.removeListener("SIGTERM", onSigTerm);
705
- process.removeListener("SIGINT", onSigInt);
706
631
  }
707
632
  }
708
633
  async function notifyRunObserver(observer, event, state) {
@@ -770,153 +695,7 @@ function isSuccessfulConvergenceTerminal(state) {
770
695
  return true;
771
696
  return SUCCESSFUL_CONVERGENCE_TERMINAL_REASONS.has(reason);
772
697
  }
773
- function buildFrontendRecoveryIntent(state, failureSource) {
774
- const existing = state.frontendRecoveryState;
775
- const requestId = existing?.requestId ?? randomUUID();
776
- return {
777
- schemaVersion: 1,
778
- phase: "child-staging",
779
- requestId,
780
- recoveryRootRunId: existing?.recoveryRootRunId ?? state.runId,
781
- parentRunId: state.runId,
782
- ...(existing?.childRunId ? { childRunId: existing.childRunId } : {}),
783
- attemptId: existing?.attemptId ?? `recovery-${requestId}`,
784
- attemptIndex: existing?.attemptIndex ?? 0,
785
- continuationCount: existing?.continuationCount ?? 0,
786
- ...(failureSource ? { failureSource } : {}),
787
- revision: (existing?.revision ?? 0) + 1,
788
- };
789
- }
790
- function buildCandidateContractInvalidResult(state, result, artifactHash) {
791
- const recovery = state.frontendRecoveryState;
792
- return {
793
- schemaVersion: 1,
794
- outcome: "candidate-contract-invalid",
795
- origin: {
796
- kind: "frontend-prewrite-gate",
797
- parentRunId: recovery?.parentRunId ?? state.runId,
798
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
799
- requestId: recovery?.requestId ?? "",
800
- failedNodeId: result.selectedPlanNodeId,
801
- },
802
- failureClass: {
803
- code: "candidate-contract-invalid",
804
- classification: result.classification,
805
- reason: result.failureReason ?? "candidate contract invalid",
806
- },
807
- evidenceRefs: [
808
- {
809
- runId: state.runId,
810
- relativePath: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
811
- sha256: artifactHash,
812
- },
813
- ],
814
- };
815
- }
816
- function buildWriterPartialWriteBlockedResult(state, failedNodeId, reason) {
817
- const recovery = state.frontendRecoveryState;
818
- return {
819
- schemaVersion: 1,
820
- outcome: "auto-recovery-blocked",
821
- origin: {
822
- kind: "frontend-writer",
823
- parentRunId: recovery?.parentRunId ?? state.runId,
824
- ...(recovery?.childRunId ? { childRunId: recovery.childRunId } : {}),
825
- requestId: recovery?.requestId ?? "",
826
- failedNodeId,
827
- },
828
- failureClass: {
829
- code: "writer-transient-partial-write",
830
- classification: "transient",
831
- reason,
832
- },
833
- evidenceRefs: [],
834
- };
835
- }
836
- function settleFrontendRecovery(state, outcome) {
837
- const recovery = state.frontendRecoveryState;
838
- if (!recovery)
839
- return;
840
- recovery.phase = "settled";
841
- if (!state.frontendRecoveryResult) {
842
- state.frontendRecoveryResult = {
843
- schemaVersion: 1,
844
- outcome,
845
- origin: {
846
- kind: "frontend-prewrite-gate",
847
- parentRunId: recovery.parentRunId,
848
- ...(recovery.childRunId ? { childRunId: recovery.childRunId } : {}),
849
- requestId: recovery.requestId,
850
- failedNodeId: "",
851
- },
852
- evidenceRefs: [],
853
- };
854
- }
855
- }
856
- export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
857
- const repoRoot = cwd ?? path.resolve(runDir, "..", "..", "..", "..");
858
- // Terminal override: a denied frontend writer must never surface as a
859
- // mechanical partial_failed just because prewrite FINISHED while the writer
860
- // was SKIPPED. Fail closed on retryable-invalid/blocked before aggregation.
861
- let recoveryPending = false;
862
- const hasFrontendWriter = Object.keys(state.nodes).some((id) => FRONTEND_WRITER_NODE_IDS.includes(id));
863
- if (hasFrontendWriter) {
864
- const admission = await readFrontendPrewriteResult(runDir);
865
- if (admission.ok) {
866
- if (admission.result.classification === "blocked") {
867
- state.status = "failed";
868
- return { recoveryPending: false };
869
- }
870
- if (admission.result.classification === "retryable-invalid") {
871
- const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
872
- if (continuationCount >= 1) {
873
- // Continuation quota exhausted: a second retryable-invalid candidate
874
- // is authoritative candidate-contract-invalid → forced failed.
875
- state.frontendRecoveryResult = buildCandidateContractInvalidResult(state, admission.result, admission.artifactHash);
876
- state.status = "failed";
877
- return { recoveryPending: false };
878
- }
879
- // Root continuation still available: record the recovery intent and let
880
- // mechanical aggregation settle partial_failed/failed. The parent keeps
881
- // phase != settled (recovery in progress) and no final root manifest.
882
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state);
883
- recoveryPending = true;
884
- }
885
- }
886
- }
887
- // Phase 5: writer transient partial write → rollback + recovery intent. Only
888
- // for frontend-implementation writers with a remaining root continuation, and
889
- // only when the rollback journal (captured before the provider call) restores
890
- // cleanly; otherwise the run stays failed with auto-recovery-blocked.
891
- if (hasFrontendWriter && !recoveryPending) {
892
- const writerNodeId = FRONTEND_WRITER_NODE_IDS.find((id) => {
893
- const node = state.nodes[id];
894
- return (node &&
895
- node.status === "ERROR" &&
896
- isFrontendWriterTransientPartialWrite({
897
- failureCategory: node.failureCategory,
898
- }));
899
- });
900
- if (writerNodeId) {
901
- const continuationCount = state.frontendRecoveryState?.continuationCount ?? 0;
902
- if (continuationCount < 1) {
903
- const { rollbackFrontendWriter } = await import("./frontend-writer-recovery.js");
904
- const rollback = await rollbackFrontendWriter({
905
- cwd: repoRoot,
906
- parentRunId: state.runId,
907
- });
908
- if (rollback.ok) {
909
- state.frontendRecoveryState = buildFrontendRecoveryIntent(state, "frontend-implement-pi");
910
- recoveryPending = true;
911
- }
912
- else {
913
- state.frontendRecoveryResult = buildWriterPartialWriteBlockedResult(state, writerNodeId, rollback.reason);
914
- state.status = "failed";
915
- return { recoveryPending: false };
916
- }
917
- }
918
- }
919
- }
698
+ function finalizeTerminalRunStatus(state, taskCount) {
920
699
  const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
921
700
  const { supersededIds, supersededFailures } = collectSupersededIntermediateFailures(state);
922
701
  if (state.convergence && supersededFailures.length > 0) {
@@ -940,19 +719,6 @@ export async function finalizeTerminalRunStatus(state, taskCount, runDir, cwd) {
940
719
  else {
941
720
  state.status = "failed";
942
721
  }
943
- // Terminal recovery bookkeeping for a child attempt: a child that finished
944
- // recovered settles its own lineage; any other child terminal settles as
945
- // auto-recovery-blocked (phase 3 performs no further recovery).
946
- const recovery = state.frontendRecoveryState;
947
- if (recovery && recovery.attemptIndex >= 1) {
948
- if (state.status === "finished") {
949
- settleFrontendRecovery(state, "recovered");
950
- }
951
- else if (!state.frontendRecoveryResult) {
952
- settleFrontendRecovery(state, "auto-recovery-blocked");
953
- }
954
- }
955
- return { recoveryPending };
956
722
  }
957
723
  function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
958
724
  if (rankWriterNodeIds.length <= 1)
@@ -1,10 +1,5 @@
1
- import { readFile } from "node:fs/promises";
2
- import path from "node:path";
3
1
  import { isPauseOnHumanDecisionGate } from "./decision-envelope.js";
4
2
  import { evaluateConditionExpression } from "./dynamic-runtime/condition.js";
5
- import { sha256Hex } from "./frontend-implementation-contract.js";
6
- import { frontendPrewriteResultV1Schema, FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME, } from "./frontend-prewrite-gate.js";
7
- import { FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH, FRONTEND_RECOVERY_INTENT_REL_DIR, } from "./frontend-recovery-plan.js";
8
3
  export function isConditionSkippedReason(reason) {
9
4
  return Boolean(reason?.startsWith("condition "));
10
5
  }
@@ -109,58 +104,6 @@ async function mapConcurrent(items, limit, fn) {
109
104
  }
110
105
  await Promise.all(executing);
111
106
  }
112
- export const FRONTEND_WRITER_NODE_IDS = [
113
- "frontend-implement-pi",
114
- "frontend-repair-pi",
115
- ];
116
- /** Run-relative artifact location written by the prewrite gate generator. */
117
- export const FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT = path.posix.join("contracts", FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME);
118
- /**
119
- * Read and validate the frontend-prewrite-result-v1 artifact. Fail-closed:
120
- * a missing file or invalid payload returns ok:false and never throws.
121
- */
122
- export async function readFrontendPrewriteResult(runDir) {
123
- const artifactPath = path.join(runDir, "contracts", FRONTEND_PREWRITE_RESULT_ARTIFACT_NAME);
124
- let raw;
125
- try {
126
- raw = await readFile(artifactPath, "utf8");
127
- }
128
- catch (error) {
129
- if (error.code === "ENOENT") {
130
- return {
131
- ok: false,
132
- reason: `frontend prewrite result artifact missing: ${artifactPath}`,
133
- };
134
- }
135
- throw error;
136
- }
137
- const artifactHash = sha256Hex(raw);
138
- let parsed;
139
- try {
140
- parsed = JSON.parse(raw);
141
- }
142
- catch (error) {
143
- return {
144
- ok: false,
145
- reason: `frontend prewrite result artifact is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
146
- };
147
- }
148
- const result = frontendPrewriteResultV1Schema.safeParse(parsed);
149
- if (!result.success) {
150
- return {
151
- ok: false,
152
- reason: "frontend prewrite result artifact failed schema validation",
153
- };
154
- }
155
- return { ok: true, result: result.data, artifactHash };
156
- }
157
- /** Authorize only accepted / accepted-normalized classifications. */
158
- export function isFrontendWriterAuthorized(result) {
159
- return result.classification === "accepted" ||
160
- result.classification === "accepted-normalized"
161
- ? "authorized"
162
- : "denied";
163
- }
164
107
  /** Fail-closed: leave no PENDING nodes that look "still scheduled" after abort. */
165
108
  export function markPendingNodesControllerInterrupted(state, reason = "run aborted by controller (abortSignal)") {
166
109
  const affected = [];
@@ -179,166 +122,8 @@ export function markPendingNodesControllerInterrupted(state, reason = "run abort
179
122
  }
180
123
  return affected;
181
124
  }
182
- /**
183
- * True when this run is a candidate-continuation child: it has a recovery
184
- * lineage whose attemptIndex is >= 1 and whose recovery root is another run.
185
- * The root/parent keeps `recoveryRootRunId === state.runId`.
186
- */
187
- export function isFrontendRecoveryChild(state) {
188
- const recovery = state.frontendRecoveryState;
189
- return Boolean(recovery &&
190
- recovery.attemptIndex >= 1 &&
191
- recovery.recoveryRootRunId !== state.runId);
192
- }
193
- function isValidFrontendRecoveryActivationMarker(raw) {
194
- return (raw.schemaVersion === 1 &&
195
- typeof raw.requestId === "string" &&
196
- raw.requestId.length > 0 &&
197
- typeof raw.parentRunId === "string" &&
198
- raw.parentRunId.length > 0 &&
199
- typeof raw.recoveryRootRunId === "string" &&
200
- raw.recoveryRootRunId.length > 0 &&
201
- typeof raw.childRunId === "string" &&
202
- raw.childRunId.length > 0 &&
203
- typeof raw.importManifestSha256 === "string" &&
204
- /^[a-f0-9]{64}$/.test(raw.importManifestSha256));
205
- }
206
- /**
207
- * Activation marker gate (phase 3c AC-1). Fail-closed: an active child is only
208
- * executable when its parent is `child-running`, points at this child, carries
209
- * a structurally valid activation marker whose lineage matches, and the marker
210
- * hash matches the child's import manifest. Read-only and never throws.
211
- */
212
- export async function checkFrontendRecoveryActivation(state, childRunDir) {
213
- if (!isFrontendRecoveryChild(state)) {
214
- return { applicable: false };
215
- }
216
- const recovery = state.frontendRecoveryState;
217
- const parentRunId = recovery.parentRunId;
218
- const parentRunDir = path.join(path.dirname(childRunDir), parentRunId);
219
- let parentState;
220
- try {
221
- parentState = JSON.parse(await readFile(path.join(parentRunDir, "state.json"), "utf8"));
222
- }
223
- catch {
224
- return { applicable: true, ok: false, reason: "parent state unreadable" };
225
- }
226
- const parentRecovery = parentState.frontendRecoveryState;
227
- if (parentRecovery?.phase !== "child-running") {
228
- return {
229
- applicable: true,
230
- ok: false,
231
- reason: "parent phase not child-running",
232
- };
233
- }
234
- if (parentRecovery.childRunId !== state.runId) {
235
- return {
236
- applicable: true,
237
- ok: false,
238
- reason: "parent childRunId mismatch",
239
- };
240
- }
241
- const markerPath = path.join(parentRunDir, FRONTEND_RECOVERY_INTENT_REL_DIR, `${recovery.requestId}.json`);
242
- let markerRaw;
243
- try {
244
- markerRaw = JSON.parse(await readFile(markerPath, "utf8"));
245
- }
246
- catch {
247
- return {
248
- applicable: true,
249
- ok: false,
250
- reason: "activation marker missing",
251
- };
252
- }
253
- if (typeof markerRaw !== "object" ||
254
- markerRaw === null ||
255
- !isValidFrontendRecoveryActivationMarker(markerRaw)) {
256
- return {
257
- applicable: true,
258
- ok: false,
259
- reason: "activation marker invalid",
260
- };
261
- }
262
- const marker = markerRaw;
263
- if (marker.requestId !== recovery.requestId) {
264
- return {
265
- applicable: true,
266
- ok: false,
267
- reason: "marker requestId mismatch",
268
- };
269
- }
270
- if (marker.parentRunId !== parentRunId) {
271
- return {
272
- applicable: true,
273
- ok: false,
274
- reason: "marker parentRunId mismatch",
275
- };
276
- }
277
- if (marker.recoveryRootRunId !== recovery.recoveryRootRunId) {
278
- return {
279
- applicable: true,
280
- ok: false,
281
- reason: "marker recoveryRootRunId mismatch",
282
- };
283
- }
284
- if (marker.childRunId !== state.runId) {
285
- return {
286
- applicable: true,
287
- ok: false,
288
- reason: "marker childRunId mismatch",
289
- };
290
- }
291
- let manifestSha256;
292
- try {
293
- manifestSha256 = sha256Hex(await readFile(path.join(childRunDir, FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH), "utf8"));
294
- }
295
- catch {
296
- return {
297
- applicable: true,
298
- ok: false,
299
- reason: "import manifest unreadable",
300
- };
301
- }
302
- if (manifestSha256 !== marker.importManifestSha256) {
303
- return {
304
- applicable: true,
305
- ok: false,
306
- reason: "import manifest sha256 mismatch",
307
- };
308
- }
309
- return {
310
- applicable: true,
311
- ok: true,
312
- requestId: recovery.requestId,
313
- childRunId: state.runId,
314
- };
315
- }
316
125
  export async function executeDagRanksOnce(input) {
317
126
  let pausedByNodeId;
318
- // Activation marker gate (phase 3c AC-1): a recovery child without a valid
319
- // activation marker must never be scheduled or executed. Fail-closed before
320
- // any executeScheduledNode call, so zero writer/provider invocations happen.
321
- if (input.runDir) {
322
- const hasPending = Object.values(input.state.nodes).some((node) => node.status === "PENDING");
323
- if (hasPending) {
324
- const activation = await checkFrontendRecoveryActivation(input.state, input.runDir);
325
- if (activation.applicable && !activation.ok) {
326
- const finishedAt = new Date().toISOString();
327
- let affected = 0;
328
- for (const node of Object.values(input.state.nodes)) {
329
- if (node.status !== "PENDING")
330
- continue;
331
- node.status = "SKIPPED";
332
- node.skippedReason = "frontend-recovery-child-not-activated";
333
- node.finishedAt = finishedAt;
334
- affected += 1;
335
- }
336
- if (affected > 0)
337
- await input.persistState();
338
- return undefined;
339
- }
340
- }
341
- }
342
127
  for (const rank of input.ranks) {
343
128
  if (input.abortSignal?.aborted) {
344
129
  const marked = markPendingNodesControllerInterrupted(input.state, input.abortSignal.reason
@@ -383,45 +168,6 @@ export async function executeDagRanksOnce(input) {
383
168
  await input.persistState();
384
169
  const conditionSkippedSet = new Set(conditionSettled);
385
170
  const actuallyRunnable = runnable.filter((id) => !conditionSkippedSet.has(id));
386
- const frontendAdmissionSettled = [];
387
- if (input.runDir) {
388
- for (const id of actuallyRunnable) {
389
- if (!FRONTEND_WRITER_NODE_IDS.includes(id))
390
- continue;
391
- const node = input.state.nodes[id];
392
- const checkedAt = new Date().toISOString();
393
- const admission = await readFrontendPrewriteResult(input.runDir);
394
- if (!admission.ok) {
395
- node.status = "SKIPPED";
396
- node.skippedReason = "frontend-prewrite-not-authorized";
397
- node.finishedAt = checkedAt;
398
- frontendAdmissionSettled.push(id);
399
- continue;
400
- }
401
- const decision = isFrontendWriterAuthorized(admission.result);
402
- node.frontendWriterAdmission = {
403
- schemaVersion: 1,
404
- writerNodeId: id,
405
- decision,
406
- sourceArtifact: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
407
- artifactHash: admission.artifactHash,
408
- checkedAt,
409
- reason: decision === "denied"
410
- ? `classification: ${admission.result.classification}`
411
- : null,
412
- };
413
- if (decision === "denied") {
414
- node.status = "SKIPPED";
415
- node.skippedReason = "frontend-prewrite-not-authorized";
416
- node.finishedAt = checkedAt;
417
- frontendAdmissionSettled.push(id);
418
- }
419
- }
420
- if (frontendAdmissionSettled.length > 0)
421
- await input.persistState();
422
- }
423
- const frontendAdmissionSkippedSet = new Set(frontendAdmissionSettled);
424
- const runnableAfterAdmission = actuallyRunnable.filter((id) => !frontendAdmissionSkippedSet.has(id));
425
171
  const blocked = pending.filter((id) => {
426
172
  const task = input.tasksById.get(id);
427
173
  return (dependencyReadiness(task, input.state.nodes, input.tasksById) === "skip");
@@ -436,12 +182,12 @@ export async function executeDagRanksOnce(input) {
436
182
  if (blocked.length > 0) {
437
183
  await input.persistState();
438
184
  }
439
- const pauseGateRunnable = runnableAfterAdmission.filter((id) => {
185
+ const pauseGateRunnable = actuallyRunnable.filter((id) => {
440
186
  const task = input.tasksById.get(id);
441
187
  return isPauseOnHumanDecisionGate(task);
442
188
  });
443
- const regularRunnable = runnableAfterAdmission.filter((id) => !pauseGateRunnable.includes(id));
444
- const rankWriterNodeIds = runnableAfterAdmission.filter((id) => {
189
+ const regularRunnable = actuallyRunnable.filter((id) => !pauseGateRunnable.includes(id));
190
+ const rankWriterNodeIds = actuallyRunnable.filter((id) => {
445
191
  const task = input.tasksById.get(id);
446
192
  return (task?.executor === "pi" &&
447
193
  task.toolProfile === "write" &&