flowviant 0.116.0 → 0.124.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/README.md +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +90 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +113 -31
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -8,25 +8,26 @@
|
|
|
8
8
|
* shared with the agent approve path.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
11
|
+
import { FLEET_URL } from './config.mjs';
|
|
12
|
+
import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment, sessionBranch } from './git.mjs';
|
|
14
13
|
import {
|
|
15
14
|
ensureOpenPr,
|
|
16
15
|
findOpenPr,
|
|
17
|
-
ghFirstLine,
|
|
18
16
|
ghReady,
|
|
19
17
|
mergeAndVerifyTip,
|
|
20
18
|
PR_URL_RE,
|
|
21
19
|
prTargetsOtherBase,
|
|
20
|
+
relayedFailure,
|
|
22
21
|
} from './prWorkflow.mjs';
|
|
23
22
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
23
|
+
import { claimFleetLease, settleLease } from './fleetPost.mjs';
|
|
24
24
|
import { REPO_PLACE } from './workPlaces.mjs';
|
|
25
25
|
|
|
26
26
|
export function createWorkPullRequests({
|
|
27
27
|
placeOf,
|
|
28
|
+
placeDir,
|
|
29
|
+
branchAt,
|
|
28
30
|
repoRoot,
|
|
29
|
-
baseDir,
|
|
30
31
|
baseRef,
|
|
31
32
|
gitNet,
|
|
32
33
|
landed,
|
|
@@ -43,40 +44,11 @@ export function createWorkPullRequests({
|
|
|
43
44
|
// one branch would open two PRs. NOTHING here closes a card — done is
|
|
44
45
|
// observed by the landed walk when the merge reaches base.
|
|
45
46
|
const prWorking = new Set();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
52
|
-
'User-Agent': USER_AGENT,
|
|
53
|
-
'Content-Type': 'application/json',
|
|
54
|
-
},
|
|
55
|
-
signal: AbortSignal.timeout(15_000),
|
|
56
|
-
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
57
|
-
});
|
|
58
|
-
const j = await res.json().catch(() => null);
|
|
59
|
-
return j?.data?.claimed === true;
|
|
60
|
-
} catch {
|
|
61
|
-
return false; // could not claim → do nothing. The other daemon may have.
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
const settlePr = async (body) => {
|
|
65
|
-
try {
|
|
66
|
-
await fetch(PR_DONE_URL, {
|
|
67
|
-
method: 'POST',
|
|
68
|
-
headers: {
|
|
69
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
70
|
-
'User-Agent': USER_AGENT,
|
|
71
|
-
'Content-Type': 'application/json',
|
|
72
|
-
},
|
|
73
|
-
signal: AbortSignal.timeout(20_000),
|
|
74
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
75
|
-
});
|
|
76
|
-
} catch {
|
|
77
|
-
/* the row expires into an honest no_answer; the re-request is the retry */
|
|
78
|
-
}
|
|
79
|
-
};
|
|
47
|
+
// Could not claim → do nothing. The other daemon may have.
|
|
48
|
+
const claimPr = (id) => claimFleetLease(PR_CLAIM_URL, { id });
|
|
49
|
+
// Twenty seconds, this lane's own ceiling; on any failure the row expires
|
|
50
|
+
// into an honest no_answer and the re-request is the retry.
|
|
51
|
+
const settlePr = (body) => settleLease(PR_DONE_URL, body, { timeoutMs: 20_000 });
|
|
80
52
|
const runPrJob = async (job) => {
|
|
81
53
|
const id = String(job.id);
|
|
82
54
|
if (!(await claimPr(id))) return;
|
|
@@ -97,20 +69,17 @@ export function createWorkPullRequests({
|
|
|
97
69
|
return;
|
|
98
70
|
}
|
|
99
71
|
// The branch is whatever the session's own worktree HEAD says — the same
|
|
100
|
-
// resolution ship uses, for the same reason
|
|
101
|
-
// driver left it, not where we put it).
|
|
72
|
+
// resolution ship uses (`branchAt`, workPlaces.mjs), for the same reason
|
|
73
|
+
// (the branch is where the driver left it, not where we put it). A place
|
|
74
|
+
// whose directory is gone falls back to the branch that place was CUT on,
|
|
75
|
+
// `session/<place>` — a person's `u-` place lives on `session/u-<id>`,
|
|
76
|
+
// never on `session/<this tab's id>` (audit 2026-10-10, F168).
|
|
77
|
+
// (The checkout always exists, so its fallback is never read and keeps
|
|
78
|
+
// the tab's own name.)
|
|
102
79
|
const sessionId = String(job.sessionId);
|
|
103
80
|
const place = placeOf(sessionId);
|
|
104
|
-
const wt =
|
|
105
|
-
|
|
106
|
-
let detached = false;
|
|
107
|
-
if (existsSync(wt)) {
|
|
108
|
-
try {
|
|
109
|
-
branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
|
|
110
|
-
} catch {
|
|
111
|
-
detached = true;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
81
|
+
const wt = placeDir(sessionId);
|
|
82
|
+
const { branch, detached } = branchAt(wt, sessionBranch(place === REPO_PLACE ? sessionId : place));
|
|
114
83
|
if (detached) {
|
|
115
84
|
await settlePr({
|
|
116
85
|
id,
|
|
@@ -146,7 +115,9 @@ export function createWorkPullRequests({
|
|
|
146
115
|
try {
|
|
147
116
|
gitNetIn(['push', '-u', 'origin', branch], pushCwd, 120_000);
|
|
148
117
|
} catch (e) {
|
|
149
|
-
|
|
118
|
+
// SCRUBBED (relayedFailure): this line lands on every delivered card's
|
|
119
|
+
// record and in the tab chat, and a push names the remote URL.
|
|
120
|
+
await settlePr({ id, outcome: 'failed', detail: relayedFailure(e) });
|
|
150
121
|
return;
|
|
151
122
|
}
|
|
152
123
|
const pr = ensureOpenPr(branch, baseName, { cwd: repoRoot });
|
|
@@ -167,9 +138,8 @@ export function createWorkPullRequests({
|
|
|
167
138
|
// cards over commits that never reached base.
|
|
168
139
|
// No --delete-branch: the local branch may be a live worktree's HEAD.
|
|
169
140
|
// THE BASE IS RE-ASKED BEFORE THE MERGE, never trusted from the open step:
|
|
170
|
-
// somebody can retarget a PR between Deliver and Approve, and
|
|
171
|
-
//
|
|
172
|
-
// targets now.
|
|
141
|
+
// somebody can retarget a PR between Deliver and Approve, and the merge
|
|
142
|
+
// acts on whichever open PR the branch has, into whatever it targets now.
|
|
173
143
|
const wrongBase = otherBase(prTargetsOtherBase(findOpenPr(branch, { cwd: repoRoot }), baseName));
|
|
174
144
|
if (wrongBase) {
|
|
175
145
|
await settlePr({ id, outcome: 'failed', detail: wrongBase });
|
|
@@ -178,7 +148,7 @@ export function createWorkPullRequests({
|
|
|
178
148
|
try {
|
|
179
149
|
gitNetIn(['push', 'origin', branch], pushCwd, 120_000);
|
|
180
150
|
} catch (e) {
|
|
181
|
-
await settlePr({ id, outcome: 'failed', detail:
|
|
151
|
+
await settlePr({ id, outcome: 'failed', detail: relayedFailure(e) });
|
|
182
152
|
return;
|
|
183
153
|
}
|
|
184
154
|
// The tip is the LOCAL branch — the state the reviewer approved and the
|
|
@@ -201,6 +171,17 @@ export function createWorkPullRequests({
|
|
|
201
171
|
await settlePr({ id, outcome: 'failed', detail: merged.error });
|
|
202
172
|
return;
|
|
203
173
|
}
|
|
174
|
+
// GitHub lists no open pull request FROM this branch in this repository,
|
|
175
|
+
// so nothing was merged — never a PR picked by a selector that only looks
|
|
176
|
+
// like this branch (prWorkflow.mjs).
|
|
177
|
+
if (merged.noPr) {
|
|
178
|
+
await settlePr({
|
|
179
|
+
id,
|
|
180
|
+
outcome: 'failed',
|
|
181
|
+
detail: `there is no open pull request from ${branch} in this repository to merge — deliver again to open one`,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
204
185
|
if (merged.notOnBase) {
|
|
205
186
|
await settlePr({
|
|
206
187
|
id,
|
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
* Split out of work.mjs (2026-09-26, SOLID F037). The queue is the skip-guard
|
|
7
7
|
* that stops a side-effecting turn being re-run over a dropped 200, so its
|
|
8
8
|
* delivery rules (ok / terminal / reject / retry) have exactly one home; the
|
|
9
|
-
* session lane settles through `settleWorkTurn
|
|
10
|
-
* `settleShip
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* session lane settles through `settleWorkTurn` and the ship lane through
|
|
10
|
+
* `settleShip`; the plan, check and intake lanes, which have no queue, settle
|
|
11
|
+
* through fleetPost.mjs's `postBestEffort` (it closed over nothing of this
|
|
12
|
+
* queue's and moved there, audit 2026-10-10 F136). The attempts counter is
|
|
13
|
+
* the session lane's, handed in by name, because a terminal delivery is what
|
|
14
|
+
* may forget it.
|
|
13
15
|
*/
|
|
14
|
-
import { FLEET_URL
|
|
16
|
+
import { FLEET_URL } from './config.mjs';
|
|
15
17
|
import { git } from './git.mjs';
|
|
16
18
|
import { sweepMergedBranch } from './shipSweep.mjs';
|
|
17
19
|
import { note } from './ui.mjs';
|
|
18
|
-
import { fleetEndpoint } from './fleetWire.mjs';
|
|
20
|
+
import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
|
|
21
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
19
22
|
|
|
20
23
|
export function createWorkReportQueue({
|
|
21
24
|
repoRoot,
|
|
@@ -58,137 +61,102 @@ export function createWorkReportQueue({
|
|
|
58
61
|
* hammered a refused body forever.
|
|
59
62
|
* - 'retry' — network errors, 408, 429 and 5xx: nothing was decided. */
|
|
60
63
|
const postSettle = async (url, body, terminalStatuses) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
}
|
|
64
|
+
const r = await fleetRequest(url, { body, timeoutMs: 30_000 });
|
|
65
|
+
if (r.error) return 'retry';
|
|
66
|
+
if (r.ok) return 'ok';
|
|
67
|
+
if (terminalStatuses.includes(r.status)) return 'terminal';
|
|
68
|
+
if (isConsideredRefusal(r.status)) return 'reject';
|
|
69
|
+
return 'retry';
|
|
80
70
|
};
|
|
81
71
|
|
|
82
|
-
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
};
|
|
72
|
+
/** How long a REJECTED report sits out before re-offering its body — long
|
|
73
|
+
* enough that a deploy-skew 400 costs a handful of POSTs a day, short
|
|
74
|
+
* enough that a server fix picks the report up the same morning. */
|
|
75
|
+
const REJECT_RETRY_MS = 10 * 60 * 1000;
|
|
76
|
+
// Keyed `${queue}:${id}` — a turn id and a session id are different
|
|
77
|
+
// namespaces, and one queue's backoff must never hold the other's report.
|
|
78
|
+
const reportBackoff = new Map(); // `work:<turnId>` | `ship:<sessionId>` -> earliest next attempt
|
|
93
79
|
|
|
94
80
|
/**
|
|
95
|
-
* A
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* swallowed: unsettled, and the server expires the job so the asker is
|
|
103
|
-
* told, never spun.
|
|
81
|
+
* WHAT A DELIVERED SHIP REPORT SETS OFF — on BOTH paths, the immediate settle
|
|
82
|
+
* and the per-poll flush (audit 2026-10-10, F158). The flush used to run only
|
|
83
|
+
* the sweep, so a ship whose first ship-done POST hit a 502 left the rail
|
|
84
|
+
* rendering the whole pre-ship diff, the Repository block counting a deleted
|
|
85
|
+
* branch and card closes waiting on the 3-minute fetch. These follow the
|
|
86
|
+
* MERGE, not the report's punctuality: an action that changes what the
|
|
87
|
+
* machine would measure must cause a new measurement.
|
|
104
88
|
*/
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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;
|
|
89
|
+
const shipDelivered = (sessionId) => {
|
|
90
|
+
// The report has landed, so the idempotency path no longer needs the
|
|
91
|
+
// branch to exist. See `sweepMergedSessionBranch`.
|
|
92
|
+
sweepMergedSessionBranch(sessionId);
|
|
93
|
+
// AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
|
|
94
|
+
// merges the tip out and deletes the branch, so a fresh measurement reads
|
|
95
|
+
// `ahead: 0` with an empty diffstat — and without this the rail keeps
|
|
96
|
+
// rendering the ENTIRE pre-ship diff while the transcript two hundred
|
|
97
|
+
// pixels away says "Shipped to main". The ship button re-arms over a
|
|
98
|
+
// branch that is already merged. Same rule the kill path just learned:
|
|
99
|
+
// an action that changes what the machine would measure must cause a new
|
|
100
|
+
// measurement, and the 60s sweep is not that.
|
|
101
|
+
void reportPlaceWorktrees(sessionId).catch(() => {});
|
|
102
|
+
burstListeners(sessionId);
|
|
103
|
+
// …and the REPO picture changed too: the session branch is gone and base
|
|
104
|
+
// moved. Without this the Repository block keeps counting a branch the
|
|
105
|
+
// ship just deleted.
|
|
106
|
+
onRepoChanged();
|
|
107
|
+
// The ship's push moved the local origin/<base> ref — observe now, so
|
|
108
|
+
// the landed report (and anything trailered a ship carried) lands on
|
|
109
|
+
// this beat rather than the next 3-minute fetch.
|
|
110
|
+
void landed.observe().catch(() => {});
|
|
138
111
|
};
|
|
139
112
|
|
|
140
|
-
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
113
|
+
/** EACH QUEUE, DESCRIBED ONCE — its endpoint, the explicit statuses under
|
|
114
|
+
* which the server will never re-offer the job, the id the body carries,
|
|
115
|
+
* and what a delivery (on time or late) forgets or sets off. The settle
|
|
116
|
+
* and the flush both deliver through `deliver`, so the two paths cannot
|
|
117
|
+
* drift apart again. */
|
|
118
|
+
const QUEUES = {
|
|
119
|
+
work: {
|
|
120
|
+
name: 'work',
|
|
121
|
+
url: WORK_DONE_URL,
|
|
122
|
+
terminal: [403, 404],
|
|
123
|
+
pending: pendingWorkReports,
|
|
124
|
+
idKey: 'turnId',
|
|
125
|
+
delivered: (id) => workAttempts.delete(id),
|
|
126
|
+
},
|
|
127
|
+
ship: {
|
|
128
|
+
name: 'ship',
|
|
129
|
+
url: SHIP_DONE_URL,
|
|
130
|
+
terminal: [403, 409],
|
|
131
|
+
pending: pendingShipReports,
|
|
132
|
+
idKey: 'sessionId',
|
|
133
|
+
delivered: shipDelivered,
|
|
134
|
+
},
|
|
157
135
|
};
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
136
|
+
const backoffKey = (q, id) => `${q.name}:${id}`;
|
|
137
|
+
|
|
138
|
+
/** POST one queue's body and apply the outcome: 'retry' and 'reject' keep
|
|
139
|
+
* it queued (the skip-guard) and only 'reject' backs off; 'ok' and
|
|
140
|
+
* 'terminal' forget it — and its backoff — and run the queue's delivered
|
|
141
|
+
* effects. */
|
|
142
|
+
const deliver = async (q, id, body) => {
|
|
143
|
+
const r = await postSettle(q.url, body, q.terminal);
|
|
161
144
|
if (r === 'retry' || r === 'reject') {
|
|
162
|
-
|
|
163
|
-
if (r === 'reject') reportBackoff.set(
|
|
145
|
+
q.pending.set(id, body);
|
|
146
|
+
if (r === 'reject') reportBackoff.set(backoffKey(q, id), Date.now() + REJECT_RETRY_MS);
|
|
164
147
|
} else {
|
|
165
|
-
|
|
166
|
-
reportBackoff.delete(
|
|
167
|
-
|
|
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(() => {});
|
|
148
|
+
q.pending.delete(id);
|
|
149
|
+
reportBackoff.delete(backoffKey(q, id));
|
|
150
|
+
q.delivered(id);
|
|
188
151
|
}
|
|
189
152
|
return r;
|
|
190
153
|
};
|
|
191
154
|
|
|
155
|
+
const settleWorkTurn = (turnId, payload) =>
|
|
156
|
+
deliver(QUEUES.work, turnId, { [QUEUES.work.idKey]: turnId, ...payload });
|
|
157
|
+
const settleShip = (sessionId, payload) =>
|
|
158
|
+
deliver(QUEUES.ship, sessionId, { [QUEUES.ship.idKey]: sessionId, ...payload });
|
|
159
|
+
|
|
192
160
|
/** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
|
|
193
161
|
const sweepMergedSessionBranch = (sessionId) =>
|
|
194
162
|
sweepMergedBranch(sessionId, {
|
|
@@ -210,30 +178,15 @@ export function createWorkReportQueue({
|
|
|
210
178
|
if (pendingWorkReports.size === 0 && pendingShipReports.size === 0) return;
|
|
211
179
|
flushingReports = true;
|
|
212
180
|
try {
|
|
213
|
-
for (const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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);
|
|
181
|
+
for (const q of [QUEUES.work, QUEUES.ship]) {
|
|
182
|
+
for (const [id, body] of [...q.pending]) {
|
|
183
|
+
// A rejected body sits out its backoff; the queued entry itself
|
|
184
|
+
// stays — it is the skip-guard against re-running a turn whose side
|
|
185
|
+
// effects already happened. Delivered late is still delivered: the
|
|
186
|
+
// same effects as the immediate path (a ship's sweep, re-measure,
|
|
187
|
+
// repo refresh and landed observation).
|
|
188
|
+
if ((reportBackoff.get(backoffKey(q, id)) ?? 0) > Date.now()) continue;
|
|
189
|
+
await deliver(q, id, body);
|
|
237
190
|
}
|
|
238
191
|
}
|
|
239
192
|
} finally {
|
|
@@ -244,7 +197,6 @@ export function createWorkReportQueue({
|
|
|
244
197
|
return {
|
|
245
198
|
pendingWorkReports,
|
|
246
199
|
pendingShipReports,
|
|
247
|
-
postBestEffort,
|
|
248
200
|
REJECT_RETRY_MS,
|
|
249
201
|
settleWorkTurn,
|
|
250
202
|
settleShip,
|
package/bin/lib/workRetire.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, readdirSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { git, isSafePathSegment } from './git.mjs';
|
|
14
|
+
import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
|
|
14
15
|
|
|
15
16
|
export function createWorkRetire({
|
|
16
17
|
repoRoot,
|
|
@@ -169,7 +170,7 @@ export function createWorkRetire({
|
|
|
169
170
|
// Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
|
|
170
171
|
// deliberately not touched: its branch is the reviewable unit, and
|
|
171
172
|
// moving it under a review would change what somebody is deciding about.
|
|
172
|
-
if (!place.startsWith(
|
|
173
|
+
if (!place.startsWith(PERSON_PLACE_PREFIX) || !isSafePathSegment(place)) continue;
|
|
173
174
|
if (placeLocks.has(place)) continue;
|
|
174
175
|
const wt = join(baseDir, 'sessions', place);
|
|
175
176
|
try {
|
|
@@ -1,32 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WHICH CLI A TAB SPEAKS
|
|
3
|
-
*
|
|
2
|
+
* WHICH CLI A TAB SPEAKS — the runtime pin, and agy's cwd registry the first
|
|
3
|
+
* turn learns its conversation from.
|
|
4
4
|
*
|
|
5
|
-
* Split out of work.mjs (2026-09-26, SOLID F037).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). None of it is about running
|
|
6
|
+
* a turn: the session lane asks `sessionRuntime` before it spawns.
|
|
7
|
+
*
|
|
8
|
+
* THE ID SHAPES MOVED to a leaf (audit 2026-10-10, F157): conversationIds.mjs
|
|
9
|
+
* owns every CLI's conversation-id guard and marker name, because the
|
|
10
|
+
* machine readers below the lanes (runtimeLimits, the session scanners) need
|
|
11
|
+
* them too and a lane module is not theirs to import. The two guards the
|
|
12
|
+
* agent lane imports from here are re-exported unchanged.
|
|
10
13
|
*/
|
|
11
14
|
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
12
15
|
import { join } from 'node:path';
|
|
13
16
|
import { homedir } from 'node:os';
|
|
14
|
-
import { canRun, RUNTIMES } from './runtimes.mjs';
|
|
17
|
+
import { canRun, RUNTIMES, runtimeById, runtimeLabel } from './runtimes.mjs';
|
|
15
18
|
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
19
|
+
import { AGY_CONV_RE, CODEX_THREAD_RE } from './conversationIds.mjs';
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
* The shape a codex thread id must have before it is written to disk or —
|
|
19
|
-
* decisive — pushed into argv as `resume <id>`. Conservative on purpose:
|
|
20
|
-
* alphanumeric plus dash/underscore, never a leading dash (an argv that
|
|
21
|
-
* parses as a flag), never whitespace. Anything else is dropped and the
|
|
22
|
-
* session simply runs fresh in its own worktree.
|
|
23
|
-
*/
|
|
24
|
-
export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
|
|
25
|
-
|
|
26
|
-
/** agy conversation ids are plain UUIDs (the db filename IS the identity —
|
|
27
|
-
* measured: a renamed copy fails "trajectory not found"). Guarded the same
|
|
28
|
-
* way as the codex id: it rides in argv as `--conversation <id>`. */
|
|
29
|
-
export const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
export { AGY_CONV_RE, CODEX_THREAD_RE };
|
|
30
22
|
|
|
31
23
|
export function createWorkSessionRuntime({ sessionMetaPath }) {
|
|
32
24
|
/**
|
|
@@ -52,15 +44,19 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
|
|
|
52
44
|
* { mismatch: { pin, runtime } } (labels, for the caller's sentence).
|
|
53
45
|
*
|
|
54
46
|
* SESSION-CAPABLE means rt.mcp is truthy — the session tools ride a real
|
|
55
|
-
* per-invocation MCP config — OR the runtime runs tabs PLAIN
|
|
56
|
-
* no MCP at all, no cards, no streaming;
|
|
57
|
-
* the daemon's own report and ship-time
|
|
58
|
-
* whole.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
47
|
+
* per-invocation MCP config — OR the runtime's row runs tabs PLAIN
|
|
48
|
+
* (`tab: 'plain'`, Antigravity's): no MCP at all, no cards, no streaming;
|
|
49
|
+
* the final answer is delivered by the daemon's own report and ship-time
|
|
50
|
+
* reconciliation keeps the ledger whole. The row says so, and the turn's
|
|
51
|
+
* shape reads the same fact (workTurnShape.mjs). `pickRuntimeFor('build')`
|
|
52
|
+
* is still the WRONG question here — it says yes to the mediated DISPATCH
|
|
53
|
+
* path without saying how a tab would speak, and a session pinned by it
|
|
54
|
+
* once threw in mcpFor on every turn.
|
|
61
55
|
*/
|
|
62
|
-
const sessionCapable = (rid) =>
|
|
63
|
-
|
|
56
|
+
const sessionCapable = (rid) => {
|
|
57
|
+
const rt = runtimeById(rid);
|
|
58
|
+
return (Boolean(rt?.mcp) || rt?.tab === 'plain') && canRun(rt, 'build');
|
|
59
|
+
};
|
|
64
60
|
const sessionRuntime = (wt, jobRuntime, sessionId) => {
|
|
65
61
|
// SCOPED: two tabs standing in one directory may run different CLIs, and an
|
|
66
62
|
// unscoped pin would hand the second one the first one's runtime.
|
|
@@ -73,12 +69,17 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
|
|
|
73
69
|
/* unreadable marker — re-pin below */
|
|
74
70
|
}
|
|
75
71
|
}
|
|
76
|
-
|
|
72
|
+
// A marker naming no runtime this daemon declares — torn, hand-edited, or
|
|
73
|
+
// a prototype key like `constructor` — is no pin at all: it is re-picked
|
|
74
|
+
// exactly like a missing one (audit 2026-10-10, F125). It used to enter
|
|
75
|
+
// this branch on truthiness and settle every turn as "unsupported
|
|
76
|
+
// constructor".
|
|
77
|
+
if (pinned && runtimeById(pinned)) {
|
|
77
78
|
if (jobRuntime && jobRuntime !== pinned) {
|
|
78
79
|
return {
|
|
79
80
|
mismatch: {
|
|
80
|
-
pin:
|
|
81
|
-
runtime:
|
|
81
|
+
pin: runtimeLabel(pinned),
|
|
82
|
+
runtime: runtimeLabel(jobRuntime),
|
|
82
83
|
},
|
|
83
84
|
};
|
|
84
85
|
}
|
|
@@ -86,18 +87,18 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
|
|
|
86
87
|
// the caller, not silently re-picked: re-picking would hand the held
|
|
87
88
|
// context to a different brain, which is the exact substitution the pin
|
|
88
89
|
// exists to prevent.
|
|
89
|
-
if (!sessionCapable(pinned)) return { unsupported:
|
|
90
|
+
if (!sessionCapable(pinned)) return { unsupported: runtimeLabel(pinned) };
|
|
90
91
|
const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
|
|
91
|
-
return installed ? { id: pinned } : { missing:
|
|
92
|
+
return installed ? { id: pinned } : { missing: runtimeLabel(pinned) };
|
|
92
93
|
}
|
|
93
94
|
// First turn, and the server named the brain: that IS the pick, gated the
|
|
94
95
|
// same two ways as a pin — not session-capable and not installed both
|
|
95
96
|
// settle honestly via the caller's existing paths, never substituted.
|
|
96
97
|
if (jobRuntime) {
|
|
97
98
|
if (!sessionCapable(jobRuntime))
|
|
98
|
-
return { unsupported:
|
|
99
|
+
return { unsupported: runtimeLabel(jobRuntime) };
|
|
99
100
|
const installed = detectRuntimes().find((r) => r.id === jobRuntime)?.installed;
|
|
100
|
-
if (!installed) return { missing:
|
|
101
|
+
if (!installed) return { missing: runtimeLabel(jobRuntime) };
|
|
101
102
|
if (marker) {
|
|
102
103
|
try {
|
|
103
104
|
writeFileSync(marker, jobRuntime);
|
|
@@ -114,7 +115,7 @@ export function createWorkSessionRuntime({ sessionMetaPath }) {
|
|
|
114
115
|
// when the server names it, never handed out as a default.
|
|
115
116
|
const rows = detectRuntimes();
|
|
116
117
|
const okFor = (rid) =>
|
|
117
|
-
Boolean(
|
|
118
|
+
Boolean(runtimeById(rid)?.mcp) &&
|
|
118
119
|
sessionCapable(rid) &&
|
|
119
120
|
Boolean(rows.find((r) => r.id === rid)?.installed);
|
|
120
121
|
const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
|