flowviant 0.74.2 → 0.75.0

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.
@@ -77,6 +77,10 @@ export async function reportDeployConfig(repoRoot) {
77
77
  healthcheck: t.healthcheck,
78
78
  healthStatus: t.healthStatus,
79
79
  pushSecrets: t.pushSecrets,
80
+ // Deploy-on-merge: the env this target auto-deploys to when commits land
81
+ // on base. MUST ride this map — a field forgotten here never reaches the
82
+ // server, and the server is what turns a landed report into the job.
83
+ ...(typeof t.onMerge === 'string' ? { onMerge: t.onMerge } : {}),
80
84
  }));
81
85
  try {
82
86
  await post('deploy-config', { pubkey: myPubB64(), targets: meta });
package/bin/lib/fleet.mjs CHANGED
@@ -816,6 +816,7 @@ export async function runFleetDaemon() {
816
816
  processShipJobs,
817
817
  processDiffJobs,
818
818
  processKillJobs,
819
+ processPrJobs,
819
820
  heldSessionIds,
820
821
  processPreviewJobs,
821
822
  livePreviewIds,
@@ -1610,6 +1611,11 @@ export async function runFleetDaemon() {
1610
1611
  // the pid on this job is a request, never an authority, because pids are
1611
1612
  // recycled and the row the browser clicked is up to a sweep old.
1612
1613
  processKillJobs(roster.killJobs);
1614
+ // PR-mode work (push + open, or merge) — leased like a kill: two daemons
1615
+ // pushing one branch would open two PRs. Runs under the operator's own
1616
+ // `gh` credential; a settle never closes a card (done is observed by the
1617
+ // landed walk when the merge reaches base).
1618
+ processPrJobs(roster.prJobs);
1613
1619
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1614
1620
  // Throttled inside, never awaited — a `git status` the human cannot run
1615
1621
  // themselves from a browser, relayed. After retirement so a directory that
@@ -0,0 +1,175 @@
1
+ /**
2
+ * THE LANDED OBSERVER — what arrived on base, whichever road it took.
3
+ *
4
+ * The daemon already fetches origin on a throttled beat (the worktree sweep)
5
+ * and moves the local base ref itself on a ship push. This module watches the
6
+ * base tip across those moments and, when it moves, walks the NEW commits and
7
+ * reports them to /fleet/base-landed: sha, subject, and any `Flowviant-Task:`
8
+ * trailer ids. The server closes what those commits name (a trailer from any
9
+ * live status, a delivered card's receipt sha) — done is OBSERVED, and this is
10
+ * the observation that covers a hand push, a PR merged on GitHub, and a
11
+ * teammate's ship, none of which pass through /fleet/ship-done.
12
+ *
13
+ * A daemon→server REPORT, so there is no version floor and the delivery
14
+ * discipline is repo-state's: a 404 (older server) goes quiet until restart,
15
+ * and the observed tip is persisted ONLY when the server accepted the report —
16
+ * a failed POST re-walks the same range on the next beat, which is free
17
+ * because the server skips done cards.
18
+ *
19
+ * FIRST SIGHT SEEDS, NEVER WALKS. A fresh install (or a base-ref change) has
20
+ * no honest "since when", and walking history would close every trailered card
21
+ * ever merged. The tip is recorded and observation starts from there. The same
22
+ * rule covers a range the repo can no longer answer (force-push, gc): reseed,
23
+ * report nothing — ignorance is never turned into a state.
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { homedir } from 'node:os';
29
+ import { createHash } from 'node:crypto';
30
+ import { git, baseBranchName } from './git.mjs';
31
+ import { taskIdsFromMessage } from './worktreeDiff.mjs';
32
+ import { warn } from './ui.mjs';
33
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
34
+
35
+ const LANDED_URL = FLEET_URL.replace(/\/agents\/?$/, '/base-landed');
36
+ /** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
37
+ * batches: the persisted tip advances to the last commit actually reported,
38
+ * so the remainder is picked up on the next beat rather than skipped forever
39
+ * — a trailered card in commit 51 of a big catch-up still closes. */
40
+ const MAX_COMMITS = 50;
41
+ const SHA_RE = /^[0-9a-f]{7,64}$/i;
42
+
43
+ export function createLandedObserver({ repoRoot, baseRef }) {
44
+ // Keyed like the worktree base dir: one state file per checkout, so two
45
+ // repos on one box never share a tip.
46
+ const key = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 8);
47
+ const stateFile = join(homedir(), '.flowviant', `landed-${key}.json`);
48
+ let unsupported = false; // 404 once → an older server; quiet until restart
49
+ let inFlight = false;
50
+
51
+ const readState = () => {
52
+ try {
53
+ const s = JSON.parse(readFileSync(stateFile, 'utf8'));
54
+ return s && typeof s.ref === 'string' && typeof s.tip === 'string' ? s : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+ const writeState = (s) => {
60
+ try {
61
+ mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
62
+ writeFileSync(stateFile, JSON.stringify(s));
63
+ } catch {
64
+ /* a box that cannot persist just re-observes from the next seed */
65
+ }
66
+ };
67
+
68
+ const tipOf = (ref) => {
69
+ try {
70
+ const t = git(['rev-parse', ref], repoRoot);
71
+ return SHA_RE.test(t) ? t : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ /** New non-merge commits in from..to, OLDEST FIRST. `--no-merges` for the
78
+ * same reason branchCommits keeps it: a merge commit describes a range
79
+ * rather than doing work, and its constituents are walked as themselves. */
80
+ const walk = (from, to) => {
81
+ const raw = git(
82
+ ['log', '--reverse', '--no-merges', '--format=%H%x1f%s%x1f%B%x1e', `${from}..${to}`],
83
+ repoRoot
84
+ );
85
+ const out = [];
86
+ for (const rec of raw.split('\x1e')) {
87
+ const line = rec.replace(/^\n+/, '');
88
+ if (!line.trim()) continue;
89
+ const [sha, subject, body] = line.split('\x1f');
90
+ if (!SHA_RE.test(sha || '')) continue;
91
+ out.push({
92
+ sha,
93
+ subject: String(subject || '').slice(0, 200),
94
+ taskIds: taskIdsFromMessage(body).slice(0, 8),
95
+ });
96
+ }
97
+ return out;
98
+ };
99
+
100
+ /** Look at the base tip; if it moved, report the range. Call after anything
101
+ * that may have moved origin/<base> — the sweep's fetch, a ship's push, a
102
+ * PR merge this daemon performed. Never throws, never awaited by a turn. */
103
+ const observe = async () => {
104
+ if (unsupported || inFlight) return;
105
+ const ref = baseRef();
106
+ if (!ref) return;
107
+ const tip = tipOf(ref);
108
+ if (!tip) return;
109
+ const st = readState();
110
+ if (!st || st.ref !== ref) {
111
+ writeState({ ref, tip });
112
+ return;
113
+ }
114
+ if (st.tip === tip) return;
115
+ let all;
116
+ try {
117
+ all = walk(st.tip, ref);
118
+ } catch {
119
+ // The old tip is no longer answerable (force-push, gc) — reseed and
120
+ // report nothing rather than guess at a range.
121
+ writeState({ ref, tip });
122
+ return;
123
+ }
124
+ // Oldest-first BATCH: a range past the server's cap advances the tip only
125
+ // to the last commit reported, so the remainder rides the next beat —
126
+ // nothing is skipped forever. (A range of nothing but merge commits still
127
+ // reports, tip-only: the tip moving is the fact deploy-on-merge rides.)
128
+ const commits = all.slice(0, MAX_COMMITS);
129
+ const reportedTip = all.length > MAX_COMMITS ? commits[commits.length - 1].sha : tip;
130
+ inFlight = true;
131
+ try {
132
+ const res = await fetch(LANDED_URL, {
133
+ method: 'POST',
134
+ headers: {
135
+ Authorization: `Bearer ${FLEET_TOKEN}`,
136
+ 'User-Agent': USER_AGENT,
137
+ 'Content-Type': 'application/json',
138
+ },
139
+ signal: AbortSignal.timeout(20_000),
140
+ body: JSON.stringify({ base: baseBranchName(ref), tip: reportedTip, commits }),
141
+ });
142
+ if (res.status === 404) {
143
+ unsupported = true;
144
+ return;
145
+ }
146
+ if (res.ok) {
147
+ // Persist ONLY an accepted report — a 5xx (the server could not close
148
+ // the cards) or a network failure leaves the tip where it was, so the
149
+ // next beat re-walks the same range and the close re-runs, idempotently.
150
+ writeState({ ref, tip: reportedTip });
151
+ // Deploy-on-merge refusals are computed server-side and would
152
+ // otherwise vanish — an onMerge:'prod' (or a target with no
153
+ // commands[env]) must not be quietly inert.
154
+ const j = await res.json().catch(() => null);
155
+ for (const r of j?.data?.deployRefused ?? []) {
156
+ warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
157
+ }
158
+ } else if (res.status >= 400 && res.status < 500) {
159
+ // A persistent 4xx (deploy skew, a payload this server refuses) would
160
+ // otherwise re-send the same poison range on every beat forever.
161
+ // Drop the range — the closes it carried re-run at the next REAL tip
162
+ // move only if their cards are still open, which is the idempotent
163
+ // half; the honest cost is stated out loud.
164
+ writeState({ ref, tip });
165
+ warn(`base-landed report refused (${res.status}) — skipped ${commits.length} commit(s)`);
166
+ }
167
+ } catch {
168
+ /* offline — the next fetch beat retries */
169
+ } finally {
170
+ inFlight = false;
171
+ }
172
+ };
173
+
174
+ return { observe };
175
+ }
@@ -237,18 +237,32 @@ MECHANICS OF THIS TAB:
237
237
  is their word to say, not yours to infer.
238
238
  5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. A real pick between known
239
239
  options — not an open question — ends your reply with a fenced block the app
240
- renders as buttons; their click composes their answer as the next message:
240
+ renders as an answer card; picking an option and pressing Submit sends its
241
+ label as their next message, so every label must read as an answer a person
242
+ would say out loud:
241
243
 
242
244
  \`\`\`flowviant-ask
243
- {"question": "Which auth flow?", "options": ["Magic link", "Password", "Both"], "multiSelect": false}
245
+ {"question": "Which auth flow should the preview gate use?",
246
+ "header": "Auth flow",
247
+ "options": [
248
+ {"label": "Cookie + CSP change", "description": "Ships today; needs the frame-src change."},
249
+ {"label": "Header-based", "description": "No CSP change, but every daemon must upgrade."},
250
+ "Prototype both"
251
+ ],
252
+ "multiSelect": false}
244
253
  \`\`\`
245
254
 
246
- ONE block per reply, and always the LAST thing in it. Two to eight options,
247
- each label short enough to sit on a button. multiSelect true only for a
248
- genuine check-several-of-these case. NEVER for an open question ask those
249
- in prose, like anyone would. And ask the question in prose above the block
250
- as well: a client that doesn't render the fence shows it as plain text, so
251
- the reply has to read as a question with its options either way.
255
+ ONE block per reply, and always the LAST thing in it. Two to eight options.
256
+ A label IS the answer a few words, never a comma (multi-select answers
257
+ arrive as the chosen labels comma-joined, in the order listed); a tradeoff
258
+ goes in "description", one short sentence, optional. "header" is an optional
259
+ topic tag, three words at most. Plain-string options still work. Do NOT add
260
+ an "Other" option the card offers a free-text path itself. multiSelect
261
+ true only for a genuine check-several-of-these case. NEVER for an open
262
+ question — ask those in prose, like anyone would. And ask the question in
263
+ prose above the block as well: a client that doesn't render the fence shows
264
+ it as plain text, so the reply has to read as a question with its options
265
+ either way.
252
266
 
253
267
  THE LEDGER. This session's work is logged as CARDS as it happens, by you,
254
268
  through tools — so a four-hour churn doesn't evaporate into scrollback. The
@@ -369,8 +383,10 @@ MECHANICS OF THIS TAB:
369
383
  their word to say, not yours to infer.
370
384
  5. WHEN THEY HAVE TO CHOOSE, HAND THEM THE CHOICES. You have no tools here, but
371
385
  this one costs none — it is text. A real pick between known options (not an
372
- open question) ends your reply with a fenced block the app renders as
373
- buttons; their click composes their answer as the next message:
386
+ open question) ends your reply with a fenced block the app renders as an
387
+ answer card; picking an option and pressing Submit sends its label as their
388
+ next message, so every label must read as an answer a person would say out
389
+ loud:
374
390
 
375
391
  \`\`\`flowviant-ask
376
392
  {"question": "Which auth flow?", "options": ["Magic link", "Password", "Both"], "multiSelect": false}
package/bin/lib/work.mjs CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  MODEL,
40
40
  } from './config.mjs';
41
41
  import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
42
+ import { createLandedObserver } from './landed.mjs';
42
43
  import { listenersIn, measureListeners, listenersSupported } from './listeners.mjs';
43
44
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
44
45
  import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
@@ -139,6 +140,12 @@ export function createWorkManager({
139
140
  const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
140
141
  const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
141
142
  const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
143
+ const PR_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-claim');
144
+ const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
145
+ // What arrived on base, whichever road it took — observed after every beat
146
+ // that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
147
+ // this daemon performed. See landed.mjs for the seeding and delivery rules.
148
+ const landed = createLandedObserver({ repoRoot, baseRef });
142
149
  const workAnswering = new Set(); // turn ids currently queued/running here
143
150
  const workAttempts = new Map(); // turn id -> completed runTurn attempts
144
151
  const MAX_WORK_TRIES = 3;
@@ -341,6 +348,10 @@ export function createWorkManager({
341
348
  // moved. Without this the Repository block keeps counting a branch the
342
349
  // ship just deleted.
343
350
  onRepoChanged();
351
+ // The ship's push moved the local origin/<base> ref — observe now, so
352
+ // the landed report (and anything trailered a ship carried) lands on
353
+ // this beat rather than the next 3-minute fetch.
354
+ void landed.observe().catch(() => {});
344
355
  }
345
356
  return r;
346
357
  };
@@ -764,6 +775,9 @@ export function createWorkManager({
764
775
  } catch {
765
776
  /* offline, or no remote — the numbers just age */
766
777
  }
778
+ // The fetch may have moved the base tip — walk and report what
779
+ // landed. Best-effort like everything in this sweep.
780
+ void landed.observe().catch(() => {});
767
781
  }
768
782
  const reports = [];
769
783
  for (const id of activeIds.slice(0, 20)) {
@@ -1301,6 +1315,253 @@ export function createWorkManager({
1301
1315
  }
1302
1316
  };
1303
1317
 
1318
+ // ── PR-mode jobs (projects.mergeMode === 'pr') ──────────────────────────
1319
+ // 'open' = push the session's branch and open a PR; 'merge' = merge it.
1320
+ // Both under the operator's own `gh` credential from the daemon's inherited
1321
+ // env — the same posture the dispatch-era merge path took, and the same one
1322
+ // claude.mjs documents for turns. LEASED like a kill: two daemons pushing
1323
+ // one branch would open two PRs. NOTHING here closes a card — done is
1324
+ // observed by the landed walk when the merge reaches base.
1325
+ const prWorking = new Set();
1326
+ const ghFirstLine = (e) =>
1327
+ ((e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) ||
1328
+ 'failed')
1329
+ .slice(0, 400);
1330
+ const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
1331
+ const claimPr = async (id) => {
1332
+ try {
1333
+ const res = await fetch(PR_CLAIM_URL, {
1334
+ method: 'POST',
1335
+ headers: {
1336
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1337
+ 'User-Agent': USER_AGENT,
1338
+ 'Content-Type': 'application/json',
1339
+ },
1340
+ signal: AbortSignal.timeout(15_000),
1341
+ body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
1342
+ });
1343
+ const j = await res.json().catch(() => null);
1344
+ return j?.data?.claimed === true;
1345
+ } catch {
1346
+ return false; // could not claim → do nothing. The other daemon may have.
1347
+ }
1348
+ };
1349
+ const settlePr = async (body) => {
1350
+ try {
1351
+ await fetch(PR_DONE_URL, {
1352
+ method: 'POST',
1353
+ headers: {
1354
+ Authorization: `Bearer ${FLEET_TOKEN}`,
1355
+ 'User-Agent': USER_AGENT,
1356
+ 'Content-Type': 'application/json',
1357
+ },
1358
+ signal: AbortSignal.timeout(20_000),
1359
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
1360
+ });
1361
+ } catch {
1362
+ /* the row expires into an honest no_answer; the re-request is the retry */
1363
+ }
1364
+ };
1365
+ const runPrJob = async (job) => {
1366
+ const id = String(job.id);
1367
+ if (!(await claimPr(id))) return;
1368
+ // gh present and authenticated, or the honest 'unsupported' — its own
1369
+ // outcome because "the machine cannot do this at all" and "GitHub said
1370
+ // no" read differently to the person who asked.
1371
+ try {
1372
+ execFileSync('gh', ['auth', 'status'], { stdio: ['ignore', 'pipe', 'pipe'] });
1373
+ } catch (e) {
1374
+ const missing = e?.code === 'ENOENT';
1375
+ await settlePr({
1376
+ id,
1377
+ outcome: 'unsupported',
1378
+ detail: missing
1379
+ ? 'the GitHub CLI (gh) is not installed on this machine'
1380
+ : 'gh is not authenticated on this machine — run `gh auth login` there',
1381
+ });
1382
+ return;
1383
+ }
1384
+ // The branch is whatever the session's own worktree HEAD says — the same
1385
+ // resolution ship uses, for the same reason (the branch is where the
1386
+ // driver left it, not where we put it).
1387
+ const sessionId = String(job.sessionId);
1388
+ const place = placeOf(sessionId);
1389
+ const wt = place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
1390
+ let branch = `session/${sessionId}`;
1391
+ let detached = false;
1392
+ if (existsSync(wt)) {
1393
+ try {
1394
+ branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
1395
+ } catch {
1396
+ detached = true;
1397
+ }
1398
+ }
1399
+ if (detached) {
1400
+ await settlePr({
1401
+ id,
1402
+ outcome: 'failed',
1403
+ detail: 'the session is on a detached HEAD — no branch to push',
1404
+ });
1405
+ return;
1406
+ }
1407
+ // NEVER the base branch. A checkout-place tab (the operator's, at N=1)
1408
+ // commonly stands on base, and pushing it would BE the direct push this
1409
+ // mode exists to replace — on an unprotected repo the work lands with no
1410
+ // PR and the observer closes the cards as landed, PR mode silently
1411
+ // defeated by its own open job.
1412
+ const baseName = baseBranchName(baseRef());
1413
+ if (branch === baseName) {
1414
+ await settlePr({
1415
+ id,
1416
+ outcome: 'failed',
1417
+ detail: `the session is on the base branch (${baseName}) — nothing to open a pull request from; work on a branch first`,
1418
+ });
1419
+ return;
1420
+ }
1421
+ const pushCwd = existsSync(wt) ? wt : repoRoot;
1422
+ /** Adopt only an OPEN PR. gh's branch finder falls back to the most recent
1423
+ * MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
1424
+ * every later delivery on a long-lived session branch into a silent
1425
+ * black hole ('opened'/'merged' over work that never moves). */
1426
+ const openPrUrl = () => {
1427
+ try {
1428
+ const j = JSON.parse(
1429
+ execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state'], {
1430
+ cwd: repoRoot,
1431
+ stdio: ['ignore', 'pipe', 'pipe'],
1432
+ }).toString()
1433
+ );
1434
+ return j?.state === 'OPEN' && typeof j?.url === 'string' ? j.url.trim() : null;
1435
+ } catch {
1436
+ return null; // no PR for the branch at all
1437
+ }
1438
+ };
1439
+ if (job.kind !== 'merge') {
1440
+ // OPEN: push, then create — or adopt a PR already OPEN for the branch
1441
+ // (a re-delivery, or one the driver opened by hand).
1442
+ try {
1443
+ git(['push', '-u', 'origin', branch], pushCwd);
1444
+ } catch (e) {
1445
+ await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
1446
+ return;
1447
+ }
1448
+ let url = openPrUrl();
1449
+ if (!url) {
1450
+ try {
1451
+ const out = execFileSync(
1452
+ 'gh',
1453
+ // `--fill` titles the PR from the branch's own commits — no model
1454
+ // call, nothing invented. baseBranchName, not baseRef: gh 422s on
1455
+ // a remote-tracking name like origin/main.
1456
+ ['pr', 'create', '--head', branch, '--base', baseName, '--fill'],
1457
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
1458
+ )
1459
+ .toString()
1460
+ .trim();
1461
+ url = out.split('\n').filter(Boolean).pop() ?? null;
1462
+ } catch (e) {
1463
+ await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
1464
+ return;
1465
+ }
1466
+ }
1467
+ await settlePr({
1468
+ id,
1469
+ outcome: 'opened',
1470
+ ...(url && PR_URL_RE.test(url) ? { prUrl: url } : {}),
1471
+ });
1472
+ return;
1473
+ }
1474
+ // MERGE: push FIRST — GitHub merges the REMOTE PR tip, and the quiz the
1475
+ // reviewer just passed fingerprinted the LOCAL worktree, so merging
1476
+ // without a push would land a stale tip while the observer closed the
1477
+ // cards over commits that never reached base. Then a MERGE COMMIT, never
1478
+ // squash and never rebase — the cards' receipts are commit shas, and a
1479
+ // squash rewrites them off base, which would orphan every receipt AND
1480
+ // blind the landed walk's trailer read.
1481
+ // No --delete-branch: the local branch may be a live worktree's HEAD.
1482
+ try {
1483
+ git(['push', 'origin', branch], pushCwd);
1484
+ } catch (e) {
1485
+ await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
1486
+ return;
1487
+ }
1488
+ try {
1489
+ execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
1490
+ cwd: repoRoot,
1491
+ stdio: ['ignore', 'pipe', 'pipe'],
1492
+ });
1493
+ } catch (e) {
1494
+ const line = ghFirstLine(e);
1495
+ if (!/already merged/i.test(line)) {
1496
+ await settlePr({ id, outcome: 'failed', detail: line });
1497
+ return;
1498
+ }
1499
+ }
1500
+ // VERIFY before settling 'merged': modern gh exits 0 on an
1501
+ // already-MERGED PR, so a dead PR from an earlier delivery reads as
1502
+ // success while this branch's newest commits sit unmerged. The branch
1503
+ // tip being an ancestor of base is the fact 'merged' claims — check it,
1504
+ // with one short retry for the fetch racing GitHub's merge commit.
1505
+ const tipSha = (() => {
1506
+ try {
1507
+ return git(['rev-parse', branch], pushCwd);
1508
+ } catch {
1509
+ return null;
1510
+ }
1511
+ })();
1512
+ const tipOnBase = () => {
1513
+ if (!tipSha) return false;
1514
+ try {
1515
+ git(['merge-base', '--is-ancestor', tipSha, baseRef()], repoRoot);
1516
+ return true;
1517
+ } catch {
1518
+ return false;
1519
+ }
1520
+ };
1521
+ let merged = false;
1522
+ for (let attempt = 0; attempt < 2 && !merged; attempt++) {
1523
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
1524
+ try {
1525
+ git(['fetch', 'origin', '--quiet'], repoRoot);
1526
+ } catch {
1527
+ /* offline — the check below answers from what we have */
1528
+ }
1529
+ merged = tipOnBase();
1530
+ }
1531
+ if (!merged) {
1532
+ await settlePr({
1533
+ id,
1534
+ outcome: 'failed',
1535
+ detail:
1536
+ 'GitHub reports a merge, but this branch\'s newest commits are not on the base branch — the PR that merged was an older one. Deliver again to open a fresh pull request.',
1537
+ });
1538
+ return;
1539
+ }
1540
+ await settlePr({ id, outcome: 'merged' });
1541
+ // The merge moved base. Observe NOW, so the cards close on this beat
1542
+ // rather than the next 3-minute sweep — the same re-measure-after-an-
1543
+ // action rule the kill and ship paths keep. (The fetch already ran in
1544
+ // the verify loop above.)
1545
+ void landed.observe().catch(() => {});
1546
+ onRepoChanged();
1547
+ };
1548
+ const processPrJobs = (jobs) => {
1549
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
1550
+ for (const job of jobs.slice(0, 5)) {
1551
+ const id = String(job?.id || '');
1552
+ const sid = String(job?.sessionId || '');
1553
+ if (!id || !isSafePathSegment(sid)) continue;
1554
+ // Keyed by SESSION, not job id: a deliver-time open and an approve-time
1555
+ // merge for one session must run in order (the roster offers them FIFO;
1556
+ // running them concurrently would merge before the push-and-create).
1557
+ if (prWorking.has(sid)) continue;
1558
+ prWorking.add(sid);
1559
+ void runPrJob(job)
1560
+ .catch(() => {})
1561
+ .finally(() => prWorking.delete(sid));
1562
+ }
1563
+ };
1564
+
1304
1565
  const processDiffJobs = (jobs) => {
1305
1566
  if (!Array.isArray(jobs) || jobs.length === 0) return;
1306
1567
  for (const job of jobs.slice(0, 5)) {
@@ -3129,6 +3390,7 @@ export function createWorkManager({
3129
3390
  processShipJobs,
3130
3391
  processDiffJobs,
3131
3392
  processKillJobs,
3393
+ processPrJobs,
3132
3394
  heldSessionIds,
3133
3395
  processPreviewJobs,
3134
3396
  livePreviewIds,
@@ -55,7 +55,7 @@ const MAX_COMMITS = 50;
55
55
  * a plausible id is dropped here rather than shipped: the server drops unknown
56
56
  * ids too, but a readout should not spend a request on obvious noise.
57
57
  */
58
- function taskIdsFromMessage(body) {
58
+ export function taskIdsFromMessage(body) {
59
59
  const ids = [];
60
60
  for (const line of String(body || '').split('\n')) {
61
61
  const m = line.match(/^\s*Flowviant-Task\s*:\s*(.+?)\s*$/i);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.74.2",
3
+ "version": "0.75.0",
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": {