@eir-labs/coltrane 0.23.1 → 0.24.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.
@@ -43,7 +43,8 @@ import { readFileSync, existsSync, mkdirSync, appendFileSync, readdirSync, write
43
43
  import { randomUUID, createHash } from "node:crypto";
44
44
  import { join, dirname } from "node:path";
45
45
  import { tmpdir } from "node:os";
46
- import { newGigRun, applyGigProgress, gigEventLogLine, pruneGigRuns } from "./gig_tracker.js";
46
+ import { newGigRun, applyGigProgress, gigEventLogLine, pruneGigRuns, isTerminalStatus } from "./gig_tracker.js";
47
+ import { acquireRepoLock, releaseRepoLock } from "./fs_atomic.js";
47
48
  import { isGig } from "./ledger.js";
48
49
  import { SubthreadRecorder, ApiVersionMismatchError } from "./subthread_recorder.js";
49
50
  import { canonJson, runFingerprint, CANONICAL_FORM_VERSION } from "./canonical_form.js";
@@ -96,6 +97,14 @@ function governanceRow(event, subject_slug, detail, subject_gig_id) {
96
97
  finished_at: now,
97
98
  };
98
99
  }
100
+ /** The prose a single-flight refusal teaches a human: WHO holds the tree, and the two ways out
101
+ * (wait for it to settle, or abort it). The machine-readable holder rides in `data.held_by`. */
102
+ function heldTreeError(held) {
103
+ return (`the working tree at "${held.genome_dir}" is held by a running gig "${held.gig_id}" ` +
104
+ `(pid ${held.pid}, since ${held.started_at}). Single-flight is law: a second dispatch against ` +
105
+ `the same repository is REFUSED — it does not queue and does not wait. Let that gig reach a ` +
106
+ `terminal state, or abort it with gig_abort "${held.gig_id}".`);
107
+ }
99
108
  const KNOWN_SLUGS = new Set(MCP_TOOLS.map((t) => t.slug));
100
109
  // Live registry of admissible tool slugs — the cage gate for agent_define's
101
110
  // allowed_tools. Seeded with the static MCP_TOOLS surface; tool_register grows
@@ -887,9 +896,41 @@ async function runImpl(slug, args, deps, approval) {
887
896
  ...(res.gates_approved ? { gates_approved: res.gates_approved } : {}),
888
897
  ...(res.resumed ? { resumed: res.resumed } : {}),
889
898
  });
