@deeeed/metamask-harness 0.67.1 → 0.69.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.69.0 - 2026-09-30
6
+
7
+ ### Changed
8
+
9
+ - Group and color recipe/action listings by domain, show library origins and paths separately, and support domain/source filters and domain/name/library sorting. Keep recipe parameter details in `--describe` and `--json`.
10
+
11
+ ### Fixed
12
+
13
+ - Accept repeated `task init --acceptance` flags at the public CLI boundary so Recipe Cook can record authored criteria in the task document and structured handoff.
14
+ - Report Mobile launch dependency blockers once with the dependency repair command; preserve the readiness cause instead of labelling it app logic or adding a conflicting doctor hint.
15
+
16
+ ## 0.68.0 - 2026-09-29
17
+
18
+ ### Changed
19
+
20
+ - Verify Mobile readiness with one combined status/bridge recipe, retaining device-discovery retries and RPC checks; recognize Lite screens when the release hides the Pro toggle.
21
+ - Restart Metro when build environment inputs change and select the matching transform-cache generation without clearing caches.
22
+ - Forward child checklist progress through the public CLI while retaining the owning step's diff-validation gate.
23
+ - Keep operation and checkout ownership records readable under restrictive umasks, and exclude observation updates from product provenance.
24
+ - Retain command-specific logs and stage observations in `status --watch`; preserve nested operation and checkout ownership across long builds. Pin protocol 0.33.0, recipe-harness 0.21.1, agent-runtime 0.15.1 and expo-recipe 0.12.2 for a consistent runtime dependency tree.
25
+
5
26
  ## 0.67.1 - 2026-09-29
6
27
 
7
28
  ### Fixed
