flowviant 0.125.23 → 0.125.24

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,18 @@ 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
+ ### Staged 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
+ This version is staged, not published or installed.
342
+
331
343
  ### Staged artifact handoff revisions (0.125.18)
332
344
 
333
345
  A failed handoff's retry drops every pending source the agent's
@@ -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
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';
@@ -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);
@@ -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.24",
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": {