jules-orchestrator-kit 0.33.0 → 0.34.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/README.md +3 -3
- package/bin/agentctl.mjs +16 -7
- package/index.mjs +4 -0
- package/package.json +1 -1
- package/src/budget.mjs +105 -78
- package/src/config.mjs +33 -4
- package/src/ops/doctor-registry.mjs +37 -0
- package/src/state.mjs +146 -52
- package/src/wizard-init.mjs +6 -1
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
|
|
|
120
120
|
|
|
121
121
|
* **🚀 Zero-Test Bootstrapping (`agentctl bootstrap`):** Synthesizes deterministic syntax-check and smoke-test verification oracles for untested legacy repositories so agents always operate against a falsifiable feedback loop.
|
|
122
122
|
|
|
123
|
-
* **📈 Proven Scale & Reliability:** Empirically tested with **
|
|
123
|
+
* **📈 Proven Scale & Reliability:** Empirically tested with **510 unit tests across 77 suites passing in < 10.0s**. An adversarial red-team suite (`test/adversarial-claims.test.mjs`) continuously attempts to falsify the safety guarantees documented above — including cross-platform probes for the case-insensitive filesystems on macOS and Windows — and a documentation-sync gate (`scripts/doc-sync-check.mjs`) blocks any release whose docs have drifted from the code.
|
|
124
124
|
|
|
125
125
|
<br/>
|
|
126
126
|
|
|
@@ -297,9 +297,9 @@ scope:
|
|
|
297
297
|
limits:
|
|
298
298
|
diffKb: 75 # 75 KB Diff Payload Governor limit
|
|
299
299
|
promptKb: 50 # Maximum prompt payload size
|
|
300
|
-
dailyTasks: 300 #
|
|
300
|
+
dailyTasks: 300 # Task quota per rolling 24h window (not per calendar day)
|
|
301
301
|
repairAttempts: 3 # Maximum OODA repair iterations
|
|
302
|
-
concurrency:
|
|
302
|
+
concurrency: 15 # Worker slots; the ultra plan allows up to 60
|
|
303
303
|
|
|
304
304
|
# Dynamic Complexity & Cost Router — opt-in, disabled by default.
|
|
305
305
|
# Provider-agnostic: "fast"/"complex" accept any provider key ("jules" |
|
package/bin/agentctl.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { acquireLock, releaseLock, lockStatus, getQueueDir } from "../src/state.
|
|
|
9
9
|
import { worktreePrune } from "../src/git.mjs";
|
|
10
10
|
import { reapOrphanedIntents, reapStaleMutexDirs } from "../src/journal.mjs";
|
|
11
11
|
import { KIT_VERSION } from "../src/version.mjs";
|
|
12
|
-
import { budgetStatus, listOpenReservations, releaseOpenReservations } from "../src/budget.mjs";
|
|
12
|
+
import { budgetStatus, listOpenReservations, releaseOpenReservations, resolveConcurrency } from "../src/budget.mjs";
|
|
13
13
|
|
|
14
14
|
const args = process.argv.slice(2);
|
|
15
15
|
const command = args[0];
|
|
@@ -21,11 +21,14 @@ export const VERSION = KIT_VERSION;
|
|
|
21
21
|
* counter. The ledger counts what *this checkout* dispatched; sessions started
|
|
22
22
|
* from the web UI or another machine spend the same quota unseen, so a bare
|
|
23
23
|
* "N / M used" invites the reader to trust a figure that cannot be complete.
|
|
24
|
+
* "last 24h", not "today": the provider's allowance resets on a rolling
|
|
25
|
+
* window, so a figure labelled by the calendar day would be a different number
|
|
26
|
+
* from the one being enforced.
|
|
24
27
|
* @param {{ used: number, limit: number, source: string, certain: boolean }} b
|
|
25
28
|
*/
|
|
26
29
|
export function formatBudgetLine(b) {
|
|
27
|
-
const scope = `${b.used} / ${b.limit} used (this repo)`;
|
|
28
|
-
if (b.source === "learned") return `${scope} — provider refused further work
|
|
30
|
+
const scope = `${b.used} / ${b.limit} used in the last 24h (this repo)`;
|
|
31
|
+
if (b.source === "learned") return `${scope} — provider refused further work`;
|
|
29
32
|
if (b.certain) return `${scope} — limit from ${b.source === "env" ? "JULES_DAILY_BUDGET" : "config"}`;
|
|
30
33
|
return `${scope} — limit estimated from tier "${b.tier || "?"}", not enforced`;
|
|
31
34
|
}
|
|
@@ -57,7 +60,7 @@ Commands:
|
|
|
57
60
|
rollback Restore git state & working tree to atomic pre-flight checkpoint
|
|
58
61
|
resume Resume warm session with human response (--response "<text>")
|
|
59
62
|
status Display queue and system status summary
|
|
60
|
-
budget Show
|
|
63
|
+
budget Show the 24h task budget, worker slots and their provenance (reset --yes)
|
|
61
64
|
scan Scan codebase for TODO/FIXME task candidates
|
|
62
65
|
hydrate [prompt] Prepend active system learnings and baton-pass state to a prompt
|
|
63
66
|
harvest Harvest failure traces and record/quarantine resolution rules
|
|
@@ -333,7 +336,7 @@ async function main() {
|
|
|
333
336
|
const confirmed = args.includes("--yes") || args.includes("-y");
|
|
334
337
|
if (!dryRun && !confirmed) {
|
|
335
338
|
const open = listOpenReservations(root);
|
|
336
|
-
console.log(`Would release ${open.length} open reservation(s) from
|
|
339
|
+
console.log(`Would release ${open.length} open reservation(s) from the last 24 hours.`);
|
|
337
340
|
console.log("This rewrites nothing — it appends `budget_released` entries.");
|
|
338
341
|
console.log("Re-run with --yes to confirm, or --dry-run for detail.");
|
|
339
342
|
process.exit(0);
|
|
@@ -353,9 +356,15 @@ async function main() {
|
|
|
353
356
|
process.exit(0);
|
|
354
357
|
}
|
|
355
358
|
|
|
356
|
-
|
|
359
|
+
const slots = resolveConcurrency(config);
|
|
360
|
+
console.log(`Task Budget : ${formatBudgetLine(b)}`);
|
|
357
361
|
console.log(` ${b.note}`);
|
|
358
|
-
console.log(`
|
|
362
|
+
console.log(` Window opened at ${b.windowStart} — the quota resets ${b.windowHours}h after each task,`);
|
|
363
|
+
console.log(" not at midnight, so this count spans yesterday's ledger too.");
|
|
364
|
+
console.log(` Open reservations in the window: ${listOpenReservations(root).length}`);
|
|
365
|
+
console.log("");
|
|
366
|
+
console.log(`Worker Slots : ${slots.concurrency} concurrent`);
|
|
367
|
+
console.log(` ${slots.note}`);
|
|
359
368
|
console.log("");
|
|
360
369
|
console.log("The ledger counts this checkout only — sessions started from the Jules");
|
|
361
370
|
console.log("web UI or another machine spend the same quota without appearing here.");
|
package/index.mjs
CHANGED
|
@@ -49,6 +49,9 @@ export {
|
|
|
49
49
|
rollbackBudgetReservation,
|
|
50
50
|
withBudget,
|
|
51
51
|
checkDailyBudget,
|
|
52
|
+
scanBudgetWindow,
|
|
53
|
+
getLedgerPathsInWindow,
|
|
54
|
+
ROLLING_WINDOW_MS,
|
|
52
55
|
acquireLock,
|
|
53
56
|
releaseLock,
|
|
54
57
|
lockStatus,
|
|
@@ -140,6 +143,7 @@ export {
|
|
|
140
143
|
isDailyQuotaRejection,
|
|
141
144
|
listOpenReservations,
|
|
142
145
|
releaseOpenReservations,
|
|
146
|
+
resolveConcurrency,
|
|
143
147
|
CEILING_FILE,
|
|
144
148
|
} from "./src/budget.mjs";
|
|
145
149
|
export { KIT_VERSION } from "./src/version.mjs";
|
package/package.json
CHANGED
package/src/budget.mjs
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { resolveRoot } from "./config.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getStateDir,
|
|
6
|
+
ensureDir,
|
|
7
|
+
appendLedger,
|
|
8
|
+
checkDailyBudget,
|
|
9
|
+
scanBudgetWindow,
|
|
10
|
+
ROLLING_WINDOW_MS,
|
|
11
|
+
} from "./state.mjs";
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* Where the observed quota ceiling lives, outside the ledger so it survives
|
|
8
15
|
* rotation.
|
|
9
16
|
*
|
|
10
|
-
* Deliberately
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
17
|
+
* Deliberately short-lived. The local ledger only counts tasks dispatched from
|
|
18
|
+
* *this* checkout, while the account's quota is also spent from the web UI and
|
|
19
|
+
* other machines, so the local count at the moment of a refusal is a lower
|
|
20
|
+
* bound on the real allowance — not the allowance. Treated as permanent it
|
|
21
|
+
* would hard-block the operator below their own quota, which is the exact
|
|
22
|
+
* failure this whole mechanism exists to prevent.
|
|
16
23
|
*
|
|
17
|
-
* What it does mean is precise and useful:
|
|
18
|
-
* refusing, so stop asking until
|
|
24
|
+
* What it does mean is precise and useful: within the last 24 hours the
|
|
25
|
+
* provider started refusing, so stop asking until that refusal ages out.
|
|
19
26
|
*/
|
|
20
27
|
export const CEILING_FILE = "budget-ceiling.json";
|
|
21
28
|
|
|
22
|
-
/** Local calendar day,
|
|
29
|
+
/** Local calendar day, retained as the fallback key for pre-0.34 records. */
|
|
23
30
|
function today() {
|
|
24
31
|
return new Date().toISOString().split("T")[0];
|
|
25
32
|
}
|
|
@@ -67,10 +74,17 @@ function writeAtomic(filePath, content) {
|
|
|
67
74
|
|
|
68
75
|
/**
|
|
69
76
|
* Read the stored ceiling, whenever it was observed.
|
|
77
|
+
*
|
|
78
|
+
* A refusal expires 24 hours after it happened, matching the window the quota
|
|
79
|
+
* itself resets on. Expiring it at midnight instead — as this did before —
|
|
80
|
+
* either freed the operator hours before the provider would, or kept them
|
|
81
|
+
* blocked hours after it already had.
|
|
82
|
+
*
|
|
70
83
|
* @param {string} [root]
|
|
71
|
-
* @
|
|
84
|
+
* @param {number} [now] - Epoch ms; injectable for tests.
|
|
85
|
+
* @returns {{ ceiling: number, day: string, observedAt: string, source: string, stale: boolean, expiresAt: string, msRemaining: number } | null}
|
|
72
86
|
*/
|
|
73
|
-
export function readObservedCeiling(root = resolveRoot()) {
|
|
87
|
+
export function readObservedCeiling(root = resolveRoot(), now = Date.now()) {
|
|
74
88
|
const filePath = join(getStateDir(root), CEILING_FILE);
|
|
75
89
|
if (!existsSync(filePath)) return null;
|
|
76
90
|
try {
|
|
@@ -78,12 +92,24 @@ export function readObservedCeiling(root = resolveRoot()) {
|
|
|
78
92
|
if (!parsed || typeof parsed.ceiling !== "number" || !Number.isFinite(parsed.ceiling)) return null;
|
|
79
93
|
if (parsed.ceiling < 0) return null;
|
|
80
94
|
const day = typeof parsed.day === "string" ? parsed.day : String(parsed.observedAt || "").slice(0, 10);
|
|
95
|
+
const observedAt = typeof parsed.observedAt === "string" ? parsed.observedAt : "";
|
|
96
|
+
|
|
97
|
+
// Records written before 0.34.0 carry only a day. Falling back to the old
|
|
98
|
+
// calendar comparison keeps them honest rather than reviving a ceiling
|
|
99
|
+
// whose age cannot be established.
|
|
100
|
+
const observedMs = Date.parse(observedAt);
|
|
101
|
+
const dated = Number.isFinite(observedMs);
|
|
102
|
+
const age = dated ? now - observedMs : Number.POSITIVE_INFINITY;
|
|
103
|
+
const stale = dated ? age >= ROLLING_WINDOW_MS : day !== today();
|
|
104
|
+
|
|
81
105
|
return {
|
|
82
106
|
ceiling: Math.floor(parsed.ceiling),
|
|
83
107
|
day,
|
|
84
|
-
observedAt
|
|
108
|
+
observedAt,
|
|
85
109
|
source: typeof parsed.source === "string" ? parsed.source : "provider-rejection",
|
|
86
|
-
stale
|
|
110
|
+
stale,
|
|
111
|
+
expiresAt: dated ? new Date(observedMs + ROLLING_WINDOW_MS).toISOString() : "",
|
|
112
|
+
msRemaining: dated ? Math.max(0, ROLLING_WINDOW_MS - age) : 0,
|
|
87
113
|
};
|
|
88
114
|
} catch (_) {
|
|
89
115
|
return null;
|
|
@@ -94,14 +120,14 @@ export function readObservedCeiling(root = resolveRoot()) {
|
|
|
94
120
|
* The ceiling only if it still applies — i.e. observed today.
|
|
95
121
|
* @param {string} [root]
|
|
96
122
|
*/
|
|
97
|
-
export function readActiveCeiling(root = resolveRoot()) {
|
|
98
|
-
const rec = readObservedCeiling(root);
|
|
123
|
+
export function readActiveCeiling(root = resolveRoot(), now = Date.now()) {
|
|
124
|
+
const rec = readObservedCeiling(root, now);
|
|
99
125
|
return rec && !rec.stale ? rec : null;
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
/**
|
|
103
129
|
* Record that the provider refused further work after `usedAtRejection` tasks
|
|
104
|
-
* were dispatched locally
|
|
130
|
+
* were dispatched locally inside the current window.
|
|
105
131
|
*
|
|
106
132
|
* Zero is a legitimate value: it means the quota was already spent elsewhere
|
|
107
133
|
* (the web UI, another machine) before this checkout dispatched anything.
|
|
@@ -166,16 +192,17 @@ export function resolveDailyLimit(config, root = resolveRoot()) {
|
|
|
166
192
|
};
|
|
167
193
|
}
|
|
168
194
|
|
|
169
|
-
// Only
|
|
170
|
-
// about
|
|
171
|
-
// operator locked out after the
|
|
195
|
+
// Only a refusal from the last 24 hours may enforce. An older one says
|
|
196
|
+
// nothing about the remaining quota, and carrying it forward would keep the
|
|
197
|
+
// operator locked out after the window had already reset.
|
|
172
198
|
const learned = readActiveCeiling(root);
|
|
173
199
|
if (learned) {
|
|
200
|
+
const hours = Math.max(1, Math.round(learned.msRemaining / 3600000));
|
|
174
201
|
return {
|
|
175
202
|
limit: learned.ceiling,
|
|
176
203
|
source: "learned",
|
|
177
204
|
certain: true,
|
|
178
|
-
note: `the provider refused further work
|
|
205
|
+
note: `the provider refused further work after ${learned.ceiling} local task(s); that refusal ages out in ~${hours}h`,
|
|
179
206
|
};
|
|
180
207
|
}
|
|
181
208
|
|
|
@@ -189,71 +216,61 @@ export function resolveDailyLimit(config, root = resolveRoot()) {
|
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
/**
|
|
192
|
-
*
|
|
219
|
+
* Resolve how many workers may run at once, and against what ceiling.
|
|
220
|
+
*
|
|
221
|
+
* The same provenance rule as {@link resolveDailyLimit}: a figure the operator
|
|
222
|
+
* stated is authoritative, a tier preset is a default. The difference is that
|
|
223
|
+
* exceeding this one is not the kit's call to refuse — the provider enforces
|
|
224
|
+
* its own slot limit, and an operator pooling several accounts legitimately
|
|
225
|
+
* runs past any single plan's ceiling. So an overrun is reported, never
|
|
226
|
+
* blocked.
|
|
227
|
+
*
|
|
228
|
+
* @param {object} config - A config from loadConfig().
|
|
229
|
+
* @returns {{ concurrency: number, ceiling: number, source: "config"|"tier", overCeiling: boolean, note: string }}
|
|
230
|
+
*/
|
|
231
|
+
export function resolveConcurrency(config) {
|
|
232
|
+
const concurrency = Math.max(1, Math.floor(Number(config?.limits?.concurrency) || 1));
|
|
233
|
+
const ceiling = Math.floor(Number(config?.limits?.maxConcurrency) || 0);
|
|
234
|
+
const source = config?.provenance?.concurrency === "config" ? "config" : "tier";
|
|
235
|
+
const overCeiling = ceiling > 0 && concurrency > ceiling;
|
|
236
|
+
const tier = config?.tier || "unknown";
|
|
237
|
+
|
|
238
|
+
let note;
|
|
239
|
+
if (overCeiling) {
|
|
240
|
+
note = `${concurrency} workers exceeds what the "${tier}" plan allows (${ceiling}); sessions past the ${ceiling}th will be refused unless the account pools several plans`;
|
|
241
|
+
} else if (source === "config") {
|
|
242
|
+
note = ceiling > 0 ? `set in .agent/config.yml (plan allows up to ${ceiling})` : "set in .agent/config.yml";
|
|
243
|
+
} else {
|
|
244
|
+
note = ceiling > 0
|
|
245
|
+
? `tier default for "${tier}" — the plan allows up to ${ceiling}, held back to leave slots for sessions this ledger cannot see`
|
|
246
|
+
: `tier default for "${tier}"`;
|
|
247
|
+
}
|
|
248
|
+
return { concurrency, ceiling, source, overCeiling, note };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* List reservations the rolling 24-hour window still counts as spent.
|
|
193
253
|
*
|
|
194
254
|
* A reservation is open until a `budget_rolled_back` or `budget_released` entry
|
|
195
255
|
* names it. `budget_committed` deliberately does not close one — a committed
|
|
196
256
|
* dispatch really did consume quota — so the open set is "everything reserved
|
|
197
|
-
*
|
|
257
|
+
* inside the window that was not given back".
|
|
198
258
|
*
|
|
199
|
-
* Anonymous reservations (no `reservationId`, as older kit versions
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* is nothing a targeted release could name. They are matched by count, the same
|
|
204
|
-
* way the counter itself treats them.
|
|
259
|
+
* Anonymous reservations (no `reservationId`, as older kit versions wrote them)
|
|
260
|
+
* appear as records with `reservationId: null`. They must, or a reconcile would
|
|
261
|
+
* silently leave them charged: the counter counts them, and with no id there is
|
|
262
|
+
* nothing a targeted release could name.
|
|
205
263
|
*
|
|
206
264
|
* @param {string} [root]
|
|
265
|
+
* @param {object} [opts] - Forwarded to scanBudgetWindow (`now`, `windowMs`).
|
|
207
266
|
* @returns {{ reservationId: string|null, timestamp: string, committed: boolean }[]}
|
|
208
267
|
*/
|
|
209
|
-
export function listOpenReservations(root = resolveRoot()) {
|
|
210
|
-
|
|
211
|
-
if (!existsSync(filePath)) return [];
|
|
212
|
-
|
|
213
|
-
/** @type {Map<string, { reservationId: string, timestamp: string, committed: boolean }>} */
|
|
214
|
-
const open = new Map();
|
|
215
|
-
/** @type {{ reservationId: null, timestamp: string, committed: boolean }[]} */
|
|
216
|
-
const anonymous = [];
|
|
217
|
-
let raw = "";
|
|
218
|
-
try {
|
|
219
|
-
raw = readFileSync(filePath, "utf-8");
|
|
220
|
-
} catch (_) {
|
|
221
|
-
return [];
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
for (const line of raw.split("\n").filter(Boolean)) {
|
|
225
|
-
let entry;
|
|
226
|
-
try {
|
|
227
|
-
entry = JSON.parse(line);
|
|
228
|
-
} catch (_) {
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
if (!entry || !entry.event) continue;
|
|
232
|
-
|
|
233
|
-
if (entry.event === "budget_reserved") {
|
|
234
|
-
if (entry.reservationId) {
|
|
235
|
-
open.set(entry.reservationId, {
|
|
236
|
-
reservationId: entry.reservationId,
|
|
237
|
-
timestamp: entry.timestamp || "",
|
|
238
|
-
committed: false,
|
|
239
|
-
});
|
|
240
|
-
} else {
|
|
241
|
-
anonymous.push({ reservationId: null, timestamp: entry.timestamp || "", committed: false });
|
|
242
|
-
}
|
|
243
|
-
} else if (entry.event === "budget_committed") {
|
|
244
|
-
const rec = entry.reservationId ? open.get(entry.reservationId) : null;
|
|
245
|
-
if (rec) rec.committed = true;
|
|
246
|
-
} else if (entry.event === "budget_rolled_back" || entry.event === "budget_released") {
|
|
247
|
-
if (entry.reservationId) open.delete(entry.reservationId);
|
|
248
|
-
else anonymous.pop();
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return [...open.values(), ...anonymous];
|
|
268
|
+
export function listOpenReservations(root = resolveRoot(), opts = {}) {
|
|
269
|
+
return scanBudgetWindow(root, opts).open;
|
|
253
270
|
}
|
|
254
271
|
|
|
255
272
|
/**
|
|
256
|
-
* Give
|
|
273
|
+
* Give the window's open reservations back, by appending `budget_released` entries.
|
|
257
274
|
*
|
|
258
275
|
* The ledger is append-only and hash-chained, so a miscounted day is corrected
|
|
259
276
|
* forwards — never by editing or deleting the file, which would break the chain
|
|
@@ -286,10 +303,16 @@ export function releaseOpenReservations(opts = {}) {
|
|
|
286
303
|
|
|
287
304
|
for (const rec of openRecords) {
|
|
288
305
|
const entry = { event: "budget_released", reason: opts.reason || "operator-reconcile" };
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
if (rec.reservationId) {
|
|
307
|
+
entry.reservationId = rec.reservationId;
|
|
308
|
+
} else {
|
|
309
|
+
// An anonymous reservation is released by an equally anonymous entry —
|
|
310
|
+
// naming an id here would leave the original charged and subtract from
|
|
311
|
+
// someone else's total instead. The timestamp pins which one it cancels,
|
|
312
|
+
// so the pair does not drift apart as the rolling window advances past
|
|
313
|
+
// the reservation but not yet past its release.
|
|
314
|
+
entry.releasedTimestamp = rec.timestamp;
|
|
315
|
+
}
|
|
293
316
|
appendLedger(entry, root);
|
|
294
317
|
}
|
|
295
318
|
return result;
|
|
@@ -311,6 +334,10 @@ export function budgetStatus(config, root = resolveRoot()) {
|
|
|
311
334
|
source: resolved.source,
|
|
312
335
|
certain: resolved.certain,
|
|
313
336
|
note: resolved.note,
|
|
337
|
+
// The count is a rolling 24h window, not a calendar day — surfaced so a
|
|
338
|
+
// caller reporting "used today" cannot quietly mean something else.
|
|
339
|
+
windowStart: check.windowStart || "",
|
|
340
|
+
windowHours: ROLLING_WINDOW_MS / 3600000,
|
|
314
341
|
// Only a limit we actually know may stop a dispatch.
|
|
315
342
|
enforced: resolved.certain,
|
|
316
343
|
exhausted: check.used >= resolved.limit,
|
package/src/config.mjs
CHANGED
|
@@ -281,35 +281,63 @@ export function resolveVerify(root = process.cwd(), userVerify = {}) {
|
|
|
281
281
|
* guess — see `isTierGuess()` and the learned-ceiling logic in src/state.mjs.
|
|
282
282
|
* An explicit `limits.daily_tasks` in .agent/config.yml always wins.
|
|
283
283
|
*/
|
|
284
|
+
/**
|
|
285
|
+
* Per-tier defaults, with the vendor's own ceiling recorded alongside them.
|
|
286
|
+
*
|
|
287
|
+
* `maxConcurrency` is what the plan allows; `concurrency` is what the kit will
|
|
288
|
+
* start by using. They differ on purpose, for the same reason the daily count
|
|
289
|
+
* is a lower bound: this ledger sees one checkout, while the account's slots
|
|
290
|
+
* are also taken by the web UI, the CLI and other machines. A default sitting
|
|
291
|
+
* on the ceiling would collide with every session the kit cannot see.
|
|
292
|
+
*
|
|
293
|
+
* The defaults used to sit at 1/2/3 against ceilings of 3/15/60 — a Pro
|
|
294
|
+
* account running two workers where it could run fifteen. Raising them is the
|
|
295
|
+
* single largest throughput change available; leaving headroom is what keeps
|
|
296
|
+
* it from being reckless. An operator who knows their account is theirs alone
|
|
297
|
+
* can state `limits.concurrency` up to the ceiling.
|
|
298
|
+
*
|
|
299
|
+
* Ceilings verified against the vendor's published limits page
|
|
300
|
+
* (jules.google/docs/usage-limits, 2026-08-20). They are a vendor number and
|
|
301
|
+
* may change without notice. The URL is written without a scheme on purpose:
|
|
302
|
+
* test/egress-allowlist.test.mjs treats every host literal in shipped source
|
|
303
|
+
* as a destination this kit might contact, and a citation is not one.
|
|
304
|
+
*/
|
|
284
305
|
export const TIER_PRESETS = {
|
|
285
306
|
free: {
|
|
286
307
|
dailyTasks: 15,
|
|
287
308
|
repairAttempts: 1,
|
|
288
|
-
|
|
309
|
+
// The whole allowance is 15 tasks a day; there is no headroom worth
|
|
310
|
+
// reserving, so the default is the ceiling.
|
|
311
|
+
concurrency: 3,
|
|
312
|
+
maxConcurrency: 3,
|
|
289
313
|
staggerMs: 3000,
|
|
290
314
|
diffKb: 50,
|
|
291
315
|
},
|
|
292
316
|
pro: {
|
|
293
317
|
dailyTasks: 100,
|
|
294
318
|
repairAttempts: 2,
|
|
295
|
-
concurrency:
|
|
319
|
+
concurrency: 8,
|
|
320
|
+
maxConcurrency: 15,
|
|
296
321
|
staggerMs: 1500,
|
|
297
322
|
diffKb: 75,
|
|
298
323
|
},
|
|
299
324
|
ultra: {
|
|
300
325
|
dailyTasks: 300,
|
|
301
326
|
repairAttempts: 3,
|
|
302
|
-
concurrency:
|
|
327
|
+
concurrency: 15,
|
|
328
|
+
maxConcurrency: 60,
|
|
303
329
|
staggerMs: 1000,
|
|
304
330
|
diffKb: 75,
|
|
305
331
|
},
|
|
306
332
|
// Not a vendor plan: a self-hosted/pooled profile for operators who front
|
|
307
333
|
// several accounts. Defined here so `tier: enterprise` resolves to what the
|
|
308
|
-
// wizard writes instead of silently collapsing onto the ultra preset.
|
|
334
|
+
// wizard writes instead of silently collapsing onto the ultra preset. Its
|
|
335
|
+
// ceiling is whatever the pool adds up to, so the kit does not claim one.
|
|
309
336
|
enterprise: {
|
|
310
337
|
dailyTasks: 1000,
|
|
311
338
|
repairAttempts: 3,
|
|
312
339
|
concurrency: 10,
|
|
340
|
+
maxConcurrency: 0,
|
|
313
341
|
staggerMs: 500,
|
|
314
342
|
diffKb: 100,
|
|
315
343
|
},
|
|
@@ -420,6 +448,7 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
420
448
|
: normalizedLimits.dailyTasks !== undefined
|
|
421
449
|
? "config"
|
|
422
450
|
: "tier",
|
|
451
|
+
concurrency: normalizedLimits.concurrency !== undefined ? "config" : "tier",
|
|
423
452
|
},
|
|
424
453
|
isolation: parsed.isolation || DEFAULTS.isolation,
|
|
425
454
|
runner: parsed.runner || DEFAULTS.runner,
|
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { loadConfig } from "../config.mjs";
|
|
6
|
+
import { resolveConcurrency } from "../budget.mjs";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* @typedef {"pass" | "warn" | "fail" | "skip" | "unknown"} DiagnosticStatus
|
|
@@ -261,6 +263,41 @@ export async function runDoctorChecks(options = {}) {
|
|
|
261
263
|
});
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
// 3b. Worker slots against the plan's ceiling.
|
|
267
|
+
//
|
|
268
|
+
// A warning, never a failure: the kit cannot see the account, only the
|
|
269
|
+
// config. Pooled accounts legitimately exceed a single plan's ceiling, and
|
|
270
|
+
// the provider refuses what it will not allow regardless of what we think.
|
|
271
|
+
try {
|
|
272
|
+
const cfg = loadConfig(root);
|
|
273
|
+
const slots = resolveConcurrency(cfg);
|
|
274
|
+
addResult({
|
|
275
|
+
id: "limits.concurrency",
|
|
276
|
+
category: "Config",
|
|
277
|
+
title: "Worker Slots vs Plan Ceiling",
|
|
278
|
+
status: slots.overCeiling ? "warn" : "pass",
|
|
279
|
+
severity: slots.overCeiling ? "medium" : "info",
|
|
280
|
+
summary: `${slots.concurrency} concurrent worker(s) — ${slots.note}`,
|
|
281
|
+
evidence: [
|
|
282
|
+
{ label: "concurrency", value: slots.concurrency, sensitive: false },
|
|
283
|
+
{ label: "planCeiling", value: slots.ceiling, sensitive: false },
|
|
284
|
+
{ label: "source", value: slots.source, sensitive: false },
|
|
285
|
+
],
|
|
286
|
+
remediation: slots.overCeiling
|
|
287
|
+
? [
|
|
288
|
+
{
|
|
289
|
+
summary: `Set limits.concurrency to ${slots.ceiling} or lower in .agent/config.yml, unless this account pools several plans`,
|
|
290
|
+
risk: "low",
|
|
291
|
+
automatic: false,
|
|
292
|
+
requiresProbe: false,
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
: [],
|
|
296
|
+
});
|
|
297
|
+
} catch (_) {
|
|
298
|
+
// A config the loader rejects is already reported by config.present.
|
|
299
|
+
}
|
|
300
|
+
|
|
264
301
|
// 4. Verification Oracle Checks
|
|
265
302
|
const pkgPath = join(root, "package.json");
|
|
266
303
|
if (existsSync(pkgPath)) {
|
package/src/state.mjs
CHANGED
|
@@ -55,6 +55,129 @@ export function getDailyLedgerPath(rootOrOpts = resolveRoot()) {
|
|
|
55
55
|
return join(getStateDir(root), `ledger-${dateStr}.jsonl`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How far back a task still counts against the allowance.
|
|
60
|
+
*
|
|
61
|
+
* The provider's daily quota resets on a rolling 24-hour window, not at
|
|
62
|
+
* midnight. Counting per calendar day — which the ledger's `ledger-<date>`
|
|
63
|
+
* rotation invites — is wrong in both directions: a batch dispatched at 23:00
|
|
64
|
+
* stops being counted at 00:01 while the provider still refuses on it, and
|
|
65
|
+
* yesterday's last hours vanish from a count that should still include them.
|
|
66
|
+
*
|
|
67
|
+
* The files stay day-scoped, because rotation is a storage concern. Counting
|
|
68
|
+
* is not: it spans whatever files the window touches and filters on entry
|
|
69
|
+
* timestamps.
|
|
70
|
+
*/
|
|
71
|
+
export const ROLLING_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
72
|
+
|
|
73
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ledger files that can hold entries inside the rolling window, oldest first.
|
|
77
|
+
*
|
|
78
|
+
* One day wider than the window itself: an entry timestamped 23:59 UTC sits in
|
|
79
|
+
* that day's file, and a window opening moments later still has to see it.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} [root]
|
|
82
|
+
* @param {number} [now] - Epoch ms; injectable so tests need not wait a day.
|
|
83
|
+
* @param {number} [windowMs]
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
*/
|
|
86
|
+
export function getLedgerPathsInWindow(root = resolveRoot(), now = Date.now(), windowMs = ROLLING_WINDOW_MS) {
|
|
87
|
+
const stateDir = getStateDir(root);
|
|
88
|
+
const spanDays = Math.ceil(windowMs / DAY_MS) + 1;
|
|
89
|
+
const paths = [];
|
|
90
|
+
for (let i = spanDays - 1; i >= 0; i--) {
|
|
91
|
+
const dateStr = new Date(now - i * DAY_MS).toISOString().split("T")[0];
|
|
92
|
+
const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
|
|
93
|
+
if (!paths.includes(filePath) && existsSync(filePath)) paths.push(filePath);
|
|
94
|
+
}
|
|
95
|
+
return paths;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Replay the budget events in the rolling window and report what is still spent.
|
|
100
|
+
*
|
|
101
|
+
* Reservations carrying a `reservationId` are matched by name. Legacy id-less
|
|
102
|
+
* ones — written by older kit versions — can only be matched by position, so a
|
|
103
|
+
* release without a `releasedTimestamp` consumes the oldest still-open
|
|
104
|
+
* anonymous reservation. `releaseOpenReservations` records that timestamp
|
|
105
|
+
* precisely so the pairing survives the window boundary: without it, an
|
|
106
|
+
* anonymous release could outlive the reservation it cancelled and start
|
|
107
|
+
* subtracting from a later one instead.
|
|
108
|
+
*
|
|
109
|
+
* Entries whose timestamp will not parse are counted. A budget event the kit
|
|
110
|
+
* cannot place in time is safer treated as spent than as free.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} [root]
|
|
113
|
+
* @param {object} [opts]
|
|
114
|
+
* @param {number} [opts.now]
|
|
115
|
+
* @param {number} [opts.windowMs]
|
|
116
|
+
* @returns {{ used: number, open: { reservationId: string|null, timestamp: string, committed: boolean }[], windowStart: string }}
|
|
117
|
+
*/
|
|
118
|
+
export function scanBudgetWindow(root = resolveRoot(), opts = {}) {
|
|
119
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
120
|
+
const windowMs = Number.isFinite(opts.windowMs) ? opts.windowMs : ROLLING_WINDOW_MS;
|
|
121
|
+
const cutoff = now - windowMs;
|
|
122
|
+
|
|
123
|
+
/** @type {Map<string, { reservationId: string, timestamp: string, committed: boolean, inWindow: boolean }>} */
|
|
124
|
+
const byId = new Map();
|
|
125
|
+
/** @type {{ reservationId: null, timestamp: string, committed: boolean, inWindow: boolean }[]} */
|
|
126
|
+
const anonymous = [];
|
|
127
|
+
|
|
128
|
+
const inWindow = (timestamp) => {
|
|
129
|
+
const ts = Date.parse(timestamp || "");
|
|
130
|
+
return Number.isFinite(ts) ? ts >= cutoff : true;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const filePath of getLedgerPathsInWindow(root, now, windowMs)) {
|
|
134
|
+
let raw = "";
|
|
135
|
+
try {
|
|
136
|
+
raw = readFileSync(filePath, "utf-8");
|
|
137
|
+
} catch (_) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
for (const line of raw.split("\n")) {
|
|
141
|
+
if (!line) continue;
|
|
142
|
+
let entry;
|
|
143
|
+
try {
|
|
144
|
+
entry = JSON.parse(line);
|
|
145
|
+
} catch (_) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!entry || !entry.event) continue;
|
|
149
|
+
const timestamp = entry.timestamp || "";
|
|
150
|
+
|
|
151
|
+
if (entry.event === "budget_reserved") {
|
|
152
|
+
const record = { timestamp, committed: false, inWindow: inWindow(timestamp) };
|
|
153
|
+
if (entry.reservationId) byId.set(entry.reservationId, { reservationId: entry.reservationId, ...record });
|
|
154
|
+
else anonymous.push({ reservationId: null, ...record });
|
|
155
|
+
} else if (entry.event === "budget_committed") {
|
|
156
|
+
// A commit does not free the slot — the task really ran — it only marks
|
|
157
|
+
// the reservation as having reached the provider.
|
|
158
|
+
const record = entry.reservationId ? byId.get(entry.reservationId) : null;
|
|
159
|
+
if (record) record.committed = true;
|
|
160
|
+
} else if (entry.event === "budget_rolled_back" || entry.event === "budget_released") {
|
|
161
|
+
if (entry.reservationId) {
|
|
162
|
+
byId.delete(entry.reservationId);
|
|
163
|
+
} else if (entry.releasedTimestamp) {
|
|
164
|
+
const idx = anonymous.findIndex((r) => r.timestamp === entry.releasedTimestamp);
|
|
165
|
+
if (idx !== -1) anonymous.splice(idx, 1);
|
|
166
|
+
} else {
|
|
167
|
+
anonymous.shift();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const open = [...byId.values(), ...anonymous].filter((r) => r.inWindow);
|
|
174
|
+
return {
|
|
175
|
+
used: open.length,
|
|
176
|
+
open: open.map(({ reservationId, timestamp, committed }) => ({ reservationId, timestamp, committed })),
|
|
177
|
+
windowStart: new Date(cutoff).toISOString(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
58
181
|
export class MutexTimeoutError extends Error {
|
|
59
182
|
constructor(message = "Failed to acquire VFS mutex lock within timeout") {
|
|
60
183
|
super(message);
|
|
@@ -187,43 +310,24 @@ export function readLedger(filePath) {
|
|
|
187
310
|
}
|
|
188
311
|
}
|
|
189
312
|
|
|
190
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Tasks still counted against the allowance, over the rolling 24-hour window.
|
|
315
|
+
*
|
|
316
|
+
* The name is kept for compatibility; "daily" here means the provider's day,
|
|
317
|
+
* which is the last 24 hours rather than the calendar one.
|
|
318
|
+
*/
|
|
319
|
+
export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300, opts = {}) {
|
|
191
320
|
let root = typeof arg1 === "string" ? arg1 : resolveRoot();
|
|
192
321
|
let limit = typeof arg1 === "number" ? arg1 : typeof arg2 === "number" ? arg2 : 300;
|
|
193
322
|
|
|
194
|
-
const filePath = getDailyLedgerPath(root);
|
|
195
|
-
if (!existsSync(filePath)) {
|
|
196
|
-
return { ok: true, used: 0, budget: limit, remaining: limit };
|
|
197
|
-
}
|
|
198
323
|
try {
|
|
199
|
-
const
|
|
200
|
-
const lines = content.split("\n").filter(Boolean);
|
|
201
|
-
let count = 0;
|
|
202
|
-
const activeIds = new Set();
|
|
203
|
-
for (const line of lines) {
|
|
204
|
-
try {
|
|
205
|
-
const entry = JSON.parse(line);
|
|
206
|
-
if (entry && entry.event === "budget_reserved") {
|
|
207
|
-
if (entry.reservationId) {
|
|
208
|
-
activeIds.add(entry.reservationId);
|
|
209
|
-
} else {
|
|
210
|
-
count++;
|
|
211
|
-
}
|
|
212
|
-
} else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
|
|
213
|
-
if (entry.reservationId) {
|
|
214
|
-
activeIds.delete(entry.reservationId);
|
|
215
|
-
} else {
|
|
216
|
-
count = Math.max(0, count - 1);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
} catch (_) {}
|
|
220
|
-
}
|
|
221
|
-
const used = count + activeIds.size;
|
|
324
|
+
const scan = scanBudgetWindow(root, opts);
|
|
222
325
|
return {
|
|
223
|
-
ok: used < limit,
|
|
224
|
-
used,
|
|
326
|
+
ok: scan.used < limit,
|
|
327
|
+
used: scan.used,
|
|
225
328
|
budget: limit,
|
|
226
|
-
remaining: Math.max(0, limit - used),
|
|
329
|
+
remaining: Math.max(0, limit - scan.used),
|
|
330
|
+
windowStart: scan.windowStart,
|
|
227
331
|
};
|
|
228
332
|
} catch (_) {
|
|
229
333
|
return { ok: true, used: 0, budget: limit, remaining: limit };
|
|
@@ -244,42 +348,32 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
244
348
|
const mutexDir = join(stateDir, ".budget.mutex");
|
|
245
349
|
|
|
246
350
|
return withVfsMutex(mutexDir, () => {
|
|
247
|
-
const
|
|
351
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
352
|
+
const dateStr = new Date(now).toISOString().split("T")[0];
|
|
248
353
|
const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
|
|
249
354
|
|
|
250
|
-
|
|
251
|
-
|
|
355
|
+
// The count spans the rolling window; the hash chain does not. A chain is
|
|
356
|
+
// per file, so the new entry links to today's last hash even when the
|
|
357
|
+
// reservations it is counted against were written yesterday.
|
|
358
|
+
const used = scanBudgetWindow(root, { ...opts, now }).used;
|
|
252
359
|
let prevHash = "0".repeat(64);
|
|
253
360
|
|
|
254
361
|
if (existsSync(filePath)) {
|
|
255
362
|
try {
|
|
256
363
|
const raw = readFileSync(filePath, "utf-8");
|
|
257
364
|
const lines = raw.split("\n").filter(Boolean);
|
|
258
|
-
for (
|
|
365
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
259
366
|
try {
|
|
260
|
-
const entry = JSON.parse(
|
|
261
|
-
if (entry && entry.event === "budget_reserved") {
|
|
262
|
-
if (entry.reservationId) {
|
|
263
|
-
activeIds.add(entry.reservationId);
|
|
264
|
-
} else {
|
|
265
|
-
count++;
|
|
266
|
-
}
|
|
267
|
-
} else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
|
|
268
|
-
if (entry.reservationId) {
|
|
269
|
-
activeIds.delete(entry.reservationId);
|
|
270
|
-
} else {
|
|
271
|
-
count = Math.max(0, count - 1);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
367
|
+
const entry = JSON.parse(lines[i]);
|
|
274
368
|
if (entry && entry.hash) {
|
|
275
369
|
prevHash = entry.hash;
|
|
370
|
+
break;
|
|
276
371
|
}
|
|
277
372
|
} catch (_) {}
|
|
278
373
|
}
|
|
279
374
|
} catch (_) {}
|
|
280
375
|
}
|
|
281
376
|
|
|
282
|
-
const used = count + activeIds.size;
|
|
283
377
|
// `enforce: false` marks a limit the kit only guessed (a tier preset rather
|
|
284
378
|
// than a stated or provider-demonstrated figure). Blocking on a guess would
|
|
285
379
|
// refuse work the provider would happily have accepted, so an uncertain
|
|
@@ -289,8 +383,8 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
289
383
|
throw new BudgetError(`Daily budget exhausted (${used}/${limit} tasks executed)`);
|
|
290
384
|
}
|
|
291
385
|
|
|
292
|
-
const timestamp = new Date().toISOString();
|
|
293
|
-
const reservationId = `res-${
|
|
386
|
+
const timestamp = new Date(now).toISOString();
|
|
387
|
+
const reservationId = `res-${now}-${Math.random().toString(36).substring(2, 8)}`;
|
|
294
388
|
const rawPayload = { timestamp, event: "budget_reserved", reservationId, budget: limit, prevHash };
|
|
295
389
|
const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
|
|
296
390
|
const payload = { ...rawPayload, hash };
|
package/src/wizard-init.mjs
CHANGED
|
@@ -60,10 +60,15 @@ export function tierOptions() {
|
|
|
60
60
|
return order.map((name) => {
|
|
61
61
|
const p = TIER_PRESETS[name];
|
|
62
62
|
const worker = p.concurrency === 1 ? "worker" : "workers";
|
|
63
|
+
// The ceiling is shown next to the default so the number the wizard writes
|
|
64
|
+
// reads as a starting point rather than as the plan's limit.
|
|
65
|
+
const slots = p.maxConcurrency > p.concurrency
|
|
66
|
+
? `${p.concurrency} ${worker} (plan allows ${p.maxConcurrency})`
|
|
67
|
+
: `${p.concurrency} ${worker}`;
|
|
63
68
|
return {
|
|
64
69
|
label: TIER_LABELS[name] || name,
|
|
65
70
|
value: name,
|
|
66
|
-
description: `${
|
|
71
|
+
description: `${slots}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
|
|
67
72
|
};
|
|
68
73
|
});
|
|
69
74
|
}
|