@tea-agent/loop-agent 0.42.0-next.9 → 0.43.0-next.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.
- package/CHANGELOG.md +65 -59
- package/dist/application/dag/run-dag.js +3 -2
- package/dist/application/task-lifecycle/advance.js +9 -4
- package/dist/build-stamp.json +3 -3
- package/dist/cli/program.js +1 -1
- package/dist/commands/dag-rerun-task.js +2 -0
- package/dist/commands/task-source-prepare.js +3 -1
- package/dist/executors/dag-pi-executor.js +1514 -175
- package/dist/executors/pi-extension-resolver.js +14 -2
- package/dist/executors/shell-executor.js +131 -54
- package/dist/shared/dag-failure-category.js +6 -0
- package/dist/task/contract/apply.js +36 -2
- package/dist/task/source-prepare/parse-intent.js +7 -0
- package/dist/worker/console/chat/chat-event-store.js +4 -2
- package/dist/worker/console/chat/pi-runtime.js +45 -2
- package/dist/worker/console/chat/resource-loader.js +4 -1
- package/dist/worker/console/chat/routes.js +28 -12
- package/dist/worker/console/chat/sdd-data-alignment.js +13 -2
- package/dist/worker/console/chat/session-store.js +5 -1
- package/dist/worker/console/chat/subagents/agent-tool.js +62 -0
- package/dist/worker/console/chat/subagents/explore-agent.js +169 -0
- package/dist/worker/console/chat/subagents/index.js +5 -0
- package/dist/worker/console/chat/subagents/orchestrator.js +273 -0
- package/dist/worker/console/chat/subagents/tool-policy.js +53 -0
- package/dist/worker/console/chat/subagents/types.js +13 -0
- package/dist/worker/console/chat/tool-preview.js +10 -0
- package/dist/worker/console/chat/tools.js +11 -1
- package/dist/worker/console/chat/turn-process.js +1 -0
- package/dist/worker/console/interview/tools.js +1 -0
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C6n9_m0P.js → abnfDiagram-N423BO3Z-B0Q0nClf.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DQh-IfZ1.js → arc-DCPjC19G.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-54NnrwUC.js → architectureDiagram-T3A2C74G-CP5n9jmG.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-pivRALGK.js → blockDiagram-VBNYF7ZC-CmaWi_Wg.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BR7OV2NJ.js → c4Diagram-5PPSVZJV-Ct5tcMfZ.js} +1 -1
- package/dist/worker/console/static/assets/channel-DAS07MdS.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B1Aq6BcK.js → chunk-2GRJ4B5K-C0osm1Zf.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CbdU0rjo.js → chunk-2Q5K7J3B-CDWviED7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-Dqi2GJeD.js → chunk-5RXB4S5H-mtSk1-j7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BYn1Hu4R.js → chunk-5VM5RSS4-D5J9PHC7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DRLjDL0k.js → chunk-6Q2QTUOP-Begg4WAa.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CY9Xx2jV.js → chunk-GF5L2VYU-sF1AEy7T.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BNCZs7_z.js → chunk-JWPE2WC7-BqbsWXZb.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DZ8AStLL.js → chunk-KBJHAD2P-BKJrPcJ6.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DsxjYrzz.js → chunk-RYQCIY6F-CcwNMRho.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-Ddx1KC1l.js → chunk-XXDRQBXY-BEJi3iIs.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cwk-5jiW.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cwk-5jiW.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-W9TveCnK.js → cose-bilkent-JH36ORCC-DRLgTvVu.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-l9j_ztZH.js → cynefin-VYW2F7L2-BoAVG3cs.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BMJMKi4G.js → cynefinDiagram-MW4NZA55-Bzol06hR.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Bb4mG9pH.js → dagre-VZM6K2ZE-UmliJM77.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BMgL_Qv0.js → diagram-7IWD3JNH-BWLcJkFo.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Bvo2T4OQ.js → diagram-B4RE2ZJO-CgApOm-O.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-_5kWRN9c.js → diagram-LBJQPF4R-Bh3RgTLs.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DqdMrltM.js → diagram-Q27KOJAE-Dsh-D5nE.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-CDDYsNkp.js → diagram-UB23O5K3-Dkbbpcpb.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-c4nfnZUV.js → ebnfDiagram-BXEA7PRR-3EBaA3t3.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DnkUcNMs.js → erDiagram-JOGREHBK-Bq2Dc5ok.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-Dz0KGLZE.js → flowDiagram-UKHOOZJN-De7y55ha.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Cj1t1uka.js → ganttDiagram-PKOTCBZU-fLiDbQRh.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-tp2FrBHd.js → gitGraphDiagram-DS77QQ5N-Du617mp1.js} +1 -1
- package/dist/worker/console/static/assets/index-C0O48S_P.js +449 -0
- package/dist/worker/console/static/assets/index-CzKf4U8P.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DQwJS8DH.js → infoDiagram-6WML65LV-BwmlF-XP.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-eimCQWD4.js → ishikawaDiagram-WSZJBQD7-zsGtS59u.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Dd9Gbwgv.js → journeyDiagram-NVQOT4AX-DTTPTe3f.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Qve3cyft.js → kanban-definition-27J2QSJJ-BkXg9aP-.js} +1 -1
- package/dist/worker/console/static/assets/{linear-UXKSe36Z.js → linear-7U2ue5IE.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-CWhj4JXN.js → mermaid.core-BUuGHmWO.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-yTcwf4SJ.js → mindmap-definition-FAOFIHXS-7q6fX0Sv.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-_7qToaZP.js → pegDiagram-VL7TDLO6-CkQPJN55.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CXmrKmDm.js → pieDiagram-7S7Q4E2Y-CCal_tfX.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-hMmrJyaS.js → quadrantDiagram-CIZ2JOQS-B7bpxyAZ.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BXw8tCBe.js → railroadDiagram-AXF67PYL-CJmww7D-.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BmjgnLZl.js → requirementDiagram-LRYGKXZP-D6ldWJEv.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Bj77SPpN.js → sankeyDiagram-W5VNT64P-VGh33I9e.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-B2FDVysL.js → sequenceDiagram-SI44F4Z6-CCkDrIjU.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-UChNY9ZZ.js → sizeCapture-X5ZJPWSS-NC8f5Otb.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-wbAEkbd7.js → stateDiagram-OKZ733FA-_92ezZdF.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyJg5gzW.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DCEkU8HR.js → swimlanes-SLNWSIFB-BdUCGtbP.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B1cw_0Uy.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BGp7H06U.js → timeline-definition-Z64GVDOM-CdD1H8ia.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DsCIQuWR.js → vennDiagram-T6HMQDX7-Dkdk3oFo.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-cRZLP4Xe.js → wardleyDiagram-T6FBY63Y-CKu2uPPY.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CP0xLR9Y.js → xychartDiagram-ELKLHX3M-ClqAnuG8.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/chat-view-types.js +1 -1
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +23 -0
- package/dist/worker/console/static-src/operator-chat/pending-user-message.js +32 -0
- package/dist/worker/console/static-src/operator-chat/tools-catalog.js +21 -2
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +2 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +2 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +30 -14
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +133 -5
- package/dist/worker/console/static-src/shell/workspace-route.js +4 -0
- package/dist/worker/observe/static/constants.js +22 -22
- package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
- package/dist/worker/observe/static/dag-history-labels.js +1 -0
- package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
- package/dist/worker/observe/static/dag-inspector-humanize.js +339 -0
- package/dist/worker/observe/static/dag-node-purpose.js +10 -10
- package/dist/worker/observe/static/index.html +4 -4
- package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
- package/dist/worker/observe/static/relations.js +5 -5
- package/dist/worker/observe/static/styles.css +171 -0
- package/dist/worker/observe/static/views/dag-graph.js +1 -1
- package/dist/worker/observe/static/views/dag-inspector.js +329 -174
- package/dist/worker/observe/static/views/dag.js +16 -12
- package/dist/worker/observe/static/views/session-timeline.js +5 -3
- package/dist/workflows/dag/backend-test-plan-protocol.js +104 -0
- package/dist/workflows/dag/dag-retry-schema.js +3 -0
- package/dist/workflows/dag/frontend-contract-facts.js +130 -0
- package/dist/workflows/dag/frontend-design-policy.js +101 -16
- package/dist/workflows/dag/frontend-implementation-contract.js +410 -41
- package/dist/workflows/dag/frontend-plan-render.js +13 -2
- package/dist/workflows/dag/frontend-prewrite-gate.js +1 -1
- package/dist/workflows/dag/frontend-recovery-controller.js +25 -1
- package/dist/workflows/dag/frontend-recovery-plan.js +4 -1
- package/dist/workflows/dag/frontend-recovery-run.js +142 -16
- package/dist/workflows/dag/frontend-risk.js +2 -0
- package/dist/workflows/dag/frontend-shadow-dual-write.js +17 -1
- package/dist/workflows/dag/frontend-shape.js +16 -6
- package/dist/workflows/dag/frontend-test-execution-evidence.js +48 -0
- package/dist/workflows/dag/frontend-typed-event-store.js +3 -0
- package/dist/workflows/dag/frontend-verification-trace.js +38 -4
- package/dist/workflows/dag/frontend-writer-admission.js +2 -2
- package/dist/workflows/dag/init-hybrid.js +57 -28
- package/dist/workflows/dag/node-execution.js +201 -85
- package/dist/workflows/dag/recovery-lease.js +106 -16
- package/dist/workflows/dag/rerun-feedback.js +1 -0
- package/dist/workflows/dag/rerun-plan.js +90 -4
- package/dist/workflows/dag/rerun-run.js +28 -6
- package/dist/workflows/dag/rerun-task.js +221 -18
- package/dist/workflows/dag/retry-policy.js +27 -10
- package/dist/workflows/dag/runner-exit-diagnostics.js +125 -0
- package/dist/workflows/dag/runner.js +220 -18
- package/dist/workflows/dag/structured-output-repair.js +4 -1
- package/dist/workflows/dag/types.js +4 -3
- package/dist/workflows/dag/validate.js +10 -8
- package/dist/workflows/dag/workspace-checkpoint.js +66 -0
- package/docs/operations/README.md +1 -0
- package/docs/templates/agent-dag.schema.json +2 -2
- package/docs/templates/backend-test-dag.json +6 -4
- package/docs/templates/frontend-implementation-contract.schema.json +34 -2
- package/harness.json +1 -1
- package/package.json +4 -5
- package/skills/frontend-plan/SKILL.md +14 -1
- package/skills/frontend-plan/references/decision-contract.md +101 -5
- package/skills/frontend-plan/references/design-decisions.md +32 -0
- package/dist/worker/console/static/assets/channel-DsZxrgqe.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CuToPeGV.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CuToPeGV.js +0 -1
- package/dist/worker/console/static/assets/index-D9Sc0f0y.js +0 -449
- package/dist/worker/console/static/assets/index-DDQc5a50.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-yPoT19ft.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-VWdarchG.js +0 -8
|
@@ -13,7 +13,7 @@ import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./p
|
|
|
13
13
|
import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
|
|
14
14
|
import { materializeDeclaredArtifactFacts } from "./artifact-bindings.js";
|
|
15
15
|
import { buildOutputLimitRecoverySection, loadBackendTestWriterProgressForRetry, } from "./backend-test-writer-completeness.js";
|
|
16
|
-
import { computeBackoffDelayMs, isCanonicalFinalVerifyShellRetryCandidate, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, projectFrontendNodeProtocolFailureReason, resolveFrontendPlanBackupRoute, resolveFrontendPlanRetryStep, VERIFY_SHELL_RETRY_POLICY, } from "./retry-policy.js";
|
|
16
|
+
import { computeBackoffDelayMs, isCanonicalFinalVerifyShellRetryCandidate, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, OUTPUT_LIMIT_RETRY_CATEGORY, projectFrontendNodeProtocolFailureReason, resolveFrontendPlanBackupRoute, resolveFrontendPlanRetryStep, VERIFY_SHELL_RETRY_POLICY, } from "./retry-policy.js";
|
|
17
17
|
import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
|
|
18
18
|
import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
|
|
19
19
|
import { getStructuredContractValidator } from "./contract-output-registry.js";
|
|
@@ -21,6 +21,7 @@ import "./contract-validator-registrations.js";
|
|
|
21
21
|
import { computeNormalizedFailureFingerprint } from "./frontend-recovery-lineage.js";
|
|
22
22
|
import { parseLedgerJson } from "../../task/source-prepare/ledger.js";
|
|
23
23
|
import { readTypedEventStoreFromJsonl } from "./frontend-typed-event-store.js";
|
|
24
|
+
import { collectCanonicalStateFlowNames, extractRequiredDeliverablePaths, resolveFrontendContractRequirements, } from "./frontend-contract-facts.js";
|
|
24
25
|
import { allowedRepairReadPaths, auditRepairAttemptToolUse, buildStructuredOutputRepairPrompt, freezeStructuredOutputRepairContext, hasNonEmptyStructuredCandidate, isFrontendStructuredRepairSchemaId, isStructuredRepairableFailureCategory, persistStructuredAttemptRaw, sessionEventsByteLength, GOVERNANCE_BLOCKED_CATEGORY, STRUCTURED_REPAIR_EXHAUSTED_CATEGORY, } from "./structured-output-repair.js";
|
|
25
26
|
import { readFrontendCanonicalCandidate } from "./frontend-implementation-contract.js";
|
|
26
27
|
import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
@@ -256,6 +257,9 @@ function frontendPlanValidationRetryGuidance(reason) {
|
|
|
256
257
|
if (/verificationTargets.*uiStates|uiStates.*verificationTargets/i.test(reason)) {
|
|
257
258
|
guidance.push("For this retry, provide verificationTargets[].uiStates as an array; use [] when the target has no named UI state.");
|
|
258
259
|
}
|
|
260
|
+
if (/duplicate|already recorded/i.test(reason)) {
|
|
261
|
+
guidance.push("For this retry, re-commit the corrected entry with the same id and replace=true; the ledger compiles the latest submission as the full replacement. A duplicate receipt is a correction invitation, not a prohibition, and backfilling a cited fact's reverse reference in-node does not re-derive committed facts.");
|
|
262
|
+
}
|
|
259
263
|
return guidance;
|
|
260
264
|
}
|
|
261
265
|
function compactRetryText(text, maxChars) {
|
|
@@ -325,6 +329,8 @@ const FRONTEND_CONTRACT_RECORD_TOOL_NAMES_LOCAL = new Set([
|
|
|
325
329
|
"record_handoff_intent",
|
|
326
330
|
"record_open_question",
|
|
327
331
|
"record_split_proposal",
|
|
332
|
+
"record_ui_state",
|
|
333
|
+
"record_required_deliverables",
|
|
328
334
|
]);
|
|
329
335
|
async function countContractRecordSubmissions(runDir, nodeId) {
|
|
330
336
|
const eventsPath = path.join(runDir, nodeId, "session-events.jsonl");
|
|
@@ -553,8 +559,9 @@ export function renderFrontendPlanInputContext(input) {
|
|
|
553
559
|
: []);
|
|
554
560
|
const contractFacts = committedFacts(input.contractRecords);
|
|
555
561
|
const scoutFacts = committedFacts(input.scoutRecords);
|
|
556
|
-
const
|
|
557
|
-
|
|
562
|
+
const requirementFacts = resolveFrontendContractRequirements(contractFacts);
|
|
563
|
+
const requiredDeliverables = extractRequiredDeliverablePaths(contractFacts);
|
|
564
|
+
const requirements = requirementFacts
|
|
558
565
|
.map((fact) => ({
|
|
559
566
|
id: planInputText(fact.id, 80),
|
|
560
567
|
text: planInputText(fact.text),
|
|
@@ -580,19 +587,28 @@ export function renderFrontendPlanInputContext(input) {
|
|
|
580
587
|
paths: fact.paths,
|
|
581
588
|
conflicts: fact.conflicts,
|
|
582
589
|
}));
|
|
590
|
+
// Authoritative UI states (contract-declared): the source's UI-state table
|
|
591
|
+
// extracted by the contract node. The planner binds these ids instead of
|
|
592
|
+
// inventing list-visibility variants.
|
|
593
|
+
const declaredUiStates = contractFacts
|
|
594
|
+
.filter((fact) => fact.kind === "ui-state-declaration" && fact.origin === "contract")
|
|
595
|
+
.map((fact) => ({
|
|
596
|
+
id: planInputText(fact.id, 80),
|
|
597
|
+
trigger: planInputText(fact.trigger),
|
|
598
|
+
observableOutcome: planInputText(fact.observableOutcome),
|
|
599
|
+
}))
|
|
600
|
+
.filter((state) => state.id !== undefined);
|
|
601
|
+
// Replay registry edits and state-flow removals/additions in commit order.
|
|
602
|
+
const committedUxNames = collectCanonicalStateFlowNames(input.planRecords ?? [], true);
|
|
603
|
+
const committedUiStateNames = [...committedUxNames.uiStateNames];
|
|
604
|
+
const committedInteractionNames = [...committedUxNames.interactionNames];
|
|
583
605
|
// Reviewer-rubric scaffold: the design reviewer re-runs the design-policy
|
|
584
606
|
// checks on the committed facts, so publish the checklist to the producer.
|
|
585
607
|
// Requirements whose contract evidence expects behavioural verification are
|
|
586
608
|
// enumerated explicitly — those are the slots the reviewer finds missing
|
|
587
609
|
// when the plan models interactions ad hoc (r8/r9 findings).
|
|
588
|
-
const behaviorRequiredIds =
|
|
589
|
-
.filter((requirement) =>
|
|
590
|
-
const fact = contractFacts.find((candidate) => candidate.kind === "requirement" &&
|
|
591
|
-
candidate.origin === "contract" &&
|
|
592
|
-
candidate.id === requirement.id);
|
|
593
|
-
const evidence = fact?.evidence;
|
|
594
|
-
return evidence?.behavior === "required";
|
|
595
|
-
})
|
|
610
|
+
const behaviorRequiredIds = requirementFacts
|
|
611
|
+
.filter((requirement) => requirement.evidence.behavior === "required")
|
|
596
612
|
.map((requirement) => requirement.id);
|
|
597
613
|
const serializeAtCap = (cap) => JSON.stringify({
|
|
598
614
|
requirements: requirements.map((requirement) => ({
|
|
@@ -600,6 +616,7 @@ export function renderFrontendPlanInputContext(input) {
|
|
|
600
616
|
text: planInputText(requirement.text, cap.text),
|
|
601
617
|
sourceFragmentIds: planInputStrings(requirement.sourceFragmentIds).slice(0, cap.array),
|
|
602
618
|
})),
|
|
619
|
+
requiredDeliverables,
|
|
603
620
|
targetSurface: targetSurface.map((surface) => ({
|
|
604
621
|
completeness: planInputText(surface.completeness, 32),
|
|
605
622
|
entrypoint: planInputText(surface.entrypoint, cap.text),
|
|
@@ -615,6 +632,18 @@ export function renderFrontendPlanInputContext(input) {
|
|
|
615
632
|
paths: planInputStrings(evidence.paths).slice(0, cap.array),
|
|
616
633
|
conflicts: planInputStrings(evidence.conflicts).slice(0, cap.array),
|
|
617
634
|
})),
|
|
635
|
+
declaredUiStates: declaredUiStates.map((state) => ({
|
|
636
|
+
id: state.id,
|
|
637
|
+
trigger: planInputText(state.trigger, cap.text),
|
|
638
|
+
observableOutcome: planInputText(state.observableOutcome, cap.text),
|
|
639
|
+
})),
|
|
640
|
+
committedUx: committedUiStateNames.length > 0 ||
|
|
641
|
+
committedInteractionNames.length > 0
|
|
642
|
+
? {
|
|
643
|
+
uiStateNames: committedUiStateNames,
|
|
644
|
+
interactionNames: committedInteractionNames,
|
|
645
|
+
}
|
|
646
|
+
: undefined,
|
|
618
647
|
});
|
|
619
648
|
let serialized = serializeAtCap(FRONTEND_PLAN_INPUT_CAP_LADDER[0]);
|
|
620
649
|
for (const cap of FRONTEND_PLAN_INPUT_CAP_LADDER.slice(1)) {
|
|
@@ -628,6 +657,7 @@ export function renderFrontendPlanInputContext(input) {
|
|
|
628
657
|
// to the minimum, and declare the degradation instead of corrupting JSON.
|
|
629
658
|
let fallback = {
|
|
630
659
|
degraded: "requirement-texts-truncated",
|
|
660
|
+
requiredDeliverables,
|
|
631
661
|
requirements: requirements.map((requirement) => ({
|
|
632
662
|
id: requirement.id,
|
|
633
663
|
text: "(truncated)",
|
|
@@ -652,20 +682,20 @@ export function renderFrontendPlanInputContext(input) {
|
|
|
652
682
|
serialized = bounded;
|
|
653
683
|
}
|
|
654
684
|
const checklistLines = [
|
|
655
|
-
"1.
|
|
656
|
-
"2. Every
|
|
657
|
-
"3. Every
|
|
658
|
-
"4.
|
|
685
|
+
"1. Record the GLOBAL UX vocabulary with record_state_registry BEFORE any record_state_flow: one stable behavior-domain name per state/interaction (e.g. planner-task-edit), never one name per AC number, and never a rename of an already-recorded concept. Coverage slices by AC; UX does not.",
|
|
686
|
+
"2. Every recorded interaction/uiState name must be in that registry, and uiState names must use the contract's declared authoritative ids (declaredUiStates below) when present.",
|
|
687
|
+
"3. Every interaction and every applicable UI state must be covered by a uiComponentChoices entry: its purpose equals the name, or the choice lists the name in covers (one choice may cover many ids). A reuse-existing decision without evidencePath is rejected; stylingStrategy alone covers nothing.",
|
|
688
|
+
"4. Every requirement marked (behavior) below needs modelled interactions plus at least one verification target that references it.",
|
|
659
689
|
"5. Verification targets may only reference UI states and requirements you actually recorded (the record_* tools reject unknown references).",
|
|
660
690
|
`Requirements requiring behavioural coverage: ${behaviorRequiredIds.length > 0 ? behaviorRequiredIds.join(", ") : "(none)"}`,
|
|
661
|
-
"6. A decision=new component must declare sourceRequirementIds and pass sourceFragmentId for the frozen PRD fragment whose section matches the component's purpose — the runtime validates that binding and derives specReference (path/section/line); the reviewer checks purpose↔citation consistency.",
|
|
691
|
+
"6. A decision=new component must declare sourceRequirementIds and pass sourceFragmentId for the frozen PRD fragment whose section matches the component's purpose — the runtime validates that binding and derives specReference (path/section/line); the reviewer checks purpose↔citation consistency. A decision=reuse-existing component must pass evidencePath pointing at the existing repo file that proves the reuse.",
|
|
662
692
|
...[...input.componentSourceCitations ?? []]
|
|
663
693
|
.filter(([id]) => behaviorRequiredIds.includes(id))
|
|
664
694
|
.flatMap(([id, citations]) => citations.map((citation) => ` ${id} + ${citation.fragmentId} → ${citation.section}${citation.line ? ` (line ${citation.line})` : ""}`)),
|
|
665
695
|
];
|
|
666
696
|
return [
|
|
667
697
|
"<frontend_plan_input>",
|
|
668
|
-
"Committed Contract/Scout facts, compiled by the runner. Treat them as the complete planning evidence.",
|
|
698
|
+
"Committed Contract/Scout facts, compiled by the runner. Treat them as the complete planning evidence. declaredUiStates are the authoritative UI states from the task source; committedUx (retry attempts) is the UX vocabulary already recorded — reuse those names, never re-invent them.",
|
|
669
699
|
serialized,
|
|
670
700
|
"Do not read upstream artifacts, task sources, or repository files. If this input cannot support a decision, record a genuine evidence gap.",
|
|
671
701
|
"</frontend_plan_input>",
|
|
@@ -692,6 +722,16 @@ export async function buildFrontendPlanInputContext(runDir, componentSourceCitat
|
|
|
692
722
|
// forbids reading anything.
|
|
693
723
|
throw new Error(`frontend-plan-input-unavailable: cannot read committed typed facts (${error instanceof Error ? error.message : String(error)})`);
|
|
694
724
|
}
|
|
725
|
+
// Retry-attempt continuity (UX slice visibility): the plan node's own
|
|
726
|
+
// committed facts are absent on the first attempt and present on retries;
|
|
727
|
+
// a missing file is normal there, not a broken pipeline.
|
|
728
|
+
let planRecords = [];
|
|
729
|
+
try {
|
|
730
|
+
planRecords = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-plan-pi", "plan-typed-facts.jsonl"));
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
planRecords = [];
|
|
734
|
+
}
|
|
695
735
|
const committedCount = [...contractRecords, ...scoutRecords].filter((record) => record.phase === "committed").length;
|
|
696
736
|
if (committedCount === 0) {
|
|
697
737
|
throw new Error(`frontend-plan-input-unavailable: no committed Contract/Scout facts in ${contractFactsPath} / ${scoutFactsPath}`);
|
|
@@ -699,6 +739,7 @@ export async function buildFrontendPlanInputContext(runDir, componentSourceCitat
|
|
|
699
739
|
return renderFrontendPlanInputContext({
|
|
700
740
|
contractRecords,
|
|
701
741
|
scoutRecords,
|
|
742
|
+
planRecords,
|
|
702
743
|
componentSourceCitations,
|
|
703
744
|
});
|
|
704
745
|
}
|
|
@@ -721,77 +762,27 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
|
|
|
721
762
|
"</retry_instruction>",
|
|
722
763
|
].join("\n");
|
|
723
764
|
}
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
"",
|
|
728
|
-
"<retry_instruction>",
|
|
729
|
-
"Frontend plan retry ladder step: compact-terminal-first.",
|
|
730
|
-
"Do NOT rewrite the narrative. Only adopt already staged/quarantined fresh facts, fill the missing required facts, then call finalize_plan exactly once.",
|
|
731
|
-
"Keep the existing committed ledger intact; do not re-derive already committed facts.",
|
|
732
|
-
"</retry_instruction>",
|
|
733
|
-
].join("\n");
|
|
734
|
-
}
|
|
735
|
-
if (frontendPlanRetryStep === "bounded-tool-only") {
|
|
736
|
-
return [
|
|
737
|
-
basePrompt,
|
|
738
|
-
"",
|
|
739
|
-
"<retry_instruction>",
|
|
740
|
-
"Frontend plan retry ladder step: bounded-tool-only.",
|
|
741
|
-
"Reduce reasoning and wall-clock time. Use only read / fact / terminal tools and only for the necessary missing facts, then call finalize_plan exactly once.",
|
|
742
|
-
"Do not expand scope or re-derive already committed facts.",
|
|
743
|
-
"</retry_instruction>",
|
|
744
|
-
].join("\n");
|
|
745
|
-
}
|
|
746
|
-
if (frontendPlanRetryStep === "backup-model") {
|
|
747
|
-
return [
|
|
748
|
-
basePrompt,
|
|
749
|
-
"",
|
|
750
|
-
"<retry_instruction>",
|
|
751
|
-
"Frontend plan retry ladder step: backup-model.",
|
|
752
|
-
"A backup provider/model route was selected after repeated non-converging failures. Re-derive only the missing committed facts, then call finalize_plan exactly once; do not repeat the failed strategy.",
|
|
753
|
-
"</retry_instruction>",
|
|
754
|
-
].join("\n");
|
|
755
|
-
}
|
|
756
|
-
if (previousFailureCategory === "protocol-invalid" &&
|
|
757
|
-
task.outputProtocol &&
|
|
758
|
-
previousProtocolReason) {
|
|
759
|
-
return [
|
|
760
|
-
basePrompt,
|
|
761
|
-
"",
|
|
762
|
-
buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
|
|
763
|
-
].join("\n");
|
|
764
|
-
}
|
|
765
|
-
if (previousFailureCategory === "review-terminal-missing") {
|
|
765
|
+
if (task.id === "generate-backend-md-plan-pi" &&
|
|
766
|
+
(previousFailureCategory === "output-too-large" ||
|
|
767
|
+
previousFailureCategory === "invalid-output")) {
|
|
766
768
|
return [
|
|
767
769
|
basePrompt,
|
|
768
770
|
"",
|
|
769
771
|
"<retry_instruction>",
|
|
770
|
-
"The
|
|
771
|
-
|
|
772
|
-
"
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if (previousFailureCategory === "read-burst") {
|
|
776
|
-
if (task.id !== FRONTEND_PLAN_NODE_ID) {
|
|
777
|
-
return [
|
|
778
|
-
basePrompt,
|
|
779
|
-
"",
|
|
780
|
-
"<retry_instruction>",
|
|
781
|
-
"The previous attempt exceeded its read budget. Start a fresh, evidence-minimal pass: do not re-read task sources, upstream stdout, skills, or full diff artifacts already represented by a canonical context artifact.",
|
|
782
|
-
"Read the smallest relevant summary first, then only the specific source file or diff fragment needed for the required decision. Finish the required terminal/output protocol as soon as evidence is sufficient.",
|
|
783
|
-
"</retry_instruction>",
|
|
784
|
-
].join("\n");
|
|
785
|
-
}
|
|
786
|
-
return [
|
|
787
|
-
basePrompt,
|
|
788
|
-
"",
|
|
789
|
-
"<retry_instruction>",
|
|
790
|
-
"Previous plan attempt issued too many read-only tool calls (read/grep/ls/find) and blew up the context window. Trust the upstream frontend-contract-pi typed requirement facts and frontend-scout-pi target surface already provided — do NOT re-read contract/scout stdout, PRD/source files, or component sources you already inspected.",
|
|
791
|
-
"Minimize discovery reads: only read what you genuinely need, once. Commit record_plan_requirement / record_plan_verification_target / record_* facts directly from the facts already in context (one tool call per message), then call finalize_plan exactly once.",
|
|
772
|
+
"The previous backend-test plan attempt was truncated or failed its mandatory Markdown protocol.",
|
|
773
|
+
previousProtocolReason ?? "The previous plan artifact was incomplete.",
|
|
774
|
+
"Return the final Markdown artifact immediately. Do not output analysis, reasoning, source summaries, or planning narration.",
|
|
775
|
+
"Emit the complete section skeleton first, including exactly one ## Coverage Scope, exactly one ## Coverage Matrix, optional ## Scenario Partitions only when applicable, and exactly one ## Module Index with its required 8-column table and at least one module row.",
|
|
776
|
+
"After the complete skeleton exists, fill only concise table rows within the remaining output budget. Do not use code fences.",
|
|
792
777
|
"</retry_instruction>",
|
|
793
778
|
].join("\n");
|
|
794
779
|
}
|
|
780
|
+
// Repair-category guidance must outrank the retry ladder position: the
|
|
781
|
+
// ladder advances monotonically on transport failures (e.g. length →
|
|
782
|
+
// compact-terminal-first), and its "keep the committed ledger intact"
|
|
783
|
+
// instruction directly contradicts the repair action for invalid-output /
|
|
784
|
+
// truncated ledger facts (re-commit corrected record_* facts). When both
|
|
785
|
+
// apply, the model receives the repair instruction, not the rung script.
|
|
795
786
|
if (previousFailureCategory === "invalid-output" &&
|
|
796
787
|
task.structuredContractOutput &&
|
|
797
788
|
previousProtocolReason) {
|
|
@@ -805,7 +796,7 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
|
|
|
805
796
|
const splitGuidance = /write-set-too-large|split the task/.test(previousProtocolReason ?? "")
|
|
806
797
|
? [
|
|
807
798
|
"",
|
|
808
|
-
"The writeSet is too large for one implement node.
|
|
799
|
+
"The writeSet is too large for one implement node. Preserve the complete requirement and file coverage. This needs a Contract-level task split, not a Plan formatting repair. Report the write-set-too-large finding and the affected paths for the controller to resume Contract/split orchestration. Plan cannot change protected targets.files or call Contract-only tools; do not invent a split tool or discard required files.",
|
|
809
800
|
]
|
|
810
801
|
: [];
|
|
811
802
|
return [
|
|
@@ -867,8 +858,96 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
|
|
|
867
858
|
"</retry_instruction>",
|
|
868
859
|
].join("\n");
|
|
869
860
|
}
|
|
861
|
+
if (frontendPlanRetryStep === "compact-terminal-first") {
|
|
862
|
+
return [
|
|
863
|
+
basePrompt,
|
|
864
|
+
"",
|
|
865
|
+
"<retry_instruction>",
|
|
866
|
+
"Frontend plan retry ladder step: compact-terminal-first.",
|
|
867
|
+
"Do NOT rewrite the narrative. Only adopt already staged/quarantined fresh facts, fill the missing required facts, then call finalize_plan exactly once.",
|
|
868
|
+
"Keep the existing committed ledger intact; do not re-derive already committed facts.",
|
|
869
|
+
"</retry_instruction>",
|
|
870
|
+
].join("\n");
|
|
871
|
+
}
|
|
872
|
+
if (frontendPlanRetryStep === "bounded-tool-only") {
|
|
873
|
+
return [
|
|
874
|
+
basePrompt,
|
|
875
|
+
"",
|
|
876
|
+
"<retry_instruction>",
|
|
877
|
+
"Frontend plan retry ladder step: bounded-tool-only.",
|
|
878
|
+
"Reduce reasoning and wall-clock time. Use only read / fact / terminal tools and only for the necessary missing facts, then call finalize_plan exactly once.",
|
|
879
|
+
"Do not expand scope or re-derive already committed facts.",
|
|
880
|
+
"</retry_instruction>",
|
|
881
|
+
].join("\n");
|
|
882
|
+
}
|
|
883
|
+
if (frontendPlanRetryStep === "backup-model") {
|
|
884
|
+
return [
|
|
885
|
+
basePrompt,
|
|
886
|
+
"",
|
|
887
|
+
"<retry_instruction>",
|
|
888
|
+
"Frontend plan retry ladder step: backup-model.",
|
|
889
|
+
"A backup provider/model route was selected after repeated non-converging failures. Re-derive only the missing committed facts, then call finalize_plan exactly once; do not repeat the failed strategy.",
|
|
890
|
+
"</retry_instruction>",
|
|
891
|
+
].join("\n");
|
|
892
|
+
}
|
|
893
|
+
if (previousFailureCategory === "protocol-invalid" &&
|
|
894
|
+
task.outputProtocol &&
|
|
895
|
+
previousProtocolReason) {
|
|
896
|
+
return [
|
|
897
|
+
basePrompt,
|
|
898
|
+
"",
|
|
899
|
+
buildProtocolRetryInstruction(task.outputProtocol, previousProtocolReason),
|
|
900
|
+
].join("\n");
|
|
901
|
+
}
|
|
902
|
+
if (previousFailureCategory === "review-terminal-missing") {
|
|
903
|
+
return [
|
|
904
|
+
basePrompt,
|
|
905
|
+
"",
|
|
906
|
+
"<retry_instruction>",
|
|
907
|
+
"The review emitted a verdict in response text but never committed the authoritative typed terminal tool call (approve_review / request_review_changes). The response text is NOT the authority: no branch or gate reads it.",
|
|
908
|
+
"Call exactly one typed terminal tool to finish: approve_review (implementation passes, no Critical/Important findings) or request_review_changes (with typed issueCategory, at least one evidenceRef, and non-empty findings). Do not repeat the review analysis; commit the terminal tool once and stop.",
|
|
909
|
+
"</retry_instruction>",
|
|
910
|
+
].join("\n");
|
|
911
|
+
}
|
|
912
|
+
if (previousFailureCategory === "read-burst") {
|
|
913
|
+
if (task.id !== FRONTEND_PLAN_NODE_ID) {
|
|
914
|
+
return [
|
|
915
|
+
basePrompt,
|
|
916
|
+
"",
|
|
917
|
+
"<retry_instruction>",
|
|
918
|
+
"The previous attempt exceeded its read budget. Start a fresh, evidence-minimal pass: do not re-read task sources, upstream stdout, skills, or full diff artifacts already represented by a canonical context artifact.",
|
|
919
|
+
"Read the smallest relevant summary first, then only the specific source file or diff fragment needed for the required decision. Finish the required terminal/output protocol as soon as evidence is sufficient.",
|
|
920
|
+
"</retry_instruction>",
|
|
921
|
+
].join("\n");
|
|
922
|
+
}
|
|
923
|
+
return [
|
|
924
|
+
basePrompt,
|
|
925
|
+
"",
|
|
926
|
+
"<retry_instruction>",
|
|
927
|
+
"Previous plan attempt issued too many read-only tool calls (read/grep/ls/find) and blew up the context window. Trust the upstream frontend-contract-pi typed requirement facts and frontend-scout-pi target surface already provided — do NOT re-read contract/scout stdout, PRD/source files, or component sources you already inspected.",
|
|
928
|
+
"Minimize discovery reads: only read what you genuinely need, once. Commit record_plan_requirement / record_plan_verification_target / record_* facts directly from the facts already in context (one tool call per message), then call finalize_plan exactly once.",
|
|
929
|
+
"</retry_instruction>",
|
|
930
|
+
].join("\n");
|
|
931
|
+
}
|
|
932
|
+
// Generic output-limit fallback. Every branch above this one carries a
|
|
933
|
+
// more precise instruction for the same capacity signal (contract repair
|
|
934
|
+
// reasons, ladder rungs — compact-terminal-first is the reason-mandated
|
|
935
|
+
// rung for length-before-terminal —, protocol/review/read-burst repair),
|
|
936
|
+
// so output-limit must not shadow them.
|
|
937
|
+
if (previousFailureCategory === OUTPUT_LIMIT_RETRY_CATEGORY) {
|
|
938
|
+
return [
|
|
939
|
+
basePrompt,
|
|
940
|
+
"",
|
|
941
|
+
"<retry_instruction>",
|
|
942
|
+
"The previous turn ended with stopReason=length before the required output was complete. This is output-capacity truncation, not empty output.",
|
|
943
|
+
"Continue incrementally from already committed typed facts and current in-scope workspace files. Do not repeat completed discovery, decisions, facts, or writes.",
|
|
944
|
+
"Generate the smallest unfinished unit next, validate its required format immediately, repair any format error in this node, and only then continue to the next unfinished unit or terminal tool.",
|
|
945
|
+
"Keep prose minimal and finish the required terminal/output protocol as soon as the remaining work is valid.",
|
|
946
|
+
"</retry_instruction>",
|
|
947
|
+
].join("\n");
|
|
948
|
+
}
|
|
870
949
|
if (previousFailureCategory === "writer-empty-diff") {
|
|
871
|
-
const maxAttempts = task.retryPolicy?.maxAttempts ??
|
|
950
|
+
const maxAttempts = task.retryPolicy?.maxAttempts ?? 5;
|
|
872
951
|
// When a completeness progress exists for this writer, fold the concrete
|
|
873
952
|
// target paths into the empty-diff retry so the model does not guess and
|
|
874
953
|
// does not need to read a forbidden `.harness/**` evidence file.
|
|
@@ -896,7 +975,7 @@ export function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFail
|
|
|
896
975
|
].join("\n");
|
|
897
976
|
}
|
|
898
977
|
if (previousFailureCategory === "incomplete-write-set") {
|
|
899
|
-
const maxAttempts = task.retryPolicy?.maxAttempts ??
|
|
978
|
+
const maxAttempts = task.retryPolicy?.maxAttempts ?? 5;
|
|
900
979
|
const bindingOnly = task.id?.startsWith("generate-backend-md-case-") === true &&
|
|
901
980
|
(recoveryTargetPaths?.length ?? 0) === 1 &&
|
|
902
981
|
Object.values(recoveryDiagnostics ?? {}).flat().some((detail) => /(?:unclassified Test Points|duplicate Test Point bindings)/i.test(detail));
|
|
@@ -1712,6 +1791,43 @@ export async function executeDagNode(input) {
|
|
|
1712
1791
|
durationMs: 0,
|
|
1713
1792
|
};
|
|
1714
1793
|
}
|
|
1794
|
+
// stopReason=length on top of a bare empty-output verdict is a provider
|
|
1795
|
+
// capacity signal, not a true empty response: reroute it through the
|
|
1796
|
+
// dedicated output-limit retry path while keeping the raw category for
|
|
1797
|
+
// diagnostics. Bare empty-output and transport aliases (network,
|
|
1798
|
+
// nonzero-exit, unknown) are rerouted — categories that already carry a
|
|
1799
|
+
// precise repair instruction (output-too-large, invalid-output,
|
|
1800
|
+
// protocol-invalid, structured-output-truncated via the validators below,
|
|
1801
|
+
// writer categories) keep their classification so their exact retry
|
|
1802
|
+
// guidance still reaches the model.
|
|
1803
|
+
if (task.executor === "pi" &&
|
|
1804
|
+
!result.ok &&
|
|
1805
|
+
result.stopReason === "length" &&
|
|
1806
|
+
(result.failureCategory === undefined ||
|
|
1807
|
+
["empty-output", "network", "nonzero-exit", "unknown"].includes(result.failureCategory))) {
|
|
1808
|
+
result = {
|
|
1809
|
+
...result,
|
|
1810
|
+
rawFailureCategory: result.rawFailureCategory ?? result.failureCategory,
|
|
1811
|
+
failureCategory: OUTPUT_LIMIT_RETRY_CATEGORY,
|
|
1812
|
+
stderr: [
|
|
1813
|
+
result.stderr,
|
|
1814
|
+
"output-limit: stopReason=length; preserve completed work and retry only the unfinished output",
|
|
1815
|
+
]
|
|
1816
|
+
.filter(Boolean)
|
|
1817
|
+
.join("\n"),
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
if (task.id === "generate-backend-md-plan-pi" &&
|
|
1821
|
+
!result.ok &&
|
|
1822
|
+
(result.failureCategory === "output-too-large" ||
|
|
1823
|
+
result.failureCategory === "invalid-output")) {
|
|
1824
|
+
const marker = "backend-test Markdown plan";
|
|
1825
|
+
const markerIndex = result.stderr?.lastIndexOf(marker) ?? -1;
|
|
1826
|
+
previousProtocolReason =
|
|
1827
|
+
markerIndex >= 0
|
|
1828
|
+
? result.stderr.slice(markerIndex, markerIndex + 4_000).trim()
|
|
1829
|
+
: `backend-test Markdown plan attempt failed with ${result.failureCategory}`;
|
|
1830
|
+
}
|
|
1715
1831
|
if (isFrontendStructuredRepairSchemaId(task.structuredContractOutput?.schemaId)) {
|
|
1716
1832
|
const rawText = canonicalNodeOutput(result);
|
|
1717
1833
|
if (rawText.trim().length > 0) {
|
|
@@ -1834,7 +1950,7 @@ export async function executeDagNode(input) {
|
|
|
1834
1950
|
!result.ok &&
|
|
1835
1951
|
retryPolicy !== undefined) {
|
|
1836
1952
|
const submissions = await countContractRecordSubmissions(runDir, nodeId);
|
|
1837
|
-
if (submissions === 0) {
|
|
1953
|
+
if (submissions === 0 && result.stopReason !== "length") {
|
|
1838
1954
|
result = {
|
|
1839
1955
|
...result,
|
|
1840
1956
|
failureCategory: "empty-output",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { hostname as localHostname } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
6
|
import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
|
|
5
7
|
import { DAG_RUNS_DIR } from "./lifecycle.js";
|
|
6
8
|
function recoveryLeasePath(cwd, parentRunId) {
|
|
@@ -23,6 +25,11 @@ function parseLease(raw, parentRunId) {
|
|
|
23
25
|
record.kind !== "standalone-task-rerun") ||
|
|
24
26
|
typeof record.leaseToken !== "string" ||
|
|
25
27
|
record.leaseToken.length === 0 ||
|
|
28
|
+
typeof record.ownerPid !== "number" ||
|
|
29
|
+
!Number.isInteger(record.ownerPid) ||
|
|
30
|
+
record.ownerPid <= 0 ||
|
|
31
|
+
typeof record.ownerHostname !== "string" ||
|
|
32
|
+
record.ownerHostname.length === 0 ||
|
|
26
33
|
typeof record.acquiredAt !== "string" ||
|
|
27
34
|
typeof record.executionRef !== "undefined" &&
|
|
28
35
|
typeof record.executionRef !== "string") {
|
|
@@ -30,6 +37,60 @@ function parseLease(raw, parentRunId) {
|
|
|
30
37
|
}
|
|
31
38
|
return record;
|
|
32
39
|
}
|
|
40
|
+
const LEASE_MUTATION_LOCK_STALE_MS = 60_000;
|
|
41
|
+
const LEASE_MUTATION_LOCK_RETRIES = 200;
|
|
42
|
+
async function withLeaseMutationLock(leasePath, operation) {
|
|
43
|
+
const lockPath = `${leasePath}.mutation-lock`;
|
|
44
|
+
for (let attempt = 0; attempt < LEASE_MUTATION_LOCK_RETRIES; attempt += 1) {
|
|
45
|
+
try {
|
|
46
|
+
await mkdir(lockPath);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) {
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const lockStat = await stat(lockPath);
|
|
54
|
+
if (Date.now() - lockStat.mtimeMs >= LEASE_MUTATION_LOCK_STALE_MS) {
|
|
55
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (statError) {
|
|
60
|
+
if (statError &&
|
|
61
|
+
typeof statError === "object" &&
|
|
62
|
+
"code" in statError &&
|
|
63
|
+
statError.code === "ENOENT") {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw statError;
|
|
67
|
+
}
|
|
68
|
+
await delay(10);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return await operation();
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error("dag recovery lease mutation lock is busy");
|
|
79
|
+
}
|
|
80
|
+
function defaultIsProcessAlive(pid) {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function leaseOwnerIsAlive(input) {
|
|
90
|
+
if (input.lease.ownerHostname !== input.hostname)
|
|
91
|
+
return true;
|
|
92
|
+
return input.isProcessAlive(input.lease.ownerPid);
|
|
93
|
+
}
|
|
33
94
|
export async function acquireDagRecoveryLease(input) {
|
|
34
95
|
const leasePath = recoveryLeasePath(input.cwd, input.parentRunId);
|
|
35
96
|
const lease = {
|
|
@@ -38,13 +99,15 @@ export async function acquireDagRecoveryLease(input) {
|
|
|
38
99
|
requestId: input.requestId,
|
|
39
100
|
kind: input.kind,
|
|
40
101
|
leaseToken: randomBytes(16).toString("hex"),
|
|
102
|
+
ownerPid: process.pid,
|
|
103
|
+
ownerHostname: localHostname(),
|
|
41
104
|
acquiredAt: (input.now ?? (() => new Date()))().toISOString(),
|
|
42
105
|
};
|
|
43
106
|
await mkdir(path.dirname(leasePath), { recursive: true });
|
|
44
107
|
try {
|
|
45
108
|
// `wx` is the cross-process compare-and-create primitive. The lease is
|
|
46
|
-
//
|
|
47
|
-
//
|
|
109
|
+
// parent-scoped for the full recovery lineage. Ownership may only be
|
|
110
|
+
// replaced by claimDagRecoveryLease after the recorded owner is proven dead.
|
|
48
111
|
await writeFile(leasePath, `${JSON.stringify(lease, null, 2)}\n`, {
|
|
49
112
|
encoding: "utf8",
|
|
50
113
|
flag: "wx",
|
|
@@ -59,22 +122,49 @@ export async function acquireDagRecoveryLease(input) {
|
|
|
59
122
|
return { kind: "held", leasePath, lease: existing };
|
|
60
123
|
}
|
|
61
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Reclaim a lease only after its recorded same-host owner is proven dead.
|
|
127
|
+
* The mutation lock serializes reclaim with execution binding, so a live owner
|
|
128
|
+
* can never race a stale-owner takeover and launch a second recovery.
|
|
129
|
+
*/
|
|
130
|
+
export async function claimDagRecoveryLease(input) {
|
|
131
|
+
const hostname = input.hostname ?? localHostname();
|
|
132
|
+
const isProcessAlive = input.isProcessAlive ?? defaultIsProcessAlive;
|
|
133
|
+
return withLeaseMutationLock(input.leasePath, async () => {
|
|
134
|
+
const existing = parseLease(JSON.parse(await readFile(input.leasePath, "utf8")), input.lease.parentRunId);
|
|
135
|
+
if (existing.leaseToken !== input.lease.leaseToken ||
|
|
136
|
+
leaseOwnerIsAlive({ lease: existing, hostname, isProcessAlive })) {
|
|
137
|
+
return { kind: "held", leasePath: input.leasePath, lease: existing };
|
|
138
|
+
}
|
|
139
|
+
const claimed = {
|
|
140
|
+
...existing,
|
|
141
|
+
leaseToken: randomBytes(16).toString("hex"),
|
|
142
|
+
ownerPid: process.pid,
|
|
143
|
+
ownerHostname: hostname,
|
|
144
|
+
acquiredAt: (input.now ?? (() => new Date()))().toISOString(),
|
|
145
|
+
};
|
|
146
|
+
await writeJsonAtomic(input.leasePath, claimed);
|
|
147
|
+
return { kind: "acquired", leasePath: input.leasePath, lease: claimed };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
62
150
|
/** Persist the operation/run identity before the caller is allowed to execute it. */
|
|
63
151
|
export async function bindDagRecoveryLeaseExecution(input) {
|
|
64
152
|
if (!input.executionRef.trim()) {
|
|
65
153
|
throw new Error("dag recovery lease executionRef is required");
|
|
66
154
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
155
|
+
return withLeaseMutationLock(input.leasePath, async () => {
|
|
156
|
+
const existing = parseLease(JSON.parse(await readFile(input.leasePath, "utf8")), input.lease.parentRunId);
|
|
157
|
+
if (existing.leaseToken !== input.lease.leaseToken) {
|
|
158
|
+
throw new Error("dag recovery lease ownership drift");
|
|
159
|
+
}
|
|
160
|
+
if (existing.executionRef && existing.executionRef !== input.executionRef) {
|
|
161
|
+
throw new Error("dag recovery lease execution identity drift");
|
|
162
|
+
}
|
|
163
|
+
const bound = {
|
|
164
|
+
...existing,
|
|
165
|
+
executionRef: input.executionRef,
|
|
166
|
+
};
|
|
167
|
+
await writeJsonAtomic(input.leasePath, bound);
|
|
168
|
+
return bound;
|
|
169
|
+
});
|
|
80
170
|
}
|