flowviant 0.9.0 → 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.
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +74 -8
- package/bin/lib/git.mjs +44 -0
- package/bin/lib/live.mjs +118 -6
- package/bin/lib/login.mjs +1 -0
- package/package.json +1 -1
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.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/fleet.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* MCP token, and only spawns Claude when the server says an agent has work.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { mkdirSync, existsSync } from 'node:fs';
|
|
8
|
+
import { mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
@@ -23,6 +23,16 @@ import {
|
|
|
23
23
|
REFRESH_BEFORE_SECONDS,
|
|
24
24
|
LIVE,
|
|
25
25
|
} from './config.mjs';
|
|
26
|
+
import {
|
|
27
|
+
git,
|
|
28
|
+
resetWorktree,
|
|
29
|
+
repoRootOrDie,
|
|
30
|
+
detectBaseRef,
|
|
31
|
+
originSlug,
|
|
32
|
+
isValidPrUrl,
|
|
33
|
+
isValidBranch,
|
|
34
|
+
isSafePathSegment,
|
|
35
|
+
} from './git.mjs';
|
|
26
36
|
import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
|
|
27
37
|
import {
|
|
28
38
|
sleep,
|
|
@@ -34,7 +44,6 @@ import {
|
|
|
34
44
|
SINGLE_KICKOFF,
|
|
35
45
|
SINGLE_RESUME,
|
|
36
46
|
} from './claude.mjs';
|
|
37
|
-
import { git, repoRootOrDie, detectBaseRef, resetWorktree } from './git.mjs';
|
|
38
47
|
import { runLiveWorker } from './live.mjs';
|
|
39
48
|
import { preflight } from './preflight.mjs';
|
|
40
49
|
|
|
@@ -45,6 +54,7 @@ async function fetchRoster(haveIds) {
|
|
|
45
54
|
// Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
|
|
46
55
|
const res = await fetch(url, {
|
|
47
56
|
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
57
|
+
signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
|
|
48
58
|
});
|
|
49
59
|
if (res.status === 401 || res.status === 403) {
|
|
50
60
|
// Fleet credential revoked/expired — retrying can't recover; signal exit.
|
|
@@ -54,7 +64,20 @@ async function fetchRoster(haveIds) {
|
|
|
54
64
|
}
|
|
55
65
|
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
56
66
|
const body = await res.json();
|
|
57
|
-
|
|
67
|
+
// Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
|
|
68
|
+
// throws a NORMAL retryable error inside the loop's try/catch, instead of a
|
|
69
|
+
// `roster.agents.map` TypeError escaping to top-level and killing the daemon.
|
|
70
|
+
const data = body?.data;
|
|
71
|
+
if (!data || !Array.isArray(data.agents)) {
|
|
72
|
+
throw new Error('fleet poll returned an unexpected shape');
|
|
73
|
+
}
|
|
74
|
+
// Drop roster agents with an unsafe id BEFORE they're used as a path segment.
|
|
75
|
+
data.agents = data.agents.filter((a) => {
|
|
76
|
+
if (isSafePathSegment(a?.agentId)) return true;
|
|
77
|
+
warn(`ignoring roster agent with an invalid id: ${JSON.stringify(a?.agentId)}`);
|
|
78
|
+
return false;
|
|
79
|
+
});
|
|
80
|
+
return data; // { mcpUrl, leaseTtlSeconds, agents: [{agentId,name,token,reviewGate,hasWork}] }
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
// One roster agent's loop: persistent worktree, one intent per turn, reset to
|
|
@@ -186,6 +209,14 @@ export async function runFleetDaemon() {
|
|
|
186
209
|
} catch {
|
|
187
210
|
/* best-effort */
|
|
188
211
|
}
|
|
212
|
+
// Stop the detached preview (dev server + cloudflared tunnel) — it's its
|
|
213
|
+
// own process group and survives our exit, otherwise leaking a port-bound
|
|
214
|
+
// server + a live tunnel serving a stale branch until reboot.
|
|
215
|
+
try {
|
|
216
|
+
w.state.stopPreview?.();
|
|
217
|
+
} catch {
|
|
218
|
+
/* best-effort */
|
|
219
|
+
}
|
|
189
220
|
}
|
|
190
221
|
};
|
|
191
222
|
process.on('SIGINT', () => {
|
|
@@ -210,6 +241,7 @@ export async function runFleetDaemon() {
|
|
|
210
241
|
'User-Agent': USER_AGENT,
|
|
211
242
|
'Content-Type': 'application/json',
|
|
212
243
|
},
|
|
244
|
+
signal: AbortSignal.timeout(30_000),
|
|
213
245
|
body: JSON.stringify(body),
|
|
214
246
|
});
|
|
215
247
|
} catch {
|
|
@@ -225,6 +257,18 @@ export async function runFleetDaemon() {
|
|
|
225
257
|
note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
|
|
226
258
|
let merged = false;
|
|
227
259
|
let failedReason = null; // permanent — tell the thread, clear the flag
|
|
260
|
+
// Refuse a PR URL that isn't an https github.com PR in THIS repo — a
|
|
261
|
+
// bad/hostile server must not merge a PR in another repo the user's
|
|
262
|
+
// gh can write to (and a leading '-' would be a gh flag).
|
|
263
|
+
if (!isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
|
|
264
|
+
mergeAttempts.delete(job.id);
|
|
265
|
+
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
266
|
+
intentId: job.id,
|
|
267
|
+
message: 'refused: PR URL is not a pull request in this repository',
|
|
268
|
+
});
|
|
269
|
+
warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
228
272
|
try {
|
|
229
273
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
230
274
|
cwd: repoRoot,
|
|
@@ -234,8 +278,13 @@ export async function runFleetDaemon() {
|
|
|
234
278
|
} catch (e) {
|
|
235
279
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
236
280
|
const line = err.split('\n')[0] || 'gh pr merge failed';
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
// Only "already merged" is a real success; a CLOSED-without-merge PR
|
|
282
|
+
// also matches "not open"/"closed" but nothing landed on main —
|
|
283
|
+
// report it as a failure so the thread learns the truth.
|
|
284
|
+
if (/already merged/i.test(err)) merged = true;
|
|
285
|
+
else if (/not open|closed/i.test(err)) {
|
|
286
|
+
failedReason = 'the PR was closed without merging';
|
|
287
|
+
} else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
|
|
239
288
|
// Permanent until a human/agent acts — don't spin on it.
|
|
240
289
|
failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
|
|
241
290
|
} else {
|
|
@@ -279,7 +328,10 @@ export async function runFleetDaemon() {
|
|
|
279
328
|
(async () => {
|
|
280
329
|
try {
|
|
281
330
|
note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
|
|
282
|
-
|
|
331
|
+
// Same guards as merge: only close a PR in THIS repo, only delete a
|
|
332
|
+
// well-formed non-base branch. A bad server must not close a stranger's
|
|
333
|
+
// PR or delete `main` (`--delete` with `main`) via a cleanup job.
|
|
334
|
+
if (job.prUrl && isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
|
|
283
335
|
try {
|
|
284
336
|
execFileSync(
|
|
285
337
|
'gh',
|
|
@@ -299,15 +351,18 @@ export async function runFleetDaemon() {
|
|
|
299
351
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
300
352
|
warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
|
|
301
353
|
}
|
|
302
|
-
} else if (job.branch) {
|
|
354
|
+
} else if (job.branch && isValidBranch(job.branch, repoRoot, baseRef)) {
|
|
303
355
|
try {
|
|
304
|
-
|
|
356
|
+
// Explicit refspec form so a leading '-' can't be a git flag.
|
|
357
|
+
execFileSync('git', ['push', 'origin', `:refs/heads/${job.branch}`], {
|
|
305
358
|
cwd: repoRoot,
|
|
306
359
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
360
|
});
|
|
308
361
|
} catch {
|
|
309
362
|
/* branch already gone — fine */
|
|
310
363
|
}
|
|
364
|
+
} else if (job.prUrl || job.branch) {
|
|
365
|
+
warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
|
|
311
366
|
}
|
|
312
367
|
await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
|
|
313
368
|
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
@@ -417,6 +472,11 @@ export async function runFleetDaemon() {
|
|
|
417
472
|
onChild: (ch) => {
|
|
418
473
|
state.child = ch;
|
|
419
474
|
},
|
|
475
|
+
// Hold the preview's stop fn so teardown/removal can kill the detached
|
|
476
|
+
// dev-server + tunnel (they survive our exit otherwise).
|
|
477
|
+
onPreview: (stop) => {
|
|
478
|
+
state.stopPreview = stop;
|
|
479
|
+
},
|
|
420
480
|
// A turn that couldn't reach the MCP server: forget the cached token so
|
|
421
481
|
// the next reconcile poll re-mints a fresh one (self-heals a token that
|
|
422
482
|
// was rotated/expired out from under a running session).
|
|
@@ -441,6 +501,11 @@ export async function runFleetDaemon() {
|
|
|
441
501
|
} catch {
|
|
442
502
|
/* best-effort */
|
|
443
503
|
}
|
|
504
|
+
try {
|
|
505
|
+
w.state.stopPreview?.();
|
|
506
|
+
} catch {
|
|
507
|
+
/* best-effort */
|
|
508
|
+
}
|
|
444
509
|
try {
|
|
445
510
|
git(['worktree', 'remove', '--force', w.wt], repoRoot);
|
|
446
511
|
} catch {
|
|
@@ -449,6 +514,7 @@ export async function runFleetDaemon() {
|
|
|
449
514
|
workers.delete(id);
|
|
450
515
|
tokenByAgent.delete(id);
|
|
451
516
|
hasWorkByAgent.delete(id);
|
|
517
|
+
mintedAt.delete(id); // was leaked on removal (finding 14)
|
|
452
518
|
}
|
|
453
519
|
}
|
|
454
520
|
|
package/bin/lib/git.mjs
CHANGED
|
@@ -15,6 +15,50 @@ export function repoRootOrDie() {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// ── Server-value validation ────────────────────────────────────────────────
|
|
19
|
+
// prUrl / branch / agentId arrive from the fleet server. execFileSync blocks
|
|
20
|
+
// SHELL injection but NOT git/gh option injection (a leading '-' becomes a
|
|
21
|
+
// flag) or cross-repo/cross-path abuse. These guards make a malicious or buggy
|
|
22
|
+
// server unable to touch a repo/branch/path outside the expected scope.
|
|
23
|
+
|
|
24
|
+
/** The `owner/repo` the daemon is running inside, from origin's URL. Null if
|
|
25
|
+
* origin isn't a github remote. */
|
|
26
|
+
export function originSlug(repoRoot) {
|
|
27
|
+
try {
|
|
28
|
+
const url = git(['remote', 'get-url', 'origin'], repoRoot);
|
|
29
|
+
const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
30
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A PR URL is accepted only if it's an https github.com PR in THIS repo. */
|
|
37
|
+
export function isValidPrUrl(prUrl, slug) {
|
|
38
|
+
if (typeof prUrl !== 'string' || !slug) return false;
|
|
39
|
+
const m = prUrl.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+$/);
|
|
40
|
+
return !!m && m[1].toLowerCase() === slug.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A branch name is accepted only if git considers it a well-formed ref, it's
|
|
44
|
+
* not the base branch, and it doesn't start with '-' (option injection). */
|
|
45
|
+
export function isValidBranch(branch, repoRoot, baseRef) {
|
|
46
|
+
if (typeof branch !== 'string' || !branch || branch.startsWith('-')) return false;
|
|
47
|
+
if (baseRef && (branch === baseRef || `origin/${branch}` === baseRef)) return false;
|
|
48
|
+
try {
|
|
49
|
+
git(['check-ref-format', '--branch', branch], repoRoot);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A roster agent id used as a filesystem path segment — strict allowlist so
|
|
57
|
+
* it can't traverse (`..`, `/`) out of the worktrees dir. */
|
|
58
|
+
export function isSafePathSegment(id) {
|
|
59
|
+
return typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
|
60
|
+
}
|
|
61
|
+
|
|
18
62
|
export function detectBaseRef(repoRoot) {
|
|
19
63
|
try {
|
|
20
64
|
return git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoRoot); // e.g. origin/main
|
package/bin/lib/live.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
22
22
|
import {
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
} from './config.mjs';
|
|
32
32
|
import { c, info, ok, warn } from './ui.mjs';
|
|
33
33
|
import { sleep } from './claude.mjs';
|
|
34
|
-
import { git, resetWorktree } from './git.mjs';
|
|
34
|
+
import { git, resetWorktree, isValidBranch } from './git.mjs';
|
|
35
35
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
36
36
|
|
|
37
37
|
// Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
|
|
@@ -46,6 +46,7 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
46
46
|
'User-Agent': USER_AGENT,
|
|
47
47
|
'Content-Type': 'application/json',
|
|
48
48
|
},
|
|
49
|
+
signal: AbortSignal.timeout(30_000),
|
|
49
50
|
body: JSON.stringify({ intentId, kind, url }),
|
|
50
51
|
});
|
|
51
52
|
} catch {
|
|
@@ -124,6 +125,7 @@ async function mcpCall(mcpUrl, token, name, args) {
|
|
|
124
125
|
// without this every live MCP call fails against api.flowviant.com.
|
|
125
126
|
'User-Agent': USER_AGENT,
|
|
126
127
|
},
|
|
128
|
+
signal: AbortSignal.timeout(30_000),
|
|
127
129
|
body: JSON.stringify({
|
|
128
130
|
jsonrpc: '2.0',
|
|
129
131
|
id: ++rpcId,
|
|
@@ -206,6 +208,13 @@ function writeTaskMarker(cwd, intentId) {
|
|
|
206
208
|
/* best-effort — worst case the next restart resets to base */
|
|
207
209
|
}
|
|
208
210
|
}
|
|
211
|
+
function clearTaskMarker(cwd) {
|
|
212
|
+
try {
|
|
213
|
+
rmSync(markerPath(cwd), { force: true });
|
|
214
|
+
} catch {
|
|
215
|
+
/* best-effort */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
209
218
|
|
|
210
219
|
// A stop word from any teammate halts the agent (interrupt at the next boundary,
|
|
211
220
|
// then hold for direction) — the "stop, you're going the wrong way" valve.
|
|
@@ -246,6 +255,52 @@ async function waitForMessage(mcpUrl, token, runId, afterId, isAlive) {
|
|
|
246
255
|
|
|
247
256
|
// One task: claim → seed → stream/mirror/inject/park → complete. Returns
|
|
248
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
|
+
|
|
249
304
|
export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resumeIntentId, onChild }) {
|
|
250
305
|
const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
|
|
251
306
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
@@ -261,7 +316,10 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
261
316
|
|
|
262
317
|
// Revision resumes its PR branch; a genuinely fresh task gets a clean base
|
|
263
318
|
// checkout; a resume (in-memory or marker) keeps its dirty worktree untouched.
|
|
264
|
-
|
|
319
|
+
// The branch is server-supplied — validate it's a well-formed non-base ref
|
|
320
|
+
// (not a leading-'-' git option) before checkout; on a bad value fall back to
|
|
321
|
+
// a clean base rather than executing it.
|
|
322
|
+
if (brief.branch && isValidBranch(brief.branch, cwd, baseRef)) {
|
|
265
323
|
try {
|
|
266
324
|
git(['fetch', 'origin', '--quiet'], cwd);
|
|
267
325
|
git(['checkout', brief.branch], cwd);
|
|
@@ -284,7 +342,13 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
284
342
|
}
|
|
285
343
|
|
|
286
344
|
const env = { ...process.env };
|
|
287
|
-
|
|
345
|
+
// Force the user's Claude Code subscription — strip EVERY var that could
|
|
346
|
+
// divert to API billing or a proxy (poll mode strips these too; live mode
|
|
347
|
+
// was only clearing API_KEY, so an exported AUTH_TOKEN/BASE_URL silently
|
|
348
|
+
// billed the API on the default path).
|
|
349
|
+
delete env.ANTHROPIC_API_KEY;
|
|
350
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
351
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
288
352
|
|
|
289
353
|
// Prior channel transcript — present when resuming a parked/re-claimed task;
|
|
290
354
|
// seed it so a fresh session picks up where the conversation left off. afterId
|
|
@@ -401,7 +465,14 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
401
465
|
await flush();
|
|
402
466
|
turnId = null;
|
|
403
467
|
|
|
404
|
-
|
|
468
|
+
// Task finished — clear the marker so this worktree is NOT treated as a
|
|
469
|
+
// resume of this intent later (esp. if the task is restarted from
|
|
470
|
+
// scratch, which discards it: a stale marker would resume the discarded
|
|
471
|
+
// attempt's dirty files).
|
|
472
|
+
if (completed) {
|
|
473
|
+
clearTaskMarker(cwd);
|
|
474
|
+
return { outcome: 'done', title, intentId };
|
|
475
|
+
}
|
|
405
476
|
|
|
406
477
|
if (sawBlocker) {
|
|
407
478
|
const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
|
|
@@ -425,6 +496,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
425
496
|
// Torn down out from under us (restart / reassign in Flowviant): the
|
|
426
497
|
// server killed this run — abandon the session, don't keep building.
|
|
427
498
|
if (poll && poll.ok === false && poll.reason === 'run_not_active') {
|
|
499
|
+
// Discarded (restart/reassign): clear the marker AND reset the
|
|
500
|
+
// worktree so the next fresh claim starts from clean base, never
|
|
501
|
+
// resuming the abandoned attempt.
|
|
502
|
+
clearTaskMarker(cwd);
|
|
503
|
+
resetWorktree(cwd, baseRef);
|
|
428
504
|
return { outcome: 'torn_down', title, intentId };
|
|
429
505
|
}
|
|
430
506
|
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
@@ -463,6 +539,14 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
463
539
|
}
|
|
464
540
|
return { outcome: completed ? 'done' : 'stalled', title, intentId };
|
|
465
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
|
+
}
|
|
466
550
|
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
467
551
|
} finally {
|
|
468
552
|
onChild?.(null); // no longer busy — token may rotate between tasks
|
|
@@ -493,6 +577,7 @@ export async function runLiveWorker({
|
|
|
493
577
|
isAlive,
|
|
494
578
|
onTokenSuspect,
|
|
495
579
|
onChild,
|
|
580
|
+
onPreview,
|
|
496
581
|
}) {
|
|
497
582
|
// The intent this worker is holding across iterations. When a task parks on a
|
|
498
583
|
// blocker its worktree keeps uncommitted work; on the resume claim we must NOT
|
|
@@ -519,6 +604,10 @@ export async function runLiveWorker({
|
|
|
519
604
|
}
|
|
520
605
|
preview = null;
|
|
521
606
|
}
|
|
607
|
+
// Detached preview children (dev server + tunnel) survive process exit, so
|
|
608
|
+
// the daemon's SIGINT teardown needs a handle to stop them — clear it here
|
|
609
|
+
// once they're down.
|
|
610
|
+
onPreview?.(null);
|
|
522
611
|
};
|
|
523
612
|
const startReviewPreview = async (intentId) => {
|
|
524
613
|
stopPreview();
|
|
@@ -544,6 +633,7 @@ export async function runLiveWorker({
|
|
|
544
633
|
log: (m) => info(`${label} ${c.dim(m)}`),
|
|
545
634
|
});
|
|
546
635
|
if (preview) {
|
|
636
|
+
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
547
637
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
548
638
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
549
639
|
}
|
|
@@ -581,7 +671,10 @@ export async function runLiveWorker({
|
|
|
581
671
|
// stalled / errored → same worktree resumes). Finishing or finding no work
|
|
582
672
|
// clears it so the next fresh task starts from a clean base.
|
|
583
673
|
lastIntentId =
|
|
584
|
-
res.outcome === 'parked' ||
|
|
674
|
+
res.outcome === 'parked' ||
|
|
675
|
+
res.outcome === 'stalled' ||
|
|
676
|
+
res.outcome === 'error' ||
|
|
677
|
+
res.outcome === 'rate_limited'
|
|
585
678
|
? res.intentId
|
|
586
679
|
: null;
|
|
587
680
|
if (res.outcome === 'nothing') {
|
|
@@ -615,6 +708,25 @@ export async function runLiveWorker({
|
|
|
615
708
|
// on reconnect. Nothing to do but stop cleanly.
|
|
616
709
|
break;
|
|
617
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
|
+
}
|
|
618
730
|
// stalled / error — usually a stale token or a stuck turn. Refresh + retry.
|
|
619
731
|
enter('reconnect', warn, `${c.yellow(res.outcome)} ${c.dim('— refreshing token, retrying')}`);
|
|
620
732
|
onTokenSuspect?.(agentId);
|
package/bin/lib/login.mjs
CHANGED
|
@@ -37,6 +37,7 @@ async function post(url, body) {
|
|
|
37
37
|
const res = await fetch(url, {
|
|
38
38
|
method: 'POST',
|
|
39
39
|
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
|
40
|
+
signal: AbortSignal.timeout(30_000),
|
|
40
41
|
body: JSON.stringify(body),
|
|
41
42
|
});
|
|
42
43
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
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": {
|