flowviant 0.38.0 → 0.40.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/bin/lib/claude.mjs +33 -2
- package/bin/lib/fleet.mjs +36 -2
- package/bin/lib/live.mjs +609 -22
- package/bin/lib/preflight.mjs +56 -15
- package/bin/lib/runtimes.mjs +473 -28
- package/package.json +2 -2
package/bin/lib/live.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
ALLOW_PATCHES,
|
|
35
35
|
} from './config.mjs';
|
|
36
36
|
import { c, info, ok, warn } from './ui.mjs';
|
|
37
|
-
import { sleep } from './claude.mjs';
|
|
37
|
+
import { sleep, runTurn, mcpFor, sawSentinel, blockedId } from './claude.mjs';
|
|
38
38
|
import {
|
|
39
39
|
git,
|
|
40
40
|
resetWorktree,
|
|
@@ -44,12 +44,28 @@ import {
|
|
|
44
44
|
clearWip,
|
|
45
45
|
} from './git.mjs';
|
|
46
46
|
import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
|
|
47
|
+
import { RUNTIMES, runtimeById, drivableHere, mediated } from './runtimes.mjs';
|
|
47
48
|
import { loadPreviewConfig, startPreview } from './preview.mjs';
|
|
48
49
|
import { materializeInto, scrub as envScrub } from './env.mjs';
|
|
49
50
|
|
|
50
51
|
// Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
|
|
51
52
|
// reviewer then drives it via "Open live preview" in the node.
|
|
52
53
|
const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What THIS WORKER can build, sent on every claim.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately the same predicate the roster report uses (`drivableHere`), not a
|
|
59
|
+
* hand-written list. The claim and the report answer the same question to two
|
|
60
|
+
* different consumers, and if they ever disagree the daemon either claims work it
|
|
61
|
+
* cannot build — the exact bug this argument was added to close — or refuses work
|
|
62
|
+
* it can. One source, so they cannot drift.
|
|
63
|
+
*
|
|
64
|
+
* Note this is NOT the live-session list. A live worker builds Claude tasks
|
|
65
|
+
* through the SDK and everything else through `driveSubprocess`, so both belong
|
|
66
|
+
* here; `live` chooses the driver, it does not gate participation.
|
|
67
|
+
*/
|
|
68
|
+
const DRIVABLE_HERE = Object.values(RUNTIMES).filter(drivableHere).map((r) => r.id);
|
|
53
69
|
// Short TTL + a heartbeat that re-asserts while the tunnel is alive. So a live
|
|
54
70
|
// preview stays linked indefinitely (survives long reviews), but one whose
|
|
55
71
|
// daemon DIED ungracefully (no more heartbeats) drops off the card within the
|
|
@@ -191,6 +207,36 @@ questions, delivery summaries, commits, or PRs — reference keys by NAME only
|
|
|
191
207
|
(e.g. "set STRIPE_KEY"). Never screenshot a terminal or page that displays a
|
|
192
208
|
credential, and never commit an env file.`;
|
|
193
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The same contract, for a runtime that has no live session.
|
|
212
|
+
*
|
|
213
|
+
* A non-live runtime is driven as a SUBPROCESS: one headless turn, then the
|
|
214
|
+
* process exits and the daemon decides what happens next. That transport cannot
|
|
215
|
+
* see tool calls the way the SDK stream can — there is no `tool_use` block to
|
|
216
|
+
* read `complete` or `report_blocker` off — so the turn has to SAY how it ended.
|
|
217
|
+
* Hence the sentinels, which are the same three words the legacy poll path has
|
|
218
|
+
* always used; this is a transport detail bolted onto the contract, not a second
|
|
219
|
+
* contract, which is why it is SYSTEM_LIVE plus an epilogue rather than a
|
|
220
|
+
* parallel prompt that would drift from it.
|
|
221
|
+
*
|
|
222
|
+
* The claim instruction that opens SYSTEM_SINGLE is deliberately absent: the
|
|
223
|
+
* daemon already claimed this task before spawning, so a second claim would come
|
|
224
|
+
* back `active_run` and the turn would waste itself puzzling over it.
|
|
225
|
+
*/
|
|
226
|
+
const SYSTEM_SUBPROCESS = `${SYSTEM_LIVE}
|
|
227
|
+
|
|
228
|
+
HOW THIS TURN ENDS. You are running as a one-shot process, not in a live session,
|
|
229
|
+
so the daemon can only see what you print. End your turn by printing EXACTLY ONE
|
|
230
|
+
of these on a line by itself, as the last thing you output:
|
|
231
|
+
DONE — the task is complete (you called complete, and opened the
|
|
232
|
+
PR unless placement is "patch")
|
|
233
|
+
BLOCKED:<blockerId> — you called report_blocker and are waiting on a human. Use
|
|
234
|
+
the id report_blocker returned. STOP after printing it;
|
|
235
|
+
you will be run again with the answer.
|
|
236
|
+
Print nothing else on that line. Do not print a sentinel you have not earned — a
|
|
237
|
+
DONE without a complete call strands the work, and the team is told the task
|
|
238
|
+
finished when it did not.`;
|
|
239
|
+
|
|
194
240
|
/** The brief minus the parts rendered as prose below (conversations, the ask). */
|
|
195
241
|
function briefWithoutThread(brief) {
|
|
196
242
|
const {
|
|
@@ -555,6 +601,455 @@ async function landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchB
|
|
|
555
601
|
warn(`patch not applied: ${reason}`);
|
|
556
602
|
}
|
|
557
603
|
|
|
604
|
+
/**
|
|
605
|
+
* The FORM a mediated runtime fills in instead of calling tools.
|
|
606
|
+
*
|
|
607
|
+
* Every field maps to one control-plane call the daemon makes on the agent's
|
|
608
|
+
* behalf, which is why the shape is this small: it is not a report, it is the
|
|
609
|
+
* arguments to `complete` / `report_blocker` / `attach_pr` with the runId taken
|
|
610
|
+
* out (the agent has no business naming a run it cannot see).
|
|
611
|
+
*/
|
|
612
|
+
const MEDIATED_RESULT_SCHEMA = {
|
|
613
|
+
type: 'object',
|
|
614
|
+
required: ['outcome', 'summary'],
|
|
615
|
+
additionalProperties: false,
|
|
616
|
+
properties: {
|
|
617
|
+
outcome: { type: 'string', enum: ['done', 'blocked', 'failed'] },
|
|
618
|
+
summary: { type: 'string' },
|
|
619
|
+
prUrl: { type: 'string' },
|
|
620
|
+
branch: { type: 'string' },
|
|
621
|
+
blockerQuestion: { type: 'string' },
|
|
622
|
+
blockerOptions: { type: 'array', items: { type: 'string' } },
|
|
623
|
+
criteria: {
|
|
624
|
+
type: 'array',
|
|
625
|
+
items: {
|
|
626
|
+
type: 'object',
|
|
627
|
+
required: ['index', 'met'],
|
|
628
|
+
properties: {
|
|
629
|
+
index: { type: 'number' },
|
|
630
|
+
met: { type: 'boolean' },
|
|
631
|
+
note: { type: 'string' },
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The contract for a runtime that cannot reach the flowviant MCP server.
|
|
640
|
+
*
|
|
641
|
+
* SYSTEM_LIVE tells the agent to call tools. This one tells it there are none —
|
|
642
|
+
* which has to be said explicitly, because the brief it is about to read is full
|
|
643
|
+
* of references to a control plane it cannot touch, and an agent that spends its
|
|
644
|
+
* turn hunting for `report_progress` is an agent that does not build anything.
|
|
645
|
+
*/
|
|
646
|
+
const SYSTEM_MEDIATED = `You are a Flowviant build agent working ONE task, running FULLY AUTONOMOUSLY.
|
|
647
|
+
There is NO interactive user, NO terminal to ask in, and — importantly — NO
|
|
648
|
+
Flowviant tools available to you in this session. Do not look for them. A daemon
|
|
649
|
+
is watching this run and reports on your behalf: your file edits, commands and
|
|
650
|
+
progress are already visible to the team as you work.
|
|
651
|
+
|
|
652
|
+
Do the work described in the brief below, in the checkout you are running in.
|
|
653
|
+
Ship it exactly as the brief's "placement" says:
|
|
654
|
+
• placement "patch": commit your change with a one-line message and STOP. No
|
|
655
|
+
branch, no push, no PR — the daemon carries it into the owner's checkout.
|
|
656
|
+
• placement "branch" (the default): create the branch named in "branchName" (use
|
|
657
|
+
that exact name), push it, and open ONE draft pull request with
|
|
658
|
+
\`gh pr create --draft\`. If the brief has a "baseBranch", target it with
|
|
659
|
+
\`--base <baseBranch>\`. NEVER merge.
|
|
660
|
+
|
|
661
|
+
THEN RETURN THE RESULT FORM as your final answer, and nothing else — it is a
|
|
662
|
+
strict JSON schema and it is the only way anything you did gets recorded:
|
|
663
|
+
• outcome "done" — you finished. Include a plain-language "summary" for the
|
|
664
|
+
humans (it becomes your delivery card), the "prUrl" and "branch" if you opened
|
|
665
|
+
one, and a "criteria" self-report indexing into the brief's "done when" list.
|
|
666
|
+
• outcome "blocked" — you hit a decision only a human can make. Put the question
|
|
667
|
+
in "blockerQuestion" and any choices in "blockerOptions", and STOP. You will be
|
|
668
|
+
run again with the answer.
|
|
669
|
+
• outcome "failed" — you could not do it. Say why in "summary".
|
|
670
|
+
Do not invent a prUrl you did not open, and do not report "done" for work you did
|
|
671
|
+
not finish: the summary is shown to a person as a claim about what exists.
|
|
672
|
+
SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their
|
|
673
|
+
VALUES must NEVER appear in the summary, in commits, or in a PR — reference keys
|
|
674
|
+
by NAME only. Never commit an env file.`;
|
|
675
|
+
|
|
676
|
+
/** Pull the result object out of a turn's output. */
|
|
677
|
+
function parseMediatedResult(out) {
|
|
678
|
+
const text = String(out ?? '').trim();
|
|
679
|
+
if (!text) return null;
|
|
680
|
+
// The whole answer SHOULD be the object — that is what schema enforcement
|
|
681
|
+
// buys. Fall back to the last balanced {...} for a runtime that wraps it in a
|
|
682
|
+
// fence or adds a sentence, so one chatty model does not strand a finished
|
|
683
|
+
// build. Last rather than first: any preamble comes before the answer.
|
|
684
|
+
const direct = tryJson(text);
|
|
685
|
+
if (direct) return direct;
|
|
686
|
+
const start = text.lastIndexOf('{');
|
|
687
|
+
for (let i = start; i >= 0; i = text.lastIndexOf('{', i - 1)) {
|
|
688
|
+
const cand = tryJson(text.slice(i, text.lastIndexOf('}') + 1));
|
|
689
|
+
if (cand) return cand;
|
|
690
|
+
if (i === 0) break;
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
function tryJson(s) {
|
|
695
|
+
try {
|
|
696
|
+
const v = JSON.parse(s);
|
|
697
|
+
return v && typeof v === 'object' && typeof v.outcome === 'string' ? v : null;
|
|
698
|
+
} catch {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Drive a task with a runtime that cannot reach the MCP server at all.
|
|
705
|
+
*
|
|
706
|
+
* THE CLI DOES THE WORK; THE DAEMON DOES THE PAPERWORK. Antigravity's server
|
|
707
|
+
* list is machine-wide (measured — a workspace-local config is never read), so
|
|
708
|
+
* handing it a per-lane worker token is impossible and handing it a shared one
|
|
709
|
+
* would make every lane indistinguishable to the control plane. Instead nothing
|
|
710
|
+
* is handed over: the agent gets a brief and returns a filled-in form, and every
|
|
711
|
+
* control-plane call below is made by the daemon with the lane's OWN token, over
|
|
712
|
+
* its own HTTP. Per-lane isolation is preserved by removing the need for the
|
|
713
|
+
* agent to have a credential at all.
|
|
714
|
+
*
|
|
715
|
+
* The cost, and it is real: NO ON-DEMAND CONTEXT. A direct-MCP agent can call
|
|
716
|
+
* search_wiki or get_module_files the moment it realises it does not understand
|
|
717
|
+
* a subsystem. A mediated one only knows what was in the brief. That is a
|
|
718
|
+
* genuine capability difference and it is why this is the fallback shape rather
|
|
719
|
+
* than the default — runtimes that CAN hold an MCP config keep the full tool
|
|
720
|
+
* surface.
|
|
721
|
+
*
|
|
722
|
+
* Also not yet carried: attach_evidence. A mediated agent cannot upload a
|
|
723
|
+
* screenshot, so its delivery card arrives without the proof a Claude lane's
|
|
724
|
+
* would have. Fixable (the agent writes files, the daemon uploads them) and
|
|
725
|
+
* deliberately not in this first pass.
|
|
726
|
+
*/
|
|
727
|
+
async function driveMediated({
|
|
728
|
+
runtimeId,
|
|
729
|
+
mcpUrl,
|
|
730
|
+
token,
|
|
731
|
+
runId,
|
|
732
|
+
intentId,
|
|
733
|
+
title,
|
|
734
|
+
cwd,
|
|
735
|
+
brief,
|
|
736
|
+
isPatch,
|
|
737
|
+
patchBase,
|
|
738
|
+
repoRoot,
|
|
739
|
+
baseRef,
|
|
740
|
+
label,
|
|
741
|
+
seedText,
|
|
742
|
+
isAlive,
|
|
743
|
+
onChild,
|
|
744
|
+
markLanded,
|
|
745
|
+
}) {
|
|
746
|
+
const rt = runtimeById(runtimeId);
|
|
747
|
+
const dir = mkdtempSync(join(tmpdir(), 'flowviant-schema-'));
|
|
748
|
+
const schemaPath = join(dir, 'result.schema.json');
|
|
749
|
+
writeFileSync(schemaPath, JSON.stringify(MEDIATED_RESULT_SCHEMA), { mode: 0o600 });
|
|
750
|
+
|
|
751
|
+
// The agent cannot call report_progress, so the daemon narrates for it off the
|
|
752
|
+
// parsed activity stream. Throttled: a build touches hundreds of files and the
|
|
753
|
+
// thread is for humans, not for a filesystem log.
|
|
754
|
+
let lastReport = 0;
|
|
755
|
+
const narrate = (activity) => {
|
|
756
|
+
if (!activity?.label) return;
|
|
757
|
+
const now = Date.now();
|
|
758
|
+
if (now - lastReport < 8000) return;
|
|
759
|
+
lastReport = now;
|
|
760
|
+
void mcpCall(mcpUrl, token, 'report_progress', {
|
|
761
|
+
runId,
|
|
762
|
+
kind: activity.kind === 'error' ? 'error' : 'progress',
|
|
763
|
+
message: envScrub(activity.label),
|
|
764
|
+
}).catch(() => {});
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
let prompt = seedText;
|
|
768
|
+
let resume = false;
|
|
769
|
+
let nudges = 0;
|
|
770
|
+
try {
|
|
771
|
+
for (;;) {
|
|
772
|
+
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
773
|
+
let out = '';
|
|
774
|
+
try {
|
|
775
|
+
out = await runTurn({
|
|
776
|
+
prompt,
|
|
777
|
+
resume,
|
|
778
|
+
system: SYSTEM_MEDIATED,
|
|
779
|
+
cwd,
|
|
780
|
+
runtime: runtimeId,
|
|
781
|
+
// NO MCP. That is the entire point of this path.
|
|
782
|
+
resultSchemaArgs: rt.resultSchema?.(schemaPath) ?? [],
|
|
783
|
+
label,
|
|
784
|
+
model: brief.agentModel || undefined,
|
|
785
|
+
effort: brief.agentEffort || undefined,
|
|
786
|
+
onActivity: narrate,
|
|
787
|
+
onSpawn: (ch) => onChild?.(ch),
|
|
788
|
+
});
|
|
789
|
+
} catch (e) {
|
|
790
|
+
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
791
|
+
} finally {
|
|
792
|
+
onChild?.(null);
|
|
793
|
+
}
|
|
794
|
+
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
795
|
+
resume = true;
|
|
796
|
+
|
|
797
|
+
const rl = classifyRateLimit(String(out).slice(-4000));
|
|
798
|
+
const result = parseMediatedResult(out);
|
|
799
|
+
if (!result && rl.isRateLimit) {
|
|
800
|
+
await mcpCall(mcpUrl, token, 'report_paused', { runId, resetAt: rl.resetAt }).catch(() => {});
|
|
801
|
+
return { outcome: 'rate_limited', resetAt: rl.resetAt, runId, title, intentId };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (!result) {
|
|
805
|
+
// No form came back. Same posture as a missing sentinel on the other
|
|
806
|
+
// paths: nudge, then give up rather than invent an outcome.
|
|
807
|
+
if (nudges < 2) {
|
|
808
|
+
nudges++;
|
|
809
|
+
prompt =
|
|
810
|
+
'You did not return the result form. Return ONLY the JSON object described in your instructions, describing what you did.';
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
return { outcome: 'stalled', title, intentId };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (result.outcome === 'blocked') {
|
|
817
|
+
const q = String(result.blockerQuestion ?? result.summary ?? '').trim();
|
|
818
|
+
const posted = await mcpCall(mcpUrl, token, 'report_blocker', {
|
|
819
|
+
runId,
|
|
820
|
+
taskId: intentId,
|
|
821
|
+
type: 'question',
|
|
822
|
+
payload: {
|
|
823
|
+
question: q || 'The agent stopped and did not say why.',
|
|
824
|
+
options: Array.isArray(result.blockerOptions) ? result.blockerOptions : undefined,
|
|
825
|
+
},
|
|
826
|
+
}).catch(() => null);
|
|
827
|
+
const blockerId = posted?.blockerId ?? posted?.id ?? null;
|
|
828
|
+
if (!blockerId) return { outcome: 'blocked', title, intentId };
|
|
829
|
+
const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
|
|
830
|
+
if (res.status === 'resolved') {
|
|
831
|
+
prompt = `The human answered your blocker: ${JSON.stringify(res.answer)}\nApply it and continue, then return the result form.`;
|
|
832
|
+
nudges = 0;
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (res.status === 'timeout') return { outcome: 'parked', title, intentId };
|
|
836
|
+
return { outcome: 'blocked', title, intentId };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// done / failed — either way the turn is over and the thread gets a card.
|
|
840
|
+
if (result.prUrl && !isPatch) {
|
|
841
|
+
await mcpCall(mcpUrl, token, 'attach_pr', {
|
|
842
|
+
runId,
|
|
843
|
+
prUrl: String(result.prUrl),
|
|
844
|
+
...(result.branch ? { branch: String(result.branch) } : {}),
|
|
845
|
+
}).catch(() => {});
|
|
846
|
+
}
|
|
847
|
+
clearTaskMarker(cwd);
|
|
848
|
+
if (result.outcome === 'done' && isPatch) {
|
|
849
|
+
await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
|
|
850
|
+
}
|
|
851
|
+
await mcpCall(mcpUrl, token, 'complete', {
|
|
852
|
+
runId,
|
|
853
|
+
outcome: result.outcome === 'done' ? 'completed' : 'failed',
|
|
854
|
+
summary: envScrub(String(result.summary ?? '').slice(0, 4000)),
|
|
855
|
+
...(Array.isArray(result.criteria) ? { criteria: result.criteria } : {}),
|
|
856
|
+
}).catch(() => {});
|
|
857
|
+
if (result.outcome === 'done') markLanded();
|
|
858
|
+
return { outcome: result.outcome === 'done' ? 'done' : 'stalled', title, intentId };
|
|
859
|
+
}
|
|
860
|
+
} finally {
|
|
861
|
+
rmSync(dir, { recursive: true, force: true });
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Drive a task with a runtime that has no live session.
|
|
867
|
+
*
|
|
868
|
+
* Same job, same outcomes, different transport. `runLiveTask` owns everything
|
|
869
|
+
* around this — the claim, the worktree, the branch/patch/stack setup, the WIP
|
|
870
|
+
* checkpoint timer, the diffstat sampler and the teardown — and calls one of two
|
|
871
|
+
* drivers in the middle. That split is the whole point: routing non-live
|
|
872
|
+
* runtimes at the WORKER level instead (the obvious shortcut, since the legacy
|
|
873
|
+
* poll worker already spawns Codex) would have sent them down a path with no
|
|
874
|
+
* patch landing, no WIP checkpoint/restore and no preview, so a `placement:
|
|
875
|
+
* "patch"` task would follow its instructions to commit-and-stop and then wait
|
|
876
|
+
* forever for a daemon that never picks it up.
|
|
877
|
+
*
|
|
878
|
+
* What is genuinely lost versus a live session, stated plainly rather than
|
|
879
|
+
* discovered: a teammate's mid-task message cannot interrupt a running turn. It
|
|
880
|
+
* lands between turns instead, which is the same place a poll-mode message has
|
|
881
|
+
* always landed. Everything else — blockers, stop, teardown, release, patches,
|
|
882
|
+
* checkpoints — behaves the same because it is the same surrounding code.
|
|
883
|
+
*/
|
|
884
|
+
async function driveSubprocess({
|
|
885
|
+
runtimeId,
|
|
886
|
+
mcpUrl,
|
|
887
|
+
token,
|
|
888
|
+
runId,
|
|
889
|
+
intentId,
|
|
890
|
+
title,
|
|
891
|
+
cwd,
|
|
892
|
+
brief,
|
|
893
|
+
isPatch,
|
|
894
|
+
patchBase,
|
|
895
|
+
repoRoot,
|
|
896
|
+
baseRef,
|
|
897
|
+
label,
|
|
898
|
+
seedText,
|
|
899
|
+
afterId,
|
|
900
|
+
isAlive,
|
|
901
|
+
onChild,
|
|
902
|
+
markLanded,
|
|
903
|
+
}) {
|
|
904
|
+
let resume = false;
|
|
905
|
+
let nudges = 0;
|
|
906
|
+
let held = false;
|
|
907
|
+
let prompt = seedText;
|
|
908
|
+
|
|
909
|
+
for (;;) {
|
|
910
|
+
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
911
|
+
|
|
912
|
+
// A fresh token hand-off per turn: the worker token is minted per lane and
|
|
913
|
+
// may rotate between turns, and for Codex it rides in the environment rather
|
|
914
|
+
// than on disk, so there is nothing to clean up in that case (`dir` is null).
|
|
915
|
+
const { dir, args: mcpArgs, env: mcpEnv } = mcpFor(runtimeId, token, mcpUrl);
|
|
916
|
+
let out = '';
|
|
917
|
+
try {
|
|
918
|
+
out = await runTurn({
|
|
919
|
+
prompt,
|
|
920
|
+
resume,
|
|
921
|
+
system: SYSTEM_SUBPROCESS,
|
|
922
|
+
cwd,
|
|
923
|
+
runtime: runtimeId,
|
|
924
|
+
mcpArgs,
|
|
925
|
+
mcpEnv,
|
|
926
|
+
label,
|
|
927
|
+
// Per-task first, this machine's default second — off the BRIEF, which is
|
|
928
|
+
// the task we actually hold, never the roster's guess.
|
|
929
|
+
model: brief.agentModel || undefined,
|
|
930
|
+
effort: brief.agentEffort || undefined,
|
|
931
|
+
onSpawn: (ch) => onChild?.(ch),
|
|
932
|
+
});
|
|
933
|
+
} catch (e) {
|
|
934
|
+
// Defensive only. runTurn resolves rather than rejects on a failed child —
|
|
935
|
+
// see the rate-limit note below — so this catches a throw from the
|
|
936
|
+
// plumbing around it, not from the CLI.
|
|
937
|
+
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
938
|
+
} finally {
|
|
939
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
940
|
+
onChild?.(null);
|
|
941
|
+
}
|
|
942
|
+
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
943
|
+
|
|
944
|
+
// A USAGE LIMIT reads differently here than it does in a live session, and
|
|
945
|
+
// getting that wrong would show the user's own plan limit as a Flowviant
|
|
946
|
+
// stall. The SDK THROWS on a 429, which is why the live path classifies an
|
|
947
|
+
// exception; `runTurn` resolves with whatever the child printed no matter
|
|
948
|
+
// how it exited, so the only evidence a subprocess leaves is text.
|
|
949
|
+
//
|
|
950
|
+
// Read the TAIL only, and only when the turn produced no sentinel. The whole
|
|
951
|
+
// transcript is the model's narration, and an agent that writes "we should
|
|
952
|
+
// handle rate limit errors" into a code comment would otherwise park a
|
|
953
|
+
// perfectly healthy run. A fatal CLI error is the last thing printed. Both
|
|
954
|
+
// ways of being wrong here are recoverable — a false park retries after the
|
|
955
|
+
// reset, a missed limit reads as a stall and is re-dispatched — so the tail
|
|
956
|
+
// heuristic buys the common case without risking the work.
|
|
957
|
+
if (!sawSentinel(out, 'DONE') && !blockedId(out)) {
|
|
958
|
+
const rl = classifyRateLimit(out.slice(-4000));
|
|
959
|
+
if (rl.isRateLimit) {
|
|
960
|
+
await mcpCall(mcpUrl, token, 'report_paused', { runId, resetAt: rl.resetAt }).catch(() => {});
|
|
961
|
+
return { outcome: 'rate_limited', resetAt: rl.resetAt, runId, title, intentId };
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Every turn after the first continues the CLI's own session where the
|
|
966
|
+
// runtime supports it (`--continue` / `resume --last`), so the agent keeps
|
|
967
|
+
// its reasoning rather than re-reading the brief cold each time.
|
|
968
|
+
resume = true;
|
|
969
|
+
|
|
970
|
+
const bid = blockedId(out);
|
|
971
|
+
if (bid) {
|
|
972
|
+
const res = await waitForResolution(mcpUrl, token, bid, isAlive);
|
|
973
|
+
if (res.status === 'resolved') {
|
|
974
|
+
prompt = `The human answered your blocker: ${JSON.stringify(res.answer)}\nApply it and continue.`;
|
|
975
|
+
nudges = 0;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (res.status === 'timeout') return { outcome: 'parked', title, intentId };
|
|
979
|
+
return { outcome: 'blocked', title, intentId };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (sawSentinel(out, 'DONE')) {
|
|
983
|
+
// Identical to the live path's completion, and it must stay identical: the
|
|
984
|
+
// marker clear is what stops this worktree being read as a resume of a
|
|
985
|
+
// task that has finished (or been discarded and restarted).
|
|
986
|
+
clearTaskMarker(cwd);
|
|
987
|
+
if (isPatch) {
|
|
988
|
+
await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
|
|
989
|
+
}
|
|
990
|
+
markLanded();
|
|
991
|
+
return { outcome: 'done', title, intentId };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// No sentinel: the turn ended without saying how. Before nudging, find out
|
|
995
|
+
// whether the RUN still exists — a restart or a release from the app tears
|
|
996
|
+
// it down out from under us, and nudging a dead run just burns the user's
|
|
997
|
+
// quota. Same three answers the live loop reads, for the same reasons.
|
|
998
|
+
const poll = await mcpCall(mcpUrl, token, 'poll_channel', {
|
|
999
|
+
runId,
|
|
1000
|
+
...(afterId ? { afterId } : {}),
|
|
1001
|
+
}).catch(() => null);
|
|
1002
|
+
if (poll && poll.ok === false && poll.released) {
|
|
1003
|
+
return { outcome: 'released', title, intentId };
|
|
1004
|
+
}
|
|
1005
|
+
if (poll && poll.ok === false && poll.reason === 'run_not_active') {
|
|
1006
|
+
clearTaskMarker(cwd);
|
|
1007
|
+
try {
|
|
1008
|
+
git(['worktree', 'remove', '--force', cwd], repoRoot);
|
|
1009
|
+
} catch {
|
|
1010
|
+
resetWorktree(cwd, baseRef);
|
|
1011
|
+
}
|
|
1012
|
+
return { outcome: 'torn_down', title, intentId };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const fresh = (poll?.messages ?? []).filter((x) => x.role === 'user');
|
|
1016
|
+
if (fresh.length) afterId = fresh[fresh.length - 1].id;
|
|
1017
|
+
|
|
1018
|
+
if (fresh.some((f) => STOP_RE.test(f.content))) {
|
|
1019
|
+
held = true;
|
|
1020
|
+
prompt =
|
|
1021
|
+
'A teammate asked you to STOP. Halt, summarize where you are in one line, and wait for direction — do not continue until told.';
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (fresh.length) {
|
|
1025
|
+
prompt = fresh
|
|
1026
|
+
.map((f) => (f.authorName ? `${f.authorName}: ` : '') + f.content)
|
|
1027
|
+
.join('\n');
|
|
1028
|
+
nudges = 0;
|
|
1029
|
+
held = false;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (held) {
|
|
1033
|
+
const next = await waitForMessage(mcpUrl, token, runId, afterId, isAlive);
|
|
1034
|
+
if (!next) return { outcome: 'parked', title, intentId };
|
|
1035
|
+
held = false;
|
|
1036
|
+
nudges = 0;
|
|
1037
|
+
afterId = next.id;
|
|
1038
|
+
prompt = (next.authorName ? `${next.authorName}: ` : '') + next.content;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (nudges < 2) {
|
|
1043
|
+
nudges++;
|
|
1044
|
+
prompt = isPatch
|
|
1045
|
+
? 'Continue until the task is complete: commit your change (no branch, no push, no PR) and call complete, then print DONE. Or report a blocker and print BLOCKED:<id>.'
|
|
1046
|
+
: 'Continue until the task is complete: open a draft PR and call complete, then print DONE. Or report a blocker and print BLOCKED:<id>.';
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
return { outcome: 'stalled', title, intentId };
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
558
1053
|
export async function runLiveTask({
|
|
559
1054
|
mcpUrl,
|
|
560
1055
|
token,
|
|
@@ -568,7 +1063,23 @@ export async function runLiveTask({
|
|
|
568
1063
|
sampleDiffstat,
|
|
569
1064
|
agentId,
|
|
570
1065
|
}) {
|
|
571
|
-
|
|
1066
|
+
// SAY WHAT THIS WORKER CAN DRIVE. The claim is UNPINNED — this worker asks for
|
|
1067
|
+
// whatever is next rather than for a named task — and that is deliberate (the
|
|
1068
|
+
// roster hint is a prediction made before anything is claimed, so pinning to it
|
|
1069
|
+
// would sometimes pin to the wrong task). The cost of not pinning is that the
|
|
1070
|
+
// server decides, and until it was told, it decided using the MACHINE's
|
|
1071
|
+
// capability report: on a box with Codex installed it would hand a
|
|
1072
|
+
// codex-addressed task to this worker, which drives the Anthropic Agent SDK
|
|
1073
|
+
// and nothing else, and Claude would build it. Nobody was told. The @mention is
|
|
1074
|
+
// the only dispatch in this product, and silently answering it with a different
|
|
1075
|
+
// CLI is the same class of bug as dispatching from the wrong surface.
|
|
1076
|
+
//
|
|
1077
|
+
// The list is every runtime this daemon can spawn or session, NOT just the
|
|
1078
|
+
// live ones — `driveSubprocess` below builds the rest. An older server ignores
|
|
1079
|
+
// the argument and behaves as before; that degrade is what `daemon:min` is for.
|
|
1080
|
+
const claim = await mcpCall(mcpUrl, token, 'claim_next_task', {
|
|
1081
|
+
runtimes: DRIVABLE_HERE,
|
|
1082
|
+
}).catch(() => null);
|
|
572
1083
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
573
1084
|
const { runId, intentId } = claim;
|
|
574
1085
|
const brief = claim.brief ?? {};
|
|
@@ -762,8 +1273,16 @@ export async function runLiveTask({
|
|
|
762
1273
|
// already told us the real intent.
|
|
763
1274
|
const stopDiffstat = sampleDiffstat?.(cwd, baseRef, intentId, agentId) ?? null;
|
|
764
1275
|
|
|
765
|
-
|
|
766
|
-
|
|
1276
|
+
// WHICH CLI builds this one, off the brief — the task we actually hold, not
|
|
1277
|
+
// the roster's prediction. Only Claude has a live session (it is an Anthropic
|
|
1278
|
+
// SDK, not a CLI contract); everything else is driven as a subprocess by
|
|
1279
|
+
// `driveSubprocess` below, which is what the registry's `live` flag has always
|
|
1280
|
+
// said would happen and what live mode never implemented.
|
|
1281
|
+
const rt = runtimeById(brief.agentRuntime ?? 'claude');
|
|
1282
|
+
const seedText = seedPrompt(runId, brief, transcript, resumedInPlace);
|
|
1283
|
+
const input = rt.live ? makeInput(seedText) : null;
|
|
1284
|
+
const session = rt.live
|
|
1285
|
+
? query({
|
|
767
1286
|
prompt: input.stream(),
|
|
768
1287
|
options: {
|
|
769
1288
|
cwd,
|
|
@@ -791,26 +1310,32 @@ export async function runLiveTask({
|
|
|
791
1310
|
},
|
|
792
1311
|
},
|
|
793
1312
|
},
|
|
794
|
-
|
|
1313
|
+
})
|
|
1314
|
+
: null;
|
|
795
1315
|
|
|
796
1316
|
// Mark this worker BUSY for the daemon's reconcile loop: buildHave keeps the
|
|
797
1317
|
// worker's token while a session is live (never rotate a credential out from
|
|
798
1318
|
// under it), and teardown/agent-removal can interrupt the SDK session via this
|
|
799
1319
|
// marker's kill(). Cleared in finally. Mirrors poll mode's onChild(child).
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1320
|
+
// The subprocess driver registers its own handle per turn (runTurn's onSpawn),
|
|
1321
|
+
// because there the killable thing is a child process and it only exists while
|
|
1322
|
+
// a turn is actually running.
|
|
1323
|
+
if (session) {
|
|
1324
|
+
onChild?.({
|
|
1325
|
+
kill: () => {
|
|
1326
|
+
try {
|
|
1327
|
+
session.interrupt?.();
|
|
1328
|
+
} catch {
|
|
1329
|
+
/* already ending */
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
session.return?.();
|
|
1333
|
+
} catch {
|
|
1334
|
+
/* already closed */
|
|
1335
|
+
}
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
814
1339
|
|
|
815
1340
|
let turnId = null;
|
|
816
1341
|
let turnText = '';
|
|
@@ -850,6 +1375,65 @@ export async function runLiveTask({
|
|
|
850
1375
|
};
|
|
851
1376
|
|
|
852
1377
|
try {
|
|
1378
|
+
// THE SEAM. Everything above prepared this task — the claim, the checkout,
|
|
1379
|
+
// the branch or patch base, the restored work in progress, the checkpoint
|
|
1380
|
+
// timer and the diffstat sampler — and everything in the `finally` below
|
|
1381
|
+
// tears it down. Only the middle differs by runtime, so only the middle
|
|
1382
|
+
// branches, and a non-live runtime inherits the other two thirds unchanged.
|
|
1383
|
+
if (!session && mediated(rt)) {
|
|
1384
|
+
// No MCP config this runtime can hold, so it is handed none: the daemon
|
|
1385
|
+
// makes every control-plane call itself with this lane's own token.
|
|
1386
|
+
return await driveMediated({
|
|
1387
|
+
runtimeId: rt.id,
|
|
1388
|
+
mcpUrl,
|
|
1389
|
+
token,
|
|
1390
|
+
runId,
|
|
1391
|
+
intentId,
|
|
1392
|
+
title,
|
|
1393
|
+
cwd,
|
|
1394
|
+
brief,
|
|
1395
|
+
isPatch,
|
|
1396
|
+
patchBase,
|
|
1397
|
+
repoRoot,
|
|
1398
|
+
baseRef,
|
|
1399
|
+
label: `[${rt.label}]`,
|
|
1400
|
+
seedText,
|
|
1401
|
+
isAlive,
|
|
1402
|
+
onChild,
|
|
1403
|
+
markLanded: () => {
|
|
1404
|
+
landed = true;
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (!session) {
|
|
1410
|
+
return await driveSubprocess({
|
|
1411
|
+
runtimeId: rt.id,
|
|
1412
|
+
mcpUrl,
|
|
1413
|
+
token,
|
|
1414
|
+
runId,
|
|
1415
|
+
intentId,
|
|
1416
|
+
title,
|
|
1417
|
+
cwd,
|
|
1418
|
+
brief,
|
|
1419
|
+
isPatch,
|
|
1420
|
+
patchBase,
|
|
1421
|
+
repoRoot,
|
|
1422
|
+
baseRef,
|
|
1423
|
+
label: `[${rt.label}]`,
|
|
1424
|
+
seedText,
|
|
1425
|
+
afterId,
|
|
1426
|
+
isAlive,
|
|
1427
|
+
onChild,
|
|
1428
|
+
// `landed` decides whether the finally deletes this task's WIP ref or
|
|
1429
|
+
// takes one last checkpoint, so the driver has to be able to set it —
|
|
1430
|
+
// returning it would be too late, the finally runs first.
|
|
1431
|
+
markLanded: () => {
|
|
1432
|
+
landed = true;
|
|
1433
|
+
},
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
853
1437
|
for await (const m of session) {
|
|
854
1438
|
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
855
1439
|
beat(); // any session traffic = alive (throttled to 1/min)
|
|
@@ -1008,14 +1592,17 @@ export async function runLiveTask({
|
|
|
1008
1592
|
}
|
|
1009
1593
|
onChild?.(null); // no longer busy — token may rotate between tasks
|
|
1010
1594
|
onIntent?.(null);
|
|
1011
|
-
input
|
|
1595
|
+
// Only a live session has a streaming input to close or a generator to
|
|
1596
|
+
// return. The subprocess driver's children are already gone — runTurn awaits
|
|
1597
|
+
// each one — and it clears its own onChild handle per turn.
|
|
1598
|
+
input?.close();
|
|
1012
1599
|
try {
|
|
1013
|
-
await session
|
|
1600
|
+
await session?.interrupt?.();
|
|
1014
1601
|
} catch {
|
|
1015
1602
|
/* session already ended */
|
|
1016
1603
|
}
|
|
1017
1604
|
try {
|
|
1018
|
-
await session
|
|
1605
|
+
await session?.return?.();
|
|
1019
1606
|
} catch {
|
|
1020
1607
|
/* generator already closed */
|
|
1021
1608
|
}
|