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 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 (opt-in)
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
- FLOWVIANT_LIVE=1 npx flowviant
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:
@@ -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.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
- // Opt-in phase-2 live mode: persistent Agent-SDK session per task (streams into
39
- // the task channel, injectable, blocker-parks in place) instead of one-shot
40
- // `claude -p` turns. Off = the proven poll/sentinel path, untouched.
41
- export const LIVE = process.env.FLOWVIANT_LIVE === '1';
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 reportMerged = async (intentId) => {
194
+ const mergeAttempts = new Map(); // job.id -> transient-failure count
195
+ const reportMergeOutcome = async (url, body) => {
194
196
  try {
195
- await fetch(MERGE_DONE_URL, {
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({ intentId }),
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
- warn(`"${job.title}" has a merge conflictrebase the branch, then it'll merge.`);
227
- else warn(`merge failed for "${job.title}": ${err.split('\n')[0]} will retry`);
229
+ else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
230
+ // Permanent until a human/agent actsdon'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
- await reportMerged(job.id);
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 (phase 2, opt-in via FLOWVIANT_LIVE=1). Instead of one-shot
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.7.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": {