faberun 0.30.0 → 0.31.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/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 +71 -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 +27 -0
- package/src/report/locale.mjs +20 -0
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- 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/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
|
+
}
|