jules-orchestrator-kit 0.33.0 → 0.35.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.
@@ -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 };