@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.
- package/agents/change-framer.json +40 -0
- package/agents/change-planner.json +40 -0
- package/agents/context-reader.json +42 -0
- package/agents/dispatcher.json +59 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/fs_atomic.d.ts +43 -0
- package/dist/src/fs_atomic.js +135 -3
- package/dist/src/fs_atomic.js.map +1 -1
- package/dist/src/gig_tracker.d.ts +10 -0
- package/dist/src/gig_tracker.js +12 -0
- package/dist/src/gig_tracker.js.map +1 -1
- package/dist/src/server.js +113 -9
- package/dist/src/server.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/institutions/coltrane.json +17 -17
- package/package.json +1 -1
- package/standards/falsifier-investigation-v0.json +95 -0
package/dist/src/server.js
CHANGED
|
@@ -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(() => {
|
|
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
|
-
|
|
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(() => {
|
|
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) {
|