faberun 0.12.0 → 0.13.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/package.json +1 -1
- package/skills/faberun/references/contract.md +10 -10
- package/src/contract/assert.mjs +5 -1
- package/src/contract/index.mjs +43 -7
- package/src/contract/runtime.mjs +4 -1
- package/src/contract/snapshot.mjs +25 -1
- package/src/contract/task-packet.mjs +5 -1
- package/src/engine/backoff.mjs +2 -1
- package/src/engine/capacity.mjs +74 -0
- package/src/engine/dispatch.mjs +12 -186
- package/src/engine/lifecycle.mjs +7 -2
- package/src/engine/phase-session.mjs +217 -0
- package/src/engine/process.mjs +26 -108
- package/src/engine/scheduler.mjs +15 -3
- package/src/engine/settle.mjs +11 -2
- package/src/engine/transcript.mjs +145 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/dsh/runner.mjs +38 -3
- package/src/harnesses/exec-jsonl/index.mjs +0 -499
- package/src/harnesses/index.mjs +55 -8
- package/src/harnesses/protocol.mjs +22 -2
- package/src/harnesses/session-metrics.mjs +674 -0
- package/src/host/tool-policy-decisions.mjs +78 -21
- package/src/host/tool-policy-hook.mjs +12 -3
- package/src/plan/freeze.mjs +24 -6
- package/src/plan/pipeline.mjs +232 -67
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/template.mjs +110 -8
- package/src/run/operations.mjs +1 -1
- package/src/run/usage.mjs +9 -3
- package/src/util.mjs +0 -0
package/src/harnesses/index.mjs
CHANGED
|
@@ -77,13 +77,23 @@ const CAPABILITY_NAMES = new Set([
|
|
|
77
77
|
* Claude-compatible adapters enforce it through hook settings; an adapter
|
|
78
78
|
* that cannot prove enforcement must never receive it.
|
|
79
79
|
*
|
|
80
|
-
* @typedef {{foregroundOnly: boolean, maxToolOutputBytes: number|null, workspace: string, writeFiles: string[], writeRoots: string[], maxReadLines: number|null}} ToolPolicy
|
|
80
|
+
* @typedef {{foregroundOnly: boolean, maxToolOutputBytes: number|null, workspace: string, writeFiles: string[], writeRoots: string[], maxReadLines: number|null, maxReadBytes?: number|null}} ToolPolicy
|
|
81
81
|
*/
|
|
82
82
|
|
|
83
83
|
/** Line count above which a whole-file read is denied by the tool policy hook. */
|
|
84
84
|
export const READ_LINE_LIMIT = 1500;
|
|
85
|
+
/**
|
|
86
|
+
* Whole-file reads above this many bytes are denied at the tool boundary
|
|
87
|
+
* with the same retry hint (offset and limit). measured 2026-09-20 over 1189
|
|
88
|
+
* Read results in stored claude worker turns: p50 2.9 KB, p90 16.7 KB, p95
|
|
89
|
+
* 24 KB, max 57 KB; 28 exceeded 32 KiB. Every byte read is written to the
|
|
90
|
+
* cache once and re-read by every later request of the turn (median 49), so
|
|
91
|
+
* one 57 KB read costs about a tenth of a median turn by itself. The line
|
|
92
|
+
* threshold above misses a file of few very long lines; this one does not.
|
|
93
|
+
*/
|
|
94
|
+
export const READ_BYTE_LIMIT = 32 * 1024;
|
|
85
95
|
|
|
86
|
-
/** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string
|
|
96
|
+
/** @typedef {{schema?: object, schemaPath?: string, continuationId?: string|null, toolPolicy?: ToolPolicy, env?: Record<string, string>, maxTurns?: number}} CommandOptions */
|
|
87
97
|
|
|
88
98
|
/** @typedef {{preferStructured?: boolean, exitCode?: number|null, signal?: string|null, stderr?: string}} NormalizeOptions */
|
|
89
99
|
|
|
@@ -262,15 +272,16 @@ export function normalizeProviderAvailability(runtimeOrHarness, response, exitCo
|
|
|
262
272
|
* of waiting on the same runtime.
|
|
263
273
|
*
|
|
264
274
|
* @param {unknown} envelope
|
|
275
|
+
* @param {number} [now] the instant a wall-clock reset sentence is read against (tests inject it)
|
|
265
276
|
* @returns {string|null}
|
|
266
277
|
*/
|
|
267
|
-
export function exhaustedUntilOf(envelope) {
|
|
278
|
+
export function exhaustedUntilOf(envelope, now = Date.now()) {
|
|
268
279
|
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return null;
|
|
269
280
|
const record = /** @type {Record<string, unknown>} */ (envelope);
|
|
270
281
|
const error = record.error && typeof record.error === "object"
|
|
271
282
|
? /** @type {Record<string, unknown>} */ (record.error)
|
|
272
283
|
: null;
|
|
273
|
-
return resetTimestamp(record.exhaustedUntil ?? error?.resetAt ?? (typeof error?.message === "string" ? error.message : null));
|
|
284
|
+
return resetTimestamp(record.exhaustedUntil ?? error?.resetAt ?? (typeof error?.message === "string" ? error.message : null), now);
|
|
274
285
|
}
|
|
275
286
|
|
|
276
287
|
/**
|
|
@@ -287,7 +298,7 @@ function classifyAvailabilityText(text) {
|
|
|
287
298
|
if (/insufficient balance/iu.test(text) || /\b402\b/u.test(text)) {
|
|
288
299
|
return { available: false, exhaustedUntil: null, reason: "insufficient_balance" };
|
|
289
300
|
}
|
|
290
|
-
if (/quota|rate.?limit|usage limit|limit exhausted|1310/iu.test(text)) {
|
|
301
|
+
if (/quota|rate.?limit|usage limit|session limit|limit exhausted|1310/iu.test(text)) {
|
|
291
302
|
return { available: false, exhaustedUntil: resetTimestamp(text), reason: "quota_exhausted" };
|
|
292
303
|
}
|
|
293
304
|
if (/auth|credential|unauthori[sz]ed|forbidden|invalid.*(?:key|token)|(?:api|access) key|login/iu.test(text)) {
|
|
@@ -296,8 +307,8 @@ function classifyAvailabilityText(text) {
|
|
|
296
307
|
return null;
|
|
297
308
|
}
|
|
298
309
|
|
|
299
|
-
/** @param {unknown} value @returns {string|null} */
|
|
300
|
-
function resetTimestamp(value) {
|
|
310
|
+
/** @param {unknown} value @param {number} [now] @returns {string|null} */
|
|
311
|
+
function resetTimestamp(value, now = Date.now()) {
|
|
301
312
|
if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
|
|
302
313
|
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
|
|
303
314
|
if (typeof value !== "string") return null;
|
|
@@ -305,7 +316,43 @@ function resetTimestamp(value) {
|
|
|
305
316
|
const input = match?.[1] ?? value;
|
|
306
317
|
const normalized = input.includes("T") || /(?:Z|[+-]\d{2}:?\d{2})$/u.test(input) ? input : `${input.replace(" ", "T")}Z`;
|
|
307
318
|
const parsed = Date.parse(normalized);
|
|
308
|
-
|
|
319
|
+
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
|
|
320
|
+
return wallClockReset(value, now);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A reset sentence that names a wall-clock time and a zone but no date --
|
|
325
|
+
* the Claude subscription's "resets 6:40pm (America/Sao_Paulo)", measured
|
|
326
|
+
* 2026-09-20 -- means the next occurrence of that time in that zone. The
|
|
327
|
+
* zone's offset is read at `now`; a daylight-saving change between now and
|
|
328
|
+
* the reset moves the answer by an hour, which is accepted and unmeasured.
|
|
329
|
+
*/
|
|
330
|
+
const WALL_CLOCK_RESET = /resets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)+|UTC|GMT)\)/iu;
|
|
331
|
+
|
|
332
|
+
/** @param {string} text @param {number} now @returns {string|null} */
|
|
333
|
+
function wallClockReset(text, now) {
|
|
334
|
+
const match = WALL_CLOCK_RESET.exec(text);
|
|
335
|
+
if (!match) return null;
|
|
336
|
+
let hour = Number(match[1]);
|
|
337
|
+
const minute = Number(match[2] ?? "0");
|
|
338
|
+
const meridiem = match[3]?.toLowerCase();
|
|
339
|
+
if (meridiem === "pm" && hour < 12) hour += 12;
|
|
340
|
+
if (meridiem === "am" && hour === 12) hour = 0;
|
|
341
|
+
if (hour > 23 || minute > 59) return null;
|
|
342
|
+
/** @type {Intl.DateTimeFormatPart[]} */
|
|
343
|
+
let parts;
|
|
344
|
+
try {
|
|
345
|
+
parts = new Intl.DateTimeFormat("en-US", { timeZone: match[4], hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).formatToParts(new Date(now));
|
|
346
|
+
} catch {
|
|
347
|
+
// An unknown zone name is not a reset time; the caller falls back to the failover edge.
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const part = (/** @type {Intl.DateTimeFormatPartTypes} */ type) => Number(parts.find((candidate) => candidate.type === type)?.value);
|
|
351
|
+
const zoneWallClock = Date.UTC(part("year"), part("month") - 1, part("day"), part("hour") % 24, part("minute"), part("second"));
|
|
352
|
+
const offset = zoneWallClock - Math.floor(now / 1000) * 1000;
|
|
353
|
+
let target = Date.UTC(part("year"), part("month") - 1, part("day"), hour, minute, 0) - offset;
|
|
354
|
+
if (target <= now) target += 24 * 60 * 60 * 1000;
|
|
355
|
+
return new Date(target).toISOString();
|
|
309
356
|
}
|
|
310
357
|
|
|
311
358
|
/**
|
|
@@ -179,6 +179,20 @@ export function normalizeClaudeResult(stdout, exitCode, signal, options = {}) {
|
|
|
179
179
|
allowance,
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
|
+
// A turn the CLI stopped at `--max-turns` is the controller's own cap, not
|
|
183
|
+
// a provider failure: it keeps its own code so the retry policy can tell.
|
|
184
|
+
if (resultEvent.subtype === "error_max_turns") {
|
|
185
|
+
return {
|
|
186
|
+
...failed(
|
|
187
|
+
"turn_limit",
|
|
188
|
+
"Claude stopped at the attempt's maxTurns (--max-turns)",
|
|
189
|
+
"failed",
|
|
190
|
+
typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
|
|
191
|
+
canonicalUsage(resultEvent.usage),
|
|
192
|
+
),
|
|
193
|
+
allowance,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
182
196
|
if (resultEvent.is_error || exitCode !== 0) {
|
|
183
197
|
return { ...failed("provider_error", result ?? `Claude exited with code ${exitCode}`), allowance };
|
|
184
198
|
}
|
|
@@ -287,8 +301,14 @@ export function eventItem(event) {
|
|
|
287
301
|
return item && typeof item === "object" && !Array.isArray(item) ? /** @type {Record<string, unknown>} */ (item) : null;
|
|
288
302
|
}
|
|
289
303
|
|
|
290
|
-
/**
|
|
291
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Provider-reported quota and rate-limit text: exhaustion, never an ordinary
|
|
306
|
+
* provider failure. "session limit" is the Claude subscription's five-hour
|
|
307
|
+
* window: measured 2026-09-20, "You've hit your session limit · resets 6:40pm
|
|
308
|
+
* (America/Sao_Paulo)" settled as provider_error and two faberun nodes burnt
|
|
309
|
+
* both attempts inside a minute instead of holding until the reset.
|
|
310
|
+
*/
|
|
311
|
+
const QUOTA_TEXT_PATTERN = /429|1310|rate.?limit|usage limit|session limit|limit exhausted|quota|too many requests/iu;
|
|
292
312
|
|
|
293
313
|
/**
|
|
294
314
|
* @param {string|null|undefined} text
|