flowviant 0.76.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 +211 -2
  2. package/package.json +2 -2
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
@@ -3712,6 +3751,31 @@ export function createWorkManager({
3712
3751
  await postAgentTurn({ turnId, outcome: 'nothing' });
3713
3752
  return;
3714
3753
  }
3754
+ /**
3755
+ * A TASK TURN WITH NO CARD IS REFUSED, NOT IMPROVISED.
3756
+ *
3757
+ * The kickoff below branches on `job.kind === 'task' && job.task` and falls
3758
+ * through to the HUMAN kickoff otherwise — and a task turn's body is empty
3759
+ * by design, because the daemon composes the prompt from the card's own
3760
+ * spec. So a card deleted while its agent held it spawned a real CLI on a
3761
+ * prompt whose entire content was "a member of this project said: (nothing).
3762
+ * Carry on, and end with the JSON object." Nothing on either side refused
3763
+ * it, and an ordinary gesture reached it: the board's Delete.
3764
+ *
3765
+ * The server now declines to hand out such a job at all. This is the
3766
+ * backstop, and it is worth having on its own: it costs one comparison and
3767
+ * it is the layer that cannot be skipped by a server on an older deploy.
3768
+ * `nothing` is the honest outcome — the agent goes to Stuck saying the turn
3769
+ * produced nothing, which is exactly what happened.
3770
+ */
3771
+ if (job.kind === 'task' && !job.task) {
3772
+ await postAgentTurn({
3773
+ turnId,
3774
+ outcome: 'nothing',
3775
+ answer: 'the card this turn was for no longer exists',
3776
+ });
3777
+ return;
3778
+ }
3715
3779
 
3716
3780
  await inPlace(place, false, async () => {
3717
3781
  const dir = placeWtFor(place);
@@ -3722,10 +3786,31 @@ export function createWorkManager({
3722
3786
  return;
3723
3787
  }
3724
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
+ };
3725
3810
  // WHERE THE BRANCH WAS BEFORE THIS TURN, so the commits reported are the
3726
3811
  // ones this turn actually made.
3727
- const before = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
3728
- 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']);
3729
3814
 
3730
3815
  const rt = job.runtime || 'claude';
3731
3816
  if (!canRun(RUNTIMES[rt], 'build')) {
@@ -3797,10 +3882,15 @@ export function createWorkManager({
3797
3882
  child = ch;
3798
3883
  workChildren.set(ch, null);
3799
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);
3800
3889
  },
3801
3890
  });
3802
3891
  } finally {
3803
3892
  if (child) workChildren.delete(child);
3893
+ if (agentChildren.get(place) === child) agentChildren.delete(place);
3804
3894
  if (ranMarker) {
3805
3895
  try {
3806
3896
  writeFileSync(ranMarker, '1');
@@ -4163,6 +4253,125 @@ export function createWorkManager({
4163
4253
  await postAgentMerge({ agentId, ok: true, sha: tip || undefined });
4164
4254
  return;
4165
4255
  }
4256
+ /**
4257
+ * THIS PROJECT MERGES THROUGH A PULL REQUEST.
4258
+ *
4259
+ * PR mode existed for sessions and was simply not honoured for agents:
4260
+ * nothing read the project's merge mode on this path and the direct
4261
+ * merge below ran unconditionally. On the repos PR mode exists FOR —
4262
+ * branch protection refuses a direct push of a merge commit — every
4263
+ * agent approval failed at the push and came back to review carrying
4264
+ * git's refusal, forever.
4265
+ *
4266
+ * PUSH, CREATE, MERGE, in that order, and each step's reasoning is the
4267
+ * session PR job's: push first because GitHub merges the REMOTE tip;
4268
+ * `--fill` titles from the branch's own commits so nothing is invented;
4269
+ * `--merge` and never squash, because the cards' receipts are commit
4270
+ * shas and a squash rewrites them off base, orphaning every receipt and
4271
+ * blinding the landed observer's trailer read. Adopt only an OPEN PR:
4272
+ * gh's branch finder falls back to the most recent merged one, and
4273
+ * adopting a dead PR would report success over work that never moves.
4274
+ */
4275
+ if (job.prMode) {
4276
+ try {
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
+ });
4286
+ } catch (e) {
4287
+ await postAgentMerge({
4288
+ agentId,
4289
+ ok: false,
4290
+ detail:
4291
+ e?.code === 'ENOENT'
4292
+ ? 'this project merges through pull requests, and the GitHub CLI (gh) is not installed on this machine'
4293
+ : `this project merges through pull requests, and gh is not signed in here: ${ghFirstLine(e)}`,
4294
+ });
4295
+ return;
4296
+ }
4297
+ const prBase = baseBranchName(baseRef());
4298
+ if (branch === prBase) {
4299
+ await postAgentMerge({
4300
+ agentId,
4301
+ ok: false,
4302
+ detail: `this agent is on the base branch (${prBase}) — there is nothing to open a pull request from`,
4303
+ });
4304
+ return;
4305
+ }
4306
+ try {
4307
+ git(['push', '-u', 'origin', branch], wt);
4308
+ } catch (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)) });
4315
+ return;
4316
+ }
4317
+ let prUrl = null;
4318
+ try {
4319
+ const j = JSON.parse(
4320
+ execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
4321
+ cwd: repoRoot,
4322
+ stdio: ['ignore', 'pipe', 'pipe'],
4323
+ timeout: 30_000,
4324
+ }).toString()
4325
+ );
4326
+ if (j?.state === 'OPEN' && typeof j?.url === 'string') prUrl = j.url.trim();
4327
+ } catch {
4328
+ /* no PR for this branch at all — created below */
4329
+ }
4330
+ if (!prUrl) {
4331
+ try {
4332
+ const out = execFileSync(
4333
+ 'gh',
4334
+ // baseBranchName, not baseRef: gh 422s on a remote-tracking name.
4335
+ ['pr', 'create', '--head', branch, '--base', prBase, '--fill'],
4336
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
4337
+ )
4338
+ .toString()
4339
+ .trim();
4340
+ prUrl = out.split('\n').filter(Boolean).pop() ?? null;
4341
+ } catch (e) {
4342
+ await postAgentMerge({ agentId, ok: false, detail: ghFirstLine(e) });
4343
+ return;
4344
+ }
4345
+ }
4346
+ try {
4347
+ execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
4348
+ cwd: repoRoot,
4349
+ stdio: ['ignore', 'pipe', 'pipe'],
4350
+ timeout: 120_000,
4351
+ });
4352
+ } catch (e) {
4353
+ const line = ghFirstLine(e);
4354
+ // Already merged is a SUCCESS: a re-offered job, or somebody merged
4355
+ // it in the browser. The observer closes the cards either way.
4356
+ if (!/already merged/i.test(line)) {
4357
+ await postAgentMerge({
4358
+ agentId,
4359
+ ok: false,
4360
+ detail:
4361
+ prUrl && PR_URL_RE.test(prUrl) ? `${line} — the pull request is at ${prUrl}` : line,
4362
+ });
4363
+ return;
4364
+ }
4365
+ }
4366
+ // THE TIP, not a merge commit: the merge commit was made on GitHub and
4367
+ // this machine has not fetched it. It identifies the state we asked to
4368
+ // be merged, which is what the receipt is for — the cards themselves
4369
+ // close when the landed observer sees the commits arrive on base.
4370
+ await postAgentMerge({ agentId, ok: true, sha: tip });
4371
+ onRepoChanged();
4372
+ landed.observe();
4373
+ return;
4374
+ }
4166
4375
  try {
4167
4376
  shipMergeOutward({
4168
4377
  tip,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.76.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": {
@@ -40,4 +40,4 @@
40
40
  "bugs": {
41
41
  "url": "https://github.com/flowviant/cli/issues"
42
42
  }
43
- }
43
+ }