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 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
- 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;
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
- 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);
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) return { outcome: 'blocked', title, intentId };
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
- await mcpCall(mcpUrl, token, 'attach_pr', {
842
- runId,
843
- prUrl: String(result.prUrl),
844
- ...(result.branch ? { branch: String(result.branch) } : {}),
845
- }).catch(() => {});
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
- if (result.outcome === 'done' && isPatch) {
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
- await mcpCall(mcpUrl, token, 'complete', {
1033
+ const carded = await postComplete({
1034
+ mcpUrl,
1035
+ token,
852
1036
  runId,
853
- outcome: result.outcome === 'done' ? 'completed' : 'failed',
1037
+ outcome: done ? 'completed' : 'failed',
854
1038
  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 };
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.
@@ -650,8 +650,13 @@ export const RUNTIMES = {
650
650
  },
651
651
  };
652
652
 
653
- /** Runtimes this daemon can actually put a task on. */
654
- export const DISPATCHABLE = Object.values(RUNTIMES).filter((r) => r.mcp && r.args);
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.0",
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": {