mtech-ai-local-runner 0.1.39 → 0.1.41

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,18 @@
1
+ ## 0.1.41: confirm a cleanup the guardian finished before a late terminate
2
+
3
+ When Codex exits by itself, the owned guardian confirms the cleanup and exits. A busy daemon could then write its
4
+ terminate request to the closed control socket before reading `cleaned`. That write error turned a confirmed cleanup
5
+ into an unconfirmed one and suspended the job. A control socket error alone no longer does that: cleanup is
6
+ confirmed by `cleaned` or by the guardian's exit code 0, which it uses only after sending `cleaned`. Timeouts,
7
+ liveness errors, malformed messages and `unconfirmed` reports still fail closed.
8
+
9
+ ## 0.1.40: say why the owned guardian could not confirm cleanup
10
+
11
+ When the owned guardian reports an unconfirmed cleanup, the `executor_cleanup` diagnostic now carries fixed
12
+ `cleanup_guardian_*` observations: what started the cleanup, how TERM and KILL went, whether the executor group was
13
+ still alive and its pipes still open, whether the root had exited, and whether the host's deadline passed. Cleanup
14
+ decisions, deadlines and recovery are unchanged.
15
+
1
16
  ## 0.1.39: keep the first failure when executor cleanup is unconfirmed
2
17
 
3
18
  A job whose Codex executor could not confirm its cleanup was suspended with only `EXECUTION_UNACKNOWLEDGED` in the
@@ -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, unconfirmedCleanupError } from '../../safety/execution-failure.js';
5
+ import { executionError, unconfirmedCleanupError, cleanupObservations } 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';
@@ -308,8 +308,10 @@ export class CodexExecLaunchHost {
308
308
  else
309
309
  this.handle?.kill('SIGKILL');
310
310
  }
311
- catch {
311
+ catch (error) {
312
312
  failed.add('cleanup_process_terminate_failed');
313
+ for (const observed of cleanupObservations(error))
314
+ failed.add(observed);
313
315
  }
314
316
  try {
315
317
  await this.deps.closeTools();
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { OwnedLaunchObserver } from './owned-launch-observer.js';
3
+ import { unconfirmedCleanupError } from '../safety/execution-failure.js';
3
4
  const startup = new WeakMap();
4
5
  const launchObservers = new WeakMap();
5
6
  export function freezeProcessLaunchObservation(handle) { return launchObservers.get(handle)?.freeze(); }
@@ -142,7 +143,7 @@ export class NodeProcessGroupHost {
142
143
  return;
143
144
  realExit = true;
144
145
  observations.add('host_process_exited');
145
- void guardian.terminate().then(() => { owned = false; const result = guardian.outcome(); terminated(result?.code ?? null, result?.signal ?? null); }, () => { failure = new Error('Provider process group cleanup could not be confirmed'); terminated(null, null); notify(); });
146
+ void guardian.terminate().then(() => { owned = false; const result = guardian.outcome(); terminated(result?.code ?? null, result?.signal ?? null); }, () => { failure = unconfirmedCleanupError('Provider process group cleanup could not be confirmed', guardian.cleanupObservations?.() ?? []); terminated(null, null); notify(); });
146
147
  });
147
148
  // Drain from spawn, not from the first adapter event read: official exec can
148
149
  // emit startup events before it initializes MCP, while the adapter awaits
@@ -266,7 +267,18 @@ export class NodeProcessGroupHost {
266
267
  clearTimeout(timer);
267
268
  }
268
269
  },
