flowviant 0.117.0 → 0.124.1
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 +41 -0
- package/bin/cli.mjs +44 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +4 -3
- package/bin/lib/agentPlan.mjs +86 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +26 -6
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +65 -10
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +63 -5
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +170 -42
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +58 -18
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +31 -10
- package/bin/lib/runtimeCodex.mjs +28 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +48 -29
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +10 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +167 -29
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +92 -12
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +24 -3
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +219 -52
- package/bin/lib/workShip.mjs +17 -16
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +7 -3
- package/npm-shrinkwrap.json +0 -59
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { git } from './git.mjs';
|
|
21
|
+
import { measureRepositoryBaseline } from './repositoryBaseline.mjs';
|
|
22
|
+
import { REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
|
|
21
23
|
import { processStartTime } from './procRegistry.mjs';
|
|
22
24
|
import { c, note, ok, warn } from './ui.mjs';
|
|
23
25
|
import { mcpFor } from './claude.mjs';
|
|
@@ -30,6 +32,9 @@ import { isAgyConversationLive } from './agySessions.mjs';
|
|
|
30
32
|
import { adoptFence } from './localSessions.mjs';
|
|
31
33
|
import { resumeRetriesFresh } from './resumeLost.mjs';
|
|
32
34
|
import { turnLockedByLivePid } from './turnLock.mjs';
|
|
35
|
+
import { createDeliveryReporter } from './workDelivery.mjs';
|
|
36
|
+
import { createLiveMessages } from './workLiveMessages.mjs';
|
|
37
|
+
import { killGroup } from './processGroup.mjs';
|
|
33
38
|
import { printable } from './printable.mjs';
|
|
34
39
|
import { brainFor } from './workBrain.mjs';
|
|
35
40
|
import { AGY_CONV_RE, CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
|
|
@@ -48,6 +53,7 @@ export const SETTLED_HERE_MAX = 500;
|
|
|
48
53
|
|
|
49
54
|
export function createWorkSessionTurns({
|
|
50
55
|
repoRoot,
|
|
56
|
+
baseRef = () => null,
|
|
51
57
|
baseDir,
|
|
52
58
|
getMcpUrl,
|
|
53
59
|
getArtifactsAccepted,
|
|
@@ -80,10 +86,37 @@ export function createWorkSessionTurns({
|
|
|
80
86
|
runTurn = defaultRunTurn,
|
|
81
87
|
// The clock `settledHere` ages by; injectable only for its bound's test.
|
|
82
88
|
now = Date.now,
|
|
89
|
+
makeLiveMessages = createLiveMessages,
|
|
90
|
+
reportDelivery = createDeliveryReporter(),
|
|
83
91
|
}) {
|
|
84
92
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
85
93
|
const MAX_WORK_TRIES = 3;
|
|
86
94
|
|
|
95
|
+
/**
|
|
96
|
+
* INTERRUPT NOW (2026-10-13) — each turn this lane has taken, with the CLI
|
|
97
|
+
* children it spawned and whether a person asked to stop it. A stop kills
|
|
98
|
+
* each child's PROCESS GROUP: the CLI is spawned detached, so everything it
|
|
99
|
+
* started (a test run, a dev server) is in that group and goes with it —
|
|
100
|
+
* which is what interrupting a prompt means, and what Pause does too. A
|
|
101
|
+
* turn asked to stop before it spawned never spawns.
|
|
102
|
+
*/
|
|
103
|
+
const runs = new Map(); // turnId -> { interrupted, spawned: [] }
|
|
104
|
+
/** Turns this process answered `ran: false` for, so the roster's next read
|
|
105
|
+
* (made before that settle landed) is not answered again at once. */
|
|
106
|
+
const notRunningSaid = new Map(); // turnId -> when
|
|
107
|
+
/**
|
|
108
|
+
* Sessions whose run this lane just stopped. The next turn there is the one
|
|
109
|
+
* sent to interrupt it, and the roster that hands it over can have been read
|
|
110
|
+
* before the stop's settle landed — so the note does not wait on the
|
|
111
|
+
* server's `interrupted` alone. Taken once, and only for a little while.
|
|
112
|
+
*/
|
|
113
|
+
const stoppedIn = new Map(); // sessionId -> when
|
|
114
|
+
const tookStop = (sessionId) => {
|
|
115
|
+
const when = stoppedIn.get(sessionId);
|
|
116
|
+
stoppedIn.delete(sessionId);
|
|
117
|
+
return when != null && now() - when < 120_000;
|
|
118
|
+
};
|
|
119
|
+
|
|
87
120
|
/**
|
|
88
121
|
* A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
|
|
89
122
|
*
|
|
@@ -115,12 +148,14 @@ export function createWorkSessionTurns({
|
|
|
115
148
|
settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
|
|
116
149
|
settledHere.set(turnId, at);
|
|
117
150
|
forgetStale(at);
|
|
118
|
-
|
|
151
|
+
const liveMessages = runs.get(turnId)?.live?.receipts();
|
|
152
|
+
return settleWorkTurn(turnId, { ...payload, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
|
|
119
153
|
};
|
|
120
154
|
|
|
121
155
|
/**
|
|
122
|
-
* A
|
|
123
|
-
* lock's in-process half, keyed by the lock's own
|
|
156
|
+
* A TAB A TURN OF THIS PROCESS IS STARTING IN (2026-09-29; per tab since
|
|
157
|
+
* 2026-10-14) — the turn lock's in-process half, keyed by the lock's own
|
|
158
|
+
* path.
|
|
124
159
|
*
|
|
125
160
|
* `flowviant-turn.lock` is written at SPAWN, several awaits after it is
|
|
126
161
|
* read (a credential mint, the attachment fetch), so two turns of one
|
|
@@ -130,18 +165,33 @@ export function createWorkSessionTurns({
|
|
|
130
165
|
* check, so the second turn waits for a later poll like any locked one,
|
|
131
166
|
* and it is released in the turn's outer `finally`, after its settle.
|
|
132
167
|
*/
|
|
133
|
-
const startingIn = new
|
|
168
|
+
const startingIn = new Map(); // lock path -> the holding tab's measured name
|
|
169
|
+
|
|
170
|
+
// A roster can contain a turn read before its stop was requested. Never
|
|
171
|
+
// start that stale job after acknowledging the same roster's interrupt.
|
|
172
|
+
let rosterInterrupts = new Set();
|
|
134
173
|
|
|
135
174
|
const processWorkTurns = (jobs) => {
|
|
136
175
|
for (const job of jobs ?? []) {
|
|
137
176
|
if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
|
|
138
|
-
if (
|
|
177
|
+
if (rosterInterrupts.has(job.id)) continue;
|
|
178
|
+
if (workAnswering.has(job.id)) {
|
|
179
|
+
const reason = runs.get(job.id)?.waitingReason;
|
|
180
|
+
if (reason) reportDelivery('session', job, 'queued', reason);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
139
183
|
// Settled by this lane already: a roster read from before the settle
|
|
140
184
|
// landed. See `settledHere`.
|
|
141
185
|
if (settledRecently(job.id)) continue;
|
|
142
186
|
// The turn already RAN and its answer sits in the delivery queue — never
|
|
143
187
|
// run it again while the report is merely undelivered.
|
|
144
188
|
if (pendingWorkReports.has(job.id)) continue;
|
|
189
|
+
// This conversation already has a streaming Claude. Its input relay
|
|
190
|
+
// reads the durable queue; a second process must never read these words.
|
|
191
|
+
if ([...runs.values()].some((r) => r.sessionId === job.sessionId && r.live?.connected() && !r.interrupted)) {
|
|
192
|
+
reportDelivery('session', job, 'queued', 'waiting for this turn to take in the message');
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
145
195
|
/**
|
|
146
196
|
* THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
|
|
147
197
|
* CEILING. Defer: return without settling and without consuming an
|
|
@@ -162,11 +212,20 @@ export function createWorkSessionTurns({
|
|
|
162
212
|
const hold = admit('interactive');
|
|
163
213
|
if (hold) {
|
|
164
214
|
sayTurnDeferred(job.sessionId, job.id, hold.reason);
|
|
215
|
+
reportDelivery('session', job, 'queued', hold.reason);
|
|
165
216
|
continue;
|
|
166
217
|
}
|
|
167
218
|
const releaseSlot = admit.reserve();
|
|
168
219
|
lastDeferSaid.delete(job.sessionId);
|
|
169
220
|
workAnswering.add(job.id);
|
|
221
|
+
const run = { interrupted: false, spawned: [], sessionId: job.sessionId, live: null };
|
|
222
|
+
runs.set(job.id, run);
|
|
223
|
+
/** The execution generation the server handed, echoed on every settle
|
|
224
|
+
* so the server can tell this execution's answer from an older one's. */
|
|
225
|
+
const attempt = Number.isInteger(job.attempt) ? { attempt: job.attempt } : {};
|
|
226
|
+
/** The CLI, unless a person already asked to stop this turn — then no
|
|
227
|
+
* run starts, the fresh retry after an empty resume included. */
|
|
228
|
+
const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurn(args));
|
|
170
229
|
const place = job.place || job.sessionId;
|
|
171
230
|
/**
|
|
172
231
|
* VALIDATED AT THE TRUST BOUNDARY, through the same door `learnPlaces`
|
|
@@ -185,6 +244,7 @@ export function createWorkSessionTurns({
|
|
|
185
244
|
*/
|
|
186
245
|
if (!notePlace(job.sessionId, place)) {
|
|
187
246
|
void settleTurn(job.id, {
|
|
247
|
+
...attempt,
|
|
188
248
|
ok: false,
|
|
189
249
|
answer:
|
|
190
250
|
'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
|
|
@@ -194,6 +254,7 @@ export function createWorkSessionTurns({
|
|
|
194
254
|
}
|
|
195
255
|
// A READER: other turns in this place run alongside it. See `inPlace`.
|
|
196
256
|
inPlace(place, false, async () => {
|
|
257
|
+
run.waitingReason = null;
|
|
197
258
|
/** This turn's artifact snapshot — set once the place is resolved and
|
|
198
259
|
* a CLI is about to spawn there; read in the `finally`. Null on every
|
|
199
260
|
* path that never ran a CLI, which is exactly when nothing is new. */
|
|
@@ -229,7 +290,7 @@ export function createWorkSessionTurns({
|
|
|
229
290
|
* a turn fails.
|
|
230
291
|
*/
|
|
231
292
|
const spent = () => ({
|
|
232
|
-
...(toolLog
|
|
293
|
+
...(toolLog != null ? { tools: toolLog } : {}),
|
|
233
294
|
...(usage ? { usage } : {}),
|
|
234
295
|
...(model ? { model } : {}),
|
|
235
296
|
});
|
|
@@ -242,6 +303,7 @@ export function createWorkSessionTurns({
|
|
|
242
303
|
// Out of local tries: SETTLE, don't skip — a silently skipped turn
|
|
243
304
|
// strands the tab for the server's whole 24h expiry window.
|
|
244
305
|
await settleTurn(job.id, {
|
|
306
|
+
...attempt,
|
|
245
307
|
ok: false,
|
|
246
308
|
answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
|
|
247
309
|
});
|
|
@@ -271,6 +333,7 @@ export function createWorkSessionTurns({
|
|
|
271
333
|
!/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
|
|
272
334
|
) {
|
|
273
335
|
await settleTurn(job.id, {
|
|
336
|
+
...attempt,
|
|
274
337
|
ok: false,
|
|
275
338
|
answer: 'that terminal session id is not one this machine can resume',
|
|
276
339
|
});
|
|
@@ -285,6 +348,7 @@ export function createWorkSessionTurns({
|
|
|
285
348
|
}
|
|
286
349
|
if (!srcCwd) {
|
|
287
350
|
await settleTurn(job.id, {
|
|
351
|
+
...attempt,
|
|
288
352
|
ok: false,
|
|
289
353
|
answer: "the terminal session's directory no longer exists on the machine",
|
|
290
354
|
});
|
|
@@ -298,6 +362,7 @@ export function createWorkSessionTurns({
|
|
|
298
362
|
const fenced = adoptFence({ repoRoot, excludeDirs: [baseDir] }).check(srcCwd);
|
|
299
363
|
if (fenced === 'outside') {
|
|
300
364
|
await settleTurn(job.id, {
|
|
365
|
+
...attempt,
|
|
301
366
|
ok: false,
|
|
302
367
|
answer: "the terminal session's directory is outside this project's repository",
|
|
303
368
|
});
|
|
@@ -305,6 +370,7 @@ export function createWorkSessionTurns({
|
|
|
305
370
|
}
|
|
306
371
|
if (fenced === 'ours') {
|
|
307
372
|
await settleTurn(job.id, {
|
|
373
|
+
...attempt,
|
|
308
374
|
ok: false,
|
|
309
375
|
answer:
|
|
310
376
|
"that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
|
|
@@ -315,6 +381,7 @@ export function createWorkSessionTurns({
|
|
|
315
381
|
srcHead = git(['rev-parse', 'HEAD'], srcCwd);
|
|
316
382
|
} catch {
|
|
317
383
|
await settleTurn(job.id, {
|
|
384
|
+
...attempt,
|
|
318
385
|
ok: false,
|
|
319
386
|
answer:
|
|
320
387
|
"the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
|
|
@@ -332,6 +399,7 @@ export function createWorkSessionTurns({
|
|
|
332
399
|
: isTerminalSessionLive(job.adopt.id);
|
|
333
400
|
if (adoptLive) {
|
|
334
401
|
await settleTurn(job.id, {
|
|
402
|
+
...attempt,
|
|
335
403
|
ok: false,
|
|
336
404
|
answer:
|
|
337
405
|
'That terminal session is still open on the machine — close it there first, then adopt.',
|
|
@@ -348,29 +416,44 @@ export function createWorkSessionTurns({
|
|
|
348
416
|
const dir = placeWtFor(place, adopting ? srcHead : undefined);
|
|
349
417
|
if (!dir) {
|
|
350
418
|
await settleTurn(job.id, {
|
|
419
|
+
...attempt,
|
|
351
420
|
ok: false,
|
|
352
421
|
answer:
|
|
353
422
|
'the session worktree could not be opened on the machine — check the daemon log',
|
|
354
423
|
});
|
|
355
424
|
return;
|
|
356
425
|
}
|
|
357
|
-
// A live CLI is ALREADY in this
|
|
358
|
-
// life, most likely; the lock outlives a restart. Leave the
|
|
359
|
-
// pending and look again next poll; spawning a second CLI would
|
|
360
|
-
// two Claudes in one held context. Costs no attempt: nothing ran.
|
|
426
|
+
// A live CLI is ALREADY in this tab's conversation — this daemon's
|
|
427
|
+
// previous life, most likely; the lock outlives a restart. Leave the
|
|
428
|
+
// job pending and look again next poll; spawning a second CLI would
|
|
429
|
+
// put two Claudes in one held context. Costs no attempt: nothing ran.
|
|
361
430
|
// …AND ONE OF OURS MAY BE ABOUT TO: `startingIn`, checked and
|
|
362
431
|
// claimed with no await between, because the file is only written
|
|
363
|
-
// at spawn. Keyed by the lock's path (the directory
|
|
364
|
-
// dir cannot be named) — the file lock's own scope.
|
|
365
|
-
|
|
366
|
-
|
|
432
|
+
// at spawn. Keyed by the lock's path (the directory and the tab,
|
|
433
|
+
// when the git dir cannot be named) — the file lock's own scope.
|
|
434
|
+
// PER TAB (2026-10-14, turnLock.mjs): other tabs in this directory
|
|
435
|
+
// run beside it. The unscoped file is an older daemon's CLI, whose
|
|
436
|
+
// tab is unknown, so it holds every tab here until it exits.
|
|
437
|
+
const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock', job.sessionId);
|
|
438
|
+
const legacyLock = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
|
|
439
|
+
const startKey = lockPath || `${dir.wt}\0${job.sessionId}`;
|
|
440
|
+
const tabId = printable(job.sessionId);
|
|
441
|
+
const tabLabel = `tab "${printable(job.sessionName) || tabId}" (${tabId})`;
|
|
442
|
+
if (turnLockedByLivePid(legacyLock)) {
|
|
443
|
+
reportDelivery('session', job, 'queued', 'waiting for another tab’s turn to finish (legacy lock; tab unknown)');
|
|
444
|
+
warn(
|
|
445
|
+
`a turn from an earlier flowviant holds the place; tab unknown (legacy lock) — ${tabLabel} waits for it to finish`
|
|
446
|
+
);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
367
449
|
if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
|
|
450
|
+
reportDelivery('session', job, 'queued', 'waiting for this turn to finish');
|
|
368
451
|
warn(
|
|
369
|
-
|
|
452
|
+
`${startingIn.get(startKey) || tabLabel} holds the turn lock — waiting for it to finish`
|
|
370
453
|
);
|
|
371
454
|
return;
|
|
372
455
|
}
|
|
373
|
-
startingIn.
|
|
456
|
+
startingIn.set(startKey, tabLabel);
|
|
374
457
|
startClaim = startKey;
|
|
375
458
|
// WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
|
|
376
459
|
// which is what every tab ran on until now) — honored by
|
|
@@ -379,12 +462,17 @@ export function createWorkSessionTurns({
|
|
|
379
462
|
// What the artifact directory held BEFORE this turn — the capture chat
|
|
380
463
|
// excepted: it runs read-only, is never told about artifacts, and
|
|
381
464
|
// whatever sibling tabs wrote in this shared place is theirs.
|
|
382
|
-
if (job.capture !== true)
|
|
465
|
+
if (job.capture !== true) {
|
|
466
|
+
artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt), reported: false };
|
|
467
|
+
artifactScan.instructions = getArtifactsAccepted()
|
|
468
|
+
? await artifacts.prepare?.({ placeDir: dir.wt, before: artifactScan.before, sessionId: job.sessionId }) ?? '' : '';
|
|
469
|
+
}
|
|
383
470
|
const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
|
|
384
471
|
if (rt.mismatch) {
|
|
385
472
|
// Something upstream changed this tab's identity mid-life. A held
|
|
386
473
|
// context must never be answered by a different brain — say so.
|
|
387
474
|
await settleTurn(job.id, {
|
|
475
|
+
...attempt,
|
|
388
476
|
ok: false,
|
|
389
477
|
answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
|
|
390
478
|
});
|
|
@@ -392,6 +480,7 @@ export function createWorkSessionTurns({
|
|
|
392
480
|
}
|
|
393
481
|
if (rt.missing) {
|
|
394
482
|
await settleTurn(job.id, {
|
|
483
|
+
...attempt,
|
|
395
484
|
ok: false,
|
|
396
485
|
answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
|
|
397
486
|
});
|
|
@@ -404,6 +493,7 @@ export function createWorkSessionTurns({
|
|
|
404
493
|
// whole control plane rides one). An honest sentence beats the
|
|
405
494
|
// mcpFor throw this used to crash into every turn.
|
|
406
495
|
await settleTurn(job.id, {
|
|
496
|
+
...attempt,
|
|
407
497
|
ok: false,
|
|
408
498
|
answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
|
|
409
499
|
});
|
|
@@ -411,6 +501,7 @@ export function createWorkSessionTurns({
|
|
|
411
501
|
}
|
|
412
502
|
if (!rt.id) {
|
|
413
503
|
await settleTurn(job.id, {
|
|
504
|
+
...attempt,
|
|
414
505
|
ok: false,
|
|
415
506
|
answer:
|
|
416
507
|
'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
|
|
@@ -428,7 +519,7 @@ export function createWorkSessionTurns({
|
|
|
428
519
|
*/
|
|
429
520
|
const shape = sessionTurnShape({ runtime: rt.id, adopting, job });
|
|
430
521
|
if (shape.refusal) {
|
|
431
|
-
await settleTurn(job.id, { ok: false, answer: shape.refusal });
|
|
522
|
+
await settleTurn(job.id, { ...attempt, ok: false, answer: shape.refusal });
|
|
432
523
|
return;
|
|
433
524
|
}
|
|
434
525
|
// A PLAIN tab (agy) mounts no MCP: no credential to mint.
|
|
@@ -447,6 +538,7 @@ export function createWorkSessionTurns({
|
|
|
447
538
|
}
|
|
448
539
|
if (mint?.gone) {
|
|
449
540
|
await settleTurn(job.id, {
|
|
541
|
+
...attempt,
|
|
450
542
|
ok: false,
|
|
451
543
|
answer:
|
|
452
544
|
'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
|
|
@@ -455,6 +547,7 @@ export function createWorkSessionTurns({
|
|
|
455
547
|
}
|
|
456
548
|
if (!mint?.token) {
|
|
457
549
|
await settleTurn(job.id, {
|
|
550
|
+
...attempt,
|
|
458
551
|
ok: false,
|
|
459
552
|
answer:
|
|
460
553
|
'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
|
|
@@ -598,7 +691,7 @@ export function createWorkSessionTurns({
|
|
|
598
691
|
toolLog = turnLog.toolLog;
|
|
599
692
|
const { pushToolEvent } = turnLog;
|
|
600
693
|
const narrator = makeNarrator(job.sessionId, job.id, () =>
|
|
601
|
-
toolLog
|
|
694
|
+
toolLog
|
|
602
695
|
);
|
|
603
696
|
|
|
604
697
|
|
|
@@ -623,6 +716,17 @@ export function createWorkSessionTurns({
|
|
|
623
716
|
.filter(Boolean)
|
|
624
717
|
.join('\n\n');
|
|
625
718
|
const turnArgs = {
|
|
719
|
+
...(rt.id === 'claude' ? { liveMessages: (run.live = makeLiveMessages({
|
|
720
|
+
scope: 'session', turnId: job.id, attempt: job.attempt, cwd: dir.wt, sessionMetaPath,
|
|
721
|
+
interrupted: () => run.interrupted,
|
|
722
|
+
format: async (followup) => {
|
|
723
|
+
const paths = await fetchAttachments(dir.wt, followup.attachments);
|
|
724
|
+
const note = paths.length ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${paths.map((f) => `- ${f}`).join('\n')}` : '';
|
|
725
|
+
const words = shape.capture ? (followup.libraryPrompt ?? '') + followup.body : followup.body + (followup.libraryPrompt ?? '');
|
|
726
|
+
return sessionTurnKickoff(shape, { sessionId: job.sessionId, sessionName: job.sessionName,
|
|
727
|
+
askedByName: followup.askedByName, message: [words, note].filter(Boolean).join('\n\n') });
|
|
728
|
+
},
|
|
729
|
+
})) } : {}),
|
|
626
730
|
// The kickoff under the turn's contract: a plain tab (or a plan
|
|
627
731
|
// turn) has no tools to name and no session id to pass, and asks
|
|
628
732
|
// for one complete report instead of a stream; a capture chat
|
|
@@ -632,6 +736,8 @@ export function createWorkSessionTurns({
|
|
|
632
736
|
sessionName: job.sessionName,
|
|
633
737
|
message,
|
|
634
738
|
askedByName: job.askedByName,
|
|
739
|
+
// INTERRUPT NOW: the previous run was stopped to send this.
|
|
740
|
+
interrupted: tookStop(job.sessionId) || job.interrupted === true,
|
|
635
741
|
}),
|
|
636
742
|
// ONE PROFILE (turnProfile.mjs), the shape's: `plan` for the
|
|
637
743
|
// capture chat, `plan-mode` for a plan turn, else `build`.
|
|
@@ -647,6 +753,7 @@ export function createWorkSessionTurns({
|
|
|
647
753
|
// library that synced a second ago is in THIS turn.
|
|
648
754
|
...(() => {
|
|
649
755
|
const knowledgeDir = knowledgeDirFor(repoRoot);
|
|
756
|
+
if (job.capture === true) toolLog.baseline = measureRepositoryBaseline({ cwd: dir.wt, baseRef, knowledgeDir });
|
|
650
757
|
return {
|
|
651
758
|
// The shape's contract (a plan turn: the PLAIN one, no
|
|
652
759
|
// artifacts paragraph, the planning sentence appended). The
|
|
@@ -655,7 +762,8 @@ export function createWorkSessionTurns({
|
|
|
655
762
|
system: sessionTurnSystem(shape, {
|
|
656
763
|
knowledgeDir,
|
|
657
764
|
artifactsAccepted: getArtifactsAccepted(),
|
|
658
|
-
|
|
765
|
+
runtime: rt.id,
|
|
766
|
+
}) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : ''),
|
|
659
767
|
// The directory is OUTSIDE a worktree's cwd; Claude Code is
|
|
660
768
|
// told it may read there (`--add-dir`) rather than left to
|
|
661
769
|
// refuse a read-only capture turn a path it was just handed.
|
|
@@ -672,7 +780,8 @@ export function createWorkSessionTurns({
|
|
|
672
780
|
answerFromResult: true,
|
|
673
781
|
onActivity: (a) => {
|
|
674
782
|
narrator.line(a?.label);
|
|
675
|
-
|
|
783
|
+
// Claude/Codex commands are audited below with their correlated outcome.
|
|
784
|
+
if (rt.id !== 'claude' && rt.id !== 'codex') auditCommand(a);
|
|
676
785
|
if (a?.kind === 'tool' && APPROVAL_REFUSED.test(a.error ?? '')) approvalRefused.push(a.label);
|
|
677
786
|
},
|
|
678
787
|
// One Codex agent MESSAGE (runtimeEvents.mjs `answer`): the turn
|
|
@@ -681,7 +790,13 @@ export function createWorkSessionTurns({
|
|
|
681
790
|
if (String(a ?? '').trim()) codexSpoke = true;
|
|
682
791
|
},
|
|
683
792
|
// The structured twin of the line above — see toolLog.
|
|
684
|
-
onToolEvent:
|
|
793
|
+
onToolEvent: (name, input, meta) => {
|
|
794
|
+
const e = pushToolEvent(name, input, meta);
|
|
795
|
+
if (e?.t === 'bash') auditCommand({
|
|
796
|
+
kind: 'bash', command: input?.command ?? e.c,
|
|
797
|
+
observation: { id: e.id, outcome: e.outcome, exitCode: e.exitCode, errorCategory: e.errorCategory },
|
|
798
|
+
});
|
|
799
|
+
},
|
|
685
800
|
// The CLI's own token counts, tagged with which CLI counted them
|
|
686
801
|
// — ADDED, not set: both runTurn calls below share these args.
|
|
687
802
|
onUsage: (u) => {
|
|
@@ -696,13 +811,15 @@ export function createWorkSessionTurns({
|
|
|
696
811
|
// a tab's build turn is the machine's measurement; its fenced
|
|
697
812
|
// capture chat is not.
|
|
698
813
|
onInit: (i) => {
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
// last. Last write wins on purpose: a resume that fell back to
|
|
703
|
-
// fresh reports the fresh id, healing the marker.
|
|
704
|
-
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
814
|
+
// Pin at init so a restart during a long tool can resume the
|
|
815
|
+
// same conversation and recover its native message UUIDs.
|
|
816
|
+
if (typeof i.sessionId === 'string' && i.sessionId.trim()) {
|
|
705
817
|
seenClaudeSession = i.sessionId.trim();
|
|
818
|
+
if (CLAUDE_SESSION_RE.test(seenClaudeSession)) {
|
|
819
|
+
const marker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
|
|
820
|
+
if (marker) try { writeFileSync(marker, seenClaudeSession); } catch { /* best effort */ }
|
|
821
|
+
}
|
|
822
|
+
}
|
|
706
823
|
},
|
|
707
824
|
cwd: dir.wt,
|
|
708
825
|
mcpArgs: mcp.args,
|
|
@@ -721,7 +838,11 @@ export function createWorkSessionTurns({
|
|
|
721
838
|
},
|
|
722
839
|
onSpawn: (ch) => {
|
|
723
840
|
if (!ch) return;
|
|
841
|
+
if (rt.id !== 'claude') reportDelivery('session', job, 'taken');
|
|
724
842
|
spawned.push(ch);
|
|
843
|
+
run.spawned.push(ch);
|
|
844
|
+
// Asked to stop between the check and the spawn.
|
|
845
|
+
if (run.interrupted) killGroup(ch);
|
|
725
846
|
// Keyed to the SESSION it serves — that id is what the machine
|
|
726
847
|
// snapshot charges this child's memory to.
|
|
727
848
|
workChildren.set(ch, job.sessionId);
|
|
@@ -745,7 +866,7 @@ export function createWorkSessionTurns({
|
|
|
745
866
|
}
|
|
746
867
|
},
|
|
747
868
|
};
|
|
748
|
-
out = await
|
|
869
|
+
out = await runCli({
|
|
749
870
|
...turnArgs,
|
|
750
871
|
resume,
|
|
751
872
|
resumeThreadId: codexResumeId || claudeResumeId || undefined,
|
|
@@ -786,7 +907,7 @@ export function createWorkSessionTurns({
|
|
|
786
907
|
!adopting &&
|
|
787
908
|
resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
|
|
788
909
|
) {
|
|
789
|
-
out = await
|
|
910
|
+
out = await runCli({ ...turnArgs, resume: false });
|
|
790
911
|
}
|
|
791
912
|
} finally {
|
|
792
913
|
// The CLI has stopped printing, so stop relaying. The LINE itself
|
|
@@ -809,24 +930,6 @@ export function createWorkSessionTurns({
|
|
|
809
930
|
// before it ever touches disk — it later rides in argv as
|
|
810
931
|
// `resume <id>` — and best-effort, like the runtime pin: an
|
|
811
932
|
// unwritable marker just means the tab runs fresh next turn.
|
|
812
|
-
// The conversation THIS TAB just spoke under, pinned so the next
|
|
813
|
-
// turn resumes it by id rather than asking the directory. Written
|
|
814
|
-
// after the turn for the same reason codex's is: an id learned
|
|
815
|
-
// mid-turn is only true once the turn that learned it finished.
|
|
816
|
-
if (
|
|
817
|
-
rt.id === 'claude' &&
|
|
818
|
-
seenClaudeSession &&
|
|
819
|
-
CLAUDE_SESSION_RE.test(seenClaudeSession)
|
|
820
|
-
) {
|
|
821
|
-
const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
|
|
822
|
-
if (convMarker) {
|
|
823
|
-
try {
|
|
824
|
-
writeFileSync(convMarker, seenClaudeSession);
|
|
825
|
-
} catch {
|
|
826
|
-
/* best-effort — the next turn re-learns it */
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
933
|
if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
|
|
831
934
|
const threadMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId);
|
|
832
935
|
if (threadMarker) {
|
|
@@ -837,6 +940,21 @@ export function createWorkSessionTurns({
|
|
|
837
940
|
}
|
|
838
941
|
}
|
|
839
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* A PERSON INTERRUPTED IT. Whatever the CLI printed was cut off, so
|
|
945
|
+
* it is not an answer: the turn settles `interrupted`, with what it
|
|
946
|
+
* spent and did, and the message sent to interrupt it runs next in
|
|
947
|
+
* this same conversation — the markers above already hold its id.
|
|
948
|
+
* Never spawned, it settles `ran: false`, unremembered, so the
|
|
949
|
+
* server's re-offer runs it as it was.
|
|
950
|
+
*/
|
|
951
|
+
if (run.interrupted) {
|
|
952
|
+
const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
|
|
953
|
+
if (body.ran) stoppedIn.set(job.sessionId, now());
|
|
954
|
+
await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
|
|
955
|
+
ok(`${c.cyan('tab')} ${c.dim('— interrupted')}`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
840
958
|
const answer = (out || '').trim();
|
|
841
959
|
// No output at all smells like a dead MCP credential (the lane
|
|
842
960
|
// workers' no-sentinel case) — drop the cached token so the next
|
|
@@ -848,6 +966,7 @@ export function createWorkSessionTurns({
|
|
|
848
966
|
// exactly that; no sessionRef is recorded, so the server keeps
|
|
849
967
|
// offering the adoption and a retry after the user checks is cheap.
|
|
850
968
|
await settleTurn(job.id, {
|
|
969
|
+
...attempt,
|
|
851
970
|
ok: false,
|
|
852
971
|
answer: "Couldn't resume the terminal session — it may have been removed.",
|
|
853
972
|
// Whatever it DID before coming back empty is exactly the
|
|
@@ -897,10 +1016,20 @@ export function createWorkSessionTurns({
|
|
|
897
1016
|
*/
|
|
898
1017
|
const loginFailure = rt.id === 'codex' && !codexSpoke ? codexLoginFailure(answer) : null;
|
|
899
1018
|
const refusedLine = rt.id === 'codex' ? codexApprovalSentence(approvalRefused) : null;
|
|
1019
|
+
let artifactFailure = null;
|
|
1020
|
+
if (artifactScan) {
|
|
1021
|
+
artifactScan.reported = true;
|
|
1022
|
+
try {
|
|
1023
|
+
await artifacts.report({ placeDir: artifactScan.dir, before: artifactScan.before, sessionId: job.sessionId, turnId: job.id });
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
artifactFailure = `Artifact delivery is incomplete: ${error.message}`;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
900
1028
|
const spoke = answer.length > 0 && !loginFailure;
|
|
901
1029
|
await settleTurn(job.id, {
|
|
902
|
-
|
|
903
|
-
|
|
1030
|
+
...attempt,
|
|
1031
|
+
ok: spoke && !artifactFailure,
|
|
1032
|
+
answer: artifactFailure ? withRelay(artifactFailure, answer) : loginFailure
|
|
904
1033
|
? withRelay(codexLoginSentence(loginFailure), answer)
|
|
905
1034
|
: answer.length > 0
|
|
906
1035
|
? refusedLine
|
|
@@ -921,7 +1050,14 @@ export function createWorkSessionTurns({
|
|
|
921
1050
|
else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
|
|
922
1051
|
else warn('session turn produced no output — settled as failed');
|
|
923
1052
|
} catch (e) {
|
|
1053
|
+
if (toolLog) toolLog.state = 'partial';
|
|
1054
|
+
if (run.interrupted) {
|
|
1055
|
+
const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
|
|
1056
|
+
await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
924
1059
|
await settleTurn(job.id, {
|
|
1060
|
+
...attempt,
|
|
925
1061
|
ok: false,
|
|
926
1062
|
// Scrub, like every string that leaves this machine: an exception
|
|
927
1063
|
// routinely quotes command output, and command output can quote a
|
|
@@ -935,6 +1071,7 @@ export function createWorkSessionTurns({
|
|
|
935
1071
|
warn(`session turn failed: ${e?.message ?? e}`);
|
|
936
1072
|
} finally {
|
|
937
1073
|
workAnswering.delete(job.id);
|
|
1074
|
+
runs.delete(job.id);
|
|
938
1075
|
if (startClaim) startingIn.delete(startClaim);
|
|
939
1076
|
// Nothing spawned, or everything already has: releasing twice is the
|
|
940
1077
|
// normal case and costs nothing. A reservation that leaked would
|
|
@@ -950,7 +1087,7 @@ export function createWorkSessionTurns({
|
|
|
950
1087
|
// …and relay what it wrote to SHOW its owner (2026-09-22) — the same
|
|
951
1088
|
// beat, the same reason it is not awaited. A failed turn's half-drawn
|
|
952
1089
|
// page is still what the turn left, and the tab should see it.
|
|
953
|
-
if (artifactScan) {
|
|
1090
|
+
if (artifactScan && !artifactScan.reported) {
|
|
954
1091
|
void artifacts
|
|
955
1092
|
.report({
|
|
956
1093
|
placeDir: artifactScan.dir,
|
|
@@ -961,9 +1098,39 @@ export function createWorkSessionTurns({
|
|
|
961
1098
|
.catch(() => {});
|
|
962
1099
|
}
|
|
963
1100
|
}
|
|
1101
|
+
}, () => {
|
|
1102
|
+
run.waitingReason = 'waiting for a ship to finish';
|
|
1103
|
+
reportDelivery('session', job, 'queued', run.waitingReason);
|
|
964
1104
|
});
|
|
965
1105
|
}
|
|
966
1106
|
};
|
|
967
1107
|
|
|
968
|
-
|
|
1108
|
+
/**
|
|
1109
|
+
* THE RUNS A PERSON ASKED TO STOP (`roster.interruptTurns`, read before the
|
|
1110
|
+
* jobs). One this lane is running is stopped — its process groups killed —
|
|
1111
|
+
* and settles `interrupted` when its CLI is gone. One it finished already
|
|
1112
|
+
* keeps its own settle. One it is not running at all is answered `ran:
|
|
1113
|
+
* false`: nothing was running here to stop, and the server puts it back in
|
|
1114
|
+
* the queue.
|
|
1115
|
+
*/
|
|
1116
|
+
const interruptWorkTurns = (ids) => {
|
|
1117
|
+
rosterInterrupts = new Set(Array.isArray(ids) ? ids : []);
|
|
1118
|
+
const at = now();
|
|
1119
|
+
for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
|
|
1120
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
1121
|
+
if (typeof id !== 'string' || !id) continue;
|
|
1122
|
+
const run = runs.get(id);
|
|
1123
|
+
if (run) {
|
|
1124
|
+
if (!run.interrupted) note(`${c.cyan('tab')} ${c.dim('— interrupting the running turn')}`);
|
|
1125
|
+
run.interrupted = true;
|
|
1126
|
+
for (const ch of run.spawned) killGroup(ch);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
if (pendingWorkReports.has(id) || settledRecently(id) || notRunningSaid.has(id)) continue;
|
|
1130
|
+
notRunningSaid.set(id, at);
|
|
1131
|
+
void Promise.resolve(settleWorkTurn(id, { ok: false, interrupted: true, ran: false, answer: '' })).catch(() => {});
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
return { processWorkTurns, interruptWorkTurns, workAnswering, settledHere };
|
|
969
1136
|
}
|
package/bin/lib/workShip.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { gitMergeIn } from './gitMergeIdentity.mjs';
|
|
|
7
7
|
import { c, note, ok, warn } from './ui.mjs';
|
|
8
8
|
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
9
9
|
import { REPO_PLACE } from './workPlaces.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { placeTurnLockHolder } from './turnLock.mjs';
|
|
11
11
|
import { printable } from './printable.mjs';
|
|
12
12
|
|
|
13
13
|
export function createWorkShipper({
|
|
@@ -227,11 +227,24 @@ export function createWorkShipper({
|
|
|
227
227
|
ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
|
|
228
228
|
return;
|
|
229
229
|
}
|
|
230
|
+
// Any tab's orphaned CLI holds this shared HEAD across a restart.
|
|
231
|
+
// This also applies when the SHIPPING tab is closed: another tab
|
|
232
|
+
// can still be editing its place. Query the directory without
|
|
233
|
+
// re-cutting a retired worktree, after the idempotent receipt path.
|
|
234
|
+
const holder = placeTurnLockHolder(sessionMetaPath(wt, 'flowviant-turn.lock'));
|
|
235
|
+
if (holder) {
|
|
236
|
+
const holderTab = holder.sessionId === null ? 'tab unknown (legacy lock)' : `tab "${printable(holder.sessionId)}"`;
|
|
237
|
+
warn(
|
|
238
|
+
`${holderTab} holds the turn lock — ship of "${sessionName || job.sessionId}" waits for it`
|
|
239
|
+
);
|
|
240
|
+
deferred = true;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
230
243
|
const ended = liveIds ? !liveIds.has(job.sessionId) : false;
|
|
231
244
|
if (ended) {
|
|
232
|
-
// The tab is closed:
|
|
233
|
-
//
|
|
234
|
-
//
|
|
245
|
+
// The tab is closed: it cannot commit, discard, or resolve, so
|
|
246
|
+
// a dirty worktree must not strand its committed work in review
|
|
247
|
+
// forever. With every other tab's CLI quiet, ship the TIP.
|
|
235
248
|
if (!branchExists) {
|
|
236
249
|
await done({
|
|
237
250
|
ok: false,
|
|
@@ -284,18 +297,6 @@ export function createWorkShipper({
|
|
|
284
297
|
});
|
|
285
298
|
return;
|
|
286
299
|
}
|
|
287
|
-
// A live CLI is in this worktree — a restarted daemon's orphan
|
|
288
|
-
// mid-turn (in-process the chain serializes, but the lock is the
|
|
289
|
-
// only guarantee that survives a crash). Folding under it would
|
|
290
|
-
// rewrite HEAD inside a held conversation; defer like the turn
|
|
291
|
-
// path, and the job re-offers next poll.
|
|
292
|
-
if (turnLockedByLivePid(sessionMetaPath(dir.wt, 'flowviant-turn.lock'))) {
|
|
293
|
-
warn(
|
|
294
|
-
`a turn is still running in "${sessionName || job.sessionId}" — ship waits for it`
|
|
295
|
-
);
|
|
296
|
-
deferred = true;
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
300
|
if (git(['status', '--porcelain'], dir.wt) !== '') {
|
|
300
301
|
await done({
|
|
301
302
|
ok: false,
|