flowviant 0.58.0 → 0.60.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/bin/lib/fleet.mjs +60 -0
- package/bin/lib/repoState.mjs +183 -0
- package/bin/lib/work.mjs +154 -33
- package/package.json +1 -1
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/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
|
};
|
|
@@ -319,6 +337,32 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
319
337
|
* network, and a teammate's push being visible within three minutes is the
|
|
320
338
|
* same promise the rest of the product makes. */
|
|
321
339
|
const WORKTREE_FETCH_MS = 3 * 60_000;
|
|
340
|
+
/**
|
|
341
|
+
* WHERE EACH SESSION WORKS, learned from the turns we are handed.
|
|
342
|
+
*
|
|
343
|
+
* Every other beat — the worktree sweep, ship, the preview re-check — has to
|
|
344
|
+
* ask the SAME directory the turn ran in, and only the turn job carries
|
|
345
|
+
* `place`. Caching it here is what keeps them agreeing without a second
|
|
346
|
+
* server→daemon field: a session absent from this map has never run a turn,
|
|
347
|
+
* and its own id is the right answer for that case anyway (it is the default
|
|
348
|
+
* place, and a session with no turn has no worktree either).
|
|
349
|
+
*
|
|
350
|
+
* A tab standing in the CHECKOUT is the case this exists for: its directory
|
|
351
|
+
* is not `sessions/<id>` and never will be, so a sweep that assumed the
|
|
352
|
+
* default would measure a directory that does not exist and report nothing —
|
|
353
|
+
* which is exactly why "I still do not see a preview URL" was true of a tab
|
|
354
|
+
* opened in the checkout.
|
|
355
|
+
*/
|
|
356
|
+
const sessionPlaces = new Map();
|
|
357
|
+
const placeOf = (sessionId) => sessionPlaces.get(sessionId) ?? sessionId;
|
|
358
|
+
/** The DIRECTORY a session works in. Every path that used to build
|
|
359
|
+
* `sessions/<id>` by hand goes through here, or a tab in the checkout gets
|
|
360
|
+
* measured against a directory that does not exist. */
|
|
361
|
+
const placeDir = (sessionId) => {
|
|
362
|
+
const place = placeOf(sessionId);
|
|
363
|
+
return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
|
|
364
|
+
};
|
|
365
|
+
|
|
322
366
|
let lastWorktreeSweep = 0;
|
|
323
367
|
let lastWorktreeFetch = 0;
|
|
324
368
|
let sweepingWorktrees = false;
|
|
@@ -340,8 +384,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
340
384
|
}
|
|
341
385
|
};
|
|
342
386
|
const sessionWorktreeReport = (sessionId) => {
|
|
343
|
-
|
|
344
|
-
|
|
387
|
+
// The session's PLACE, not its name: a tab in the checkout is measured in
|
|
388
|
+
// the checkout, and a tab sharing another tab's worktree is measured there.
|
|
389
|
+
const place = placeOf(sessionId);
|
|
390
|
+
if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
|
|
391
|
+
const wt = placeDir(sessionId);
|
|
345
392
|
const d = worktreeDiff(wt, baseRef);
|
|
346
393
|
if (!d) return null;
|
|
347
394
|
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
@@ -567,7 +614,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
567
614
|
void (async () => {
|
|
568
615
|
try {
|
|
569
616
|
if (!(await claimPreview(sessionId))) return; // somebody else has it
|
|
570
|
-
const wt =
|
|
617
|
+
const wt = placeDir(sessionId);
|
|
571
618
|
// RE-VALIDATE the attribution here, not just the liveness. The server
|
|
572
619
|
// checked this port against a report up to a minute old; more
|
|
573
620
|
// importantly, checking `listenersIn` again is what keeps the answer
|
|
@@ -755,7 +802,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
755
802
|
void (async () => {
|
|
756
803
|
try {
|
|
757
804
|
if (!(await claimDevRun(sessionId))) return; // somebody else has it
|
|
758
|
-
const wt =
|
|
805
|
+
const wt = placeDir(sessionId);
|
|
759
806
|
const r = await startDevServer({
|
|
760
807
|
sessionId,
|
|
761
808
|
worktree: wt,
|
|
@@ -827,7 +874,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
827
874
|
const adoptDevRuns = (activeIds) => {
|
|
828
875
|
let adopted = 0;
|
|
829
876
|
for (const entry of reapOrphanDevRuns(activeIds, note)) {
|
|
830
|
-
const wt =
|
|
877
|
+
const wt = placeDir(entry.sessionId);
|
|
831
878
|
// The recorded cwd must still be this session's worktree. A recycled pid
|
|
832
879
|
// pointing anywhere else is somebody else's process.
|
|
833
880
|
if (entry.cwd !== wt) continue;
|
|
@@ -1159,8 +1206,34 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1159
1206
|
* is the point. If the directory was retired but the branch survives, the
|
|
1160
1207
|
* worktree re-attaches to the branch and the committed work is still there.
|
|
1161
1208
|
*/
|
|
1162
|
-
|
|
1163
|
-
|
|
1209
|
+
/**
|
|
1210
|
+
* WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
|
|
1211
|
+
*
|
|
1212
|
+
* A session used to BE a worktree: one tab, one directory, cut at birth and
|
|
1213
|
+
* retired at close. That binding was never an isolation guarantee — a turn
|
|
1214
|
+
* runs with permissions skipped, so the worktree is a starting directory and
|
|
1215
|
+
* not a fence, and any agent could always `cd` into another one. The product
|
|
1216
|
+
* was asserting an invariant it did not have.
|
|
1217
|
+
*
|
|
1218
|
+
* So a session now REFERENCES a place rather than being one. Many sessions
|
|
1219
|
+
* may name the same place; a session may name the repo checkout itself; and
|
|
1220
|
+
* `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
|
|
1221
|
+
* which is why an absent `place` behaves exactly as every existing tab does.
|
|
1222
|
+
*
|
|
1223
|
+
* `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
|
|
1224
|
+
* the checkout the daemon already serves — never created, never retired,
|
|
1225
|
+
* because it is not ours to remove. The value reaching here is a server-side
|
|
1226
|
+
* enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
|
|
1227
|
+
* same way adoption resolves a cwd, and for the same reason.
|
|
1228
|
+
*/
|
|
1229
|
+
const placeWtFor = (placeId, baseAt) => {
|
|
1230
|
+
if (placeId === REPO_PLACE) {
|
|
1231
|
+
// The checkout. `fresh: false` on purpose — nothing was opened, so no
|
|
1232
|
+
// caller may treat this as a newly-cut branch.
|
|
1233
|
+
return { wt: repoRoot, fresh: false };
|
|
1234
|
+
}
|
|
1235
|
+
if (!isSafePathSegment(placeId)) return null;
|
|
1236
|
+
const sessionId = placeId;
|
|
1164
1237
|
const wt = join(baseDir, 'sessions', sessionId);
|
|
1165
1238
|
const fresh = !existsSync(wt);
|
|
1166
1239
|
if (fresh) {
|
|
@@ -1538,6 +1611,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1538
1611
|
}
|
|
1539
1612
|
};
|
|
1540
1613
|
|
|
1614
|
+
/** The pre-places name, kept so every existing caller reads unchanged: a
|
|
1615
|
+
* session's own id IS its default place. */
|
|
1616
|
+
const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
|
|
1617
|
+
|
|
1541
1618
|
const retireWorkSessions = (activeIds, heldElsewhere) => {
|
|
1542
1619
|
if (!Array.isArray(activeIds)) return;
|
|
1543
1620
|
// Sessions ANOTHER daemon on this credential is serving. They are absent
|
|
@@ -1596,7 +1673,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1596
1673
|
// run it again while the report is merely undelivered.
|
|
1597
1674
|
if (pendingWorkReports.has(job.id)) continue;
|
|
1598
1675
|
workAnswering.add(job.id);
|
|
1599
|
-
|
|
1676
|
+
// Serialized by PLACE: two tabs sharing a worktree take turns in it
|
|
1677
|
+
// rather than editing the same files at the same time.
|
|
1678
|
+
const place = job.place || job.sessionId;
|
|
1679
|
+
// Remembered for every other beat — the sweep, ship, the preview
|
|
1680
|
+
// re-check — so they all ask the same directory this turn runs in.
|
|
1681
|
+
sessionPlaces.set(job.sessionId, place);
|
|
1682
|
+
chainFor(place, async () => {
|
|
1600
1683
|
try {
|
|
1601
1684
|
const tries = workAttempts.get(job.id) ?? 0;
|
|
1602
1685
|
if (tries >= MAX_WORK_TRIES) {
|
|
@@ -1711,7 +1794,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1711
1794
|
}
|
|
1712
1795
|
// Based at the SOURCE's HEAD when adopting — the resumed
|
|
1713
1796
|
// conversation was had against those commits, not the project base.
|
|
1714
|
-
|
|
1797
|
+
// The PLACE this tab works in — its own worktree unless the server
|
|
1798
|
+
// named another. An older server sends no `place` and the default is
|
|
1799
|
+
// the session's own id, which is what every tab has always done.
|
|
1800
|
+
const dir = placeWtFor(place, adopting ? srcHead : undefined);
|
|
1715
1801
|
if (!dir) {
|
|
1716
1802
|
await settleWorkTurn(job.id, {
|
|
1717
1803
|
ok: false,
|
|
@@ -2193,8 +2279,55 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2193
2279
|
return;
|
|
2194
2280
|
}
|
|
2195
2281
|
note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
|
|
2196
|
-
|
|
2197
|
-
|
|
2282
|
+
/**
|
|
2283
|
+
* WHAT IS ACTUALLY CHECKED OUT — not what we named it at birth.
|
|
2284
|
+
*
|
|
2285
|
+
* Ship used to compute `session/<id>` and then REFUSE if HEAD had
|
|
2286
|
+
* moved: "ask it to return to its session branch, then ship again".
|
|
2287
|
+
* That refusal is the thing this product says it never does — it had
|
|
2288
|
+
* no reason of its own beyond bookkeeping, and in a terminal
|
|
2289
|
+
* `git checkout -b` breaks nothing, which is the whole standard this
|
|
2290
|
+
* surface is held to.
|
|
2291
|
+
*
|
|
2292
|
+
* The bug it was written for was real and is fixed properly here
|
|
2293
|
+
* rather than frozen out: ship once merged the branch NAME while
|
|
2294
|
+
* logging HEAD, so receipts named commits that never landed on main.
|
|
2295
|
+
* That was TWO SOURCES OF TRUTH, not branch switching. There is one
|
|
2296
|
+
* now, and it is the worktree's own HEAD.
|
|
2297
|
+
*
|
|
2298
|
+
* Resolved BEFORE the idempotency check below, and that ordering is
|
|
2299
|
+
* load-bearing: `session/<id>` can still exist, stale and already an
|
|
2300
|
+
* ancestor of base, while the real work sits on the branch that was
|
|
2301
|
+
* checked out afterwards. Asking the old name first would answer
|
|
2302
|
+
* "already merged — nothing new to ship" over unshipped commits.
|
|
2303
|
+
*
|
|
2304
|
+
* A directory that is gone (a retired or closed tab) cannot be asked,
|
|
2305
|
+
* so the recorded name is the fallback — the one case where the name
|
|
2306
|
+
* is the only thing there is.
|
|
2307
|
+
*/
|
|
2308
|
+
// The session's PLACE — the directory it actually works in.
|
|
2309
|
+
const shipPlace = placeOf(job.sessionId);
|
|
2310
|
+
const wt = shipPlace === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', shipPlace);
|
|
2311
|
+
let branch = `session/${job.sessionId}`;
|
|
2312
|
+
let detached = false;
|
|
2313
|
+
if (existsSync(wt)) {
|
|
2314
|
+
try {
|
|
2315
|
+
branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
|
|
2316
|
+
} catch {
|
|
2317
|
+
detached = true;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
// THE ONE REFUSAL LEFT, and it is not policy. A detached HEAD names
|
|
2321
|
+
// no branch, so there is nothing to merge and nothing to record —
|
|
2322
|
+
// that is an ambiguity in git, not a rule of ours.
|
|
2323
|
+
if (detached) {
|
|
2324
|
+
await done({
|
|
2325
|
+
ok: false,
|
|
2326
|
+
error:
|
|
2327
|
+
'this session is on a detached HEAD — no branch to ship. Ask it to check out a branch, then ship again',
|
|
2328
|
+
});
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2198
2331
|
let branchExists = true;
|
|
2199
2332
|
try {
|
|
2200
2333
|
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot);
|
|
@@ -2360,7 +2493,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2360
2493
|
ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
|
|
2361
2494
|
return;
|
|
2362
2495
|
}
|
|
2363
|
-
const dir =
|
|
2496
|
+
const dir = placeWtFor(shipPlace);
|
|
2364
2497
|
if (!dir) {
|
|
2365
2498
|
await done({
|
|
2366
2499
|
ok: false,
|
|
@@ -2388,23 +2521,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2388
2521
|
});
|
|
2389
2522
|
return;
|
|
2390
2523
|
}
|
|
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
|
-
}
|
|
2524
|
+
// NO "return to your session branch" GUARD. `branch` was read from
|
|
2525
|
+
// this worktree's HEAD above, so the fold, the tip and the receipts
|
|
2526
|
+
// below all name the same thing by construction — which is what the
|
|
2527
|
+
// old guard was really protecting, and it protected it by refusing
|
|
2528
|
+
// instead of by measuring.
|
|
2408
2529
|
// Fold main into the branch FIRST: conflicts land here, in the
|
|
2409
2530
|
// session's own worktree, where the next turn can resolve them.
|
|
2410
2531
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
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": {
|