@jc_stack/ez-agents 0.1.0-beta.50 → 0.1.0-beta.51

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
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.51
4
+
5
+ - Keep native runs alive during handoff-compatible core upgrades. The retiring executor finishes existing work while its replacement accepts new requests; the relay adopts runs without replay and preserves schedule holds, cancellation and settled delivery receipts. Isolated agents migrate once while idle to a separate, unprivileged executor container; later compatible upgrades preserve its running generation. Older runtimes without the handoff contract retain the full native-work drain.
6
+ - Prepare compatible-upgrade workspace and native-client backups before pausing admission or stopping the relay, so large histories do not extend the swap outage. Rollback restores code and configuration without rewinding active native state.
7
+ - Make stop requests reach handed-off native work even while its observer is starting. A failed cancellation write leaves the observer active and reports the failed stop request.
8
+ - Show paused schedules in `/tasks` and task details with a paused label and no next run. Editing a paused schedule keeps it paused.
9
+ - Replace the oldest unused saved AI choice when the saved-choice limit is reached, preserving the selected, default and recently used choices.
10
+ - Use the isolated native client's own catalog for AI selection, so available models such as Sonnet appear without relying on a host catalog.
11
+
12
+ Beta limits: live fresh-host/reboot acceptance and live compatible upgrades during active native work on the VM and Mac remain pending. Synthetic container lifecycle checks and an idle EzStocks OVH private-candidate upgrade passed; these do not establish live active-run or provider acceptance. Publication does not establish an installed-agent upgrade or authorize provider/domain writes.
13
+
3
14
  ## 0.1.0-beta.50
4
15
 
5
16
  - Share memory across engines through the workspace. New workspaces seed a short `## Memory` note (durable facts in `memory/`, one per file, indexed in `memory/MEMORY.md`); every engine loads `AGENTS.md`, so Codex and Claude read the same files. When a chat conversation is replaced (`/new`, the New button, web app new, or an AI/provider switch), its engine gets one silent turn in that native session to save durable facts; nothing is delivered and the engine decides what to write.
@@ -151,8 +151,7 @@ record, or an adoption failure) is recorded failed and interrupted, which holds
151
151
  its schedule until an explicit edit; the host is asked to cancel it. A request
152
152
  the host had not claimed before the relay stopped is failed by the host without
153
153
  starting. Records older than 24 hours are ignored, and the file is removed on
154
- every start. Queued runs and deliveries are still dropped, and messages a run
155
- sends while no relay is up fail and need a retry.
154
+ every start. Queued runs are still dropped. Accepted deliveries are drained before listener shutdown completes; settled receipts for handed-off runs move with the handoff. A connection failure during a declared upgrade swap waits for the relay; an accepted timeout is never retried.
156
155
  A `409 Conflict` still requires the operator to stop the competing poller; it
157
156
  remains unhealthy after the startup grace. Pending and uncertain deliveries keep
158
157
  their existing receipt semantics; executor stdout is not replayed as a reply.
