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 +12 -0
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeCodex.mjs +8 -6
- package/bin/lib/turnLock.mjs +14 -1
- package/bin/lib/work.mjs +1 -0
- package/bin/lib/workAgentTurnBegun.mjs +6 -2
- package/bin/lib/workAgentTurnExecution.mjs +14 -0
- package/bin/lib/workPlaces.mjs +13 -2
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/worktreeHealth.mjs +23 -0
- package/package.json +1 -1
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
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -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
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -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
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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.
|
package/bin/lib/turnLock.mjs
CHANGED
|
@@ -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 && !
|
|
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 ||
|
|
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);
|
package/bin/lib/workPlaces.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/bin/lib/workRetire.mjs
CHANGED
|
@@ -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.
|
|
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": {
|