flowviant 0.125.18 → 0.125.21

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.
Files changed (48) hide show
  1. package/README.md +11 -1
  2. package/bin/cli.mjs +13 -0
  3. package/bin/lib/artifactCache.mjs +336 -0
  4. package/bin/lib/artifactContracts.mjs +87 -2
  5. package/bin/lib/artifactIteration.mjs +3 -2
  6. package/bin/lib/artifactPolicy.mjs +5 -1
  7. package/bin/lib/artifactReporter.mjs +91 -9
  8. package/bin/lib/artifacts.mjs +12 -6
  9. package/bin/lib/claudeStream.mjs +7 -0
  10. package/bin/lib/codexFencedMount.mjs +2 -2
  11. package/bin/lib/fleet.mjs +9 -0
  12. package/bin/lib/intermediateArtifacts.mjs +40 -8
  13. package/bin/lib/knowledgeLibrary.mjs +2 -1
  14. package/bin/lib/localCleanupReport.mjs +34 -0
  15. package/bin/lib/localCleanupState.mjs +68 -0
  16. package/bin/lib/logReplay.mjs +6 -0
  17. package/bin/lib/managedLocalCleanup.mjs +158 -0
  18. package/bin/lib/pinnedCapture.mjs +19 -8
  19. package/bin/lib/postureTools.mjs +2 -2
  20. package/bin/lib/publicReferenceBrowser.mjs +180 -0
  21. package/bin/lib/publicReferenceNetwork.mjs +131 -0
  22. package/bin/lib/publicReferenceProtocol.mjs +55 -0
  23. package/bin/lib/publicReferenceRuntime.mjs +65 -0
  24. package/bin/lib/publicReferenceTool.mjs +89 -0
  25. package/bin/lib/referenceCaptureProtocol.mjs +12 -0
  26. package/bin/lib/referenceCaptures.mjs +140 -0
  27. package/bin/lib/resultInputs.mjs +4 -2
  28. package/bin/lib/runTurn.mjs +8 -0
  29. package/bin/lib/runtimeCodex.mjs +1 -1
  30. package/bin/lib/runtimeEvents.mjs +24 -2
  31. package/bin/lib/uiReview.mjs +305 -0
  32. package/bin/lib/uiReviewBrowser.mjs +212 -0
  33. package/bin/lib/uiReviewContract.mjs +51 -0
  34. package/bin/lib/uiReviewTurn.mjs +15 -0
  35. package/bin/lib/websiteAccess.mjs +68 -0
  36. package/bin/lib/websiteTrace.mjs +36 -0
  37. package/bin/lib/work.mjs +46 -3
  38. package/bin/lib/workAgentPlans.mjs +3 -0
  39. package/bin/lib/workAgentPrecheck.mjs +16 -15
  40. package/bin/lib/workAgentReview.mjs +2 -2
  41. package/bin/lib/workAgentTools.mjs +3 -2
  42. package/bin/lib/workAgentTurnExecution.mjs +69 -4
  43. package/bin/lib/workAgentTurns.mjs +3 -1
  44. package/bin/lib/workLocalCleanup.mjs +125 -0
  45. package/bin/lib/workMergeBatch.mjs +19 -4
  46. package/bin/lib/workSessionGroups.mjs +20 -1
  47. package/bin/lib/workToolLog.mjs +24 -3
  48. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ /** Startup/idle runner for local-only cleanup. Recovery I/O is outside the
2
+ * writer lock; only a fresh prepared version may enter the short unlink phase. */
3
+ import { randomUUID } from 'node:crypto';
4
+ import { cleanupState } from './localCleanupState.mjs';
5
+ import { agentIdFromPlace } from './workPlaces.mjs';
6
+
7
+ export const LOCAL_CLEANUP_INTERVAL_MS = 15_000;
8
+ export const LOCAL_CLEANUP_BACKOFF_MS = 60_000;
9
+ const MAX_BACKOFF = 60 * 60_000;
10
+ const keyOf = ref => JSON.stringify([ref.kind, ref.name, ref.localVersion]);
11
+
12
+ export function createWorkLocalCleanup({ placeLocks, inPlace, targetsFor, eligible, permission = () => false,
13
+ now = Date.now, log = () => {}, onResult = () => {}, onIncomplete = () => {} }) {
14
+ let snapshot = null, sweeping = null, lastAt = -Infinity, placeCursor = 0;
15
+ const targetCursors = new Map();
16
+ const allowed = (place, locked = false) => {
17
+ if (!snapshot || snapshot.serving !== true || snapshot.draining !== false ||
18
+ !Array.isArray(snapshot.activeIds) || !Array.isArray(snapshot.heldElsewhere) ||
19
+ now() < snapshot.at || now() - snapshot.at > 15_000 || permission() !== true ||
20
+ !snapshot.activeIds.includes(place) || snapshot.heldElsewhere.includes(place)) return false;
21
+ const lock = placeLocks.get(place);
22
+ if (locked ? lock?.waiters?.length > 0 : placeLocks.has(place)) return false;
23
+ return eligible(place) === true;
24
+ };
25
+ const attemptTarget = async (place, target) => {
26
+ const identity = JSON.stringify({ place, placeDir: target.placeDir, agentId: target.agentId, projectId: target.projectId });
27
+ const store = cleanupState(target.statePath, identity), release = store.claim();
28
+ if (!release) return;
29
+ let locked = false;
30
+ const guard = () => allowed(place, locked);
31
+ const memo = new Map(); let preparedOnly = false;
32
+ try {
33
+ if (!guard()) return;
34
+ const state = store.load();
35
+ // Replay durable outcomes before selecting work, including when every
36
+ // version is already evicted. Stable ids make replay retry-safe.
37
+ const publish = () => {
38
+ let changed = false;
39
+ for (const entry of state.results) {
40
+ if (entry.reported === true) continue;
41
+ if (onResult(entry) !== false) { entry.reported = true; changed = true; }
42
+ }
43
+ if (changed) store.save(state);
44
+ };
45
+ publish();
46
+ for (const attempt of Object.values(state.attempts)) {
47
+ if (attempt.state !== 'running' || attempt.interruptionReported) continue;
48
+ if (onIncomplete({ id: `interrupted:${attempt.id}`, status: 'retained',
49
+ reason: 'interrupted-attempt-completion-unknown', incomplete: true,
50
+ projectId: target.projectId, agentId: target.agentId, placeDir: target.placeDir,
51
+ place, at: now(), nextAt: attempt.nextAt, tries: attempt.tries,
52
+ resumedAttempt: attempt.id, previousCompletion: 'unknown' }) !== false) {
53
+ attempt.interruptionReported = true; store.save(state);
54
+ }
55
+ }
56
+ const operations = target.operations({ guard, recoveryMemo: memo, preparedOnly: () => preparedOnly });
57
+ if (!operations.length) return;
58
+ let chosen = null;
59
+ for (let i = 0; i < Math.min(32, operations.length); i++) {
60
+ const index = (state.cursor + i) % operations.length, op = operations[index], key = keyOf(op.ref);
61
+ const previous = state.attempts[key];
62
+ if (op.ref.state === 'discarded' || (op.ref.state === 'evicted' && previous?.state !== 'running')) continue;
63
+ if (previous && previous.nextAt > now()) continue;
64
+ chosen = { op, key, previous }; state.cursor = (index + 1) % operations.length; break;
65
+ }
66
+ if (!chosen) { state.cursor = (state.cursor + 32) % operations.length; store.save(state); return; }
67
+ const { op, key, previous } = chosen, id = randomUUID();
68
+ const tries = Math.min(100, (previous?.tries ?? 0) + 1);
69
+ state.attempts[key] = { id, state: 'running', tries, nextAt: now() + LOCAL_CLEANUP_BACKOFF_MS, startedAt: now() };
70
+ // Persist before remote verification/unlink. A restart honours backoff
71
+ // and re-runs the prerequisite's current version/pin/recovery checks.
72
+ store.save(state);
73
+ let result = await op.prepare();
74
+ if (result.status === 'ready') {
75
+ if (!guard() || placeLocks.has(place)) result = { status: 'retained', reason: 'lifecycle-changed' };
76
+ else await inPlace(place, true, async () => {
77
+ locked = true; preparedOnly = true;
78
+ try {
79
+ if (!guard()) { result = { status: 'retained', reason: 'lifecycle-changed' }; return; }
80
+ const current = target.operations({ guard, recoveryMemo: memo, preparedOnly: () => preparedOnly })
81
+ .find(candidate => keyOf(candidate.ref) === key);
82
+ result = current ? await current.run() : { status: 'retained', reason: 'local-version-changed' };
83
+ } finally { locked = false; }
84
+ });
85
+ }
86
+ const retryable = result.status === 'failed' || result.incomplete === true || /remote|recovery|unreadable|unlink-failed/.test(result.reason ?? '');
87
+ const nextAt = now() + (retryable ? Math.min(MAX_BACKOFF, LOCAL_CLEANUP_BACKOFF_MS * 2 ** Math.min(tries - 1, 6)) : LOCAL_CLEANUP_BACKOFF_MS);
88
+ const entry = { ...result, id, kind: op.ref.kind, name: op.ref.name, localVersion: op.ref.localVersion,
89
+ projectId: target.projectId, agentId: target.agentId, placeDir: target.placeDir, place, at: now(),
90
+ bytes: result.status === 'reclaimed' ? result.bytes : 0, nextAt, tries,
91
+ ...(previous?.state === 'running' ? { resumedAttempt: previous.id, previousCompletion: 'unknown' } : {}) };
92
+ state.attempts[key] = { id, state: result.incomplete ? 'incomplete' : result.status, tries: retryable ? tries : 0, nextAt, result: entry };
93
+ state.results.push(entry); state.results = state.results.slice(-128);
94
+ const liveKeys = new Set(operations.map(candidate => keyOf(candidate.ref)));
95
+ for (const old of Object.keys(state.attempts)) if (!liveKeys.has(old)) delete state.attempts[old];
96
+ store.save(state);
97
+ publish();
98
+ } finally { memo.clear(); release(); }
99
+ };
100
+ const reconcileLocalCleanup = evidence => {
101
+ // Refresh even while an older verification is in flight. Lost serving
102
+ // permission, new foreground work and peer leases invalidate its guard.
103
+ snapshot = { ...evidence, at: now() };
104
+ if (sweeping || now() - lastAt < LOCAL_CLEANUP_INTERVAL_MS || permission() !== true ||
105
+ evidence?.serving !== true || evidence?.draining !== false || !Array.isArray(evidence.activeIds) || !Array.isArray(evidence.heldElsewhere)) return sweeping;
106
+ const places = evidence.activeIds.filter(place => agentIdFromPlace(place) && /^[A-Za-z0-9_-]+$/.test(place));
107
+ for (const place of targetCursors.keys()) if (!places.includes(place)) targetCursors.delete(place);
108
+ if (!places.length) return null;
109
+ lastAt = now(); const place = places[placeCursor++ % places.length];
110
+ if (!allowed(place)) return null;
111
+ sweeping = (async () => {
112
+ try {
113
+ const targets = targetsFor(place);
114
+ if (!targets.length) return;
115
+ const cursor = targetCursors.get(place) ?? 0;
116
+ const target = targets[cursor % targets.length];
117
+ targetCursors.set(place, cursor + 1);
118
+ await attemptTarget(place, target);
119
+ } catch (error) { log(`local cleanup incomplete: ${error.message}`); }
120
+ finally { sweeping = null; }
121
+ })();
122
+ return sweeping;
123
+ };
124
+ return { reconcileLocalCleanup, cleanupBusy: () => sweeping !== null };
125
+ }
@@ -9,7 +9,7 @@ import { mergeBatchPrompt, MERGE_BATCH_SYSTEM } from './repositoryPrompts.mjs';
9
9
  import { runTurn } from './runTurn.mjs';