899
+ // The gig id the whole performance runs under — minted ONCE for both the wait and async
900
+ // doors, so the single-flight lock names the same holder either way.
901
+ const chartGigId = resumeArg ?? randomUUID();
902
+ // ── single-flight: ONE lock for the CHART's full lifetime, never per-movement ──────────
903
+ // A chart runs its movements sequentially under one promise. The lock is claimed ONCE here
904
+ // (holder = the chart's own gig_id) BEFORE the first movement and released only when the
905
+ // performance settles. A per-movement acquire/release would either open a gap between
906
+ // movements where a concurrent dispatch could slip in, or deadlock movement 2 against the
907
+ // lock movement 1 still holds. Released on every terminal outcome; RETAINED through
908
+ // awaiting_approval (a parked performance holds uncommitted work in the tree).
909
+ let releaseChartLock;
910
+ let chartLockReacquired = false; // re-entered a parked performance's own lock, vs freshly minted
911
+ if (deps.genome_dir) {
912
+ const acq = acquireRepoLock(deps.genome_dir, {
913
+ gigId: chartGigId, pid: process.pid, startedAt: new Date().toISOString(),
914
+ });
915
+ if (!acq.ok) {
916
+ return {
917
+ ok: false, requires_approval: approval, refusal: "repo_locked",
918
+ error: heldTreeError(acq.held_by), data: { held_by: acq.held_by },
919
+ };
920
+ }
921
+ chartLockReacquired = acq.reacquired;
922
+ const genomeDir = deps.genome_dir;
923
+ releaseChartLock = () => { try {
924
+ releaseRepoLock(genomeDir, chartGigId);
925
+ }
926
+ catch { /* best-effort */ } };
927
+ }
890
928
  if (wait) {
891
929
  try {
892
- const res = await runChart(plan, gigInput, chartDeps);
930
+ const res = await runChart(plan, gigInput, { ...chartDeps, gig_id: chartGigId });
931
+ // Terminal (complete / budget-exhausted) frees the tree; a parked chart RETAINS it.
932
+ if (releaseChartLock && res.status !== "awaiting_approval")
933
+ releaseChartLock();
893
934
  return {
894
935
  ok: true, requires_approval: approval,
895
936
  data: {
@@ -902,9 +943,15 @@ async function runImpl(slug, args, deps, approval) {
902
943
  }
903
944
  catch (e) {
904
945
  if (e instanceof ResumeRefused) {
946
+ // A re-entered lock belongs to the parked performance; a fresh one is freed here.
947
+ if (releaseChartLock && !chartLockReacquired)
948
+ releaseChartLock();
905
949
  return { ok: false, requires_approval: approval, error: e.message,
906
950
  data: { resume_refused: true, gig_id: e.gig_id, drift: e.drift } };
907
951
  }
952
+ // Any other terminal throw (budget exhausted, a movement failed) frees the tree.
953
+ if (releaseChartLock)
954
+ releaseChartLock();
908
955
  if (e instanceof BudgetExhausted) {
909
956
  const partial = partialGigUsage(e);
910
957
  return { ok: false, requires_approval: approval, error: e.message,
@@ -917,7 +964,6 @@ async function runImpl(slug, args, deps, approval) {
917
964
  // Async, the default. Same live-state row an async standard dispatch registers, so
918
965
  // gig_monitor and gig_abort reach a performance exactly as they reach a run: the row
919
966
  // names the standard the performance OPENS with, and `chart_slug` names the arrangement.
920
- const chartGigId = resumeArg ?? randomUUID();
921
967
  const chartRuns = deps.gig_runs ?? (deps.gig_runs = new Map());
922
968
  const priorChartState = chartRuns.get(chartGigId);
923
969
  const chartState = newGigRun(chartGigId, plan.movements[0].standard.slug, plan.movements.reduce((n, m) => n + m.standard.phases.length, 0), new Date().toISOString());
@@ -993,7 +1039,14 @@ async function runImpl(slug, args, deps, approval) {
993
1039
  chartState.budget_state = bs;
994
1040
  onChartProgress({ type: "gig_failed", error: chartState.error });
995
1041
  })
996
- .finally(() => { chartState.controller = undefined; });
1042
+ .finally(() => {
1043
+ chartState.controller = undefined; // don't pin a controller past settle
1044
+ // Free the tree on every terminal outcome (complete / failed / aborted); a parked
1045
+ // performance RETAINS it. A refused resume that re-entered a parked holder's lock
1046
+ // leaves it held; one that minted a fresh lock still frees it.
1047
+ if (releaseChartLock && isTerminalStatus(chartState.status) && !(chartRefusal && chartLockReacquired))
1048
+ releaseChartLock();
1049
+ });
997
1050
  if (resumeArg !== undefined) {
998
1051
  await Promise.resolve(); // one turn — see the ordering note on the standard path below
999
1052
  if (chartRefusal) {
@@ -1040,14 +1093,50 @@ async function runImpl(slug, args, deps, approval) {
1040
1093
  venue, venues: deps.venues, venueRealizer: deps.venueRealizer,
1041
1094
  repoUrl: dispatchRepoUrl,
1042
1095
  });
1096
+ // The gig id this run seals under — minted ONCE for both doors so the single-flight lock
1097
+ // names the same holder whether the caller blocked (wait:true) or polled (async default).
1098
+ const gigId = resumeArg ?? randomUUID();
1099
+ // ── single-flight: claim the working tree BEFORE any chair runs ─────────────────────────
1100
+ // Every LOCAL dispatch entry point funnels here (the in-process gig_dispatch tool AND the
1101
+ // CLI, which calls dispatchTool). The lock is per genome ROOT (deps.genome_dir): two gigs
1102
+ // against the same tree each derive their sealed change-set from `git diff` of a tree the
1103
+ // other mutates, so the second is REFUSED — never queued — with a structured error naming
1104
+ // the holder. A parked gig's OWN resume (same gig_id) re-enters the tree it already holds.
1105
+ // The lock activates only when a genome_dir is present: a hosted/bare-deps drain carries no
1106
+ // local tree to lock and is unaffected by construction.
1107
+ let releaseLock;
1108
+ // Did this claim RE-ENTER a lock the same gig already held (a parked gig's own resume), as
1109
+ // opposed to minting a fresh one? A refused resume must NOT free a re-entered lock (the
1110
+ // parked holder keeps its tree), but MUST free a fresh one (nothing else holds it).
1111
+ let lockReacquired = false;
1112
+ if (deps.genome_dir) {
1113
+ const acq = acquireRepoLock(deps.genome_dir, {
1114
+ gigId, pid: process.pid, startedAt: new Date().toISOString(),
1115
+ });
1116
+ if (!acq.ok) {
1117
+ return {
1118
+ ok: false, requires_approval: approval, refusal: "repo_locked",
1119
+ error: heldTreeError(acq.held_by), data: { held_by: acq.held_by },
1120
+ };
1121
+ }
1122
+ lockReacquired = acq.reacquired;
1123
+ const genomeDir = deps.genome_dir;
1124
+ releaseLock = () => { try {
1125
+ releaseRepoLock(genomeDir, gigId);
1126
+ }
1127
+ catch { /* best-effort */ } };
1128
+ }
1043
1129
  // Synchronous mode (opt-in via wait:true) — block, return the manifest. The
1044
1130
  // deterministic test path and any caller that wants the answer in one call.
1045
1131
  if (wait) {
1046
1132
  try {
1047
1133
  const res = await runGig(standard, gigInput, {
1048
- ...dispatchDeps,
1134
+ ...dispatchDeps, gig_id: gigId,
1049
1135
  ...(depth ? { depth } : {}), ...reuseWiring, ...humanWiring,
1050
1136
  });
1137
+ // Terminal (complete) frees the tree; a parked gig (awaiting_approval) RETAINS it.
1138
+ if (releaseLock && res.status !== "awaiting_approval")
1139
+ releaseLock();
1051
1140
  return {
1052
1141
  ok: true, requires_approval: approval,
1053
1142
  data: {
@@ -1069,11 +1158,18 @@ async function runImpl(slug, args, deps, approval) {
1069
1158
  }
1070
1159
  catch (e) {
1071
1160
  // A refused resume is a REFUSAL, not a crash: nothing ran, nothing was spent, and
1072
- // the caller needs the drift list to decide whether to re-dispatch cold.
1161
+ // the caller needs the drift list to decide whether to re-dispatch cold. The holder it
1162
+ // re-entered is unchanged, so the tree is NOT freed here.
1073
1163
  if (e instanceof ResumeRefused) {
1164
+ // Free only a FRESH claim; a re-entered lock belongs to the parked holder, untouched.
1165
+ if (releaseLock && !lockReacquired)
1166
+ releaseLock();
1074
1167
  return { ok: false, requires_approval: approval, error: e.message,
1075
1168
  data: { resume_refused: true, gig_id: e.gig_id, drift: e.drift } };
1076
1169
  }
1170
+ // Every other terminal throw (budget exhausted, a chair failed) frees the tree.
1171
+ if (releaseLock)
1172
+ releaseLock();
1077
1173
  if (e instanceof BudgetExhausted) {
1078
1174
  // #236 — the synchronous half: a depleted gig also burned real dollars before it
1079
1175
  // stopped, and the operator needs them in the same reply as the depletion notice.
@@ -1091,8 +1187,8 @@ async function runImpl(slug, args, deps, approval) {
1091
1187
  // A resumed run CONTINUES the gig it resumes — same id — so the restored outputs stay
1092
1188
  // in-gig and `output_trace` still reaches them. The live-state entry for the earlier
1093
1189
  // attempt is replaced: that gig is running again, and showing its old `failed` state
1094
- // while it runs would be a lie the operator acts on.
1095
- const gigId = resumeArg ?? randomUUID();
1190
+ // while it runs would be a lie the operator acts on. (`gigId` was minted above, before the
1191
+ // wait/async split, so the single-flight lock names this same id.)
1096
1192
  const runs = deps.gig_runs ?? (deps.gig_runs = new Map());
1097
1193
  // #278 review — keep the prior attempt's record so a REFUSED resume can put it back.
1098
1194
  // Overwriting it is right when the resume proceeds (that gig is running again), and
@@ -1203,7 +1299,15 @@ async function runImpl(slug, args, deps, approval) {
1203
1299
  state.budget_state = bs;
1204
1300
  onProgress({ type: "gig_failed", error: state.error });
1205
1301
  })
1206
- .finally(() => { state.controller = undefined; }); // don't pin a controller past settle
1302
+ .finally(() => {
1303
+ state.controller = undefined; // don't pin a controller past settle
1304
+ // Free the tree on every terminal outcome (complete / failed / aborted); a parked gig
1305
+ // (awaiting_approval) RETAINS it — it holds uncommitted work the resume continues from.
1306
+ // A refused resume that RE-ENTERED a parked holder's lock leaves it held; a refused
1307
+ // resume that minted a FRESH lock still frees it (nothing else holds that tree).
1308
+ if (releaseLock && isTerminalStatus(state.status) && !(resumeRefusal && lockReacquired))
1309
+ releaseLock();
1310
+ });
1207
1311
  if (resumeArg !== undefined) {
1208
1312
  await Promise.resolve(); // one turn — see the ordering note above
1209
1313
  if (resumeRefusal) {