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

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.52
4
+
5
+ - Restore isolated executor startup with existing named volumes and a private, root-owned read-only container binding. Host file ownership and permissions remain unchanged.
6
+ - Start a missing isolated executor when native requests are queued, while retaining the live-run drain and single-worker ownership checks.
7
+
8
+
9
+ ## 0.1.0-beta.51
10
+
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - Show paused schedules in `/tasks` and task details with a paused label and no next run. Editing a paused schedule keeps it paused.
15
+ - Replace the oldest unused saved AI choice when the saved-choice limit is reached, preserving the selected, default and recently used choices.
16
+ - Use the isolated native client's own catalog for AI selection, so available models such as Sonnet appear without relying on a host catalog.
17
+
18
+ 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.
19
+
3
20
  ## 0.1.0-beta.50
4
21
 
5
22
  - 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.
@@ -0,0 +1,35 @@
1
+ // Synthetic private binding and existing-volume acceptance; no provider access.
2
+ import * as fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {tmpdir} from 'node:os';
5
+ import {randomUUID} from 'node:crypto';
6
+ import {execute} from '../src/updates/runtime.mjs';
7
+ import {executorBindingArchive,executorVolumes} from '../src/updates/supervisor.mjs';
8
+ const root=await fs.mkdtemp(path.join(tmpdir(),'ez-executor-smoke-'));
9
+ const project='ez-executor-smoke-'+randomUUID().slice(0,8),volume=project+'-existing';
10
+ const file=path.join(root,'compose.json'),image=process.env.EZ_RELAY_IMAGE||'ezenciel-agents:local';
11
+ const binding={agents:[{workspace:'/synthetic',binDir:'/app/bin'}]};
12
+ const code=`const fs=require('fs'),assert=require('assert/strict');const p='/run/executor.json',s=fs.statSync(p),d=fs.statSync('/run');assert.equal(process.getuid(),1001);assert.equal(s.uid,0);assert.equal(s.gid,1000);assert.equal(s.mode&511,288);assert.equal(d.uid,0);assert.equal(d.mode&18,0);assert.deepEqual(JSON.parse(fs.readFileSync(p)),${JSON.stringify(binding)});for(const fn of [()=>fs.writeFileSync(p,'changed'),()=>fs.chmodSync(p,384),()=>fs.unlinkSync(p)])assert.throws(fn,e=>['EACCES','EPERM','EROFS'].includes(e.code));assert.equal(fs.readFileSync('/ipc/marker','utf8'),'retained');console.log('Private readonly binding and retained volume passed');`;
13
+ const worker={image,user:'1001:1000',network_mode:'none',cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','-e',code],volumes:[{type:'volume',source:'ipc',target:'/ipc'}]};
14
+ const args=['compose','--project-name',project,'--file',file];
15
+ try {
16
+ await execute('docker',['volume','create',volume]);
17
+ await execute('docker',['run','--rm','--network','none','--user','0:0','--mount',`type=volume,source=${volume},target=/ipc`,'--entrypoint','node',image,'-e',"require('fs').writeFileSync('/ipc/marker','retained')"]);
18
+ await fs.writeFile(file,JSON.stringify({services:{executor:worker},volumes:executorVolumes({volumes:{ipc:{name:volume}}},worker)}),{mode:0o600});
19
+ await execute('docker',[...args,'create','--no-build','executor']);
20
+ const cid=(await execute('docker',[...args,'ps','-aq','executor'],{stdoutOnly:true})).trim();
21
+ await execute('docker',['cp','-',`${cid}:/run`],{input:executorBindingArchive(binding,1000)});
22
+ console.log(await execute('docker',['start','-a',cid]));
23
+ const exit=(await execute('docker',['inspect','--format','{{.State.ExitCode}}',cid],{stdoutOnly:true})).trim();
24
+ if(exit!=='0')throw Error('Executor binding acceptance failed');
25
+ await execute('docker',[...args,'down']);
26
+ const retained=(await execute('docker',['volume','inspect','--format','{{.Name}}',volume],{stdoutOnly:true})).trim();
27
+ if(retained!==volume)throw Error('External volume identity changed');
28
+ // Rejected archive input must fail cleanly, with no uncaught stdin EPIPE.
29
+ await execute('docker',['cp','-',`${project}-missing:/run`],{input:Buffer.alloc(1024*1024)}).then(()=>{throw Error('Missing container copy succeeded');},()=>{});
30
+ console.log('PASS: unprivileged executor reads only its private binding; writes, chmod and unlink fail; external volume preserved.');
31
+ } finally {
32
+ await execute('docker',[...args,'down']).catch(()=>{});
33
+ await execute('docker',['volume','rm',volume]).catch(()=>{});
34
+ await fs.rm(root,{recursive:true,force:true});
35
+ }
@@ -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.
package/docs/releasing.md CHANGED
@@ -22,6 +22,7 @@ separate release bot is not required.
22
22
  audit advances. Baseline CI does not validate a newly published native version.
23
23
  Managed installs and upgrades keep their existing native version selection.
24
24
  Main relay: `EZ_RELAY_IMAGE=ez-release-runtime node docker/smoke.mjs`.
25
+ Isolated executor: `EZ_RELAY_IMAGE=ez-release-runtime node docker/isolated-executor-smoke.mjs`.
25
26
  The WhatsApp Compose overlay has no separate relay image; verify its
26
27
  provider integration with the synthetic plugin smoke in `docs/plugins.md`.
27
28
  4. Create an artifact with `npm pack --ignore-scripts`. Inspect its file list,
@@ -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 after live native work finishes to a separate, unprivileged executor container. Pending requests remain queued for that executor; its private binding is copied into the container as root-owned, group-readable and not writable by the configured runtime user. Existing named volumes are attached as external volumes. It uses 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
@@ -58,6 +63,19 @@ restart and verify. Do not recreate the agent or its registry. Subsequent
58
63
  compatible upgrades are agent-owned. Noncanonical/multi-agent host configuration
59
64
  files require migration to one canonical deployment per service first.
60
65
 
66
+ ## Recovering an isolated-executor startup defect
67
+
68
+ If an older supervisor cannot start its isolated executor, queued requests can
69
+ prevent that same supervisor from installing its correction. Use an exact,
70
+ reviewed newer package's `src/updates/supervisor.mjs DEPLOYMENT_DIRECTORY` as the
71
+ existing OS service's temporary entry point. First verify that no native run is
72
+ active. Keep the same service user, environment, registry, deployment and queued
73
+ requests; do not edit published package files or delete claims. This CLI runs the
74
+ same supervisor and retains the active package binding until normal upgrade
75
+ activation. Let existing queued jobs finish, then prepare and apply the corrected
76
+ release through `ez updates`. Restore the retained bootstrap entry point after
77
+ the active package and running services verify the corrected version.
78
+
61
79
  ## Package-manager preflight and repair
62
80
 
63
81
  Before installing candidate dependencies or stopping services, the supervisor
@@ -153,7 +171,7 @@ is required. Stable automation excludes prereleases, major changes and 0.x minor
153
171
  changes. Beta policy permits compatible prereleases. Downgrades are rejected;
154
172
  rollback is a recovery operation for the previous installation only.
155
173
 
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.
174
+ `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
175
  One job at a time; an unresolved recovery blocks new upgrades. The agent processes
158
176
  multiple requested packages over successive update requests.
159
177
 
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.52",
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 }