flowviant 0.125.23 → 0.125.25

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 CHANGED
@@ -328,6 +328,20 @@ pinned because [npm 12 removed shrinkwrap support](https://github.com/npm/cli/bl
328
328
  and omits it from tarballs; npm 12 consumers also ignore that lockfile.
329
329
  No sibling repository or separate app deploy key is required.
330
330
 
331
+ ### Agent worktree startup and restart protection (0.125.24)
332
+
333
+ Card launches verify their exact Git worktree and repository before starting,
334
+ including after tool preparation. Fresh empty leftover directories are
335
+ initialized; nonempty invalid directories are preserved. Verified Codex
336
+ launches use an explicit cwd and the daemon's repository checks. Agent turns,
337
+ retirement and manual fast-forwarding honor live CLI PID/start-time receipts
338
+ across daemon restarts. Existing begun work still needs its worktree, surviving
339
+ branch or published recovery copy; restarting the daemon alone does not reset
340
+ a stuck board agent. No new wire floor or database migration is required.
341
+ Published on npm and the standalone download channel on 2026-10-26. Running
342
+ daemons receive the version signal from the app and update when idle, unless
343
+ automatic updates are disabled.
344
+
331
345
  ### Staged artifact handoff revisions (0.125.18)
332
346
 
333
347
  A failed handoff's retry drops every pending source the agent's
@@ -297,7 +297,7 @@ export function createArtifactReporter({
297
297
 
298
298
  /** Upload what this turn changed in `placeDir`. Resolves to the number of
299
299
  * bodies SENT this call (delivered or held), for the tests and the log. */
300
- const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null, referenceCapture = false }) => {
300
+ const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null, evidenceNames = [], referenceCapture = false }) => {
301
301
  if (!placeDir || (!sessionId && !agentId)) return 0;
302
302
  const changes = iterationChanges(placeDir, before);
303
303
  const protectedNames = imageRevisionProtection(before?.stored, before?.imageRevision);
@@ -334,9 +334,13 @@ export function createArtifactReporter({
334
334
  // discovery window. Re-walk safely, then verify the built bytes against
335
335
  // the prepared version before sending anything to storage.
336
336
  const discovered = idle ? [] : listChanged(placeDir, before);
337
- const entries = required.size
338
- ? [...scanArtifacts(placeDir, { all: true, includeNames: new Set(required.keys()) }),
339
- ...discovered.filter(e => !artifactTypeFor(e.name))] // unsupported files still report by name
337
+ // Verification evidence is explicitly selected too, but never becomes a
338
+ // required producer input. Multiple review attempts can exceed the normal
339
+ // discovery window; retain their reports and captures on the same turn.
340
+ const selected = new Set([...required.keys(), ...evidenceNames]);
341
+ const entries = selected.size
342
+ ? [...scanArtifacts(placeDir, { all: true, includeNames: selected }),
343
+ ...discovered.filter(e => !selected.has(e.name) && (!required.size || !artifactTypeFor(e.name)))]
340
344
  : discovered;
341
345
  const changed = idle ? [] : entries.filter((e) =>
342
346
  !removing.has(artifactStoredName(e.name)) && (!includeNames || includeNames.has(e.name)));
@@ -11,6 +11,7 @@ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
11
11
  import { scrub, secretIn } from './uplinkScrub.mjs';
12
12
  import { exportModelPage } from './modelExport.mjs';
13
13
  import { usableModelGlb } from './modelExportProtocol.mjs';
14
+ import { uiReviewEvidenceNames } from './uiReviewArtifacts.mjs';
14
15
 
15
16
  const hash = bytes => createHash('sha256').update(bytes).digest('hex');
16
17
  const safeName = name => typeof name === 'string' && name.length <= 400 &&
@@ -18,6 +19,8 @@ const safeName = name => typeof name === 'string' && name.length <= 400 &&
18
19
  const copyPath = (sha256, name) => `.flowviant/artifacts/.handoff/${sha256}/${name}`;
19
20
  const taskOf = job => job.task?.id ?? job.repositoryTaskId ?? null;
20
21
 
22
+ export class IntermediateArtifactCapacityError extends Error {}
23
+
21
24
  export function readIntermediateFile(wt, relative, max = ARTIFACT_BINARY_MODEL_MAX_BYTES) {
22
25
  if (!safeName(relative) || !relative.startsWith('.flowviant/artifacts/')) throw new Error('Unsafe intermediate artifact path.');
23
26
  const parts = dirname(relative).split('/');
@@ -99,9 +102,22 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
99
102
  let state;
100
103
  try { state = JSON.parse(readFileSync(path, 'utf8')); }
101
104
  catch (error) { if (error.code === 'ENOENT') return { version: 1, agentId, files: [], pending: null }; throw new Error('The intermediate artifact receipt could not be read. Restore it before retrying.'); }
102
- if (state.version !== 1 || state.agentId !== agentId || !Array.isArray(state.files) || state.files.length > 160 ||
105
+ if (state.version !== 1 || state.agentId !== agentId || !Array.isArray(state.files) || state.files.length > ARTIFACT_MAX_PER_AGENT ||
103
106
  state.files.some(f => !safeName(f.name) || f.path !== copyPath(f.sha256, f.name) || !/^[a-f0-9]{64}$/.test(f.sha256)))
104
107
  throw new Error('The intermediate artifact receipt has invalid identity or paths.');
108
+ // Older daemons put verification reports and captures in this producer
109
+ // receipt. Migrate the references, never delete the original evidence.
110
+ const evidence = uiReviewEvidenceNames(state.files, ref => {
111
+ let bytes;
112
+ try { bytes = readIntermediateFile(wt, ref.path, artifactMaxBytesFor(ref.name)); }
113
+ catch (error) {
114
+ if (error.code !== 'ENOENT' || realDirState(privateFiles) !== 'dir') throw error;
115
+ bytes = readRegular(join(privateFiles, ref.sha256), artifactMaxBytesFor(ref.name));
116
+ }
117
+ if (hash(bytes) !== ref.sha256) throw new Error('Review receipt hash mismatch');
118
+ return bytes;
119
+ });
120
+ state.files = state.files.filter(ref => !evidence.has(ref.name));
105
121
  return state;
106
122
  };
107
123
  const save = state => {
@@ -145,7 +161,12 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
145
161
  const state = load(), taskId = taskOf(job);
146
162
  const identity = { agentId, taskId, turnId, taskKind };
147
163
  const standing = scanArtifacts(wt, { all: true });
148
- let changed = changedArtifacts(before, standing);
164
+ const evidence = uiReviewEvidenceNames(standing, entry => readIntermediateFile(wt, `.flowviant/artifacts/${entry.name}`, artifactMaxBytesFor(entry.name)));
165
+ const changed = changedArtifacts(before, standing);
166
+ const evidenceNames = [...new Set([...changed.map(entry => entry.name), ...(state.pending?.sources ?? []).map(ref => ref.name)].filter(name => evidence.has(name)))];
167
+ let producerChanges = changed.filter(entry => !evidence.has(entry.name));
168
+ if (state.pending && !state.pending.recovery)
169
+ state.pending.sources = state.pending.sources.filter(ref => !evidence.has(ref.name));
149
170
  // Failed export/storage retries are tied to a card and exact source hash.
150
171
  // A changed page is a new version; it never reuses a previous GLB receipt.
151
172
  // A source the agent's iteration declaration discards or replaces is the
@@ -155,11 +176,11 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
155
176
  throw new Error('An exact producer recovery is pending. Retry that producer before starting different work.');
156
177
  if (pending?.recovery) {
157
178
  identity.recoveredFromTurnId = pending.recoverySourceTurnId ?? pending.turnId;
158
- changed = changed.filter(e => pending.sources.some(s => s.name === e.name));
179
+ producerChanges = producerChanges.filter(e => pending.sources.some(s => s.name === e.name));
159
180
  }
160
181
  const discarded = pending ? declaredDiscards(wt) : new Set();
161
182
  if (pending) for (const ref of pending.sources) {
162
- if (!pending.recovery && (changed.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name)))) continue;
183
+ if (!pending.recovery && (producerChanges.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name)))) continue;
163
184
  const entry = standing.find(e => e.name === ref.name);
164
185
  let bytes;
165
186
  try { bytes = readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name)); }
