flowviant 0.103.0 → 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.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +183 -2383
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +2 -2
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +17 -17
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3034
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +91 -898
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/workPreviews.mjs
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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:
|
|
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,
|
|
9
|
-
const KILL_DONE_URL =
|
|
10
|
-
const KILL_CLAIM_URL =
|
|
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
|
-
/**
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 =
|
|
25
|
-
const PR_DONE_URL =
|
|
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
|
-
//
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 (
|
|
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
|
+
}
|