flowviant 0.43.0 → 0.44.1

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,7 @@ 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';
82
87
 
83
88
  async function fetchRoster(haveIds) {
84
89
  const url = new URL(FLEET_URL);
@@ -453,6 +458,7 @@ export async function runFleetDaemon() {
453
458
  const workers = new Map(); // agentId -> { state, promise, wt, label }
454
459
  let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
455
460
  let stream = null; // push channel handle (set once the loop is set up)
461
+ let workShutdown = null; // kills live session-turn CLIs (set with the work manager below)
456
462
 
457
463
  // Shutdown KEEPS the worktrees: in-flight local work survives Ctrl+C and
458
464
  // resumes in place on the next run (the task marker matches). Worktrees are
@@ -473,6 +479,14 @@ export async function runFleetDaemon() {
473
479
  } catch {
474
480
  /* best-effort */
475
481
  }
482
+ // Session-turn CLIs die with the daemon too: an orphan keeps editing the
483
+ // session worktree and burning quota, and its live-pid lock would make the
484
+ // restarted daemon skip that tab's turns for as long as it survived.
485
+ try {
486
+ workShutdown?.();
487
+ } catch {
488
+ /* best-effort */
489
+ }
476
490
  for (const [, w] of workers) {
477
491
  w.state.alive = false;
478
492
  try {
@@ -496,6 +510,15 @@ export async function runFleetDaemon() {
496
510
  teardown();
497
511
  process.exit(130);
498
512
  });
513
+ // A service manager stops the daemon with SIGTERM, not Ctrl+C. Without this
514
+ // handler every child survived a `systemctl stop` — the exact orphaning the
515
+ // teardown exists to prevent.
516
+ process.on('SIGTERM', () => {
517
+ console.log('');
518
+ note('shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.');
519
+ teardown();
520
+ process.exit(143);
521
+ });
499
522
  // Keep the daemon alive on a stray rejection. Many loops here are fire-and-
500
523
  // forget (`void drainWiki()`, dispatch, sync) and rely on their callees never
501
524
  // rejecting; Node ≥15 terminates the process on an unhandled rejection, which
@@ -1091,7 +1114,9 @@ export async function runFleetDaemon() {
1091
1114
  await reportMergeOutcome(CONSULT_DONE_URL, {
1092
1115
  consultId: job.id,
1093
1116
  ok: false,
1094
- answer: e?.message ?? 'the planning turn failed',
1117
+ // Scrub, like the success path: an exception routinely quotes
1118
+ // command output, and command output can quote a synced secret.
1119
+ answer: envScrub(String(e?.message ?? 'the planning turn failed')).slice(0, 2000),
1095
1120
  });
1096
1121
  warn(`planning turn failed: ${e?.message ?? e}`);
1097
1122
  } finally {
@@ -1103,312 +1128,23 @@ export async function runFleetDaemon() {
1103
1128
 
1104
1129
  // ── Work sessions — the Workbench tabs ─────────────────────────────────────
1105
1130
  //
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
- };
1131
+ // The whole machinery per-session turn/ship chains, per-session work
1132
+ // tokens, the settle-every-turn contract, the ship executor, worktree
1133
+ // retirement lives in work.mjs; this hands it the loop's mutable state.
1134
+ const {
1135
+ flushWorkReports,
1136
+ processWorkTurns,
1137
+ processShipJobs,
1138
+ retireWorkSessions,
1139
+ shutdownWork,
1140
+ } = createWorkManager({
1141
+ repoRoot,
1142
+ baseDir,
1143
+ baseRef,
1144
+ getMcpUrl: () => mcpUrl,
1145
+ getLeaseTtl: () => leaseTtlSeconds,
1146
+ });
1147
+ workShutdown = shutdownWork; // teardown can now reach the live session CLIs
1412
1148
 
1413
1149
  const processMergeJobs = (jobs) => {
1414
1150
  for (const job of jobs ?? []) {
@@ -2002,12 +1738,22 @@ export async function runFleetDaemon() {
2002
1738
  });
2003
1739
  if (updating) return;
2004
1740
  }
1741
+ // Settle any turn/ship answers whose earlier report POST failed BEFORE
1742
+ // taking new work — the skip-if-pending guards make the ordering safe, but
1743
+ // delivering first keeps the tab honest a poll sooner.
1744
+ void flushWorkReports();
2005
1745
  processMergeJobs(roster.mergeJobs);
2006
1746
  processPatchRevertJobs(roster.patchRevertJobs);
2007
1747
  processPlanCheckJobs(roster.planCheckJobs);
2008
1748
  processConsultJobs(roster.consultJobs);
2009
1749
  processWorkTurns(roster.workTurnJobs);
2010
- processShipJobs(roster.shipJobs);
1750
+ // The roster's live-session list rides along: an ENDED session's ship
1751
+ // must not be refused by checks whose remedies need a live tab.
1752
+ processShipJobs(roster.shipJobs, roster.activeWorkSessions);
1753
+ // AFTER the work/ship intake: retirement is the server saying which
1754
+ // sessions are LIVE, and the guards above (chains, shipping) are populated
1755
+ // by the intake this same tick.
1756
+ retireWorkSessions(roster.activeWorkSessions);
2011
1757
  processJoinJobs(roster.joinJobs);
2012
1758
  processCleanupJobs(roster.cleanupJobs);
2013
1759
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));