flowviant 0.125.18 → 0.125.20

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.
@@ -33,7 +33,7 @@ function readRegular(path, max = ARTIFACT_BINARY_MODEL_MAX_BYTES) {
33
33
  try {
34
34
  const stat = fstatSync(fd);
35
35
  if (!stat.isFile()) throw new Error('Intermediate artifact is not a regular file.');
36
- if (stat.size > max) throw new Error(`Intermediate artifact is ${stat.size} bytes; the per-file limit is ${max} bytes.`);
36
+ if (stat.size > max) throw new Error(`Intermediate artifact is ${stat.size} bytes; the per-file limit is ${max} bytes. Preserve these exact producer bytes and retry after a compatible update, or revise this producer explicitly.`);
37
37
  const bytes = Buffer.alloc(stat.size);
38
38
  let offset = 0;
39
39
  while (offset < bytes.length) {
@@ -116,7 +116,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
116
116
  try {
117
117
  if (!safeName(ref.name) || !/^[a-f0-9]{64}$/.test(ref.sha256) || ref.path !== copyPath(ref.sha256, ref.name))
118
118
  throw new Error('Invalid intermediate artifact version identity.');
119
- if (!historical && state.pending?.taskId && state.pending.taskId === ref.taskId && state.pending.sources.some(source =>
119
+ if (!historical && state.pending && (state.pending.recovery === true || state.pending.taskId === ref.taskId) && state.pending.sources.some(source =>
120
120
  source.name === ref.name || source.name === ref.model?.page)) throw new Error('Producer handoff remains unfinished');
121
121
  let bytes;
122
122
  try { bytes = readIntermediateFile(wt, ref.path); }
@@ -145,15 +145,21 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
145
145
  const state = load(), taskId = taskOf(job);
146
146
  const identity = { agentId, taskId, turnId, taskKind };
147
147
  const standing = scanArtifacts(wt, { all: true });
148
- const changed = changedArtifacts(before, standing);
148
+ let changed = changedArtifacts(before, standing);
149
149
  // Failed export/storage retries are tied to a card and exact source hash.
150
150
  // A changed page is a new version; it never reuses a previous GLB receipt.
151
151
  // A source the agent's iteration declaration discards or replaces is the
152
152
  // explicit revision and leaves the retry set.
153
- const pending = state.pending?.taskId === taskId && taskId ? state.pending : null;
153
+ const pending = state.pending?.taskId === taskId && (taskId || (state.pending.recovery === true && job.kind === 'human')) ? state.pending : null;
154
+ if (state.pending?.recovery && !pending)
155
+ throw new Error('An exact producer recovery is pending. Retry that producer before starting different work.');
156
+ if (pending?.recovery) {
157
+ identity.recoveredFromTurnId = pending.recoverySourceTurnId ?? pending.turnId;
158
+ changed = changed.filter(e => pending.sources.some(s => s.name === e.name));
159
+ }
154
160
  const discarded = pending ? declaredDiscards(wt) : new Set();
155
161
  if (pending) for (const ref of pending.sources) {
156
- if (changed.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name))) continue;
162
+ if (!pending.recovery && (changed.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name)))) continue;
157
163
  const entry = standing.find(e => e.name === ref.name);
158
164
  let bytes;
159
165
  try { bytes = readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name)); }
@@ -161,7 +167,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
161
167
  if (hash(bytes) !== ref.sha256)
162
168
  throw new Error(`Required intermediate output ${ref.name} is missing or changed since the failed handoff. Restore it or revise the producer explicitly, then retry.`);
163
169
  if (!entry) throw new Error(`Required intermediate output ${ref.name} is outside the safe artifact walk (non-hidden paths with at most ${ARTIFACT_MAX_DEPTH} directories). Move it to a supported artifact path and retry this producer.`);
164
- changed.push(entry);
170
+ if (!changed.some(e => e.name === entry.name)) changed.push(entry);
165
171
  }
166
172
  if (taskKind === 'model' && !changed.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
167
173
  for (const ref of state.files.filter(f => f.taskId === taskId && f.taskKind === 'model' && /\.html?$/i.test(f.name))) {
@@ -171,7 +177,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
171
177
  }
172
178
  const pages = taskKind === 'model' ? changed.filter(e => /\.html?$/i.test(e.name)) : [];
173
179
  const outputs = changed.filter(e => artifactTypeFor(e.name));
174
- state.pending = { taskId, turnId, sources: outputs.map(e => ({ name: e.name, sha256: hash(readIntermediateFile(wt, `.flowviant/artifacts/${e.name}`, artifactMaxBytesFor(e.name))) })), exports: pending?.exports ?? [] };
180
+ state.pending = { taskId, turnId, ...(pending?.recovery ? { recovery: true, recoverySourceTurnId: identity.recoveredFromTurnId } : {}), sources: outputs.map(e => ({ name: e.name, sha256: hash(readIntermediateFile(wt, `.flowviant/artifacts/${e.name}`, artifactMaxBytesFor(e.name))) })), exports: pending?.exports ?? [] };
175
181
  save(state);
176
182
  const exports = [];
177
183
  for (const page of pages) {
@@ -226,7 +232,33 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
226
232
  save({ ...state, files: [...byName.values()], pending: null });
227
233
  } };
