acuvo-code 0.2.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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/best-of.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ BEST-OF-N — COST TURNED INTO CAPABILITY ─────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Run the same task several times independently, then keep the attempt that
|
|
5
|
+
* actually PASSED. This is not a trick; it is the one thing our price makes
|
|
6
|
+
* possible and a competitor's price does not. A run here costs ~$0.001, so
|
|
7
|
+
* three attempts cost a third of a cent. An agent billing a hundred times that
|
|
8
|
+
* cannot offer this at all — not because they lack the idea, because the
|
|
9
|
+
* arithmetic forbids it.
|
|
10
|
+
*
|
|
11
|
+
* ── ⚠️⚠️ THE SELECTION MUST NOT BE THE MODEL'S OPINION ─────────────────────
|
|
12
|
+
*
|
|
13
|
+
* The obvious design is to generate N answers and ask a model which is best.
|
|
14
|
+
* That is N chances to be confidently wrong, aggregated by a judge with the
|
|
15
|
+
* same blind spots — and this codebase has measured exactly that failure: a
|
|
16
|
+
* critic scored five of six different pages identically, and an agent declared
|
|
17
|
+
* a page "clean and balanced" that it had never rendered.
|
|
18
|
+
*
|
|
19
|
+
* ⭐ SO THE WINNER IS CHOSEN BY WHAT RAN. `sessionFailed()` already encodes the
|
|
20
|
+
* honest verdict — did the verification command pass, did a declared acceptance
|
|
21
|
+
* criterion hold. Selection is that verdict first, and taste never.
|
|
22
|
+
*
|
|
23
|
+
* ── ⚠️ IT MUST NEVER BE WORSE THAN N=1 ──────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* A single run that fails verification still leaves its files on disk; the user
|
|
26
|
+
* gets something to look at. So when NO attempt verifies, this applies one
|
|
27
|
+
* anyway and says plainly that none passed. Applying nothing would make asking
|
|
28
|
+
* for three attempts worse than asking for one, which is the kind of surprise
|
|
29
|
+
* that makes a feature untrustworthy.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { cpSync, mkdtempSync, rmSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import { join, relative, sep } from 'node:path';
|
|
35
|
+
|
|
36
|
+
import { changedPaths } from './changed-paths.mjs';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ⚠️ THE SAME SKIP SET THE REST OF THE PACKAGE USES. Copying `node_modules` for
|
|
40
|
+
* each of three attempts turns a 40MB workspace into 1.2GB of I/O on a laptop
|
|
41
|
+
* that may be on battery — and none of it is read by the agent, which reaches
|
|
42
|
+
* dependencies through the executor, not by walking them.
|
|
43
|
+
*/
|
|
44
|
+
export const COPY_SKIP_DIRS = new Set([
|
|
45
|
+
'node_modules', '.git', '.next', 'dist', 'build', '.vercel', 'coverage', '.turbo', '.acuvo',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export const MIN_ATTEMPTS = 2;
|
|
49
|
+
export const MAX_ATTEMPTS = 5;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ⚠️ A CEILING ON WHAT WILL BE COPIED, because the alternative is discovering
|
|
53
|
+
* mid-run that a workspace contained a 4GB dataset. Refusing with the number is
|
|
54
|
+
* a decision the user can act on; silently copying it is a hang they cannot.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_COPY_BYTES = 256 * 1024 * 1024;
|
|
57
|
+
|
|
58
|
+
export function measureWorkspace(root, { limit = MAX_COPY_BYTES } = {}) {
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
let files = 0;
|
|
61
|
+
const walk = (dir) => {
|
|
62
|
+
let entries;
|
|
63
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
64
|
+
for (const e of entries) {
|
|
65
|
+
if (bytes > limit) return;
|
|
66
|
+
const full = join(dir, e.name);
|
|
67
|
+
if (e.isDirectory()) {
|
|
68
|
+
if (COPY_SKIP_DIRS.has(e.name)) continue;
|
|
69
|
+
walk(full);
|
|
70
|
+
} else if (e.isFile()) {
|
|
71
|
+
try { bytes += statSync(full).size; files += 1; } catch { /* vanished mid-walk */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(root);
|
|
76
|
+
return { bytes, files, overLimit: bytes > limit };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Score one finished attempt. Lower is better.
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ THE FIRST DIGIT IS THE ONLY ONE THAT DECIDES CORRECTNESS. Rounds and cost
|
|
83
|
+
* break ties BETWEEN VERIFIED ATTEMPTS — they can never promote a failing
|
|
84
|
+
* attempt above a passing one, however cheap or fast it was. An agent that
|
|
85
|
+
* picked the cheapest answer would learn to do nothing.
|
|
86
|
+
*/
|
|
87
|
+
export function scoreAttempt(attempt, { failed }) {
|
|
88
|
+
if (!attempt || attempt.error) return { rank: 3, why: 'the attempt crashed' };
|
|
89
|
+
const outcome = attempt.outcome;
|
|
90
|
+
if (!outcome) return { rank: 3, why: 'the attempt produced no result' };
|
|
91
|
+
|
|
92
|
+
const mutations = (outcome.executed ?? []).filter((e) => e?.mutated).length;
|
|
93
|
+
|
|
94
|
+
if (!failed(outcome)) {
|
|
95
|
+
const v = outcome.verification;
|
|
96
|
+
/**
|
|
97
|
+
* ── ⚠️⚠️ DOING NOTHING IS NOT SUCCEEDING ─────────────────────────────
|
|
98
|
+
*
|
|
99
|
+
* CAUGHT BY A REAL RUN, not by reasoning. An attempt that wrote no files
|
|
100
|
+
* and ran no verification came back `ok: true`, so `failed()` said false,
|
|
101
|
+
* so it ranked as a WIN — and then beat the attempt that actually did the
|
|
102
|
+
* work, because the tie-break prefers fewer rounds and doing nothing takes
|
|
103
|
+
* the fewest rounds of all.
|
|
104
|
+
*
|
|
105
|
+
* ⭐ That is the same trap the cheap-failure test was written to close, one
|
|
106
|
+
* level deeper: I guarded against a cheap FAILURE outranking an expensive
|
|
107
|
+
* success and missed that a cheap NO-OP outranks everything. An agent
|
|
108
|
+
* rewarded for this learns that the optimal move is to do nothing quickly.
|
|
109
|
+
*
|
|
110
|
+
* `failed()` cannot see it — a run with no verification is not a run that
|
|
111
|
+
* failed verification, and that distinction is correct everywhere else.
|
|
112
|
+
* So the emptiness is judged here, where the alternatives are visible.
|
|
113
|
+
*/
|
|
114
|
+
if (!v?.ran && mutations === 0) {
|
|
115
|
+
return {
|
|
116
|
+
rank: 2,
|
|
117
|
+
why: 'changed nothing and verified nothing',
|
|
118
|
+
rounds: outcome.roundsUsed ?? 0,
|
|
119
|
+
cost: outcome.usage?.cost ?? 0,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
rank: 0,
|
|
124
|
+
why: v?.ran
|
|
125
|
+
? `verified: ${v.command} passed`
|
|
126
|
+
: `changed ${mutations} file${mutations === 1 ? '' : 's'}, nothing left failing`,
|
|
127
|
+
rounds: outcome.roundsUsed ?? 0,
|
|
128
|
+
cost: outcome.usage?.cost ?? 0,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Ran something and it failed — still more informative than never running.
|
|
133
|
+
if (outcome.verification?.ran) {
|
|
134
|
+
return { rank: 1, why: `ran ${outcome.verification.command} and it failed`, rounds: outcome.roundsUsed ?? 0, cost: outcome.usage?.cost ?? 0 };
|
|
135
|
+
}
|
|
136
|
+
return { rank: 2, why: outcome.stoppedBecause ? `stopped: ${outcome.stoppedBecause}` : 'nothing was verified', rounds: outcome.roundsUsed ?? 0, cost: outcome.usage?.cost ?? 0 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function pickWinner(attempts, { failed }) {
|
|
140
|
+
const scored = attempts.map((a, i) => ({ index: i, attempt: a, score: scoreAttempt(a, { failed }) }));
|
|
141
|
+
const ordered = [...scored].sort((a, b) => (
|
|
142
|
+
a.score.rank - b.score.rank
|
|
143
|
+
|| (a.score.rounds ?? 99) - (b.score.rounds ?? 99)
|
|
144
|
+
|| (a.score.cost ?? 0) - (b.score.cost ?? 0)
|
|
145
|
+
|| a.index - b.index
|
|
146
|
+
));
|
|
147
|
+
const winner = ordered[0];
|
|
148
|
+
return {
|
|
149
|
+
winner,
|
|
150
|
+
scored,
|
|
151
|
+
anyVerified: scored.some((s) => s.score.rank === 0),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Copy the files an attempt changed back into the real workspace.
|
|
157
|
+
*
|
|
158
|
+
* ⚠️ ONLY THE FILES THE ATTEMPT ITSELF REPORTED WRITING. Mirroring the whole
|
|
159
|
+
* directory would also delete anything the user created while the attempts ran,
|
|
160
|
+
* and would resurrect files an attempt deliberately deleted in a way the
|
|
161
|
+
* report never mentioned. The change list is the attempt's own account of what
|
|
162
|
+
* it did, and applying exactly that keeps the summary and the disk in agreement.
|
|
163
|
+
*/
|
|
164
|
+
export function applyAttempt(fromRoot, toRoot, outcome, { copy = cpSync, remove = rmSync } = {}) {
|
|
165
|
+
const applied = [];
|
|
166
|
+
const problems = [];
|
|
167
|
+
for (const record of outcome?.executed ?? []) {
|
|
168
|
+
/**
|
|
169
|
+
* ── ⚠️⚠️ THIS READ `record.args?.path`, AND THAT SILENTLY DROPPED FILES ──
|
|
170
|
+
*
|
|
171
|
+
* Measured by reading `write-many.mjs:128`: a `write_files` call carries
|
|
172
|
+
* `files[]` in its ARGUMENTS and reports `written[{path,…}]` in its
|
|
173
|
+
* RESULT. There is no top-level `args.path`, so a winning attempt that
|
|
174
|
+
* created its whole feature in one bulk call had every file skipped
|
|
175
|
+
* here — and `formatBestOf` still printed "kept attempt 2" over an empty
|
|
176
|
+
* change list. ⭐ `changedPaths` is now the one place that knows the
|
|
177
|
+
* shapes, and it also carries a delegated builder's files.
|
|
178
|
+
*/
|
|
179
|
+
for (const path of changedPaths(record)) {
|
|
180
|
+
// ⚠️ Refuse anything that climbs out — the attempt ran in a copy, but the
|
|
181
|
+
// path it reports is still a string from a model.
|
|
182
|
+
if (path.includes('..') || path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:/.test(path)) {
|
|
183
|
+
problems.push(`refused to apply a path that leaves the workspace: ${path}`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const src = join(fromRoot, path);
|
|
187
|
+
const dest = join(toRoot, path);
|
|
188
|
+
try {
|
|
189
|
+
if (existsSync(src)) {
|
|
190
|
+
copy(src, dest, { recursive: true, force: true });
|
|
191
|
+
applied.push(path);
|
|
192
|
+
} else if (existsSync(dest)) {
|
|
193
|
+
// The attempt deleted it. Deleting it here is what "apply" means.
|
|
194
|
+
remove(dest, { recursive: true, force: true });
|
|
195
|
+
applied.push(path);
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
problems.push(`${path}: ${String(err?.message ?? err)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { applied, problems };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Run one task N times in isolated copies and apply the best result.
|
|
207
|
+
*
|
|
208
|
+
* `runOne({ root, label })` must resolve to a SessionDone-shaped outcome.
|
|
209
|
+
* Injected so this module never imports the turn loop — it is a strategy, not
|
|
210
|
+
* a second engine.
|
|
211
|
+
*/
|
|
212
|
+
export async function runBestOf({
|
|
213
|
+
root,
|
|
214
|
+
attempts = 3,
|
|
215
|
+
runOne,
|
|
216
|
+
failed,
|
|
217
|
+
pool,
|
|
218
|
+
concurrency = 2,
|
|
219
|
+
onEvent = () => {},
|
|
220
|
+
makeTempDir = () => mkdtempSync(join(tmpdir(), 'acuvo-bestof-')),
|
|
221
|
+
copyDir = cpSync,
|
|
222
|
+
removeDir = rmSync,
|
|
223
|
+
/**
|
|
224
|
+
* ⚠️ INJECTABLE SO THE GUARD CAN BE TESTED AT ALL. With the limit hardcoded,
|
|
225
|
+
* the only way to exercise the refusal is to actually build a 256MB workspace
|
|
226
|
+
* in a test — so nobody does, and the one branch that protects a laptop from
|
|
227
|
+
* copying a dataset three times is the one branch never run.
|
|
228
|
+
*/
|
|
229
|
+
maxBytes = MAX_COPY_BYTES,
|
|
230
|
+
}) {
|
|
231
|
+
const n = Math.max(MIN_ATTEMPTS, Math.min(MAX_ATTEMPTS, Number(attempts) || MIN_ATTEMPTS));
|
|
232
|
+
|
|
233
|
+
const size = measureWorkspace(root, { limit: maxBytes });
|
|
234
|
+
if (size.overLimit) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
error: `this workspace holds more than ${Math.round(maxBytes / 1024 / 1024)}MB of files that would have to be copied ${n} times. `
|
|
238
|
+
+ 'Run without --best-of here, or point --dir at the subdirectory the task actually touches.',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const roots = [];
|
|
243
|
+
try {
|
|
244
|
+
for (let i = 0; i < n; i += 1) {
|
|
245
|
+
const dir = makeTempDir();
|
|
246
|
+
/**
|
|
247
|
+
* ⚠️ `dereference: false` — a symlink copied as its target silently
|
|
248
|
+
* doubles a workspace and breaks any code that checks `isSymbolicLink`.
|
|
249
|
+
*/
|
|
250
|
+
copyDir(root, dir, {
|
|
251
|
+
recursive: true,
|
|
252
|
+
dereference: false,
|
|
253
|
+
force: true,
|
|
254
|
+
filter: (src) => {
|
|
255
|
+
const rel = relative(root, src);
|
|
256
|
+
if (rel === '') return true;
|
|
257
|
+
return !rel.split(sep).some((part) => COPY_SKIP_DIRS.has(part));
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
roots.push(dir);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
onEvent({ type: 'best-of-start', attempts: n, files: size.files, bytes: size.bytes });
|
|
264
|
+
|
|
265
|
+
const jobs = roots.map((dir, i) => () => runOne({ root: dir, label: `attempt ${i + 1}/${n}` })
|
|
266
|
+
.then((outcome) => ({ outcome }))
|
|
267
|
+
.catch((err) => ({ error: String(err?.message ?? err) })));
|
|
268
|
+
|
|
269
|
+
const results = pool
|
|
270
|
+
? await pool(jobs, { concurrency })
|
|
271
|
+
: await Promise.all(jobs.map((j) => j()));
|
|
272
|
+
|
|
273
|
+
const { winner, scored, anyVerified } = pickWinner(results, { failed });
|
|
274
|
+
|
|
275
|
+
const { applied, problems } = applyAttempt(roots[winner.index], root, winner.attempt?.outcome, {
|
|
276
|
+
copy: copyDir,
|
|
277
|
+
remove: removeDir,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
attempts: n,
|
|
283
|
+
anyVerified,
|
|
284
|
+
winnerIndex: winner.index,
|
|
285
|
+
winner: winner.attempt?.outcome ?? null,
|
|
286
|
+
applied,
|
|
287
|
+
problems,
|
|
288
|
+
scored: scored.map((s) => ({
|
|
289
|
+
index: s.index,
|
|
290
|
+
rank: s.score.rank,
|
|
291
|
+
why: s.score.why,
|
|
292
|
+
rounds: s.score.rounds ?? null,
|
|
293
|
+
cost: s.score.cost ?? null,
|
|
294
|
+
})),
|
|
295
|
+
totalCost: results.reduce((sum, r) => sum + (r?.outcome?.usage?.cost ?? 0), 0),
|
|
296
|
+
};
|
|
297
|
+
} finally {
|
|
298
|
+
// ⚠️ ALWAYS, even when an attempt threw. Three abandoned copies of a
|
|
299
|
+
// workspace per invocation is how a temp directory becomes a disk problem
|
|
300
|
+
// nobody connects back to this feature.
|
|
301
|
+
for (const dir of roots) {
|
|
302
|
+
try { removeDir(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Render the outcome for a human. */
|
|
308
|
+
export function formatBestOf(result) {
|
|
309
|
+
if (!result?.ok) return String(result?.error ?? 'best-of failed');
|
|
310
|
+
const lines = [];
|
|
311
|
+
const verifiedCount = result.scored.filter((s) => s.rank === 0).length;
|
|
312
|
+
|
|
313
|
+
lines.push(`best of ${result.attempts}: ${verifiedCount} verified, kept attempt ${result.winnerIndex + 1}`);
|
|
314
|
+
for (const s of result.scored) {
|
|
315
|
+
const mark = s.index === result.winnerIndex ? '→' : ' ';
|
|
316
|
+
const cost = s.cost ? ` · $${s.cost.toFixed(4)}` : '';
|
|
317
|
+
const rounds = s.rounds ? ` · ${s.rounds}r` : '';
|
|
318
|
+
lines.push(`${mark} attempt ${s.index + 1}: ${s.why}${rounds}${cost}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* ⚠️ THE ALL-FAILED CASE SAYS SO IN THOSE WORDS. "kept attempt 2" on its own
|
|
323
|
+
* reads like a success, and a user who asked for three attempts precisely
|
|
324
|
+
* because the task was hard is the last person who should have to infer that
|
|
325
|
+
* none of them worked.
|
|
326
|
+
*/
|
|
327
|
+
if (!result.anyVerified) {
|
|
328
|
+
lines.push('⚠ NONE of the attempts verified. The files from the least-bad one were applied so you have something to look at, but nothing here is proven.');
|
|
329
|
+
}
|
|
330
|
+
if (result.applied.length > 0) lines.push(`applied ${result.applied.length} file${result.applied.length === 1 ? '' : 's'} from the winner`);
|
|
331
|
+
for (const p of result.problems) lines.push(`⚠ ${p}`);
|
|
332
|
+
lines.push(`total spend across all attempts: $${result.totalCost.toFixed(4)}`);
|
|
333
|
+
return lines.join('\n');
|
|
334
|
+
}
|
package/lib/board.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE TASK BOARD — SEVEN TERMINALS, SEVEN JOBS, NOBODY DOING THE SAME ONE ─
|
|
3
|
+
*
|
|
4
|
+
* The stated direction for this tool is *"open seven terminals and have seven AI
|
|
5
|
+
* workers building your software in the same brain, and they are cheap"*. Nearly
|
|
6
|
+
* all of that already worked and was measured: seven terminals run today, they
|
|
7
|
+
* are $0.001–0.003 a task, `lease.mjs` stops two of them writing one file,
|
|
8
|
+
* `auto-lease.mjs` made that automatic, `fleet-budget.mjs` caps the whole
|
|
9
|
+
* workspace for the day, and the plan ledger is now per worker.
|
|
10
|
+
*
|
|
11
|
+
* The one missing piece was never a lock. It is that **nothing said what the
|
|
12
|
+
* work WAS**. Seven terminals meant seven people typing seven prompts, and
|
|
13
|
+
* nothing stopped two of them typing the same one.
|
|
14
|
+
*
|
|
15
|
+
* ── ⭐⭐ AND THE DESIGN IS: THERE IS NO NEW LOCKING HERE ─────────────────────
|
|
16
|
+
*
|
|
17
|
+
* A task board looks like it needs a queue with a mutex. It does not, and the
|
|
18
|
+
* reason is worth stating because it is the whole reason this file is short:
|
|
19
|
+
*
|
|
20
|
+
* a task IS a file, and claiming one IS taking a lease on that file.
|
|
21
|
+
*
|
|
22
|
+
* `.acuvo/board/<id>.json` is a real path inside the workspace, so
|
|
23
|
+
* `lease.mjs` — 786 lines of proven, raced-by-eight-real-processes,
|
|
24
|
+
* TTL-reclaiming, takeover-capped exclusive claim — works on it UNMODIFIED.
|
|
25
|
+
* Measured before this file was written: a lease on `.acuvo/board/t3.json` is
|
|
26
|
+
* granted, a second terminal is refused with `heldBy`, and `acuvo leases`
|
|
27
|
+
* already renders it. Nothing here re-invents any of that.
|
|
28
|
+
*
|
|
29
|
+
* ⭐ Which also means every property already argued for leases comes free: a
|
|
30
|
+
* worker that dies mid-task has its claim reclaimed after the TTL rather than
|
|
31
|
+
* blocking the board forever, and `acuvo leases` is already the answer to "who
|
|
32
|
+
* is doing what".
|
|
33
|
+
*
|
|
34
|
+
* ── ⚠️ WHAT THIS DELIBERATELY IS NOT ────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* There are no `board_*` TOOLS. The model cannot add, claim or complete a task,
|
|
37
|
+
* and that is a decision rather than an omission: agent-callable claiming is a
|
|
38
|
+
* second unproven behaviour riding on a board that has not yet been used once.
|
|
39
|
+
* The workflow that actually exists is a person opening seven windows and
|
|
40
|
+
* typing, so the board is a CLI surface for that person. Give it a week of real
|
|
41
|
+
* use before letting the thing being scheduled do the scheduling.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
|
|
47
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
48
|
+
import { acquire, release, inspect } from './lease.mjs';
|
|
49
|
+
|
|
50
|
+
export const BOARD_DIR = '.acuvo/board';
|
|
51
|
+
/** A task longer than this is a project, and a project is not a board entry. */
|
|
52
|
+
export const MAX_TASK_CHARS = 400;
|
|
53
|
+
/** Enough for any real fleet; a bound so a runaway script cannot fill a disk. */
|
|
54
|
+
export const MAX_TASKS = 200;
|
|
55
|
+
|
|
56
|
+
const nowIso = () => new Date().toISOString();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ SORTABLE AND COLLISION-RESISTANT, in that order of importance. Two
|
|
60
|
+
* terminals can add a task in the same millisecond, so the timestamp alone is
|
|
61
|
+
* not an id — but the timestamp PREFIX is what makes `readdirSync` return the
|
|
62
|
+
* board in the order it was written without anybody sorting by a parsed field.
|
|
63
|
+
*/
|
|
64
|
+
function newId(clock = Date.now, rand = () => Math.random()) {
|
|
65
|
+
const stamp = new Date(clock()).toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
|
66
|
+
const salt = Math.floor(rand() * 46_656).toString(36).padStart(3, '0');
|
|
67
|
+
return `${stamp}-${salt}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function boardPathFor(root, id) {
|
|
71
|
+
return resolveInWorkspace(root, `${BOARD_DIR}/${id}.json`, 'write');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Add one task. Returns the id, which is what a person needs to talk about it.
|
|
76
|
+
*/
|
|
77
|
+
export function boardAdd(root, text, { clock = Date.now, rand = () => Math.random() } = {}) {
|
|
78
|
+
const task = String(text ?? '').trim().replace(/\s+/g, ' ');
|
|
79
|
+
if (!task) return { ok: false, error: 'a board task needs some text — an empty one cannot be picked up' };
|
|
80
|
+
if (task.length > MAX_TASK_CHARS) {
|
|
81
|
+
return { ok: false, error: `that task is ${task.length} characters, over the ${MAX_TASK_CHARS} limit. Put the detail in the repo, not on the board.` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const existing = boardList(root);
|
|
85
|
+
if (existing.ok && existing.tasks.length >= MAX_TASKS) {
|
|
86
|
+
return { ok: false, error: `the board already holds ${existing.tasks.length} tasks (limit ${MAX_TASKS}) — finish or remove some before adding more` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const id = newId(clock, rand);
|
|
90
|
+
const target = boardPathFor(root, id);
|
|
91
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(join(root, BOARD_DIR), { recursive: true });
|
|
95
|
+
/**
|
|
96
|
+
* ⚠️ `wx` — EXCLUSIVE CREATE. Two terminals adding in the same millisecond
|
|
97
|
+
* would otherwise silently overwrite one another's task, and a board that
|
|
98
|
+
* loses work is worse than no board. The id has a random salt, so a
|
|
99
|
+
* collision is already unlikely; refusing outright is what makes it
|
|
100
|
+
* impossible rather than unlikely.
|
|
101
|
+
*/
|
|
102
|
+
writeFileSync(target.absolute, `${JSON.stringify({ id, task, addedAt: nowIso(), state: 'todo' }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { ok: false, error: `could not add the task: ${err?.message ?? err}` };
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, id, task };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every task, with who holds it, oldest first. */
|
|
110
|
+
export function boardList(root) {
|
|
111
|
+
const dir = join(root, BOARD_DIR);
|
|
112
|
+
if (!existsSync(dir)) return { ok: true, tasks: [] };
|
|
113
|
+
|
|
114
|
+
let names;
|
|
115
|
+
try {
|
|
116
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return { ok: false, error: `could not read the board: ${err?.message ?? err}`, tasks: [] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* ⭐ WHO HOLDS WHAT COMES FROM `lease.mjs`, NOT FROM THE TASK FILE. A holder
|
|
123
|
+
* written into the task itself would be a second copy of a fact the lease
|
|
124
|
+
* layer already owns — and it would go stale the moment a worker died,
|
|
125
|
+
* because a dead process cannot come back to correct its own file. The lease
|
|
126
|
+
* expires on its own; a field would not.
|
|
127
|
+
*/
|
|
128
|
+
const held = new Map();
|
|
129
|
+
try {
|
|
130
|
+
for (const l of inspect(root)?.leases ?? []) held.set(l.path, l);
|
|
131
|
+
} catch { /* an unreadable lease dir means we cannot say who holds what, not that the board is gone */ }
|
|
132
|
+
|
|
133
|
+
const tasks = [];
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
let rec;
|
|
136
|
+
try {
|
|
137
|
+
rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
// ⚠️ Counted, not skipped silently — a task nobody can read is still work
|
|
140
|
+
// somebody wrote down, and pretending it is absent is how it gets lost.
|
|
141
|
+
tasks.push({ id: name.replace(/\.json$/, ''), task: '(unreadable — the file is not valid JSON)', state: 'damaged', heldBy: null });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const lease = held.get(`${BOARD_DIR}/${rec.id}.json`);
|
|
145
|
+
tasks.push({ ...rec, heldBy: lease?.holder ?? null, heldSince: lease?.acquiredAt ?? null });
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, tasks };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Claim the oldest task nobody else holds.
|
|
152
|
+
*
|
|
153
|
+
* ⚠️ THE LEASE IS THE CLAIM, AND THE RACE IS ALREADY SOLVED. Two terminals
|
|
154
|
+
* calling this in the same instant both see the same "first free" task; exactly
|
|
155
|
+
* one wins the `acquire`, and the loser simply moves to the next one. That is
|
|
156
|
+
* `lease.mjs`'s exclusive-create doing the work, which is precisely why this
|
|
157
|
+
* function has no lock of its own.
|
|
158
|
+
*/
|
|
159
|
+
export function boardClaim(root, { holder, ttlMs, acquireImpl = acquire } = {}) {
|
|
160
|
+
const who = String(holder ?? '').trim();
|
|
161
|
+
if (!who) return { ok: false, error: 'claiming a task needs a --holder, so the board can say who is doing what' };
|
|
162
|
+
|
|
163
|
+
const listed = boardList(root);
|
|
164
|
+
if (!listed.ok) return listed;
|
|
165
|
+
|
|
166
|
+
const open = listed.tasks.filter((t) => t.state === 'todo' && !t.heldBy);
|
|
167
|
+
if (open.length === 0) {
|
|
168
|
+
const total = listed.tasks.length;
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
empty: true,
|
|
172
|
+
error: total === 0
|
|
173
|
+
? `the board is empty — add work with \`acuvo board add "…"\``
|
|
174
|
+
: `every task on the board is either done or already held by another terminal (${total} total). \`acuvo board\` shows who has what.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const t of open) {
|
|
179
|
+
const got = acquireImpl(root, { path: `${BOARD_DIR}/${t.id}.json`, holder: who, pid: process.pid, ...(ttlMs ? { ttlMs } : {}) });
|
|
180
|
+
if (got?.ok) return { ok: true, id: t.id, task: t.task, lease: got.lease };
|
|
181
|
+
// Somebody won it between the list and the acquire. Try the next one —
|
|
182
|
+
// that is the race resolving itself, not an error worth reporting.
|
|
183
|
+
}
|
|
184
|
+
return { ok: false, empty: true, error: 'every open task was claimed by another terminal while this one was looking' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Mark a task finished and let go of it. */
|
|
188
|
+
export function boardDone(root, id, { lease = null, releaseImpl = release } = {}) {
|
|
189
|
+
const target = boardPathFor(root, String(id ?? '').trim());
|
|
190
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
191
|
+
if (!existsSync(target.absolute)) return { ok: false, error: `no task on the board has the id "${id}"` };
|
|
192
|
+
|
|
193
|
+
let rec;
|
|
194
|
+
try {
|
|
195
|
+
rec = JSON.parse(readFileSync(target.absolute, 'utf8'));
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return { ok: false, error: `that task file could not be read: ${err?.message ?? err}` };
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(target.absolute, `${JSON.stringify({ ...rec, state: 'done', doneAt: nowIso() }, null, 2)}\n`, 'utf8');
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return { ok: false, error: `could not mark the task done: ${err?.message ?? err}` };
|
|
203
|
+
}
|
|
204
|
+
if (lease) { try { releaseImpl(lease); } catch { /* the TTL clears it */ } }
|
|
205
|
+
return { ok: true, id: rec.id, task: rec.task };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The human view. Same shape as every other report in this package. */
|
|
209
|
+
export function formatBoard(listed) {
|
|
210
|
+
if (!listed?.ok) return `board: ${listed?.error ?? 'unavailable'}`;
|
|
211
|
+
if (listed.tasks.length === 0) {
|
|
212
|
+
return 'The board is empty.\n\n Add work with: acuvo board add "make the failing suite pass"\n Then claim it: acuvo --holder t1 --claim';
|
|
213
|
+
}
|
|
214
|
+
const lines = [];
|
|
215
|
+
const todo = listed.tasks.filter((t) => t.state === 'todo');
|
|
216
|
+
const done = listed.tasks.filter((t) => t.state === 'done');
|
|
217
|
+
lines.push(`${listed.tasks.length} task${listed.tasks.length === 1 ? '' : 's'} — ${todo.filter((t) => !t.heldBy).length} open, ${todo.filter((t) => t.heldBy).length} in progress, ${done.length} done`);
|
|
218
|
+
lines.push('');
|
|
219
|
+
for (const t of listed.tasks) {
|
|
220
|
+
const mark = t.state === 'done' ? '✔' : t.heldBy ? '▶' : '·';
|
|
221
|
+
/**
|
|
222
|
+
* ⚠️ A FINISHED TASK SHOWS NO HOLDER. Its lease often outlives it — a
|
|
223
|
+
* worker that finished and exited without releasing leaves the record until
|
|
224
|
+
* the TTL clears it — and "✔ done ← t1" reads as *t1 is working on this*,
|
|
225
|
+
* which is the opposite of what it means. The holder is only interesting
|
|
226
|
+
* while the work is in flight.
|
|
227
|
+
*/
|
|
228
|
+
const who = t.heldBy && t.state !== 'done' ? ` ← ${t.heldBy}` : '';
|
|
229
|
+
lines.push(` ${mark} ${t.id} ${t.task}${who}`);
|
|
230
|
+
}
|
|
231
|
+
return lines.join('\n');
|
|
232
|
+
}
|
package/lib/breaker.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⚠️⭐ ONE DEAD SERVICE MUST NOT EAT THE WHOLE SESSION ─────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-08-10 on a real multi-page website build. `generate_image` was
|
|
5
|
+
* unreachable. Each attempt waited the full 180-second timeout; the model tried
|
|
6
|
+
* twice in one round, then — following our own error text, which said "try once
|
|
7
|
+
* more" — retried in the next. Six minutes of a run spent on a service that was
|
|
8
|
+
* never going to answer, and the user saw a session that appeared to hang and
|
|
9
|
+
* produced nothing.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ THE ASYMMETRY IS THE WHOLE ARGUMENT. Being wrong about a service being dead
|
|
12
|
+
* costs one missing image. Being wrong about it being alive costs three minutes
|
|
13
|
+
* PER ATTEMPT out of a budget measured in minutes. So: one unreachable answer,
|
|
14
|
+
* and this endpoint is dead for the rest of the process.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ IT TRIPS ON UNREACHABLE, NEVER ON A REFUSAL. An HTTP 400 means the request
|
|
17
|
+
* was wrong and the next one may well be right — tripping on that would disable
|
|
18
|
+
* a working service because the model sent a bad prompt once. Only "no response"
|
|
19
|
+
* counts: a timeout, a DNS failure, a refused connection.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ AND IT IS PER-PROCESS, DELIBERATELY. A CLI run is minutes long, so there is
|
|
22
|
+
* no half-life worth modelling and no reason to make a dead service recoverable
|
|
23
|
+
* mid-run — the next invocation gets a clean slate for free, which is exactly
|
|
24
|
+
* the retry policy a human would apply.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** endpoint URL → the reason it was declared dead. */
|
|
28
|
+
const dead = new Map();
|
|
29
|
+
|
|
30
|
+
/** Reset between tests. Never called in normal operation — a run is short. */
|
|
31
|
+
export function resetBreakers() {
|
|
32
|
+
dead.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Is this endpoint already known to be unreachable this run?
|
|
37
|
+
* Returns the recorded reason, or null.
|
|
38
|
+
*/
|
|
39
|
+
export function deadReason(url) {
|
|
40
|
+
return dead.get(String(url)) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Record that an endpoint did not answer.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ The CALLER decides whether the failure was "no response" or merely "no",
|
|
47
|
+
* because only the caller can tell an AbortError apart from an HTTP 422. Passing
|
|
48
|
+
* an application-level refusal here would be the bug this module exists to
|
|
49
|
+
* avoid.
|
|
50
|
+
*/
|
|
51
|
+
export function markUnreachable(url, reason) {
|
|
52
|
+
dead.set(String(url), String(reason ?? 'no response'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The message a later caller gets. It must NOT invite another attempt.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ THE ORIGINAL TEXT SAID "try once more" AND THE MODEL DID, twice. An error
|
|
59
|
+
* string is an instruction to whatever reads it, and a model reads it literally.
|
|
60
|
+
* This one closes the door and names the alternative, so the round is spent on
|
|
61
|
+
* work instead of on waiting.
|
|
62
|
+
*/
|
|
63
|
+
export function skipMessage(label, url) {
|
|
64
|
+
const why = deadReason(url);
|
|
65
|
+
return `${label} is not responding this session (${why}). Skipping it rather than waiting again — `
|
|
66
|
+
+ 'earlier attempts already spent the full timeout. Carry on without it and say so in your summary; '
|
|
67
|
+
+ 'do not call this tool again in this run.';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wrap a fetch so the breaker is consulted first and tripped on a network-level
|
|
72
|
+
* failure. Returns `{ ok: false, skipped: true, error }` when already dead.
|
|
73
|
+
*
|
|
74
|
+
* ⭐ Kept as a wrapper rather than a rule each call site reimplements: the two
|
|
75
|
+
* existing callers already disagreed about what an unreachable service should
|
|
76
|
+
* say, which is how "try once more" survived in one of them.
|
|
77
|
+
*/
|
|
78
|
+
export async function throughBreaker(url, label, run) {
|
|
79
|
+
const already = deadReason(url);
|
|
80
|
+
if (already) return { ok: false, skipped: true, error: skipMessage(label, url) };
|
|
81
|
+
try {
|
|
82
|
+
return await run();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const networkLevel = err?.name === 'TimeoutError'
|
|
85
|
+
|| err?.name === 'AbortError'
|
|
86
|
+
|| Boolean(err?.cause?.code);
|
|
87
|
+
if (networkLevel) {
|
|
88
|
+
const why = err?.name === 'TimeoutError' ? 'timed out' : (err?.cause?.code ?? err?.name ?? 'no response');
|
|
89
|
+
markUnreachable(url, why);
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|