mtech-ai-local-runner 0.1.37 → 0.1.39

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/README.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 0.1.39: keep the first failure when executor cleanup is unconfirmed
2
+
3
+ A job whose Codex executor could not confirm its cleanup was suspended with only `EXECUTION_UNACKNOWLEDGED` in the
4
+ daemon log: the unconfirmed stop was rethrown before the failure was logged. The daemon now writes the stage that
5
+ failed first (P1 runs use `p1_freeze`, `p1_verify` and `p1_receipts`) and a second `execution_failed` line with
6
+ stage `executor_cleanup`, whose fixed `cleanup_*` observations name the cleanup step that failed or was still
7
+ pending at the deadline. Suspension and manual reconciliation are unchanged.
8
+
9
+ ## 0.1.38: find the Codex app shipped as ChatGPT.app
10
+
11
+ 0.1.37 looked only for `Codex.app`, so a Mac whose Codex app is the ChatGPT app (`/Applications/ChatGPT.app`,
12
+ bundle id `com.openai.codex`) reported `chatgpt: null` and got no [Open in ChatGPT] button. The runner now also
13
+ takes `ChatGPT.app`, in `/Applications` or `~/Applications`, when its Info.plist registers the `codex` URL
14
+ scheme. It reads the plist with `/usr/bin/plutil` (no shell, five-second timeout). The plain ChatGPT chat app
15
+ registers no `codex` scheme and is still not taken. Nothing else changes.
16
+
1
17
  ## 0.1.37: open my AI session from Slack
2
18
 
3
19
  The daemon runs a second loop beside the job poller. When its employee presses [Open in Claude] or
@@ -1,4 +1,4 @@
1
- import { executionError } from '../../safety/execution-failure.js';
1
+ import { executionError, unconfirmedCleanupError, cleanupObservations } from '../../safety/execution-failure.js';
2
2
  import { UnsupportedSupervisorCommandError } from '../../safety/unsupported-supervisor-command.js';
3
3
  import { CodexExecLaunchHost } from './exec-launch-host.js';
4
4
  import { requireSecurityRequirements } from '../../safety/security-requirements.js';
@@ -250,9 +250,27 @@ export class CodexAdapter {
250
250
  if (this.cancelPromise)
251
251
  return this.cancelPromise;
252
252
  this.cancelled = true;
253
- let finish, reject, failed = false;
253
+ let finish, reject;
254
+ // Fixed step names only: which cleanup step failed, or was still pending at the deadline.
255
+ const failed = new Set(), pending = new Set();
256
+ const unconfirmed = () => unconfirmedCleanupError('Provider process group cleanup could not be confirmed', [...failed, ...pending]);
257
+ const step = async (name, work) => {
258
+ const waiting = `cleanup_${name}_pending`;
259
+ pending.add(waiting);
260
+ try {
261
+ await work();
262
+ }
263
+ catch (error) {
264
+ failed.add(`cleanup_${name}_failed`);
265
+ for (const inner of cleanupObservations(error))
266
+ failed.add(inner);
267
+ }
268
+ finally {
269
+ pending.delete(waiting);
270
+ }
271
+ };
254
272
  this.cancelPromise = new Promise((resolve, rejectPromise) => { finish = resolve; reject = rejectPromise; });
255
- const timer = setTimeout(() => reject(new Error('Provider process group cleanup could not be confirmed')), 6000);
273
+ const timer = setTimeout(() => { failed.add('cleanup_timeout'); reject(unconfirmed()); }, 6000);
256
274
  // Let the official owner freeze its observations before external revocation.
257
275
  // Known owner cleanup and endpoint closure start without waiting for a late
258
276
  // retained handle; only confirmation waits for its acquisition/disposal.
@@ -262,68 +280,53 @@ export class CodexAdapter {
262
280
  officialStop = this.ownedExec?.cancel();
263
281
  }
264
282
  catch {
265
- failed = true;
283
+ failed.add('cleanup_owner_stop_failed');
266
284
  }
267
285
  }
268
286
  try {
269
287
  this.options.revokeSecurity?.();
270
288
  }
271
289
  catch {
272
- failed = true;
290
+ failed.add('cleanup_security_revoke_failed');
273
291
  }
274
292
  try {
275
293
  this.tools?.revoke();
276
294
  }
277
295
  catch {
278
- failed = true;
296
+ failed.add('cleanup_tools_revoke_failed');
279
297
  }
280
298
  this.startupAbort.abort();
