flowviant 0.9.1 → 0.11.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/bin/cli.mjs +16 -3
- package/bin/lib/config.mjs +6 -1
- package/bin/lib/fleet.mjs +16 -0
- package/bin/lib/live.mjs +77 -1
- package/bin/lib/update.mjs +137 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Three modes, picked by which env var is set:
|
|
6
6
|
*
|
|
7
|
-
* FLOWVIANT_TOKEN=fva_… npx flowviant
|
|
8
|
-
* FLOWVIANT_TOKENS=a,b,c npx flowviant
|
|
9
|
-
* FLOWVIANT_FLEET=fft_… npx flowviant
|
|
7
|
+
* FLOWVIANT_TOKEN=fva_… npx flowviant@latest # 1 worker, current checkout
|
|
8
|
+
* FLOWVIANT_TOKENS=a,b,c npx flowviant@latest # static fleet, 1 worktree each
|
|
9
|
+
* FLOWVIANT_FLEET=fft_… npx flowviant@latest # FLEET DAEMON (recommended)
|
|
10
|
+
*
|
|
11
|
+
* Launch with `@latest` so each start pulls the newest published version (bare
|
|
12
|
+
* `npx flowviant` can reuse a stale cache). A running daemon also self-updates
|
|
13
|
+
* on its own — at startup and when idle — so it stays current without restarts
|
|
14
|
+
* (FLOWVIANT_NO_UPDATE=1 makes it nag-only; `flowviant update` updates now).
|
|
10
15
|
*
|
|
11
16
|
* Fleet daemon: install ONCE with a fleet credential, then manage everything
|
|
12
17
|
* from Flowviant. The daemon polls GET /api/v2/fleet/agents, reconciles one
|
|
@@ -44,6 +49,14 @@ if (process.argv[2] === 'login') {
|
|
|
44
49
|
process.exit(0);
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
// `flowviant update` — install the latest published version now. The daemon also
|
|
53
|
+
// self-updates on its own (at startup + when idle); this is the manual path.
|
|
54
|
+
if (process.argv[2] === 'update') {
|
|
55
|
+
const { runUpdateCommand } = await import('./lib/update.mjs');
|
|
56
|
+
runUpdateCommand();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
// `flowviant clean` — reclaim the persistent worktrees (~/.flowviant/worktrees).
|
|
48
61
|
// They're kept across runs so in-flight work survives Ctrl+C; this is the drain.
|
|
49
62
|
// Repos self-heal: the daemon runs `git worktree prune` if a stale registration
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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.
|
|
7
|
+
export const VERSION = '0.11.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.
|
|
@@ -35,6 +35,11 @@ export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
|
|
|
35
35
|
// so a long-lived daemon never silently 401s on an expired token.
|
|
36
36
|
export const REFRESH_BEFORE_SECONDS = Number(process.env.REFRESH_BEFORE_SECONDS || 3600);
|
|
37
37
|
export const SAFE = process.env.FLOWVIANT_SAFE === '1';
|
|
38
|
+
// Self-update: a running daemon updates itself to the latest published version
|
|
39
|
+
// (at startup + when idle) and re-execs. On by default; FLOWVIANT_NO_UPDATE=1
|
|
40
|
+
// keeps it nag-only (it still tells you to update, never installs). Below the
|
|
41
|
+
// server's MIN version it updates regardless, since live mode won't work.
|
|
42
|
+
export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
|
|
38
43
|
// Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
|
|
39
44
|
// streams into the task channel, injectable mid-task, blocker-parks in place,
|
|
40
45
|
// delivery card on complete, branch preview tunnels. The legacy poll/sentinel
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
RECONCILE_SECONDS,
|
|
23
23
|
REFRESH_BEFORE_SECONDS,
|
|
24
24
|
LIVE,
|
|
25
|
+
AUTO_UPDATE,
|
|
25
26
|
} from './config.mjs';
|
|
27
|
+
import { handleVersionSignal } from './update.mjs';
|
|
26
28
|
import {
|
|
27
29
|
git,
|
|
28
30
|
resetWorktree,
|
|
@@ -411,6 +413,20 @@ export async function runFleetDaemon() {
|
|
|
411
413
|
}
|
|
412
414
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
413
415
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
416
|
+
// Keep the daemon current. Safe = no worker mid-task (true at startup, since
|
|
417
|
+
// no workers are spawned yet). If it self-updates it re-execs into the new
|
|
418
|
+
// version and this process becomes a proxy — stop the loop.
|
|
419
|
+
if (roster.daemon) {
|
|
420
|
+
const safeToUpdate = [...workers.values()].every((w) => w.state.child == null);
|
|
421
|
+
const updating = handleVersionSignal({
|
|
422
|
+
latest: roster.daemon.latest,
|
|
423
|
+
min: roster.daemon.min,
|
|
424
|
+
autoUpdate: AUTO_UPDATE,
|
|
425
|
+
safeToUpdate,
|
|
426
|
+
teardown,
|
|
427
|
+
});
|
|
428
|
+
if (updating) return;
|
|
429
|
+
}
|
|
414
430
|
processMergeJobs(roster.mergeJobs);
|
|
415
431
|
processCleanupJobs(roster.cleanupJobs);
|
|
416
432
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
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' ||
|
|
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);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update — keep a long-running daemon current without babysitting it.
|
|
3
|
+
*
|
|
4
|
+
* A daemon runs for hours/days from one launch, so "latest at launch" (even with
|
|
5
|
+
* `npx flowviant@latest`) doesn't help a process that's already up when a new
|
|
6
|
+
* version ships. The server reports {latest, min} on every roster poll; the
|
|
7
|
+
* daemon compares its own VERSION and, at a SAFE boundary (startup or idle —
|
|
8
|
+
* never mid-task), self-updates + re-execs. Below `min` it updates regardless
|
|
9
|
+
* (older protocol is known-broken); otherwise it honors AUTO_UPDATE. When it
|
|
10
|
+
* can't install (npx cache, or auto off) it nags with the exact command.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { VERSION } from './config.mjs';
|
|
16
|
+
import { note, ok, warn } from './ui.mjs';
|
|
17
|
+
|
|
18
|
+
/** Compare x.y.z version strings → -1 | 0 | 1 (missing parts read as 0). */
|
|
19
|
+
export function cmpVersion(a, b) {
|
|
20
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
21
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
24
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* npx runs from a per-invocation cache dir. `npm i -g` would install to a
|
|
31
|
+
* DIFFERENT location than the one executing, so re-execing our own path would
|
|
32
|
+
* loop on the stale cached copy. Detect npx and skip the install (nag instead —
|
|
33
|
+
* relaunching with `npx flowviant@latest` is the npx-native update).
|
|
34
|
+
*/
|
|
35
|
+
export function runningViaNpx() {
|
|
36
|
+
const ua = process.env.npm_config_user_agent || '';
|
|
37
|
+
const argv1 = process.argv[1] || '';
|
|
38
|
+
let self = '';
|
|
39
|
+
try {
|
|
40
|
+
self = fileURLToPath(import.meta.url);
|
|
41
|
+
} catch {
|
|
42
|
+
/* non-file URL — ignore */
|
|
43
|
+
}
|
|
44
|
+
return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Replace this process with a fresh one running the just-installed version.
|
|
49
|
+
* `npm i -g` overwrote the global package in place, so re-running argv[1] loads
|
|
50
|
+
* the NEW code. We tear down first (idle-gated, so nothing's mid-task) and keep
|
|
51
|
+
* this process alive only as a thin proxy waiting on the child, so the user's
|
|
52
|
+
* shell stays attached to one foreground process.
|
|
53
|
+
*/
|
|
54
|
+
function reexec(teardown) {
|
|
55
|
+
try {
|
|
56
|
+
teardown?.();
|
|
57
|
+
} catch {
|
|
58
|
+
/* best-effort */
|
|
59
|
+
}
|
|
60
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
61
|
+
stdio: 'inherit',
|
|
62
|
+
env: process.env,
|
|
63
|
+
});
|
|
64
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Install @latest globally. Throws on failure (EACCES without sudo, offline…). */
|
|
68
|
+
function installLatest() {
|
|
69
|
+
execFileSync('npm', ['install', '-g', 'flowviant@latest'], { stdio: 'inherit' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `flowviant update` — explicit, manual update. Does not re-exec into a daemon
|
|
73
|
+
* (the user ran a one-shot command); it installs and tells them to relaunch. */
|
|
74
|
+
export function runUpdateCommand() {
|
|
75
|
+
if (runningViaNpx()) {
|
|
76
|
+
note('running via npx — just relaunch with `npx flowviant@latest` to get the newest.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
note(`updating flowviant (currently ${VERSION})…`);
|
|
81
|
+
installLatest();
|
|
82
|
+
ok('updated. Relaunch `flowviant` to run the new version.');
|
|
83
|
+
} catch (e) {
|
|
84
|
+
warn(`update failed (${e?.message ?? e}). Try: npm i -g flowviant@latest`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Nag at most once per target version, so a poll every ~10s doesn't spam.
|
|
89
|
+
let naggedFor = null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* React to the server's {latest, min} signal from a roster poll.
|
|
93
|
+
* @returns true if it kicked off a self-update + re-exec (caller must stop).
|
|
94
|
+
*/
|
|
95
|
+
export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
|
|
96
|
+
const cur = VERSION;
|
|
97
|
+
const belowMin = min && cmpVersion(cur, min) < 0;
|
|
98
|
+
const belowLatest = latest && cmpVersion(cur, latest) < 0;
|
|
99
|
+
if (!belowMin && !belowLatest) return false; // current — nothing to do
|
|
100
|
+
const target = latest || min;
|
|
101
|
+
const npx = runningViaNpx();
|
|
102
|
+
const wantInstall = belowMin || autoUpdate;
|
|
103
|
+
|
|
104
|
+
if (wantInstall && !npx) {
|
|
105
|
+
if (!safeToUpdate) {
|
|
106
|
+
// Outdated but an agent is mid-task — wait for idle. Nag once meanwhile.
|
|
107
|
+
if (naggedFor !== target) {
|
|
108
|
+
naggedFor = target;
|
|
109
|
+
note(`flowviant ${cur} → ${target} available — self-updating once agents go idle.`);
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
note(`flowviant ${cur} → ${target}: self-updating…`);
|
|
115
|
+
installLatest();
|
|
116
|
+
ok('updated — restarting into the new version.');
|
|
117
|
+
reexec(teardown);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
warn(`self-update failed (${e?.message ?? e}) — update manually: npm i -g flowviant@latest`);
|
|
121
|
+
naggedFor = target; // don't retry-spam a failing install every poll
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Can't or won't auto-install → nag once per target version.
|
|
127
|
+
if (naggedFor !== target) {
|
|
128
|
+
naggedFor = target;
|
|
129
|
+
const how = npx ? 'relaunch with `npx flowviant@latest`' : 'run `npm i -g flowviant@latest`';
|
|
130
|
+
if (belowMin) {
|
|
131
|
+
warn(`flowviant ${cur} is below the minimum ${min} — live mode may not work. Update: ${how}.`);
|
|
132
|
+
} else {
|
|
133
|
+
note(`flowviant ${cur} → ${latest} available. Update: ${how}.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|