flowviant 0.125.13 → 0.125.16
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 +2 -1
- package/bin/cli.mjs +5 -0
- package/bin/lib/artifactReporter.mjs +40 -3
- package/bin/lib/artifacts.mjs +9 -4
- package/bin/lib/captureAuthority.mjs +82 -0
- package/bin/lib/claudePosture.mjs +9 -0
- package/bin/lib/hooks/orchestrator.mjs +1 -1
- package/bin/lib/intermediateArtifacts.mjs +13 -10
- package/bin/lib/mcpStdio.mjs +11 -3
- package/bin/lib/mergeBatch.mjs +52 -23
- package/bin/lib/nativeAgentTools.mjs +4 -1
- package/bin/lib/orchestrator.mjs +46 -20
- package/bin/lib/orchestratorClient.mjs +2 -2
- package/bin/lib/orchestratorPrompt.mjs +70 -0
- package/bin/lib/readOnlyRun.mjs +172 -0
- package/bin/lib/repositoryPrompts.mjs +1 -1
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeClaude.mjs +6 -5
- package/bin/lib/runtimeCodex.mjs +4 -4
- package/bin/lib/runtimeEvents.mjs +4 -2
- package/bin/lib/subagentBounds.mjs +1 -1
- package/bin/lib/trace.mjs +9 -1
- package/bin/lib/turnProfile.mjs +1 -0
- package/bin/lib/workAgentCheck.mjs +14 -10
- package/bin/lib/workAgentPlans.mjs +69 -43
- package/bin/lib/workAgentTurnExecution.mjs +3 -2
- package/bin/lib/workAgentTurnOutcome.mjs +1 -1
- package/bin/lib/workCappedTurn.mjs +4 -0
- package/bin/lib/workMergeBatch.mjs +4 -0
- package/bin/lib/workNarration.mjs +3 -2
- package/bin/lib/workPlanRecovery.mjs +2 -21
- package/bin/lib/workSessionTurns.mjs +109 -22
- package/bin/lib/workToolLog.mjs +3 -0
- package/package.json +1 -1
|
@@ -120,6 +120,19 @@ export function checkEnv(env = process.env, browserHome = null) {
|
|
|
120
120
|
* child whose 'close' never comes, which a real process only shows with a
|
|
121
121
|
* survivor outside its group holding the pipe.
|
|
122
122
|
*/
|
|
123
|
+
/** The repository's declared check, `.flowviant/check.json`'s `command` —
|
|
124
|
+
* read once here for the check and the batch merge's coordinator alike. */
|
|
125
|
+
export function readConfiguredCheck(root) {
|
|
126
|
+
try {
|
|
127
|
+
const raw = readFileSync(join(root, '.flowviant', 'check.json'), 'utf8');
|
|
128
|
+
const cfg = JSON.parse(raw);
|
|
129
|
+
const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
|
|
130
|
+
return cmd ? cmd.slice(0, 500) : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null; // absent, unreadable or not JSON — all mean "no check"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
export function createWorkAgentCheck({
|
|
124
137
|
repoRoot,
|
|
125
138
|
postBestEffort,
|
|
@@ -150,16 +163,7 @@ export function createWorkAgentCheck({
|
|
|
150
163
|
const CHECK_TIMEOUT_MS = 10 * 60_000;
|
|
151
164
|
const CHECK_OUTPUT_CAP = 4000;
|
|
152
165
|
|
|
153
|
-
const readCheckCommand = (root = repoRoot) =>
|
|
154
|
-
try {
|
|
155
|
-
const raw = readFileSync(join(root, '.flowviant', 'check.json'), 'utf8');
|
|
156
|
-
const cfg = JSON.parse(raw);
|
|
157
|
-
const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
|
|
158
|
-
return cmd ? cmd.slice(0, 500) : null;
|
|
159
|
-
} catch {
|
|
160
|
-
return null; // absent, unreadable or not JSON — all mean "no check"
|
|
161
|
-
}
|
|
162
|
-
};
|
|
166
|
+
const readCheckCommand = (root = repoRoot) => readConfiguredCheck(root);
|
|
163
167
|
|
|
164
168
|
const postCheck = async (body) => {
|
|
165
169
|
// Same bounded retry as postAgentPlan — see postBestEffort. On the
|
|
@@ -6,7 +6,7 @@ import { discoveryGuide } from './discoveryContract.mjs';
|
|
|
6
6
|
import { compareRepositoryBaselines, measureRepositoryBaseline, repositoryBaselineOf } from './repositoryBaseline.mjs';
|
|
7
7
|
import { knowledgeDirFor } from './knowledgeFiles.mjs';
|
|
8
8
|
import { createToolLog } from './workToolLog.mjs';
|
|
9
|
-
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
9
|
+
import { FLEET_URL, DAEMON_INSTANCE, MODEL } from './config.mjs';
|
|
10
10
|
import { measureAgentFootprint, liveAgentContext } from './agentFootprint.mjs';
|
|
11
11
|
import { parseProposal } from './agentPlan.mjs';
|
|
12
12
|
import { c, note } from './ui.mjs';
|
|
@@ -14,9 +14,11 @@ import { runTurn as defaultRunTurn } from './runTurn.mjs';
|
|
|
14
14
|
import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF, withProjectContext, REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
|
|
15
15
|
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
16
16
|
import { canRun, runtimeById, runtimeLabel } from './runtimes.mjs';
|
|
17
|
-
import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
|
|
17
|
+
import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor, measuredRuntimeVersion } from './runtimeDetection.mjs';
|
|
18
18
|
import { brainFor } from './workBrain.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { runReadOnlyConsult } from './readOnlyRun.mjs';
|
|
20
|
+
import { planningOrchestratorContract } from './orchestratorPrompt.mjs';
|
|
21
|
+
import { knownCodexModels } from './runtimeOfferings.mjs';
|
|
20
22
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
21
23
|
import { claimFleetLease, fleetHeaders } from './fleetPost.mjs';
|
|
22
24
|
import { REPO_PLACE } from './workPlaces.mjs';
|
|
@@ -37,6 +39,8 @@ export function createWorkAgentPlans({
|
|
|
37
39
|
pickRuntimeFor = defaultPickRuntimeFor,
|
|
38
40
|
detectRuntimes = defaultDetectRuntimes,
|
|
39
41
|
authStatus = readClaudeAuthStatus,
|
|
42
|
+
runtimeVersion = id => measuredRuntimeVersion(id)?.version,
|
|
43
|
+
runConsult = runReadOnlyConsult,
|
|
40
44
|
}) {
|
|
41
45
|
const AGENT_PLAN_DONE_URL = fleetEndpoint('agent-plan-done', FLEET_URL);
|
|
42
46
|
const AGENT_PLAN_ACTIVITY_URL = fleetEndpoint('agent-plan-activity', FLEET_URL);
|
|
@@ -55,10 +59,10 @@ export function createWorkAgentPlans({
|
|
|
55
59
|
// the CLI path-validates itself, and the `git` readers, which it does not —
|
|
56
60
|
// their path fence is the read guard's refusal (hooks/readGuard.mjs), of
|
|
57
61
|
// `--no-index` and of any git argument aimed off the checkout — and no
|
|
58
|
-
// Write, no Edit, no mkdir, no rm)
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// than through a tool, which
|
|
62
|
+
// Write, no Edit, no mkdir, no rm). Compatible held attempts may receive
|
|
63
|
+
// only a local read-only coordination capability. No board/staging token,
|
|
64
|
+
// project/personal server or extra network access is supplied. The proposal
|
|
65
|
+
// comes back as the turn's final message rather than through a tool, which lets that permission set be
|
|
62
66
|
// this narrow.
|
|
63
67
|
//
|
|
64
68
|
// It takes the checkout's place lock as a READER, beside the operator's own
|
|
@@ -287,49 +291,71 @@ export function createWorkAgentPlans({
|
|
|
287
291
|
discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
|
|
288
292
|
discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
|
|
289
293
|
discovery.toolLog.reconciliation = compareRepositoryBaselines(discovery.toolLog.baseline, discovery.toolLog.captureBaseline);
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
294
|
+
const brain = brainFor(job, rt);
|
|
295
|
+
let consults = 0;
|
|
296
|
+
const consult = async (prompt, system, diagnosing = false) => {
|
|
297
|
+
let rootSlot = releaseSlot;
|
|
298
|
+
if (consults++ > 0) {
|
|
299
|
+
// Diagnosis ended its CLI and released its children. A fresh
|
|
300
|
+
// paid consult must ask the pool again, like every other root.
|
|
301
|
+
while (admit('churn')) {
|
|
302
|
+
if (control?.signal.aborted) return { out: '', wedged: false };
|
|
303
|
+
await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
304
|
+
}
|
|
305
|
+
rootSlot = admit.reserve();
|
|
306
|
+
}
|
|
307
|
+
return await runConsult({
|
|
308
|
+
enabled: job.readOnlyOrchestrationVersion === 1 && job.recoveryEnabled === true,
|
|
309
|
+
identity: { scope: 'plan', id, attempt: job.attempt },
|
|
310
|
+
version: runtimeVersion(rt),
|
|
311
|
+
admit,
|
|
312
|
+
children: { ...brain, model: brain.model ?? (rt === 'claude' ? MODEL : null),
|
|
313
|
+
...(rt === 'codex' ? { catalog: knownCodexModels() } : {}) },
|
|
314
|
+
contract: planningOrchestratorContract({ diagnosis: diagnosing, runtime: rt, ...brain, model: brain.model ?? (rt === 'claude' ? MODEL : null) }),
|
|
315
|
+
onUnavailable: error => say(`Native investigation coordination unavailable: ${error.message}`),
|
|
316
|
+
runTurn,
|
|
317
|
+
signal: control?.signal,
|
|
318
|
+
capMs: PLAN_TURN_TIMEOUT_MS,
|
|
319
|
+
workChildren,
|
|
320
|
+
releaseSlot: rootSlot,
|
|
321
|
+
transcriptCwd: repoRoot,
|
|
322
|
+
turnArgs: {
|
|
323
|
+
diagnosticPlanId: id,
|
|
324
|
+
prompt,
|
|
325
|
+
system,
|
|
326
|
+
knowledgeDir,
|
|
327
|
+
// The consult fence stays intact. runReadOnlyConsult may mount
|
|
328
|
+
// only the attempt's local coordination tool, never a board token.
|
|
329
|
+
profile: 'consult',
|
|
330
|
+
cwd: repoRoot,
|
|
331
|
+
runtime: rt,
|
|
332
|
+
...brain,
|
|
333
|
+
answerFromResult: true,
|
|
334
|
+
work: planWork(job),
|
|
335
|
+
// The CLI's own tail, forwarded to the press. Same rule as an agent
|
|
336
|
+
// turn's: throttled, overwritten rather than appended, and never
|
|
337
|
+
// awaited by the turn.
|
|
338
|
+
onActivity: (a) => {
|
|
339
|
+
if (a?.label) narrate(String(a.label));
|
|
340
|
+
},
|
|
341
|
+
onToolEvent: discovery.pushToolEvent,
|
|
342
|
+
// Tagged with the CLI that counted it; one turn, so the sum is the
|
|
343
|
+
// one report (turnUsage.mjs keeps it null-safe).
|
|
344
|
+
onUsage: (u) => {
|
|
345
|
+
usage = addUsage(usage, { ...u, runtime: rt });
|
|
346
|
+
},
|
|
315
347
|
},
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// one report (turnUsage.mjs keeps it null-safe).
|
|
319
|
-
onUsage: (u) => {
|
|
320
|
-
usage = addUsage(usage, { ...u, runtime: rt });
|
|
348
|
+
onSpawn: () => {
|
|
349
|
+
say(`${runtimeLabel(rt)} started on this machine`);
|
|
321
350
|
},
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
say(`${runtimeLabel(rt)} started on this machine`);
|
|
325
|
-
},
|
|
326
|
-
});
|
|
351
|
+
});
|
|
352
|
+
};
|
|
327
353
|
let context = AGENT_PLAN_KICKOFF({ tasks, liveAgents, agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3 });
|
|
328
354
|
if (job.recovery?.start) context += '\nSTART REPAIR: This is a supporting diagnosis for an already approved Start. Return exactly ONE group with the same ordered taskIds, attachment target (or absence) and CLI. Preserve the approval; do not start an unapproved sibling. Diagnose admission/pickup errors and repair planning evidence/context. Ask only a genuine person-only question in the diagnostic JSON. Approval and source revision (data): ' + JSON.stringify(job.recovery.start);
|
|
329
355
|
const system = withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation);
|
|
330
356
|
if (job.recovery) {
|
|
331
357
|
say(`Diagnosing the failed Plan: ${job.recovery.reason}`);
|
|
332
|
-
const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'));
|
|
358
|
+
const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'), true);
|
|
333
359
|
if (control?.signal.aborted) return;
|
|
334
360
|
if (diagnostic.wedged) { wedged = true; return; }
|
|
335
361
|
const result = parsePlanDiagnosis(diagnostic.out);
|
|
@@ -754,7 +754,8 @@ export function createAgentTurnExecution({
|
|
|
754
754
|
throw error;
|
|
755
755
|
}
|
|
756
756
|
}
|
|
757
|
-
await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId,
|
|
757
|
+
await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId,
|
|
758
|
+
requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs });
|
|
758
759
|
}
|
|
759
760
|
|
|
760
761
|
|
|
@@ -784,7 +785,7 @@ export function createAgentTurnExecution({
|
|
|
784
785
|
usage,
|
|
785
786
|
model,
|
|
786
787
|
commits,
|
|
787
|
-
artifactsBefore,
|
|
788
|
+
artifactsBefore: intermediateDelivery?.uploadBefore ?? artifactsBefore,
|
|
788
789
|
branch,
|
|
789
790
|
wt,
|
|
790
791
|
modelExports: intermediateDelivery?.modelExports,
|
|
@@ -139,7 +139,7 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
|
|
|
139
139
|
const want = kind.artifact.match;
|
|
140
140
|
const has = (l) => l.some((e) => want.test(e.name)
|
|
141
141
|
&& (kind.posture !== 'image' || usableImageArtifact(e)));
|
|
142
|
-
const standing = scanArtifacts(wt);
|
|
142
|
+
const standing = scanArtifacts(wt, { all: true });
|
|
143
143
|
const wrote = has(changedArtifacts(artifactsBefore, standing));
|
|
144
144
|
const exported = kind.exportModel && measuredModelExports(wt, modelExports);
|
|
145
145
|
const present = wrote || exported || ((job.kind !== 'task' || job.redo === true) && has(standing));
|
|
@@ -73,6 +73,7 @@ export async function runCappedTurn({
|
|
|
73
73
|
let timer = null;
|
|
74
74
|
let wedged = false;
|
|
75
75
|
let sessionId = null;
|
|
76
|
+
let finished = false;
|
|
76
77
|
let stopWaiting = () => {};
|
|
77
78
|
const capped = new Promise((resolve) => {
|
|
78
79
|
stopWaiting = resolve;
|
|
@@ -88,10 +89,12 @@ export async function runCappedTurn({
|
|
|
88
89
|
const turn = runTurn({
|
|
89
90
|
...turnArgs,
|
|
90
91
|
onInit: (i) => {
|
|
92
|
+
if (finished) return;
|
|
91
93
|
if (typeof i?.sessionId === 'string' && i.sessionId.trim()) sessionId = i.sessionId.trim();
|
|
92
94
|
onInit(i);
|
|
93
95
|
},
|
|
94
96
|
onSpawn: (ch) => {
|
|
97
|
+
if (finished) { try { if (killChild) killChild(ch); else ch?.kill('SIGKILL'); } catch { /* already gone */ } return; }
|
|
95
98
|
child = ch;
|
|
96
99
|
workChildren.set(ch, null);
|
|
97
100
|
releaseSlot();
|
|
@@ -112,6 +115,7 @@ export async function runCappedTurn({
|
|
|
112
115
|
const out = await Promise.race([turn, capped]);
|
|
113
116
|
return { out, wedged, spawned: child !== null, sessionId };
|
|
114
117
|
} finally {
|
|
118
|
+
finished = true;
|
|
115
119
|
signal?.removeEventListener('abort', cancel);
|
|
116
120
|
if (timer) clearTimeout(timer);
|
|
117
121
|
if (child) workChildren.delete(child);
|
|
@@ -18,6 +18,7 @@ import { scrub } from './uplinkScrub.mjs';
|
|
|
18
18
|
import { relayedText } from './prWorkflow.mjs';
|
|
19
19
|
import { isSafePathSegment, stripUrlUserinfo } from './git.mjs';
|
|
20
20
|
import { killGroup } from './processGroup.mjs';
|
|
21
|
+
import { readConfiguredCheck } from './workAgentCheck.mjs';
|
|
21
22
|
|
|
22
23
|
export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPlace, runCheck, workChildren, groupKillChildren, admit, onRepoChanged, landed, runCli = runTurn }) {
|
|
23
24
|
const request = async (route, body) => {
|
|
@@ -48,6 +49,7 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
|
|
|
48
49
|
integrated = await integrateMergeBatch({ spec, progress: claim.progress ?? job.batch.progress, previousError: claim.previousError ?? job.batch.previousError, baseDir,
|
|
49
50
|
rootFor: (id) => repositoryPlaces?.rootFor?.(id) ?? (spec.repositories.length === 1 ? repoRoot : null),
|
|
50
51
|
setup: trackedRepositorySetup(workChildren, groupKillChildren),
|
|
52
|
+
checkCommand: (r) => readConfiguredCheck(r.wt),
|
|
51
53
|
entries: (member) => {
|
|
52
54
|
const rows = repositoryPlaces?.entries(member.placeId) ?? [];
|
|
53
55
|
if (rows.length) return rows;
|
|
@@ -81,6 +83,8 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
|
|
|
81
83
|
// Run configured checks too; a coordinator cannot replace them with a weaker command.
|
|
82
84
|
const configured = await runCheck(leader.agentId, r.wt, r);
|
|
83
85
|
if (!['passed', 'none'].includes(configured?.status)) throw new Error(`${r.name}: the configured combined check failed or could not run.\n${configured?.text || ''}`);
|
|
86
|
+
// The same command twice is one validation (2026-10-22).
|
|
87
|
+
if (r.command.trim() === readConfiguredCheck(r.wt)) return;
|
|
84
88
|
const measured = await runCheck(leader.agentId, r.wt, r, r.command);
|
|
85
89
|
if (measured?.status !== 'passed') throw new Error(`${r.name}: combined validation failed. Retry the batch to resolve it.\n${measured?.text || ''}`);
|
|
86
90
|
},
|
|
@@ -35,7 +35,7 @@ export function createWorkNarration() {
|
|
|
35
35
|
* line over the finished reply — the server drops narration for a turn
|
|
36
36
|
* that is no longer pending. (A session-level pending count can't tell the
|
|
37
37
|
* settled turn's stale line from the queued NEXT turn's fresh one.) */
|
|
38
|
-
const makeNarrator = (sessionId, turnId, getTools) => {
|
|
38
|
+
const makeNarrator = (sessionId, turnId, getTools, identity = {}) => {
|
|
39
39
|
const recent = [];
|
|
40
40
|
let lastSent = 0;
|
|
41
41
|
let dirty = false;
|
|
@@ -60,6 +60,7 @@ export function createWorkNarration() {
|
|
|
60
60
|
body: JSON.stringify({
|
|
61
61
|
sessionId,
|
|
62
62
|
turnId,
|
|
63
|
+
...identity,
|
|
63
64
|
lines,
|
|
64
65
|
// The structured tool log so far, riding the same throttled beat.
|
|
65
66
|
// Same lifecycle as the lines: overwritten as the turn moves,
|
|
@@ -101,7 +102,7 @@ export function createWorkNarration() {
|
|
|
101
102
|
'Content-Type': 'application/json',
|
|
102
103
|
},
|
|
103
104
|
signal: AbortSignal.timeout(10_000),
|
|
104
|
-
body: JSON.stringify({ sessionId, turnId, lines: [], context }),
|
|
105
|
+
body: JSON.stringify({ sessionId, turnId, ...identity, lines: [], context }),
|
|
105
106
|
});
|
|
106
107
|
} catch {
|
|
107
108
|
/* a readout is decoration, like the line */
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/** The person's own CLI diagnoses planning, under the ordinary consult fence. */
|
|
2
2
|
import { candidateObjects } from './modelJson.mjs';
|
|
3
3
|
import { scrub } from './uplinkScrub.mjs';
|
|
4
|
-
|
|
5
|
-
import { DAEMON_INSTANCE } from './config.mjs';
|
|
4
|
+
export { watchReadOnlyAttempt as watchPlanAttempt } from './readOnlyRun.mjs';
|
|
6
5
|
|
|
7
6
|
export const PLAN_RECOVERY_SYSTEM = `You are recovering the person's retained Plan press.
|
|
8
7
|
Read the original selection, failure, retained evidence, answers and repository.
|
|
9
8
|
Establish the cause before claiming a repair. Diagnose/debug the planning failure
|
|
10
9
|
with the available read-only tools. Repair the planning approach, context or output
|
|
11
|
-
instructions. Do not change repository files, start
|
|
10
|
+
instructions. Do not change repository files, start Board work or approve work.
|
|
12
11
|
Retry count is never a reason to ask the person to click Plan again.
|
|
13
12
|
Only ask for an answer, credential action or changed-scope decision that reading
|
|
14
13
|
and diagnosis cannot supply. Ask one specific question and explain what it settles.
|
|
@@ -30,21 +29,3 @@ export function parsePlanDiagnosis(out) {
|
|
|
30
29
|
}
|
|
31
30
|
return null;
|
|
32
31
|
}
|
|
33
|
-
|
|
34
|
-
/** Unknown transport state never means Stop. Only an answered holder read does. */
|
|
35
|
-
export async function watchPlanAttempt(url, job) {
|
|
36
|
-
const controller = new AbortController();
|
|
37
|
-
let busy = false;
|
|
38
|
-
const check = async () => {
|
|
39
|
-
if (busy) return;
|
|
40
|
-
busy = true;
|
|
41
|
-
try {
|
|
42
|
-
const r = await fleetRequest(url, { body: { id: job.id, attempt: job.attempt, instance: DAEMON_INSTANCE }, timeoutMs: 15_000 });
|
|
43
|
-
if (r.ok && r.json?.data?.active === false) controller.abort();
|
|
44
|
-
} finally { busy = false; }
|
|
45
|
-
};
|
|
46
|
-
await check();
|
|
47
|
-
const timer = setInterval(check, 5_000);
|
|
48
|
-
timer.unref?.();
|
|
49
|
-
return { signal: controller.signal, check, stop: () => clearInterval(timer) };
|
|
50
|
-
}
|
|
@@ -46,6 +46,15 @@ import { sessionTurnKickoff, sessionTurnShape, sessionTurnSystem } from './workT
|
|
|
46
46
|
import { createToolLog } from './workToolLog.mjs';
|
|
47
47
|
import { createCommandAudit } from './workCommandAudit.mjs';
|
|
48
48
|
import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, withRelay } from './codexRelay.mjs';
|
|
49
|
+
import { createReadOnlyCoordination, readOnlyCoordinationSafe, watchReadOnlyAttempt } from './readOnlyRun.mjs';
|
|
50
|
+
import { createCaptureAuthority } from './captureAuthority.mjs';
|
|
51
|
+
import { captureOrchestratorContract } from './orchestratorPrompt.mjs';
|
|
52
|
+
import { runCappedTurn } from './workCappedTurn.mjs';
|
|
53
|
+
import { measuredRuntimeVersion } from './runtimeDetection.mjs';
|
|
54
|
+
import { knownCodexModels } from './runtimeOfferings.mjs';
|
|
55
|
+
import { FLEET_URL, DAEMON_INSTANCE, MODEL } from './config.mjs';
|
|
56
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
57
|
+
const defaultWatchCapture = job => watchReadOnlyAttempt(fleetEndpoint('capture-run-state', FLEET_URL), job);
|
|
49
58
|
|
|
50
59
|
/**
|
|
51
60
|
* HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
|
|
@@ -91,6 +100,9 @@ export function createWorkSessionTurns({
|
|
|
91
100
|
// The real CLI driver; injectable only so the lane can be driven in a test
|
|
92
101
|
// without spending a model turn (the wiki runner's shape).
|
|
93
102
|
runTurn = defaultRunTurn,
|
|
103
|
+
runtimeVersion = id => measuredRuntimeVersion(id)?.version,
|
|
104
|
+
coordinate = createReadOnlyCoordination,
|
|
105
|
+
watchCapture = defaultWatchCapture,
|
|
94
106
|
// The clock `settledHere` ages by; injectable only for its bound's test.
|
|
95
107
|
now = Date.now,
|
|
96
108
|
makeLiveMessages = createLiveMessages,
|
|
@@ -140,14 +152,17 @@ export function createWorkSessionTurns({
|
|
|
140
152
|
* be delivered by `pendingWorkReports`, and this covers the one that was.
|
|
141
153
|
*/
|
|
142
154
|
const settledHere = new Map(); // turnId -> when this lane settled it, oldest first
|
|
155
|
+
const settledAttempts = new Map(); // only measured execution generations
|
|
143
156
|
const forgetStale = (at) => {
|
|
144
157
|
for (const [id, when] of settledHere) {
|
|
145
158
|
if (settledHere.size <= SETTLED_HERE_MAX && at - when < SETTLED_HERE_TTL_MS) break;
|
|
146
159
|
settledHere.delete(id);
|
|
160
|
+
settledAttempts.delete(id);
|
|
147
161
|
}
|
|
148
162
|
};
|
|
149
|
-
const settledRecently = (turnId) => {
|
|
163
|
+
const settledRecently = (turnId, attempt) => {
|
|
150
164
|
forgetStale(now());
|
|
165
|
+
if (Number.isInteger(attempt) && settledAttempts.has(turnId) && settledAttempts.get(turnId) !== attempt) return false;
|
|
151
166
|
return settledHere.has(turnId);
|
|
152
167
|
};
|
|
153
168
|
const settleTurn = (turnId, payload) => {
|
|
@@ -156,6 +171,7 @@ export function createWorkSessionTurns({
|
|
|
156
171
|
const at = now();
|
|
157
172
|
settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
|
|
158
173
|
settledHere.set(turnId, at);
|
|
174
|
+
if (Number.isInteger(run?.job?.attempt)) settledAttempts.set(turnId, run.job.attempt);
|
|
159
175
|
forgetStale(at);
|
|
160
176
|
const liveMessages = runs.get(turnId)?.live?.receipts();
|
|
161
177
|
return settleWorkTurn(turnId, { ...payload, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
|
|
@@ -185,13 +201,18 @@ export function createWorkSessionTurns({
|
|
|
185
201
|
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
186
202
|
if (rosterInterrupts.has(job.id)) continue;
|
|
187
203
|
if (workAnswering.has(job.id)) {
|
|
188
|
-
const
|
|
204
|
+
const previous = runs.get(job.id);
|
|
205
|
+
if (job.captureOrchestrationVersion === 1 && Number.isInteger(job.attempt) && Number.isInteger(previous?.job.attempt) && job.attempt !== previous.job.attempt) {
|
|
206
|
+
previous.interrupted = true; previous.abort.abort();
|
|
207
|
+
for (const child of previous.spawned) interruptThenKill(child);
|
|
208
|
+
}
|
|
209
|
+
const reason = previous?.waitingReason;
|
|
189
210
|
if (reason) reportDelivery('session', job, 'queued', reason);
|
|
190
211
|
continue;
|
|
191
212
|
}
|
|
192
213
|
// Settled by this lane already: a roster read from before the settle
|
|
193
214
|
// landed. See `settledHere`.
|
|
194
|
-
if (settledRecently(job.id)) continue;
|
|
215
|
+
if (settledRecently(job.id, job.attempt)) continue;
|
|
195
216
|
// The turn already RAN and its answer sits in the delivery queue — never
|
|
196
217
|
// run it again while the report is merely undelivered.
|
|
197
218
|
if (pendingWorkReports.has(job.id)) continue;
|
|
@@ -225,9 +246,10 @@ export function createWorkSessionTurns({
|
|
|
225
246
|
continue;
|
|
226
247
|
}
|
|
227
248
|
const releaseSlot = admit.reserve();
|
|
249
|
+
if (Number.isInteger(job.attempt) && settledAttempts.has(job.id) && settledAttempts.get(job.id) !== job.attempt) workAttempts.delete(job.id);
|
|
228
250
|
lastDeferSaid.delete(job.sessionId);
|
|
229
251
|
workAnswering.add(job.id);
|
|
230
|
-
const run = { interrupted: false, spawned: [], sessionId: job.sessionId, live: null, job };
|
|
252
|
+
const run = { interrupted: false, spawned: [], sessionId: job.sessionId, live: null, job, abort: new AbortController() };
|
|
231
253
|
runs.set(job.id, run);
|
|
232
254
|
/** The execution generation the server handed, echoed on every settle
|
|
233
255
|
* so the server can tell this execution's answer from an older one's. */
|
|
@@ -644,17 +666,29 @@ export function createWorkSessionTurns({
|
|
|
644
666
|
// without one — see workTurnShape.mjs). A plan turn's credential is
|
|
645
667
|
// still minted above: its lease answers (held elsewhere, gone) are
|
|
646
668
|
// about the SESSION, and a plan turn needs them as much as any other.
|
|
647
|
-
|
|
648
|
-
? mcpFor(rt.id, mint.token, getMcpUrl())
|
|
649
|
-
: { args: [], env: null, dir: null };
|
|
669
|
+
let mcp = { args: [], env: null, dir: null };
|
|
650
670
|
// The tab's model/effort, if it named any. Spread into turnArgs so
|
|
651
671
|
// BOTH runTurn calls below carry it — the retry is the same turn on
|
|
652
672
|
// the same brain, not a quieter second opinion.
|
|
653
673
|
const brain = brainFor(job, rt.id);
|
|
674
|
+
const childBrain = { ...brain, runtime: rt.id, model: brain.model ?? (rt.id === 'claude' ? MODEL : null),
|
|
675
|
+
...(rt.id === 'codex' ? { catalog: knownCodexModels() } : {}) };
|
|
676
|
+
const version = shape.capture && job.captureOrchestrationVersion === 1 ? runtimeVersion(rt.id) : null;
|
|
677
|
+
const delegated = shape.capture && job.captureOrchestrationVersion === 1 && Number.isSafeInteger(job.attempt)
|
|
678
|
+
&& job.attempt >= 0 && readOnlyCoordinationSafe(rt.id, version);
|
|
679
|
+
let coordination = null, control = null;
|
|
680
|
+
let captureSignal = run.abort.signal;
|
|
681
|
+
const makeCoordination = () => coordinate({ enabled: true, runtime: rt.id, version, admit,
|
|
682
|
+
identity: { scope: 'capture', id: job.id, attempt: job.attempt }, signal: captureSignal,
|
|
683
|
+
children: childBrain,
|
|
684
|
+
rootTools: createCaptureAuthority({ url: getMcpUrl(), token: mint.token, sessionId: job.sessionId,
|
|
685
|
+
turnId: job.id, attempt: job.attempt, signal: captureSignal }),
|
|
686
|
+
});
|
|
654
687
|
// Attempts count RUNS: the infra refusals above consumed nothing and
|
|
655
688
|
// settled on their own terms.
|
|
656
689
|
workAttempts.set(job.id, tries + 1);
|
|
657
690
|
let out;
|
|
691
|
+
let turnReturned = false;
|
|
658
692
|
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
659
693
|
let seenClaudeSession = null; // claude's own conversation id, off system.init
|
|
660
694
|
// What a Codex turn's own events say went wrong (codexRelay.mjs):
|
|
@@ -678,9 +712,8 @@ export function createWorkSessionTurns({
|
|
|
678
712
|
const turnLog = createToolLog(dir.wt);
|
|
679
713
|
toolLog = turnLog.toolLog;
|
|
680
714
|
const { pushToolEvent } = turnLog;
|
|
681
|
-
const narrator = makeNarrator(job.sessionId, job.id, () =>
|
|
682
|
-
|
|
683
|
-
);
|
|
715
|
+
const narrator = makeNarrator(job.sessionId, job.id, () => toolLog,
|
|
716
|
+
delegated ? { attempt: job.attempt, instance: DAEMON_INSTANCE } : {});
|
|
684
717
|
|
|
685
718
|
|
|
686
719
|
// THE COMMAND AUDIT — see workCommandAudit.mjs.
|
|
@@ -691,6 +724,19 @@ export function createWorkSessionTurns({
|
|
|
691
724
|
cwd: dir.wt,
|
|
692
725
|
});
|
|
693
726
|
try {
|
|
727
|
+
if (delegated) {
|
|
728
|
+
control = await watchCapture(job);
|
|
729
|
+
captureSignal = AbortSignal.any([run.abort.signal, control.signal]);
|
|
730
|
+
const stopped = () => { run.interrupted = true; run.abort.abort(); };
|
|
731
|
+
if (captureSignal.aborted) stopped();
|
|
732
|
+
else control.signal.addEventListener('abort', stopped, { once: true });
|
|
733
|
+
if (!run.interrupted) coordination = await makeCoordination();
|
|
734
|
+
}
|
|
735
|
+
// A safe native gate replaces the credential mount, keeping the
|
|
736
|
+
// actual capture credential entirely outside the CLI's children.
|
|
737
|
+
// Unsupported/gate-unavailable turns retain the original solo path.
|
|
738
|
+
if (!coordination && shape.mountMcp) mcp = mcpFor(rt.id, mint.token, getMcpUrl());
|
|
739
|
+
const captureContract = coordination ? captureOrchestratorContract(childBrain) : '';
|
|
694
740
|
// Files first, then the message that references them: the agent
|
|
695
741
|
// must be able to open what it is being told about. Only the ones
|
|
696
742
|
// that actually landed are named.
|
|
@@ -750,7 +796,7 @@ export function createWorkSessionTurns({
|
|
|
750
796
|
knowledgeDir,
|
|
751
797
|
artifactsAccepted: getArtifactsAccepted(),
|
|
752
798
|
runtime: rt.id,
|
|
753
|
-
}) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : '');
|
|
799
|
+
}) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : '') + (captureContract ? `\n\n${captureContract}` : '');
|
|
754
800
|
contractSent = !codexContractKnown(contractPath, codexResumeId, turnSystem);
|
|
755
801
|
return contractSent ? turnSystem : '';
|
|
756
802
|
})(),
|
|
@@ -862,7 +908,43 @@ export function createWorkSessionTurns({
|
|
|
862
908
|
}
|
|
863
909
|
},
|
|
864
910
|
};
|
|
865
|
-
|
|
911
|
+
const invoke = async (args, retry = false) => {
|
|
912
|
+
if (run.interrupted) return '';
|
|
913
|
+
if (!captureContract) return runCli(args);
|
|
914
|
+
let releaseRetry = () => {};
|
|
915
|
+
if (retry) {
|
|
916
|
+
// The prior process no longer counts. Reserve its replacement
|
|
917
|
+
// against the SAME pool before awaiting a new local gate.
|
|
918
|
+
while (!run.interrupted) {
|
|
919
|
+
const hold = admit('interactive');
|
|
920
|
+
if (!hold) { releaseRetry = admit.reserve(); break; }
|
|
921
|
+
narrator.line(hold.reason);
|
|
922
|
+
await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
if (retry && !run.interrupted) coordination = await makeCoordination();
|
|
927
|
+
if (run.interrupted) return '';
|
|
928
|
+
if (!coordination) {
|
|
929
|
+
if (!mcp.env) mcp = mcpFor(rt.id, mint.token, getMcpUrl());
|
|
930
|
+
return await runCli({ ...args, mcpArgs: mcp.args, mcpEnv: mcp.env,
|
|
931
|
+
onSpawn: ch => { releaseRetry(); args.onSpawn?.(ch); } });
|
|
932
|
+
}
|
|
933
|
+
const { onSpawn, onInit, ...turnArgs } = coordination.wrap({ ...args, mcpArgs: [], mcpEnv: null });
|
|
934
|
+
const result = await runCappedTurn({ runTurn: runCli, turnArgs, capMs: 15 * 60_000, workChildren,
|
|
935
|
+
releaseSlot: () => { releaseSlot(); releaseRetry(); }, onSpawn, onInit,
|
|
936
|
+
signal: captureSignal, killChild: coordination.kill,
|
|
937
|
+
// This is the ongoing conversation, never a scratch consult.
|
|
938
|
+
removeTranscript: () => {},
|
|
939
|
+
});
|
|
940
|
+
if (result.wedged && !run.interrupted) { cliFailed = true; return 'The capture turn timed out. Continue in this conversation; read list_staged before changing any proposals.'; }
|
|
941
|
+
return envScrub(result.out);
|
|
942
|
+
} finally {
|
|
943
|
+
releaseRetry();
|
|
944
|
+
await coordination?.close(run.interrupted); coordination = null;
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
out = await invoke({
|
|
866
948
|
...turnArgs,
|
|
867
949
|
resume,
|
|
868
950
|
resumeThreadId: codexResumeId || claudeResumeId || undefined,
|
|
@@ -913,9 +995,15 @@ export function createWorkSessionTurns({
|
|
|
913
995
|
cliFailed = false;
|
|
914
996
|
badExit = null;
|
|
915
997
|
contractSent = true;
|
|
916
|
-
out = await
|
|
998
|
+
out = await invoke({ ...turnArgs, resume: false, system: turnSystem }, true);
|
|
917
999
|
}
|
|
1000
|
+
turnReturned = true;
|
|
918
1001
|
} finally {
|
|
1002
|
+
await coordination?.close(run.interrupted);
|
|
1003
|
+
control?.stop();
|
|
1004
|
+
// The cap can resolve before a wedged provider closes stdout.
|
|
1005
|
+
// Its live-input relay must not keep taking the next prompt.
|
|
1006
|
+
if (delegated) await run.live?.stop();
|
|
919
1007
|
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
920
1008
|
// is cleared server-side at settle — clearing it here would race
|
|
921
1009
|
// the settle and blank the tab a beat before the reply lands.
|
|
@@ -930,15 +1018,13 @@ export function createWorkSessionTurns({
|
|
|
930
1018
|
}
|
|
931
1019
|
}
|
|
932
1020
|
if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
pinId(sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId), CODEX_THREAD_RE, seenThreadId);
|
|
941
|
-
recordCodexContract(contractPath, seenThreadId, turnSystem, contractSent);
|
|
1021
|
+
// Keep the exact native conversation on failures too. A later
|
|
1022
|
+
// capture continuation must reconcile its own staged rows, not
|
|
1023
|
+
// silently start a second conversation after a provider throws.
|
|
1024
|
+
if ((shape.capture || turnReturned) && rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
|
|
1025
|
+
pinId(sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId), CODEX_THREAD_RE, seenThreadId);
|
|
1026
|
+
recordCodexContract(contractPath, seenThreadId, turnSystem, contractSent);
|
|
1027
|
+
}
|
|
942
1028
|
}
|
|
943
1029
|
/**
|
|
944
1030
|
* A PERSON INTERRUPTED IT. Whatever the CLI printed was cut off, so
|
|
@@ -1116,6 +1202,7 @@ export function createWorkSessionTurns({
|
|
|
1116
1202
|
if (run) {
|
|
1117
1203
|
if (!run.interrupted) note(`${c.cyan('tab')} ${c.dim('— interrupting the running turn')}`);
|
|
1118
1204
|
run.interrupted = true;
|
|
1205
|
+
run.abort.abort();
|
|
1119
1206
|
for (const ch of run.spawned) interruptThenKill(ch);
|
|
1120
1207
|
continue;
|
|
1121
1208
|
}
|
package/bin/lib/workToolLog.mjs
CHANGED
|
@@ -84,6 +84,8 @@ export function createToolLog(cwd) {
|
|
|
84
84
|
// past the server's limits (review, 2026-09-01).
|
|
85
85
|
const e = toolEventOf(name, input, cwd, envScrub);
|
|
86
86
|
if (!e) return;
|
|
87
|
+
if (typeof meta.agentId === 'string' && meta.agentId) e.agentId = scrubCut(meta.agentId, 160);
|
|
88
|
+
if (e.agentId && typeof meta.agentTitle === 'string' && meta.agentTitle) e.agentTitle = scrubCut(meta.agentTitle, 120);
|
|
87
89
|
if (e.t !== 'plan') {
|
|
88
90
|
if (id) { e.id = scrubCut(id, 160); correlated.set(id, e); }
|
|
89
91
|
if (name === 'Read') for (const key of ['offset', 'limit']) {
|
|
@@ -106,6 +108,7 @@ export function createToolLog(cwd) {
|
|
|
106
108
|
const last = toolLog.ev[toolLog.ev.length - 1];
|
|
107
109
|
const sameKey =
|
|
108
110
|
last &&
|
|
111
|
+
last.agentId === e.agentId &&
|
|
109
112
|
!id && !last.id &&
|
|
110
113
|
last.outcome === e.outcome &&
|
|
111
114
|
last.exitCode === e.exitCode &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.16",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|