flowviant 0.40.1 → 0.43.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
@@ -57,8 +57,10 @@ import {
57
57
  SYSTEM_PLAN_CHECK,
58
58
  PLAN_CHECK_KICKOFF,
59
59
  REGROUND_KICKOFF,
60
- SYSTEM_CONSULT,
61
- CONSULT_KICKOFF,
60
+ SYSTEM_PLAN,
61
+ PLAN_TURN_KICKOFF,
62
+ SYSTEM_WORK,
63
+ WORK_TURN_KICKOFF,
62
64
  SYSTEM_QUICK_EDIT,
63
65
  QUICK_EDIT_KICKOFF,
64
66
  } from './claude.mjs';
@@ -191,7 +193,7 @@ function sampleDiffstat(cwd, baseRef, intentId, agentId) {
191
193
  signal: AbortSignal.timeout(15_000),
192
194
  // The lane, not just the task: the server matches the run on both, so a
193
195
  // sample can only ever overwrite the diffstat of THIS lane's own run.
194
- body: JSON.stringify({ intentId, agentId, diffstat: stat }),
196
+ body: JSON.stringify({ taskId: intentId, agentId, diffstat: stat }),
195
197
  });
196
198
  // Only a sample the server ACCEPTED counts as sent. Marking it delivered
197
199
  // before the round-trip meant a dropped request suppressed every retry
