flowviant 0.52.0 → 0.54.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 +25 -31
- package/bin/cli.mjs +28 -22
- package/bin/lib/authproxy.mjs +131 -32
- package/bin/lib/config.mjs +15 -20
- package/bin/lib/fleet.mjs +141 -63
- package/bin/lib/git.mjs +1 -1
- package/bin/lib/instance.mjs +296 -17
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/login.mjs +5 -1
- package/bin/lib/mcp-cli.mjs +12 -11
- package/bin/lib/preflight.mjs +8 -5
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/runtimes.mjs +12 -3
- package/bin/lib/stream.mjs +10 -8
- package/bin/lib/update.mjs +3 -2
- package/bin/lib/work.mjs +249 -5
- package/package.json +1 -1
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -687,9 +687,18 @@ export const RUNTIMES = {
|
|
|
687
687
|
// turn reports failure having touched nothing. Caught only because an
|
|
688
688
|
// end-to-end test had to add the flag by hand to work.
|
|
689
689
|
//
|
|
690
|
-
// NOT `--sandbox` here, unlike wiki: a build has to `git push
|
|
691
|
-
//
|
|
692
|
-
//
|
|
690
|
+
// NOT `--sandbox` here, unlike wiki: a build has to `git push`, so it
|
|
691
|
+
// needs the network by definition.
|
|
692
|
+
//
|
|
693
|
+
// AND NOTHING ELSE CONTAINS IT EITHER. This comment used to end "the
|
|
694
|
+
// containment is the worktree, as it is for every runtime", which was
|
|
695
|
+
// false and is the kind of false that stops people looking: `cwd` is a
|
|
696
|
+
// starting directory, not a jail. A build turn runs with the operator's
|
|
697
|
+
// full user permissions — it can write outside the worktree, to their
|
|
698
|
+
// home directory, to their other checkouts. Claude gets
|
|
699
|
+
// --dangerously-skip-permissions, codex gets --sandbox
|
|
700
|
+
// danger-full-access, and agy gets no sandbox flag at all. The only
|
|
701
|
+
// real boundary today is that the person driving the tab is trusted.
|
|
693
702
|
//
|
|
694
703
|
// FLOWVIANT_SAFE HAS NO EXPRESSION ON THIS RUNTIME. Claude narrows to an
|
|
695
704
|
// allowlist and Codex to `workspace-write`; agy's only per-invocation
|
package/bin/lib/stream.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Machine daemon push channel — the best-practice endgame for pickup latency.
|
|
3
3
|
*
|
|
4
|
-
* Holds a hibernatable WebSocket open to the server. When a job lands
|
|
5
|
-
*
|
|
6
|
-
* `{type:'wake'}` frame and the
|
|
7
|
-
* roster fetch — instead of waiting
|
|
8
|
-
* from ≤RECONCILE_SECONDS to ~a
|
|
4
|
+
* Holds a hibernatable WebSocket open to the server. When a job lands — a
|
|
5
|
+
* session turn typed into a tab, a diff request, a preview, an env sync, a
|
|
6
|
+
* deploy, a wiki regen — the server pushes a `{type:'wake'}` frame and the
|
|
7
|
+
* daemon reconciles IMMEDIATELY — its normal roster fetch — instead of waiting
|
|
8
|
+
* out the poll. That collapses pickup latency from ≤RECONCILE_SECONDS to ~a
|
|
9
|
+
* round trip, which is what makes a message typed in the browser start on the
|
|
10
|
+
* machine at once.
|
|
9
11
|
*
|
|
10
12
|
* The socket carries NO authority: it's a dumb nudge, the roster HTTP fetch is
|
|
11
13
|
* the source of truth (notify-then-reconcile, à la k8s watch). So a dropped or
|
|
@@ -74,9 +76,9 @@ export function connectStream({ onWake, isAlive }) {
|
|
|
74
76
|
lastRxAt = Date.now();
|
|
75
77
|
if (announcedDown) {
|
|
76
78
|
announcedDown = false;
|
|
77
|
-
info(c.dim('push channel reconnected —
|
|
79
|
+
info(c.dim('push channel reconnected — turns start instantly again'));
|
|
78
80
|
} else {
|
|
79
|
-
note(c.dim('push channel connected —
|
|
81
|
+
note(c.dim('push channel connected — turns start instantly'));
|
|
80
82
|
}
|
|
81
83
|
clearPing();
|
|
82
84
|
pingTimer = setInterval(() => {
|
package/bin/lib/update.mjs
CHANGED
|
@@ -114,10 +114,11 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
114
114
|
|
|
115
115
|
if (wantInstall && !npx) {
|
|
116
116
|
if (!safeToUpdate) {
|
|
117
|
-
// Outdated but
|
|
117
|
+
// Outdated but a turn is running — wait until the machine is quiet. Nag
|
|
118
|
+
// once meanwhile.
|
|
118
119
|
if (naggedFor !== target) {
|
|
119
120
|
naggedFor = target;
|
|
120
|
-
note(`flowviant ${cur} → ${target} available — self-updating once
|
|
121
|
+
note(`flowviant ${cur} → ${target} available — self-updating once no turn is running.`);
|
|
121
122
|
}
|
|
122
123
|
return false;
|
|
123
124
|
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -30,8 +30,16 @@ import {
|
|
|
30
30
|
} from 'node:fs';
|
|
31
31
|
import { execFileSync } from 'node:child_process';
|
|
32
32
|
import { join, dirname } from 'node:path';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
FLEET_URL,
|
|
35
|
+
FLEET_TOKEN,
|
|
36
|
+
USER_AGENT,
|
|
37
|
+
REFRESH_BEFORE_SECONDS,
|
|
38
|
+
DAEMON_INSTANCE,
|
|
39
|
+
} from './config.mjs';
|
|
34
40
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
41
|
+
import { listenersIn } from './listeners.mjs';
|
|
42
|
+
import { openTunnel } from './preview.mjs';
|
|
35
43
|
import { c, note, ok, warn } from './ui.mjs';
|
|
36
44
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
37
45
|
import {
|
|
@@ -92,6 +100,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
92
100
|
const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
|
|
93
101
|
const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
|
|
94
102
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
103
|
+
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
104
|
+
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
95
105
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
96
106
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
97
107
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
@@ -327,8 +337,19 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
327
337
|
};
|
|
328
338
|
const sessionWorktreeReport = (sessionId) => {
|
|
329
339
|
if (!isSafePathSegment(sessionId)) return null;
|
|
330
|
-
const
|
|
331
|
-
|
|
340
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
341
|
+
const d = worktreeDiff(wt, baseRef);
|
|
342
|
+
if (!d) return null;
|
|
343
|
+
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
344
|
+
// holding the socket. It rides the sweep the daemon already makes rather
|
|
345
|
+
// than taking a beat of its own, exactly as the commit trailers do — and
|
|
346
|
+
// like them it needs no version floor, because it is a daemon→server report
|
|
347
|
+
// on an endpoint that already exists. An older server ignores the key.
|
|
348
|
+
//
|
|
349
|
+
// The browser NEVER names a directory and never names a port this did not
|
|
350
|
+
// report: ports are global to a box and a worktree is not, so this
|
|
351
|
+
// measurement is the security boundary for the whole preview feature.
|
|
352
|
+
return { sessionId, ...d, listening: listenersIn(wt) };
|
|
332
353
|
};
|
|
333
354
|
/** One session, now — called after its turn settles. */
|
|
334
355
|
const reportSessionWorktree = async (sessionId) => {
|
|
@@ -403,6 +424,189 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
403
424
|
/* the row stays pending and expires; the next click re-requests */
|
|
404
425
|
}
|
|
405
426
|
};
|
|
427
|
+
// ── SESSION PREVIEWS ──────────────────────────────────────────────────────
|
|
428
|
+
//
|
|
429
|
+
// Share the dev server the DRIVER is already running in their tab, behind a
|
|
430
|
+
// generated password, on a quick tunnel. This daemon never starts an app: the
|
|
431
|
+
// deleted live-preview feature ran a repo-declared command through a shell,
|
|
432
|
+
// and that is the reason it is deleted. Here the human runs their own server,
|
|
433
|
+
// `listenersIn` notices it, and this only ever wraps a port that measurement
|
|
434
|
+
// already named for that session.
|
|
435
|
+
//
|
|
436
|
+
// CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
|
|
437
|
+
// the case `machineDaemonsDisagree` exists because it happens, and the 0.51.2
|
|
438
|
+
// instance lock is blind to an OLDER peer — so both are handed the same job
|
|
439
|
+
// array. Both opening a tunnel leaves a public hostname alive that nobody
|
|
440
|
+
// owns and nobody can tear down, because only the lease holder can settle the
|
|
441
|
+
// row. `processDiffJobs` gets away without this because running `git show`
|
|
442
|
+
// twice costs nothing.
|
|
443
|
+
const livePreviews = new Map(); // sessionId -> { port, url, stop }
|
|
444
|
+
const previewClaiming = new Set(); // sessionIds mid-claim on this tick
|
|
445
|
+
|
|
446
|
+
const postPreview = async (body) => {
|
|
447
|
+
try {
|
|
448
|
+
await fetch(PREVIEW_DONE_URL, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: {
|
|
451
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
452
|
+
'User-Agent': USER_AGENT,
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
},
|
|
455
|
+
signal: AbortSignal.timeout(30_000),
|
|
456
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
457
|
+
});
|
|
458
|
+
} catch {
|
|
459
|
+
/* the row stops being confirmed and reads as ended — which is true */
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const claimPreview = async (sessionId) => {
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(PREVIEW_CLAIM_URL, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: {
|
|
468
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
469
|
+
'User-Agent': USER_AGENT,
|
|
470
|
+
'Content-Type': 'application/json',
|
|
471
|
+
},
|
|
472
|
+
signal: AbortSignal.timeout(15_000),
|
|
473
|
+
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
|
|
474
|
+
});
|
|
475
|
+
const j = await res.json().catch(() => null);
|
|
476
|
+
return j?.data?.claimed === true;
|
|
477
|
+
} catch {
|
|
478
|
+
return false; // could not claim → do nothing at all. The other daemon may have.
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** Tear one down here, and say so. `reason` is why, stored server-side rather
|
|
483
|
+
* than inferred: "the origin stopped listening" and "the owner pressed Stop"
|
|
484
|
+
* are different sentences to a teammate holding a phone. */
|
|
485
|
+
const stopPreview = async (sessionId, reason) => {
|
|
486
|
+
const live = livePreviews.get(sessionId);
|
|
487
|
+
livePreviews.delete(sessionId);
|
|
488
|
+
if (live) {
|
|
489
|
+
try {
|
|
490
|
+
live.stop();
|
|
491
|
+
} catch {
|
|
492
|
+
/* best-effort */
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
await postPreview({ sessionId, ended: true, endedReason: reason });
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const processPreviewJobs = (jobs) => {
|
|
499
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
500
|
+
for (const job of jobs.slice(0, 5)) {
|
|
501
|
+
const sessionId = String(job?.sessionId || '');
|
|
502
|
+
const port = Number(job?.port);
|
|
503
|
+
if (!isSafePathSegment(sessionId)) continue;
|
|
504
|
+
|
|
505
|
+
if (job?.action === 'stop') {
|
|
506
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
507
|
+
previewClaiming.add(sessionId);
|
|
508
|
+
void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
|
513
|
+
// Already serving exactly this. Re-opening would replace a working URL
|
|
514
|
+
// somebody may be looking at right now.
|
|
515
|
+
if (livePreviews.get(sessionId)?.port === port) continue;
|
|
516
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
517
|
+
previewClaiming.add(sessionId);
|
|
518
|
+
|
|
519
|
+
void (async () => {
|
|
520
|
+
try {
|
|
521
|
+
if (!(await claimPreview(sessionId))) return; // somebody else has it
|
|
522
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
523
|
+
// RE-VALIDATE the attribution here, not just the liveness. The server
|
|
524
|
+
// checked this port against a report up to a minute old; more
|
|
525
|
+
// importantly, checking `listenersIn` again is what keeps the answer
|
|
526
|
+
// to "whose port is this" on the machine that can actually see it.
|
|
527
|
+
const measured = listenersIn(wt).some((l) => l.port === port);
|
|
528
|
+
if (!measured) {
|
|
529
|
+
await postPreview({
|
|
530
|
+
sessionId,
|
|
531
|
+
error: `nothing is listening on port ${port} in this worktree.`,
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// Replace anything this session already had — one tab, one door.
|
|
536
|
+
const prev = livePreviews.get(sessionId);
|
|
537
|
+
if (prev) {
|
|
538
|
+
try {
|
|
539
|
+
prev.stop();
|
|
540
|
+
} catch {
|
|
541
|
+
/* best-effort */
|
|
542
|
+
}
|
|
543
|
+
livePreviews.delete(sessionId);
|
|
544
|
+
}
|
|
545
|
+
const t = await openTunnel({
|
|
546
|
+
port,
|
|
547
|
+
log: (m) => note(`preview ${sessionId.slice(0, 8)}: ${m}`),
|
|
548
|
+
// The origin died under a live tunnel. cloudflared happily outlives
|
|
549
|
+
// a dead dev server and the gate answers a dead origin with 502, so
|
|
550
|
+
// without this the app would print "live" over a 502.
|
|
551
|
+
onDead: () => {
|
|
552
|
+
livePreviews.delete(sessionId);
|
|
553
|
+
void postPreview({ sessionId, ended: true, endedReason: 'origin_gone' });
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
if (t.error) {
|
|
557
|
+
await postPreview({ sessionId, error: t.error });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
livePreviews.set(sessionId, { port, url: t.url, stop: t.stop });
|
|
561
|
+
await postPreview({ sessionId, url: t.url, user: t.user, password: t.password });
|
|
562
|
+
} finally {
|
|
563
|
+
previewClaiming.delete(sessionId);
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
570
|
+
* server can tell a live share from one whose machine went away. Silence
|
|
571
|
+
* must never read as "live". */
|
|
572
|
+
const livePreviewIds = () => [...livePreviews.keys()];
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The tab closed (or the server stopped listing it). Ordered BEFORE
|
|
576
|
+
* `retireWorkSessions`, and that ordering is load-bearing: `git worktree
|
|
577
|
+
* remove` under a running dev server reintroduces the stale-server bug — on
|
|
578
|
+
* Linux the process keeps serving bytes from open file handles in a directory
|
|
579
|
+
* that no longer exists, which shows a human the wrong thing without erroring
|
|
580
|
+
* anywhere.
|
|
581
|
+
*/
|
|
582
|
+
const retirePreviews = (activeIds) => {
|
|
583
|
+
// Same guard `retireWorkSessions` keeps: a roster response missing the
|
|
584
|
+
// field is an older server, not a close, and must not tear down every live
|
|
585
|
+
// share at once.
|
|
586
|
+
if (!Array.isArray(activeIds)) return;
|
|
587
|
+
const live = new Set(activeIds);
|
|
588
|
+
for (const sessionId of [...livePreviews.keys()]) {
|
|
589
|
+
if (live.has(sessionId)) continue;
|
|
590
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
591
|
+
previewClaiming.add(sessionId);
|
|
592
|
+
void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/** Daemon shutdown. Detached tunnels survive our exit by design, so leaving
|
|
597
|
+
* them would strand a public hostname until the box rebooted — the exact
|
|
598
|
+
* case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
|
|
599
|
+
const shutdownPreviews = () => {
|
|
600
|
+
for (const [, live] of livePreviews) {
|
|
601
|
+
try {
|
|
602
|
+
live.stop();
|
|
603
|
+
} catch {
|
|
604
|
+
/* best-effort */
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
livePreviews.clear();
|
|
608
|
+
};
|
|
609
|
+
|
|
406
610
|
const processDiffJobs = (jobs) => {
|
|
407
611
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
408
612
|
for (const job of jobs.slice(0, 5)) {
|
|
@@ -595,9 +799,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
595
799
|
'Content-Type': 'application/json',
|
|
596
800
|
},
|
|
597
801
|
signal: AbortSignal.timeout(30_000),
|
|
598
|
-
|
|
802
|
+
// The instance is what CLAIMS the session lease server-side. Two
|
|
803
|
+
// daemons share one fleet credential, so the token cannot say which of
|
|
804
|
+
// us is serving this tab — and the mint is the moment that matters:
|
|
805
|
+
// there is one work-token row per session and minting ROTATES it, so a
|
|
806
|
+
// second mint revokes the first daemon's live secret mid-turn.
|
|
807
|
+
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
|
|
599
808
|
});
|
|
600
809
|
if (res.status === 404) return { gone: true };
|
|
810
|
+
// 409 — another daemon on this credential holds the session. Not ours to
|
|
811
|
+
// serve and not a retry: stand down and let the holder answer.
|
|
812
|
+
if (res.status === 409) return { heldElsewhere: true };
|
|
601
813
|
if (!res.ok) return null;
|
|
602
814
|
const token = (await res.json().catch(() => null))?.data?.token ?? null;
|
|
603
815
|
if (!token) return null;
|
|
@@ -964,8 +1176,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
964
1176
|
* machines. When the roster omits the field entirely (older server),
|
|
965
1177
|
* absence of signal is not a close — retire nothing.
|
|
966
1178
|
*/
|
|
967
|
-
|
|
1179
|
+
/** Every session this daemon currently has a worktree for — what renews our
|
|
1180
|
+
* lease on the poll. Read off the directory rather than a map, so it is the
|
|
1181
|
+
* same fact retirement acts on. */
|
|
1182
|
+
const heldSessionIds = () => {
|
|
1183
|
+
const dir = join(baseDir, 'sessions');
|
|
1184
|
+
try {
|
|
1185
|
+
return readdirSync(dir).filter(isSafePathSegment).slice(0, 50);
|
|
1186
|
+
} catch {
|
|
1187
|
+
return [];
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
const retireWorkSessions = (activeIds, heldElsewhere) => {
|
|
968
1192
|
if (!Array.isArray(activeIds)) return;
|
|
1193
|
+
// Sessions ANOTHER daemon on this credential is serving. They are absent
|
|
1194
|
+
// from activeWorkSessions for us and present for them, and removing their
|
|
1195
|
+
// worktree would pull the directory out from under a running turn. Absence
|
|
1196
|
+
// means "the tab closed"; this is the one other thing it can mean.
|
|
1197
|
+
const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
|
|
969
1198
|
const dir = join(baseDir, 'sessions');
|
|
970
1199
|
if (!existsSync(dir)) return;
|
|
971
1200
|
let ids;
|
|
@@ -978,6 +1207,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
978
1207
|
let removed = 0;
|
|
979
1208
|
for (const id of ids) {
|
|
980
1209
|
if (live.has(id)) continue;
|
|
1210
|
+
if (peers.has(id)) continue; // another daemon's tab — not ours to retire
|
|
981
1211
|
if (workChains.has(id) || shipping.has(id)) continue; // still draining here
|
|
982
1212
|
const wt = join(dir, id);
|
|
983
1213
|
try {
|
|
@@ -1208,6 +1438,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1208
1438
|
if (!plainTab) {
|
|
1209
1439
|
mint = await mintWorkToken(job.sessionId);
|
|
1210
1440
|
if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
|
|
1441
|
+
// Another daemon on this credential holds the session. Return
|
|
1442
|
+
// WITHOUT settling: the holder is answering this same turn, and
|
|
1443
|
+
// settling it here — even as a failure — would race the real
|
|
1444
|
+
// answer and could win. Dropping it means the turn stays pending
|
|
1445
|
+
// and the holder's answer lands, which is the whole point.
|
|
1446
|
+
if (mint?.heldElsewhere) {
|
|
1447
|
+
workAnswering.delete(job.id);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1211
1450
|
if (mint?.gone) {
|
|
1212
1451
|
await settleWorkTurn(job.id, {
|
|
1213
1452
|
ok: false,
|
|
@@ -1860,6 +2099,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1860
2099
|
processWorkTurns,
|
|
1861
2100
|
processShipJobs,
|
|
1862
2101
|
processDiffJobs,
|
|
2102
|
+
heldSessionIds,
|
|
2103
|
+
processPreviewJobs,
|
|
2104
|
+
livePreviewIds,
|
|
2105
|
+
retirePreviews,
|
|
2106
|
+
shutdownPreviews,
|
|
1863
2107
|
retireWorkSessions,
|
|
1864
2108
|
reportWorktrees,
|
|
1865
2109
|
shutdownWork,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|