269
- terminateOwned: () => cleanupPromise ??= (async () => { launch?.stop(); stopInput(); await guardian.terminate(); owned = false; })(),
270
+ terminateOwned: () => cleanupPromise ??= (async () => {
271
+ launch?.stop();
272
+ stopInput();
273
+ // Keep the fixed message; carry only the guardian's fixed cleanup observations.
274
+ try {
275
+ await guardian.terminate();
276
+ }
277
+ catch {
278
+ throw unconfirmedCleanupError('Provider process group cleanup could not be confirmed', guardian.cleanupObservations?.() ?? []);
279
+ }
280
+ owned = false;
281
+ })(),
270
282
  once: (_event, callback) => { if (termination) {
271
283
  const ended = termination;
272
284
  queueMicrotask(() => callback(ended.code, ended.signal));
@@ -12,30 +12,35 @@ let child, ownedPid, ownedGone=false, cleaning, ready=Promise.resolve(), rootExi
12
12
  const send=value=>{if(!control.destroyed)control.write(JSON.stringify(value)+'\n');};
13
13
  const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
14
14
  const alive=()=>{if(!ownedPid||ownedGone)return false;try{process.kill(-ownedPid,0);return true;}catch(e){if(e.code==='ESRCH'){ownedGone=true;return false;}if(e.code==='EPERM')return true;throw Error('cleanup');}};
15
- const signal=s=>{if(!ownedPid)throw Error('ownership');if(ownedGone)return;try{process.kill(-ownedPid,s);}catch(e){if(e.code==='ESRCH'){ownedGone=true;return;}if(e.code==='EPERM'){if(!rootExited)child.kill(s);return;}throw Error('cleanup');}};
16
- function cleanup(immediate=false){
15
+ const signal=s=>{if(!ownedPid)throw Error('ownership');if(ownedGone)return 'esrch';try{process.kill(-ownedPid,s);return 'sent';}catch(e){if(e.code==='ESRCH'){ownedGone=true;return 'esrch';}if(e.code==='EPERM'){if(!rootExited)child.kill(s);return 'eperm';}throw Error('cleanup');}};
16
+ // Fixed words only: what started cleanup, each signal outcome and what was still open.
17
+ function cleanup(immediate=false,trigger='terminate'){
17
18
  if(cleaning)return cleaning;
19
+ const detail={trigger,term:'not_needed',kill:'not_needed'};
18
20
  cleaning=(async()=>{
19
21
  try{
20
22
  if(!child)throw Error('ownership');
21
23
  await ready;
22
24
  child.stdin.end();
23
25
  if(ownedPid){
24
- if(alive())signal(immediate?'SIGKILL':'SIGTERM');
26
+ if(alive())detail.term=signal(immediate?'SIGKILL':'SIGTERM');
25
27
  const deadline=Date.now()+(immediate?0:2000);
26
28
  while(alive()&&Date.now()<deadline)await delay(20);
27
- if(alive())signal('SIGKILL');
29
+ if(alive())detail.kill=signal('SIGKILL');
28
30
  const killed=Date.now()+2000;
29
31
  while((alive()||!closed)&&Date.now()<killed)await delay(20);
30
32
  if(alive()||!closed)throw Error('cleanup');
31
33
  }
32
34
  send({type:'cleaned'});finish(0);
33
- }catch{send({type:'unconfirmed'});finish(1);}
35
+ }catch{
36
+ let groupAlive=true;try{groupAlive=alive();}catch{/* an unknown liveness error stays alive */}
37
+ send({type:'unconfirmed',detail:{...detail,alive:groupAlive,closed,root:rootExited}});finish(1);
38
+ }
34
39
  })();
35
40
  return cleaning;
36
41
  }
37
42
  function finish(code){process.exitCode=code;process.stdin.destroy();liveness.destroy();control.end(()=>control.destroy());process.stdout.end();process.stderr.end();}
38
- function lost(){orphaned=true;if(configured)void cleanup();else finish(1);}
43
+ function lost(){orphaned=true;if(configured)void cleanup(false,'lost');else finish(1);}
39
44
  liveness.on('data',()=>{});liveness.once('end',lost);liveness.once('error',lost);
40
45
  control.on('error',lost);control.on('end',lost);
41
46
  control.on('data',chunk=>{
@@ -47,19 +52,19 @@ control.on('data',chunk=>{
47
52
  configured=true;
48
53
  try{child=spawn(message.command,message.args,{cwd:message.cwd,env:message.env,shell:false,detached:true,stdio:['pipe','pipe','pipe']});}catch{send({type:'spawn_failed'});finish(0);return;}
49
54
  ready=new Promise(resolve=>{
50
- child.once('spawn',()=>{ownedPid=child.pid;send({type:'spawned',pid:ownedPid,parentPid:process.pid});resolve();if(orphaned)void cleanup();});
51
- child.once('error',()=>{send({type:'spawn_failed'});closed=true;resolve();void cleanup(true);});
55
+ child.once('spawn',()=>{ownedPid=child.pid;send({type:'spawned',pid:ownedPid,parentPid:process.pid});resolve();if(orphaned)void cleanup(false,'lost');});
56
+ child.once('error',()=>{send({type:'spawn_failed'});closed=true;resolve();void cleanup(true,'spawn_error');});
52
57
  });
53
58
  child.stdin.on('error',()=>{});
54
59
  process.stdin.pipe(child.stdin);child.stdout.pipe(process.stdout,{end:false});child.stderr.pipe(process.stderr,{end:false});
55
- child.once('exit',(code,signal)=>{rootExited=true;send({type:'root_exit',code,signal});void cleanup();});
60
+ child.once('exit',(code,signal)=>{rootExited=true;send({type:'root_exit',code,signal});void cleanup(false,'root_exit');});
56
61
  child.once('close',()=>{closed=true;});
57
- }else if(message.type==='terminate')void cleanup(message.immediate===true);
62
+ }else if(message.type==='terminate')void cleanup(message.immediate===true,'terminate');
58
63
  else lost();
59
64
  }
60
65
  });
61
- process.stdout.on('error',()=>{child?.stdout.resume();void cleanup(true);});
62
- process.stderr.on('error',()=>{child?.stderr.resume();void cleanup(true);});
66
+ process.stdout.on('error',()=>{child?.stdout.resume();void cleanup(true,'output_error');});
67
+ process.stderr.on('error',()=>{child?.stderr.resume();void cleanup(true,'output_error');});
63
68
  `;
64
69
  export function spawnOwnedGuardian(command, args, options) {
65
70
  const child = spawn(process.execPath, ['--input-type=commonjs', '-e', GUARDIAN_SOURCE], {
@@ -73,13 +78,18 @@ export function spawnOwnedGuardian(command, args, options) {
73
78
  // Hosts can attach observers after spawn returns without unhandled rejection.
74
79
  void started.catch(() => { });
75
80
  let confirmed = false, spawnFailed = false, protocolFailed = false, buffer = '';
81
+ let terminateWritten = false, hostTimeout = false, controlFailed = false, detail;
76
82
  let outcome;
77
83
  let closePromise;
78
84
  const closed = new Promise(resolve => child.once('close', () => { liveness.destroy(); resolve(); }));
85
+ // A guardian that has confirmed and exited closes its control socket; a terminate written late by a
86
+ // busy host then fails, possibly before "cleaned" is read. That write error alone is not a protocol
87
+ // failure: the guardian exits 0 only after it sent "cleaned" (or after a spawn failure owned nothing).
79
88
  control.on('error', () => { if (!confirmed && !spawnFailed)
80
- protocolFailed = true; });
89
+ controlFailed = true; });
81
90
  liveness.on('error', () => { if (!confirmed && !spawnFailed)
82
91
  protocolFailed = true; });
92
+ const cleanedByExit = () => controlFailed && !protocolFailed && child.exitCode === 0 && child.signalCode === null;
83
93
  control.on('data', (chunk) => {
84
94
  buffer += chunk;
85
95
  if (buffer.length > 8192) {
@@ -109,8 +119,11 @@ export function spawnOwnedGuardian(command, args, options) {
109
119
  outcome = { code: message.code, signal: message.signal };
110
120
  else if (message.type === 'cleaned')
111
121
  confirmed = true;
112
- else if (message.type === 'unconfirmed')
122
+ else if (message.type === 'unconfirmed') {
113
123
  protocolFailed = true;
124
+ if (message.detail && typeof message.detail === 'object' && !Array.isArray(message.detail))
125
+ detail = message.detail;
126
+ }
114
127
  else {
115
128
  protocolFailed = true;
116
129
  liveness.destroy();
@@ -121,20 +134,55 @@ export function spawnOwnedGuardian(command, args, options) {
121
134
  child.once('close', () => { rejectStarted(new Error('Provider process could not start')); });
122
135
  control.write(JSON.stringify({ type: 'launch', command, args, ...options }) + '\n');
123
136
  return { child, started, closed, outcome: () => outcome, spawnFailed: () => spawnFailed,
124
- signal(immediate) { if (child.exitCode === null && child.signalCode === null && !confirmed && !spawnFailed && !control.destroyed)
125
- control.write(JSON.stringify({ type: 'terminate', immediate }) + '\n'); },
137
+ signal(immediate) { if (child.exitCode === null && child.signalCode === null && !confirmed && !spawnFailed && !control.destroyed) {
138
+ control.write(JSON.stringify({ type: 'terminate', immediate }) + '\n');
139
+ terminateWritten = true;
140
+ } },
126
141
  terminate(immediate = false) {
127
142
  if (closePromise)
128
143
  return closePromise;
129
144
  this.signal(immediate);
130
145
  closePromise = new Promise((resolve, reject) => {
131
- const timer = setTimeout(() => { protocolFailed = true; liveness.destroy(); reject(new Error('Provider process group cleanup could not be confirmed')); }, 5500);
132
- void closed.then(() => { clearTimeout(timer); if (protocolFailed || !confirmed)
146
+ const timer = setTimeout(() => { protocolFailed = true; hostTimeout = true; liveness.destroy(); reject(new Error('Provider process group cleanup could not be confirmed')); }, 5500);
147
+ void closed.then(() => { clearTimeout(timer); if (protocolFailed || !(confirmed || cleanedByExit()))
133
148
  reject(new Error('Provider process group cleanup could not be confirmed'));
134
149
  else
135
150
  resolve(); });
136
151
  });
137
152
  return closePromise;
138
153
  },
154
+ /** Fixed cleanup observations after an unconfirmed cleanup; empty once cleanup is confirmed. */
155
+ cleanupObservations() {
156
+ if ((confirmed || cleanedByExit()) && !protocolFailed)
157
+ return [];
158
+ if (!protocolFailed && !hostTimeout && !controlFailed)
159
+ return [];
160
+ const observed = [terminateWritten ? 'cleanup_guardian_terminate_written' : 'cleanup_guardian_terminate_not_written'];
161
+ if (hostTimeout)
162
+ observed.push('cleanup_guardian_host_timeout');
163
+ if (controlFailed)
164
+ observed.push('cleanup_guardian_control_failed');
165
+ if (!detail) {
166
+ if (!hostTimeout && protocolFailed)
167
+ observed.push('cleanup_guardian_protocol_failed');
168
+ return observed;
169
+ }
170
+ const word = (value, allowed) => allowed.find(item => item === value);
171
+ const trigger = word(detail.trigger, ['terminate', 'root_exit', 'lost', 'output_error', 'spawn_error']);
172
+ const term = word(detail.term, ['sent', 'eperm', 'esrch', 'not_needed']), kill = word(detail.kill, ['sent', 'eperm', 'esrch', 'not_needed']);
173
+ if (trigger)
174
+ observed.push(`cleanup_guardian_trigger_${trigger}`);
175
+ if (term)
176
+ observed.push(`cleanup_guardian_term_${term}`);
177
+ if (kill)
178
+ observed.push(`cleanup_guardian_kill_${kill}`);
179
+ if (typeof detail.alive === 'boolean')
180
+ observed.push(detail.alive ? 'cleanup_guardian_group_alive' : 'cleanup_guardian_group_gone');
181
+ if (typeof detail.closed === 'boolean')
182
+ observed.push(detail.closed ? 'cleanup_guardian_pipes_closed' : 'cleanup_guardian_pipes_open');
183
+ if (typeof detail.root === 'boolean')
184
+ observed.push(detail.root ? 'cleanup_guardian_root_exited' : 'cleanup_guardian_root_running');
185
+ return observed;
186
+ },
139
187
  };
140
188
  }
@@ -19,6 +19,13 @@ export const STARTUP_OBSERVATIONS = Object.freeze([
19
19
  'cleanup_official_exec_close_failed', 'cleanup_official_exec_close_pending',
20
20
  'cleanup_exec_tools_revoke_failed', 'cleanup_job_tools_revoke_failed', 'cleanup_stdin_end_failed', 'cleanup_process_terminate_failed',
21
21
  'cleanup_tool_server_close_failed', 'cleanup_timeout',
22
+ // The owned guardian's own account of an unconfirmed cleanup (fixed words, no PIDs or paths).
23
+ 'cleanup_guardian_terminate_written', 'cleanup_guardian_terminate_not_written', 'cleanup_guardian_host_timeout', 'cleanup_guardian_control_failed', 'cleanup_guardian_protocol_failed',
24
+ 'cleanup_guardian_trigger_terminate', 'cleanup_guardian_trigger_root_exit', 'cleanup_guardian_trigger_lost', 'cleanup_guardian_trigger_output_error', 'cleanup_guardian_trigger_spawn_error',
25
+ 'cleanup_guardian_term_sent', 'cleanup_guardian_term_eperm', 'cleanup_guardian_term_esrch', 'cleanup_guardian_term_not_needed',
26
+ 'cleanup_guardian_kill_sent', 'cleanup_guardian_kill_eperm', 'cleanup_guardian_kill_esrch', 'cleanup_guardian_kill_not_needed',
27
+ 'cleanup_guardian_group_alive', 'cleanup_guardian_group_gone', 'cleanup_guardian_pipes_closed', 'cleanup_guardian_pipes_open',
28
+ 'cleanup_guardian_root_exited', 'cleanup_guardian_root_running',
22
29
  ]);
23
30
  const issued = new WeakMap();
24
31
  export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {