flowviant 0.77.0 → 0.77.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.
Files changed (2) hide show
  1. package/bin/lib/work.mjs +85 -5
  2. package/package.json +1 -1
package/bin/lib/work.mjs CHANGED
@@ -2275,6 +2275,36 @@ export function createWorkManager({
2275
2275
  for (const id of ids) {
2276
2276
  if (live.has(id)) continue;
2277
2277
  if (peers.has(id)) continue; // another daemon's tab — not ours to retire
2278
+ /**
2279
+ * A HARD STOP REACHES THE CLI HERE, and this is the whole of it.
2280
+ *
2281
+ * `stopAgent` marked the agent abandoned in D1 and nothing on the wire
2282
+ * told the machine, so the CLI went on working, editing the worktree and
2283
+ * spending the operator's quota — while the confirm dialog said the agent
2284
+ * was stopped. It also never got cleaned up, because the check below
2285
+ * skips any place whose lock is held and a running turn holds it: a
2286
+ * stopped agent mid-turn kept its directory forever.
2287
+ *
2288
+ * No new wire field and no version floor: an abandoned agent simply drops
2289
+ * out of `activeWorkSessions` (that list is built from LIVE statuses), so
2290
+ * the server ALREADY says everything needed. Absence means "nothing here
2291
+ * is live any more", and the honest response to that is to stop what we
2292
+ * are running in it and then take the directory.
2293
+ *
2294
+ * SIGTERM the CHILD, never its group — the rule teardown keeps, so an
2295
+ * unattended sweep cannot take the driver's dev server with it. The lock
2296
+ * is released when the child exits, so the removal happens on the next
2297
+ * pass rather than this one.
2298
+ */
2299
+ const running = agentChildren.get(id);
2300
+ if (running) {
2301
+ try {
2302
+ running.kill('SIGTERM');
2303
+ } catch {
2304
+ /* already gone */
2305
+ }
2306
+ agentChildren.delete(id);
2307
+ }
2278
2308
  // Asked of the PLACE, not the session: the lock is keyed by directory,
2279
2309
  // and a session sharing one with a busy peer is not ours to retire
2280
2310
  // either — its worktree is the peer's working directory.
@@ -3604,6 +3634,15 @@ export function createWorkManager({
3604
3634
  // do is run a CLI twice in one worktree, which is what this in-flight set and
3605
3635
  // the place lock prevent — the same discipline `processWorkTurns` keeps.
3606
3636
  const agentTurns = new Set(); // turn ids in flight on this tick
3637
+ /**
3638
+ * The CLI a live agent turn is running in, by PLACE.
3639
+ *
3640
+ * `workChildren` is keyed by the child itself, which answers "kill everything
3641
+ * at teardown" and cannot answer "kill THIS agent's". Keyed by place rather
3642
+ * than by agent id because the one consumer — the retire sweep — walks
3643
+ * directory names, and those are places.
3644
+ */
3645
+ const agentChildren = new Map(); // placeId -> child process
3607
3646
 
3608
3647
  /** Returns the server's reply, because it carries ONE instruction the machine
3609
3648
  * can act on immediately: `review: true` means the agent's queue just
@@ -3747,10 +3786,31 @@ export function createWorkManager({
3747
3786
  return;
3748
3787
  }
3749
3788
  const wt = dir.wt;
3789
+ /**
3790
+ * NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
3791
+ * of these exit non-zero in states a real worktree reaches: `symbolic-ref`
3792
+ * on a DETACHED HEAD (an agent that ran `git checkout <sha>`, a rebase
3793
+ * left half-done), and `rev-parse HEAD` on a branch with no commits yet.
3794
+ * An escape here happens BEFORE any settle, so the turn stays pending
3795
+ * until the six-hour expiry while the agent sits in Working with nothing
3796
+ * behind it — the wedge this whole lane exists to avoid.
3797
+ *
3798
+ * `runAgentMerge` learned this two commits ago and says so in its own
3799
+ * comment; this is the same call, in the same file, left unguarded. Both
3800
+ * facts are OPTIONAL to a turn — they describe the branch, they do not
3801
+ * gate the work — so failing to read them costs a null, not the turn.
3802
+ */
3803
+ const readGit = (args) => {
3804
+ try {
3805
+ return (git(args, wt) || '').trim() || null;
3806
+ } catch {
3807
+ return null;
3808
+ }
3809
+ };
3750
3810
  // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
3751
3811
  // 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;
3812
+ const before = readGit(['rev-parse', 'HEAD']);
3813
+ const branch = readGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
3754
3814
 
3755
3815
  const rt = job.runtime || 'claude';
3756
3816
  if (!canRun(RUNTIMES[rt], 'build')) {
@@ -3822,10 +3882,15 @@ export function createWorkManager({
3822
3882
  child = ch;
3823
3883
  workChildren.set(ch, null);
3824
3884
  noteSessionGroup(agentId, ch.pid);
3885
+ // Keyed by PLACE, because the retire sweep iterates directory names
3886
+ // and a place id IS one. It is what lets a hard stop actually reach
3887
+ // the CLI — see `retireWorkSessions`.
3888
+ agentChildren.set(place, ch);
3825
3889
  },
3826
3890
  });
3827
3891
  } finally {
3828
3892
  if (child) workChildren.delete(child);
3893
+ if (agentChildren.get(place) === child) agentChildren.delete(place);
3829
3894
  if (ranMarker) {
3830
3895
  try {
3831
3896
  writeFileSync(ranMarker, '1');
@@ -4209,7 +4274,15 @@ export function createWorkManager({
4209
4274
  */
4210
4275
  if (job.prMode) {
4211
4276
  try {
4212
- execFileSync('gh', ['auth', 'status'], { stdio: ['ignore', 'pipe', 'pipe'] });
4277
+ // EVERY `gh` CALL IS TIMED OUT. `execFileSync` blocks the daemon's
4278
+ // whole event loop, and this one runs INSIDE the place writer lock —
4279
+ // so a `gh` that hangs (an expired token prompting, a network black
4280
+ // hole, a hung credential helper) stops every turn on the machine,
4281
+ // not merely this merge, and holds the lock while it does.
4282
+ execFileSync('gh', ['auth', 'status'], {
4283
+ stdio: ['ignore', 'pipe', 'pipe'],
4284
+ timeout: 20_000,
4285
+ });
4213
4286
  } catch (e) {
4214
4287
  await postAgentMerge({
4215
4288
  agentId,
@@ -4233,7 +4306,12 @@ export function createWorkManager({
4233
4306
  try {
4234
4307
  git(['push', '-u', 'origin', branch], wt);
4235
4308
  } catch (e) {
4236
- await postAgentMerge({ agentId, ok: false, detail: ghFirstLine(e) });
4309
+ // SCRUBBED. Every other failure on this path relays `gh`'s own words,
4310
+ // but a push writes the REMOTE URL to stderr and a remote can carry a
4311
+ // token in its userinfo — so this one line is the only place on the
4312
+ // agent merge path that can leak a credential into a stored,
4313
+ // team-visible `mergeError`.
4314
+ await postAgentMerge({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
4237
4315
  return;
4238
4316
  }
4239
4317
  let prUrl = null;
@@ -4242,6 +4320,7 @@ export function createWorkManager({
4242
4320
  execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
4243
4321
  cwd: repoRoot,
4244
4322
  stdio: ['ignore', 'pipe', 'pipe'],
4323
+ timeout: 30_000,
4245
4324
  }).toString()
4246
4325
  );
4247
4326
  if (j?.state === 'OPEN' && typeof j?.url === 'string') prUrl = j.url.trim();
@@ -4254,7 +4333,7 @@ export function createWorkManager({
4254
4333
  'gh',
4255
4334
  // baseBranchName, not baseRef: gh 422s on a remote-tracking name.
4256
4335
  ['pr', 'create', '--head', branch, '--base', prBase, '--fill'],
4257
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
4336
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
4258
4337
  )
4259
4338
  .toString()
4260
4339
  .trim();
@@ -4268,6 +4347,7 @@ export function createWorkManager({
4268
4347
  execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
4269
4348
  cwd: repoRoot,
4270
4349
  stdio: ['ignore', 'pipe', 'pipe'],
4350
+ timeout: 120_000,
4271
4351
  });
4272
4352
  } catch (e) {
4273
4353
  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.2",
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": {