228
234
  };
229
- return { inputs, prepare };
235
+ /** Offline recovery of an interrupted producer with no saved pending set.
236
+ * The operator supplies the original log/export binding, never a new model
237
+ * or a same-name Library lookup. This publishes no consumer receipt. */
238
+ const restorePending = ({ taskId, turnId, sources, exports }) => {
239
+ const id = /^[A-Za-z0-9_-]{1,80}$/;
240
+ if (!(taskId === null || id.test(taskId ?? '')) || !id.test(turnId ?? '') ||
241
+ !Array.isArray(sources) || !sources.length || sources.length > ARTIFACT_MAX_PER_AGENT ||
242
+ !Array.isArray(exports) || !exports.length ||
243
+ new Set(sources.map(s => s.name)).size !== sources.length)
244
+ throw new Error('Recovery needs the exact producer task, turn, source hashes and page-export bindings.');
245
+ const state = load();
246
+ if (state.pending && (state.pending.taskId !== taskId || state.pending.turnId !== turnId))
247
+ throw new Error('Another producer handoff is pending. Preserve its receipt before recovery.');
248
+ for (const ref of sources) {
249
+ if (!safeName(ref.name) || !artifactTypeFor(ref.name) || !/^[a-f0-9]{64}$/.test(ref.sha256 ?? '') ||
250
+ hash(readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name))) !== ref.sha256)
251
+ throw new Error(`Recovery source ${ref.name} does not match its original producer hash. Restore the exact bytes before retrying.`);
252
+ }
253
+ if (!exports.every(ref => ref.name === ref.page?.replace(/\.html?$/i, '.glb') &&
254
+ /\.html?$/i.test(ref.page) && sources.some(s => s.name === ref.page && s.sha256 === ref.pageSha256) &&
255
+ sources.some(s => s.name === ref.name && s.sha256 === ref.sha256)) || !measuredModelExports(wt, exports))
256
+ throw new Error('Recovery page/export binding is missing, changed or unusable. Restore the original producer export evidence.');
257
+ save({ ...state, pending: { taskId, turnId, recovery: true, recoverySourceTurnId: turnId,
258
+ sources: sources.map(({ name, sha256 }) => ({ name, sha256 })),
259
+ exports: exports.map(({ page, pageSha256, name, sha256 }) => ({ page, pageSha256, name, sha256 })) } });
260
+ };
261
+ return { inputs, prepare, restorePending };
230
262
  }
231
263
 
