flowviant 0.114.0 → 0.114.2

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.
@@ -59,6 +59,14 @@ import { createCommandAudit } from './workCommandAudit.mjs';
59
59
  export const PLAN_TURN_SENTENCE =
60
60
  'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
61
61
 
62
+ /**
63
+ * HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
64
+ * `settledHere` below. Ten minutes is far past any roster a poll could still
65
+ * be holding; the cap keeps a box that answers all day from growing the map.
66
+ */
67
+ export const SETTLED_HERE_TTL_MS = 10 * 60 * 1000;
68
+ export const SETTLED_HERE_MAX = 500;
69
+
62
70
  export function createWorkSessionTurns({
63
71
  repoRoot,
64
72
  baseDir,
@@ -91,14 +99,67 @@ export function createWorkSessionTurns({
91
99
  // The real CLI driver; injectable only so the lane can be driven in a test
92
100
  // without spending a model turn (the wiki runner's shape).
93
101
  runTurn = defaultRunTurn,
102
+ // The clock `settledHere` ages by; injectable only for its bound's test.
103
+ now = Date.now,
94
104
  }) {
95
105
  const workAnswering = new Set(); // turn ids currently queued/running here
96
106
  const MAX_WORK_TRIES = 3;
97
107
 
108
+ /**
109
+ * A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
110
+ *
111
+ * A delivered settle forgets the turn everywhere: the report queue drops it
112
+ * from `pendingWorkReports` and `workAttempts`, and the turn's `finally`
113
+ * drops it from `workAnswering`. A roster READ made before that settle
114
+ * landed still offers it — the poll was in flight while the POST went out —
115
+ * and nothing was left to say it had run: the turn ran twice, settled
116
+ * twice, the rerun's reply overwrote the first, and the person's follow-up
117
+ * waited behind a turn nobody asked for. So every settle this lane makes
118
+ * goes through `settleTurn`, which remembers the turn id, and a job in the
119
+ * map is skipped where jobs are picked. Remembered BEFORE the POST: a
120
+ * settle still in flight is covered by `workAnswering`, one that could not
121
+ * be delivered by `pendingWorkReports`, and this covers the one that was.
122
+ */
123
+ const settledHere = new Map(); // turnId -> when this lane settled it, oldest first
124
+ const forgetStale = (at) => {
125
+ for (const [id, when] of settledHere) {
126
+ if (settledHere.size <= SETTLED_HERE_MAX && at - when < SETTLED_HERE_TTL_MS) break;
127
+ settledHere.delete(id);
128
+ }
129
+ };
130
+ const settledRecently = (turnId) => {
131
+ forgetStale(now());
132
+ return settledHere.has(turnId);
133
+ };
134
+ const settleTurn = (turnId, payload) => {
135
+ const at = now();
136
+ settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
137
+ settledHere.set(turnId, at);
138
+ forgetStale(at);
139
+ return settleWorkTurn(turnId, payload);
140
+ };
141
+
142
+ /**
143
+ * A PLACE A TURN OF THIS PROCESS IS STARTING IN (2026-09-29) — the turn
144
+ * lock's in-process half, keyed by the lock's own path.
145
+ *
146
+ * `flowviant-turn.lock` is written at SPAWN, several awaits after it is
147
+ * read (a credential mint, the attachment fetch), so two turns of one
148
+ * session offered in one roster both read "unlocked" and both spawned: two
149
+ * CLIs resuming one conversation at once, the incoherence the lock exists
150
+ * to prevent. The claim is checked and taken synchronously beside the file
151
+ * check, so the second turn waits for a later poll like any locked one,
152
+ * and it is released in the turn's outer `finally`, after its settle.
153
+ */
154
+ const startingIn = new Set();
155
+
98
156
  const processWorkTurns = (jobs) => {
99
157
  for (const job of jobs ?? []) {
100
158
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
101
159
  if (workAnswering.has(job.id)) continue;
160
+ // Settled by this lane already: a roster read from before the settle
161
+ // landed. See `settledHere`.
162
+ if (settledRecently(job.id)) continue;
102
163
  // The turn already RAN and its answer sits in the delivery queue — never
103
164
  // run it again while the report is merely undelivered.
104
165
  if (pendingWorkReports.has(job.id)) continue;
@@ -140,7 +201,7 @@ export function createWorkSessionTurns({
140
201
  * tab for the server's whole expiry window.
141
202
  */
142
203
  if (place !== REPO_PLACE && !isSafePathSegment(place)) {
143
- void settleWorkTurn(job.id, {
204
+ void settleTurn(job.id, {
144
205
  ok: false,
145
206
  answer:
146
207
  'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
@@ -178,12 +239,15 @@ export function createWorkSessionTurns({
178
239
  * out here beside `usage`, and spread wherever it is. Last write wins
179
240
  * across a fresh retry: that spawn is the one that answered. */
180
241
  let model = null;
242
+ /** The `startingIn` key this turn claimed — released in the outer
243
+ * `finally`, and only by the turn that took it. */
244
+ let startClaim = null;
181
245
  try {
182
246
  const tries = workAttempts.get(job.id) ?? 0;
183
247
  if (tries >= MAX_WORK_TRIES) {
184
248
  // Out of local tries: SETTLE, don't skip — a silently skipped turn
185
249
  // strands the tab for the server's whole 24h expiry window.
186
- await settleWorkTurn(job.id, {
250
+ await settleTurn(job.id, {
187
251
  ok: false,
188
252
  answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
189
253
  });
@@ -209,7 +273,7 @@ export function createWorkSessionTurns({
209
273
  typeof job.adopt.id !== 'string' ||
210
274
  !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
211
275
  ) {
212
- await settleWorkTurn(job.id, {
276
+ await settleTurn(job.id, {
213
277
  ok: false,
214
278
  answer: 'that terminal session id is not one this machine can resume',
215
279
  });
@@ -223,7 +287,7 @@ export function createWorkSessionTurns({
223
287
  srcCwd = null;
224
288
  }
225
289
  if (!srcCwd) {
226
- await settleWorkTurn(job.id, {
290
+ await settleTurn(job.id, {
227
291
  ok: false,
228
292
  answer: "the terminal session's directory no longer exists on the machine",
229
293
  });
@@ -247,14 +311,14 @@ export function createWorkSessionTurns({
247
311
  /* keep the literal path */
248
312
  }
249
313
  if (!under(srcCwd, realRoot)) {
250
- await settleWorkTurn(job.id, {
314
+ await settleTurn(job.id, {
251
315
  ok: false,
252
316
  answer: "the terminal session's directory is outside this project's repository",
253
317
  });
254
318
  return;
255
319
  }
256
320
  if (under(srcCwd, realBase)) {
257
- await settleWorkTurn(job.id, {
321
+ await settleTurn(job.id, {
258
322
  ok: false,
259
323
  answer:
260
324
  "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
@@ -264,7 +328,7 @@ export function createWorkSessionTurns({
264
328
  try {
265
329
  srcHead = git(['rev-parse', 'HEAD'], srcCwd);
266
330
  } catch {
267
- await settleWorkTurn(job.id, {
331
+ await settleTurn(job.id, {
268
332
  ok: false,
269
333
  answer:
270
334
  "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
@@ -281,7 +345,7 @@ export function createWorkSessionTurns({
281
345
  ? isAgyConversationLive(job.adopt.id)
282
346
  : isTerminalSessionLive(job.adopt.id);
283
347
  if (adoptLive) {
284
- await settleWorkTurn(job.id, {
348
+ await settleTurn(job.id, {
285
349
  ok: false,
286
350
  answer:
287
351
  'That terminal session is still open on the machine — close it there first, then adopt.',
@@ -297,7 +361,7 @@ export function createWorkSessionTurns({
297
361
  // the session's own id, which is what every tab has always done.
298
362
  const dir = placeWtFor(place, adopting ? srcHead : undefined);
299
363
  if (!dir) {
300
- await settleWorkTurn(job.id, {
364
+ await settleTurn(job.id, {
301
365
  ok: false,
302
366
  answer:
303
367
  'the session worktree could not be opened on the machine — check the daemon log',
@@ -308,13 +372,20 @@ export function createWorkSessionTurns({
308
372
  // life, most likely; the lock outlives a restart. Leave the job
309
373
  // pending and look again next poll; spawning a second CLI would put
310
374
  // two Claudes in one held context. Costs no attempt: nothing ran.
375
+ // …AND ONE OF OURS MAY BE ABOUT TO: `startingIn`, checked and
376
+ // claimed with no await between, because the file is only written
377
+ // at spawn. Keyed by the lock's path (the directory, when the git
378
+ // dir cannot be named) — the file lock's own scope.
311
379
  const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
312
- if (turnLockedByLivePid(lockPath)) {
380
+ const startKey = lockPath || dir.wt;
381
+ if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
313
382
  warn(
314
383
  `a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
315
384
  );
316
385
  return;
317
386
  }
387
+ startingIn.add(startKey);
388
+ startClaim = startKey;
318
389
  // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
319
390
  // which is what every tab ran on until now) — honored by
320
391
  // sessionRuntime: on a first turn a named runtime IS the pick, and a
@@ -327,14 +398,14 @@ export function createWorkSessionTurns({
327
398
  if (rt.mismatch) {
328
399
  // Something upstream changed this tab's identity mid-life. A held
329
400
  // context must never be answered by a different brain — say so.
330
- await settleWorkTurn(job.id, {
401
+ await settleTurn(job.id, {
331
402
  ok: false,
332
403
  answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
333
404
  });
334
405
  return;
335
406
  }
336
407
  if (rt.missing) {
337
- await settleWorkTurn(job.id, {
408
+ await settleTurn(job.id, {
338
409
  ok: false,
339
410
  answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
340
411
  });
@@ -346,14 +417,14 @@ export function createWorkSessionTurns({
346
417
  // tab can run on (Antigravity has no MCP config, and the session's
347
418
  // whole control plane rides one). An honest sentence beats the
348
419
  // mcpFor throw this used to crash into every turn.
349
- await settleWorkTurn(job.id, {
420
+ await settleTurn(job.id, {
350
421
  ok: false,
351
422
  answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
352
423
  });
353
424
  return;
354
425
  }
355
426
  if (!rt.id) {
356
- await settleWorkTurn(job.id, {
427
+ await settleTurn(job.id, {
357
428
  ok: false,
358
429
  answer:
359
430
  'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
@@ -366,7 +437,7 @@ export function createWorkSessionTurns({
366
437
  // (--conversation). Codex has no adoptable store yet, and its
367
438
  // args builder backstops this with a loud throw — but a sentence
368
439
  // here beats a stack there.
369
- await settleWorkTurn(job.id, {
440
+ await settleTurn(job.id, {
370
441
  ok: false,
371
442
  answer:
372
443
  'adopting this terminal session needs its own CLI on the machine — install it, then try again',
@@ -383,7 +454,7 @@ export function createWorkSessionTurns({
383
454
  */
384
455
  const planTurn = job.planMode === true && job.capture !== true;
385
456
  if (planTurn && rt.id !== 'claude') {
386
- await settleWorkTurn(job.id, {
457
+ await settleTurn(job.id, {
387
458
  ok: false,
388
459
  answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
389
460
  });
@@ -409,7 +480,7 @@ export function createWorkSessionTurns({
409
480
  return;
410
481
  }
411
482
  if (mint?.gone) {
412
- await settleWorkTurn(job.id, {
483
+ await settleTurn(job.id, {
413
484
  ok: false,
414
485
  answer:
415
486
  'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
@@ -417,7 +488,7 @@ export function createWorkSessionTurns({
417
488
  return;
418
489
  }
419
490
  if (!mint?.token) {
420
- await settleWorkTurn(job.id, {
491
+ await settleTurn(job.id, {
421
492
  ok: false,
422
493
  answer:
423
494
  'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
@@ -694,6 +765,11 @@ export function createWorkSessionTurns({
694
765
  cwd: dir.wt,
695
766
  mcpArgs: mcp.args,
696
767
  mcpEnv: mcp.env,
768
+ // A PERSON AT THE KEYBOARD MAY ASK FOR A WINDOW (2026-09-29): a
769
+ // Terminal tab keeps its environment's display. The capture chat
770
+ // does not — it only reads and stages cards, and the person
771
+ // talking to it may be on a phone (noWindowEnv.mjs).
772
+ display: !captureTab,
697
773
  runtime: rt.id,
698
774
  label: c.cyan('[tab]'),
699
775
  // Only codex announces one (thread.started); held here so the id
@@ -760,6 +836,11 @@ export function createWorkSessionTurns({
760
836
  * that tab replied identically, with nothing on any surface able
761
837
  * to clear it. A tab bricked forever by its own CLI pruning its
762
838
  * history, which it does on its own schedule.
839
+ *
840
+ * An EMPTY Claude resume that emitted init is not retried
841
+ * (2026-09-29, resumeLost.mjs): its conversation was found, and a
842
+ * fresh run would answer with no memory of the chat and re-pin the
843
+ * tab to that new conversation.
763
844
  */
764
845
  if (
765
846
  !adopting &&
@@ -826,7 +907,7 @@ export function createWorkSessionTurns({
826
907
  // transcript is most likely gone (cleaned, expired, deleted). Say
827
908
  // exactly that; no sessionRef is recorded, so the server keeps
828
909
  // offering the adoption and a retry after the user checks is cheap.
829
- await settleWorkTurn(job.id, {
910
+ await settleTurn(job.id, {
830
911
  ok: false,
831
912
  answer: "Couldn't resume the terminal session — it may have been removed.",
832
913
  // Whatever it DID before coming back empty is exactly the
@@ -857,13 +938,24 @@ export function createWorkSessionTurns({
857
938
  }
858
939
  }
859
940
  }
860
- await settleWorkTurn(job.id, {
941
+ /**
942
+ * NO WORDS, SAID AS MEASURED (2026-09-29). Claude's init event means
943
+ * it ran in this conversation — kept, and resumed by the next
944
+ * message, since an empty resume that found it no longer runs fresh
945
+ * (resumeLost.mjs) — and its result held no words. "Signed out" is
946
+ * a guess, left to the case where no init came.
947
+ */
948
+ const noWords =
949
+ rt.id === 'claude' && seenClaudeSession
950
+ ? 'Claude Code ran in this conversation and returned no words — send the message again'
951
+ : 'the turn produced no output on the machine — its CLI may be signed out; try again';
952
+ await settleTurn(job.id, {
861
953
  ok: answer.length > 0,
862
954
  answer:
863
955
  answer.length > 0
864
956
  ? // Scrub: a reply can quote config or env-adjacent code.
865
957
  envScrub(answer).slice(0, 16000)
866
- : 'the turn produced no output on the machine — its CLI may be signed out; try again',
958
+ : noWords,
867
959
  // Only a turn that actually SPOKE proves a conversation lives
868
960
  // here. Recording the path unconditionally is how a crashed first
869
961
  // turn used to brick resume for the session's whole life.
@@ -878,7 +970,7 @@ export function createWorkSessionTurns({
878
970
  if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
879
971
  else warn('session turn produced no output — settled as failed');
880
972
  } catch (e) {
881
- await settleWorkTurn(job.id, {
973
+ await settleTurn(job.id, {
882
974
  ok: false,
883
975
  // Scrub, like every string that leaves this machine: an exception
884
976
  // routinely quotes command output, and command output can quote a
@@ -894,6 +986,7 @@ export function createWorkSessionTurns({
894
986
  warn(`session turn failed: ${e?.message ?? e}`);
895
987
  } finally {
896
988
  workAnswering.delete(job.id);
989
+ if (startClaim) startingIn.delete(startClaim);
897
990
  // Nothing spawned, or everything already has: releasing twice is the
898
991
  // normal case and costs nothing. A reservation that leaked would
899
992
  // shrink this machine's ceiling for the life of the process.
@@ -923,5 +1016,5 @@ export function createWorkSessionTurns({
923
1016
  }
924
1017
  };
925
1018
 
926
- return { processWorkTurns, workAnswering };
1019
+ return { processWorkTurns, workAnswering, settledHere };
927
1020
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.114.0",
3
+ "version": "0.114.2",
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": {