flowviant 0.48.1 → 0.48.2
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/bin/lib/env.mjs +1 -1
- package/bin/lib/fleet.mjs +8 -2
- package/bin/lib/localSessions.mjs +78 -32
- package/bin/lib/prompts.mjs +16 -0
- package/bin/lib/update.mjs +26 -2
- package/bin/lib/work.mjs +167 -15
- package/bin/lib/worktreeDiff.mjs +18 -3
- package/package.json +1 -1
package/bin/lib/env.mjs
CHANGED
|
@@ -232,7 +232,7 @@ export async function loadCachedEnv(projectId) {
|
|
|
232
232
|
* verification in materializeInto, which refuses to write a secret that git can
|
|
233
233
|
* still see.
|
|
234
234
|
*/
|
|
235
|
-
function excludeInWorktree(wt, relPaths) {
|
|
235
|
+
export function excludeInWorktree(wt, relPaths) {
|
|
236
236
|
try {
|
|
237
237
|
let gitdir;
|
|
238
238
|
try {
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -1215,6 +1215,7 @@ export async function runFleetDaemon() {
|
|
|
1215
1215
|
retireWorkSessions,
|
|
1216
1216
|
reportWorktrees,
|
|
1217
1217
|
shutdownWork,
|
|
1218
|
+
workBusy,
|
|
1218
1219
|
} = createWorkManager({
|
|
1219
1220
|
repoRoot,
|
|
1220
1221
|
baseDir,
|
|
@@ -1804,9 +1805,14 @@ export async function runFleetDaemon() {
|
|
|
1804
1805
|
if (roster.daemon) {
|
|
1805
1806
|
// "No worker mid-task" must include the wiki runner: updating mid-sweep
|
|
1806
1807
|
// re-execs the daemon, orphans the wiki Claude, and the fresh process
|
|
1807
|
-
// starts a second sweep racing it on the same vault.
|
|
1808
|
+
// starts a second sweep racing it on the same vault. And it must include
|
|
1809
|
+
// SESSION work (workBusy — turns, ships, undelivered settle reports):
|
|
1810
|
+
// dispatch workers' children say nothing about the Workbench tabs, and a
|
|
1811
|
+
// re-exec mid-turn SIGTERMs the tab's CLI and settles a partial answer.
|
|
1808
1812
|
const safeToUpdate =
|
|
1809
|
-
!wikiBusy &&
|
|
1813
|
+
!wikiBusy &&
|
|
1814
|
+
!workBusy() &&
|
|
1815
|
+
[...workers.values()].every((w) => w.state.child == null);
|
|
1810
1816
|
const updating = handleVersionSignal({
|
|
1811
1817
|
latest: roster.daemon.latest,
|
|
1812
1818
|
min: roster.daemon.min,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
existsSync,
|
|
16
17
|
readdirSync,
|
|
17
18
|
readFileSync,
|
|
18
19
|
realpathSync,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
} from 'node:fs';
|
|
24
25
|
import { homedir } from 'node:os';
|
|
25
26
|
import { join } from 'node:path';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
26
28
|
|
|
27
29
|
const REPORT_CAP = 30;
|
|
28
30
|
// ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
|
|
@@ -35,11 +37,14 @@ const ENDED_CAP = 5;
|
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* The conversation's own title, off the transcript's `ai-title` records
|
|
38
|
-
* (the LAST one wins — titles get rewritten as a session evolves),
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* (the LAST one wins — titles get rewritten as a session evolves), and ONLY
|
|
41
|
+
* those records: this wire is presence METADATA, and the first user message
|
|
42
|
+
* — the fallback this used to relay — is transcript CONTENT wearing a
|
|
43
|
+
* title's clothes. An untitled row is honest presence; a quoted message is
|
|
44
|
+
* a leak. Those records sit anywhere in the file (measured: line 81 to line
|
|
45
|
+
* 4457), so this reads the WHOLE transcript — behind an mtime cache, because
|
|
46
|
+
* the scan runs every minute and a title only changes when the file does:
|
|
47
|
+
* steady state is a stat, not a read.
|
|
43
48
|
*/
|
|
44
49
|
const titleCache = new Map(); // file → { mtimeMs, title }
|
|
45
50
|
function transcriptTitle(file, mtimeMs) {
|
|
@@ -50,31 +55,15 @@ function transcriptTitle(file, mtimeMs) {
|
|
|
50
55
|
const stat = statSync(file);
|
|
51
56
|
// A transcript past this is not worth a read per minute of drift.
|
|
52
57
|
if (stat.size <= 64 * 1024 * 1024) {
|
|
53
|
-
let firstUser = null;
|
|
54
58
|
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
55
|
-
if (line.includes('"type":"ai-title"'))
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
} else if (!firstUser && !title && line.includes('"type":"user"') && !line.includes('"isMeta":true')) {
|
|
63
|
-
try {
|
|
64
|
-
const content = JSON.parse(line)?.message?.content;
|
|
65
|
-
const text =
|
|
66
|
-
typeof content === 'string'
|
|
67
|
-
? content
|
|
68
|
-
: Array.isArray(content)
|
|
69
|
-
? (content.find((b) => typeof b?.text === 'string')?.text ?? '')
|
|
70
|
-
: '';
|
|
71
|
-
if (text.trim() && !text.startsWith('<')) firstUser = text.trim();
|
|
72
|
-
} catch {
|
|
73
|
-
/* torn line */
|
|
74
|
-
}
|
|
59
|
+
if (!line.includes('"type":"ai-title"')) continue;
|
|
60
|
+
try {
|
|
61
|
+
const t = JSON.parse(line)?.aiTitle;
|
|
62
|
+
if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
|
|
63
|
+
} catch {
|
|
64
|
+
/* torn line */
|
|
75
65
|
}
|
|
76
66
|
}
|
|
77
|
-
if (!title && firstUser) title = firstUser;
|
|
78
67
|
if (title) title = title.replace(/\s+/g, ' ').slice(0, 120);
|
|
79
68
|
}
|
|
80
69
|
} catch {
|
|
@@ -104,7 +93,32 @@ function pidAlive(pid, procStart) {
|
|
|
104
93
|
try {
|
|
105
94
|
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
106
95
|
} catch {
|
|
107
|
-
|
|
96
|
+
// No /proc ENTRY means the process is gone — but only where /proc exists
|
|
97
|
+
// at all. On macOS there is no /proc, so this read fails for EVERY pid,
|
|
98
|
+
// every session scans as dead, and live sessions become adoptable — the
|
|
99
|
+
// exact two-drivers-on-one-conversation outcome liveness exists to
|
|
100
|
+
// prevent. Probe by name instead: `ps -o comm=` on the pid, alive only if
|
|
101
|
+
// the surviving process still LOOKS like a coding-CLI process. A bare
|
|
102
|
+
// signal-0 probe was tried first and reads a RECYCLED pid as alive
|
|
103
|
+
// forever — a crashed session whose pid a launchd service inherited would
|
|
104
|
+
// report live for weeks, refuse adoption, and suppress its own ended row.
|
|
105
|
+
// The name check keeps the conservative direction (a transient race still
|
|
106
|
+
// errs live) without the permanent false-live.
|
|
107
|
+
if (!existsSync('/proc')) {
|
|
108
|
+
try {
|
|
109
|
+
const comm = execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], {
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
112
|
+
timeout: 5_000,
|
|
113
|
+
})
|
|
114
|
+
.trim()
|
|
115
|
+
.toLowerCase();
|
|
116
|
+
return /\b(claude|node|bun|codex|agy)\b|\/(claude|node|bun|codex|agy)$/.test(comm);
|
|
117
|
+
} catch {
|
|
118
|
+
return false; // ps errored or the pid is gone — the process is dead
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false; // /proc is real and has no entry — the process is gone
|
|
108
122
|
}
|
|
109
123
|
if (procStart == null) return true; // nothing recorded to compare against
|
|
110
124
|
const close = stat.lastIndexOf(')');
|
|
@@ -389,8 +403,28 @@ function agyLastWriteMs(id) {
|
|
|
389
403
|
}
|
|
390
404
|
|
|
391
405
|
/** Any agy process on the machine right now? /proc comm scan — cheap at the
|
|
392
|
-
* 60s cadence, and the only liveness signal agy leaves (locks are inert).
|
|
406
|
+
* 60s cadence, and the only liveness signal agy leaves (locks are inert).
|
|
407
|
+
* Tri-state on purpose: true (an agy process exists), false (scanned /proc
|
|
408
|
+
* and found none), null (no /proc to scan — macOS — so the question is
|
|
409
|
+
* UNANSWERABLE here, which is a different fact from "no", and the adoption
|
|
410
|
+
* path below treats it differently: unknowable refuses, absent permits). */
|
|
393
411
|
function agyProcessAlive() {
|
|
412
|
+
if (!existsSync('/proc')) {
|
|
413
|
+
// No /proc (macOS): ask pgrep the same question. A MEASURED "none" here
|
|
414
|
+
// matters — answering null instead made every agy conversation on such a
|
|
415
|
+
// machine read live forever, which both invented a state ("live" when the
|
|
416
|
+
// truth was "unmeasured") and permanently killed agy adoption there.
|
|
417
|
+
try {
|
|
418
|
+
execFileSync('pgrep', ['-x', 'agy'], {
|
|
419
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
420
|
+
timeout: 5_000,
|
|
421
|
+
});
|
|
422
|
+
return true; // exit 0 — at least one agy process
|
|
423
|
+
} catch (e) {
|
|
424
|
+
if (e?.status === 1) return false; // pgrep ran and found none
|
|
425
|
+
return null; // pgrep itself unavailable/errored — genuinely unknowable
|
|
426
|
+
}
|
|
427
|
+
}
|
|
394
428
|
try {
|
|
395
429
|
for (const name of readdirSync('/proc')) {
|
|
396
430
|
if (!/^\d+$/.test(name)) continue;
|
|
@@ -401,7 +435,7 @@ function agyProcessAlive() {
|
|
|
401
435
|
}
|
|
402
436
|
}
|
|
403
437
|
} catch {
|
|
404
|
-
|
|
438
|
+
return null; // /proc exists but won't read — still unknowable
|
|
405
439
|
}
|
|
406
440
|
return false;
|
|
407
441
|
}
|
|
@@ -418,7 +452,15 @@ function agyProcessAlive() {
|
|
|
418
452
|
const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
|
|
419
453
|
export function isAgyConversationLive(id) {
|
|
420
454
|
try {
|
|
421
|
-
|
|
455
|
+
const up = agyProcessAlive();
|
|
456
|
+
// Unknowable is NOT "ended". Without /proc the process half of the
|
|
457
|
+
// composite cannot be measured, and calling that "no process" would make
|
|
458
|
+
// every agy conversation on such a machine adoptable — the move-adoption
|
|
459
|
+
// then puts a second driver on a store someone may still be writing.
|
|
460
|
+
// "Live" here means "refuse adoption", and refusing what we cannot verify
|
|
461
|
+
// is the same conservatism as the composite itself.
|
|
462
|
+
if (up === null) return true;
|
|
463
|
+
if (!up) return false;
|
|
422
464
|
const t = agyLastWriteMs(id);
|
|
423
465
|
return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
|
|
424
466
|
} catch {
|
|
@@ -466,7 +508,11 @@ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
|
|
|
466
508
|
out.push({
|
|
467
509
|
id,
|
|
468
510
|
cwd: real,
|
|
469
|
-
|
|
511
|
+
// Mirrors isAgyConversationLive exactly, unknowable (null) included:
|
|
512
|
+
// the report must never offer as adoptable a conversation the adopt
|
|
513
|
+
// path will then refuse — an offer wired to a refusal is the dead
|
|
514
|
+
// control this product keeps deleting.
|
|
515
|
+
live: processUp === null ? true : processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
|
|
470
516
|
lastActiveAt: new Date(lastMs).toISOString(),
|
|
471
517
|
runtime: 'antigravity',
|
|
472
518
|
});
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -493,6 +493,14 @@ rules:
|
|
|
493
493
|
10. BE PROPORTIONAL. A one-line typo fix inside the card you already hold is
|
|
494
494
|
that card's work, not a new card. When in doubt, fewer cards.
|
|
495
495
|
|
|
496
|
+
THERE IS NO LATER. Your turn ends when you stop writing, and nothing of yours
|
|
497
|
+
runs after that — so never promise to report back, keep watching, follow up, or
|
|
498
|
+
tell them the result "as soon as it finishes". If something you started is
|
|
499
|
+
still running, either wait for it inside this turn and report what happened, or
|
|
500
|
+
end by saying plainly that it is unfinished, what is still running, and how they
|
|
501
|
+
can check. A promise you cannot keep reads as a hang: they sit waiting for a
|
|
502
|
+
message that will never come.
|
|
503
|
+
|
|
496
504
|
POSTURE: terminal, not ticket. Don't ask permission to look at things. Don't
|
|
497
505
|
narrate ceremony. Ground claims in files you opened. When they ask a question,
|
|
498
506
|
answer it; when they ask for work, do it; when you spot something broken along
|
|
@@ -544,6 +552,14 @@ MECHANICS OF THIS TAB:
|
|
|
544
552
|
as well: a client that doesn't render the fence shows it as plain text, so
|
|
545
553
|
the reply has to read as a question with its options either way.
|
|
546
554
|
|
|
555
|
+
THERE IS NO LATER. Your turn ends when you stop writing, and nothing of yours
|
|
556
|
+
runs after that — so never promise to report back, keep watching, or tell them
|
|
557
|
+
the result "as soon as it finishes". If something you started is still running,
|
|
558
|
+
either wait for it inside this turn and report what happened, or end by saying
|
|
559
|
+
plainly that it is unfinished, what is still running, and how they can check. A
|
|
560
|
+
promise you cannot keep reads as a hang: they sit waiting for a message that
|
|
561
|
+
will never come.
|
|
562
|
+
|
|
547
563
|
POSTURE: terminal, not ticket. Don't ask permission to look at things. Ground
|
|
548
564
|
claims in files you opened. When they ask a question, answer it; when they ask
|
|
549
565
|
for work, do it.
|
package/bin/lib/update.mjs
CHANGED
|
@@ -87,6 +87,17 @@ export function runUpdateCommand() {
|
|
|
87
87
|
|
|
88
88
|
// Nag at most once per target version, so a poll every ~10s doesn't spam.
|
|
89
89
|
let naggedFor = null;
|
|
90
|
+
/**
|
|
91
|
+
* When the last install ATTEMPT failed, so a failure can be retried instead of
|
|
92
|
+
* being final. It used to set `naggedFor` and stop: one unwritable global
|
|
93
|
+
* prefix, one npm blip, or one offline moment and the daemon stayed on its old
|
|
94
|
+
* version until the server announced a DIFFERENT one — which on a box nobody
|
|
95
|
+
* logs into means features silently missing for days (measured: a machine sat
|
|
96
|
+
* on 0.47.0 through two releases). A readout in the app now surfaces it too,
|
|
97
|
+
* but the machine should also just try again.
|
|
98
|
+
*/
|
|
99
|
+
let lastInstallFailAt = 0;
|
|
100
|
+
const INSTALL_RETRY_MS = 15 * 60_000;
|
|
90
101
|
|
|
91
102
|
/**
|
|
92
103
|
* React to the server's {latest, min} signal from a roster poll.
|
|
@@ -110,6 +121,14 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
110
121
|
}
|
|
111
122
|
return false;
|
|
112
123
|
}
|
|
124
|
+
// A failed install is retried on a cooldown, not abandoned: the common
|
|
125
|
+
// causes (an unwritable global prefix mid-fix, a registry blip, a network
|
|
126
|
+
// that came back) are all transient, and the poll runs every ~10s so
|
|
127
|
+
// without this a single failure is effectively permanent. Checked BEFORE
|
|
128
|
+
// the npm-view probe below, which is a SYNCHRONOUS network call — during
|
|
129
|
+
// backoff every ~10s poll would otherwise block the whole event loop on a
|
|
130
|
+
// question whose answer we already decided not to act on.
|
|
131
|
+
if (Date.now() - lastInstallFailAt < INSTALL_RETRY_MS) return false;
|
|
113
132
|
// Loop guard: the server can announce a version before it's published. npm is
|
|
114
133
|
// the source of truth — only install if npm ACTUALLY has something newer than
|
|
115
134
|
// us, else `npm i -g @latest` reinstalls our own version and we'd re-exec
|
|
@@ -119,6 +138,9 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
119
138
|
published = execFileSync('npm', ['view', 'flowviant', 'version'], {
|
|
120
139
|
encoding: 'utf8',
|
|
121
140
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
141
|
+
// Synchronous, so it holds the event loop hostage for however long it
|
|
142
|
+
// runs — a hung registry must not become a hung daemon.
|
|
143
|
+
timeout: 10_000,
|
|
122
144
|
}).trim();
|
|
123
145
|
} catch {
|
|
124
146
|
/* offline / npm hiccup — treat as "can't confirm", skip this poll */
|
|
@@ -137,8 +159,10 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
|
|
|
137
159
|
reexec(teardown);
|
|
138
160
|
return true;
|
|
139
161
|
} catch (e) {
|
|
140
|
-
|
|
141
|
-
|
|
162
|
+
lastInstallFailAt = Date.now();
|
|
163
|
+
warn(
|
|
164
|
+
`self-update failed (${e?.message ?? e}) — retrying in 15m; to fix it now: npm i -g flowviant@latest`
|
|
165
|
+
);
|
|
142
166
|
return false;
|
|
143
167
|
}
|
|
144
168
|
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
SYSTEM_WORK_PLAIN,
|
|
41
41
|
WORK_TURN_KICKOFF_PLAIN,
|
|
42
42
|
} from './prompts.mjs';
|
|
43
|
-
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
43
|
+
import { materializeInto, excludeInWorktree, scrub as envScrub } from './env.mjs';
|
|
44
44
|
import { detectRuntimes, canRun, RUNTIMES } from './runtimes.mjs';
|
|
45
45
|
import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
|
|
46
46
|
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
@@ -91,6 +91,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
91
91
|
const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
|
|
92
92
|
const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
|
|
93
93
|
const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
|
|
94
|
+
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
94
95
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
95
96
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
96
97
|
const MAX_WORK_TRIES = 3;
|
|
@@ -132,9 +133,23 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
132
133
|
*/
|
|
133
134
|
const pendingWorkReports = new Map(); // turnId -> work-turn-done body
|
|
134
135
|
const pendingShipReports = new Map(); // sessionId -> ship-done body
|
|
135
|
-
/** POST a settle body.
|
|
136
|
-
*
|
|
137
|
-
*
|
|
136
|
+
/** POST a settle body. Four outcomes, and the split between the last two is
|
|
137
|
+
* load-bearing:
|
|
138
|
+
* - 'ok' — delivered.
|
|
139
|
+
* - 'terminal' — the EXPLICIT per-endpoint statuses under which the server
|
|
140
|
+
* will never re-offer the job (403 not this fleet's session, 404 unknown
|
|
141
|
+
* turn, 409 ship already settled). Only these may drop the report AND
|
|
142
|
+
* the attempts counter: they are the statuses where forgetting is safe
|
|
143
|
+
* because the job is gone server-side too.
|
|
144
|
+
* - 'reject' — any OTHER 4xx (a 400 from deploy skew, an edge/WAF rule):
|
|
145
|
+
* the server refused this BODY, but the job row may still be pending and
|
|
146
|
+
* riding every poll. The report must stay QUEUED — it is the skip-guard
|
|
147
|
+
* that stops the turn being re-run with all its side effects — but
|
|
148
|
+
* re-POSTing a body the server just refused every poll is spam, so
|
|
149
|
+
* delivery backs off. Treating this as terminal once re-ran whole
|
|
150
|
+
* non-idempotent CLI turns in a loop; treating it as plain retry
|
|
151
|
+
* hammered a refused body forever.
|
|
152
|
+
* - 'retry' — network errors, 408, 429 and 5xx: nothing was decided. */
|
|
138
153
|
const postSettle = async (url, body, terminalStatuses) => {
|
|
139
154
|
try {
|
|
140
155
|
const res = await fetch(url, {
|
|
@@ -149,26 +164,41 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
149
164
|
});
|
|
150
165
|
if (res.ok) return 'ok';
|
|
151
166
|
if (terminalStatuses.includes(res.status)) return 'terminal';
|
|
167
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
168
|
+
return 'reject';
|
|
152
169
|
return 'retry';
|
|
153
170
|
} catch {
|
|
154
171
|
return 'retry';
|
|
155
172
|
}
|
|
156
173
|
};
|
|
174
|
+
/** How long a REJECTED report sits out before re-offering its body — long
|
|
175
|
+
* enough that a deploy-skew 400 costs a handful of POSTs a day, short
|
|
176
|
+
* enough that a server fix picks the report up the same morning. */
|
|
177
|
+
const REJECT_RETRY_MS = 10 * 60 * 1000;
|
|
178
|
+
const reportBackoff = new Map(); // turnId|sessionId -> earliest next attempt
|
|
157
179
|
const settleWorkTurn = async (turnId, payload) => {
|
|
158
180
|
const body = { turnId, ...payload };
|
|
159
181
|
const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
|
|
160
|
-
if (r === 'retry'
|
|
161
|
-
|
|
182
|
+
if (r === 'retry' || r === 'reject') {
|
|
183
|
+
pendingWorkReports.set(turnId, body);
|
|
184
|
+
if (r === 'reject') reportBackoff.set(turnId, Date.now() + REJECT_RETRY_MS);
|
|
185
|
+
} else {
|
|
162
186
|
pendingWorkReports.delete(turnId);
|
|
163
187
|
workAttempts.delete(turnId);
|
|
188
|
+
reportBackoff.delete(turnId);
|
|
164
189
|
}
|
|
165
190
|
return r;
|
|
166
191
|
};
|
|
167
192
|
const settleShip = async (sessionId, payload) => {
|
|
168
193
|
const body = { sessionId, ...payload };
|
|
169
194
|
const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
|
|
170
|
-
if (r === 'retry'
|
|
171
|
-
|
|
195
|
+
if (r === 'retry' || r === 'reject') {
|
|
196
|
+
pendingShipReports.set(sessionId, body);
|
|
197
|
+
if (r === 'reject') reportBackoff.set(sessionId, Date.now() + REJECT_RETRY_MS);
|
|
198
|
+
} else {
|
|
199
|
+
pendingShipReports.delete(sessionId);
|
|
200
|
+
reportBackoff.delete(sessionId);
|
|
201
|
+
}
|
|
172
202
|
return r;
|
|
173
203
|
};
|
|
174
204
|
/**
|
|
@@ -187,7 +217,12 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
187
217
|
*/
|
|
188
218
|
const ACTIVITY_MIN_MS = 1_500;
|
|
189
219
|
const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
|
|
190
|
-
|
|
220
|
+
/** `turnId` scopes the narration to the turn that produced it: a POST
|
|
221
|
+
* already on the wire when the turn settles must not re-stamp a "working…"
|
|
222
|
+
* line over the finished reply — the server drops narration for a turn
|
|
223
|
+
* that is no longer pending. (A session-level pending count can't tell the
|
|
224
|
+
* settled turn's stale line from the queued NEXT turn's fresh one.) */
|
|
225
|
+
const makeNarrator = (sessionId, turnId) => {
|
|
191
226
|
const recent = [];
|
|
192
227
|
let lastSent = 0;
|
|
193
228
|
let dirty = false;
|
|
@@ -209,7 +244,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
209
244
|
'Content-Type': 'application/json',
|
|
210
245
|
},
|
|
211
246
|
signal: AbortSignal.timeout(10_000),
|
|
212
|
-
body: JSON.stringify({ sessionId, lines }),
|
|
247
|
+
body: JSON.stringify({ sessionId, turnId, lines }),
|
|
213
248
|
});
|
|
214
249
|
} catch {
|
|
215
250
|
/* narration is decoration — a dropped line is not an incident */
|
|
@@ -228,7 +263,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
228
263
|
};
|
|
229
264
|
return {
|
|
230
265
|
line(label) {
|
|
231
|
-
|
|
266
|
+
// Scrub, like every string that leaves this machine: a narration line
|
|
267
|
+
// is the CLI's own stdout — a command echoing an env var, a read of a
|
|
268
|
+
// config file — and it rides the same uplink the final answer does.
|
|
269
|
+
const s = envScrub(String(label ?? ''))
|
|
232
270
|
.replace(/\s+/g, ' ')
|
|
233
271
|
.trim()
|
|
234
272
|
.slice(0, 200);
|
|
@@ -328,6 +366,78 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
328
366
|
})();
|
|
329
367
|
};
|
|
330
368
|
|
|
369
|
+
/**
|
|
370
|
+
* FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
|
|
371
|
+
*
|
|
372
|
+
* A screenshot in a chat bubble is useless to an agent; a path is not. So the
|
|
373
|
+
* turn's attachments are downloaded into `.flowviant/uploads/` inside the
|
|
374
|
+
* session's own worktree and the prompt is handed the relative paths.
|
|
375
|
+
*
|
|
376
|
+
* `.flowviant/` rather than the repo proper, and gitignored-or-not it is
|
|
377
|
+
* never committed by us: these are the human's inputs to a conversation, not
|
|
378
|
+
* project files. The name is re-sanitized HERE even though the server already
|
|
379
|
+
* did it — this string becomes a path on someone's machine, and one place
|
|
380
|
+
* doing that check is one deploy away from being zero places.
|
|
381
|
+
*/
|
|
382
|
+
const UPLOAD_DIR = '.flowviant/uploads';
|
|
383
|
+
const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
384
|
+
const safeUploadName = (raw) => {
|
|
385
|
+
const base = String(raw ?? '')
|
|
386
|
+
.split(/[\\/]/)
|
|
387
|
+
.pop()
|
|
388
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
389
|
+
.replace(/^[.-]+/, '')
|
|
390
|
+
.slice(0, 80);
|
|
391
|
+
return base || 'attachment';
|
|
392
|
+
};
|
|
393
|
+
/** @returns relative paths written, in the order the human attached them. */
|
|
394
|
+
const fetchAttachments = async (wt, attachments) => {
|
|
395
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return [];
|
|
396
|
+
const dir = join(wt, UPLOAD_DIR);
|
|
397
|
+
try {
|
|
398
|
+
mkdirSync(dir, { recursive: true });
|
|
399
|
+
} catch {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
// "Never committed by us" has to be true for GIT, not just for this code:
|
|
403
|
+
// an untracked `.flowviant/` makes the whole worktree dirty, which refuses
|
|
404
|
+
// every ship, exempts the tree from closed-tab retirement forever, and
|
|
405
|
+
// shows the human's own uploads in the rail as session changes. Same
|
|
406
|
+
// mechanism as the materialized env files — the exclude file git actually
|
|
407
|
+
// reads (env.mjs), which already skips lines it has written before, so
|
|
408
|
+
// calling it per fetch is idempotent.
|
|
409
|
+
excludeInWorktree(wt, ['.flowviant/']);
|
|
410
|
+
const written = [];
|
|
411
|
+
for (const a of attachments.slice(0, 8)) {
|
|
412
|
+
if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
|
|
413
|
+
if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
|
|
414
|
+
try {
|
|
415
|
+
const res = await fetch(`${ATTACHMENT_URL}/${a.id}`, {
|
|
416
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
417
|
+
signal: AbortSignal.timeout(60_000),
|
|
418
|
+
});
|
|
419
|
+
if (!res.ok) continue;
|
|
420
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
421
|
+
if (buf.byteLength === 0 || buf.byteLength > ATTACHMENT_MAX_BYTES) continue;
|
|
422
|
+
// Collisions are real (two screenshots both named Screenshot.png), and
|
|
423
|
+
// silently overwriting one with the other loses a file the human sent.
|
|
424
|
+
let name = safeUploadName(a.name);
|
|
425
|
+
if (existsSync(join(dir, name))) {
|
|
426
|
+
const dot = name.lastIndexOf('.');
|
|
427
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
428
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
429
|
+
name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
|
|
430
|
+
}
|
|
431
|
+
writeFileSync(join(dir, name), buf);
|
|
432
|
+
written.push(`${UPLOAD_DIR}/${name}`);
|
|
433
|
+
} catch {
|
|
434
|
+
/* one file failing must not fail the turn — the prompt lists what
|
|
435
|
+
actually arrived, so the agent never chases a path that isn't there */
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return written;
|
|
439
|
+
};
|
|
440
|
+
|
|
331
441
|
let flushingReports = false;
|
|
332
442
|
const flushWorkReports = async () => {
|
|
333
443
|
if (flushingReports) return;
|
|
@@ -335,15 +445,26 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
335
445
|
flushingReports = true;
|
|
336
446
|
try {
|
|
337
447
|
for (const [id, body] of [...pendingWorkReports]) {
|
|
448
|
+
// A rejected body sits out its backoff; the queued entry itself stays
|
|
449
|
+
// — it is the skip-guard against re-running a turn whose side effects
|
|
450
|
+
// already happened.
|
|
451
|
+
if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
|
|
338
452
|
const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
|
|
339
|
-
if (r
|
|
453
|
+
if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
|
|
454
|
+
else if (r !== 'retry') {
|
|
340
455
|
pendingWorkReports.delete(id);
|
|
341
456
|
workAttempts.delete(id);
|
|
457
|
+
reportBackoff.delete(id);
|
|
342
458
|
}
|
|
343
459
|
}
|
|
344
460
|
for (const [id, body] of [...pendingShipReports]) {
|
|
461
|
+
if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
|
|
345
462
|
const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
|
|
346
|
-
if (r
|
|
463
|
+
if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
|
|
464
|
+
else if (r !== 'retry') {
|
|
465
|
+
pendingShipReports.delete(id);
|
|
466
|
+
reportBackoff.delete(id);
|
|
467
|
+
}
|
|
347
468
|
}
|
|
348
469
|
} finally {
|
|
349
470
|
flushingReports = false;
|
|
@@ -1088,9 +1209,20 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1088
1209
|
let out;
|
|
1089
1210
|
let seenThreadId = null; // codex's conversation id, off thread.started
|
|
1090
1211
|
const spawned = []; // this turn's children, for the teardown registry
|
|
1091
|
-
const narrator = makeNarrator(job.sessionId);
|
|
1212
|
+
const narrator = makeNarrator(job.sessionId, job.id);
|
|
1092
1213
|
try {
|
|
1093
|
-
|
|
1214
|
+
// Files first, then the message that references them: the agent
|
|
1215
|
+
// must be able to open what it is being told about. Only the ones
|
|
1216
|
+
// that actually landed are named.
|
|
1217
|
+
const files = await fetchAttachments(dir.wt, job.attachments);
|
|
1218
|
+
const filesNote = files.length
|
|
1219
|
+
? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${files
|
|
1220
|
+
.map((f) => `- ${f}`)
|
|
1221
|
+
.join('\n')}`
|
|
1222
|
+
: '';
|
|
1223
|
+
const message = [job.body, filesNote, adoptNote, carryNote]
|
|
1224
|
+
.filter(Boolean)
|
|
1225
|
+
.join('\n\n');
|
|
1094
1226
|
const turnArgs = {
|
|
1095
1227
|
// A plain tab has no tools to name and no session id to pass —
|
|
1096
1228
|
// its kickoff asks for one complete report instead of a stream.
|
|
@@ -1599,6 +1731,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1599
1731
|
}
|
|
1600
1732
|
};
|
|
1601
1733
|
|
|
1734
|
+
/**
|
|
1735
|
+
* Is ANY session work in flight — the answer safeToUpdate needs. A self-
|
|
1736
|
+
* update re-execs the process: a mid-turn CLI would be SIGTERM'd and its
|
|
1737
|
+
* half-finished answer settled as the tab's reply, and a queued-but-
|
|
1738
|
+
* undelivered settle report would die in memory — after which the skip-
|
|
1739
|
+
* guard's protection is gone and the re-exec'd daemon re-runs a turn whose
|
|
1740
|
+
* side effects (edits, commits, cards) already happened. `workChains` holds
|
|
1741
|
+
* an entry for every queued-or-running turn and ship (entries self-delete
|
|
1742
|
+
* when a chain drains); the other collections are belt over braces for the
|
|
1743
|
+
* windows around it.
|
|
1744
|
+
*/
|
|
1745
|
+
const workBusy = () =>
|
|
1746
|
+
workChains.size > 0 ||
|
|
1747
|
+
shipping.size > 0 ||
|
|
1748
|
+
workChildren.size > 0 ||
|
|
1749
|
+
workAnswering.size > 0 ||
|
|
1750
|
+
pendingWorkReports.size > 0 ||
|
|
1751
|
+
pendingShipReports.size > 0;
|
|
1752
|
+
|
|
1602
1753
|
return {
|
|
1603
1754
|
flushWorkReports,
|
|
1604
1755
|
processWorkTurns,
|
|
@@ -1606,5 +1757,6 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1606
1757
|
retireWorkSessions,
|
|
1607
1758
|
reportWorktrees,
|
|
1608
1759
|
shutdownWork,
|
|
1760
|
+
workBusy,
|
|
1609
1761
|
};
|
|
1610
1762
|
}
|
package/bin/lib/worktreeDiff.mjs
CHANGED
|
@@ -71,6 +71,10 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
71
71
|
let deletions = 0;
|
|
72
72
|
const push = (path, added, deleted, binary = false) => {
|
|
73
73
|
if (!path) return;
|
|
74
|
+
// Clamped to the server's zod cap (file path ≤ 300 — see the caps at the
|
|
75
|
+
// return, below): one over-cap string would 400 the whole report batch,
|
|
76
|
+
// and a readout must degrade to a shorter label, never to silence.
|
|
77
|
+
path = path.slice(0, 300);
|
|
74
78
|
files.push(binary ? { path, added, deleted, binary } : { path, added, deleted });
|
|
75
79
|
additions += added;
|
|
76
80
|
deletions += deleted;
|
|
@@ -143,7 +147,14 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
143
147
|
for (const line of raw.split('\n')) {
|
|
144
148
|
if (!line.trim()) continue;
|
|
145
149
|
const [sha, subject, author] = line.split('\x1f');
|
|
146
|
-
|
|
150
|
+
// Same clamp-to-the-server's-caps rule as the file paths: a subject or
|
|
151
|
+
// author name is whatever a person typed, and git puts no bound on it.
|
|
152
|
+
if (sha)
|
|
153
|
+
baseCommits.push({
|
|
154
|
+
sha,
|
|
155
|
+
subject: (subject ?? '').slice(0, 200),
|
|
156
|
+
author: (author ?? '').slice(0, 80),
|
|
157
|
+
});
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
160
|
} catch {
|
|
@@ -161,12 +172,16 @@ export function worktreeDiff(wt, baseRef) {
|
|
|
161
172
|
files.sort(
|
|
162
173
|
(x, y) => y.added + y.deleted - (x.added + x.deleted) || (x.path < y.path ? -1 : 1)
|
|
163
174
|
);
|
|
175
|
+
// Every string here is clamped to the server's own zod caps (branch ≤ 200,
|
|
176
|
+
// baseLabel ≤ 120, subject ≤ 200, author ≤ 80, file path ≤ 300): the report
|
|
177
|
+
// rides in a BATCH, so a single over-cap string — a generated branch name, a
|
|
178
|
+
// pathological commit subject — would 400 every session's readout at once.
|
|
164
179
|
return {
|
|
165
|
-
branch,
|
|
180
|
+
branch: branch.slice(0, 200),
|
|
166
181
|
path: wt,
|
|
167
182
|
ahead,
|
|
168
183
|
behind,
|
|
169
|
-
baseLabel: String(baseRef).replace(/^origin\//, ''),
|
|
184
|
+
baseLabel: String(baseRef).replace(/^origin\//, '').slice(0, 120),
|
|
170
185
|
baseCommits,
|
|
171
186
|
dirty,
|
|
172
187
|
additions,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.2",
|
|
4
4
|
"description": "Run your own coding CLIs as headless build agents for Flowviant — Claude Code or Codex, on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|