flowviant 0.51.0 → 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 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
+ }
@@ -299,11 +299,17 @@ rules:
299
299
  cards that exist — \`points\`, \`priority\`, \`featureName\` — up to 25 in one
300
300
  call. This is the tool for "help me plan the backlog": list_cards, decide,
301
301
  then send every change in ONE call. It cannot move a card, close one, assign
302
- anyone or touch a receipt; say what you are doing with log_work and finish
303
- with deliver_card. A card that is already delivered is refused, because its
302
+ anyone or touch a receipt organising a backlog is not working on it, so do
303
+ not log_work or deliver anything you have not actually built. A card that is already delivered is refused, because its
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.0",
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": {