@@ -392,3 +391,14 @@ The isolated executor image includes Node, jq and Python 3 (`python` and
392
391
  remain behind installed plugins. Registered command admission waits up to five
393
392
  seconds for the short registry lock; lifecycle changes still fail closed, and
394
393
  no caller removes an existing lock. Serialize calls when a plugin requires it.
394
+
395
+ ## Isolated executor generations
396
+
397
+ A handoff-capable supervisor runs the existing native file transport in a separate
398
+ unprivileged per-agent container. It derives workspace/control mounts and the image
399
+ from the owning relay deployment, adds only a read-only executor binding, and gives
400
+ the worker no relay secrets, provider secret environment or Docker socket. Native
401
+ credential homes stay in the existing control volume. Only the active generation
402
+ claims work; retired generations keep cancellation and per-run liveness until
403
+ their last process exits. The supervisor removes their temporary Compose resources.
404
+ The first upgrade from an embedded isolated executor drains before this migration.
@@ -21,7 +21,8 @@ ezenciel-agents-schedule cancel RUN_ID
21
21
  ```
22
22
 
23
23
  Use `--text-file` for longer instructions. `edit ID` replaces the complete schedule
24
- with a new revision; use the same trigger/name/text flags as `create`.
24
+ with a new revision; use the same trigger/name/text flags as `create`. It keeps
25
+ the paused/enabled state: an edited paused task stays paused until `resume`.
25
26
  An edit keeps a future pending occurrence when its trigger is unchanged;
26
27
  otherwise the edited rule begins at its next future occurrence. Editing does
27
28
  not replay missed work from an old start date. Use `--now` to request a new
@@ -338,9 +339,11 @@ messages, read private sessions, trigger work or certify owning results.
338
339
 
339
340
  The prompt controls diagnosis, authorized recovery and quiet notification behavior. Inspect prior effects and receipts before retrying anything. A failed review run itself remains visible as a new failure for the next occurrence.
340
341
 
341
- The Telegram Scheduled tasks menu lists enabled schedules that still have a pending
342
- occurrence or a queued/running occurrence. Finished one-time tasks, paused
343
- schedules and revisions stopped for review are hidden. Each entry shows the
342
+ The Telegram Scheduled tasks menu lists paused schedules alongside enabled schedules
343
+ that still have a pending occurrence or a queued/running occurrence. Paused tasks
344
+ are labeled Paused and have no next run; an existing queued/running occurrence
345
+ keeps its execution state visible. Finished enabled one-time tasks and enabled
346
+ revisions stopped for review are hidden. Each entry shows the
344
347
  frequency with the cron timezone, the saved primary and fallback setups in order
345
348
  (CLI, login profile, model and effort), next occurrence in UTC (or queued/running state),
346
349
  and the first sentence of its saved invocation
package/docs/upgrades.md CHANGED
@@ -16,7 +16,12 @@ automatic channel for core and plugins without a saved policy. Existing explicit
16
16
  stable or manual policies are preserved. The owner may select either per target.
17
17
  The main target and installed plugins version independently.
18
18
 
19
- Core upgrades leave channel admission open while native work runs, then pause it after work is idle and recheck races. Plugin upgrades build while native work continues. Activation closes the existing registry admission lock, drains in-flight plugin command leases, and replaces the plugin under that lock. New native plugin calls wait for admission; an outdated revision fails before provider execution and must be rediscovered. No admitted provider operation is replayed. The shared broker and native sessions remain running. Core upgrades still require the full native-work drain. A plugin update also retains that drain if an operator replaced the broker’s single-file host binding (for example to approve new network pins), because that case still requires broker recreation.
19
+ Handoff-compatible core upgrades build while native work continues and pause admission only for activation. The current native executor retires from claiming new work; the replacement claims new requests while the retiring generation finishes its existing runs. The relay adopts those runs without replay, preserving their schedule holds, cancellation and settled delivery receipts. The supervisor reloads only after native work is idle, so OS service process-group cleanup cannot kill a retiring generation. Plugin upgrades build while native work continues. Activation closes the existing registry admission lock, drains in-flight plugin command leases, and replaces the plugin under that lock. New native plugin calls wait for admission; an outdated revision fails before provider execution and must be rediscovered. No admitted provider operation is replayed. The shared broker and native sessions remain running. Older runtimes without the handoff contract retain the full native-work drain. Isolated agents migrate once while idle to a separate, unprivileged executor container using the same file transport; later relay/runtime upgrades preserve that executor generation. No native CLI or provider credentials move to the host. A plugin update also retains that drain if an operator replaced the broker’s single-file host binding (for example to approve new network pins), because that case still requires broker recreation.
20
+
21
+ Compatible main upgrades also finish their advisory workspace/native-client
22
+ backup before closing admission or stopping the relay. Large native histories do
23
+ not extend the swap's delivery outage. These live-state copies are best effort;
24
+ rollback restores code and configuration, never the backup over active state.
20
25
 
21
26
  The owner chooses the saved update policy. The host supervisor owns
22
27
  interruption-safe replacement. There is one host service per agent, not a second
@@ -153,7 +158,7 @@ is required. Stable automation excludes prereleases, major changes and 0.x minor
153
158
  changes. Beta policy permits compatible prereleases. Downgrades are rejected;
154
159
  rollback is a recovery operation for the previous installation only.
155
160
 
156
- `apply` records one durable queued job. **For a core upgrade, the agent must finish its turn after queuing it** so the supervisor can drain native work. A plugin upgrade can complete while that turn continues; only plugin-call admission is held during activation. Calls already executing finish first.
161
+ `apply` records one durable queued job. **For a core upgrade from a runtime without the handoff contract, the agent must finish its turn after queuing it** so the supervisor can drain native work. New compatible runtimes can swap while native work continues. A plugin upgrade can complete while that turn continues; only plugin-call admission is held during activation. Calls already executing finish first.
157
162
  One job at a time; an unresolved recovery blocks new upgrades. The agent processes
158
163
  multiple requested packages over successive update requests.
159
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.50",
3
+ "version": "0.1.0-beta.51",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
@@ -112,7 +112,9 @@
112
112
  "protocol": 1,
113
113
  "kind": "main",
114
114
  "stateSchema": 1,
115
- "mainProtocol": 1
115
+ "mainProtocol": 1,
116
+ "runHandoff": 1,
117
+ "containerExecutor": 1
116
118
  },
117
119
  "exports": {
118
120
  "./application-client": "./src/application-client.mjs",
package/src/ai-cli.ts CHANGED
@@ -5,6 +5,7 @@ import { mkdir, readFile } from 'node:fs/promises'
5
5
  import { authProfileStatus, readModels, presetProvider, sameChoice, sameEngine, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
6
6
  import { cliHome } from './auth-profile.js'
7
7
  import { ControlStore } from './control-state.js'
8
+ import { resolveIsolation } from './isolation.js'
8
9
 
9
10
  const usage='Usage: ezenciel-agents-ai list | select --cli <installed-cli> [--auth-profile <name>] [--provider <provider>] [--model <model>] [--effort <effort>] | default | profiles | profile add --cli codex|claude --auth-profile <name>'
10
11
  const {values,positionals}=parseArgs({allowPositionals:true,options:{help:{type:'boolean'},cli:{type:'string'},'auth-profile':{type:'string'},provider:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
@@ -15,8 +16,9 @@ if(values.help){
15
16
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
16
17
  const controlDir=process.env.EZ_CONTROL_DIR
17
18
  const authProfile=values['auth-profile']
18
- const catalog=():Promise<ModelChoice[]>=>readFile(join(controlDir,'host-executor','models.json'),'utf8')
19
- .then(text=>JSON.parse(text)).catch(()=>readModels(undefined,undefined,join(controlDir,'cli','codex'),undefined,undefined,undefined,controlDir))
19
+ const catalog=():Promise<ModelChoice[]>=>(process.env.EZ_AI_CATALOG==='host'||(process.env.EZ_AI_CATALOG===undefined&&resolveIsolation(process.env)==='host-capable'))
20
+ ? readFile(join(controlDir,'host-executor','models.json'),'utf8').then(text=>JSON.parse(text))
21
+ : readModels(undefined,undefined,join(controlDir,'cli','codex'),undefined,undefined,undefined,controlDir)
20
22
  if(positionals[0]==='list')console.log(JSON.stringify(await catalog()))
21
23
  else if(positionals[0]==='profiles')console.log(JSON.stringify(await authProfileStatus(controlDir)))
22
24
  else if(positionals[0]==='profile' && positionals[1]==='add'){
@@ -763,10 +763,16 @@ export class ControlStore {
763
763
  await requireControlGuard(state, guard)
764
764
  if (!state.ai) throw new Error('AI settings not initialized')
765
765
  // Auto-detected client defaults come and go with installed clients; only
766
- // owner-saved choices count toward the cap.
767
- if (state.ai.presets.filter((p) => !p.id.startsWith('detected_')).length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
768
- throw new Error('Keep it small: at most 12 saved AIs.')
769
- state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
766
+ // owner-saved choices count toward the cap. Conversations keep their own
767
+ // preset copy, so at the cap the oldest unreferenced choice makes room.
768
+ const ai = state.ai
769
+ const saved = ai.presets.filter((p) => !p.id.startsWith('detected_'))
770
+ if (saved.length >= 12 && !ai.presets.some((p) => p.id === preset.id)) {
771
+ const kept = new Set([ai.selectedId, ai.defaultId, ...(ai.recentIds ?? [])])
772
+ const oldest = saved.find((p) => !kept.has(p.id))!
773
+ ai.presets = ai.presets.filter((p) => p.id !== oldest.id)
774
+ }
775
+ ai.presets = [...ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
770
776
  await this.writeState(state)
771
777
  })
772
778
  }
@@ -78,7 +78,7 @@ const readEndpoint = async controlDir => {
78
78
  } catch { return null }
79
79
  }
80
80
 
81
- export const callDeliverySocket = async (socketPath, op, timeoutMs = 130_000) => {
81
+ const callOnce = async (socketPath, op, timeoutMs) => {
82
82
  const id = `${Date.now().toString(36)}_${Math.floor(Math.random() * 0xffffff).toString(16)}`
83
83
  try {
84
84
  return await exchange(() => createConnection(socketPath), { id, ...op }, timeoutMs, socketPath)
@@ -104,3 +104,17 @@ export const deliverySocketAlive = async (socketPath, timeoutMs = 2000) => {
104
104
  return false
105
105
  }
106
106
  }
107
+
108
+ // Only retry connection failures during a declared runtime swap. No frame was
109
+ // accepted in that case. Timeouts, provider failures and uncertain writes fail.
110
+ export const callDeliverySocket = async (socketPath, op, timeoutMs = 130_000) => {
111
+ const until=Date.now()+120000;
112
+ for(;;){
113
+ try{return await callOnce(socketPath,op,timeoutMs);}
114
+ catch(error){
115
+ if(!error?.connectFailure||op.op!=='enqueue'||Date.now()>=until)throw error;
116
+ try{await readFile(path.join(path.dirname(socketPath),'upgrade-pause.json'),'utf8');}catch{throw error;}
117
+ await new Promise(resolve=>setTimeout(resolve,250));
118
+ }
119
+ }
120
+ };
@@ -83,7 +83,7 @@ export const authorizeRun = async (controlDir: string, runId: string): Promise<R
83
83
  const encode = (value: unknown): string => `${JSON.stringify(value)}\n`
84
84
 
85
85
  export type DeliveryEndpoint = { host: '127.0.0.1'; port: number; token: string }
86
- export type DeliveryServeOptions = { tcpPort?: number }
86
+ export type DeliveryServeOptions = { tcpPort?: number; beforeClose?: () => Promise<void> }
87
87
  const endpointFile = 'delivery-endpoint.json'
88
88
  const tokensMatch = (candidate: unknown, token: string): boolean =>
89
89
  typeof candidate === 'string' && candidate.length === token.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(token))
@@ -94,6 +94,8 @@ export const serveDeliverySocket = async (
94
94
  options: DeliveryServeOptions = {},
95
95
  ): Promise<{ socketPath: string; endpoint: DeliveryEndpoint | null; stop: () => Promise<void> }> => {
96
96
  const socketPath = deliverySocketPath(controlDir)
97
+ let closing=false
98
+ const admissions=new Set<Promise<void>>()
97
99
  await rm(socketPath, { force: true }).catch(() => {})
98
100
  const attach = (socket: Socket, token: string | null): void => {
99
101
  let buffer = ''
@@ -105,7 +107,8 @@ export const serveDeliverySocket = async (
105
107
  const line = buffer.slice(0, newline)
106
108
  buffer = buffer.slice(newline + 1)
107
109
  if (!line.trim()) continue
108
- void (async () => {
110
+ let admission=false
111
+ const work=(async () => {
109
112
  let id: unknown = null
110
113
  try {
111
114
  const envelope = JSON.parse(line) as { id?: unknown; token?: unknown; op?: unknown; payload?: unknown }
@@ -116,12 +119,19 @@ export const serveDeliverySocket = async (
116
119
  // re-verifies owner authority server-side.
117
120
  if (token !== null && !tokensMatch(envelope.token, token)) { socket.destroy(); return }
118
121
  const { token: _token, ...request } = envelope
122
+ if(request.op==='enqueue'){
123
+ if(closing)throw Error('Delivery relay is shutting down; message was not accepted')
124
+ admission=true
125
+ }
119
126
  const result = await handle(request as DeliverySocketOp)
120
127
  socket.write(encode({ id, ok: true, result: result ?? null }))
121
128
  } catch (error) {
122
129
  socket.write(encode({ id, ok: false, error: error instanceof Error ? error.message : String(error ?? 'Unknown error') }))
123
130
  }
124
131
  })()
132
+ // Only finite admission handlers are a drain barrier; a receipt wait
133
+ // completes after the relay's delivery pump flushes the accepted items.
134
+ if(admission){admissions.add(work);void work.finally(()=>admissions.delete(work))}
125
135
  }
126
136
  })
127
137
  }
@@ -176,9 +186,13 @@ export const serveDeliverySocket = async (
176
186
  socketPath,
177
187
  endpoint,
178
188
  stop: async () => {
179
- await new Promise<void>((resolve) => server.close(() => resolve()))
189
+ closing=true
190
+ const closed=Promise.all([new Promise<void>((resolve) => server.close(() => resolve())),
191
+ ...(tcpServer ? [new Promise<void>((resolve) => tcpServer!.close(() => resolve()))] : [])])
192
+ await Promise.all(admissions)
193
+ await options.beforeClose?.()
194
+ await closed
180
195
  await rm(socketPath, { force: true }).catch(() => {})
181
- if (tcpServer) await new Promise<void>((resolve) => tcpServer.close(() => resolve()))
182
196
  if (tcpEndpoint) await rm(path.join(controlDir, endpointFile), { force: true }).catch(() => {})
183
197
  },
184
198
  }
package/src/executor.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { executionDefaults } from './model-policy.js'
2
2
  import { startTaskExecutor } from './task-executor.js'
3
3
  import { deliverySocketPath } from './delivery-socket.js'
4
- import { mkdtemp, rm, writeFile, mkdir, symlink, readFile } from 'node:fs/promises'
4
+ import { mkdtemp, rm, writeFile, mkdir, symlink, readFile, rename } from 'node:fs/promises'
5
+ import { randomUUID } from 'node:crypto'
5
6
  import { accessSync, openSync, closeSync, constants as fsConstants } from 'node:fs'
6
7
  import { tmpdir, homedir } from 'node:os'
7
8
  import path from 'node:path'
@@ -13,9 +14,17 @@ import { DESKTOP_UNAVAILABLE } from './desktop-bridge.js'
13
14
  import { macosWorkspaceProfile, workspaceSiblingDenies } from './workspace-confine.js'
14
15
  import { Scripts, type ScriptRunRef } from './scripts.js'
15
16
  import { claudeAuthEnvironment, cliHome, provisionedHome } from './auth-profile.js'
17
+ import { isHostRunId } from './host-executor-protocol.js'
18
+
19
+ const hostCancelFiles = new WeakMap<ChildProcess, string>()
20
+ const hostCancelFile = (controlDir: string, runId: string): string => {
21
+ if (!isHostRunId(runId)) throw new Error('Invalid host executor run ID')
22
+ return path.join(controlDir, 'host-executor', runId + '.cancel')
23
+ }
16
24
 
17
25
  export type ExecutorOptions = {
18
26
  repairEnabled?: boolean
27
+ catalogTransport?: 'host' | 'local'
19
28
  workspace: string
20
29
  timeoutMs: number
21
30
  runId: string
@@ -79,7 +88,7 @@ export const executorEnvironment = (environment: NodeJS.ProcessEnv = process.env
79
88
  }
80
89
 
81
90
  export const executorJobEnv = (
82
- options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled'> & { workspace?: string },
91
+ options: Pick<ExecutorOptions, 'runId' | 'controlDir' | 'binDir' | 'toolsHome' | 'repairEnabled' | 'catalogTransport'> & { workspace?: string },
83
92
  environment: NodeJS.ProcessEnv = process.env,
84
93
  ): NodeJS.ProcessEnv => {
85
94
  const base = executorEnvironment(environment)
@@ -88,6 +97,7 @@ export const executorJobEnv = (
88
97
  ...base,
89
98
  PATH: pathValue,
90
99
  EZ_RUN_ID: options.runId,
100
+ EZ_AI_CATALOG: options.catalogTransport ?? (environment.EZ_EXECUTOR_TRANSPORT==='host' ? 'host' : 'local'),
91
101
  EZ_CONTROL_DIR: options.controlDir,
92
102
  ...(options.workspace ? { EZ_AGENT_WORKSPACE: options.workspace } : {}),
93
103
  EZ_DELIVERY_SOCKET: deliverySocketPath(options.controlDir),
@@ -340,22 +350,33 @@ export const executorInvocation = (command: string, args: string[]) => {
340
350
  : { command, args }
341
351
  }
342
352
 
353
+ // The isolated worker uses the same file transport as the host worker, but
354
+ // stays in its own container. A published worker binding must never fall back
355
+ // to local execution when its heartbeat expires.
356
+ export const usesFileTransport = async (controlDir: string, env: NodeJS.ProcessEnv = process.env): Promise<boolean> => {
357
+ if (env.EZ_CONTAINER_EXECUTOR === '1') return false
358
+ if (env.EZ_EXECUTOR_TRANSPORT === 'host') return true
359
+ try { return JSON.parse(await readFile(path.join(controlDir,'host-executor/heartbeat.json'),'utf8')).containerExecutor === true }
360
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; return false }
361
+ }
362
+
343
363
  export const startExecutorJob = async (
344
364
  texts: string[],
345
365
  options: ExecutorOptions,
346
366
  ): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; stdout: string }> => {
347
- if (options.script && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
367
+ const host = await usesFileTransport(options.controlDir)
368
+ const cancelFile = host ? hostCancelFile(options.controlDir, options.runId) : undefined
369
+ if (options.script && !host) return startScriptJob(options as ExecutorOptions & { script: ScriptLaunch })
348
370
  options = executionDefaults(executorKey(options.cli), options)
349
371
  // Named credential homes are owner-provisioned; fail closed before any launch state.
350
372
  if (options.authProfile !== undefined) await provisionedHome(options.controlDir, executorKey(options.cli), options.authProfile)
351
373
  // Routing is caller-owned: a restricted task run goes to the task runner
352
374
  // unless the host transport must ship it across the boundary first.
353
- if (options.taskRun && process.env.EZ_EXECUTOR_TRANSPORT !== 'host') return startTaskExecutor(options)
375
+ if (options.taskRun && !host) return startTaskExecutor(options)
354
376
  if (options.nativeSession && !/^[a-zA-Z0-9_-]+$/.test(options.runId)) throw new Error('Invalid native task run ID')
355
377
  if (options.codexSandbox !== undefined && (options.codexSandbox !== 'external' || process.env.EZ_EXECUTOR_TRANSPORT !== 'local' || options.taskRun || executorKey(options.cli) !== 'codex')) throw new Error('External Codex sandbox requires an owner-authorized native local run')
356
378
  const outputDirectory = await mkdtemp(path.join(tmpdir(), 'ezenciel-agents-'))
357
379
  const key = executorKey(options.cli)
358
- const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
359
380
  const gui = !host && key === 'codex-gui'
360
381
  const nativeSession = !host && key === 'codex' && options.nativeSession === true
361
382
  // Literal input plus the caller-provided trigger line. No coaching,
@@ -458,6 +479,7 @@ export const startExecutorJob = async (
458
479
  stdio: ['pipe', 'pipe', 'pipe'],
459
480
  detached: process.platform !== 'win32',
460
481
  })
482
+ if (cancelFile) hostCancelFiles.set(child, cancelFile)
461
483
  await new Promise<void>((resolve, reject) => {
462
484
  child.once('spawn', resolve)
463
485
  child.once('error', reject)
@@ -502,9 +524,11 @@ export const startGrokJob = startExecutorJob
502
524
 
503
525
  // Watch a host-transport run a previous relay submitted; it is never resubmitted.
504
526
  export const attachHostJob = async (controlDir: string, runId: string): Promise<ChildProcess> => {
527
+ const cancelFile = hostCancelFile(controlDir, runId)
505
528
  const invocation = executorInvocation(process.execPath, ['--import', fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs', import.meta.url)),
506
529
  fileURLToPath(new URL('./host-executor-client.ts', import.meta.url)), controlDir, runId, '--attach'])
507
530
  const child = spawn(invocation.command, invocation.args, { env: executorEnvironment(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' })
531
+ hostCancelFiles.set(child, cancelFile)
508
532
  await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject) })
509
533
  return child
510
534
  }
@@ -552,11 +576,27 @@ export const nativeSessionId = (cli: string, line: string): string | undefined =
552
576
  } catch {}
553
577
  }
554
578
 
555
- const terminating = new WeakSet<ChildProcess>()
556
- export const terminateJob = (child: ChildProcess, inspect = processSnapshot): void => {
557
- if (!child.pid || child.exitCode !== null || child.signalCode !== null || terminating.has(child)) return
558
- terminating.add(child)
559
- void (async () => {
579
+ const terminating = new WeakMap<ChildProcess, Promise<boolean>>()
580
+ export const terminateJob = (child: ChildProcess, inspect = processSnapshot): Promise<boolean> => {
581
+ const pending = terminating.get(child)
582
+ if (pending) return pending
583
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
584
+ const work = (async () => {
585
+ const cancelFile = hostCancelFiles.get(child)
586
+ if (cancelFile) {
587
+ // The observer may still be loading, or unable to handle a signal. Commit
588
+ // the native worker's stop request before terminating that observer.
589
+ const temporary = cancelFile + '.' + process.pid + '.' + randomUUID() + '.tmp'
590
+ try {
591
+ await mkdir(path.dirname(cancelFile), { recursive: true, mode: 0o700 })
592
+ await writeFile(temporary, '', { mode: 0o600, flag: 'wx' })
593
+ await rename(temporary, cancelFile)
594
+ } catch (error) {
595
+ await rm(temporary, { force: true }).catch(() => {})
596
+ console.error('Cannot request host executor cancellation; observer remains active', error)
597
+ return false
598
+ }
599
+ }
560
600
  const targets = new Set([child.pid!])
561
601
  // Native tool terminals can start separate process groups. Capture ancestry
562
602
  // before stopping the CLI, while those children still have their parent.
@@ -584,5 +624,9 @@ export const terminateJob = (child: ChildProcess, inspect = processSnapshot): vo
584
624
  void processSnapshot().then(current => signal(matchingProcessIds(identities, current), 'SIGKILL'))
585
625
  .catch(error => console.error('Cannot inspect executor descendants for escalation', error))
586
626
  }, 3000)
627
+ return true
587
628
  })()
629
+ terminating.set(child, work)
630
+ void work.then(stopped => { if (!stopped) terminating.delete(child) })
631
+ return work
588
632
  }
@@ -50,7 +50,10 @@ try {
50
50
  // Drain completion first. A brief missing file at the shared-filesystem
51
51
  // boundary must not cancel a healthy job or hide its terminal result.
52
52
  try {
53
- const heartbeat = JSON.parse(await readFile(path.join(directory,'heartbeat.json'),'utf8'))
53
+ const claimed = await stat(base+'.running.json').then(()=>true,error=>{if(error.code==='ENOENT')return false;throw error})
54
+ const heartbeat = claimed
55
+ ? {at:(await stat(base+'.events')).mtimeMs}
56
+ : JSON.parse(await readFile(path.join(directory,'heartbeat.json'),'utf8'))
54
57
  if (!Number.isFinite(heartbeat.at)) throw new Error('Invalid host CLI heartbeat')
55
58
  lastHeartbeat = heartbeat.at
56
59
  } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
@@ -3,7 +3,7 @@ import { Tasks } from './tasks.js'
3
3
  import { ControlStore } from './control-state.js'
4
4
  import { authorizeRun, readRun } from './delivery-socket.js'
5
5
  import { runPromptSuffix } from './prompt-suffix.js'
6
- import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
6
+ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath, utimes, stat } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
8
  import { isHostRunId } from './host-executor-protocol.js'
9
9
  import { fileURLToPath } from 'node:url'
@@ -42,13 +42,16 @@ export const refreshAgentCatalog = async (agent: HostBinding,
42
42
  const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
43
43
 
44
44
  export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob,
45
- readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>) => {
45
+ readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>, retire?: AbortSignal) => {
46
46
  if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
47
- if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
47
+ if (installation.isolation === 'isolated' && process.env.EZ_CONTAINER_EXECUTOR !== '1') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
48
48
  resolveExecutor(installation.cli)
49
49
  if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
50
50
  new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
51
51
  throw new Error('Each agent requires a separate workspace and control directory')
52
+ const containerId = process.env.EZ_CONTAINER_EXECUTOR === '1' ? process.env.HOSTNAME : undefined
53
+ const hostStarted = await processStart(process.pid)
54
+ let retiring = false
52
55
  const active = new Map<string, ChildProcess>()
53
56
  // A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
54
57
  const claimed = new Set<string>()
@@ -74,9 +77,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
74
77
  const freshCatalog = readCatalog ?? catalog
75
78
  const beat = async (agent: HostBinding) => {
76
79
  const directory = path.join(agent.controlDir, 'host-executor')
77
- await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
80
+ await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, ...(process.env.EZ_CONTAINER_EXECUTOR === '1' ? {containerExecutor:true,containerId} : {}), plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
78
81
  await rename(path.join(directory, 'heartbeat.tmp'), path.join(directory, 'heartbeat.json'))
79
82
  }
83
+ const recoverClaims = async (agent: HostBinding) => {
84
+ const directory=path.join(agent.controlDir,'host-executor')
85
+ for (const file of await readdir(directory)) if (file.endsWith('.running.json') && isHostRunId(file.slice(0,-13))) {
86
+ const base=path.join(directory,file.slice(0,-13))
87
+ if (claimed.has(base)) continue
88
+ let prior: {pid?:number;started?:string;hostPid?:number;hostStarted?:string;containerId?:string} | undefined
89
+ try { prior=JSON.parse(await readFile(base+'.process.json','utf8')) } catch(error) { if ((error as NodeJS.ErrnoException).code!=='ENOENT') throw error }
90
+ if(prior?.containerId && prior.containerId!==containerId){
91
+ const touched=await stat(base+'.events').then(s=>s.mtimeMs,()=>0)
92
+ if(Date.now()-touched<15000)continue
93
+ }
94
+ if (!prior?.containerId && prior?.hostPid && prior.hostStarted && await processStart(prior.hostPid)===prior.hostStarted) continue
95
+ if (!prior?.containerId && prior?.pid && await processStart(prior.pid)) {
96
+ if (!prior.hostPid) throw Error('Previous host CLI is still running; stop it before recovery')
97
+ if (prior.started && await processStart(prior.pid)===prior.started) terminateJob({pid:prior.pid,exitCode:null,signalCode:null} as ChildProcess)
98
+ }
99
+ await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
100
+ await rm(base+'.process.json',{force:true});await rm(base+'.running.json',{force:true})
101
+ }
102
+ }
80
103
  try {
81
104
  for (const agent of installation.agents) {
82
105
  if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
@@ -89,7 +112,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
89
112
  if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
90
113
  additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
91
114
  }
92
- if (agent.toolsHome) {
115
+ if (agent.toolsHome && !containerId) {
93
116
  if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
94
117
  const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
95
118
  if (config.schemaVersion!==1 || config.workspace!==await realpath(agent.workspace)) throw new Error('Plugin registry belongs to another workspace')
@@ -99,6 +122,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
99
122
  const lock=path.join(directory,'worker.lock')
100
123
  try {
101
124
  const prior=JSON.parse(await readFile(lock,'utf8'))
125
+ if(prior.containerId&&prior.containerId!==containerId)throw Error('Previous isolated worker still owns the claim lock')
102
126
  const currentStart=await processStart(prior.pid)
103
127
  if (!prior.started || !currentStart || prior.started===currentStart) {
104
128
  try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
@@ -107,34 +131,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
107
131
  await rm(lock)
108
132
  }
109
133
  catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
110
- await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
134
+ await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid),...(containerId ? {containerId} : {})}),{mode:0o600,flag:'wx'})
111
135
  locks.push(lock)
112
136
  const models = await freshCatalog(agent)
113
137
  await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
114
138
  const modelsFile=path.join(directory,'models.json')
115
139
  await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
116
- // A host crash is terminal for a claimed job. Never replay an action.
117
- for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
118
- const base=path.join(directory,file.slice(0,-13))
119
- try {
120
- const prior=JSON.parse(await readFile(base+'.process.json','utf8'))
121
- try { process.kill(prior.pid,0); throw new Error('Previous host CLI is still running; stop it before recovery') }
122
- catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
123
- } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
124
- await rm(base+'.process.json',{force:true})
125
- await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
126
- await rm(path.join(directory,file))
127
- }
128
- if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
140
+ await recoverClaims(agent)
141
+ if (agent.toolsHome && !containerId) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
129
142
  }
130
143
  let catalogAt=Date.now()
131
144
  const catalogSeen=new Map<HostBinding,string>()
132
145
  while (!signal.aborted) {
133
146
  const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
134
- if(parent) { try { process.kill(parent,0) } catch { break } }
147
+ if(parent && !retiring) { try { process.kill(parent,0) } catch { break } }
148
+ if (retire?.aborted && !retiring) {
149
+ retiring=true
150
+ await catalogRefresh
151
+ for (const lock of locks.splice(0)) await rm(lock,{force:true})
152
+ }
153
+ if (retiring && !tasks.size) break
135
154
  // Keep transport liveness independent of native catalog I/O.
136
- for (const agent of installation.agents) await beat(agent)
137
- if(!catalogRefresh && Date.now()-catalogAt>30000){
155
+ if (!retiring) for (const agent of installation.agents) await beat(agent)
156
+ if(!retiring && !catalogRefresh && Date.now()-catalogAt>30000){
138
157
  catalogAt=Date.now()
139
158
  catalogRefresh=(async()=>{
140
159
  for(const agent of installation.agents){
@@ -149,6 +168,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
149
168
  }
150
169
  for (const agent of installation.agents) {
151
170
  const directory=path.join(agent.controlDir,'host-executor')
171
+ if (retiring) continue
172
+ await recoverClaims(agent)
152
173
  for (const file of await readdir(directory)) {
153
174
  if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
154
175
  const id=file.slice(0,-13)
@@ -168,9 +189,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
168
189
  let workspace = activeWorkspaces.get(agent)
169
190
  if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
170
191
  if (!workspace) {
171
- const release = agent.toolsHome
192
+ const release = agent.toolsHome && !containerId
172
193
  ? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
173
- if (agent.toolsHome && !release) continue
194
+ if (agent.toolsHome && !containerId && !release) continue
174
195
  workspace = { lanes: new Map(), release }
175
196
  activeWorkspaces.set(agent, workspace)
176
197
  }
@@ -184,7 +205,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
184
205
  await workspace.release?.()
185
206
  }
186
207
  }
187
- try { await rename(base+'.request.json',base+'.running.json') }
208
+ try {
209
+ await writeFile(base+'.process.json',JSON.stringify({hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
210
+ await appendFile(base+'.events','',{mode:0o600})
211
+ await rename(base+'.request.json',base+'.running.json')
212
+ }
188
213
  catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
189
214
  const task=(async()=>{
190
215
  let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
@@ -205,7 +230,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
205
230
  // Script occurrences come from the authorized run record, never the request.
206
231
  if (run?.script) {
207
232
  if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
208
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
233
+ const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
209
234
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
210
235
  script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
211
236
  job=await launch(request.texts,options)
@@ -237,14 +262,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
237
262
  // transient native-client probe failure must not reject a model
238
263
  // that the host just advertised to the relay and application.
239
264
  if (cli !== installation.cli || opts.authProfile !== undefined) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,authProfile:opts.authProfile,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
240
- const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
265
+ const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
241
266
  runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
242
267
  sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
243
268
  promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
244
269
  job=await launch(request.texts,options)
245
270
  }
246
271
  active.set(base,job.child)
247
- await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
272
+ // Observe completion before process metadata I/O: a fast CLI may
273
+ // already have exited when launch returns, or while that I/O waits.
274
+ const closed=job.child.exitCode!==null||job.child.signalCode!==null
275
+ ? Promise.resolve(job.child.exitCode??1)
276
+ : new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
248
277
  if(signal.aborted)terminateJob(job.child)
249
278
  // Scheduled Claude streams every event; forward only the startup summary.
250
279
  const startup = observeClaude ? startupObserver() : undefined
@@ -255,8 +284,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
255
284
  if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
256
285
  })
257
286
  job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
258
- cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
259
- const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
287
+ await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid,started:job.child.pid ? await processStart(job.child.pid) : undefined,hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
288
+ cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{}); const now=new Date();void utimes(base+'.events',now,now).catch(()=>{})},250)
289
+ const code=await closed
260
290
  if (cancellation) clearInterval(cancellation)
261
291
  await job.cleanup()
262
292
  job = undefined
@@ -290,8 +320,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
290
320
  }
291
321
 
292
322
  if (process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
293
- const abort=new AbortController()
323
+ const abort=new AbortController(),retire=new AbortController()
324
+ process.once('SIGUSR2',()=>retire.abort())
294
325
  for(const signal of ['SIGTERM','SIGINT'] as const)process.once(signal,()=>abort.abort())
295
326
  const config=JSON.parse(await readFile(process.argv[2],'utf8'))
296
- await serveHostExecutor(config,abort.signal)
327
+ await serveHostExecutor(config,abort.signal,undefined,undefined,retire.signal)
297
328
  }