faberun 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/campaign/campaign-brief.mjs +17 -6
- package/src/campaign/chain.mjs +124 -8
- package/src/campaign/ledger.mjs +19 -7
- package/src/campaign/metrics-command.mjs +68 -9
- package/src/campaign/metrics.mjs +75 -45
- package/src/campaign/record.mjs +8 -1
- package/src/campaign/reserve.mjs +340 -0
- package/src/campaign/spend.mjs +229 -0
- package/src/campaign/unpark.mjs +54 -1
- package/src/campaign/watch.mjs +314 -37
- package/src/cli/brand.mjs +2 -1
- package/src/cli.mjs +52 -2
- package/src/contract/judge-envelope.mjs +17 -2
- package/src/contract/scope-findings.mjs +3 -1
- package/src/contract/task-packet.mjs +40 -5
- package/src/contract/verification.mjs +67 -1
- package/src/engine/aux-admission.mjs +124 -0
- package/src/engine/capacity.mjs +78 -0
- package/src/engine/dispatch.mjs +116 -23
- package/src/engine/failure-cause.mjs +59 -0
- package/src/engine/lifecycle.mjs +3 -2
- package/src/engine/phase-session.mjs +56 -10
- package/src/engine/prompts.mjs +57 -2
- package/src/engine/reserve-admission.mjs +99 -0
- package/src/engine/resume.mjs +3 -1
- package/src/engine/review.mjs +21 -0
- package/src/engine/run-command.mjs +166 -11
- package/src/engine/scheduler.mjs +92 -88
- package/src/engine/settle.mjs +15 -8
- package/src/engine/settlement-overlap.mjs +143 -0
- package/src/engine/verify.mjs +131 -7
- package/src/harnesses/session-metrics.mjs +112 -20
- package/src/notify/index.mjs +86 -10
- package/src/plan/freeze.mjs +7 -2
- package/src/plan/human-step.mjs +36 -0
- package/src/plan/pipeline.mjs +53 -22
- package/src/plan/preflight.mjs +81 -5
- package/src/plan/progress.mjs +101 -1
- package/src/plan/repo-facts.mjs +403 -49
- package/src/plan/resolve.mjs +7 -0
- package/src/plan/revise-context.mjs +108 -0
- package/src/plan/rounds.mjs +180 -45
- package/src/plan/sizing.mjs +33 -1
- package/src/plan/template.mjs +134 -22
- package/src/repo/integrate.mjs +47 -5
- package/src/report/campaign-brief-estimate.mjs +47 -1
- package/src/report/campaign-brief.mjs +31 -2
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +13 -1
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- package/src/report/render.mjs +1 -1
- package/src/report/report-html.mjs +235 -0
- package/src/report/report-video.mjs +323 -0
- package/src/run/usage-windows.mjs +99 -4
- package/src/run/usage.mjs +69 -7
- package/src/web/api.mjs +179 -24
- package/src/web/app.mjs +27 -2
- package/src/web/server.mjs +32 -0
package/src/plan/human-step.mjs
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* freeze.mjs can carry it on the frozen plan as an explicit stop, and the
|
|
6
6
|
* Campaign Brief can list it among the human decisions. Pure text matching:
|
|
7
7
|
* no file I/O, no git, no model.
|
|
8
|
+
*
|
|
9
|
+
* The same module owns the other thing only an operator can do, because it
|
|
10
|
+
* is the same boundary at a different source: a packet refusal the failure
|
|
11
|
+
* classifier named `ambiguous_requirement` (R5, campaign-efficiency phase 4)
|
|
12
|
+
* is not repairable by widening the packet — the requirement itself can be
|
|
13
|
+
* read more than one way — so it becomes a durable question
|
|
14
|
+
* (`durableQuestion`) the run carries until `resume --answer <node>` closes
|
|
15
|
+
* it, instead of another widening attempt. The source is the run's failure
|
|
16
|
+
* journal, not the spec's constraints; the discipline is unchanged: pure
|
|
17
|
+
* record shaping, no file I/O, no git, no model.
|
|
8
18
|
*/
|
|
9
19
|
|
|
10
20
|
/** @typedef {{requirementId: string, step: string, command: string}} HumanStep */
|
|
@@ -36,3 +46,29 @@ export function collectHumanSteps(requirements) {
|
|
|
36
46
|
.map((requirement) => detectHumanStep(requirement))
|
|
37
47
|
.filter((step) => step !== null);
|
|
38
48
|
}
|
|
49
|
+
|
|
50
|
+
/** @typedef {{node: string, artifactVersion: string, question: string}} DurableQuestion */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The durable question an ambiguous-requirement refusal becomes. Null for
|
|
54
|
+
* every other cause: a read or write gap is the packet's own defect and a
|
|
55
|
+
* widening round can repair it, but an ambiguity is a property of the
|
|
56
|
+
* requirement the packet was written from, and no widening of read or write
|
|
57
|
+
* scope can answer it — another attempt would re-read the same prose.
|
|
58
|
+
*
|
|
59
|
+
* The question names the node and the packet version it refuses so the
|
|
60
|
+
* operator's answer is bound to the same key the failure journal uses: a
|
|
61
|
+
* widened packet changes the version, and with it the question.
|
|
62
|
+
*
|
|
63
|
+
* @param {{cause: string, node: string, artifactVersion: string}} record a
|
|
64
|
+
* failureCause record as the reauthor journal writes it
|
|
65
|
+
* @returns {DurableQuestion|null}
|
|
66
|
+
*/
|
|
67
|
+
export function durableQuestion(record) {
|
|
68
|
+
if (record.cause !== "ambiguous_requirement") return null;
|
|
69
|
+
return {
|
|
70
|
+
node: record.node,
|
|
71
|
+
artifactVersion: record.artifactVersion,
|
|
72
|
+
question: `Node ${record.node} refused its packet at ${record.artifactVersion} with cause ambiguous_requirement: the requirement can be read more than one way, and no widening of the packet can answer it. Decide what the requirement means, then resume with --answer ${record.node}; no reauthor round and no further attempt runs on this packet version until then.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -25,13 +25,12 @@ import { readCampaign } from "../campaign/record.mjs";
|
|
|
25
25
|
import { campaignCli } from "../cli/campaign.mjs";
|
|
26
26
|
import { appendJsonl } from "../run/store.mjs";
|
|
27
27
|
import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
|
|
28
|
-
import { askPlanningRuntimes, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
|
|
28
|
+
import { askPlanningRuntimes, checkPlanBeforeReview, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
|
|
29
29
|
import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
|
|
30
30
|
import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
|
|
31
31
|
import { parseSpec, validateSpec } from "./spec.mjs";
|
|
32
|
-
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
33
|
-
import { clearPlanProgress, writePlanProgress } from "./progress.mjs";
|
|
34
|
-
import { checkPlanProofs } from "./proof-check.mjs";
|
|
32
|
+
import { collectRepoFacts, persistRepoFacts } from "./repo-facts.mjs";
|
|
33
|
+
import { abandonedPlanProgress, clearPlanProgress, startDraftPlan, writePlanDraftRecord, writePlanProgress } from "./progress.mjs";
|
|
35
34
|
import { collectHumanSteps } from "./human-step.mjs";
|
|
36
35
|
import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validatePlanOutput } from "./template.mjs";
|
|
37
36
|
import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
|
|
@@ -159,6 +158,12 @@ async function runPlanningStages(options) {
|
|
|
159
158
|
// the home, and the scratch relay inside the repository is excluded by the
|
|
160
159
|
// same pathspec the launch uses.
|
|
161
160
|
assertLaunchBaseClean(cwd, undefined);
|
|
161
|
+
// The refusal half of the pre-flight, decided from the catalogue and the
|
|
162
|
+
// machine alone and returned before anything is dispatched, so a runtime
|
|
163
|
+
// choice no frozen contract could carry costs no model call: it runs before
|
|
164
|
+
// the liveness ask below because that ask is itself a provider invocation,
|
|
165
|
+
// and everything a plan can still get wrong is a finding for the repair
|
|
166
|
+
// loop rather than a refusal.
|
|
162
167
|
refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence);
|
|
163
168
|
refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
|
|
164
169
|
|
|
@@ -202,6 +207,8 @@ async function runPlanningStages(options) {
|
|
|
202
207
|
// the draft stage reads what the planner measured instead of inferring it
|
|
203
208
|
// from the spec's prose.
|
|
204
209
|
const parsedSpec = parseSpec(specText);
|
|
210
|
+
// Sampled before this pipeline's own first liveness write (repo facts) overwrites it.
|
|
211
|
+
const abandoned = abandonedPlanProgress(cwd, campaignId, phase);
|
|
205
212
|
// This stage holds the terminal for minutes, which is why it says what it is
|
|
206
213
|
// about to do before it does it and one line per command as each finishes.
|
|
207
214
|
// Measured 2026-09-25/26 (AP3): five to eight minutes with nothing written
|
|
@@ -217,8 +224,12 @@ async function runPlanningStages(options) {
|
|
|
217
224
|
// here so both the freeze below and a caller inspecting the pipeline agree
|
|
218
225
|
// on the same list.
|
|
219
226
|
const humanSteps = collectHumanSteps(parsedSpec.requirements);
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
// The draft and review stages read the cut through `readFiles`, and the
|
|
228
|
+
// complete path index is staged beside it rather than inside it: the
|
|
229
|
+
// artefact stays byte-bounded, and a discovery node explicitly authorised to
|
|
230
|
+
// enumerate the tree is handed `repo-paths.txt` instead of a cut that lies
|
|
231
|
+
// about what the tree holds (R4).
|
|
232
|
+
const repoFactsPath = persistRepoFacts(scratchDir, repoFacts, cwd);
|
|
222
233
|
const relativeRepoFactsPath = relative(cwd, repoFactsPath);
|
|
223
234
|
// The taskKind catalogue is staged like every other planning input. It used
|
|
224
235
|
// to be handed over as faberun's own `src/plan/template.mjs`, which resolves
|
|
@@ -278,7 +289,13 @@ async function runPlanningStages(options) {
|
|
|
278
289
|
return { contract: validated, output };
|
|
279
290
|
};
|
|
280
291
|
|
|
281
|
-
|
|
292
|
+
// R2 resume: the sampled record survives only a process that died, and a
|
|
293
|
+
// dead attempt that validated its draft left writePlanDraftRecord's file
|
|
294
|
+
// behind. A finished phase — frozen or contested — has a plan.json, and that drafts fresh.
|
|
295
|
+
const draft = await startDraftPlan({
|
|
296
|
+
abandoned, plansDir, specDigest, runStage, logStage,
|
|
297
|
+
draftStageInput: { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, packageMode },
|
|
298
|
+
});
|
|
282
299
|
/** @type {PlanOutput|null} */
|
|
283
300
|
let plan = null;
|
|
284
301
|
// Everything still open against the plan in hand, accumulated across rounds
|
|
@@ -288,23 +305,16 @@ async function runPlanningStages(options) {
|
|
|
288
305
|
/** @type {PlanFindingOutput[]} */
|
|
289
306
|
let findings = [];
|
|
290
307
|
try {
|
|
291
|
-
plan = validatePlanOutput(draft.
|
|
308
|
+
plan = validatePlanOutput(draft.planOutput);
|
|
292
309
|
} catch (error) {
|
|
293
310
|
findings = [invalidPlanFinding("draft", error)];
|
|
294
311
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// finding this raises is exactly as actionable to a revise as a reviewer's
|
|
302
|
-
// own, and raising it before review means review never spends a round
|
|
303
|
-
// re-discovering what a mechanical check already knows for certain.
|
|
304
|
-
if (plan !== null) {
|
|
305
|
-
const proofFindings = checkPlanProofs(plan, repoFacts, cwd);
|
|
306
|
-
if (proofFindings.length > 0) findings = [...findings, ...proofFindings];
|
|
307
|
-
logStage("proof-check", { findingsCount: proofFindings.length });
|
|
312
|
+
if (!draft.resumed) {
|
|
313
|
+
// Durable before its stage line: a death in between leaves an adoptable draft.
|
|
314
|
+
if (plan !== null) writePlanDraftRecord(plansDir, { runId: draft.runId, at: new Date().toISOString(), specDigest, plan });
|
|
315
|
+
logStage("draft", plan === null
|
|
316
|
+
? { runId: draft.runId, invalid: findings[0].text }
|
|
317
|
+
: { runId: draft.runId, nodeCount: plan.nodes.length });
|
|
308
318
|
}
|
|
309
319
|
|
|
310
320
|
const workingPlanPath = join(scratchDir, "plan.working.json");
|
|
@@ -364,8 +374,29 @@ async function runPlanningStages(options) {
|
|
|
364
374
|
*/
|
|
365
375
|
const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, judgeIndependence });
|
|
366
376
|
|
|
377
|
+
// R5: the complete deterministic check set — the freeze rules `freeze.mjs`
|
|
378
|
+
// and `proof-scope.mjs` apply, plus the plan-wide proof scan (R15) — runs
|
|
379
|
+
// here, before the first semantic review is dispatched, and its diagnostics
|
|
380
|
+
// are forwarded into the round loop as findings: a mechanical objection is
|
|
381
|
+
// then answered by the same revise a reviewer's objection is, inside the
|
|
382
|
+
// rounds `--review-rounds` already pays for, never by a refusal path of its
|
|
383
|
+
// own. The plan it hands on is the repaired one, so the artefact the
|
|
384
|
+
// reviewer reads is the artefact that freezes. A failure the freeze itself
|
|
385
|
+
// would raise is logged here and raised as a finding by the round loop's own
|
|
386
|
+
// copy of this check, in the same round and under the round's own id: one
|
|
387
|
+
// objection, one finding, one repair.
|
|
388
|
+
if (plan !== null) {
|
|
389
|
+
const checks = checkPlanBeforeReview(plan, { repoFacts, cwd, plansDir, assembleFrozenNodes, frozenContractRaw });
|
|
390
|
+
plan = checks.plan;
|
|
391
|
+
if (checks.raised.length > 0) logStage("timeouts-raised", { raised: checks.raised });
|
|
392
|
+
if (checks.declared.length > 0) logStage("guards-declared", { declared: checks.declared });
|
|
393
|
+
findings = [...findings, ...checks.findings];
|
|
394
|
+
logStage("proof-check", { findingsCount: checks.findings.length });
|
|
395
|
+
logStage("preflight", { raised: checks.raised.length, declared: checks.declared.length, ...(checks.failure === null ? {} : { failed: checks.failure instanceof Error ? checks.failure.message : String(checks.failure) }) });
|
|
396
|
+
}
|
|
397
|
+
|
|
367
398
|
const roundsResult = await runReviewRounds({
|
|
368
|
-
reviewRounds, plan, rejectedDraft: plan === null ? draft.
|
|
399
|
+
reviewRounds, plan, rejectedDraft: plan === null ? draft.planOutput : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
|
|
369
400
|
relativeSpecPath, relativeRepoFactsPath, relativeCataloguePath, packageMode, repoFacts,
|
|
370
401
|
runStage, assembleFrozenNodes, frozenContractRaw, contest,
|
|
371
402
|
invalidPlanFinding, logStage,
|
package/src/plan/preflight.mjs
CHANGED
|
@@ -1,17 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* What `faberun plan` decides before it spends anything: the refusals it
|
|
3
|
+
* returns before the first stage is dispatched -- which runtimes a planning run
|
|
4
|
+
* will spend, and what counts as one of them not answering -- and the
|
|
5
|
+
* deterministic checks a plan passes before any semantic review grades it.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* tree's 800-line ceiling.
|
|
7
|
+
* Both halves live beside the pipeline rather than inside it because asking
|
|
8
|
+
* and checking are their own concerns, and because `pipeline.mjs` sits close
|
|
9
|
+
* to this tree's 800-line ceiling.
|
|
8
10
|
*/
|
|
11
|
+
import { join } from "node:path";
|
|
9
12
|
import { preflightRuntimes } from "../engine/live-preflight.mjs";
|
|
10
13
|
import { liveSilenceCause } from "../engine/live-silence.mjs";
|
|
11
14
|
import { harnessCapabilities } from "../harnesses/index.mjs";
|
|
15
|
+
import { validateContract } from "../contract/index.mjs";
|
|
12
16
|
import { assertRuntimeExecutesCommands, validateRuntime } from "../contract/runtime.mjs";
|
|
13
17
|
import { effectiveProvider } from "../contract/provider.mjs";
|
|
14
18
|
import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
|
|
19
|
+
import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
|
|
20
|
+
import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
|
|
21
|
+
import { checkPlanProofs } from "./proof-check.mjs";
|
|
22
|
+
|
|
23
|
+
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
24
|
+
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
25
|
+
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
26
|
+
/** @typedef {import("./pipeline.mjs").AssembledPlan} AssembledPlan */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the deterministic checks need: the measured repository facts the freeze
|
|
30
|
+
* rules read, the cwd and plansDir the contract is assembled against, and the
|
|
31
|
+
* two closures that render the plan the way `freezePlan` will. They are
|
|
32
|
+
* supplied rather than imported because they are the pipeline's own assembly
|
|
33
|
+
* (`pipeline.mjs` imports this module), and taking them as data is what lets
|
|
34
|
+
* `rounds.mjs` — and a test — run the checks with fakes.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {{repoFacts: import("./repo-facts.mjs").RepoFacts, cwd: string, plansDir: string, assembleFrozenNodes: (plan: PlanOutput) => AssembledPlan, frozenContractRaw: (assembly: AssembledPlan) => JsonObject}} PlanCheckContext
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The two mechanically repairable freeze rules, applied, then the freeze
|
|
41
|
+
* contract itself validated the way `freeze.mjs` and `proof-scope.mjs`
|
|
42
|
+
* validate it: the rules are imported, never restated, so this cannot drift
|
|
43
|
+
* from the freeze it stands in front of.
|
|
44
|
+
*
|
|
45
|
+
* It does not throw. A plan the checks refuse is a diagnostic for the repair
|
|
46
|
+
* loop, not a refusal: the refusal is the pre-dispatch half of this module,
|
|
47
|
+
* and a thrown plan check would spend a round's budget twice — once on the
|
|
48
|
+
* revise that answers a finding, and once on the error that killed it.
|
|
49
|
+
*
|
|
50
|
+
* The failure is returned beside the repaired plan rather than instead of it,
|
|
51
|
+
* so the caller can hand the plan on to a review (or to the freeze) while
|
|
52
|
+
* still recording what the freeze would have said.
|
|
53
|
+
*
|
|
54
|
+
* @param {PlanOutput} plan
|
|
55
|
+
* @param {PlanCheckContext} ctx
|
|
56
|
+
* @returns {{plan: PlanOutput, raised: string[], declared: string[], failure: unknown|null}}
|
|
57
|
+
*/
|
|
58
|
+
export function freezePreflight(plan, ctx) {
|
|
59
|
+
const timeouts = raiseTimeoutsToMeasured(plan, ctx.repoFacts);
|
|
60
|
+
const guards = declareDirectoryGuards(timeouts.plan, ctx.repoFacts, ctx.cwd);
|
|
61
|
+
const candidate = guards.plan;
|
|
62
|
+
const repaired = { plan: candidate, raised: timeouts.raised, declared: guards.declared };
|
|
63
|
+
try {
|
|
64
|
+
const contract = validateContract(ctx.frozenContractRaw(ctx.assembleFrozenNodes(candidate)), join(ctx.plansDir, "contract.json"));
|
|
65
|
+
assertFilteredProofsNameTheirTest(contract);
|
|
66
|
+
assertTimeoutsCoverMeasured(contract, ctx.repoFacts);
|
|
67
|
+
return { ...repaired, failure: null };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { ...repaired, failure: error };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The complete deterministic check set a plan passes before any semantic
|
|
75
|
+
* review begins: `freezePreflight` plus the plan-wide proof scan, whose two
|
|
76
|
+
* shapes (a name filter that selects no test, a bare grep proving an absence)
|
|
77
|
+
* no reviewer decides better than a local read. The pipeline runs this once on
|
|
78
|
+
* the draft and forwards `findings` into the round loop's own repair budget —
|
|
79
|
+
* the rounds `--review-rounds` already pays for — while the loop runs
|
|
80
|
+
* `freezePreflight` on every candidate it grades, where the repair it drives
|
|
81
|
+
* is the answer to whatever the check refused.
|
|
82
|
+
*
|
|
83
|
+
* @param {PlanOutput} plan
|
|
84
|
+
* @param {PlanCheckContext} ctx
|
|
85
|
+
* @returns {{plan: PlanOutput, raised: string[], declared: string[], findings: PlanFindingOutput[], failure: unknown|null}}
|
|
86
|
+
*/
|
|
87
|
+
export function checkPlanBeforeReview(plan, ctx) {
|
|
88
|
+
const checks = freezePreflight(plan, ctx);
|
|
89
|
+
return { ...checks, findings: checkPlanProofs(checks.plan, ctx.repoFacts, ctx.cwd) };
|
|
90
|
+
}
|
|
15
91
|
|
|
16
92
|
/**
|
|
17
93
|
* The runtimes a planning run will spend, asked once before its first stage.
|
package/src/plan/progress.mjs
CHANGED
|
@@ -19,12 +19,24 @@
|
|
|
19
19
|
* also why `plan --resolve` writes none: it is never detached, so a death
|
|
20
20
|
* there is visible where it happens and a record would only add a second
|
|
21
21
|
* account of something the operator already watched.
|
|
22
|
+
*
|
|
23
|
+
* The module also holds the record of what a dead attempt finished:
|
|
24
|
+
* `writePlanDraftRecord` persists the validated draft plan beside the phase's
|
|
25
|
+
* plan artifacts, and `resumablePlanDraft` reads it back for a resumed
|
|
26
|
+
* attempt, because the liveness record says that the attempt died but not
|
|
27
|
+
* what its death had already paid for. `startDraftPlan` decides, for one
|
|
28
|
+
* attempt, between adopting that draft and dispatching the draft stage —
|
|
29
|
+
* dispatching stays the pipeline's verb (it is passed in), but whether to
|
|
30
|
+
* dispatch at all is this module's question. Same boundary, same reader —
|
|
31
|
+
* all of it answers a later launch's one question, what did the last attempt
|
|
32
|
+
* leave behind.
|
|
22
33
|
*/
|
|
23
|
-
import { rmSync } from "node:fs";
|
|
34
|
+
import { existsSync, rmSync } from "node:fs";
|
|
24
35
|
import { join } from "node:path";
|
|
25
36
|
import { pidAlive, processStartToken, sameProcessStartToken } from "../run/lock.mjs";
|
|
26
37
|
import { campaignTree } from "../run/paths.mjs";
|
|
27
38
|
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
39
|
+
import { validatePlanOutput } from "./template.mjs";
|
|
28
40
|
|
|
29
41
|
/** @typedef {{pid?: number, processStartToken?: string|null, at?: string, stage?: string, [key: string]: unknown}} PlanProgressRecord */
|
|
30
42
|
|
|
@@ -99,3 +111,91 @@ export function abandonedPlanProgress(cwd, campaignId, phase) {
|
|
|
99
111
|
export function clearPlanProgress(cwd, campaignId, phase) {
|
|
100
112
|
rmSync(planProgressPath(cwd, campaignId, phase), { force: true });
|
|
101
113
|
}
|
|
114
|
+
|
|
115
|
+
/** The file beside a phase's plan artifacts holding the draft a resumed attempt can adopt. */
|
|
116
|
+
const PLAN_DRAFT_FILE = "draft.json";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The validated draft a later attempt can adopt without paying for it again.
|
|
120
|
+
*
|
|
121
|
+
* @typedef {{runId: string, at: string, specDigest: string, plan: import("./template.mjs").PlanOutput}} PlanDraftRecord
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Persist the validated draft plan where a later launch of the same phase
|
|
126
|
+
* finds it: beside the phase's durable plan artifacts, never in the disposable
|
|
127
|
+
* scratch tree, for the same reason the progress record is there — a record a
|
|
128
|
+
* later launch has to find cannot live where the next `plan` may wipe. The
|
|
129
|
+
* record is written once, when the draft validates, and never removed: a
|
|
130
|
+
* frozen or contested phase writes `plan.json` beside it, and the presence of
|
|
131
|
+
* that file is what stops a later attempt from adopting anything.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} plansDir
|
|
134
|
+
* @param {PlanDraftRecord} record
|
|
135
|
+
* @returns {void}
|
|
136
|
+
*/
|
|
137
|
+
export function writePlanDraftRecord(plansDir, record) {
|
|
138
|
+
writeJsonAtomic(join(plansDir, PLAN_DRAFT_FILE), record);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The dead attempt's draft, when adopting it costs no paid call, or null when
|
|
143
|
+
* there is nothing to adopt. Two records must agree before anything is
|
|
144
|
+
* adopted: `abandoned` — the progress record above, sampled by the caller
|
|
145
|
+
* before its own first liveness write, which would otherwise overwrite the
|
|
146
|
+
* evidence of the death it is about to resume from — and the draft record
|
|
147
|
+
* `writePlanDraftRecord` wrote when the dead attempt validated its draft. The
|
|
148
|
+
* plan in it is exactly the paid work a resumed attempt would otherwise
|
|
149
|
+
* duplicate. A phase that ever finished, frozen or contested, has a
|
|
150
|
+
* `plan.json` beside the record: that file, not the record's absence, is
|
|
151
|
+
* what makes a later deliberate re-plan draft fresh. A record from a
|
|
152
|
+
* different spec (the operator edited the spec between attempts) or one
|
|
153
|
+
* whose plan no longer validates is refused the same way.
|
|
154
|
+
*
|
|
155
|
+
* @param {{abandoned: PlanProgressRecord|null, plansDir: string, specDigest: string}} options
|
|
156
|
+
* @returns {{runId: string, plan: import("./template.mjs").PlanOutput}|null}
|
|
157
|
+
*/
|
|
158
|
+
export function resumablePlanDraft({ abandoned, plansDir, specDigest }) {
|
|
159
|
+
if (abandoned === null) return null;
|
|
160
|
+
if (existsSync(join(plansDir, "plan.json"))) return null;
|
|
161
|
+
let record;
|
|
162
|
+
try {
|
|
163
|
+
record = /** @type {PlanDraftRecord|unknown} */ (readJson(join(plansDir, PLAN_DRAFT_FILE)));
|
|
164
|
+
} catch {
|
|
165
|
+
// An absent record is the ordinary case: a draft still running, or a
|
|
166
|
+
// death before the draft validated, leaves nothing to adopt.
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
170
|
+
const draft = /** @type {PlanDraftRecord} */ (record);
|
|
171
|
+
if (draft.specDigest !== specDigest || typeof draft.runId !== "string") return null;
|
|
172
|
+
try {
|
|
173
|
+
return { runId: draft.runId, plan: validatePlanOutput(draft.plan) };
|
|
174
|
+
} catch {
|
|
175
|
+
// The record's plan no longer validates: adopting it would spend the
|
|
176
|
+
// round budget repairing an unknown shape, so the caller drafts fresh.
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The draft one attempt starts from: the dead attempt's validated plan when
|
|
183
|
+
* adopting it costs no paid call, or a fresh dispatch of the draft stage.
|
|
184
|
+
* The adoption is announced as its own stage line — a resumed attempt that
|
|
185
|
+
* dispatched nothing must still leave the same record a drafting one does.
|
|
186
|
+
*
|
|
187
|
+
* @param {{abandoned: PlanProgressRecord|null, plansDir: string, specDigest: string,
|
|
188
|
+
* draftStageInput: Record<string, unknown>,
|
|
189
|
+
* runStage: (kind: import("./template.mjs").PlanningKind, input: Record<string, unknown>) => Promise<{contract: {id: string}, output: Record<string, unknown>}>,
|
|
190
|
+
* logStage: (stage: string, detail: Record<string, unknown>) => void}} options
|
|
191
|
+
* @returns {Promise<{runId: string, planOutput: unknown, resumed: boolean}>}
|
|
192
|
+
*/
|
|
193
|
+
export async function startDraftPlan({ abandoned, plansDir, specDigest, draftStageInput, runStage, logStage }) {
|
|
194
|
+
const resumed = resumablePlanDraft({ abandoned, plansDir, specDigest });
|
|
195
|
+
if (resumed !== null) {
|
|
196
|
+
logStage("draft-resumed", { runId: resumed.runId, nodeCount: resumed.plan.nodes.length });
|
|
197
|
+
return { runId: resumed.runId, planOutput: resumed.plan, resumed: true };
|
|
198
|
+
}
|
|
199
|
+
const stage = await runStage("draft", draftStageInput);
|
|
200
|
+
return { runId: stage.contract.id, planOutput: stage.output.plan, resumed: false };
|
|
201
|
+
}
|