unitbob 0.4.3 → 0.4.5
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/README.md +1 -1
- package/dist/cli.js +4 -2
- package/dist/files/suiteBuild.js +4 -3
- package/dist/files/workerPlan.js +13 -16
- package/dist/proc.js +15 -0
- package/dist/runner/failureDigest.js +238 -0
- package/dist/runner/worldProbe.js +10 -1
- package/dist/verbs/runLocal.js +50 -16
- package/dist/verbs/suitePrepare.js +7 -9
- package/dist/verbs/suiteReviewPrepare.js +0 -22
- package/dist/verbs/validateBuild.js +7 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +18 -6
- package/plugin/codex/agents/suite-worker.toml +31 -20
- package/dist/files/budget.js +0 -74
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ claude plugin install unitbob@unitbob
|
|
|
30
30
|
```
|
|
31
31
|
codex plugin marketplace add sergeygershun/unitbob-connector
|
|
32
32
|
codex plugin add unitbob@unitbob
|
|
33
|
-
npx -y unitbob@0.4.
|
|
33
|
+
npx -y unitbob@0.4.4 codex-install
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
Start a new Claude Code or Codex thread so the installed skill and named agents
|
package/dist/cli.js
CHANGED
|
@@ -133,8 +133,10 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
133
133
|
await contractPrompt(await linked(), args);
|
|
134
134
|
return 0;
|
|
135
135
|
case 'run-local':
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
// The one verb whose non-zero exit is not an error: a branch that failed
|
|
137
|
+
// the same set of cases twice in a row (spec 34-6, criterion 3). Red
|
|
138
|
+
// tests still exit zero — a live defect is the suite working.
|
|
139
|
+
return await runLocal(await linked(), args);
|
|
138
140
|
case 'run':
|
|
139
141
|
case 'check':
|
|
140
142
|
await run(await linked(), args);
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -2,7 +2,6 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname, join, sep } from 'node:path';
|
|
4
4
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
|
-
import { readBudget, RUN_BUDGET } from "./budget.js";
|
|
6
5
|
import { readWorkerPlan, validateWorkerPlanFiles, workerPlanDigest, workerPlanPath } from "./workerPlan.js";
|
|
7
6
|
export function requestPath(projectRoot) {
|
|
8
7
|
return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
|
|
@@ -232,7 +231,6 @@ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext
|
|
|
232
231
|
output_path: outputPath(projectRoot),
|
|
233
232
|
branches,
|
|
234
233
|
known_defect_context: knownDefectContext,
|
|
235
|
-
budget: RUN_BUDGET,
|
|
236
234
|
};
|
|
237
235
|
const path = requestPath(projectRoot);
|
|
238
236
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -251,10 +249,13 @@ export function readSuiteBuildRequest(projectRoot) {
|
|
|
251
249
|
!Array.isArray(request.branches)) {
|
|
252
250
|
throw new Error(`${path} is malformed: expected project_root, output_path, and a branches array.`);
|
|
253
251
|
}
|
|
252
|
+
// Spec 34-6, criterion 2.3. A request written by an older connector still
|
|
253
|
+
// carries a `budget` block; it is spread through untouched and read by nobody,
|
|
254
|
+
// which is the whole of the compatibility story — there is no ceiling left for
|
|
255
|
+
// it to name.
|
|
254
256
|
return {
|
|
255
257
|
...request,
|
|
256
258
|
known_defect_context: readKnownDefectContext(request.known_defect_context, path),
|
|
257
|
-
budget: readBudget(request.budget),
|
|
258
259
|
};
|
|
259
260
|
}
|
|
260
261
|
function readKnownDefectContext(value, path) {
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -54,8 +54,6 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
54
54
|
branch.suite_kind,
|
|
55
55
|
assignmentIds(branch.assignment),
|
|
56
56
|
]));
|
|
57
|
-
const budget = request.budget;
|
|
58
|
-
const workerCeiling = typeof budget?.workers === 'number' ? budget.workers : Number.POSITIVE_INFINITY;
|
|
59
57
|
const seenWorkers = new Set();
|
|
60
58
|
const seenPaths = new Map();
|
|
61
59
|
for (const [index, item] of plan.workers.entries()) {
|
|
@@ -97,9 +95,6 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
97
95
|
if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
|
|
98
96
|
errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
|
|
99
97
|
}
|
|
100
|
-
if (!Number.isInteger(item?.limits?.fact_finder_lookups) || item.limits.fact_finder_lookups < 0 || item.limits.fact_finder_lookups > 8) {
|
|
101
|
-
errors.push(`${label}: limits.fact_finder_lookups must be an integer from 0 to 8`);
|
|
102
|
-
}
|
|
103
98
|
for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
|
|
104
99
|
if (!isNonEmptyString(ownedPath)) {
|
|
105
100
|
errors.push(`${label}: owned path must be a non-empty string`);
|
|
@@ -118,27 +113,29 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
118
113
|
seenPaths.set(ownedPath, label);
|
|
119
114
|
}
|
|
120
115
|
}
|
|
116
|
+
// Spec 34-6, criterion 1.6. What is left here catches a corrupted plan, never
|
|
117
|
+
// a small one. The gate used to also demand that every assigned capability
|
|
118
|
+
// appear — which made the plan's size a copy of the assignment's size, and the
|
|
119
|
+
// assignment is the whole product map. That is the load one worker could not
|
|
120
|
+
// finish on a2time, 2026-08-10. Scope is chosen by the coordinator with the
|
|
121
|
+
// user (workflow step 3), and the plan is the only record of it, so a plan
|
|
122
|
+
// that covers part of the assignment is now an ordinary plan.
|
|
123
|
+
//
|
|
124
|
+
// The neighbours stay for the opposite reason: naming a capability the request
|
|
125
|
+
// never assigned, or naming one twice, are ways a plan is wrong rather than
|
|
126
|
+
// ways it is narrow. So is an empty plan for a branch that was given work.
|
|
121
127
|
for (const [branch, expected] of expectedByBranch) {
|
|
122
128
|
const items = plan.workers.filter((item) => item && typeof item === 'object' && !Array.isArray(item) && item.branch === branch);
|
|
123
129
|
if (expected.length > 0 && items.length === 0)
|
|
124
130
|
errors.push(`${branch}: no worker slice was planned`);
|
|
125
|
-
if (items.length > Math.min(workerCeiling, expected.length)) {
|
|
126
|
-
errors.push(`${branch}: worker count ${items.length} exceeds min(budget.workers, capability count)`);
|
|
127
|
-
}
|
|
128
131
|
const assigned = items.flatMap((item) => Array.isArray(item.capability_ids) ? item.capability_ids : []);
|
|
129
132
|
for (const id of expected) {
|
|
130
|
-
|
|
131
|
-
if (count === 0)
|
|
132
|
-
errors.push(`${branch}: assigned capability ${id} is missing from the plan`);
|
|
133
|
-
if (count > 1)
|
|
133
|
+
if (assigned.filter((candidate) => candidate === id).length > 1) {
|
|
134
134
|
errors.push(`${branch}: assigned capability ${id} appears more than once`);
|
|
135
|
+
}
|
|
135
136
|
}
|
|
136
137
|
for (const id of assigned.filter((id) => !expected.includes(id)))
|
|
137
138
|
errors.push(`${branch}: capability ${id} was not assigned by the request`);
|
|
138
|
-
const sizes = items.map((item) => Array.isArray(item.planned_cases) ? item.planned_cases.length : 0).filter((size) => size > 0);
|
|
139
|
-
if (sizes.length > 1 && Math.max(...sizes) / Math.min(...sizes) > 1.5) {
|
|
140
|
-
errors.push(`${branch}: planned case slice ratio exceeds 1.5`);
|
|
141
|
-
}
|
|
142
139
|
}
|
|
143
140
|
return errors;
|
|
144
141
|
}
|
package/dist/proc.js
CHANGED
|
@@ -115,6 +115,21 @@ export const GRAPH_NOISE_PATTERNS = [
|
|
|
115
115
|
// Type declarations — a contract for a compiler, with no runtime behaviour.
|
|
116
116
|
'*.d.ts',
|
|
117
117
|
'*.pyi',
|
|
118
|
+
// Spec 34-6, criterion 6. Unitbob's own session reports, left in the repo root
|
|
119
|
+
// by the operator, are read as source: on a2time, 2026-08-10, two of them
|
|
120
|
+
// contributed 44 and 25 nodes and pushed their community to third-largest in
|
|
121
|
+
// the whole project. The more often you run unitbob, the dirtier its map gets —
|
|
122
|
+
// a feedback loop with no floor.
|
|
123
|
+
'/unitbob*.md',
|
|
124
|
+
// Test scaffolding and boot wiring. Factories and model specs describe the
|
|
125
|
+
// fixtures a suite builds, not a business promise a guardrail could protect;
|
|
126
|
+
// `config/initializers/` and `config/deploy/` run once at boot and at deploy.
|
|
127
|
+
// The project's own request and feature specs stay — they are evidence of what
|
|
128
|
+
// the app promises.
|
|
129
|
+
'spec/factories/',
|
|
130
|
+
'spec/models/',
|
|
131
|
+
'config/deploy/',
|
|
132
|
+
'config/initializers/',
|
|
118
133
|
];
|
|
119
134
|
export function ensureUnitbobIgnored(projectRoot) {
|
|
120
135
|
// `.graphifyignore` is unitbob's own bookkeeping, like the other two entries —
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const RUN_STATE_FILE = 'run-state.json';
|
|
5
|
+
// A marker embedded in a reported test name or Gherkin tag. Same shape the
|
|
6
|
+
// server joins on (`CaseMarker::EMBEDDED`): a full 12-hex marker, never a prefix
|
|
7
|
+
// of a longer hex run. A case with no marker still counts as a failure — it is
|
|
8
|
+
// identified by its file and message alone.
|
|
9
|
+
const MARKER = /ubc_[0-9a-f]{12}(?![0-9a-f])/;
|
|
10
|
+
// The failures a report describes, canonically ordered, or `null` when the
|
|
11
|
+
// report cannot be read as one. `null` is not "green": a runner that died before
|
|
12
|
+
// the first test produces no set at all, and comparing against nothing would
|
|
13
|
+
// stop a branch over a harness problem the loop never even reached.
|
|
14
|
+
export function failureSet(runner, report) {
|
|
15
|
+
if (!report.trim())
|
|
16
|
+
return null;
|
|
17
|
+
const found = extract(runner, report);
|
|
18
|
+
return found && canonical(found);
|
|
19
|
+
}
|
|
20
|
+
// One hash for one set. Same set, same hash, on any machine and in any order.
|
|
21
|
+
export function digestOf(failures) {
|
|
22
|
+
return createHash('sha256').update(JSON.stringify(failures)).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
export function runStatePath(projectRoot) {
|
|
25
|
+
return join(projectRoot, '.unitbob', 'suite-build', RUN_STATE_FILE);
|
|
26
|
+
}
|
|
27
|
+
// A damaged, hand-edited or absent file reads as "no previous run". Refusing to
|
|
28
|
+
// run over a bookkeeping file would turn the one soft stop in the loop into the
|
|
29
|
+
// hardest thing in it — and this file is written by a process that repair
|
|
30
|
+
// deliberately kills and restarts.
|
|
31
|
+
export function readRunState(projectRoot) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(runStatePath(projectRoot), 'utf8'));
|
|
34
|
+
const branches = parsed?.branches;
|
|
35
|
+
if (!branches || typeof branches !== 'object' || Array.isArray(branches))
|
|
36
|
+
return {};
|
|
37
|
+
return Object.fromEntries(Object.entries(branches).filter(([, value]) => typeof value === 'string'));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// `undefined` forgets this branch: a run that produced no comparable set leaves
|
|
44
|
+
// the next one to be a first run again.
|
|
45
|
+
export function rememberFailures(projectRoot, branch, digest) {
|
|
46
|
+
const branches = readRunState(projectRoot);
|
|
47
|
+
if (digest === undefined)
|
|
48
|
+
delete branches[branch];
|
|
49
|
+
else
|
|
50
|
+
branches[branch] = digest;
|
|
51
|
+
const path = runStatePath(projectRoot);
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
// Written whole and moved into place. A plain write that is interrupted leaves
|
|
54
|
+
// truncated JSON, which reads back as "no previous run" — exactly at the
|
|
55
|
+
// moment a run is being killed and restarted, which is the loop this bounds.
|
|
56
|
+
const staging = `${path}.tmp`;
|
|
57
|
+
writeFileSync(staging, `${JSON.stringify({ branches }, null, 2)}\n`);
|
|
58
|
+
renameSync(staging, path);
|
|
59
|
+
}
|
|
60
|
+
export function clearRunState(projectRoot) {
|
|
61
|
+
rmSync(runStatePath(projectRoot), { force: true });
|
|
62
|
+
}
|
|
63
|
+
function canonical(failures) {
|
|
64
|
+
const byKey = new Map(failures.map((failure) => [keyOf(failure), failure]));
|
|
65
|
+
return [...byKey.keys()].sort().map((key) => byKey.get(key));
|
|
66
|
+
}
|
|
67
|
+
function keyOf(failure) {
|
|
68
|
+
return `${failure.marker}\u0000${failure.file}\u0000${failure.message}`;
|
|
69
|
+
}
|
|
70
|
+
// The connector interprets a report in exactly one other place — `boundReport`,
|
|
71
|
+
// which bounds it for transport — and this switch deliberately mirrors that
|
|
72
|
+
// one's shape rather than inventing a second dispatch. Neither of them judges a
|
|
73
|
+
// run — the server owns every verdict a report leads to. This one only asks "is
|
|
74
|
+
// this the same wall we hit last time", and its answer reaches nothing but an
|
|
75
|
+
// exit code.
|
|
76
|
+
function extract(runner, report) {
|
|
77
|
+
switch (runner) {
|
|
78
|
+
case 'rspec':
|
|
79
|
+
return fromRspec(report);
|
|
80
|
+
case 'vitest':
|
|
81
|
+
return fromVitest(report);
|
|
82
|
+
case 'pytest':
|
|
83
|
+
return fromJunitXml(report);
|
|
84
|
+
case 'cucumber':
|
|
85
|
+
case 'cucumber-js':
|
|
86
|
+
return fromCucumberMessages(report);
|
|
87
|
+
case 'pytest-bdd':
|
|
88
|
+
return fromPytestBdd(report);
|
|
89
|
+
default:
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function fromRspec(report) {
|
|
94
|
+
const data = parseObject(report);
|
|
95
|
+
if (!Array.isArray(data?.examples))
|
|
96
|
+
return null;
|
|
97
|
+
return rows(data.examples).flatMap((example) => {
|
|
98
|
+
if (example.status === 'passed')
|
|
99
|
+
return [];
|
|
100
|
+
const name = `${text(example.description)} ${text(example.full_description)}`;
|
|
101
|
+
const exception = example.exception;
|
|
102
|
+
return [failure(name, text(example.file_path), text(exception?.message))];
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function fromVitest(report) {
|
|
106
|
+
const data = parseObject(report);
|
|
107
|
+
if (!Array.isArray(data?.testResults))
|
|
108
|
+
return null;
|
|
109
|
+
return rows(data.testResults).flatMap((file) => {
|
|
110
|
+
const assertions = Array.isArray(file.assertionResults) ? rows(file.assertionResults) : [];
|
|
111
|
+
return assertions.flatMap((assertion) => {
|
|
112
|
+
if (assertion.status === 'passed')
|
|
113
|
+
return [];
|
|
114
|
+
const messages = Array.isArray(assertion.failureMessages) ? assertion.failureMessages : [];
|
|
115
|
+
const name = `${text(assertion.title)} ${text(assertion.fullName)}`;
|
|
116
|
+
return [failure(name, text(file.name), messages.map((m) => text(m)).join('\n'))];
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// pytest's JUnit XML, read the way `boundReport` already reads it: by pattern,
|
|
121
|
+
// because a whole XML parser to answer one yes/no question is a dependency this
|
|
122
|
+
// package does not need.
|
|
123
|
+
//
|
|
124
|
+
// `failure` and `error`, but not `skipped` — and this is the one place the set
|
|
125
|
+
// deliberately does not match the server's "did not pass". A skip never moves:
|
|
126
|
+
// it reports the same thing on every run forever, so counting it here would stop
|
|
127
|
+
// a branch whose only permanent case is a skip, and it would do it while the
|
|
128
|
+
// repair was still fixing everything else. A skip that should not be there is
|
|
129
|
+
// caught at upload, where it costs a message rather than a branch.
|
|
130
|
+
function fromJunitXml(report) {
|
|
131
|
+
if (!/<testsuites?\b/.test(report))
|
|
132
|
+
return null;
|
|
133
|
+
const cases = report.match(/<testcase\b[^>]*(?:\/>|>[\s\S]*?<\/testcase>)/g) ?? [];
|
|
134
|
+
return cases.flatMap((testcase) => {
|
|
135
|
+
const problem = testcase.match(/<(?:failure|error)\b[^>]*(?:\/>|>[\s\S]*?<\/(?:failure|error)>)/);
|
|
136
|
+
if (!problem)
|
|
137
|
+
return [];
|
|
138
|
+
const name = attribute(testcase, 'name');
|
|
139
|
+
const file = attribute(testcase, 'file') || attribute(testcase, 'classname');
|
|
140
|
+
return [failure(name, file, attribute(problem[0], 'message'))];
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// Cucumber Messages (NDJSON), both the Ruby and the JS emitter. One scenario is
|
|
144
|
+
// spread over several envelopes: the pickle holds its name, tags and file, the
|
|
145
|
+
// testCase maps its steps, and testStepFinished carries each step's result.
|
|
146
|
+
function fromCucumberMessages(report) {
|
|
147
|
+
const envelopes = [];
|
|
148
|
+
for (const line of report.split('\n')) {
|
|
149
|
+
if (!line.trim())
|
|
150
|
+
continue;
|
|
151
|
+
const parsed = parseObject(line);
|
|
152
|
+
if (!parsed)
|
|
153
|
+
return null;
|
|
154
|
+
envelopes.push(parsed);
|
|
155
|
+
}
|
|
156
|
+
if (envelopes.length === 0)
|
|
157
|
+
return null;
|
|
158
|
+
const pickles = indexBy(envelopes, 'pickle');
|
|
159
|
+
const testCases = indexBy(envelopes, 'testCase');
|
|
160
|
+
const results = new Map();
|
|
161
|
+
for (const envelope of envelopes) {
|
|
162
|
+
const finished = envelope.testStepFinished;
|
|
163
|
+
if (!finished)
|
|
164
|
+
continue;
|
|
165
|
+
const startedId = text(finished.testCaseStartedId);
|
|
166
|
+
const list = results.get(startedId) ?? [];
|
|
167
|
+
list.push(finished.testStepResult ?? {});
|
|
168
|
+
results.set(startedId, list);
|
|
169
|
+
}
|
|
170
|
+
return envelopes.flatMap((envelope) => {
|
|
171
|
+
const started = envelope.testCaseStarted;
|
|
172
|
+
if (!started)
|
|
173
|
+
return [];
|
|
174
|
+
const testCase = testCases.get(text(started.testCaseId)) ?? {};
|
|
175
|
+
const pickle = pickles.get(text(testCase.pickleId)) ?? {};
|
|
176
|
+
const steps = results.get(text(started.id)) ?? [];
|
|
177
|
+
const failed = steps.filter((step) => text(step.status) !== 'PASSED' && text(step.status) !== 'SKIPPED');
|
|
178
|
+
if (failed.length === 0)
|
|
179
|
+
return [];
|
|
180
|
+
const tags = Array.isArray(pickle.tags) ? pickle.tags : [];
|
|
181
|
+
const tagText = rows(tags).map((tag) => text(tag.name)).join(' ');
|
|
182
|
+
const message = failed.map((step) => text(step.message)).find((line) => line.trim()) ?? '';
|
|
183
|
+
return [failure(`${tagText} ${text(pickle.name)}`, text(pickle.uri), message)];
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// The connector's own pytest-bdd report (`runner/pytestBddPlugin.ts`). It names
|
|
187
|
+
// no file — the whole behavioral bundle is one run — so the scenario's marker
|
|
188
|
+
// and message carry the identity alone.
|
|
189
|
+
function fromPytestBdd(report) {
|
|
190
|
+
const data = parseObject(report);
|
|
191
|
+
if (!Array.isArray(data?.scenarios))
|
|
192
|
+
return null;
|
|
193
|
+
return rows(data.scenarios).flatMap((scenario) => {
|
|
194
|
+
if (text(scenario.status) === 'passed')
|
|
195
|
+
return [];
|
|
196
|
+
const tags = Array.isArray(scenario.tags) ? scenario.tags.map((tag) => text(tag)).join(' ') : '';
|
|
197
|
+
return [failure(`${tags} ${text(scenario.name)}`, '', text(scenario.failure))];
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// Only the first line of a message. Later lines are backtraces and diffs, which
|
|
201
|
+
// carry object ids and absolute paths that differ between two runs of the same
|
|
202
|
+
// unchanged failure — the very drift that would make this comparison useless.
|
|
203
|
+
function failure(name, file, message) {
|
|
204
|
+
return {
|
|
205
|
+
marker: name.match(MARKER)?.[0] ?? '',
|
|
206
|
+
file,
|
|
207
|
+
message: message.split('\n')[0]?.trim() ?? '',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function indexBy(envelopes, key) {
|
|
211
|
+
const found = new Map();
|
|
212
|
+
for (const envelope of envelopes) {
|
|
213
|
+
const item = envelope[key];
|
|
214
|
+
if (item)
|
|
215
|
+
found.set(text(item.id), item);
|
|
216
|
+
}
|
|
217
|
+
return found;
|
|
218
|
+
}
|
|
219
|
+
function attribute(tag, name) {
|
|
220
|
+
return tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] ?? '';
|
|
221
|
+
}
|
|
222
|
+
function rows(values) {
|
|
223
|
+
return values.filter((value) => value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
224
|
+
}
|
|
225
|
+
function text(value) {
|
|
226
|
+
return typeof value === 'string' ? value : '';
|
|
227
|
+
}
|
|
228
|
+
function parseObject(source) {
|
|
229
|
+
try {
|
|
230
|
+
const parsed = JSON.parse(source);
|
|
231
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
232
|
+
? parsed
|
|
233
|
+
: null;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -28,13 +28,14 @@ export async function probeBehavioralWorld(projectRoot, deps = {
|
|
|
28
28
|
env: {
|
|
29
29
|
...process.env,
|
|
30
30
|
RAILS_ENV: 'test',
|
|
31
|
+
CUCUMBER_PUBLISH_QUIET: 'true',
|
|
31
32
|
UNITBOB_REPO_ROOT: projectRoot,
|
|
32
33
|
BUNDLE_GEMFILE: join(projectRoot, '.unitbob', 'behavioral', 'Gemfile'),
|
|
33
34
|
},
|
|
34
35
|
});
|
|
35
36
|
if (result.code === 0)
|
|
36
37
|
return { status: 'ok' };
|
|
37
|
-
const detail = [result.
|
|
38
|
+
const detail = [result.stdout, result.stderr].map((text) => text.trim()).filter(Boolean).join('\n') || `exit ${result.code}`;
|
|
38
39
|
return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe failed: ${detail}` };
|
|
39
40
|
}
|
|
40
41
|
catch (error) {
|
|
@@ -58,6 +59,14 @@ PROBE_OTHER_TIME_ZONE = PROBE_TIME_ZONE&.name == 'UTC' ? 'Hawaii' : 'UTC'
|
|
|
58
59
|
PROBE_LOCALE = I18n.locale
|
|
59
60
|
PROBE_RECEIVER = Object.new
|
|
60
61
|
|
|
62
|
+
# The probe runs in a dedicated process, so replacing its in-memory route set
|
|
63
|
+
# cannot affect the host application after the process exits. Keeping these
|
|
64
|
+
# endpoints here makes the profile check independent of routes the host owns.
|
|
65
|
+
Rails.application.routes.draw do
|
|
66
|
+
match '/__unitbob_world_probe__', to: proc { [200, { 'Content-Type' => 'text/plain' }, ['ok']] }, via: :all
|
|
67
|
+
match '/__unitbob_world_probe_redirect__', to: redirect('/__unitbob_world_probe_target__'), via: :all
|
|
68
|
+
end
|
|
69
|
+
|
|
61
70
|
Given('the first World probe scenario mutates supported state') do
|
|
62
71
|
@unitbob_connection.execute("INSERT INTO schema_migrations (version) VALUES ('#{PROBE_VERSION}')")
|
|
63
72
|
Time.zone = PROBE_OTHER_TIME_ZONE
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
-
import {
|
|
2
|
+
import { digestOf, failureSet, readRunState, rememberFailures } from "../runner/failureDigest.js";
|
|
3
3
|
import { validateStack } from "../runner/precheck.js";
|
|
4
4
|
import { runBddSuite } from "../runner/bdd.js";
|
|
5
5
|
import { runStructuralByRunner } from "./run.js";
|
|
@@ -15,6 +15,8 @@ export async function runLocal(config, args = [], deps) {
|
|
|
15
15
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
16
16
|
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
17
17
|
const wanted = selectBranches(request, args);
|
|
18
|
+
const previous = readRunState(config.projectRoot);
|
|
19
|
+
let stuck = false;
|
|
18
20
|
for (const suiteKind of wanted) {
|
|
19
21
|
d.stdout.write(`\n── ${suiteKind} ──\n`);
|
|
20
22
|
// An entry that exists but will not parse is a different problem from an
|
|
@@ -27,16 +29,47 @@ export async function runLocal(config, args = [], deps) {
|
|
|
27
29
|
continue;
|
|
28
30
|
}
|
|
29
31
|
const ran = await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// examined the suite.
|
|
32
|
+
// A branch with no entry written yet, or one the stack cannot execute,
|
|
33
|
+
// produced nothing to compare: it is the ordinary state halfway through a
|
|
34
|
+
// build, not a repair loop going nowhere.
|
|
34
35
|
if (!ran)
|
|
35
36
|
continue;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
spend(config.projectRoot, `run-local:${suiteKind}`);
|
|
37
|
+
if (compareFailures(config, d, suiteKind, ran, previous[suiteKind]))
|
|
38
|
+
stuck = true;
|
|
39
39
|
}
|
|
40
|
+
return stuck ? 1 : 0;
|
|
41
|
+
}
|
|
42
|
+
// Spec 34-6, criterion 3. The whole stop condition, and it stops the branch
|
|
43
|
+
// rather than the worker: the set of failures belongs to the branch, and a
|
|
44
|
+
// repair worker looking only at its own slice cannot see that the branch as a
|
|
45
|
+
// whole has stopped moving.
|
|
46
|
+
//
|
|
47
|
+
// Returns true when this branch is the one that has stopped moving.
|
|
48
|
+
function compareFailures(config, d, suiteKind, ran, before) {
|
|
49
|
+
const failures = failureSet(ran.runner, ran.result.report);
|
|
50
|
+
// No comparable set: the run produced no readable report, which is a harness
|
|
51
|
+
// problem the loop never reached. Forget the branch so the next run that does
|
|
52
|
+
// produce one is a first run again, rather than a match against a set from
|
|
53
|
+
// before the harness broke.
|
|
54
|
+
if (!failures) {
|
|
55
|
+
rememberFailures(config.projectRoot, suiteKind, undefined);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// Green. Nothing to be stuck on, and remembering an empty set would stop a
|
|
59
|
+
// branch that passes twice in a row.
|
|
60
|
+
if (failures.length === 0) {
|
|
61
|
+
rememberFailures(config.projectRoot, suiteKind, undefined);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const digest = digestOf(failures);
|
|
65
|
+
rememberFailures(config.projectRoot, suiteKind, digest);
|
|
66
|
+
if (digest !== before)
|
|
67
|
+
return false;
|
|
68
|
+
d.stdout.write(`\nStopping ${suiteKind}: it just failed the same ${failures.length} case(s) as the previous run, ` +
|
|
69
|
+
'down to the first line of every message. The edits since then changed nothing this run can see.\n' +
|
|
70
|
+
'Look at the failures yourself, replan the slice, or record the branch as a build_error. ' +
|
|
71
|
+
'Running it again unchanged prints this same line.\n');
|
|
72
|
+
return true;
|
|
40
73
|
}
|
|
41
74
|
// Which branches to run. No argument runs every branch the request asked for —
|
|
42
75
|
// the same "one suite, one run" shape both recipes insist on, so the default
|
|
@@ -53,19 +86,20 @@ function selectBranches(request, args) {
|
|
|
53
86
|
}
|
|
54
87
|
return named;
|
|
55
88
|
}
|
|
56
|
-
//
|
|
57
|
-
//
|
|
89
|
+
// Non-null when the runner actually executed the branch. Everything else — no
|
|
90
|
+
// entry, a declared `build_error`, a stack that cannot run it — is a branch that
|
|
91
|
+
// produced no result to compare against.
|
|
58
92
|
async function runOneBranch(config, d, suiteKind, output) {
|
|
59
93
|
// Nothing written for this branch yet. That is the ordinary state halfway
|
|
60
94
|
// through a build, not an error — say what is missing and move to the peer.
|
|
61
95
|
if (!output) {
|
|
62
96
|
d.stdout.write(`Nothing to run: your answer has no entry for this branch yet. Write its suite under ` +
|
|
63
97
|
`${branchRoot(config, suiteKind)} and add its entry to the answer, then run this again.\n`);
|
|
64
|
-
return
|
|
98
|
+
return null;
|
|
65
99
|
}
|
|
66
100
|
if (output.build_error) {
|
|
67
101
|
d.stdout.write(`Not built, by your own answer: ${output.build_error.message}\n`);
|
|
68
|
-
return
|
|
102
|
+
return null;
|
|
69
103
|
}
|
|
70
104
|
let runner;
|
|
71
105
|
let suitePath;
|
|
@@ -75,12 +109,12 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
75
109
|
}
|
|
76
110
|
catch (err) {
|
|
77
111
|
d.stdout.write(`Cannot run this branch: ${err.message}\n`);
|
|
78
|
-
return
|
|
112
|
+
return null;
|
|
79
113
|
}
|
|
80
114
|
const check = d.validateStack(config.projectRoot, runner);
|
|
81
115
|
if (!check.ok) {
|
|
82
116
|
d.stdout.write(`Cannot run this branch: ${check.message ?? `this project does not match "${runner}".`}\n`);
|
|
83
|
-
return
|
|
117
|
+
return null;
|
|
84
118
|
}
|
|
85
119
|
let result;
|
|
86
120
|
try {
|
|
@@ -91,10 +125,10 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
91
125
|
}
|
|
92
126
|
catch (err) {
|
|
93
127
|
d.stdout.write(`The runner could not start: ${err.message}\n`);
|
|
94
|
-
return
|
|
128
|
+
return null;
|
|
95
129
|
}
|
|
96
130
|
d.stdout.write(report(result));
|
|
97
|
-
return
|
|
131
|
+
return { runner, result };
|
|
98
132
|
}
|
|
99
133
|
// The command first, and always — including on a green run. It is the answer to
|
|
100
134
|
// "how do I run just this one file again", which is the question the whole
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clearRunState } from "../runner/failureDigest.js";
|
|
2
2
|
import { materializeHelper } from "../files/guardrails.js";
|
|
3
3
|
import { materializeBehavioralWorld } from "../files/behavioral.js";
|
|
4
4
|
import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
@@ -153,13 +153,11 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
153
153
|
throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
|
|
154
154
|
}
|
|
155
155
|
const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
|
|
156
|
-
// A new request is a new build, and a new build
|
|
157
|
-
// (spec 34-
|
|
158
|
-
// step of the loop
|
|
159
|
-
// a
|
|
160
|
-
|
|
161
|
-
actual.stdout.write('Starting a fresh build: the review and run counts from the previous one are cleared.\n');
|
|
162
|
-
}
|
|
156
|
+
// A new request is a new build, and a new build has no previous run to be
|
|
157
|
+
// stuck against (spec 34-6, criterion 3). Re-running this verb is a documented
|
|
158
|
+
// step of the loop, so a failure set remembered from the build before it would
|
|
159
|
+
// stop a branch that has not run once yet.
|
|
160
|
+
clearRunState(config.projectRoot);
|
|
163
161
|
const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
|
|
164
162
|
const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
|
|
165
163
|
? '`unitbob suite-review-prepare` before upload'
|
|
@@ -173,7 +171,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
173
171
|
// A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
|
|
174
172
|
// vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
|
|
175
173
|
if (fixableNotices.length > 0) {
|
|
176
|
-
actual.stdout.write('\nBehavioral suite skipped this run — its
|
|
174
|
+
actual.stdout.write('\nBehavioral suite skipped this run — its runner or connector-owned World profile is not ready. ' +
|
|
177
175
|
'This is a fixable setup step, not a build failure, and it does not affect the structural suite:\n' +
|
|
178
176
|
fixableNotices.join('\n') +
|
|
179
177
|
'\nFix the above, then re-run `unitbob suite-prepare` to build the behavioral peer.\n');
|
|
@@ -6,7 +6,6 @@ import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBeh
|
|
|
6
6
|
import { runBddSuite } from "../runner/bdd.js";
|
|
7
7
|
import { boundReport } from "../runner/boundReport.js";
|
|
8
8
|
import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
|
|
9
|
-
import { spend } from "../files/budget.js";
|
|
10
9
|
export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
11
10
|
const actual = {
|
|
12
11
|
runCandidate: runCandidate,
|
|
@@ -38,27 +37,6 @@ export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
|
38
37
|
const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
|
|
39
38
|
actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
|
|
40
39
|
actual.stdout.write(`Next: have an independent reviewer inspect this exact candidate and write bdd_quality_review and known_defect_probe to ${request.output_path}, then run \`unitbob put-suite-build\`.\n`);
|
|
41
|
-
// Counted after the request is written, never before it. Refusing the round
|
|
42
|
-
// would rebuild the autobrella deadlock on a different number: the branch
|
|
43
|
-
// could not publish, so the work would be lost again for the sake of the
|
|
44
|
-
// ceiling meant to protect it. What the ceiling buys is a sentence.
|
|
45
|
-
const round = spend(config.projectRoot, 'review_rounds');
|
|
46
|
-
const budget = buildRequest.budget;
|
|
47
|
-
if (budget && round > budget.review_rounds) {
|
|
48
|
-
actual.stdout.write(lastRoundNotice(round, budget.review_rounds));
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
// Says what to do, not that something is forbidden. This is only sayable because
|
|
52
|
-
// spec 34-2 already made an imperfect suite publishable: before it, "publish what
|
|
53
|
-
// you have" was not available to the reviewer at all, and the only way to record
|
|
54
|
-
// a bad Scenario was to take the branch down.
|
|
55
|
-
function lastRoundNotice(round, allowed) {
|
|
56
|
-
return (`\nThis is review round ${round}; the budget for this build was ${allowed}. Treat it as the last round ` +
|
|
57
|
-
'and publish what you have.\n' +
|
|
58
|
-
'A Scenario the reviewer still objects to does not hold the branch back: it is recorded with ' +
|
|
59
|
-
'`verdict: "does_not_pass"` and a `reviewer_objection_text` saying what is wrong, the branch publishes, ' +
|
|
60
|
-
'and the objection is read afterwards by the operator. Another round of rewriting buys less than ' +
|
|
61
|
-
'publishing the objection does.\n');
|
|
62
40
|
}
|
|
63
41
|
async function runCandidate(projectRoot, output, revision) {
|
|
64
42
|
if (revision)
|
|
@@ -227,10 +227,16 @@ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
|
|
|
227
227
|
// Spec 34-3, criterion 6. Cheap here and expensive later: over the ceiling is
|
|
228
228
|
// one of the answers the server rejects, and finding it after the suite has
|
|
229
229
|
// been written, run and reviewed costs the whole cycle.
|
|
230
|
+
//
|
|
231
|
+
// Spec 34-6, criterion 5: it names how many surfaces have to move, because
|
|
232
|
+
// that number is the edit, and it lands in the same batch as every other
|
|
233
|
+
// capability over the ceiling. On a2time, 2026-08-09 the server's version of
|
|
234
|
+
// this complaint arrived one capability at a time and cost five
|
|
235
|
+
// `validate-build` rounds for one kind of mistake.
|
|
230
236
|
const budget = expected.surfaceBudget;
|
|
231
237
|
if (budget !== undefined && reached.size > budget) {
|
|
232
238
|
add(`${id} guards ${reached.size} surfaces, over the surface_budget of ${budget}` +
|
|
233
|
-
|
|
239
|
+
` — move ${reached.size - budget} of them into deferred_surfaces and keep the most important ones guarded.`);
|
|
234
240
|
}
|
|
235
241
|
const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface) && !deferred.has(surface));
|
|
236
242
|
if (missed.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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": {
|
|
@@ -7,6 +7,11 @@ You receive one failure packet: one validated plan item, its checkpoint, branch,
|
|
|
7
7
|
owned paths and case markers, and only related failures or stack traces. This is
|
|
8
8
|
your complete write scope.
|
|
9
9
|
|
|
10
|
+
The checkpoint's `facts` come to you established, by the coordinator and by the
|
|
11
|
+
worker before you. Inherit them; do not go and find them out again. A fact
|
|
12
|
+
carries source references, so you can check one on the spot when a failure makes
|
|
13
|
+
you doubt it — that is a targeted re-check, not a fresh survey.
|
|
14
|
+
|
|
10
15
|
Complete `unresolved_promises` first while preserving every completed file and
|
|
11
16
|
decision. You may read the plan item's source paths and, only as needed for the
|
|
12
17
|
owned diagnosis, stack-referenced project source, the runner setup and harness
|
|
@@ -16,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
16
21
|
connector-owned harness, or another slice.
|
|
17
22
|
|
|
18
23
|
After every owned edit, run
|
|
19
|
-
`npx -y --loglevel=error unitbob@0.4.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.4.5 run-local <branch>` and inspect the machine
|
|
20
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
21
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
22
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|
|
@@ -24,14 +29,21 @@ and an already-confirmed product red do not widen your scope. Repeat the bounded
|
|
|
24
29
|
as a product defect. Do not run the project's suite directly, boot a dev server,
|
|
25
30
|
or invoke an arbitrary runner command.
|
|
26
31
|
|
|
32
|
+
`run-local` exits non-zero when the branch comes back with exactly the failures
|
|
33
|
+
it came back with last time. That is not your slice being red — it is the branch
|
|
34
|
+
as a whole having stopped moving. Stop the loop, hand the packet back with what
|
|
35
|
+
you have, and say so; do not run it again unchanged.
|
|
36
|
+
|
|
27
37
|
A product-defect diagnosis must briefly name the violated business contract, the
|
|
28
38
|
reason, and production source references. Never delete a planned case, marker,
|
|
29
39
|
capability binding, or assertion; never add `skip`, `pending`, `todo`, or weaken a
|
|
30
40
|
business promise for green. You may correct a generated expectation only when
|
|
31
41
|
the business promise remains intact and source confirms the correction. If the
|
|
32
42
|
harness is still wrong, continue the loop. If the outcome is ambiguous, the
|
|
33
|
-
runner is unusable, or the
|
|
34
|
-
branch `build_error`, never a product red.
|
|
43
|
+
runner is unusable, or the emergency fuse stops unfinished work, leave an honest
|
|
44
|
+
branch `build_error`, never a product red. That fuse sits far above the work one
|
|
45
|
+
packet takes: reaching it means the run is broken, not that the packet was big.
|
|
46
|
+
No strict JSON handoff is required.
|
|
35
47
|
|
|
36
48
|
Update the same checkpoint as promises complete. Keep facts compact and
|
|
37
49
|
source-referenced. The normative JSON shape of one facts entry is:
|
|
@@ -41,11 +53,11 @@ source-referenced. The normative JSON shape of one facts entry is:
|
|
|
41
53
|
Every facts entry is an object in that shape, never a string. Before handoff,
|
|
42
54
|
make one final read of the checkpoint and confirm every `facts` entry is an
|
|
43
55
|
object in the normative shape above. Do not delegate repair or auto-resume after
|
|
44
|
-
the
|
|
56
|
+
the fuse. Preserve files and checkpoint for the coordinator's existing
|
|
45
57
|
`Continue once / Stop` choice; record unfinished work in `unresolved_promises`.
|
|
46
58
|
'''
|
|
47
59
|
|
|
48
60
|
[features.rollout_budget]
|
|
49
61
|
enabled = true
|
|
50
|
-
limit_tokens =
|
|
51
|
-
reminder_at_remaining_tokens = [
|
|
62
|
+
limit_tokens = 100000
|
|
63
|
+
reminder_at_remaining_tokens = [8000]
|
|
@@ -7,27 +7,34 @@ You receive exactly one worker-plan item and the request paths it references.
|
|
|
7
7
|
That item is your complete scope. Do not add capabilities, promises, examples,
|
|
8
8
|
or scenarios after fan-out.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
Your checkpoint already exists: the coordinator wrote it before fan-out, at the
|
|
11
|
+
path the workflow prescribes, with the exact request and plan digests, your
|
|
12
|
+
branch and worker id, your promises in `unresolved_promises`, and the facts it
|
|
13
|
+
had already verified. Never initialize it again — not on the first incarnation,
|
|
14
|
+
and not on an explicitly approved fresh incarnation after a native budget stop,
|
|
15
|
+
where you preserve the supplied checkpoint and completed files and continue only
|
|
16
|
+
its `unresolved_promises`. Update it after every completed promise. Also keep
|
|
17
|
+
`known_problems` as a compact array of precise unresolved harness problems
|
|
18
|
+
(empty when none are known). Facts are short statements with source references.
|
|
19
|
+
The normative JSON shape of one facts entry is:
|
|
20
20
|
```json
|
|
21
21
|
{"fact":"The route creates an order.","source_refs":["app/orders.rb:12"]}
|
|
22
22
|
```
|
|
23
23
|
Every facts entry is an object in that shape, never a string. Never embed source
|
|
24
24
|
files, suite copies, or transcript.
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
Write first, then find out. Start with the planned cases your seeded facts
|
|
27
|
+
already support and get them onto disk; go reading only for what you still lack
|
|
28
|
+
after that. The opposite order — survey the sources, then write — is what spent
|
|
29
|
+
seven of eight workers' entire ceilings on a2time, 2026-08-10, and produced no
|
|
30
|
+
file at all. A fact already in your checkpoint is settled: do not establish it a
|
|
31
|
+
second time. Nothing mechanical enforces that rule; it holds because you keep
|
|
32
|
+
it.
|
|
33
|
+
|
|
34
|
+
Read only the `source_paths` and dependencies your finite planned cases need.
|
|
35
|
+
Ask closed questions with the files to look in. For a closed missing fact, use
|
|
36
|
+
the named `fact-finder` agent, as often as the work genuinely needs. A lookup
|
|
37
|
+
may confirm implementation facts but may not expand the plan.
|
|
31
38
|
|
|
32
39
|
Write only the plan item's `owned_paths` and its checkpoint. Never edit the
|
|
33
40
|
connector-owned harness, another worker's file, the user's own tests, manifests,
|
|
@@ -39,12 +46,16 @@ marker, metadata, or surface validation. You may make one final read of your
|
|
|
39
46
|
owned files before handoff. During that final read, confirm every `facts` entry
|
|
40
47
|
is an object in the normative shape above and correct the checkpoint if it is
|
|
41
48
|
not. Do not create temporary self-validation scripts or
|
|
42
|
-
loop over repeated rereads.
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
loop over repeated rereads.
|
|
50
|
+
|
|
51
|
+
Your ceiling is an emergency fuse, not a budget to spend. It sits far above the
|
|
52
|
+
work one plan item takes, so reaching it means this run is broken rather than
|
|
53
|
+
large. If it arrives, leave partial files and an accurate checkpoint; the
|
|
54
|
+
coordinator rotates unresolved work into one fresh repair task and reports the
|
|
55
|
+
fuse as a fault of the run, never as an outcome.
|
|
45
56
|
'''
|
|
46
57
|
|
|
47
58
|
[features.rollout_budget]
|
|
48
59
|
enabled = true
|
|
49
|
-
limit_tokens =
|
|
50
|
-
reminder_at_remaining_tokens = [
|
|
60
|
+
limit_tokens = 100000
|
|
61
|
+
reminder_at_remaining_tokens = [20000, 10000]
|
package/dist/files/budget.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
export const RUN_BUDGET = { workers: 4, review_rounds: 2, repair_rounds: 8 };
|
|
4
|
-
// Only two of the three have a counter behind them. The connector cannot see
|
|
5
|
-
// the host launch a subagent, so `workers` is a number it states and cannot
|
|
6
|
-
// check. It travels in the same block anyway, and the recipe is told not to sort
|
|
7
|
-
// the fields into checked and unchecked: an agent that knows which half is
|
|
8
|
-
// watched has been handed a reason to treat the other half as advice.
|
|
9
|
-
const SPENT_FILE = 'budget-spent.json';
|
|
10
|
-
function spentPath(projectRoot) {
|
|
11
|
-
return join(projectRoot, '.unitbob', 'suite-build', SPENT_FILE);
|
|
12
|
-
}
|
|
13
|
-
// On disk, not in memory, because the loop these bound spans separate processes:
|
|
14
|
-
// every `run-local` and every `suite-review-prepare` is a fresh `npx`. A count
|
|
15
|
-
// held in a running command would reset on each one and bound nothing.
|
|
16
|
-
export function spend(projectRoot, key) {
|
|
17
|
-
const path = spentPath(projectRoot);
|
|
18
|
-
const spent = readSpent(path);
|
|
19
|
-
const count = (spent[key] ?? 0) + 1;
|
|
20
|
-
spent[key] = count;
|
|
21
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
-
// Written whole and moved into place, never written in place. A plain write
|
|
23
|
-
// that is interrupted leaves truncated JSON, which `readSpent` then reads as
|
|
24
|
-
// nothing spent — so the count would reset exactly when a run is being killed
|
|
25
|
-
// and restarted, which is the loop this exists to bound.
|
|
26
|
-
const staging = `${path}.tmp`;
|
|
27
|
-
writeFileSync(staging, `${JSON.stringify(spent, null, 2)}\n`);
|
|
28
|
-
renameSync(staging, path);
|
|
29
|
-
return count;
|
|
30
|
-
}
|
|
31
|
-
// A new build request starts on a fresh budget. Without this a project Unitbob
|
|
32
|
-
// ran a month ago opens today already over its ceiling, and every command
|
|
33
|
-
// announces the last round — noise, and advice that is wrong besides.
|
|
34
|
-
//
|
|
35
|
-
// True when there was something to clear, so the caller can say so out loud.
|
|
36
|
-
// Re-running `suite-prepare` is a documented step of the loop, and it resets
|
|
37
|
-
// both counters; a reset nobody is told about is a ceiling that quietly is not
|
|
38
|
-
// one.
|
|
39
|
-
export function clearSpending(projectRoot) {
|
|
40
|
-
const path = spentPath(projectRoot);
|
|
41
|
-
const had = existsSync(path);
|
|
42
|
-
rmSync(path, { force: true });
|
|
43
|
-
return had;
|
|
44
|
-
}
|
|
45
|
-
// A damaged or hand-edited file counts as nothing spent. The alternative is
|
|
46
|
-
// refusing to run over a bookkeeping file, which would make a counter that
|
|
47
|
-
// deliberately never blocks into the one thing that does.
|
|
48
|
-
function readSpent(path) {
|
|
49
|
-
if (!existsSync(path))
|
|
50
|
-
return {};
|
|
51
|
-
try {
|
|
52
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
53
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
54
|
-
return {};
|
|
55
|
-
return Object.fromEntries(Object.entries(parsed)
|
|
56
|
-
.filter(([, value]) => typeof value === 'number' && Number.isFinite(value)));
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return {};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// A request written by an older connector carries no budget, and that is not an
|
|
63
|
-
// error: there is no ceiling to enforce, so every counter goes quiet and the run
|
|
64
|
-
// works as it did before (spec 34-2, edge cases).
|
|
65
|
-
export function readBudget(value) {
|
|
66
|
-
if (!value || typeof value !== 'object')
|
|
67
|
-
return undefined;
|
|
68
|
-
const block = value;
|
|
69
|
-
const fields = ['workers', 'review_rounds', 'repair_rounds']
|
|
70
|
-
.map((field) => [field, block[field]]);
|
|
71
|
-
if (fields.some(([, number]) => typeof number !== 'number' || !Number.isFinite(number)))
|
|
72
|
-
return undefined;
|
|
73
|
-
return Object.fromEntries(fields);
|
|
74
|
-
}
|