@@ -84,8 +84,12 @@ module.exports = async function harnessMetroConfig(baseConfig) {
84
84
  `^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
85
85
  blockListFlags(existingBlockList),
86
86
  );
87
+ // Inline environment values affect transformed JavaScript. Keep separate
88
+ // cache generations for those inputs instead of deleting the shared cache.
89
+ const envFingerprint = process.env.MM_HARNESS_METRO_ENV_FINGERPRINT;
87
90
  return {
88
91
  ...productConfig,
92
+ ...(envFingerprint ? { cacheVersion: `${productConfig.cacheVersion ?? ''}:mm-env:${envFingerprint}` } : {}),
89
93
  resolver: {
90
94
  ...productConfig.resolver,
91
95
  blockList: appendBlockList(existingBlockList, tempPattern),
@@ -10,6 +10,7 @@
10
10
  # Inputs:
11
11
  # --target <metamask-mobile dir> (default $PWD)
12
12
  # --port <number> (default: WATCHER_PORT env, else METRO_PORT, else 8081)
13
+ # --restart restart Metro while retaining its transform cache
13
14
  # --clear reset Metro bundler cache before starting
14
15
  # Outputs:
15
16
  # Progress on stderr; exit 0 Metro ready; 1 failed to start; 2 bad args.
@@ -20,16 +21,19 @@ set -uo pipefail
20
21
  TARGET="$PWD"
21
22
  PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
22
23
  CLEAR=false
24
+ RESTART=false
23
25
 
24
26
  while [ "$#" -gt 0 ]; do
25
27
  case "$1" in
26
28
  --target) TARGET="$2"; shift 2 ;;
27
29
  --port) PORT="$2"; shift 2 ;;
28
30
  --clear) CLEAR=true; shift ;;
31
+ --restart) RESTART=true; shift ;;
29
32
  -h|--help)
30
- printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--clear]\n'
33
+ printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--restart] [--clear]\n'
31
34
  printf ' --target MetaMask Mobile checkout directory\n'
32
35
  printf ' --port Metro port (default: WATCHER_PORT env or 8081)\n'
36
+ printf ' --restart restart while preserving the transform cache\n'
33
37
  printf ' --clear restart with cleared cache\n'
34
38
  exit 0
35
39
  ;;
@@ -60,6 +64,9 @@ export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
60
64
  export MM_HARNESS_METRO_BASE_CONFIG="$PRODUCT_METRO_CONFIG"
61
65
  export EXPO_OVERRIDE_METRO_CONFIG="$HARNESS_METRO_CONFIG"
62
66
  MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT} MM_HARNESS_METRO_PROJECT_ROOT MM_HARNESS_METRO_BASE_CONFIG EXPO_OVERRIDE_METRO_CONFIG"
67
+ if [ -n "${MM_HARNESS_METRO_ENV_FINGERPRINT:-}" ]; then
68
+ MOBILE_METRO_REQUIRED_ENV="$MOBILE_METRO_REQUIRED_ENV MM_HARNESS_METRO_ENV_FINGERPRINT"
69
+ fi
63
70
 
64
71
  # RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
65
72
  # sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
@@ -212,8 +219,12 @@ fi
212
219
 
213
220
  # Decide whether to (re)start or skip.
214
221
  if metro_ready; then
215
- if [ "$CLEAR" = true ]; then
216
- printf 'Restarting Metro on port %s with cleared cache\n' "$PORT" >&2
222
+ if [ "$CLEAR" = true ] || [ "$RESTART" = true ]; then
223
+ if [ "$CLEAR" = true ]; then
224
+ printf 'Restarting Metro on port %s with cleared cache\n' "$PORT" >&2
225
+ else
226
+ printf 'Restarting Metro on port %s with its transform cache preserved\n' "$PORT" >&2
227
+ fi
217
228
  stop_metro_listener || exit 1
218
229
  rm -f "$PID_FILE"
219
230
  elif metro_listener_valid; then
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readiness";
4
4
  import { resolveAndExportMobileToolPaths } from "../../../library/actions/mobile/platform/tool-paths.mjs";
5
5
  import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
6
+ import { shellQuote } from "../../commands/parse-args.js";
6
7
  import { runnerDir } from "../../paths.js";
7
8
  import {
8
9
  decideMobileReadiness,
@@ -51,12 +52,14 @@ async function prepareMobile(target, opts = {}) {
51
52
  });
52
53
  if (report.decision === "blocked") {
53
54
  const reasons = report.reasons.join(" ");
54
- const next = report.userAction ?? "fix the bundle error in app code before retrying recipe up.";
55
+ const next = report.userAction ?? `mm-harness logs --target ${shellQuote(target)} --full`;
55
56
  const msg = `mobile prepare blocked: ${reasons}
56
57
  Next: ${next}`;
57
- if (!json) process.stderr.write(`${msg}
58
- `);
59
- return { status: EXIT.runtime, output: msg };
58
+ return {
59
+ status: EXIT.runtime,
60
+ output: msg,
61
+ readinessBlock: { reasonCode: report.reasonCode, message: reasons, userAction: next }
62
+ };
60
63
  }
61
64
  const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
62
65
  const rebuildNative = preflightMode === "rebuild-native" || preflightMode === "clean";
@@ -270,7 +273,8 @@ async function dispatchActionSequence(requestedActions, target, platform, json,
270
273
  platform,
271
274
  json,
272
275
  preflightMode,
273
- watcherPort
276
+ watcherPort,
277
+ pendingMetroEnvFingerprint
274
278
  );
275
279
  if (result.status !== 0) return result;
276
280
  if (action.id === "yarn-setup") recordDepsBaseline(resolved);
@@ -378,7 +382,7 @@ function enableIosAccessibility(json) {
378
382
  return false;
379
383
  }
380
384
  }
381
- async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort) {
385
+ async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort, metroEnvFingerprint) {
382
386
  const cwd = action.cwd ?? target;
383
387
  switch (action.id) {
384
388
  case "yarn-setup": {
@@ -396,7 +400,8 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
396
400
  return spawnScriptStreaming(
397
401
  leaf,
398
402
  withWatcherPort(["--target", cwd, ...extra], watcherPort),
399
- target
403
+ target,
404
+ { MM_HARNESS_METRO_ENV_FINGERPRINT: metroEnvFingerprint ?? mobileMetroEnvCheck(path.resolve(target)).fingerprint }
400
405
  );
401
406
  }
402
407
  case "start-console-forwarder": {
@@ -12,6 +12,7 @@ import {
12
12
  } from "@farmslot/recipe-harness/runtime/log-analysis";
13
13
  import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
14
14
  import { color } from "../../cli-color.js";
15
+ import { shellQuote } from "../../commands/parse-args.js";
15
16
  import { recipeRuntimePath } from "../../paths.js";
16
17
  import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
17
18
  import { mobileProductMarkers } from "./deps-markers.js";
@@ -149,7 +150,7 @@ const launchActions = (target, clearMetro = false) => {
149
150
  return actions;
150
151
  };
151
152
  function depsInstallCommand(resolved) {
152
- return fs.existsSync(path.join(resolved, "node_modules")) ? "`yarn setup:expo --no-build-ios --no-build-android`" : "`yarn install --immutable && yarn setup:expo --no-build-ios --no-build-android`";
153
+ return fs.existsSync(path.join(resolved, "node_modules")) ? "yarn setup:expo --no-build-ios --no-build-android" : "yarn install --immutable && yarn setup:expo --no-build-ios --no-build-android";
153
154
  }
154
155
  async function decideMobileReadiness(target, options = {}) {
155
156
  const resolved = path.resolve(target);
@@ -157,18 +158,15 @@ async function decideMobileReadiness(target, options = {}) {
157
158
  const fast = options.preflightMode === "fast";
158
159
  const report = await computeMobileReadiness(resolved, options, fast);
159
160
  if (fast && report.decision === "install") {
160
- const standalone = depsInstallCommand(resolved);
161
161
  return {
162
162
  ...report,
163
163
  decision: "blocked",
164
164
  reasonCode: "deps-not-ready",
165
165
  reasons: [
166
- "Fast preflight found dependencies not ready and does not install them (the orchestrator deps phase owns installation).",
167
- ...report.reasons
166
+ ...report.reasons,
167
+ "Quick launch does not install dependencies."
168
168
  ],
169
- userAction: `1. Standalone checkout \u2014 run ${standalone} in the checkout.
170
- 2. Orchestrated slot \u2014 run its deps/prepare phase.
171
- After the selected action succeeds, re-run \`mm-harness launch <platform>\`.`,
169
+ userAction: `cd ${shellQuote(resolved)} && ${depsInstallCommand(resolved)}`,
172
170
  actions: []
173
171
  };
