flowviant 0.40.0 → 0.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/live.mjs +241 -28
- package/bin/lib/runtimes.mjs +19 -4
- package/package.json +1 -1
package/bin/lib/live.mjs
CHANGED
|
@@ -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 });
|
|
@@ -1355,6 +1548,25 @@ export async function runLiveTask({
|
|
|
1355
1548
|
lastBeat = Date.now();
|
|
1356
1549
|
void mcpCall(mcpUrl, token, 'heartbeat', { runId }).catch(() => {});
|
|
1357
1550
|
};
|
|
1551
|
+
// AND ON A TIMER, because "activity" is not a signal every driver has.
|
|
1552
|
+
//
|
|
1553
|
+
// `beat()` used to be called from exactly ONE place — the live session's
|
|
1554
|
+
// message loop, below — and the two other drivers return before they ever
|
|
1555
|
+
// reach it. So a mediated or subprocess turn renewed the task lease only
|
|
1556
|
+
// incidentally: `report_progress`, which fires only when the CLI happens to
|
|
1557
|
+
// emit a tool activity and is throttled to one per 8s. Meanwhile Antigravity
|
|
1558
|
+
// is handed `--print-timeout 60m` and AGENT_LEASE_TTL_MINUTES is 30, so a
|
|
1559
|
+
// quiet stretch INSIDE a turn we explicitly permitted made the task stale to
|
|
1560
|
+
// `isEligible` and claimable by another worker while it was still building it.
|
|
1561
|
+
// `heartbeat` renews the task lease server-side (refreshTaskLeaseRemote), not
|
|
1562
|
+
// just this token's last-seen, which is exactly the thing that goes stale.
|
|
1563
|
+
//
|
|
1564
|
+
// Fires at half the throttle window; `beat()`'s own guard is what rate-limits
|
|
1565
|
+
// the wire, so session traffic and this timer cannot double up. Started here
|
|
1566
|
+
// rather than in each driver for the same reason the checkpoint timer is
|
|
1567
|
+
// shared: three drivers with three answers is how this diverged once already.
|
|
1568
|
+
const heartbeatTimer = setInterval(beat, 30_000);
|
|
1569
|
+
heartbeatTimer.unref?.();
|
|
1358
1570
|
|
|
1359
1571
|
const flush = async () => {
|
|
1360
1572
|
if (turnId && turnText.trim()) {
|
|
@@ -1574,6 +1786,7 @@ export async function runLiveTask({
|
|
|
1574
1786
|
return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
|
|
1575
1787
|
} finally {
|
|
1576
1788
|
clearInterval(checkpointTimer);
|
|
1789
|
+
clearInterval(heartbeatTimer);
|
|
1577
1790
|
// Same finally as the checkpoint: every path out of this task — done,
|
|
1578
1791
|
// parked, rate-limited, thrown — must stop reporting a worktree that is
|
|
1579
1792
|
// about to stop being this run's.
|
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.40.
|
|
3
|
+
"version": "0.40.1",
|
|
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": {
|