flowviant 0.63.1 → 0.65.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.
@@ -359,6 +359,25 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
359
359
  const child = spawn(rt.bin, args, {
360
360
  cwd,
361
361
  stdio: ['ignore', 'pipe', 'pipe'],
362
+ /**
363
+ * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
364
+ *
365
+ * Everything the CLI spawns inherits this pgid and KEEPS it through
366
+ * `nohup` and `setsid` — which is exactly when attribution by descendancy
367
+ * fails, because reparenting to init breaks the ppid chain the moment a
368
+ * process becomes long-running. `processes.mjs` reads the group; the
369
+ * Workbench renders it.
370
+ *
371
+ * TEARDOWN IS DELIBERATELY UNCHANGED: `shutdownWork` still SIGTERMs this
372
+ * CHILD and never the group. Signalling the group would kill the dev
373
+ * server the driver started every time the daemon restarts — including
374
+ * on an ordinary auto-update, unattended — which is the outcome the
375
+ * deleted dev-run supervisor spent a whole registry avoiding. Flowviant
376
+ * does not manage those processes; it reports them.
377
+ *
378
+ * Not `unref`'d: the daemon must still wait on this turn.
379
+ */
380
+ detached: true,
362
381
  // Only ADDS to the environment (the worker token, for runtimes that read
363
382
  // it from there). Never replaces it: the CLI's own credentials live in
364
383
  // this environment, and handing it a curated one signs it out.
package/bin/lib/fleet.mjs CHANGED
@@ -91,8 +91,7 @@ import { repoState } from './repoState.mjs';
91
91
  async function fetchRoster(
92
92
  haveIds,
93
93
  livePreviewSessionIds = [],
94
- heldSessionIds = [],
95
- liveDevRunSessionIds = []
94
+ heldSessionIds = []
96
95
  ) {
97
96
  const url = new URL(FLEET_URL);
98
97
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
@@ -123,11 +122,6 @@ async function fetchRoster(
123
122
  // the server still calls live is a 530 on somebody's phone. Always set, even
124
123
  // empty: '' means "serving none", absent would mean "an older daemon".
125
124
  url.searchParams.set('pv', livePreviewSessionIds.join(','));
126
- // The dev runs this machine is still carrying. Same rule as `pv`: always set,
127
- // even empty — '' means "running none", absent would mean "an older daemon",
128
- // and a run the server still calls running with nothing behind it is a chip
129
- // that says Serving over a dead port.
130
- url.searchParams.set('dr', liveDevRunSessionIds.join(','));
131
125
  // The sessions this daemon holds a worktree for. Its LEASE on each renews
132
126
  // here — one beat, no extra endpoint, and the server can tell "this daemon is
133
127
  // still serving that tab" from "it went away" within a reconcile interval
@@ -514,11 +508,6 @@ export async function runFleetDaemon() {
514
508
  // Kill any preview dev-server/tunnel groups a previously-crashed daemon left
515
509
  // running (detached children survive an ungraceful exit) before we start fresh.
516
510
  reapOrphanPreviews((m) => info(m));
517
- // Dev servers are ADOPTED rather than reaped when their session is still
518
- // live: a self-update or a same-repo takeover leaves them running on purpose,
519
- // and the successor must supervise them or the row says running while nothing
520
- // owns the process. The activeWorkSessions list is not known yet at startup,
521
- // so the first reconcile does the adopting — see `adoptDevRuns`.
522
511
 
523
512
  // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
524
513
  // so Ctrl+C mid-task never loses local work. Keyed per repo path.
@@ -642,20 +631,6 @@ export async function runFleetDaemon() {
642
631
  // commanded stop.
643
632
  //
644
633
  // A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
645
- // stated limitation rather than an oversight. The takeover asks the holder
646
- // to stand down with SIGTERM, which is byte-for-byte what `systemctl stop`
647
- // sends, so this handler cannot tell "another daemon is about to serve this
648
- // repo in one second" from "this box is going down". Killing is the safe
649
- // reading: the wrong guess in the other direction leaves dev servers
650
- // running with nothing supervising them until a reboot. The cost is that
651
- // re-running `flowviant` in your own repo restarts the app you were
652
- // watching, and the successor's `adoptDevRuns` finds nothing.
653
- //
654
- // The SELF-UPDATE re-exec — the common unattended case — does NOT reach
655
- // teardown, so its runs survive and are adopted, which is the half of
656
- // "consistently running" this delivers today. Distinguishing a takeover
657
- // would need the successor to mark the lock before it signals.
658
- shutdownDevRuns(true);
659
634
  };
660
635
  process.on('SIGINT', () => {
661
636
  console.log('');
@@ -833,11 +808,6 @@ export async function runFleetDaemon() {
833
808
  livePreviewIds,
834
809
  retirePreviews,
835
810
  shutdownPreviews,
836
- processDevRunJobs,
837
- liveDevRunIds,
838
- retireDevRuns,
839
- shutdownDevRuns,
840
- adoptDevRuns,
841
811
  retireWorkSessions,
842
812
  reportWorktrees,
843
813
  shutdownWork,
@@ -1443,7 +1413,7 @@ export async function runFleetDaemon() {
1443
1413
  for (;;) {
1444
1414
  let roster;
1445
1415
  try {
1446
- roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds(), liveDevRunIds());
1416
+ roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
1447
1417
  } catch (e) {
1448
1418
  if (e.auth) {
1449
1419
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -1572,12 +1542,6 @@ export async function runFleetDaemon() {
1572
1542
  // `git worktree remove` would happily pull the directory out from under a
1573
1543
  // running node process, which then serves bytes from open file handles in
1574
1544
  // a directory that no longer exists, with no error anywhere.
1575
- // ADOPT BEFORE RETIRING. A run left alive by a self-update or a takeover
1576
- // must be re-attached before the retire pass can decide it is unowned;
1577
- // doing it the other way round would kill exactly the processes this
1578
- // feature exists to keep.
1579
- adoptDevRuns(roster.activeWorkSessions);
1580
- retireDevRuns(roster.activeWorkSessions);
1581
1545
  // A session another daemon on this credential is serving is NOT a closed
1582
1546
  // tab. Without this the daemon that lost the lease removes the worktree the
1583
1547
  // winner is working in — absence would mean "somebody else won" instead of
@@ -1596,7 +1560,6 @@ export async function runFleetDaemon() {
1596
1560
  // credential are both handed this array, and both opening a tunnel strands
1597
1561
  // a public hostname nobody can settle.
1598
1562
  processPreviewJobs(roster.previewJobs);
1599
- processDevRunJobs(roster.devRunJobs);
1600
1563
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1601
1564
  // Throttled inside, never awaited — a `git status` the human cannot run
1602
1565
  // themselves from a browser, relayed. After retirement so a directory that
@@ -0,0 +1,106 @@
1
+ /**
2
+ * THE PLACE LOCK — several turns may run in one directory; Flowviant's own git
3
+ * may not run beside any of them.
4
+ *
5
+ * Its own module because it is the only concurrency PRIMITIVE in the daemon and
6
+ * the only one whose failure mode is invisible: a lock that grants too much
7
+ * shows up as corrupted work weeks later, and one that grants too little shows
8
+ * up as "ship did nothing". Inside `createWorkManager` it could not be tested
9
+ * at all.
10
+ */
11
+ export function createPlaceLock() {
12
+ /**
13
+ * THE PLACE LOCK — turns run CONCURRENTLY; Flowviant's own git does not.
14
+ *
15
+ * This was `chainFor`, a strict serial queue per place: every turn waited for
16
+ * the one before it, so two tabs in one directory took turns and — because
17
+ * every tab's place defaulted to the checkout — no two tabs in the product
18
+ * ever ran at the same time. The driver's instruction ended it: "i feel like
19
+ * we shouldnt need to manage parallelism on flowviant, the ai clis should
20
+ * intrinsictly factor that in."
21
+ *
22
+ * It is a READERS-WRITER lock now, and the split is exactly where the
23
+ * argument lands. A TURN is a READER: several may run in one place at once,
24
+ * because coordinating two agents editing a tree is their job and they are
25
+ * better at it than a queue is — measured, three agents on disjoint files
26
+ * lost nothing across 120 concurrent writes. A SHIP is a WRITER: it folds and
27
+ * merges with git, in that same directory, and the CLIs cannot coordinate
28
+ * with it because they do not know it exists. That is the one piece of
29
+ * concurrency Flowviant still owns, so it is the one piece it still manages.
30
+ *
31
+ * WRITER PREFERENCE, deliberately: once a ship is waiting, later turns queue
32
+ * behind it. Without that a busy place could starve a ship indefinitely, and
33
+ * "ship it" would appear to do nothing for as long as anybody kept typing.
34
+ *
35
+ * THE SHIP SIDE ALSO FIXES A LATENT BUG. Ship used to take `chainFor` on the
36
+ * SESSION id while turns took it on the PLACE, under a comment claiming a
37
+ * ship "must not run git in this worktree while a turn's CLI is live in it".
38
+ * Those are different keys the moment a place is set — which was every tab —
39
+ * so the two never shared a chain and the guarantee was not held. Both sides
40
+ * key on the place now.
41
+ *
42
+ * The cross-process pid lock (`flowviant-turn.lock`) is UNCHANGED and stays
43
+ * per-place: it is a crash backstop for an orphaned CLI a dead daemon left
44
+ * behind, not the parallelism policy. It is more conservative than this lock
45
+ * — two sessions sharing one place still serialize across processes — which
46
+ * costs nothing in practice, since a place holds one session unless somebody
47
+ * deliberately points a second one at it.
48
+ */
49
+ const placeLocks = new Map(); // placeId -> { readers, writing, waiters: [] }
50
+
51
+ const pumpPlace = (id) => {
52
+ const st = placeLocks.get(id);
53
+ if (!st) return;
54
+ while (st.waiters.length) {
55
+ const next = st.waiters[0];
56
+ if (next.write) {
57
+ // A writer runs only in a quiet place, and blocks everything behind it.
58
+ if (st.readers === 0 && !st.writing) {
59
+ st.waiters.shift();
60
+ st.writing = true;
61
+ next.go();
62
+ }
63
+ break;
64
+ }
65
+ if (st.writing) break;
66
+ st.waiters.shift();
67
+ st.readers += 1;
68
+ next.go();
69
+ }
70
+ // Drop the entry when the place goes quiet, so the map cannot grow for the
71
+ // process lifetime.
72
+ if (!st.writing && st.readers === 0 && st.waiters.length === 0) placeLocks.delete(id);
73
+ };
74
+
75
+ /**
76
+ * Run `fn` holding the place's lock. `write: true` is exclusive.
77
+ *
78
+ * `.finally` rather than a try/catch that swallows: one rejected turn must
79
+ * release the lock but must NOT be turned into a success — the settle
80
+ * contract above is what guarantees a turn is answered, and hiding a throw
81
+ * here would let a turn fail silently while holding nothing.
82
+ */
83
+ const inPlace = async (placeId, write, fn) => {
84
+ let st = placeLocks.get(placeId);
85
+ if (!st) {
86
+ st = { readers: 0, writing: false, waiters: [] };
87
+ placeLocks.set(placeId, st);
88
+ }
89
+ await new Promise((go) => {
90
+ st.waiters.push({ write, go });
91
+ pumpPlace(placeId);
92
+ });
93
+ try {
94
+ return await fn();
95
+ } finally {
96
+ const cur = placeLocks.get(placeId);
97
+ if (cur) {
98
+ if (write) cur.writing = false;
99
+ else cur.readers = Math.max(0, cur.readers - 1);
100
+ pumpPlace(placeId);
101
+ }
102
+ }
103
+ };
104
+
105
+ return { placeLocks, inPlace };
106
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * WHAT YOUR CLAUDE IS ACTUALLY RUNNING — the processes it started, still alive.
3
+ *
4
+ * The Workbench could say what a tab had CHANGED (the diffstat) and what was
5
+ * LISTENING in it (`listeners.mjs`), and nothing about what it was RUNNING. A
6
+ * backgrounded watcher — `rbxtsc -w`, `tsc --watch`, `cargo watch` — holds no
7
+ * socket and touches no file for minutes at a time, so it was invisible from a
8
+ * browser in a way it never is in a terminal, where you can just look.
9
+ *
10
+ * ATTRIBUTED BY PROCESS GROUP, not by working directory, and that choice is the
11
+ * whole design. Two candidates were weighed:
12
+ *
13
+ * · BY CWD, the rule `listeners.mjs` uses. It answers "what is running in
14
+ * this directory", which is the wrong question here — under a shared place
15
+ * it sweeps in a teammate's processes and their command lines, and this
16
+ * product's standing rule is that a teammate's activity is never surfaced.
17
+ * · BY DESCENDANCY, walking ppid. It answers the right question and then
18
+ * loses exactly the processes worth showing: `nohup` and `setsid` reparent
19
+ * to init, so the long-running watcher drops off the chain the moment it
20
+ * becomes long-running.
21
+ *
22
+ * A process GROUP survives reparenting. The daemon spawns each turn's CLI
23
+ * `detached`, which makes it a group leader, and every process the agent starts
24
+ * inherits that pgid however it is backgrounded. So the group IS "started by
25
+ * the AI instructed through Flowviant", precisely, and it keeps being that
26
+ * after the turn ends and the CLI exits — which is when it matters.
27
+ *
28
+ * THE GROUP LEADER IS NEVER REPORTED, and this is not tidiness. The CLI is
29
+ * spawned as `-p <prompt> --append-system-prompt <system>`, so its own argv
30
+ * holds the driver's entire message and the whole operating contract. Reporting
31
+ * it would push several kilobytes of prompt through the wire and into a browser
32
+ * on every sweep. The leader is also not something the agent STARTED — it is
33
+ * the agent. `pid === pgid` identifies it for free.
34
+ *
35
+ * Command lines are SCRUBBED with the project's own secret values before they
36
+ * leave, the same treatment the activity line and the command audit get, and
37
+ * capped. Scrubbing catches materialized env secrets; it cannot catch a token
38
+ * somebody types inline, and nothing here pretends otherwise.
39
+ *
40
+ * Linux (including WSL2) reads /proc. macOS shells out to `ps`. Windows reports
41
+ * NOTHING and says so by returning null — the same three-state rule the rest of
42
+ * this product keeps, where "looked and found none" is `[]` and "cannot look"
43
+ * is not the same answer.
44
+ */
45
+
46
+ import { execFileSync } from 'node:child_process';
47
+ import { readFileSync, readdirSync } from 'node:fs';
48
+ import { platform } from 'node:os';
49
+
50
+ /** A box with more processes than this is not one we walk per sweep. */
51
+ const MAX_PIDS = 4000;
52
+ /** Rows reported per session. A watcher, a dev server and its child is three;
53
+ * twenty is somebody's compose stack and the extra rows say nothing. */
54
+ export const MAX_PROCS = 12;
55
+ /** Longest command line relayed. Long enough for `node x.js --flag value`,
56
+ * short enough that a pathological argv cannot become the report. */
57
+ const MAX_CMD = 200;
58
+
59
+ export function processesSupported() {
60
+ return platform() === 'linux' || platform() === 'darwin';
61
+ }
62
+
63
+ /**
64
+ * `pid (comm) state ppid pgrp …` — comm is arbitrary text and CAN contain a
65
+ * `)`, so the fields are read after the LAST one rather than by splitting the
66
+ * whole line. A process named `foo) bar` is not hypothetical; it is what
67
+ * anything that sets its own title can produce.
68
+ */
69
+ function pgrpOf(pid) {
70
+ let stat;
71
+ try {
72
+ stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
73
+ } catch {
74
+ return null; // gone between readdir and read — ordinary
75
+ }
76
+ const close = stat.lastIndexOf(')');
77
+ if (close < 0) return null;
78
+ const rest = stat.slice(close + 1).trim().split(/\s+/);
79
+ const pgrp = Number(rest[2]);
80
+ return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
81
+ }
82
+
83
+ function cmdlineOf(pid) {
84
+ try {
85
+ const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
86
+ const parts = raw.split('\0').filter(Boolean);
87
+ if (!parts.length) return null; // a kernel thread — no argv at all
88
+ return parts.join(' ');
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function scanLinux(pgids) {
95
+ let pids;
96
+ try {
97
+ pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
98
+ } catch {
99
+ return [];
100
+ }
101
+ if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
102
+
103
+ const out = [];
104
+ for (const raw of pids) {
105
+ const pid = Number(raw);
106
+ const pgrp = pgrpOf(raw);
107
+ if (pgrp === null || !pgids.has(pgrp)) continue;
108
+ // The CLI itself — its argv is the prompt. See the header.
109
+ if (pid === pgrp) continue;
110
+ const cmd = cmdlineOf(raw);
111
+ if (!cmd) continue;
112
+ out.push({ pid, pgid: pgrp, cmd });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function scanDarwin(pgids) {
118
+ let text;
119
+ try {
120
+ text = execFileSync('ps', ['-axo', 'pid=,pgid=,command='], {
121
+ encoding: 'utf8',
122
+ stdio: ['ignore', 'pipe', 'ignore'],
123
+ timeout: 5000,
124
+ maxBuffer: 4 * 1024 * 1024,
125
+ });
126
+ } catch {
127
+ return [];
128
+ }
129
+ const out = [];
130
+ for (const line of text.split('\n')) {
131
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
132
+ if (!m) continue;
133
+ const pid = Number(m[1]);
134
+ const pgid = Number(m[2]);
135
+ if (!pgids.has(pgid) || pid === pgid) continue;
136
+ const cmd = m[3].trim();
137
+ if (cmd) out.push({ pid, pgid, cmd });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Live processes in any of `pgids`.
144
+ *
145
+ * Returns null where the platform cannot answer — never `[]`, which means
146
+ * "looked, found none" and is a different fact a surface renders differently.
147
+ */
148
+ export function processesInGroups(pgids, { scrub } = {}) {
149
+ if (!processesSupported()) return null;
150
+ const want = pgids instanceof Set ? pgids : new Set(pgids ?? []);
151
+ if (want.size === 0) return [];
152
+ const rows = platform() === 'darwin' ? scanDarwin(want) : scanLinux(want);
153
+ // Oldest first: a pid is monotonic, so the long-running watcher you started
154
+ // an hour ago sorts above the `sh -c` spawned two seconds ago. Cutting from
155
+ // the END keeps the durable processes and drops the churn.
156
+ rows.sort((a, b) => a.pid - b.pid);
157
+ return rows.slice(0, MAX_PROCS).map((r) => ({
158
+ pid: r.pid,
159
+ cmd: String(scrub ? scrub(r.cmd) : r.cmd).slice(0, MAX_CMD),
160
+ }));
161
+ }
162
+
163
+ /**
164
+ * Which of `pgids` still has anything alive in it.
165
+ *
166
+ * The caller remembers a pgid per session so a backgrounded process outlives
167
+ * the turn that started it. That set must be pruned or it grows for the life of
168
+ * the daemon — and a recycled pgid would eventually attribute a stranger's
169
+ * process to a tab.
170
+ */
171
+ export function liveGroups(pgids) {
172
+ if (!processesSupported()) return new Set(pgids ?? []);
173
+ const want = pgids instanceof Set ? pgids : new Set(pgids ?? []);
174
+ if (want.size === 0) return new Set();
175
+ const alive = new Set();
176
+ if (platform() === 'darwin') {
177
+ for (const r of scanDarwin(want)) alive.add(r.pgid);
178
+ // A group whose only member is its leader is still alive; scanDarwin drops
179
+ // leaders, so ask the kernel directly for the rest.
180
+ for (const g of want) {
181
+ if (alive.has(g)) continue;
182
+ try {
183
+ process.kill(g, 0);
184
+ alive.add(g);
185
+ } catch {
186
+ /* gone */
187
+ }
188
+ }
189
+ return alive;
190
+ }
191
+ let pids;
192
+ try {
193
+ pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
194
+ } catch {
195
+ return want; // cannot tell — keep what we had rather than forget a live tab
196
+ }
197
+ for (const raw of pids) {
198
+ const pgrp = pgrpOf(raw);
199
+ if (pgrp !== null && want.has(pgrp)) alive.add(pgrp);
200
+ }
201
+ return alive;
202
+ }