@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,85 +1,955 @@
1
- import { appendFile, readFile } from "node:fs/promises";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { link, lstat, open, realpath, unlink } from "node:fs/promises";
4
+ import { hostname } from "node:os";
2
5
  import path from "node:path";
3
- import { invalidArgumentsError, unsupportedOperationError } from "./tool-errors.js";
6
+ import { readExitSignal, readActiveRunExecutionLease, writeExitSignal } from "../vendor/core/index.js";
7
+ import { invalidArgumentsError, storeUnreadableError, unsupportedOperationError } from "./tool-errors.js";
4
8
  import { loadDetailedLoopRecord } from "./run-store.js";
5
- export async function createRunControlReceipt(action, input) {
6
- const detail = await loadDetailedLoopRecord(input);
9
+ export async function createRunControlReceipt(action, input, testHooks) {
10
+ if (input.authority?.transport !== "local_stdio") {
11
+ throw unsupportedOperationError("Run controls are available only over the local stdio transport.", "Use a local MartinLoop MCP stdio session; remote HTTP control requires authenticated principal support.");
12
+ }
13
+ const initialDetail = await loadDetailedLoopRecord(input);
14
+ if (!initialDetail.canonicalRunDirectory) {
15
+ throw unsupportedOperationError("Run control receipts require a canonical run directory.", "Use a canonical loopId-backed Martin run before writing control receipts.");
16
+ }
17
+ return withRunControlLock(initialDetail.canonicalRunDirectory, async () => {
18
+ await testHooks?.beforeLockedReload?.();
19
+ const detail = await loadDetailedLoopRecord(input);
20
+ if (detail.canonicalRunDirectory !== initialDetail.canonicalRunDirectory) {
21
+ throw controlReceiptIntegrityError();
22
+ }
23
+ return createRunControlReceiptLocked(action, input, detail, testHooks);
24
+ }, testHooks?.lockMaxRetries);
25
+ }
26
+ async function createRunControlReceiptLocked(action, input, detail, _testHooks) {
7
27
  if (!detail.canonicalRunDirectory) {
8
28
  throw unsupportedOperationError("Run control receipts require a canonical run directory.", "Use a canonical loopId-backed Martin run before writing control receipts.");
9
29
  }
10
- const receipt = {
30
+ if (isTerminalRun(detail.loop)) {
31
+ throw unsupportedOperationError(`Run ${detail.loop.loopId} is already terminal and cannot accept ${action}.`, "Inspect the terminal run evidence instead of recording a new control request.");
32
+ }
33
+ if (await readRunTerminalIntent(detail.canonicalRunDirectory, detail.loop.loopId)) {
34
+ throw unsupportedOperationError(`Run ${detail.loop.loopId} is entering terminal persistence and cannot accept ${action}.`, "Inspect the terminal run evidence instead of recording a new control request.");
35
+ }
36
+ const executionLease = await readActiveRunExecutionLease({
37
+ runsRoot: detail.runsRoot,
38
+ runId: detail.loop.loopId
39
+ }).catch(() => undefined);
40
+ if (!executionLease) {
41
+ throw unsupportedOperationError(`Run ${detail.loop.loopId} has no live control-capable executor lease.`, "Start or resume the run through a supported MartinLoop executor before requesting controls.");
42
+ }
43
+ const priorState = await readRunControlStateUnlocked(detail);
44
+ const priorCancellation = priorState.receipts.find((candidate) => candidate.action === "cancel");
45
+ if (action === "cancel" && priorCancellation) {
46
+ return publishCancellation(detail, priorCancellation, priorState, _testHooks);
47
+ }
48
+ if (action === "pause" && priorState.requestedState === "paused") {
49
+ throw unsupportedOperationError("The run already has a pending pause request.", "Continue the existing pause before requesting another pause.");
50
+ }
51
+ if (action === "pause" && priorState.appliedState === "paused") {
52
+ throw unsupportedOperationError("The previous pause is still applied by the executor.", "Wait until the matching continue application is visible before requesting another pause.");
53
+ }
54
+ if (action !== "cancel" && priorState.requestedState === "cancellation_requested") {
55
+ throw unsupportedOperationError("The run already has a cancellation request.", "Wait for the active executor to record terminal cancellation evidence.");
56
+ }
57
+ const expectedPreviousControlId = action === "continue"
58
+ ? requirePendingPauseControlId(priorState)
59
+ : undefined;
60
+ const unsignedReceipt = {
61
+ schemaVersion: "martin.run-control.v2",
11
62
  controlId: `ctl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
12
63
  loopId: detail.loop.loopId,
13
64
  action,
14
- requestedAt: new Date().toISOString(),
65
+ requestedAt: nextControlRequestedAt(priorState),
66
+ authorityBoundary: "local_os_user",
67
+ principal: "local_mcp_client",
68
+ executorLeaseToken: executionLease.lease.token,
69
+ ...(typeof detail.loop.workspaceId === "string" ? { workspaceId: detail.loop.workspaceId } : {}),
70
+ ...(expectedPreviousControlId ? { expectedPreviousControlId } : {}),
15
71
  ...(input.reason ? { reason: input.reason } : {}),
16
- ...(input.requestedBy ? { requestedBy: input.requestedBy } : {})
72
+ ...(priorState.receipts.at(-1)?.receiptHash
73
+ ? { previousReceiptHash: priorState.receipts.at(-1).receiptHash }
74
+ : {})
75
+ };
76
+ const receipt = {
77
+ ...unsignedReceipt,
78
+ receiptHash: hashCanonical(unsignedReceipt)
17
79
  };
80
+ const currentDetail = await loadDetailedLoopRecord(input);
81
+ if (currentDetail.canonicalRunDirectory !== detail.canonicalRunDirectory ||
82
+ isTerminalRun(currentDetail.loop) ||
83
+ await readRunTerminalIntent(detail.canonicalRunDirectory, detail.loop.loopId)) {
84
+ throw unsupportedOperationError(`Run ${detail.loop.loopId} is already terminal and cannot accept ${action}.`, "Inspect the terminal run evidence instead of recording a new control request.");
85
+ }
86
+ const currentLease = await readActiveRunExecutionLease({
87
+ runsRoot: currentDetail.runsRoot,
88
+ runId: currentDetail.loop.loopId
89
+ });
90
+ if (currentLease?.lease.token !== executionLease.lease.token) {
91
+ throw controlReceiptIntegrityError();
92
+ }
18
93
  const receiptPath = path.join(detail.canonicalRunDirectory, "controls.jsonl");
19
- await appendFile(receiptPath, `${JSON.stringify(receipt)}\n`, "utf8");
20
- const state = await readRunControlState(detail);
94
+ await safeAppendJournal(detail.canonicalRunDirectory, receiptPath, `${JSON.stringify(receipt)}\n`, _testHooks?.afterJournalOpen);
95
+ const receiptState = await readRunControlStateUnlocked(detail);
96
+ if (action === "cancel") {
97
+ return publishCancellation(detail, receipt, receiptState, _testHooks);
98
+ }
99
+ const latestDetail = await loadDetailedLoopRecord(input);
100
+ const state = await readRunControlStateUnlocked(latestDetail);
101
+ const terminal = isTerminalRun(latestDetail.loop);
21
102
  return {
22
103
  ok: true,
23
- summary: action === "cancel"
24
- ? `Cancellation request recorded for ${detail.loop.loopId}.`
104
+ summary: terminal
105
+ ? `Run ${detail.loop.loopId} became terminal while the control was recorded; terminal evidence wins.`
25
106
  : action === "pause"
26
- ? `Pause request recorded for ${detail.loop.loopId}.`
27
- : `Continue request recorded for ${detail.loop.loopId}.`,
107
+ ? `Pause requested for ${detail.loop.loopId}; application waits for the next safe attempt boundary.`
108
+ : `Continue request bound to pause ${expectedPreviousControlId} for ${detail.loop.loopId}.`,
28
109
  loopId: detail.loop.loopId,
29
110
  requestedAction: action,
30
111
  state,
31
112
  action: receipt.action,
32
113
  controlId: receipt.controlId,
33
114
  requestedAt: receipt.requestedAt,
115
+ publicationState: "receipt_recorded",
116
+ applicationState: terminal ? "terminal_reconciled" : "requested_only",
34
117
  ...(receipt.reason ? { reason: receipt.reason } : {}),
35
- ...(receipt.requestedBy ? { requestedBy: receipt.requestedBy } : {})
118
+ authorityBoundary: receipt.authorityBoundary,
119
+ principal: receipt.principal,
120
+ receiptHash: receipt.receiptHash
121
+ };
122
+ }
123
+ async function publishCancellation(detail, receipt, state, testHooks) {
124
+ const existingPublication = state.publications.find((candidate) => candidate.controlId === receipt.controlId);
125
+ const expectedSignal = {
126
+ schemaVersion: "exit-signal/1",
127
+ kind: "human_interrupt",
128
+ runId: detail.loop.loopId,
129
+ requestedAt: receipt.requestedAt,
130
+ requestedBy: "local_mcp_client",
131
+ reason: receipt.reason ?? "Cancellation requested through martin_cancel."
132
+ };
133
+ // A publication receipt never substitutes for the canonical signal. This
134
+ // repairs a missing signal and fails closed if first-writer-wins preserved a
135
+ // different signal payload in the canonical slot.
136
+ const signalState = await writeExitSignal(detail.runsRoot, expectedSignal);
137
+ const durableSignal = await readExitSignal(detail.runsRoot, detail.loop.loopId, "human_interrupt");
138
+ if (!durableSignal ||
139
+ durableSignal.schemaVersion !== expectedSignal.schemaVersion ||
140
+ durableSignal.kind !== expectedSignal.kind ||
141
+ durableSignal.runId !== expectedSignal.runId ||
142
+ durableSignal.requestedAt !== expectedSignal.requestedAt ||
143
+ durableSignal.requestedBy !== expectedSignal.requestedBy ||
144
+ durableSignal.reason !== expectedSignal.reason) {
145
+ throw controlReceiptIntegrityError();
146
+ }
147
+ let publicationState = "already_requested";
148
+ if (!existingPublication) {
149
+ const previous = state.publications.at(-1);
150
+ const unsigned = {
151
+ schemaVersion: "martin.run-control-publication.v1",
152
+ loopId: receipt.loopId,
153
+ controlId: receipt.controlId,
154
+ receiptHash: receipt.receiptHash,
155
+ signalState,
156
+ recordedAt: new Date().toISOString(),
157
+ ...(previous ? { previousPublicationHash: previous.publicationHash } : {})
158
+ };
159
+ const publication = {
160
+ ...unsigned,
161
+ publicationHash: hashCanonical(unsigned)
162
+ };
163
+ try {
164
+ await safeAppendJournal(detail.canonicalRunDirectory, path.join(detail.canonicalRunDirectory, "control-publications.jsonl"), `${JSON.stringify(publication)}\n`, testHooks?.afterJournalOpen);
165
+ publicationState = signalState === "created" ? "published" : "already_requested";
166
+ }
167
+ catch {
168
+ publicationState = "published_audit_pending";
169
+ }
170
+ }
171
+ const currentDetail = await loadDetailedLoopRecord({
172
+ loopId: detail.loop.loopId,
173
+ runsDir: detail.runsRoot
174
+ });
175
+ const currentState = await readRunControlStateUnlocked(currentDetail);
176
+ const terminal = isTerminalRun(currentDetail.loop) || currentState.requestedState === "terminal";
177
+ return {
178
+ ok: true,
179
+ summary: terminal
180
+ ? `Run ${detail.loop.loopId} is terminal; terminal evidence wins over pending cancellation state.`
181
+ : publicationState === "published_audit_pending"
182
+ ? `Cancellation signal is live for ${detail.loop.loopId}; publication audit is pending a safe retry.`
183
+ : `Cancellation signal is authoritative for ${detail.loop.loopId}; executor observation is pending.`,
184
+ loopId: detail.loop.loopId,
185
+ requestedAction: "cancel",
186
+ state: currentState,
187
+ action: "cancel",
188
+ controlId: receipt.controlId,
189
+ requestedAt: receipt.requestedAt,
190
+ publicationState,
191
+ applicationState: terminal ? "terminal_reconciled" : "pending_observation",
192
+ ...(receipt.reason ? { reason: receipt.reason } : {}),
193
+ authorityBoundary: receipt.authorityBoundary,
194
+ principal: receipt.principal,
195
+ receiptHash: receipt.receiptHash
36
196
  };
37
197
  }
38
198
  export async function readRunControlState(detailOrInput) {
39
199
  const detail = isDetailedLoopSource(detailOrInput)
40
200
  ? detailOrInput
41
201
  : await loadDetailedLoopRecord(detailOrInput);
202
+ return detail.canonicalRunDirectory
203
+ ? withRunControlLock(detail.canonicalRunDirectory, () => readRunControlStateUnlocked(detail))
204
+ : readRunControlStateUnlocked(detail);
205
+ }
206
+ async function readRunControlStateUnlocked(detail) {
42
207
  const receiptPath = detail.canonicalRunDirectory
43
208
  ? path.join(detail.canonicalRunDirectory, "controls.jsonl")
44
209
  : undefined;
45
- const receipts = receiptPath ? await readControlReceipts(receiptPath) : [];
210
+ const receipts = receiptPath
211
+ ? await readControlReceipts(receiptPath, detail.loop.loopId)
212
+ : [];
213
+ const applicationPath = detail.canonicalRunDirectory
214
+ ? path.join(detail.canonicalRunDirectory, "control-applications.jsonl")
215
+ : undefined;
216
+ const applications = applicationPath
217
+ ? await readRunControlApplications(applicationPath, detail.loop.loopId)
218
+ : [];
219
+ const publicationPath = detail.canonicalRunDirectory
220
+ ? path.join(detail.canonicalRunDirectory, "control-publications.jsonl")
221
+ : undefined;
222
+ const publications = publicationPath
223
+ ? await readRunControlPublications(publicationPath, detail.loop.loopId)
224
+ : [];
225
+ const terminalIntent = detail.canonicalRunDirectory
226
+ ? await readRunTerminalIntent(detail.canonicalRunDirectory, detail.loop.loopId)
227
+ : undefined;
46
228
  const latestReceipt = receipts.at(-1);
229
+ const snapshot = resolveRunControlSnapshot(receipts);
230
+ const reconciledAppliedState = reconcileRunControlApplications(receipts, applications);
231
+ reconcileRunControlPublications(receipts, publications);
47
232
  return {
48
- requestedState: latestReceipt?.action === "pause"
49
- ? "paused"
233
+ requestedState: isTerminalRun(detail.loop) || terminalIntent !== undefined
234
+ ? "terminal"
50
235
  : latestReceipt?.action === "cancel"
51
236
  ? "cancellation_requested"
52
- : "active",
237
+ : snapshot.pause
238
+ ? "paused"
239
+ : "active",
53
240
  ...(latestReceipt ? { latestReceipt } : {}),
54
- approvalState: latestReceipt?.action === "pause" ? "resume_requested" : "not_required",
241
+ approvalState: "not_required",
242
+ appliedState: isTerminalRun(detail.loop) || terminalIntent !== undefined
243
+ ? "terminal"
244
+ : latestReceipt?.action === "cancel"
245
+ ? "cancellation_pending"
246
+ : reconciledAppliedState,
55
247
  receipts,
56
- ...(receiptPath ? { receiptPath } : {})
248
+ applications,
249
+ publications,
250
+ ...(receiptPath ? { receiptPath } : {}),
251
+ ...(applicationPath ? { applicationPath } : {})
252
+ };
253
+ }
254
+ function reconcileRunControlPublications(receipts, publications) {
255
+ const receiptsById = new Map(receipts.map((receipt) => [receipt.controlId, receipt]));
256
+ const seen = new Set();
257
+ for (const publication of publications) {
258
+ if (seen.has(publication.controlId))
259
+ throw controlReceiptIntegrityError();
260
+ seen.add(publication.controlId);
261
+ const receipt = receiptsById.get(publication.controlId);
262
+ if (receipt?.action !== "cancel" ||
263
+ publication.loopId !== receipt.loopId ||
264
+ publication.receiptHash !== receipt.receiptHash) {
265
+ throw controlReceiptIntegrityError();
266
+ }
267
+ }
268
+ }
269
+ export function createFileRunControlSource(runsRoot) {
270
+ return {
271
+ async poll(runId) {
272
+ const runDirectory = path.join(runsRoot, runId);
273
+ const runDirectoryEntry = await lstat(runDirectory).catch((error) => {
274
+ if (isNodeError(error, "ENOENT"))
275
+ return undefined;
276
+ throw storeUnreadableError();
277
+ });
278
+ if (!runDirectoryEntry)
279
+ return {};
280
+ if (!runDirectoryEntry.isDirectory() || runDirectoryEntry.isSymbolicLink()) {
281
+ throw controlReceiptIntegrityError();
282
+ }
283
+ return withRunControlLock(runDirectory, async () => {
284
+ if (await readRunTerminalIntent(runDirectory, runId))
285
+ return {};
286
+ const receipts = await readControlReceipts(path.join(runDirectory, "controls.jsonl"), runId);
287
+ return resolveRunControlSnapshot(receipts);
288
+ });
289
+ },
290
+ async recordApplication(application) {
291
+ const runDirectory = path.join(runsRoot, application.loopId);
292
+ await withRunControlLock(runDirectory, async () => {
293
+ const receiptPath = path.join(runDirectory, "controls.jsonl");
294
+ const applicationPath = path.join(runDirectory, "control-applications.jsonl");
295
+ const receipts = await readControlReceipts(receiptPath, application.loopId);
296
+ resolveRunControlSnapshot(receipts);
297
+ const existing = await readRunControlApplications(applicationPath, application.loopId);
298
+ reconcileRunControlApplications(receipts, existing);
299
+ if (existing.some((candidate) => candidate.controlId === application.controlId &&
300
+ candidate.action === application.action)) {
301
+ return;
302
+ }
303
+ const previousApplicationHash = existing.at(-1)?.applicationHash;
304
+ const unsigned = {
305
+ schemaVersion: "martin.run-control-application.v2",
306
+ ...application,
307
+ ...(previousApplicationHash ? { previousApplicationHash } : {})
308
+ };
309
+ const stored = {
310
+ ...unsigned,
311
+ applicationHash: hashCanonical(unsigned)
312
+ };
313
+ reconcileRunControlApplications(receipts, [...existing, stored]);
314
+ await safeAppendJournal(runDirectory, applicationPath, `${JSON.stringify(stored)}\n`);
315
+ });
316
+ },
317
+ async recordTerminalIntent(runId) {
318
+ const runDirectory = path.join(runsRoot, runId);
319
+ await withRunControlLock(runDirectory, async () => {
320
+ if (await readRunTerminalIntent(runDirectory, runId))
321
+ return;
322
+ const unsigned = {
323
+ schemaVersion: "martin.run-terminal-intent.v1",
324
+ loopId: runId,
325
+ recordedAt: new Date().toISOString()
326
+ };
327
+ const intent = {
328
+ ...unsigned,
329
+ intentHash: hashCanonical(unsigned)
330
+ };
331
+ await safeAppendJournal(runDirectory, path.join(runDirectory, "terminal-intent.jsonl"), `${JSON.stringify(intent)}\n`);
332
+ });
333
+ }
334
+ };
335
+ }
336
+ export function reconcileRunControlApplications(receipts, applications) {
337
+ const receiptPositions = new Map(receipts.map((receipt, index) => [receipt.controlId, { receipt, index }]));
338
+ const seenApplications = new Set();
339
+ let appliedPauseId;
340
+ let lastReceiptPosition = -1;
341
+ let lastAppliedAt = -Infinity;
342
+ for (const application of applications) {
343
+ const identity = `${application.action}:${application.controlId}`;
344
+ if (seenApplications.has(identity))
345
+ throw controlReceiptIntegrityError();
346
+ seenApplications.add(identity);
347
+ const matched = receiptPositions.get(application.controlId);
348
+ if (!matched || matched.index < lastReceiptPosition)
349
+ throw controlReceiptIntegrityError();
350
+ if (application.receiptHash !== matched.receipt.receiptHash) {
351
+ throw controlReceiptIntegrityError();
352
+ }
353
+ const appliedAt = Date.parse(application.appliedAt);
354
+ if (appliedAt < Date.parse(matched.receipt.requestedAt) || appliedAt < lastAppliedAt) {
355
+ throw controlReceiptIntegrityError();
356
+ }
357
+ if (receipts
358
+ .slice(matched.index + 1)
359
+ .some((receipt) => Date.parse(receipt.requestedAt) <= appliedAt)) {
360
+ throw controlReceiptIntegrityError();
361
+ }
362
+ if (application.action === "pause_applied") {
363
+ if (matched.receipt.action !== "pause" ||
364
+ application.relatedControlId !== undefined ||
365
+ appliedPauseId !== undefined) {
366
+ throw controlReceiptIntegrityError();
367
+ }
368
+ appliedPauseId = matched.receipt.controlId;
369
+ }
370
+ else {
371
+ if (matched.receipt.action !== "continue" ||
372
+ matched.receipt.expectedPreviousControlId !== application.relatedControlId ||
373
+ appliedPauseId !== application.relatedControlId) {
374
+ throw controlReceiptIntegrityError();
375
+ }
376
+ appliedPauseId = undefined;
377
+ }
378
+ lastReceiptPosition = matched.index;
379
+ lastAppliedAt = appliedAt;
380
+ }
381
+ return appliedPauseId ? "paused" : "active";
382
+ }
383
+ export function resolveRunControlSnapshot(receipts) {
384
+ let pause;
385
+ let continuedBy;
386
+ let cancellationRequested = false;
387
+ const controlIds = new Set();
388
+ let lastRequestedAt = -Infinity;
389
+ for (const receipt of receipts) {
390
+ if (controlIds.has(receipt.controlId))
391
+ throw controlReceiptIntegrityError();
392
+ controlIds.add(receipt.controlId);
393
+ const requestedAt = Date.parse(receipt.requestedAt);
394
+ if (requestedAt < lastRequestedAt)
395
+ throw controlReceiptIntegrityError();
396
+ lastRequestedAt = requestedAt;
397
+ if (receipt.action === "cancel") {
398
+ cancellationRequested = true;
399
+ pause = undefined;
400
+ continuedBy = undefined;
401
+ continue;
402
+ }
403
+ if (cancellationRequested) {
404
+ throw controlReceiptIntegrityError();
405
+ }
406
+ if (receipt.action === "pause") {
407
+ if (pause)
408
+ throw controlReceiptIntegrityError();
409
+ pause = receipt;
410
+ continuedBy = undefined;
411
+ continue;
412
+ }
413
+ if (!pause || receipt.expectedPreviousControlId !== pause.controlId) {
414
+ throw controlReceiptIntegrityError();
415
+ }
416
+ continuedBy = receipt;
417
+ pause = undefined;
418
+ }
419
+ return {
420
+ ...(pause ? { pause } : {}),
421
+ ...(!pause && continuedBy ? { continuedBy } : {})
57
422
  };
58
423
  }
59
- export async function readControlReceipts(receiptPath) {
424
+ export async function readRunControlApplications(applicationPath, expectedLoopId) {
425
+ let contents;
426
+ try {
427
+ contents = await safeReadJournal(path.dirname(applicationPath), applicationPath);
428
+ }
429
+ catch (error) {
430
+ if (isFileNotFoundError(error))
431
+ return [];
432
+ throw controlReceiptIntegrityError();
433
+ }
434
+ const applications = [];
435
+ for (const line of contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean)) {
436
+ let candidate;
437
+ try {
438
+ candidate = JSON.parse(line);
439
+ }
440
+ catch {
441
+ throw controlReceiptIntegrityError();
442
+ }
443
+ if (!isRunControlApplication(candidate, expectedLoopId)) {
444
+ throw controlReceiptIntegrityError();
445
+ }
446
+ const previous = applications.at(-1);
447
+ if (candidate.previousApplicationHash !== previous?.applicationHash) {
448
+ throw controlReceiptIntegrityError();
449
+ }
450
+ applications.push(candidate);
451
+ }
452
+ return applications;
453
+ }
454
+ export async function readControlReceipts(receiptPath, expectedLoopId) {
455
+ let contents;
456
+ try {
457
+ contents = await safeReadJournal(path.dirname(receiptPath), receiptPath);
458
+ }
459
+ catch (error) {
460
+ if (isFileNotFoundError(error)) {
461
+ return [];
462
+ }
463
+ throw storeUnreadableError();
464
+ }
465
+ const receipts = [];
466
+ for (const line of contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean)) {
467
+ let candidate;
468
+ try {
469
+ candidate = JSON.parse(line);
470
+ }
471
+ catch {
472
+ throw controlReceiptIntegrityError();
473
+ }
474
+ if (!isRunControlReceipt(candidate)) {
475
+ throw controlReceiptIntegrityError();
476
+ }
477
+ if (expectedLoopId !== undefined && candidate.loopId !== expectedLoopId) {
478
+ throw controlReceiptIntegrityError();
479
+ }
480
+ const previous = receipts.at(-1);
481
+ if (candidate.previousReceiptHash !== previous?.receiptHash) {
482
+ throw controlReceiptIntegrityError();
483
+ }
484
+ receipts.push(candidate);
485
+ }
486
+ return receipts;
487
+ }
488
+ async function readRunControlPublications(publicationPath, expectedLoopId) {
489
+ let contents;
60
490
  try {
61
- const contents = await readFile(receiptPath, "utf8");
62
- return contents
63
- .split(/\r?\n/u)
64
- .map((line) => line.trim())
65
- .filter(Boolean)
66
- .map((line) => JSON.parse(line))
67
- .filter(isRunControlReceipt);
491
+ contents = await safeReadJournal(path.dirname(publicationPath), publicationPath);
492
+ }
493
+ catch (error) {
494
+ if (isFileNotFoundError(error))
495
+ return [];
496
+ throw controlReceiptIntegrityError();
497
+ }
498
+ const publications = [];
499
+ for (const line of contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean)) {
500
+ let candidate;
501
+ try {
502
+ candidate = JSON.parse(line);
503
+ }
504
+ catch {
505
+ throw controlReceiptIntegrityError();
506
+ }
507
+ if (!isRunControlPublication(candidate, expectedLoopId)) {
508
+ throw controlReceiptIntegrityError();
509
+ }
510
+ const previous = publications.at(-1);
511
+ if (candidate.previousPublicationHash !== previous?.publicationHash) {
512
+ throw controlReceiptIntegrityError();
513
+ }
514
+ publications.push(candidate);
515
+ }
516
+ return publications;
517
+ }
518
+ async function readRunTerminalIntent(runDirectory, expectedLoopId) {
519
+ const intentPath = path.join(runDirectory, "terminal-intent.jsonl");
520
+ let contents;
521
+ try {
522
+ contents = await safeReadJournal(runDirectory, intentPath);
523
+ }
524
+ catch (error) {
525
+ if (isFileNotFoundError(error))
526
+ return undefined;
527
+ throw controlReceiptIntegrityError();
528
+ }
529
+ const lines = contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean);
530
+ if (lines.length !== 1)
531
+ throw controlReceiptIntegrityError();
532
+ let candidate;
533
+ try {
534
+ candidate = JSON.parse(lines[0]);
68
535
  }
69
536
  catch {
70
- return [];
537
+ throw controlReceiptIntegrityError();
71
538
  }
539
+ if (typeof candidate !== "object" || candidate === null)
540
+ throw controlReceiptIntegrityError();
541
+ const intent = candidate;
542
+ if (!hasOnlyOwnKeys(intent, ["schemaVersion", "loopId", "recordedAt", "intentHash"]) ||
543
+ intent.schemaVersion !== "martin.run-terminal-intent.v1" ||
544
+ intent.loopId !== expectedLoopId ||
545
+ !isIsoTimestamp(intent.recordedAt) ||
546
+ !isSha256(intent.intentHash)) {
547
+ throw controlReceiptIntegrityError();
548
+ }
549
+ const { intentHash, ...unsigned } = intent;
550
+ if (intentHash !== hashCanonical(unsigned))
551
+ throw controlReceiptIntegrityError();
552
+ return intent;
72
553
  }
73
554
  function isDetailedLoopSource(value) {
74
555
  return typeof value === "object" && value !== null && "loop" in value && "runsRoot" in value;
75
556
  }
557
+ function isTerminalRun(loop) {
558
+ return (loop.terminationEnvelope !== undefined ||
559
+ !["queued", "running", "verifying"].includes(loop.status) ||
560
+ !["created", "running", "verifying"].includes(loop.lifecycleState));
561
+ }
76
562
  function isRunControlReceipt(value) {
77
- return (typeof value === "object" &&
78
- value !== null &&
79
- typeof value.controlId === "string" &&
80
- typeof value.loopId === "string" &&
81
- typeof value.action === "string" &&
82
- typeof value.requestedAt === "string");
563
+ if (typeof value !== "object" || value === null) {
564
+ return false;
565
+ }
566
+ const candidate = value;
567
+ const structurallyValid = (hasOnlyOwnKeys(candidate, [
568
+ "schemaVersion",
569
+ "controlId",
570
+ "loopId",
571
+ "action",
572
+ "requestedAt",
573
+ "authorityBoundary",
574
+ "principal",
575
+ "executorLeaseToken",
576
+ "workspaceId",
577
+ "projectId",
578
+ "expectedPreviousControlId",
579
+ "reason",
580
+ "previousReceiptHash",
581
+ "receiptHash"
582
+ ]) &&
583
+ candidate.schemaVersion === "martin.run-control.v2" &&
584
+ isNonEmptyString(candidate.controlId) &&
585
+ isNonEmptyString(candidate.loopId) &&
586
+ isControlAction(candidate.action) &&
587
+ isIsoTimestamp(candidate.requestedAt) &&
588
+ (candidate.expectedPreviousControlId === undefined ||
589
+ isNonEmptyString(candidate.expectedPreviousControlId)) &&
590
+ (candidate.action === "continue"
591
+ ? isNonEmptyString(candidate.expectedPreviousControlId)
592
+ : candidate.expectedPreviousControlId === undefined) &&
593
+ candidate.authorityBoundary === "local_os_user" &&
594
+ candidate.principal === "local_mcp_client" &&
595
+ isNonEmptyString(candidate.executorLeaseToken) &&
596
+ (candidate.workspaceId === undefined || isNonEmptyString(candidate.workspaceId)) &&
597
+ (candidate.projectId === undefined || isNonEmptyString(candidate.projectId)) &&
598
+ (candidate.reason === undefined || isNonEmptyString(candidate.reason)) &&
599
+ (candidate.previousReceiptHash === undefined || isSha256(candidate.previousReceiptHash)) &&
600
+ isSha256(candidate.receiptHash));
601
+ if (!structurallyValid)
602
+ return false;
603
+ const { receiptHash, ...unsigned } = value;
604
+ return receiptHash === hashCanonical(unsigned);
605
+ }
606
+ function isRunControlApplication(value, expectedLoopId) {
607
+ if (typeof value !== "object" || value === null)
608
+ return false;
609
+ const candidate = value;
610
+ const structurallyValid = (hasOnlyOwnKeys(candidate, [
611
+ "schemaVersion",
612
+ "loopId",
613
+ "controlId",
614
+ "action",
615
+ "appliedAt",
616
+ "relatedControlId",
617
+ "receiptHash",
618
+ "previousApplicationHash",
619
+ "applicationHash"
620
+ ]) &&
621
+ candidate.schemaVersion === "martin.run-control-application.v2" &&
622
+ isNonEmptyString(candidate.loopId) &&
623
+ (expectedLoopId === undefined || candidate.loopId === expectedLoopId) &&
624
+ isNonEmptyString(candidate.controlId) &&
625
+ (candidate.action === "pause_applied" || candidate.action === "continue_applied") &&
626
+ isIsoTimestamp(candidate.appliedAt) &&
627
+ (candidate.relatedControlId === undefined || isNonEmptyString(candidate.relatedControlId)) &&
628
+ (candidate.action === "continue_applied"
629
+ ? isNonEmptyString(candidate.relatedControlId)
630
+ : candidate.relatedControlId === undefined) &&
631
+ isSha256(candidate.receiptHash) &&
632
+ (candidate.previousApplicationHash === undefined || isSha256(candidate.previousApplicationHash)) &&
633
+ isSha256(candidate.applicationHash));
634
+ if (!structurallyValid)
635
+ return false;
636
+ const { applicationHash, ...unsigned } = value;
637
+ return applicationHash === hashCanonical(unsigned);
638
+ }
639
+ function isRunControlPublication(value, expectedLoopId) {
640
+ if (typeof value !== "object" || value === null)
641
+ return false;
642
+ const candidate = value;
643
+ const structurallyValid = hasOnlyOwnKeys(candidate, [
644
+ "schemaVersion",
645
+ "loopId",
646
+ "controlId",
647
+ "receiptHash",
648
+ "signalState",
649
+ "recordedAt",
650
+ "previousPublicationHash",
651
+ "publicationHash"
652
+ ]) &&
653
+ candidate.schemaVersion === "martin.run-control-publication.v1" &&
654
+ candidate.loopId === expectedLoopId &&
655
+ isNonEmptyString(candidate.controlId) &&
656
+ isSha256(candidate.receiptHash) &&
657
+ (candidate.signalState === "created" || candidate.signalState === "already_exists") &&
658
+ isIsoTimestamp(candidate.recordedAt) &&
659
+ (candidate.previousPublicationHash === undefined || isSha256(candidate.previousPublicationHash)) &&
660
+ isSha256(candidate.publicationHash);
661
+ if (!structurallyValid)
662
+ return false;
663
+ const { publicationHash, ...unsigned } = value;
664
+ return publicationHash === hashCanonical(unsigned);
665
+ }
666
+ function requirePendingPauseControlId(state) {
667
+ const latest = state.latestReceipt;
668
+ if (latest?.action !== "pause") {
669
+ throw unsupportedOperationError("Continue requires one pending pause request.", "Pause the active run first, then continue the exact pause returned by MartinLoop.");
670
+ }
671
+ return latest.controlId;
672
+ }
673
+ function nextControlRequestedAt(state) {
674
+ let latestEvidenceMs = -Infinity;
675
+ for (const receipt of state.receipts) {
676
+ latestEvidenceMs = Math.max(latestEvidenceMs, Date.parse(receipt.requestedAt));
677
+ }
678
+ for (const application of state.applications) {
679
+ latestEvidenceMs = Math.max(latestEvidenceMs, Date.parse(application.appliedAt));
680
+ }
681
+ return new Date(Math.max(Date.now(), latestEvidenceMs + 1)).toISOString();
682
+ }
683
+ function isControlAction(value) {
684
+ return value === "pause" || value === "cancel" || value === "continue";
685
+ }
686
+ function isNonEmptyString(value) {
687
+ return typeof value === "string" && value.trim().length > 0;
688
+ }
689
+ function hasOnlyOwnKeys(value, allowedKeys) {
690
+ const allowed = new Set(allowedKeys);
691
+ return Object.keys(value).every((key) => allowed.has(key));
692
+ }
693
+ function isSha256(value) {
694
+ return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
695
+ }
696
+ export function hashCanonical(value) {
697
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
698
+ }
699
+ function isIsoTimestamp(value) {
700
+ return (isNonEmptyString(value) &&
701
+ Number.isFinite(Date.parse(value)) &&
702
+ new Date(value).toISOString() === value);
703
+ }
704
+ function isFileNotFoundError(error) {
705
+ return (typeof error === "object" &&
706
+ error !== null &&
707
+ "code" in error &&
708
+ error.code === "ENOENT");
709
+ }
710
+ function controlReceiptIntegrityError() {
711
+ return storeUnreadableError("Run control receipt integrity check failed.");
712
+ }
713
+ function sameFileIdentity(left, right) {
714
+ return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
715
+ }
716
+ async function assertSafeRunDirectory(runDirectory) {
717
+ const parentDirectory = path.dirname(runDirectory);
718
+ const parentEntry = await lstat(parentDirectory);
719
+ if (!parentEntry.isDirectory() || parentEntry.isSymbolicLink())
720
+ throw controlReceiptIntegrityError();
721
+ const entry = await lstat(runDirectory);
722
+ if (!entry.isDirectory() || entry.isSymbolicLink())
723
+ throw controlReceiptIntegrityError();
724
+ const canonical = path.resolve(await realpath(runDirectory));
725
+ const canonicalParent = path.resolve(await realpath(parentDirectory));
726
+ // Compare the leaf against the canonical parent rather than the lexical
727
+ // absolute path. macOS exposes /var through the trusted /private/var system
728
+ // alias; canonicalizing the parent preserves that platform behavior while
729
+ // still rejecting a linked runs-root or run-directory leaf.
730
+ const expected = path.join(canonicalParent, path.basename(runDirectory));
731
+ const isSamePath = process.platform === "win32"
732
+ ? canonical.toLowerCase() === expected.toLowerCase()
733
+ : canonical === expected;
734
+ if (!isSamePath) {
735
+ throw controlReceiptIntegrityError();
736
+ }
737
+ }
738
+ async function safeReadJournal(runDirectory, journalPath) {
739
+ await assertSafeRunDirectory(runDirectory);
740
+ const before = await lstat(journalPath);
741
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
742
+ throw controlReceiptIntegrityError();
743
+ }
744
+ const handle = await open(journalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
745
+ try {
746
+ const opened = await handle.stat();
747
+ if (!opened.isFile() || opened.nlink !== 1 || !sameFileIdentity(before, opened)) {
748
+ throw controlReceiptIntegrityError();
749
+ }
750
+ const contents = await handle.readFile({ encoding: "utf8" });
751
+ const after = await lstat(journalPath);
752
+ await assertSafeRunDirectory(runDirectory);
753
+ if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1 || !sameFileIdentity(opened, after)) {
754
+ throw controlReceiptIntegrityError();
755
+ }
756
+ return contents;
757
+ }
758
+ finally {
759
+ await handle.close();
760
+ }
761
+ }
762
+ async function safeAppendJournal(runDirectory, journalPath, contents, afterOpen) {
763
+ await assertSafeRunDirectory(runDirectory);
764
+ const before = await lstat(journalPath).catch((error) => {
765
+ if (isNodeError(error, "ENOENT"))
766
+ return undefined;
767
+ throw error;
768
+ });
769
+ if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1)) {
770
+ throw controlReceiptIntegrityError();
771
+ }
772
+ const flags = constants.O_WRONLY | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0) |
773
+ (before ? 0 : constants.O_CREAT | constants.O_EXCL);
774
+ const handle = await open(journalPath, flags, 0o600);
775
+ try {
776
+ const opened = await handle.stat();
777
+ if (!opened.isFile() || opened.nlink !== 1 || (before && !sameFileIdentity(before, opened))) {
778
+ throw controlReceiptIntegrityError();
779
+ }
780
+ await afterOpen?.(journalPath);
781
+ await handle.writeFile(contents, "utf8");
782
+ await handle.sync();
783
+ const after = await lstat(journalPath);
784
+ await assertSafeRunDirectory(runDirectory);
785
+ if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1 || !sameFileIdentity(opened, after)) {
786
+ throw controlReceiptIntegrityError();
787
+ }
788
+ }
789
+ finally {
790
+ await handle.close();
791
+ }
792
+ }
793
+ const RUN_CONTROL_LOCK_RETRY_MS = 25;
794
+ const RUN_CONTROL_LOCK_MAX_RETRIES = 200;
795
+ async function withRunControlLock(runDirectory, operation, maxRetries = RUN_CONTROL_LOCK_MAX_RETRIES) {
796
+ await assertSafeRunDirectory(runDirectory);
797
+ const lockPath = path.join(runDirectory, ".run-control.lock");
798
+ const token = randomBytes(16).toString("hex");
799
+ let lockHandle;
800
+ let lease;
801
+ for (let attempt = 0; attempt < maxRetries; attempt += 1) {
802
+ try {
803
+ lockHandle = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
804
+ const now = new Date().toISOString();
805
+ lease = {
806
+ schemaVersion: "martin.run-control-lock.v1",
807
+ token,
808
+ pid: process.pid,
809
+ hostname: hostname(),
810
+ acquiredAt: now,
811
+ heartbeatAt: now
812
+ };
813
+ await lockHandle.writeFile(`${JSON.stringify(lease)}\n`, "utf8");
814
+ await lockHandle.sync();
815
+ break;
816
+ }
817
+ catch (error) {
818
+ if (!isNodeError(error, "EEXIST"))
819
+ throw storeUnreadableError();
820
+ try {
821
+ const before = await lstat(lockPath);
822
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
823
+ throw storeUnreadableError();
824
+ }
825
+ const raw = await safeReadJournal(runDirectory, lockPath);
826
+ const existing = JSON.parse(raw);
827
+ if (existing.schemaVersion === "martin.run-control-lock.v1" &&
828
+ typeof existing.token === "string" &&
829
+ existing.hostname === hostname() &&
830
+ typeof existing.pid === "number" &&
831
+ !isProcessAlive(existing.pid)) {
832
+ if (await reclaimDeadRunControlLock(runDirectory, lockPath, raw, before, existing.token)) {
833
+ continue;
834
+ }
835
+ }
836
+ }
837
+ catch (inspectionError) {
838
+ if (isNodeError(inspectionError, "ENOENT"))
839
+ continue;
840
+ }
841
+ await new Promise((resolve) => setTimeout(resolve, RUN_CONTROL_LOCK_RETRY_MS));
842
+ }
843
+ }
844
+ if (!lockHandle || !lease) {
845
+ throw storeUnreadableError("Run control journal is busy; no control was recorded.");
846
+ }
847
+ let heartbeatRunning = false;
848
+ const heartbeat = setInterval(() => {
849
+ if (!lockHandle || !lease || heartbeatRunning)
850
+ return;
851
+ heartbeatRunning = true;
852
+ lease = { ...lease, heartbeatAt: new Date().toISOString() };
853
+ const serializedLease = `${JSON.stringify(lease)}\n`;
854
+ void lockHandle.truncate(0)
855
+ // FileHandle.writeFile() writes from the handle's current position. The
856
+ // explicit position prevents a heartbeat from leaving a sparse prefix
857
+ // after truncate(), which would make the ownership record unreadable.
858
+ .then(() => lockHandle.write(serializedLease, 0, "utf8"))
859
+ .then(() => lockHandle.sync())
860
+ .catch(() => undefined)
861
+ .finally(() => {
862
+ heartbeatRunning = false;
863
+ });
864
+ }, 1_000);
865
+ heartbeat.unref();
866
+ try {
867
+ return await operation();
868
+ }
869
+ finally {
870
+ clearInterval(heartbeat);
871
+ await lockHandle.close();
872
+ const raw = await safeReadJournal(runDirectory, lockPath).catch(() => undefined);
873
+ if (raw) {
874
+ try {
875
+ const current = JSON.parse(raw);
876
+ if (current.token === token)
877
+ await unlink(lockPath);
878
+ }
879
+ catch {
880
+ // A replaced/corrupt lock is never deleted by a former owner.
881
+ }
882
+ }
883
+ }
884
+ }
885
+ async function reclaimDeadRunControlLock(runDirectory, lockPath, observedRaw, observed, staleToken) {
886
+ const claimPath = path.join(runDirectory, `.run-control.reclaim-${createHash("sha256").update(staleToken).digest("hex").slice(0, 24)}`);
887
+ try {
888
+ // A hard-link is an atomic compare token for the observed inode. Exactly
889
+ // one concurrent reclaimer can create this deterministic claim.
890
+ await link(lockPath, claimPath);
891
+ }
892
+ catch (error) {
893
+ if (isNodeError(error, "EEXIST") || isNodeError(error, "ENOENT"))
894
+ return false;
895
+ throw error;
896
+ }
897
+ try {
898
+ const claimed = await lstat(claimPath);
899
+ const current = await lstat(lockPath).catch((error) => {
900
+ if (isNodeError(error, "ENOENT"))
901
+ return undefined;
902
+ throw error;
903
+ });
904
+ if (!current ||
905
+ !claimed.isFile() ||
906
+ claimed.isSymbolicLink() ||
907
+ !sameFileIdentity(observed, claimed) ||
908
+ !sameFileIdentity(claimed, current)) {
909
+ return false;
910
+ }
911
+ const handle = await open(claimPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
912
+ let claimRaw;
913
+ try {
914
+ const opened = await handle.stat();
915
+ if (!sameFileIdentity(claimed, opened))
916
+ return false;
917
+ claimRaw = await handle.readFile({ encoding: "utf8" });
918
+ }
919
+ finally {
920
+ await handle.close();
921
+ }
922
+ if (claimRaw !== observedRaw)
923
+ return false;
924
+ // Only the successful claim creator may unlink. Other reclaimers wait for
925
+ // the claim to disappear, preventing a second stale observer from deleting
926
+ // a newly acquired lock after the first reclaimer succeeds.
927
+ const beforeUnlink = await lstat(lockPath).catch(() => undefined);
928
+ if (!beforeUnlink || !sameFileIdentity(claimed, beforeUnlink))
929
+ return false;
930
+ await unlink(lockPath);
931
+ return true;
932
+ }
933
+ finally {
934
+ await unlink(claimPath).catch(() => undefined);
935
+ }
936
+ }
937
+ function isProcessAlive(pid) {
938
+ if (!Number.isInteger(pid) || pid <= 0)
939
+ return true;
940
+ try {
941
+ process.kill(pid, 0);
942
+ return true;
943
+ }
944
+ catch (error) {
945
+ return !isNodeError(error, "ESRCH");
946
+ }
947
+ }
948
+ function isNodeError(error, code) {
949
+ return (typeof error === "object" &&
950
+ error !== null &&
951
+ "code" in error &&
952
+ error.code === code);
83
953
  }
84
954
  export function validateControlReason(value) {
85
955
  if (value === undefined) {