flowviant 0.40.0 → 0.41.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/fleet.mjs +30 -20
- package/bin/lib/live.mjs +251 -32
- package/bin/lib/resources.mjs +2 -2
- package/bin/lib/runtimes.mjs +19 -4
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -191,7 +191,7 @@ function sampleDiffstat(cwd, baseRef, intentId, agentId) {
|
|
|
191
191
|
signal: AbortSignal.timeout(15_000),
|
|
192
192
|
// The lane, not just the task: the server matches the run on both, so a
|
|
193
193
|
// sample can only ever overwrite the diffstat of THIS lane's own run.
|
|
194
|
-
body: JSON.stringify({ intentId, agentId, diffstat: stat }),
|
|
194
|
+
body: JSON.stringify({ taskId: intentId, agentId, diffstat: stat }),
|
|
195
195
|
});
|
|
196
196
|
// Only a sample the server ACCEPTED counts as sent. Marking it delivered
|
|
197
197
|
// before the round-trip meant a dropped request suppressed every retry
|
|
@@ -575,7 +575,7 @@ export async function runFleetDaemon() {
|
|
|
575
575
|
// roster re-serves the job every poll, and each pass reverts the
|
|
576
576
|
// revert — the change flapping in and out of the owner's tree forever.
|
|
577
577
|
await reportMergeOutcome(PATCH_REVERT_DONE_URL, {
|
|
578
|
-
|
|
578
|
+
taskId: job.id,
|
|
579
579
|
ok: res.ok,
|
|
580
580
|
error: res.ok ? undefined : String(res.error ?? 'revert failed'),
|
|
581
581
|
});
|
|
@@ -693,9 +693,12 @@ export async function runFleetDaemon() {
|
|
|
693
693
|
const checkingPlans = new Set();
|
|
694
694
|
const processPlanCheckJobs = (jobs) => {
|
|
695
695
|
for (const job of jobs ?? []) {
|
|
696
|
-
|
|
696
|
+
// New name first; the roster mirrors `intents` off `tasks` for exactly
|
|
697
|
+
// this fallback window.
|
|
698
|
+
const planTasks = Array.isArray(job?.tasks) ? job.tasks : job?.intents;
|
|
699
|
+
if (!job || typeof job.id !== 'string' || !Array.isArray(planTasks)) continue;
|
|
697
700
|
if (checkingPlans.has(job.id)) continue;
|
|
698
|
-
if (
|
|
701
|
+
if (planTasks.length === 0) continue;
|
|
699
702
|
checkingPlans.add(job.id);
|
|
700
703
|
(async () => {
|
|
701
704
|
try {
|
|
@@ -711,7 +714,7 @@ export async function runFleetDaemon() {
|
|
|
711
714
|
const out = await withWikiLock(async () => {
|
|
712
715
|
ensureWikiWorktree();
|
|
713
716
|
return runTurn({
|
|
714
|
-
prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents:
|
|
717
|
+
prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: planTasks }),
|
|
715
718
|
resume: false,
|
|
716
719
|
system: SYSTEM_PLAN_CHECK,
|
|
717
720
|
cwd: wikiWt,
|
|
@@ -721,12 +724,12 @@ export async function runFleetDaemon() {
|
|
|
721
724
|
label: c.cyan('[plan]'),
|
|
722
725
|
});
|
|
723
726
|
});
|
|
724
|
-
const checks = parsePlanChecks(out,
|
|
727
|
+
const checks = parsePlanChecks(out, planTasks);
|
|
725
728
|
if (checks === null) {
|
|
726
729
|
warn(`plan check for "${job.title}": no usable JSON — leaving the plan as drafted`);
|
|
727
730
|
}
|
|
728
731
|
await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
|
|
729
|
-
|
|
732
|
+
taskId: job.id,
|
|
730
733
|
checks: checks ?? [],
|
|
731
734
|
});
|
|
732
735
|
if (checks?.length) {
|
|
@@ -737,7 +740,7 @@ export async function runFleetDaemon() {
|
|
|
737
740
|
} catch (e) {
|
|
738
741
|
warn(`plan check failed for "${job.title}": ${e?.message ?? e}`);
|
|
739
742
|
// Clear the flag anyway — a stuck job would re-run every poll forever.
|
|
740
|
-
await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
|
|
743
|
+
await reportMergeOutcome(PLAN_CHECK_DONE_URL, { taskId: job.id, checks: [] });
|
|
741
744
|
} finally {
|
|
742
745
|
checkingPlans.delete(job.id);
|
|
743
746
|
}
|
|
@@ -797,7 +800,7 @@ export async function runFleetDaemon() {
|
|
|
797
800
|
joinChain = joinChain.then(async () => {
|
|
798
801
|
let settled = false;
|
|
799
802
|
try {
|
|
800
|
-
const target = worktreeBuilding(job.intentId);
|
|
803
|
+
const target = worktreeBuilding(job.taskId ?? job.intentId);
|
|
801
804
|
if (!target) {
|
|
802
805
|
// The run ended (or moved) between the human pressing ⚡ and this
|
|
803
806
|
// poll. Settle rather than retry: there is no worktree to join, and
|
|
@@ -818,11 +821,11 @@ export async function runFleetDaemon() {
|
|
|
818
821
|
const claim = await postForData(JOIN_TAKE_URL, { joinId: job.id });
|
|
819
822
|
if (!claim?.taken) return;
|
|
820
823
|
note(
|
|
821
|
-
`${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.intentTitle || 'a task'}"`)}`
|
|
824
|
+
`${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.taskTitle || job.intentTitle || 'a task'}"`)}`
|
|
822
825
|
);
|
|
823
826
|
const out = await runTurn({
|
|
824
827
|
prompt: QUICK_EDIT_KICKOFF({
|
|
825
|
-
intentTitle: job.intentTitle,
|
|
828
|
+
intentTitle: job.taskTitle ?? job.intentTitle,
|
|
826
829
|
instruction: job.instruction,
|
|
827
830
|
askedByName: job.askedByName,
|
|
828
831
|
}),
|
|
@@ -946,7 +949,7 @@ export async function runFleetDaemon() {
|
|
|
946
949
|
if (!isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
|
|
947
950
|
mergeAttempts.delete(job.id);
|
|
948
951
|
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
949
|
-
|
|
952
|
+
taskId: job.id,
|
|
950
953
|
message: 'refused: PR URL is not a pull request in this repository',
|
|
951
954
|
});
|
|
952
955
|
warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
|
|
@@ -973,7 +976,7 @@ export async function runFleetDaemon() {
|
|
|
973
976
|
if (!/no changes|already/i.test(err)) {
|
|
974
977
|
mergeAttempts.delete(job.id);
|
|
975
978
|
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
976
|
-
|
|
979
|
+
taskId: job.id,
|
|
977
980
|
message: `could not retarget the stacked PR onto ${baseBranchName(baseRef)} — merging it now would land in the branch below it, not ${baseBranchName(baseRef)}`,
|
|
978
981
|
});
|
|
979
982
|
warn(`merge held for "${job.title}": retarget failed — ${err.split('\n')[0]}`);
|
|
@@ -1010,7 +1013,7 @@ export async function runFleetDaemon() {
|
|
|
1010
1013
|
}
|
|
1011
1014
|
if (merged) {
|
|
1012
1015
|
mergeAttempts.delete(job.id);
|
|
1013
|
-
await reportMergeOutcome(MERGE_DONE_URL, {
|
|
1016
|
+
await reportMergeOutcome(MERGE_DONE_URL, { taskId: job.id });
|
|
1014
1017
|
ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
|
|
1015
1018
|
// The code just landed — re-ground the living wiki for what shipped
|
|
1016
1019
|
// (touched nodes re-read + a persistent feature-history node).
|
|
@@ -1023,7 +1026,7 @@ export async function runFleetDaemon() {
|
|
|
1023
1026
|
// button + notifies) — the job disappears from the roster.
|
|
1024
1027
|
mergeAttempts.delete(job.id);
|
|
1025
1028
|
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
1026
|
-
|
|
1029
|
+
taskId: job.id,
|
|
1027
1030
|
message: failedReason,
|
|
1028
1031
|
});
|
|
1029
1032
|
warn(`merge failed for "${job.title}": ${failedReason} — reported to the thread`);
|
|
@@ -1083,7 +1086,7 @@ export async function runFleetDaemon() {
|
|
|
1083
1086
|
} else if (job.prUrl || job.branch) {
|
|
1084
1087
|
warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
|
|
1085
1088
|
}
|
|
1086
|
-
await reportMergeOutcome(CLEANUP_DONE_URL, {
|
|
1089
|
+
await reportMergeOutcome(CLEANUP_DONE_URL, { taskId: job.id });
|
|
1087
1090
|
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
1088
1091
|
} finally {
|
|
1089
1092
|
cleaning.delete(job.id);
|
|
@@ -1397,7 +1400,7 @@ export async function runFleetDaemon() {
|
|
|
1397
1400
|
// sweep), so a failing re-ground can't loop-burn quota. Only a
|
|
1398
1401
|
// crash BEFORE this line leaves the job listed for a retry.
|
|
1399
1402
|
regroundAttempts.delete(task.intentId);
|
|
1400
|
-
await reportMergeOutcome(REGROUND_DONE_URL, {
|
|
1403
|
+
await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
|
|
1401
1404
|
}
|
|
1402
1405
|
} catch (e) {
|
|
1403
1406
|
warn(`wiki ${task.type} failed: ${e.message}`);
|
|
@@ -1553,7 +1556,13 @@ export async function runFleetDaemon() {
|
|
|
1553
1556
|
mintedAt.set(a.agentId, Date.now());
|
|
1554
1557
|
}
|
|
1555
1558
|
hasWorkByAgent.set(a.agentId, !!a.hasWork);
|
|
1556
|
-
|
|
1559
|
+
// The hint's task id, new name first. Normalized ONTO `intentId` here so
|
|
1560
|
+
// every downstream read (poll worker, kickoff, diffstat attribution)
|
|
1561
|
+
// keeps its one spelling — intent is still the daemon's internal word,
|
|
1562
|
+
// taskId is the wire's.
|
|
1563
|
+
const nextId = a.next && (a.next.taskId ?? a.next.intentId);
|
|
1564
|
+
if (a.next && typeof nextId === 'string')
|
|
1565
|
+
nextByAgent.set(a.agentId, { ...a.next, intentId: nextId });
|
|
1557
1566
|
else nextByAgent.delete(a.agentId);
|
|
1558
1567
|
if (!workers.has(a.agentId)) {
|
|
1559
1568
|
// Local ceiling, enforced and not merely requested. The roster can carry
|
|
@@ -1647,8 +1656,9 @@ export async function runFleetDaemon() {
|
|
|
1647
1656
|
// whose earlier mint failed.
|
|
1648
1657
|
enqueueSweep(roster.codeMapJob);
|
|
1649
1658
|
for (const j of roster.regroundJobs ?? []) {
|
|
1650
|
-
|
|
1651
|
-
|
|
1659
|
+
const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
|
|
1660
|
+
if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
|
|
1661
|
+
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
|
|
1652
1662
|
}
|
|
1653
1663
|
void drainWiki();
|
|
1654
1664
|
|
package/bin/lib/live.mjs
CHANGED
|
@@ -88,7 +88,7 @@ async function registerLiveTarget(intentId, kind, url) {
|
|
|
88
88
|
'Content-Type': 'application/json',
|
|
89
89
|
},
|
|
90
90
|
signal: AbortSignal.timeout(30_000),
|
|
91
|
-
body: JSON.stringify({ intentId, kind, url, ttlMinutes: PREVIEW_TTL_MINUTES }),
|
|
91
|
+
body: JSON.stringify({ taskId: intentId, kind, url, ttlMinutes: PREVIEW_TTL_MINUTES }),
|
|
92
92
|
});
|
|
93
93
|
} catch {
|
|
94
94
|
/* best-effort — the tunnel still works; it just isn't linked in the app */
|
|
@@ -108,7 +108,7 @@ function clearLiveTarget(intentId, kind) {
|
|
|
108
108
|
'Content-Type': 'application/json',
|
|
109
109
|
},
|
|
110
110
|
signal: AbortSignal.timeout(5_000),
|
|
111
|
-
body: JSON.stringify({ intentId, kind }),
|
|
111
|
+
body: JSON.stringify({ taskId: intentId, kind }),
|
|
112
112
|
}).catch(() => {});
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -127,7 +127,7 @@ function postPreviewNote(intentId, text) {
|
|
|
127
127
|
signal: AbortSignal.timeout(10_000),
|
|
128
128
|
// Scrub: preview failure reasons can quote dev-server output, which can
|
|
129
129
|
// echo env values.
|
|
130
|
-
body: JSON.stringify({ intentId, text: envScrub(text) }),
|
|
130
|
+
body: JSON.stringify({ taskId: intentId, text: envScrub(text) }),
|
|
131
131
|
}).catch(() => {});
|
|
132
132
|
}
|
|
133
133
|
|
|
@@ -673,6 +673,33 @@ SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their
|
|
|
673
673
|
VALUES must NEVER appear in the summary, in commits, or in a PR — reference keys
|
|
674
674
|
by NAME only. Never commit an env file.`;
|
|
675
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Walk forward from an opening brace to its MATCHING close, or null.
|
|
678
|
+
*
|
|
679
|
+
* String-aware, because the thing being matched is JSON and this object's whole
|
|
680
|
+
* job is to carry human prose: a summary reading `fixed the {x} case` would
|
|
681
|
+
* otherwise close the object early, and an escaped quote inside it would end the
|
|
682
|
+
* string early. Depth counting alone is not enough.
|
|
683
|
+
*/
|
|
684
|
+
function balancedSpan(text, start) {
|
|
685
|
+
let depth = 0;
|
|
686
|
+
let inStr = false;
|
|
687
|
+
let esc = false;
|
|
688
|
+
for (let i = start; i < text.length; i++) {
|
|
689
|
+
const ch = text[i];
|
|
690
|
+
if (inStr) {
|
|
691
|
+
if (esc) esc = false;
|
|
692
|
+
else if (ch === '\\') esc = true;
|
|
693
|
+
else if (ch === '"') inStr = false;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (ch === '"') inStr = true;
|
|
697
|
+
else if (ch === '{') depth++;
|
|
698
|
+
else if (ch === '}' && --depth === 0) return text.slice(start, i + 1);
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
676
703
|
/** Pull the result object out of a turn's output. */
|
|
677
704
|
function parseMediatedResult(out) {
|
|
678
705
|
const text = String(out ?? '').trim();
|
|
@@ -683,10 +710,35 @@ function parseMediatedResult(out) {
|
|
|
683
710
|
// build. Last rather than first: any preamble comes before the answer.
|
|
684
711
|
const direct = tryJson(text);
|
|
685
712
|
if (direct) return direct;
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
713
|
+
// Each candidate open brace gets its OWN close, found by scanning forward.
|
|
714
|
+
// The previous version anchored every attempt on `text.lastIndexOf('}')` —
|
|
715
|
+
// recomputed per iteration but loop-INVARIANT, so it was always the final `}`
|
|
716
|
+
// of the whole output. Only the start moved; the end never retreated. Any
|
|
717
|
+
// sentence after the object containing a brace (`Note: the } above closes it`)
|
|
718
|
+
// therefore made every slice unparseable, and a FINISHED build came back as
|
|
719
|
+
// `stalled` after two nudges. Reproduced before fixing.
|
|
720
|
+
let tried = 0;
|
|
721
|
+
for (let i = text.lastIndexOf('{'); i >= 0; i = text.lastIndexOf('{', i - 1)) {
|
|
722
|
+
// A candidate must OPEN ITS OWN LINE (whitespace aside). A form echoed
|
|
723
|
+
// mid-sentence is how a hypothetical became a delivery card: `I would
|
|
724
|
+
// return {"outcome":"done",…} once done. But I could not…` parsed as done
|
|
725
|
+
// and posted a completed card for a failed build (reproduced). A real form
|
|
726
|
+
// — bare, fenced, or followed by notes — opens at a line start, and a
|
|
727
|
+
// wrapper that inlines it gets the nudge, which asks for the bare object
|
|
728
|
+
// anyway. A wrong card has no recovery; a nudge does. Skipped candidates
|
|
729
|
+
// don't count against the bound, which also keeps a trailing prose brace
|
|
730
|
+
// from burning slots the real object needs.
|
|
731
|
+
const bol = text.lastIndexOf('\n', i - 1) + 1;
|
|
732
|
+
if (!text.slice(bol, i).trim()) {
|
|
733
|
+
// Bounded: an unbalanced brace scans to end-of-text, and a build's output
|
|
734
|
+
// can be very long. The real object is at the end — 200 candidates is far
|
|
735
|
+
// past any honest wrapper and keeps a pathological output from stalling
|
|
736
|
+
// the turn loop instead of the model.
|
|
737
|
+
if (++tried > 200) break;
|
|
738
|
+
const span = balancedSpan(text, i);
|
|
739
|
+
const cand = span && tryJson(span);
|
|
740
|
+
if (cand) return cand;
|
|
741
|
+
}
|
|
690
742
|
if (i === 0) break;
|
|
691
743
|
}
|
|
692
744
|
return null;
|
|
@@ -700,6 +752,77 @@ function tryJson(s) {
|
|
|
700
752
|
}
|
|
701
753
|
}
|
|
702
754
|
|
|
755
|
+
/**
|
|
756
|
+
* The server's own rule for a PR URL (`mcpAttachPrSchema`), checked BEFORE the
|
|
757
|
+
* call instead of discovered as a swallowed rejection after it.
|
|
758
|
+
*
|
|
759
|
+
* The result schema can only say `prUrl: string` — the model writes the value
|
|
760
|
+
* freehand — and the server's zod REJECTS a non-github or non-pull URL, so a
|
|
761
|
+
* plausible-looking mistake meant the PR was never linked and the task never
|
|
762
|
+
* moved to `review`, with nothing anywhere saying so. Deliberately NOT expressed
|
|
763
|
+
* as a `pattern` in MEDIATED_RESULT_SCHEMA: the mediated path is the one whose
|
|
764
|
+
* schema enforcement is a vendor flag we verified empirically on exactly one
|
|
765
|
+
* version, and adding a keyword that CLI may not implement risks the working
|
|
766
|
+
* case to defend the broken one. Validate on our side, where we know the rules.
|
|
767
|
+
*/
|
|
768
|
+
const PR_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/;
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Coerce the model's criteria self-report into the shape `complete` accepts.
|
|
772
|
+
*
|
|
773
|
+
* MEDIATED_RESULT_SCHEMA can only say `index: number`; the server says
|
|
774
|
+
* `int().min(0)`, note ≤500, array ≤50 — and ONE bad row makes the whole
|
|
775
|
+
* `complete` call throw, which on this path means no delivery card at all. So
|
|
776
|
+
* repairable rows are repaired and the rest dropped: a self-report missing an
|
|
777
|
+
* entry is worth far more than a card that never arrives.
|
|
778
|
+
*/
|
|
779
|
+
function sanitizeCriteria(criteria) {
|
|
780
|
+
if (!Array.isArray(criteria)) return null;
|
|
781
|
+
const rows = criteria
|
|
782
|
+
// A negative index is DROPPED, not clamped: Math.max(0, …) would silently
|
|
783
|
+
// re-attribute the row to criterion 0, which is a wrong self-report rather
|
|
784
|
+
// than a missing one.
|
|
785
|
+
.filter((c) => c && Number.isFinite(c.index) && c.index >= 0 && typeof c.met === 'boolean')
|
|
786
|
+
.map((c) => ({
|
|
787
|
+
index: Math.trunc(c.index),
|
|
788
|
+
met: c.met,
|
|
789
|
+
...(typeof c.note === 'string' && c.note ? { note: c.note.slice(0, 500) } : {}),
|
|
790
|
+
}))
|
|
791
|
+
.slice(0, 50);
|
|
792
|
+
return rows.length ? rows : null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Post the delivery card, and get one honest retry at it.
|
|
797
|
+
*
|
|
798
|
+
* The retry drops `criteria` on purpose. runId, outcome and summary are all
|
|
799
|
+
* daemon-controlled and already clamped, so the only argument that can still be
|
|
800
|
+
* rejected is the one the model wrote — and dropping it also re-enters
|
|
801
|
+
* `complete`'s idempotent branch, which is what recovers the OTHER failure the
|
|
802
|
+
* server documents here (`task_status_failed`: the run row moved but the task's
|
|
803
|
+
* status write didn't, and the fix is to call again).
|
|
804
|
+
*
|
|
805
|
+
* Returns false only on an EXPLICIT `ok: false`. An unparseable or empty
|
|
806
|
+
* response is treated as success: this verdict decides whether the run is left
|
|
807
|
+
* for the stale sweep to roll back and rebuild, and a transient hiccup is not
|
|
808
|
+
* worth rebuilding a finished task over.
|
|
809
|
+
*/
|
|
810
|
+
async function postComplete({ mcpUrl, token, runId, outcome, summary, criteria }) {
|
|
811
|
+
const rows = sanitizeCriteria(criteria);
|
|
812
|
+
const call = (args) =>
|
|
813
|
+
mcpCall(mcpUrl, token, 'complete', args).catch((e) => ({
|
|
814
|
+
ok: false,
|
|
815
|
+
reason: e?.message ?? String(e),
|
|
816
|
+
}));
|
|
817
|
+
const base = { runId, outcome, summary };
|
|
818
|
+
let res = await call(rows ? { ...base, criteria: rows } : base);
|
|
819
|
+
if (res?.ok === false && rows) {
|
|
820
|
+
warn(`complete rejected (${res.reason ?? 'unknown'}) — retrying without the criteria self-report`);
|
|
821
|
+
res = await call(base);
|
|
822
|
+
}
|
|
823
|
+
return res?.ok !== false;
|
|
824
|
+
}
|
|
825
|
+
|
|
703
826
|
/**
|
|
704
827
|
* Drive a task with a runtime that cannot reach the MCP server at all.
|
|
705
828
|
*
|
|
@@ -746,7 +869,6 @@ async function driveMediated({
|
|
|
746
869
|
const rt = runtimeById(runtimeId);
|
|
747
870
|
const dir = mkdtempSync(join(tmpdir(), 'flowviant-schema-'));
|
|
748
871
|
const schemaPath = join(dir, 'result.schema.json');
|
|
749
|
-
writeFileSync(schemaPath, JSON.stringify(MEDIATED_RESULT_SCHEMA), { mode: 0o600 });
|
|
750
872
|
|
|
751
873
|
// The agent cannot call report_progress, so the daemon narrates for it off the
|
|
752
874
|
// parsed activity stream. Throttled: a build touches hundreds of files and the
|
|
@@ -768,6 +890,9 @@ async function driveMediated({
|
|
|
768
890
|
let resume = false;
|
|
769
891
|
let nudges = 0;
|
|
770
892
|
try {
|
|
893
|
+
// Inside the try so a failed write (disk full) still removes `dir` in the
|
|
894
|
+
// finally instead of leaking one temp directory per attempt.
|
|
895
|
+
writeFileSync(schemaPath, JSON.stringify(MEDIATED_RESULT_SCHEMA), { mode: 0o600 });
|
|
771
896
|
for (;;) {
|
|
772
897
|
if (!isAlive()) return { outcome: 'blocked', title, intentId };
|
|
773
898
|
let out = '';
|
|
@@ -814,18 +939,50 @@ async function driveMediated({
|
|
|
814
939
|
}
|
|
815
940
|
|
|
816
941
|
if (result.outcome === 'blocked') {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
942
|
+
// Clamp AND scrub, same discipline as postComplete below, and for the
|
|
943
|
+
// same reason: on this path the DAEMON is the caller, so the model never
|
|
944
|
+
// sees the server's zod rejection and cannot self-correct. The server
|
|
945
|
+
// caps question at 2000 and options at 10×500 (questionPayloadSchema) —
|
|
946
|
+
// an oversize value posted raw is a rejected post, i.e. a question that
|
|
947
|
+
// silently never reaches the human. And the question is model narration
|
|
948
|
+
// leaving the box, exactly what the uplink scrub exists for.
|
|
949
|
+
const q =
|
|
950
|
+
envScrub(String(result.blockerQuestion ?? result.summary ?? '').trim()).slice(0, 2000) ||
|
|
951
|
+
'The agent stopped and did not say why.';
|
|
952
|
+
const options = (Array.isArray(result.blockerOptions) ? result.blockerOptions : [])
|
|
953
|
+
.filter((o) => typeof o === 'string' && o.trim())
|
|
954
|
+
.map((o) => envScrub(o.trim()).slice(0, 500))
|
|
955
|
+
.filter(Boolean)
|
|
956
|
+
.slice(0, 10);
|
|
957
|
+
const post = () =>
|
|
958
|
+
mcpCall(mcpUrl, token, 'report_blocker', {
|
|
959
|
+
runId,
|
|
960
|
+
taskId: intentId,
|
|
961
|
+
type: 'question',
|
|
962
|
+
payload: { question: q, ...(options.length ? { options } : {}) },
|
|
963
|
+
}).catch(() => null);
|
|
964
|
+
// One retry: reportBlockerOnce is idempotent server-side, and a dropped
|
|
965
|
+
// response is the documented reason it is.
|
|
966
|
+
let posted = await post();
|
|
967
|
+
if (!posted?.blockerId && !posted?.id) {
|
|
968
|
+
await sleep(2);
|
|
969
|
+
posted = await post();
|
|
970
|
+
}
|
|
827
971
|
const blockerId = posted?.blockerId ?? posted?.id ?? null;
|
|
828
|
-
if (!blockerId)
|
|
972
|
+
if (!blockerId) {
|
|
973
|
+
// The question exists only in this process. Say why on the way out —
|
|
974
|
+
// silence here reads identically to a human who has not answered yet.
|
|
975
|
+
//
|
|
976
|
+
// 'error', NOT 'blocked': on every driver 'blocked' means "shutting
|
|
977
|
+
// down mid-park", and runLiveWorker BREAKS on it — a lane that ends
|
|
978
|
+
// its loop is never respawned (workers.delete fires only on roster
|
|
979
|
+
// removal), so returning it here turned one failed post into a lane
|
|
980
|
+
// that sat dead-but-listed until the daemon restarted. 'error' takes
|
|
981
|
+
// the refresh-token-and-retry path, and the shared finally's
|
|
982
|
+
// checkpoint keeps the work for whoever picks the task back up.
|
|
983
|
+
warn(`report_blocker did not return an id (${posted?.reason ?? posted?.raw ?? 'no response'}) — the question was not posted`);
|
|
984
|
+
return { outcome: 'error', error: 'report_blocker failed', title, intentId };
|
|
985
|
+
}
|
|
829
986
|
const res = await waitForResolution(mcpUrl, token, blockerId, isAlive);
|
|
830
987
|
if (res.status === 'resolved') {
|
|
831
988
|
prompt = `The human answered your blocker: ${JSON.stringify(res.answer)}\nApply it and continue, then return the result form.`;
|
|
@@ -837,25 +994,61 @@ async function driveMediated({
|
|
|
837
994
|
}
|
|
838
995
|
|
|
839
996
|
// done / failed — either way the turn is over and the thread gets a card.
|
|
997
|
+
//
|
|
998
|
+
// NOTHING FROM HERE DOWN IS BEST-EFFORT, and that is the difference this
|
|
999
|
+
// path has to make up for. On the direct-MCP paths the AGENT makes these
|
|
1000
|
+
// calls and sees the rejection, so it corrects and retries; a mediated
|
|
1001
|
+
// agent never learns that the daemon's call failed. Swallowing them (which
|
|
1002
|
+
// is what this shipped as) produced the worst available outcome: the PR
|
|
1003
|
+
// silently unlinked, the task never moved to `review`, no delivery card —
|
|
1004
|
+
// and `markLanded()` firing anyway, so the shared `finally` DELETED the WIP
|
|
1005
|
+
// checkpoint for work the control plane had never been told about.
|
|
840
1006
|
if (result.prUrl && !isPatch) {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1007
|
+
const prUrl = String(result.prUrl).trim();
|
|
1008
|
+
if (!PR_URL_RE.test(prUrl)) {
|
|
1009
|
+
// The model can fix this one, so ask it to — it already opened the PR.
|
|
1010
|
+
if (nudges < 2) {
|
|
1011
|
+
nudges++;
|
|
1012
|
+
prompt =
|
|
1013
|
+
`"${prUrl}" is not a GitHub pull request URL (expected https://github.com/<owner>/<repo>/pull/<number>). ` +
|
|
1014
|
+
'Do NOT redo any work and do NOT open another PR. Return the result form again with the real URL of the ' +
|
|
1015
|
+
'pull request you already opened, or omit prUrl entirely if you did not open one.';
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
warn(`attach_pr skipped: unusable prUrl ${prUrl}`);
|
|
1019
|
+
} else {
|
|
1020
|
+
const attached = await mcpCall(mcpUrl, token, 'attach_pr', {
|
|
1021
|
+
runId,
|
|
1022
|
+
prUrl,
|
|
1023
|
+
...(result.branch ? { branch: String(result.branch) } : {}),
|
|
1024
|
+
}).catch((e) => ({ ok: false, reason: e?.message ?? String(e) }));
|
|
1025
|
+
if (attached?.ok === false) warn(`attach_pr rejected: ${attached.reason ?? 'unknown'}`);
|
|
1026
|
+
}
|
|
846
1027
|
}
|
|
847
1028
|
clearTaskMarker(cwd);
|
|
848
|
-
|
|
1029
|
+
const done = result.outcome === 'done';
|
|
1030
|
+
if (done && isPatch) {
|
|
849
1031
|
await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
|
|
850
1032
|
}
|
|
851
|
-
|
|
1033
|
+
const carded = await postComplete({
|
|
1034
|
+
mcpUrl,
|
|
1035
|
+
token,
|
|
852
1036
|
runId,
|
|
853
|
-
outcome:
|
|
1037
|
+
outcome: done ? 'completed' : 'failed',
|
|
854
1038
|
summary: envScrub(String(result.summary ?? '').slice(0, 4000)),
|
|
855
|
-
|
|
856
|
-
})
|
|
857
|
-
if (
|
|
858
|
-
|
|
1039
|
+
criteria: result.criteria,
|
|
1040
|
+
});
|
|
1041
|
+
if (!carded) {
|
|
1042
|
+
// No delivery card exists, so this run is not done however the work
|
|
1043
|
+
// ended. `landed` deliberately stays false: the shared finally takes one
|
|
1044
|
+
// last checkpoint instead of deleting the WIP ref, and the run is left
|
|
1045
|
+
// active for the stale sweep to roll back and re-dispatch — recoverable,
|
|
1046
|
+
// unlike reporting success into a thread that shows nothing.
|
|
1047
|
+
warn('complete failed — leaving the run for the server to reclaim');
|
|
1048
|
+
return { outcome: 'error', error: 'complete rejected', title, intentId };
|
|
1049
|
+
}
|
|
1050
|
+
if (done) markLanded();
|
|
1051
|
+
return { outcome: done ? 'done' : 'stalled', title, intentId };
|
|
859
1052
|
}
|
|
860
1053
|
} finally {
|
|
861
1054
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -1081,7 +1274,13 @@ export async function runLiveTask({
|
|
|
1081
1274
|
runtimes: DRIVABLE_HERE,
|
|
1082
1275
|
}).catch(() => null);
|
|
1083
1276
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
1084
|
-
const
|
|
1277
|
+
const runId = claim.runId;
|
|
1278
|
+
// New name first: the server returns `taskId` natively and mirrors
|
|
1279
|
+
// `intentId` beside it for exactly this read. Reading taskId is what lets
|
|
1280
|
+
// that mirror (and the fleet routes' intentId compat) retire once
|
|
1281
|
+
// daemon:min passes this release. The variable keeps the old spelling —
|
|
1282
|
+
// it is the daemon's internal word, not a wire field.
|
|
1283
|
+
const intentId = claim.taskId ?? claim.intentId;
|
|
1085
1284
|
const brief = claim.brief ?? {};
|
|
1086
1285
|
const title = brief.title ?? 'a task';
|
|
1087
1286
|
|
|
@@ -1355,6 +1554,25 @@ export async function runLiveTask({
|
|
|
1355
1554
|
lastBeat = Date.now();
|
|
1356
1555
|
void mcpCall(mcpUrl, token, 'heartbeat', { runId }).catch(() => {});
|
|
1357
1556
|
};
|
|
1557
|
+
// AND ON A TIMER, because "activity" is not a signal every driver has.
|
|
1558
|
+
//
|
|
1559
|
+
// `beat()` used to be called from exactly ONE place — the live session's
|
|
1560
|
+
// message loop, below — and the two other drivers return before they ever
|
|
1561
|
+
// reach it. So a mediated or subprocess turn renewed the task lease only
|
|
1562
|
+
// incidentally: `report_progress`, which fires only when the CLI happens to
|
|
1563
|
+
// emit a tool activity and is throttled to one per 8s. Meanwhile Antigravity
|
|
1564
|
+
// is handed `--print-timeout 60m` and AGENT_LEASE_TTL_MINUTES is 30, so a
|
|
1565
|
+
// quiet stretch INSIDE a turn we explicitly permitted made the task stale to
|
|
1566
|
+
// `isEligible` and claimable by another worker while it was still building it.
|
|
1567
|
+
// `heartbeat` renews the task lease server-side (refreshTaskLeaseRemote), not
|
|
1568
|
+
// just this token's last-seen, which is exactly the thing that goes stale.
|
|
1569
|
+
//
|
|
1570
|
+
// Fires at half the throttle window; `beat()`'s own guard is what rate-limits
|
|
1571
|
+
// the wire, so session traffic and this timer cannot double up. Started here
|
|
1572
|
+
// rather than in each driver for the same reason the checkpoint timer is
|
|
1573
|
+
// shared: three drivers with three answers is how this diverged once already.
|
|
1574
|
+
const heartbeatTimer = setInterval(beat, 30_000);
|
|
1575
|
+
heartbeatTimer.unref?.();
|
|
1358
1576
|
|
|
1359
1577
|
const flush = async () => {
|
|
1360
1578
|
if (turnId && turnText.trim()) {
|
|
@@ -1574,6 +1792,7 @@ export async function runLiveTask({
|
|
|
1574
1792
|
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
1575
1793
|
} finally {
|
|
1576
1794
|
clearInterval(checkpointTimer);
|
|
1795
|
+
clearInterval(heartbeatTimer);
|
|
1577
1796
|
// Same finally as the checkpoint: every path out of this task — done,
|
|
1578
1797
|
// parked, rate-limited, thrown — must stop reporting a worktree that is
|
|
1579
1798
|
// about to stop being this run's.
|
package/bin/lib/resources.mjs
CHANGED
|
@@ -121,7 +121,7 @@ export function machineSnapshot({ worktreeDir, tasks = [] } = {}) {
|
|
|
121
121
|
diskTotal: disk?.total ?? null,
|
|
122
122
|
// Per-task, so "the box is full" can be traced to the task that filled it.
|
|
123
123
|
tasks: tasks
|
|
124
|
-
.map((t) => ({
|
|
125
|
-
.filter((t) => t.
|
|
124
|
+
.map((t) => ({ taskId: t.intentId, rss: processTreeRssBytes(t.pid) }))
|
|
125
|
+
.filter((t) => t.taskId && t.rss),
|
|
126
126
|
};
|
|
127
127
|
}
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -650,8 +650,13 @@ export const RUNTIMES = {
|
|
|
650
650
|
},
|
|
651
651
|
};
|
|
652
652
|
|
|
653
|
-
|
|
654
|
-
|
|
653
|
+
// DELETED: `DISPATCHABLE`, a `mcp && args` filter last described as "runtimes
|
|
654
|
+
// this daemon can actually put a task on". It had no importers left — `canRun`
|
|
655
|
+
// replaced it — but it was the exact predicate the per-profile split exists to
|
|
656
|
+
// correct, still exported under a name that invites reuse, and it answers FALSE
|
|
657
|
+
// for Antigravity on every job. The next caller to reach for the obvious-looking
|
|
658
|
+
// constant would have silently undone this release. `canRun(rt, profile)` /
|
|
659
|
+
// `drivableHere(rt)` below are the answers.
|
|
655
660
|
|
|
656
661
|
export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
|
|
657
662
|
|
|
@@ -761,7 +766,7 @@ export function pickRuntimeFor(profile, { detected } = {}) {
|
|
|
761
766
|
// ── Detection ──────────────────────────────────────────────────────────────
|
|
762
767
|
|
|
763
768
|
/**
|
|
764
|
-
* Which of these is on this machine, asked once.
|
|
769
|
+
* Which of these is on this machine, asked at most once per DETECT_TTL_MS.
|
|
765
770
|
*
|
|
766
771
|
* `--version` rather than `which`: a binary on PATH that cannot execute (a
|
|
767
772
|
* broken install, a wrong-arch download, a shell alias pointing at nothing) is
|
|
@@ -774,8 +779,18 @@ export function pickRuntimeFor(profile, { detected } = {}) {
|
|
|
774
779
|
* account, no quota, no entitlement. Flowviant relays; it does not enforce.
|
|
775
780
|
*/
|
|
776
781
|
let detectedCache = null;
|
|
782
|
+
let detectedAt = 0;
|
|
783
|
+
// The cache EXPIRES rather than living for the process: pickRuntimeFor's whole
|
|
784
|
+
// premise is "a CLI can be installed while the daemon runs", and both it and
|
|
785
|
+
// the roster poll read this cache — a forever-cache made that comment a lie
|
|
786
|
+
// (nothing after preflight ever passed refresh, so a mid-run install was
|
|
787
|
+
// invisible until restart, to the server included). 5 minutes keeps the probes
|
|
788
|
+
// (3 sync --version execs) off the hot path while an install still surfaces on
|
|
789
|
+
// the next poll or job.
|
|
790
|
+
const DETECT_TTL_MS = 5 * 60 * 1000;
|
|
777
791
|
export function detectRuntimes({ refresh = false } = {}) {
|
|
778
|
-
if (detectedCache && !refresh) return detectedCache;
|
|
792
|
+
if (detectedCache && !refresh && Date.now() - detectedAt < DETECT_TTL_MS) return detectedCache;
|
|
793
|
+
detectedAt = Date.now();
|
|
779
794
|
detectedCache = Object.values(RUNTIMES).map((rt) => {
|
|
780
795
|
let version = null;
|
|
781
796
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
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": {
|