flowviant 0.102.2 → 0.104.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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -1,13 +1,17 @@
1
1
  /** Session preview claims, tunnels, and retirement. */
2
2
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
3
3
  import { isSafePathSegment } from './git.mjs';
4
- import { originFor } from './listeners.mjs';
5
- import { openTunnel } from './preview.mjs';
4
+ import { originFor as measuredOrigin } from './listeners.mjs';
5
+ import { openTunnel as openLiveTunnel } from './preview.mjs';
6
6
  import { note } from './ui.mjs';
7
+ import { fleetEndpoint } from './fleetWire.mjs';
7
8
 
8
- export function createWorkPreviews({ placeDir }) {
9
- const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
10
- const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
9
+ /** `originFor` and `openTunnel` are injectable only so the lane can be driven
10
+ * without a dev server or cloudflared (workPreviews.test.mjs); work.mjs passes
11
+ * neither. */
12
+ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openTunnel = openLiveTunnel }) {
13
+ const PREVIEW_DONE_URL = fleetEndpoint('preview-done', FLEET_URL);
14
+ const PREVIEW_CLAIM_URL = fleetEndpoint('preview-claim', FLEET_URL);
11
15
 
12
16
  // ── SESSION PREVIEWS ──────────────────────────────────────────────────────
13
17
  //
@@ -96,7 +100,19 @@ export function createWorkPreviews({ placeDir }) {
96
100
  // the component that can be published ahead of a deploy.) A stop for a
97
101
  // tunnel whose daemon crashed resolves server-side: an unanswered 'ending'
98
102
  // row reads as over once it goes stale.
99
- if (live) await postPreview({ sessionId, ended: true, endedReason: reason });
103
+ //
104
+ // The ended report names the SHARE it ends (F017, 2026-09-26): a stop
105
+ // confirm without one could end a replacement share opened on the same
106
+ // session in the meantime, and the server checks the id when it is
107
+ // present. A daemon→server report field, so no floor; absent when the
108
+ // live entry was opened without one (an older server), the old rule.
109
+ if (live)
110
+ await postPreview({
111
+ sessionId,
112
+ ended: true,
113
+ endedReason: reason,
114
+ ...(live.shareId ? { shareId: live.shareId } : {}),
115
+ });
100
116
  };
101
117
 
102
118
  const processPreviewJobs = (jobs) => {
@@ -164,11 +180,17 @@ export function createWorkPreviews({ placeDir }) {
164
180
  // importantly, checking `originFor` again is what keeps the answer
165
181
  // to "whose port is this" on the machine that can actually see it —
166
182
  // the same rule the gate itself dials by (below).
167
- const measured = !originFor(wt, port).error;
168
- if (!measured) {
183
+ //
184
+ // The refusal is RELAYED in originFor's own words (review 2026-09-26,
185
+ // F007): "nothing is listening" is only one of its answers, and
186
+ // posting that sentence for a contested address or a walk that could
187
+ // not reach the holder told the person a measured absence the
188
+ // machine never measured. A wire string, not a field — no floor.
189
+ const refusal = originFor(wt, port).error;
190
+ if (refusal) {
169
191
  await postPreview({
170
192
  sessionId,
171
- error: `nothing is listening on port ${port} in this worktree.`,
193
+ error: refusal,
172
194
  ...(shareId ? { shareId } : {}),
173
195
  });
174
196
  return;
@@ -4,12 +4,11 @@ import { isSafePathSegment } from './git.mjs';
4
4
  import { measureListeners } from './listeners.mjs';
5
5
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
6
6
  import { processAlive } from './procRegistry.mjs';
7
+ import { fleetEndpoint } from './fleetWire.mjs';
7
8
 
8
- export function createWorkProcesses({ placeDir, reportPlaceWorktrees, state }) {
9
- const KILL_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-done');
10
- const KILL_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/kill-claim');
11
-
12
- const { sessionGroups } = state;
9
+ export function createWorkProcesses({ placeDir, reportPlaceWorktrees, sessionGroups }) {
10
+ const KILL_DONE_URL = fleetEndpoint('kill-done', FLEET_URL);
11
+ const KILL_CLAIM_URL = fleetEndpoint('kill-claim', FLEET_URL);
13
12
 
14
13
  // ── stopping one measured process ────────────────────────────────────────
15
14
  //
@@ -1,19 +1,30 @@
1
- /** Session pull-request claims, publishing, and merges. */
1
+ /**
2
+ * Session pull-request claims, publishing, and merges.
3
+ *
4
+ * What is the SESSION's lives here: the lease, which branch a session's job
5
+ * pushes (its worktree's HEAD, never base, never detached), and the words each
6
+ * outcome settles with. The GitHub rules themselves — adopt only an open PR
7
+ * into base, `--fill`, `--merge`, verify the tip — are `prWorkflow.mjs`'s,
8
+ * shared with the agent approve path.
9
+ */
2
10
  import { existsSync } from 'node:fs';
3
- import { execFileSync } from 'node:child_process';
4
11
  import { join } from 'node:path';
5
12
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
6
13
  import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
7
-
8
- export const ghFirstLine = (e) =>
9
- ((e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) ||
10
- 'failed')
11
- .slice(0, 400);
12
- export const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
14
+ import {
15
+ ensureOpenPr,
16
+ findOpenPr,
17
+ ghFirstLine,
18
+ ghReady,
19
+ mergeAndVerifyTip,
20
+ PR_URL_RE,
21
+ prTargetsOtherBase,
22
+ } from './prWorkflow.mjs';
23
+ import { fleetEndpoint } from './fleetWire.mjs';
24
+ import { REPO_PLACE } from './workPlaces.mjs';
13
25
 
14
26
  export function createWorkPullRequests({
15
27
  placeOf,
16
- REPO_PLACE,
17
28
  repoRoot,
18
29
  baseDir,
19
30
  baseRef,
@@ -21,14 +32,14 @@ export function createWorkPullRequests({
21
32
  landed,
22
33
  onRepoChanged,
23
34
  }) {
24
- const PR_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-claim');
25
- const PR_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/pr-done');
35
+ const PR_CLAIM_URL = fleetEndpoint('pr-claim', FLEET_URL);
36
+ const PR_DONE_URL = fleetEndpoint('pr-done', FLEET_URL);
26
37
 
27
38
  // ── PR-mode jobs (projects.mergeMode === 'pr') ──────────────────────────
28
39
  // 'open' = push the session's branch and open a PR; 'merge' = merge it.
29
40
  // Both under the operator's own `gh` credential from the daemon's inherited
30
41
  // env — the same posture the dispatch-era merge path took, and the same one
31
- // claude.mjs documents for turns. LEASED like a kill: two daemons pushing
42
+ // runTurn.mjs documents for turns. LEASED like a kill: two daemons pushing
32
43
  // one branch would open two PRs. NOTHING here closes a card — done is
33
44
  // observed by the landed walk when the merge reaches base.
34
45
  const prWorking = new Set();
@@ -72,25 +83,14 @@ export function createWorkPullRequests({
72
83
  // gh present and authenticated, or the honest 'unsupported' — its own
73
84
  // outcome because "the machine cannot do this at all" and "GitHub said
74
85
  // no" read differently to the person who asked.
75
- try {
76
- // TIMED OUT, like every other `gh` call. `execFileSync` blocks the whole
77
- // event loop, so a hung `gh` — an expired token whose refresh hits a
78
- // black hole, a credential helper waiting on a keyring prompt that has
79
- // no terminal — stops the roster poll, every in-flight settle, the
80
- // worktree sweep and the deploy heartbeat (whose 3-minute staleness
81
- // window then re-queues a deploy this daemon is still running). The
82
- // AGENT merge path was given exactly these timeouts in 0.77.1; this
83
- // copy, forty lines of the same logic, was missed.
84
- execFileSync('gh', ['auth', 'status'], {
85
- stdio: ['ignore', 'pipe', 'pipe'],
86
- timeout: 20_000,
87
- });
88
- } catch (e) {
89
- const missing = e?.code === 'ENOENT';
86
+ // TIMED OUT, like every other `gh` call (prWorkflow.mjs says why: a hung
87
+ // `gh` blocks the roster poll, every settle and the deploy heartbeat).
88
+ const ghDown = ghReady();
89
+ if (ghDown) {
90
90
  await settlePr({
91
91
  id,
92
92
  outcome: 'unsupported',
93
- detail: missing
93
+ detail: ghDown.missing
94
94
  ? 'the GitHub CLI (gh) is not installed on this machine'
95
95
  : 'gh is not authenticated on this machine — run `gh auth login` there',
96
96
  });
@@ -134,38 +134,11 @@ export function createWorkPullRequests({
134
134
  return;
135
135
  }
136
136
  const pushCwd = existsSync(wt) ? wt : repoRoot;
137
- /** Adopt only an OPEN PR. gh's branch finder falls back to the most recent
138
- * MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
139
- * every later delivery on a long-lived session branch into a silent
140
- * black hole ('opened'/'merged' over work that never moves). */
141
- /** …and ONLY ONE THAT TARGETS THE PROJECT'S BASE. A PR somebody opened by
142
- * hand into another branch (`staging`, to try it there) was adopted as
143
- * this delivery's PR, and Approve then merged the unreviewed branch INTO
144
- * that branch — the ancestry check afterwards blamed "an older PR". The
145
- * agent merge path has refused this since it shipped; this is the same
146
- * guard, measured the same way: refused only on a MEASURED mismatch, an
147
- * absent field adopts as before. Returns `{ url, base }` or null. */
148
- const openPr = () => {
149
- try {
150
- const j = JSON.parse(
151
- execFileSync('gh', ['pr', 'view', branch, '--json', 'url,state,baseRefName'], {
152
- cwd: repoRoot,
153
- stdio: ['ignore', 'pipe', 'pipe'],
154
- timeout: 30_000,
155
- }).toString()
156
- );
157
- if (j?.state !== 'OPEN' || typeof j?.url !== 'string') return null;
158
- return {
159
- url: j.url.trim(),
160
- base: typeof j?.baseRefName === 'string' ? j.baseRefName : null,
161
- };
162
- } catch {
163
- return null; // no PR for the branch at all
164
- }
165
- };
166
- const otherBase = (pr) =>
167
- pr && pr.base && pr.base !== baseName
168
- ? `the open pull request for ${branch} targets ${pr.base}, not ${baseName} — retarget or close it, then try again`
137
+ // A PR into another branch is refused, never adopted (prWorkflow.mjs) —
138
+ // in the session's words.
139
+ const otherBase = (target) =>
140
+ target
141
+ ? `the open pull request for ${branch} targets ${target}, not ${baseName} — retarget or close it, then try again`
169
142
  : null;
170
143
  if (job.kind !== 'merge') {
171
144
  // OPEN: push, then create — or adopt a PR already OPEN for the branch
@@ -176,51 +149,28 @@ export function createWorkPullRequests({
176
149
  await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
177
150
  return;
178
151
  }
179
- const existing = openPr();
180
- const wrongBase = otherBase(existing);
181
- if (wrongBase) {
182
- await settlePr({ id, outcome: 'failed', detail: wrongBase });
152
+ const pr = ensureOpenPr(branch, baseName, { cwd: repoRoot });
153
+ if (pr.wrongBase || pr.error) {
154
+ await settlePr({ id, outcome: 'failed', detail: otherBase(pr.wrongBase) ?? pr.error });
183
155
  return;
184
156
  }
185
- let url = existing?.url ?? null;
186
- if (!url) {
187
- try {
188
- const out = execFileSync(
189
- 'gh',
190
- // `--fill` titles the PR from the branch's own commits — no model
191
- // call, nothing invented. baseBranchName, not baseRef: gh 422s on
192
- // a remote-tracking name like origin/main.
193
- ['pr', 'create', '--head', branch, '--base', baseName, '--fill'],
194
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
195
- )
196
- .toString()
197
- .trim();
198
- url = out.split('\n').filter(Boolean).pop() ?? null;
199
- } catch (e) {
200
- await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
201
- return;
202
- }
203
- }
204
157
  await settlePr({
205
158
  id,
206
159
  outcome: 'opened',
207
- ...(url && PR_URL_RE.test(url) ? { prUrl: url } : {}),
160
+ ...(pr.url && PR_URL_RE.test(pr.url) ? { prUrl: pr.url } : {}),
208
161
  });
209
162
  return;
210
163
  }
211
164
  // MERGE: push FIRST — GitHub merges the REMOTE PR tip, and the quiz the
212
165
  // reviewer just passed fingerprinted the LOCAL worktree, so merging
213
166
  // without a push would land a stale tip while the observer closed the
214
- // cards over commits that never reached base. Then a MERGE COMMIT, never
215
- // squash and never rebase — the cards' receipts are commit shas, and a
216
- // squash rewrites them off base, which would orphan every receipt AND
217
- // blind the landed walk's trailer read.
167
+ // cards over commits that never reached base.
218
168
  // No --delete-branch: the local branch may be a live worktree's HEAD.
219
169
  // THE BASE IS RE-ASKED BEFORE THE MERGE, never trusted from the open step:
220
170
  // somebody can retarget a PR between Deliver and Approve, and `gh pr merge
221
171
  // <branch>` merges whichever open PR the branch has, into whatever it
222
172
  // targets now.
223
- const wrongBase = otherBase(openPr());
173
+ const wrongBase = otherBase(prTargetsOtherBase(findOpenPr(branch, { cwd: repoRoot }), baseName));
224
174
  if (wrongBase) {
225
175
  await settlePr({ id, outcome: 'failed', detail: wrongBase });
226
176
  return;
@@ -231,24 +181,8 @@ export function createWorkPullRequests({
231
181
  await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
232
182
  return;
233
183
  }
234
- try {
235
- execFileSync('gh', ['pr', 'merge', branch, '--merge'], {
236
- cwd: repoRoot,
237
- stdio: ['ignore', 'pipe', 'pipe'],
238
- timeout: 120_000,
239
- });
240
- } catch (e) {
241
- const line = ghFirstLine(e);
242
- if (!/already merged/i.test(line)) {
243
- await settlePr({ id, outcome: 'failed', detail: line });
244
- return;
245
- }
246
- }
247
- // VERIFY before settling 'merged': modern gh exits 0 on an
248
- // already-MERGED PR, so a dead PR from an earlier delivery reads as
249
- // success while this branch's newest commits sit unmerged. The branch
250
- // tip being an ancestor of base is the fact 'merged' claims — check it,
251
- // with one short retry for the fetch racing GitHub's merge commit.
184
+ // The tip is the LOCAL branch — the state the reviewer approved and the
185
+ // push just sent; GitHub's merge does not move it.
252
186
  const tipSha = (() => {
253
187
  try {
254
188
  return git(['rev-parse', branch], pushCwd);
@@ -256,26 +190,18 @@ export function createWorkPullRequests({
256
190
  return null;
257
191
  }
258
192
  })();
259
- const tipOnBase = () => {
260
- if (!tipSha) return false;
261
- try {
262
- git(['merge-base', '--is-ancestor', tipSha, baseRef()], repoRoot);
263
- return true;
264
- } catch {
265
- return false;
266
- }
267
- };
268
- let merged = false;
269
- for (let attempt = 0; attempt < 2 && !merged; attempt++) {
270
- if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
271
- try {
272
- gitNet(['fetch', 'origin', '--quiet'], 60_000);
273
- } catch {
274
- /* offline — the check below answers from what we have */
275
- }
276
- merged = tipOnBase();
193
+ const merged = await mergeAndVerifyTip(branch, {
194
+ cwd: repoRoot,
195
+ tip: tipSha,
196
+ baseRef,
197
+ repoRoot,
198
+ fetchOrigin: () => gitNet(['fetch', 'origin', '--quiet'], 60_000),
199
+ });
200
+ if (merged.error) {
201
+ await settlePr({ id, outcome: 'failed', detail: merged.error });
202
+ return;
277
203
  }
278
- if (!merged) {
204
+ if (merged.notOnBase) {
279
205
  await settlePr({
280
206
  id,
281
207
  outcome: 'failed',
@@ -0,0 +1,254 @@
1
+ /**
2
+ * EVERY TURN SETTLES — the settle POSTs, the in-memory queue of reports that
3
+ * could not be delivered yet, their per-poll retry and reject backoff, and
4
+ * what a delivered ship report sets off.
5
+ *
6
+ * Split out of work.mjs (2026-09-26, SOLID F037). The queue is the skip-guard
7
+ * that stops a side-effecting turn being re-run over a dropped 200, so its
8
+ * delivery rules (ok / terminal / reject / retry) have exactly one home; the
9
+ * session lane settles through `settleWorkTurn`, the ship lane through
10
+ * `settleShip`, and the plan and check lanes through `postBestEffort`. The
11
+ * attempts counter is the session lane's, handed in by name, because a
12
+ * terminal delivery is what may forget it.
13
+ */
14
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
15
+ import { git } from './git.mjs';
16
+ import { sweepMergedBranch } from './shipSweep.mjs';
17
+ import { note } from './ui.mjs';
18
+ import { fleetEndpoint } from './fleetWire.mjs';
19
+
20
+ export function createWorkReportQueue({
21
+ repoRoot,
22
+ baseRef,
23
+ workAttempts,
24
+ artifacts,
25
+ landed,
26
+ onRepoChanged,
27
+ reportPlaceWorktrees,
28
+ burstListeners,
29
+ }) {
30
+ const WORK_DONE_URL = fleetEndpoint('work-turn-done', FLEET_URL);
31
+ const SHIP_DONE_URL = fleetEndpoint('ship-done', FLEET_URL);
32
+ /**
33
+ * EVERY turn settles — the work loop's prime contract. A pending turn nobody
34
+ * answers holds one of the tab's slots until the server expires it (24h);
35
+ * silence is the worst outcome. So a report that cannot be DELIVERED right
36
+ * now is queued in memory and retried at the top of every poll, and a turn
37
+ * whose finished answer sits in that queue is never re-run — a session turn
38
+ * has side effects (edits, commits, cards), and a dropped 200 must not apply
39
+ * them twice.
40
+ */
41
+ const pendingWorkReports = new Map(); // turnId -> work-turn-done body
42
+ const pendingShipReports = new Map(); // sessionId -> ship-done body
43
+ /** POST a settle body. Four outcomes, and the split between the last two is
44
+ * load-bearing:
45
+ * - 'ok' — delivered.
46
+ * - 'terminal' — the EXPLICIT per-endpoint statuses under which the server
47
+ * will never re-offer the job (403 not this fleet's session, 404 unknown
48
+ * turn, 409 ship already settled). Only these may drop the report AND
49
+ * the attempts counter: they are the statuses where forgetting is safe
50
+ * because the job is gone server-side too.
51
+ * - 'reject' — any OTHER 4xx (a 400 from deploy skew, an edge/WAF rule):
52
+ * the server refused this BODY, but the job row may still be pending and
53
+ * riding every poll. The report must stay QUEUED — it is the skip-guard
54
+ * that stops the turn being re-run with all its side effects — but
55
+ * re-POSTing a body the server just refused every poll is spam, so
56
+ * delivery backs off. Treating this as terminal once re-ran whole
57
+ * non-idempotent CLI turns in a loop; treating it as plain retry
58
+ * hammered a refused body forever.
59
+ * - 'retry' — network errors, 408, 429 and 5xx: nothing was decided. */
60
+ const postSettle = async (url, body, terminalStatuses) => {
61
+ try {
62
+ const res = await fetch(url, {
63
+ method: 'POST',
64
+ headers: {
65
+ Authorization: `Bearer ${FLEET_TOKEN}`,
66
+ 'User-Agent': USER_AGENT,
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ signal: AbortSignal.timeout(30_000),
70
+ body: JSON.stringify(body),
71
+ });
72
+ if (res.ok) return 'ok';
73
+ if (terminalStatuses.includes(res.status)) return 'terminal';
74
+ if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
75
+ return 'reject';
76
+ return 'retry';
77
+ } catch {
78
+ return 'retry';
79
+ }
80
+ };
81
+
82
+ /** Retry-After, in ms — seconds or an HTTP-date — capped so one bad or
83
+ * hostile header cannot stall a caller for an hour. Falls back to the
84
+ * caller's own backoff when the header is absent or unparseable. */
85
+ const retryAfterMs = (res, fallbackMs) => {
86
+ const h = res?.headers?.get?.('retry-after');
87
+ if (h == null) return fallbackMs;
88
+ const secs = Number(h);
89
+ if (Number.isFinite(secs)) return Math.max(0, Math.min(secs * 1000, 60_000));
90
+ const at = Date.parse(h);
91
+ return Number.isFinite(at) ? Math.max(0, Math.min(at - Date.now(), 60_000)) : fallbackMs;
92
+ };
93
+
94
+ /**
95
+ * A best-effort settle POST, AWAITED INLINE by a caller with no per-poll
96
+ * queue of its own to retry from — unlike `postSettle` above, whose callers
97
+ * requeue a 'retry'/'reject' outcome themselves. Bounded attempts: 408/429
98
+ * retry honouring Retry-After, 5xx retries on a short fixed backoff, and
99
+ * any OTHER 4xx is the server's considered answer — retrying it would just
100
+ * spend the same refusal again, so it counts as delivered, the existing
101
+ * trace convention. A network error retries the same way and is then
102
+ * swallowed: unsettled, and the server expires the job so the asker is
103
+ * told, never spun.
104
+ */
105
+ const postBestEffort = async (url, body, { attempts = 4, timeoutMs = 30_000 } = {}) => {
106
+ for (let i = 0; i < attempts; i += 1) {
107
+ let res;
108
+ try {
109
+ res = await fetch(url, {
110
+ method: 'POST',
111
+ headers: {
112
+ Authorization: `Bearer ${FLEET_TOKEN}`,
113
+ 'User-Agent': USER_AGENT,
114
+ 'Content-Type': 'application/json',
115
+ },
116
+ signal: AbortSignal.timeout(timeoutMs),
117
+ body: JSON.stringify(body),
118
+ });
119
+ } catch {
120
+ if (i === attempts - 1) return false;
121
+ await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
122
+ continue;
123
+ }
124
+ if (res.ok) return true;
125
+ if (res.status === 408 || res.status === 429) {
126
+ if (i === attempts - 1) return false;
127
+ await new Promise((r) => setTimeout(r, retryAfterMs(res, 2_000 * (i + 1))));
128
+ continue;
129
+ }
130
+ if (res.status >= 500) {
131
+ if (i === attempts - 1) return false;
132
+ await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
133
+ continue;
134
+ }
135
+ return true; // any other 4xx — the server's considered answer
136
+ }
137
+ return false;
138
+ };
139
+
140
+ /** How long a REJECTED report sits out before re-offering its body — long
141
+ * enough that a deploy-skew 400 costs a handful of POSTs a day, short
142
+ * enough that a server fix picks the report up the same morning. */
143
+ const REJECT_RETRY_MS = 10 * 60 * 1000;
144
+ const reportBackoff = new Map(); // turnId|sessionId -> earliest next attempt
145
+ const settleWorkTurn = async (turnId, payload) => {
146
+ const body = { turnId, ...payload };
147
+ const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
148
+ if (r === 'retry' || r === 'reject') {
149
+ pendingWorkReports.set(turnId, body);
150
+ if (r === 'reject') reportBackoff.set(turnId, Date.now() + REJECT_RETRY_MS);
151
+ } else {
152
+ pendingWorkReports.delete(turnId);
153
+ workAttempts.delete(turnId);
154
+ reportBackoff.delete(turnId);
155
+ }
156
+ return r;
157
+ };
158
+ const settleShip = async (sessionId, payload) => {
159
+ const body = { sessionId, ...payload };
160
+ const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
161
+ if (r === 'retry' || r === 'reject') {
162
+ pendingShipReports.set(sessionId, body);
163
+ if (r === 'reject') reportBackoff.set(sessionId, Date.now() + REJECT_RETRY_MS);
164
+ } else {
165
+ pendingShipReports.delete(sessionId);
166
+ reportBackoff.delete(sessionId);
167
+ // The report has landed, so the idempotency path no longer needs the
168
+ // branch to exist. See `sweepMergedSessionBranch`.
169
+ sweepMergedSessionBranch(sessionId);
170
+ // AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
171
+ // merges the tip out and deletes the branch, so a fresh measurement reads
172
+ // `ahead: 0` with an empty diffstat — and without this the rail keeps
173
+ // rendering the ENTIRE pre-ship diff while the transcript two hundred
174
+ // pixels away says "Shipped to main". The ship button re-arms over a
175
+ // branch that is already merged. Same rule the kill path just learned:
176
+ // an action that changes what the machine would measure must cause a new
177
+ // measurement, and the 60s sweep is not that.
178
+ void reportPlaceWorktrees(sessionId).catch(() => {});
179
+ burstListeners(sessionId);
180
+ // …and the REPO picture changed too: the session branch is gone and base
181
+ // moved. Without this the Repository block keeps counting a branch the
182
+ // ship just deleted.
183
+ onRepoChanged();
184
+ // The ship's push moved the local origin/<base> ref — observe now, so
185
+ // the landed report (and anything trailered a ship carried) lands on
186
+ // this beat rather than the next 3-minute fetch.
187
+ void landed.observe().catch(() => {});
188
+ }
189
+ return r;
190
+ };
191
+
192
+ /** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
193
+ const sweepMergedSessionBranch = (sessionId) =>
194
+ sweepMergedBranch(sessionId, {
195
+ git,
196
+ repoRoot,
197
+ baseRef: baseRef(),
198
+ note,
199
+ // The report queue is consulted at CALL time, never captured — a sweep
200
+ // scheduled while a report was outstanding must still see it land.
201
+ isReportPending: (id) => pendingShipReports.has(id),
202
+ });
203
+
204
+ let flushingReports = false;
205
+ const flushWorkReports = async () => {
206
+ // Held artifact uploads ride this beat too — the settle retry's own shape —
207
+ // and are never awaited: a readout's retry must not hold a settle's.
208
+ void artifacts.retryPending().catch(() => {});
209
+ if (flushingReports) return;
210
+ if (pendingWorkReports.size === 0 && pendingShipReports.size === 0) return;
211
+ flushingReports = true;
212
+ try {
213
+ for (const [id, body] of [...pendingWorkReports]) {
214
+ // A rejected body sits out its backoff; the queued entry itself stays
215
+ // — it is the skip-guard against re-running a turn whose side effects
216
+ // already happened.
217
+ if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
218
+ const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
219
+ if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
220
+ else if (r !== 'retry') {
221
+ pendingWorkReports.delete(id);
222
+ workAttempts.delete(id);
223
+ reportBackoff.delete(id);
224
+ }
225
+ }
226
+ for (const [id, body] of [...pendingShipReports]) {
227
+ if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
228
+ const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
229
+ if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
230
+ else if (r !== 'retry') {
231
+ pendingShipReports.delete(id);
232
+ reportBackoff.delete(id);
233
+ // Delivered late is still delivered — same sweep as the immediate
234
+ // path, and it must be here too or a report that needed a retry
235
+ // would leave its branch behind forever.
236
+ sweepMergedSessionBranch(id);
237
+ }
238
+ }
239
+ } finally {
240
+ flushingReports = false;
241
+ }
242
+ };
243
+
244
+ return {
245
+ pendingWorkReports,
246
+ pendingShipReports,
247
+ postBestEffort,
248
+ REJECT_RETRY_MS,
249
+ settleWorkTurn,
250
+ settleShip,
251
+ flushWorkReports,
252
+ sweepMergedSessionBranch,
253
+ };
254
+ }