flowviant 0.124.0 → 0.124.1
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 +8 -0
- package/bin/lib/agentRepositories.mjs +48 -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/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 +6 -1
- 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 +22 -24
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- 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 +76 -9
- package/bin/lib/workAgentTurnOutcome.mjs +9 -3
- package/bin/lib/workAgentTurns.mjs +33 -9
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -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/trace.mjs
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
*/
|
|
61
61
|
|
|
62
62
|
import { THINK_MARKER } from './runtimeEvents.mjs';
|
|
63
|
+
import { withoutTurnDeclaration } from './turnDeclaration.mjs';
|
|
63
64
|
|
|
64
65
|
/** One batch every two seconds — the discipline the tab's narrator keeps, for
|
|
65
66
|
* the same reason: a turn emits hundreds of entries and nobody is reading them
|
|
@@ -207,7 +208,8 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
207
208
|
* did when a thought was 300 characters.
|
|
208
209
|
*/
|
|
209
210
|
prose(kind, text) {
|
|
210
|
-
const
|
|
211
|
+
const readable = kind === 'think' ? String(text ?? '') : withoutTurnDeclaration(text);
|
|
212
|
+
const t = scrub(readable)
|
|
211
213
|
// One line ending, whatever the CLI printed.
|
|
212
214
|
.replace(/\r\n?/g, '\n')
|
|
213
215
|
// Spaces and tabs collapse; `\n` is deliberately excluded from the class.
|
|
@@ -216,7 +218,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
216
218
|
.replace(/\n{3,}/g, '\n\n')
|
|
217
219
|
.trim()
|
|
218
220
|
.slice(0, TRACE_PROSE_CAP);
|
|
219
|
-
if (!t) return;
|
|
221
|
+
if (!t) return null;
|
|
220
222
|
const k = kind === 'think' ? 'think' : kind === 'say' ? 'say' : 'note';
|
|
221
223
|
/**
|
|
222
224
|
* A RUN OF BARE "thinking…" IS ONE STEP, not forty.
|
|
@@ -238,9 +240,12 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
238
240
|
* steps are not shown" count stays a count of steps that existed.
|
|
239
241
|
*/
|
|
240
242
|
if (k === 'think' && t === THINK_MARKER && lastEntry?.k === 'think' && lastEntry.t === t) {
|
|
241
|
-
return;
|
|
243
|
+
return readable;
|
|
242
244
|
}
|
|
243
245
|
push({ k, t });
|
|
246
|
+
// The activity pulse uses this same filtering; its original compact
|
|
247
|
+
// label stands unless protocol text was removed from the full message.
|
|
248
|
+
return readable;
|
|
244
249
|
},
|
|
245
250
|
/** One structured tool event, exactly as `toolEventOf` built it. A tool this
|
|
246
251
|
* builder does not know returns null there and nothing is pushed here — a
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** A final outcome is protocol, not conversation prose. Filter before trace
|
|
2
|
+
* normalization/caps; the unmodified answer still goes to parseTurnResult.
|
|
3
|
+
* Only standalone objects and JSON/unlabelled fences count. Code examples
|
|
4
|
+
* inside prose or another language's fence stay as the CLI wrote them. */
|
|
5
|
+
import { jsonObjectClosers } from './agentPlan.mjs';
|
|
6
|
+
|
|
7
|
+
const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'nothing']);
|
|
8
|
+
function declaration(body) {
|
|
9
|
+
const raw = body.trim();
|
|
10
|
+
if (!raw.startsWith('{')) return false;
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(raw);
|
|
13
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
14
|
+
&& ((Object.hasOwn(value, 'summary') && Object.hasOwn(value, 'progress'))
|
|
15
|
+
|| (typeof value.status === 'string'
|
|
16
|
+
&& (statuses.has(value.status) || ['summary', 'progress', 'question'].some(key => Object.hasOwn(value, key)))));
|
|
17
|
+
} catch {
|
|
18
|
+
// A truncated/invalid declaration must not fall through as an answer.
|
|
19
|
+
// Require a protocol key at the root's opening and the declared status;
|
|
20
|
+
// arbitrary malformed JSON remains useful diagnostic output.
|
|
21
|
+
return /^\{\s*"(?:status|summary|progress|question)"\s*:/.test(raw)
|
|
22
|
+
&& /"status"\s*:\s*"(?:delivered|blocked|prerequisite|nothing)"/.test(raw);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Objects on their own lines, including a final object after a preamble.
|
|
27
|
+
* Skip a whole outer span before looking again; nested examples are data. */
|
|
28
|
+
function bareDeclarations(raw) {
|
|
29
|
+
const closes = jsonObjectClosers(raw);
|
|
30
|
+
let kept = '', from = 0, removed = false;
|
|
31
|
+
for (let i = 0; i < raw.length; i++) {
|
|
32
|
+
if (raw[i] !== '{') continue;
|
|
33
|
+
const end = closes.get(i) ?? -1;
|
|
34
|
+
const lineStart = raw.lastIndexOf('\n', i - 1) + 1;
|
|
35
|
+
const nextLine = end < 0 ? -1 : raw.indexOf('\n', end + 1);
|
|
36
|
+
const alone = !raw.slice(lineStart, i).trim()
|
|
37
|
+
&& (end < 0 || !raw.slice(end + 1, nextLine < 0 ? raw.length : nextLine).trim());
|
|
38
|
+
const finish = end < 0 ? raw.length : end + 1;
|
|
39
|
+
if (alone && declaration(raw.slice(i, finish))) {
|
|
40
|
+
kept += raw.slice(from, i); from = finish; removed = true;
|
|
41
|
+
}
|
|
42
|
+
if (end >= 0) i = end;
|
|
43
|
+
else {
|
|
44
|
+
// An unclosed outer object owns the remaining candidate. For a stray
|
|
45
|
+
// brace in prose, skip its line instead of rescanning a brace wall.
|
|
46
|
+
const newline = raw.indexOf('\n', i);
|
|
47
|
+
if (alone || newline < 0) break;
|
|
48
|
+
i = newline;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return removed ? kept + raw.slice(from) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function withoutTurnDeclaration(text) {
|
|
55
|
+
const raw = String(text ?? '');
|
|
56
|
+
const kept = [];
|
|
57
|
+
let block = [], fence = null, json = false, removed = false;
|
|
58
|
+
let prose = [];
|
|
59
|
+
const flushProse = () => {
|
|
60
|
+
const plain = bareDeclarations(prose.join('\n'));
|
|
61
|
+
if (plain !== null) { removed = true; kept.push(plain); }
|
|
62
|
+
else kept.push(...prose);
|
|
63
|
+
prose = [];
|
|
64
|
+
};
|
|
65
|
+
// Consume every fence, including other languages, in one forward pass.
|
|
66
|
+
// A nested JSON example is part of its outer code block, never a result.
|
|
67
|
+
for (const line of raw.split('\n')) {
|
|
68
|
+
if (fence) {
|
|
69
|
+
if (line.trim() === fence) {
|
|
70
|
+
if (json && declaration(block.slice(1).join('\n'))) removed = true;
|
|
71
|
+
else kept.push(...block, line);
|
|
72
|
+
block = []; fence = null;
|
|
73
|
+
} else block.push(line);
|
|
74
|
+
} else {
|
|
75
|
+
const open = line.trim().match(/^(`{3,}|~{3,})[ \t]*([\w-]*)$/);
|
|
76
|
+
if (open) {
|
|
77
|
+
flushProse();
|
|
78
|
+
fence = open[1]; json = !open[2] || open[2].toLowerCase() === 'json'; block = [line];
|
|
79
|
+
} else prose.push(line);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
flushProse();
|
|
83
|
+
if (fence) {
|
|
84
|
+
if (json && declaration(block.slice(1).join('\n'))) removed = true;
|
|
85
|
+
else kept.push(...block);
|
|
86
|
+
}
|
|
87
|
+
return removed ? kept.join('\n').replace(/\n{3,}/g, '\n\n').trim() : raw;
|
|
88
|
+
}
|
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,15 @@ 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
|
+
repositoryPlaces,
|
|
395
394
|
repoRoot,
|
|
396
395
|
inPlace,
|
|
397
396
|
baseDir,
|
|
@@ -404,10 +403,9 @@ export function createWorkManager({
|
|
|
404
403
|
agentPublished,
|
|
405
404
|
});
|
|
406
405
|
|
|
407
|
-
//
|
|
408
|
-
// only the agent lane knows which child that is.
|
|
406
|
+
// Retirement asks the agent lane which CLI to stop.
|
|
409
407
|
const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
|
|
410
|
-
repoRoot,
|
|
408
|
+
repositoryPlaces, repoRoot,
|
|
411
409
|
baseDir,
|
|
412
410
|
baseRef,
|
|
413
411
|
placeOf,
|
|
@@ -485,7 +483,7 @@ export function createWorkManager({
|
|
|
485
483
|
liveTurnCount,
|
|
486
484
|
processAgentPlanJobs,
|
|
487
485
|
processIntakeJobs,
|
|
488
|
-
processAgentTurnJobs, interruptAgentTurns,
|
|
486
|
+
processAgentTurnJobs, processAgentRecoveryJobs, interruptAgentTurns,
|
|
489
487
|
// Only the roster's stand-down calls this — a displacement, a removal or a
|
|
490
488
|
// commanded stop (holder.mjs, `standDownDisplaced`); see its comment.
|
|
491
489
|
// 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) => {
|
|
172
|
+
const cmd = 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,11 @@
|
|
|
1
|
+
import { mergeRepositoryPlace } from './repositoryMerge.mjs';
|
|
2
|
+
import { fleetRequest } from './fleetPost.mjs';
|
|
1
3
|
/** Agent merge claims, pull-request approval, and branch cleanup. */
|
|
2
4
|
import { ensureOpenPr, ghReady, mergeAndVerifyTip, PR_URL_RE, relayedFailure } from './prWorkflow.mjs';
|
|
3
5
|
import { existsSync } from 'node:fs';
|
|
4
6
|
import { execFileSync } from 'node:child_process';
|
|
5
7
|
import { join } from 'node:path';
|
|
6
|
-
import { FLEET_URL } from './config.mjs';
|
|
8
|
+
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
7
9
|
import { git, gitOrNull, gitNet as gitNetIn, baseBranchName, isSafePathSegment, sessionBranch, stripUrlUserinfo } from './git.mjs';
|
|
8
10
|
import { isPublishRef, publishPushArgs, publishDeleteArgs, publishErrorText } from './agentPublish.mjs';
|
|
9
11
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
@@ -30,6 +32,7 @@ export function agentMergeFailureDetail(error) {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export function createWorkAgentMerges({
|
|
35
|
+
repositoryPlaces,
|
|
33
36
|
repoRoot,
|
|
34
37
|
inPlace,
|
|
35
38
|
baseDir,
|
|
@@ -111,7 +114,7 @@ export function createWorkAgentMerges({
|
|
|
111
114
|
const report = async (body) => {
|
|
112
115
|
reported = true;
|
|
113
116
|
if (body?.ok === true) landedRef = job.publishedRef ?? null;
|
|
114
|
-
await postAgentMerge(body);
|
|
117
|
+
await postAgentMerge({ ...body, ...(body.detail ? { detail: envScrub(stripUrlUserinfo(String(body.detail))).slice(0, 2000) } : {}) });
|
|
115
118
|
};
|
|
116
119
|
|
|
117
120
|
// A WRITER on the place, exactly as a ship is. It folds base in and pushes
|
|
@@ -119,6 +122,20 @@ export function createWorkAgentMerges({
|
|
|
119
122
|
// does not know exists.
|
|
120
123
|
try {
|
|
121
124
|
await inPlace(place, true, async () => {
|
|
125
|
+
if (job.repositoryMerge) {
|
|
126
|
+
if (!repositoryPlaces) throw new Error('Project repository worktrees are unavailable.');
|
|
127
|
+
const result = await mergeRepositoryPlace({ job, entries: repositoryPlaces.entries(place), baseDir,
|
|
128
|
+
check: (r) => runReviewEntry(agentId, r.wt, job.agentName, r),
|
|
129
|
+
progress: async (states, retry) => {
|
|
130
|
+
const response = await fleetRequest(fleetEndpoint('agent-merge-progress', FLEET_URL), {
|
|
131
|
+
body: { agentId, instance: DAEMON_INSTANCE, states, retry } });
|
|
132
|
+
if (response.error || response.json?.data?.ok !== true) throw new Error('Repository merge progress was not acknowledged. The next attempt will remeasure base.');
|
|
133
|
+
} });
|
|
134
|
+
if (result.retry) reported = true; // The lease was released; approval remains.
|
|
135
|
+
else await report({ agentId, ...result });
|
|
136
|
+
onRepoChanged();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
122
139
|
const wt = worktreePath(baseDir, place);
|
|
123
140
|
if (!existsSync(wt)) {
|
|
124
141
|
await report({ agentId, ok: false, detail: 'the worktree is gone' });
|
|
@@ -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
|
+
}
|