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.
- package/bin/lib/work.mjs +211 -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 = (
|
|
3728
|
-
const branch = (
|
|
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.
|
|
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
|
+
}
|