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 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 **498 unit tests across 75 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.
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 # Daily task session quota limit
300
+ dailyTasks: 300 # Task quota per rolling 24h window (not per calendar day)
301
301
  repairAttempts: 3 # Maximum OODA repair iterations
302
- concurrency: 1 # Worker slot concurrency limit
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 today`;
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 today's task budget and its provenance (reset --yes to reconcile)
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 today's ledger.`);
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
- console.log(`Daily Budget : ${formatBudgetLine(b)}`);
359
+ const slots = resolveConcurrency(config);
360
+ console.log(`Task Budget : ${formatBudgetLine(b)}`);
357
361
  console.log(` ${b.note}`);
358
- console.log(` Open reservations today: ${listOpenReservations(root).length}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
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 { getStateDir, getDailyLedgerPath, ensureDir, appendLedger, checkDailyBudget } from "./state.mjs";
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 scoped to the day it was observed. The local ledger only counts
11
- * tasks dispatched from *this* checkout, while the account's quota is also
12
- * spent from the web UI and other machines, so the local count at the moment of
13
- * a refusal is a lower bound on the real allowance — not the allowance. Treated
14
- * as permanent it would hard-block the operator below their own quota, which is
15
- * the exact failure this whole mechanism exists to prevent.
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: *today* the provider has started
18
- * refusing, so stop asking until tomorrow.
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, matching the ledger's own rotation key. */
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
- * @returns {{ ceiling: number, day: string, observedAt: string, source: string, stale: boolean } | null}
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: typeof parsed.observedAt === "string" ? parsed.observedAt : "",
108
+ observedAt,
85
109
  source: typeof parsed.source === "string" ? parsed.source : "provider-rejection",
86
- stale: day !== today(),
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 today.
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 today's observation may enforce. Yesterday's refusal says nothing
170
- // about today's remaining quota, and carrying it forward would keep the
171
- // operator locked out after the quota reset.
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 today after ${learned.ceiling} local task(s); resets tomorrow`,
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
- * List reservations that today's ledger still counts as spent.
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
- * today that was not given back".
257
+ * inside the window that was not given back".
198
258
  *
199
- * Anonymous reservations (no `reservationId`, as older kit versions and
200
- * scripts/utils.mjs wrote them) are counted too, as records with
201
- * `reservationId: null`. They must be, or a reconcile would silently leave them
202
- * charged against the day: `checkDailyBudget` counts them, and with no id there
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
- const filePath = getDailyLedgerPath(root);
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 today's open reservations back, by appending `budget_released` entries.
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
- // An anonymous reservation is released by an equally anonymous entry: the
290
- // counter decrements those by count, so naming an id here would leave the
291
- // original charged and subtract from someone else's total instead.
292
- if (rec.reservationId) entry.reservationId = rec.reservationId;
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
- concurrency: 1,
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: 2,
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: 3,
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
- export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
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 content = readFileSync(filePath, "utf-8");
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 dateStr = new Date().toISOString().split("T")[0];
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
- let count = 0;
251
- const activeIds = new Set();
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 (const line of lines) {
365
+ for (let i = lines.length - 1; i >= 0; i--) {
259
366
  try {
260
- const entry = JSON.parse(line);
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-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
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 };
@@ -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: `${p.concurrency} ${worker}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
71
+ description: `${slots}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
67
72
  };
68
73
  });
69
74
  }