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/fleet.mjs CHANGED
@@ -5,7 +5,13 @@
5
5
  * MCP token, and only spawns Claude when the server says an agent has work.
6
6
  */
7
7
 
8
- import { mkdirSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
8
+ import {
9
+ mkdirSync,
10
+ existsSync,
11
+ rmSync,
12
+ readdirSync,
13
+ statSync,
14
+ } from 'node:fs';
9
15
  import { execFileSync } from 'node:child_process';
10
16
  import { createHash } from 'node:crypto';
11
17
  import { homedir } from 'node:os';
@@ -59,8 +65,6 @@ import {
59
65
  REGROUND_KICKOFF,
60
66
  SYSTEM_PLAN,
61
67
  PLAN_TURN_KICKOFF,
62
- SYSTEM_WORK,
63
- WORK_TURN_KICKOFF,
64
68
  SYSTEM_QUICK_EDIT,
65
69
  QUICK_EDIT_KICKOFF,
66
70
  } from './claude.mjs';
@@ -79,6 +83,8 @@ import {
79
83
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
80
84
  import { machineSnapshot } from './resources.mjs';
81
85
  import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
86
+ import { createWorkManager } from './work.mjs';
87
+ import { scanLocalSessions } from './localSessions.mjs';
82
88
 
83
89
  async function fetchRoster(haveIds) {
84
90
  const url = new URL(FLEET_URL);
@@ -217,6 +223,69 @@ function sampleDiffstat(cwd, baseRef, intentId, agentId) {
217
223
  };
218
224
  }
219
225
 
226
+ /**
227
+ * Terminal-session presence: tell the server which Claude sessions exist in
228
+ * this repo (localSessions.mjs reads them off Claude's own on-disk state), so
229
+ * the Workbench can offer "adopt this terminal session as a tab". Best-effort
230
+ * in exactly the way the env/runtimes blocks are — a presence report that can
231
+ * fail a poll is worse than no presence at all — with three quiet economies:
232
+ * the scan runs at most once a minute (the reconcile loop ticks far faster), a
233
+ * report identical to the last DELIVERED one is not re-sent, and a 404 means
234
+ * an older server that has never heard of the endpoint, after which this
235
+ * process stops asking (a deploy that adds it also restarts nothing on this
236
+ * machine, so silence-until-restart costs one daemon restart, not a feature).
237
+ */
238
+ const LOCAL_SESSIONS_URL = FLEET_URL.replace(/\/agents\/?$/, '/local-sessions');
239
+ const LOCAL_SESSIONS_SCAN_MS = 60_000;
240
+ // The web hides a report older than 10 minutes (presence must not linger as
241
+ // fact after the machine dies), so an UNCHANGED report is re-sent inside that
242
+ // window anyway — the re-send is the machine's heartbeat on this fact, and
243
+ // suppressing it entirely would blank the strip while everything still holds.
244
+ const LOCAL_SESSIONS_RESEND_MS = 5 * 60_000;
245
+ let localSessionsUnsupported = false; // the server 404'd — quiet until restart
246
+ let localSessionsScanAt = 0;
247
+ let localSessionsSent = null; // last payload the server ACCEPTED, stringified
248
+ let localSessionsSentAt = 0;
249
+ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
250
+ if (localSessionsUnsupported) return;
251
+ if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
252
+ localSessionsScanAt = Date.now();
253
+ let payload;
254
+ try {
255
+ // scanLocalSessions orders deterministically, so this string only changes
256
+ // when the facts on disk do — the dedup below compares whole payloads.
257
+ payload = JSON.stringify({ sessions: scanLocalSessions({ repoRoot, excludeDirs }) });
258
+ } catch {
259
+ return; // presence must never throw into the poll loop
260
+ }
261
+ if (payload === localSessionsSent && Date.now() - localSessionsSentAt < LOCAL_SESSIONS_RESEND_MS)
262
+ return;
263
+ try {
264
+ const res = await fetch(LOCAL_SESSIONS_URL, {
265
+ method: 'POST',
266
+ headers: {
267
+ Authorization: `Bearer ${FLEET_TOKEN}`,
268
+ 'User-Agent': USER_AGENT,
269
+ 'Content-Type': 'application/json',
270
+ },
271
+ signal: AbortSignal.timeout(15_000),
272
+ body: payload,
273
+ });
274
+ if (res.status === 404) {
275
+ localSessionsUnsupported = true; // older server — it REPLACED nothing here
276
+ return;
277
+ }
278
+ // Only an accepted report counts as sent; anything else forgets the
279
+ // last-sent payload so the next pass retries instead of dedup-suppressing
280
+ // a report the server never received.
281
+ localSessionsSent = res.ok ? payload : null;
282
+ localSessionsSentAt = res.ok ? Date.now() : 0;
283
+ } catch {
284
+ localSessionsSent = null;
285
+ localSessionsSentAt = 0;
286
+ }
287
+ }
288
+
220
289
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
221
290
  // base between tasks (fresh conversation), resume in place while on a blocker.
222
291
  async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
@@ -453,6 +522,7 @@ export async function runFleetDaemon() {
453
522
  const workers = new Map(); // agentId -> { state, promise, wt, label }
454
523
  let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
455
524
  let stream = null; // push channel handle (set once the loop is set up)
525
+ let workShutdown = null; // kills live session-turn CLIs (set with the work manager below)
456
526
 
457
527
  // Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
458
528
  // resumes in place on the next run (the task marker matches). Worktrees are
@@ -473,6 +543,14 @@ export async function runFleetDaemon() {
473
543
  } catch {
474
544
  /* best-effort */
475
545
  }
546
+ // Session-turn CLIs die with the daemon too: an orphan keeps editing the
547
+ // session worktree and burning quota, and its live-pid lock would make the
548
+ // restarted daemon skip that tab's turns for as long as it survived.
549
+ try {
550
+ workShutdown?.();
551
+ } catch {
552
+ /* best-effort */
553
+ }
476
554
  for (const [, w] of workers) {
477
555
  w.state.alive = false;
478
556
  try {
@@ -496,6 +574,15 @@ export async function runFleetDaemon() {
496
574
  teardown();
497
575
  process.exit(130);
498
576
  });
577
+ // A service manager stops the daemon with SIGTERM, not Ctrl+C. Without this
578
+ // handler every child survived a `systemctl stop` — the exact orphaning the
579
+ // teardown exists to prevent.
580
+ process.on('SIGTERM', () => {
581
+ console.log('');
582
+ note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
583
+ teardown();
584
+ process.exit(143);
585
+ });
499
586
  // Keep the daemon alive on a stray rejection. Many loops here are fire-and-
500
587
  // forget (`void drainWiki()`, dispatch, sync) and rely on their callees never
501
588
  // rejecting; Node ≥15 terminates the process on an unhandled rejection, which
@@ -1091,7 +1178,9 @@ export async function runFleetDaemon() {
1091
1178
  await reportMergeOutcome(CONSULT_DONE_URL, {
1092
1179
  consultId: job.id,
1093
1180
  ok: false,
1094
- answer: e?.message ?? 'the planning turn failed',
1181
+ // Scrub, like the success path: an exception routinely quotes
1182
+ // command output, and command output can quote a synced secret.
1183
+ answer: envScrub(String(e?.message ?? 'the planning turn failed')).slice(0, 2000),
1095
1184
  });
1096
1185
  warn(`planning turn failed: ${e?.message ?? e}`);
1097
1186
  } finally {
@@ -1103,312 +1192,23 @@ export async function runFleetDaemon() {
1103
1192
 
1104
1193
  // ── Work sessions — the Workbench tabs ─────────────────────────────────────
1105
1194
  //
1106
- // A tab is a held Claude session with BUILD permissions in a PERSISTENT
1107
- // worktree on its own branch. The opposite of a plan directory on both
1108
- // counts: nothing here is detached and nothing is ever reset uncommitted
1109
- // state between turns IS the session, and blowing it away would be closing
1110
- // the human's editor mid-thought.
1111
- const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
1112
- const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
1113
- const workAnswering = new Set();
1114
- const workAttempts = new Map(); // turn id -> tries
1115
- const MAX_WORK_TRIES = 3;
1116
- /** Per-SESSION serialization, parallel ACROSS sessions: turns within one tab
1117
- * must land in order (they share a directory and a context), but two tabs
1118
- * are two terminals — the human opened both on purpose. */
1119
- const workChains = new Map(); // sessionId -> Promise
1120
-
1121
- let workToken = null;
1122
- const mintWorkToken = async (force = false) => {
1123
- if (workToken && !force) return workToken;
1124
- try {
1125
- const res = await fetch(WORK_TOKEN_URL, {
1126
- method: 'POST',
1127
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
1128
- });
1129
- if (!res.ok) return null;
1130
- const data = await res.json().catch(() => null);
1131
- workToken = data?.data?.token ?? null;
1132
- return workToken;
1133
- } catch {
1134
- return null;
1135
- }
1136
- };
1137
-
1138
- /**
1139
- * This tab's worktree — its held context, expressed as a place, ON A BRANCH.
1140
- *
1141
- * Fresh: branch `session/<id>` off the current base. Existing: touched not at
1142
- * all — no fetch-reset-clean like a plan directory, because the dirty state
1143
- * is the point. If the directory was retired but the branch survives, the
1144
- * worktree re-attaches to the branch and the committed work is still there.
1145
- */
1146
- const sessionWtFor = (sessionId) => {
1147
- if (!isSafePathSegment(sessionId)) return null;
1148
- const wt = join(baseDir, 'sessions', sessionId);
1149
- const fresh = !existsSync(wt);
1150
- if (fresh) {
1151
- const branch = `session/${sessionId}`;
1152
- try {
1153
- git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
1154
- } catch {
1155
- git(['worktree', 'prune'], repoRoot);
1156
- try {
1157
- // The branch may already exist (a retired directory's work) — attach.
1158
- git(['worktree', 'add', wt, branch], repoRoot);
1159
- } catch {
1160
- try {
1161
- git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
1162
- } catch {
1163
- return null;
1164
- }
1165
- }
1166
- }
1167
- }
1168
- return { wt, fresh };
1169
- };
1170
-
1171
- /**
1172
- * Retire the least-recently-touched CLEAN session directories past the cap.
1173
- * A dirty worktree is never touched — uncommitted work is the human's, and a
1174
- * resource bound does not outrank it. Committed work survives retirement on
1175
- * the session branch either way.
1176
- */
1177
- const MAX_WORK_DIRS = 12;
1178
- const workTouched = new Map(); // sessionId -> ms
1179
- const retireIdleWorkSessions = () => {
1180
- const dir = join(baseDir, 'sessions');
1181
- if (!existsSync(dir)) return;
1182
- let ids;
1183
- try {
1184
- ids = readdirSync(dir);
1185
- } catch {
1186
- return;
1187
- }
1188
- if (ids.length <= MAX_WORK_DIRS) return;
1189
- const oldestFirst = ids.sort(
1190
- (a, b) => (workTouched.get(a) ?? 0) - (workTouched.get(b) ?? 0)
1191
- );
1192
- let excess = ids.length - MAX_WORK_DIRS;
1193
- for (const id of oldestFirst) {
1194
- if (excess <= 0) break;
1195
- const wt = join(dir, id);
1196
- try {
1197
- if (git(['status', '--porcelain'], wt).trim() !== '') continue; // dirty — skip
1198
- git(['worktree', 'remove', wt], repoRoot);
1199
- workTouched.delete(id);
1200
- excess--;
1201
- } catch {
1202
- /* leave it; a directory we can't cleanly remove is not worth a turn */
1203
- }
1204
- }
1205
- try {
1206
- git(['worktree', 'prune'], repoRoot);
1207
- } catch {
1208
- /* best effort */
1209
- }
1210
- };
1211
-
1212
- const processWorkTurns = (jobs) => {
1213
- for (const job of jobs ?? []) {
1214
- if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
1215
- if (workAnswering.has(job.id)) continue;
1216
- const tries = (workAttempts.get(job.id) ?? 0) + 1;
1217
- if (tries > MAX_WORK_TRIES) continue;
1218
- workAttempts.set(job.id, tries);
1219
- workAnswering.add(job.id);
1220
- const chain = workChains.get(job.sessionId) ?? Promise.resolve();
1221
- workChains.set(
1222
- job.sessionId,
1223
- chain.then(async () => {
1224
- try {
1225
- note(
1226
- `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
1227
- );
1228
- const workRt = pickRuntimeFor('build');
1229
- if (!workRt) {
1230
- warn('a session turn is waiting, but no installed CLI can build here');
1231
- return;
1232
- }
1233
- const token = await mintWorkToken();
1234
- if (!token) {
1235
- warn('a session turn is waiting, but the work credential could not be minted');
1236
- return;
1237
- }
1238
- const dir = sessionWtFor(job.sessionId);
1239
- if (!dir) {
1240
- warn('a session turn is waiting, but its worktree could not be opened');
1241
- return;
1242
- }
1243
- workTouched.set(job.sessionId, Date.now());
1244
- const resume = !dir.fresh && Boolean(job.sessionRef);
1245
- const mcp = mcpFor(workRt, token, mcpUrl);
1246
- let out;
1247
- try {
1248
- out = await runTurn({
1249
- prompt: WORK_TURN_KICKOFF({
1250
- sessionId: job.sessionId,
1251
- sessionName: job.sessionName,
1252
- message: job.body,
1253
- askedByName: job.askedByName,
1254
- }),
1255
- resume,
1256
- system: SYSTEM_WORK,
1257
- cwd: dir.wt,
1258
- mcpArgs: mcp.args,
1259
- mcpEnv: mcp.env,
1260
- runtime: workRt,
1261
- label: c.cyan('[tab]'),
1262
- });
1263
- } finally {
1264
- if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
1265
- }
1266
- const answer = (out || '').trim();
1267
- const posted = await reportMergeOutcome(WORK_DONE_URL, {
1268
- turnId: job.id,
1269
- ok: answer.length > 0,
1270
- // Scrub: a reply can quote config or env-adjacent code.
1271
- answer: envScrub(answer).slice(0, 16000),
1272
- sessionRef: dir.wt,
1273
- });
1274
- if (posted) workAttempts.delete(job.id);
1275
- ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
1276
- retireIdleWorkSessions();
1277
- } catch (e) {
1278
- await reportMergeOutcome(WORK_DONE_URL, {
1279
- turnId: job.id,
1280
- ok: false,
1281
- answer: e?.message ?? 'the session turn failed',
1282
- });
1283
- warn(`session turn failed: ${e?.message ?? e}`);
1284
- } finally {
1285
- workAnswering.delete(job.id);
1286
- }
1287
- })
1288
- );
1289
- }
1290
- };
1291
-
1292
- // Ship — a session's branch merging to main, on the human's word.
1293
- //
1294
- // --no-ff, NEVER squash: every delivered card carries commit shas as its
1295
- // receipts, and a squash would point them all at commits that no longer
1296
- // exist on main. Sequence: refuse a dirty worktree (auto-committing someone's
1297
- // mid-thought state is not shipping, it is guessing), fold main INTO the
1298
- // branch first so conflicts surface in the worktree where the session can
1299
- // resolve them, collect the branch's own commits (the server's
1300
- // reconciliation input), then merge outward through a throwaway worktree so
1301
- // nobody's checkout moves. Failures report INTO the tab — a ship that failed
1302
- // silently leaves the human believing their work is on main.
1303
- const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
1304
- const shipping = new Set();
1305
- let shipChain = Promise.resolve();
1306
-
1307
- const processShipJobs = (jobs) => {
1308
- for (const job of jobs ?? []) {
1309
- if (!job || typeof job.sessionId !== 'string') continue;
1310
- if (shipping.has(job.sessionId)) continue;
1311
- shipping.add(job.sessionId);
1312
- shipChain = shipChain.then(async () => {
1313
- const done = (payload) =>
1314
- reportMergeOutcome(SHIP_DONE_URL, { sessionId: job.sessionId, ...payload }).catch(
1315
- () => {}
1316
- );
1317
- try {
1318
- if (!isSafePathSegment(job.sessionId)) {
1319
- await done({ ok: false, error: 'invalid session id' });
1320
- return;
1321
- }
1322
- note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
1323
- const wt = join(baseDir, 'sessions', job.sessionId);
1324
- if (!existsSync(wt)) {
1325
- await done({ ok: false, error: 'no session worktree on this machine' });
1326
- return;
1327
- }
1328
- if (git(['status', '--porcelain'], wt) !== '') {
1329
- await done({
1330
- ok: false,
1331
- error:
1332
- 'the session has uncommitted changes — ask it to commit or discard them first',
1333
- });
1334
- return;
1335
- }
1336
- try {
1337
- git(['fetch', 'origin', '--quiet'], repoRoot);
1338
- } catch {
1339
- /* offline fetch — merge against what we have */
1340
- }
1341
- // Fold main into the branch FIRST: conflicts land here, in the
1342
- // session's own worktree, where the next turn can resolve them.
1343
- try {
1344
- git(['merge', '--no-edit', baseRef], wt);
1345
- } catch {
1346
- try {
1347
- git(['merge', '--abort'], wt);
1348
- } catch {
1349
- /* nothing in progress */
1350
- }
1351
- await done({
1352
- ok: false,
1353
- error: 'conflicts with main — ask the session to resolve them, then ship again',
1354
- });
1355
- return;
1356
- }
1357
- // The branch's own commits — the server's reconciliation input.
1358
- // --no-merges: fold-commits describe plumbing, not work.
1359
- const commits = git([
1360
- 'log',
1361
- `${baseRef}..HEAD`,
1362
- '--no-merges',
1363
- '--format=%H%x09%s',
1364
- ], wt)
1365
- .split('\n')
1366
- .filter(Boolean)
1367
- .map((l) => {
1368
- const [sha, ...rest] = l.split('\t');
1369
- return { sha, subject: envScrub(rest.join('\t')).slice(0, 200) };
1370
- });
1371
- if (commits.length === 0) {
1372
- await done({ ok: false, error: 'nothing to ship — no commits on the session branch' });
1373
- return;
1374
- }
1375
- // Merge outward through a throwaway worktree so no checkout moves.
1376
- const branch = `session/${job.sessionId}`;
1377
- const tmp = join(baseDir, 'ship', job.sessionId);
1378
- try {
1379
- try {
1380
- git(['worktree', 'remove', '--force', tmp], repoRoot);
1381
- } catch {
1382
- /* not there — fine */
1383
- }
1384
- git(['worktree', 'add', '--detach', tmp, baseRef], repoRoot);
1385
- git([
1386
- 'merge',
1387
- '--no-ff',
1388
- branch,
1389
- '-m',
1390
- `ship(${job.sessionName || job.sessionId.slice(0, 8)}): ${commits.length} commit${commits.length === 1 ? '' : 's'}`,
1391
- ], tmp);
1392
- git(['push', 'origin', `HEAD:${baseBranchName(baseRef)}`], tmp);
1393
- } finally {
1394
- try {
1395
- git(['worktree', 'remove', '--force', tmp], repoRoot);
1396
- git(['worktree', 'prune'], repoRoot);
1397
- } catch {
1398
- /* best effort */
1399
- }
1400
- }
1401
- await done({ ok: true, commits });
1402
- ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
1403
- } catch (e) {
1404
- await done({ ok: false, error: envScrub(e?.message ?? 'the merge failed').slice(0, 500) });
1405
- warn(`ship failed: ${e?.message ?? e}`);
1406
- } finally {
1407
- shipping.delete(job.sessionId);
1408
- }
1409
- });
1410
- }
1411
- };
1195
+ // The whole machinery per-session turn/ship chains, per-session work
1196
+ // tokens, the settle-every-turn contract, the ship executor, worktree
1197
+ // retirement lives in work.mjs; this hands it the loop's mutable state.
1198
+ const {
1199
+ flushWorkReports,
1200
+ processWorkTurns,
1201
+ processShipJobs,
1202
+ retireWorkSessions,
1203
+ shutdownWork,
1204
+ } = createWorkManager({
1205
+ repoRoot,
1206
+ baseDir,
1207
+ baseRef,
1208
+ getMcpUrl: () => mcpUrl,
1209
+ getLeaseTtl: () => leaseTtlSeconds,
1210
+ });
1211
+ workShutdown = shutdownWork; // teardown can now reach the live session CLIs
1412
1212
 
1413
1213
  const processMergeJobs = (jobs) => {
1414
1214
  for (const job of jobs ?? []) {
@@ -2002,12 +1802,26 @@ export async function runFleetDaemon() {
2002
1802
  });
2003
1803
  if (updating) return;
2004
1804
  }
1805
+ // Settle any turn/ship answers whose earlier report POST failed BEFORE
1806
+ // taking new work — the skip-if-pending guards make the ordering safe, but
1807
+ // delivering first keeps the tab honest a poll sooner.
1808
+ void flushWorkReports();
2005
1809
  processMergeJobs(roster.mergeJobs);
2006
1810
  processPatchRevertJobs(roster.patchRevertJobs);
2007
1811
  processPlanCheckJobs(roster.planCheckJobs);
2008
1812
  processConsultJobs(roster.consultJobs);
2009
1813
  processWorkTurns(roster.workTurnJobs);
2010
- processShipJobs(roster.shipJobs);
1814
+ // The roster's live-session list rides along: an ENDED session's ship
1815
+ // must not be refused by checks whose remedies need a live tab.
1816
+ processShipJobs(roster.shipJobs, roster.activeWorkSessions);
1817
+ // AFTER the work/ship intake: retirement is the server saying which
1818
+ // sessions are LIVE, and the guards above (chains, shipping) are populated
1819
+ // by the intake this same tick.
1820
+ retireWorkSessions(roster.activeWorkSessions);
1821
+ // Terminal-session presence, throttled + dedup'd inside; never awaited —
1822
+ // the daemon's own worktrees are carved out (a session the daemon spawned
1823
+ // is already a tab, not something to offer adopting).
1824
+ void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
2011
1825
  processJoinJobs(roster.joinJobs);
2012
1826
  processCleanupJobs(roster.cleanupJobs);
2013
1827
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));