flowviant 0.9.1 → 0.10.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.
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.9.1';
7
+ export const VERSION = '0.10.0';
8
8
 
9
9
  // Credential stored by `flowviant login` (device auth) — the no-token,
10
10
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
package/bin/lib/live.mjs CHANGED
@@ -255,6 +255,52 @@ async function waitForMessage(mcpUrl, token, runId, afterId, isAlive) {
255
255
 
256
256
  // One task: claim → seed → stream/mirror/inject/park → complete. Returns
257
257
  // { outcome: 'nothing' | 'done' | 'blocked' | 'stalled' | 'error' }.
258
+ // Distinguish "YOUR OWN Claude account is out of quota" (the user must wait or
259
+ // hand off — a park) from a transient Anthropic-side hiccup (retry soon — a
260
+ // plain error). Only the former parks. resetAt is lifted from the limit
261
+ // response's retry headers when present, so the thread can say when it's back.
262
+ export function classifyRateLimit(e) {
263
+ const status = e?.status ?? e?.statusCode ?? e?.response?.status;
264
+ const msg = String(e?.message ?? e ?? '').toLowerCase();
265
+ const overloaded = status === 529 || msg.includes('overloaded');
266
+ const isRateLimit =
267
+ !overloaded &&
268
+ (status === 429 ||
269
+ /rate.?limit|usage limit|quota|too many requests|exceeded your|reached your|limit reached/.test(
270
+ msg,
271
+ ));
272
+ if (!isRateLimit) return { isRateLimit: false };
273
+ let resetAt;
274
+ const hdrs = e?.headers ?? e?.response?.headers;
275
+ const get = (k) => hdrs?.get?.(k) ?? hdrs?.[k];
276
+ const retryAfter = Number(get?.('retry-after'));
277
+ const resetHdr = get?.('anthropic-ratelimit-unified-reset');
278
+ if (Number.isFinite(retryAfter) && retryAfter > 0) {
279
+ resetAt = new Date(Date.now() + retryAfter * 1000).toISOString();
280
+ } else if (resetHdr != null) {
281
+ const epoch = Number(resetHdr);
282
+ if (Number.isFinite(epoch) && epoch > 0) resetAt = new Date(epoch * 1000).toISOString();
283
+ else if (!Number.isNaN(Date.parse(resetHdr))) resetAt = new Date(resetHdr).toISOString();
284
+ }
285
+ return { isRateLimit: true, resetAt };
286
+ }
287
+
288
+ // Wait out a Claude-account limit, heartbeating so the 30-min lease stays warm
289
+ // and the task isn't reclaimed while paused. Caps the wait so an unknown or very
290
+ // distant reset still retries eventually (and re-parks if still limited).
291
+ async function parkUntilReset(resetAt, { mcpUrl, getToken, runId, isAlive }) {
292
+ const MAX_PARK_MS = 60 * 60 * 1000; // never sit longer than an hour before retrying
293
+ const DEFAULT_PARK_MS = 15 * 60 * 1000; // no reset given → try again in 15 min
294
+ const now = Date.now();
295
+ const target = resetAt ? Date.parse(resetAt) : now + DEFAULT_PARK_MS;
296
+ const until = Math.min(Number.isFinite(target) ? target : now + DEFAULT_PARK_MS, now + MAX_PARK_MS);
297
+ while (isAlive() && Date.now() < until) {
298
+ const token = getToken();
299
+ if (token) await mcpCall(mcpUrl, token, 'heartbeat', { runId }).catch(() => {});
300
+ await sleep(IDLE_SECONDS);
301
+ }
302
+ }
303
+
258
304
  export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resumeIntentId, onChild }) {
259
305
  const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
260
306
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
@@ -493,6 +539,14 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
493
539
  }
494
540
  return { outcome: completed ? 'done' : 'stalled', title, intentId };
495
541
  } catch (e) {
542
+ const rl = classifyRateLimit(e);
543
+ if (rl.isRateLimit) {
544
+ // The user's OWN Claude account is tapped. Park the run so the thread shows
545
+ // it as their plan's limit (not a Flowviant error) and the lease stays warm
546
+ // for a resume in place — never reset this worktree's work.
547
+ await mcpCall(mcpUrl, token, 'report_paused', { runId, resetAt: rl.resetAt }).catch(() => {});
548
+ return { outcome: 'rate_limited', resetAt: rl.resetAt, runId, title, intentId };
549
+ }
496
550
  return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
497
551
  } finally {
498
552
  onChild?.(null); // no longer busy — token may rotate between tasks
@@ -617,7 +671,10 @@ export async function runLiveWorker({
617
671
  // stalled / errored → same worktree resumes). Finishing or finding no work
618
672
  // clears it so the next fresh task starts from a clean base.
619
673
  lastIntentId =
620
- res.outcome === 'parked' || res.outcome === 'stalled' || res.outcome === 'error'
674
+ res.outcome === 'parked' ||
675
+ res.outcome === 'stalled' ||
676
+ res.outcome === 'error' ||
677
+ res.outcome === 'rate_limited'
621
678
  ? res.intentId
622
679
  : null;
623
680
  if (res.outcome === 'nothing') {
@@ -651,6 +708,25 @@ export async function runLiveWorker({
651
708
  // on reconnect. Nothing to do but stop cleanly.
652
709
  break;
653
710
  }
711
+ if (res.outcome === 'rate_limited') {
712
+ // The agent's OWN Claude account hit its limit — not a Flowviant failure.
713
+ // Hold the worktree + lease and wait it out (heartbeating so it isn't
714
+ // reclaimed), then resume the SAME task in place. Never reset the worktree.
715
+ const when = res.resetAt ? ` until ~${new Date(res.resetAt).toLocaleTimeString()}` : '';
716
+ enter(
717
+ 'paused',
718
+ warn,
719
+ `${c.yellow('paused')} ${c.dim(`— your Claude account hit its usage limit; holding your work${when}`)}`,
720
+ );
721
+ await parkUntilReset(res.resetAt, {
722
+ mcpUrl: getMcpUrl() ?? MCP_URL,
723
+ getToken: () => getToken(agentId),
724
+ runId: res.runId,
725
+ isAlive,
726
+ });
727
+ phase = '';
728
+ continue;
729
+ }
654
730
  // stalled / error — usually a stale token or a stuck turn. Refresh + retry.
655
731
  enter('reconnect', warn, `${c.yellow(res.outcome)} ${c.dim('— refreshing token, retrying')}`);
656
732
  onTokenSuspect?.(agentId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {