tines 0.0.146 → 0.0.147

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.
Files changed (2) hide show
  1. package/dist/index.js +231 -12
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3560,6 +3560,9 @@ var selfRef = () => ({ kind: "self-ref" });
3560
3560
  function str(v) {
3561
3561
  return v === null || v === void 0 ? "" : String(v);
3562
3562
  }
3563
+ function isoTime(v) {
3564
+ return typeof v === "number" && Number.isFinite(v) ? new Date(v).toISOString() : "an unknown time";
3565
+ }
3563
3566
  function joinChanged(v, sep2) {
3564
3567
  return Array.isArray(v) ? v.join(sep2) : "";
3565
3568
  }
@@ -3659,6 +3662,13 @@ var DESCRIBERS = {
3659
3662
  // runner dropped after launch. The cause is in the error text.
3660
3663
  text(`fail (${str(p.consecutive_failures)} consecutive): ${str(p.error)}`)
3661
3664
  ],
3665
+ "runner.rate_limited": (_ev, p) => [
3666
+ text("saw runner"),
3667
+ name(p.runner_name),
3668
+ // A provider condition, not a failure: the runner holds itself until the
3669
+ // reported reset and resumes with nobody in the loop.
3670
+ text(`hit its usage limit \u2014 resumes ${isoTime(p.resets_at)}: ${str(p.error)}`)
3671
+ ],
3662
3672
  "routing_rule.created": (ev, p) => [
3663
3673
  text(`${action(ev.type)} the ${str(p.scope_label)} routing rule`)
3664
3674
  ],
@@ -4227,6 +4237,8 @@ function scheduleRef(s) {
4227
4237
  }
4228
4238
  function runnerStatusLabel(runner) {
4229
4239
  if (runner.status === "paused") return "paused";
4240
+ if (runner.backoff_reason === "rate_limit" && (runner.backoff_until ?? 0) > Date.now())
4241
+ return "rate limited";
4230
4242
  return runner.online ? "online" : "offline";
4231
4243
  }
4232
4244
  function ruleTargetsLabel(rule) {
@@ -6725,15 +6737,27 @@ function renderStreamEvent(event) {
6725
6737
  if (event.is_error && event.result) lines.push(`[error] ${clip(event.result, 2e3)}`);
6726
6738
  return lines;
6727
6739
  }
6740
+ case "rate_limit_event": {
6741
+ const info = event.rate_limit_info;
6742
+ if (info?.status !== "rejected") return [];
6743
+ const resets = typeof info.resetsAt === "number" && Number.isFinite(info.resetsAt) ? new Date(info.resetsAt < 1e12 ? info.resetsAt * 1e3 : info.resetsAt).toISOString() : "unknown";
6744
+ return [`[session] rate limit: ${info.rateLimitType ?? "usage"} rejected \u2014 resets ${resets}`];
6745
+ }
6728
6746
  default:
6729
6747
  return [];
6730
6748
  }
6731
6749
  }