174
172
  }
@@ -184,12 +182,14 @@ async function decideMobileReadiness(target, options = {}) {
184
182
  env.status === "missing" ? "Metro environment has no successful bundle baseline; restart Metro to load the current environment without clearing its transform cache." : "Metro environment inputs changed since the successful bundle baseline; restart Metro without clearing its transform cache. Pass --clear-metro only if stale transforms are observed.",
185
183
  ...report.reasons
186
184
  ],
187
- actions: report.decision === "ready" ? launchActions(resolved) : report.actions
185
+ actions: (report.decision === "ready" ? launchActions(resolved) : report.actions).map(
186
+ (action) => action.id === "start-metro" && !action.argv?.includes("--clear") && !action.argv?.includes("--restart") ? { ...action, argv: [...action.argv ?? [], "--restart"] } : action
187
+ )
188
188
  };
189
189
  }
190
- function writeDepsNote(resolved) {
190
+ function writeDepsNote() {
191
191
  const stream = process.stderr;
192
- const body = `package.json/yarn.lock are newer than the node_modules install markers and no install baseline is recorded yet, so the installed node_modules are used as they are. Pulling commits refreshes those timestamps without changing dependencies, and a required-dependency presence check still runs, so this on its own is not evidence that anything drifted. Act only if you know dependencies changed: run ${depsInstallCommand(resolved)} once \u2014 that refreshes the markers, and \`mm-harness prepare\` records a baseline when it runs the install, so this note stops.`;
192
+ const body = "No install baseline is recorded. Required package checks passed; timestamps alone do not establish dependency drift. Reusing the installed dependencies.";
193
193
  stream.write(
194
194
  `
195
195
  ${color("warn", "mm-harness note (deps):", { stream })} ${color("dim", body, { stream })}
@@ -204,7 +204,6 @@ async function computeMobileReadiness(resolved, options, fast) {
204
204
  let deps = rawDeps;
205
205
  if (rawDeps.status === "stale" && !rawDeps.hasBaseline) {
206
206
  deps = { installed: rawDeps.installed, status: "current", hasBaseline: false };
207
- writeDepsNote(resolved);
208
207
  }
209
208
  if (deps.status === "current") {
210
209
  const absent = missingRequiredDeps(resolved);
@@ -323,6 +322,7 @@ async function computeMobileReadiness(resolved, options, fast) {
323
322
  actions: installActions(resolved)
324
323
  };
325
324
  }
325
+ if (rawDeps.status === "stale" && !rawDeps.hasBaseline) writeDepsNote();
326
326
  if (metro.status === "down") {
327
327
  return {
328
328
  schemaVersion: 1,
@@ -1,7 +1,8 @@
1
- import { execFileSync, spawnSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { spawnScriptStreaming } from "../../commands/shared.js";
5
6
  import { readRuntimeContextField } from "../../harness.js";
6
7
  import { recipeHarnessPath, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "../../paths.js";
7
8
  const USAGE = "Usage: mm-harness verify --adapter mobile -- [--target <metamask-mobile>] [--artifacts-dir <dir>] [--static-only] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--no-auto-start]";
@@ -11,19 +12,6 @@ const AUTO_START_TRUE = ["1", "true", "TRUE", "True", "yes", "YES", "Yes", "on",
11
12
  const AUTO_START_FALSE = ["0", "false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", ""];
12
13
  const AUTO_START_REFUSAL = "Mobile auto-start is not allowed from product-local scripts. Start or prepare the app through the runner/slot runtime, then rerun verify with --no-auto-start.";
13
14
  const EVM_READINESS_EXPRESSION = "(async function(){try{var engine=globalThis.Engine;var controller=engine&&engine.context&&engine.context.NetworkController;if(!controller||typeof controller.getSelectedNetworkClient!=='function')throw new Error('NetworkController selected client is unavailable');var client=controller.getSelectedNetworkClient();if(!client||!client.provider||typeof client.provider.request!=='function')throw new Error('selected EVM provider is unavailable');var result=await client.provider.request({method:'eth_blockNumber'});if(typeof result!=='string'||!/^0x[0-9a-f]+$/i.test(result))throw new Error('eth_blockNumber returned an invalid result');return {ok:true,result:result};}catch(error){return {ok:false,error:error&&(error.message||String(error))};}})()";
14
- const STATUS_SMOKE_RECIPE = `{
15
- "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
16
- "title": "Mobile runner status smoke",
17
- "description": "Checks that the installed runner can read Mobile app status before live verification.",
18
- "workflow": {
19
- "entry": "status",
20
- "nodes": {
21
- "status": { "action": "app.status", "intent": "Read Mobile app status through the runner-owned bridge", "next": "done" },
22
- "done": { "action": "end", "status": "pass" }
23
- }
24
- }
25
- }
26
- `;
27
15
  const LIVE_SMOKE_RECIPE = `{
28
16
  "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
29
17
  "title": "Mobile v1 runner live bridge smoke",
@@ -241,30 +229,21 @@ function mobilePortHolder(port) {
241
229
  note: "HTTP /status probing is skipped during live verify because the React Native bridge is the authoritative readiness path for Mobile."
242
230
  };
243
231
  }
244
- function spawnToLog(logPath, command, args, options = {}) {
232
+ async function spawnToLog(logPath, command, args, options = {}) {
245
233
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
246
- const fd = fs.openSync(logPath, "w");
247
- try {
248
- const result = spawnSync(command, args, {
249
- cwd: options.cwd,
250
- env: options.env ?? process.env,
251
- stdio: ["ignore", fd, fd],
252
- ...options.timeoutMs === void 0 ? {} : { timeout: options.timeoutMs }
253
- });
254
- if (result.error) {
255
- const timedOut = result.error.code === "ETIMEDOUT";
256
- fs.writeSync(
257
- fd,
258
- timedOut ? `Timed out after ${(options.timeoutMs ?? 0) / 1e3}s: ${[command, ...args].join(" ")}
259
- ` : `${result.error.message}
260
- `
261
- );
262
- return timedOut ? 124 : 127;
263
- }
264
- return result.status ?? 1;
265
- } finally {
266
- fs.closeSync(fd);
234
+ const result = await spawnScriptStreaming(command, args, options.cwd ?? process.cwd(), {
235
+ env: Object.fromEntries(Object.entries(options.env ?? process.env).filter((entry) => entry[1] !== void 0)),
236
+ timeoutMs: options.timeoutMs,
237
+ forward: "none",
238
+ stage: path.basename(logPath, ".log")
239
+ });
240
+ fs.writeFileSync(logPath, result.output);
241
+ if (result.error) {
242
+ fs.appendFileSync(logPath, `${result.error.message}
243
+ `);
244
+ return 127;
267
245
  }
246
+ return result.timedOut ? 124 : result.status;
268
247
  }
269
248
  function readJsonFile(file) {
270
249
  try {
@@ -372,42 +351,6 @@ async function runMobileVerify(argv, io = defaultIo) {
372
351
  deviceTarget = platform === "android" ? resolveJsEnvValue(target, "ADB_SERIAL") || resolveJsEnvValue(target, "ANDROID_DEVICE") : resolveJsEnvValue(target, "IOS_SIMULATOR");
373
352
  }
374
353
  const deviceArgs = deviceTarget ? ["--device", deviceTarget] : [];
375
- const statusSmokeRecipe = path.join(artifacts, "mobile-status-smoke.recipe.json");
376
- const precheckLog = path.join(artifacts, "logs/app-status-precheck.log");
377
- let liveRuntimeOk = false;
378
- for (let attempt = 1; attempt <= 5; attempt += 1) {
379
- fs.writeFileSync(statusSmokeRecipe, STATUS_SMOKE_RECIPE);
380
- const code = spawnToLog(precheckLog, runnerBin, [
381
- "run",
382
- statusSmokeRecipe,
383
- "--adapter",
384
- "mobile",
385
- "--target",
386
- target,
387
- ...deviceArgs,
388
- "--watcher-port",
389
- port,
390
- "--artifacts-dir",
391
- path.join(artifacts, "runner-status-smoke"),
392
- "--json"
393
- ], { cwd: target });
394
- if (code === 0) {
395
- liveRuntimeOk = true;
396
- break;
397
- }
398
- const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
399
- fs.readFileSync(precheckLog, "utf8")
400
- );
401
- if (attempt < 5 && deviceNotFound) {
402
- io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
403
- await sleep(1e3);
404
- continue;
405
- }
406
- break;
407
- }
408
- if (!liveRuntimeOk && autoStart) io.err(AUTO_START_REFUSAL);
409
- checks.push(liveRuntimeOk ? { name: "mobile runtime controllable precheck", status: "pass" } : { name: "mobile runtime controllable precheck", status: "fail", detail: "see logs/app-status-precheck.log" });
410
- if (!liveRuntimeOk) status = "fail";
411
354
  fixtureStatus = mobileFixtureStatus(target);
412
355
  fs.writeFileSync(path.join(artifacts, "logs/fixture-status.json"), `${JSON.stringify(fixtureStatus)}
413
356
  `);
@@ -423,12 +366,12 @@ async function runMobileVerify(argv, io = defaultIo) {
423
366
  portHolder = mobilePortHolder(port);
424
367
  fs.writeFileSync(path.join(artifacts, "logs/port-holder.json"), `${JSON.stringify(portHolder)}
425
368
  `);
426
- if (status !== "fail" && portHolder.listening) {
369
+ if (!checks.some((check) => check.status === "fail") && portHolder.listening) {
427
370
  const waiter = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
428
371
  if (!fs.existsSync(waiter)) {
429
372
  addNote("Mobile bridge settle skipped: wait-for-bridge.sh not found under the runner adapters; reinstall the runner.");
430
373
  } else {
431
- const waiterCode = spawnToLog(
374
+ const waiterCode = await spawnToLog(
432
375
  path.join(artifacts, "logs/wait-for-bridge.log"),
433
376
  "bash",
434
377
  [waiter, "--target", target, "--port", port, "--platform", platform],
@@ -446,34 +389,45 @@ async function runMobileVerify(argv, io = defaultIo) {
446
389
  fs.writeFileSync(liveSmokeRecipe, LIVE_SMOKE_RECIPE);
447
390
  const iosSimulatorResolved = resolveJsEnvValue(target, "IOS_SIMULATOR");
448
391
  const adbSerialResolved = resolveJsEnvValue(target, "ADB_SERIAL");
449
- const liveSmokeCode = spawnToLog(
450
- path.join(artifacts, "logs/runner-live-smoke.log"),
451
- runnerBin,
452
- [
453
- "run",
454
- liveSmokeRecipe,
455
- "--adapter",
456
- "mobile",
457
- "--target",
458
- target,
459
- ...deviceArgs,
460
- "--watcher-port",
461
- port,
462
- "--artifacts-dir",
463
- path.join(artifacts, "runner-live-smoke"),
464
- "--json"
465
- ],
466
- {
467
- cwd: target,
468
- env: {
469
- ...process.env,
470
- METAMASK_RECIPE_AUTO_HUD: "0",
471
- IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
472
- ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
473
- ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
392
+ let liveSmokeCode = 1;
393
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
394
+ liveSmokeCode = await spawnToLog(
395
+ path.join(artifacts, "logs/runner-live-smoke.log"),
396
+ runnerBin,
397
+ [
398
+ "run",
399
+ liveSmokeRecipe,
400
+ "--adapter",
401
+ "mobile",
402
+ "--target",
403
+ target,
404
+ ...deviceArgs,
405
+ "--watcher-port",
406
+ port,
407
+ "--artifacts-dir",
408
+ path.join(artifacts, "runner-live-smoke"),
409
+ "--json"
410
+ ],
411
+ {
412
+ cwd: target,
413
+ env: {
414
+ ...process.env,
415
+ METAMASK_RECIPE_AUTO_HUD: "0",
416
+ IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
417
+ ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
418
+ ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
419
+ }
474
420
  }
475
- }
476
- );
421
+ );
422
+ if (liveSmokeCode === 0) break;
423
+ const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
424
+ fs.readFileSync(path.join(artifacts, "logs/runner-live-smoke.log"), "utf8")
425
+ );
426
+ if (!deviceNotFound || attempt === 5) break;
427
+ io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
428
+ await sleep(1e3);
429
+ }
430
+ if (liveSmokeCode !== 0 && autoStart) io.err(AUTO_START_REFUSAL);
477
431
  if (liveSmokeCode === 0) {
478
432
  checks.push({ name: "runner v1 live bridge smoke", status: "pass" });
479
433
  } else {
@@ -488,7 +442,7 @@ async function runMobileVerify(argv, io = defaultIo) {
488
442
  fs.writeFileSync(evmLog, `Mobile bridge is missing at ${bridge}
489
443
  `);
490
444
  } else {
491
- const evmCode = spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
445
+ const evmCode = await spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
492
446
  timeoutMs: 25e3,
493
447
  env: {
494
448
  ...process.env,
@@ -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
  };
@@ -168,6 +168,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
168
168
  "--title": value(),
169
169
  "--task-text": value(),
170
170
  "--task-file": value(),
171
+ "--acceptance": value(),
171
172
  "--ticket": value(),
172
173
  "--source-ref": value(),
173
174
  "--surface": value(),
@@ -241,6 +242,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
241
242
  call: {
242
243
  options: options(HELP, JSON, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
243
244
  "--list": bool(),
245
+ "--domain": value(),
246
+ "--source": value(),
247
+ "--sort": value(["domain", "name", "library"]),
244
248
  "--arg": value()
245
249
  }),
246
250
  positionals: [{ label: "action" }],
@@ -253,6 +257,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
253
257
  run: {
254
258
  options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, RUN_FUNDING, {
255
259
  "--list": bool(),
260
+ "--domain": value(),
261
+ "--source": value(),
262
+ "--sort": value(["domain", "name", "library"]),
256
263
  "--plan": bool(),
257
264
  "--proof": bool(),
258
265
  "--describe": bool(),
@@ -425,6 +432,7 @@ function validatePublicInvocation(argv, examples = {}) {
425
432
  }
426
433
  const { name, contract } = resolved;
427
434
  const tokens = argv.slice(1);
435
+ if (name === "checklist" && tokens[0] === "mark" && tokens[1] && !tokens[1].startsWith("-") && tokens[2] === "sub") return null;
428
436
  const positionals = [];
429
437
  const seenOptions = /* @__PURE__ */ new Set();
430
438
  const validOptions = Object.keys(contract.options);