flowviant 0.77.5 → 0.78.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.
@@ -259,7 +259,17 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
259
259
  // permission, an aborted run). Under `answerFromResult` this is the only
260
260
  // stdout that would have said so, and a caller whose `out` is the answer
261
261
  // must not report "no output" for a turn that explained itself.
262
- const msg = ev.error?.message ?? ev.error ?? ev.subtype;
262
+ // `errors[]` FIRST, because it is where the real sentence is. Claude
263
+ // Code reports a dead `--resume` id as
264
+ // `{subtype:'error_during_execution', errors:['No conversation found
265
+ // with session ID: …']}` — reading only `error`/`subtype` dropped that
266
+ // and appended the literal string `error_during_execution`, which told
267
+ // the driver nothing and hid the one phrase the caller needs to
268
+ // recognise a lost conversation.
269
+ const listed = Array.isArray(ev.errors)
270
+ ? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
271
+ : '';
272
+ const msg = listed || ev.error?.message || ev.error || ev.subtype;
263
273
  appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
264
274
  }
265
275
  }
@@ -10,13 +10,14 @@
10
10
  * wrangler output routinely echoes secrets.
11
11
  */
12
12
 
13
- import { readFileSync, existsSync } from 'node:fs';
13
+
14
14
  import { spawn } from 'node:child_process';
15
15
  import { join } from 'node:path';
16
16
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
17
17
  import { c, note, ok, warn } from './ui.mjs';
18
18
  import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
19
19
  import { childEnv } from './childEnv.mjs';
20
+ import { git } from './git.mjs';
20
21
 
21
22
  const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
22
23
 
@@ -38,27 +39,64 @@ async function post(tail, body) {
38
39
  return json?.data;
39
40
  }
40
41
 