232
264
  export function intermediateArtifactContext(files = []) {
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import { safeFileName } from './safeFileName.mjs';
44
+ import { ARTIFACT_BINARY_MODEL_MAX_BYTES } from './artifactPolicy.mjs';
44
45
 
45
46
  /** Relative to the checkout, under `.flowviant/` beside the turn uploads and
46
47
  * the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
@@ -140,7 +141,7 @@ export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
140
141
  export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
141
142
  export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
142
143
  export const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
143
- ? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
144
+ ? Math.max(base, /^(models|designs)\//.test(name) ? ARTIFACT_BINARY_MODEL_MAX_BYTES : KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
144
145
  /** The manifest is capped at the server (25 files); a manifest longer than
145
146
  * this is not one the product produced, and is cut rather than trusted. */
146
147
  export const MAX_FILES = 64;
@@ -0,0 +1,34 @@
1
+ /** Local diagnostics only: consume measured outcomes, never estimate disk use. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { retainTurnRecord } from './daemonLogging.mjs';
4
+ import { encodeDetailRecord, decodeDetailRecord } from './daemonJournal.mjs';
5
+ import { formatTurnRecord } from './turnRecord.mjs';
6
+
7
+ export function reportLocalCleanup(entry, { retain = retainTurnRecord,
8
+ write = text => process.stderr.write(text) } = {}) {
9
+ if (!entry || entry.reason === 'already-evicted') return true;
10
+ const id = entry.id || randomUUID();
11
+ const bytes = entry.status === 'reclaimed' && Number.isFinite(entry.bytes) && entry.bytes >= 0 ? entry.bytes : null;
12
+ const text = JSON.stringify({ operation: entry.kind === 'artifact' ? 'local-cache-eviction' : 'managed-local-cleanup',
13
+ attemptId: id, status: entry.status, kind: entry.kind,
14
+ name: entry.name, path: entry.path, projectId: entry.projectId,
15
+ place: entry.place, placeDir: entry.placeDir, agentId: entry.agentId,
16
+ sessionId: entry.sessionId, localVersion: entry.localVersion,
17
+ reclaimedFiles: entry.status === 'reclaimed' ? 1 : 0,
18
+ reclaimedBytes: bytes, reason: entry.reason, error: entry.error,
19
+ incomplete: entry.incomplete === true,
20
+ retryAt: entry.status === 'failed' || entry.incomplete || entry.status === 'retained' ? entry.nextAt : undefined,
21
+ tries: entry.tries, resumedAttempt: entry.resumedAttempt,
22
+ previousCompletion: entry.previousCompletion,
23
+ cloudCopies: 'unchanged', repositoryFiles: 'unchanged' });
24
+ // Use the journal's own scrub/clip policy even when retention fails and the
25
+ // complete diagnostic must be shown live. No raw paths/errors reach output.
26
+ const record = decodeDetailRecord(encodeDetailRecord({ kind: 'cleanup',
27
+ name: 'Local cleanup', workId: entry.agentId || entry.sessionId || entry.place || '',
28
+ turnId: entry.turnId || `cleanup:${id}`, source: `cleanup:${id}`,
29
+ observedAt: entry.at ?? Date.now(), text }));
30
+ const retained = retain(record);
31
+ if (retained !== true || entry.status === 'failed' || entry.incomplete)
32
+ write(formatTurnRecord(record, { color: false }));
33
+ return retained === true;
34
+ }
@@ -0,0 +1,68 @@
1
+ /** Private operational attempts, not a diagnostic log. Atomic and bounded;
2
+ * losing/corrupting state never grants permission to delete. */
3
+ import { constants, openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, rmSync, lstatSync, realpathSync, linkSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { processAlive, processStartTime, bootMark, sameBoot } from './procRegistry.mjs';
7
+
8
+ export function cleanupState(path, identity) {
9
+ if (realpathSync(dirname(path)) !== resolve(dirname(path))) throw new Error('cleanup-state-parent-unsafe');
10
+ const read = file => {
11
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
12
+ try {
13
+ const st = fstatSync(fd);
14
+ if (!st.isFile() || st.size > 2 * 1024 * 1024) throw new Error('cleanup-state-unreadable');
15
+ return JSON.parse(readFileSync(fd, 'utf8'));
16
+ } finally { closeSync(fd); }
17
+ };
18
+ const load = () => {
19
+ let value;
20
+ try { value = read(path); }
21
+ catch (e) { if (e.code === 'ENOENT') return { version: 1, identity, cursor: 0, attempts: {}, results: [] }; throw e; }
22
+ if (value.version !== 1 || value.identity !== identity || !Number.isSafeInteger(value.cursor) || value.cursor < 0 ||
23
+ !value.attempts || typeof value.attempts !== 'object' || Array.isArray(value.attempts) || Object.keys(value.attempts).length > 2000 ||
24
+ !Array.isArray(value.results) || value.results.length > 128 || Object.values(value.attempts).some(a =>
25
+ !Number.isFinite(a.nextAt) || !Number.isSafeInteger(a.tries) || a.tries < 0)) throw new Error('cleanup-state-unreadable');
26
+ return value;
27
+ };
28
+ const save = value => {
29
+ const tmp = `${path}.${randomUUID()}.tmp`, bytes = JSON.stringify(value);
30
+ if (Buffer.byteLength(bytes) > 2 * 1024 * 1024) throw new Error('cleanup-state-budget-exhausted');
31
+ try {
32
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
33
+ try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
34
+ renameSync(tmp, path);
35
+ const parent = openSync(dirname(path), constants.O_RDONLY | constants.O_NOFOLLOW);
36
+ try { fsyncSync(parent); } finally { closeSync(parent); }
37
+ } finally { rmSync(tmp, { force: true }); }
38
+ };
39
+ /** No age-only lock stealing: a live or unreadable holder keeps its claim. */
40
+ const claim = () => {
41
+ const lock = `${path}.lock`, token = randomUUID();
42
+ for (let i = 0; i < 2; i++) {
43
+ try {
44
+ const tmp = `${lock}.${token}.tmp`;
45
+ try {
46
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
47
+ try { writeFileSync(fd, JSON.stringify({ identity, token, pid: process.pid, start: processStartTime(process.pid), boot: bootMark() })); fsyncSync(fd); }
48
+ finally { closeSync(fd); }
49
+ linkSync(tmp, lock); // publish only complete ownership evidence
50
+ } finally { rmSync(tmp, { force: true }); }
51
+ return () => { try { if (read(lock).token === token) rmSync(lock); } catch { /* retain an unknown replacement */ } };
52
+ } catch (error) {
53
+ if (error.code !== 'EEXIST') return null;
54
+ try {
55
+ const stat = lstatSync(lock), holder = read(lock);
56
+ if (holder.identity !== identity || !Number.isInteger(holder.pid) || holder.pid <= 1) return null;
57
+ const alive = processAlive(holder.pid), start = processStartTime(holder.pid);
58
+ if (alive && (!holder.boot || sameBoot(holder.boot)) && (!holder.start || !start || start === holder.start)) return null;
59
+ const current = lstatSync(lock);
60
+ if (current.ino !== stat.ino || current.mtimeMs !== stat.mtimeMs) return null;
61
+ rmSync(lock);
62
+ } catch { return null; }
63
+ }
64
+ }
65
+ return null;
66
+ };
67
+ return { load, save, claim };
68
+ }
@@ -29,10 +29,16 @@ function openCursor(path) {
29
29
 
30
30
  export function createLogReader(path, { turnId = null } = {}) {
31
31
  let current = null, previousSeen = null, initialized = false, closed = false;
32
+ const cleanupIds = new Set();
32
33
  const line = (raw, batch) => {
33
34
  const row = decodeDetailRecord(raw);
34
35
  if (row === false) { batch.lines.push('[detail gap: an incomplete or unreadable retained record was skipped]'); return; }
35
36
  if (row) {
37
+ if (row.kind === 'cleanup' && row.source.startsWith('cleanup:')) {
38
+ if (cleanupIds.has(row.source)) return;
39
+ if (cleanupIds.size >= 8192) cleanupIds.delete(cleanupIds.values().next().value);
40
+ cleanupIds.add(row.source);
41
+ }
36
42
  if (turnId !== null && !matchesTurn(row.turnId, turnId)) return;
37
43
  batch.matched++;
38
44
  for (const text of formatTurnRecord(row, { color: false }).slice(0, -1).split('\n')) batch.lines.push(text);
@@ -0,0 +1,158 @@
1
+ /** Inventory and release of turn-owned scratch/native originals. All mutations
2
+ * run under the caller's place writer lock. The artifact cache owns version,
3
+ * pin, recovery and unlink policy; this module supplies provenance and roots. */
4
+ import { readdirSync, lstatSync, realpathSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { createArtifactCache } from './artifactCache.mjs';
7
+ import { realDirState } from './ownedDirs.mjs';
8
+
9
+ const MAX_FILES = 512;
10
+ const stamp = st => `${st.dev}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
11
+
12
+ /** Unknown and linked entries are never visited. No fallback to a personal home. */
13
+ function inventory(root) {
14
+ const files = new Map(); let visited = 0;
15
+ if (realDirState(root) === 'none') return files;
16
+ if (realDirState(root) !== 'dir' || realpathSync(root) !== resolve(root)) throw new Error('unsafe-managed-root');
17
+ const walk = (at, prefix, depth) => {
18
+ for (const name of readdirSync(at)) {
19
+ if (++visited > MAX_FILES) throw new Error('inventory-budget-exhausted');
20
+ if (name.startsWith('.')) continue;
21
+ const path = join(at, name), relative = prefix ? `${prefix}/${name}` : name;
22
+ const st = lstatSync(path);
23
+ if (st.isSymbolicLink()) continue;
24
+ if (st.isFile()) files.set(relative, stamp(st));
25
+ else if (st.isDirectory() && depth < 4) walk(path, relative, depth + 1);
26
+ }
27
+ };
28
+ walk(root, '', 0); return files;
29
+ }
30
+
31
+ export function createManagedLocalCleanup({ placeDir, projectId, agentId, path, codexHome = null, outputCache = null, getUses = () => [], unlink = null }) {
32
+ if (!projectId || !agentId || !path) throw new Error('ownership-unknown');
33
+ const privateDir = dirname(path);
34
+ // The kept home is derived by workAgentResume from this private git dir.
35
+ // Never accept arbitrary CODEX_HOME, downloads, or another repository root.
36
+ const expectedHome = join(privateDir, `flowviant-agent-codex-home-${agentId}`);
37
+ if (codexHome && resolve(codexHome) !== resolve(expectedHome)) throw new Error('unowned-native-home');
38
+ const roots = { scratch: join(placeDir, '.flowviant/tmp'),
39
+ ...(codexHome ? { 'native-image': join(codexHome, 'generated_images') } : {}) };
40
+ let turnId = null, before = null;
41
+ const changedBeforeTurn = {};
42
+ const stores = {};
43
+ for (const [kind, root] of Object.entries(roots)) {
44
+ stores[kind] = createArtifactCache({ placeDir, path: `${path}.${kind}`, scope: { projectId, agentId },
45
+ managedKind: kind, managedRoot: root, getUses: name => getUses({ kind, name }),
46
+ ...(unlink ? { unlink } : {}),
47
+ fetchVersion: async ref => {
48
+ const owned = stores[kind].records().find(f => f.name === ref.name && f.sha256 === ref.sha256);
49
+ const copy = owned?.preserved;
50
+ if (!outputCache || !copy) throw new Error('preserved-copy-unknown');
51
+ const recorded = outputCache.records().find(f => f.name === copy.name && f.localVersion === copy.localVersion && f.sha256 === ref.sha256 && f.remote?.id === ref.remote.id);
52
+ if (!recorded || recorded.state === 'discarded' || recorded.uses?.includes('pending-upload')) throw new Error('preserved-version-changed');
53
+ const local = outputCache.measure(copy.name);
54
+ if (local.sha256 !== ref.sha256) throw new Error('preserved-version-changed');
55
+ const bytes = await outputCache.verifyRemote({ ...recorded, remote: recorded.remote });
56
+ const after = outputCache.records().find(f => f.name === copy.name && f.localVersion === copy.localVersion && f.remote?.id === ref.remote.id);
57
+ if (!after || after.uses?.includes('pending-upload') || after.state !== 'local' || outputCache.measure(copy.name).signature !== local.signature) throw new Error('preserved-version-changed');
58
+ return bytes;
59
+ } });
60
+ }
61
+ const begin = id => {
62
+ if (!id) throw new Error('turn-unknown');
63
+ turnId = id;
64
+ before = Object.fromEntries(Object.entries(roots).map(([kind, root]) => {
65
+ try {
66
+ const measured = inventory(root), known = stores[kind].records().filter(f => measured.has(f.name));
67
+ if (known.length > 32) throw new Error('ownership-budget-exhausted');
68
+ changedBeforeTurn[kind] = new Set();
69
+ for (const ref of known) {
70
+ if (ref.signature !== measured.get(ref.name)) { changedBeforeTurn[kind].add(ref.name); continue; }
71
+ stores[kind].recordManaged({ name: ref.name, turnId });
72
+ }
73
+ return [kind, measured];
74
+ } catch { return [kind, null]; }
75
+ }));
76
+ };
77
+ const preservedCopy = (store, name) => {
78
+ if (!outputCache || !/\.(png|webp)$/i.test(name)) return null;
79
+ const measured = store.measure(name);
80
+ // Native originals can have different names. Only exact encoded bytes
81
+ // bind the copy, never a filename, pixel similarity or newest selection.
82
+ for (const ref of outputCache.records()) {
83
+ if (ref.state !== 'local' || ref.sha256 !== measured.sha256 || !ref.remote?.id || !/\.(png|webp)$/i.test(ref.name)) continue;
84
+ if (outputCache.measure(ref.name).sha256 === measured.sha256)
85
+ return { name: ref.name, localVersion: ref.localVersion, sha256: ref.sha256, remote: ref.remote };
86
+ }
87
+ return null;
88
+ };
89
+ const capture = () => {
90
+ const results = [];
91
+ if (!before) return [{ status: 'retained', reason: 'turn-ownership-unknown' }];
92
+ for (const [kind, root] of Object.entries(roots)) {
93
+ try {
94
+ if (!before[kind]) { results.push({ status: 'retained', kind, reason: 'initial-inventory-unknown' }); continue; }
95
+ const now = inventory(root), store = stores[kind], known = new Set(store.records().map(f => f.name));
96
+ let remaining = 32;
97
+ for (const [name] of now) {
98
+ if (changedBeforeTurn[kind]?.has(name)) { results.push({ status: 'retained', kind, name, reason: 'local-version-changed' }); continue; }
99
+ // A pre-existing untracked file remains the person's/unknown file,
100
+ // even if this turn rewrote it. Only newly created entries qualify.
101
+ if (before[kind].has(name) && !known.has(name)) { results.push({ status: 'retained', kind, name, reason: 'ownership-unknown' }); continue; }
102
+ if (remaining-- <= 0) { results.push({ status: 'retained', kind, name, reason: 'ownership-budget-exhausted' }); continue; }
103
+ try {
104
+ const preserved = kind === 'native-image' ? preservedCopy(store, name) : null;
105
+ store.recordManaged({ name, turnId, preserved });
106
+ } catch (error) { results.push({ status: 'retained', kind, name, reason: error.message }); }
107
+ }
108
+ } catch (error) { results.push({ status: 'retained', kind, reason: error.message }); }
109
+ }
110
+ return results;
111
+ };
112
+ /** Explicit current lifecycle evidence, not Done/upload completion. Callers
113
+ * retain revision/texture/producer/resume requirements until released. */
114
+ const release = ({ kind, name, localVersion, uses }) => {
115
+ const store = stores[kind]; if (!store) throw new Error('ownership-unknown');
116
+ const ref = store.records().find(f => f.name === name && f.localVersion === localVersion);
117
+ if (!ref) throw new Error('local-version-changed');
118
+ if (kind === 'native-image' && !ref.preserved) throw new Error('preserved-copy-unknown');
119
+ store.requirements({ name, localVersion, uses, ...(kind === 'native-image' ? {
120
+ bundle: { complete: true, files: [{ name, id: ref.remote.id, sha256: ref.sha256 }] },
121
+ } : {}) });
122
+ };
123
+ const completeTurn = ({ drained, handoffComplete, uploadsComplete }) => {
124
+ if (drained !== true || handoffComplete !== true || uploadsComplete !== true) return [];
125
+ const results = [];
126
+ try {
127
+ for (const ref of stores.scratch.records()) {
128
+ if (ref.turnId !== turnId || ref.state !== 'local') continue;
129
+ // Only the turn pin is released. Any explicit or unknown dependency
130
+ // survives completion; native originals retain their resume pin.
131
+ try { release({ kind: 'scratch', ...ref, uses: (ref.uses ?? ['requirements-unknown']).filter(u => u !== `turn:${turnId}`) }); }
132
+ catch (error) { results.push({ status: 'retained', kind: 'scratch', name: ref.name, reason: error.message }); }
133
+ }
134
+ } catch (error) { results.push({ status: 'retained', kind: 'scratch', reason: error.message }); }
135
+ return results;
136
+ };
137
+ const reclaim = async ({ drained, budget = 32 } = {}) => {
138
+ const results = [];
139
+ if (drained !== true) return [{ status: 'retained', reason: 'process-state-unknown-or-running' }];
140
+ for (const [kind, store] of Object.entries(stores)) {
141
+ if (before && !before[kind]) { results.push({ status: 'retained', kind, reason: 'initial-inventory-unknown' }); continue; }
142
+ let records;
143
+ try { records = store.records(); }
144
+ catch (error) { results.push({ status: 'retained', kind, reason: error.message }); continue; }
145
+ for (const ref of records) {
146
+ if (results.length >= budget) return results;
147
+ if (ref.state === 'discarded') continue;
148
+ const result = await store.evict(ref);
149
+ results.push({ ...result, kind, path: join(roots[kind], ref.name), projectId, agentId, placeDir });
150
+ }
151
+ }
152
+ return results;
153
+ };
154
+ const prepare = ref => stores[ref.kind]?.prepareEviction(ref) ?? { status: 'retained', reason: 'ownership-unknown' };
155
+ const reclaimOne = ref => stores[ref.kind]?.evict(ref) ?? { status: 'retained', reason: 'ownership-unknown' };
156
+ return { begin, capture, release, completeTurn, reclaim, prepare, reclaimOne,
157
+ records: () => Object.entries(stores).flatMap(([kind, store]) => store.records().map(ref => ({ ...ref, kind }))) };
158
+ }
@@ -8,6 +8,8 @@ import { ensureRealSubdir } from './ownedDirs.mjs';
8
8
  import { excludeInWorktree } from './git.mjs';
9
9
  import { fleetEndpoint } from './fleetWire.mjs';
10
10
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
11
+ import { artifactMaxBytesFor } from './artifacts.mjs';
12
+ import { RESULT_INPUT_MAX_FILES } from './artifactPolicy.mjs';
11
13
 
12
14
  const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
13
15
  const hex = (s) => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s);
@@ -15,11 +17,11 @@ const safeName = (s) => typeof s === 'string' && s.length <= 500 &&
15
17
  !/[\\\x00-\x1f\x7f:]/.test(s) && s.split('/').every(p => p && p !== '.' && p !== '..');
16
18
  function inputPath(file) {
17
19
  if (!file || !/^result-[a-f0-9]{48}$/.test(file.contextId) || !hex(file.id) || !hex(file.sha256) || !safeName(file.name) ||
18
- !Number.isSafeInteger(file.size) || file.size < 0 || file.size > 20 * 1024 * 1024) throw new Error('The selected result input identity was malformed.');
20
+ !Number.isSafeInteger(file.size) || file.size < 0 || file.size > Math.max(20 * 1024 * 1024, artifactMaxBytesFor(file.name))) throw new Error('The selected result input identity was malformed.');
19
21
  return `.flowviant/inputs/${file.contextId}/${file.name}`;
20
22
  }
21
23
  function inputList(inputs) {
22
- if (!Array.isArray(inputs) || inputs.length > 128) throw new Error('The selected result inputs were malformed.');
24
+ if (!Array.isArray(inputs) || inputs.length > RESULT_INPUT_MAX_FILES) throw new Error('The selected result inputs were malformed.');
23
25
  return inputs;
24
26
  }
25
27
 
@@ -432,7 +432,7 @@ export function codexArtifactFence(cwd, { name, imageGeneration = false, what, c
432
432
  throw new Error(`${what} needs its worktree to fence — none reached codex's builder`);
433
433
  }
434
434
  const artifacts = join(cwd, ARTIFACT_DIR);
435
- // THE TURN'S OWN SCRATCH (artifacts.mjs `ensureFenceScratch`, made empty by
435
+ // THE TURN'S OWN SCRATCH (artifacts.mjs `ensureFenceScratch`, made by
436
436
  // the lane before spawn): `/tmp` is read-only in here, and a here-document
437
437
  // needs a temp file — measured, a `cat > page.html <<EOF` failed "can't
438
438
  // create temp file for here document: read-only file system" and the turn
package/bin/lib/work.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { reportLocalCleanup } from './localCleanupReport.mjs';
1
2
  /**
2
3
  * Work sessions — the Workbench tabs and the agent lanes, daemon side: THE
3
4
  * MANAGER that builds every lane and hands each one exactly what it uses.
@@ -27,6 +28,7 @@
27
28
  * whole — `workBusy` and the returned surface. A pure rule or constant
28
29
  * (REPO_PLACE, the resume-id guards, brainFor, the turn lock) is IMPORTED by
29
30
  * every lane that reads it, never carried through here.
31
+ * workLocalCleanup.mjs released managed session and agent cleanup
30
32
  * workPlaces.mjs where a session works, its worktree, its markers
31
33
  * workSessionGroups.mjs which process groups each tab started
32
34
  * workChildren.mjs the live CLI children, teardown, the admission
@@ -60,6 +62,10 @@
60
62
  * repositoryPlaces + agentRepositories: multi-repo places; the live brief: workAgentBrief, workAgentBriefRunner)
61
63
  */
62
64
 
65
+ import { dirname, join } from 'node:path';
66
+ import { placeTurnLockedByLivePid } from './turnLock.mjs';
67
+ import { createWorkLocalCleanup } from './workLocalCleanup.mjs';
68
+ import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
63
69
  import { trackedRepositorySetup } from './repositorySetup.mjs';
64
70
  import { createRepositoryPlaces } from './repositoryPlaces.mjs';
65
71
  import { createAgentRepositories } from './agentRepositories.mjs';
@@ -91,7 +97,7 @@ import { createWorkSessionRuntime } from './workSessionRuntime.mjs';
91
97
  import { createAdoptCarry } from './workAdoptCarry.mjs';
92
98
  import { createWorkSessionTurns } from './workSessionTurns.mjs';
93
99
  import { createWorkRetire } from './workRetire.mjs';
94
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
100
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, PROJECT_ID } from './config.mjs';
95
101
  import { gitNet as gitNetIn, excludeInWorktree } from './git.mjs';
96
102
  import { createProjectLandedObserver } from './projectLanded.mjs';
97
103
  import { createPlaceLock } from './placeLock.mjs';
@@ -110,6 +116,7 @@ export function createWorkManager({
110
116
  getBaseRef,
111
117
  getMcpUrl,
112
118
  getLeaseTtl, exportModel,
119
+ getCleanupPermission = () => false,
113
120
  /** "The repo picture changed — look again." See the caller in fleet.mjs. */
114
121
  onRepoChanged = () => {},
115
122
  /**
@@ -156,6 +163,7 @@ export function createWorkManager({
156
163
  */
157
164
  const artifacts = createArtifactReporter({
158
165
  fleetUrl: FLEET_URL,
166
+ projectIdentity: () => PROJECT_ID,
159
167
  token: FLEET_TOKEN,
160
168
  userAgent: USER_AGENT,
161
169
  scrub: envScrub,
@@ -211,7 +219,7 @@ export function createWorkManager({
211
219
 
212
220
  const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt } =
213
221
  createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
214
- const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
222
+ const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses, cleanupProcessesIdle } =
215
223
  createWorkSessionGroups({ repoRoot });
216
224
  const { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit } =
217
225
  createWorkChildren({ extraLiveTurns });
@@ -226,6 +234,39 @@ export function createWorkManager({
226
234
  getArtifactsAccepted,
227
235
  log: (line) => warn(line),
228
236
  });
237
+ const { reconcileLocalCleanup, cleanupBusy } = createWorkLocalCleanup({
238
+ placeLocks, inPlace, permission: getCleanupPermission,
239
+ onResult: reportLocalCleanup, onIncomplete: reportLocalCleanup,
240
+ eligible: place => {
241
+ if (shipping.size || cleanupProcessesIdle(place) !== true) return false;
242
+ const wt = placeDir(place), lock = wt && sessionMetaPath(wt, 'flowviant-turn.lock');
243
+ return Boolean(lock) && !placeTurnLockedByLivePid(lock);
244
+ },
245
+ targetsFor: place => {
246
+ if (!PROJECT_ID) return [];
247
+ const agentId = place.slice(2), siblings = repositoryPlaces?.entries(place);
248
+ const roots = siblings?.length ? siblings.filter(r => r.writable).map(r => r.wt) : [placeDir(place)];
249
+ return roots.flatMap(wt => {
250
+ const statePath = wt && sessionMetaPath(wt, 'flowviant-local-cleanup', agentId);
251
+ if (!statePath) return [];
252
+ return [{ placeDir: wt, agentId, projectId: PROJECT_ID, statePath,
253
+ operations: ({ guard, recoveryMemo, preparedOnly }) => {
254
+ const output = artifacts.cacheFor({ placeDir: wt, agentId, cleanupGuard: guard, recoveryMemo, preparedOnly });
255
+ if (!output) return [];
256
+ const managed = createManagedLocalCleanup({ placeDir: wt, projectId: PROJECT_ID, agentId,
257
+ path: sessionMetaPath(wt, 'flowviant-managed-local', agentId),
258
+ codexHome: join(dirname(statePath), `flowviant-agent-codex-home-${agentId}`),
259
+ outputCache: output, getUses: () => guard() ? [] : ['lifecycle-unknown-or-active'],
260
+ });
261
+ return [...output.records().map(ref => ({ ref: { ...ref, kind: 'artifact' },
262
+ prepare: () => output.prepareEviction(ref), run: () => output.evict(ref) })),
263
+ ...managed.records().map(ref => ({ ref,
264
+ prepare: () => managed.prepare(ref), run: () => managed.reclaimOne(ref) }))];
265
+ } }];
266
+ });
267
+ },
268
+ log: line => warn(line),
269
+ });
229
270
  const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
230
271
  createWorkPlaceReports({
231
272
  repositoryPlaces,
@@ -455,7 +496,7 @@ export function createWorkManager({
455
496
  workChildren.size > 0 ||
456
497
  workAnswering.size > 0 ||
457
498
  pendingWorkReports.size > 0 ||
458
- pendingShipReports.size > 0;
499
+ pendingShipReports.size > 0 || cleanupBusy();
459
500
 
460
501
  return {
461
502
  flushWorkReports,
@@ -473,6 +514,7 @@ export function createWorkManager({
473
514
  retireWorkSessions,
474
515
  reportWorktrees,
475
516
  reconcileIdleArtifacts,
517
+ reconcileLocalCleanup,
476
518
  shutdownWork,
477
519
  workBusy,
478
520
  runningAgentCount: () => agentTurns.size,
@@ -1,3 +1,4 @@
1
+ import { fetchResultInputs } from './resultInputs.mjs';
1
2
  import { planWork, reportPlanOutcome } from './workActivity.mjs';
2
3
  /** Agent plan claims, read-only planning turns, and proposal reports. */
3
4
  import { PLAN_RECOVERY_SYSTEM, parsePlanDiagnosis, watchPlanAttempt } from './workPlanRecovery.mjs';
@@ -287,6 +288,7 @@ export function createWorkAgentPlans({
287
288
  */
288
289
  const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map(a =>
289
290
  liveAgentContext(a, measureAgentFootprint({ placeId: a?.placeId, baseDir, baseRef })));
291
+ await fetchResultInputs(repoRoot, job.resultInputs);
290
292
  const knowledgeDir = knowledgeDirFor(repoRoot);
291
293
  discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
292
294
  discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
@@ -351,6 +353,7 @@ export function createWorkAgentPlans({
351
353
  });
352
354
  };
353
355
  let context = AGENT_PLAN_KICKOFF({ tasks, liveAgents, agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3 });
356
+ if (job.resultContext) context += '\n\n' + job.resultContext;
354
357
  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);
355
358
  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);
356
359
  if (job.recovery) {
@@ -12,10 +12,12 @@ import { gitOrNull } from './git.mjs';
12
12
  import { parsePrecheck } from './agentPlan.mjs';
13
13
  import { readStash } from './agentCards.mjs';
14
14
  import { c, note } from './ui.mjs';
15
- import { runTurn } from './runTurn.mjs';
15
+ import { runTurn as defaultRunTurn } from './runTurn.mjs';
16
16
  import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
17
17
  import { scrub as envScrub } from './uplinkScrub.mjs';
18
- import { pickRuntimeFor } from './runtimeDetection.mjs';
18
+ import { detectRuntimes as defaultDetectRuntimes } from './runtimeDetection.mjs';
19
+ import { canRun, runtimeById } from './runtimes.mjs';
20
+ import { brainFor } from './workBrain.mjs';
19
21
  import { runCappedTurn } from './workCappedTurn.mjs';
20
22
  import { taskIdsFromMessage } from './worktreeDiff.mjs';
21
23
  import { limitLine } from './cliLimit.mjs';
@@ -23,12 +25,12 @@ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
23
25
  import { postFleetJson } from './fleetPost.mjs';
24
26
  import { agentBrowserHome } from './noWindowEnv.mjs';
25
27
 
26
- export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren }) {
28
+ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren, runTurn = defaultRunTurn, detectRuntimes = defaultDetectRuntimes }) {
27
29
  const AGENT_PRECHECK_URL = fleetEndpoint('agent-precheck', FLEET_URL);
28
30
 
29
31
  // ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
30
32
  //
31
- // A FRESH Claude reads the branch before the human does. The owner asked for
33
+ // A fresh conversation reads the branch before the human does. The owner asked for
32
34
  // it in these words: "before having the user manually check, can we have the
33
35
  // daemon … spawn an agent to review the work so basically we get an ai to look
34
36
  // at the review before a human looks at it for a double check."
@@ -135,15 +137,14 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
135
137
  return { text, taskIds: [...taskIds] };
136
138
  };
137
139
 
138
- const runPrecheck = async (agentId, wt, agentName, repository) => {
139
- /**
140
- * WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
141
- * planner uses, and for the same reason: this prompt was written against
142
- * Claude, and a machine with no read-only-capable runtime simply does not
143
- * produce a precheck. Nothing is posted and nothing is said on the row.
144
- */
145
- const rt = pickRuntimeFor('consult');
146
- if (!rt) return;
140
+ const runPrecheck = async (agentId, wt, agentName, repository, source) => {
141
+ // A fresh read-only conversation, on this agent's CLI/model/effort.
142
+ // Missing provenance or a missing CLI skips this optional reading; it
143
+ // never licenses a machine-wide provider substitution.
144
+ if (!source) return;
145
+ const rt = source.runtime || 'claude';
146
+ if (!canRun(runtimeById(rt), 'consult') || !detectRuntimes().some(row => row.id === rt && row.installed)) return;
147
+ const brain = brainFor(source, rt);
147
148
  /**
148
149
  * THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
149
150
  * asks it. `churn` and never `interactive`: nobody is watching this, and a
@@ -232,6 +233,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
232
233
  profile: 'consult',
233
234
  cwd: wt,
234
235
  runtime: rt,
236
+ ...brain,
235
237
  // The agent's own browser profile, in the worktree this reader stands
236
238
  // in; no display either way (noWindowEnv.mjs, 2026-09-29).
237
239
  browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
@@ -242,8 +244,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
242
244
  work: { kind: 'pre-review', id: agentId, name: agentName },
243
245
  // The CLI's own count for this reading. Set, never accumulated: one
244
246
  // turn is one `result` event, and the adding-up is the server's.
245
- // Tagged with the CLI, so a Claude pre-review of a Codex agent is
246
- // charged as Claude's and not folded into the agent's own figure.
247
+ // Tagged with the same CLI as the associated agent.
247
248
  onUsage: (u) => {
248
249
  usage = { ...u, runtime: rt };
249
250
  },