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/README.md +3 -3
- package/bin/agentctl.mjs +131 -10
- package/index.mjs +19 -0
- package/package.json +1 -1
- package/scripts/doc-sync-check.mjs +69 -8
- package/scripts/utils.mjs +6 -2
- package/src/budget.mjs +345 -0
- package/src/config.mjs +71 -3
- package/src/dashboard.mjs +2 -1
- package/src/engine.mjs +24 -2
- package/src/mcp.mjs +15 -4
- package/src/ops/command-registry.mjs +24 -1
- package/src/ops/doctor-registry.mjs +87 -5
- package/src/ops/next-step.mjs +111 -0
- package/src/state.mjs +162 -57
- package/src/version.mjs +27 -0
- package/src/wizard-init.mjs +57 -28
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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 {
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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"],
|