flowviant 0.53.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/config.mjs +0 -21
- package/bin/lib/fleet.mjs +107 -50
- package/bin/lib/git.mjs +1 -1
- package/bin/lib/instance.mjs +296 -17
- 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/runtimes.mjs +12 -3
- package/bin/lib/stream.mjs +10 -8
- package/bin/lib/update.mjs +3 -2
- package/bin/lib/work.mjs +38 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# flowviant
|
|
2
2
|
|
|
3
|
-
Run your own
|
|
3
|
+
Run your own coding CLIs as build agents for [Flowviant](https://flowviant.com) — [Claude Code](https://claude.com/claude-code), Codex or Antigravity, on your own credentials. This daemon holds your sessions, keeps a worktree per tab, and ships branches on your word. Flowviant never sees your Claude, Codex or GitHub logins.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx flowviant login
|
|
7
|
-
npx flowviant # run your fleet
|
|
6
|
+
npx flowviant@latest login # approve the code in Flowviant → connected
|
|
8
7
|
```
|
|
9
8
|
|
|
9
|
+
Login keeps going straight into the daemon — there is no second command to run.
|
|
10
|
+
|
|
10
11
|
## What it does
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
A Flowviant project has ONE machine, and this is it: one box, running one Claude (or Codex, or Antigravity) account, serving the whole team's sessions so nobody has to set up their own. Solo and team are the same architecture at N=1 and N>1.
|
|
14
|
+
|
|
15
|
+
You work in the **Workbench**, where your sessions are TABS — each one a held context plus a persistent git worktree on its own `session/<id>` branch. The browser is a terminal projected onto this machine, so you reach the same session from any device. When you say ship, the tab merges its own branch into base with `--no-ff` — no squash, so the commit shas the work reported still exist on main.
|
|
13
16
|
|
|
14
17
|
Because it drives the CLIs you're already logged into, **the cost is yours** (your Claude subscription, your GitHub) and **the daemon never handles a credential** — it shells out to tools you authenticated yourself.
|
|
15
18
|
|
|
@@ -17,9 +20,9 @@ Because it drives the CLIs you're already logged into, **the cost is yours** (yo
|
|
|
17
20
|
|
|
18
21
|
On the machine that runs the daemon:
|
|
19
22
|
|
|
20
|
-
- **[Claude Code](https://claude.com/claude-code)
|
|
21
|
-
- **[GitHub CLI](https://cli.github.com)** authenticated (`gh auth login`) — for opening PRs
|
|
23
|
+
- **at least one coding CLI** installed and signed in — [Claude Code](https://claude.com/claude-code) (`claude`), Codex (`codex`) or Antigravity (`agy`)
|
|
22
24
|
- **git**, and **Node 20+**
|
|
25
|
+
- **[GitHub CLI](https://cli.github.com)** (`gh`) — optional; the daemon offers to fetch an isolated copy, and `flowviant gh-auth` signs it in
|
|
23
26
|
- run it from inside the git repository you want worked
|
|
24
27
|
|
|
25
28
|
## Connecting
|
|
@@ -27,53 +30,44 @@ On the machine that runs the daemon:
|
|
|
27
30
|
The easy way — device login, like `gh auth login`:
|
|
28
31
|
|
|
29
32
|
```bash
|
|
30
|
-
npx flowviant login
|
|
33
|
+
npx flowviant@latest login
|
|
31
34
|
```
|
|
32
35
|
|
|
33
|
-
It shows a short code
|
|
36
|
+
It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `npx flowviant@latest` just runs.
|
|
34
37
|
|
|
35
|
-
Prefer an explicit token? Create a
|
|
38
|
+
Prefer an explicit token? Create a machine credential in the app and pass it directly:
|
|
36
39
|
|
|
37
40
|
```bash
|
|
38
|
-
FLOWVIANT_FLEET=fva_… npx flowviant
|
|
41
|
+
FLOWVIANT_FLEET=fva_… npx flowviant@latest
|
|
39
42
|
```
|
|
40
43
|
|
|
41
|
-
|
|
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
|
+
Launch with `@latest` so each start pulls the newest published version — a bare `npx flowviant` can reuse a stale cache. A running daemon also self-updates at startup and when idle (`FLOWVIANT_NO_UPDATE=1` makes it nag-only; `flowviant update` updates now).
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
## Sessions
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
FLOWVIANT_POLL=1 npx flowviant
|
|
49
|
-
```
|
|
48
|
+
Each tab in the Workbench is a persistent Claude session with its own worktree, and it stays where you left it — the branch outlives the tab. The daemon runs each turn in event mode and relays what the CLI is printing (thinking, reads, greps, commands) back to the tab, reports the worktree's branch and diffstat after every turn, and fetches a commit's patch when you click a sha in the app.
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
Nothing starts work except you opening a tab and typing in it.
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
## Sharing a preview
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
// .flowviant/preview.json
|
|
57
|
-
{ "ui": { "cmd": "npm run dev", "port": 5173 } }
|
|
58
|
-
```
|
|
54
|
+
You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly.
|
|
59
55
|
|
|
60
|
-
|
|
56
|
+
The daemon never executes anything the repository declares. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that start path was removed in 0.53.0 and is not coming back; see the header of `bin/lib/preview.mjs` for exactly what it did, so nobody rebuilds it.
|
|
61
57
|
|
|
62
58
|
## Modes
|
|
63
59
|
|
|
64
60
|
| Env | What runs |
|
|
65
61
|
| --- | --- |
|
|
66
|
-
| _(stored login)_ or `FLOWVIANT_FLEET` | **
|
|
67
|
-
| `FLOWVIANT_TOKEN` | a single agent in the current checkout |
|
|
68
|
-
| `FLOWVIANT_TOKENS=a,b,c` | a static fleet, one worktree each |
|
|
62
|
+
| _(stored login)_ or `FLOWVIANT_FLEET` | **the daemon** — the project's machine, serving its sessions |
|
|
69
63
|
| `FLOWVIANT_SAFE=1` | restrict the toolset instead of running unattended |
|
|
70
64
|
|
|
71
65
|
## Security posture
|
|
72
66
|
|
|
73
|
-
Every project member with edit access can run turns on this machine —
|
|
74
|
-
Workbench
|
|
75
|
-
|
|
76
|
-
|
|
67
|
+
Every project member with edit access can run turns on this machine — a
|
|
68
|
+
Workbench tab executes a coding agent with the daemon's own OS permissions.
|
|
69
|
+
Membership is the consent boundary, the same trust plane as the shared
|
|
70
|
+
repository: invite people you would give a shell to.
|
|
77
71
|
|
|
78
72
|
Two knobs bound the blast radius, and both are worth setting on a shared box:
|
|
79
73
|
|
package/bin/cli.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* flowviant — run your own Claude Code
|
|
3
|
+
* flowviant — run your own coding CLI (Claude Code, Codex, Antigravity) as the
|
|
4
|
+
* machine behind a Flowviant project.
|
|
4
5
|
*
|
|
5
6
|
* ONE mode, one credential:
|
|
6
7
|
*
|
|
7
|
-
* FLOWVIANT_FLEET=
|
|
8
|
+
* FLOWVIANT_FLEET=fva_… npx flowviant@latest # the machine daemon
|
|
8
9
|
*
|
|
9
10
|
* `FLOWVIANT_TOKEN` (one worker, current checkout) and `FLOWVIANT_TOKENS` (a
|
|
10
11
|
* comma list, one worktree each) stood beside it until 2026-08-19. Both ran the
|
|
@@ -19,28 +20,32 @@
|
|
|
19
20
|
* on its own — at startup and when idle — so it stays current without restarts
|
|
20
21
|
* (FLOWVIANT_NO_UPDATE=1 makes it nag-only; `flowviant update` updates now).
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
23
|
+
* The daemon: install ONCE with a machine credential, then work entirely from
|
|
24
|
+
* Flowviant. It polls GET /api/v2/fleet/agents, and the roster hands it the
|
|
25
|
+
* project's SESSIONS — the Workbench's tabs. Each session gets one persistent
|
|
26
|
+
* git worktree on its own `session/<id>` branch, held across turns (never reset
|
|
27
|
+
* to base: the branch outlives the tab). A turn spawns the session's CLI with a
|
|
28
|
+
* short-lived per-session MCP token, relays what it prints back to the tab,
|
|
29
|
+
* reports the worktree's branch and diffstat when it settles, and answers the
|
|
30
|
+
* odd side job the roster carries — a commit's patch, a preview share, a wiki
|
|
31
|
+
* regen. When you say ship, the daemon merges that branch into base `--no-ff`.
|
|
29
32
|
*
|
|
30
33
|
* Env:
|
|
31
|
-
*
|
|
34
|
+
* FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
|
|
32
35
|
* FLOWVIANT_API_URL default https://api.flowviant.com/api/v2
|
|
33
36
|
* FLOWVIANT_MCP_URL default <API_URL>/mcp
|
|
34
37
|
* FLOWVIANT_FLEET_URL default <API_URL>/fleet/agents
|
|
35
|
-
*
|
|
36
|
-
* IDLE_SECONDS gap between work checks when idle (default 30)
|
|
37
|
-
* RECONCILE_SECONDS fleet roster poll cadence (default 10)
|
|
38
|
+
* RECONCILE_SECONDS roster poll cadence (default 10)
|
|
38
39
|
* FLOWVIANT_SAFE=1 restrict the toolset instead of running unattended.
|
|
39
40
|
*
|
|
40
|
-
* Requires
|
|
41
|
-
* repo you want worked.
|
|
41
|
+
* Requires one of `claude` / `codex` / `agy` on PATH, plus `git`; run from
|
|
42
|
+
* inside the git repo you want worked. `gh` is optional.
|
|
42
43
|
*
|
|
43
|
-
* Implementation lives in ./lib/: config, ui,
|
|
44
|
+
* Implementation lives in ./lib/: config, ui, preflight, install, update,
|
|
45
|
+
* instance, login, mcp-cli; fleet (the roster loop) and work (session turns);
|
|
46
|
+
* claude + runtimes + prompts + stream (spawning a CLI and reading its events);
|
|
47
|
+
* git + worktreeDiff + patch; localSessions, listeners, preview + authproxy;
|
|
48
|
+
* env + env-cli + vault, resources, deploy, shot.
|
|
44
49
|
*/
|
|
45
50
|
import { FLEET_TOKEN } from './lib/config.mjs';
|
|
46
51
|
import { runFleetDaemon } from './lib/fleet.mjs';
|
|
@@ -132,9 +137,9 @@ if (process.argv[2] === 'clean') {
|
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
// `flowviant shot <url>` — capture a headless-browser screenshot of a running
|
|
135
|
-
// page.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
140
|
+
// page. A session's agent shells out to this to SEE the change it just made.
|
|
141
|
+
// Self-contained + graceful (no browser → exit 1, and the agent carries on in
|
|
142
|
+
// text); needs no credential, so it runs before the auth gate.
|
|
138
143
|
if (process.argv[2] === 'shot') {
|
|
139
144
|
const { runShot } = await import('./lib/shot.mjs');
|
|
140
145
|
await runShot(process.argv.slice(3));
|
|
@@ -145,8 +150,9 @@ if (process.argv[2] === 'shot') {
|
|
|
145
150
|
// are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
|
|
146
151
|
// the browser); `show` decrypts locally — it only works on an ENROLLED machine.
|
|
147
152
|
// `flowviant mcp` — connect YOUR Claude to Flowviant so you can file work from
|
|
148
|
-
// the terminal. Mints a `cli` credential: a separate principal from the
|
|
149
|
-
//
|
|
153
|
+
// the terminal. Mints a `cli` credential: a separate principal from the
|
|
154
|
+
// per-session tokens, with only the management tools and no way to work or ship
|
|
155
|
+
// a card.
|
|
150
156
|
if (process.argv[2] === 'mcp') {
|
|
151
157
|
const { runMcpCommand } = await import('./lib/mcp-cli.mjs');
|
|
152
158
|
await runMcpCommand(process.argv.slice(3));
|
|
@@ -164,7 +170,7 @@ if (!FLEET_TOKEN) {
|
|
|
164
170
|
'error: no credential found. Easiest:\n' +
|
|
165
171
|
' flowviant login (approve in the app — recommended)\n' +
|
|
166
172
|
'Or set:\n' +
|
|
167
|
-
' FLOWVIANT_FLEET=
|
|
173
|
+
' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
|
|
168
174
|
);
|
|
169
175
|
process.exit(1);
|
|
170
176
|
}
|
package/bin/lib/config.mjs
CHANGED
|
@@ -44,11 +44,6 @@ function argFlag(name) {
|
|
|
44
44
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/** A bare boolean flag (no value follows it). */
|
|
48
|
-
function hasFlag(name) {
|
|
49
|
-
return process.argv.includes(name);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
47
|
const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api/v2';
|
|
53
48
|
export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
|
|
54
49
|
export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
|
|
@@ -149,22 +144,6 @@ export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
|
|
|
149
144
|
// path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
|
|
150
145
|
// escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
|
|
151
146
|
export const LIVE = process.env.FLOWVIANT_POLL !== '1';
|
|
152
|
-
/**
|
|
153
|
-
* Does this machine accept PATCHES — commits cherry-picked straight into your
|
|
154
|
-
* working checkout, with no PR and no review?
|
|
155
|
-
*
|
|
156
|
-
* Patch placement is chosen by a model, and any teammate who @mentions one of
|
|
157
|
-
* your agents can trigger it, so whether it happens at all belongs to whoever
|
|
158
|
-
* owns the checkout. Turning it off does not lose the work: the task falls back
|
|
159
|
-
* to branch placement and arrives as a PR like anything else.
|
|
160
|
-
*
|
|
161
|
-
* On by default — the guard that actually protects you (never touching a file
|
|
162
|
-
* you have uncommitted edits in) is enforced at apply time, and the whole point
|
|
163
|
-
* of patches is to spare you a review cycle for a nine-character diff.
|
|
164
|
-
* `--no-patches` or FLOWVIANT_PATCHES=0 to refuse them.
|
|
165
|
-
*/
|
|
166
|
-
export const ALLOW_PATCHES =
|
|
167
|
-
!hasFlag('--no-patches') && process.env.FLOWVIANT_PATCHES !== '0';
|
|
168
147
|
// Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
|
|
169
148
|
// them (Node's default UA is treated as a bot). Claude Code sends its own UA.
|
|
170
149
|
export const USER_AGENT = `flowviant/${VERSION}`;
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
REFRESH_BEFORE_SECONDS,
|
|
32
32
|
LIVE,
|
|
33
33
|
AUTO_UPDATE,
|
|
34
|
-
ALLOW_PATCHES,
|
|
35
34
|
} from './config.mjs';
|
|
36
35
|
import { handleVersionSignal } from './update.mjs';
|
|
37
36
|
import {
|
|
@@ -78,7 +77,7 @@ import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtime
|
|
|
78
77
|
import { createWorkManager } from './work.mjs';
|
|
79
78
|
import { scanLocalSessions } from './localSessions.mjs';
|
|
80
79
|
|
|
81
|
-
async function fetchRoster(haveIds, livePreviewSessionIds = []) {
|
|
80
|
+
async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds = []) {
|
|
82
81
|
const url = new URL(FLEET_URL);
|
|
83
82
|
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
84
83
|
// What this machine will run at once. The server grows lanes to meet waiting
|
|
@@ -108,6 +107,11 @@ async function fetchRoster(haveIds, livePreviewSessionIds = []) {
|
|
|
108
107
|
// the server still calls live is a 530 on somebody's phone. Always set, even
|
|
109
108
|
// empty: '' means "serving none", absent would mean "an older daemon".
|
|
110
109
|
url.searchParams.set('pv', livePreviewSessionIds.join(','));
|
|
110
|
+
// The sessions this daemon holds a worktree for. Its LEASE on each renews
|
|
111
|
+
// here — one beat, no extra endpoint, and the server can tell "this daemon is
|
|
112
|
+
// still serving that tab" from "it went away" within a reconcile interval
|
|
113
|
+
// instead of minutes. Always set, even empty: '' means "holding none".
|
|
114
|
+
url.searchParams.set('ws', heldSessionIds.join(','));
|
|
111
115
|
// WHICH CLIs this machine actually has, so the app can stop guessing.
|
|
112
116
|
//
|
|
113
117
|
// Until now every surface that listed Gemini or Codex said "not wired up yet"
|
|
@@ -271,20 +275,13 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
|
271
275
|
|
|
272
276
|
export async function runFleetDaemon() {
|
|
273
277
|
console.log('');
|
|
274
|
-
console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`
|
|
278
|
+
console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
|
|
275
279
|
console.log(` ${c.dim('──────────────────────────────────────────────')}`);
|
|
276
280
|
const repoRoot = repoRootOrDie();
|
|
277
281
|
const baseRef = detectBaseRef(repoRoot);
|
|
278
282
|
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
279
283
|
info(`repo · ${repoRoot}`);
|
|
280
284
|
info(`base · ${baseRef}`);
|
|
281
|
-
// Stated out loud because it is the one setting that lets something else write
|
|
282
|
-
// into the checkout you are sitting in.
|
|
283
|
-
info(
|
|
284
|
-
ALLOW_PATCHES
|
|
285
|
-
? 'patches· accepted — small changes land in your checkout for Keep/Revert (--no-patches to refuse)'
|
|
286
|
-
: 'patches· refused — everything arrives as a branch + PR'
|
|
287
|
-
);
|
|
288
285
|
info(`server · ${FLEET_URL}`);
|
|
289
286
|
console.log('');
|
|
290
287
|
|
|
@@ -295,18 +292,42 @@ export async function runFleetDaemon() {
|
|
|
295
292
|
// the SAME project served twice, and the worst version of this: their session
|
|
296
293
|
// worktrees are in different directories, so the per-turn lock cannot even see
|
|
297
294
|
// across them. See instance.mjs for why that lock is not enough on its own.
|
|
298
|
-
|
|
295
|
+
// Same repo -> this run replaces whatever was serving it. Different repo ->
|
|
296
|
+
// refused, and nothing is signalled. See instance.mjs's header for the rule.
|
|
297
|
+
const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
|
|
298
|
+
takeover:
|
|
299
|
+
process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
|
|
300
|
+
noTakeover:
|
|
301
|
+
process.argv.includes('--no-takeover') || process.env.FLOWVIANT_NO_TAKEOVER === '1',
|
|
302
|
+
allowDowngrade: process.argv.includes('--takeover-downgrade'),
|
|
303
|
+
log: (m) => info(m),
|
|
304
|
+
});
|
|
299
305
|
if (!instance.ok) {
|
|
300
306
|
const h = instance.holder;
|
|
301
307
|
console.log('');
|
|
302
|
-
|
|
308
|
+
// Two different refusals, because they are two different mistakes and the
|
|
309
|
+
// fix is not the same. Same CREDENTIAL: one project is being served twice.
|
|
310
|
+
// Same REPO under another credential: two daemons in one working tree,
|
|
311
|
+
// which the credential-keyed lock cannot see on its own.
|
|
312
|
+
if (instance.takeoverFailed) {
|
|
313
|
+
fail(`could not replace the running daemon: ${instance.takeoverFailed}`);
|
|
314
|
+
} else if (instance.sameRepo) {
|
|
315
|
+
fail('a flowviant daemon is already running in this repo.');
|
|
316
|
+
} else {
|
|
317
|
+
fail('a flowviant daemon is already running for this credential.');
|
|
318
|
+
}
|
|
303
319
|
if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
|
|
304
320
|
// The two-checkouts case is the one nobody spots on their own: both tabs
|
|
305
321
|
// look healthy, and the damage is doubled cards and doubled edits in a repo
|
|
306
322
|
// you are not looking at. Name the other repo when it is a different one.
|
|
307
|
-
if (h?.repoRoot && h.repoRoot !== repoRoot)
|
|
323
|
+
if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
|
|
308
324
|
warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
|
|
309
|
-
|
|
325
|
+
// Not offered lightly: that daemon is serving other work, and this
|
|
326
|
+
// command was run somewhere else. Replacing it is a decision, not a
|
|
327
|
+
// restart, so it takes a word.
|
|
328
|
+
note('run with --takeover to stop it and serve this repo instead.');
|
|
329
|
+
}
|
|
330
|
+
note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
|
|
310
331
|
console.log('');
|
|
311
332
|
process.exit(1);
|
|
312
333
|
}
|
|
@@ -606,6 +627,7 @@ export async function runFleetDaemon() {
|
|
|
606
627
|
processWorkTurns,
|
|
607
628
|
processShipJobs,
|
|
608
629
|
processDiffJobs,
|
|
630
|
+
heldSessionIds,
|
|
609
631
|
processPreviewJobs,
|
|
610
632
|
livePreviewIds,
|
|
611
633
|
retirePreviews,
|
|
@@ -873,20 +895,42 @@ export async function runFleetDaemon() {
|
|
|
873
895
|
|
|
874
896
|
// Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
|
|
875
897
|
// PR can't blow up the prompt. prUrl was already validated before the merge.
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
898
|
+
// WHICH FILES A SHIP CHANGED, read from the commits it landed.
|
|
899
|
+
//
|
|
900
|
+
// This asked `gh pr view <prUrl> --json files` until 2026-08-22, and `prUrl`
|
|
901
|
+
// has been null by construction since dispatch was deleted on 2026-08-19 —
|
|
902
|
+
// the server writes null and says so in a comment. Node threw on the null
|
|
903
|
+
// argument, the catch below read it as "gh failed", and the re-ground retried
|
|
904
|
+
// three times and gave up. Every post-ship re-ground for three months did
|
|
905
|
+
// that silently, while the spec said ship re-grounds the wiki.
|
|
906
|
+
//
|
|
907
|
+
// Returns null when it learned NOTHING (no shas, or none of them resolvable),
|
|
908
|
+
// which the caller still treats as retryable — distinct from a ship that
|
|
909
|
+
// genuinely changed no files.
|
|
910
|
+
const changedFilesForShas = (shas) => {
|
|
911
|
+
if (!Array.isArray(shas) || shas.length === 0) return null;
|
|
912
|
+
const files = new Set();
|
|
913
|
+
for (const sha of shas.slice(0, 50)) {
|
|
914
|
+
if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
|
|
915
|
+
try {
|
|
916
|
+
const out = execFileSync(
|
|
917
|
+
'git',
|
|
918
|
+
['show', '--name-only', '--pretty=format:', String(sha)],
|
|
919
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
|
920
|
+
);
|
|
921
|
+
for (const line of out.split('\n')) {
|
|
922
|
+
const f = line.trim();
|
|
923
|
+
if (f) files.add(f);
|
|
924
|
+
if (files.size >= 60) break;
|
|
925
|
+
}
|
|
926
|
+
} catch {
|
|
927
|
+
// One unreachable commit is not a failed re-ground — the ship merged
|
|
928
|
+
// to main and the rest of the shas still name real files. Only an
|
|
929
|
+
// EMPTY result is treated as "we learned nothing".
|
|
930
|
+
}
|
|
931
|
+
if (files.size >= 60) break;
|
|
889
932
|
}
|
|
933
|
+
return files.size ? [...files] : null;
|
|
890
934
|
};
|
|
891
935
|
const regroundAttempts = new Map(); // intentId -> gh-failure count
|
|
892
936
|
|
|
@@ -1042,7 +1086,7 @@ export async function runFleetDaemon() {
|
|
|
1042
1086
|
warn('wiki sweep ended without WIKI_DONE — partial pages synced; retry from the app.');
|
|
1043
1087
|
await runSync(complete);
|
|
1044
1088
|
} else {
|
|
1045
|
-
const files =
|
|
1089
|
+
const files = changedFilesForShas(task.shas);
|
|
1046
1090
|
if (files === null) {
|
|
1047
1091
|
// gh failed (network/auth) — retry via the durable job a couple
|
|
1048
1092
|
// of times before consuming it, so a transient outage doesn't
|
|
@@ -1050,11 +1094,11 @@ export async function runFleetDaemon() {
|
|
|
1050
1094
|
const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
|
|
1051
1095
|
regroundAttempts.set(task.intentId, n);
|
|
1052
1096
|
if (n < 3) {
|
|
1053
|
-
warn(`wiki re-ground for "${task.title}":
|
|
1097
|
+
warn(`wiki re-ground for "${task.title}": no changed files resolved — will retry (${n}/3)`);
|
|
1054
1098
|
groundedIntents.delete(task.intentId); // let the roster re-offer it
|
|
1055
1099
|
continue;
|
|
1056
1100
|
}
|
|
1057
|
-
warn(`wiki re-ground for "${task.title}":
|
|
1101
|
+
warn(`wiki re-ground for "${task.title}": could not resolve changed files ${n} times — giving up (heals on the next full sweep)`);
|
|
1058
1102
|
} else if (files.length === 0) {
|
|
1059
1103
|
note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
|
|
1060
1104
|
} else {
|
|
@@ -1170,7 +1214,7 @@ export async function runFleetDaemon() {
|
|
|
1170
1214
|
for (;;) {
|
|
1171
1215
|
let roster;
|
|
1172
1216
|
try {
|
|
1173
|
-
roster = await fetchRoster(buildHave(), livePreviewIds());
|
|
1217
|
+
roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
|
|
1174
1218
|
} catch (e) {
|
|
1175
1219
|
if (e.auth) {
|
|
1176
1220
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
@@ -1237,7 +1281,16 @@ export async function runFleetDaemon() {
|
|
|
1237
1281
|
// in a directory that no longer exists — a human is shown the wrong thing
|
|
1238
1282
|
// and nothing errors anywhere.
|
|
1239
1283
|
retirePreviews(roster.activeWorkSessions);
|
|
1240
|
-
|
|
1284
|
+
// A session another daemon on this credential is serving is NOT a closed
|
|
1285
|
+
// tab. Without this the daemon that lost the lease removes the worktree the
|
|
1286
|
+
// winner is working in — absence would mean "somebody else won" instead of
|
|
1287
|
+
// "the tab closed".
|
|
1288
|
+
retireWorkSessions(
|
|
1289
|
+
Array.isArray(roster.activeWorkSessions)
|
|
1290
|
+
? roster.activeWorkSessions
|
|
1291
|
+
: roster.activeWorkSessions,
|
|
1292
|
+
roster.sessionsHeldElsewhere
|
|
1293
|
+
);
|
|
1241
1294
|
// Diffs somebody has open and is waiting on. Project-scoped rather than
|
|
1242
1295
|
// per-session: `git show` runs from the repo ROOT, which can see a closed
|
|
1243
1296
|
// tab's branch and a shipped commit on main alike.
|
|
@@ -1262,24 +1315,23 @@ export async function runFleetDaemon() {
|
|
|
1262
1315
|
const sig = [...rosterIds].sort().join(',');
|
|
1263
1316
|
if (sig !== rosterSig) {
|
|
1264
1317
|
rosterSig = sig;
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
info('Machine online. Open a tab in Flowviant → Workbench to start working.');
|
|
1272
|
-
} else {
|
|
1273
|
-
note(`Roster: ${c.bold(String(rosterIds.size))} agent${rosterIds.size === 1 ? '' : 's'}.`);
|
|
1274
|
-
}
|
|
1318
|
+
// `agents` is permanently [] — the lanes it counted died with dispatch
|
|
1319
|
+
// and the array survives only as wire compat, so this runs once, on the
|
|
1320
|
+
// first poll. It used to point at the Cockpit, a surface deleted
|
|
1321
|
+
// 2026-08-04 that now redirects to the Board. Say what is actually true
|
|
1322
|
+
// instead: the machine is up, and work starts in a tab.
|
|
1323
|
+
info('Machine online. Open a tab in Flowviant → Workbench to start working.');
|
|
1275
1324
|
}
|
|
1276
|
-
// Heartbeat so a quiet
|
|
1277
|
-
|
|
1325
|
+
// Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
|
|
1326
|
+
// `rosterIds` is built from `roster.agents`, which the server sends
|
|
1327
|
+
// permanently empty, so gating on it printed "waiting" once a minute even
|
|
1328
|
+
// while a tab's turn was running. `workBusy()` is the honest question: are
|
|
1329
|
+
// there session turns, ships or unsettled reports in flight?
|
|
1330
|
+
if (!workBusy() && Date.now() - idleBeatAt > 60_000) {
|
|
1278
1331
|
idleBeatAt = Date.now();
|
|
1279
|
-
info('
|
|
1332
|
+
info('machine online — nothing running right now.');
|
|
1280
1333
|
}
|
|
1281
1334
|
|
|
1282
|
-
|
|
1283
1335
|
// Living-wiki work (runs under its own minted wiki token — no agent
|
|
1284
1336
|
// needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
|
|
1285
1337
|
// deliveries whose re-ground never ran (e.g. we restarted between merge and
|
|
@@ -1295,13 +1347,18 @@ export async function runFleetDaemon() {
|
|
|
1295
1347
|
|
|
1296
1348
|
// Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
|
|
1297
1349
|
// dictates (self-guarded — one operation at a time, errors retry next
|
|
1298
|
-
// poll). A fresh bundle rematerializes every
|
|
1299
|
-
//
|
|
1350
|
+
// poll). A fresh bundle rematerializes every SESSION worktree this daemon
|
|
1351
|
+
// holds — read off the sessions directory, the same fact retirement acts
|
|
1352
|
+
// on; the wiki worktree NEVER gets env (the cartographer doesn't need
|
|
1353
|
+
// secrets). This used to iterate the dispatch-era `workers` map, which
|
|
1354
|
+
// nothing has ever `.set()`, so a rotation reached no worktree at all.
|
|
1355
|
+
// Safe mid-turn by construction: materializeInto refuses to write anything
|
|
1356
|
+
// git does not ignore, so it cannot dirty a tree and block a ship.
|
|
1300
1357
|
void handleRosterEnv(roster.env, { projectId: roster.project?.id }).then(({ changed }) => {
|
|
1301
1358
|
if (!changed) return;
|
|
1302
|
-
for (const
|
|
1359
|
+
for (const id of heldSessionIds()) {
|
|
1303
1360
|
try {
|
|
1304
|
-
materializeInto(
|
|
1361
|
+
materializeInto(join(baseDir, 'sessions', id));
|
|
1305
1362
|
} catch {
|
|
1306
1363
|
/* best-effort */
|
|
1307
1364
|
}
|
package/bin/lib/git.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export function repoRootOrDie() {
|
|
|
35
35
|
try {
|
|
36
36
|
return git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
37
37
|
} catch {
|
|
38
|
-
console.error('error:
|
|
38
|
+
console.error('error: the flowviant daemon must run inside a git repo.');
|
|
39
39
|
process.exit(1);
|
|
40
40
|
}
|
|
41
41
|
}
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ONE DAEMON PER
|
|
2
|
+
* ONE DAEMON PER REPO (and per credential), ARBITRATED at startup — a second
|
|
3
|
+
* run in the same repo takes the first one's place rather than being turned
|
|
4
|
+
* away. See "WHAT A SECOND RUN DOES" below for the whole rule.
|
|
3
5
|
*
|
|
4
|
-
* WHY THIS EXISTS. Nothing stopped two daemons before, and the server
|
|
5
|
-
* work out by READING, never claiming: `listWorkTurnJobs`
|
|
6
|
-
* turn for the
|
|
7
|
-
* credential
|
|
8
|
-
* connected daemon socket at once, so they
|
|
6
|
+
* WHY THIS EXISTS. Nothing stopped two daemons before, and the server USED to
|
|
7
|
+
* hand work out by READING, never claiming: `listWorkTurnJobs` selected every
|
|
8
|
+
* pending turn for the machine credential, `listShipJobs` read a flag. So two
|
|
9
|
+
* daemons on one credential were offered the SAME turn — and the ProjectRoom
|
|
10
|
+
* nudges every connected daemon socket at once, so they did not even drift out
|
|
11
|
+
* of phase.
|
|
12
|
+
*
|
|
13
|
+
* That half is fixed on the server now: since 0.53.0 each SESSION is leased to
|
|
14
|
+
* one daemon INSTANCE nonce — `di`, regenerated every start (config.mjs) and
|
|
15
|
+
* sent on every poll beside `ws`, the list of sessions this daemon holds a
|
|
16
|
+
* worktree for — so a turn is handed to the instance holding that session and
|
|
17
|
+
* to no one else. It does NOT retire this lock. The lease fails OPEN when no
|
|
18
|
+
* instance is reported (an older daemon cannot name itself), and it arbitrates
|
|
19
|
+
* only what rides a session: the wiki sweep, env materialization, previews,
|
|
20
|
+
* deploys and every worktree operation the server never sees are still first
|
|
21
|
+
* come, first served.
|
|
9
22
|
*
|
|
10
23
|
* The per-worktree `flowviant-turn.lock` cannot save it. That lock is written
|
|
11
24
|
* AFTER the work token is minted and the attachments are fetched — a window
|
|
@@ -14,27 +27,70 @@
|
|
|
14
27
|
* (its own comment says so, work.mjs), where the holder is already live when
|
|
15
28
|
* the successor looks; it was never a concurrency primitive.
|
|
16
29
|
*
|
|
17
|
-
* What
|
|
18
|
-
* editing one worktree, two cards from one `file_card` (no
|
|
19
|
-
* the session write budget spent twice, quota spent twice —
|
|
20
|
-
* ONE answer
|
|
21
|
-
*
|
|
30
|
+
* What a duplicate run cost before the session lease, all of it invisible in
|
|
31
|
+
* the tab: two Claudes editing one worktree, two cards from one `file_card` (no
|
|
32
|
+
* idempotency key), the session write budget spent twice, quota spent twice —
|
|
33
|
+
* and then exactly ONE answer surviving, because `settleWorkTurn` is atomic.
|
|
34
|
+
* The side effects landed twice and the transcript showed one turn. Two daemons
|
|
35
|
+
* in one checkout still cost the un-leased half of that: two `git fetch`, two
|
|
36
|
+
* worktree sweeps, and the collisions listed under ONE DAEMON PER REPO below.
|
|
22
37
|
*
|
|
23
|
-
* KEYED ON THE CREDENTIAL,
|
|
38
|
+
* KEYED ON THE CREDENTIAL — and, as the next paragraph adds, on the REPO as
|
|
39
|
+
* well; both checks run, and either one is enough. The credential is stored once, at
|
|
24
40
|
* ~/.flowviant/credentials.json, so `flowviant` in two DIFFERENT checkouts is
|
|
25
41
|
* still one project served twice — and that case is strictly worse, because the
|
|
26
42
|
* two daemons have different worktree roots and the turn lock cannot even see
|
|
27
43
|
* across them. Keying on the token catches both, and still lets a second
|
|
28
44
|
* credential run a second project on the same machine.
|
|
29
45
|
*
|
|
46
|
+
* ...AND ONE DAEMON PER REPO, which is NOT the same statement. The lock above
|
|
47
|
+
* is keyed on the credential, and the two coincide only while one credential
|
|
48
|
+
* serves one project — which is the product's law but not a thing this file can
|
|
49
|
+
* assume. Two DIFFERENT credentials pointing at one checkout both acquired
|
|
50
|
+
* happily (measured), giving two daemons in one working tree: two `git fetch`,
|
|
51
|
+
* two worktree sweeps, `retireWorkSessions` in one removing directories the
|
|
52
|
+
* other is serving, and a ship in one racing a rebase in the other. No server
|
|
53
|
+
* lease can arbitrate any of that, because the server never sees a directory.
|
|
54
|
+
* So the repo is checked too, across every credential's lock.
|
|
55
|
+
*
|
|
56
|
+
* WHAT A SECOND RUN DOES, and this is the whole rule:
|
|
57
|
+
*
|
|
58
|
+
* SAME REPO -> the new run WINS. The holder is asked to stand down and
|
|
59
|
+
* this daemon takes its place. Re-running `flowviant` in a
|
|
60
|
+
* directory you are working in means "serve this repo", and
|
|
61
|
+
* the process already serving it is by definition the one
|
|
62
|
+
* you are replacing. That is a restart, and a restart
|
|
63
|
+
* should not require you to go and find a pid.
|
|
64
|
+
*
|
|
65
|
+
* DIFFERENT REPO -> REFUSED, and nothing is signalled. That daemon is serving
|
|
66
|
+
* other work; killing it because you happened to run this
|
|
67
|
+
* command elsewhere is not a restart, it is collateral.
|
|
68
|
+
* `--takeover` overrides, deliberately explicitly.
|
|
69
|
+
*
|
|
70
|
+
* One rule, and it is the invariant stated as behaviour: one daemon per repo.
|
|
71
|
+
* `--no-takeover` (or FLOWVIANT_NO_TAKEOVER=1) makes even the same-repo case
|
|
72
|
+
* refuse, for anyone who wants the old ceremony.
|
|
73
|
+
*
|
|
30
74
|
* IT FAILS OPEN. A home directory we cannot write to is not a reason to refuse
|
|
31
75
|
* to start; it is a reason to say so and carry on unguarded.
|
|
32
76
|
*/
|
|
33
77
|
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
78
|
+
import { execFileSync } from 'node:child_process';
|
|
79
|
+
import {
|
|
80
|
+
closeSync,
|
|
81
|
+
mkdirSync,
|
|
82
|
+
openSync,
|
|
83
|
+
readdirSync,
|
|
84
|
+
readFileSync,
|
|
85
|
+
realpathSync,
|
|
86
|
+
rmSync,
|
|
87
|
+
writeFileSync,
|
|
88
|
+
writeSync,
|
|
89
|
+
} from 'node:fs';
|
|
90
|
+
import { homedir, platform } from 'node:os';
|
|
36
91
|
import { join } from 'node:path';
|
|
37
92
|
import { createHash } from 'node:crypto';
|
|
93
|
+
import { VERSION } from './config.mjs';
|
|
38
94
|
|
|
39
95
|
/** Deliberately a HASH: a credential must never become a filename. */
|
|
40
96
|
export function instanceLockPath(fleetToken) {
|
|
@@ -42,6 +98,20 @@ export function instanceLockPath(fleetToken) {
|
|
|
42
98
|
return join(homedir(), '.flowviant', `daemon-${key}.lock`);
|
|
43
99
|
}
|
|
44
100
|
|
|
101
|
+
/** Numeric dotted compare, -1/0/1. Unparsable compares EQUAL, so a version we
|
|
102
|
+
* cannot read never silently authorises a downgrade. */
|
|
103
|
+
function cmpVersion(a, b) {
|
|
104
|
+
const x = String(a).split('.').map((n) => Number.parseInt(n, 10));
|
|
105
|
+
const y = String(b).split('.').map((n) => Number.parseInt(n, 10));
|
|
106
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
107
|
+
const p = x[i] ?? 0;
|
|
108
|
+
const q = y[i] ?? 0;
|
|
109
|
+
if (Number.isNaN(p) || Number.isNaN(q)) return 0;
|
|
110
|
+
if (p !== q) return p > q ? 1 : -1;
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
45
115
|
/** Signal 0 — a liveness probe, not a kill. EPERM means alive and not ours. */
|
|
46
116
|
function alive(pid) {
|
|
47
117
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -63,7 +133,192 @@ function readHolder(path) {
|
|
|
63
133
|
}
|
|
64
134
|
|
|
65
135
|
const record = (repoRoot) =>
|
|
66
|
-
JSON.stringify({
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
pid: process.pid,
|
|
138
|
+
repoRoot,
|
|
139
|
+
startedAt: new Date().toISOString(),
|
|
140
|
+
// The script we were started from, and what we are. A takeover matches the
|
|
141
|
+
// live command line against `entry` before signalling anything — a lock
|
|
142
|
+
// records a PID, and a crashed daemon's PID can be reused by anything.
|
|
143
|
+
entry: process.argv[1] || '',
|
|
144
|
+
version: VERSION,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** Same directory, whatever it is spelled as — symlinks and trailing slashes
|
|
148
|
+
* included. A repo compared by string would let `/repo` and `/repo/` past. */
|
|
149
|
+
function samePath(a, b) {
|
|
150
|
+
if (!a || !b) return false;
|
|
151
|
+
const norm = (v) => {
|
|
152
|
+
try {
|
|
153
|
+
return realpathSync(v);
|
|
154
|
+
} catch {
|
|
155
|
+
return String(v).replace(/\/+$/, '');
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return norm(a) === norm(b);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A LIVE daemon in this same checkout, under a DIFFERENT credential.
|
|
163
|
+
*
|
|
164
|
+
* The lock above cannot see one: it is keyed on the credential, so a second
|
|
165
|
+
* token in the same directory opens its own file and takes it. Every other
|
|
166
|
+
* lock file on this machine is ours to read, so read them.
|
|
167
|
+
*
|
|
168
|
+
* Returns the holder, or null. A stale file never blocks — it is cleared by
|
|
169
|
+
* whichever acquire owns it, and blocking on a corpse would be worse than the
|
|
170
|
+
* thing this prevents.
|
|
171
|
+
*/
|
|
172
|
+
/** Which lock file a neighbour holder was read from — takeOverFrom waits on it. */
|
|
173
|
+
const NEIGHBOUR_PATHS = new WeakMap();
|
|
174
|
+
function neighbourLockPath(holder, fallback) {
|
|
175
|
+
return NEIGHBOUR_PATHS.get(holder) ?? fallback;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function daemonInSameRepo(repoRoot, ownPath) {
|
|
179
|
+
const dir = join(homedir(), '.flowviant');
|
|
180
|
+
let files;
|
|
181
|
+
try {
|
|
182
|
+
files = readdirSync(dir).filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
for (const f of files) {
|
|
187
|
+
const path = join(dir, f);
|
|
188
|
+
if (path === ownPath) continue; // our own credential — the lock above owns that question
|
|
189
|
+
const holder = readHolder(path);
|
|
190
|
+
if (!holder || !alive(holder.pid)) continue;
|
|
191
|
+
if (holder.pid === process.ppid) continue; // ourselves mid self-update re-exec
|
|
192
|
+
if (samePath(holder.repoRoot, repoRoot)) {
|
|
193
|
+
NEIGHBOUR_PATHS.set(holder, path);
|
|
194
|
+
return holder;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* IS THIS PID STILL THE DAEMON THAT TOOK THE LOCK?
|
|
202
|
+
*
|
|
203
|
+
* `process.kill(pid, 0)` says "a process exists", which is not the same claim,
|
|
204
|
+
* and the difference matters the moment we are about to signal it. Matched on
|
|
205
|
+
* the holder's own recorded ENTRYPOINT, never on the word "flowviant": a
|
|
206
|
+
* command line merely CONTAINING it matches a shell, an editor, or a test
|
|
207
|
+
* runner living under a `…-flowviant/` directory. That last one is not
|
|
208
|
+
* hypothetical — a looser version of this check SIGTERMed one.
|
|
209
|
+
*
|
|
210
|
+
* A lock with no `entry` predates this and is never signalled.
|
|
211
|
+
*/
|
|
212
|
+
function stillTheHolder(holder) {
|
|
213
|
+
const want = typeof holder?.entry === 'string' ? holder.entry : null;
|
|
214
|
+
if (!want) return false;
|
|
215
|
+
try {
|
|
216
|
+
if (platform() === 'linux') {
|
|
217
|
+
return readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').includes(want);
|
|
218
|
+
}
|
|
219
|
+
return execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
|
|
220
|
+
encoding: 'utf8',
|
|
221
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
222
|
+
timeout: 3000,
|
|
223
|
+
}).includes(want);
|
|
224
|
+
} catch {
|
|
225
|
+
return false; // gone, or unreadable — not something we signal
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Blocking, because this runs before there is an event loop worth yielding to
|
|
230
|
+
* and the caller cannot proceed until it knows whether the holder is gone. */
|
|
231
|
+
const sleep = (ms) => {
|
|
232
|
+
try {
|
|
233
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
234
|
+
} catch {
|
|
235
|
+
/* no SharedArrayBuffer — check again immediately */
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** How long the outgoing daemon gets to stand down cleanly. Its SIGTERM handler
|
|
240
|
+
* kills the CLI children it spawned and stops its preview tunnels; both are
|
|
241
|
+
* why we ask before we insist. */
|
|
242
|
+
const TAKEOVER_GRACE_MS = 20_000;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Ask the holder to stand down, then take its place.
|
|
246
|
+
*
|
|
247
|
+
* SIGTERM FIRST, and not out of politeness: the daemon's handler runs its
|
|
248
|
+
* teardown — it kills the CLI children it spawned and stops its preview
|
|
249
|
+
* tunnels, which are DETACHED and would otherwise keep a public hostname
|
|
250
|
+
* serving a worktree until the box reboots.
|
|
251
|
+
*
|
|
252
|
+
* WAIT ON THE LOCK FILE, not the pid. A departing daemon's release() removes it
|
|
253
|
+
* on exit, so the file changing IS the handover. `kill(pid, 0)` cannot see it:
|
|
254
|
+
* a process that exited but has not been reaped is a ZOMBIE and answers signal
|
|
255
|
+
* 0 exactly like a living one — measured, a peer that exited cleanly still read
|
|
256
|
+
* as alive for the full grace window.
|
|
257
|
+
*
|
|
258
|
+
* And "gone" is NOT "the file stopped naming our pid". It can stop naming it
|
|
259
|
+
* because the holder SELF-UPDATED: update.mjs re-execs and the successor adopts
|
|
260
|
+
* this same lock through the ppid branch. Treating that as free steals a live
|
|
261
|
+
* daemon's lock and leaves it running unguarded — measured doing exactly that.
|
|
262
|
+
*/
|
|
263
|
+
function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
264
|
+
if (!holder?.pid || !alive(holder.pid)) return null; // already gone
|
|
265
|
+
if (!stillTheHolder(holder)) {
|
|
266
|
+
return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
|
|
267
|
+
}
|
|
268
|
+
if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
|
|
269
|
+
return {
|
|
270
|
+
failed: `the running daemon is ${holder.version} and this one is ${VERSION} — refusing to replace a newer daemon with an older one (--takeover-downgrade if you mean it)`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
log?.(`asking daemon pid ${holder.pid} to stand down…`);
|
|
275
|
+
try {
|
|
276
|
+
process.kill(holder.pid, 'SIGTERM');
|
|
277
|
+
} catch {
|
|
278
|
+
return { failed: `could not signal pid ${holder.pid}` };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const standing = () => {
|
|
282
|
+
const now = readHolder(path);
|
|
283
|
+
if (!now || !alive(now.pid)) return null;
|
|
284
|
+
return now;
|
|
285
|
+
};
|
|
286
|
+
const deadline = Date.now() + TAKEOVER_GRACE_MS;
|
|
287
|
+
for (;;) {
|
|
288
|
+
const now = standing();
|
|
289
|
+
if (!now) break;
|
|
290
|
+
if (now.pid !== holder.pid) {
|
|
291
|
+
return {
|
|
292
|
+
failed: `the daemon handed over to pid ${now.pid}${now.version ? ` (${now.version})` : ''} while we waited — it is mid-update, so try again in a moment`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (Date.now() >= deadline) {
|
|
296
|
+
log?.(`pid ${holder.pid} did not stand down within ${TAKEOVER_GRACE_MS / 1000}s — forcing it.`);
|
|
297
|
+
try {
|
|
298
|
+
process.kill(holder.pid, 'SIGKILL');
|
|
299
|
+
} catch {
|
|
300
|
+
/* exited in the gap */
|
|
301
|
+
}
|
|
302
|
+
sleep(600);
|
|
303
|
+
const after = standing();
|
|
304
|
+
if (after && after.pid !== holder.pid) {
|
|
305
|
+
return { failed: `the daemon handed over to pid ${after.pid} — try again in a moment` };
|
|
306
|
+
}
|
|
307
|
+
if (after) return { failed: `pid ${holder.pid} would not stop` };
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
sleep(400);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// A SIGKILLed daemon never ran its release(), so clear what it left.
|
|
314
|
+
try {
|
|
315
|
+
rmSync(path, { force: true });
|
|
316
|
+
} catch {
|
|
317
|
+
return { failed: 'could not clear the lock file' };
|
|
318
|
+
}
|
|
319
|
+
log?.(`daemon pid ${holder.pid} stopped — taking over.`);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
67
322
|
|
|
68
323
|
/**
|
|
69
324
|
* Take the lock, or report who holds it.
|
|
@@ -75,7 +330,8 @@ const record = (repoRoot) =>
|
|
|
75
330
|
* `wx` is the whole guarantee: create-exclusive is one atomic syscall, which is
|
|
76
331
|
* the property the turn lock's check-then-write does not have.
|
|
77
332
|
*/
|
|
78
|
-
export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
333
|
+
export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
334
|
+
const { takeover: force = false, noTakeover = false, allowDowngrade = false, log } = opts;
|
|
79
335
|
if (process.env.FLOWVIANT_ALLOW_MULTI === '1') return { ok: true, release: () => {} };
|
|
80
336
|
const path = instanceLockPath(fleetToken);
|
|
81
337
|
try {
|
|
@@ -84,6 +340,20 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
|
84
340
|
return { ok: true, release: () => {}, unguarded: true };
|
|
85
341
|
}
|
|
86
342
|
|
|
343
|
+
// ONE DAEMON PER REPO, checked across every credential — see the header. This
|
|
344
|
+
// runs BEFORE we take our own lock, so a refusal leaves nothing behind.
|
|
345
|
+
const neighbour = daemonInSameRepo(repoRoot, path);
|
|
346
|
+
if (neighbour) {
|
|
347
|
+
// Same working tree, another credential. Under "one daemon per repo" the
|
|
348
|
+
// new run wins here too — but it is signalling a process that belongs to a
|
|
349
|
+
// DIFFERENT project, so it is worth saying out loud rather than doing
|
|
350
|
+
// quietly.
|
|
351
|
+
if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
|
|
352
|
+
log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
|
|
353
|
+
const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log);
|
|
354
|
+
if (bad) return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed };
|
|
355
|
+
}
|
|
356
|
+
|
|
87
357
|
// Two passes at most: one to clear a stale holder, one to take the lock. A
|
|
88
358
|
// loop here would spin against a peer that keeps re-taking it.
|
|
89
359
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -118,7 +388,16 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
|
118
388
|
}
|
|
119
389
|
return { ok: true, release: makeRelease(path) };
|
|
120
390
|
}
|
|
121
|
-
|
|
391
|
+
// THE RULE. Same repo -> this run replaces it; different repo -> refuse
|
|
392
|
+
// and signal nothing, unless --takeover says otherwise. See the header.
|
|
393
|
+
const here = samePath(holder.repoRoot, repoRoot);
|
|
394
|
+
const wanted = force || (here && !noTakeover);
|
|
395
|
+
if (wanted) {
|
|
396
|
+
const bad = takeOverFrom(holder, path, log, { allowDowngrade });
|
|
397
|
+
if (bad) return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here };
|
|
398
|
+
continue; // the file is gone — the next pass takes it
|
|
399
|
+
}
|
|
400
|
+
return { ok: false, holder, sameRepo: here };
|
|
122
401
|
}
|
|
123
402
|
try {
|
|
124
403
|
writeSync(fd, record(repoRoot));
|
package/bin/lib/login.mjs
CHANGED
|
@@ -56,7 +56,11 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
|
|
58
58
|
const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
|
|
59
|
-
|
|
59
|
+
// Where the control ACTUALLY is. It was "the Agents panel", a settings
|
|
60
|
+
// section deleted 2026-08-17; connecting a machine is offered on the surface
|
|
61
|
+
// you are on when it matters, and for a new operator that is the Workbench —
|
|
62
|
+
// the project's empty state says so before it can show you any sessions.
|
|
63
|
+
console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
|
|
60
64
|
console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
|
|
61
65
|
info('waiting for you to approve…');
|
|
62
66
|
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
* `flowviant mcp` — connect YOUR Claude to Flowviant so you can file work from
|
|
3
3
|
* the terminal ("stick that on the board", "file a task for this TODO").
|
|
4
4
|
*
|
|
5
|
-
* This mints a `cli` credential, which is a different principal from the
|
|
6
|
-
* tokens the daemon
|
|
7
|
-
*
|
|
8
|
-
* giving THAT principal tools that write to
|
|
9
|
-
* string in a README could file work as
|
|
10
|
-
*
|
|
11
|
-
* reach create_task.
|
|
5
|
+
* This mints a `cli` credential, which is a different principal from the
|
|
6
|
+
* per-session `work` tokens the daemon mints for Workbench tabs. That
|
|
7
|
+
* separation is the point, not bookkeeping: a session's agent reads untrusted
|
|
8
|
+
* repo and issue text all day, so giving THAT principal tools that write to
|
|
9
|
+
* your workspace would mean a hostile string in a README could file work as
|
|
10
|
+
* you. The cli credential sees only the management tools and can never work or
|
|
11
|
+
* ship a card; a session token can never reach create_task.
|
|
12
12
|
*
|
|
13
13
|
* There is deliberately no invite capability on it. Invites grant access to a
|
|
14
|
-
* paid workspace and are guarded by a human browser session
|
|
15
|
-
* the
|
|
14
|
+
* paid workspace and are guarded by a human browser session — they are sent
|
|
15
|
+
* from the workspace card's gear menu in the app, and never from a CLI
|
|
16
|
+
* credential sitting on a shared machine.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
|
|
@@ -77,7 +78,7 @@ export async function runMcpCommand(args = []) {
|
|
|
77
78
|
console.log(` ${cmd}`);
|
|
78
79
|
console.log('');
|
|
79
80
|
console.log('Then, in any Claude session: "file a task in Flowviant for …".');
|
|
80
|
-
console.log('Tasks land as drafts — nothing runs until you open
|
|
81
|
-
console.log('
|
|
81
|
+
console.log('Tasks land as drafts on the board — nothing runs until you open');
|
|
82
|
+
console.log('a session tab in the Workbench and type in it.');
|
|
82
83
|
console.log('');
|
|
83
84
|
}
|
package/bin/lib/preflight.mjs
CHANGED
|
@@ -91,20 +91,23 @@ export async function preflight({ needGit = true } = {}) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// gh —
|
|
95
|
-
//
|
|
94
|
+
// gh — OPTIONAL. The daemon pushes and merges with plain `git`; nothing in
|
|
95
|
+
// the product opens a pull request any more, so gh is a convenience for the
|
|
96
|
+
// agent, not a requirement. Hence the offer no longer defaults to yes: an
|
|
97
|
+
// install nobody asked for, defaulted on, is how a preflight installs
|
|
98
|
+
// software on a machine whose operator only wanted a status line.
|
|
96
99
|
if (gh && ghAuthed()) {
|
|
97
100
|
ok('gh authenticated');
|
|
98
101
|
} else if (gh) {
|
|
99
102
|
warn('gh not signed in — run: gh auth login');
|
|
100
103
|
} else {
|
|
101
|
-
|
|
102
|
-
if (await promptYesNo('Install GitHub CLI (gh) now?',
|
|
104
|
+
info('gh not found (optional).');
|
|
105
|
+
if (await promptYesNo('Install GitHub CLI (gh) now?', false)) {
|
|
103
106
|
if (await installGh((m) => info(m))) gh = present('gh');
|
|
104
107
|
}
|
|
105
108
|
gh
|
|
106
109
|
? ok('gh installed to ~/.flowviant/bin — authenticate with: flowviant gh-auth')
|
|
107
|
-
:
|
|
110
|
+
: info(c.dim('or install it later: https://cli.github.com, then: gh auth login'));
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
if (needGit) (git ? ok('git installed') : warn('git NOT found — install git'));
|
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
|
@@ -799,9 +799,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
799
799
|
'Content-Type': 'application/json',
|
|
800
800
|
},
|
|
801
801
|
signal: AbortSignal.timeout(30_000),
|
|
802
|
-
|
|
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 }),
|
|
803
808
|
});
|
|
804
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 };
|
|
805
813
|
if (!res.ok) return null;
|
|
806
814
|
const token = (await res.json().catch(() => null))?.data?.token ?? null;
|
|
807
815
|
if (!token) return null;
|
|
@@ -1168,8 +1176,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1168
1176
|
* machines. When the roster omits the field entirely (older server),
|
|
1169
1177
|
* absence of signal is not a close — retire nothing.
|
|
1170
1178
|
*/
|
|
1171
|
-
|
|
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) => {
|
|
1172
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 : []);
|
|
1173
1198
|
const dir = join(baseDir, 'sessions');
|
|
1174
1199
|
if (!existsSync(dir)) return;
|
|
1175
1200
|
let ids;
|
|
@@ -1182,6 +1207,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1182
1207
|
let removed = 0;
|
|
1183
1208
|
for (const id of ids) {
|
|
1184
1209
|
if (live.has(id)) continue;
|
|
1210
|
+
if (peers.has(id)) continue; // another daemon's tab — not ours to retire
|
|
1185
1211
|
if (workChains.has(id) || shipping.has(id)) continue; // still draining here
|
|
1186
1212
|
const wt = join(dir, id);
|
|
1187
1213
|
try {
|
|
@@ -1412,6 +1438,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1412
1438
|
if (!plainTab) {
|
|
1413
1439
|
mint = await mintWorkToken(job.sessionId);
|
|
1414
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
|
+
}
|
|
1415
1450
|
if (mint?.gone) {
|
|
1416
1451
|
await settleWorkTurn(job.id, {
|
|
1417
1452
|
ok: false,
|
|
@@ -2064,6 +2099,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2064
2099
|
processWorkTurns,
|
|
2065
2100
|
processShipJobs,
|
|
2066
2101
|
processDiffJobs,
|
|
2102
|
+
heldSessionIds,
|
|
2067
2103
|
processPreviewJobs,
|
|
2068
2104
|
livePreviewIds,
|
|
2069
2105
|
retirePreviews,
|
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": {
|