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 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
- intentId: job.id,
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
- if (!job || typeof job.id !== 'string' || !Array.isArray(job.intents)) continue;
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 (job.intents.length === 0) continue;
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: job.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, job.intents);
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
- intentId: job.id,
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, { intentId: job.id, checks: [] });
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
- intentId: job.id,
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
- intentId: job.id,
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, { intentId: job.id });
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
- intentId: job.id,
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, { intentId: job.id });
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, { intentId: task.intentId });
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
- if (a.next && typeof a.next.intentId === 'string') nextByAgent.set(a.agentId, a.next);
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
- if (!j || typeof j.intentId !== 'string') continue; // a null element would throw + wedge the loop
1651
- enqueueReground(j.intentId, j.prUrl, j.title, j.dirtiesPages);
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
- 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 });
@@ -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 { runId, intentId } = claim;
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.
@@ -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) => ({ intentId: t.intentId, rss: processTreeRssBytes(t.pid) }))
125
- .filter((t) => t.intentId && t.rss),
124
+ .map((t) => ({ taskId: t.intentId, rss: processTreeRssBytes(t.pid) }))
125
+ .filter((t) => t.taskId && t.rss),
126
126
  };
127
127
  }
@@ -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.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": {