@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 +17 -0
- package/docker/isolated-executor-smoke.mjs +35 -0
- package/docs/docker-runtime.md +12 -2
- package/docs/releasing.md +1 -0
- package/docs/scheduling.md +7 -4
- package/docs/upgrades.md +20 -2
- package/package.json +4 -2
- package/src/ai-cli.ts +4 -2
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +54 -10
- package/src/host-executor-client.ts +4 -1
- package/src/host-executor.ts +63 -32
- package/src/index.ts +37 -19
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +8 -2
- package/src/schedule-cli.ts +6 -3
- package/src/scheduled-tasks.ts +12 -10
- package/src/scheduler.ts +6 -7
- package/src/updates/runtime.mjs +27 -10
- package/src/updates/supervisor.mjs +137 -18
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +24 -2
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +8 -1
- package/test/plugin-manager.test.mjs +1 -1
- package/test/run-handoff.test.ts +4 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +23 -15
- package/test/updates.test.mjs +64 -3
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
|
+
}
|
package/docs/docker-runtime.md
CHANGED
|
@@ -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
|
|
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,
|
package/docs/scheduling.md
CHANGED
|
@@ -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
|
|
342
|
-
occurrence or a queued/running occurrence.
|
|
343
|
-
|
|
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
|
-
|
|
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.
|
|
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[]>=>
|
|
19
|
-
|
|
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'){
|
package/src/control-state.ts
CHANGED
|
@@ -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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/delivery-socket.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
556
|
-
export const terminateJob = (child: ChildProcess, inspect = processSnapshot):
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
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 }
|