flowviant 0.124.0 → 0.125.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 +6 -0
- package/bin/cli.mjs +7 -0
- package/bin/lib/agentInternalWait.mjs +2 -1
- package/bin/lib/agentPlan.mjs +9 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/agentTaskKinds.mjs +2 -0
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +21 -3
- package/bin/lib/fleet.mjs +19 -6
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/mergeBatch.mjs +172 -0
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectTools.mjs +2 -1
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +128 -48
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +23 -2
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeClaude.mjs +5 -2
- package/bin/lib/runtimeCodex.mjs +2 -1
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/work.mjs +23 -24
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +24 -4
- package/bin/lib/workAgentPrecheck.mjs +5 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +79 -9
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +33 -9
- package/bin/lib/workCappedTurn.mjs +6 -4
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workMergeBatch.mjs +104 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +6 -1
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +72 -40
- package/bin/lib/workShip.mjs +17 -16
- package/package.json +1 -1
- package/npm-shrinkwrap.json +0 -69
package/bin/lib/turnLock.mjs
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE CROSS-PROCESS TURN LOCK — the one reading of "is a CLI of ours already
|
|
3
|
-
* working in this
|
|
3
|
+
* working in this tab's conversation" (and, for ship, "anywhere in this
|
|
4
|
+
* directory").
|
|
4
5
|
*
|
|
5
6
|
* Split out of work.mjs (2026-09-26, SOLID F037). It is read by the session
|
|
6
7
|
* lane before a spawn and by the ship executor before a merge, and it is pure
|
|
7
8
|
* over the disk and the kernel: no manager state, so it needs no factory and
|
|
8
9
|
* is tested directly (turnLock.test.mjs). The lock FILE's path is the
|
|
9
|
-
* caller's (`sessionMetaPath(wt, 'flowviant-turn.lock')`); what a
|
|
10
|
-
* means is this module's.
|
|
10
|
+
* caller's (`sessionMetaPath(wt, 'flowviant-turn.lock', sessionId)`); what a
|
|
11
|
+
* pid in it means is this module's.
|
|
12
|
+
*
|
|
13
|
+
* PER TAB, NOT PER DIRECTORY (2026-10-14). It was keyed by the directory alone,
|
|
14
|
+
* on the assumption that a place holds one session. Every tab a person opens
|
|
15
|
+
* shares the checkout, so the one file serialized all of their tabs: a
|
|
16
|
+
* fourteen-minute turn in one tab held a message in two others for eight
|
|
17
|
+
* minutes each. The lock guards a CONVERSATION (two CLIs resuming one held
|
|
18
|
+
* context), so it is scoped by the tab; the unscoped file is what a daemon
|
|
19
|
+
* from before this change wrote, and is still honored while its CLI lives.
|
|
11
20
|
*/
|
|
12
|
-
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
22
|
+
import { basename, dirname, join } from 'node:path';
|
|
13
23
|
import { aliveAndOurs, processStartTime } from './procRegistry.mjs';
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
* The spawn lock: the pid of the CLI currently live in this
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
26
|
+
* The spawn lock: the pid of the CLI currently live in this tab. A restarted
|
|
27
|
+
* daemon must not resume the conversation its previous life's orphan still
|
|
28
|
+
* holds — two CLIs appending to one held conversation is exactly the
|
|
29
|
+
* incoherence the synchronous start claim prevents in-process,
|
|
20
30
|
* and the lock extends that guarantee across a restart. A dead pid is a
|
|
21
31
|
* stale lock (removed here); a live one means "come back next poll".
|
|
22
32
|
*
|
|
@@ -59,3 +69,33 @@ export const turnLockedByLivePid = (lockPath) => {
|
|
|
59
69
|
}
|
|
60
70
|
return false;
|
|
61
71
|
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ANY TAB'S TURN IN THIS DIRECTORY — ship's question, asked of the unscoped
|
|
75
|
+
* lock path (`sessionMetaPath(wt, 'flowviant-turn.lock')`). Ship folds and
|
|
76
|
+
* merges with git in the directory every tab there shares, so a live CLI of
|
|
77
|
+
* ANY tab defers it, not only the shipping tab's own. Reads the unscoped file
|
|
78
|
+
* and every `<name>-<scope>` beside it; nothing else in the git dir. Return
|
|
79
|
+
* the holding tab's id so the waiting log names the holder, not the shipper.
|
|
80
|
+
* A legacy lock has no tab id: null is an honest unknown.
|
|
81
|
+
*/
|
|
82
|
+
export const placeTurnLockHolder = (placeLockPath) => {
|
|
83
|
+
if (!placeLockPath) return null;
|
|
84
|
+
const dir = dirname(placeLockPath);
|
|
85
|
+
const name = basename(placeLockPath);
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = readdirSync(dir);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (entry !== name && !entry.startsWith(`${name}-`)) continue;
|
|
94
|
+
if (turnLockedByLivePid(join(dir, entry))) {
|
|
95
|
+
return { sessionId: entry === name ? null : entry.slice(name.length + 1) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const placeTurnLockedByLivePid = (placeLockPath) => Boolean(placeTurnLockHolder(placeLockPath));
|
package/bin/lib/work.mjs
CHANGED
|
@@ -51,17 +51,21 @@
|
|
|
51
51
|
* workCappedTurn.mjs one capped read-only consult turn: its child, its slot,
|
|
52
52
|
* its cap and its transcript — the planner, the pre-review
|
|
53
53
|
* and intake all run one (2026-10-10)
|
|
54
|
-
* turnLock.mjs
|
|
54
|
+
* turnLock.mjs tab pid locks; workLiveMessages.mjs — live input; workDelivery.mjs — queue
|
|
55
55
|
* (and, split earlier: workDiffs, workPreviews, workPullRequests, workShip,
|
|
56
56
|
* workAgentPlans, workAgentCheck, workAgentPrecheck, workAgentReview,
|
|
57
|
-
* workAgentTurns, workAgentMerges, workProcesses; and out of workAgentTurns,
|
|
57
|
+
* workAgentTurns, workAgentRecovery, workAgentMerges, workProcesses; and out of workAgentTurns,
|
|
58
58
|
* SOLID F036: workAgentTurnReports, workAgentTurnExecution, workAgentTurnBegun,
|
|
59
|
-
* workAgentTurnOutcome; the live brief: workAgentBrief, workAgentBriefRunner)
|
|
59
|
+
* workAgentTurnOutcome; repositoryPlaces + agentRepositories: multi-repo places; the live brief: workAgentBrief, workAgentBriefRunner)
|
|
60
60
|
*/
|
|
61
61
|
|
|
62
|
+
import { trackedRepositorySetup } from './repositorySetup.mjs';
|
|
63
|
+
import { createRepositoryPlaces } from './repositoryPlaces.mjs';
|
|
64
|
+
import { createAgentRepositories } from './agentRepositories.mjs';
|
|
62
65
|
import { createWorkProcesses } from './workProcesses.mjs';
|
|
63
66
|
import { createWorkAgentMerges } from './workAgentMerges.mjs';
|
|
64
67
|
import { createWorkAgentTurns } from './workAgentTurns.mjs';
|
|
68
|
+
import { createAgentRecovery } from './workAgentRecovery.mjs';
|
|
65
69
|
import { createWorkAgentCheck } from './workAgentCheck.mjs';
|
|
66
70
|
import { createWorkAgentPrecheck } from './workAgentPrecheck.mjs';
|
|
67
71
|
import { createWorkAgentReview } from './workAgentReview.mjs';
|
|
@@ -87,7 +91,7 @@ import { createWorkSessionTurns } from './workSessionTurns.mjs';
|
|
|
87
91
|
import { createWorkRetire } from './workRetire.mjs';
|
|
88
92
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
89
93
|
import { gitNet as gitNetIn, excludeInWorktree } from './git.mjs';
|
|
90
|
-
import {
|
|
94
|
+
import { createProjectLandedObserver } from './projectLanded.mjs';
|
|
91
95
|
import { createPlaceLock } from './placeLock.mjs';
|
|
92
96
|
import { warn } from './ui.mjs';
|
|
93
97
|
import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
|
|
@@ -99,6 +103,7 @@ import { scrub as envScrub, secretIn as envSecretIn } from './uplinkScrub.mjs';
|
|
|
99
103
|
|
|
100
104
|
export function createWorkManager({
|
|
101
105
|
repoRoot,
|
|
106
|
+
repositories,
|
|
102
107
|
baseDir,
|
|
103
108
|
getBaseRef,
|
|
104
109
|
getMcpUrl,
|
|
@@ -135,10 +140,12 @@ export function createWorkManager({
|
|
|
135
140
|
* `origin/<branch you happened to be on>` for the life of the daemon.
|
|
136
141
|
*/
|
|
137
142
|
const baseRef = () => getBaseRef();
|
|
143
|
+
const repositoryPlaces = repositories && createRepositoryPlaces({ repoRoot, baseDir, repositories, changed: onRepoChanged, setup: (...args) => trackedRepositorySetup(workChildren, groupKillChildren)(...args) });
|
|
144
|
+
const agentRepositories = repositories && createAgentRepositories({ repositories, places: repositoryPlaces });
|
|
138
145
|
// What arrived on base, whichever road it took — observed after every beat
|
|
139
146
|
// that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
|
|
140
147
|
// this daemon performed. See landed.mjs for the seeding and delivery rules.
|
|
141
|
-
const landed =
|
|
148
|
+
const landed = createProjectLandedObserver({ repoRoot, baseRef, repositories });
|
|
142
149
|
/**
|
|
143
150
|
* THE ARTIFACT RELAY (2026-09-22, 0.94.0) — what a turn wrote under
|
|
144
151
|
* `.flowviant/artifacts/`, uploaded after it. One reporter for the life of
|
|
@@ -200,14 +207,13 @@ export function createWorkManager({
|
|
|
200
207
|
*/
|
|
201
208
|
const gitNet = (args, ms) => gitNetIn(args, repoRoot, ms);
|
|
202
209
|
|
|
203
|
-
// ── THE SESSION'S OWN HALF — built first, because the lanes below are handed it.
|
|
204
210
|
const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt } =
|
|
205
211
|
createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
|
|
206
212
|
const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
|
|
207
213
|
createWorkSessionGroups({ repoRoot });
|
|
208
214
|
const { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit } =
|
|
209
215
|
createWorkChildren({ extraLiveTurns });
|
|
210
|
-
const { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch } =
|
|
216
|
+
const { agentPublished, agentRemoteAt, publishAgentBranch, stopAgentPublishing, fetchPublishedBranch, fetchPublishedBranchAsync } =
|
|
211
217
|
createWorkAgentPublish({ repoRoot, gitNet });
|
|
212
218
|
const { reconcileIdleArtifacts } = createWorkArtifactReconcile({
|
|
213
219
|
artifacts,
|
|
@@ -220,6 +226,7 @@ export function createWorkManager({
|
|
|
220
226
|
});
|
|
221
227
|
const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
|
|
222
228
|
createWorkPlaceReports({
|
|
229
|
+
repositoryPlaces,
|
|
223
230
|
repoRoot,
|
|
224
231
|
baseRef,
|
|
225
232
|
placeOf,
|
|
@@ -295,15 +302,10 @@ export function createWorkManager({
|
|
|
295
302
|
* which lanes share which map (workChildren: plans, review, turns; the
|
|
296
303
|
* publication maps: turns and merges).
|
|
297
304
|
*/
|
|
298
|
-
|
|
299
|
-
const { processDiffJobs } = createWorkDiffs({
|
|
300
|
-
repoRoot,
|
|
301
|
-
});
|
|
302
|
-
|
|
305
|
+
const { processDiffJobs } = createWorkDiffs({ repoRoot });
|
|
303
306
|
const { processPreviewJobs, livePreviewIds, retirePreviews, shutdownPreviews } = createWorkPreviews({
|
|
304
307
|
placeDir,
|
|
305
308
|
});
|
|
306
|
-
|
|
307
309
|
const { processPrJobs } = createWorkPullRequests({
|
|
308
310
|
placeOf,
|
|
309
311
|
placeDir,
|
|
@@ -314,7 +316,6 @@ export function createWorkManager({
|
|
|
314
316
|
landed,
|
|
315
317
|
onRepoChanged,
|
|
316
318
|
});
|
|
317
|
-
|
|
318
319
|
const { processShipJobs } = createWorkShipper({
|
|
319
320
|
inPlace,
|
|
320
321
|
placeOf,
|
|
@@ -330,7 +331,6 @@ export function createWorkManager({
|
|
|
330
331
|
shipping,
|
|
331
332
|
pendingShipReports,
|
|
332
333
|
});
|
|
333
|
-
|
|
334
334
|
const { processAgentPlanJobs, planning } = createWorkAgentPlans({
|
|
335
335
|
postBestEffort,
|
|
336
336
|
baseDir,
|
|
@@ -341,7 +341,6 @@ export function createWorkManager({
|
|
|
341
341
|
placeLocks,
|
|
342
342
|
workChildren,
|
|
343
343
|
});
|
|
344
|
-
|
|
345
344
|
const { processIntakeJobs, intake } = createWorkIntake({
|
|
346
345
|
postBestEffort,
|
|
347
346
|
inPlace,
|
|
@@ -367,9 +366,9 @@ export function createWorkManager({
|
|
|
367
366
|
workChildren,
|
|
368
367
|
});
|
|
369
368
|
const { runReviewEntry } = createWorkAgentReview({ runCheck, runPrecheck });
|
|
370
|
-
|
|
369
|
+
const processAgentRecoveryJobs = createAgentRecovery({ baseDir, repoRoot, fetchPublishedBranch: fetchPublishedBranchAsync, inPlace, placeWtFor, reportSessionWorktree });
|
|
371
370
|
const { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
|
|
372
|
-
REJECT_RETRY_MS,
|
|
371
|
+
agentRepositories, REJECT_RETRY_MS,
|
|
373
372
|
baseRef,
|
|
374
373
|
inPlace,
|
|
375
374
|
baseDir,
|
|
@@ -383,15 +382,16 @@ export function createWorkManager({
|
|
|
383
382
|
reportSessionWorktree,
|
|
384
383
|
artifacts,
|
|
385
384
|
runReviewEntry,
|
|
386
|
-
publishAgentBranch,
|
|
385
|
+
publishAgentBranch, stopAgentPublishing,
|
|
387
386
|
admit,
|
|
388
387
|
workChildren,
|
|
389
388
|
agentPublished,
|
|
390
389
|
agentRemoteAt,
|
|
391
390
|
fetchAgentFiles,
|
|
392
391
|
});
|
|
393
|
-
|
|
394
392
|
const { processAgentMergeJobs, agentMerges } = createWorkAgentMerges({
|
|
393
|
+
runCheck, workChildren, groupKillChildren, admit,
|
|
394
|
+
repositoryPlaces,
|
|
395
395
|
repoRoot,
|
|
396
396
|
inPlace,
|
|
397
397
|
baseDir,
|
|
@@ -404,10 +404,9 @@ export function createWorkManager({
|
|
|
404
404
|
agentPublished,
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
-
//
|
|
408
|
-
// only the agent lane knows which child that is.
|
|
407
|
+
// Retirement asks the agent lane which CLI to stop.
|
|
409
408
|
const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
|
|
410
|
-
repoRoot,
|
|
409
|
+
repositoryPlaces, repoRoot,
|
|
411
410
|
baseDir,
|
|
412
411
|
baseRef,
|
|
413
412
|
placeOf,
|
|
@@ -485,7 +484,7 @@ export function createWorkManager({
|
|
|
485
484
|
liveTurnCount,
|
|
486
485
|
processAgentPlanJobs,
|
|
487
486
|
processIntakeJobs,
|
|
488
|
-
processAgentTurnJobs, interruptAgentTurns,
|
|
487
|
+
processAgentTurnJobs, processAgentRecoveryJobs, interruptAgentTurns,
|
|
489
488
|
// Only the roster's stand-down calls this — a displacement, a removal or a
|
|
490
489
|
// commanded stop (holder.mjs, `standDownDisplaced`); see its comment.
|
|
491
490
|
// Exported rather than hooked into `shutdownWork` because the signal
|
|
@@ -150,9 +150,9 @@ export function createWorkAgentCheck({
|
|
|
150
150
|
const CHECK_TIMEOUT_MS = 10 * 60_000;
|
|
151
151
|
const CHECK_OUTPUT_CAP = 4000;
|
|
152
152
|
|
|
153
|
-
const readCheckCommand = () => {
|
|
153
|
+
const readCheckCommand = (root = repoRoot) => {
|
|
154
154
|
try {
|
|
155
|
-
const raw = readFileSync(join(
|
|
155
|
+
const raw = readFileSync(join(root, '.flowviant', 'check.json'), 'utf8');
|
|
156
156
|
const cfg = JSON.parse(raw);
|
|
157
157
|
const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
|
|
158
158
|
return cmd ? cmd.slice(0, 500) : null;
|
|
@@ -168,8 +168,8 @@ export function createWorkAgentCheck({
|
|
|
168
168
|
await postBestEffort(AGENT_CHECK_DONE_URL, body);
|
|
169
169
|
};
|
|
170
170
|
|
|
171
|
-
const runCheck = async (agentId, wt) => {
|
|
172
|
-
const cmd = readCheckCommand();
|
|
171
|
+
const runCheck = async (agentId, wt, repository, commandOverride) => {
|
|
172
|
+
const cmd = commandOverride ?? readCheckCommand(repository ? wt : repoRoot);
|
|
173
173
|
// OPTIONAL, like the pre-review's twin read: an unreadable head (a
|
|
174
174
|
// worktree retired mid-beat, an unborn branch) costs the answer its
|
|
175
175
|
// headSha, never the answer. This read was the one copy left unguarded,
|
|
@@ -178,7 +178,7 @@ export function createWorkAgentCheck({
|
|
|
178
178
|
const headSha = gitOrNull(['rev-parse', 'HEAD'], wt) ?? undefined;
|
|
179
179
|
if (!cmd) {
|
|
180
180
|
await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
|
|
181
|
-
return;
|
|
181
|
+
return { status: 'none' };
|
|
182
182
|
}
|
|
183
183
|
const cleared = clearNonArtifacts(wt);
|
|
184
184
|
if (cleared.length)
|
|
@@ -325,6 +325,7 @@ export function createWorkAgentCheck({
|
|
|
325
325
|
output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
|
|
326
326
|
...(headSha ? { headSha } : {}),
|
|
327
327
|
});
|
|
328
|
+
return out;
|
|
328
329
|
};
|
|
329
330
|
|
|
330
331
|
return { runCheck };
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { createWorkMergeBatch } from './workMergeBatch.mjs';
|
|
2
|
+
import { mergeRepositoryPlace } from './repositoryMerge.mjs';
|
|
3
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
1
4
|
/** Agent merge claims, pull-request approval, and branch cleanup. */
|
|
2
5
|
import { ensureOpenPr, ghReady, mergeAndVerifyTip, PR_URL_RE, relayedFailure } from './prWorkflow.mjs';
|
|
3
6
|
import { existsSync } from 'node:fs';
|
|
4
7
|
import { execFileSync } from 'node:child_process';
|
|
5
8
|
import { join } from 'node:path';
|
|
6
|
-
import { FLEET_URL } from './config.mjs';
|
|
9
|
+
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
7
10
|
import { git, gitOrNull, gitNet as gitNetIn, baseBranchName, isSafePathSegment, sessionBranch, stripUrlUserinfo } from './git.mjs';
|
|
8
11
|
import { isPublishRef, publishPushArgs, publishDeleteArgs, publishErrorText } from './agentPublish.mjs';
|
|
9
12
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
@@ -30,6 +33,8 @@ export function agentMergeFailureDetail(error) {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export function createWorkAgentMerges({
|
|
36
|
+
runCheck, workChildren = new Map(), groupKillChildren = new Set(), admit,
|
|
37
|
+
repositoryPlaces,
|
|
33
38
|
repoRoot,
|
|
34
39
|
inPlace,
|
|
35
40
|
baseDir,
|
|
@@ -111,7 +116,7 @@ export function createWorkAgentMerges({
|
|
|
111
116
|
const report = async (body) => {
|
|
112
117
|
reported = true;
|
|
113
118
|
if (body?.ok === true) landedRef = job.publishedRef ?? null;
|
|
114
|
-
await postAgentMerge(body);
|
|
119
|
+
await postAgentMerge({ ...body, ...(body.detail ? { detail: envScrub(stripUrlUserinfo(String(body.detail))).slice(0, 2000) } : {}) });
|
|
115
120
|
};
|
|
116
121
|
|
|
117
122
|
// A WRITER on the place, exactly as a ship is. It folds base in and pushes
|
|
@@ -119,6 +124,20 @@ export function createWorkAgentMerges({
|
|
|
119
124
|
// does not know exists.
|
|
120
125
|
try {
|
|
121
126
|
await inPlace(place, true, async () => {
|
|
127
|
+
if (job.repositoryMerge) {
|
|
128
|
+
if (!repositoryPlaces) throw new Error('Project repository worktrees are unavailable.');
|
|
129
|
+
const result = await mergeRepositoryPlace({ job, entries: repositoryPlaces.entries(place), baseDir,
|
|
130
|
+
check: (r) => runReviewEntry(agentId, r.wt, job.agentName, r),
|
|
131
|
+
progress: async (states, retry) => {
|
|
132
|
+
const response = await fleetRequest(fleetEndpoint('agent-merge-progress', FLEET_URL), {
|
|
133
|
+
body: { agentId, instance: DAEMON_INSTANCE, states, retry } });
|
|
134
|
+
if (response.error || response.json?.data?.ok !== true) throw new Error('Repository merge progress was not acknowledged. The next attempt will remeasure base.');
|
|
135
|
+
} });
|
|
136
|
+
if (result.retry) reported = true; // The lease was released; approval remains.
|
|
137
|
+
else await report({ agentId, ...result });
|
|
138
|
+
onRepoChanged();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
122
141
|
const wt = worktreePath(baseDir, place);
|
|
123
142
|
if (!existsSync(wt)) {
|
|
124
143
|
await report({ agentId, ok: false, detail: 'the worktree is gone' });
|
|
@@ -417,13 +436,14 @@ export function createWorkAgentMerges({
|
|
|
417
436
|
}
|
|
418
437
|
};
|
|
419
438
|
|
|
439
|
+
const runBatch = createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPlace, runCheck, workChildren, groupKillChildren, admit, onRepoChanged, landed });
|
|
420
440
|
const processAgentMergeJobs = (jobs) => {
|
|
421
441
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
422
442
|
for (const job of jobs.slice(0, 2)) {
|
|
423
|
-
const id = String(job?.agentId || '');
|
|
443
|
+
const id = String(job?.batch?.spec?.id || job?.agentId || '');
|
|
424
444
|
if (!id || agentMerges.has(id)) continue;
|
|
425
445
|
agentMerges.add(id);
|
|
426
|
-
void runAgentMerge(job).finally(() => agentMerges.delete(id));
|
|
446
|
+
void (job.batch ? runBatch(job) : runAgentMerge(job)).finally(() => agentMerges.delete(id));
|
|
427
447
|
}
|
|
428
448
|
};
|
|
429
449
|
|
|
@@ -116,8 +116,8 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
|
|
|
116
116
|
* ref at all; an unreadable range costs the reviewer its context, never the
|
|
117
117
|
* review-entry beat it is standing in.
|
|
118
118
|
*/
|
|
119
|
-
const branchLog = (wt) => {
|
|
120
|
-
const out = gitOrNull(['log', '--format=%s%n%b%n--', `${
|
|
119
|
+
const branchLog = (wt, ref = baseRef()) => {
|
|
120
|
+
const out = gitOrNull(['log', '--format=%s%n%b%n--', `${ref}..HEAD`, '--not', ref], wt);
|
|
121
121
|
if (out == null) return { text: '', taskIds: [] };
|
|
122
122
|
// LINEAR, never backtracking (audit 2026-09-24) — this was
|
|
123
123
|
// `out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)`, whose `\s*$` is
|
|
@@ -135,7 +135,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
|
|
|
135
135
|
return { text, taskIds: [...taskIds] };
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
-
const runPrecheck = async (agentId, wt, agentName) => {
|
|
138
|
+
const runPrecheck = async (agentId, wt, agentName, repository) => {
|
|
139
139
|
/**
|
|
140
140
|
* WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
|
|
141
141
|
* planner uses, and for the same reason: this prompt was written against
|
|
@@ -166,7 +166,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
|
|
|
166
166
|
const headSha = gitOrNull(['rev-parse', 'HEAD'], wt);
|
|
167
167
|
|
|
168
168
|
const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
|
|
169
|
-
const log = branchLog(wt);
|
|
169
|
+
const log = branchLog(wt, repository?.baseRef ?? baseRef());
|
|
170
170
|
/** Cards the BRANCH names that this box has no spec for. Measured, not
|
|
171
171
|
* guessed — see agentCards.mjs on why a stash is per-box. */
|
|
172
172
|
const held = new Set(stash.map((s) => s.taskId));
|
|
@@ -224,7 +224,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
|
|
|
224
224
|
cards: stash.map((s) => s.prompt).join('\n---\n'),
|
|
225
225
|
missingSpecs,
|
|
226
226
|
commits: log.text,
|
|
227
|
-
diffCommand: `git diff ${baseRef()}...HEAD`,
|
|
227
|
+
diffCommand: `git diff ${repository?.baseRef ?? baseRef()}...HEAD`,
|
|
228
228
|
}),
|
|
229
229
|
system: SYSTEM_PRECHECK,
|
|
230
230
|
// READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* agentPublish.mjs's (pure, tested there); this module owns the STATE and the
|
|
9
9
|
* network calls. The agent lane pushes after a settle and fetches at the
|
|
10
10
|
* begun-guard, the sweep keeps a published branch current, the merge lane
|
|
11
|
-
* retires the ref — all through the maps and
|
|
11
|
+
* retires the ref — all through the maps and functions returned here.
|
|
12
12
|
*/
|
|
13
|
-
import { gitOrNull, sessionBranchRef } from './git.mjs';
|
|
13
|
+
import { gitOrNull, sessionBranchRef, gitNetAsync } from './git.mjs';
|
|
14
14
|
import { publishPushArgs, publishFetchArgs, publishErrorText } from './agentPublish.mjs';
|
|
15
15
|
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
16
16
|
|
|
@@ -41,6 +41,8 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
|
|
|
41
41
|
* something a rebase can invalidate.
|
|
42
42
|
*/
|
|
43
43
|
const agentPublished = new Map();
|
|
44
|
+
let publishingStopped = false;
|
|
45
|
+
const stopAgentPublishing = () => { publishingStopped = true; };
|
|
44
46
|
/**
|
|
45
47
|
* WHAT THIS PROCESS HAS SEEN AT EACH AGENT'S REMOTE REF — `{ ref, sha }`, and
|
|
46
48
|
* the ONLY input to the push's lease.
|
|
@@ -85,6 +87,7 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
|
|
|
85
87
|
* Never throws.
|
|
86
88
|
*/
|
|
87
89
|
const publishAgentBranch = async (place, target) => {
|
|
90
|
+
if (publishingStopped) return false;
|
|
88
91
|
const sha = agentBranchSha(place);
|
|
89
92
|
if (!sha) return false;
|
|
90
93
|
const prev = agentPublished.get(place);
|
|
@@ -124,8 +127,8 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
|
|
|
124
127
|
* BRING A PUBLISHED BRANCH BACK DOWN — the other half of the durability
|
|
125
128
|
* promise, and the only thing that lets an agent's work outlive its box.
|
|
126
129
|
*
|
|
127
|
-
* Reached
|
|
128
|
-
*
|
|
130
|
+
* Reached after the begun guard measures neither a worktree nor a branch,
|
|
131
|
+
* by the ordinary turn path or the holder's recovery inspection.
|
|
129
132
|
* The server only sends `publishedRef` when it heard about a real push, so
|
|
130
133
|
* this is not a guess at a remote branch — it is a fetch of one a machine
|
|
131
134
|
* reported writing.
|
|
@@ -159,6 +162,16 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
|
|
|
159
162
|
why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')),
|
|
160
163
|
};
|
|
161
164
|
}
|
|
165
|
+
return fetchedBranch(place, ref);
|
|
166
|
+
};
|
|
167
|
+
const fetchPublishedBranchAsync = async (place, ref) => {
|
|
168
|
+
const args = publishFetchArgs(ref, place);
|
|
169
|
+
if (!args) return null;
|
|
170
|
+
try { await gitNetAsync(args, repoRoot, 120_000); }
|
|
171
|
+
catch (e) { return { ok: false, why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')) }; }
|
|
172
|
+
return fetchedBranch(place, ref);
|
|
173
|
+
};
|
|
174
|
+
const fetchedBranch = (place, ref) => {
|
|
162
175
|
const sha = agentBranchSha(place);
|
|
163
176
|
if (!sha) return { ok: false, why: 'the fetch reported nothing and left no local branch' };
|
|
164
177
|
// WHAT THIS PROCESS HAS NOW SEEN AT THAT REMOTE REF. A fetch is an
|
|
@@ -171,5 +184,5 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
|
|
|
171
184
|
return { ok: true };
|
|
172
185
|
};
|
|
173
186
|
|
|
174
|
-
return { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch };
|
|
187
|
+
return { agentPublished, agentRemoteAt, publishAgentBranch, stopAgentPublishing, fetchPublishedBranch, fetchPublishedBranchAsync };
|
|
175
188
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
2
|
+
import { myPubB64 } from './boxIdentity.mjs';
|
|
3
|
+
import { isSafePathSegment, sessionBranchRef, gitNetAsync } from './git.mjs';
|
|
4
|
+
import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
|
|
5
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
6
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
7
|
+
|
|
8
|
+
/** Inspect before any cut or CLI. A failed request waits for the next poll. */
|
|
9
|
+
export function createAgentRecovery({
|
|
10
|
+
baseDir, repoRoot, fetchPublishedBranch, inPlace, placeWtFor, reportSessionWorktree,
|
|
11
|
+
boxId = () => myPubB64(), fleetUrl = FLEET_URL,
|
|
12
|
+
request = async (job, id) => {
|
|
13
|
+
const result = await fleetRequest(fleetEndpoint('agent-recover', fleetUrl), { body: {
|
|
14
|
+
agentId: job.agentId, sourceKey: job.sourceKey, sourceBoxId: job.sourceBoxId,
|
|
15
|
+
boxId: id, daemonInstance: DAEMON_INSTANCE,
|
|
16
|
+
} });
|
|
17
|
+
return result.ok && result.json?.data?.recovered === true;
|
|
18
|
+
},
|
|
19
|
+
} = {}) {
|
|
20
|
+
const ready = new Map(); // source snapshot inspected for the next poll
|
|
21
|
+
let checking = null; // one repository fetch at a time; polling never waits
|
|
22
|
+
let lastInspected = null;
|
|
23
|
+
const processAgentRecoveryJobs = (jobs) => {
|
|
24
|
+
const blocked = new Set();
|
|
25
|
+
if (!Array.isArray(jobs)) return blocked;
|
|
26
|
+
const id = boxId();
|
|
27
|
+
if (!id) return blocked;
|
|
28
|
+
const offered = new Set(jobs.map((j) => j?.agentId));
|
|
29
|
+
for (const agent of ready.keys()) if (!offered.has(agent)) ready.delete(agent);
|
|
30
|
+
// A failed inspection must not keep every agent behind it waiting.
|
|
31
|
+
const next = jobs.findIndex((j) => j?.agentId === lastInspected) + 1;
|
|
32
|
+
for (const job of [...jobs.slice(next), ...jobs.slice(0, next)]) {
|
|
33
|
+
if (!job || !isSafePathSegment(job.agentId) || !isSafePathSegment(job.placeId) ||
|
|
34
|
+
sessionBranchRef(job.placeId) === null || typeof job.sourceKey !== 'string' ||
|
|
35
|
+
!job.sourceBoxId || job.sourceBoxId === id || job.placeId !== `a-${job.agentId}`) continue;
|
|
36
|
+
const key = `${job.sourceBoxId}:${job.sourceKey}`;
|
|
37
|
+
if (ready.get(job.agentId) === key) { ready.delete(job.agentId); continue; }
|
|
38
|
+
blocked.add(job.agentId);
|
|
39
|
+
if (checking) continue;
|
|
40
|
+
lastInspected = job.agentId;
|
|
41
|
+
checking = Promise.resolve().then(() => inPlace(job.placeId, true, async () => {
|
|
42
|
+
let available = false;
|
|
43
|
+
const facts = { begunOn: job.begunOn, publishedRef: job.publishedRef };
|
|
44
|
+
// The measurement stays in the begun guard. Only its network fallback
|
|
45
|
+
// is asynchronous here, so a slow origin never stops heartbeats.
|
|
46
|
+
const missing = begunTurnRefusal(facts, job.placeId, {
|
|
47
|
+
baseDir, repoRoot, fetchPublishedBranch: () => null,
|
|
48
|
+
onAvailable: () => { available = true; },
|
|
49
|
+
});
|
|
50
|
+
let refusal = null;
|
|
51
|
+
if (missing) {
|
|
52
|
+
const fetched = await fetchPublishedBranch(job.placeId, job.publishedRef);
|
|
53
|
+
refusal = begunTurnRefusal(facts, job.placeId, {
|
|
54
|
+
baseDir, repoRoot, fetchPublishedBranch: () => fetched,
|
|
55
|
+
onAvailable: () => { available = true; },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!refusal) {
|
|
59
|
+
// Unknown reads start no redo and leave the ordinary begun guard
|
|
60
|
+
// to report its own git failure. Inspect again on a later poll.
|
|
61
|
+
// Review and Stuck have no offered task turn to attach and measure
|
|
62
|
+
// the recovered branch. Report custody here for every available one.
|
|
63
|
+
if (available && placeWtFor?.(job.placeId)) await reportSessionWorktree?.(job.placeId);
|
|
64
|
+
ready.set(job.agentId, key);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Never run or settle the old offer while a restart is being decided.
|
|
68
|
+
await request(job, id);
|
|
69
|
+
})).catch(() => {}).finally(() => { checking = null; });
|
|
70
|
+
}
|
|
71
|
+
return blocked;
|
|
72
|
+
};
|
|
73
|
+
// A test or a graceful owner may wait for the current inspection. The fleet
|
|
74
|
+
// loop deliberately does not: the next poll observes its result.
|
|
75
|
+
processAgentRecoveryJobs.settled = () => checking ?? Promise.resolve();
|
|
76
|
+
return processAgentRecoveryJobs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
/** Refresh before cutting a redo. A failed fetch keeps the prompt pending. */
|
|
81
|
+
export async function refreshRestartBase(job, repoRoot) {
|
|
82
|
+
if (!job.freshAfterSwap) return true;
|
|
83
|
+
try { await gitNetAsync(['fetch', '--prune', 'origin'], repoRoot); return true; }
|
|
84
|
+
catch { return false; }
|
|
85
|
+
}
|
|
@@ -27,17 +27,19 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
|
|
|
27
27
|
* INSIDE the place's writer lock on a path whose callers settle real work —
|
|
28
28
|
* `runAgentMerge` in particular reports a claimed merge after this returns.
|
|
29
29
|
*/
|
|
30
|
-
const runReviewEntry = async (agentId, wt, agentName) => {
|
|
30
|
+
const runReviewEntry = async (agentId, wt, agentName, repository) => {
|
|
31
|
+
let result = { status: 'unknown' };
|
|
31
32
|
try {
|
|
32
|
-
await runCheck(agentId, wt);
|
|
33
|
+
result = await runCheck(agentId, wt, repository) ?? result;
|
|
33
34
|
} catch {
|
|
34
35
|
/* the row keeps its previous check answer, which is null the first time */
|
|
35
36
|
}
|
|
36
37
|
try {
|
|
37
|
-
await runPrecheck(agentId, wt, agentName);
|
|
38
|
+
await runPrecheck(agentId, wt, agentName, repository);
|
|
38
39
|
} catch {
|
|
39
40
|
/* no pre-review is posted, and absence renders nothing */
|
|
40
41
|
}
|
|
42
|
+
return result;
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
return { runReviewEntry };
|
|
@@ -40,11 +40,11 @@ import { worktreePath } from './workPlaces.mjs';
|
|
|
40
40
|
* cleaned up on THIS box is same-box recovery of real committed work and
|
|
41
41
|
* behaves exactly as it did before this guard existed.
|
|
42
42
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* This remains the same-box and older-server refusal. The recovery coordinator
|
|
44
|
+
* now inspects foreign work before this ordinary turn path: after a person's
|
|
45
|
+
* machine swap it can replace missing work with an explicit start from briefs.
|
|
46
46
|
*/
|
|
47
|
-
export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublishedBranch }) {
|
|
47
|
+
export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublishedBranch, onAvailable = () => {} }) {
|
|
48
48
|
// The caller validated `place` (workAgentTurns); an unsafe one would give a
|
|
49
49
|
// null directory AND a null ref below, and the null ref stands the guard
|
|
50
50
|
// down — the same fail-open direction as an unreadable repo.
|
|
@@ -105,9 +105,11 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
|
|
|
105
105
|
(fetched ? ` Its published branch could not be fetched (${fetched.why}).` : '')
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
|
+
onAvailable();
|
|
108
109
|
// The branch is here and measured. `placeWtFor`'s attach fallback
|
|
109
110
|
// opens a worktree on it below — the same path a directory somebody
|
|
110
111
|
// cleaned up already takes, on commits this box now genuinely holds.
|
|
111
112
|
}
|
|
113
|
+
if (hasBranch || (wtDir && existsSync(wtDir))) onAvailable();
|
|
112
114
|
return null;
|
|
113
115
|
}
|