@deeeed/metamask-harness 0.67.0 → 0.68.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.
@@ -1,3 +1,4 @@
1
+ import { ensureRuntimeDirectory, processIdentity } from "@farmslot/recipe-harness/runtime/operation";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -5,15 +6,19 @@ import { recipeRuntimeDir } from "./paths.js";
5
6
  function acquireCheckoutLock(target, operation) {
6
7
  const repoRoot = fs.realpathSync(target);
7
8
  const lockPath = path.join(repoRoot, recipeRuntimeDir(), "sandbox.lock");
8
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
9
+ ensureRuntimeDirectory(path.dirname(lockPath));
9
10
  const inheritedToken = process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
10
11
  for (let attempt = 0; attempt < 4; attempt += 1) {
11
12
  try {
13
+ const identity = processIdentity(process.pid);
14
+ if (!identity) throw new Error("Cannot identify checkout lock owner process");
12
15
  const fd = fs.openSync(lockPath, "wx", 384);
13
- const token = inheritedToken || randomUUID();
16
+ fs.fchmodSync(fd, 384);
17
+ const token = randomUUID();
14
18
  const owner = {
15
19
  schemaVersion: 1,
16
20
  pid: process.pid,
21
+ processStartedAt: identity,
17
22
  operation,
18
23
  repoRoot,
19
24
  token,
@@ -22,30 +27,38 @@ function acquireCheckoutLock(target, operation) {
22
27
  fs.writeFileSync(fd, `${JSON.stringify(owner, null, 2)}
23
28
  `);
24
29
  fs.closeSync(fd);
25
- if (!inheritedToken) process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = token;
30
+ process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = token;
26
31
  return {
27
32
  path: lockPath,
28
33
  release: () => {
29
34
  const current = readOwner(lockPath);
30
- if (current?.pid === process.pid && current.token === token) fs.rmSync(lockPath, { force: true });
31
- if (!inheritedToken && process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN === token) {
32
- delete process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
35
+ if (current?.pid === process.pid && current.token === token && !childrenAlive(current)) {
36
+ fs.rmSync(lockPath, { force: true });
37
+ fs.rmSync(path.join(path.dirname(lockPath), "lock-members", token), { recursive: true, force: true });
38
+ }
39
+ if (process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN === token) {
40
+ if (inheritedToken) process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = inheritedToken;
41
+ else delete process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
33
42
  }
34
43
  }
35
44
  };
36
45
  } catch (error) {
37
46
  if (error.code !== "EEXIST") throw error;
38
47
  const owner = readOwner(lockPath);
39
- if (inheritedToken && owner?.token === inheritedToken && owner.repoRoot === repoRoot && typeof owner.pid === "number" && processAlive(owner.pid)) {
48
+ if (inheritedToken && owner?.token === inheritedToken && owner.repoRoot === repoRoot && typeof owner.pid === "number" && ownerAlive(owner)) {
49
+ trackCheckoutChild(repoRoot, process.pid);
40
50
  return { path: lockPath, release: () => void 0 };
41
51
  }
42
- if (owner && typeof owner.pid === "number" && processAlive(owner.pid)) {
52
+ if (owner && typeof owner.pid === "number" && ownerAlive(owner)) {
43
53
  return {
44
54
  path: lockPath,
45
55
  owner,
46
56
  message: `checkout sandbox is busy with ${String(owner.operation ?? "another operation")} (pid ${owner.pid}).`
47
57
  };
48
58
  }
59
+ if (!owner || childrenAlive(owner)) {
60
+ return { path: lockPath, owner, message: `checkout sandbox owner is unreadable or has surviving child processes. Inspect ${lockPath} and its lock-members directory; remove the lock only after verifying all recorded processes have exited.` };
61
+ }
49
62
  claimStaleLock(lockPath, owner);
50
63
  }
51
64
  }
@@ -61,12 +74,14 @@ function claimStaleLock(lockPath, expectedOwner) {
61
74
  }
62
75
  try {
63
76
  const claimedOwner = readOwner(claimedPath);
64
- if (!sameOwner(claimedOwner, expectedOwner) || claimedOwner && typeof claimedOwner.pid === "number" && processAlive(claimedOwner.pid)) {
77
+ if (!sameOwner(claimedOwner, expectedOwner) || claimedOwner && typeof claimedOwner.pid === "number" && (ownerAlive(claimedOwner) || childrenAlive(claimedOwner))) {
65
78
  try {
66
79
  fs.linkSync(claimedPath, lockPath);
67
80
  } catch (error) {
68
81
  if (error.code !== "EEXIST") throw error;
69
82
  }
83
+ } else if (typeof claimedOwner?.token === "string" && /^[a-f0-9-]{36}$/.test(claimedOwner.token)) {
84
+ fs.rmSync(path.join(path.dirname(lockPath), "lock-members", claimedOwner.token), { recursive: true, force: true });
70
85
  }
71
86
  } finally {
72
87
  fs.rmSync(claimedPath, { force: true });
@@ -84,6 +99,42 @@ function readOwner(lockPath) {
84
99
  return null;
85
100
  }
86
101
  }
102
+ function ownerAlive(owner) {
103
+ if (typeof owner.pid !== "number") return true;
104
+ if (typeof owner.processStartedAt === "string") return processIdentity(owner.pid) === owner.processStartedAt;
105
+ return processAlive(owner.pid);
106
+ }
107
+ function childrenAlive(owner) {
108
+ if (typeof owner.repoRoot !== "string" || typeof owner.token !== "string" || !/^[a-f0-9-]{36}$/.test(owner.token)) return false;
109
+ const directory = path.join(owner.repoRoot, recipeRuntimeDir(), "lock-members", owner.token);
110
+ if (!fs.existsSync(directory)) return false;
111
+ return fs.readdirSync(directory).filter((name) => name.endsWith(".json")).some((name) => {
112
+ const child = readOwner(path.join(directory, name));
113
+ if (child && typeof child.processGroupId === "number" && child.processGroupId > 0) return processAlive(-child.processGroupId);
114
+ return !child || typeof child.pid !== "number" || typeof child.processStartedAt !== "string" || child.pid !== owner.pid && processIdentity(child.pid) === child.processStartedAt;
115
+ });
116
+ }
117
+ function trackCheckoutChild(target, pid, ownsProcessGroup = false) {
118
+ const repoRoot = fs.realpathSync(target);
119
+ const lockPath = path.join(repoRoot, recipeRuntimeDir(), "sandbox.lock");
120
+ const owner = readOwner(lockPath);
121
+ if (!owner || owner.token !== process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN || typeof owner.token !== "string" || !/^[a-f0-9-]{36}$/.test(owner.token)) return () => void 0;
122
+ const identity = processIdentity(pid);
123
+ if (!identity && !ownsProcessGroup) return () => void 0;
124
+ const directory = path.join(repoRoot, recipeRuntimeDir(), "lock-members", owner.token);
125
+ ensureRuntimeDirectory(directory);
126
+ const file = path.join(directory, `${pid}.json`);
127
+ const previous = readOwner(file);
128
+ const group = ownsProcessGroup || previous?.processStartedAt === identity && previous?.processGroupId === pid;
129
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
130
+ fs.writeFileSync(temporary, JSON.stringify({ pid, processStartedAt: identity, ...group ? { processGroupId: pid } : {} }), { mode: 384 });
131
+ fs.chmodSync(temporary, 384);
132
+ fs.renameSync(temporary, file);
133
+ return () => {
134
+ const current = readOwner(file);
135
+ if (current?.pid === pid && current.processStartedAt === identity) fs.rmSync(file, { force: true });
136
+ };
137
+ }
87
138
  function processAlive(pid) {
88
139
  try {
89
140
  process.kill(pid, 0);
@@ -93,5 +144,6 @@ function processAlive(pid) {
93
144
  }
94
145
  }
95
146
  export {
96
- acquireCheckoutLock
147
+ acquireCheckoutLock,
148
+ trackCheckoutChild
97
149
  };
@@ -425,6 +425,7 @@ function validatePublicInvocation(argv, examples = {}) {
425
425
  }
426
426
  const { name, contract } = resolved;
427
427
  const tokens = argv.slice(1);
428
+ if (name === "checklist" && tokens[0] === "mark" && tokens[1] && !tokens[1].startsWith("-") && tokens[2] === "sub") return null;
428
429
  const positionals = [];
429
430
  const seenOptions = /* @__PURE__ */ new Set();
430
431
  const validOptions = Object.keys(contract.options);
@@ -1,9 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { OperationRecord, readOperations, processIdentity, ensureRuntimeDirectory } from "@farmslot/recipe-harness/runtime/operation";
5
+ import { latestTaskDir } from "@farmslot/agent-runtime";
4
6
  import { recipeRuntimeDir } from "./paths.js";
5
7
  const COMMAND_JOURNAL_FILE = "last-command.json";
6
8
  const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
9
+ "prepare",
7
10
  "call",
8
11
  "check",
9
12
  "checklist",
@@ -12,6 +15,7 @@ const JOURNALED_COMMANDS = /* @__PURE__ */ new Set([
12
15
  "fixtures",
13
16
  "install",
14
17
  "launch",
18
+ "runtime-launch",
15
19
  "provision",
16
20
  "recipe-quality",
17
21
  "run",
@@ -25,6 +29,8 @@ async function withCommandJournal(command, argv, execute) {
25
29
  if (!JOURNALED_COMMANDS.has(command)) return execute();
26
30
  const handle = tryBeginCommandJournal(command, argv);
27
31
  const previous = activeCommandJournal;
32
+ const parentId = process.env.MM_HARNESS_OPERATION_ID;
33
+ if (handle) process.env.MM_HARNESS_OPERATION_ID = handle.operation.value.id;
28
34
  activeCommandJournal = handle;
29
35
  try {
30
36
  const exitCode = await execute();
@@ -35,8 +41,16 @@ async function withCommandJournal(command, argv, execute) {
35
41
  throw error;
36
42
  } finally {
37
43
  activeCommandJournal = previous;
44
+ if (parentId) process.env.MM_HARNESS_OPERATION_ID = parentId;
45
+ else delete process.env.MM_HARNESS_OPERATION_ID;
38
46
  }
39
47
  }
48
+ function recordCommandStage(stage) {
49
+ activeCommandJournal?.operation.stage(stage);
50
+ }
51
+ function recordCommandOutput(chunk) {
52
+ activeCommandJournal?.operation.output(chunk);
53
+ }
40
54
  function recordCommandEvidence(...evidence) {
41
55
  const handle = activeCommandJournal;
42
56
  if (!handle || evidence.length === 0) return;
@@ -58,7 +72,12 @@ function commandJournalPath(target, runtimeDir) {
58
72
  return path.join(path.resolve(target), relative, COMMAND_JOURNAL_FILE);
59
73
  }
60
74
  function readCommandJournal(target, runtimeDir) {
61
- const file = commandJournalPath(target, runtimeDir);
75
+ const legacyFile = commandJournalPath(target, runtimeDir);
76
+ const operations = readOperations(path.join(path.dirname(legacyFile), "operations"));
77
+ const live = operations.filter((operation) => operation.status === "running" && processIdentity(operation.pid) === operation.processStartedAt);
78
+ const roots = operations.filter((operation) => !operation.parentId).sort((a, b) => (a.finishedAt ?? a.startedAt).localeCompare(b.finishedAt ?? b.startedAt));
79
+ const selected = live.find((operation) => !operation.parentId) ?? live.at(-1) ?? roots.at(-1) ?? operations.at(-1);
80
+ const file = selected ? path.join(path.dirname(legacyFile), "operations", `${selected.id}.journal.json`) : legacyFile;
62
81
  let descriptor;
63
82
  try {
64
83
  const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
@@ -113,12 +132,34 @@ function redactCommandArgs(argv) {
113
132
  }
114
133
  return redacted;
115
134
  }
135
+ function pruneCompletedJournals(directory, operations) {
136
+ const completed = operations.filter((operation) => operation.status !== "running");
137
+ for (const operation of completed.slice(0, -100)) {
138
+ for (const suffix of [".json", ".journal.json", ".log"]) {
139
+ fs.rmSync(path.join(directory, `${operation.id}${suffix}`), { force: true });
140
+ }
141
+ }
142
+ }
116
143
  function tryBeginCommandJournal(command, argv) {
117
144
  try {
118
145
  const target = invocationTarget(argv);
119
146
  if (!fs.existsSync(target)) return void 0;
120
147
  const runtimeDir = optionValue(argv, "--runtime-dir");
121
- const file = commandJournalPath(target, runtimeDir);
148
+ const legacyFile = commandJournalPath(target, runtimeDir);
149
+ const taskDir = latestTaskDir(path.join(target, "temp", "tasks"));
150
+ const signalFile = taskDir && path.join(taskDir, "SIGNAL.json");
151
+ const liveTask = signalFile && fs.existsSync(signalFile) && JSON.parse(fs.readFileSync(signalFile, "utf8")).status === "running";
152
+ const directory = path.join(path.dirname(legacyFile), "operations");
153
+ const operations = readOperations(directory, (error) => process.stderr.write(`mm-harness: ignoring invalid prior operation: ${error.message}
154
+ `));
155
+ pruneCompletedJournals(directory, operations);
156
+ const inherited = process.env.MM_HARNESS_OPERATION_ID;
157
+ const parent = inherited && operations.find((operation2) => operation2.id === inherited && operation2.target === target && operation2.status === "running" && processIdentity(operation2.pid) === operation2.processStartedAt);
158
+ const operation = new OperationRecord(directory, command, target, {
159
+ ...parent ? { parentId: parent.id } : {},
160
+ ...liveTask ? { mirrorDirectory: path.join(taskDir, "artifacts", "operations") } : {}
161
+ });
162
+ const file = path.join(directory, `${operation.value.id}.journal.json`);
122
163
  const now = (/* @__PURE__ */ new Date()).toISOString();
123
164
  const record = {
124
165
  schemaVersion: 1,
@@ -132,7 +173,7 @@ function tryBeginCommandJournal(command, argv) {
132
173
  finishedAt: null
133
174
  };
134
175
  writeAtomic(file, record);
135
- return { file, record };
176
+ return { file, record, operation };
136
177
  } catch (error) {
137
178
  process.stderr.write(
138
179
  `mm-harness: resumability journal unavailable: ${errorMessage(error)}
@@ -155,14 +196,17 @@ function tryFinishCommandJournal(handle, exitCode) {
155
196
  `mm-harness: resumability journal could not finalize: ${errorMessage(error)}
156
197
  `
157
198
  );
199
+ } finally {
200
+ handle.operation.finish(exitCode);
158
201
  }
159
202
  }
160
203
  function writeAtomic(file, record) {
161
- fs.mkdirSync(path.dirname(file), { recursive: true });
204
+ ensureRuntimeDirectory(path.dirname(file));
162
205
  const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
163
206
  let descriptor;
164
207
  try {
165
208
  descriptor = fs.openSync(temporary, "wx", 384);
209
+ fs.fchmodSync(descriptor, 384);
166
210
  fs.writeFileSync(descriptor, `${JSON.stringify(record, null, 2)}
167
211
  `);
168
212
  fs.fsyncSync(descriptor);
@@ -175,9 +219,8 @@ function writeAtomic(file, record) {
175
219
  }
176
220
  }
177
221
  function invocationTarget(argv) {
178
- return path.resolve(
179
- optionValue(argv, "--target") ?? process.cwd()
180
- );
222
+ const target = path.resolve(optionValue(argv, "--target") ?? process.cwd());
223
+ return fs.existsSync(target) ? fs.realpathSync(target) : target;
181
224
  }
182
225
  function evidencePaths(argv) {
183
226
  const values = /* @__PURE__ */ new Set();
@@ -257,6 +300,8 @@ export {
257
300
  isSensitiveKey,
258
301
  readCommandJournal,
259
302
  recordCommandEvidence,
303
+ recordCommandOutput,
304
+ recordCommandStage,
260
305
  redactCommandArgs,
261
306
  redactStructuredValue,
262
307
  withCommandJournal
@@ -12,6 +12,7 @@ const MARK_SCRIPT = require2.resolve(
12
12
  const { resolveChecklistPaths } = require2(
13
13
  "@farmslot/agent-runtime/scripts/checklist-target.cjs"
14
14
  );
15
+ const { readSubtaskIndex, SubtaskRefusal } = require2("@farmslot/agent-runtime/scripts/subtask-unit.cjs");
15
16
  const USAGE = "mm-harness checklist <mark <task-dir> <step> | closeout <task-dir>> [options]";
16
17
  const CHECKLIST_VALUE_OPTIONS = /* @__PURE__ */ new Set([
17
18
  "--reason",
@@ -129,10 +130,10 @@ function checklistStepRequiresDiffPass(checklist, stepNumber) {
129
130
  const step = enumerateChecklistCheckboxes(checklist).find((item) => item.stepNumber === stepNumber);
130
131
  return step !== void 0 && /mm-harness check diff/u.test(step.rawLabel);
131
132
  }
132
- function checkDiffGateReady(taskDir, step) {
133
+ function checkDiffGateReady(taskDir, step, parentChecklist) {
133
134
  const stepNumber = numericMarkStep(step);
134
135
  if (stepNumber === null) return true;
135
- const { taskPath } = resolveChecklistPaths(taskDir);
136
+ const taskPath = parentChecklist ? path.join(taskDir, parentChecklist) : resolveChecklistPaths(taskDir).taskPath;
136
137
  let checklist = "";
137
138
  try {
138
139
  checklist = fs.readFileSync(taskPath, "utf8");
@@ -203,6 +204,16 @@ async function handleChecklist(argv) {
203
204
  return EXIT.usage;
204
205
  }
205
206
  if (preserveTerminalSignal(resolvedTaskDir, step)) return EXIT.ok;
207
+ if (step === "sub" && forwarded[2] === "complete") {
208
+ try {
209
+ const unit = readSubtaskIndex(resolvedTaskDir)?.units.find((unit2) => unit2.id === forwarded[1]);
210
+ if (unit && !checkDiffGateReady(resolvedTaskDir, String(unit.parent.stepNumber), unit.parent.checklist)) return EXIT.runtime;
211
+ } catch (error) {
212
+ if (!(error instanceof SubtaskRefusal)) throw error;
213
+ console.error(error.message);
214
+ return error.code;
215
+ }
216
+ }
206
217
  if (!checkDiffGateReady(resolvedTaskDir, step)) return EXIT.runtime;
207
218
  teachRecipeCompletion(resolvedTaskDir, step);
208
219
  const result = spawnSync(
@@ -12,6 +12,7 @@ import { FeatureFlagSpecError, fixtureFeatureFlags, parseRemoteFlagSpec, sameFea
12
12
  import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
13
13
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["dev", "force", "json"]);
14
14
  const FIXTURE_STREAM_TIMEOUT_MS = 12e4;
15
+ const FIXTURE_BRIDGE_READY_TIMEOUT_MS = 18e4;
15
16
  const MOBILE_SETUP_WALLET_TIMEOUT_MS = 36e4;
16
17
  async function handleFixtures(argv) {
17
18
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
@@ -251,9 +252,9 @@ async function handleFixturesLocked(argv) {
251
252
  const waitForBridge = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
252
253
  const waitResult = await spawnScriptStreaming(
253
254
  waitForBridge,
254
- ["--target", target, "--port", port, "--platform", platform],
255
+ ["--target", target, "--port", port, "--platform", platform, "--timeout-ms", String(FIXTURE_BRIDGE_READY_TIMEOUT_MS)],
255
256
  target,
256
- { timeoutMs: FIXTURE_STREAM_TIMEOUT_MS }
257
+ { timeoutMs: FIXTURE_BRIDGE_READY_TIMEOUT_MS + 1e4 }
257
258
  );
258
259
  if (waitResult.status !== 0) {
259
260
  resetError = new Error("Mobile bridge did not return after app data reset.");
@@ -558,13 +559,12 @@ async function fixturesFinalize(adapter, target, options, json) {
558
559
  }
559
560
  return exitCode;
560
561
  }
561
- function fixturesSync(adapter, target, json) {
562
+ async function fixturesSync(adapter, target, json) {
562
563
  const mmHarnessBin = path.join(runnerDir, "bin/mm-harness");
563
- const installResult = spawnScript(
564
+ const installResult = await spawnScriptStreaming(
564
565
  mmHarnessBin,
565
566
  ["install", "--adapter", adapter, "--target", target],
566
- target,
567
- json
567
+ target
568
568
  );
569
569
  if (installResult.status !== 0) {
570
570
  if (!json) {
@@ -577,7 +577,7 @@ function fixturesSync(adapter, target, json) {
577
577
  const syncArgs = ["--target", target];
578
578
  if (process.env.CDP_PORT) syncArgs.push("--cdp-port", process.env.CDP_PORT);
579
579
  if (process.env.RECIPE_SLOT_ID) syncArgs.push("--slot-id", process.env.RECIPE_SLOT_ID);
580
- const syncResult = spawnScript(syncFixtureSh, syncArgs, target, json);
580
+ const syncResult = await spawnScriptStreaming(syncFixtureSh, syncArgs, target);
581
581
  if (syncResult.status !== 0 || fixtureSummary(target, adapter).status !== "ready") {
582
582
  if (!json) console.error("\u2717 no usable wallet fixture source was found.\n Next: mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
583
583
  return EXIT.runtime;
@@ -381,6 +381,26 @@ async function handleLaunchLocked(argv, stream) {
381
381
  originalError: attempt.output.trim() || void 0
382
382
  });
383
383
  }
384
+ if (adapter === "mobile" && attempt.output.includes("open-device: Android device is locked.")) {
385
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
386
+ code: "MOBILE_DEVICE_LOCKED",
387
+ message: "the selected Android device is locked.",
388
+ recoverable: false,
389
+ userAction: "Unlock the selected Android device, then retry the launch.",
390
+ exitCode: EXIT.runtime,
391
+ originalError: attempt.output.trim()
392
+ });
393
+ }
394
+ if (adapter === "mobile" && /open-device: Android (?:lock state unavailable|display preparation failed)\./u.test(attempt.output)) {
395
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
396
+ code: "MOBILE_DEVICE_UI_UNAVAILABLE",
397
+ message: "the selected Android display could not be prepared.",
398
+ recoverable: false,
399
+ userAction: "Check adb access and the selected device screen, then retry the launch.",
400
+ exitCode: EXIT.runtime,
401
+ originalError: attempt.output.trim()
402
+ });
403
+ }
384
404
  if (heal === "off") {
385
405
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
386
406
  code: "LAUNCH_FAILED",
@@ -1,4 +1,7 @@
1
+ import { acquireCheckoutLock, trackCheckoutChild } from "../checkout-lock.js";
2
+ import { recordCommandStage } from "../command-journal.js";
1
3
  import { spawn } from "node:child_process";
4
+ import { OperationOutputTail } from "@farmslot/recipe-harness/runtime/operation";
2
5
  import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import {
@@ -168,18 +171,19 @@ function spawnStep(executable, args, target) {
168
171
  // stream a recorded readiness step captures.
169
172
  env: { ...process.env, MM_HARNESS_NO_UPDATE_CHECK: "1" }
170
173
  });
174
+ if (child.pid) trackCheckoutChild(target, child.pid);
171
175
  let stdout = "";
172
- let stderr = "";
176
+ const stderr = new OperationOutputTail();
173
177
  child.stdout?.setEncoding("utf8");
174
178
  child.stderr?.setEncoding("utf8");
175
179
  child.stdout?.on("data", (chunk) => {
176
180
  stdout += chunk;
177
181
  });
178
182
  child.stderr?.on("data", (chunk) => {
179
- stderr += chunk;
183
+ stderr.append(chunk);
180
184
  });
181
- child.on("error", (error) => resolve({ error, status: null, stdout, stderr }));
182
- child.on("close", (status) => resolve({ status, stdout, stderr }));
185
+ child.on("error", (error) => resolve({ error, status: null, stdout, stderr: stderr.toString() }));
186
+ child.on("close", (status) => resolve({ status, stdout, stderr: stderr.toString() }));
183
187
  });
184
188
  }
185
189
  async function runStep(executable, target, artifactsDir, id, args, { json, onSpawn }) {
@@ -305,6 +309,22 @@ function resolvePlatform(requested, target) {
305
309
  );
306
310
  }
307
311
  async function handlePrepare(argv) {
312
+ const { options } = parseArgs(argv, "prepare");
313
+ const target = targetPath(options);
314
+ if (!fs.existsSync(target)) return handlePrepareLocked(argv);
315
+ const lock = acquireCheckoutLock(target, "prepare");
316
+ if ("message" in lock) {
317
+ if (optionFlag(options, "json")) console.log(JSON.stringify({ status: "fail", error: { code: "SANDBOX_BUSY", message: lock.message }, exitCode: EXIT.runtime }));
318
+ else console.error(`${lock.message} Next: inspect the owning operation with mm-harness status --watch`);
319
+ return EXIT.runtime;
320
+ }
321
+ try {
322
+ return await handlePrepareLocked(argv);
323
+ } finally {
324
+ lock.release();
325
+ }
326
+ }
327
+ async function handlePrepareLocked(argv) {
308
328
  const { options } = parseArgs(argv, "prepare");
309
329
  const json = optionFlag(options, "json");
310
330
  const target = targetPath(options);
@@ -372,6 +392,7 @@ async function handlePrepare(argv) {
372
392
  const step = await runStep(executable, target, artifactsDir, id, args, {
373
393
  json,
374
394
  onSpawn: (stepStartedAt) => {
395
+ recordCommandStage(id);
375
396
  activeStep = { id, startedAt: new Date(stepStartedAt).toISOString() };
376
397
  writeProgress();
377
398
  view?.start(id, stepStartedAt);
@@ -20,7 +20,8 @@ import {
20
20
  import {
21
21
  importRecipeHarness,
22
22
  importRecipeProtocol,
23
- runnerDir
23
+ runnerDir,
24
+ recipeRuntimePath
24
25
  } from "../paths.js";
25
26
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
26
27
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
@@ -277,7 +278,11 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
277
278
  recipeDocument: recipeDocument ?? recipe,
278
279
  ...absoluteRecipePath ? { recipePath: absoluteRecipePath } : {},
279
280
  ...librarySources ? { librarySources } : {},
280
- excludedProductRoots: [absoluteArtifactsDir],
281
+ excludedProductRoots: [
282
+ absoluteArtifactsDir,
283
+ recipeRuntimePath(projectRoot, "operations"),
284
+ recipeRuntimePath(projectRoot, "lock-members")
285
+ ],
281
286
  helperPaths: commandHelperPaths(recipeDocument ?? recipe, projectRoot)
282
287
  };
283
288
  const startProvenance = await captureExecutionProvenance(
@@ -416,6 +421,7 @@ function runtimeRecipeDocument(recipe, absoluteRecipePath) {
416
421
  function recipeProcessEnvironment() {
417
422
  const env = { ...process.env };
418
423
  delete env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
424
+ delete env.MM_HARNESS_OPERATION_ID;
419
425
  return env;
420
426
  }
421
427
  function executionLibrarySources(sources, invocationTrust) {
@@ -1,4 +1,5 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { checkoutBusyOut, spawnScriptStreaming } from "./shared.js";
2
+ import { acquireCheckoutLock } from "../checkout-lock.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import {
@@ -23,7 +24,18 @@ import {
23
24
  usageError
24
25
  } from "./parse-args.js";
25
26
  import { FeatureFlagSpecError, parseRemoteFlagSpec } from "../feature-flags.js";
26
- async function handleRuntimeLaunch({ options }) {
27
+ async function handleRuntimeLaunch(args) {
28
+ const target = targetPath(args.options);
29
+ if (optionFlag(args.options, "help") || !fs.existsSync(target)) return handleRuntimeLaunchLocked(args);
30
+ const lock = acquireCheckoutLock(target, "runtime-launch");
31
+ if ("message" in lock) return checkoutBusyOut(optionFlag(args.options, "json"), "runtime-launch", lock.message, lock.path);
32
+ try {
33
+ return await handleRuntimeLaunchLocked(args);
34
+ } finally {
35
+ lock.release();
36
+ }
37
+ }
38
+ async function handleRuntimeLaunchLocked({ options }) {
27
39
  if (optionFlag(options, "help")) {
28
40
  console.log(`mm-harness runtime-launch --adapter <extension|mobile> --target <checkout> [flags]
29
41
 
@@ -186,11 +198,11 @@ async function handleRuntimeLaunch({ options }) {
186
198
  cdpPort
187
199
  }));
188
200
  }
189
- const result = spawnSync(command[0], command.slice(1), {
190
- cwd: target,
191
- encoding: "utf8",
192
- maxBuffer: 5 * 1024 * 1024,
193
- stdio: jsonMode ? ["ignore", "pipe", "pipe"] : "inherit"
201
+ const result = await spawnScriptStreaming(command[0], command.slice(1), target, {
202
+ forward: jsonMode ? "stderr" : "original",
203
+ stdin: jsonMode ? "ignore" : "inherit",
204
+ capture: false,
205
+ stage: mode
194
206
  });
195
207
  const summaryPath = path.join(artifactsDir, "summary.json");
196
208
  const summary = readJsonIfExists(summaryPath);