flowviant 0.51.1 → 0.51.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/fleet.mjs +27 -0
- package/bin/lib/instance.mjs +152 -0
- package/bin/lib/prompts.mjs +6 -0
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
REGROUND_KICKOFF,
|
|
61
61
|
} from './claude.mjs';
|
|
62
62
|
import { reapOrphanPreviews } from './preview.mjs';
|
|
63
|
+
import { acquireInstanceLock } from './instance.mjs';
|
|
63
64
|
import { preflight } from './preflight.mjs';
|
|
64
65
|
import { connectStream } from './stream.mjs';
|
|
65
66
|
import { ensureVault, syncVault } from './vault.mjs';
|
|
@@ -261,6 +262,32 @@ export async function runFleetDaemon() {
|
|
|
261
262
|
);
|
|
262
263
|
info(`server · ${FLEET_URL}`);
|
|
263
264
|
console.log('');
|
|
265
|
+
|
|
266
|
+
// ONE DAEMON PER CREDENTIAL. Before preflight, before the preview reap,
|
|
267
|
+
// before anything with a side effect — a second daemon must not so much as
|
|
268
|
+
// install a CLI or clear a registry on its way to being refused. Keyed on the
|
|
269
|
+
// credential rather than the repo, because two checkouts on one credential is
|
|
270
|
+
// the SAME project served twice, and the worst version of this: their session
|
|
271
|
+
// worktrees are in different directories, so the per-turn lock cannot even see
|
|
272
|
+
// across them. See instance.mjs for why that lock is not enough on its own.
|
|
273
|
+
const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot);
|
|
274
|
+
if (!instance.ok) {
|
|
275
|
+
const h = instance.holder;
|
|
276
|
+
console.log('');
|
|
277
|
+
fail('a flowviant daemon is already running for this credential.');
|
|
278
|
+
if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
|
|
279
|
+
// The two-checkouts case is the one nobody spots on their own: both tabs
|
|
280
|
+
// look healthy, and the damage is doubled cards and doubled edits in a repo
|
|
281
|
+
// you are not looking at. Name the other repo when it is a different one.
|
|
282
|
+
if (h?.repoRoot && h.repoRoot !== repoRoot)
|
|
283
|
+
warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
|
|
284
|
+
note('stop the other one first, or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
|
|
285
|
+
console.log('');
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
if (instance.unguarded)
|
|
289
|
+
warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
|
|
290
|
+
|
|
264
291
|
await preflight({ needGit: true });
|
|
265
292
|
// Kill any preview dev-server/tunnel groups a previously-crashed daemon left
|
|
266
293
|
// running (detached children survive an ungraceful exit) before we start fresh.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE DAEMON PER CREDENTIAL, refused at startup.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Nothing stopped two daemons before, and the server hands
|
|
5
|
+
* work out by READING, never claiming: `listWorkTurnJobs` selects every pending
|
|
6
|
+
* turn for the fleet token, `listShipJobs` reads a flag. So two daemons on one
|
|
7
|
+
* credential are offered the SAME turn — and the ProjectRoom nudges every
|
|
8
|
+
* connected daemon socket at once, so they do not even drift out of phase.
|
|
9
|
+
*
|
|
10
|
+
* The per-worktree `flowviant-turn.lock` cannot save it. That lock is written
|
|
11
|
+
* AFTER the work token is minted and the attachments are fetched — a window
|
|
12
|
+
* containing a network round trip — so both daemons clear the check and both
|
|
13
|
+
* spawn a CLI into one held conversation. It was built for a RESTARTED daemon
|
|
14
|
+
* (its own comment says so, work.mjs), where the holder is already live when
|
|
15
|
+
* the successor looks; it was never a concurrency primitive.
|
|
16
|
+
*
|
|
17
|
+
* What the duplicate run costs, all of it invisible in the tab: two Claudes
|
|
18
|
+
* editing one worktree, two cards from one `file_card` (no idempotency key),
|
|
19
|
+
* the session write budget spent twice, quota spent twice — and then exactly
|
|
20
|
+
* ONE answer survives, because `settleWorkTurn` is atomic. The side effects
|
|
21
|
+
* land twice and the transcript shows one turn.
|
|
22
|
+
*
|
|
23
|
+
* KEYED ON THE CREDENTIAL, NOT THE REPO. The credential is stored once, at
|
|
24
|
+
* ~/.flowviant/credentials.json, so `flowviant` in two DIFFERENT checkouts is
|
|
25
|
+
* still one project served twice — and that case is strictly worse, because the
|
|
26
|
+
* two daemons have different worktree roots and the turn lock cannot even see
|
|
27
|
+
* across them. Keying on the token catches both, and still lets a second
|
|
28
|
+
* credential run a second project on the same machine.
|
|
29
|
+
*
|
|
30
|
+
* IT FAILS OPEN. A home directory we cannot write to is not a reason to refuse
|
|
31
|
+
* to start; it is a reason to say so and carry on unguarded.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
35
|
+
import { homedir } from 'node:os';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { createHash } from 'node:crypto';
|
|
38
|
+
|
|
39
|
+
/** Deliberately a HASH: a credential must never become a filename. */
|
|
40
|
+
export function instanceLockPath(fleetToken) {
|
|
41
|
+
const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
|
|
42
|
+
return join(homedir(), '.flowviant', `daemon-${key}.lock`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Signal 0 — a liveness probe, not a kill. EPERM means alive and not ours. */
|
|
46
|
+
function alive(pid) {
|
|
47
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return e.code === 'EPERM';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readHolder(path) {
|
|
57
|
+
try {
|
|
58
|
+
const v = JSON.parse(readFileSync(path, 'utf8'));
|
|
59
|
+
return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null; // absent, truncated, or half-written — treat as no holder
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const record = (repoRoot) =>
|
|
66
|
+
JSON.stringify({ pid: process.pid, repoRoot, startedAt: new Date().toISOString() });
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Take the lock, or report who holds it.
|
|
70
|
+
*
|
|
71
|
+
* Returns `{ ok: true, release }` — call `release()` to drop it, and it is
|
|
72
|
+
* already wired to process exit — or `{ ok: false, holder }` with the other
|
|
73
|
+
* daemon's pid and repo so the caller can say something useful.
|
|
74
|
+
*
|
|
75
|
+
* `wx` is the whole guarantee: create-exclusive is one atomic syscall, which is
|
|
76
|
+
* the property the turn lock's check-then-write does not have.
|
|
77
|
+
*/
|
|
78
|
+
export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
79
|
+
if (process.env.FLOWVIANT_ALLOW_MULTI === '1') return { ok: true, release: () => {} };
|
|
80
|
+
const path = instanceLockPath(fleetToken);
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
|
|
83
|
+
} catch {
|
|
84
|
+
return { ok: true, release: () => {}, unguarded: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Two passes at most: one to clear a stale holder, one to take the lock. A
|
|
88
|
+
// loop here would spin against a peer that keeps re-taking it.
|
|
89
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
90
|
+
let fd;
|
|
91
|
+
try {
|
|
92
|
+
fd = openSync(path, 'wx');
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
|
|
95
|
+
const holder = readHolder(path);
|
|
96
|
+
if (!holder || !alive(holder.pid)) {
|
|
97
|
+
// A crashed daemon's leftover. Clear it and take it on the next pass.
|
|
98
|
+
try {
|
|
99
|
+
rmSync(path, { force: true });
|
|
100
|
+
} catch {
|
|
101
|
+
return { ok: true, release: () => {}, unguarded: true };
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// OUR OWN PARENT, which is not a second daemon — it is this one, mid
|
|
106
|
+
// re-exec. The SELF-UPDATE is the case: a live daemon holding this lock
|
|
107
|
+
// installs a new version, spawns it, and stays alive as a proxy awaiting
|
|
108
|
+
// it (update.mjs), so the successor's ppid IS the holder. Refusing there
|
|
109
|
+
// would brick every auto-update. Adopt instead; the parent's release is
|
|
110
|
+
// ownership-checked, so it will not delete the lock it handed over.
|
|
111
|
+
// (`flowviant login` also proxies a child, but that parent never reached
|
|
112
|
+
// the daemon and holds nothing — the child simply acquires.)
|
|
113
|
+
if (holder.pid === process.ppid) {
|
|
114
|
+
try {
|
|
115
|
+
writeFileSync(path, record(repoRoot));
|
|
116
|
+
} catch {
|
|
117
|
+
return { ok: true, release: () => {}, unguarded: true };
|
|
118
|
+
}
|
|
119
|
+
return { ok: true, release: makeRelease(path) };
|
|
120
|
+
}
|
|
121
|
+
return { ok: false, holder };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
writeSync(fd, record(repoRoot));
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, release: makeRelease(path) };
|
|
129
|
+
}
|
|
130
|
+
// Both passes lost to something re-creating the file — assume a peer.
|
|
131
|
+
return { ok: false, holder: readHolder(path) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Release ONLY what we still own: a successor that adopted the lock (see the
|
|
135
|
+
* ppid branch) must not have it deleted out from under it when we exit. */
|
|
136
|
+
function makeRelease(path) {
|
|
137
|
+
let released = false;
|
|
138
|
+
const release = () => {
|
|
139
|
+
if (released) return;
|
|
140
|
+
released = true;
|
|
141
|
+
const holder = readHolder(path);
|
|
142
|
+
if (holder && holder.pid !== process.pid) return; // handed over — leave it
|
|
143
|
+
try {
|
|
144
|
+
rmSync(path, { force: true });
|
|
145
|
+
} catch {
|
|
146
|
+
/* best-effort; a stale file is cleared by the next acquire */
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
// 'exit' covers the SIGINT/SIGTERM handlers too — both call process.exit().
|
|
150
|
+
process.on('exit', release);
|
|
151
|
+
return release;
|
|
152
|
+
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -304,6 +304,12 @@ rules:
|
|
|
304
304
|
spec is what somebody's review is about. And when list_cards says
|
|
305
305
|
\`truncated\` is above zero, the queue is LONGER than the list you were
|
|
306
306
|
handed — say so rather than letting a short list read as the whole board.
|
|
307
|
+
SAY WHAT WAITS ON WHAT. \`waitsOn\` takes the task ids a card cannot start
|
|
308
|
+
until, and it is what turns a feature from a heap into a sequence: the
|
|
309
|
+
migration before the endpoint, the endpoint before the UI, the polish last.
|
|
310
|
+
The Board orders and bands cards from it — READY vs WAITING — so a person who
|
|
311
|
+
was not in this conversation can still see where to start. Declare it while
|
|
312
|
+
you are decomposing, because that is the one moment anyone knows.
|
|
307
313
|
11. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
|
|
308
314
|
one-paragraph summary and the commit shas. Delivered is ASSERTED; done is
|
|
309
315
|
OBSERVED (the merge, on their word). Never claim done, and never deliver
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.2",
|
|
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": {
|