@@ -575,7 +577,7 @@ export async function runFleetDaemon() {
575
577
  // roster re-serves the job every poll, and each pass reverts the
576
578
  // revert — the change flapping in and out of the owner's tree forever.
577
579
  await reportMergeOutcome(PATCH_REVERT_DONE_URL, {
578
- intentId: job.id,
580
+ taskId: job.id,
579
581
  ok: res.ok,
580
582
  error: res.ok ? undefined : String(res.error ?? 'revert failed'),
581
583
  });
@@ -693,9 +695,12 @@ export async function runFleetDaemon() {
693
695
  const checkingPlans = new Set();
694
696
  const processPlanCheckJobs = (jobs) => {
695
697
  for (const job of jobs ?? []) {
696
- if (!job || typeof job.id !== 'string' || !Array.isArray(job.intents)) continue;
698
+ // New name first; the roster mirrors `intents` off `tasks` for exactly
699
+ // this fallback window.
700
+ const planTasks = Array.isArray(job?.tasks) ? job.tasks : job?.intents;
701
+ if (!job || typeof job.id !== 'string' || !Array.isArray(planTasks)) continue;
697
702
  if (checkingPlans.has(job.id)) continue;
698
- if (job.intents.length === 0) continue;
703
+ if (planTasks.length === 0) continue;
699
704
  checkingPlans.add(job.id);
700
705
  (async () => {
701
706
  try {
@@ -711,7 +716,7 @@ export async function runFleetDaemon() {
711
716
  const out = await withWikiLock(async () => {
712
717
  ensureWikiWorktree();
713
718
  return runTurn({
714
- prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: job.intents }),
719
+ prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: planTasks }),
715
720
  resume: false,
716
721
  system: SYSTEM_PLAN_CHECK,
717
722
  cwd: wikiWt,
@@ -721,12 +726,12 @@ export async function runFleetDaemon() {
721
726
  label: c.cyan('[plan]'),
722
727
  });
723
728
  });
724
- const checks = parsePlanChecks(out, job.intents);
729
+ const checks = parsePlanChecks(out, planTasks);
725
730
  if (checks === null) {
726
731
  warn(`plan check for "${job.title}": no usable JSON — leaving the plan as drafted`);
727
732
  }
728
733
  await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
729
- intentId: job.id,
734
+ taskId: job.id,
730
735
  checks: checks ?? [],
731
736
  });
732
737
  if (checks?.length) {
@@ -737,7 +742,7 @@ export async function runFleetDaemon() {
737
742
  } catch (e) {
738
743
  warn(`plan check failed for "${job.title}": ${e?.message ?? e}`);
739
744
  // Clear the flag anyway — a stuck job would re-run every poll forever.
740
- await reportMergeOutcome(PLAN_CHECK_DONE_URL, { intentId: job.id, checks: [] });
745
+ await reportMergeOutcome(PLAN_CHECK_DONE_URL, { taskId: job.id, checks: [] });
741
746
  } finally {
742
747
  checkingPlans.delete(job.id);
743
748
  }
@@ -745,21 +750,155 @@ export async function runFleetDaemon() {
745
750
  }
746
751
  };
747
752
 
748
- // Consults a planning question aimed at THIS machine, answered by reading
749
- // the repo. Deliberately the lightest job on the roster: same read-only
750
- // detached checkout the plan check uses, no MCP, no writes, no run recorded.
753
+ // ── Planning sessions ────────────────────────────────────────────────────
754
+ //
755
+ // A turn in a plan thread, answered inside a HELD session. This was the
756
+ // consult, which answered one question in prose and kept nothing: it existed
757
+ // because the planner was a different, weaker brain and this turn's only job
758
+ // was to correct it from the real code. That planner is gone, so the session
759
+ // reads the repo AND writes the plan, over many turns, in one context.
760
+ //
761
+ // Two things changed shape as a result.
762
+ //
763
+ // ONE WORKTREE PER PLAN, not the shared `wikiWt`. Every CLI here resumes with
764
+ // "continue the last session in this directory" (`--continue`, `resume
765
+ // --last`) rather than by session id, so the WORKING DIRECTORY *is* the
766
+ // session handle. A shared directory would have made two plans on one machine
767
+ // take turns wearing each other's context — and the wiki queue hard-resets
768
+ // that directory between tasks, which would pull the files out from under a
769
+ // session mid-argument. A private detached checkout per plan also means plan
770
+ // turns no longer queue behind the wiki lock.
771
+ //
772
+ // IT CARRIES MCP. A consult passed none — nothing to write. A session spawns
773
+ // slices, re-shapes them, drops them and maintains the spec, all of which are
774
+ // control-plane calls. The token is the fleet's PLAN principal, whose entire
775
+ // tool set is those five: it cannot claim, cannot open a worktree, cannot
776
+ // commit. That absence is the product rule, not a hardening measure — it is
777
+ // what makes "add a dark mode toggle" typed at a plan add a slice instead of
778
+ // building one, with nothing reading the sentence to decide.
751
779
  const CONSULT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/consult-done');
780
+ const PLAN_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-token');
752
781
  const answering = new Set();
753
- const consultAttempts = new Map(); // consultId -> tries
754
- /** Give up after this many turns on one question. A /consult-done that never
782
+ const consultAttempts = new Map(); // turn id -> tries
783
+ /** Give up after this many turns on one message. A /consult-done that never
755
784
  * reaches the server (offline, 500) would otherwise re-run the whole Claude
756
785
  * turn every poll, forever, on the owner's quota. */
757
786
  const MAX_CONSULT_TRIES = 3;
758
- /** ONE consult at a time. They all read the same worktree, and the roster can
759
- * hand back a batch un-awaited spawns meant N pending questions became N
760
- * concurrent `claude` processes on someone's laptop. */
787
+ /** ONE planning turn at a time on this machine. Sessions are per-plan so they
788
+ * no longer collide on a directory, but the roster can hand back a batch, and
789
+ * un-awaited spawns would put N concurrent CLI processes on someone's laptop
790
+ * for what is, on the human's side, a chat. */
761
791
  let consultChain = Promise.resolve();
762
792
 
793
+ /**
794
+ * The plan credential, cached until it stops working.
795
+ *
796
+ * Minted lazily rather than at startup: most daemons never host a planning
797
+ * session, and a token nobody uses is a credential sitting on disk for no
798
+ * reason. Rotated by the server on every mint, so a re-mint after a 401 is the
799
+ * recovery path.
800
+ */
801
+ let planToken = null;
802
+ const mintPlanToken = async (force = false) => {
803
+ if (planToken && !force) return planToken;
804
+ try {
805
+ const res = await fetch(PLAN_TOKEN_URL, {
806
+ method: 'POST',
807
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
808
+ });
809
+ if (!res.ok) return null;
810
+ const data = await res.json().catch(() => null);
811
+ planToken = data?.data?.token ?? null;
812
+ return planToken;
813
+ } catch {
814
+ return null;
815
+ }
816
+ };
817
+
818
+ /**
819
+ * This plan's session directory — its context, expressed as a place.
820
+ *
821
+ * A detached checkout at base, like a consult's, but PRIVATE and PERSISTENT:
822
+ * private so `--continue` resumes this argument rather than whichever ran last
823
+ * on the box, persistent so it survives the daemon restarting or updating
824
+ * under it. Re-pointed at the current base each turn, because "reads your
825
+ * code" has to mean the code as it is now — a plan that runs for days would
826
+ * otherwise keep answering from the commit it was opened at.
827
+ *
828
+ * Returns null when the id is not a safe path segment: it comes off the wire.
829
+ */
830
+ const planWtFor = (planId) => {
831
+ if (!isSafePathSegment(planId)) return null;
832
+ const wt = join(baseDir, 'plans', planId);
833
+ const fresh = !existsSync(wt);
834
+ if (fresh) {
835
+ try {
836
+ git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
837
+ } catch {
838
+ git(['worktree', 'prune'], repoRoot);
839
+ try {
840
+ git(['worktree', 'add', '--detach', wt, baseRef], repoRoot);
841
+ } catch {
842
+ return null;
843
+ }
844
+ }
845
+ } else {
846
+ try {
847
+ git(['fetch', 'origin', '--quiet'], repoRoot);
848
+ git(['checkout', '--detach', baseRef], wt);
849
+ git(['reset', '--hard', baseRef], wt);
850
+ git(['clean', '-fd'], wt);
851
+ } catch {
852
+ /* offline, or a turn left it dirty — read what we have */
853
+ }
854
+ }
855
+ return { wt, fresh };
856
+ };
857
+
858
+ /**
859
+ * Retire the least-recently-touched session directories.
860
+ *
861
+ * The bound belongs HERE, in the machine, and never in the interface: ten
862
+ * plans open across a team is ten checkouts on one box, which is a resource
863
+ * question. Announcing a session limit in the app would be advertising
864
+ * capacity, which this product does not do. A retired session simply rebuilds
865
+ * from the spec next time it is asked for — the fallback the server already
866
+ * expects, and which the thread says out loud when it happens.
867
+ */
868
+ const MAX_PLAN_SESSIONS = 8;
869
+ const planTouched = new Map(); // planId -> ms
870
+ const retireIdlePlanSessions = () => {
871
+ const dir = join(baseDir, 'plans');
872
+ if (!existsSync(dir)) return;
873
+ let ids;
874
+ try {
875
+ ids = readdirSync(dir);
876
+ } catch {
877
+ return;
878
+ }
879
+ if (ids.length <= MAX_PLAN_SESSIONS) return;
880
+ const oldestFirst = ids.sort(
881
+ (a, b) => (planTouched.get(a) ?? 0) - (planTouched.get(b) ?? 0)
882
+ );
883
+ for (const id of oldestFirst.slice(0, ids.length - MAX_PLAN_SESSIONS)) {
884
+ try {
885
+ git(['worktree', 'remove', '--force', join(dir, id)], repoRoot);
886
+ } catch {
887
+ try {
888
+ rmSync(join(dir, id), { recursive: true, force: true });
889
+ } catch {
890
+ /* it is a directory we will overwrite next time; not worth failing a turn */
891
+ }
892
+ }
893
+ planTouched.delete(id);
894
+ }
895
+ try {
896
+ git(['worktree', 'prune'], repoRoot);
897
+ } catch {
898
+ /* best effort */
899
+ }
900
+ };
901
+
763
902
  // Quick edits — a SECOND Claude alongside a task this machine is already
764
903
  // building. Unlike every other roster job it does not get a worktree of its
765
904
  // own: the whole point is to work in the one the running task opened, on that
@@ -797,7 +936,7 @@ export async function runFleetDaemon() {
797
936
  joinChain = joinChain.then(async () => {
798
937
  let settled = false;
799
938
  try {
800
- const target = worktreeBuilding(job.intentId);
939
+ const target = worktreeBuilding(job.taskId ?? job.intentId);
801
940
  if (!target) {
802
941
  // The run ended (or moved) between the human pressing ⚡ and this
803
942
  // poll. Settle rather than retry: there is no worktree to join, and
@@ -818,11 +957,11 @@ export async function runFleetDaemon() {
818
957
  const claim = await postForData(JOIN_TAKE_URL, { joinId: job.id });
819
958
  if (!claim?.taken) return;
820
959
  note(
821
- `${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.intentTitle || 'a task'}"`)}`
960
+ `${c.cyan('quick')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.taskTitle || job.intentTitle || 'a task'}"`)}`
822
961
  );
823
962
  const out = await runTurn({
824
963
  prompt: QUICK_EDIT_KICKOFF({
825
- intentTitle: job.intentTitle,
964
+ intentTitle: job.taskTitle ?? job.intentTitle,
826
965
  instruction: job.instruction,
827
966
  askedByName: job.askedByName,
828
967
  }),
@@ -872,57 +1011,89 @@ export async function runFleetDaemon() {
872
1011
  answering.add(job.id);
873
1012
  consultChain = consultChain.then(async () => {
874
1013
  try {
875
- note(`${c.cyan('ask')} ${c.dim(`— ${job.askedByName || 'someone'} asked about "${job.planTitle || 'a plan'}"`)}`);
876
- // Serialised against the wiki queue as well: that queue hard-resets
877
- // this worktree mid-turn, which would pull the files out from under a
878
- // consult that is reading them.
879
- // A consult's prompt is steered by a question ANY project editor can
880
- // type, so the profile is the enforcement and a runtime that cannot
881
- // express it does not get the job. Today that means Claude; see the
882
- // `profiles` notes in runtimes.mjs for exactly what Codex is missing.
883
- const consultRt = pickRuntimeFor('consult');
884
- if (!consultRt) {
885
- warn('a consult is waiting, but no installed CLI can run a read-only turn');
1014
+ note(`${c.cyan('plan')} ${c.dim(`— ${job.askedByName || 'someone'} on "${job.planTitle || 'a plan'}"`)}`);
1015
+ // The profile is the enforcement, not the prompt: this turn is steered
1016
+ // by anything a project editor can type, and it holds write tools. A
1017
+ // runtime that cannot express `plan` does not get the job rather than
1018
+ // getting it with guarantees nobody wrote down which today excludes
1019
+ // Antigravity, whose mediated shape fits a build and not an argument.
1020
+ const planRt = pickRuntimeFor('plan');
1021
+ if (!planRt) {
1022
+ warn('a planning turn is waiting, but no installed CLI can run a planning session');
886
1023
  return;
887
1024
  }
888
- await withWikiLock(async () => {
889
- ensureWikiWorktree();
890
- const out = await runTurn({
891
- prompt: CONSULT_KICKOFF({
1025
+ const token = await mintPlanToken();
1026
+ if (!token) {
1027
+ warn('a planning turn is waiting, but the plan credential could not be minted');
1028
+ return;
1029
+ }
1030
+ const dir = planWtFor(job.taskId);
1031
+ if (!dir) {
1032
+ warn(`a planning turn is waiting, but its session directory could not be opened`);
1033
+ return;
1034
+ }
1035
+ planTouched.set(job.taskId, Date.now());
1036
+ // Resume only when this plan already HAS a session here. A fresh
1037
+ // directory means either the first turn or a session we retired, and
1038
+ // both want the same thing: start over from the spec, which the
1039
+ // kickoff carries. `--continue` against an empty directory is not an
1040
+ // error on every CLI, so asking `fresh` is what keeps it honest.
1041
+ const resume = !dir.fresh && Boolean(job.sessionRef);
1042
+ const mcp = mcpFor(planRt, token, mcpUrl);
1043
+ let out;
1044
+ try {
1045
+ out = await runTurn({
1046
+ prompt: PLAN_TURN_KICKOFF({
1047
+ planId: job.taskId,
892
1048
  planTitle: job.planTitle,
893
1049
  question: job.question,
894
1050
  askedByName: job.askedByName,
1051
+ // Sent only when we are NOT resuming: a live session already has
1052
+ // the argument in its context, and re-stating the spec every
1053
+ // turn would spend tokens telling it what it just wrote. On a
1054
+ // rebuild it is the whole inheritance.
1055
+ spec: resume ? null : job.spec,
895
1056
  }),
896
- resume: false,
897
- system: SYSTEM_CONSULT,
898
- cwd: wikiWt,
899
- // TRULY read-only no Write/Edit/rm, no MCP. The prompt also says
900
- // not to change anything, but the prompt is what an injected
901
- // question competes with; the toolset is what it cannot.
902
- readOnly: true,
903
- runtime: consultRt,
904
- label: c.cyan('[ask]'),
905
- });
906
- const answer = (out || '').trim();
907
- const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
908
- consultId: job.id,
909
- ok: answer.length > 0,
910
- // Scrub: an answer can quote config or env-adjacent code.
911
- answer: envScrub(answer).slice(0, 8000),
1057
+ resume,
1058
+ system: SYSTEM_PLAN,
1059
+ cwd: dir.wt,
1060
+ // Read the repo, write the PLAN. No Edit/Write/commit anywhere in
1061
+ // the toolset the prompt says so too, but the prompt is what an
1062
+ // injected message competes with.
1063
+ planPerm: true,
1064
+ mcpArgs: mcp.args,
1065
+ mcpEnv: mcp.env,
1066
+ runtime: planRt,
1067
+ label: c.cyan('[plan]'),
912
1068
  });
913
- if (posted) consultAttempts.delete(job.id);
914
- ok(`${c.cyan('ask')} ${c.dim('— answered in the plan thread')}`);
1069
+ } finally {
1070
+ if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
1071
+ }
1072
+ const answer = (out || '').trim();
1073
+ const posted = await reportMergeOutcome(CONSULT_DONE_URL, {
1074
+ consultId: job.id,
1075
+ ok: answer.length > 0,
1076
+ // Scrub: a reply can quote config or env-adjacent code.
1077
+ answer: envScrub(answer).slice(0, 8000),
1078
+ // The handle the server stores, reported on EVERY turn: a session we
1079
+ // had to rebuild comes back under a new directory state, and a
1080
+ // stored handle that does not follow it leaves later turns trying to
1081
+ // resume something that is gone.
1082
+ sessionRef: dir.wt,
915
1083
  });
1084
+ if (posted) consultAttempts.delete(job.id);
1085
+ ok(`${c.cyan('plan')} ${c.dim('— replied in the plan thread')}`);
1086
+ retireIdlePlanSessions();
916
1087
  } catch (e) {
917
- // Settle it. A question that cannot be answered must not re-burn a
918
- // Claude turn every poll, and silence would leave the human waiting on
919
- // a machine that already gave up.
1088
+ // Settle it. A turn that cannot be answered must not re-burn quota
1089
+ // every poll, and silence would leave the human waiting on a machine
1090
+ // that already gave up.
920
1091
  await reportMergeOutcome(CONSULT_DONE_URL, {
921
1092
  consultId: job.id,
922
1093
  ok: false,
923
- answer: e?.message ?? 'the read failed',
1094
+ answer: e?.message ?? 'the planning turn failed',
924
1095
  });
925
- warn(`consult failed: ${e?.message ?? e}`);
1096
+ warn(`planning turn failed: ${e?.message ?? e}`);
926
1097
  } finally {
927
1098
  answering.delete(job.id);
928
1099
  }
@@ -930,6 +1101,315 @@ export async function runFleetDaemon() {
930
1101
  }
931
1102
  };
932
1103
 
1104
+ // ── Work sessions — the Workbench tabs ─────────────────────────────────────
1105
+ //
1106
+ // A tab is a held Claude session with BUILD permissions in a PERSISTENT
1107
+ // worktree on its own branch. The opposite of a plan directory on both
1108
+ // counts: nothing here is detached and nothing is ever reset — uncommitted
1109
+ // state between turns IS the session, and blowing it away would be closing
1110
+ // the human's editor mid-thought.
1111
+ const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
1112
+ const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
1113
+ const workAnswering = new Set();
1114
+ const workAttempts = new Map(); // turn id -> tries
1115
+ const MAX_WORK_TRIES = 3;
1116
+ /** Per-SESSION serialization, parallel ACROSS sessions: turns within one tab
1117
+ * must land in order (they share a directory and a context), but two tabs
1118
+ * are two terminals — the human opened both on purpose. */
1119
+ const workChains = new Map(); // sessionId -> Promise
1120
+
1121
+ let workToken = null;
1122
+ const mintWorkToken = async (force = false) => {
1123
+ if (workToken && !force) return workToken;
1124
+ try {
1125
+ const res = await fetch(WORK_TOKEN_URL, {
1126
+ method: 'POST',
1127
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
1128
+ });
1129
+ if (!res.ok) return null;
1130
+ const data = await res.json().catch(() => null);
1131
+ workToken = data?.data?.token ?? null;
1132
+ return workToken;
1133
+ } catch {
1134
+ return null;
1135
+ }
1136
+ };
1137
+
1138
+ /**
1139
+ * This tab's worktree — its held context, expressed as a place, ON A BRANCH.
1140
+ *
1141
+ * Fresh: branch `session/<id>` off the current base. Existing: touched not at
1142
+ * all — no fetch-reset-clean like a plan directory, because the dirty state
1143
+ * is the point. If the directory was retired but the branch survives, the
1144
+ * worktree re-attaches to the branch and the committed work is still there.
1145
+ */
1146
+ const sessionWtFor = (sessionId) => {
1147
+ if (!isSafePathSegment(sessionId)) return null;
1148
+ const wt = join(baseDir, 'sessions', sessionId);
1149
+ const fresh = !existsSync(wt);
1150
+ if (fresh) {
1151
+ const branch = `session/${sessionId}`;
1152
+ try {
1153
+ git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
1154
+ } catch {
1155
+ git(['worktree', 'prune'], repoRoot);
1156
+ try {
1157
+ // The branch may already exist (a retired directory's work) — attach.
1158
+ git(['worktree', 'add', wt, branch], repoRoot);
1159
+ } catch {
1160
+ try {
1161
+ git(['worktree', 'add', '-b', branch, wt, baseRef], repoRoot);
1162
+ } catch {
1163
+ return null;
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+ return { wt, fresh };
1169
+ };
1170
+
1171
+ /**
1172
+ * Retire the least-recently-touched CLEAN session directories past the cap.
1173
+ * A dirty worktree is never touched — uncommitted work is the human's, and a
1174
+ * resource bound does not outrank it. Committed work survives retirement on
1175
+ * the session branch either way.
1176
+ */
1177
+ const MAX_WORK_DIRS = 12;
1178
+ const workTouched = new Map(); // sessionId -> ms
1179
+ const retireIdleWorkSessions = () => {
1180
+ const dir = join(baseDir, 'sessions');
1181
+ if (!existsSync(dir)) return;
1182
+ let ids;
1183
+ try {
1184
+ ids = readdirSync(dir);
1185
+ } catch {
1186
+ return;
1187
+ }
1188
+ if (ids.length <= MAX_WORK_DIRS) return;
1189
+ const oldestFirst = ids.sort(
1190
+ (a, b) => (workTouched.get(a) ?? 0) - (workTouched.get(b) ?? 0)
1191
+ );
1192
+ let excess = ids.length - MAX_WORK_DIRS;
1193
+ for (const id of oldestFirst) {
1194
+ if (excess <= 0) break;
1195
+ const wt = join(dir, id);
1196
+ try {
1197
+ if (git(['status', '--porcelain'], wt).trim() !== '') continue; // dirty — skip
1198
+ git(['worktree', 'remove', wt], repoRoot);
1199
+ workTouched.delete(id);
1200
+ excess--;
1201
+ } catch {
1202
+ /* leave it; a directory we can't cleanly remove is not worth a turn */
1203
+ }
1204
+ }
1205
+ try {
1206
+ git(['worktree', 'prune'], repoRoot);
1207
+ } catch {
1208
+ /* best effort */
1209
+ }
1210
+ };
1211
+
1212
+ const processWorkTurns = (jobs) => {
1213
+ for (const job of jobs ?? []) {
1214
+ if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
1215
+ if (workAnswering.has(job.id)) continue;
1216
+ const tries = (workAttempts.get(job.id) ?? 0) + 1;
1217
+ if (tries > MAX_WORK_TRIES) continue;
1218
+ workAttempts.set(job.id, tries);
1219
+ workAnswering.add(job.id);
1220
+ const chain = workChains.get(job.sessionId) ?? Promise.resolve();
1221
+ workChains.set(
1222
+ job.sessionId,
1223
+ chain.then(async () => {
1224
+ try {
1225
+ note(
1226
+ `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
1227
+ );
1228
+ const workRt = pickRuntimeFor('build');
1229
+ if (!workRt) {
1230
+ warn('a session turn is waiting, but no installed CLI can build here');
1231
+ return;
1232
+ }
1233
+ const token = await mintWorkToken();
1234
+ if (!token) {
1235
+ warn('a session turn is waiting, but the work credential could not be minted');
1236
+ return;
1237
+ }
1238
+ const dir = sessionWtFor(job.sessionId);
1239
+ if (!dir) {
1240
+ warn('a session turn is waiting, but its worktree could not be opened');
1241
+ return;
1242
+ }
1243
+ workTouched.set(job.sessionId, Date.now());
1244
+ const resume = !dir.fresh && Boolean(job.sessionRef);
1245
+ const mcp = mcpFor(workRt, token, mcpUrl);
1246
+ let out;
1247
+ try {
1248
+ out = await runTurn({
1249
+ prompt: WORK_TURN_KICKOFF({
1250
+ sessionId: job.sessionId,
1251
+ sessionName: job.sessionName,
1252
+ message: job.body,
1253
+ askedByName: job.askedByName,
1254
+ }),
1255
+ resume,
1256
+ system: SYSTEM_WORK,
1257
+ cwd: dir.wt,
1258
+ mcpArgs: mcp.args,
1259
+ mcpEnv: mcp.env,
1260
+ runtime: workRt,
1261
+ label: c.cyan('[tab]'),
1262
+ });
1263
+ } finally {
1264
+ if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
1265
+ }
1266
+ const answer = (out || '').trim();
1267
+ const posted = await reportMergeOutcome(WORK_DONE_URL, {
1268
+ turnId: job.id,
1269
+ ok: answer.length > 0,
1270
+ // Scrub: a reply can quote config or env-adjacent code.
1271
+ answer: envScrub(answer).slice(0, 16000),
1272
+ sessionRef: dir.wt,
1273
+ });
1274
+ if (posted) workAttempts.delete(job.id);
1275
+ ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
1276
+ retireIdleWorkSessions();
1277
+ } catch (e) {
1278
+ await reportMergeOutcome(WORK_DONE_URL, {
1279
+ turnId: job.id,
1280
+ ok: false,
1281
+ answer: e?.message ?? 'the session turn failed',
1282
+ });
1283
+ warn(`session turn failed: ${e?.message ?? e}`);
1284
+ } finally {
1285
+ workAnswering.delete(job.id);
1286
+ }
1287
+ })
1288
+ );
1289
+ }
1290
+ };
1291
+
1292
+ // Ship — a session's branch merging to main, on the human's word.
1293
+ //
1294
+ // --no-ff, NEVER squash: every delivered card carries commit shas as its
1295
+ // receipts, and a squash would point them all at commits that no longer
1296
+ // exist on main. Sequence: refuse a dirty worktree (auto-committing someone's
1297
+ // mid-thought state is not shipping, it is guessing), fold main INTO the
1298
+ // branch first so conflicts surface in the worktree where the session can
1299
+ // resolve them, collect the branch's own commits (the server's
1300
+ // reconciliation input), then merge outward through a throwaway worktree so
1301
+ // nobody's checkout moves. Failures report INTO the tab — a ship that failed
1302
+ // silently leaves the human believing their work is on main.
1303
+ const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
1304
+ const shipping = new Set();
1305
+ let shipChain = Promise.resolve();
1306
+
1307
+ const processShipJobs = (jobs) => {
1308
+ for (const job of jobs ?? []) {
1309
+ if (!job || typeof job.sessionId !== 'string') continue;
1310
+ if (shipping.has(job.sessionId)) continue;
1311
+ shipping.add(job.sessionId);
1312
+ shipChain = shipChain.then(async () => {
1313
+ const done = (payload) =>
1314
+ reportMergeOutcome(SHIP_DONE_URL, { sessionId: job.sessionId, ...payload }).catch(
1315
+ () => {}
1316
+ );
1317
+ try {
1318
+ if (!isSafePathSegment(job.sessionId)) {
1319
+ await done({ ok: false, error: 'invalid session id' });
1320
+ return;
1321
+ }
1322
+ note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
1323
+ const wt = join(baseDir, 'sessions', job.sessionId);
1324
+ if (!existsSync(wt)) {
1325
+ await done({ ok: false, error: 'no session worktree on this machine' });
1326
+ return;
1327
+ }
1328
+ if (git(['status', '--porcelain'], wt) !== '') {
1329
+ await done({
1330
+ ok: false,
1331
+ error:
1332
+ 'the session has uncommitted changes — ask it to commit or discard them first',
1333
+ });
1334
+ return;
1335
+ }
1336
+ try {
1337
+ git(['fetch', 'origin', '--quiet'], repoRoot);
1338
+ } catch {
1339
+ /* offline fetch — merge against what we have */
1340
+ }
1341
+ // Fold main into the branch FIRST: conflicts land here, in the
1342
+ // session's own worktree, where the next turn can resolve them.
1343
+ try {
1344
+ git(['merge', '--no-edit', baseRef], wt);
1345
+ } catch {
1346
+ try {
1347
+ git(['merge', '--abort'], wt);
1348
+ } catch {
1349
+ /* nothing in progress */
1350
+ }
1351
+ await done({
1352
+ ok: false,
1353
+ error: 'conflicts with main — ask the session to resolve them, then ship again',
1354
+ });
1355
+ return;
1356
+ }
1357
+ // The branch's own commits — the server's reconciliation input.
1358
+ // --no-merges: fold-commits describe plumbing, not work.
1359
+ const commits = git([
1360
+ 'log',
1361
+ `${baseRef}..HEAD`,
1362
+ '--no-merges',
1363
+ '--format=%H%x09%s',
1364
+ ], wt)
1365
+ .split('\n')
1366
+ .filter(Boolean)
1367
+ .map((l) => {
1368
+ const [sha, ...rest] = l.split('\t');
1369
+ return { sha, subject: envScrub(rest.join('\t')).slice(0, 200) };
1370
+ });
1371
+ if (commits.length === 0) {
1372
+ await done({ ok: false, error: 'nothing to ship — no commits on the session branch' });
1373
+ return;
1374
+ }
1375
+ // Merge outward through a throwaway worktree so no checkout moves.
1376
+ const branch = `session/${job.sessionId}`;
1377
+ const tmp = join(baseDir, 'ship', job.sessionId);
1378
+ try {
1379
+ try {
1380
+ git(['worktree', 'remove', '--force', tmp], repoRoot);
1381
+ } catch {
1382
+ /* not there — fine */
1383
+ }
1384
+ git(['worktree', 'add', '--detach', tmp, baseRef], repoRoot);
1385
+ git([
1386
+ 'merge',
1387
+ '--no-ff',
1388
+ branch,
1389
+ '-m',
1390
+ `ship(${job.sessionName || job.sessionId.slice(0, 8)}): ${commits.length} commit${commits.length === 1 ? '' : 's'}`,
1391
+ ], tmp);
1392
+ git(['push', 'origin', `HEAD:${baseBranchName(baseRef)}`], tmp);
1393
+ } finally {
1394
+ try {
1395
+ git(['worktree', 'remove', '--force', tmp], repoRoot);
1396
+ git(['worktree', 'prune'], repoRoot);
1397
+ } catch {
1398
+ /* best effort */
1399
+ }
1400
+ }
1401
+ await done({ ok: true, commits });
1402
+ ok(`${c.cyan('ship')} ${c.dim(`— ${commits.length} commit${commits.length === 1 ? '' : 's'} on main`)}`);
1403
+ } catch (e) {
1404
+ await done({ ok: false, error: envScrub(e?.message ?? 'the merge failed').slice(0, 500) });
1405
+ warn(`ship failed: ${e?.message ?? e}`);
1406
+ } finally {
1407
+ shipping.delete(job.sessionId);
1408
+ }
1409
+ });
1410
+ }
1411
+ };
1412
+
933
1413
  const processMergeJobs = (jobs) => {
934
1414
  for (const job of jobs ?? []) {
935
1415
  if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
@@ -946,7 +1426,7 @@ export async function runFleetDaemon() {
946
1426
  if (!isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
947
1427
  mergeAttempts.delete(job.id);
948
1428
  await reportMergeOutcome(MERGE_FAILED_URL, {
949
- intentId: job.id,
1429
+ taskId: job.id,
950
1430
  message: 'refused: PR URL is not a pull request in this repository',
951
1431
  });
952
1432
  warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
@@ -973,7 +1453,7 @@ export async function runFleetDaemon() {
973
1453
  if (!/no changes|already/i.test(err)) {
974
1454
  mergeAttempts.delete(job.id);
975
1455
  await reportMergeOutcome(MERGE_FAILED_URL, {
976
- intentId: job.id,
1456
+ taskId: job.id,
977
1457
  message: `could not retarget the stacked PR onto ${baseBranchName(baseRef)} — merging it now would land in the branch below it, not ${baseBranchName(baseRef)}`,
978
1458
  });
979
1459
  warn(`merge held for "${job.title}": retarget failed — ${err.split('\n')[0]}`);
@@ -1010,7 +1490,7 @@ export async function runFleetDaemon() {
1010
1490
  }
1011
1491
  if (merged) {
1012
1492
  mergeAttempts.delete(job.id);
1013
- await reportMergeOutcome(MERGE_DONE_URL, { intentId: job.id });
1493
+ await reportMergeOutcome(MERGE_DONE_URL, { taskId: job.id });
1014
1494
  ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
1015
1495
  // The code just landed — re-ground the living wiki for what shipped
1016
1496
  // (touched nodes re-read + a persistent feature-history node).
@@ -1023,7 +1503,7 @@ export async function runFleetDaemon() {
1023
1503
  // button + notifies) — the job disappears from the roster.
1024
1504
  mergeAttempts.delete(job.id);
1025
1505
  await reportMergeOutcome(MERGE_FAILED_URL, {
1026
- intentId: job.id,
1506
+ taskId: job.id,
1027
1507
  message: failedReason,
1028
1508
  });
1029
1509
  warn(`merge failed for "${job.title}": ${failedReason} — reported to the thread`);
@@ -1083,7 +1563,7 @@ export async function runFleetDaemon() {
1083
1563
  } else if (job.prUrl || job.branch) {
1084
1564
  warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
1085
1565
  }
1086
- await reportMergeOutcome(CLEANUP_DONE_URL, { intentId: job.id });
1566
+ await reportMergeOutcome(CLEANUP_DONE_URL, { taskId: job.id });
1087
1567
  ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
1088
1568
  } finally {
1089
1569
  cleaning.delete(job.id);
@@ -1397,7 +1877,7 @@ export async function runFleetDaemon() {
1397
1877
  // sweep), so a failing re-ground can't loop-burn quota. Only a
1398
1878
  // crash BEFORE this line leaves the job listed for a retry.
1399
1879
  regroundAttempts.delete(task.intentId);
1400
- await reportMergeOutcome(REGROUND_DONE_URL, { intentId: task.intentId });
1880
+ await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
1401
1881
  }
1402
1882
  } catch (e) {
1403
1883
  warn(`wiki ${task.type} failed: ${e.message}`);
@@ -1526,6 +2006,8 @@ export async function runFleetDaemon() {
1526
2006
  processPatchRevertJobs(roster.patchRevertJobs);
1527
2007
  processPlanCheckJobs(roster.planCheckJobs);
1528
2008
  processConsultJobs(roster.consultJobs);
2009
+ processWorkTurns(roster.workTurnJobs);
2010
+ processShipJobs(roster.shipJobs);
1529
2011
  processJoinJobs(roster.joinJobs);
1530
2012
  processCleanupJobs(roster.cleanupJobs);
1531
2013
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
@@ -1553,7 +2035,13 @@ export async function runFleetDaemon() {
1553
2035
  mintedAt.set(a.agentId, Date.now());
1554
2036
  }
1555
2037
  hasWorkByAgent.set(a.agentId, !!a.hasWork);
1556
- if (a.next && typeof a.next.intentId === 'string') nextByAgent.set(a.agentId, a.next);
2038
+ // The hint's task id, new name first. Normalized ONTO `intentId` here so
2039
+ // every downstream read (poll worker, kickoff, diffstat attribution)
2040
+ // keeps its one spelling — intent is still the daemon's internal word,
2041
+ // taskId is the wire's.
2042
+ const nextId = a.next && (a.next.taskId ?? a.next.intentId);
2043
+ if (a.next && typeof nextId === 'string')
2044
+ nextByAgent.set(a.agentId, { ...a.next, intentId: nextId });
1557
2045
  else nextByAgent.delete(a.agentId);
1558
2046
  if (!workers.has(a.agentId)) {
1559
2047
  // Local ceiling, enforced and not merely requested. The roster can carry
@@ -1647,8 +2135,9 @@ export async function runFleetDaemon() {
1647
2135
  // whose earlier mint failed.
1648
2136
  enqueueSweep(roster.codeMapJob);
1649
2137
  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);
2138
+ const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
2139
+ if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
2140
+ enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
1652
2141
  }
1653
2142
  void drainWiki();
1654
2143