@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 +11 -0
- package/docs/docker-runtime.md +12 -2
- package/docs/scheduling.md +7 -4
- package/docs/upgrades.md +7 -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 +25 -9
- package/src/updates/supervisor.mjs +105 -16
- 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 +36 -2
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.
|
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/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 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.
|
|
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[]>=>
|
|
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 }
|
package/src/host-executor.ts
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
}
|