flowviant 0.65.0 → 0.67.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 +30 -4
- package/bin/lib/git.mjs +26 -0
- package/bin/lib/listeners.mjs +39 -7
- package/bin/lib/repoState.mjs +20 -1
- package/bin/lib/shipSweep.mjs +59 -0
- package/bin/lib/work.mjs +49 -12
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -319,7 +319,7 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
|
|
|
319
319
|
repoStateScanAt = Date.now();
|
|
320
320
|
let payload;
|
|
321
321
|
try {
|
|
322
|
-
const state = repoState(repoRoot,
|
|
322
|
+
const state = repoState(repoRoot, getBaseRef());
|
|
323
323
|
if (!state) return; // not readable — say nothing rather than say "none"
|
|
324
324
|
payload = JSON.stringify(state);
|
|
325
325
|
} catch {
|
|
@@ -399,7 +399,19 @@ export async function runFleetDaemon() {
|
|
|
399
399
|
console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
|
|
400
400
|
console.log(` ${c.dim('──────────────────────────────────────────────')}`);
|
|
401
401
|
const repoRoot = repoRootOrDie();
|
|
402
|
-
|
|
402
|
+
/**
|
|
403
|
+
* WHERE SHIP LANDS. Detected at startup, then OVERRIDDEN by the roster when a
|
|
404
|
+
* human has chosen one (`projects.baseBranch`).
|
|
405
|
+
*
|
|
406
|
+
* A `let` and a getter rather than a const, because the answer can change
|
|
407
|
+
* while the daemon runs — and because the detected value itself is fragile:
|
|
408
|
+
* with no `origin/HEAD` set, `detectBaseRef` falls back to
|
|
409
|
+
* `origin/<whatever was checked out at startup>`, which froze for the life of
|
|
410
|
+
* the process. A stored value is the fix; this is the wiring that lets it
|
|
411
|
+
* reach the code that merges.
|
|
412
|
+
*/
|
|
413
|
+
let baseRef = detectBaseRef(repoRoot);
|
|
414
|
+
const getBaseRef = () => baseRef;
|
|
403
415
|
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
404
416
|
// WHICH PROJECT, before anything connects — the roster names it again a few
|
|
405
417
|
// seconds later with the server's word, but "which project is this daemon
|
|
@@ -815,7 +827,7 @@ export async function runFleetDaemon() {
|
|
|
815
827
|
} = createWorkManager({
|
|
816
828
|
repoRoot,
|
|
817
829
|
baseDir,
|
|
818
|
-
|
|
830
|
+
getBaseRef,
|
|
819
831
|
getMcpUrl: () => mcpUrl,
|
|
820
832
|
getLeaseTtl: () => leaseTtlSeconds,
|
|
821
833
|
});
|
|
@@ -1542,6 +1554,20 @@ export async function runFleetDaemon() {
|
|
|
1542
1554
|
// `git worktree remove` would happily pull the directory out from under a
|
|
1543
1555
|
// running node process, which then serves bytes from open file handles in
|
|
1544
1556
|
// a directory that no longer exists, with no error anywhere.
|
|
1557
|
+
/**
|
|
1558
|
+
* WHERE SHIP LANDS, if a human has chosen. Absence means "you decide" —
|
|
1559
|
+
* the state every daemon was in before this existed, and what an
|
|
1560
|
+
* unconfigured project still means — so it must NOT clear a detection.
|
|
1561
|
+
* Announced on change, because a silent switch of merge target is the one
|
|
1562
|
+
* thing worse than not offering the choice at all.
|
|
1563
|
+
*/
|
|
1564
|
+
if (typeof roster.baseBranch === 'string' && roster.baseBranch.trim()) {
|
|
1565
|
+
const want = `origin/${baseBranchName(roster.baseBranch.trim())}`;
|
|
1566
|
+
if (want !== baseRef) {
|
|
1567
|
+
note(`base · ${want} ${c.dim('(set for this project)')}`);
|
|
1568
|
+
baseRef = want;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1545
1571
|
// A session another daemon on this credential is serving is NOT a closed
|
|
1546
1572
|
// tab. Without this the daemon that lost the lease removes the worktree the
|
|
1547
1573
|
// winner is working in — absence would mean "somebody else won" instead of
|
|
@@ -1662,7 +1688,7 @@ export async function runFleetDaemon() {
|
|
|
1662
1688
|
// machines). Config report is cheap + dedup'd; jobs are single-flight.
|
|
1663
1689
|
if (roster.env?.deployAuthorized) {
|
|
1664
1690
|
void reportDeployConfig(repoRoot);
|
|
1665
|
-
processDeployJobs(roster.deployJobs, { repoRoot, baseRef, myPubB64 });
|
|
1691
|
+
processDeployJobs(roster.deployJobs, { repoRoot, baseRef: getBaseRef(), myPubB64 });
|
|
1666
1692
|
}
|
|
1667
1693
|
|
|
1668
1694
|
// Stop workers whose agent left the roster (removed in the app).
|
package/bin/lib/git.mjs
CHANGED
|
@@ -98,6 +98,32 @@ export function detectBaseRef(repoRoot) {
|
|
|
98
98
|
} catch {
|
|
99
99
|
/* origin/HEAD not set */
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* NO `origin/HEAD`. Prefer a CONVENTION over an accident.
|
|
103
|
+
*
|
|
104
|
+
* This used to fall straight through to `origin/<whatever is checked out
|
|
105
|
+
* right now>` — which, since the result is computed once at daemon start and
|
|
106
|
+
* held for the life of the process, meant starting the daemon while you
|
|
107
|
+
* happened to be on `staging` silently made staging the merge target for
|
|
108
|
+
* every ship until you restarted. Nothing said so.
|
|
109
|
+
*
|
|
110
|
+
* A remote branch actually called `main` or `master` is a far better guess
|
|
111
|
+
* than the branch you were standing on, and unlike that one it does not
|
|
112
|
+
* depend on when the process booted. The old behaviour survives as the last
|
|
113
|
+
* resort, because a repo with neither is a repo where we genuinely have
|
|
114
|
+
* nothing better.
|
|
115
|
+
*
|
|
116
|
+
* The real fix is that a human can now SET it (`projects.baseBranch`), which
|
|
117
|
+
* overrides all of this. This just stops the unset case being arbitrary.
|
|
118
|
+
*/
|
|
119
|
+
for (const conventional of ['origin/main', 'origin/master']) {
|
|
120
|
+
try {
|
|
121
|
+
git(['rev-parse', '--verify', '--quiet', `refs/remotes/${conventional}`], repoRoot);
|
|
122
|
+
return conventional;
|
|
123
|
+
} catch {
|
|
124
|
+
/* not this one */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
101
127
|
try {
|
|
102
128
|
return `origin/${git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)}`;
|
|
103
129
|
} catch {
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const MAX_PIDS = 4000;
|
|
|
40
40
|
* twenty is somebody's docker-compose and the extra rows say nothing. */
|
|
41
41
|
const MAX_ROWS = 8;
|
|
42
42
|
/** Longest process label we relay. */
|
|
43
|
-
const MAX_LABEL =
|
|
43
|
+
const MAX_LABEL = 40;
|
|
44
44
|
|
|
45
45
|
// ── /proc/net/tcp parsing (linux) ──────────────────────────────────────────
|
|
46
46
|
|
|
@@ -83,15 +83,47 @@ function listeningByInode() {
|
|
|
83
83
|
return out;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* WHAT TO CALL A LISTENING PROCESS.
|
|
88
|
+
*
|
|
89
|
+
* This used to be `basename(argv[0])`, which names the RUNTIME and not the
|
|
90
|
+
* program — so half the rows in a JavaScript repo read `node` and told the
|
|
91
|
+
* reader nothing about which of their servers this was. The question that
|
|
92
|
+
* changed it: "a non developer wouldnt know what node is or workerd."
|
|
93
|
+
*
|
|
94
|
+
* THE FIX IS NOT A LOOKUP TABLE. Mapping `workerd` → "Cloudflare Worker" is
|
|
95
|
+
* the `DEV_ARGV0` shape: a hardcoded list of tools that is wrong for every
|
|
96
|
+
* stack nobody enumerated, needing a release per ecosystem forever. What is
|
|
97
|
+
* always available instead is argv[1] — the thing the runtime was asked to
|
|
98
|
+
* run — so `node …/node_modules/.bin/vite` becomes `node vite` and
|
|
99
|
+
* `node server.js` becomes `node server.js`. No list, works on every stack.
|
|
100
|
+
*
|
|
101
|
+
* A FLAG IS SKIPPED rather than guessed past: `python3 -m http.server` keeps
|
|
102
|
+
* `python3` instead of claiming to be called `-m`. Only the first two argv
|
|
103
|
+
* elements are ever read — enough to name the program, far short of the whole
|
|
104
|
+
* command line, which is the thing this deliberately does not relay.
|
|
105
|
+
*
|
|
106
|
+
* And this is still only a MEASUREMENT. The name a person gives a share lives
|
|
107
|
+
* on `session_previews.label`, is written by a human PATCH, and is what a
|
|
108
|
+
* teammate actually reads — no amount of argv produces "Storefront".
|
|
109
|
+
*/
|
|
110
|
+
export function labelFromArgv(argv) {
|
|
111
|
+
const base = (v) => (v || '').split('/').pop() || v || '';
|
|
112
|
+
const head = base(argv?.[0]);
|
|
113
|
+
if (!head) return null;
|
|
114
|
+
// argv[1] only when it NAMES something rather than configuring it. A `-`
|
|
115
|
+
// prefix is the one universally reliable tell, and skipping is the honest
|
|
116
|
+
// answer — `python3 -m http.server` keeps `python3` rather than claiming to
|
|
117
|
+
// be called `-m`.
|
|
118
|
+
const next = argv?.[1] && !argv[1].startsWith('-') ? base(argv[1]) : '';
|
|
119
|
+
const label = next && next !== head ? `${head} ${next}` : head;
|
|
120
|
+
return label.slice(0, MAX_LABEL) || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
86
123
|
function labelFor(pid) {
|
|
87
124
|
try {
|
|
88
125
|
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
89
|
-
|
|
90
|
-
// The basename only. A full argv is the driver's command line, which can
|
|
91
|
-
// carry a token in an inline env assignment — and the whole argv is never
|
|
92
|
-
// what a reader needs to recognise their own dev server.
|
|
93
|
-
const base = first.split('/').pop() || first;
|
|
94
|
-
return base.slice(0, MAX_LABEL) || null;
|
|
126
|
+
return labelFromArgv(raw.split('\0').filter(Boolean));
|
|
95
127
|
} catch {
|
|
96
128
|
return null;
|
|
97
129
|
}
|
package/bin/lib/repoState.mjs
CHANGED
|
@@ -165,6 +165,7 @@ export function repoState(repoRoot, baseRef) {
|
|
|
165
165
|
const listening = listenersIn(repoRoot);
|
|
166
166
|
const wt = worktrees ?? [];
|
|
167
167
|
const br = branches ?? [];
|
|
168
|
+
const sessionRows = br.filter((b) => b.session);
|
|
168
169
|
return {
|
|
169
170
|
base: baseRef,
|
|
170
171
|
worktrees: wt.slice(0, MAX_WORKTREES),
|
|
@@ -173,7 +174,25 @@ export function repoState(repoRoot, baseRef) {
|
|
|
173
174
|
// part somebody is actually working in.
|
|
174
175
|
branches: br.slice(0, MAX_BRANCHES),
|
|
175
176
|
branchesTotal: br.length,
|
|
176
|
-
sessionBranches:
|
|
177
|
+
sessionBranches: sessionRows.length,
|
|
178
|
+
/**
|
|
179
|
+
* …AND HOW MANY OF THEM STILL HOLD WORK.
|
|
180
|
+
*
|
|
181
|
+
* "40 branches" is meaningful to nobody. "3 branches holding work you
|
|
182
|
+
* haven't shipped" is meaningful to everybody, and it is the only half of
|
|
183
|
+
* the count anyone can act on. `ahead` is commits this branch has that base
|
|
184
|
+
* does not — already measured above by `ahead-behind`, so this costs no
|
|
185
|
+
* extra git call.
|
|
186
|
+
*
|
|
187
|
+
* OMITTED, never guessed, when the measurement is missing: an older git
|
|
188
|
+
* takes the fallback format in `readBranches` and reports no `ahead` at
|
|
189
|
+
* all, and a count of zero unshipped branches would then be a claim nobody
|
|
190
|
+
* measured. Absent means "could not tell"; the surface renders the flat
|
|
191
|
+
* count instead.
|
|
192
|
+
*/
|
|
193
|
+
...(sessionRows.every((b) => typeof b.ahead === 'number')
|
|
194
|
+
? { sessionBranchesUnshipped: sessionRows.filter((b) => b.ahead > 0).length }
|
|
195
|
+
: {}),
|
|
177
196
|
listening,
|
|
178
197
|
// "Nothing is listening" and "this machine cannot look" (Windows, a failed
|
|
179
198
|
// scan) are the same empty array without this — and the second must never
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { baseBranchName } from './git.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RETIRE A BRANCH FLOWVIANT MADE AND FLOWVIANT MERGED.
|
|
5
|
+
*
|
|
6
|
+
* NOT PRUNING SOMEBODY'S REPO — removing our own bookkeeping. `session/<id>`
|
|
7
|
+
* is an artifact this daemon created, named and merged; the driver's work is
|
|
8
|
+
* the commits, and after a `--no-ff` ship those are on base. The standing law
|
|
9
|
+
* that the Repository block reports and never prunes is untouched: it was
|
|
10
|
+
* written for refs of UNKNOWN provenance — a branch the agent cut mid-turn, a
|
|
11
|
+
* worktree a crash left behind — and this is the one category that is
|
|
12
|
+
* provably ours.
|
|
13
|
+
*
|
|
14
|
+
* WHY IT MATTERS MORE THAN TIDINESS: a reporting surface only works if what
|
|
15
|
+
* it reports is rare. Every merged session branch used to accumulate forever,
|
|
16
|
+
* so "is claude polluting the branches" could not be answered from a list
|
|
17
|
+
* dominated by our own litter. Stop littering and what remains is worth
|
|
18
|
+
* reading.
|
|
19
|
+
*
|
|
20
|
+
* `git branch -d` IS THE GUARD, deliberately, rather than a stack of checks
|
|
21
|
+
* of our own. It refuses an UNMERGED branch and it refuses one CHECKED OUT in
|
|
22
|
+
* any worktree — which are two of the three conditions, enforced by the tool
|
|
23
|
+
* that owns the truth instead of by our reading of it. Never `-D`: if git
|
|
24
|
+
* objects, git is right and we stop. The third condition is ours and is the
|
|
25
|
+
* name: only `session/<id>` exactly, so a branch the agent cut is never in
|
|
26
|
+
* scope no matter what it was merged into.
|
|
27
|
+
*
|
|
28
|
+
* ORDERING IS LOAD-BEARING. This must not run while a ship report is still
|
|
29
|
+
* undelivered. Ship's idempotency path recovers from a lost report by asking
|
|
30
|
+
* `branchExists && ancestorOfBase(branch)`; with the branch gone a re-offered
|
|
31
|
+
* job answers "nothing to ship — this session has no branch on this machine"
|
|
32
|
+
* for work that shipped, and the server's reconciliation backstop silently
|
|
33
|
+
* never books the commits no card claimed.
|
|
34
|
+
*
|
|
35
|
+
* Local only. Ship pushes base and has never pushed `session/*`, so there is
|
|
36
|
+
* nothing to clean on a remote.
|
|
37
|
+
*/
|
|
38
|
+
export function sweepMergedBranch(sessionId, { git, repoRoot, baseRef, note, isReportPending }) {
|
|
39
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(String(sessionId ?? ''))) return false;
|
|
40
|
+
// The report has not landed. See ORDERING above.
|
|
41
|
+
if (isReportPending?.(sessionId)) return false;
|
|
42
|
+
const name = `session/${sessionId}`;
|
|
43
|
+
try {
|
|
44
|
+
git(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], repoRoot);
|
|
45
|
+
} catch {
|
|
46
|
+
return false; // already gone, or never existed
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
git(['branch', '-d', name], repoRoot);
|
|
50
|
+
} catch {
|
|
51
|
+
// Unmerged, or checked out somewhere. Both are correct reasons to keep it,
|
|
52
|
+
// and both are git's answer rather than ours.
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
// NARRATED, like every other sweep: a deletion with no trace in the log
|
|
56
|
+
// cannot be diagnosed from either end.
|
|
57
|
+
note?.(`retired ${name} — already merged into ${baseBranchName(baseRef)}`);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.
|
|
|
42
42
|
import { listenersIn, listenersSupported } from './listeners.mjs';
|
|
43
43
|
import { processesInGroups, liveGroups, processesSupported } from './processes.mjs';
|
|
44
44
|
import { createPlaceLock } from './placeLock.mjs';
|
|
45
|
+
import { sweepMergedBranch } from './shipSweep.mjs';
|
|
45
46
|
import { openTunnel } from './preview.mjs';
|
|
46
47
|
import { c, note, ok, warn } from './ui.mjs';
|
|
47
48
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
@@ -100,7 +101,18 @@ function brainFor(job) {
|
|
|
100
101
|
return out;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
export function createWorkManager({ repoRoot, baseDir,
|
|
104
|
+
export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, getLeaseTtl }) {
|
|
105
|
+
/**
|
|
106
|
+
* WHERE SHIP LANDS, read fresh every time rather than captured at startup.
|
|
107
|
+
*
|
|
108
|
+
* A getter, like `getMcpUrl` and `getLeaseTtl` beside it, because the answer
|
|
109
|
+
* can now change while the daemon runs: a human sets `projects.baseBranch`
|
|
110
|
+
* and the next roster poll carries it. Captured by value this would be
|
|
111
|
+
* whatever `origin/HEAD` said the moment the process booted — which is also
|
|
112
|
+
* the shape of the bug it replaces, where an unset `origin/HEAD` froze
|
|
113
|
+
* `origin/<branch you happened to be on>` for the life of the daemon.
|
|
114
|
+
*/
|
|
115
|
+
const baseRef = () => getBaseRef();
|
|
104
116
|
const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
|
|
105
117
|
const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
|
|
106
118
|
const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
|
|
@@ -227,6 +239,9 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
227
239
|
} else {
|
|
228
240
|
pendingShipReports.delete(sessionId);
|
|
229
241
|
reportBackoff.delete(sessionId);
|
|
242
|
+
// The report has landed, so the idempotency path no longer needs the
|
|
243
|
+
// branch to exist. See `sweepMergedSessionBranch`.
|
|
244
|
+
sweepMergedSessionBranch(sessionId);
|
|
230
245
|
}
|
|
231
246
|
return r;
|
|
232
247
|
};
|
|
@@ -423,7 +438,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
423
438
|
const place = placeOf(sessionId);
|
|
424
439
|
if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
|
|
425
440
|
const wt = placeDir(sessionId);
|
|
426
|
-
const d = worktreeDiff(wt, baseRef);
|
|
441
|
+
const d = worktreeDiff(wt, baseRef());
|
|
427
442
|
if (!d) return null;
|
|
428
443
|
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
429
444
|
// holding the socket. It rides the sweep the daemon already makes rather
|
|
@@ -972,6 +987,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
972
987
|
else if (r !== 'retry') {
|
|
973
988
|
pendingShipReports.delete(id);
|
|
974
989
|
reportBackoff.delete(id);
|
|
990
|
+
// Delivered late is still delivered — same sweep as the immediate
|
|
991
|
+
// path, and it must be here too or a report that needed a retry
|
|
992
|
+
// would leave its branch behind forever.
|
|
993
|
+
sweepMergedSessionBranch(id);
|
|
975
994
|
}
|
|
976
995
|
}
|
|
977
996
|
} finally {
|
|
@@ -1071,7 +1090,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1071
1090
|
// would hand it a repo state it has never seen. Everything else is
|
|
1072
1091
|
// unchanged, the attach fallback included: a surviving branch already
|
|
1073
1092
|
// chose its base, and re-basing it here would move committed work.
|
|
1074
|
-
const at = baseAt || baseRef;
|
|
1093
|
+
const at = baseAt || baseRef();
|
|
1075
1094
|
try {
|
|
1076
1095
|
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
1077
1096
|
} catch {
|
|
@@ -1442,6 +1461,18 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1442
1461
|
* session's own id IS its default place. */
|
|
1443
1462
|
const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
|
|
1444
1463
|
|
|
1464
|
+
/** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
|
|
1465
|
+
const sweepMergedSessionBranch = (sessionId) =>
|
|
1466
|
+
sweepMergedBranch(sessionId, {
|
|
1467
|
+
git,
|
|
1468
|
+
repoRoot,
|
|
1469
|
+
baseRef: baseRef(),
|
|
1470
|
+
note,
|
|
1471
|
+
// The report queue is consulted at CALL time, never captured — a sweep
|
|
1472
|
+
// scheduled while a report was outstanding must still see it land.
|
|
1473
|
+
isReportPending: (id) => pendingShipReports.has(id),
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1445
1476
|
const retireWorkSessions = (activeIds, heldElsewhere) => {
|
|
1446
1477
|
if (!Array.isArray(activeIds)) return;
|
|
1447
1478
|
// Sessions ANOTHER daemon on this credential is serving. They are absent
|
|
@@ -1479,9 +1510,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1479
1510
|
// it, closed tab or not. (The non-force remove would refuse anyway;
|
|
1480
1511
|
// the explicit check keeps the intent legible.)
|
|
1481
1512
|
if (git(['status', '--porcelain'], wt) !== '') continue;
|
|
1482
|
-
git(['worktree', 'remove', wt], repoRoot); // non-force
|
|
1513
|
+
git(['worktree', 'remove', wt], repoRoot); // non-force
|
|
1483
1514
|
workTokens.delete(id);
|
|
1484
1515
|
removed++;
|
|
1516
|
+
// NOW the branch can be judged. While this worktree existed the branch
|
|
1517
|
+
// was checked out in it, so `git branch -d` refused on every earlier
|
|
1518
|
+
// attempt — a tab that shipped and then closed would otherwise leave
|
|
1519
|
+
// its merged branch behind forever, which is the common case.
|
|
1520
|
+
// Unshipped work still refuses here: `-d` is what decides.
|
|
1521
|
+
sweepMergedSessionBranch(id);
|
|
1485
1522
|
} catch {
|
|
1486
1523
|
/* not cleanly removable — leave it */
|
|
1487
1524
|
}
|
|
@@ -2191,7 +2228,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2191
2228
|
}
|
|
2192
2229
|
const ancestorOfBase = (ref) => {
|
|
2193
2230
|
try {
|
|
2194
|
-
git(['merge-base', '--is-ancestor', ref, baseRef], repoRoot);
|
|
2231
|
+
git(['merge-base', '--is-ancestor', ref, baseRef()], repoRoot);
|
|
2195
2232
|
return true;
|
|
2196
2233
|
} catch {
|
|
2197
2234
|
return false;
|
|
@@ -2240,7 +2277,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2240
2277
|
} catch {
|
|
2241
2278
|
/* not there — fine */
|
|
2242
2279
|
}
|
|
2243
|
-
git(['worktree', 'add', '--detach', tmp, baseRef], repoRoot);
|
|
2280
|
+
git(['worktree', 'add', '--detach', tmp, baseRef()], repoRoot);
|
|
2244
2281
|
gitMerge(
|
|
2245
2282
|
[
|
|
2246
2283
|
'merge',
|
|
@@ -2251,7 +2288,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2251
2288
|
],
|
|
2252
2289
|
tmp
|
|
2253
2290
|
);
|
|
2254
|
-
git(['push', 'origin', `HEAD:${baseBranchName(baseRef)}`], tmp);
|
|
2291
|
+
git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmp);
|
|
2255
2292
|
} finally {
|
|
2256
2293
|
try {
|
|
2257
2294
|
git(['worktree', 'remove', '--force', tmp], repoRoot);
|
|
@@ -2272,7 +2309,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2272
2309
|
const tip = git(['rev-parse', branch], repoRoot);
|
|
2273
2310
|
let commits = [];
|
|
2274
2311
|
try {
|
|
2275
|
-
const m = git(['log', baseRef, '--merges', '--format=%H %P', '-n', '500'], repoRoot)
|
|
2312
|
+
const m = git(['log', baseRef(), '--merges', '--format=%H %P', '-n', '500'], repoRoot)
|
|
2276
2313
|
.split('\n')
|
|
2277
2314
|
.map((l) => l.trim().split(' '))
|
|
2278
2315
|
.find((p) => p.length >= 3 && p[2] === tip);
|
|
@@ -2283,7 +2320,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2283
2320
|
await done({
|
|
2284
2321
|
ok: true,
|
|
2285
2322
|
commits,
|
|
2286
|
-
note: `${baseBranchName(baseRef)} already contains this session's branch — nothing new to merge`,
|
|
2323
|
+
note: `${baseBranchName(baseRef())} already contains this session's branch — nothing new to merge`,
|
|
2287
2324
|
});
|
|
2288
2325
|
ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
|
|
2289
2326
|
return;
|
|
@@ -2301,7 +2338,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2301
2338
|
return;
|
|
2302
2339
|
}
|
|
2303
2340
|
const tip = git(['rev-parse', branch], repoRoot);
|
|
2304
|
-
const commits = logCommits(`${baseRef}..${tip}`);
|
|
2341
|
+
const commits = logCommits(`${baseRef()}..${tip}`);
|
|
2305
2342
|
if (commits.length === 0) {
|
|
2306
2343
|
await done({
|
|
2307
2344
|
ok: false,
|
|
@@ -2369,7 +2406,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2369
2406
|
// Fold main into the branch FIRST: conflicts land here, in the
|
|
2370
2407
|
// session's own worktree, where the next turn can resolve them.
|
|
2371
2408
|
try {
|
|
2372
|
-
gitMerge(['merge', '--no-edit', baseRef], dir.wt);
|
|
2409
|
+
gitMerge(['merge', '--no-edit', baseRef()], dir.wt);
|
|
2373
2410
|
} catch (e) {
|
|
2374
2411
|
const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
|
|
2375
2412
|
// NEVER leave the session mid-merge: a MERGE_HEAD left behind puts
|
|
@@ -2400,7 +2437,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2400
2437
|
// one X for both, so the ledger can never carry receipts for commits
|
|
2401
2438
|
// that did not land.
|
|
2402
2439
|
const tip = git(['rev-parse', branch], repoRoot);
|
|
2403
|
-
const commits = logCommits(`${baseRef}..${tip}`);
|
|
2440
|
+
const commits = logCommits(`${baseRef()}..${tip}`);
|
|
2404
2441
|
if (commits.length === 0) {
|
|
2405
2442
|
// Post-fold this is nearly unreachable (a zero-commit branch is an
|
|
2406
2443
|
// ancestor of base, settled above) — but if the branch's commits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.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": {
|