41
- /** Read + parse .flowviant/deploy.json from the repo root. Returns [] if none. */
42
- export function readDeployConfig(repoRoot) {
43
- const path = join(repoRoot, '.flowviant', 'deploy.json');
44
- if (!existsSync(path)) return [];
42
+ /**
43
+ * Read + parse `.flowviant/deploy.json` AS IT IS ON THE BASE BRANCH. Returns []
44
+ * if the branch has none.
45
+ *
46
+ * IT USED TO READ THE WORKING TREE, which made the feature's one stated bound
47
+ * false. `deploy_target`'s own description says "only ids already declared in
48
+ * `.flowviant/deploy.json` on MAIN can be named (the daemon reads and runs from
49
+ * the repo ROOT, never a session worktree, so an agent cannot author the command
50
+ * it triggers without shipping it first)" — and the repo root's WORKING TREE is
51
+ * exactly where the machine operator's tabs stand (their place is the checkout).
52
+ * So an agent that had read an injected instruction could write an uncommitted
53
+ * `.flowviant/deploy.json` naming any shell command, call `deploy_target`, and
54
+ * have the daemon run it from the repo root with `CLOUDFLARE_API_TOKEN` and
55
+ * every other deploy-scope credential in its environment. Nothing about that
56
+ * needed a commit, a review, or an owner.
57
+ *
58
+ * Reading the COMMITTED tree is what makes the sentence true: authoring the
59
+ * command now requires landing it on base, which is a reviewed act. The file is
60
+ * read through git rather than the filesystem, so an uncommitted edit is simply
61
+ * not there.
62
+ *
63
+ * A base ref that does not resolve yields NO TARGETS, and says so once. That is
64
+ * the withholding direction and it is the right one here — a deploy is
65
+ * irreversible and running the wrong file is worse than running nothing.
66
+ */
67
+ export function readDeployConfig(repoRoot, baseRef) {
68
+ let raw;
69
+ if (baseRef) {
70
+ try {
71
+ raw = git(['show', `${baseRef}:.flowviant/deploy.json`], repoRoot);
72
+ } catch {
73
+ // No such file on base, or a base ref that does not resolve. Both mean
74
+ // "this branch declares no targets", which is a real answer.
75
+ return [];
76
+ }
77
+ } else {
78
+ // No base ref in hand (a caller that has not been updated). Refuse rather
79
+ // than silently falling back to the working tree — that fallback IS the bug.
80
+ warn('deploy: no base branch resolved, so no deploy targets were read.');
81
+ return [];
82
+ }
45
83
  try {
46
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
84
+ const parsed = JSON.parse(raw);
47
85
  const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
48
86
  // Keep only fields the server + runner need; the daemon holds the commands.
49
87
  return targets
50
88
  .filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
51
89
  .slice(0, 20);
52
90
  } catch (e) {
53
- warn(`deploy: .flowviant/deploy.json is not valid JSON — ${e.message}`);
91
+ warn(`deploy: .flowviant/deploy.json on the base branch is not valid JSON — ${e.message}`);
54
92
  return [];
55
93
  }
56
94
  }
57
95
 
58
96
  /** Report the parsed config to the server (only when it changed). */
59
97
  let lastConfigJson = null;
60
- export async function reportDeployConfig(repoRoot) {
61
- const targets = readDeployConfig(repoRoot);
98
+ export async function reportDeployConfig(repoRoot, baseRef) {
99
+ const targets = readDeployConfig(repoRoot, baseRef);
62
100
  const json = JSON.stringify(targets);
63
101
  if (json === lastConfigJson) return;
64
102
  // Scrub command strings before the server sees them — a command line can embed
@@ -154,6 +192,23 @@ async function verifyHealth(url, status) {
154
192
  }
155
193
 
156
194
  const claiming = new Set(); // in-flight guard (single-flight per daemon process)
195
+ /**
196
+ * JOBS THIS PROCESS HAS ALREADY RUN, whatever the server thinks.
197
+ *
198
+ * A deploy is IRREVERSIBLE and the report is not: a transient 5xx, a DNS blip
199
+ * or the 30s timeout meant the outcome never landed, the heartbeat stopped,
200
+ * and three minutes later the server requeued the job and this same daemon ran
201
+ * `wrangler rollback` — or a full prod deploy with every pushSecret re-pushed —
202
+ * a SECOND time, leaving production two versions behind the intended one with
203
+ * nothing recording that it happened twice.
204
+ *
205
+ * So the process remembers. Not a substitute for the report (see the retry
206
+ * below, which is the real fix); a floor under it, for the case where the
207
+ * report never lands at all. It does not survive a restart — nothing local
208
+ * could be trusted to — which is why the retry has to keep the heartbeat alive
209
+ * while it runs.
210
+ */
211
+ const ran = new Set();
157
212
 
158
213
  /**
159
214
  * Process queued deploy jobs from the roster. `ctx` = { repoRoot, baseRef,
@@ -168,6 +223,7 @@ export function processDeployJobs(jobs, ctx) {
168
223
  // reconcile loop, since this runs unguarded from the fleet tick.
169
224
  if (!job || typeof job.id !== 'string') continue;
170
225
  if (claiming.has(job.id)) continue;
226
+ if (ran.has(job.id)) continue; // already executed here — never twice
171
227
  claiming.add(job.id);
172
228
  void (async () => {
173
229
  let beat = null;
@@ -185,10 +241,35 @@ export function processDeployJobs(jobs, ctx) {
185
241
  // Keep the claim fresh while we run — a long deploy must never be
186
242
  // re-queued out from under us (that would double-deploy). The async
187
243
  // run() below keeps the event loop free so this fires.
244
+ /**
245
+ * …AND IT CAN DIE, which is what makes the `stillBeating` predicate
246
+ * below mean anything.
247
+ *
248
+ * `report` is handed `() => beat != null` so it stops retrying once the
249
+ * claim is certainly stale — but `beat` only ever held a timer handle
250
+ * and was never nulled, so that predicate could not return false and
251
+ * `report` retried into a job another daemon may already own.
252
+ *
253
+ * The server re-queues a deploy whose heartbeat is older than three
254
+ * minutes, and this fires every sixty seconds — so three consecutive
255
+ * failures is exactly the point past which the claim cannot be assumed.
256
+ * A single blip does not count: only an unbroken run does.
257
+ */
258
+ let missed = 0;
188
259
  beat = setInterval(() => {
189
- void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() }).catch(() => {});
260
+ void post('deploy-heartbeat', { jobId: job.id, pubkey: ctx.myPubB64() })
261
+ .then(() => {
262
+ missed = 0;
263
+ })
264
+ .catch(() => {
265
+ missed += 1;
266
+ if (missed >= 3 && beat) {
267
+ clearInterval(beat);
268
+ beat = null;
269
+ }
270
+ });
190
271
  }, 60_000);
191
- const targets = readDeployConfig(ctx.repoRoot);
272
+ const targets = readDeployConfig(ctx.repoRoot, ctx.baseRef);
192
273
  const target = targets.find((t) => t.id === job.targetId);
193
274
  if (!target) {
194
275
  await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
@@ -196,13 +277,19 @@ export function processDeployJobs(jobs, ctx) {
196
277
  }
197
278
  note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
198
279
  const outcome = await runDeploy(job, target, ctx);
199
- await report(job, ctx, outcome);
280
+ // From here the work is DONE. Whatever the report does, this job must
281
+ // never run again in this process.
282
+ ran.add(job.id);
283
+ await report(job, ctx, outcome, () => beat != null);
200
284
  if (outcome.ok) ok(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} done${outcome.healthOk === false ? ' (health failed)' : ''}`)}`);
201
285
  else warn(`deploy: ${job.targetId} → ${job.env} failed — ${outcome.message}`);
202
286
  } catch (e) {
203
287
  warn(`deploy job ${job.id} errored: ${e.message}`);
204
288
  await report(job, ctx, { ok: false, message: e.message }).catch(() => {});
205
289
  } finally {
290
+ // Stopped only AFTER the report has landed or given up — the requeue is
291
+ // gated on heartbeat staleness, so beating through the retries is what
292
+ // stops the server handing this job out again mid-retry.
206
293
  if (beat) clearInterval(beat);
207
294
  claiming.delete(job.id);
208
295
  }
@@ -278,13 +365,49 @@ async function runDeploy(job, target, ctx) {
278
365
  };
279
366
  }
280
367
 