10
10
  import { runCappedTurn } from './workCappedTurn.mjs';
11
11
  import { candidateObjects } from './modelJson.mjs';
12
- import { pickRuntimeFor } from './runtimeDetection.mjs';
12
+ import { brainFor } from './workBrain.mjs';
13
13
  import { fleetRequest } from './fleetPost.mjs';
14
14
  import { fleetEndpoint } from './fleetWire.mjs';
15
15
  import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
@@ -20,6 +20,22 @@ import { isSafePathSegment, stripUrlUserinfo } from './git.mjs';
20
20
  import { killGroup } from './processGroup.mjs';
21
21
  import { readConfiguredCheck } from './workAgentCheck.mjs';
22
22
 
23
+ /** A joint repair has no unique originating brain when its members differ. */
24
+ export function mergeBatchBrain(members, coordinatorAgentId) {
25
+ if (!members?.length) throw new Error('The selected merge has no originating agent.');
26
+ if (coordinatorAgentId) {
27
+ const selected = members.find(member => member.agentId === coordinatorAgentId);
28
+ if (!selected) throw new Error('The selected merge coordinator is not part of this merge.');
29
+ const runtime = selected.runtime || 'claude';
30
+ return { runtime, ...brainFor(selected, runtime) };
31
+ }
32
+ const picks = members.map(member => ({ runtime: member.runtime || 'claude', model: member.model || null, effort: member.effort || null }));
33
+ const first = picks[0];
34
+ if (picks.some(pick => pick.runtime !== first.runtime || pick.model !== first.model || pick.effort !== first.effort))
35
+ throw new Error('The selected agents use different CLI/model/effort choices. Merge them separately so repair uses each agent’s own choice.');
36
+ return { runtime: first.runtime, ...brainFor(first, first.runtime) };
37
+ }
38
+
23
39
  export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPlace, runCheck, workChildren, groupKillChildren, admit, onRepoChanged, landed, runCli = runTurn }) {
24
40
  const request = async (route, body) => {
25
41
  const response = await fleetRequest(fleetEndpoint(route, FLEET_URL), { body: { ...body, instance: DAEMON_INSTANCE } });
@@ -64,15 +80,14 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
64
80
  if (!result?.ok) throw new Error('Batch ownership or progress changed. Retry will remeasure base.');
65
81
  },
66
82
  coordinate: async (context) => {
67
- const runtime = leader.runtime || pickRuntimeFor('build');
68
- if (!runtime) throw new Error('No coding CLI is installed on this machine to coordinate the batch.');
83
+ const brain = mergeBatchBrain(spec.members, spec.coordinatorAgentId);
69
84
  let child;
70
85
  try {
71
86
  const result = await runCappedTurn({ runTurn: runCli, workChildren, releaseSlot: release,
72
87
  transcriptCwd: context.repositories[0].integrationWorktree, capMs: 20 * 60_000, killChild: killGroup,
73
88
  onSpawn: (ch) => { child = ch; groupKillChildren?.add(ch); },
74
89
  turnArgs: { prompt: mergeBatchPrompt(context), system: MERGE_BATCH_SYSTEM,
75
- runtime, profile: 'build', knowledgeDir: knowledgeDirFor(repoRoot), model: leader.model || undefined, effort: leader.effort || undefined,
90
+ ...brain, profile: 'build', knowledgeDir: knowledgeDirFor(repoRoot),
76
91
  cwd: context.repositories[0].integrationWorktree, writableDirs: context.repositories.filter((r) => !r.alreadyLanded).map((r) => r.integrationWorktree),
77
92
  answerFromResult: true, work } });
78
93
  if (result.wedged) throw new Error('The coordinating CLI ran out of time. Retry the batch to continue with its saved integration work.');
@@ -11,6 +11,7 @@
11
11
  * manager against a real registry file.
12
12
  */
13
13
  import { join } from 'node:path';
14
+ import { constants, openSync, closeSync, fstatSync, readFileSync } from 'node:fs';
14
15
  import { homedir } from 'node:os';
15
16
  import { createHash } from 'node:crypto';
16
17
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
@@ -162,5 +163,23 @@ export function createWorkSessionGroups({ repoRoot }) {
162
163
  return measureProcesses(alive, { scrub: envScrub });
163
164
  };
164
165
 
165
- return { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses };
166
+ /** Cleanup needs stronger evidence than a display: reload the durable
167
+ * registry to observe peer/orphan groups, and retain on missing/bad reads. */
168
+ const cleanupProcessesIdle = place => {
169
+ if (!processesSupported()) return null;
170
+ let fd;
171
+ try {
172
+ fd = openSync(GROUPS_FILE, constants.O_RDONLY | constants.O_NOFOLLOW);
173
+ const st = fstatSync(fd);
174
+ if (!st.isFile() || st.size > 256 * 1024) return null;
175
+ const entries = JSON.parse(readFileSync(fd, 'utf8'));
176
+ if (!Array.isArray(entries) || entries.length >= 32 || entries.some(e =>
177
+ typeof e?.sessionId !== 'string' || !Number.isInteger(e.pid) || e.pid <= 1 || typeof e.boot !== 'string' || !e.boot)) return null;
178
+ const groups = new Set(sessionGroups.get(place) ?? []);
179
+ for (const entry of entries) if (entry.sessionId === place && sameBoot(entry.boot, BOOT)) groups.add(entry.pid);
180
+ return liveGroups(groups).size === 0;
181
+ } catch { return null; }
182
+ finally { if (fd !== undefined) closeSync(fd); }
183
+ };
184
+ return { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses, cleanupProcessesIdle };
166
185
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { toolEventOf } from './runtimeEvents.mjs';
11
11
  import { scrubCut, scrub as envScrub } from './uplinkScrub.mjs';
12
+ import { observeWebsite } from './websiteAccess.mjs';
12
13
 
13
14
  /**
14
15
  * THE TURN'S TOOL LOG — the structured relay behind the transcript's
@@ -47,7 +48,15 @@ export function createToolLog(cwd) {
47
48
  e.outcome = !meta.result ? 'unknown' : failed ? 'failed' :
48
49
  meta.runtime === 'codex' && code === undefined ? 'unknown' : 'success';
49
50
  if (code !== undefined) e.exitCode = code;
50
- if (failed) {
51
+ if (e.website) {
52
+ observeWebsite(e, meta, envScrub);
53
+ if (e.errorTruncated) toolLog.state = 'partial';
54
+ }
55
+ if (e.outcome === 'failed') {
56
+ if (e.website) {
57
+ if (!toolLog.firstFailure) { toolLog.firstFailure = structuredClone(e); toolLog.recovery = []; }
58
+ return;
59
+ }
51
60
  const output = scrubCut(meta.output ?? '', 16_384);
52
61
  const guardAt = output.indexOf('flowviant: this turn is read-only, so ');
53
62
  const guard = guardAt >= 0 ? output.slice(guardAt).split('\n', 1)[0] : undefined;
@@ -67,8 +76,19 @@ export function createToolLog(cwd) {
67
76
  };
68
77
  const pushToolEvent = (name, input, meta = {}) => {
69
78
  const id = typeof meta.id === 'string' && meta.id && meta.id.length <= 160 ? meta.id : undefined;
70
- const existing = id && correlated.get(id);
79
+ const key = id && JSON.stringify([meta.agentId ?? null, id]);
80
+ const existing = key && correlated.get(key);
71
81
  if (existing) {
82
+ // Codex may only name the web action/URL on completion. Enrich that
83
+ // same native request instead of keeping its provisional search label.
84
+ if (existing.website && name) {
85
+ const request = toolEventOf(name, input, cwd, envScrub)?.website;
86
+ if (request) {
87
+ existing.website.method = request.method;
88
+ existing.website.tool = request.tool;
89
+ if (request.requestedUrl) existing.website.requestedUrl = request.requestedUrl;
90
+ }
91
+ }
72
92
  if (meta.result) observe(existing, meta);
73
93
  refresh();
74
94
  return existing;
@@ -87,7 +107,7 @@ export function createToolLog(cwd) {
87
107
  if (typeof meta.agentId === 'string' && meta.agentId) e.agentId = scrubCut(meta.agentId, 160);
88
108
  if (e.agentId && typeof meta.agentTitle === 'string' && meta.agentTitle) e.agentTitle = scrubCut(meta.agentTitle, 120);
89
109
  if (e.t !== 'plan') {
90
- if (id) { e.id = scrubCut(id, 160); correlated.set(id, e); }
110
+ if (id) { e.id = scrubCut(id, 160); correlated.set(key, e); }
91
111
  if (name === 'Read') for (const key of ['offset', 'limit']) {
92
112
  if (Number.isSafeInteger(input?.[key]) && input[key] >= 0) e[key] = input[key];
93
113
  }
@@ -115,6 +135,7 @@ export function createToolLog(cwd) {
115
135
  last.errorCategory === e.errorCategory &&
116
136
  last.offset === e.offset && last.limit === e.limit &&
117
137
  last.t === e.t &&
138
+ !e.website && !last.website &&
118
139
  last.p === e.p &&
119
140
  last.q === e.q &&
120
141
  last.c === e.c;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.18",
3
+ "version": "0.125.21",
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": {