flowviant 0.125.17 → 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.
- package/README.md +9 -0
- package/bin/lib/artifactCache.mjs +336 -0
- package/bin/lib/artifactIteration.mjs +19 -1
- package/bin/lib/artifactPolicy.mjs +8 -2
- package/bin/lib/artifactReporter.mjs +89 -7
- package/bin/lib/artifacts.mjs +4 -5
- package/bin/lib/fleet.mjs +9 -0
- package/bin/lib/intermediateArtifacts.mjs +52 -15
- package/bin/lib/knowledgeLibrary.mjs +2 -1
- package/bin/lib/localCleanupReport.mjs +34 -0
- package/bin/lib/localCleanupState.mjs +68 -0
- package/bin/lib/logReplay.mjs +6 -0
- package/bin/lib/managedLocalCleanup.mjs +158 -0
- package/bin/lib/orchestratorPrompt.mjs +14 -1
- package/bin/lib/resultInputs.mjs +4 -2
- package/bin/lib/runtimeCodex.mjs +1 -1
- package/bin/lib/work.mjs +45 -3
- package/bin/lib/workAgentPlans.mjs +3 -0
- package/bin/lib/workAgentPrecheck.mjs +16 -15
- package/bin/lib/workAgentReview.mjs +2 -2
- package/bin/lib/workAgentTurnExecution.mjs +26 -2
- package/bin/lib/workAgentTurns.mjs +1 -1
- package/bin/lib/workLocalCleanup.mjs +125 -0
- package/bin/lib/workMergeBatch.mjs +19 -4
- package/bin/lib/workSessionGroups.mjs +20 -1
- package/package.json +1 -1
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { basename, dirname, join } from 'node:path';
|
|
7
|
-
import { ARTIFACT_MAX_DEPTH, artifactTypeFor, artifactMaxBytesFor, changedArtifacts, scanArtifacts } from './artifacts.mjs';
|
|
8
|
-
import { ARTIFACT_BINARY_MODEL_MAX_BYTES,
|
|
7
|
+
import { ARTIFACT_MAX_DEPTH, artifactStoredName, artifactTypeFor, artifactMaxBytesFor, changedArtifacts, scanArtifacts } from './artifacts.mjs';
|
|
8
|
+
import { ARTIFACT_BINARY_MODEL_MAX_BYTES, ARTIFACT_MAX_PER_AGENT } from './artifactPolicy.mjs';
|
|
9
|
+
import { declaredDiscards } from './artifactIteration.mjs';
|
|
9
10
|
import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
10
11
|
import { scrub, secretIn } from './uplinkScrub.mjs';
|
|
11
12
|
import { exportModelPage } from './modelExport.mjs';
|
|
@@ -32,7 +33,7 @@ function readRegular(path, max = ARTIFACT_BINARY_MODEL_MAX_BYTES) {
|
|
|
32
33
|
try {
|
|
33
34
|
const stat = fstatSync(fd);
|
|
34
35
|
if (!stat.isFile()) throw new Error('Intermediate artifact is not a regular file.');
|
|
35
|
-
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.`);
|
|
36
37
|
const bytes = Buffer.alloc(stat.size);
|
|
37
38
|
let offset = 0;
|
|
38
39
|
while (offset < bytes.length) {
|
|
@@ -115,7 +116,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
115
116
|
try {
|
|
116
117
|
if (!safeName(ref.name) || !/^[a-f0-9]{64}$/.test(ref.sha256) || ref.path !== copyPath(ref.sha256, ref.name))
|
|
117
118
|
throw new Error('Invalid intermediate artifact version identity.');
|
|
118
|
-
if (!historical && state.pending
|
|
119
|
+
if (!historical && state.pending && (state.pending.recovery === true || state.pending.taskId === ref.taskId) && state.pending.sources.some(source =>
|
|
119
120
|
source.name === ref.name || source.name === ref.model?.page)) throw new Error('Producer handoff remains unfinished');
|
|
120
121
|
let bytes;
|
|
121
122
|
try { bytes = readIntermediateFile(wt, ref.path); }
|
|
@@ -144,12 +145,21 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
144
145
|
const state = load(), taskId = taskOf(job);
|
|
145
146
|
const identity = { agentId, taskId, turnId, taskKind };
|
|
146
147
|
const standing = scanArtifacts(wt, { all: true });
|
|
147
|
-
|
|
148
|
+
let changed = changedArtifacts(before, standing);
|
|
148
149
|
// Failed export/storage retries are tied to a card and exact source hash.
|
|
149
150
|
// A changed page is a new version; it never reuses a previous GLB receipt.
|
|
150
|
-
|
|
151
|
+
// A source the agent's iteration declaration discards or replaces is the
|
|
152
|
+
// explicit revision and leaves the retry set.
|
|
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
|
+
}
|
|
160
|
+
const discarded = pending ? declaredDiscards(wt) : new Set();
|
|
151
161
|
if (pending) for (const ref of pending.sources) {
|
|
152
|
-
if (changed.some(e => e.name === ref.name)) continue;
|
|
162
|
+
if (!pending.recovery && (changed.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name)))) continue;
|
|
153
163
|
const entry = standing.find(e => e.name === ref.name);
|
|
154
164
|
let bytes;
|
|
155
165
|
try { bytes = readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name)); }
|
|
@@ -157,7 +167,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
157
167
|
if (hash(bytes) !== ref.sha256)
|
|
158
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.`);
|
|
159
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.`);
|
|
160
|
-
changed.push(entry);
|
|
170
|
+
if (!changed.some(e => e.name === entry.name)) changed.push(entry);
|
|
161
171
|
}
|
|
162
172
|
if (taskKind === 'model' && !changed.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
|
|
163
173
|
for (const ref of state.files.filter(f => f.taskId === taskId && f.taskKind === 'model' && /\.html?$/i.test(f.name))) {
|
|
@@ -167,7 +177,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
167
177
|
}
|
|
168
178
|
const pages = taskKind === 'model' ? changed.filter(e => /\.html?$/i.test(e.name)) : [];
|
|
169
179
|
const outputs = changed.filter(e => artifactTypeFor(e.name));
|
|
170
|
-
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 ?? [] };
|
|
171
181
|
save(state);
|
|
172
182
|
const exports = [];
|
|
173
183
|
for (const page of pages) {
|
|
@@ -192,11 +202,12 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
192
202
|
state.pending.sources = outputs.map(e => ({ name: e.name,
|
|
193
203
|
sha256: hash(readIntermediateFile(wt, `.flowviant/artifacts/${e.name}`, artifactMaxBytesFor(e.name))) }));
|
|
194
204
|
save(state);
|
|
195
|
-
// The server retains
|
|
196
|
-
// storage limit, but uploading a larger required set
|
|
197
|
-
// this very handoff's files and then falsely claim
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
// The server retains ARTIFACT_MAX_PER_AGENT artifacts per agent. Discovery
|
|
206
|
+
// crowding is not a storage limit, but uploading a larger required set
|
|
207
|
+
// would prune one of this very handoff's files and then falsely claim
|
|
208
|
+
// complete delivery.
|
|
209
|
+
if (outputs.length > ARTIFACT_MAX_PER_AGENT)
|
|
210
|
+
throw new Error(`This producer requires ${outputs.length} output files including model exports; artifact storage retains at most ${ARTIFACT_MAX_PER_AGENT} files per agent. Narrow this producer's output set before retrying; its handoff is unfinished.`);
|
|
200
211
|
const textures = [...state.files.flatMap(f => [...(/\.png$/i.test(f.name) ? [f] : []), ...(f.model?.textureInputs ?? [])]),
|
|
201
212
|
...outputs.filter(e => /\.png$/i.test(e.name)).map(e => snapshot(wt, e, identity, retain))]
|
|
202
213
|
.map(({ name, path, sha256, taskId, turnId, agentId, taskKind }) => ({ name, path, sha256, taskId, turnId, agentId, taskKind }));
|
|
@@ -221,7 +232,33 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
221
232
|
save({ ...state, files: [...byName.values()], pending: null });
|
|
222
233
|
} };
|
|
223
234
|
};
|
|
224
|
-
|
|
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 };
|
|
225
262
|
}
|
|
226
263
|
|
|
227
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
|
+
}
|
package/bin/lib/logReplay.mjs
CHANGED
|
@@ -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
|
+
}
|
|
@@ -22,6 +22,19 @@ export const orchestratesPosture = (posture) => Object.hasOwn(ORCHESTRATED_POSTU
|
|
|
22
22
|
*/
|
|
23
23
|
const SPLIT = `SPLIT INDEPENDENT WORK INTO NATIVE SUBAGENTS WITHOUT BEING ASKED. Before your first edit, list the card's parts in a short update. When two or more parts can proceed without waiting on each other, spawn one native in-process subagent for each independent part but one, and do that one yourself meanwhile. This holds however small the parts are; the person does not have to request it, and this contract is the authorization. Independent parts look like: several selected findings, separate files or areas (server and daemon, API and UI), tests written beside the code they cover, independent questions to investigate. A part that needs another part's result runs after that prerequisite, by you or by a child you start then. Stay solo only when the card has no independent parts, and if you keep an independent part yourself, say why in your update. Give each child a concrete title, prompt and kind (Backend/UI/Tests/Research…). Do not create extra branches or Flowviant agents. Use a native agent type that can call card_orchestrator MCP (Claude general-purpose); keep the work yourself if none can. Never give a child isolation or a different cwd: children share this card's worktree on this machine. After spawning, use describe_agent to report the kind and title you chose when its native role is generic.`;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* WRITE THE SHARED CODE FIRST (2026-10-22). Parts that depend on each other
|
|
27
|
+
* only through code they all use — a schema, a wire field, a signature — fell
|
|
28
|
+
* under "runs after that prerequisite" and ran in sequence, or a child started
|
|
29
|
+
* from a prose description of code a sibling had not written yet. Once the
|
|
30
|
+
* orchestrator commits that shared code, the parts no longer wait on each
|
|
31
|
+
* other: Next-Concept Modeling's coarse-to-fine move, where each finer level is
|
|
32
|
+
* filled in parallel from the coarser levels already decoded, never from a
|
|
33
|
+
* plan of them. A child asks rather than edits it, because its siblings build
|
|
34
|
+
* on it. Code cards only: a research child writes nothing.
|
|
35
|
+
*/
|
|
36
|
+
const SHARED_FIRST = `WRITE THE CODE THE PARTS SHARE BEFORE YOU SPLIT THEM. Parts often depend on each other only through code they all use: a type or schema, a migration, a wire field, a function or route signature, a component's props. When you list the card's parts, name that shared code too. Write it yourself first — the declarations, with stub bodies where an implementation belongs to a part — and commit it as its own commit before you spawn any child. The parts that waited only on it are now independent: split them as above. Name the shared files in each child's prompt so it builds on the committed code, not on a description of it, and tell it not to change them: a child that finds the shared code wrong or missing asks you with agent_question, and you change it yourself and tell every child building on it. Keep this step to what two or more parts actually use; a part's own internals stay with that part. When the parts share no code, skip it.`;
|
|
37
|
+
|
|
25
38
|
const RESEARCH = `THIS IS A RESEARCH CARD, SO EVERY CHILD ONLY READS. A child works inside this card's research fence: the same read fence, web policy and working directory as you. It writes no file, edits nothing and commits nothing, and the gate refuses a child's write. Give each child one question or one set of sources, and ask it to return its findings with their sources (links or repository paths) and what it could not verify. You alone deliver the card: merge the children's findings, resolve or state their conflicts, write any requested document yourself, and keep the research contract's own stops, including an investigation's numbered-findings question and its wait for the person's selection.`;
|
|
26
39
|
|
|
27
40
|
/** Each CLI's own spelling of a child's model and effort, as measured
|
|
@@ -118,7 +131,7 @@ export function orchestratorContract({ posture = 'build', taskType = null, runti
|
|
|
118
131
|
return `CARD ORCHESTRATOR — this applies to this card's one CLI and worktree.
|
|
119
132
|
You are the model the person chose and remain responsible for the card and its final JSON.
|
|
120
133
|
When an agent queue snapshot is supplied, use it across resumed and fresh turns. Execute only the active card. A person's instruction keeps its stated scope across later cards; its recorded card is provenance, not an implicit target. Give each child a focused assignment with relevant prior findings, decisions and exact prerequisite references. Upcoming work never expands a child's assignment or the active card's product/write contract.
|
|
121
|
-
${SPLIT}${taskType === 'tests' ? `\n${TESTS_SCOPE} Give every child this Tests scope explicitly.` : ''}${reads ? `\n${RESEARCH}` : ''}
|
|
134
|
+
${SPLIT}${reads ? '' : `\n${SHARED_FIRST}`}${taskType === 'tests' ? `\n${TESTS_SCOPE} Give every child this Tests scope explicitly.` : ''}${reads ? `\n${RESEARCH}` : ''}
|
|
122
135
|
${childBrain({ runtime, model, effort })}
|
|
123
136
|
|
|
124
137
|
The daemon gates every native spawn against this project's Turns at once pool, shared with its other turns. Claude children run in the background so you remain available for messages and their questions; Codex spawns are already asynchronous. A refused spawn with a waiting id has not started. Keep working yourself; do not wait for it. When notified of a free slot, retry its native spawn (the same or a reworded prompt) or its native follow-up; the slot is held for about a minute, then offered again later. Never bypass this gate with another CLI process. The orchestrator itself also holds a slot. If no slot becomes available, finish the work yourself.
|
package/bin/lib/resultInputs.mjs
CHANGED
|
@@ -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 >
|
|
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
|
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -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
|
|
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) {
|