281
- const ownerStop = (async () => {
299
+ const ownerStop = step('owner_stop', async () => {
282
300
  if (this.usingOfficialExec) {
283
- try {
284
- await officialStop;
285
- }
286
- catch {
287
- failed = true;
288
- }
301
+ await officialStop;
302
+ return;
289
303
  }
290
- else {
291
- try {
292
- this.handle?.stdin.end();
293
- }
294
- catch {
295
- failed = true;
296
- }
297
- try {
298
- if (this.handle?.terminateOwned)
299
- await this.handle.terminateOwned();
300
- else
301
- this.handle?.kill('SIGKILL');
302
- }
303
- catch {
304
- failed = true;
305
- }
304
+ let stopFailed = false;
305
+ try {
306
+ this.handle?.stdin.end();
307
+ }
308
+ catch {
309
+ stopFailed = true;
306
310
  }
307
- })();
308
- const toolsClose = (async () => { try {
309
- await this.tools?.close();
310
- }
311
- catch {
312
- failed = true;
313
- } })();
314
- void (async () => {
315
- const outcomes = await Promise.allSettled([ownerStop, toolsClose, this.pendingHandle]);
316
- if (outcomes.some(outcome => outcome.status === 'rejected'))
317
- failed = true;
318
311
  try {
319
- await this.options.closeOfficialExec?.();
312
+ if (this.handle?.terminateOwned)
313
+ await this.handle.terminateOwned();
314
+ else
315
+ this.handle?.kill('SIGKILL');
320
316
  }
321
317
  catch {
322
- failed = true;
318
+ stopFailed = true;
323
319
  }
320
+ if (stopFailed)
321
+ throw Error('owner stop failed');
322
+ });
323
+ const toolsClose = step('tools_close', () => this.tools?.close());
324
+ void (async () => {
325
+ await Promise.all([ownerStop, toolsClose, step('pending_handle', () => this.pendingHandle)]);
326
+ await step('official_exec_close', () => this.options.closeOfficialExec?.());
324
327
  clearTimeout(timer);
325
- if (failed)
326
- reject(new Error('Provider process group cleanup could not be confirmed'));
328
+ if (failed.size)
329
+ reject(unconfirmed());
327
330
  else
328
331
  finish();
329
332
  })();
@@ -2,7 +2,7 @@ import { performance } from 'node:perf_hooks';
2
2
  import { PreparationClock, CODEX_PREPARATION_BUDGET_MS } from '../../safety/preparation-timing.js';
3
3
  import { localEndpointObservations, localEndpointReadObservations } from '../../safety/local-tool-server.js';
4
4
  import { processStartupObservations, freezeProcessLaunchObservation } from '../../process/node-process-group-host.js';
5
- import { executionError } from '../../safety/execution-failure.js';
5
+ import { executionError, unconfirmedCleanupError } from '../../safety/execution-failure.js';
6
6
  import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
7
7
  import { fingerprint as taskFingerprint } from '../../daemon/execution-coordinator.js';
8
8
  import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
@@ -280,19 +280,19 @@ export class CodexExecLaunchHost {
280
280
  }
281
281
  let finish, reject;
282
282
  this.closePromise = new Promise((resolve, rejectPromise) => { finish = resolve; reject = rejectPromise; });
283
- let cleanupError = false;
283
+ const failed = new Set();
284
284
  // Publish closePromise first: synchronous exit/cancel cannot recurse or skip cleanup.
285
285
  try {
286
286
  this.deps.execTools?.revoke();
287
287
  }
288
288
  catch {
289
- cleanupError = true;
289
+ failed.add('cleanup_exec_tools_revoke_failed');
290
290
  }
291
291
  try {
292
292
  this.deps.revokeTools();
293
293
  }
294
294
  catch {
295
- cleanupError = true;
295
+ failed.add('cleanup_job_tools_revoke_failed');
296
296
  }
297
297
  this.abort.abort();
298
298
  Promise.resolve().then(async () => {
@@ -300,7 +300,7 @@ export class CodexExecLaunchHost {
300
300
  this.handle?.stdin.end();
301
301
  }
302
302
  catch {
303
- cleanupError = true;
303
+ failed.add('cleanup_stdin_end_failed');
304
304
  }
305
305
  try {
306
306
  if (this.handle?.terminateOwned)
@@ -309,16 +309,16 @@ export class CodexExecLaunchHost {
309
309
  this.handle?.kill('SIGKILL');
310
310
  }
311
311
  catch {
312
- cleanupError = true;
312
+ failed.add('cleanup_process_terminate_failed');
313
313
  }
314
314
  try {
315
315
  await this.deps.closeTools();
316
316
  }
317
317
  catch {
318
- cleanupError = true;
318
+ failed.add('cleanup_tool_server_close_failed');
319
319
  }
320
- if (cleanupError)
321
- reject(new Error('CODEX_EXEC_CLEANUP_UNCONFIRMED'));
320
+ if (failed.size)
321
+ reject(unconfirmedCleanupError('CODEX_EXEC_CLEANUP_UNCONFIRMED', failed));
322
322
  else
323
323
  finish();
324
324
  });
@@ -476,9 +476,12 @@ export class ExecutionCoordinator {
476
476
  await publicationStep(() => adapter.freezeForPublication());
477
477
  lifecycle.frozen = true;
478
478
  } await assertModelFrozen(); };
479
+ executionStage = 'p1_freeze';
479
480
  await freezeOwnedModel();
480
481
  await readControls();
482
+ executionStage = 'p1_verify';
481
483
  p1Result = await publicationStep(() => this.deps.p1Host.verify({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen }));
484
+ executionStage = 'p1_receipts';
482
485
  await readControls();
483
486
  await assertModelFrozen();
484
487
  const seen = new Set();
@@ -491,6 +494,7 @@ export class ExecutionCoordinator {
491
494
  const id = await publicationStep(() => append({ eventType: 'test_result', summary: 'runner command observed', payload: { observer: 'runner', runner_receipt: receipt } }, true));
492
495
  p1ReceiptIds.push(id);
493
496
  }
497
+ executionStage = 'event_stream';
494
498
  }
495
499
  if (event.eventType === "completed" && fixSession) {
496
500
  // Stop only this owned model before running verified host publication.
@@ -572,7 +576,22 @@ export class ExecutionCoordinator {
572
576
  await append(terminal);
573
577
  }
574
578
  catch (error) {
575
- await stop(true);
579
+ try {
580
+ await stop(true);
581
+ }
582
+ catch (cleanupError) {
583
+ // An unconfirmed executor stop keeps the job suspended, but it must not erase
584
+ // the stage that failed first: log both fixed diagnostics before rethrowing.
585
+ try {
586
+ this.deps.reportExecutionFailure?.(executionFailure(error, executionStage));
587
+ }
588
+ catch { /* logging must not change terminal reconciliation */ }
589
+ try {
590
+ this.deps.reportExecutionFailure?.(executionFailure(cleanupError, 'executor_cleanup'));
591
+ }
592
+ catch { /* logging must not change terminal reconciliation */ }
593
+ throw cleanupError;
594
+ }
576
595
  if (error instanceof PublicationOutcomeUnknownError || (publicationPending && (heartbeatFailure || requestedTerminal))) {
577
596
  // The provider may already have committed/pushed/opened a PR. Do not
578
597
  // release this run as failed or let restart begin another model session.
@@ -32,19 +32,46 @@ const LINKS = {
32
32
  const defaultExists = async (path) => access(path).then(() => true, () => false);
33
33
  // A timeout keeps a hung `open` from blocking the opens loop; there is still no shell.
34
34
  const defaultRun = (file, args) => execFileAsync(file, args, { timeout: 10_000 });
35
- /** The app each client opens on this Mac: the Claude desktop app before Claude Code's URL handler. */
35
+ // plutil reads both XML and binary property lists.
36
+ const defaultUrlSchemes = async (infoPlist) => {
37
+ const stdout = await new Promise((resolve, reject) => {
38
+ execFile("/usr/bin/plutil", ["-convert", "json", "-o", "-", infoPlist], { timeout: 5_000 }, (error, out) => error ? reject(error) : resolve(String(out)));
39
+ });
40
+ const types = JSON.parse(stdout).CFBundleURLTypes;
41
+ return Array.isArray(types) ? types.flatMap(type => {
42
+ const schemes = type?.CFBundleURLSchemes;
43
+ return Array.isArray(schemes) ? schemes.filter((scheme) => typeof scheme === "string") : [];
44
+ }) : [];
45
+ };
46
+ /**
47
+ * The app each client opens on this Mac: the Claude desktop app before Claude Code's URL handler. The Codex app
48
+ * is Codex.app, or ChatGPT.app when that app registers the codex scheme (the ChatGPT app that bundles Codex
49
+ * ships as com.openai.codex); the plain ChatGPT chat app registers no codex scheme and is not taken.
50
+ */
36
51
  export async function detectSessionSurfaces(deps) {
37
52
  const exists = deps.exists ?? defaultExists;
53
+ const urlSchemes = deps.urlSchemes ?? defaultUrlSchemes;
38
54
  const anyOf = async (paths) => {
39
55
  for (const path of paths)
40
56
  if (await exists(path))
41
57
  return true;
42
58
  return false;
43
59
  };
60
+ const registersCodex = async (paths) => {
61
+ for (const path of paths) {
62
+ if (!await exists(path))
63
+ continue;
64
+ const schemes = await urlSchemes(join(path, "Contents", "Info.plist")).catch(() => []);
65
+ if (schemes.includes("codex"))
66
+ return true;
67
+ }
68
+ return false;
69
+ };
44
70
  const userApplications = deps.home === "" ? [] : [join(deps.home, "Applications")];
45
- const desktop = await anyOf(["/Applications/Claude.app", ...userApplications.map(dir => join(dir, "Claude.app"))]);
71
+ const inApplications = (app) => [join("/Applications", app), ...userApplications.map(dir => join(dir, app))];
72
+ const desktop = await anyOf(inApplications("Claude.app"));
46
73
  const cli = deps.home !== "" && await exists(join(deps.home, "Applications", "Claude Code URL Handler.app"));
47
- const codex = await anyOf(["/Applications/Codex.app", ...userApplications.map(dir => join(dir, "Codex.app"))]);
74
+ const codex = await anyOf(inApplications("Codex.app")) || await registersCodex(inApplications("ChatGPT.app"));
48
75
  return { claude: desktop ? "claude_desktop" : cli ? "claude_cli" : null, chatgpt: codex ? "codex_app" : null };
49
76
  }
50
77
  export async function sessionOpenAdvertisement(deps) {
@@ -67,7 +94,7 @@ export class SessionOpener {
67
94
  return { status: "failed", failureCode: "invalid_request" };
68
95
  let clients;
69
96
  try {
70
- clients = await detectSessionSurfaces({ home: this.deps.home, exists: this.deps.exists });
97
+ clients = await detectSessionSurfaces({ home: this.deps.home, exists: this.deps.exists, urlSchemes: this.deps.urlSchemes });
71
98
  }
72
99
  catch {
73
100
  return { status: "failed", failureCode: "handler_missing" };
@@ -13,6 +13,12 @@ export const STARTUP_OBSERVATIONS = Object.freeze([
13
13
  'native_stdin_read', 'native_model_catalog_failed', 'native_cloud_policy_failed', 'native_cloud_policy_timeout',
14
14
  'native_required_mcp_failed', 'native_config_failed', 'native_auth_refresh_unsupported',
15
15
  'local_mcp_request_received', 'local_mcp_auth_rejected', 'local_mcp_origin_or_host_rejected', 'local_mcp_initialize_received',
16
+ // Unconfirmed executor cleanup: which fixed step failed, or was still pending at its deadline.
17
+ 'cleanup_owner_stop_failed', 'cleanup_owner_stop_pending', 'cleanup_security_revoke_failed', 'cleanup_tools_revoke_failed',
18
+ 'cleanup_tools_close_failed', 'cleanup_tools_close_pending', 'cleanup_pending_handle_failed', 'cleanup_pending_handle_pending',
19
+ 'cleanup_official_exec_close_failed', 'cleanup_official_exec_close_pending',
20
+ 'cleanup_exec_tools_revoke_failed', 'cleanup_job_tools_revoke_failed', 'cleanup_stdin_end_failed', 'cleanup_process_terminate_failed',
21
+ 'cleanup_tool_server_close_failed', 'cleanup_timeout',
16
22
  ]);
17
23
  const issued = new WeakMap();
18
24
  export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
@@ -42,6 +48,20 @@ export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
42
48
  }
43
49
  return { stage, code: fallback };
44
50
  }
51
+ /** An unconfirmed cleanup keeps its fixed message for callers and carries only fixed
52
+ * `cleanup_*` observations, never the underlying error, path or provider output. */
53
+ export function unconfirmedCleanupError(message, observations) {
54
+ const seen = new Set(observations);
55
+ const fixed = STARTUP_OBSERVATIONS.filter(value => value.startsWith('cleanup_') && seen.has(value));
56
+ const error = new Error(message);
57
+ issued.set(error, Object.freeze({ stage: 'executor_cleanup', code: 'EXECUTION_FAILED', ...(fixed.length ? { observations: fixed } : {}) }));
58
+ return error;
59
+ }
60
+ /** The fixed cleanup observations an inner owner issued, for an outer owner to carry. */
61
+ export function cleanupObservations(error) {
62
+ const trusted = error && typeof error === 'object' ? issued.get(error) : undefined;
63
+ return (trusted?.observations ?? []).filter(value => value.startsWith('cleanup_'));
64
+ }
45
65
  export function executionError(error, stage, fallback, detail, observations, preparationTiming, launchObservation) {
46
66
  const diagnostic = executionFailure(error, stage, fallback);
47
67
  // Only trusted callers supply this fixed vocabulary. Serialized provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {