jules-orchestrator-kit 0.32.8 → 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/src/budget.mjs ADDED
@@ -0,0 +1,345 @@
1
+ import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveRoot } from "./config.mjs";
4
+ import {
5
+ getStateDir,
6
+ ensureDir,
7
+ appendLedger,
8
+ checkDailyBudget,
9
+ scanBudgetWindow,
10
+ ROLLING_WINDOW_MS,
11
+ } from "./state.mjs";
12
+
13
+ /**
14
+ * Where the observed quota ceiling lives, outside the ledger so it survives
15
+ * rotation.
16
+ *
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.
23
+ *
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.
26
+ */
27
+ export const CEILING_FILE = "budget-ceiling.json";
28
+
29
+ /** Local calendar day, retained as the fallback key for pre-0.34 records. */
30
+ function today() {
31
+ return new Date().toISOString().split("T")[0];
32
+ }
33
+
34
+ /**
35
+ * Signals that a provider rejection means "you are out of quota for today"
36
+ * rather than "you are going too fast right now". A 429 alone cannot tell the
37
+ * two apart, and learning a ceiling from a per-minute throttle would pin the
38
+ * daily limit to whatever burst happened to trip it.
39
+ */
40
+ const DAILY_QUOTA_HINT = /resource[_\s-]?exhausted|daily|per[\s-]?day|quota/i;
41
+ const TRANSIENT_HINT = /per[\s-]?minute|per[\s-]?second|too many requests|slow down|retry[\s-]?after/i;
42
+
43
+ /**
44
+ * Decide whether a provider error is evidence of a daily quota ceiling.
45
+ *
46
+ * Deliberately conservative: an unrecognised rejection teaches nothing. A
47
+ * missed lesson costs one wasted API call, whereas a ceiling learned from a
48
+ * burst throttle would be recorded as certain and would then hard-block the
49
+ * operator well below their real allowance.
50
+ *
51
+ * @param {unknown} err
52
+ * @returns {boolean}
53
+ */
54
+ export function isDailyQuotaRejection(err) {
55
+ if (!err || typeof err !== "object") return false;
56
+ const status = /** @type {any} */ (err).status;
57
+ if (status !== 429 && status !== 403) return false;
58
+ const text = `${/** @type {any} */ (err).message || ""} ${/** @type {any} */ (err).body || ""}`;
59
+ if (TRANSIENT_HINT.test(text)) return false;
60
+ return DAILY_QUOTA_HINT.test(text);
61
+ }
62
+
63
+ function writeAtomic(filePath, content) {
64
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
65
+ const fd = openSync(tmpPath, "w");
66
+ try {
67
+ writeFileSync(fd, content, "utf-8");
68
+ fsyncSync(fd);
69
+ } finally {
70
+ closeSync(fd);
71
+ }
72
+ renameSync(tmpPath, filePath);
73
+ }
74
+
75
+ /**
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
+ *
83
+ * @param {string} [root]
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}
86
+ */
87
+ export function readObservedCeiling(root = resolveRoot(), now = Date.now()) {
88
+ const filePath = join(getStateDir(root), CEILING_FILE);
89
+ if (!existsSync(filePath)) return null;
90
+ try {
91
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
92
+ if (!parsed || typeof parsed.ceiling !== "number" || !Number.isFinite(parsed.ceiling)) return null;
93
+ if (parsed.ceiling < 0) return null;
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
+
105
+ return {
106
+ ceiling: Math.floor(parsed.ceiling),
107
+ day,
108
+ observedAt,
109
+ source: typeof parsed.source === "string" ? parsed.source : "provider-rejection",
110
+ stale,
111
+ expiresAt: dated ? new Date(observedMs + ROLLING_WINDOW_MS).toISOString() : "",
112
+ msRemaining: dated ? Math.max(0, ROLLING_WINDOW_MS - age) : 0,
113
+ };
114
+ } catch (_) {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * The ceiling only if it still applies — i.e. observed today.
121
+ * @param {string} [root]
122
+ */
123
+ export function readActiveCeiling(root = resolveRoot(), now = Date.now()) {
124
+ const rec = readObservedCeiling(root, now);
125
+ return rec && !rec.stale ? rec : null;
126
+ }
127
+
128
+ /**
129
+ * Record that the provider refused further work after `usedAtRejection` tasks
130
+ * were dispatched locally inside the current window.
131
+ *
132
+ * Zero is a legitimate value: it means the quota was already spent elsewhere
133
+ * (the web UI, another machine) before this checkout dispatched anything.
134
+ *
135
+ * @param {number} usedAtRejection - Tasks reserved locally when the refusal came.
136
+ * @param {string} [root]
137
+ * @param {object} [meta]
138
+ * @returns {{ ceiling: number, day: string, observedAt: string, source: string } | null}
139
+ */
140
+ export function recordObservedCeiling(usedAtRejection, root = resolveRoot(), meta = {}) {
141
+ const ceiling = Math.floor(Number(usedAtRejection));
142
+ if (!Number.isFinite(ceiling) || ceiling < 0) return null;
143
+
144
+ const stateDir = getStateDir(root);
145
+ ensureDir(stateDir);
146
+ const record = {
147
+ ceiling,
148
+ day: today(),
149
+ observedAt: new Date().toISOString(),
150
+ source: meta.source || "provider-rejection",
151
+ };
152
+ writeAtomic(join(stateDir, CEILING_FILE), JSON.stringify(record, null, 2) + "\n");
153
+
154
+ // Mirrored into the hash-chained ledger so the change is auditable; the JSON
155
+ // file above is only a cheap index that survives ledger rotation.
156
+ try {
157
+ appendLedger({ event: "budget_ceiling_observed", ceiling, source: record.source }, root);
158
+ } catch (_) {}
159
+
160
+ return record;
161
+ }
162
+
163
+ /**
164
+ * Resolve today's effective task limit *and how much we trust it*.
165
+ *
166
+ * Precedence, most to least authoritative:
167
+ * 1. `limits.daily_tasks` written explicitly in .agent/config.yml, or
168
+ * JULES_DAILY_BUDGET — the operator stating their own plan.
169
+ * 2. A ceiling the provider demonstrated by refusing work.
170
+ * 3. The tier preset — a guess, and marked as one.
171
+ *
172
+ * `certain` is what callers must branch on: an uncertain limit may warn but
173
+ * must not hard-block, because refusing a request the provider would have
174
+ * accepted breaks the tool for anyone whose plan we guessed wrong.
175
+ *
176
+ * @param {object} config - A config from loadConfig().
177
+ * @param {string} [root]
178
+ * @returns {{ limit: number, source: "config"|"env"|"learned"|"tier"|"default", certain: boolean, note: string }}
179
+ */
180
+ export function resolveDailyLimit(config, root = resolveRoot()) {
181
+ const provenance = config?.provenance?.dailyTasks || "default";
182
+ const configured = Number(config?.limits?.dailyTasks);
183
+
184
+ // `>= 0`, not `> 0`: a limit of zero is a deliberate "dispatch nothing", and
185
+ // treating it as absent would silently fall through to a permissive estimate.
186
+ if ((provenance === "config" || provenance === "env") && Number.isFinite(configured) && configured >= 0) {
187
+ return {
188
+ limit: configured,
189
+ source: provenance,
190
+ certain: true,
191
+ note: provenance === "env" ? "set via JULES_DAILY_BUDGET" : "set in .agent/config.yml",
192
+ };
193
+ }
194
+
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.
198
+ const learned = readActiveCeiling(root);
199
+ if (learned) {
200
+ const hours = Math.max(1, Math.round(learned.msRemaining / 3600000));
201
+ return {
202
+ limit: learned.ceiling,
203
+ source: "learned",
204
+ certain: true,
205
+ note: `the provider refused further work after ${learned.ceiling} local task(s); that refusal ages out in ~${hours}h`,
206
+ };
207
+ }
208
+
209
+ const fallback = Number.isFinite(configured) && configured > 0 ? configured : 300;
210
+ return {
211
+ limit: fallback,
212
+ source: provenance === "tier" ? "tier" : "default",
213
+ certain: false,
214
+ note: `estimated from tier "${config?.tier || "unknown"}" — set limits.daily_tasks to make this exact`,
215
+ };
216
+ }
217
+
218
+ /**
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.
253
+ *
254
+ * A reservation is open until a `budget_rolled_back` or `budget_released` entry
255
+ * names it. `budget_committed` deliberately does not close one — a committed
256
+ * dispatch really did consume quota — so the open set is "everything reserved
257
+ * inside the window that was not given back".
258
+ *
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.
263
+ *
264
+ * @param {string} [root]
265
+ * @param {object} [opts] - Forwarded to scanBudgetWindow (`now`, `windowMs`).
266
+ * @returns {{ reservationId: string|null, timestamp: string, committed: boolean }[]}
267
+ */
268
+ export function listOpenReservations(root = resolveRoot(), opts = {}) {
269
+ return scanBudgetWindow(root, opts).open;
270
+ }
271
+
272
+ /**
273
+ * Give the window's open reservations back, by appending `budget_released` entries.
274
+ *
275
+ * The ledger is append-only and hash-chained, so a miscounted day is corrected
276
+ * forwards — never by editing or deleting the file, which would break the chain
277
+ * and destroy the audit trail the ledger exists to provide.
278
+ *
279
+ * This is an operator override, not an inference. The kit cannot tell a
280
+ * reservation that reached the provider from one whose process died first, so
281
+ * only the operator knows whether the local count still reflects reality.
282
+ *
283
+ * @param {object} [opts]
284
+ * @param {string} [opts.root]
285
+ * @param {string} [opts.reason] - Recorded on every released entry.
286
+ * @param {boolean} [opts.dryRun] - Report what would be released, write nothing.
287
+ * @returns {{ released: number, committed: number, uncommitted: number, ids: string[], dryRun: boolean }}
288
+ */
289
+ export function releaseOpenReservations(opts = {}) {
290
+ const root = opts.root || resolveRoot();
291
+ const openRecords = listOpenReservations(root);
292
+ const committed = openRecords.filter((r) => r.committed).length;
293
+
294
+ const result = {
295
+ released: openRecords.length,
296
+ committed,
297
+ uncommitted: openRecords.length - committed,
298
+ anonymous: openRecords.filter((r) => !r.reservationId).length,
299
+ ids: openRecords.map((r) => r.reservationId).filter(Boolean),
300
+ dryRun: Boolean(opts.dryRun),
301
+ };
302
+ if (opts.dryRun || openRecords.length === 0) return result;
303
+
304
+ for (const rec of openRecords) {
305
+ const entry = { event: "budget_released", reason: opts.reason || "operator-reconcile" };
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
+ }
316
+ appendLedger(entry, root);
317
+ }
318
+ return result;
319
+ }
320
+
321
+ /**
322
+ * Human-readable budget status for the CLI, dashboard and MCP surface.
323
+ * @param {object} config
324
+ * @param {string} [root]
325
+ */
326
+ export function budgetStatus(config, root = resolveRoot()) {
327
+ const resolved = resolveDailyLimit(config, root);
328
+ const check = checkDailyBudget(root, resolved.limit);
329
+ return {
330
+ used: check.used,
331
+ limit: resolved.limit,
332
+ remaining: check.remaining,
333
+ tier: config?.tier || "unknown",
334
+ source: resolved.source,
335
+ certain: resolved.certain,
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,
341
+ // Only a limit we actually know may stop a dispatch.
342
+ enforced: resolved.certain,
343
+ exhausted: check.used >= resolved.limit,
344
+ };
345
+ }
package/src/config.mjs CHANGED
@@ -270,30 +270,85 @@ export function resolveVerify(root = process.cwd(), userVerify = {}) {
270
270
  };
271
271
  }
272
272
 
273
+ /**
274
+ * The single source of truth for tier defaults. `src/wizard-init.mjs` projects
275
+ * this into snake_case rather than keeping a second table: the two tables
276
+ * previously disagreed (wizard wrote free=30 while the runtime assumed 15), so
277
+ * a freshly initialised repo was budgeted against numbers no other code used.
278
+ *
279
+ * `dailyTasks` here is a *hint*, not a fact. Provider quotas are set by the
280
+ * vendor and change without notice, so these numbers are only ever a starting
281
+ * guess — see `isTierGuess()` and the learned-ceiling logic in src/state.mjs.
282
+ * An explicit `limits.daily_tasks` in .agent/config.yml always wins.
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
+ */
273
305
  export const TIER_PRESETS = {
274
306
  free: {
275
307
  dailyTasks: 15,
276
308
  repairAttempts: 1,
277
- 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,
278
313
  staggerMs: 3000,
279
314
  diffKb: 50,
280
315
  },
281
316
  pro: {
282
317
  dailyTasks: 100,
283
318
  repairAttempts: 2,
284
- concurrency: 2,
319
+ concurrency: 8,
320
+ maxConcurrency: 15,
285
321
  staggerMs: 1500,
286
322
  diffKb: 75,
287
323
  },
288
324
  ultra: {
289
325
  dailyTasks: 300,
290
326
  repairAttempts: 3,
291
- concurrency: 3,
327
+ concurrency: 15,
328
+ maxConcurrency: 60,
292
329
  staggerMs: 1000,
293
330
  diffKb: 75,
294
331
  },
332
+ // Not a vendor plan: a self-hosted/pooled profile for operators who front
333
+ // several accounts. Defined here so `tier: enterprise` resolves to what the
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.
336
+ enterprise: {
337
+ dailyTasks: 1000,
338
+ repairAttempts: 3,
339
+ concurrency: 10,
340
+ maxConcurrency: 0,
341
+ staggerMs: 500,
342
+ diffKb: 100,
343
+ },
295
344
  };
296
345
 
346
+ /** Tier names that correspond to real vendor plans, in ascending order. */
347
+ export const VENDOR_TIERS = ["free", "pro", "ultra"];
348
+
349
+ /** The tier used when a config names one that does not exist. */
350
+ export const FALLBACK_TIER = "ultra";
351
+
297
352
  /**
298
353
  * Loads and validates configuration from .agent/config.yml or .agent/jules.yml.
299
354
  */
@@ -382,6 +437,19 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
382
437
  ...(envDailyTasks !== null && !isNaN(envDailyTasks) ? { dailyTasks: envDailyTasks } : {}),
383
438
  ...(envDiffKb !== null && !isNaN(envDiffKb) ? { diffKb: envDiffKb } : {}),
384
439
  },
440
+ // Where each contested limit actually came from. The merge above flattens
441
+ // config, env and tier into one number, after which no caller can tell a
442
+ // figure the operator stated from one the kit guessed — and the budget gate
443
+ // must not hard-block on a guess. See resolveDailyLimit() in src/budget.mjs.
444
+ provenance: {
445
+ dailyTasks:
446
+ envDailyTasks !== null && !isNaN(envDailyTasks)
447
+ ? "env"
448
+ : normalizedLimits.dailyTasks !== undefined
449
+ ? "config"
450
+ : "tier",
451
+ concurrency: normalizedLimits.concurrency !== undefined ? "config" : "tier",
452
+ },
385
453
  isolation: parsed.isolation || DEFAULTS.isolation,
386
454
  runner: parsed.runner || DEFAULTS.runner,
387
455
  branchPrefix: parsed.branch_prefix || parsed.branchPrefix || DEFAULTS.branchPrefix,
package/src/dashboard.mjs CHANGED
@@ -10,6 +10,7 @@ import { join, dirname } from "node:path";
10
10
  import { readTelemetry, verifyTelemetryIntegrity } from "./telemetry.mjs";
11
11
  import { readVerifyRuns, flakyVerdict } from "./flaky-ledger.mjs";
12
12
  import { lockStatus } from "./state.mjs";
13
+ import { KIT_VERSION } from "./version.mjs";
13
14
 
14
15
  const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version;
15
16
 
@@ -92,7 +93,7 @@ export function createDashboardServer({ root = process.cwd(), port: _port = 4100
92
93
 
93
94
  if (url.pathname === "/api/status") {
94
95
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
95
- return res.end(JSON.stringify({ ok: true, version: "0.29.1", root, ts: new Date().toISOString() }));
96
+ return res.end(JSON.stringify({ ok: true, version: KIT_VERSION, root, ts: new Date().toISOString() }));
96
97
  }
97
98
 
98
99
  if (url.pathname === "/api/telemetry") {
package/src/engine.mjs CHANGED
@@ -3,7 +3,8 @@ import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
3
3
  import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
4
4
  import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
5
5
  import { resolveRoutedProvider } from "./router.mjs";
6
- import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked } from "./state.mjs";
6
+ import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
7
+ import { resolveDailyLimit, recordObservedCeiling, isDailyQuotaRejection } from "./budget.mjs";
7
8
  import { sanitizeUntrustedData, buildAgentEnvelope } from "./prompt-guard.mjs";
8
9
  import { recordVerifyRun, readVerifyRuns, flakyVerdict } from "./flaky-ledger.mjs";
9
10
  import fs, { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
@@ -746,6 +747,19 @@ export async function dispatch(task = {}, opts = {}) {
746
747
 
747
748
  const runDispatch = () => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun });
748
749
 
750
+ // An estimated ceiling may warn but must not block: refusing a dispatch the
751
+ // provider would have accepted is a worse failure than an over-count.
752
+ const budget = resolveDailyLimit(config, root);
753
+ if (!opts.dryRun && !budget.certain) {
754
+ const check = checkDailyBudget(root, budget.limit);
755
+ if (check.used >= budget.limit) {
756
+ console.warn(
757
+ `[BUDGET_ESTIMATE] ${check.used}/${budget.limit} tasks used — ${budget.note}. ` +
758
+ `Proceeding: the real allowance is unknown, so this is not enforced.`
759
+ );
760
+ }
761
+ }
762
+
749
763
  try {
750
764
  // A dry run performs no provider call and produces no work, so it must not
751
765
  // consume one of the operator's finite daily task slots. Reserving here also
@@ -753,9 +767,17 @@ export async function dispatch(task = {}, opts = {}) {
753
767
  // ledger and turned the suite red for reasons unrelated to any code change.
754
768
  const session = opts.dryRun
755
769
  ? await runDispatch()
756
- : await withBudget(runDispatch, root, config.limits.dailyTasks);
770
+ : await withBudget(runDispatch, root, budget.limit, { enforce: budget.certain });
757
771
  return classification ? { ...session, _routeTier: classification.tier, _routeReason: classification.reason } : session;
758
772
  } catch (err) {
773
+ // A refusal for daily quota is the only authoritative statement of the
774
+ // account's real allowance, so record it: from here on the gate enforces a
775
+ // number the provider demonstrated instead of one a tier preset guessed.
776
+ if (isDailyQuotaRejection(err)) {
777
+ try {
778
+ recordObservedCeiling(checkDailyBudget(root, budget.limit).used, root, { source: "provider-rejection" });
779
+ } catch (_) {}
780
+ }
759
781
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
760
782
  if (err.reservationId && !err.budgetReservationRolledBack) {
761
783
  rollbackBudgetReservation(root, err.reservationId);
package/src/mcp.mjs CHANGED
@@ -2,14 +2,16 @@ import { Transform } from "node:stream";
2
2
  import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
3
3
  import { gate, dispatch } from "./engine.mjs";
4
4
  import { classifyRiskTier } from "./risk.mjs";
5
- import { checkDailyBudget, lockStatus } from "./state.mjs";
5
+ import { lockStatus } from "./state.mjs";
6
+ import { budgetStatus } from "./budget.mjs";
6
7
  import { reapOrphanedIntents, reapStaleMutexDirs } from "./journal.mjs";
7
8
  import { readTelemetry } from "./telemetry.mjs";
8
9
  import { ProgressBus } from "./mcp-progress.mjs";
10
+ import { KIT_VERSION } from "./version.mjs";
9
11
 
10
12
  export const MCP_SERVER_INFO = {
11
13
  name: "jules-orchestrator-kit",
12
- version: "0.29.1",
14
+ version: KIT_VERSION,
13
15
  };
14
16
 
15
17
  export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
@@ -321,13 +323,22 @@ export async function handleMcpRequest(request, opts = {}) {
321
323
 
322
324
  if (toolName === "get_jules_status") {
323
325
  const stackInfo = detectStack(root);
324
- const budget = checkDailyBudget(root, config.limits.dailyTasks);
326
+ const budget = budgetStatus(config, root);
325
327
  const locks = lockStatus(root);
326
328
  const status = {
327
329
  version: MCP_SERVER_INFO.version,
328
330
  root,
329
331
  stack: stackInfo.stack,
330
- budget: { used: budget.used, limit: budget.budget, remaining: budget.budget - budget.used },
332
+ // `source`/`enforced` travel with the number so an agent reading this
333
+ // can tell a measured limit from an estimated one.
334
+ budget: {
335
+ used: budget.used,
336
+ limit: budget.limit,
337
+ remaining: budget.remaining,
338
+ source: budget.source,
339
+ enforced: budget.enforced,
340
+ scope: "this-repository",
341
+ },
331
342
  activeLocksCount: locks.length,
332
343
  locks,
333
344
  };
@@ -155,7 +155,7 @@ export const COMMAND_REGISTRY = [
155
155
  "agentctl init --tier pro --yes",
156
156
  ],
157
157
  flags: [
158
- { name: "tier", type: "string", description: "Target configuration tier (free, pro, enterprise)" },
158
+ { name: "tier", type: "string", description: "Target configuration tier (free, pro, ultra, enterprise)" },
159
159
  { name: "interactive", type: "boolean", description: "Launch interactive onboarding wizard" },
160
160
  { name: "yes", type: "boolean", description: "Accept auto-detected Stack Oracle defaults" },
161
161
  ],
@@ -180,6 +180,29 @@ export const COMMAND_REGISTRY = [
180
180
  { name: "host", type: "string", description: "Bind host address (default 127.0.0.1)" },
181
181
  ],
182
182
  },
183
+ {
184
+ id: "budget",
185
+ path: ["budget"],
186
+ title: "budget",
187
+ description: "Show today's task budget, where its limit came from, and reconcile a wrong count",
188
+ category: "Inspect",
189
+ mutates: false,
190
+ risk: "low",
191
+ interactive: "never",
192
+ requiresRepository: true,
193
+ shortcuts: ["b"],
194
+ examples: [
195
+ "agentctl budget",
196
+ "agentctl budget --json",
197
+ "agentctl budget reset --dry-run",
198
+ "agentctl budget reset --yes",
199
+ ],
200
+ flags: [
201
+ { name: "json", type: "boolean", description: "Output structured JSON budget snapshot" },
202
+ { name: "dry-run", type: "boolean", description: "Report what reset would release, write nothing" },
203
+ { name: "yes", type: "boolean", description: "Confirm releasing today's open reservations" },
204
+ ],
205
+ },
183
206
  {
184
207
  id: "status",
185
208
  path: ["status"],