flowviant 0.57.0 → 0.59.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 +1 -1
- package/bin/cli.mjs +5 -2
- package/bin/lib/fleet.mjs +60 -0
- package/bin/lib/repoState.mjs +183 -0
- package/bin/lib/update.mjs +94 -6
- package/bin/lib/work.mjs +113 -26
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Prefer an explicit token? Create a machine credential in the app and pass it dir
|
|
|
41
41
|
FLOWVIANT_FLEET=fva_… npx flowviant@latest
|
|
42
42
|
```
|
|
43
43
|
|
|
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
|
|
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 keeps itself current: from **0.58.0** it restarts itself through `npx flowviant@latest` when a new version ships, so npx launches stay up to date the same way a global install does. (Before 0.58.0 that was only true of a global install — under npx the daemon printed a notice and stayed put, which is how machines ended up sitting several releases back.) It only ever restarts when no turn is running. `FLOWVIANT_NO_UPDATE=1` makes it nag-only; `flowviant update` updates now.
|
|
45
45
|
|
|
46
46
|
## Sessions
|
|
47
47
|
|
package/bin/cli.mjs
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
* sat against an empty tool list, which is a worse failure than not starting.
|
|
17
17
|
*
|
|
18
18
|
* Launch with `@latest` so each start pulls the newest published version (bare
|
|
19
|
-
* `npx flowviant` can reuse a stale cache). A running daemon also
|
|
20
|
-
*
|
|
19
|
+
* `npx flowviant` can reuse a stale cache). A running daemon also keeps itself
|
|
20
|
+
* current — at startup and when idle, never mid-turn. Since 0.58.0 that is true
|
|
21
|
+
* under NPX too, by relaunching through `npx flowviant@latest`; before it, the
|
|
22
|
+
* npx branch refused to install and only nagged, so npx launches — the way this
|
|
23
|
+
* README tells everyone to start — silently stayed on whatever was cached
|
|
21
24
|
* (FLOWVIANT_NO_UPDATE=1 makes it nag-only; `flowviant update` updates now).
|
|
22
25
|
* `flowviant stop` stops every daemon on this box — the answer to "is one even
|
|
23
26
|
* running?", which otherwise ends in a pid hunt through `ps`.
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
} from './runtimes.mjs';
|
|
87
87
|
import { createWorkManager } from './work.mjs';
|
|
88
88
|
import { scanLocalSessions } from './localSessions.mjs';
|
|
89
|
+
import { repoState } from './repoState.mjs';
|
|
89
90
|
|
|
90
91
|
async function fetchRoster(
|
|
91
92
|
haveIds,
|
|
@@ -298,6 +299,62 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
|
298
299
|
}
|
|
299
300
|
}
|
|
300
301
|
|
|
302
|
+
/**
|
|
303
|
+
* EVERY BRANCH AND WORKTREE ON THIS MACHINE, pushed on the same beat as
|
|
304
|
+
* presence — the answer to "is my Claude leaving a mess in here?".
|
|
305
|
+
*
|
|
306
|
+
* Same three economies as the presence report above and for the same reasons:
|
|
307
|
+
* scanned at most once a minute, not re-sent while identical (repoState orders
|
|
308
|
+
* deterministically so the string only moves when the repo does), and silent
|
|
309
|
+
* for the rest of the process once an older server 404s.
|
|
310
|
+
*
|
|
311
|
+
* ONE DIFFERENCE, deliberate: there is no re-send heartbeat window. Presence
|
|
312
|
+
* expires in the UI because a session that ENDED must stop reading as live;
|
|
313
|
+
* a branch list is not presence — a branch that existed a minute ago still
|
|
314
|
+
* exists — so re-posting an unchanged list would be a write per machine per
|
|
315
|
+
* five minutes to say nothing at all.
|
|
316
|
+
*/
|
|
317
|
+
const REPO_STATE_URL = FLEET_URL.replace(/\/agents\/?$/, '/repo-state');
|
|
318
|
+
const REPO_STATE_SCAN_MS = 60_000;
|
|
319
|
+
let repoStateUnsupported = false; // the server 404'd — quiet until restart
|
|
320
|
+
let repoStateScanAt = 0;
|
|
321
|
+
let repoStateSent = null; // last payload the server ACCEPTED, stringified
|
|
322
|
+
async function maybeReportRepoState({ repoRoot, baseRef }) {
|
|
323
|
+
if (repoStateUnsupported) return;
|
|
324
|
+
if (Date.now() - repoStateScanAt < REPO_STATE_SCAN_MS) return;
|
|
325
|
+
repoStateScanAt = Date.now();
|
|
326
|
+
let payload;
|
|
327
|
+
try {
|
|
328
|
+
const state = repoState(repoRoot, baseRef);
|
|
329
|
+
if (!state) return; // not readable — say nothing rather than say "none"
|
|
330
|
+
payload = JSON.stringify(state);
|
|
331
|
+
} catch {
|
|
332
|
+
return; // a readout must never throw into the poll loop
|
|
333
|
+
}
|
|
334
|
+
if (payload === repoStateSent) return;
|
|
335
|
+
try {
|
|
336
|
+
const res = await fetch(REPO_STATE_URL, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: {
|
|
339
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
340
|
+
'User-Agent': USER_AGENT,
|
|
341
|
+
'Content-Type': 'application/json',
|
|
342
|
+
},
|
|
343
|
+
signal: AbortSignal.timeout(15_000),
|
|
344
|
+
body: payload,
|
|
345
|
+
});
|
|
346
|
+
if (res.status === 404) {
|
|
347
|
+
repoStateUnsupported = true; // older server — nothing was replaced here
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Only an ACCEPTED report counts: anything else forgets it so the next
|
|
351
|
+
// pass retries rather than dedup-suppressing a report nobody received.
|
|
352
|
+
repoStateSent = res.ok ? payload : null;
|
|
353
|
+
} catch {
|
|
354
|
+
repoStateSent = null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
301
358
|
/**
|
|
302
359
|
* A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
|
|
303
360
|
*
|
|
@@ -1544,6 +1601,9 @@ export async function runFleetDaemon() {
|
|
|
1544
1601
|
// the daemon's own worktrees are carved out (a session the daemon spawned
|
|
1545
1602
|
// is already a tab, not something to offer adopting).
|
|
1546
1603
|
void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
|
|
1604
|
+
// …and the repo itself: every worktree and every branch, ours and not.
|
|
1605
|
+
// Never awaited, throttled inside, and silent on an older server.
|
|
1606
|
+
void maybeReportRepoState({ repoRoot, baseRef });
|
|
1547
1607
|
// WHAT `/` CAN OFFER, on a machine no turn has taught yet. One-shot and
|
|
1548
1608
|
// self-cancelling (it returns immediately if a turn has already reported),
|
|
1549
1609
|
// never awaited, and it lands in the cache that the NEXT poll reads — so
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY WORKTREE AND EVERY BRANCH ON THIS MACHINE — including the ones
|
|
3
|
+
* Flowviant did not make.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS, in the user's words: "can we show all branches or worktrees
|
|
6
|
+
* so we know if claude is polluting the branches or worktrees or not". The
|
|
7
|
+
* Workbench already reports the branch a TAB is standing on, but only for
|
|
8
|
+
* sessions the server knows about — so a branch your Claude cut mid-turn, a
|
|
9
|
+
* worktree left behind by a crash, or anything you made yourself at the
|
|
10
|
+
* keyboard was invisible from the browser. That is the same gap the Changes
|
|
11
|
+
* block was built to close, one level up: a browser has no `git branch` to run,
|
|
12
|
+
* so the machine runs it.
|
|
13
|
+
*
|
|
14
|
+
* IT IS A RELAY, NOT A JUDGEMENT. Nothing here decides what "pollution" is —
|
|
15
|
+
* it reports what git says and marks which refs Flowviant itself created
|
|
16
|
+
* (`session/<id>`), because that is a FACT about who made them and it is the
|
|
17
|
+
* distinction the question is actually asking about. No cleanup, no warnings,
|
|
18
|
+
* no "you have too many branches": the surface counts what is there, and a
|
|
19
|
+
* person decides.
|
|
20
|
+
*
|
|
21
|
+
* BOUNDED AT THE MACHINE, like every other report in this daemon. A repo with
|
|
22
|
+
* eight hundred branches must not put eight hundred rows on the wire every
|
|
23
|
+
* minute; the newest are kept, the rest are counted, and the caller says so
|
|
24
|
+
* rather than letting a short list read as the whole repo.
|
|
25
|
+
*
|
|
26
|
+
* DETERMINISTIC ORDER, for the same reason `recordSkills` sorts: the report is
|
|
27
|
+
* dedupe-compared against the last one that was accepted, and an unstable order
|
|
28
|
+
* would post a "change" every single minute forever.
|
|
29
|
+
*
|
|
30
|
+
* NOTHING HERE THROWS. It runs inside the poll loop's best-effort tail, and a
|
|
31
|
+
* repo mid-rebase or an unborn HEAD is a field to omit, not an error to raise.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
import { listenersIn, listenersSupported } from './listeners.mjs';
|
|
36
|
+
|
|
37
|
+
/** Same cap the session diffstat uses: enough to see the shape, small enough
|
|
38
|
+
* that one machine cannot flood a row. */
|
|
39
|
+
const MAX_BRANCHES = 60;
|
|
40
|
+
const MAX_WORKTREES = 40;
|
|
41
|
+
|
|
42
|
+
function git(args, cwd) {
|
|
43
|
+
return execFileSync('git', args, {
|
|
44
|
+
cwd,
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
47
|
+
timeout: 10_000,
|
|
48
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
49
|
+
}).trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A ref Flowviant itself cut for a tab. The ONLY thing that makes a branch
|
|
53
|
+
* "ours", and the distinction the whole report exists to draw. */
|
|
54
|
+
export function isSessionBranch(name) {
|
|
55
|
+
return /^session\//.test(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `git worktree list --porcelain` → rows. The porcelain form is parsed rather
|
|
60
|
+
* than the human one because the human one aligns columns with spaces and a
|
|
61
|
+
* path containing a space silently splits into the wrong fields.
|
|
62
|
+
*/
|
|
63
|
+
function readWorktrees(repoRoot) {
|
|
64
|
+
let out;
|
|
65
|
+
try {
|
|
66
|
+
out = git(['worktree', 'list', '--porcelain'], repoRoot);
|
|
67
|
+
} catch {
|
|
68
|
+
return null; // not a git repo, or git is unhappy — say nothing
|
|
69
|
+
}
|
|
70
|
+
const rows = [];
|
|
71
|
+
let cur = null;
|
|
72
|
+
for (const line of out.split('\n')) {
|
|
73
|
+
if (line.startsWith('worktree ')) {
|
|
74
|
+
if (cur) rows.push(cur);
|
|
75
|
+
cur = { path: line.slice(9), branch: null, detached: false, locked: false, prunable: false };
|
|
76
|
+
} else if (!cur) {
|
|
77
|
+
continue;
|
|
78
|
+
} else if (line.startsWith('branch ')) {
|
|
79
|
+
cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
|
|
80
|
+
} else if (line === 'detached') {
|
|
81
|
+
cur.detached = true;
|
|
82
|
+
} else if (line.startsWith('locked')) {
|
|
83
|
+
cur.locked = true;
|
|
84
|
+
} else if (line.startsWith('prunable')) {
|
|
85
|
+
// A directory git still lists but that is gone from disk — exactly the
|
|
86
|
+
// "left behind" case somebody looking for mess wants to see.
|
|
87
|
+
cur.prunable = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (cur) rows.push(cur);
|
|
91
|
+
return rows;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Local branches with their distance from base.
|
|
96
|
+
*
|
|
97
|
+
* `for-each-ref` does the whole thing in ONE process — a `rev-list` per branch
|
|
98
|
+
* would be sixty spawns a minute on a busy repo. `%(ahead-behind:<ref>)` needs
|
|
99
|
+
* git 2.41+; when it is missing the counts are simply absent and the surface
|
|
100
|
+
* shows names without numbers, which is still the answer to "what is here".
|
|
101
|
+
*/
|
|
102
|
+
function readBranches(repoRoot, baseRef) {
|
|
103
|
+
const fmt = '%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:' + baseRef + ')';
|
|
104
|
+
let out;
|
|
105
|
+
try {
|
|
106
|
+
out = git(['for-each-ref', '--sort=-committerdate', `--format=${fmt}`, 'refs/heads'], repoRoot);
|
|
107
|
+
} catch {
|
|
108
|
+
// No ahead-behind on this git. Names and dates still answer most of it.
|
|
109
|
+
try {
|
|
110
|
+
out = git(
|
|
111
|
+
['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)%09%(committerdate:unix)', 'refs/heads'],
|
|
112
|
+
repoRoot
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const rows = [];
|
|
119
|
+
for (const line of out.split('\n')) {
|
|
120
|
+
if (!line.trim()) continue;
|
|
121
|
+
const [name, when, ab] = line.split('\t');
|
|
122
|
+
if (!name) continue;
|
|
123
|
+
const row = { name, at: Number(when) || 0, session: isSessionBranch(name) };
|
|
124
|
+
// `ahead-behind` prints "N M" — ahead of base, behind base, in that order.
|
|
125
|
+
if (ab) {
|
|
126
|
+
const [a, b] = ab.trim().split(/\s+/).map((n) => parseInt(n, 10));
|
|
127
|
+
if (Number.isFinite(a)) row.ahead = a;
|
|
128
|
+
if (Number.isFinite(b)) row.behind = b;
|
|
129
|
+
}
|
|
130
|
+
rows.push(row);
|
|
131
|
+
}
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The whole picture, or null if this is not a repo we can read.
|
|
137
|
+
*
|
|
138
|
+
* `truncated` is not decoration: a list silently cut at sixty reads as the
|
|
139
|
+
* whole repo, and the one question this report exists to answer is "how much is
|
|
140
|
+
* in here". Same rule the session diffstat's own `truncated` keeps.
|
|
141
|
+
*/
|
|
142
|
+
export function repoState(repoRoot, baseRef) {
|
|
143
|
+
const worktrees = readWorktrees(repoRoot);
|
|
144
|
+
const branches = readBranches(repoRoot, baseRef);
|
|
145
|
+
if (!worktrees && !branches) return null;
|
|
146
|
+
/**
|
|
147
|
+
* WHAT IS LISTENING IN THE CHECKOUT ITSELF.
|
|
148
|
+
*
|
|
149
|
+
* `listenersIn` has always taken any directory, and had only ever been asked
|
|
150
|
+
* about SESSION WORKTREES — so somebody running `npm run dev` in their normal
|
|
151
|
+
* checkout, which is what "just testing or playing around in dev" actually
|
|
152
|
+
* looks like, was invisible to every surface in the product. The measurement
|
|
153
|
+
* was there; nobody was pointing it at the repo.
|
|
154
|
+
*
|
|
155
|
+
* THE ATTRIBUTION RULE IS UNCHANGED, and it is the reason this widens to the
|
|
156
|
+
* repo root and no further: a port is attributed by the CWD OF THE PROCESS
|
|
157
|
+
* HOLDING THE SOCKET, so this reports servers running inside THIS PROJECT'S
|
|
158
|
+
* checkout and nothing else. Postgres on 5432 has its own cwd and does not
|
|
159
|
+
* appear here — which is the whole point, and why "just show every port on
|
|
160
|
+
* the box" is not what this does.
|
|
161
|
+
*
|
|
162
|
+
* Reported, not offered: this is a readout of what is up. Sharing one is a
|
|
163
|
+
* separate act with its own gates (see previewJobs).
|
|
164
|
+
*/
|
|
165
|
+
const listening = listenersIn(repoRoot);
|
|
166
|
+
const wt = worktrees ?? [];
|
|
167
|
+
const br = branches ?? [];
|
|
168
|
+
return {
|
|
169
|
+
base: baseRef,
|
|
170
|
+
worktrees: wt.slice(0, MAX_WORKTREES),
|
|
171
|
+
worktreesTotal: wt.length,
|
|
172
|
+
// Newest first (for-each-ref already sorted), so a truncated list is the
|
|
173
|
+
// part somebody is actually working in.
|
|
174
|
+
branches: br.slice(0, MAX_BRANCHES),
|
|
175
|
+
branchesTotal: br.length,
|
|
176
|
+
sessionBranches: br.filter((b) => b.session).length,
|
|
177
|
+
listening,
|
|
178
|
+
// "Nothing is listening" and "this machine cannot look" (Windows, a failed
|
|
179
|
+
// scan) are the same empty array without this — and the second must never
|
|
180
|
+
// render as the first. Same field, same reason, as the session report.
|
|
181
|
+
listeningSupported: listenersSupported(),
|
|
182
|
+
};
|
|
183
|
+
}
|
package/bin/lib/update.mjs
CHANGED
|
@@ -6,8 +6,27 @@
|
|
|
6
6
|
* version ships. The server reports {latest, min} on every roster poll; the
|
|
7
7
|
* daemon compares its own VERSION and, at a SAFE boundary (startup or idle —
|
|
8
8
|
* never mid-task), self-updates + re-execs. Below `min` it updates regardless
|
|
9
|
-
* (older protocol is known-broken); otherwise it honors AUTO_UPDATE.
|
|
10
|
-
*
|
|
9
|
+
* (older protocol is known-broken); otherwise it honors AUTO_UPDATE.
|
|
10
|
+
*
|
|
11
|
+
* NPX UPDATES TOO, SINCE 0.58.0 — and until then it never did, which is the
|
|
12
|
+
* whole reason this comment is longer than it was. `AUTO_UPDATE` is ON by
|
|
13
|
+
* default (`FLOWVIANT_NO_UPDATE !== '1'`), so the flag was never what held
|
|
14
|
+
* machines back: the npx branch was. It refused to install — correctly, since
|
|
15
|
+
* `npm i -g` lands where the running process will never look — and then only
|
|
16
|
+
* NAGGED A CONSOLE NOBODY READS. The README meanwhile told everyone to launch
|
|
17
|
+
* with `npx flowviant@latest` and promised that "a running daemon also
|
|
18
|
+
* self-updates", which was false for exactly the audience it was written for.
|
|
19
|
+
* The result, measured across one account's five machines on 2026-08-25:
|
|
20
|
+
* 0.48.3, 0.51.1, 0.51.2, 0.54.2 and 0.56.0, each frozen at whatever npx had
|
|
21
|
+
* cached the day it launched. The clincher was the 0.56.0 one — it polled that
|
|
22
|
+
* morning, saw LATEST 0.56.1, had AUTO_UPDATE on, and still did not move.
|
|
23
|
+
*
|
|
24
|
+
* The fix is that under npx the RE-EXEC IS THE UPDATE. There is nothing to
|
|
25
|
+
* install: relaunching through `npx -y flowviant@latest` makes npx resolve
|
|
26
|
+
* `latest` against the registry and fetch it (measured — it pulled 0.57.0 into
|
|
27
|
+
* a new cache entry beside the stale 0.54.2). So the npx path stops nagging and
|
|
28
|
+
* starts restarting itself, honouring AUTO_UPDATE and the same idle gate as the
|
|
29
|
+
* global path.
|
|
11
30
|
*/
|
|
12
31
|
|
|
13
32
|
import { execFileSync, spawn } from 'node:child_process';
|
|
@@ -51,13 +70,22 @@ export function runningViaNpx() {
|
|
|
51
70
|
* this process alive only as a thin proxy waiting on the child, so the user's
|
|
52
71
|
* shell stays attached to one foreground process.
|
|
53
72
|
*/
|
|
54
|
-
function reexec(teardown) {
|
|
73
|
+
function reexec(teardown, { viaNpx = false, target = null } = {}) {
|
|
55
74
|
try {
|
|
56
75
|
teardown?.();
|
|
57
76
|
} catch {
|
|
58
77
|
/* best-effort */
|
|
59
78
|
}
|
|
60
|
-
|
|
79
|
+
// UNDER NPX THE RE-EXEC IS THE UPDATE, so it must not re-run our own argv[1]:
|
|
80
|
+
// that path points into the npx cache entry holding the version we are trying
|
|
81
|
+
// to leave, and re-running it would reload the stale copy forever. Going back
|
|
82
|
+
// through `npx -y flowviant@latest` is what makes npx resolve `latest` against
|
|
83
|
+
// the registry and fetch the new one. `-y` because a restart must never stop
|
|
84
|
+
// on npx's install prompt — the same rule FLOWVIANT_REEXEC keeps below.
|
|
85
|
+
const [cmd, args] = viaNpx
|
|
86
|
+
? ['npx', ['-y', 'flowviant@latest', ...process.argv.slice(2)]]
|
|
87
|
+
: [process.execPath, process.argv.slice(1)];
|
|
88
|
+
const child = spawn(cmd, args, {
|
|
61
89
|
stdio: 'inherit',
|
|
62
90
|
// MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
|
|
63
91
|
// stdio is inherited, so the child sees two TTYs and believes a human is
|
|
@@ -69,11 +97,35 @@ function reexec(teardown) {
|
|
|
69
97
|
// is not a widening — the daemon serves exactly the credential it was
|
|
70
98
|
// already serving one second ago, and the question gets asked the next
|
|
71
99
|
// time a human starts it by hand.
|
|
72
|
-
env: {
|
|
100
|
+
env: {
|
|
101
|
+
...process.env,
|
|
102
|
+
FLOWVIANT_REEXEC: '1',
|
|
103
|
+
// WHAT WE RESTARTED IN ORDER TO BECOME. The successor compares its own
|
|
104
|
+
// VERSION against this: if it came back still short, the update did not
|
|
105
|
+
// take (a registry serving a stale `latest`, an npx cache that refused to
|
|
106
|
+
// move, a half-written global install) and it must NAG rather than
|
|
107
|
+
// restart again. Without this the npx path is a re-exec loop — and unlike
|
|
108
|
+
// the global path there is no install step whose failure would throw and
|
|
109
|
+
// stop it.
|
|
110
|
+
...(target ? { FLOWVIANT_UPDATE_TARGET: target } : {}),
|
|
111
|
+
},
|
|
73
112
|
});
|
|
74
113
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
75
114
|
}
|
|
76
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Did a restart that was supposed to land us on `target` fail to?
|
|
118
|
+
*
|
|
119
|
+
* True only when a PREVIOUS process handed us a target we are still below. A
|
|
120
|
+
* plain start has no marker, and a successful update is at or above it — so
|
|
121
|
+
* this is false in every case except the one it exists for.
|
|
122
|
+
*/
|
|
123
|
+
export function updateRestartFailed(target) {
|
|
124
|
+
const attempted = process.env.FLOWVIANT_UPDATE_TARGET;
|
|
125
|
+
if (!attempted) return false;
|
|
126
|
+
return cmpVersion(VERSION, attempted) < 0 && cmpVersion(target ?? attempted, attempted) <= 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
77
129
|
/** Install @latest globally. Throws on failure (EACCES without sudo, offline…). */
|
|
78
130
|
function installLatest() {
|
|
79
131
|
execFileSync('npm', ['install', '-g', 'flowviant@latest'], { stdio: 'inherit' });
|
|
@@ -122,6 +174,42 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
122
174
|
const npx = runningViaNpx();
|
|
123
175
|
const wantInstall = belowMin || autoUpdate;
|
|
124
176
|
|
|
177
|
+
// A RESTART THAT DID NOT TAKE must not be tried again on the next poll. The
|
|
178
|
+
// global path is self-limiting (a failed `npm i -g` throws and lands in the
|
|
179
|
+
// 15-minute backoff), but the npx path has no install step to fail — it just
|
|
180
|
+
// relaunches, so a registry or cache that keeps serving the old version would
|
|
181
|
+
// loop this process forever, tearing down live turns every ten seconds.
|
|
182
|
+
if (updateRestartFailed(target)) {
|
|
183
|
+
if (naggedFor !== target) {
|
|
184
|
+
naggedFor = target;
|
|
185
|
+
warn(
|
|
186
|
+
`restarted to pick up ${target} but came back as ${cur} — staying put. Update by hand: ${
|
|
187
|
+
npx ? 'relaunch with `npx flowviant@latest`' : 'npm i -g flowviant@latest'
|
|
188
|
+
}.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// UNDER NPX THERE IS NOTHING TO INSTALL — the relaunch IS the update, because
|
|
195
|
+
// `npx -y flowviant@latest` resolves `latest` against the registry. Same two
|
|
196
|
+
// gates as the global path: the operator's AUTO_UPDATE choice, and an idle
|
|
197
|
+
// machine, because a re-exec mid-turn SIGTERMs the tab's CLI. No npm-view
|
|
198
|
+
// probe here: npx is about to ask the registry itself, and the loop guard
|
|
199
|
+
// above is what a stale answer runs into.
|
|
200
|
+
if (wantInstall && npx) {
|
|
201
|
+
if (!safeToUpdate) {
|
|
202
|
+
if (naggedFor !== target) {
|
|
203
|
+
naggedFor = target;
|
|
204
|
+
note(`flowviant ${cur} → ${target} available — restarting once no turn is running.`);
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
note(`flowviant ${cur} → ${target}: restarting through npx to pick it up…`);
|
|
209
|
+
reexec(teardown, { viaNpx: true, target });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
125
213
|
if (wantInstall && !npx) {
|
|
126
214
|
if (!safeToUpdate) {
|
|
127
215
|
// Outdated but a turn is running — wait until the machine is quiet. Nag
|
|
@@ -167,7 +255,7 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
167
255
|
note(`flowviant ${cur} → ${published}: self-updating…`);
|
|
168
256
|
installLatest();
|
|
169
257
|
ok('updated — restarting into the new version.');
|
|
170
|
-
reexec(teardown);
|
|
258
|
+
reexec(teardown, { target: published });
|
|
171
259
|
return true;
|
|
172
260
|
} catch (e) {
|
|
173
261
|
lastInstallFailAt = Date.now();
|
package/bin/lib/work.mjs
CHANGED
|
@@ -51,6 +51,10 @@ import {
|
|
|
51
51
|
} from './prompts.mjs';
|
|
52
52
|
import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
53
53
|
import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
|
|
54
|
+
|
|
55
|
+
/** The place id meaning "the checkout", not a worktree. Must match the
|
|
56
|
+
* server's REPO_PLACE — it is a wire value, not a local convention. */
|
|
57
|
+
const REPO_PLACE = 'repo';
|
|
54
58
|
import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
|
|
55
59
|
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
56
60
|
import { homedir } from 'node:os';
|
|
@@ -119,8 +123,22 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
119
123
|
* while that session's turn has a live CLI in it.
|
|
120
124
|
*/
|
|
121
125
|
const workChains = new Map(); // sessionId -> settled-safe tail promise
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Serialize work by PLACE — the directory — not by session.
|
|
128
|
+
*
|
|
129
|
+
* It was keyed by session id, which was the same thing right up until a place
|
|
130
|
+
* could be shared: two sessions pointed at one worktree had independent
|
|
131
|
+
* chains, so their turns would run at the same time in the same directory and
|
|
132
|
+
* edit each other's files mid-edit. Keying on the place is what makes "two
|
|
133
|
+
* tabs in one repo" behave the way two terminal tabs in one repo behave —
|
|
134
|
+
* they take turns.
|
|
135
|
+
*
|
|
136
|
+
* The cross-PROCESS half was already right and needed no change: the turn
|
|
137
|
+
* lock is a file inside the worktree (`flowviant-turn.lock`), so two sessions
|
|
138
|
+
* sharing a place already share the lock by construction.
|
|
139
|
+
*/
|
|
140
|
+
const chainFor = (placeId, fn) => {
|
|
141
|
+
const prev = workChains.get(placeId) ?? Promise.resolve();
|
|
124
142
|
// `.then(fn, fn)`, like withWikiLock: one rejected link must never wedge
|
|
125
143
|
// every later turn of the tab.
|
|
126
144
|
const run = prev.then(fn, fn);
|
|
@@ -128,11 +146,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
128
146
|
() => {},
|
|
129
147
|
() => {}
|
|
130
148
|
);
|
|
131
|
-
workChains.set(
|
|
149
|
+
workChains.set(placeId, stored);
|
|
132
150
|
// Release the entry when the chain drains, so the map cannot grow for the
|
|
133
151
|
// process lifetime and `workChains.has()` means "busy right now".
|
|
134
152
|
stored.then(() => {
|
|
135
|
-
if (workChains.get(
|
|
153
|
+
if (workChains.get(placeId) === stored) workChains.delete(placeId);
|
|
136
154
|
});
|
|
137
155
|
return run;
|
|
138
156
|
};
|
|
@@ -1159,8 +1177,34 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1159
1177
|
* is the point. If the directory was retired but the branch survives, the
|
|
1160
1178
|
* worktree re-attaches to the branch and the committed work is still there.
|
|
1161
1179
|
*/
|
|
1162
|
-
|
|
1163
|
-
|
|
1180
|
+
/**
|
|
1181
|
+
* WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
|
|
1182
|
+
*
|
|
1183
|
+
* A session used to BE a worktree: one tab, one directory, cut at birth and
|
|
1184
|
+
* retired at close. That binding was never an isolation guarantee — a turn
|
|
1185
|
+
* runs with permissions skipped, so the worktree is a starting directory and
|
|
1186
|
+
* not a fence, and any agent could always `cd` into another one. The product
|
|
1187
|
+
* was asserting an invariant it did not have.
|
|
1188
|
+
*
|
|
1189
|
+
* So a session now REFERENCES a place rather than being one. Many sessions
|
|
1190
|
+
* may name the same place; a session may name the repo checkout itself; and
|
|
1191
|
+
* `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
|
|
1192
|
+
* which is why an absent `place` behaves exactly as every existing tab does.
|
|
1193
|
+
*
|
|
1194
|
+
* `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
|
|
1195
|
+
* the checkout the daemon already serves — never created, never retired,
|
|
1196
|
+
* because it is not ours to remove. The value reaching here is a server-side
|
|
1197
|
+
* enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
|
|
1198
|
+
* same way adoption resolves a cwd, and for the same reason.
|
|
1199
|
+
*/
|
|
1200
|
+
const placeWtFor = (placeId, baseAt) => {
|
|
1201
|
+
if (placeId === REPO_PLACE) {
|
|
1202
|
+
// The checkout. `fresh: false` on purpose — nothing was opened, so no
|
|
1203
|
+
// caller may treat this as a newly-cut branch.
|
|
1204
|
+
return { wt: repoRoot, fresh: false };
|
|
1205
|
+
}
|
|
1206
|
+
if (!isSafePathSegment(placeId)) return null;
|
|
1207
|
+
const sessionId = placeId;
|
|
1164
1208
|
const wt = join(baseDir, 'sessions', sessionId);
|
|
1165
1209
|
const fresh = !existsSync(wt);
|
|
1166
1210
|
if (fresh) {
|
|
@@ -1538,6 +1582,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1538
1582
|
}
|
|
1539
1583
|
};
|
|
1540
1584
|
|
|
1585
|
+
/** The pre-places name, kept so every existing caller reads unchanged: a
|
|
1586
|
+
* session's own id IS its default place. */
|
|
1587
|
+
const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
|
|
1588
|
+
|
|
1541
1589
|
const retireWorkSessions = (activeIds, heldElsewhere) => {
|
|
1542
1590
|
if (!Array.isArray(activeIds)) return;
|
|
1543
1591
|
// Sessions ANOTHER daemon on this credential is serving. They are absent
|
|
@@ -1596,7 +1644,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1596
1644
|
// run it again while the report is merely undelivered.
|
|
1597
1645
|
if (pendingWorkReports.has(job.id)) continue;
|
|
1598
1646
|
workAnswering.add(job.id);
|
|
1599
|
-
|
|
1647
|
+
// Serialized by PLACE: two tabs sharing a worktree take turns in it
|
|
1648
|
+
// rather than editing the same files at the same time.
|
|
1649
|
+
const place = job.place || job.sessionId;
|
|
1650
|
+
chainFor(place, async () => {
|
|
1600
1651
|
try {
|
|
1601
1652
|
const tries = workAttempts.get(job.id) ?? 0;
|
|
1602
1653
|
if (tries >= MAX_WORK_TRIES) {
|
|
@@ -1711,7 +1762,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1711
1762
|
}
|
|
1712
1763
|
// Based at the SOURCE's HEAD when adopting — the resumed
|
|
1713
1764
|
// conversation was had against those commits, not the project base.
|
|
1714
|
-
|
|
1765
|
+
// The PLACE this tab works in — its own worktree unless the server
|
|
1766
|
+
// named another. An older server sends no `place` and the default is
|
|
1767
|
+
// the session's own id, which is what every tab has always done.
|
|
1768
|
+
const dir = placeWtFor(place, adopting ? srcHead : undefined);
|
|
1715
1769
|
if (!dir) {
|
|
1716
1770
|
await settleWorkTurn(job.id, {
|
|
1717
1771
|
ok: false,
|
|
@@ -2193,8 +2247,53 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2193
2247
|
return;
|
|
2194
2248
|
}
|
|
2195
2249
|
note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
|
|
2196
|
-
|
|
2250
|
+
/**
|
|
2251
|
+
* WHAT IS ACTUALLY CHECKED OUT — not what we named it at birth.
|
|
2252
|
+
*
|
|
2253
|
+
* Ship used to compute `session/<id>` and then REFUSE if HEAD had
|
|
2254
|
+
* moved: "ask it to return to its session branch, then ship again".
|
|
2255
|
+
* That refusal is the thing this product says it never does — it had
|
|
2256
|
+
* no reason of its own beyond bookkeeping, and in a terminal
|
|
2257
|
+
* `git checkout -b` breaks nothing, which is the whole standard this
|
|
2258
|
+
* surface is held to.
|
|
2259
|
+
*
|
|
2260
|
+
* The bug it was written for was real and is fixed properly here
|
|
2261
|
+
* rather than frozen out: ship once merged the branch NAME while
|
|
2262
|
+
* logging HEAD, so receipts named commits that never landed on main.
|
|
2263
|
+
* That was TWO SOURCES OF TRUTH, not branch switching. There is one
|
|
2264
|
+
* now, and it is the worktree's own HEAD.
|
|
2265
|
+
*
|
|
2266
|
+
* Resolved BEFORE the idempotency check below, and that ordering is
|
|
2267
|
+
* load-bearing: `session/<id>` can still exist, stale and already an
|
|
2268
|
+
* ancestor of base, while the real work sits on the branch that was
|
|
2269
|
+
* checked out afterwards. Asking the old name first would answer
|
|
2270
|
+
* "already merged — nothing new to ship" over unshipped commits.
|
|
2271
|
+
*
|
|
2272
|
+
* A directory that is gone (a retired or closed tab) cannot be asked,
|
|
2273
|
+
* so the recorded name is the fallback — the one case where the name
|
|
2274
|
+
* is the only thing there is.
|
|
2275
|
+
*/
|
|
2197
2276
|
const wt = join(baseDir, 'sessions', job.sessionId);
|
|
2277
|
+
let branch = `session/${job.sessionId}`;
|
|
2278
|
+
let detached = false;
|
|
2279
|
+
if (existsSync(wt)) {
|
|
2280
|
+
try {
|
|
2281
|
+
branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
|
|
2282
|
+
} catch {
|
|
2283
|
+
detached = true;
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
// THE ONE REFUSAL LEFT, and it is not policy. A detached HEAD names
|
|
2287
|
+
// no branch, so there is nothing to merge and nothing to record —
|
|
2288
|
+
// that is an ambiguity in git, not a rule of ours.
|
|
2289
|
+
if (detached) {
|
|
2290
|
+
await done({
|
|
2291
|
+
ok: false,
|
|
2292
|
+
error:
|
|
2293
|
+
'this session is on a detached HEAD — no branch to ship. Ask it to check out a branch, then ship again',
|
|
2294
|
+
});
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2198
2297
|
let branchExists = true;
|
|
2199
2298
|
try {
|
|
2200
2299
|
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot);
|
|
@@ -2388,23 +2487,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2388
2487
|
});
|
|
2389
2488
|
return;
|
|
2390
2489
|
}
|
|
2391
|
-
//
|
|
2392
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
|
|
2396
|
-
try {
|
|
2397
|
-
head = git(['symbolic-ref', '--short', 'HEAD'], dir.wt);
|
|
2398
|
-
} catch {
|
|
2399
|
-
/* detached */
|
|
2400
|
-
}
|
|
2401
|
-
if (head !== branch) {
|
|
2402
|
-
await done({
|
|
2403
|
-
ok: false,
|
|
2404
|
-
error: `the session is on ${head ? `branch '${head}'` : 'a detached HEAD'}, not its own '${branch}' — ask it to return to its session branch, then ship again`,
|
|
2405
|
-
});
|
|
2406
|
-
return;
|
|
2407
|
-
}
|
|
2490
|
+
// NO "return to your session branch" GUARD. `branch` was read from
|
|
2491
|
+
// this worktree's HEAD above, so the fold, the tip and the receipts
|
|
2492
|
+
// below all name the same thing by construction — which is what the
|
|
2493
|
+
// old guard was really protecting, and it protected it by refusing
|
|
2494
|
+
// instead of by measuring.
|
|
2408
2495
|
// Fold main into the branch FIRST: conflicts land here, in the
|
|
2409
2496
|
// session's own worktree, where the next turn can resolve them.
|
|
2410
2497
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.59.0",
|
|
4
|
+
"description": "Run your own coding CLIs as build agents for Flowviant — 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": {
|
|
7
7
|
"flowviant": "bin/cli.mjs"
|