unitbob 0.7.1 → 0.7.2
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/dist/files/workerPlan.js
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
5
5
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
6
|
-
import { branchWidth } from "./fanOut.js";
|
|
7
6
|
export function workerPlanPath(projectRoot) {
|
|
8
7
|
return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
|
|
9
8
|
}
|
|
@@ -233,62 +232,9 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
233
232
|
}
|
|
234
233
|
for (const id of assigned.filter((id) => !expected.includes(id)))
|
|
235
234
|
errors.push(`${branch}: capability ${id} was not assigned by the request`);
|
|
236
|
-
const cases = items.reduce((sum, item) => sum + (Array.isArray(item.planned_cases) ? item.planned_cases.length : 0), 0);
|
|
237
|
-
errors.push(...fanOutErrors(branch, items.length, cases));
|
|
238
235
|
}
|
|
239
236
|
return errors;
|
|
240
237
|
}
|
|
241
|
-
// Which assigned ids each branch of a plan actually took. Spec 37-3 weighs a
|
|
242
|
-
// plan against the work it took on, never against the whole assignment: the
|
|
243
|
-
// packets are built before anybody chooses a scope, and since criterion 2 both
|
|
244
|
-
// branches may be narrowed, so the two are different jobs.
|
|
245
|
-
export function takenIds(plan) {
|
|
246
|
-
const taken = new Map();
|
|
247
|
-
for (const item of Array.isArray(plan?.workers) ? plan.workers : []) {
|
|
248
|
-
if (!item || typeof item !== 'object' || Array.isArray(item) || !isNonEmptyString(item.branch))
|
|
249
|
-
continue;
|
|
250
|
-
const ids = taken.get(item.branch) ?? new Set();
|
|
251
|
-
for (const id of Array.isArray(item.capability_ids) ? item.capability_ids : []) {
|
|
252
|
-
if (isNonEmptyString(id))
|
|
253
|
-
ids.add(id);
|
|
254
|
-
}
|
|
255
|
-
taken.set(item.branch, ids);
|
|
256
|
-
}
|
|
257
|
-
return taken;
|
|
258
|
-
}
|
|
259
|
-
// Spec 37-3, criterion 1. Two things are checked, and only when the packets
|
|
260
|
-
// exist to measure against: that the plan says what it divided, and that it did
|
|
261
|
-
// not divide work that already fits in one worker.
|
|
262
|
-
//
|
|
263
|
-
// A run without packets has no measured work, and a rule with no measurement
|
|
264
|
-
// behind it refuses nobody — the same policy the packets themselves follow.
|
|
265
|
-
function fanOutErrors(branch, planned, cases) {
|
|
266
|
-
const width = branchWidth(branch, cases);
|
|
267
|
-
if (!width || planned === 0)
|
|
268
|
-
return [];
|
|
269
|
-
// A band, not a ceiling. Both ends are expensive and neither is safe: on the
|
|
270
|
-
// 2026-08-24 bench fifteen workers cost 28% more than the cheapest width, and
|
|
271
|
-
// one worker cost 85% more — and a single worker on the behavioral branch
|
|
272
|
-
// would have run 216 turns into a 150-turn fuse. Anywhere inside the band is
|
|
273
|
-
// within about a tenth of the cheapest, so this refuses only what costs.
|
|
274
|
-
//
|
|
275
|
-
// Nothing is restated in the plan to prove the coordinator did this division.
|
|
276
|
-
// Both halves are already in the file — the cases in `planned_cases`, the
|
|
277
|
-
// width as the length of the branch's slice list — so a `fan_out` record would
|
|
278
|
-
// be the same two numbers copied by hand, which is what spec 37-1 refused for
|
|
279
|
-
// `packet_paths`. The gate is the guarantee; `accept-worker-plan` prints the
|
|
280
|
-
// derivation next to it.
|
|
281
|
-
if (planned >= width.fewest && planned <= width.most)
|
|
282
|
-
return [];
|
|
283
|
-
const way = planned > width.most ? 'wide' : 'narrow';
|
|
284
|
-
return [
|
|
285
|
-
`${branch}: ${planned} slices for ${cases} planned cases is too ${way} — ` +
|
|
286
|
-
`${width.fewest}-${width.most} is the band, ${width.workers} the cheapest. ` +
|
|
287
|
-
`Each slice costs a whole opening context (26,065 tokens on that bench, re-read every turn), ` +
|
|
288
|
-
`and each slice fewer makes one conversation longer, which costs with the square of its ` +
|
|
289
|
-
`length. At ${width.workers} a worker of this branch runs about ${width.turns_each} turns.`,
|
|
290
|
-
];
|
|
291
|
-
}
|
|
292
238
|
function assignmentIds(value) {
|
|
293
239
|
const assignment = value;
|
|
294
240
|
if (Array.isArray(assignment?.capabilities)) {
|
|
Binary file
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { clearRunState } from "../runner/failureDigest.js";
|
|
2
2
|
import { materializeHelper } from "../files/guardrails.js";
|
|
3
3
|
import { materializeBehavioralWorld } from "../files/behavioral.js";
|
|
4
|
-
import { branchWorkloads, workloadLine } from "../files/fanOut.js";
|
|
5
4
|
import { PACKETS_DIR, writeSuitePackets } from "../files/packets.js";
|
|
6
5
|
import { movePreviousRunAside, recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
7
6
|
import { bddStepLoading } from "../runner/bdd.js";
|
|
@@ -280,7 +279,6 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
280
279
|
'the request that was just replaced.\n');
|
|
281
280
|
}
|
|
282
281
|
actual.stdout.write(packetNotice(request.project_root, sourcePackets));
|
|
283
|
-
actual.stdout.write(workloadNotice(request.project_root));
|
|
284
282
|
actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
|
|
285
283
|
`write your answer to ${request.output_path} as a branches array — one entry per branch named above, and a branch you cannot ` +
|
|
286
284
|
`finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
|
|
@@ -366,22 +364,6 @@ function packetNotice(projectRoot, packets) {
|
|
|
366
364
|
` (${packets.notes.join('; ')}). ` +
|
|
367
365
|
`Each says why in ${where}/index.json.\n`);
|
|
368
366
|
}
|
|
369
|
-
// Spec 37-3, criterion 1. The size of the work, printed before the plan exists,
|
|
370
|
-
// because that is the only moment it can decide anything: the packets are built
|
|
371
|
-
// from the request's entrypoints, and the entrypoints are known before the
|
|
372
|
-
// workers are. A number that arrives after the plan is a number the plan was
|
|
373
|
-
// not made from.
|
|
374
|
-
function workloadNotice(projectRoot) {
|
|
375
|
-
const loads = branchWorkloads(projectRoot);
|
|
376
|
-
if (loads.length === 0)
|
|
377
|
-
return '';
|
|
378
|
-
return ('\nHow much each branch has to read, over the whole assignment and before you narrow it:\n' +
|
|
379
|
-
loads.map(workloadLine).join('') +
|
|
380
|
-
'This does not set how many workers a branch gets, and that is worth knowing before you plan: ' +
|
|
381
|
-
'on the bench of 2026-08-24 the branch with three times the source spent half the turns. What ' +
|
|
382
|
-
'sets the width is how many cases you intend to write, so it is decided by the plan and ' +
|
|
383
|
-
'checked by `accept-worker-plan`, which prints the band it accepted.\n');
|
|
384
|
-
}
|
|
385
367
|
// The runner's own rule for which step files it will load, in the words of the
|
|
386
368
|
// side that loads them (spec ask-before-you-spend, §3.2). The same object is in `request.json`, on
|
|
387
369
|
// the behavioral branch; this is the copy the coordinator sees without opening a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.7.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.7.2 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|
package/dist/files/fanOut.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { readPacketIndex } from "./packets.js";
|
|
2
|
-
// Spec 37-3, criterion 1. How wide a branch's fan-out should be.
|
|
3
|
-
//
|
|
4
|
-
// The rule this replaces said there was no ceiling at all: "an agent re-reads
|
|
5
|
-
// its context every turn, so splitting the work never costs more than keeping it
|
|
6
|
-
// together." Half right, and the wrong half was load-bearing. An agent's cost is
|
|
7
|
-
// the sum of its context over its turns, so splitting pulls in two directions:
|
|
8
|
-
//
|
|
9
|
-
// - the opening context is bought once per worker and re-read every turn, so
|
|
10
|
-
// it multiplies with the width. 26,065 tokens on the bench of 2026-08-24,
|
|
11
|
-
// the same to within ±370 across fifteen workers.
|
|
12
|
-
// - each worker's conversation is shorter, and a conversation's cost grows
|
|
13
|
-
// with the square of its length, so this falls with the width.
|
|
14
|
-
//
|
|
15
|
-
// There is therefore a minimum, and it is neither end. Measured on that bench,
|
|
16
|
-
// against what the fifteen workers actually cost:
|
|
17
|
-
//
|
|
18
|
-
// workers 1 2 3 5 8 15 20
|
|
19
|
-
// input 51.2M 34.6M 29.9M 27.7M 28.8M 35.6M 41.3M
|
|
20
|
-
//
|
|
21
|
-
// Fifteen was 28% over the cheapest width. One worker — which is what "the work
|
|
22
|
-
// fits in one context" would have said, and what the first draft of this rule
|
|
23
|
-
// enforced — is 85% over it, and would have run a 216-turn worker into a
|
|
24
|
-
// 150-turn fuse. The floor is as expensive a mistake as the ceiling.
|
|
25
|
-
//
|
|
26
|
-
// See ai/specs/37-3-fan-out-by-workload/after-2026-08-24.md in the brain repo.
|
|
27
|
-
// What the optimum is not a function of. Productive turns per 1,000 tokens of
|
|
28
|
-
// source were 7.8 on the behavioral branch and 1.4 on the structural one of the
|
|
29
|
-
// same run — 5.6× apart — and per assigned id, 8× apart. Bytes measure how much
|
|
30
|
-
// there is to read, which turns out not to be what a worker spends its turns on.
|
|
31
|
-
// They stay here for the printout and for the record in `fan_out`; they do not
|
|
32
|
-
// set the width.
|
|
33
|
-
const BYTES_PER_TOKEN = 4;
|
|
34
|
-
const WRITTEN_PER_READ = 3;
|
|
35
|
-
// What it is a function of. A planned case is one intent the worker has to turn
|
|
36
|
-
// into a written example or Scenario, and its cost in turns is a property of the
|
|
37
|
-
// branch, not of the project: a Gherkin Scenario needs the World, a session, a
|
|
38
|
-
// fixture and an assertion; a structural example calls a method.
|
|
39
|
-
//
|
|
40
|
-
// Measured 2026-08-24: 35 behavioral cases over 126 productive turns, 91
|
|
41
|
-
// structural cases over 65.
|
|
42
|
-
const TURNS_PER_CASE = { behavioral: 3.6, structural: 0.7 };
|
|
43
|
-
// The optimum width is the branch's productive turns over this. It comes out of
|
|
44
|
-
// setting the derivative of the cost above to zero, which gives
|
|
45
|
-
// `sqrt(2·warmup·preamble/added + warmup²)` — 31.3 on the behavioral branch of
|
|
46
|
-
// that run and 41.7 on the structural one, near enough to each other that one
|
|
47
|
-
// number carries both and the flat bottom of the curve absorbs the difference.
|
|
48
|
-
const TURNS_PER_WORKER = 36;
|
|
49
|
-
// What a worker spends before it writes anything — reading its packets, its plan
|
|
50
|
-
// item and its seeded facts. Measured 2026-08-24: 176 warm-up turns over eight
|
|
51
|
-
// behavioral workers, 202 over seven structural ones. It is per worker and does
|
|
52
|
-
// not divide, which is half of why width costs; it is added back here so that
|
|
53
|
-
// the turns this prints are the whole conversation, the thing that meets the
|
|
54
|
-
// 150-turn fuse.
|
|
55
|
-
const WARMUP_TURNS = { behavioral: 22, structural: 28 };
|
|
56
|
-
// Every case ends up at the same handful of widths, so the rule has to be a band
|
|
57
|
-
// rather than a number: anywhere from three to eight workers cost within 10% of
|
|
58
|
-
// the cheapest on the measured run. What the band excludes is what actually
|
|
59
|
-
// costs — fifteen at one end, one at the other.
|
|
60
|
-
const NARROWEST = 0.5;
|
|
61
|
-
const WIDEST = 1.5;
|
|
62
|
-
// How wide a branch should be, from the cases its plan intends to write.
|
|
63
|
-
// Returns nothing for a branch this connector has no measured cost for: a rule
|
|
64
|
-
// with no measurement behind it must not refuse anybody's plan.
|
|
65
|
-
export function branchWidth(branch, plannedCases) {
|
|
66
|
-
const perCase = TURNS_PER_CASE[branch];
|
|
67
|
-
if (perCase === undefined || plannedCases <= 0)
|
|
68
|
-
return undefined;
|
|
69
|
-
const turns = plannedCases * perCase;
|
|
70
|
-
const workers = Math.max(1, Math.round(turns / TURNS_PER_WORKER));
|
|
71
|
-
return {
|
|
72
|
-
branch,
|
|
73
|
-
planned_cases: plannedCases,
|
|
74
|
-
turns_each: Math.round(turns / workers) + (WARMUP_TURNS[branch] ?? 0),
|
|
75
|
-
workers,
|
|
76
|
-
fewest: Math.max(1, Math.round(workers * NARROWEST)),
|
|
77
|
-
most: Math.max(1, Math.ceil(workers * WIDEST)),
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
// What each branch's source weighs. Kept because it is the honest answer to "how
|
|
81
|
-
// much is there", printed before the plan exists and recorded in `fan_out` — but
|
|
82
|
-
// it is not what decides the width. See TURNS_PER_CASE above.
|
|
83
|
-
//
|
|
84
|
-
// `taken` narrows the count to the ids a plan actually took, which matters since
|
|
85
|
-
// criterion 2 let the structural branch be narrowed too: the packets are built
|
|
86
|
-
// from the whole assignment, before anybody chose a scope.
|
|
87
|
-
export function branchWorkloads(projectRoot, taken) {
|
|
88
|
-
const index = readPacketIndex(projectRoot);
|
|
89
|
-
if (!index || index.targets.length === 0)
|
|
90
|
-
return [];
|
|
91
|
-
const measured = new Map();
|
|
92
|
-
const unmeasured = new Map();
|
|
93
|
-
for (const target of index.targets) {
|
|
94
|
-
const ids = taken?.get(target.branch);
|
|
95
|
-
if (taken && !ids?.has(target.id))
|
|
96
|
-
continue;
|
|
97
|
-
const size = sizeOf(target);
|
|
98
|
-
// A file too large to copy still has a path and a size, and it is the
|
|
99
|
-
// heaviest reading on the branch — counting it as nothing would let the
|
|
100
|
-
// biggest sources look like the smallest.
|
|
101
|
-
const file = target.packet ?? target.source_file;
|
|
102
|
-
if (file !== undefined && size !== undefined) {
|
|
103
|
-
const files = measured.get(target.branch) ?? new Map();
|
|
104
|
-
files.set(file, size);
|
|
105
|
-
measured.set(target.branch, files);
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
unmeasured.set(target.branch, (unmeasured.get(target.branch) ?? 0) + 1);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const branches = new Set([...measured.keys(), ...unmeasured.keys()]);
|
|
112
|
-
return [...branches].sort().flatMap((branch) => {
|
|
113
|
-
const files = measured.get(branch);
|
|
114
|
-
if (!files || files.size === 0)
|
|
115
|
-
return [];
|
|
116
|
-
const bytes = [...files.values()].reduce((sum, size) => sum + size, 0);
|
|
117
|
-
const missing = unmeasured.get(branch) ?? 0;
|
|
118
|
-
const withMissing = bytes + Math.round((bytes / files.size) * missing);
|
|
119
|
-
const read_tokens = Math.round(withMissing / BYTES_PER_TOKEN);
|
|
120
|
-
return [{
|
|
121
|
-
branch, files: files.size, bytes, unmeasured: missing,
|
|
122
|
-
read_tokens, work_tokens: read_tokens * (1 + WRITTEN_PER_READ),
|
|
123
|
-
}];
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
// The index is a file on the vibecoder's disk, so a size that is not a real byte
|
|
127
|
-
// count is treated as a size we do not have rather than as zero. Zero would
|
|
128
|
-
// quietly shrink the branch's average.
|
|
129
|
-
function sizeOf(target) {
|
|
130
|
-
const { bytes } = target;
|
|
131
|
-
return typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
|
132
|
-
}
|
|
133
|
-
export function widthLine(width) {
|
|
134
|
-
return (` ${width.branch} — ${width.planned_cases} planned ` +
|
|
135
|
-
`${width.planned_cases === 1 ? 'case' : 'cases'}: ${width.workers} ` +
|
|
136
|
-
`${width.workers === 1 ? 'worker' : 'workers'} of about ${width.turns_each} turns each ` +
|
|
137
|
-
`(${width.fewest}–${width.most} accepted).\n`);
|
|
138
|
-
}
|
|
139
|
-
export function workloadLine(load) {
|
|
140
|
-
const missing = load.unmeasured === 0
|
|
141
|
-
? ''
|
|
142
|
-
: ` plus ${load.unmeasured} ${load.unmeasured === 1 ? 'entrypoint' : 'entrypoints'} nothing resolved, ` +
|
|
143
|
-
'priced at what the others average';
|
|
144
|
-
return (` ${load.branch} — ${load.files} ${load.files === 1 ? 'file' : 'files'}, ` +
|
|
145
|
-
`${load.bytes.toLocaleString('en-US')} bytes${missing}: ` +
|
|
146
|
-
`${load.read_tokens.toLocaleString('en-US')} tokens to read and about ` +
|
|
147
|
-
`${(load.work_tokens - load.read_tokens).toLocaleString('en-US')} to write.\n`);
|
|
148
|
-
}
|