flowviant 0.43.0 → 0.45.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/claude.mjs +15 -604
- package/bin/lib/fleet.mjs +125 -311
- package/bin/lib/localSessions.mjs +266 -0
- package/bin/lib/prompts.mjs +610 -0
- package/bin/lib/runtimes.mjs +21 -4
- package/bin/lib/work.mjs +1144 -0
- package/package.json +1 -1
package/bin/lib/work.mjs
ADDED
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work sessions — the Workbench tabs, daemon side.
|
|
3
|
+
*
|
|
4
|
+
* A tab is a held Claude session with BUILD permissions in a PERSISTENT
|
|
5
|
+
* worktree on its own `session/<id>` branch. Nothing here is detached and
|
|
6
|
+
* nothing is ever reset — uncommitted state between turns IS the session, and
|
|
7
|
+
* blowing it away would be closing the human's editor mid-thought. (Plan
|
|
8
|
+
* worktrees are the deliberate opposite: reset at base every turn.)
|
|
9
|
+
*
|
|
10
|
+
* Everything the loop guarantees lives here: per-session turn/ship chains,
|
|
11
|
+
* per-session work credentials, the settle-every-turn contract, the ship
|
|
12
|
+
* executor, and worktree retirement. Split out of fleet.mjs mechanically —
|
|
13
|
+
* the daemon's reconcile loop constructs one manager per run and feeds it
|
|
14
|
+
* roster jobs; the only state it borrows from the loop is read through the
|
|
15
|
+
* two getters (the MCP URL and the lease TTL can change with any poll).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
realpathSync,
|
|
25
|
+
statSync,
|
|
26
|
+
lstatSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
cpSync,
|
|
29
|
+
} from 'node:fs';
|
|
30
|
+
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import { join, dirname } from 'node:path';
|
|
32
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, REFRESH_BEFORE_SECONDS } from './config.mjs';
|
|
33
|
+
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
34
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
35
|
+
import { mcpFor, runTurn } from './claude.mjs';
|
|
36
|
+
import { SYSTEM_WORK, WORK_TURN_KICKOFF } from './prompts.mjs';
|
|
37
|
+
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
38
|
+
import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
|
|
39
|
+
import { isTerminalSessionLive } from './localSessions.mjs';
|
|
40
|
+
|
|
41
|
+
export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLeaseTtl }) {
|
|
42
|
+
const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
|
|
43
|
+
const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
|
|
44
|
+
const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
|
|
45
|
+
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
46
|
+
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
47
|
+
const MAX_WORK_TRIES = 3;
|
|
48
|
+
const shipping = new Set(); // sessionIds with a ship queued/running here
|
|
49
|
+
/**
|
|
50
|
+
* Per-SESSION serialization, parallel ACROSS sessions: turns within one tab
|
|
51
|
+
* must land in order (they share a directory and a context), but two tabs
|
|
52
|
+
* are two terminals — the human opened both on purpose. Ship jobs ride the
|
|
53
|
+
* SAME chain, never a separate one: a ship must not run git in a worktree
|
|
54
|
+
* while that session's turn has a live CLI in it.
|
|
55
|
+
*/
|
|
56
|
+
const workChains = new Map(); // sessionId -> settled-safe tail promise
|
|
57
|
+
const chainFor = (sessionId, fn) => {
|
|
58
|
+
const prev = workChains.get(sessionId) ?? Promise.resolve();
|
|
59
|
+
// `.then(fn, fn)`, like withWikiLock: one rejected link must never wedge
|
|
60
|
+
// every later turn of the tab.
|
|
61
|
+
const run = prev.then(fn, fn);
|
|
62
|
+
const stored = run.then(
|
|
63
|
+
() => {},
|
|
64
|
+
() => {}
|
|
65
|
+
);
|
|
66
|
+
workChains.set(sessionId, stored);
|
|
67
|
+
// Release the entry when the chain drains, so the map cannot grow for the
|
|
68
|
+
// process lifetime and `workChains.has()` means "busy right now".
|
|
69
|
+
stored.then(() => {
|
|
70
|
+
if (workChains.get(sessionId) === stored) workChains.delete(sessionId);
|
|
71
|
+
});
|
|
72
|
+
return run;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* EVERY turn settles — the work loop's prime contract. A pending turn nobody
|
|
77
|
+
* answers holds one of the tab's slots until the server expires it (24h);
|
|
78
|
+
* silence is the worst outcome. So a report that cannot be DELIVERED right
|
|
79
|
+
* now is queued in memory and retried at the top of every poll, and a turn
|
|
80
|
+
* whose finished answer sits in that queue is never re-run — a session turn
|
|
81
|
+
* has side effects (edits, commits, cards), and a dropped 200 must not apply
|
|
82
|
+
* them twice.
|
|
83
|
+
*/
|
|
84
|
+
const pendingWorkReports = new Map(); // turnId -> work-turn-done body
|
|
85
|
+
const pendingShipReports = new Map(); // sessionId -> ship-done body
|
|
86
|
+
/** POST a settle body. 'ok' | 'terminal' (the server will never accept this
|
|
87
|
+
* body — 403 not this fleet's session, 404 unknown turn, 409 ship already
|
|
88
|
+
* settled — so retrying is spam, not delivery) | 'retry'. */
|
|
89
|
+
const postSettle = async (url, body, terminalStatuses) => {
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
95
|
+
'User-Agent': USER_AGENT,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
signal: AbortSignal.timeout(30_000),
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
});
|
|
101
|
+
if (res.ok) return 'ok';
|
|
102
|
+
if (terminalStatuses.includes(res.status)) return 'terminal';
|
|
103
|
+
return 'retry';
|
|
104
|
+
} catch {
|
|
105
|
+
return 'retry';
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const settleWorkTurn = async (turnId, payload) => {
|
|
109
|
+
const body = { turnId, ...payload };
|
|
110
|
+
const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
|
|
111
|
+
if (r === 'retry') pendingWorkReports.set(turnId, body);
|
|
112
|
+
else {
|
|
113
|
+
pendingWorkReports.delete(turnId);
|
|
114
|
+
workAttempts.delete(turnId);
|
|
115
|
+
}
|
|
116
|
+
return r;
|
|
117
|
+
};
|
|
118
|
+
const settleShip = async (sessionId, payload) => {
|
|
119
|
+
const body = { sessionId, ...payload };
|
|
120
|
+
const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
|
|
121
|
+
if (r === 'retry') pendingShipReports.set(sessionId, body);
|
|
122
|
+
else pendingShipReports.delete(sessionId);
|
|
123
|
+
return r;
|
|
124
|
+
};
|
|
125
|
+
let flushingReports = false;
|
|
126
|
+
const flushWorkReports = async () => {
|
|
127
|
+
if (flushingReports) return;
|
|
128
|
+
if (pendingWorkReports.size === 0 && pendingShipReports.size === 0) return;
|
|
129
|
+
flushingReports = true;
|
|
130
|
+
try {
|
|
131
|
+
for (const [id, body] of [...pendingWorkReports]) {
|
|
132
|
+
const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
|
|
133
|
+
if (r !== 'retry') {
|
|
134
|
+
pendingWorkReports.delete(id);
|
|
135
|
+
workAttempts.delete(id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const [id, body] of [...pendingShipReports]) {
|
|
139
|
+
const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
|
|
140
|
+
if (r !== 'retry') pendingShipReports.delete(id);
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
flushingReports = false;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The work credential, ONE PER SESSION. The server binds each minted token
|
|
149
|
+
* to the sessionId in the mint body and the MCP layer refuses it for any
|
|
150
|
+
* other session, so a process-wide token would fail every tab but the one
|
|
151
|
+
* that minted it. Cached per session, re-minted near expiry (the endpoint
|
|
152
|
+
* rotates on every mint; per-session chaining means no turn is in flight
|
|
153
|
+
* for the session when its next turn mints). 404 means the server no longer
|
|
154
|
+
* holds that session for this fleet — a fact for the turn to settle with,
|
|
155
|
+
* not a retry.
|
|
156
|
+
*/
|
|
157
|
+
const workTokens = new Map(); // sessionId -> { token, mintedAt }
|
|
158
|
+
const mintWorkToken = async (sessionId, force = false) => {
|
|
159
|
+
const cached = workTokens.get(sessionId);
|
|
160
|
+
const freshEnoughS = getLeaseTtl() - REFRESH_BEFORE_SECONDS;
|
|
161
|
+
if (cached && !force && (Date.now() - cached.mintedAt) / 1000 < freshEnoughS)
|
|
162
|
+
return { token: cached.token };
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(WORK_TOKEN_URL, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
168
|
+
'User-Agent': USER_AGENT,
|
|
169
|
+
'Content-Type': 'application/json',
|
|
170
|
+
},
|
|
171
|
+
signal: AbortSignal.timeout(30_000),
|
|
172
|
+
body: JSON.stringify({ sessionId }),
|
|
173
|
+
});
|
|
174
|
+
if (res.status === 404) return { gone: true };
|
|
175
|
+
if (!res.ok) return null;
|
|
176
|
+
const token = (await res.json().catch(() => null))?.data?.token ?? null;
|
|
177
|
+
if (!token) return null;
|
|
178
|
+
workTokens.set(sessionId, { token, mintedAt: Date.now() });
|
|
179
|
+
return { token };
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* This tab's worktree — its held context, expressed as a place, ON A BRANCH.
|
|
187
|
+
*
|
|
188
|
+
* Fresh: branch `session/<id>` off the current base. Existing: touched not at
|
|
189
|
+
* all — no fetch-reset-clean like a plan directory, because the dirty state
|
|
190
|
+
* is the point. If the directory was retired but the branch survives, the
|
|
191
|
+
* worktree re-attaches to the branch and the committed work is still there.
|
|
192
|
+
*/
|
|
193
|
+
const sessionWtFor = (sessionId, baseAt) => {
|
|
194
|
+
if (!isSafePathSegment(sessionId)) return null;
|
|
195
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
196
|
+
const fresh = !existsSync(wt);
|
|
197
|
+
if (fresh) {
|
|
198
|
+
const branch = `session/${sessionId}`;
|
|
199
|
+
// `baseAt` is the adoption override: a tab born from a terminal session
|
|
200
|
+
// branches from THAT checkout's HEAD, because the conversation being
|
|
201
|
+
// resumed was had against those commits — putting it on the project base
|
|
202
|
+
// would hand it a repo state it has never seen. Everything else is
|
|
203
|
+
// unchanged, the attach fallback included: a surviving branch already
|
|
204
|
+
// chose its base, and re-basing it here would move committed work.
|
|
205
|
+
const at = baseAt || baseRef;
|
|
206
|
+
try {
|
|
207
|
+
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
208
|
+
} catch {
|
|
209
|
+
git(['worktree', 'prune'], repoRoot);
|
|
210
|
+
try {
|
|
211
|
+
// The branch may already exist (a retired directory's work) — attach.
|
|
212
|
+
git(['worktree', 'add', wt, branch], repoRoot);
|
|
213
|
+
} catch {
|
|
214
|
+
try {
|
|
215
|
+
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Synced env into the fresh worktree, exactly like a task checkout gets
|
|
222
|
+
// (worktreeFor): a tab builds and runs dev servers here, and without the
|
|
223
|
+
// bundle every session build was missing its .env while dispatched runs
|
|
224
|
+
// got theirs. Only on creation — a live directory's env belongs to the
|
|
225
|
+
// session, same as a resumed task tree. Ship's dirty-check is safe by
|
|
226
|
+
// construction: materializeInto writes ONLY gitignored paths (it refuses
|
|
227
|
+
// otherwise), and ignored files never appear in `git status --porcelain`.
|
|
228
|
+
// Best-effort, like everywhere else — the session still builds; paths
|
|
229
|
+
// that need secrets may 500.
|
|
230
|
+
try {
|
|
231
|
+
materializeInto(wt);
|
|
232
|
+
} catch {
|
|
233
|
+
/* best-effort */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { wt, fresh };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A file in the worktree's PRIVATE git dir (…/.git/worktrees/<name>). It
|
|
241
|
+
* travels with the worktree, dies with `git worktree remove`, and is
|
|
242
|
+
* invisible to `git status` — so nothing stored here can ever make the
|
|
243
|
+
* session look dirty (a dirty tree refuses ships). A marker file in the
|
|
244
|
+
* working tree itself would show up as an untracked path and block every
|
|
245
|
+
* ship of an otherwise-clean session.
|
|
246
|
+
*/
|
|
247
|
+
const sessionMetaPath = (wt, name) => {
|
|
248
|
+
try {
|
|
249
|
+
return join(git(['rev-parse', '--absolute-git-dir'], wt), name);
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Carry a terminal checkout's DIRTY state into a fresh adopt worktree. The
|
|
257
|
+
* source is strictly READ-ONLY — nothing here writes to it, because it is
|
|
258
|
+
* the human's own checkout and adoption promises to leave it exactly as the
|
|
259
|
+
* closed terminal did. Tracked changes travel as one binary patch staged
|
|
260
|
+
* through the worktree's PRIVATE git dir (invisible to status, dies with the
|
|
261
|
+
* tree); untracked files are copied one by one, skipping anything over 5MB.
|
|
262
|
+
*
|
|
263
|
+
* Returns '' or ONE bracketed line for the turn's prompt: a carry problem is
|
|
264
|
+
* the AGENT's to explain to the user, never a reason to fail the adoption —
|
|
265
|
+
* the conversation is the thing being adopted, and it resumes either way.
|
|
266
|
+
*/
|
|
267
|
+
const carryDirtyState = (srcCwd, wt) => {
|
|
268
|
+
const problems = [];
|
|
269
|
+
try {
|
|
270
|
+
// A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
|
|
271
|
+
// text file) must round-trip byte-exact or the apply corrupts what it
|
|
272
|
+
// carries. 64MB of headroom — a dirtier tree than that fails the read
|
|
273
|
+
// here and is SAID, below, rather than half-applied.
|
|
274
|
+
const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
|
|
275
|
+
cwd: srcCwd,
|
|
276
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
277
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
278
|
+
});
|
|
279
|
+
if (patch.length) {
|
|
280
|
+
const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch');
|
|
281
|
+
if (!patchPath) throw new Error('no private git dir to stage the patch in');
|
|
282
|
+
try {
|
|
283
|
+
writeFileSync(patchPath, patch);
|
|
284
|
+
git(['apply', '--whitespace=nowarn', patchPath], wt);
|
|
285
|
+
} finally {
|
|
286
|
+
try {
|
|
287
|
+
rmSync(patchPath, { force: true });
|
|
288
|
+
} catch {
|
|
289
|
+
/* best-effort — the private git dir dies with the worktree anyway */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
problems.push(
|
|
295
|
+
'their uncommitted TRACKED changes did not carry over (they are still in the terminal checkout, untouched)'
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const skipped = [];
|
|
300
|
+
for (const rel of splitNul(
|
|
301
|
+
gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], srcCwd)
|
|
302
|
+
)) {
|
|
303
|
+
try {
|
|
304
|
+
const from = join(srcCwd, rel);
|
|
305
|
+
// lstat, not stat: a symlink is carried as itself, and its own size
|
|
306
|
+
// is what the 5MB budget judges — never the file it points at.
|
|
307
|
+
if (lstatSync(from).size > 5 * 1024 * 1024) {
|
|
308
|
+
skipped.push(rel);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const to = join(wt, rel);
|
|
312
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
313
|
+
cpSync(from, to);
|
|
314
|
+
} catch {
|
|
315
|
+
skipped.push(rel);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (skipped.length) {
|
|
319
|
+
problems.push(
|
|
320
|
+
`${skipped.length} untracked file${skipped.length === 1 ? '' : 's'} did not carry (over 5MB or unreadable): ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', …' : ''}`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
problems.push(
|
|
325
|
+
'untracked files could not be listed in the terminal checkout, so none were carried'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return problems.length
|
|
329
|
+
? `[ADOPTION NOTE from the daemon — tell the user plainly at the start of your reply: ${problems.join('; ')}.]`
|
|
330
|
+
: '';
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* WHICH CLI drives this session — picked ONCE, on the first turn, and pinned
|
|
335
|
+
* in the worktree's meta dir. The held context belongs to the CLI that made
|
|
336
|
+
* it: `--continue` under a different binary is a different brain wearing the
|
|
337
|
+
* session's half-finished state (the dispatch path pins heldRuntime for the
|
|
338
|
+
* same reason). If the pinned CLI has left the machine, the turn settles
|
|
339
|
+
* honestly instead of substituting. A retired-and-reattached directory has
|
|
340
|
+
* no marker and no held context either, so re-picking there is correct.
|
|
341
|
+
* Returns { id } | { id: null } (nothing installed) | { missing: label } |
|
|
342
|
+
* { unsupported: label } (pinned to a runtime no session can run on).
|
|
343
|
+
*
|
|
344
|
+
* SESSION-CAPABLE means rt.mcp is truthy, and the gate is not optional:
|
|
345
|
+
* a session turn hands its per-session token over a real MCP config, so
|
|
346
|
+
* `pickRuntimeFor('build')` is the WRONG question here — it also says yes
|
|
347
|
+
* to the MEDIATED build path (Antigravity, mcp: null), and a session pinned
|
|
348
|
+
* that way threw in mcpFor on every turn, failing the tab with an internal
|
|
349
|
+
* error instead of a sentence.
|
|
350
|
+
*/
|
|
351
|
+
const sessionCapable = (rid) => Boolean(RUNTIMES[rid]?.mcp) && canRun(RUNTIMES[rid], 'build');
|
|
352
|
+
const sessionRuntime = (wt) => {
|
|
353
|
+
const marker = sessionMetaPath(wt, 'flowviant-runtime');
|
|
354
|
+
let pinned = null;
|
|
355
|
+
if (marker && existsSync(marker)) {
|
|
356
|
+
try {
|
|
357
|
+
pinned = readFileSync(marker, 'utf8').trim() || null;
|
|
358
|
+
} catch {
|
|
359
|
+
/* unreadable marker — re-pin below */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (pinned && RUNTIMES[pinned]) {
|
|
363
|
+
// A pin that names a non-session-capable runtime is settled honestly by
|
|
364
|
+
// the caller, not silently re-picked: re-picking would hand the held
|
|
365
|
+
// context to a different brain, which is the exact substitution the pin
|
|
366
|
+
// exists to prevent.
|
|
367
|
+
if (!sessionCapable(pinned)) return { unsupported: RUNTIMES[pinned].label || pinned };
|
|
368
|
+
const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
|
|
369
|
+
return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
|
|
370
|
+
}
|
|
371
|
+
// The fresh pick, gated the same way — Claude first when it qualifies, for
|
|
372
|
+
// the reason pickRuntimeFor gives: the prompts were tuned against it.
|
|
373
|
+
const rows = detectRuntimes();
|
|
374
|
+
const okFor = (rid) => sessionCapable(rid) && Boolean(rows.find((r) => r.id === rid)?.installed);
|
|
375
|
+
const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
|
|
376
|
+
if (!id) return { id: null };
|
|
377
|
+
if (marker) {
|
|
378
|
+
try {
|
|
379
|
+
writeFileSync(marker, id);
|
|
380
|
+
} catch {
|
|
381
|
+
/* best-effort — an unpinnable session just re-picks next turn */
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { id };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The spawn lock: the pid of the CLI currently live in this worktree. A
|
|
389
|
+
* restarted daemon must not put a second Claude into a directory the orphan
|
|
390
|
+
* of its previous life is still editing — two CLIs appending to one held
|
|
391
|
+
* conversation is exactly the incoherence workChains prevents in-process,
|
|
392
|
+
* and the lock extends that guarantee across a restart. A dead pid is a
|
|
393
|
+
* stale lock (removed here); a live one means "come back next poll".
|
|
394
|
+
*/
|
|
395
|
+
const turnLockedByLivePid = (lockPath) => {
|
|
396
|
+
if (!lockPath || !existsSync(lockPath)) return false;
|
|
397
|
+
let pid = 0;
|
|
398
|
+
try {
|
|
399
|
+
pid = Number(readFileSync(lockPath, 'utf8').trim());
|
|
400
|
+
} catch {
|
|
401
|
+
/* unreadable — treat as stale */
|
|
402
|
+
}
|
|
403
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
404
|
+
try {
|
|
405
|
+
process.kill(pid, 0);
|
|
406
|
+
return true; // signal 0 delivered — the process is alive
|
|
407
|
+
} catch (e) {
|
|
408
|
+
if (e.code === 'EPERM') return true; // alive, just not ours to signal
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
rmSync(lockPath, { force: true }); // dead holder — clear the stale lock
|
|
413
|
+
} catch {
|
|
414
|
+
/* best-effort */
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Live session-turn CLI children. The daemon's teardown SIGTERMs them: an
|
|
421
|
+
* orphaned CLI keeps editing the session worktree and burning quota after
|
|
422
|
+
* the daemon is gone. Each child's pid-lock is deliberately LEFT IN PLACE —
|
|
423
|
+
* a CLI can trap SIGTERM to finish an in-flight request and outlive this
|
|
424
|
+
* loop by seconds, and removing the lock in the same tick handed the
|
|
425
|
+
* restarted daemon a green light to spawn a second CLI into the same held
|
|
426
|
+
* context. turnLockedByLivePid already covers both outcomes: it waits while
|
|
427
|
+
* the pid lives and clears the lock once it is dead.
|
|
428
|
+
*/
|
|
429
|
+
const workChildren = new Map(); // child process -> lockPath | null
|
|
430
|
+
const shutdownWork = () => {
|
|
431
|
+
for (const [ch] of workChildren) {
|
|
432
|
+
try {
|
|
433
|
+
ch.kill('SIGTERM');
|
|
434
|
+
} catch {
|
|
435
|
+
/* best-effort */
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
workChildren.clear();
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Retire the worktrees of sessions the server says are CLOSED.
|
|
443
|
+
*
|
|
444
|
+
* `activeWorkSessions` on the roster is the list of this fleet's LIVE
|
|
445
|
+
* sessions; a directory whose id is absent belongs to a tab its owner
|
|
446
|
+
* closed, and the directory — never the branch: committed work survives on
|
|
447
|
+
* `session/<id>`, and ship re-attaches to it — is returned to disk. NEVER
|
|
448
|
+
* by count: the old cap-12 retirement destroyed live sessions on shared
|
|
449
|
+
* machines. When the roster omits the field entirely (older server),
|
|
450
|
+
* absence of signal is not a close — retire nothing.
|
|
451
|
+
*/
|
|
452
|
+
const retireWorkSessions = (activeIds) => {
|
|
453
|
+
if (!Array.isArray(activeIds)) return;
|
|
454
|
+
const dir = join(baseDir, 'sessions');
|
|
455
|
+
if (!existsSync(dir)) return;
|
|
456
|
+
let ids;
|
|
457
|
+
try {
|
|
458
|
+
ids = readdirSync(dir);
|
|
459
|
+
} catch {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const live = new Set(activeIds);
|
|
463
|
+
let removed = 0;
|
|
464
|
+
for (const id of ids) {
|
|
465
|
+
if (live.has(id)) continue;
|
|
466
|
+
if (workChains.has(id) || shipping.has(id)) continue; // still draining here
|
|
467
|
+
const wt = join(dir, id);
|
|
468
|
+
try {
|
|
469
|
+
// Uncommitted work is the human's — a resource sweep does not outrank
|
|
470
|
+
// it, closed tab or not. (The non-force remove would refuse anyway;
|
|
471
|
+
// the explicit check keeps the intent legible.)
|
|
472
|
+
if (git(['status', '--porcelain'], wt) !== '') continue;
|
|
473
|
+
git(['worktree', 'remove', wt], repoRoot); // non-force; the branch survives
|
|
474
|
+
workTokens.delete(id);
|
|
475
|
+
removed++;
|
|
476
|
+
} catch {
|
|
477
|
+
/* not cleanly removable — leave it */
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (removed) {
|
|
481
|
+
try {
|
|
482
|
+
git(['worktree', 'prune'], repoRoot);
|
|
483
|
+
} catch {
|
|
484
|
+
/* best effort */
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const processWorkTurns = (jobs) => {
|
|
490
|
+
for (const job of jobs ?? []) {
|
|
491
|
+
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
492
|
+
if (workAnswering.has(job.id)) continue;
|
|
493
|
+
// The turn already RAN and its answer sits in the delivery queue — never
|
|
494
|
+
// run it again while the report is merely undelivered.
|
|
495
|
+
if (pendingWorkReports.has(job.id)) continue;
|
|
496
|
+
workAnswering.add(job.id);
|
|
497
|
+
chainFor(job.sessionId, async () => {
|
|
498
|
+
try {
|
|
499
|
+
const tries = workAttempts.get(job.id) ?? 0;
|
|
500
|
+
if (tries >= MAX_WORK_TRIES) {
|
|
501
|
+
// Out of local tries: SETTLE, don't skip — a silently skipped turn
|
|
502
|
+
// strands the tab for the server's whole 24h expiry window.
|
|
503
|
+
await settleWorkTurn(job.id, {
|
|
504
|
+
ok: false,
|
|
505
|
+
answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
|
|
506
|
+
});
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
note(
|
|
510
|
+
`${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
|
|
511
|
+
);
|
|
512
|
+
// WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
|
|
513
|
+
// which is what every tab ran on until now). The phase-2 hook: this
|
|
514
|
+
// daemon drives Claude tabs only, and a runtime it cannot honor is
|
|
515
|
+
// settled honestly — never answered by a different brain wearing the
|
|
516
|
+
// session's name.
|
|
517
|
+
if (job.runtime && job.runtime !== 'claude') {
|
|
518
|
+
await settleWorkTurn(job.id, {
|
|
519
|
+
ok: false,
|
|
520
|
+
answer: `This machine's daemon serves Claude tabs only for now — runtime '${job.runtime}' isn't supported yet.`,
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// ── ADOPTION: a tab born from a TERMINAL session ────────────────
|
|
525
|
+
// The server sends `adopt {id, cwd}` only while the session has no
|
|
526
|
+
// sessionRef — no turn has ever spoken from a worktree here — and
|
|
527
|
+
// the first turn resumes the terminal conversation by forking it
|
|
528
|
+
// into the tab's own worktree. Everything the server asserts is
|
|
529
|
+
// re-validated MACHINE-side: the id shape, the source directory,
|
|
530
|
+
// and — decisive — that the terminal is actually closed, because
|
|
531
|
+
// forking a session someone is still typing into puts two Claudes
|
|
532
|
+
// on one conversation.
|
|
533
|
+
const adopting = Boolean(job.adopt) && !job.sessionRef;
|
|
534
|
+
let srcHead = null;
|
|
535
|
+
let adoptSrc = null; // the validated, realpath'd source checkout
|
|
536
|
+
if (adopting) {
|
|
537
|
+
if (
|
|
538
|
+
typeof job.adopt.id !== 'string' ||
|
|
539
|
+
!/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
|
|
540
|
+
) {
|
|
541
|
+
await settleWorkTurn(job.id, {
|
|
542
|
+
ok: false,
|
|
543
|
+
answer: 'that terminal session id is not one this machine can resume',
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
let srcCwd = null;
|
|
548
|
+
try {
|
|
549
|
+
srcCwd = realpathSync(String(job.adopt.cwd ?? ''));
|
|
550
|
+
if (!statSync(srcCwd).isDirectory()) srcCwd = null;
|
|
551
|
+
} catch {
|
|
552
|
+
srcCwd = null;
|
|
553
|
+
}
|
|
554
|
+
if (!srcCwd) {
|
|
555
|
+
await settleWorkTurn(job.id, {
|
|
556
|
+
ok: false,
|
|
557
|
+
answer: "the terminal session's directory no longer exists on the machine",
|
|
558
|
+
});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
// Inside the repo, outside the daemon's own worktrees: an adopt
|
|
562
|
+
// source is a HUMAN's checkout, and one of our directories showing
|
|
563
|
+
// up here means a stale or confused offer, not a session to fork.
|
|
564
|
+
const under = (p, root) =>
|
|
565
|
+
p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
|
|
566
|
+
let realRoot = repoRoot;
|
|
567
|
+
let realBase = baseDir;
|
|
568
|
+
try {
|
|
569
|
+
realRoot = realpathSync(repoRoot);
|
|
570
|
+
} catch {
|
|
571
|
+
/* keep the literal path */
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
realBase = realpathSync(baseDir);
|
|
575
|
+
} catch {
|
|
576
|
+
/* keep the literal path */
|
|
577
|
+
}
|
|
578
|
+
if (!under(srcCwd, realRoot)) {
|
|
579
|
+
await settleWorkTurn(job.id, {
|
|
580
|
+
ok: false,
|
|
581
|
+
answer: "the terminal session's directory is outside this project's repository",
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (under(srcCwd, realBase)) {
|
|
586
|
+
await settleWorkTurn(job.id, {
|
|
587
|
+
ok: false,
|
|
588
|
+
answer:
|
|
589
|
+
"that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
srcHead = git(['rev-parse', 'HEAD'], srcCwd);
|
|
595
|
+
} catch {
|
|
596
|
+
await settleWorkTurn(job.id, {
|
|
597
|
+
ok: false,
|
|
598
|
+
answer:
|
|
599
|
+
"the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (isTerminalSessionLive(job.adopt.id)) {
|
|
604
|
+
await settleWorkTurn(job.id, {
|
|
605
|
+
ok: false,
|
|
606
|
+
answer:
|
|
607
|
+
'That terminal session is still open on the machine — close it there first, then adopt.',
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
adoptSrc = srcCwd;
|
|
612
|
+
}
|
|
613
|
+
// Based at the SOURCE's HEAD when adopting — the resumed
|
|
614
|
+
// conversation was had against those commits, not the project base.
|
|
615
|
+
const dir = sessionWtFor(job.sessionId, adopting ? srcHead : undefined);
|
|
616
|
+
if (!dir) {
|
|
617
|
+
await settleWorkTurn(job.id, {
|
|
618
|
+
ok: false,
|
|
619
|
+
answer:
|
|
620
|
+
'the session worktree could not be opened on the machine — check the daemon log',
|
|
621
|
+
});
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// A live CLI is ALREADY in this worktree — this daemon's previous
|
|
625
|
+
// life, most likely; the lock outlives a restart. Leave the job
|
|
626
|
+
// pending and look again next poll; spawning a second CLI would put
|
|
627
|
+
// two Claudes in one held context. Costs no attempt: nothing ran.
|
|
628
|
+
const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
|
|
629
|
+
if (turnLockedByLivePid(lockPath)) {
|
|
630
|
+
warn(
|
|
631
|
+
`a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
|
|
632
|
+
);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const rt = sessionRuntime(dir.wt);
|
|
636
|
+
if (rt.missing) {
|
|
637
|
+
await settleWorkTurn(job.id, {
|
|
638
|
+
ok: false,
|
|
639
|
+
answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
|
|
640
|
+
});
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (rt.unsupported) {
|
|
644
|
+
// A pin from before the session-capable gate existed can name a
|
|
645
|
+
// runtime no tab can run on (Antigravity has no MCP config, and
|
|
646
|
+
// the session's whole control plane rides one). An honest sentence
|
|
647
|
+
// beats the mcpFor throw this used to crash into every turn.
|
|
648
|
+
await settleWorkTurn(job.id, {
|
|
649
|
+
ok: false,
|
|
650
|
+
answer: `this session is pinned to ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
|
|
651
|
+
});
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (!rt.id) {
|
|
655
|
+
await settleWorkTurn(job.id, {
|
|
656
|
+
ok: false,
|
|
657
|
+
answer:
|
|
658
|
+
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (adopting && rt.id !== 'claude') {
|
|
663
|
+
// The adopt id names a CLAUDE conversation; only claude can fork
|
|
664
|
+
// it (--resume --fork-session). The runtimes registry backstops
|
|
665
|
+
// this with a loud throw, but a sentence here beats a stack there.
|
|
666
|
+
await settleWorkTurn(job.id, {
|
|
667
|
+
ok: false,
|
|
668
|
+
answer:
|
|
669
|
+
'adopting a terminal session needs Claude Code on the machine — install it, then try again',
|
|
670
|
+
});
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
let mint = await mintWorkToken(job.sessionId);
|
|
674
|
+
if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
|
|
675
|
+
if (mint?.gone) {
|
|
676
|
+
await settleWorkTurn(job.id, {
|
|
677
|
+
ok: false,
|
|
678
|
+
answer:
|
|
679
|
+
'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (!mint?.token) {
|
|
684
|
+
await settleWorkTurn(job.id, {
|
|
685
|
+
ok: false,
|
|
686
|
+
answer:
|
|
687
|
+
'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
|
|
688
|
+
});
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
// Resume iff a conversation is known to live in THIS directory: the
|
|
692
|
+
// server's sessionRef is only ever a path some turn actually SPOKE
|
|
693
|
+
// from (see the settle below), and it must match the directory we
|
|
694
|
+
// just opened. Anything else starts fresh IN the existing worktree —
|
|
695
|
+
// never a reset; the dirty state is the session.
|
|
696
|
+
const resume = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
|
|
697
|
+
// The dirty carry, on the adopt worktree's FIRST life only: a
|
|
698
|
+
// re-attempted adoption (the directory already exists) carried what
|
|
699
|
+
// it could the first time, and re-applying would double it. A carry
|
|
700
|
+
// problem never fails the adoption — it becomes one bracketed line
|
|
701
|
+
// in the prompt, so the AGENT tells the user what stayed behind.
|
|
702
|
+
let carryNote = '';
|
|
703
|
+
if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt);
|
|
704
|
+
const mcp = mcpFor(rt.id, mint.token, getMcpUrl());
|
|
705
|
+
// Attempts count RUNS: the infra refusals above consumed nothing and
|
|
706
|
+
// settled on their own terms.
|
|
707
|
+
workAttempts.set(job.id, tries + 1);
|
|
708
|
+
let out;
|
|
709
|
+
const spawned = []; // this turn's children, for the teardown registry
|
|
710
|
+
try {
|
|
711
|
+
const turnArgs = {
|
|
712
|
+
prompt: WORK_TURN_KICKOFF({
|
|
713
|
+
sessionId: job.sessionId,
|
|
714
|
+
sessionName: job.sessionName,
|
|
715
|
+
message: carryNote ? `${job.body}\n\n${carryNote}` : job.body,
|
|
716
|
+
askedByName: job.askedByName,
|
|
717
|
+
}),
|
|
718
|
+
// The adopt turn resumes the TERMINAL conversation by forking it
|
|
719
|
+
// into this cwd (claude: --resume <id> --fork-session). After it
|
|
720
|
+
// speaks once, the fork lives natively here and turn 2+ is the
|
|
721
|
+
// ordinary --continue resume path, unchanged.
|
|
722
|
+
...(adopting ? { adoptResumeId: job.adopt.id } : {}),
|
|
723
|
+
system: SYSTEM_WORK,
|
|
724
|
+
cwd: dir.wt,
|
|
725
|
+
mcpArgs: mcp.args,
|
|
726
|
+
mcpEnv: mcp.env,
|
|
727
|
+
runtime: rt.id,
|
|
728
|
+
label: c.cyan('[tab]'),
|
|
729
|
+
onSpawn: (ch) => {
|
|
730
|
+
if (!ch) return;
|
|
731
|
+
spawned.push(ch);
|
|
732
|
+
workChildren.set(ch, lockPath ?? null);
|
|
733
|
+
if (lockPath && ch.pid) {
|
|
734
|
+
try {
|
|
735
|
+
writeFileSync(lockPath, String(ch.pid));
|
|
736
|
+
} catch {
|
|
737
|
+
/* best-effort */
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
out = await runTurn({ ...turnArgs, resume });
|
|
743
|
+
// A resume that produced NOTHING usually means the held
|
|
744
|
+
// conversation is gone (a first turn that crashed before writing
|
|
745
|
+
// state, a wiped CLI dir). Retry once fresh in the SAME worktree —
|
|
746
|
+
// never reset — instead of bricking the tab forever. NEVER on an
|
|
747
|
+
// adopt turn (`resume` is structurally false there, and the guard
|
|
748
|
+
// says so out loud): a fresh conversation would silently discard
|
|
749
|
+
// the adoption and answer as a new session wearing its name — the
|
|
750
|
+
// empty adopt turn settles failed below instead.
|
|
751
|
+
if (!adopting && resume && !(out || '').trim())
|
|
752
|
+
out = await runTurn({ ...turnArgs, resume: false });
|
|
753
|
+
} finally {
|
|
754
|
+
for (const ch of spawned) workChildren.delete(ch);
|
|
755
|
+
if (lockPath) {
|
|
756
|
+
try {
|
|
757
|
+
rmSync(lockPath, { force: true });
|
|
758
|
+
} catch {
|
|
759
|
+
/* best-effort */
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
763
|
+
}
|
|
764
|
+
const answer = (out || '').trim();
|
|
765
|
+
// No output at all smells like a dead MCP credential (the lane
|
|
766
|
+
// workers' no-sentinel case) — drop the cached token so the next
|
|
767
|
+
// turn re-mints instead of failing the same way forever.
|
|
768
|
+
if (!answer) workTokens.delete(job.sessionId);
|
|
769
|
+
if (adopting && !answer) {
|
|
770
|
+
// The fork came back with nothing — the terminal session's
|
|
771
|
+
// transcript is most likely gone (cleaned, expired, deleted). Say
|
|
772
|
+
// exactly that; no sessionRef is recorded, so the server keeps
|
|
773
|
+
// offering the adoption and a retry after the user checks is cheap.
|
|
774
|
+
await settleWorkTurn(job.id, {
|
|
775
|
+
ok: false,
|
|
776
|
+
answer: "Couldn't resume the terminal session — it may have been removed.",
|
|
777
|
+
});
|
|
778
|
+
warn('adopt turn produced no output — settled as failed');
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
await settleWorkTurn(job.id, {
|
|
782
|
+
ok: answer.length > 0,
|
|
783
|
+
answer:
|
|
784
|
+
answer.length > 0
|
|
785
|
+
? // Scrub: a reply can quote config or env-adjacent code.
|
|
786
|
+
envScrub(answer).slice(0, 16000)
|
|
787
|
+
: 'the turn produced no output on the machine — its CLI may be signed out; try again',
|
|
788
|
+
// Only a turn that actually SPOKE proves a conversation lives
|
|
789
|
+
// here. Recording the path unconditionally is how a crashed first
|
|
790
|
+
// turn used to brick resume for the session's whole life.
|
|
791
|
+
...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
|
|
792
|
+
});
|
|
793
|
+
if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
|
|
794
|
+
else warn('session turn produced no output — settled as failed');
|
|
795
|
+
} catch (e) {
|
|
796
|
+
await settleWorkTurn(job.id, {
|
|
797
|
+
ok: false,
|
|
798
|
+
// Scrub, like every string that leaves this machine: an exception
|
|
799
|
+
// routinely quotes command output, and command output can quote a
|
|
800
|
+
// synced secret.
|
|
801
|
+
answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
|
|
802
|
+
});
|
|
803
|
+
warn(`session turn failed: ${e?.message ?? e}`);
|
|
804
|
+
} finally {
|
|
805
|
+
workAnswering.delete(job.id);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
// Ship — a session's branch merging to main, on the human's word.
|
|
812
|
+
//
|
|
813
|
+
// --no-ff, NEVER squash: every delivered card carries commit shas as its
|
|
814
|
+
// receipts, and a squash would point them all at commits that no longer
|
|
815
|
+
// exist on main. Sequence: idempotency FIRST (a re-offered job after a lost
|
|
816
|
+
// report recovers its receipts and re-reports — it must never re-merge, and
|
|
817
|
+
// never be refused by checks that judge a merge this job already made).
|
|
818
|
+
// Then two paths. A LIVE session: re-open the worktree if it was retired,
|
|
819
|
+
// defer while a turn's CLI holds it, refuse a dirty worktree
|
|
820
|
+
// (auto-committing someone's mid-thought state is not shipping, it is
|
|
821
|
+
// guessing), refuse a worktree that left its own branch, fold main INTO the
|
|
822
|
+
// branch first so conflicts surface where the session can resolve them,
|
|
823
|
+
// then merge THE RESOLVED TIP outward through a throwaway worktree so
|
|
824
|
+
// nobody's checkout moves — receipts and merged ref are the same sha by
|
|
825
|
+
// construction. An ENDED session (absent from the roster's
|
|
826
|
+
// activeWorkSessions): the BRANCH is the session now — nobody can commit,
|
|
827
|
+
// discard, or resolve anything in its directory, so the checks whose
|
|
828
|
+
// remedies address a live tab don't apply; merge the tip directly through
|
|
829
|
+
// the throwaway, and a conflict fails honestly. Every exit reports
|
|
830
|
+
// ship-done exactly once — except a deliberate deferral, re-offered next
|
|
831
|
+
// poll; a ship that failed silently leaves the human believing their work
|
|
832
|
+
// is on main.
|
|
833
|
+
const processShipJobs = (jobs, activeIds) => {
|
|
834
|
+
// Field absent (older server) = no liveness signal: treat every session
|
|
835
|
+
// as live, which keeps the stricter checks.
|
|
836
|
+
const liveIds = Array.isArray(activeIds) ? new Set(activeIds) : null;
|
|
837
|
+
for (const job of jobs ?? []) {
|
|
838
|
+
if (!job || typeof job.sessionId !== 'string') continue;
|
|
839
|
+
if (shipping.has(job.sessionId)) continue;
|
|
840
|
+
// The merge already LANDED and only the report is owed — flushing
|
|
841
|
+
// delivers it; re-running the ship would misread its own success.
|
|
842
|
+
if (pendingShipReports.has(job.sessionId)) continue;
|
|
843
|
+
shipping.add(job.sessionId);
|
|
844
|
+
// The SESSION's own chain, never a ship-wide one: a ship must not run
|
|
845
|
+
// git in this worktree while a turn's CLI is live in it. `shipping`
|
|
846
|
+
// (above) keeps overlapping polls from queueing the same job twice.
|
|
847
|
+
chainFor(job.sessionId, async () => {
|
|
848
|
+
let settled = false;
|
|
849
|
+
let deferred = false;
|
|
850
|
+
const done = async (payload) => {
|
|
851
|
+
if (settled) return;
|
|
852
|
+
settled = true;
|
|
853
|
+
await settleShip(job.sessionId, payload);
|
|
854
|
+
};
|
|
855
|
+
try {
|
|
856
|
+
if (!isSafePathSegment(job.sessionId)) {
|
|
857
|
+
await done({ ok: false, error: 'invalid session id' });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
|
|
861
|
+
const branch = `session/${job.sessionId}`;
|
|
862
|
+
const wt = join(baseDir, 'sessions', job.sessionId);
|
|
863
|
+
let branchExists = true;
|
|
864
|
+
try {
|
|
865
|
+
git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot);
|
|
866
|
+
} catch {
|
|
867
|
+
branchExists = false;
|
|
868
|
+
}
|
|
869
|
+
// "Nothing to ship" is a statement about the BRANCH. A retired
|
|
870
|
+
// directory is not a missing session — retirement promises that
|
|
871
|
+
// committed work survives, and ship re-attaches below to keep it.
|
|
872
|
+
if (!existsSync(wt) && !branchExists) {
|
|
873
|
+
await done({
|
|
874
|
+
ok: false,
|
|
875
|
+
error: 'nothing to ship — this session has no branch on this machine',
|
|
876
|
+
});
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
881
|
+
} catch {
|
|
882
|
+
/* offline fetch — merge against what we have */
|
|
883
|
+
}
|
|
884
|
+
const ancestorOfBase = (ref) => {
|
|
885
|
+
try {
|
|
886
|
+
git(['merge-base', '--is-ancestor', ref, baseRef], repoRoot);
|
|
887
|
+
return true;
|
|
888
|
+
} catch {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
// The machine may have no git identity, and a merge COMMIT needs
|
|
893
|
+
// one. Prefer the user's own config; fall back to the daemon's (the
|
|
894
|
+
// same fallback checkpointWip uses) so a bare machine doesn't fail
|
|
895
|
+
// the fold with "Please tell me who you are".
|
|
896
|
+
let idEnv = null;
|
|
897
|
+
try {
|
|
898
|
+
git(['config', 'user.email'], repoRoot);
|
|
899
|
+
} catch {
|
|
900
|
+
idEnv = {
|
|
901
|
+
GIT_AUTHOR_NAME: 'Flowviant',
|
|
902
|
+
GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
|
|
903
|
+
GIT_COMMITTER_NAME: 'Flowviant',
|
|
904
|
+
GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
const gitMerge = (args, cwd) =>
|
|
908
|
+
execFileSync('git', args, {
|
|
909
|
+
cwd,
|
|
910
|
+
encoding: 'utf8',
|
|
911
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
912
|
+
...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
|
|
913
|
+
});
|
|
914
|
+
// Receipts for a range: --no-merges, because fold commits describe
|
|
915
|
+
// plumbing, not work.
|
|
916
|
+
const logCommits = (range) =>
|
|
917
|
+
git(['log', range, '--no-merges', '--format=%H%x09%s'], repoRoot)
|
|
918
|
+
.split('\n')
|
|
919
|
+
.filter(Boolean)
|
|
920
|
+
.map((l) => {
|
|
921
|
+
const [sha, ...rest] = l.split('\t');
|
|
922
|
+
return { sha, subject: envScrub(rest.join('\t')).slice(0, 200) };
|
|
923
|
+
});
|
|
924
|
+
// Merge outward through a throwaway worktree so no checkout moves.
|
|
925
|
+
// The throwaway dies on EVERY exit — success, conflict or throw —
|
|
926
|
+
// or the next ship of this session trips over its corpse.
|
|
927
|
+
const mergeOutward = (tip, count) => {
|
|
928
|
+
const tmp = join(baseDir, 'ship', job.sessionId);
|
|
929
|
+
try {
|
|
930
|
+
try {
|
|
931
|
+
git(['worktree', 'remove', '--force', tmp], repoRoot);
|
|
932
|
+
} catch {
|
|
933
|
+
/* not there — fine */
|
|
934
|
+
}
|
|
935
|
+
git(['worktree', 'add', '--detach', tmp, baseRef], repoRoot);
|
|
936
|
+
gitMerge(
|
|
937
|
+
[
|
|
938
|
+
'merge',
|
|
939
|
+
'--no-ff',
|
|
940
|
+
tip,
|
|
941
|
+
'-m',
|
|
942
|
+
`ship(${job.sessionName || job.sessionId.slice(0, 8)}): ${count} commit${count === 1 ? '' : 's'}`,
|
|
943
|
+
],
|
|
944
|
+
tmp
|
|
945
|
+
);
|
|
946
|
+
git(['push', 'origin', `HEAD:${baseBranchName(baseRef)}`], tmp);
|
|
947
|
+
} finally {
|
|
948
|
+
try {
|
|
949
|
+
git(['worktree', 'remove', '--force', tmp], repoRoot);
|
|
950
|
+
git(['worktree', 'prune'], repoRoot);
|
|
951
|
+
} catch {
|
|
952
|
+
/* best effort */
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
// Idempotency: base already contains the branch tip. A re-offered
|
|
957
|
+
// job after a lost report lands here — never a re-merge, and never
|
|
958
|
+
// "nothing to ship" AS A FAILURE for work that in fact shipped. The
|
|
959
|
+
// receipts must not die with the lost report: the --no-ff merge
|
|
960
|
+
// commit that carried the tip in holds it as its SECOND parent, so
|
|
961
|
+
// the original commit list is recoverable — settling with none
|
|
962
|
+
// would silently skip the reconciliation backstop for this branch.
|
|
963
|
+
if (branchExists && ancestorOfBase(branch)) {
|
|
964
|
+
const tip = git(['rev-parse', branch], repoRoot);
|
|
965
|
+
let commits = [];
|
|
966
|
+
try {
|
|
967
|
+
const m = git(['log', baseRef, '--merges', '--format=%H %P', '-n', '500'], repoRoot)
|
|
968
|
+
.split('\n')
|
|
969
|
+
.map((l) => l.trim().split(' '))
|
|
970
|
+
.find((p) => p.length >= 3 && p[2] === tip);
|
|
971
|
+
if (m) commits = logCommits(`${m[1]}..${tip}`);
|
|
972
|
+
} catch {
|
|
973
|
+
/* recovery is best-effort — an ok ship with no receipts beats a false failure */
|
|
974
|
+
}
|
|
975
|
+
await done({
|
|
976
|
+
ok: true,
|
|
977
|
+
commits,
|
|
978
|
+
note: `${baseBranchName(baseRef)} already contains this session's branch — nothing new to merge`,
|
|
979
|
+
});
|
|
980
|
+
ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const ended = liveIds ? !liveIds.has(job.sessionId) : false;
|
|
984
|
+
if (ended) {
|
|
985
|
+
// The tab is closed: no turn can commit, discard, or resolve
|
|
986
|
+
// anything in the directory, so a dirty worktree must not strand
|
|
987
|
+
// the branch's committed work in review forever. Ship the TIP.
|
|
988
|
+
if (!branchExists) {
|
|
989
|
+
await done({
|
|
990
|
+
ok: false,
|
|
991
|
+
error: 'nothing to ship — this session has no branch on this machine',
|
|
992
|
+
});
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const tip = git(['rev-parse', branch], repoRoot);
|
|
996
|
+
const commits = logCommits(`${baseRef}..${tip}`);
|
|
997
|
+
if (commits.length === 0) {
|
|
998
|
+
await done({
|
|
999
|
+
ok: false,
|
|
1000
|
+
error: 'nothing to ship — no commits on the session branch',
|
|
1001
|
+
});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
mergeOutward(tip, commits.length);
|
|
1006
|
+
} catch (e) {
|
|
1007
|
+
const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
|
|
1008
|
+
if (/conflict/i.test(detail)) {
|
|
1009
|
+
await done({
|
|
1010
|
+
ok: false,
|
|
1011
|
+
error:
|
|
1012
|
+
'conflicts with main — the tab is closed, so open a new session from this branch to resolve them, then ship again',
|
|
1013
|
+
});
|
|
1014
|
+
} else {
|
|
1015
|
+
const line = envScrub(
|
|
1016
|
+
String(detail)
|
|
1017
|
+
.split('\n')
|
|
1018
|
+
.find((l) => l.trim()) ?? 'git merge failed'
|
|
1019
|
+
);
|
|
1020
|
+
await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
|
|
1021
|
+
}
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
await done({ ok: true, commits });
|
|
1025
|
+
ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const dir = sessionWtFor(job.sessionId);
|
|
1029
|
+
if (!dir) {
|
|
1030
|
+
await done({
|
|
1031
|
+
ok: false,
|
|
1032
|
+
error: 'the session worktree could not be opened on this machine',
|
|
1033
|
+
});
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
// A live CLI is in this worktree — a restarted daemon's orphan
|
|
1037
|
+
// mid-turn (in-process the chain serializes, but the lock is the
|
|
1038
|
+
// only guarantee that survives a crash). Folding under it would
|
|
1039
|
+
// rewrite HEAD inside a held conversation; defer like the turn
|
|
1040
|
+
// path, and the job re-offers next poll.
|
|
1041
|
+
if (turnLockedByLivePid(sessionMetaPath(dir.wt, 'flowviant-turn.lock'))) {
|
|
1042
|
+
warn(
|
|
1043
|
+
`a turn is still running in "${job.sessionName || job.sessionId}" — ship waits for it`
|
|
1044
|
+
);
|
|
1045
|
+
deferred = true;
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (git(['status', '--porcelain'], dir.wt) !== '') {
|
|
1049
|
+
await done({
|
|
1050
|
+
ok: false,
|
|
1051
|
+
error:
|
|
1052
|
+
'the session has uncommitted changes — ask it to commit or discard them first',
|
|
1053
|
+
});
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
// What is checked out here must BE the session branch. Sessions may
|
|
1057
|
+
// create branches when asked — but then "ship" is ambiguous, and
|
|
1058
|
+
// folding+logging HEAD while merging the stale branch NAME once
|
|
1059
|
+
// shipped receipts for commits that never landed on main.
|
|
1060
|
+
let head = null;
|
|
1061
|
+
try {
|
|
1062
|
+
head = git(['symbolic-ref', '--short', 'HEAD'], dir.wt);
|
|
1063
|
+
} catch {
|
|
1064
|
+
/* detached */
|
|
1065
|
+
}
|
|
1066
|
+
if (head !== branch) {
|
|
1067
|
+
await done({
|
|
1068
|
+
ok: false,
|
|
1069
|
+
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`,
|
|
1070
|
+
});
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
// Fold main into the branch FIRST: conflicts land here, in the
|
|
1074
|
+
// session's own worktree, where the next turn can resolve them.
|
|
1075
|
+
try {
|
|
1076
|
+
gitMerge(['merge', '--no-edit', baseRef], dir.wt);
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
const detail = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
|
|
1079
|
+
// NEVER leave the session mid-merge: a MERGE_HEAD left behind puts
|
|
1080
|
+
// every later turn inside someone else's half-finished merge.
|
|
1081
|
+
try {
|
|
1082
|
+
git(['merge', '--abort'], dir.wt);
|
|
1083
|
+
} catch {
|
|
1084
|
+
/* nothing in progress */
|
|
1085
|
+
}
|
|
1086
|
+
if (/conflict/i.test(detail)) {
|
|
1087
|
+
await done({
|
|
1088
|
+
ok: false,
|
|
1089
|
+
error: 'conflicts with main — ask the session to resolve them, then ship again',
|
|
1090
|
+
});
|
|
1091
|
+
} else {
|
|
1092
|
+
// An honest error beats a fabricated conflict — the human can
|
|
1093
|
+
// only fix what they are told about.
|
|
1094
|
+
const line = envScrub(
|
|
1095
|
+
String(detail)
|
|
1096
|
+
.split('\n')
|
|
1097
|
+
.find((l) => l.trim()) ?? 'git merge failed'
|
|
1098
|
+
);
|
|
1099
|
+
await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
|
|
1100
|
+
}
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
// Resolve the EXACT sha to merge, then compute the receipts from it:
|
|
1104
|
+
// one X for both, so the ledger can never carry receipts for commits
|
|
1105
|
+
// that did not land.
|
|
1106
|
+
const tip = git(['rev-parse', branch], repoRoot);
|
|
1107
|
+
const commits = logCommits(`${baseRef}..${tip}`);
|
|
1108
|
+
if (commits.length === 0) {
|
|
1109
|
+
// Post-fold this is nearly unreachable (a zero-commit branch is an
|
|
1110
|
+
// ancestor of base, settled above) — but if the branch's commits
|
|
1111
|
+
// all exist on main already, say so truthfully.
|
|
1112
|
+
if (ancestorOfBase(tip)) {
|
|
1113
|
+
await done({ ok: true, commits: [], note: 'already merged — nothing new to ship' });
|
|
1114
|
+
} else {
|
|
1115
|
+
await done({
|
|
1116
|
+
ok: false,
|
|
1117
|
+
error: 'nothing to ship — no commits on the session branch',
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
mergeOutward(tip, commits.length);
|
|
1123
|
+
await done({ ok: true, commits });
|
|
1124
|
+
ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
warn(`ship failed: ${e?.message ?? e}`);
|
|
1127
|
+
await done({
|
|
1128
|
+
ok: false,
|
|
1129
|
+
error: envScrub(String(e?.message ?? 'the merge failed')).slice(0, 500),
|
|
1130
|
+
});
|
|
1131
|
+
} finally {
|
|
1132
|
+
if (!settled && !deferred) {
|
|
1133
|
+
// Belt over braces: NO exit path may leave the ship unreported —
|
|
1134
|
+
// a deferral is the one deliberate exception, re-offered next poll.
|
|
1135
|
+
await done({ ok: false, error: 'the ship did not complete — check the daemon log' });
|
|
1136
|
+
}
|
|
1137
|
+
shipping.delete(job.sessionId);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
return { flushWorkReports, processWorkTurns, processShipJobs, retireWorkSessions, shutdownWork };
|
|
1144
|
+
}
|