flowviant 0.77.0 → 0.77.3

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.
Files changed (2) hide show
  1. package/bin/lib/work.mjs +136 -7
  2. package/package.json +1 -1
package/bin/lib/work.mjs CHANGED
@@ -567,6 +567,21 @@ export function createWorkManager({
567
567
  const worktreeSeen = new Set();
568
568
  let lastWorktreeFetch = 0;
569
569
  let sweepingWorktrees = false;
570
+ /**
571
+ * WHERE THE NEXT SWEEP STARTS.
572
+ *
573
+ * A safety valve with a rotation, and the rotation is the load-bearing half.
574
+ * The sweep used to take `activeIds.slice(0, 20)` — a silent truncation of a
575
+ * list the server builds tabs-first and agent places LAST, so on a project
576
+ * with twenty live tabs no agent was EVER measured: no branch diff, no head
577
+ * sha, and none of their trailered commits ever reached a card. Permanently,
578
+ * because the same twenty won every pass.
579
+ *
580
+ * Chunking removes the cut for any realistic project (see below). The cursor
581
+ * is what makes the residual cap fair rather than arbitrary: past it, the
582
+ * places that missed one sweep are the ones that lead the next.
583
+ */
584
+ let worktreeCursor = 0;
570
585
  const postWorktrees = async (reports) => {
571
586
  if (!reports.length) return;
572
587
  try {
@@ -800,12 +815,46 @@ export function createWorkManager({
800
815
  // landed. Best-effort like everything in this sweep.
801
816
  void landed.observe().catch(() => {});
802
817
  }
818
+ /**
819
+ * MEASURED IN CHUNKS, not truncated to one.
820
+ *
821
+ * The server's endpoint takes twenty entries per request, and this read
822
+ * that bound as "measure twenty places" — so everything past the
823
+ * twentieth was silently never measured, and the server builds that
824
+ * list with agent places at the END. A project with twenty live tabs
825
+ * therefore measured no agent at all: their review pane showed no
826
+ * branch diff, `checkIsStale` had no head to compare against, and their
827
+ * commits never reached the cards they name.
828
+ *
829
+ * The cap on a REQUEST is not a cap on the WORK. Several requests of
830
+ * twenty cost several round trips and each is validated per entry
831
+ * exactly as before, so no contract changes and no floor is needed.
832
+ *
833
+ * `SWEEP_MAX_PLACES` is a bound on the MACHINE — each place costs a
834
+ * `git diff`, a listener scan and a process scan — and the cursor
835
+ * rotates so a project past it still measures everything, just across
836
+ * successive sweeps instead of one.
837
+ */
838
+ const SWEEP_MAX_PLACES = 60;
839
+ const CHUNK = 20;
840
+ const total = activeIds.length;
841
+ const start = total > SWEEP_MAX_PLACES ? worktreeCursor % total : 0;
842
+ const take = Math.min(total, SWEEP_MAX_PLACES);
843
+ // Rotated slice, so the tail of a long list leads the next sweep rather
844
+ // than never being reached.
845
+ const order = Array.from({ length: take }, (_, i) => activeIds[(start + i) % total]);
846
+ worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
803
847
  const reports = [];
804
- for (const id of activeIds.slice(0, 20)) {
848
+ for (const id of order) {
805
849
  const r = sessionWorktreeReport(id);
806
850
  if (r) reports.push(r);
807
851
  }
808
- await postWorktrees(reports);
852
+ // One POST per chunk. Awaited in sequence rather than fired together:
853
+ // this runs on the machine's own poll beat and a burst of parallel
854
+ // writes to the same rows buys nothing.
855
+ for (let i = 0; i < reports.length; i += CHUNK) {
856
+ await postWorktrees(reports.slice(i, i + CHUNK));
857
+ }
809
858
  } finally {
810
859
  sweepingWorktrees = false;
811
860
  }
@@ -2275,6 +2324,36 @@ export function createWorkManager({
2275
2324
  for (const id of ids) {
2276
2325
  if (live.has(id)) continue;
2277
2326
  if (peers.has(id)) continue; // another daemon's tab — not ours to retire
2327
+ /**
2328
+ * A HARD STOP REACHES THE CLI HERE, and this is the whole of it.
2329
+ *
2330
+ * `stopAgent` marked the agent abandoned in D1 and nothing on the wire
2331
+ * told the machine, so the CLI went on working, editing the worktree and
2332
+ * spending the operator's quota — while the confirm dialog said the agent
2333
+ * was stopped. It also never got cleaned up, because the check below
2334
+ * skips any place whose lock is held and a running turn holds it: a
2335
+ * stopped agent mid-turn kept its directory forever.
2336
+ *
2337
+ * No new wire field and no version floor: an abandoned agent simply drops
2338
+ * out of `activeWorkSessions` (that list is built from LIVE statuses), so
2339
+ * the server ALREADY says everything needed. Absence means "nothing here
2340
+ * is live any more", and the honest response to that is to stop what we
2341
+ * are running in it and then take the directory.
2342
+ *
2343
+ * SIGTERM the CHILD, never its group — the rule teardown keeps, so an
2344
+ * unattended sweep cannot take the driver's dev server with it. The lock
2345
+ * is released when the child exits, so the removal happens on the next
2346
+ * pass rather than this one.
2347
+ */
2348
+ const running = agentChildren.get(id);
2349
+ if (running) {
2350
+ try {
2351
+ running.kill('SIGTERM');
2352
+ } catch {
2353
+ /* already gone */
2354
+ }
2355
+ agentChildren.delete(id);
2356
+ }
2278
2357
  // Asked of the PLACE, not the session: the lock is keyed by directory,
2279
2358
  // and a session sharing one with a busy peer is not ours to retire
2280
2359
  // either — its worktree is the peer's working directory.
@@ -3604,6 +3683,15 @@ export function createWorkManager({
3604
3683
  // do is run a CLI twice in one worktree, which is what this in-flight set and
3605
3684
  // the place lock prevent — the same discipline `processWorkTurns` keeps.
3606
3685
  const agentTurns = new Set(); // turn ids in flight on this tick
3686
+ /**
3687
+ * The CLI a live agent turn is running in, by PLACE.
3688
+ *
3689
+ * `workChildren` is keyed by the child itself, which answers "kill everything
3690
+ * at teardown" and cannot answer "kill THIS agent's". Keyed by place rather
3691
+ * than by agent id because the one consumer — the retire sweep — walks
3692
+ * directory names, and those are places.
3693
+ */
3694
+ const agentChildren = new Map(); // placeId -> child process
3607
3695
 
3608
3696
  /** Returns the server's reply, because it carries ONE instruction the machine
3609
3697
  * can act on immediately: `review: true` means the agent's queue just
@@ -3747,10 +3835,31 @@ export function createWorkManager({
3747
3835
  return;
3748
3836
  }
3749
3837
  const wt = dir.wt;
3838
+ /**
3839
+ * NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
3840
+ * of these exit non-zero in states a real worktree reaches: `symbolic-ref`
3841
+ * on a DETACHED HEAD (an agent that ran `git checkout <sha>`, a rebase
3842
+ * left half-done), and `rev-parse HEAD` on a branch with no commits yet.
3843
+ * An escape here happens BEFORE any settle, so the turn stays pending
3844
+ * until the six-hour expiry while the agent sits in Working with nothing
3845
+ * behind it — the wedge this whole lane exists to avoid.
3846
+ *
3847
+ * `runAgentMerge` learned this two commits ago and says so in its own
3848
+ * comment; this is the same call, in the same file, left unguarded. Both
3849
+ * facts are OPTIONAL to a turn — they describe the branch, they do not
3850
+ * gate the work — so failing to read them costs a null, not the turn.
3851
+ */
3852
+ const readGit = (args) => {
3853
+ try {
3854
+ return (git(args, wt) || '').trim() || null;
3855
+ } catch {
3856
+ return null;
3857
+ }
3858
+ };
3750
3859
  // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
3751
3860
  // ones this turn actually made.
3752
- const before = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
3753
- const branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim() || null;
3861
+ const before = readGit(['rev-parse', 'HEAD']);
3862
+ const branch = readGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
3754
3863
 
3755
3864
  const rt = job.runtime || 'claude';
3756
3865
  if (!canRun(RUNTIMES[rt], 'build')) {
@@ -3822,10 +3931,15 @@ export function createWorkManager({
3822
3931
  child = ch;
3823
3932
  workChildren.set(ch, null);
3824
3933
  noteSessionGroup(agentId, ch.pid);
3934
+ // Keyed by PLACE, because the retire sweep iterates directory names
3935
+ // and a place id IS one. It is what lets a hard stop actually reach
3936
+ // the CLI — see `retireWorkSessions`.
3937
+ agentChildren.set(place, ch);
3825
3938
  },
3826
3939
  });
3827
3940
  } finally {
3828
3941
  if (child) workChildren.delete(child);
3942
+ if (agentChildren.get(place) === child) agentChildren.delete(place);
3829
3943
  if (ranMarker) {
3830
3944
  try {
3831
3945
  writeFileSync(ranMarker, '1');
@@ -4209,7 +4323,15 @@ export function createWorkManager({
4209
4323
  */
4210
4324
  if (job.prMode) {
4211
4325
  try {
4212
- execFileSync('gh', ['auth', 'status'], { stdio: ['ignore', 'pipe', 'pipe'] });
4326
+ // EVERY `gh` CALL IS TIMED OUT. `execFileSync` blocks the daemon's
4327
+ // whole event loop, and this one runs INSIDE the place writer lock —
4328
+ // so a `gh` that hangs (an expired token prompting, a network black
4329
+ // hole, a hung credential helper) stops every turn on the machine,
4330
+ // not merely this merge, and holds the lock while it does.
4331
+ execFileSync('gh', ['auth', 'status'], {
4332
+ stdio: ['ignore', 'pipe', 'pipe'],
4333
+ timeout: 20_000,
4334
+ });
4213
4335
  } catch (e) {
4214
4336
  await postAgentMerge({
4215
4337
  agentId,
@@ -4233,7 +4355,12 @@ export function createWorkManager({
4233
4355
  try {
4234
4356
  git(['push', '-u', 'origin', branch], wt);
4235
4357
  } catch (e) {
4236
- await postAgentMerge({ agentId, ok: false, detail: ghFirstLine(e) });
4358
+ // SCRUBBED. Every other failure on this path relays `gh`'s own words,
4359
+ // but a push writes the REMOTE URL to stderr and a remote can carry a
4360
+ // token in its userinfo — so this one line is the only place on the
4361
+ // agent merge path that can leak a credential into a stored,
4362
+ // team-visible `mergeError`.
4363
+ await postAgentMerge({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
4237
4364
  return;
4238
4365
  }
4239
4366
  let prUrl = null;
@@ -4242,6 +4369,7 @@ export function createWorkManager({
4242
4369
  execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
4243
4370
  cwd: repoRoot,
4244
4371
  stdio: ['ignore', 'pipe', 'pipe'],
4372
+ timeout: 30_000,
4245
4373
  }).toString()
4246
4374
  );
4247
4375
  if (j?.state === 'OPEN' && typeof j?.url === 'string') prUrl = j.url.trim();
@@ -4254,7 +4382,7 @@ export function createWorkManager({
4254
4382
  'gh',
4255
4383
  // baseBranchName, not baseRef: gh 422s on a remote-tracking name.
4256
4384
  ['pr', 'create', '--head', branch, '--base', prBase, '--fill'],
4257
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
4385
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
4258
4386
  )
4259
4387
  .toString()
4260
4388
  .trim();
@@ -4268,6 +4396,7 @@ export function createWorkManager({
4268
4396
  execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
4269
4397
  cwd: repoRoot,
4270
4398
  stdio: ['ignore', 'pipe', 'pipe'],
4399
+ timeout: 120_000,
4271
4400
  });
4272
4401
  } catch (e) {
4273
4402
  const line = ghFirstLine(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.77.0",
3
+ "version": "0.77.3",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {