flowviant 0.7.0 → 0.8.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/README.md +6 -4
- package/bin/lib/config.mjs +7 -5
- package/bin/lib/fleet.mjs +80 -7
- package/bin/lib/live.mjs +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,14 +38,16 @@ Prefer an explicit token? Create a fleet credential in the app and pass it direc
|
|
|
38
38
|
FLOWVIANT_FLEET=fva_… npx flowviant
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
## Live mode (
|
|
41
|
+
## Live mode (the default)
|
|
42
|
+
|
|
43
|
+
Each task runs a **persistent** Claude session you can talk to mid-task from the app: the agent streams its work into the task's conversation, you `@`-mention it to steer or answer questions, and it resumes in place. Blockers park the session at zero cost until you answer. When it finishes, it posts a delivery card (summary + checklist self-report) in the thread — a human confirms done by merging there.
|
|
44
|
+
|
|
45
|
+
Prefer the legacy one-shot poll mode (no streaming, no previews)? Escape hatch:
|
|
42
46
|
|
|
43
47
|
```bash
|
|
44
|
-
|
|
48
|
+
FLOWVIANT_POLL=1 npx flowviant
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
Each task runs a **persistent** Claude session you can talk to mid-task from the app: the agent streams its work into the task's conversation, you `@`-mention it to steer or answer questions, and it resumes in place. Blockers park the session at zero cost until you answer.
|
|
48
|
-
|
|
49
51
|
### Live previews
|
|
50
52
|
|
|
51
53
|
For UI/API tasks, the daemon can start the branch's dev server in the agent's worktree and open a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel so you can drive the real running change during review — no Cloudflare account needed (it's auto-fetched if missing). Configure it once per repo, or let it infer common setups:
|
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.8.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,10 +35,12 @@ 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
|
-
//
|
|
39
|
-
// the task channel, injectable, blocker-parks in place
|
|
40
|
-
//
|
|
41
|
-
|
|
38
|
+
// Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
|
|
39
|
+
// streams into the task channel, injectable mid-task, blocker-parks in place,
|
|
40
|
+
// delivery card on complete, branch preview tunnels. The legacy poll/sentinel
|
|
41
|
+
// path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
|
|
42
|
+
// escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
|
|
43
|
+
export const LIVE = process.env.FLOWVIANT_POLL !== '1';
|
|
42
44
|
// Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
|
|
43
45
|
// them (Node's default UA is treated as a bot). Claude Code sends its own UA.
|
|
44
46
|
export const USER_AGENT = `flowviant/${VERSION}`;
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -189,17 +189,19 @@ export async function runFleetDaemon() {
|
|
|
189
189
|
// Merge jobs (Flowvy-commanded): approved PRs to squash-merge to main on the
|
|
190
190
|
// user's own gh. `merging` guards against re-processing a job mid-flight.
|
|
191
191
|
const MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-done');
|
|
192
|
+
const MERGE_FAILED_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-failed');
|
|
192
193
|
const merging = new Set();
|
|
193
|
-
const
|
|
194
|
+
const mergeAttempts = new Map(); // job.id -> transient-failure count
|
|
195
|
+
const reportMergeOutcome = async (url, body) => {
|
|
194
196
|
try {
|
|
195
|
-
await fetch(
|
|
197
|
+
await fetch(url, {
|
|
196
198
|
method: 'POST',
|
|
197
199
|
headers: {
|
|
198
200
|
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
199
201
|
'User-Agent': USER_AGENT,
|
|
200
202
|
'Content-Type': 'application/json',
|
|
201
203
|
},
|
|
202
|
-
body: JSON.stringify(
|
|
204
|
+
body: JSON.stringify(body),
|
|
203
205
|
});
|
|
204
206
|
} catch {
|
|
205
207
|
/* best-effort — the job reappears next poll if this failed */
|
|
@@ -213,6 +215,7 @@ export async function runFleetDaemon() {
|
|
|
213
215
|
try {
|
|
214
216
|
note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
|
|
215
217
|
let merged = false;
|
|
218
|
+
let failedReason = null; // permanent — tell the thread, clear the flag
|
|
216
219
|
try {
|
|
217
220
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
218
221
|
cwd: repoRoot,
|
|
@@ -221,14 +224,33 @@ export async function runFleetDaemon() {
|
|
|
221
224
|
merged = true;
|
|
222
225
|
} catch (e) {
|
|
223
226
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
227
|
+
const line = err.split('\n')[0] || 'gh pr merge failed';
|
|
224
228
|
if (/already merged|not open|closed/i.test(err)) merged = true;
|
|
225
|
-
else if (/conflict|not mergeable|CONFLICTING/i.test(err))
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
|
|
230
|
+
// Permanent until a human/agent acts — don't spin on it.
|
|
231
|
+
failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
|
|
232
|
+
} else {
|
|
233
|
+
// Transient (auth hiccup, network, CI requirement): retry a few
|
|
234
|
+
// polls, then surface it instead of silently looping forever.
|
|
235
|
+
const n = (mergeAttempts.get(job.id) ?? 0) + 1;
|
|
236
|
+
mergeAttempts.set(job.id, n);
|
|
237
|
+
if (n >= 3) failedReason = line;
|
|
238
|
+
else warn(`merge failed for "${job.title}": ${line} — will retry`);
|
|
239
|
+
}
|
|
228
240
|
}
|
|
229
241
|
if (merged) {
|
|
230
|
-
|
|
242
|
+
mergeAttempts.delete(job.id);
|
|
243
|
+
await reportMergeOutcome(MERGE_DONE_URL, { intentId: job.id });
|
|
231
244
|
ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
|
|
245
|
+
} else if (failedReason) {
|
|
246
|
+
// Report into the thread (server narrates + re-arms the merge
|
|
247
|
+
// button + notifies) — the job disappears from the roster.
|
|
248
|
+
mergeAttempts.delete(job.id);
|
|
249
|
+
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
250
|
+
intentId: job.id,
|
|
251
|
+
message: failedReason,
|
|
252
|
+
});
|
|
253
|
+
warn(`merge failed for "${job.title}": ${failedReason} — reported to the thread`);
|
|
232
254
|
}
|
|
233
255
|
} finally {
|
|
234
256
|
merging.delete(job.id);
|
|
@@ -237,6 +259,56 @@ export async function runFleetDaemon() {
|
|
|
237
259
|
}
|
|
238
260
|
};
|
|
239
261
|
|
|
262
|
+
// Cleanup jobs (task restarts): close the abandoned PR + delete its remote
|
|
263
|
+
// branch on the user's own gh, so a restart doesn't litter the repo.
|
|
264
|
+
const CLEANUP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/cleanup-done');
|
|
265
|
+
const cleaning = new Set();
|
|
266
|
+
const processCleanupJobs = (jobs) => {
|
|
267
|
+
for (const job of jobs ?? []) {
|
|
268
|
+
if (cleaning.has(job.id)) continue;
|
|
269
|
+
cleaning.add(job.id);
|
|
270
|
+
(async () => {
|
|
271
|
+
try {
|
|
272
|
+
note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
|
|
273
|
+
if (job.prUrl) {
|
|
274
|
+
try {
|
|
275
|
+
execFileSync(
|
|
276
|
+
'gh',
|
|
277
|
+
[
|
|
278
|
+
'pr',
|
|
279
|
+
'close',
|
|
280
|
+
job.prUrl,
|
|
281
|
+
'--comment',
|
|
282
|
+
'Task restarted in Flowviant — this attempt was discarded.',
|
|
283
|
+
'--delete-branch',
|
|
284
|
+
],
|
|
285
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
286
|
+
);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// Already closed/merged/missing = fine; anything else we still
|
|
289
|
+
// report done — a restart must never wedge on stale remotes.
|
|
290
|
+
const err = e.stderr?.toString?.() || e.message || '';
|
|
291
|
+
warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
|
|
292
|
+
}
|
|
293
|
+
} else if (job.branch) {
|
|
294
|
+
try {
|
|
295
|
+
execFileSync('git', ['push', 'origin', '--delete', job.branch], {
|
|
296
|
+
cwd: repoRoot,
|
|
297
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
/* branch already gone — fine */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
|
|
304
|
+
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
305
|
+
} finally {
|
|
306
|
+
cleaning.delete(job.id);
|
|
307
|
+
}
|
|
308
|
+
})();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
240
312
|
let connected = false; // log the first successful poll once
|
|
241
313
|
let rosterSig = null; // last roster membership, to log changes only
|
|
242
314
|
let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
|
|
@@ -276,6 +348,7 @@ export async function runFleetDaemon() {
|
|
|
276
348
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
277
349
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
278
350
|
processMergeJobs(roster.mergeJobs);
|
|
351
|
+
processCleanupJobs(roster.cleanupJobs);
|
|
279
352
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
280
353
|
|
|
281
354
|
// Announce roster size only when it changes (not every poll).
|
package/bin/lib/live.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live mode (
|
|
2
|
+
* Live mode (the DEFAULT since 0.8.0; FLOWVIANT_POLL=1 = legacy path). Instead of one-shot
|
|
3
3
|
* `claude -p` turns + sentinels, each task runs a PERSISTENT Agent-SDK session:
|
|
4
4
|
* the daemon claims, seeds the session with the brief, mirrors the model's
|
|
5
5
|
* streamed reply into the task channel (stream_turn), injects human @-messages
|
|
@@ -368,6 +368,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
368
368
|
runId,
|
|
369
369
|
...(afterId ? { afterId } : {}),
|
|
370
370
|
}).catch(() => null);
|
|
371
|
+
// Torn down out from under us (restart / reassign in Flowviant): the
|
|
372
|
+
// server killed this run — abandon the session, don't keep building.
|
|
373
|
+
if (poll && poll.ok === false && poll.reason === 'run_not_active') {
|
|
374
|
+
return { outcome: 'torn_down', title, intentId };
|
|
375
|
+
}
|
|
371
376
|
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
372
377
|
|
|
373
378
|
if (fresh.some((f) => STOP_RE.test(f.content))) {
|
|
@@ -527,6 +532,13 @@ export async function runLiveWorker({
|
|
|
527
532
|
await startReviewPreview(res.intentId);
|
|
528
533
|
continue;
|
|
529
534
|
}
|
|
535
|
+
if (res.outcome === 'torn_down') {
|
|
536
|
+
// The human restarted/reassigned the task in Flowviant. Drop everything —
|
|
537
|
+
// the next fresh claim resets the worktree to base.
|
|
538
|
+
info(`${label} ${c.dim(`"${res.title}" was restarted/reassigned — abandoned this attempt`)}`);
|
|
539
|
+
phase = '';
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
530
542
|
if (res.outcome === 'parked') {
|
|
531
543
|
// Idle-parked too long on a blocker: we freed the Claude process. The intent
|
|
532
544
|
// stays claimed; a later poll re-claims + resumes (with transcript) once the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|