6732
6750
  var ClaudeStreamRenderer = class {
6733
- constructor(emit) {
6751
+ /**
6752
+ * @param onEvent every successfully parsed event, before rendering — so the
6753
+ * rate-limit detector can read the stream without parsing it a second time.
6754
+ */
6755
+ constructor(emit, onEvent) {
6734
6756
  this.emit = emit;
6757
+ this.onEvent = onEvent;
6735
6758
  }
6736
6759
  emit;
6760
+ onEvent;
6737
6761
  pending = "";
6738
6762
  write(chunk) {
6739
6763
  this.pending += chunk;
@@ -6767,11 +6791,172 @@ var ClaudeStreamRenderer = class {
6767
6791
  `);
6768
6792
  return;
6769
6793
  }
6794
+ this.onEvent?.(event);
6770
6795
  for (const line of renderStreamEvent(event)) this.emit(`${line}
6771
6796
  `);
6772
6797
  }
6773
6798
  };
6774
6799
 
6800
+ // src/daemon/rate-limit.ts
6801
+ var USAGE_LIMIT_PREFIXES = [
6802
+ "You've hit your",
6803
+ "You've reached your",
6804
+ "You're out of usage credits",
6805
+ "Your org is out of usage",
6806
+ "Your seat type doesn't include usage credits",
6807
+ "Your usage allocation has been disabled by your admin",
6808
+ "Your group's usage limit is set to $0",
6809
+ "You're out of extra usage"
6810
+ ];
6811
+ var USAGE_LIMIT_PATTERN = /^Fable(?: [^·\n]{1,40})? requires usage credits\./;
6812
+ var ANSI = /\x1b\[[0-9;]*m/g;
6813
+ function toEpochMs(value) {
6814
+ return value < 1e12 ? Math.round(value * 1e3) : Math.round(value);
6815
+ }
6816
+ function rateLimitFromStreamEvent(event) {
6817
+ if (typeof event !== "object" || event === null) return null;
6818
+ const ev = event;
6819
+ if (ev.type !== "rate_limit_event") return null;
6820
+ const info = ev.rate_limit_info;
6821
+ if (typeof info !== "object" || info === null) return null;
6822
+ const { status, resetsAt, rateLimitType } = info;
6823
+ if (status !== "rejected") return null;
6824
+ const limit = typeof rateLimitType === "string" && rateLimitType ? rateLimitType : null;
6825
+ const resumeAt = typeof resetsAt === "number" && Number.isFinite(resetsAt) && resetsAt > 0 ? toEpochMs(resetsAt) : null;
6826
+ return {
6827
+ resumeAt,
6828
+ source: "stream",
6829
+ limit,
6830
+ detail: `${limit ?? "usage"} limit rejected${resumeAt === null ? "" : ` \xB7 resets ${new Date(resumeAt).toISOString()}`}`
6831
+ };
6832
+ }
6833
+ function rateLimitFromStderrLine(line, now) {
6834
+ const clean = line.replace(ANSI, "").trim();
6835
+ if (!clean) return null;
6836
+ const matches = USAGE_LIMIT_PREFIXES.some((prefix) => clean.startsWith(prefix)) || USAGE_LIMIT_PATTERN.test(clean);
6837
+ if (!matches) return null;
6838
+ const [, reset] = clean.split("\xB7");
6839
+ const resumeAt = reset ? parseResetTime(reset.replace(/^\s*resets\s*/i, ""), now) : null;
6840
+ return { resumeAt, source: "stderr", limit: null, detail: clean };
6841
+ }
6842
+ var MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
6843
+ function zoneOffsetMs(zone, at) {
6844
+ const parts = new Intl.DateTimeFormat("en-US", {
6845
+ timeZone: zone,
6846
+ hour12: false,
6847
+ year: "numeric",
6848
+ month: "2-digit",
6849
+ day: "2-digit",
6850
+ hour: "2-digit",
6851
+ minute: "2-digit",
6852
+ second: "2-digit"
6853
+ }).formatToParts(new Date(at));
6854
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? "0");
6855
+ const hour = get("hour") % 24;
6856
+ const asUtc = Date.UTC(
6857
+ get("year"),
6858
+ get("month") - 1,
6859
+ get("day"),
6860
+ hour,
6861
+ get("minute"),
6862
+ get("second")
6863
+ );
6864
+ return asUtc - Math.floor(at / 1e3) * 1e3;
6865
+ }
6866
+ function zonedTimeToEpoch(zone, y, m, d, hh, mm) {
6867
+ const naive = Date.UTC(y, m, d, hh, mm, 0);
6868
+ let guess = naive - zoneOffsetMs(zone, naive);
6869
+ guess = naive - zoneOffsetMs(zone, guess);
6870
+ return guess;
6871
+ }
6872
+ function zonedDate(zone, at) {
6873
+ const parts = new Intl.DateTimeFormat("en-US", {
6874
+ timeZone: zone,
6875
+ year: "numeric",
6876
+ month: "2-digit",
6877
+ day: "2-digit"
6878
+ }).formatToParts(new Date(at));
6879
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? "0");
6880
+ return { y: get("year"), m: get("month") - 1, d: get("day") };
6881
+ }
6882
+ var DURATION = /(\d+)\s*([dhms])/g;
6883
+ function parseResetTime(text2, now) {
6884
+ const raw = text2.trim();
6885
+ if (!raw) return null;
6886
+ const relative = raw.match(/^in\s+(.+)$/i);
6887
+ if (relative) {
6888
+ let ms = 0;
6889
+ let matched = false;
6890
+ for (const [, amount, unit] of relative[1].matchAll(DURATION)) {
6891
+ const n = Number(amount);
6892
+ ms += n * { d: 864e5, h: 36e5, m: 6e4, s: 1e3 }[unit];
6893
+ matched = true;
6894
+ }
6895
+ return matched ? now + ms : null;
6896
+ }
6897
+ const zoned = raw.match(/^(.*?)\s*\(([A-Za-z_]+\/[A-Za-z_+-]+|UTC)\)$/);
6898
+ if (!zoned) return null;
6899
+ const [, head, zone] = zoned;
6900
+ try {
6901
+ zoneOffsetMs(zone, now);
6902
+ } catch {
6903
+ return null;
6904
+ }
6905
+ const clock = head.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
6906
+ if (!clock) return null;
6907
+ const meridiem = clock[3].toLowerCase();
6908
+ const hour12 = Number(clock[1]) % 12;
6909
+ const hh = meridiem === "pm" ? hour12 + 12 : hour12;
6910
+ const mm = Number(clock[2] ?? "0");
6911
+ const dated = head.match(/^([A-Za-z]{3})[a-z]*\s+(\d{1,2})(?:,\s*(\d{4}))?,/);
6912
+ if (dated) {
6913
+ const month = MONTHS.indexOf(dated[1].toLowerCase());
6914
+ if (month === -1) return null;
6915
+ const here = zonedDate(zone, now);
6916
+ const year = dated[3] ? Number(dated[3]) : here.y;
6917
+ let at2 = zonedTimeToEpoch(zone, year, month, Number(dated[2]), hh, mm);
6918
+ if (!dated[3] && at2 < now - 864e5)
6919
+ at2 = zonedTimeToEpoch(zone, year + 1, month, Number(dated[2]), hh, mm);
6920
+ return at2;
6921
+ }
6922
+ const today = zonedDate(zone, now);
6923
+ const at = zonedTimeToEpoch(zone, today.y, today.m, today.d, hh, mm);
6924
+ return at > now ? at : zonedTimeToEpoch(zone, today.y, today.m, today.d + 1, hh, mm);
6925
+ }
6926
+ var RateLimitDetector = class {
6927
+ stream = null;
6928
+ stderr = null;
6929
+ pending = "";
6930
+ noteStreamEvent(event) {
6931
+ const signal = rateLimitFromStreamEvent(event);
6932
+ if (signal) this.stream = signal;
6933
+ }
6934
+ noteStderr(chunk, now = Date.now()) {
6935
+ this.pending += chunk;
6936
+ let nl = this.pending.indexOf("\n");
6937
+ while (nl !== -1) {
6938
+ this.line(this.pending.slice(0, nl), now);
6939
+ this.pending = this.pending.slice(nl + 1);
6940
+ nl = this.pending.indexOf("\n");
6941
+ }
6942
+ }
6943
+ /** Flushes a trailing partial line. Call once the harness has exited. */
6944
+ finish(now = Date.now()) {
6945
+ if (this.pending) {
6946
+ this.line(this.pending, now);
6947
+ this.pending = "";
6948
+ }
6949
+ }
6950
+ /** The structured signal when there is one; the prose only as a fallback. */
6951
+ signal() {
6952
+ return this.stream ?? this.stderr;
6953
+ }
6954
+ line(raw, now) {
6955
+ const signal = rateLimitFromStderrLine(raw, now);
6956
+ if (signal) this.stderr = signal;
6957
+ }
6958
+ };
6959
+
6775
6960
  // src/daemon/cli-refresh.ts
6776
6961
  import { spawn } from "node:child_process";
6777
6962
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync7 } from "node:fs";
@@ -6901,11 +7086,13 @@ var RunTable = class {
6901
7086
  * this still cleans up — the slot, workspace, and state entry must never
6902
7087
  * outlive the run — but reports nothing.
6903
7088
  *
6904
- * Pass `judgment: 'interrupted'` when the daemon itself ended the run
7089
+ * Pass `{ judgment: 'interrupted' }` when the daemon itself ended the run
6905
7090
  * (shutdown, restart) rather than the work failing: the supervisor then
6906
7091
  * spares the issue a strike. Everything a run can do wrong to itself —
6907
7092
  * a non-zero harness exit, a workspace that would not set up — must not
6908
- * set it.
7093
+ * set it. The one exception is `{ judgment: 'rate_limited' }`: a non-zero
7094
+ * exit whose cause was the provider refusing on a usage limit is the
7095
+ * runner's condition, not the work's.
6909
7096
  */
6910
7097
  async finishAndCleanup(run, status, error, judgment) {
6911
7098
  if (run.settled) return this.cleanup(run);
@@ -7442,7 +7629,7 @@ async function runDaemon(opts) {
7442
7629
  await client2.finishRun(run.runId, {
7443
7630
  status,
7444
7631
  ...error ? { error } : {},
7445
- ...judgment ? { judgment } : {}
7632
+ ...judgment
7446
7633
  });
7447
7634
  },
7448
7635
  release: (run, { keep, outcome }) => {
@@ -7615,7 +7802,12 @@ async function runDaemon(opts) {
7615
7802
  table2.persist();
7616
7803
  log(`run ${runId}: launched ${invocation.file} (pid ${child.pid})`);
7617
7804
  if (opts.harness === "claude_code") {
7618
- const renderer = new ClaudeStreamRenderer((line) => run.batcher.append(line));
7805
+ const limiter = new RateLimitDetector();
7806
+ run.limiter = limiter;
7807
+ const renderer = new ClaudeStreamRenderer(
7808
+ (line) => run.batcher.append(line),
7809
+ (event) => limiter.noteStreamEvent(event)
7810
+ );
7619
7811
  run.renderer = renderer;
7620
7812
  run.drain = () => renderer.finish();
7621
7813
  const spoolPath = join5(opts.configDir, "rawlogs", `${runId}.ndjson`);
@@ -7630,7 +7822,11 @@ async function runDaemon(opts) {
7630
7822
  } else {
7631
7823
  child.stdout?.on("data", (data) => run.batcher.append(data.toString("utf8")));
7632
7824
  }
7633
- child.stderr?.on("data", (data) => run.batcher.append(data.toString("utf8")));
7825
+ child.stderr?.on("data", (data) => {
7826
+ const text2 = data.toString("utf8");
7827
+ run.batcher.append(text2);
7828
+ run.limiter?.noteStderr(text2);
7829
+ });
7634
7830
  run.timeout = setTimeout(() => {
7635
7831
  if (run.settled) return;
7636
7832
  log(`run ${runId} hit its ${assignment.timeout_minutes}m timeout; killing`);
@@ -7643,6 +7839,7 @@ async function runDaemon(opts) {
7643
7839
  });
7644
7840
  child.on("exit", (code, signal) => {
7645
7841
  run.drain?.();
7842
+ run.limiter?.finish();
7646
7843
  const closing = exitLineForRun(run, {
7647
7844
  code,
7648
7845
  signal,
@@ -7658,11 +7855,22 @@ async function runDaemon(opts) {
7658
7855
  } else if (code === 0) {
7659
7856
  void table2.finishAndCleanup(run, "completed");
7660
7857
  } else {
7661
- void table2.finishAndCleanup(
7662
- run,
7663
- "failed",
7664
- signal ? `harness killed by ${signal}` : `harness exited with code ${code}`
7665
- );
7858
+ const limited = signal ? null : run.limiter?.signal() ?? null;
7859
+ if (limited) {
7860
+ log(
7861
+ `run ${runId}: harness rate limited (${limited.detail}); reporting without a strike`
7862
+ );
7863
+ void table2.finishAndCleanup(run, "failed", `rate limited: ${limited.detail}`, {
7864
+ judgment: "rate_limited",
7865
+ ...limited.resumeAt !== null ? { resume_at: limited.resumeAt } : {}
7866
+ });
7867
+ } else {
7868
+ void table2.finishAndCleanup(
7869
+ run,
7870
+ "failed",
7871
+ signal ? `harness killed by ${signal}` : `harness exited with code ${code}`
7872
+ );
7873
+ }
7666
7874
  }
7667
7875
  });
7668
7876
  } catch (err) {
@@ -7676,7 +7884,9 @@ async function runDaemon(opts) {
7676
7884
  await Promise.all(
7677
7885
  table2.values().map(async (run) => {
7678
7886
  if (run.child?.pid) killTree(run.child.pid, "SIGTERM");
7679
- await table2.finishAndCleanup(run, "failed", "daemon shut down", "interrupted");
7887
+ await table2.finishAndCleanup(run, "failed", "daemon shut down", {
7888
+ judgment: "interrupted"
7889
+ });
7680
7890
  })
7681
7891
  );
7682
7892
  process.exit(0);
@@ -7815,6 +8025,11 @@ function register8(program3) {
7815
8025
  `active runs: ${runner.active_runs}/${runner.max_concurrent} timeout: ${runner.max_run_minutes}m default tier: ${runner.default_tier}`
7816
8026
  );
7817
8027
  if (runner.last_seen_at) console.log(`last seen: ${timestamp(runner.last_seen_at)}`);
8028
+ if (runner.backoff_reason === "rate_limit" && runner.backoff_until) {
8029
+ console.log(
8030
+ `rate limited: the harness account is out of usage; resumes ${timestamp(runner.backoff_until)}`
8031
+ );
8032
+ }
7818
8033
  if (runner.launch_failures > 0) {
7819
8034
  console.log(
7820
8035
  `consecutive failures: ${runner.launch_failures}${runner.backoff_until ? ` (backing off until ${timestamp(runner.backoff_until)})` : ""}`
@@ -8416,6 +8631,8 @@ function queueHeadline(block) {
8416
8631
  return `${runner} draining`;
8417
8632
  case "backing_off":
8418
8633
  return `${runner} backing off`;
8634
+ case "rate_limited":
8635
+ return `${runner} rate limited`;
8419
8636
  case "no_rule":
8420
8637
  return "no matching routing rule";
8421
8638
  case "no_targets":
@@ -8452,6 +8669,8 @@ function queueFix(block) {
8452
8669
  return "tines supervisor enable";
8453
8670
  case "backing_off":
8454
8671
  return "retries automatically; check the daemon log if it keeps failing";
8672
+ case "rate_limited":
8673
+ return "resumes automatically when the usage window resets; nothing to do";
8455
8674
  default:
8456
8675
  return null;
8457
8676
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tines",
3
- "version": "0.0.146",
3
+ "version": "0.0.147",
4
4
  "description": "CLI for Tines, an orchestration layer for AI agents",
5
5
  "repository": {
6
6
  "type": "git",