281
- function report(job, ctx, outcome) {
282
- return post('deploy-report', {
368
+ /**
369
+ * THE OUTCOME IS RETRIED, because losing it re-runs the deploy.
370
+ *
371
+ * One `post` with a `.catch(warn)` was the whole of this: a transient 5xx, a
372
+ * DNS blip or the 30s timeout dropped the outcome, the `finally` stopped the
373
+ * heartbeat, and the server — which requeues a running job after three minutes
374
+ * without one — handed the SAME job back to the SAME daemon, which ran it
375
+ * again. For a rollback that is production two versions behind the intended
376
+ * one; for a prod deploy it is every pushSecret pushed twice. Nothing recorded
377
+ * that it had happened at all.
378
+ *
379
+ * The heartbeat keeps running throughout (the caller's `finally` is what stops
380
+ * it), so the requeue window stays shut for as long as we are still trying.
381
+ * Bounded: six attempts over roughly a minute, then a warning and the local
382
+ * `ran` guard as the floor.
383
+ */
384
+ async function report(job, ctx, outcome, stillBeating = () => true) {
385
+ const body = {
283
386
  jobId: job.id,
284
387
  pubkey: ctx.myPubB64(),
285
388
  ok: !!outcome.ok,
286
389
  deploymentId: outcome.deploymentId ?? null,
287
390
  healthOk: outcome.healthOk ?? null,
288
391
  message: scrub(outcome.message || ''),
289
- }).catch((e) => warn(`deploy: could not report outcome — ${e.message}`));
392
+ };
393
+ for (let attempt = 0; attempt < 6; attempt++) {
394
+ try {
395
+ await post('deploy-report', body);
396
+ return;
397
+ } catch (e) {
398
+ // The last attempt says so; the ones before it are noise on a path that
399
+ // usually recovers.
400
+ if (attempt === 5) {
401
+ warn(`deploy: could not report outcome after 6 tries — ${e.message}`);
402
+ return;
403
+ }
404
+ // If the heartbeat is already gone the requeue window is open and
405
+ // retrying buys nothing — the job may have been handed to somebody else.
406
+ if (!stillBeating()) {
407
+ warn(`deploy: could not report outcome — ${e.message}`);
408
+ return;
409
+ }
410
+ await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
411
+ }
412
+ }
290
413
  }
package/bin/lib/fleet.mjs CHANGED
@@ -85,7 +85,7 @@ import {
85
85
  RUNTIMES,
86
86
  } from './runtimes.mjs';
87
87
  import { createWorkManager } from './work.mjs';
88
- import { scanLocalSessions } from './localSessions.mjs';
88
+ import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
89
89
  import { repoState } from './repoState.mjs';
90
90
 
91
91
  async function fetchRoster(
@@ -253,7 +253,7 @@ let localSessionsUnsupported = false; // the server 404'd — quiet until restar
253
253
  let localSessionsScanAt = 0;
254
254
  let localSessionsSent = null; // last payload the server ACCEPTED, stringified
255
255
  let localSessionsSentAt = 0;
256
- async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
256
+ async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
257
257
  if (localSessionsUnsupported) return;
258
258
  if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
259
259
  localSessionsScanAt = Date.now();
@@ -261,7 +261,9 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
261
261
  try {
262
262
  // scanLocalSessions orders deterministically, so this string only changes
263
263
  // when the facts on disk do — the dedup below compares whole payloads.
264
- payload = JSON.stringify({ sessions: scanLocalSessions({ repoRoot, excludeDirs }) });
264
+ payload = JSON.stringify({
265
+ sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
266
+ });
265
267
  } catch {
266
268
  return; // presence must never throw into the poll loop
267
269
  }
@@ -892,6 +894,7 @@ export async function runFleetDaemon() {
892
894
  execFileSync('gh', ['pr', 'edit', job.prUrl, '--base', baseBranchName(baseRef)], {
893
895
  cwd: repoRoot,
894
896
  stdio: ['ignore', 'pipe', 'pipe'],
897
+ timeout: 30_000,
895
898
  });
896
899
  } catch (e) {
897
900
  // Already targeting base is the common no-op; anything else is
@@ -912,6 +915,7 @@ export async function runFleetDaemon() {
912
915
  execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
913
916
  cwd: repoRoot,
914
917
  stdio: ['ignore', 'pipe', 'pipe'],
918
+ timeout: 120_000,
915
919
  });
916
920
  merged = true;
917
921
  } catch (e) {
@@ -989,7 +993,7 @@ export async function runFleetDaemon() {
989
993
  'Task restarted in Flowviant — this attempt was discarded.',
990
994
  '--delete-branch',
991
995
  ],
992
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
996
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
993
997
  );
994
998
  } catch (e) {
995
999
  // Already closed/merged/missing = fine; anything else we still
@@ -1031,10 +1035,22 @@ export async function runFleetDaemon() {
1031
1035
  const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
1032
1036
  const WIKI_VAULT_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-vault');
1033
1037
  const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
1038
+ const WIKI_ABANDONED_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-abandoned');
1034
1039
  const wikiQueue = [];
1035
1040
  let wikiBusy = false;
1036
1041
  let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
1037
1042
  let lastSweepAt = null; // dedup: run each Regenerate request once
1043
+ // …UNLESS IT FAILED. A sweep that ends without WIKI_DONE never finalizes, so
1044
+ // the server's `regen_requested_at` stays set and the roster keeps offering
1045
+ // the same `requestedAt` — which this dedup then swallowed forever. The
1046
+ // console said "retry from the app", to a console nobody reads. Bounded the
1047
+ // same way the re-ground path is: a full sweep is an expensive model turn, so
1048
+ // a repo that fails one every time must not be able to loop-burn quota.
1049
+ let sweepAttempts = 0;
1050
+ // Which request the counter belongs to, so a NEW Regenerate click starts with
1051
+ // a full budget rather than inheriting an exhausted one.
1052
+ let sweepAttemptsFor = null;
1053
+ const MAX_SWEEP_ATTEMPTS = 3;
1038
1054
  const groundedIntents = new Set(); // dedup: re-ground each delivery once
1039
1055
  // The vault is keyed by the server project this fleet credential serves
1040
1056
  // (learned from the roster); until the first poll names it, fall back to a
@@ -1077,8 +1093,38 @@ export async function runFleetDaemon() {
1077
1093
  }
1078
1094
  };
1079
1095
 
1096
+ /**
1097
+ * Tell the server this daemon has stopped retrying the pending sweep.
1098
+ *
1099
+ * The retry budget is a `let` in this process; `regen_requested_at` is a
1100
+ * durable column with a 24-hour TTL. Without this the two disagreed — the
1101
+ * daemon had permanently given up while every surface went on calling the
1102
+ * sweep queued, for the rest of the day. Best-effort: an older server 404s
1103
+ * once and the TTL is still the backstop it always was.
1104
+ */
1105
+ const postWikiAbandoned = async () => {
1106
+ try {
1107
+ await fetch(WIKI_ABANDONED_URL, {
1108
+ method: 'POST',
1109
+ headers: {
1110
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1111
+ 'User-Agent': USER_AGENT,
1112
+ 'Content-Type': 'application/json',
1113
+ },
1114
+ signal: AbortSignal.timeout(15_000),
1115
+ body: '{}',
1116
+ });
1117
+ } catch {
1118
+ /* best-effort — the request's own TTL still expires it */
1119
+ }
1120
+ };
1121
+
1080
1122
  const enqueueSweep = (job) => {
1081
1123
  if (!job || job.requestedAt === lastSweepAt) return;
1124
+ if (job.requestedAt !== sweepAttemptsFor) {
1125
+ sweepAttemptsFor = job.requestedAt;
1126
+ sweepAttempts = 0;
1127
+ }
1082
1128
  lastSweepAt = job.requestedAt;
1083
1129
  // A full sweep is expensive — never stack two. One queued sweep already
1084
1130
  // covers any newer Regenerate click (it reads the repo fresh when it runs).
@@ -1224,6 +1270,18 @@ export async function runFleetDaemon() {
1224
1270
  // thinking block or slow tool (which emit nothing until they finish) —
1225
1271
  // otherwise the cover would flap back to the empty state mid-sweep.
1226
1272
  let heartbeat = null;
1273
+ /**
1274
+ * Did THIS sweep finish? Read by the `finally` below, which owns the
1275
+ * retry decision for every way out of this block.
1276
+ *
1277
+ * It used to be decided inline on the one branch where the turn
1278
+ * returned without its sentinel — so the two OTHER ways a sweep fails,
1279
+ * `pickRuntimeFor` finding no CLI and the catch around the whole turn,
1280
+ * left the request pinned and never retried at all. Those are the
1281
+ * failures most worth retrying: a missing runtime is fixed by
1282
+ * installing one, and a thrown turn is exactly the transient case.
1283
+ */
1284
+ let sweepCompleted = false;
1227
1285
  try {
1228
1286
  // Immediate frame so the cover shows the daemon feed right away (the
1229
1287
  // "reading your code" phase), not a static message, while Claude warms up.
@@ -1283,6 +1341,7 @@ export async function runFleetDaemon() {
1283
1341
  }
1284
1342
  const wikiLabel = RUNTIMES[wikiRt].label;
1285
1343
  if (task.type === 'sweep') {
1344
+ sweepAttempts++;
1286
1345
  note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
1287
1346
  const out = await runTurn({
1288
1347
  prompt: WIKI_KICKOFF(sha, vaultDir),
@@ -1307,9 +1366,12 @@ export async function runFleetDaemon() {
1307
1366
  },
1308
1367
  });
1309
1368
  const complete = sawSentinel(out, 'WIKI_DONE');
1310
- if (complete) ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
1311
- else
1312
- warn('wiki sweep ended without WIKI_DONE partial pages synced; retry from the app.');
1369
+ if (complete) {
1370
+ sweepCompleted = true;
1371
+ ok(`${c.cyan('wiki')} ${c.dim('vault regenerated from your code.')}`);
1372
+ } else {
1373
+ warn('wiki sweep ended without WIKI_DONE — partial pages synced');
1374
+ }
1313
1375
  await runSync(complete);
1314
1376
  } else {
1315
1377
  const files = changedFilesForShas(task.shas);
@@ -1376,6 +1438,34 @@ export async function runFleetDaemon() {
1376
1438
  } finally {
1377
1439
  wikiChild = null;
1378
1440
  if (heartbeat) clearInterval(heartbeat);
1441
+ /**
1442
+ * THE RETRY DECISION, IN ONE PLACE, FOR EVERY WAY OUT OF THIS BLOCK.
1443
+ *
1444
+ * Deciding it inline on the no-sentinel branch covered one of the
1445
+ * three ways a sweep fails and silently declined the other two. Here
1446
+ * it covers the thrown turn and the no-runtime return as well, which
1447
+ * are the two most worth retrying.
1448
+ *
1449
+ * A successful sweep resets the budget. A failed one with budget left
1450
+ * clears `lastSweepAt` so the roster's next offer of the SAME request
1451
+ * is accepted — partial pages are synced without pruning either way,
1452
+ * so a retry resumes rather than starting over. A failed one with the
1453
+ * budget spent tells the SERVER, because the counter is process-local
1454
+ * and the request it bounds is durable for 24 hours.
1455
+ */
1456
+ if (task.type === 'sweep') {
1457
+ if (sweepCompleted) {
1458
+ sweepAttempts = 0;
1459
+ } else if (sweepAttempts < MAX_SWEEP_ATTEMPTS) {
1460
+ lastSweepAt = null;
1461
+ warn(`wiki sweep failed — retrying (${sweepAttempts}/${MAX_SWEEP_ATTEMPTS})`);
1462
+ } else {
1463
+ warn(
1464
+ `wiki sweep failed ${sweepAttempts} times — giving up on this request; press Regenerate to try again.`
1465
+ );
1466
+ await postWikiAbandoned();
1467
+ }
1468
+ }
1379
1469
  // Terminal frame so the app cover clears promptly (don't wait for the
1380
1470
  // freshness window to lapse). force-sent past the throttle.
1381
1471
  await postWikiProgress(frame({ done: true }), true);
@@ -1643,7 +1733,15 @@ export async function runFleetDaemon() {
1643
1733
  // Terminal-session presence, throttled + dedup'd inside; never awaited —
1644
1734
  // the daemon's own worktrees are carved out (a session the daemon spawned
1645
1735
  // is already a tab, not something to offer adopting).
1646
- void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1736
+ void maybeReportLocalSessions({
1737
+ repoRoot,
1738
+ excludeDirs: [baseDir],
1739
+ // …and our OWN tabs' conversations. Only the CHECKOUT needs this: every
1740
+ // other place is under `baseDir` and already fenced by directory, while
1741
+ // the operator's tabs share the checkout with real terminal sessions and
1742
+ // cannot be. See `ourConversationIds`.
1743
+ excludeIds: ourConversationIds(repoRoot),
1744
+ });
1647
1745
  // …and the repo itself: every worktree and every branch, ours and not.
1648
1746
  // Never awaited, throttled inside, and silent on an older server.
1649
1747
  void maybeReportRepoState({ repoRoot, baseRef });
@@ -1736,7 +1834,10 @@ export async function runFleetDaemon() {
1736
1834
  // runs queued deploy jobs (the server only sends deployJobs to authorized
1737
1835
  // machines). Config report is cheap + dedup'd; jobs are single-flight.
1738
1836
  if (roster.env?.deployAuthorized) {
1739
- void reportDeployConfig(repoRoot);
1837
+ // The BASE branch's copy, not the working tree's — see readDeployConfig.
1838
+ // Reporting the working tree would advertise targets the runner will not
1839
+ // find, which is the same lie in the other direction.
1840
+ void reportDeployConfig(repoRoot, getBaseRef());
1740
1841
  processDeployJobs(roster.deployJobs, { repoRoot, baseRef: getBaseRef(), myPubB64 });
1741
1842
  }
1742
1843
 
@@ -234,6 +234,72 @@ function firstCwdRecord(file) {
234
234
  }
235
235
  }
236
236
 
237
+ /**
238
+ * THE MARKER NAMES THE DAEMON PINS A TAB'S CONVERSATION UNDER.
239
+ *
240
+ * One owner, because two things read them and a drift here is silent: `work.mjs`
241
+ * WRITES them (`sessionMetaPath(wt, <name>, sessionId)`) and this file READS
242
+ * them to know which conversations are its own.
243
+ */
244
+ export const SESSION_MARKERS = [
245
+ 'flowviant-claude-session',
246
+ 'flowviant-codex-thread',
247
+ 'flowviant-agy-conversation',
248
+ ];
249
+
250
+ /**
251
+ * EVERY CONVERSATION THIS DAEMON STARTED, so the adopt strip never offers you
252
+ * your own reflection.
253
+ *
254
+ * The machine OPERATOR's tabs work in the checkout itself (their place is
255
+ * `'repo'`), so their CLI transcripts land in `~/.claude/projects/<munge(repoRoot)>/`
256
+ * — the very directory this scan reads. The `excludeDirs` fence cannot help:
257
+ * repoRoot is the scan ROOT, not something under it. So the daemon's own tabs
258
+ * were reported as adoptable "terminal sessions", and two things followed:
259
+ * the `+` menu offered to adopt a tab you already have open (accepting FORKS
260
+ * that conversation and copies the checkout's uncommitted and untracked files
261
+ * into a new worktree), and — because the ended walk keeps only the newest row
262
+ * per directory, and a live tab's transcript is always the freshest thing in
263
+ * the checkout — a REAL terminal session started in the repo root could never
264
+ * be offered at all.
265
+ *
266
+ * Excluded by conversation ID rather than by directory, because the directory
267
+ * is shared with exactly the sessions we want to keep offering.
268
+ */
269
+ export function ourConversationIds(repoRoot, gitDirs = []) {
270
+ const ids = new Set();
271
+ const dirs = new Set(gitDirs.filter(Boolean));
272
+ try {
273
+ dirs.add(
274
+ execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
275
+ cwd: repoRoot,
276
+ encoding: 'utf8',
277
+ timeout: 5_000,
278
+ }).trim()
279
+ );
280
+ } catch {
281
+ /* not a repo, or git unavailable — the fence is simply empty */
282
+ }
283
+ for (const dir of dirs) {
284
+ let names = [];
285
+ try {
286
+ names = readdirSync(dir);
287
+ } catch {
288
+ continue;
289
+ }
290
+ for (const n of names) {
291
+ if (!SESSION_MARKERS.some((m) => n === m || n.startsWith(`${m}-`))) continue;
292
+ try {
293
+ const v = readFileSync(join(dir, n), 'utf8').trim();
294
+ if (v) ids.add(v);
295
+ } catch {
296
+ /* unreadable marker: nothing to fence */
297
+ }
298
+ }
299
+ }
300
+ return ids;
301
+ }
302
+
237
303
  /**
238
304
  * Every Claude terminal session belonging to this repo: LIVE ones from the
239
305
  * liveness registry, ENDED ones from the transcript store. Returns
@@ -245,7 +311,11 @@ function firstCwdRecord(file) {
245
311
  * itself spawned are tabs already, and offering to adopt one would be the
246
312
  * product offering the user their own reflection.
247
313
  */
248
- export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
314
+ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
315
+ /** Conversations this daemon started — never offered for adoption. See
316
+ * `ourConversationIds`: the operator's tabs share the checkout with real
317
+ * terminal sessions, so the fence has to be by id, not by directory. */
318
+ const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
249
319
  const live = [];
250
320
  const ended = [];
251
321
  try {
@@ -311,6 +381,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
311
381
  /* no transcript yet */
312
382
  }
313
383
  if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
384
+ if (mine.has(rec.sessionId)) continue; // our own tab, not a terminal session
314
385
  live.push({
315
386
  id: rec.sessionId,
316
387
  cwd,
@@ -387,6 +458,13 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
387
458
  continue;
388
459
  }
389
460
  if (!ours(cwd)) continue;
461
+ /**
462
+ * OUR OWN TAB IS NOT A CANDIDATE, and it is skipped BEFORE `seenCwds`
463
+ * claims the directory — otherwise the daemon's transcript (always the
464
+ * freshest thing in the checkout) took the one slot that directory gets
465
+ * and a genuine terminal session there could never be offered at all.
466
+ */
467
+ if (mine.has(cand.id)) continue;
390
468
  if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
391
469
  seenCwds.add(cwd);
392
470
  const title = transcriptTitle(cand.file, cand.mtimeMs);
@@ -405,7 +483,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
405
483
  const claude = [...live.slice(0, REPORT_CAP), ...ended];
406
484
  // agy rides in whatever room the cap leaves — Claude sessions first, they
407
485
  // are the ones adoption serves best (fork, never move).
408
- const agy = scanAgyConversations({ repoRoot, excludeDirs }).slice(
486
+ const agy = scanAgyConversations({ repoRoot, excludeDirs, excludeIds: mine }).slice(
409
487
  0,
410
488
  Math.max(0, REPORT_CAP - claude.length)
411
489
  );
@@ -511,7 +589,8 @@ export function isAgyConversationLive(id) {
511
589
 
512
590
  /** The repo's agy conversations, via the cwd registry — see the section
513
591
  * comment for why this is deliberately a subset. */
514
- function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
592
+ function scanAgyConversations({ repoRoot, excludeDirs = [], excludeIds }) {
593
+ const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
515
594
  const out = [];
516
595
  try {
517
596
  let realRoot;
@@ -537,6 +616,9 @@ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
537
616
  const processUp = agyProcessAlive();
538
617
  for (const [cwd, id] of Object.entries(map)) {
539
618
  if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
619
+ // The operator's own agy TAB, whose conversation is registered against
620
+ // the checkout exactly as a terminal one would be.
621
+ if (mine.has(id)) continue;
540
622
  let real;
541
623
  try {
542
624
  real = realpathSync(cwd);
@@ -20,7 +20,7 @@ function present(cmd) {
20
20
 
21
21
  function ghAuthed() {
22
22
  try {
23
- execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' });
23
+ execFileSync('gh', ['auth', 'status'], { stdio: 'ignore', timeout: 20_000 });
24
24
  return true;
25
25
  } catch {
26
26
  return false;
@@ -482,11 +482,29 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
482
482
  });
483
483
 
484
484
 
485
+ /**
486
+ * THE FEATURE NAME AND THE FILE LIST ARE FENCED, and they were the only two
487
+ * unfenced strings left in this file.
488
+ *
489
+ * A card title is member-authored, and worse: ticket triage falls back to the
490
+ * REPORTER's ticket title verbatim, so a stranger can put words in it. That
491
+ * string rides the ship report into `code_map_reground_jobs`, comes back on the
492
+ * roster, and landed here as a bare `Feature: <text>` line — no delimiter, no
493
+ * instruction not to obey it. This turn runs UNATTENDED under `WIKI_PERM`,
494
+ * which grants Write, Edit, `Bash(mkdir:*)` and `Bash(rm:*)`, and whose own
495
+ * comment concedes that Write/Edit cannot be path-scoped here — the worktree
496
+ * reset is the backstop, and it only cleans the wiki worktree. Anything written
497
+ * outside it survives.
498
+ *
499
+ * Every other kickoff in this file already fences its untrusted input; this one
500
+ * was simply missed. The file list is fenced for the same reason at lower
501
+ * stakes — a path is attacker-influenceable too, and there is no cost to it.
502
+ */
485
503
  export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
486
504
  `A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
487
- `Feature: ${title}\n` +
505
+ `Feature:\n${fence('FEATURE NAME', title)}\n` +
488
506
  `Grounded commit: ${sha}\n` +
489
- `Changed files:\n${files.map((f) => `- ${f}`).join('\n')}\n\n` +
507
+ `Changed files:\n${fence('CHANGED FILES', files.map((f) => `- ${f}`).join('\n'))}\n\n` +
490
508
  // The plan's own prediction, made when this work was drafted. Overlapping
491
509
  // changed files against each page's frontmatter finds most of what moved, but
492
510
  // misses a page whose file list has drifted or that documents a CONCEPT rather
package/bin/lib/work.mjs CHANGED
@@ -82,6 +82,37 @@ import {
82
82
  import { worktreeDiff } from './worktreeDiff.mjs';
83
83
  import { homedir } from 'node:os';
84
84
 
85
+ /**
86
+ * A RESUME THAT FOUND NO CONVERSATION, in the CLI's own words.
87
+ *
88
+ * "Produced nothing" was the only signal the retry backstop had, and it is not
89
+ * the signal these failures give: Claude Code answers a dead `--resume` id with
90
+ * a result event carrying `errors: ['No conversation found with session ID: …']`
91
+ * AND writes the same line to stderr, so `out` is non-empty, the backstop never
92
+ * fired, and the turn SETTLED SUCCESSFULLY with the error as its answer. Every
93
+ * later message in that tab replied the same way — the marker file still held
94
+ * the dead id, nothing rewrote it (that only happens when an init event is
95
+ * seen, and there is none), and no surface offered a way to clear it. A tab
96
+ * bricked forever by its own CLI pruning its history, which it does on its own
97
+ * schedule.
98
+ *
99
+ * Matched on the CLI's phrasing rather than a code, because neither runtime
100
+ * gives one. Deliberately narrow: it must not swallow a rate limit or a
101
+ * permission refusal, both of which are real answers that should stand.
102
+ */
103
+ const RESUME_LOST = [
104
+ /no conversation found/i,
105
+ /no session found/i,
106
+ /session .{0,80}not found/i,
107
+ /conversation .{0,80}not found/i,
108
+ /thread .{0,80}not found/i,
109
+ /trajectory not found/i,
110
+ ];
111
+ const resumeConversationLost = (text) => {
112
+ const t = String(text || '');
113
+ return t.length > 0 && RESUME_LOST.some((re) => re.test(t));
114
+ };
115
+
85
116
  /**
86
117
  * The shape a per-tab model name must have before it rides argv as
87
118
  * `--model <name>`. Conservative for the same reason the codex thread id is
@@ -1309,6 +1340,23 @@ export function createWorkManager({
1309
1340
  const pid = Number(job.pid);
1310
1341
  const signal = job.signal === 'KILL' ? 'SIGKILL' : 'SIGTERM';
1311
1342
 
1343
+ /**
1344
+ * CLAIM FIRST, EVEN FOR THE ANSWERS THAT SIGNAL NOTHING.
1345
+ *
1346
+ * `unsupported` and `not_found` cost nothing to produce, which is exactly
1347
+ * why they must be leased: two daemons on one credential are handed the
1348
+ * same job, and the one holding NOTHING reaches these branches without
1349
+ * measuring anything or making a round trip — so it would answer first,
1350
+ * settle the row, and the machine that could actually have signalled would
1351
+ * find the job already closed and never touch the process. The person reads
1352
+ * "the pid is no longer one of this tab's", which is a real sentence, over a
1353
+ * watcher that is still running.
1354
+ *
1355
+ * The server enforces holder-only settles again (it briefly accepted these
1356
+ * two unclaimed, which is the bug above), so an unclaimed post here would
1357
+ * simply be dropped. One extra round trip on a path nobody is waiting on.
1358
+ */
1359
+ if (!(await claimKill(id))) return;
1312
1360
  if (!processesSupported()) {
1313
1361
  await postKill({ id, outcome: 'unsupported' });
1314
1362
  return;
@@ -1324,7 +1372,6 @@ export function createWorkManager({
1324
1372
  await remeasureAfterKill(sessionId);
1325
1373
  return;
1326
1374
  }
1327
- if (!(await claimKill(id))) return;
1328
1375
  try {
1329
1376
  process.kill(pid, signal);
1330
1377
  // WHAT HAPPENED, not what we did. "We sent a signal" is a fact about us;
@@ -1439,7 +1486,18 @@ export function createWorkManager({
1439
1486
  // outcome because "the machine cannot do this at all" and "GitHub said
1440
1487
  // no" read differently to the person who asked.
1441
1488
  try {
1442
- execFileSync('gh', ['auth', 'status'], { stdio: ['ignore', 'pipe', 'pipe'] });
1489
+ // TIMED OUT, like every other `gh` call. `execFileSync` blocks the whole
1490
+ // event loop, so a hung `gh` — an expired token whose refresh hits a
1491
+ // black hole, a credential helper waiting on a keyring prompt that has
1492
+ // no terminal — stops the roster poll, every in-flight settle, the
1493
+ // worktree sweep and the deploy heartbeat (whose 3-minute staleness
1494
+ // window then re-queues a deploy this daemon is still running). The
1495
+ // AGENT merge path was given exactly these timeouts in 0.77.1; this
1496
+ // copy, forty lines of the same logic, was missed.
1497
+ execFileSync('gh', ['auth', 'status'], {
1498
+ stdio: ['ignore', 'pipe', 'pipe'],
1499
+ timeout: 20_000,
1500
+ });
1443
1501
  } catch (e) {
1444
1502
  const missing = e?.code === 'ENOENT';
1445
1503
  await settlePr({
@@ -1499,6 +1557,7 @@ export function createWorkManager({
1499
1557
  execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
1500
1558
  cwd: repoRoot,
1501
1559
  stdio: ['ignore', 'pipe', 'pipe'],
1560
+ timeout: 30_000,
1502
1561
  }).toString()
1503
1562
  );
1504
1563
  return j?.state === 'OPEN' && typeof j?.url === 'string' ? j.url.trim() : null;
@@ -1524,7 +1583,7 @@ export function createWorkManager({
1524
1583
  // call, nothing invented. baseBranchName, not baseRef: gh 422s on
1525
1584
  // a remote-tracking name like origin/main.
1526
1585
  ['pr', 'create', '--head', branch, '--base', baseName, '--fill'],
1527
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
1586
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
1528
1587
  )
1529
1588
  .toString()
1530
1589
  .trim();
@@ -1559,6 +1618,7 @@ export function createWorkManager({
1559
1618
  execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
1560
1619
  cwd: repoRoot,
1561
1620
  stdio: ['ignore', 'pipe', 'pipe'],
1621
+ timeout: 120_000,
1562
1622
  });
1563
1623
  } catch (e) {
1564
1624
  const line = ghFirstLine(e);
@@ -2247,15 +2307,41 @@ export function createWorkManager({
2247
2307
  * the pid lives and clears the lock once it is dead.
2248
2308
  */
2249
2309
  const workChildren = new Map(); // child process -> lockPath | null
2310
+ /**
2311
+ * Children whose whole PROCESS GROUP must go, not just the child.
2312
+ *
2313
+ * The standing rule is the opposite — teardown SIGTERMs the CLI child and
2314
+ * never its group, precisely so an unattended auto-update does not kill the
2315
+ * driver's dev server, which a turn started INTO the CLI's group. That rule
2316
+ * is about the CLI's group and stays.
2317
+ *
2318
+ * A project CHECK is a different group entirely: the daemon spawns it itself,
2319
+ * `detached` with `shell: true`, so its group holds the check command and
2320
+ * nothing else — no dev server of anybody's. And `shell: true` is exactly
2321
+ * what makes signalling only the child useless: a compound command like
2322
+ * `npm run lint && npm test` leaves `/bin/sh` as the child, so SIGTERM killed
2323
+ * the shell and the test runner underneath it carried on holding the
2324
+ * worktree — and that place's WRITER lock — through every stop, takeover and
2325
+ * auto-update. The check's own ten-minute timer already kills `-child.pid`
2326
+ * for this reason; teardown simply did not.
2327
+ */
2328
+ const groupKillChildren = new Set();
2250
2329
  const shutdownWork = () => {
2251
2330
  for (const [ch] of workChildren) {
2252
2331
  try {
2253
- ch.kill('SIGTERM');
2332
+ if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
2333
+ else ch.kill('SIGTERM');
2254
2334
  } catch {
2255
- /* best-effort */
2335
+ // A group that has already gone, or a pid that is no longer a leader.
2336
+ try {
2337
+ ch.kill('SIGTERM');
2338
+ } catch {
2339
+ /* best-effort */
2340
+ }
2256
2341
  }
2257
2342
  }
2258
2343
  workChildren.clear();
2344
+ groupKillChildren.clear();
2259
2345
  };
2260
2346
 
2261
2347
  /**
@@ -2816,6 +2902,7 @@ export function createWorkManager({
2816
2902
  toolLog.ev.length > 0 ? toolLog : undefined
2817
2903
  );
2818
2904
 
2905
+
2819
2906
  // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
2820
2907
  // to the server verbatim so an admin can read what actually ran on
2821
2908
  // this box. Same events the narrator renders and forgets; this is
@@ -2973,8 +3060,28 @@ export function createWorkManager({
2973
3060
  // loud): a fresh conversation would silently discard the adoption
2974
3061
  // and answer as a new session wearing its name — the empty adopt
2975
3062
  // turn settles failed below instead.
2976
- if (!adopting && resume && !(out || '').trim())
3063
+ /**
3064
+ * …OR PRODUCED ONLY THE CLI SAYING THE CONVERSATION IS GONE.
3065
+ *
3066
+ * "Produced nothing" was the whole test, and it is not the shape
3067
+ * these failures take: Claude Code answers a dead `--resume` id
3068
+ * with a result event carrying `errors: ['No conversation found
3069
+ * with session ID: …']` and writes the same line to stderr, so
3070
+ * `out` is non-empty. The backstop never fired, the turn settled
3071
+ * SUCCESSFULLY with that error as its answer, and — because the
3072
+ * marker is only rewritten when an init event is seen, and there
3073
+ * was none — the dead id stayed pinned. Every later message in
3074
+ * that tab replied identically, with nothing on any surface able
3075
+ * to clear it. A tab bricked forever by its own CLI pruning its
3076
+ * history, which it does on its own schedule.
3077
+ */
3078
+ if (
3079
+ !adopting &&
3080
+ resume &&
3081
+ (!(out || '').trim() || resumeConversationLost(out))
3082
+ ) {
2977
3083
  out = await runTurn({ ...turnArgs, resume: false });
3084
+ }
2978
3085
  } finally {
2979
3086
  // The CLI has stopped printing, so stop relaying. The LINE itself
2980
3087
  // is cleared server-side at settle — clearing it here would race
@@ -4139,8 +4246,15 @@ export function createWorkManager({
4139
4246
  * orphaned a running test suite inside a worktree the sweep may then
4140
4247
  * try to remove. The ten-minute timer would eventually kill it, but by
4141
4248
  * then it belongs to no daemon and nothing on any surface names it.
4249
+ *
4250
+ * Registered for a GROUP kill: with `shell: true` the child is
4251
+ * `/bin/sh`, and signalling it leaves the runner it started behind —
4252
+ * which is the process actually holding the worktree. See
4253
+ * `groupKillChildren` for why this one is exempt from the
4254
+ * never-signal-the-group rule.
4142
4255
  */
4143
4256
  workChildren.set(child, null);
4257
+ groupKillChildren.add(child);
4144
4258
  } catch (e) {
4145
4259
  // TEXT BEFORE FINISH: `finish` captures `text` by value into the
4146
4260
  // resolved object, so assigning afterwards threw the spawn error away
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.77.5",
3
+ "version": "0.78.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {