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/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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
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));
|