@martinloop/mcp 0.8.9 → 0.9.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.
Files changed (35) hide show
  1. package/README.md +2 -0
  2. package/dist/package-version.d.ts +1 -1
  3. package/dist/package-version.js +1 -1
  4. package/dist/server-validation.js +2 -3
  5. package/dist/server.d.ts +1 -0
  6. package/dist/server.js +181 -24
  7. package/dist/tools/get-status.d.ts +3 -0
  8. package/dist/tools/get-status.js +11 -2
  9. package/dist/tools/logs.d.ts +2 -0
  10. package/dist/tools/logs.js +25 -3
  11. package/dist/tools/run-controls.d.ts +51 -6
  12. package/dist/tools/run-controls.js +905 -35
  13. package/dist/tools/run-loop.js +7 -0
  14. package/dist/vendor/adapters/process-supervisor.d.ts +12 -0
  15. package/dist/vendor/adapters/process-supervisor.js +40 -2
  16. package/dist/vendor/adapters/verifier-only.js +4 -0
  17. package/dist/vendor/contracts/index.d.ts +8 -0
  18. package/dist/vendor/contracts/index.js +1 -0
  19. package/dist/vendor/contracts/swarm-timing.d.ts +7 -0
  20. package/dist/vendor/contracts/swarm-timing.js +22 -0
  21. package/dist/vendor/core/index.d.ts +37 -2
  22. package/dist/vendor/core/index.js +120 -14
  23. package/dist/vendor/core/persistence/index.d.ts +1 -1
  24. package/dist/vendor/core/persistence/index.js +1 -1
  25. package/dist/vendor/core/persistence/integrity.d.ts +1 -0
  26. package/dist/vendor/core/persistence/integrity.js +1 -1
  27. package/dist/vendor/core/policy.js +11 -1
  28. package/dist/vendor/core/resume.d.ts +25 -1
  29. package/dist/vendor/core/resume.js +187 -13
  30. package/dist/vendor/core/rollback.d.ts +14 -0
  31. package/dist/vendor/core/rollback.js +91 -34
  32. package/dist/vendor/core/swarm/live-runtime.js +2 -0
  33. package/dist/vendor/core/verified-handoff.js +62 -2
  34. package/package.json +3 -7
  35. package/server.json +2 -2
@@ -1,18 +1,28 @@
1
1
  import type { LoopRecord, RollbackBoundaryArtifact } from "../contracts/index.js";
2
2
  export interface RunExecutionLease {
3
- schemaVersion: "martin.execution-lease.v1";
3
+ schemaVersion: "martin.execution-lease.v2";
4
4
  runId: string;
5
5
  token: string;
6
6
  owner: string;
7
7
  pid: number;
8
8
  hostname: string;
9
9
  acquiredAt: string;
10
+ capabilities: {
11
+ runControl: boolean;
12
+ };
13
+ processInstanceId: string;
14
+ heartbeatAt: string;
15
+ heartbeatSequence: number;
10
16
  }
11
17
  export interface AcquiredRunExecutionLease {
12
18
  lease: RunExecutionLease;
13
19
  path: string;
14
20
  release(): Promise<void>;
15
21
  }
22
+ export interface ActiveRunExecutionLease {
23
+ lease: RunExecutionLease;
24
+ path: string;
25
+ }
16
26
  export interface ResumeVerifierFailure {
17
27
  command: string;
18
28
  exitCode?: number;
@@ -36,4 +46,18 @@ export declare function acquireRunExecutionLease(input: {
36
46
  now?: () => string;
37
47
  host?: string;
38
48
  pid?: number;
49
+ runControlCapable?: boolean;
50
+ }, hooks?: {
51
+ beforeStaleReclaim?: () => Promise<void>;
39
52
  }): Promise<AcquiredRunExecutionLease>;
53
+ /**
54
+ * Returns the persisted execution lease only when its schema, run identity,
55
+ * supported owner and local process liveness are all authoritative. Control
56
+ * surfaces use this to avoid accepting commands for an abandoned or incapable
57
+ * executor.
58
+ */
59
+ export declare function readActiveRunExecutionLease(input: {
60
+ runsRoot: string;
61
+ runId: string;
62
+ host?: string;
63
+ }): Promise<ActiveRunExecutionLease | undefined>;
@@ -1,9 +1,12 @@
1
1
  /// <reference types="node" />
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { mkdir, open, readFile, unlink } from "node:fs/promises";
3
+ import { link, lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
4
4
  import { hostname } from "node:os";
5
5
  import { join } from "node:path";
6
- import { artifactDir } from "./persistence/index.js";
6
+ import { performance } from "node:perf_hooks";
7
+ import { artifactDir, resolveReceiptIntegrityRoot } from "./persistence/index.js";
8
+ import { authenticateManagedPathExclusions, RollbackStateUnavailableError } from "./rollback.js";
9
+ const locallyOwnedExecutionLeases = new Map();
7
10
  export function evaluateRunResumability(loop) {
8
11
  if (loop.terminationEnvelope) {
9
12
  return { allowed: false, reason: "The run already has authoritative terminal evidence." };
@@ -73,7 +76,14 @@ export async function loadResumeCandidateBoundary(runsRoot, loop) {
73
76
  }
74
77
  throw error;
75
78
  });
76
- return JSON.parse(raw);
79
+ const boundary = JSON.parse(raw);
80
+ if (loop.task.repoRoot) {
81
+ authenticateManagedPathExclusions(loop.task.repoRoot, boundary, [runsRoot, resolveReceiptIntegrityRoot()]);
82
+ }
83
+ else if (boundary.managedPathExclusions !== undefined) {
84
+ throw new RollbackStateUnavailableError("Rollback boundary managed path exclusions cannot be authenticated without a repository root.");
85
+ }
86
+ return boundary;
77
87
  }
78
88
  export async function loadResumeVerifierFailure(runsRoot, loop) {
79
89
  const attempt = loop.attempts.at(-1);
@@ -106,7 +116,7 @@ export async function loadResumeVerifierFailure(runsRoot, loop) {
106
116
  attemptIndex: attempt.index
107
117
  };
108
118
  }
109
- export async function acquireRunExecutionLease(input) {
119
+ export async function acquireRunExecutionLease(input, hooks = {}) {
110
120
  const now = input.now ?? (() => new Date().toISOString());
111
121
  const host = input.host ?? hostname();
112
122
  const pid = input.pid ?? process.pid;
@@ -115,14 +125,18 @@ export async function acquireRunExecutionLease(input) {
115
125
  const path = join(directory, leaseFile);
116
126
  await mkdir(directory, { recursive: true });
117
127
  const token = `${pid}-${randomBytes(16).toString("hex")}`;
118
- const lease = {
119
- schemaVersion: "martin.execution-lease.v1",
128
+ let lease = {
129
+ schemaVersion: "martin.execution-lease.v2",
120
130
  runId: input.runId,
121
131
  token,
122
132
  owner: input.owner,
123
133
  pid,
124
134
  hostname: host,
125
- acquiredAt: now()
135
+ acquiredAt: now(),
136
+ capabilities: { runControl: input.runControlCapable === true },
137
+ processInstanceId: randomBytes(16).toString("hex"),
138
+ heartbeatAt: wallClockIso(),
139
+ heartbeatSequence: 0
126
140
  };
127
141
  for (let attempt = 0; attempt < 2; attempt += 1) {
128
142
  try {
@@ -134,10 +148,36 @@ export async function acquireRunExecutionLease(input) {
134
148
  finally {
135
149
  await handle.close();
136
150
  }
151
+ const heartbeatPath = executionLeaseHeartbeatPath(path, lease.processInstanceId);
152
+ await writeLeaseAtomically(heartbeatPath, lease);
153
+ locallyOwnedExecutionLeases.set(path, `${lease.token}:${lease.processInstanceId}`);
154
+ let active = true;
155
+ let heartbeatPromise;
156
+ const heartbeat = setInterval(() => {
157
+ if (!active || heartbeatPromise)
158
+ return;
159
+ const nextLease = {
160
+ ...lease,
161
+ heartbeatAt: wallClockIso(),
162
+ heartbeatSequence: lease.heartbeatSequence + 1
163
+ };
164
+ heartbeatPromise = writeLeaseAtomically(heartbeatPath, nextLease)
165
+ .then(() => { lease = nextLease; })
166
+ .catch(() => undefined)
167
+ .finally(() => { heartbeatPromise = undefined; });
168
+ }, 500);
169
+ heartbeat.unref();
137
170
  return {
138
171
  lease,
139
172
  path,
140
173
  async release() {
174
+ active = false;
175
+ clearInterval(heartbeat);
176
+ await heartbeatPromise;
177
+ if (locallyOwnedExecutionLeases.get(path) ===
178
+ `${lease.token}:${lease.processInstanceId}`) {
179
+ locallyOwnedExecutionLeases.delete(path);
180
+ }
141
181
  try {
142
182
  const existing = JSON.parse(await readFile(path, "utf8"));
143
183
  if (existing.token === token)
@@ -147,29 +187,163 @@ export async function acquireRunExecutionLease(input) {
147
187
  if (error.code !== "ENOENT")
148
188
  throw error;
149
189
  }
190
+ await unlink(heartbeatPath).catch((error) => {
191
+ if (error.code !== "ENOENT")
192
+ throw error;
193
+ });
150
194
  }
151
195
  };
152
196
  }
153
197
  catch (error) {
154
198
  if (error.code !== "EEXIST")
155
199
  throw error;
156
- const existing = await readLease(path);
200
+ const existing = await readLeaseWithHeartbeat(path);
157
201
  if (!existing)
158
202
  continue;
159
203
  if (existing.hostname !== host) {
160
204
  throw new Error(`Run ${input.runId} is leased by ${existing.owner} on ${existing.hostname}; refusing cross-host takeover.`);
161
205
  }
162
- if (isProcessAlive(existing.pid)) {
206
+ if (isProcessAlive(existing.pid) && isFreshExecutionLease(existing)) {
163
207
  throw new Error(`Run ${input.runId} is already executing under ${existing.owner} (pid ${existing.pid}).`);
164
208
  }
165
- await unlink(path).catch((unlinkError) => {
166
- if (unlinkError.code !== "ENOENT")
167
- throw unlinkError;
168
- });
209
+ await hooks.beforeStaleReclaim?.();
210
+ await reclaimStaleExecutionLease(path, existing);
169
211
  }
170
212
  }
171
213
  throw new Error(`Unable to acquire execution lease for run ${input.runId}.`);
172
214
  }
215
+ async function reclaimStaleExecutionLease(path, observed) {
216
+ const claimPath = `${path}.reclaim-${createHash("sha256")
217
+ .update(`${observed.token}:${observed.processInstanceId}`)
218
+ .digest("hex")
219
+ .slice(0, 24)}`;
220
+ try {
221
+ // The deterministic hard-link claim atomically elects one reclaimer for
222
+ // the observed lease inode. A stale observer can never unlink a successor.
223
+ await link(path, claimPath);
224
+ }
225
+ catch (error) {
226
+ const code = error.code;
227
+ if (code === "EEXIST" || code === "ENOENT")
228
+ return false;
229
+ throw error;
230
+ }
231
+ try {
232
+ const claimedStat = await lstat(claimPath);
233
+ const claimed = await readLease(claimPath);
234
+ const currentStat = await lstat(path).catch((error) => {
235
+ if (error.code === "ENOENT")
236
+ return undefined;
237
+ throw error;
238
+ });
239
+ if (!claimed ||
240
+ !currentStat ||
241
+ claimed.token !== observed.token ||
242
+ claimed.processInstanceId !== observed.processInstanceId ||
243
+ claimedStat.dev !== currentStat.dev ||
244
+ claimedStat.ino !== currentStat.ino) {
245
+ return false;
246
+ }
247
+ await unlink(path);
248
+ await unlink(executionLeaseHeartbeatPath(path, observed.processInstanceId)).catch((error) => {
249
+ if (error.code !== "ENOENT")
250
+ throw error;
251
+ });
252
+ return true;
253
+ }
254
+ finally {
255
+ await unlink(claimPath).catch(() => undefined);
256
+ }
257
+ }
258
+ /**
259
+ * Returns the persisted execution lease only when its schema, run identity,
260
+ * supported owner and local process liveness are all authoritative. Control
261
+ * surfaces use this to avoid accepting commands for an abandoned or incapable
262
+ * executor.
263
+ */
264
+ export async function readActiveRunExecutionLease(input) {
265
+ const host = input.host ?? hostname();
266
+ const directory = join(input.runsRoot, ".execution-leases");
267
+ const leaseFile = `${createHash("sha256").update(input.runId).digest("hex")}.json`;
268
+ const path = join(directory, leaseFile);
269
+ const lease = await readLeaseWithHeartbeat(path);
270
+ if (!isAdmissibleActiveLease(lease, input.runId, host))
271
+ return undefined;
272
+ // Same-process executors have a stronger identity proof than a sampled
273
+ // heartbeat: the unguessable token/instance pair is held in live memory.
274
+ // A stale lease whose PID was reused cannot satisfy this binding.
275
+ if (lease.pid === process.pid &&
276
+ locallyOwnedExecutionLeases.get(path) === `${lease.token}:${lease.processInstanceId}`) {
277
+ return { lease, path };
278
+ }
279
+ // A PID can be reused after the former owner crashes. Require the same
280
+ // unguessable process instance to advance its heartbeat before admitting a
281
+ // mutation; PID liveness alone is never authority.
282
+ await new Promise((resolve) => setTimeout(resolve, 650));
283
+ const confirmed = await readLeaseWithHeartbeat(path);
284
+ if (!isAdmissibleActiveLease(confirmed, input.runId, host) ||
285
+ confirmed.token !== lease.token ||
286
+ confirmed.processInstanceId !== lease.processInstanceId ||
287
+ confirmed.heartbeatSequence <= lease.heartbeatSequence) {
288
+ return undefined;
289
+ }
290
+ return { lease: confirmed, path };
291
+ }
292
+ function isAdmissibleActiveLease(lease, runId, host) {
293
+ return Boolean(lease &&
294
+ lease.schemaVersion === "martin.execution-lease.v2" &&
295
+ lease.runId === runId &&
296
+ Boolean(lease.token) &&
297
+ ["runMartin", "runMartin:resume"].includes(lease.owner) &&
298
+ lease.capabilities?.runControl === true &&
299
+ lease.hostname === host &&
300
+ isProcessAlive(lease.pid) &&
301
+ isFreshExecutionLease(lease));
302
+ }
303
+ function isFreshExecutionLease(lease) {
304
+ const heartbeat = Date.parse(lease.heartbeatAt);
305
+ const now = wallClockMs();
306
+ return Number.isFinite(heartbeat) && now - heartbeat <= 2_000 && heartbeat <= now + 5_000;
307
+ }
308
+ function wallClockMs() {
309
+ return performance.timeOrigin + performance.now();
310
+ }
311
+ function wallClockIso() {
312
+ return new Date(wallClockMs()).toISOString();
313
+ }
314
+ async function writeLeaseAtomically(path, lease) {
315
+ const temporaryPath = `${path}.heartbeat-${process.pid}-${randomBytes(8).toString("hex")}`;
316
+ const handle = await open(temporaryPath, "wx", 0o600);
317
+ try {
318
+ await handle.writeFile(`${JSON.stringify(lease, null, 2)}\n`, "utf8");
319
+ await handle.sync();
320
+ }
321
+ finally {
322
+ await handle.close();
323
+ }
324
+ try {
325
+ await rename(temporaryPath, path);
326
+ }
327
+ finally {
328
+ await unlink(temporaryPath).catch(() => undefined);
329
+ }
330
+ }
331
+ function executionLeaseHeartbeatPath(path, processInstanceId) {
332
+ return `${path}.${processInstanceId}.heartbeat`;
333
+ }
334
+ async function readLeaseWithHeartbeat(path) {
335
+ const lease = await readLease(path);
336
+ if (!lease || lease.schemaVersion !== "martin.execution-lease.v2" || !lease.processInstanceId) {
337
+ return lease;
338
+ }
339
+ const heartbeat = await readLease(executionLeaseHeartbeatPath(path, lease.processInstanceId));
340
+ if (!heartbeat ||
341
+ heartbeat.token !== lease.token ||
342
+ heartbeat.processInstanceId !== lease.processInstanceId) {
343
+ return lease;
344
+ }
345
+ return heartbeat;
346
+ }
173
347
  async function readLease(path) {
174
348
  try {
175
349
  return JSON.parse(await readFile(path, "utf8"));
@@ -10,12 +10,16 @@ export declare class RollbackStateUnavailableError extends Error {
10
10
  export declare function listAttemptChangedFilesSinceBoundary(input: {
11
11
  repoRoot?: string;
12
12
  boundary?: RollbackBoundaryArtifact;
13
+ /** Trusted, currently configured MartinLoop-owned roots. */
14
+ managedRoots?: string[];
13
15
  /** Include content changes to dirty/untracked paths that existed at the boundary. */
14
16
  includeBaselineSnapshotChanges?: boolean;
15
17
  }): string[];
16
18
  export declare function captureRollbackBoundary(input: {
17
19
  repoRoot?: string;
18
20
  capturedAt: string;
21
+ /** Absolute, configured MartinLoop-owned roots to exclude when contained in the repo. */
22
+ managedRoots?: string[];
19
23
  }): Promise<RollbackBoundaryArtifact | undefined>;
20
24
  /**
21
25
  * Git-ignored files the attempt created or modified since the boundary.
@@ -24,6 +28,8 @@ export declare function captureRollbackBoundary(input: {
24
28
  export declare function listIgnoredFileChangesSinceBoundary(input: {
25
29
  repoRoot?: string;
26
30
  boundary?: RollbackBoundaryArtifact;
31
+ /** Trusted, currently configured MartinLoop-owned roots. */
32
+ managedRoots?: string[];
27
33
  }): IgnoredFileChanges;
28
34
  export declare function restoreRollbackBoundary(input: {
29
35
  repoRoot?: string;
@@ -34,9 +40,17 @@ export declare function restoreRollbackBoundary(input: {
34
40
  removePaths?: string[];
35
41
  /** Pre-existing ignored paths to restore from the in-memory boundary. */
36
42
  restoreIgnoredPaths?: string[];
43
+ /** Trusted, currently configured MartinLoop-owned roots. */
44
+ managedRoots?: string[];
37
45
  }): Promise<RollbackOutcomeArtifact | undefined>;
38
46
  /**
39
47
  * Repo-relative links (symlinks or junctions) among `paths` whose target
40
48
  * resolves outside the repository. Paths are first collapsed to the link.
41
49
  */
42
50
  export declare function findEscapingLinks(repoRoot: string, paths: string[]): string[];
51
+ export declare function filterManagedRollbackPaths(values: readonly string[], exclusions: readonly string[]): string[];
52
+ /**
53
+ * Re-derive exclusion authority from trusted current configuration. Persisted
54
+ * rollback evidence records what was authorized, but never grants authority.
55
+ */
56
+ export declare function authenticateManagedPathExclusions(repoRoot: string, boundary: RollbackBoundaryArtifact, managedRoots: readonly string[]): string[];
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
- import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
6
6
  import { DEPENDENCY_AND_BUILD_DIRECTORIES, isInsideDependencyOrBuildDirectory } from "./output-directories.js";
7
7
  /** Past this many ignored files, further ignored directories stay collapsed. */
8
8
  const MAX_EXPANDED_IGNORED_FILES = 20_000;
@@ -25,25 +25,29 @@ export function listAttemptChangedFilesSinceBoundary(input) {
25
25
  }
26
26
  // Diff against the boundary commit (not the live HEAD) so that changes an
27
27
  // attempt commits are still reported as attempt changes.
28
- const repoState = readRepoState(input.repoRoot, input.boundary?.headRef);
28
+ const exclusions = input.boundary
29
+ ? authenticateManagedPathExclusions(input.repoRoot, input.boundary, input.managedRoots ?? [])
30
+ : [];
31
+ const repoState = readRepoState(input.repoRoot, input.boundary?.headRef, exclusions);
29
32
  if (!input.boundary) {
30
33
  return uniqueSorted([...repoState.trackedDirtyFiles, ...repoState.untrackedFiles]);
31
34
  }
32
- const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
33
- const baselineUntracked = new Set(input.boundary.untrackedFiles);
34
- return uniqueSorted([
35
+ const baselineTracked = new Set(filterManagedRollbackPaths(input.boundary.trackedDirtyFiles, exclusions));
36
+ const baselineUntracked = new Set(filterManagedRollbackPaths(input.boundary.untrackedFiles, exclusions));
37
+ return filterManagedRollbackPaths(uniqueSorted([
35
38
  ...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)),
36
39
  ...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)),
37
40
  ...(input.includeBaselineSnapshotChanges
38
- ? snapshotChangedPaths(input.repoRoot, input.boundary.snapshots)
41
+ ? snapshotChangedPaths(input.repoRoot, input.boundary.snapshots.filter((snapshot) => !isManagedRollbackPath(snapshot.path, exclusions)))
39
42
  : [])
40
- ]);
43
+ ]), exclusions);
41
44
  }
42
45
  export async function captureRollbackBoundary(input) {
43
46
  if (!input.repoRoot) {
44
47
  return undefined;
45
48
  }
46
- const repoState = readRepoState(input.repoRoot);
49
+ const managedPathExclusions = resolveManagedPathExclusions(input.repoRoot, input.managedRoots ?? []);
50
+ const repoState = readRepoState(input.repoRoot, "HEAD", managedPathExclusions);
47
51
  // Links are recorded by path only: their targets are never read or restored.
48
52
  const snapshotPaths = uniqueSorted([
49
53
  ...repoState.trackedDirtyFiles,
@@ -53,7 +57,7 @@ export async function captureRollbackBoundary(input) {
53
57
  for (const filePath of snapshotPaths) {
54
58
  snapshots.push(await readRollbackSnapshot(input.repoRoot, filePath));
55
59
  }
56
- const observedIgnoredFiles = readIgnoredInventory(input.repoRoot);
60
+ const observedIgnoredFiles = readIgnoredInventory(input.repoRoot, managedPathExclusions);
57
61
  const ignoredFiles = observedIgnoredFiles.map(({ path, size, mtimeMs }) => ({
58
62
  path,
59
63
  size,
@@ -69,7 +73,8 @@ export async function captureRollbackBoundary(input) {
69
73
  trackedDirtyFiles: repoState.trackedDirtyFiles,
70
74
  untrackedFiles: repoState.untrackedFiles,
71
75
  snapshots,
72
- ignoredFiles
76
+ ignoredFiles,
77
+ ...(managedPathExclusions.length > 0 ? { managedPathExclusions } : {})
73
78
  };
74
79
  ignoredObservations.set(boundary, new Map(observedIgnoredFiles.map((entry) => [entry.path, entry])));
75
80
  ignoredContentSnapshots.set(boundary, captureIgnoredContent(input.repoRoot, ignoredFiles));
@@ -80,16 +85,23 @@ export async function captureRollbackBoundary(input) {
80
85
  * Boundaries recorded before the ignored inventory existed report nothing.
81
86
  */
82
87
  export function listIgnoredFileChangesSinceBoundary(input) {
83
- if (!input.repoRoot || !input.boundary?.ignoredFiles) {
88
+ if (!input.repoRoot || !input.boundary) {
84
89
  return makeIgnoredFileChanges([], [], []);
85
90
  }
86
- const baseline = new Map(input.boundary.ignoredFiles.map((entry) => [entry.path, entry]));
91
+ const exclusions = authenticateManagedPathExclusions(input.repoRoot, input.boundary, input.managedRoots ?? []);
92
+ if (!input.boundary.ignoredFiles) {
93
+ return makeIgnoredFileChanges([], [], []);
94
+ }
95
+ const baseline = new Map(input.boundary.ignoredFiles
96
+ .filter((entry) => !isManagedRollbackPath(entry.path, exclusions))
97
+ .map((entry) => [entry.path, entry]));
87
98
  const baselineObservations = ignoredObservations.get(input.boundary);
88
99
  const created = [];
89
100
  const modified = [];
90
- const currentEntries = readIgnoredInventory(input.repoRoot);
101
+ const currentEntries = readIgnoredInventory(input.repoRoot, exclusions);
91
102
  const current = new Map(currentEntries.map((entry) => [entry.path, entry]));
92
103
  const deleted = input.boundary.ignoredFiles
104
+ .filter((entry) => !isManagedRollbackPath(entry.path, exclusions))
93
105
  .filter((entry) => !current.has(entry.path))
94
106
  .map((entry) => entry.path);
95
107
  for (const entry of currentEntries) {
@@ -141,16 +153,18 @@ export async function restoreRollbackBoundary(input) {
141
153
  error: "Rollback boundary was unavailable for this attempt."
142
154
  };
143
155
  }
144
- const before = readRepoState(input.repoRoot, input.boundary.headRef);
156
+ const exclusions = authenticateManagedPathExclusions(input.repoRoot, input.boundary, input.managedRoots ?? []);
157
+ const before = readRepoState(input.repoRoot, input.boundary.headRef, exclusions);
145
158
  const currentHead = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
146
159
  const headMoved = input.boundary.headRef !== undefined && currentHead !== input.boundary.headRef;
147
- const removePaths = uniqueSorted(input.removePaths ?? []);
148
- const restoreIgnoredPaths = uniqueSorted(input.restoreIgnoredPaths ?? []);
149
- const snapshotsUnchanged = await rollbackSnapshotsMatch(input.repoRoot, input.boundary.snapshots);
160
+ const removePaths = filterManagedRollbackPaths(uniqueSorted(input.removePaths ?? []), exclusions);
161
+ const restoreIgnoredPaths = filterManagedRollbackPaths(uniqueSorted(input.restoreIgnoredPaths ?? []), exclusions);
162
+ const rollbackSnapshots = input.boundary.snapshots.filter((snapshot) => !isManagedRollbackPath(snapshot.path, exclusions));
163
+ const snapshotsUnchanged = await rollbackSnapshotsMatch(input.repoRoot, rollbackSnapshots);
150
164
  if (!headMoved &&
151
165
  removePaths.length === 0 &&
152
166
  restoreIgnoredPaths.length === 0 &&
153
- repoStateMatchesBoundary(before, input.boundary) &&
167
+ repoStateMatchesBoundary(before, input.boundary, exclusions) &&
154
168
  snapshotsUnchanged) {
155
169
  return {
156
170
  attempted: false,
@@ -171,8 +185,8 @@ export async function restoreRollbackBoundary(input) {
171
185
  rewindAttemptCommits(input.repoRoot, input.boundary, currentHead);
172
186
  rewoundHead = { from: currentHead, to: input.boundary.headRef };
173
187
  }
174
- const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
175
- const baselineUntracked = new Set(input.boundary.untrackedFiles);
188
+ const baselineTracked = new Set(filterManagedRollbackPaths(input.boundary.trackedDirtyFiles, exclusions));
189
+ const baselineUntracked = new Set(filterManagedRollbackPaths(input.boundary.untrackedFiles, exclusions));
176
190
  for (const filePath of before.trackedDirtyFiles) {
177
191
  if (!baselineTracked.has(filePath)) {
178
192
  if (existsInHead(input.repoRoot, filePath)) {
@@ -192,7 +206,7 @@ export async function restoreRollbackBoundary(input) {
192
206
  deletedFiles.add(filePath);
193
207
  }
194
208
  }
195
- for (const snapshot of input.boundary.snapshots) {
209
+ for (const snapshot of rollbackSnapshots) {
196
210
  await restoreRollbackSnapshot(input.repoRoot, snapshot);
197
211
  if (snapshot.existed) {
198
212
  restoredFiles.add(snapshot.path);
@@ -217,8 +231,8 @@ export async function restoreRollbackBoundary(input) {
217
231
  await writeFile(absolutePath, contents);
218
232
  restoredFiles.add(filePath);
219
233
  }
220
- const after = readRepoState(input.repoRoot, input.boundary.headRef);
221
- const restored = repoStateMatchesBoundary(after, input.boundary) &&
234
+ const after = readRepoState(input.repoRoot, input.boundary.headRef, exclusions);
235
+ const restored = repoStateMatchesBoundary(after, input.boundary, exclusions) &&
222
236
  (input.boundary.headRef === undefined ||
223
237
  readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]) === input.boundary.headRef);
224
238
  return {
@@ -237,7 +251,7 @@ export async function restoreRollbackBoundary(input) {
237
251
  };
238
252
  }
239
253
  catch (error) {
240
- const after = readRepoState(input.repoRoot, input.boundary.headRef);
254
+ const after = readRepoState(input.repoRoot, input.boundary.headRef, exclusions);
241
255
  return {
242
256
  attempted: true,
243
257
  status: "failed",
@@ -251,13 +265,13 @@ export async function restoreRollbackBoundary(input) {
251
265
  };
252
266
  }
253
267
  }
254
- function readRepoState(repoRoot, baseRef = "HEAD") {
268
+ function readRepoState(repoRoot, baseRef = "HEAD", managedPathExclusions = []) {
255
269
  return {
256
- trackedDirtyFiles: readGitLines(repoRoot, ["diff", "--name-only", baseRef, "--", "."]),
270
+ trackedDirtyFiles: filterManagedRollbackPaths(readGitLines(repoRoot, ["diff", "--name-only", baseRef, "--", "."]), managedPathExclusions),
257
271
  // Git for Windows lists untracked files *through* junctions, so a link to a
258
272
  // directory outside the repo would otherwise surface that directory's files as
259
273
  // repo paths. Collapse every such path to the link itself.
260
- untrackedFiles: collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."]))
274
+ untrackedFiles: filterManagedRollbackPaths(collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."])), managedPathExclusions)
261
275
  };
262
276
  }
263
277
  /**
@@ -357,10 +371,10 @@ function unstagePath(repoRoot, filePath) {
357
371
  throw new Error(`git rm --cached failed for ${filePath}: ${gitFailureMessage(result)}`);
358
372
  }
359
373
  }
360
- function readIgnoredInventory(repoRoot) {
374
+ function readIgnoredInventory(repoRoot, managedPathExclusions = []) {
361
375
  const collapsed = collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, [
362
376
  "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--", "."
363
- ]));
377
+ ])).filter((entry) => !isManagedRollbackPath(entry, managedPathExclusions));
364
378
  const inventory = [];
365
379
  let expanded = 0;
366
380
  const hashBudget = { remainingBytes: MAX_HASHED_IGNORED_TOTAL_BYTES };
@@ -374,9 +388,9 @@ function readIgnoredInventory(repoRoot) {
374
388
  if (isDirectory && expanded >= MAX_EXPANDED_IGNORED_FILES) {
375
389
  throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
376
390
  }
377
- const files = isDirectory
391
+ const files = (isDirectory
378
392
  ? collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--", entry]))
379
- : [entry];
393
+ : [entry]).filter((filePath) => !isManagedRollbackPath(filePath, managedPathExclusions));
380
394
  for (const filePath of files) {
381
395
  if (expanded >= MAX_EXPANDED_IGNORED_FILES) {
382
396
  throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
@@ -619,9 +633,8 @@ function resolveRepoPath(repoRoot, filePath, options = {}) {
619
633
  }
620
634
  return resolvedPath;
621
635
  }
622
- function repoStateMatchesBoundary(state, boundary) {
623
- return arraysEqual(state.trackedDirtyFiles, boundary.trackedDirtyFiles) &&
624
- arraysEqual(state.untrackedFiles, boundary.untrackedFiles);
636
+ function repoStateMatchesBoundary(state, boundary, exclusions = []) {
637
+ return arraysEqual(state.trackedDirtyFiles, filterManagedRollbackPaths(boundary.trackedDirtyFiles, exclusions)) && arraysEqual(state.untrackedFiles, filterManagedRollbackPaths(boundary.untrackedFiles, exclusions));
625
638
  }
626
639
  function arraysEqual(left, right) {
627
640
  if (left.length !== right.length) {
@@ -632,6 +645,50 @@ function arraysEqual(left, right) {
632
645
  function uniqueSorted(values) {
633
646
  return [...new Set(values.map((value) => normalizeRepoPath(value)).filter(Boolean))].sort();
634
647
  }
648
+ export function filterManagedRollbackPaths(values, exclusions) {
649
+ return values.filter((value) => !isManagedRollbackPath(value, exclusions));
650
+ }
651
+ /**
652
+ * Re-derive exclusion authority from trusted current configuration. Persisted
653
+ * rollback evidence records what was authorized, but never grants authority.
654
+ */
655
+ export function authenticateManagedPathExclusions(repoRoot, boundary, managedRoots) {
656
+ const expected = resolveManagedPathExclusions(repoRoot, managedRoots);
657
+ const persistedValue = boundary.managedPathExclusions;
658
+ if (persistedValue !== undefined &&
659
+ (!Array.isArray(persistedValue) || persistedValue.some((entry) => typeof entry !== "string"))) {
660
+ throw new RollbackStateUnavailableError("Rollback boundary managed path exclusions are invalid and cannot be trusted.");
661
+ }
662
+ const persisted = uniqueSorted(persistedValue ?? []);
663
+ if (!arraysEqual(persisted, expected)) {
664
+ throw new RollbackStateUnavailableError("Rollback boundary managed path exclusions do not match the configured MartinLoop roots.");
665
+ }
666
+ return expected;
667
+ }
668
+ function isManagedRollbackPath(value, exclusions) {
669
+ const path = normalizeRepoPath(value).replace(/\/$/u, "");
670
+ return exclusions.some((entry) => {
671
+ const root = normalizeRepoPath(entry).replace(/\/$/u, "");
672
+ return root.length > 0 && (path === root || path.startsWith(`${root}/`));
673
+ });
674
+ }
675
+ function resolveManagedPathExclusions(repoRoot, roots) {
676
+ const absoluteRepoRoot = resolve(repoRoot);
677
+ return uniqueSorted(roots.flatMap((root) => {
678
+ const absoluteRoot = resolve(root);
679
+ const relativeRoot = relative(absoluteRepoRoot, absoluteRoot);
680
+ if (relativeRoot === "" ||
681
+ relativeRoot === ".." ||
682
+ relativeRoot.startsWith(`..${sep}`) ||
683
+ isAbsolute(relativeRoot)) {
684
+ return [];
685
+ }
686
+ // A configured link/junction is not an authenticated in-repository
687
+ // persistence root. Reuse rollback's link-safe resolver and fail closed.
688
+ resolveRepoPath(absoluteRepoRoot, relativeRoot);
689
+ return [relativeRoot];
690
+ }));
691
+ }
635
692
  function normalizeRepoPath(value) {
636
693
  return value.trim().replace(/\\/gu, "/");
637
694
  }
@@ -656,6 +656,8 @@ export async function runLiveSwarmWithDependencies(input, dependencies) {
656
656
  }
657
657
  },
658
658
  metadata: {
659
+ executionMode: "governed",
660
+ governanceClaimEligible: "true",
659
661
  "swarm.parentId": record.swarmId,
660
662
  "swarm.agentId": agent.agentId,
661
663
  "swarm.taskIds": JSON.stringify([task.taskId]),