@@ -167,16 +188,16 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
167
188
  if (hash(bytes) !== ref.sha256)
168
189
  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.`);
169
190
  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.`);
170
- if (!changed.some(e => e.name === entry.name)) changed.push(entry);
191
+ if (!producerChanges.some(e => e.name === entry.name)) producerChanges.push(entry);
171
192
  }
172
- if (taskKind === 'model' && !changed.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
193
+ if (taskKind === 'model' && !producerChanges.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
173
194
  for (const ref of state.files.filter(f => f.taskId === taskId && f.taskKind === 'model' && /\.html?$/i.test(f.name))) {
174
195
  const entry = standing.find(e => e.name === ref.name);
175
- if (entry) changed.push(entry);
196
+ if (entry) producerChanges.push(entry);
176
197
  }
177
198
  }
178
- const pages = taskKind === 'model' ? changed.filter(e => /\.html?$/i.test(e.name)) : [];
179
- const outputs = changed.filter(e => artifactTypeFor(e.name));
199
+ const pages = taskKind === 'model' ? producerChanges.filter(e => /\.html?$/i.test(e.name)) : [];
200
+ const outputs = producerChanges.filter(e => artifactTypeFor(e.name));
180
201
  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 ?? [] };
181
202
  save(state);
182
203
  const exports = [];
@@ -207,7 +228,12 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
207
228
  // would prune one of this very handoff's files and then falsely claim
208
229
  // complete delivery.
209
230
  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.`);
231
+ throw new IntermediateArtifactCapacityError(`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.`);
232
+ const retainedNames = new Set(state.files.map(ref => ref.name));
233
+ const newNames = outputs.filter(entry => !retainedNames.has(entry.name));
234
+ const total = retainedNames.size + newNames.length;
235
+ if (total > ARTIFACT_MAX_PER_AGENT)
236
+ throw new IntermediateArtifactCapacityError(`The intermediate artifact receipt has ${retainedNames.size} retained producer files plus ${newNames.length} new files (${total} total); artifact storage retains at most ${ARTIFACT_MAX_PER_AGENT} files per agent. Narrow the producer outputs before retrying; its handoff is unfinished.`);
211
237
  const textures = [...state.files.flatMap(f => [...(/\.png$/i.test(f.name) ? [f] : []), ...(f.model?.textureInputs ?? [])]),
212
238
  ...outputs.filter(e => /\.png$/i.test(e.name)).map(e => snapshot(wt, e, identity, retain))]
213
239
  .map(({ name, path, sha256, taskId, turnId, agentId, taskKind }) => ({ name, path, sha256, taskId, turnId, agentId, taskKind }));
@@ -225,10 +251,9 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
225
251
  // Retry all prepared files even if their mtime did not change this turn.
226
252
  const uploadBefore = Object.assign(new Map(before), before);
227
253
  for (const ref of files) uploadBefore.delete(ref.name);
228
- return { modelExports: exports, requiredOutputs: files.map(({ name, sha256 }) => ({ name, sha256 })), uploadBefore, commit: () => {
254
+ return { modelExports: exports, requiredOutputs: files.map(({ name, sha256 }) => ({ name, sha256 })), evidenceNames, uploadBefore, commit: () => {
229
255
  const byName = new Map(state.files.map(f => [f.name, f]));
230
256
  for (const ref of files) byName.set(ref.name, ref);
231
- if (byName.size > 160) throw new Error('The intermediate artifact receipt exceeds 160 files. Narrow the outputs before retrying.');
232
257
  save({ ...state, files: [...byName.values()], pending: null });
233
258
  } };
234
259
  };
@@ -204,6 +204,7 @@ function runPlainTurn(opts) {
204
204
  const stdinText = rt.stdin?.({ prompt, system, profile: turnProfile.adapterProfile }) ?? null;
205
205
  const args = rt.args({
206
206
  cardTurn: opts.cardTurn === true,
207
+ verifiedWorktree: opts.verifiedWorktree === true,
207
208
  orchestration: opts.orchestration === true,
208
209
  readOnlyCoordination: opts.readOnlyCoordination === true,
209
210
  // The lane's own measurement when it decided to orchestrate, so the
@@ -270,18 +270,17 @@ export const CODEX_RUNTIME = {
270
270
  * the prompt always carries them too. Workbench tabs keep their normal
271
271
  * checkout discovery.
272
272
  *
273
- * `--skip-git-repo-check` is deliberately NOT passed by the build or any
274
- * fence but research's: a task always builds in a git worktree, and if it
275
- * somehow is not one, failing loudly beats silently editing files nobody
276
- * can diff. A write-up reads a COPY with no `.git` and never resumes
277
- * (codexResearch.mjs), so its fence passes it.
273
+ * Agent launches verify their exact worktree root and repository ownership
274
+ * through git before every spawn. Only those launches may replace Codex's
275
+ * own repository discovery with `--skip-git-repo-check`; this grants no
276
+ * project-config trust. Research's isolated copy has its own exception.
278
277
  *
279
278
  * The prompt positional stays last; runTurn uses `-` and sends the complete
280
279
  * framed text through stdin. A whole batch can exceed Linux's single-argument
281
280
  * limit even when its argv plus environment is far below ARG_MAX. Both exec
282
281
  * and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
283
282
  */
284
- args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
283
+ args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
285
284
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
286
285
  // agy moves) — codex has no adoptable store wired yet. Reaching here
287
286
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -289,6 +288,8 @@ export const CODEX_RUNTIME = {
289
288
  // context with a different brain.
290
289
  if (adoptResumeId) throw new Error("codex has no adoptable terminal store — an adopt id can't reach this builder");
291
290
  const a = ['exec'];
291
+ // --cd belongs to exec, before resume (resume has no --cd option).
292
+ if (verifiedWorktree) a.push('--cd', cwd);
292
293
  // BY ID when the caller knows WHICH conversation this is — a Workbench
293
294
  // tab's held context, captured off thread.started and stored with its
294
295
  // worktree. `--last` resumes the machine's most recent codex conversation,
@@ -298,6 +299,7 @@ export const CODEX_RUNTIME = {
298
299
  if (resumeThreadId) a.push('resume', resumeThreadId);
299
300
  else if (resume) a.push('resume', '--last');
300
301
  a.push('--json');
302
+ if (verifiedWorktree) a.push('--skip-git-repo-check');
301
303
  if (agentTools) {
302
304
  a.push('-c', 'project_doc_max_bytes=0');
303
305
  // A person's existing developer instructions keep their authority.
@@ -18,10 +18,23 @@
18
18
  * context), so it is scoped by the tab; the unscoped file is what a daemon
19
19
  * from before this change wrote, and is still honored while its CLI lives.
20
20
  */
21
- import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
21
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
22
22
  import { basename, dirname, join } from 'node:path';
23
23
  import { aliveAndOurs, processStartTime } from './procRegistry.mjs';
24
24
 
25
+ /** The child owns the receipt, so a daemon restart still sees its live PID. */
26
+ export function recordTurnLock(path, pid) {
27
+ if (!path || !pid) return () => {};
28
+ const start = processStartTime(pid);
29
+ const value = start ? `${pid}:${start}` : String(pid);
30
+ writeFileSync(path, value);
31
+ return () => {
32
+ try {
33
+ if (readFileSync(path, 'utf8') === value) rmSync(path);
34
+ } catch { /* already removed, or replaced by another holder */ }
35
+ };
36
+ }
37
+
25
38
  /**
26
39
  * The spawn lock: the pid of the CLI currently live in this tab. A restarted
27
40
  * daemon must not resume the conversation its previous life's orphan still
@@ -157,7 +157,7 @@ export async function createUiReview({ wt, signal, capture = captureLocalReview,
157
157
  const stop = () => runController.abort();
158
158
  controller.signal.addEventListener('abort', stop, { once: true });
159
159
  const timer = setTimeout(stop, timeoutMs);
160
- const record = { status: 'unavailable', before: reviewSourceState(wt), basis: reviewDiagnostic(input.basis), fixture: reviewDiagnostic(input.fixture.description),
160
+ const record = { purpose: 'ui-review', status: 'unavailable', before: reviewSourceState(wt), basis: reviewDiagnostic(input.basis), fixture: reviewDiagnostic(input.fixture.description),
161
161
  requestedScenarios: input.scenarios.map(s => ({ name: reviewDiagnostic(s.name), path: reviewDiagnostic(s.path), actions: s.actions.map(a => ({ type: a.type, ...(a.selector ? { selector: reviewDiagnostic(a.selector) } : {}) })) })),
162
162
  scenarios: [], consoleErrors: [], failedRequests: [] };
163
163
  let child, proxy, scratch, artifactDir, untrack;
@@ -0,0 +1,30 @@
1
+ /** Identify only files explicitly owned by a measured UI-review report.
2
+ * Legacy reports predate the purpose marker. A folder prefix alone never
3
+ * exempts producer data from handoff; unknown/unreadable reports exempt none.
4
+ * read receives the entry so callers can use immutable receipt bytes. */
5
+ export function uiReviewEvidenceNames(entries, read) {
6
+ const evidence = new Set();
7
+ const byName = new Map(entries.map(entry => [entry.name, entry]));
8
+ for (const entry of entries) {
9
+ if (!/^ui-review-[a-f0-9]{8}-[a-f0-9]{3}\/report\.json$/.test(entry.name)) continue;
10
+ try {
11
+ const report = JSON.parse(read(entry));
12
+ const folder = entry.name.slice(0, -'/report.json'.length);
13
+ if (report.folder !== `.flowviant/artifacts/${folder}` ||
14
+ report.artifact !== `${report.folder}/report.json` ||
15
+ !report.before || !report.after || !Array.isArray(report.requestedScenarios) || !Array.isArray(report.scenarios) ||
16
+ (report.purpose !== undefined && report.purpose !== 'ui-review') ||
17
+ (report.screenshots !== undefined && !Array.isArray(report.screenshots))) continue;
18
+ const screenshots = report.screenshots ?? [];
19
+ if (screenshots.some(name => typeof name !== 'string' || !/^(desktop|mobile)-\d+-(initial|final|failure)\.png$/.test(name))) continue;
20
+ evidence.add(entry.name);
21
+ for (const name of screenshots) evidence.add(`${folder}/${name}`);
22
+ const delivery = byName.get(`${folder}/delivery-state.json`);
23
+ if (delivery) {
24
+ const state = JSON.parse(read(delivery));
25
+ if (state.finalState && typeof state.latestMatches === 'boolean' && Array.isArray(state.imageInputs)) evidence.add(delivery.name);
26
+ }
27
+ } catch { /* Unknown evidence remains an ordinary producer output. */ }
28
+ }
29
+ return evidence;
30
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import { tmpdir, platform } from 'node:os';
@@ -39,6 +40,7 @@ export const LOCAL_REVIEW_BOOTSTRAP = `for (const name of ['RTCPeerConnection',
39
40
  export async function captureLocalReview({ origin, scenarios, artifactDir, signal,
40
41
  browser = resolveBrowser(), spawnImpl = spawn, timeoutMs = UI_REVIEW_TIMEOUT_MS, onChild = () => () => {} }) {
41
42
  const result = { status: 'unavailable', scenarios: [], consoleErrors: [], failedRequests: [], screenshots: [] };
43
+ const capturesByHash = new Map();
42
44
  if (!browser) return { ...result, reason: 'No Chrome/Chromium/Edge found; no browser checks ran.' };
43
45
  if (browser.viaWindows) return { ...result, reason: 'WSL Windows interop cannot carry the scoped DevTools pipe.' };
44
46
  if (signal?.aborted) return { ...result, reason: 'Review was cancelled before launch.' };
@@ -158,9 +160,19 @@ export async function captureLocalReview({ origin, scenarios, artifactDir, signa
158
160
  const shot = await send('Page.captureScreenshot', { format: 'png' });
159
161
  const bytes = Buffer.from(shot.data ?? '', 'base64');
160
162
  if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new Error('Screenshot was empty or exceeded the retained artifact size.');
161
- const name = `${viewport.name}-${index + 1}-${phase}.png`;
162
- writeFileSync(join(artifactDir, name), bytes, { flag: 'wx' });
163
- row.screenshots.push(name); result.screenshots.push(name);
163
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
164
+ let name = capturesByHash.get(sha256);
165
+ if (!name) {
166
+ name = `${viewport.name}-${index + 1}-${phase}.png`;
167
+ writeFileSync(join(artifactDir, name), bytes, { flag: 'wx' });
168
+ capturesByHash.set(sha256, name);
169
+ result.screenshots.push(name);
170
+ }
171
+ // Each capture keeps its viewport/scenario/phase even when its
172
+ // exact pixels already have a retained file in this review run.
173
+ row.captures ??= [];
174
+ row.captures.push({ phase, name, sha256 });
175
+ if (!row.screenshots.includes(name)) row.screenshots.push(name);
164
176
  };
165
177
  captureFailure = () => screenshot('failure');
166
178
  await screenshot('initial');
package/bin/lib/work.mjs CHANGED
@@ -450,6 +450,7 @@ export function createWorkManager({
450
450
  // Retirement asks the agent lane which CLI to stop.
451
451
  const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
452
452
  repositoryPlaces, repoRoot,
453
+ sessionMetaPath,
453
454
  baseDir,
454
455
  baseRef,
455
456
  placeOf,
@@ -13,6 +13,7 @@
13
13
  * through the one turn-done wire (workAgentTurnReports.mjs). Null means go on.
14
14
  */
15
15
  import { existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
16
17
  import { git, sessionBranchRef } from './git.mjs';
17
18
  import { worktreePath } from './workPlaces.mjs';
18
19
 
@@ -49,6 +50,9 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
49
50
  // null directory AND a null ref below, and the null ref stands the guard
50
51
  // down — the same fail-open direction as an unreadable repo.
51
52
  const wtDir = worktreePath(baseDir, place);
53
+ // A shell left by an interrupted setup is not the agent's git worktree.
54
+ // The launch path validates ownership and readability before using it.
55
+ const hasWorktree = Boolean(wtDir && existsSync(join(wtDir, '.git')));
52
56
  const ref = sessionBranchRef(place);
53
57
  let hasBranch = false;
54
58
  /**
@@ -76,7 +80,7 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
76
80
  else branchMeasured = false;
77
81
  }
78
82
  }
79
- if (branchMeasured && !existsSync(wtDir) && !hasBranch) {
83
+ if (branchMeasured && !hasWorktree && !hasBranch) {
80
84
  /**
81
85
  * …UNLESS THE WORK IS ON THE REMOTE (0.86.0).
82
86
  *
@@ -110,6 +114,6 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
110
114
  // opens a worktree on it below — the same path a directory somebody
111
115
  // cleaned up already takes, on commits this box now genuinely holds.
112
116
  }
113
- if (hasBranch || (wtDir && existsSync(wtDir))) onAvailable();
117
+ if (hasBranch || hasWorktree) onAvailable();
114
118
  return null;
115
119
  }
@@ -31,6 +31,8 @@ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryD
31
31
  import { MODEL, PROJECT_ID } from './config.mjs';
32
32
  import { setRuntimeLimit } from './daemonState.mjs';
33
33
  import { gitOrNull } from './git.mjs';
34
+ import { assertWorktree } from './worktreeHealth.mjs';
35
+ import { placeTurnLockedByLivePid, recordTurnLock } from './turnLock.mjs';
34
36
  import { turnCommits } from './turnReceipts.mjs';
35
37
  import { addUsage } from './turnUsage.mjs';
36
38
  import { stashCard, stashGaps } from './agentCards.mjs';
@@ -65,7 +67,7 @@ import { ORCHESTRATED_POSTURES, orchestratesPosture } from './orchestratorPrompt
65
67
  import { knownCodexModels } from './runtimeOfferings.mjs';
66
68
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
67
69
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
68
- import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
70
+ import { createIntermediateArtifacts, IntermediateArtifactCapacityError } from './intermediateArtifacts.mjs';
69
71
  import { createUiReview } from './uiReview.mjs';
70
72
  import { createVideoRender } from './videoRender.mjs';
71
73
  import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
@@ -148,6 +150,10 @@ export function createAgentTurnExecution({
148
150
  return;
149
151
  }
150
152
  const wt = dir.wt;
153
+ const worktreeRepo = repositoryRun?.sources.find(r => r.wt === wt)?.repoRoot ?? repoRoot;
154
+ assertWorktree(wt, worktreeRepo);
155
+ const turnLock = sessionMetaPath(wt, 'flowviant-turn.lock', place);
156
+ if (placeTurnLockedByLivePid(sessionMetaPath(wt, 'flowviant-turn.lock'))) return;
151
157
  /**
152
158
  * NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
153
159
  * of these exit non-zero in states a real worktree reaches: `symbolic-ref`
@@ -412,6 +418,10 @@ export function createAgentTurnExecution({
412
418
  let videoNote = '';
413
419
  const runCli = (args) => {
414
420
  if (run.interrupted) return Promise.resolve('');
421
+ // Preparation awaits external work; remeasure before each fresh or
422
+ // resumed spawn, before replacing Codex's own repository discovery.
423
+ assertWorktree(wt, worktreeRepo);
424
+ args.verifiedWorktree = true;
415
425
  args = withUiReview(args, uiReviewNote, uiReview?.env);
416
426
  args = withUiReview(args, videoNote);
417
427
  return runTurnImpl(args);
@@ -667,6 +677,10 @@ export function createAgentTurnExecution({
667
677
  // (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
668
678
  // startup probe and its Terminal tabs.
669
679
  onSpawn: (ch) => {
680
+ let releaseTurnLock;
681
+ try { releaseTurnLock = recordTurnLock(turnLock, ch.pid); }
682
+ catch (error) { killGroup(ch); throw error; }
683
+ ch.once('close', releaseTurnLock);
670
684
  if (orchestrator && rt === 'codex') run.live?.connect((id, body) => orchestrator.input(id, body));
671
685
  if (rt !== 'claude') reportDelivery('agent', job, 'taken');
672
686
  brief?.running(true);
@@ -813,6 +827,9 @@ export function createAgentTurnExecution({
813
827
  try { intermediateDelivery = await intermediate.prepare({ job, turnId, taskKind, before: artifactsBefore, signal: setupController.signal }); }
814
828
  catch (error) {
815
829
  if (run.interrupted) throw error;
830
+ // A measured storage overflow must stop before sending a subset
831
+ // that could evict this producer's already retained inputs.
832
+ if (error instanceof IntermediateArtifactCapacityError) throw error;
816
833
  // The failed page still has its preview/files for repair. Reporting
817
834
  // those bytes cannot turn a failed export into a delivered card.
818
835
  try { await artifacts.report({ placeDir: wt, before: artifactsBefore, agentId, turnId, requireStored: kind.artifact != null }); }
@@ -821,7 +838,7 @@ export function createAgentTurnExecution({
821
838
  }
822
839
  }
823
840
  await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId,
824
- requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs });
841
+ requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs, evidenceNames: intermediateDelivery?.evidenceNames });
825
842
  }
826
843
 
827
844
 
@@ -13,9 +13,10 @@
13
13
  * so a lane that learns a place cannot store one unvalidated.
14
14
  */
15
15
  import { readRepositoryPlace } from './repositoryPlaces.mjs';
16
- import { existsSync, statSync } from 'node:fs';
16
+ import { existsSync, readdirSync, statSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
19
+ import { assertWorktree } from './worktreeHealth.mjs';
19
20
 
20
21
  /** The place id meaning "the checkout", not a worktree. Must match the
21
22
  * server's REPO_PLACE — it is a wire value, not a local convention. The
@@ -213,6 +214,7 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
213
214
  * same way adoption resolves a cwd, and for the same reason.
214
215
  */
215
216
  const placeWtFor = (placeId, baseAt) => {
217
+ placeWtFor.lastError = null;
216
218
  if (placeId === REPO_PLACE) {
217
219
  // The checkout. `fresh: false` on purpose — nothing was opened, so no
218
220
  // caller may treat this as a newly-cut branch.
@@ -220,7 +222,14 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
220
222
  }
221
223
  const wt = worktreePath(baseDir, placeId);
222
224
  if (!wt) return null;
223
- const fresh = !existsSync(wt);
225
+ let fresh;
226
+ try {
227
+ fresh = !existsSync(wt) || readdirSync(wt).length === 0;
228
+ if (!fresh) assertWorktree(wt, repoRoot);
229
+ } catch (error) {
230
+ placeWtFor.lastError = error.message;
231
+ return null;
232
+ }
224
233
  if (fresh) {
225
234
  const branch = sessionBranch(placeId);
226
235
  // `baseAt` is the adoption override: a tab born from a terminal session
@@ -276,6 +285,8 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
276
285
  // always did (runTurn.mjs spawns with `{...process.env}`), so anything
277
286
  // exported in the shell the daemon was started from is present here.
278
287
  }
288
+ try { assertWorktree(wt, repoRoot); }
289
+ catch (error) { placeWtFor.lastError = error.message; return null; }
279
290
  return { wt, fresh };
280
291
  };
281
292
 
@@ -12,6 +12,7 @@ import { existsSync, readdirSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { git, isSafePathSegment } from './git.mjs';
14
14
  import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
15
+ import { placeTurnLockedByLivePid } from './turnLock.mjs';
15
16
 
16
17
  export function createWorkRetire({
17
18
  repositoryPlaces,
@@ -24,6 +25,7 @@ export function createWorkRetire({
24
25
  workTokens,
25
26
  agentChildren,
26
27
  sweepMergedSessionBranch,
28
+ sessionMetaPath,
27
29
  }) {
28
30
  /**
29
31
  * Retire the worktrees of sessions the server says are CLOSED.
@@ -111,6 +113,8 @@ export function createWorkRetire({
111
113
  if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
112
114
  const wt = join(dir, id);
113
115
  try {
116
+ const places = repositoryPlaces?.stateFor(id) ? repositoryPlaces.entries(id).filter(r => r.writable).map(r => r.wt) : [wt];
117
+ if (places.some(path => placeTurnLockedByLivePid(sessionMetaPath?.(path, 'flowviant-turn.lock')))) continue;
114
118
  // Uncommitted work is the human's — a resource sweep does not outrank
115
119
  // it, closed tab or not. (The non-force remove would refuse anyway;
116
120
  // the explicit check keeps the intent legible.)
@@ -179,6 +183,7 @@ export function createWorkRetire({
179
183
  if (placeLocks.has(place)) continue;
180
184
  const wt = join(baseDir, 'sessions', place);
181
185
  try {
186
+ if (placeTurnLockedByLivePid(sessionMetaPath?.(wt, 'flowviant-turn.lock'))) continue;
182
187
  if (git(['status', '--porcelain'], wt).trim() !== '') continue;
183
188
  git(['merge', '--ff-only', baseRef()], wt);
184
189
  } catch {
@@ -0,0 +1,23 @@
1
+ /** Verify the directory itself, rather than a parent git happened to discover. */
2
+ import { realpathSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { git } from './git.mjs';
5
+
6
+ export function assertWorktree(wt, repoRoot) {
7
+ try {
8
+ const top = realpathSync(git(['rev-parse', '--show-toplevel'], wt));
9
+ if (top !== realpathSync(wt)) throw new Error('the directory is not a git worktree root');
10
+ const common = (dir) => realpathSync(resolve(dir, git(['rev-parse', '--git-common-dir'], dir)));
11
+ if (common(wt) !== common(repoRoot)) throw new Error('the worktree belongs to another repository');
12
+ const registered = git(['worktree', 'list', '--porcelain', '-z'], repoRoot).split('\0')
13
+ .some(line => {
14
+ if (!line.startsWith('worktree ')) return false;
15
+ try { return realpathSync(line.slice(9)) === top; } catch { return false; }
16
+ });
17
+ if (!registered) throw new Error('the worktree is not registered with this repository');
18
+ git(['rev-parse', '--verify', 'HEAD'], wt);
19
+ } catch (error) {
20
+ const why = error.stderr?.toString().trim() || error.message;
21
+ throw new Error(`The worktree at ${wt} is unavailable: ${why}`);
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.23",
3
+ "version": "0.125.25",
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": {