flowviant 0.6.1 → 0.8.0
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 +6 -4
- package/bin/lib/claude.mjs +12 -11
- package/bin/lib/config.mjs +7 -5
- package/bin/lib/fleet.mjs +85 -9
- package/bin/lib/live.mjs +72 -13
- package/bin/lib/preview.mjs +25 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,14 +38,16 @@ Prefer an explicit token? Create a fleet credential in the app and pass it direc
|
|
|
38
38
|
FLOWVIANT_FLEET=fva_… npx flowviant
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
## Live mode (
|
|
41
|
+
## Live mode (the default)
|
|
42
|
+
|
|
43
|
+
Each task runs a **persistent** Claude session you can talk to mid-task from the app: the agent streams its work into the task's conversation, you `@`-mention it to steer or answer questions, and it resumes in place. Blockers park the session at zero cost until you answer. When it finishes, it posts a delivery card (summary + checklist self-report) in the thread — a human confirms done by merging there.
|
|
44
|
+
|
|
45
|
+
Prefer the legacy one-shot poll mode (no streaming, no previews)? Escape hatch:
|
|
42
46
|
|
|
43
47
|
```bash
|
|
44
|
-
|
|
48
|
+
FLOWVIANT_POLL=1 npx flowviant
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
Each task runs a **persistent** Claude session you can talk to mid-task from the app: the agent streams its work into the task's conversation, you `@`-mention it to steer or answer questions, and it resumes in place. Blockers park the session at zero cost until you answer.
|
|
48
|
-
|
|
49
51
|
### Live previews
|
|
50
52
|
|
|
51
53
|
For UI/API tasks, the daemon can start the branch's dev server in the agent's worktree and open a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel so you can drive the real running change during review — no Cloudflare account needed (it's auto-fetched if missing). Configure it once per repo, or let it infer common setups:
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -25,13 +25,14 @@ Operate this loop:
|
|
|
25
25
|
3. If you hit ANYTHING only a human can decide, call report_blocker with a clear
|
|
26
26
|
question (and options when you can), then call get_blocker_resolution. If it is
|
|
27
27
|
not yet resolved, output exactly BLOCKED:<blockerId> on its own line and STOP.
|
|
28
|
-
4.
|
|
29
|
-
proof it is met. This is what the human reviews instead of the diff.
|
|
30
|
-
5. Ship: on a revision, \`git push\` to the SAME existing branch (the PR updates in place)
|
|
28
|
+
4. Ship: on a revision, \`git push\` to the SAME existing branch (the PR updates in place)
|
|
31
29
|
and re-call attach_pr with that PR URL; otherwise open ONE draft PR (git push +
|
|
32
|
-
\`gh pr create --draft\`) and call attach_pr. Then complete
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
\`gh pr create --draft\`) and call attach_pr. Then call complete with a plain-language
|
|
31
|
+
summary of what you built AND a criteria self-report (index into the brief's
|
|
32
|
+
"done when" list + met true/false + a short note) — that becomes your delivery
|
|
33
|
+
card in the task thread. NEVER merge — a human confirms done in the thread and
|
|
34
|
+
the merge runs separately.
|
|
35
|
+
5. Return to step 1.
|
|
35
36
|
|
|
36
37
|
Keep every change scoped to the claimed intent. If a tool errors, report_progress with
|
|
37
38
|
the error, then retry or report_blocker.`;
|
|
@@ -53,12 +54,12 @@ Do EXACTLY ONE task this turn:
|
|
|
53
54
|
3. If you hit ANYTHING only a human can decide, call report_blocker (with options when
|
|
54
55
|
you can), then get_blocker_resolution. If unresolved, output exactly
|
|
55
56
|
BLOCKED:<blockerId> on its own line and STOP. Do NOT guess past a real decision.
|
|
56
|
-
4.
|
|
57
|
-
proof it is met.
|
|
58
|
-
5. Ship: if this is a revision, \`git push\` to the SAME existing branch (the open PR
|
|
57
|
+
4. Ship: if this is a revision, \`git push\` to the SAME existing branch (the open PR
|
|
59
58
|
updates in place) and re-call attach_pr with that same PR URL. Otherwise open ONE
|
|
60
|
-
draft PR (git push + \`gh pr create --draft\`) and call attach_pr. Then complete
|
|
61
|
-
|
|
59
|
+
draft PR (git push + \`gh pr create --draft\`) and call attach_pr. Then call complete
|
|
60
|
+
with a plain-language summary AND a criteria self-report (index into the brief's
|
|
61
|
+
"done when" list + met true/false + a short note) — your delivery card in the task
|
|
62
|
+
thread. NEVER merge. Then output exactly DONE on its own line and stop.
|
|
62
63
|
|
|
63
64
|
Do NOT claim a second intent — exactly one per turn. Keep every change scoped to the
|
|
64
65
|
claimed intent. If a tool errors, report_progress with the error, then retry or
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
|
-
export const VERSION = '0.
|
|
7
|
+
export const VERSION = '0.8.0';
|
|
8
8
|
|
|
9
9
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
10
10
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
|
@@ -35,10 +35,12 @@ export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
|
|
|
35
35
|
// so a long-lived daemon never silently 401s on an expired token.
|
|
36
36
|
export const REFRESH_BEFORE_SECONDS = Number(process.env.REFRESH_BEFORE_SECONDS || 3600);
|
|
37
37
|
export const SAFE = process.env.FLOWVIANT_SAFE === '1';
|
|
38
|
-
//
|
|
39
|
-
// the task channel, injectable, blocker-parks in place
|
|
40
|
-
//
|
|
41
|
-
|
|
38
|
+
// Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
|
|
39
|
+
// streams into the task channel, injectable mid-task, blocker-parks in place,
|
|
40
|
+
// delivery card on complete, branch preview tunnels. The legacy poll/sentinel
|
|
41
|
+
// path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
|
|
42
|
+
// escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
|
|
43
|
+
export const LIVE = process.env.FLOWVIANT_POLL !== '1';
|
|
42
44
|
// Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
|
|
43
45
|
// them (Node's default UA is treated as a bot). Claude Code sends its own UA.
|
|
44
46
|
export const USER_AGENT = `flowviant/${VERSION}`;
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -128,8 +128,11 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
|
|
|
128
128
|
// don't fake a blocker or a completion.
|
|
129
129
|
enter('reconnect', warn, `${c.yellow('no result')}${c.dim(' — refreshing token, retrying')}`);
|
|
130
130
|
onTokenSuspect?.(agentId);
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
// A no-sentinel turn while RESUMING a blocked task is a transient MCP/token
|
|
132
|
+
// failure, not completion — retry in place and KEEP the worktree. Resetting
|
|
133
|
+
// here would wipe the blocked task's uncommitted changes. Only a fresh-task
|
|
134
|
+
// turn (not resuming) warrants a clean slate next time.
|
|
135
|
+
if (!resuming) needsReset = true;
|
|
133
136
|
await sleep(IDLE_SECONDS);
|
|
134
137
|
}
|
|
135
138
|
info(`${label} stopped`);
|
|
@@ -186,17 +189,19 @@ export async function runFleetDaemon() {
|
|
|
186
189
|
// Merge jobs (Flowvy-commanded): approved PRs to squash-merge to main on the
|
|
187
190
|
// user's own gh. `merging` guards against re-processing a job mid-flight.
|
|
188
191
|
const MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-done');
|
|
192
|
+
const MERGE_FAILED_URL = FLEET_URL.replace(/\/agents\/?$/, '/merge-failed');
|
|
189
193
|
const merging = new Set();
|
|
190
|
-
const
|
|
194
|
+
const mergeAttempts = new Map(); // job.id -> transient-failure count
|
|
195
|
+
const reportMergeOutcome = async (url, body) => {
|
|
191
196
|
try {
|
|
192
|
-
await fetch(
|
|
197
|
+
await fetch(url, {
|
|
193
198
|
method: 'POST',
|
|
194
199
|
headers: {
|
|
195
200
|
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
196
201
|
'User-Agent': USER_AGENT,
|
|
197
202
|
'Content-Type': 'application/json',
|
|
198
203
|
},
|
|
199
|
-
body: JSON.stringify(
|
|
204
|
+
body: JSON.stringify(body),
|
|
200
205
|
});
|
|
201
206
|
} catch {
|
|
202
207
|
/* best-effort — the job reappears next poll if this failed */
|
|
@@ -210,6 +215,7 @@ export async function runFleetDaemon() {
|
|
|
210
215
|
try {
|
|
211
216
|
note(`${c.cyan('merge')} ${c.dim(`— ${job.title}`)}`);
|
|
212
217
|
let merged = false;
|
|
218
|
+
let failedReason = null; // permanent — tell the thread, clear the flag
|
|
213
219
|
try {
|
|
214
220
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
215
221
|
cwd: repoRoot,
|
|
@@ -218,14 +224,33 @@ export async function runFleetDaemon() {
|
|
|
218
224
|
merged = true;
|
|
219
225
|
} catch (e) {
|
|
220
226
|
const err = e.stderr?.toString?.() || e.message || '';
|
|
227
|
+
const line = err.split('\n')[0] || 'gh pr merge failed';
|
|
221
228
|
if (/already merged|not open|closed/i.test(err)) merged = true;
|
|
222
|
-
else if (/conflict|not mergeable|CONFLICTING/i.test(err))
|
|
223
|
-
|
|
224
|
-
|
|
229
|
+
else if (/conflict|not mergeable|CONFLICTING/i.test(err)) {
|
|
230
|
+
// Permanent until a human/agent acts — don't spin on it.
|
|
231
|
+
failedReason = `merge conflict with ${baseRef} — the branch needs a rebase`;
|
|
232
|
+
} else {
|
|
233
|
+
// Transient (auth hiccup, network, CI requirement): retry a few
|
|
234
|
+
// polls, then surface it instead of silently looping forever.
|
|
235
|
+
const n = (mergeAttempts.get(job.id) ?? 0) + 1;
|
|
236
|
+
mergeAttempts.set(job.id, n);
|
|
237
|
+
if (n >= 3) failedReason = line;
|
|
238
|
+
else warn(`merge failed for "${job.title}": ${line} — will retry`);
|
|
239
|
+
}
|
|
225
240
|
}
|
|
226
241
|
if (merged) {
|
|
227
|
-
|
|
242
|
+
mergeAttempts.delete(job.id);
|
|
243
|
+
await reportMergeOutcome(MERGE_DONE_URL, { intentId: job.id });
|
|
228
244
|
ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
|
|
245
|
+
} else if (failedReason) {
|
|
246
|
+
// Report into the thread (server narrates + re-arms the merge
|
|
247
|
+
// button + notifies) — the job disappears from the roster.
|
|
248
|
+
mergeAttempts.delete(job.id);
|
|
249
|
+
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
250
|
+
intentId: job.id,
|
|
251
|
+
message: failedReason,
|
|
252
|
+
});
|
|
253
|
+
warn(`merge failed for "${job.title}": ${failedReason} — reported to the thread`);
|
|
229
254
|
}
|
|
230
255
|
} finally {
|
|
231
256
|
merging.delete(job.id);
|
|
@@ -234,6 +259,56 @@ export async function runFleetDaemon() {
|
|
|
234
259
|
}
|
|
235
260
|
};
|
|
236
261
|
|
|
262
|
+
// Cleanup jobs (task restarts): close the abandoned PR + delete its remote
|
|
263
|
+
// branch on the user's own gh, so a restart doesn't litter the repo.
|
|
264
|
+
const CLEANUP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/cleanup-done');
|
|
265
|
+
const cleaning = new Set();
|
|
266
|
+
const processCleanupJobs = (jobs) => {
|
|
267
|
+
for (const job of jobs ?? []) {
|
|
268
|
+
if (cleaning.has(job.id)) continue;
|
|
269
|
+
cleaning.add(job.id);
|
|
270
|
+
(async () => {
|
|
271
|
+
try {
|
|
272
|
+
note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
|
|
273
|
+
if (job.prUrl) {
|
|
274
|
+
try {
|
|
275
|
+
execFileSync(
|
|
276
|
+
'gh',
|
|
277
|
+
[
|
|
278
|
+
'pr',
|
|
279
|
+
'close',
|
|
280
|
+
job.prUrl,
|
|
281
|
+
'--comment',
|
|
282
|
+
'Task restarted in Flowviant — this attempt was discarded.',
|
|
283
|
+
'--delete-branch',
|
|
284
|
+
],
|
|
285
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
286
|
+
);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// Already closed/merged/missing = fine; anything else we still
|
|
289
|
+
// report done — a restart must never wedge on stale remotes.
|
|
290
|
+
const err = e.stderr?.toString?.() || e.message || '';
|
|
291
|
+
warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
|
|
292
|
+
}
|
|
293
|
+
} else if (job.branch) {
|
|
294
|
+
try {
|
|
295
|
+
execFileSync('git', ['push', 'origin', '--delete', job.branch], {
|
|
296
|
+
cwd: repoRoot,
|
|
297
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
/* branch already gone — fine */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
|
|
304
|
+
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
305
|
+
} finally {
|
|
306
|
+
cleaning.delete(job.id);
|
|
307
|
+
}
|
|
308
|
+
})();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
237
312
|
let connected = false; // log the first successful poll once
|
|
238
313
|
let rosterSig = null; // last roster membership, to log changes only
|
|
239
314
|
let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
|
|
@@ -273,6 +348,7 @@ export async function runFleetDaemon() {
|
|
|
273
348
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
274
349
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
275
350
|
processMergeJobs(roster.mergeJobs);
|
|
351
|
+
processCleanupJobs(roster.cleanupJobs);
|
|
276
352
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
277
353
|
|
|
278
354
|
// Announce roster size only when it changes (not every poll).
|
package/bin/lib/live.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Live mode (
|
|
2
|
+
* Live mode (the DEFAULT since 0.8.0; FLOWVIANT_POLL=1 = legacy path). Instead of one-shot
|
|
3
3
|
* `claude -p` turns + sentinels, each task runs a PERSISTENT Agent-SDK session:
|
|
4
4
|
* the daemon claims, seeds the session with the brief, mirrors the model's
|
|
5
5
|
* streamed reply into the task channel (stream_turn), injects human @-messages
|
|
@@ -78,12 +78,15 @@ answered…" or teammate line as a new instruction and adapt. There is NO termin
|
|
|
78
78
|
and NO interactive prompt — your only channel to a human is the flowviant MCP
|
|
79
79
|
tools. When you hit a decision only a human can make, call report_blocker (with
|
|
80
80
|
options when you can) and then STOP your turn — do not spin or guess; you will be
|
|
81
|
-
resumed with the answer. When the work is done:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
resumed with the answer. When the work is done: open ONE draft PR (git push +
|
|
82
|
+
gh pr create --draft), call attach_pr, then call complete with a plain-language
|
|
83
|
+
summary of what you built AND a criteria self-report (index into the brief's
|
|
84
|
+
"done when" list + met true/false + a short note per item). That summary +
|
|
85
|
+
self-report becomes your DELIVERY CARD in the task thread — it's what the team
|
|
86
|
+
reads to confirm done, so write it for them, not for a log. A live preview of
|
|
87
|
+
your branch is started for you automatically — you do NOT need to open a tunnel
|
|
88
|
+
or register a live target. NEVER merge — a human confirms done in the thread
|
|
89
|
+
(the merge card) and the merge runs separately.`;
|
|
87
90
|
|
|
88
91
|
function seedPrompt(runId, brief, transcript) {
|
|
89
92
|
return [
|
|
@@ -98,7 +101,7 @@ function seedPrompt(runId, brief, transcript) {
|
|
|
98
101
|
? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
|
|
99
102
|
: []),
|
|
100
103
|
``,
|
|
101
|
-
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; report_blocker + stop if you hit a human decision;
|
|
104
|
+
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
|
|
102
105
|
].join('\n');
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -216,22 +219,26 @@ async function waitForMessage(mcpUrl, token, runId, afterId, isAlive) {
|
|
|
216
219
|
|
|
217
220
|
// One task: claim → seed → stream/mirror/inject/park → complete. Returns
|
|
218
221
|
// { outcome: 'nothing' | 'done' | 'blocked' | 'stalled' | 'error' }.
|
|
219
|
-
export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
|
|
222
|
+
export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resumeIntentId, onChild }) {
|
|
220
223
|
const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
|
|
221
224
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
222
225
|
const { runId, intentId } = claim;
|
|
223
226
|
const brief = claim.brief ?? {};
|
|
224
227
|
const title = brief.title ?? 'a task';
|
|
228
|
+
// Re-claiming the SAME intent this worker was just working (parked on a blocker,
|
|
229
|
+
// now resuming) — its worktree holds hours of uncommitted work. Do NOT reset.
|
|
230
|
+
const resuming = !!resumeIntentId && intentId === resumeIntentId;
|
|
225
231
|
|
|
226
|
-
// Revision resumes its PR branch;
|
|
232
|
+
// Revision resumes its PR branch; a genuinely fresh task gets a clean base
|
|
233
|
+
// checkout; a resume keeps its dirty worktree untouched.
|
|
227
234
|
if (brief.branch) {
|
|
228
235
|
try {
|
|
229
236
|
git(['fetch', 'origin', '--quiet'], cwd);
|
|
230
237
|
git(['checkout', brief.branch], cwd);
|
|
231
238
|
} catch {
|
|
232
|
-
resetWorktree(cwd, baseRef);
|
|
239
|
+
if (!resuming) resetWorktree(cwd, baseRef);
|
|
233
240
|
}
|
|
234
|
-
} else {
|
|
241
|
+
} else if (!resuming) {
|
|
235
242
|
resetWorktree(cwd, baseRef);
|
|
236
243
|
}
|
|
237
244
|
|
|
@@ -267,6 +274,25 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
|
|
|
267
274
|
},
|
|
268
275
|
});
|
|
269
276
|
|
|
277
|
+
// Mark this worker BUSY for the daemon's reconcile loop: buildHave keeps the
|
|
278
|
+
// worker's token while a session is live (never rotate a credential out from
|
|
279
|
+
// under it), and teardown/agent-removal can interrupt the SDK session via this
|
|
280
|
+
// marker's kill(). Cleared in finally. Mirrors poll mode's onChild(child).
|
|
281
|
+
onChild?.({
|
|
282
|
+
kill: () => {
|
|
283
|
+
try {
|
|
284
|
+
session.interrupt?.();
|
|
285
|
+
} catch {
|
|
286
|
+
/* already ending */
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
session.return?.();
|
|
290
|
+
} catch {
|
|
291
|
+
/* already closed */
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
270
296
|
let turnId = null;
|
|
271
297
|
let turnText = '';
|
|
272
298
|
let turnAt = null;
|
|
@@ -342,6 +368,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
|
|
|
342
368
|
runId,
|
|
343
369
|
...(afterId ? { afterId } : {}),
|
|
344
370
|
}).catch(() => null);
|
|
371
|
+
// Torn down out from under us (restart / reassign in Flowviant): the
|
|
372
|
+
// server killed this run — abandon the session, don't keep building.
|
|
373
|
+
if (poll && poll.ok === false && poll.reason === 'run_not_active') {
|
|
374
|
+
return { outcome: 'torn_down', title, intentId };
|
|
375
|
+
}
|
|
345
376
|
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
346
377
|
|
|
347
378
|
if (fresh.some((f) => STOP_RE.test(f.content))) {
|
|
@@ -380,6 +411,7 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
|
|
|
380
411
|
} catch (e) {
|
|
381
412
|
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
382
413
|
} finally {
|
|
414
|
+
onChild?.(null); // no longer busy — token may rotate between tasks
|
|
383
415
|
input.close();
|
|
384
416
|
try {
|
|
385
417
|
await session.interrupt?.();
|
|
@@ -406,7 +438,12 @@ export async function runLiveWorker({
|
|
|
406
438
|
getMcpUrl,
|
|
407
439
|
isAlive,
|
|
408
440
|
onTokenSuspect,
|
|
441
|
+
onChild,
|
|
409
442
|
}) {
|
|
443
|
+
// The intent this worker is holding across iterations. When a task parks on a
|
|
444
|
+
// blocker its worktree keeps uncommitted work; on the resume claim we must NOT
|
|
445
|
+
// reset it. Cleared once the task finishes or the worker goes idle.
|
|
446
|
+
let lastIntentId = null;
|
|
410
447
|
let phase = '';
|
|
411
448
|
const enter = (p, fn, msg) => {
|
|
412
449
|
if (phase !== p) {
|
|
@@ -462,13 +499,28 @@ export async function runLiveWorker({
|
|
|
462
499
|
}
|
|
463
500
|
let res;
|
|
464
501
|
try {
|
|
465
|
-
res = await runLiveTask({
|
|
502
|
+
res = await runLiveTask({
|
|
503
|
+
mcpUrl: getMcpUrl() ?? MCP_URL,
|
|
504
|
+
token,
|
|
505
|
+
cwd,
|
|
506
|
+
baseRef,
|
|
507
|
+
isAlive,
|
|
508
|
+
resumeIntentId: lastIntentId,
|
|
509
|
+
onChild,
|
|
510
|
+
});
|
|
466
511
|
} catch (e) {
|
|
467
512
|
enter('error', warn, `${c.yellow('error')} ${c.dim(`— ${e?.message ?? e}`)}`);
|
|
468
513
|
await sleep(IDLE_SECONDS);
|
|
469
514
|
continue;
|
|
470
515
|
}
|
|
471
516
|
if (!isAlive()) break;
|
|
517
|
+
// Keep the held intent only while a task is genuinely in flight (parked /
|
|
518
|
+
// stalled / errored → same worktree resumes). Finishing or finding no work
|
|
519
|
+
// clears it so the next fresh task starts from a clean base.
|
|
520
|
+
lastIntentId =
|
|
521
|
+
res.outcome === 'parked' || res.outcome === 'stalled' || res.outcome === 'error'
|
|
522
|
+
? res.intentId
|
|
523
|
+
: null;
|
|
472
524
|
if (res.outcome === 'nothing') {
|
|
473
525
|
enter('idle', info, 'idle — no work assigned');
|
|
474
526
|
await sleep(IDLE_SECONDS);
|
|
@@ -480,6 +532,13 @@ export async function runLiveWorker({
|
|
|
480
532
|
await startReviewPreview(res.intentId);
|
|
481
533
|
continue;
|
|
482
534
|
}
|
|
535
|
+
if (res.outcome === 'torn_down') {
|
|
536
|
+
// The human restarted/reassigned the task in Flowviant. Drop everything —
|
|
537
|
+
// the next fresh claim resets the worktree to base.
|
|
538
|
+
info(`${label} ${c.dim(`"${res.title}" was restarted/reassigned — abandoned this attempt`)}`);
|
|
539
|
+
phase = '';
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
483
542
|
if (res.outcome === 'parked') {
|
|
484
543
|
// Idle-parked too long on a blocker: we freed the Claude process. The intent
|
|
485
544
|
// stays claimed; a later poll re-claims + resumes (with transcript) once the
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -137,14 +137,36 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
137
137
|
const cf = await ensureCloudflared(log);
|
|
138
138
|
if (!cf) return null; // fall back to captured evidence
|
|
139
139
|
return new Promise((resolve) => {
|
|
140
|
-
|
|
140
|
+
// detached so each gets its own process group — `bun run dev` via a shell
|
|
141
|
+
// spawns a grandchild dev server that would otherwise SURVIVE a kill of the
|
|
142
|
+
// shell, keep port bound, and get silently re-fronted by the NEXT task's
|
|
143
|
+
// tunnel (reviewer sees the wrong branch). We kill the whole group instead.
|
|
144
|
+
const server = spawn(cmd, {
|
|
145
|
+
cwd: worktree,
|
|
146
|
+
shell: true,
|
|
147
|
+
detached: true,
|
|
148
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
149
|
+
});
|
|
141
150
|
const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
|
|
151
|
+
detached: true,
|
|
142
152
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
143
153
|
});
|
|
144
154
|
let settled = false;
|
|
155
|
+
const killGroup = (child) => {
|
|
156
|
+
if (!child.pid) return;
|
|
157
|
+
try {
|
|
158
|
+
process.kill(-child.pid, 'SIGKILL'); // negative pid = the whole group
|
|
159
|
+
} catch {
|
|
160
|
+
try {
|
|
161
|
+
child.kill('SIGKILL');
|
|
162
|
+
} catch {
|
|
163
|
+
/* gone */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
145
167
|
const stop = () => {
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
killGroup(server);
|
|
169
|
+
killGroup(tunnel);
|
|
148
170
|
};
|
|
149
171
|
const finish = (val) => {
|
|
150
172
|
if (settled) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — 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": {
|