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.
- package/README.md +9 -5
- package/bin/agentctl.mjs +256 -7
- package/index.mjs +30 -2
- package/package.json +1 -1
- package/src/budget.mjs +105 -78
- package/src/config.mjs +48 -4
- package/src/flaky-ledger.mjs +205 -2
- package/src/ops/command-registry.mjs +54 -0
- package/src/ops/doctor-registry.mjs +37 -0
- package/src/state.mjs +146 -52
- package/src/webhook.mjs +415 -119
- package/src/wizard-init.mjs +6 -1
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { loadConfig } from "../config.mjs";
|
|
6
|
+
import { resolveConcurrency } from "../budget.mjs";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* @typedef {"pass" | "warn" | "fail" | "skip" | "unknown"} DiagnosticStatus
|
|
@@ -261,6 +263,41 @@ export async function runDoctorChecks(options = {}) {
|
|
|
261
263
|
});
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
// 3b. Worker slots against the plan's ceiling.
|
|
267
|
+
//
|
|
268
|
+
// A warning, never a failure: the kit cannot see the account, only the
|
|
269
|
+
// config. Pooled accounts legitimately exceed a single plan's ceiling, and
|
|
270
|
+
// the provider refuses what it will not allow regardless of what we think.
|
|
271
|
+
try {
|
|
272
|
+
const cfg = loadConfig(root);
|
|
273
|
+
const slots = resolveConcurrency(cfg);
|
|
274
|
+
addResult({
|
|
275
|
+
id: "limits.concurrency",
|
|
276
|
+
category: "Config",
|
|
277
|
+
title: "Worker Slots vs Plan Ceiling",
|
|
278
|
+
status: slots.overCeiling ? "warn" : "pass",
|
|
279
|
+
severity: slots.overCeiling ? "medium" : "info",
|
|
280
|
+
summary: `${slots.concurrency} concurrent worker(s) — ${slots.note}`,
|
|
281
|
+
evidence: [
|
|
282
|
+
{ label: "concurrency", value: slots.concurrency, sensitive: false },
|
|
283
|
+
{ label: "planCeiling", value: slots.ceiling, sensitive: false },
|
|
284
|
+
{ label: "source", value: slots.source, sensitive: false },
|
|
285
|
+
],
|
|
286
|
+
remediation: slots.overCeiling
|
|
287
|
+
? [
|
|
288
|
+
{
|
|
289
|
+
summary: `Set limits.concurrency to ${slots.ceiling} or lower in .agent/config.yml, unless this account pools several plans`,
|
|
290
|
+
risk: "low",
|
|
291
|
+
automatic: false,
|
|
292
|
+
requiresProbe: false,
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
: [],
|
|
296
|
+
});
|
|
297
|
+
} catch (_) {
|
|
298
|
+
// A config the loader rejects is already reported by config.present.
|
|
299
|
+
}
|
|
300
|
+
|
|
264
301
|
// 4. Verification Oracle Checks
|
|
265
302
|
const pkgPath = join(root, "package.json");
|
|
266
303
|
if (existsSync(pkgPath)) {
|
package/src/state.mjs
CHANGED
|
@@ -55,6 +55,129 @@ export function getDailyLedgerPath(rootOrOpts = resolveRoot()) {
|
|
|
55
55
|
return join(getStateDir(root), `ledger-${dateStr}.jsonl`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How far back a task still counts against the allowance.
|
|
60
|
+
*
|
|
61
|
+
* The provider's daily quota resets on a rolling 24-hour window, not at
|
|
62
|
+
* midnight. Counting per calendar day — which the ledger's `ledger-<date>`
|
|
63
|
+
* rotation invites — is wrong in both directions: a batch dispatched at 23:00
|
|
64
|
+
* stops being counted at 00:01 while the provider still refuses on it, and
|
|
65
|
+
* yesterday's last hours vanish from a count that should still include them.
|
|
66
|
+
*
|
|
67
|
+
* The files stay day-scoped, because rotation is a storage concern. Counting
|
|
68
|
+
* is not: it spans whatever files the window touches and filters on entry
|
|
69
|
+
* timestamps.
|
|
70
|
+
*/
|
|
71
|
+
export const ROLLING_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
72
|
+
|
|
73
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ledger files that can hold entries inside the rolling window, oldest first.
|
|
77
|
+
*
|
|
78
|
+
* One day wider than the window itself: an entry timestamped 23:59 UTC sits in
|
|
79
|
+
* that day's file, and a window opening moments later still has to see it.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} [root]
|
|
82
|
+
* @param {number} [now] - Epoch ms; injectable so tests need not wait a day.
|
|
83
|
+
* @param {number} [windowMs]
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
*/
|
|
86
|
+
export function getLedgerPathsInWindow(root = resolveRoot(), now = Date.now(), windowMs = ROLLING_WINDOW_MS) {
|
|
87
|
+
const stateDir = getStateDir(root);
|
|
88
|
+
const spanDays = Math.ceil(windowMs / DAY_MS) + 1;
|
|
89
|
+
const paths = [];
|
|
90
|
+
for (let i = spanDays - 1; i >= 0; i--) {
|
|
91
|
+
const dateStr = new Date(now - i * DAY_MS).toISOString().split("T")[0];
|
|
92
|
+
const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
|
|
93
|
+
if (!paths.includes(filePath) && existsSync(filePath)) paths.push(filePath);
|
|
94
|
+
}
|
|
95
|
+
return paths;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Replay the budget events in the rolling window and report what is still spent.
|
|
100
|
+
*
|
|
101
|
+
* Reservations carrying a `reservationId` are matched by name. Legacy id-less
|
|
102
|
+
* ones — written by older kit versions — can only be matched by position, so a
|
|
103
|
+
* release without a `releasedTimestamp` consumes the oldest still-open
|
|
104
|
+
* anonymous reservation. `releaseOpenReservations` records that timestamp
|
|
105
|
+
* precisely so the pairing survives the window boundary: without it, an
|
|
106
|
+
* anonymous release could outlive the reservation it cancelled and start
|
|
107
|
+
* subtracting from a later one instead.
|
|
108
|
+
*
|
|
109
|
+
* Entries whose timestamp will not parse are counted. A budget event the kit
|
|
110
|
+
* cannot place in time is safer treated as spent than as free.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} [root]
|
|
113
|
+
* @param {object} [opts]
|
|
114
|
+
* @param {number} [opts.now]
|
|
115
|
+
* @param {number} [opts.windowMs]
|
|
116
|
+
* @returns {{ used: number, open: { reservationId: string|null, timestamp: string, committed: boolean }[], windowStart: string }}
|
|
117
|
+
*/
|
|
118
|
+
export function scanBudgetWindow(root = resolveRoot(), opts = {}) {
|
|
119
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
120
|
+
const windowMs = Number.isFinite(opts.windowMs) ? opts.windowMs : ROLLING_WINDOW_MS;
|
|
121
|
+
const cutoff = now - windowMs;
|
|
122
|
+
|
|
123
|
+
/** @type {Map<string, { reservationId: string, timestamp: string, committed: boolean, inWindow: boolean }>} */
|
|
124
|
+
const byId = new Map();
|
|
125
|
+
/** @type {{ reservationId: null, timestamp: string, committed: boolean, inWindow: boolean }[]} */
|
|
126
|
+
const anonymous = [];
|
|
127
|
+
|
|
128
|
+
const inWindow = (timestamp) => {
|
|
129
|
+
const ts = Date.parse(timestamp || "");
|
|
130
|
+
return Number.isFinite(ts) ? ts >= cutoff : true;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const filePath of getLedgerPathsInWindow(root, now, windowMs)) {
|
|
134
|
+
let raw = "";
|
|
135
|
+
try {
|
|
136
|
+
raw = readFileSync(filePath, "utf-8");
|
|
137
|
+
} catch (_) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
for (const line of raw.split("\n")) {
|
|
141
|
+
if (!line) continue;
|
|
142
|
+
let entry;
|
|
143
|
+
try {
|
|
144
|
+
entry = JSON.parse(line);
|
|
145
|
+
} catch (_) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!entry || !entry.event) continue;
|
|
149
|
+
const timestamp = entry.timestamp || "";
|
|
150
|
+
|
|
151
|
+
if (entry.event === "budget_reserved") {
|
|
152
|
+
const record = { timestamp, committed: false, inWindow: inWindow(timestamp) };
|
|
153
|
+
if (entry.reservationId) byId.set(entry.reservationId, { reservationId: entry.reservationId, ...record });
|
|
154
|
+
else anonymous.push({ reservationId: null, ...record });
|
|
155
|
+
} else if (entry.event === "budget_committed") {
|
|
156
|
+
// A commit does not free the slot — the task really ran — it only marks
|
|
157
|
+
// the reservation as having reached the provider.
|
|
158
|
+
const record = entry.reservationId ? byId.get(entry.reservationId) : null;
|
|
159
|
+
if (record) record.committed = true;
|
|
160
|
+
} else if (entry.event === "budget_rolled_back" || entry.event === "budget_released") {
|
|
161
|
+
if (entry.reservationId) {
|
|
162
|
+
byId.delete(entry.reservationId);
|
|
163
|
+
} else if (entry.releasedTimestamp) {
|
|
164
|
+
const idx = anonymous.findIndex((r) => r.timestamp === entry.releasedTimestamp);
|
|
165
|
+
if (idx !== -1) anonymous.splice(idx, 1);
|
|
166
|
+
} else {
|
|
167
|
+
anonymous.shift();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const open = [...byId.values(), ...anonymous].filter((r) => r.inWindow);
|
|
174
|
+
return {
|
|
175
|
+
used: open.length,
|
|
176
|
+
open: open.map(({ reservationId, timestamp, committed }) => ({ reservationId, timestamp, committed })),
|
|
177
|
+
windowStart: new Date(cutoff).toISOString(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
58
181
|
export class MutexTimeoutError extends Error {
|
|
59
182
|
constructor(message = "Failed to acquire VFS mutex lock within timeout") {
|
|
60
183
|
super(message);
|
|
@@ -187,43 +310,24 @@ export function readLedger(filePath) {
|
|
|
187
310
|
}
|
|
188
311
|
}
|
|
189
312
|
|
|
190
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Tasks still counted against the allowance, over the rolling 24-hour window.
|
|
315
|
+
*
|
|
316
|
+
* The name is kept for compatibility; "daily" here means the provider's day,
|
|
317
|
+
* which is the last 24 hours rather than the calendar one.
|
|
318
|
+
*/
|
|
319
|
+
export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300, opts = {}) {
|
|
191
320
|
let root = typeof arg1 === "string" ? arg1 : resolveRoot();
|
|
192
321
|
let limit = typeof arg1 === "number" ? arg1 : typeof arg2 === "number" ? arg2 : 300;
|
|
193
322
|
|
|
194
|
-
const filePath = getDailyLedgerPath(root);
|
|
195
|
-
if (!existsSync(filePath)) {
|
|
196
|
-
return { ok: true, used: 0, budget: limit, remaining: limit };
|
|
197
|
-
}
|
|
198
323
|
try {
|
|
199
|
-
const
|
|
200
|
-
const lines = content.split("\n").filter(Boolean);
|
|
201
|
-
let count = 0;
|
|
202
|
-
const activeIds = new Set();
|
|
203
|
-
for (const line of lines) {
|
|
204
|
-
try {
|
|
205
|
-
const entry = JSON.parse(line);
|
|
206
|
-
if (entry && entry.event === "budget_reserved") {
|
|
207
|
-
if (entry.reservationId) {
|
|
208
|
-
activeIds.add(entry.reservationId);
|
|
209
|
-
} else {
|
|
210
|
-
count++;
|
|
211
|
-
}
|
|
212
|
-
} else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
|
|
213
|
-
if (entry.reservationId) {
|
|
214
|
-
activeIds.delete(entry.reservationId);
|
|
215
|
-
} else {
|
|
216
|
-
count = Math.max(0, count - 1);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
} catch (_) {}
|
|
220
|
-
}
|
|
221
|
-
const used = count + activeIds.size;
|
|
324
|
+
const scan = scanBudgetWindow(root, opts);
|
|
222
325
|
return {
|
|
223
|
-
ok: used < limit,
|
|
224
|
-
used,
|
|
326
|
+
ok: scan.used < limit,
|
|
327
|
+
used: scan.used,
|
|
225
328
|
budget: limit,
|
|
226
|
-
remaining: Math.max(0, limit - used),
|
|
329
|
+
remaining: Math.max(0, limit - scan.used),
|
|
330
|
+
windowStart: scan.windowStart,
|
|
227
331
|
};
|
|
228
332
|
} catch (_) {
|
|
229
333
|
return { ok: true, used: 0, budget: limit, remaining: limit };
|
|
@@ -244,42 +348,32 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
244
348
|
const mutexDir = join(stateDir, ".budget.mutex");
|
|
245
349
|
|
|
246
350
|
return withVfsMutex(mutexDir, () => {
|
|
247
|
-
const
|
|
351
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
352
|
+
const dateStr = new Date(now).toISOString().split("T")[0];
|
|
248
353
|
const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
|
|
249
354
|
|
|
250
|
-
|
|
251
|
-
|
|
355
|
+
// The count spans the rolling window; the hash chain does not. A chain is
|
|
356
|
+
// per file, so the new entry links to today's last hash even when the
|
|
357
|
+
// reservations it is counted against were written yesterday.
|
|
358
|
+
const used = scanBudgetWindow(root, { ...opts, now }).used;
|
|
252
359
|
let prevHash = "0".repeat(64);
|
|
253
360
|
|
|
254
361
|
if (existsSync(filePath)) {
|
|
255
362
|
try {
|
|
256
363
|
const raw = readFileSync(filePath, "utf-8");
|
|
257
364
|
const lines = raw.split("\n").filter(Boolean);
|
|
258
|
-
for (
|
|
365
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
259
366
|
try {
|
|
260
|
-
const entry = JSON.parse(
|
|
261
|
-
if (entry && entry.event === "budget_reserved") {
|
|
262
|
-
if (entry.reservationId) {
|
|
263
|
-
activeIds.add(entry.reservationId);
|
|
264
|
-
} else {
|
|
265
|
-
count++;
|
|
266
|
-
}
|
|
267
|
-
} else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
|
|
268
|
-
if (entry.reservationId) {
|
|
269
|
-
activeIds.delete(entry.reservationId);
|
|
270
|
-
} else {
|
|
271
|
-
count = Math.max(0, count - 1);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
367
|
+
const entry = JSON.parse(lines[i]);
|
|
274
368
|
if (entry && entry.hash) {
|
|
275
369
|
prevHash = entry.hash;
|
|
370
|
+
break;
|
|
276
371
|
}
|
|
277
372
|
} catch (_) {}
|
|
278
373
|
}
|
|
279
374
|
} catch (_) {}
|
|
280
375
|
}
|
|
281
376
|
|
|
282
|
-
const used = count + activeIds.size;
|
|
283
377
|
// `enforce: false` marks a limit the kit only guessed (a tier preset rather
|
|
284
378
|
// than a stated or provider-demonstrated figure). Blocking on a guess would
|
|
285
379
|
// refuse work the provider would happily have accepted, so an uncertain
|
|
@@ -289,8 +383,8 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
289
383
|
throw new BudgetError(`Daily budget exhausted (${used}/${limit} tasks executed)`);
|
|
290
384
|
}
|
|
291
385
|
|
|
292
|
-
const timestamp = new Date().toISOString();
|
|
293
|
-
const reservationId = `res-${
|
|
386
|
+
const timestamp = new Date(now).toISOString();
|
|
387
|
+
const reservationId = `res-${now}-${Math.random().toString(36).substring(2, 8)}`;
|
|
294
388
|
const rawPayload = { timestamp, event: "budget_reserved", reservationId, budget: limit, prevHash };
|
|
295
389
|
const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
|
|
296
390
|
const payload = { ...rawPayload, hash };
|