@jc_stack/ez-agents 0.1.0-beta.49 → 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/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +28 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +62 -22
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +16 -15
- package/package.json +4 -2
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +4 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- 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 +63 -10
- package/src/host-executor-client.ts +23 -8
- package/src/host-executor.ts +63 -32
- package/src/index.ts +157 -35
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +67 -3
- package/src/schedule-cli.ts +24 -15
- package/src/scheduled-tasks.ts +53 -17
- package/src/scheduler.ts +7 -8
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/src/workspace.ts +12 -1
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +72 -4
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- 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 +71 -3
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +136 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +45 -15
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +55 -12
- package/test/workspace.test.ts +6 -0
package/src/setup-fallback.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { presetLabel, presetProvider, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
2
2
|
|
|
3
3
|
// Ordered task setups for one scheduled occurrence. Core advances only on typed
|
|
4
|
-
// evidence
|
|
5
|
-
//
|
|
6
|
-
|
|
4
|
+
// evidence: a missing CLI or a provider rejection before any model output or
|
|
5
|
+
// tool item, or a usage limit the run ended on after work began (continued in a
|
|
6
|
+
// fresh session that is told so). Anything else stops the chain.
|
|
7
|
+
export type StartupCategory = 'cli-unavailable' | 'login-unavailable' | 'quota' | 'provider-rejected' | 'access-denied'
|
|
7
8
|
export type StartupRejection = { category: StartupCategory; resetAt?: string }
|
|
8
|
-
export type AttemptCategory = StartupCategory | 'after-work-began' | 'uncertain' | 'same-quota-scope'
|
|
9
|
+
export type AttemptCategory = StartupCategory | 'quota-after-work-began' | 'after-work-began' | 'uncertain' | 'same-quota-scope' | 'same-login-scope'
|
|
9
10
|
export type SetupAttempt = {
|
|
10
11
|
preset: AiPreset
|
|
11
12
|
quotaScope: string
|
|
@@ -13,36 +14,54 @@ export type SetupAttempt = {
|
|
|
13
14
|
category?: AttemptCategory
|
|
14
15
|
resetAt?: string
|
|
15
16
|
exitCode?: number | null
|
|
17
|
+
sessionId?: string
|
|
16
18
|
startedAt?: string
|
|
17
19
|
endedAt?: string
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const categories: StartupCategory[] = ['cli-unavailable', 'quota', 'provider-rejected', 'access-denied']
|
|
21
|
-
|
|
22
|
+
const categories: StartupCategory[] = ['cli-unavailable', 'login-unavailable', 'quota', 'provider-rejected', 'access-denied']
|
|
23
|
+
|
|
24
|
+
// A failed attempt's category and reset time, and whether the occurrence
|
|
25
|
+
// continues on the next setup. Once work began only a quota limit the run ended
|
|
26
|
+
// on continues; access denial, other failures and unknown outcomes stop.
|
|
27
|
+
export const attemptFailure = ({ workBegan, rejection }: { workBegan: boolean; rejection?: StartupRejection }): { category: AttemptCategory; resetAt?: string; continues: boolean } => {
|
|
28
|
+
const resetAt = rejection?.resetAt ? { resetAt: rejection.resetAt } : {}
|
|
29
|
+
if (!workBegan) return rejection ? { category: rejection.category, ...resetAt, continues: rejection.category !== 'access-denied' } : { category: 'uncertain', continues: false }
|
|
30
|
+
return rejection?.category === 'quota' ? { category: 'quota-after-work-began', ...resetAt, continues: true } : { category: 'after-work-began', continues: false }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Transport fact for a setup that continues an occurrence after an earlier setup
|
|
34
|
+
// stopped at its usage limit once work had begun. Facts only, no guidance.
|
|
35
|
+
export const continuationLine = (runId: string, attempts: SetupAttempt[]): string | undefined => {
|
|
36
|
+
const limited = attempts.filter(a => a.category === 'quota-after-work-began')
|
|
37
|
+
return limited.length ? `[run ${runId} continues: ${limited.map(a => `${presetLabel(a.preset)} began work in native session ${a.sessionId} and stopped at its usage limit${a.resetAt ? ` (resets ${a.resetAt})` : ''}`).join('; ')}. That work may be partly applied.]` : undefined
|
|
38
|
+
}
|
|
22
39
|
|
|
23
40
|
export const setupCandidates = (execution: ExecutionChoice): AiPreset[] => [execution.preset, ...(execution.fallbacks ?? [])]
|
|
24
41
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
42
|
+
// The owner provisions native profiles for separate provider accounts. Models
|
|
43
|
+
// in one login share its quota scope; another named login may be tried once.
|
|
44
|
+
// OpenCode/Pi still share their upstream provider scope.
|
|
28
45
|
export const quotaScope = (preset: AiPreset): string => {
|
|
29
46
|
if (['opencode', 'pi'].includes(preset.cli)) {
|
|
30
47
|
const provider = preset.cli === 'pi' ? preset.model?.split('/')[0] : presetProvider(preset)
|
|
31
48
|
return provider ? `provider:${provider}` : preset.cli
|
|
32
49
|
}
|
|
33
|
-
|
|
50
|
+
const cli = preset.cli === 'codex-gui' ? 'codex' : preset.cli
|
|
51
|
+
return preset.authProfile ? `${cli}@${preset.authProfile}` : cli
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
// The next untried setup, skipping
|
|
37
|
-
// this occurrence. Each candidate is tried at most once.
|
|
54
|
+
// The next untried setup, skipping a login that already rejected authentication
|
|
55
|
+
// or reached its quota in this occurrence. Each candidate is tried at most once.
|
|
38
56
|
export const nextSetup = (candidates: AiPreset[], attempts: SetupAttempt[]): { skipped: SetupAttempt[]; preset?: AiPreset } => {
|
|
39
|
-
const exhausted = new
|
|
57
|
+
const exhausted = new Map(attempts.filter(a => ['quota', 'quota-after-work-began', 'login-unavailable'].includes(a.category!))
|
|
58
|
+
.map(a => [a.quotaScope, a.category === 'login-unavailable' ? 'same-login-scope' as const : 'same-quota-scope' as const]))
|
|
40
59
|
const skipped: SetupAttempt[] = []
|
|
41
60
|
for (const preset of candidates.slice(attempts.length)) {
|
|
42
61
|
const scope = quotaScope(preset)
|
|
43
62
|
if (!exhausted.has(scope)) return { skipped, preset }
|
|
44
63
|
const at = new Date().toISOString()
|
|
45
|
-
skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category:
|
|
64
|
+
skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category: exhausted.get(scope), startedAt: at, endedAt: at })
|
|
46
65
|
}
|
|
47
66
|
return { skipped }
|
|
48
67
|
}
|
|
@@ -57,20 +76,23 @@ const epochSeconds = (value: unknown): string | undefined =>
|
|
|
57
76
|
|
|
58
77
|
const claudeErrors: Record<string, StartupCategory> = {
|
|
59
78
|
rate_limit: 'quota', billing_error: 'quota',
|
|
60
|
-
authentication_failed: '
|
|
79
|
+
authentication_failed: 'login-unavailable', oauth_org_not_allowed: 'access-denied', account_on_hold: 'access-denied',
|
|
61
80
|
verification_required: 'access-denied', cloud_credential_error: 'access-denied',
|
|
62
81
|
overloaded: 'provider-rejected', server_error: 'provider-rejected', model_not_found: 'provider-rejected', invalid_request: 'provider-rejected',
|
|
63
82
|
}
|
|
64
83
|
|
|
84
|
+
const httpRejection = (status: unknown): StartupCategory | undefined =>
|
|
85
|
+
typeof status !== 'number' ? undefined : status === 401 ? 'login-unavailable' : status === 429 ? 'quota' :
|
|
86
|
+
status === 403 ? 'access-denied' : [400, 404].includes(status) || status >= 500 ? 'provider-rejected' : undefined
|
|
87
|
+
|
|
65
88
|
// Codex app-server `codexErrorInfo` of a failed turn.
|
|
66
89
|
export const codexRejection = (info: unknown): StartupCategory | undefined => {
|
|
67
90
|
if (info === 'usageLimitExceeded' || info === 'rateLimitExceeded') return 'quota'
|
|
68
|
-
if (info === 'unauthorized') return '
|
|
91
|
+
if (info === 'unauthorized') return 'login-unavailable'
|
|
69
92
|
if (['serverOverloaded', 'internalServerError', 'badRequest', 'flexUnavailable'].includes(info as string)) return 'provider-rejected'
|
|
70
93
|
if (!info || typeof info !== 'object') return undefined
|
|
71
94
|
const status = (Object.values(info)[0] as { httpStatusCode?: unknown } | undefined)?.httpStatusCode
|
|
72
|
-
|
|
73
|
-
return status === 429 ? 'quota' : [401, 403].includes(status) ? 'access-denied' : [400, 404].includes(status) || status >= 500 ? 'provider-rejected' : undefined
|
|
95
|
+
return httpRejection(status)
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
// The latest reset among exhausted Codex rate-limit windows.
|
|
@@ -85,29 +107,44 @@ export const startupLine = (state: { workBegan: boolean; rejection?: StartupReje
|
|
|
85
107
|
// Reads structured stdout only: Ez transport summaries (`ez.startup`) and
|
|
86
108
|
// Claude stream-json envelopes. Model text is never inspected. Any event that
|
|
87
109
|
// is not known pre-work metadata counts as work, so ambiguity never advances.
|
|
110
|
+
// The rejection is the one the run ended on: later work supersedes it, except
|
|
111
|
+
// an access denial, which always stops the chain.
|
|
88
112
|
export const startupObserver = () => {
|
|
89
113
|
let buffer = '', workBegan = false, rejection: StartupRejection | undefined
|
|
114
|
+
const reject = (value: StartupRejection) => { if (rejection?.category !== 'access-denied') rejection = value }
|
|
115
|
+
const work = () => { workBegan = true; if (rejection?.category !== 'access-denied') rejection = undefined }
|
|
90
116
|
const line = (text: string) => {
|
|
91
117
|
if (!text.trim()) return
|
|
92
118
|
let event: any
|
|
93
|
-
try { event = JSON.parse(text) } catch {
|
|
119
|
+
try { event = JSON.parse(text) } catch { work(); return }
|
|
120
|
+
// A summary is the sender's current state; one without a rejection means
|
|
121
|
+
// work superseded an earlier one.
|
|
94
122
|
if (event?.type === 'ez.startup') {
|
|
95
123
|
if (event.workBegan === true) workBegan = true
|
|
96
|
-
const r = event.rejection
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
const r = event.rejection, reset = typeof r?.resetAt === 'string' ? Date.parse(r.resetAt) : NaN
|
|
125
|
+
// Re-render the reset time: it reaches a continuation prompt, and
|
|
126
|
+
// Date.parse also accepts arbitrary words around a date.
|
|
127
|
+
if (r && categories.includes(r.category) && (r.resetAt === undefined || Number.isFinite(reset)))
|
|
128
|
+
reject({ category: r.category, ...(r.resetAt === undefined ? {} : { resetAt: new Date(reset).toISOString() }) })
|
|
129
|
+
else if (rejection?.category !== 'access-denied') rejection = undefined
|
|
99
130
|
return
|
|
100
131
|
}
|
|
101
132
|
if (event?.type === 'thread.started' || event?.type === 'result' ||
|
|
102
133
|
(event?.type === 'system' && event.subtype === 'init')) return
|
|
103
134
|
if (event?.type === 'rate_limit_event') {
|
|
104
135
|
const info = event.rate_limit_info
|
|
105
|
-
if (info?.status === 'rejected')
|
|
136
|
+
if (info?.status === 'rejected') reject({ category: 'quota', ...(epochSeconds(info.resetsAt) ? { resetAt: epochSeconds(info.resetsAt) } : {}) })
|
|
106
137
|
return
|
|
107
138
|
}
|
|
139
|
+
// Native API retry/error metadata is not model output. Do not inspect error
|
|
140
|
+
// prose; only typed HTTP status can establish a rejected startup.
|
|
141
|
+
if (event?.type === 'system' && ['api_error', 'api_retry'].includes(event.subtype)) {
|
|
142
|
+
const category = httpRejection(event.subtype === 'api_retry' ? event.error_status : event.error?.status)
|
|
143
|
+
if (category) { reject({ category }); return }
|
|
144
|
+
}
|
|
108
145
|
const category = event?.type === 'assistant' && typeof event.error === 'string' ? claudeErrors[event.error] : undefined
|
|
109
|
-
if (category)
|
|
110
|
-
else
|
|
146
|
+
if (category) reject({ category, ...(category === 'quota' && rejection?.resetAt ? { resetAt: rejection.resetAt } : {}) })
|
|
147
|
+
else work()
|
|
111
148
|
}
|
|
112
149
|
return {
|
|
113
150
|
write: (chunk: string) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const TOOLS_LINE: string
|
package/src/updates/binding.mjs
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath
|
|
4
|
-
import { atomic } from '../plugins/manager.mjs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { atomic, bindToolDiscovery } from '../plugins/manager.mjs';
|
|
5
5
|
import { read } from './control.mjs';
|
|
6
6
|
|
|
7
|
-
async function packageToolDiscovery(packageRoot,home,workspace) {
|
|
8
|
-
const entry=path.join(packageRoot,'src/plugins/manager.mjs');
|
|
9
|
-
const manager=await import(pathToFileURL(entry).href);
|
|
10
|
-
if(typeof manager.bindToolDiscovery!=='function')throw Error('Selected package has no tool discovery binding');
|
|
11
|
-
await manager.bindToolDiscovery(home,workspace);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
7
|
export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new URL('../../',import.meta.url))) {
|
|
15
8
|
home=await fs.realpath(home);hostConfig=await fs.realpath(hostConfig);
|
|
16
9
|
const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig),agent=host.agents[0];
|
|
@@ -28,6 +21,7 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
|
|
|
28
21
|
const dest=path.join(bin,name);await fs.rm(dest,{force:true});
|
|
29
22
|
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR:${JSON.stringify(agent.controlDir)}}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
|
|
30
23
|
}
|
|
31
|
-
|
|
24
|
+
// Idempotent: retires a former `ez tools` footer, never writes guidance.
|
|
25
|
+
await bindToolDiscovery(home,config.workspace);
|
|
32
26
|
return {ok:true,home,deploymentDir,packageRoot,policy:'Automatic compatible beta-channel updates by default; saved stable-only or manual policies take precedence. Local candidates require an explicit owner request.'};
|
|
33
27
|
}
|
package/src/updates/control.mjs
CHANGED
|
@@ -191,7 +191,8 @@ export async function submit(home,id,automatic) {
|
|
|
191
191
|
if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Installation changed since preparation');
|
|
192
192
|
if(digest(await fs.readFile(path.join(dir,'candidate.tgz')))!==job.sha256)throw Error('Candidate changed');
|
|
193
193
|
job.status='queued';job.automatic=automatic;await atomic(path.join(dir,'job.json'),job);return job;
|
|
194
|
-
|
|
194
|
+
// Queueing writes only the job; activation drains plugin calls before switching.
|
|
195
|
+
},{allowInvocations:true,waitMs:5000});
|
|
195
196
|
}
|
|
196
197
|
export async function retryRecovery(home,id) {
|
|
197
198
|
const {directory}=await state(home),dir=jobPath(home,id);
|
|
@@ -201,7 +202,7 @@ export async function retryRecovery(home,id) {
|
|
|
201
202
|
if((await jobs(home)).some(j=>['queued','applying'].includes(j.status)))throw Error('An upgrade is already pending');
|
|
202
203
|
await requireSupervisor(directory);
|
|
203
204
|
job.status='queued';job.recoveryRequested=true;await atomic(path.join(dir,'job.json'),job);return {id,status:job.status,recoveryRequested:true};
|
|
204
|
-
});
|
|
205
|
+
},{allowInvocations:true,waitMs:5000});
|
|
205
206
|
}
|
|
206
207
|
export async function command(home,args) {
|
|
207
208
|
const [action,...rest]=args;
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -33,8 +33,8 @@ export function execute(command,args,options={}) {
|
|
|
33
33
|
export async function textAtomic(file,text) {
|
|
34
34
|
const tmp=file+'.update.tmp';await fs.writeFile(tmp,text,{mode:0o600});await fs.rename(tmp,file);
|
|
35
35
|
}
|
|
36
|
-
export async function backupStateDirectory(source,destination) {
|
|
37
|
-
await fs.cp(source,destination,{recursive:true,filter:async entry=>!(await fs.lstat(entry)).isSocket()});
|
|
36
|
+
export async function backupStateDirectory(source,destination,omit=[]) {
|
|
37
|
+
await fs.cp(source,destination,{recursive:true,filter:async entry=>!omit.includes(path.relative(source,entry).split(path.sep)[0])&&!(await fs.lstat(entry)).isSocket()});
|
|
38
38
|
}
|
|
39
39
|
export const pluginArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
|
|
40
40
|
export async function packageManager(root,run=execute) {
|
|
@@ -159,7 +159,7 @@ export async function perform(home,job,hooks) {
|
|
|
159
159
|
const root=path.join(dir,'runtime');await fs.rm(root,{recursive:true,force:true});await extract(archive,root);
|
|
160
160
|
const next=await eligibility(home,job.target,root,job.automatic);
|
|
161
161
|
if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
|
|
162
|
-
job.status='applying';job.root=root;job.startedAt=new Date().toISOString();await save();
|
|
162
|
+
job.status='applying';job.preserveRuns=hooks.preserveRuns===true;job.root=root;job.startedAt=new Date().toISOString();await save();
|
|
163
163
|
const complete=async()=>{
|
|
164
164
|
job.status='completed';job.endedAt=new Date().toISOString();await save();
|
|
165
165
|
try { job.cleanup=await cleanupStaleBackups(home);await save(); }
|
|
@@ -193,17 +193,30 @@ export async function perform(home,job,hooks) {
|
|
|
193
193
|
});
|
|
194
194
|
const image=`ez-upgrade-${digest(JSON.stringify([job.sha256,buildArgs])).slice(0,24)}`;
|
|
195
195
|
await run('docker',['build','--target','runtime',...buildArgs,'-t',image,root]);
|
|
196
|
+
const backup=path.join(dir,'backup');
|
|
197
|
+
const backupState=async()=>{
|
|
198
|
+
await fs.mkdir(backup,{mode:0o700});
|
|
199
|
+
await backupStateDirectory(config.workspace,path.join(backup,'workspace'));
|
|
200
|
+
await backupStateDirectory(agent.controlDir,path.join(backup,'control'),hooks.preserveRuns ? ['host-executor'] : []);
|
|
201
|
+
for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
|
|
202
|
+
};
|
|
203
|
+
// Native client state can be many gigabytes. This advisory copy never
|
|
204
|
+
// rewinds live state, so compatible generations keep serving throughout it.
|
|
205
|
+
if(hooks.preserveRuns)await backupState();
|
|
196
206
|
const envFile=path.join(config.deploymentDir,'docker.env'),oldEnv=await fs.readFile(envFile,'utf8');
|
|
197
207
|
const cid=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
|
|
198
208
|
if(!cid||/\s/.test(cid))throw Error('Expected one running relay');
|
|
199
209
|
const oldImage=(await run('docker',['inspect','--format','{{.Image}}',cid])).trim();
|
|
200
210
|
if(!/^sha256:[a-f0-9]{64}$/.test(oldImage))throw Error('Cannot pin rollback image');
|
|
201
211
|
job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
212
|
+
const activate=async()=>{
|
|
213
|
+
const current=await eligibility(home,job.target,root,job.automatic);
|
|
214
|
+
if(current.old.root!==job.previousRoot||current.old.pkg.version!==job.previousVersion)throw Error('Stale upgrade job');
|
|
215
|
+
const pause=path.join(agent.controlDir,'upgrade-pause.json');
|
|
216
|
+
if(hooks.preserveRuns)await atomic(pause,{id:job.id});
|
|
217
|
+
try {
|
|
218
|
+
await run('docker',[...relayArgs(config),'stop','relay']);await (hooks.preserveRuns ? hooks.retireHost() : hooks.stopHost());
|
|
219
|
+
if(!hooks.preserveRuns)await backupState();
|
|
207
220
|
let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
|
|
208
221
|
await textAtomic(envFile,env);
|
|
209
222
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),root);
|
|
@@ -213,6 +226,9 @@ export async function perform(home,job,hooks) {
|
|
|
213
226
|
const brokerImage=(await run('docker',['image','inspect','--format','{{.Id}}',image],{stdoutOnly:true})).trim();
|
|
214
227
|
job.brokerReadiness=await refreshBroker(config,run,brokerImage);
|
|
215
228
|
}
|
|
229
|
+
}finally{if(hooks.preserveRuns)await fs.rm(pause,{force:true});}
|
|
230
|
+
};
|
|
231
|
+
if(hooks.preserveRuns)await locked(home,activate,{drainInvocations:true,signal:hooks.signal});else await activate();
|
|
216
232
|
} else {
|
|
217
233
|
const old=next.old.record;
|
|
218
234
|
const secrets=await read(path.join(home,'packages',job.target,'secrets.json')).catch(e=>{if(e.code==='ENOENT')return {};throw e;});
|
|
@@ -266,7 +282,7 @@ export async function recover(home,job,hooks) {
|
|
|
266
282
|
return /^sha256:[a-f0-9]{64}$/.test(pinned||'')&¤t.Image===pinned&¤t.State?.Running===true&¤t.State?.Health?.Status==='healthy';
|
|
267
283
|
})().catch(()=>false);
|
|
268
284
|
if(!retained)await run('docker',[...relayArgs(config),'stop','relay']);
|
|
269
|
-
await hooks.stopHost();
|
|
285
|
+
await (hooks.preserveRuns ? hooks.retireHost() : hooks.stopHost());
|
|
270
286
|
await textAtomic(path.join(config.deploymentDir,'docker.env'),job.rollback.env);
|
|
271
287
|
await bindUpdates(home,path.join(config.deploymentDir,'host-executor.json'),job.rollback.packageRoot);
|
|
272
288
|
await hooks.startHost(job.rollback.packageRoot);
|
|
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import { atomic, locked } from '../plugins/manager.mjs';
|
|
6
6
|
import { callDeliverySocket } from '../delivery-socket-client.mjs';
|
|
7
7
|
import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
|
|
8
|
-
import { perform, environment, brokerNeedsRefresh } from './runtime.mjs';
|
|
8
|
+
import { perform, environment, brokerNeedsRefresh, execute, relayArgs } from './runtime.mjs';
|
|
9
9
|
|
|
10
10
|
const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
|
|
11
11
|
const providerEnvironmentKey=value=>{
|
|
@@ -54,7 +54,7 @@ export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait
|
|
|
54
54
|
}
|
|
55
55
|
// Plugin replacement drains command leases at activation, not native turns.
|
|
56
56
|
export async function withUpgrade(control,job,signal,apply,options) {
|
|
57
|
-
if(job.target==='main'||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
|
|
57
|
+
if((job.target==='main'&&!options?.preserveRuns)||options?.requiresIdle)return withIdleUpgrade(control,job,signal,apply,options);
|
|
58
58
|
if(!signal.aborted)return apply();
|
|
59
59
|
}
|
|
60
60
|
export async function queueAutomatic(home,available) {
|
|
@@ -84,20 +84,88 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
84
84
|
if(prior&&(!Number.isSafeInteger(prior.pid)||prior.pid<1))throw Error('Corrupt supervisor lock');
|
|
85
85
|
if(prior){try{process.kill(prior.pid,0);throw Error('Supervisor already running');}catch(e){if(e.code!=='ESRCH')throw e;}await fs.rm(lock);}
|
|
86
86
|
await fs.writeFile(lock,JSON.stringify({pid:process.pid}),{flag:'wx',mode:0o600});
|
|
87
|
-
let child;
|
|
87
|
+
let child,container;
|
|
88
|
+
let retirements=[];
|
|
89
|
+
let reloadPending=false;
|
|
90
|
+
const generations=new Map();
|
|
91
|
+
const removeContainer=async id=>{
|
|
92
|
+
const generation=generations.get(id);
|
|
93
|
+
if(generation){await execute('docker',['compose','--project-name',generation.project,'--file',generation.file,'down']);await fs.rm(generation.file,{force:true});await fs.rm(generation.configFile,{force:true});generations.delete(id);}
|
|
94
|
+
else await execute('docker',['rm',id]);
|
|
95
|
+
};
|
|
88
96
|
const stopHost=async()=>{
|
|
97
|
+
if(container){await execute('docker',['stop','--time','10',container]);await removeContainer(container);container=undefined;}
|
|
89
98
|
if(!child)return;const running=child;child=undefined;
|
|
90
99
|
if(running.exitCode!==null)return;
|
|
91
100
|
const closed=new Promise(resolve=>running.once('close',resolve));running.kill('SIGTERM');
|
|
92
101
|
const timer=setTimeout(()=>running.kill('SIGKILL'),10000);await closed;clearTimeout(timer);
|
|
93
102
|
};
|
|
94
103
|
const startHost=async root=>{
|
|
95
|
-
if(host.isolation==='isolated')
|
|
104
|
+
if(host.isolation==='isolated') {
|
|
105
|
+
const pkg=await read(path.join(root,'package.json'));
|
|
106
|
+
if(pkg.ezRelease?.containerExecutor!==1)return;
|
|
107
|
+
const lock=path.join(agent.controlDir,'host-executor/worker.lock');
|
|
108
|
+
const prior=await read(lock).catch(missing);
|
|
109
|
+
if(prior?.containerId){
|
|
110
|
+
if(!/^[a-f0-9]{12,64}$/.test(prior.containerId))throw Error('Invalid previous isolated worker identity');
|
|
111
|
+
const running=await execute('docker',['inspect','--format','{{.State.Running}}',prior.containerId],{stdoutOnly:true}).catch(()=>null);
|
|
112
|
+
if(running?.trim()==='true')throw Error('Previous isolated worker is still running');
|
|
113
|
+
// Only the supervisor can prove a foreign container stopped.
|
|
114
|
+
if(JSON.stringify(await read(lock))!==JSON.stringify(prior))throw Error('Worker lock changed during recovery');
|
|
115
|
+
await fs.rm(lock);
|
|
116
|
+
}
|
|
117
|
+
const config=(await state(home)).config;
|
|
118
|
+
const deployment=JSON.parse(await execute('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
|
|
119
|
+
const relay=deployment.services.relay;
|
|
120
|
+
if(!relay?.image||!Array.isArray(relay.volumes))throw Error('Invalid isolated relay binding');
|
|
121
|
+
const generation=path.basename(root).replace(/[^a-zA-Z0-9_.-]/g,'-')+'-'+Date.now();
|
|
122
|
+
const project='ez-executor-'+generation.toLowerCase();
|
|
123
|
+
const file=path.join(directory,project+'.json');
|
|
124
|
+
const configFile=path.join(directory,project+'-binding.json');
|
|
125
|
+
await atomic(configFile,{...host,agents:host.agents.map(a=>({...a,binDir:'/app/bin'}))});
|
|
126
|
+
const worker={image:relay.image,init:true,restart:'no',user:`${relay.environment.EZ_RUNTIME_UID||1000}:${relay.environment.EZ_RUNTIME_GID||1000}`,
|
|
127
|
+
cap_drop:['ALL'],security_opt:['no-new-privileges:true'],entrypoint:['node','--import','/app/node_modules/tsx/dist/loader.mjs','/app/src/host-executor.ts','/run/executor.json'],
|
|
128
|
+
environment:{...relay.environment,EZ_CONTAINER_EXECUTOR:'1',EZ_EXECUTOR_TRANSPORT:'local'},
|
|
129
|
+
volumes:[...relay.volumes,{type:'bind',source:configFile,target:'/run/executor.json',read_only:true}],tmpfs:['/tmp'],
|
|
130
|
+
...Object.fromEntries(['networks','network_mode','extra_hosts','dns','dns_search'].filter(key=>relay[key]!==undefined).map(key=>[key,relay[key]]))};
|
|
131
|
+
if(worker.networks&&!Array.isArray(worker.networks))worker.networks=Object.fromEntries(Object.keys(worker.networks).map(name=>[name,{}]));
|
|
132
|
+
const networks=worker.networks?Object.fromEntries(Object.entries(deployment.networks||{}).map(([name,binding])=>[name,{name:binding.name,external:true}])):undefined;
|
|
133
|
+
await atomic(file,{services:{executor:worker},...(networks?{networks}:{})});
|
|
134
|
+
await execute('docker',['compose','--project-name',project,'--file',file,'up','-d','--no-build','executor']);
|
|
135
|
+
container=(await execute('docker',['compose','--project-name',project,'--file',file,'ps','-q','executor'],{stdoutOnly:true})).trim();
|
|
136
|
+
if(!/^[a-f0-9]{12,64}$/.test(container))throw Error('Missing isolated executor');
|
|
137
|
+
generations.set(container,{project,file,configFile});
|
|
138
|
+
for(let n=0;n<300;n++){
|
|
139
|
+
const running=await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true});
|
|
140
|
+
if(running.trim()!=='true')throw Error('Isolated executor exited during startup');
|
|
141
|
+
const heartbeat=await read(path.join(agent.controlDir,'host-executor/heartbeat.json')).catch(missing);
|
|
142
|
+
if(heartbeat?.containerExecutor===true&&container.startsWith(heartbeat.containerId||'missing')&&Date.now()-heartbeat.at<1000)return;
|
|
143
|
+
await sleep(100);
|
|
144
|
+
}
|
|
145
|
+
throw Error('Isolated executor heartbeat timeout');
|
|
146
|
+
}
|
|
96
147
|
child=spawn(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'src/host-executor.ts'),path.join(deployment,'host-executor.json')],
|
|
97
|
-
{env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
|
|
148
|
+
{detached:true,env:{...environment(),...providerEnvironment(host),EZ_HOST_SUPERVISOR_PID:String(process.pid)},stdio:['ignore','inherit','inherit']});
|
|
98
149
|
let error;child.once('error',e=>{error=e;});
|
|
99
150
|
await waitForHostHeartbeat(agent.controlDir,child,()=>error);
|
|
100
151
|
};
|
|
152
|
+
const retireHost=async()=>{
|
|
153
|
+
if(container){await execute('docker',['kill','--signal','SIGUSR2',container]);retirements.push({container});container=undefined;}
|
|
154
|
+
if(child){const running=child;child=undefined;if(running.exitCode===null){running.kill('SIGUSR2');retirements.push({child:running});}}
|
|
155
|
+
// A retiring generation releases its single claim lock before replacement.
|
|
156
|
+
for(let n=0;n<300;n++){
|
|
157
|
+
if(!await fs.stat(path.join(agent.controlDir,'host-executor/worker.lock')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e;}))return;
|
|
158
|
+
await sleep(100);
|
|
159
|
+
}
|
|
160
|
+
throw Error('Native executor retirement timeout');
|
|
161
|
+
};
|
|
162
|
+
const preserveRuns=async job=>{
|
|
163
|
+
if(job.target!=='main')return false;
|
|
164
|
+
const old=await read(path.join((await state(home)).config.packageRoot,'package.json'));
|
|
165
|
+
const candidate=await read(path.join(jobPath(home,job.id),'package/package.json'));
|
|
166
|
+
return old.ezRelease?.runHandoff===1&&candidate.ezRelease?.runHandoff===1&&
|
|
167
|
+
(host.isolation!=='isolated'||(container&&old.ezRelease?.containerExecutor===1&&candidate.ezRelease?.containerExecutor===1));
|
|
168
|
+
};
|
|
101
169
|
const pause=path.join(agent.controlDir,'upgrade-pause.json');
|
|
102
170
|
const refreshRequirement=async job=>{
|
|
103
171
|
if(job.target==='main')return false;
|
|
@@ -119,36 +187,56 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
119
187
|
if(owner&&prior&&owner.pid===prior.pid)await fs.rm(registryLock);
|
|
120
188
|
// Wait for an orphaned transport's parent-watch to terminate it first.
|
|
121
189
|
await sleep(1200);
|
|
190
|
+
const workerOwner=await read(path.join(agent.controlDir,'host-executor/worker.lock')).catch(missing);
|
|
191
|
+
if(host.isolation==='isolated'&&workerOwner?.containerId){
|
|
192
|
+
if(!/^[a-f0-9]{12,64}$/.test(workerOwner.containerId))throw Error('Invalid isolated worker identity');
|
|
193
|
+
if((await execute('docker',['inspect','--format','{{.State.Running}}',workerOwner.containerId],{stdoutOnly:true}).catch(()=>''))?.trim()==='true')container=workerOwner.containerId;
|
|
194
|
+
}
|
|
122
195
|
const interrupted=(await jobs(home)).find(j=>j.status==='applying');
|
|
123
196
|
if(interrupted){
|
|
124
197
|
const refreshPluginBroker=await refreshRequirement(interrupted);
|
|
125
198
|
const result=refreshPluginBroker===null?undefined:interrupted.target==='main'
|
|
126
|
-
? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost}));}finally{await fs.rm(pause,{force:true});}})()
|
|
127
|
-
: await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
|
|
128
|
-
if(result?.status
|
|
199
|
+
? await (async()=>{await atomic(pause,{id:interrupted.id});try{return await locked(home,()=>perform(home,interrupted,{stopHost,startHost,retireHost,preserveRuns:interrupted.preserveRuns===true}));}finally{await fs.rm(pause,{force:true});}})()
|
|
200
|
+
: await withUpgrade(agent.controlDir,interrupted,signal,()=>perform(home,interrupted,{stopHost,startHost,retireHost,signal,refreshPluginBroker}),{requiresIdle:refreshPluginBroker});
|
|
201
|
+
if(['completed','rolled-back'].includes(result?.status)&&interrupted.target==='main')reloadPending=true;
|
|
129
202
|
}
|
|
130
203
|
const isolated=host.isolation==='isolated'
|
|
131
|
-
if(!isolated
|
|
204
|
+
if(!isolated&&!child)await startHost((await state(home)).config.packageRoot);
|
|
205
|
+
const initializeIsolated=async()=>{
|
|
206
|
+
if(!isolated||container)return;
|
|
207
|
+
const root=(await state(home)).config.packageRoot;
|
|
208
|
+
if((await read(path.join(root,'package.json'))).ezRelease?.containerExecutor!==1)return;
|
|
209
|
+
await withIdleUpgrade(agent.controlDir,{id:'executor-migration'},signal,()=>startHost(root));
|
|
210
|
+
};
|
|
211
|
+
await initializeIsolated();
|
|
132
212
|
let nextCheck=0;
|
|
133
213
|
while(!signal.aborted) {
|
|
134
214
|
if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
|
|
135
|
-
|
|
215
|
+
await initializeIsolated();
|
|
216
|
+
if(container&&(await execute('docker',['inspect','--format','{{.State.Running}}',container],{stdoutOnly:true})).trim()!=='true')throw Error('Isolated executor exited; supervisor service should restart');
|
|
217
|
+
if(reloadPending&&await idle(agent.controlDir))return;
|
|
218
|
+
// Reap retired containers only after their worker exits.
|
|
219
|
+
for(const item of retirements.filter(r=>r.container)){
|
|
220
|
+
if((await execute('docker',['inspect','--format','{{.State.Running}}',item.container],{stdoutOnly:true})).trim()==='false'){await removeContainer(item.container);retirements=retirements.filter(r=>r!==item);}
|
|
221
|
+
}
|
|
222
|
+
const pending=!reloadPending&&(await jobs(home)).find(j=>j.status==='queued');
|
|
136
223
|
if(pending) {
|
|
137
224
|
const refreshPluginBroker=await refreshRequirement(pending);
|
|
138
225
|
if(refreshPluginBroker===null)continue;
|
|
226
|
+
const preserve=await preserveRuns(pending);
|
|
139
227
|
const apply=async()=>{
|
|
140
228
|
try {
|
|
141
|
-
const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,signal,refreshPluginBroker});};
|
|
142
|
-
return pending.target==='main'?await locked(home,activate):await activate();
|
|
229
|
+
const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,retireHost,signal,refreshPluginBroker,preserveRuns:preserve});};
|
|
230
|
+
return pending.target==='main'&&!preserve?await locked(home,activate,{drainInvocations:true,signal}):await activate();
|
|
143
231
|
} catch(error) {
|
|
144
232
|
// A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
|
|
145
233
|
const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
|
|
146
234
|
if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
|
|
147
235
|
}
|
|
148
236
|
};
|
|
149
|
-
const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker});
|
|
150
|
-
if(result?.status
|
|
151
|
-
if(pending.target==='main')
|
|
237
|
+
const result=await withUpgrade(agent.controlDir,pending,signal,apply,{requiresIdle:refreshPluginBroker,preserveRuns:preserve});
|
|
238
|
+
if(['completed','rolled-back'].includes(result?.status)) {
|
|
239
|
+
if(pending.target==='main')reloadPending=true;
|
|
152
240
|
nextCheck=0;
|
|
153
241
|
}
|
|
154
242
|
}
|
|
@@ -166,7 +254,8 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
166
254
|
await sleep(500);
|
|
167
255
|
}
|
|
168
256
|
}finally {
|
|
169
|
-
clearInterval(beat);await stopHost();
|
|
257
|
+
clearInterval(beat);await stopHost();
|
|
258
|
+
for(const item of retirements){if(item.container){await execute('docker',['stop','--time','10',item.container]);await removeContainer(item.container);}else if(item.child.exitCode===null)item.child.kill('SIGTERM');}await fs.rm(lock,{force:true});
|
|
170
259
|
if(!(await jobs(home)).some(j=>j.status==='applying'))await fs.rm(pause,{force:true});
|
|
171
260
|
}
|
|
172
261
|
}
|
package/src/workspace.ts
CHANGED
|
@@ -2,6 +2,17 @@ import { installAgentGuidance } from './agent-guidance.js'
|
|
|
2
2
|
import { link, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { TOOLS_LINE } from './tools-line.mjs'
|
|
6
|
+
|
|
7
|
+
// Engine-neutral memory: every native CLI loads AGENTS.md, so both read the
|
|
8
|
+
// same plain files. The agent owns the folder and the section after seeding.
|
|
9
|
+
const MEMORY_SEED = `## Memory
|
|
10
|
+
|
|
11
|
+
Keep durable facts (people, preferences, decisions, standing commitments) in
|
|
12
|
+
\`memory/\`, one fact per file, each listed on one line in \`memory/MEMORY.md\`.
|
|
13
|
+
Read \`memory/MEMORY.md\` when a conversation starts. Save or update a fact as
|
|
14
|
+
soon as you learn something a future conversation will need; skip what other
|
|
15
|
+
workspace files already hold.`
|
|
5
16
|
|
|
6
17
|
// Publish each complete seed exclusively. Reinstall never replaces the agent's mind.
|
|
7
18
|
export const initializeWorkspace = async (workspace: string, purposeFile: string | undefined = process.env.EZ_AGENT_PURPOSE_FILE): Promise<string[]> => {
|
|
@@ -22,7 +33,7 @@ export const initializeWorkspace = async (workspace: string, purposeFile: string
|
|
|
22
33
|
try {
|
|
23
34
|
const purpose = (await readFile(purposeFile, 'utf8')).trim()
|
|
24
35
|
if (!purpose) throw new Error('Purpose file must not be empty.')
|
|
25
|
-
await writeFile(temporary, `## Purpose\n\n${purpose}\n`, { mode: 0o600, flag: 'wx' })
|
|
36
|
+
await writeFile(temporary, `## Purpose\n\n${purpose}\n\n${MEMORY_SEED}\n\n${TOOLS_LINE}\n`, { mode: 0o600, flag: 'wx' })
|
|
26
37
|
try {
|
|
27
38
|
await link(temporary, target)
|
|
28
39
|
created.push(name)
|
package/test/ai-cli.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path'
|
|
|
6
6
|
import {spawnSync} from 'node:child_process'
|
|
7
7
|
import {fileURLToPath} from 'node:url'
|
|
8
8
|
import {ControlStore} from '../src/control-state.js'
|
|
9
|
+
import {executorJobEnv} from '../src/executor.js'
|
|
9
10
|
import {initialPreset} from '../src/ai.js'
|
|
10
11
|
|
|
11
12
|
test('explicit CLI/model selection preserves installation default and rejects unavailable choices',async()=>{
|
|
@@ -50,12 +51,33 @@ test('an agent can select a declared provider and model without putting the key
|
|
|
50
51
|
await writeFile(path.join(binDir,'codex'),'#!/bin/sh\nexit 0\n',{mode:0o700})
|
|
51
52
|
await writeFile(path.join(controlDir,'host-executor','models.json'),JSON.stringify([{cli:'codex',provider:'openrouter',model:'google/gemini-3.8-flash',name:'Gemini',efforts:[]}]))
|
|
52
53
|
const store=new ControlStore(controlDir,900000);await store.aiState(initialPreset('codex'))
|
|
53
|
-
const env={...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,
|
|
54
|
+
const env=executorJobEnv({runId:'fixture',controlDir,binDir,catalogTransport:'host'},{...process.env,HOME:root,PATH:binDir+path.delimiter+process.env.PATH,OPENROUTER_API_KEY:'must-not-persist'})
|
|
54
55
|
const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
|
|
55
56
|
const result=spawnSync(process.execPath,[bin,'select','--cli','codex','--provider','openrouter','--model','google/gemini-3.8-flash'],{env,encoding:'utf8'})
|
|
56
57
|
assert.equal(result.status,0,result.stderr)
|
|
57
58
|
const state=await store.status(),selected=state.ai?.presets.find(p=>p.id===state.ai?.selectedId)
|
|
58
59
|
assert.equal(selected?.provider,'openrouter');assert.equal(selected?.model,'google/gemini-3.8-flash')
|
|
59
60
|
assert.equal(JSON.stringify(state).includes('must-not-persist'),false)
|
|
61
|
+
assert.equal(env.EZ_EXECUTOR_TRANSPORT,undefined)
|
|
62
|
+
assert.equal(env.EZ_ISOLATION,undefined)
|
|
63
|
+
}finally{await rm(root,{recursive:true,force:true})}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
test('isolated AI CLI ignores a legacy host catalog and discovers its own Sonnet',async()=>{
|
|
68
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-ai-local-catalog-'))
|
|
69
|
+
try{
|
|
70
|
+
const controlDir=path.join(root,'control'),binDir=path.join(root,'bin')
|
|
71
|
+
await mkdir(path.join(controlDir,'host-executor'),{recursive:true});await mkdir(binDir)
|
|
72
|
+
const stale=JSON.stringify([{cli:'codex',model:'host-only',name:'Host only',efforts:[]}])
|
|
73
|
+
await writeFile(path.join(controlDir,'host-executor','models.json'),stale)
|
|
74
|
+
await writeFile(path.join(binDir,'claude'),"#!/bin/sh\nprintf '%s\n' \" --model <model> Model (e.g. 'sonnet', 'opus')\" \" --effort <level> Effort (low, high)\"\n",{mode:0o700})
|
|
75
|
+
const env={...process.env,HOME:root,PATH:binDir,EZ_CONTROL_DIR:controlDir,EZ_ISOLATION:'isolated',EZ_EXECUTOR_TRANSPORT:'local'}
|
|
76
|
+
const bin=fileURLToPath(new URL('../bin/ezenciel-agents-ai.mjs',import.meta.url))
|
|
77
|
+
const result=spawnSync(process.execPath,[bin,'list'],{env,encoding:'utf8'})
|
|
78
|
+
assert.equal(result.status,0,result.stderr)
|
|
79
|
+
const catalog=JSON.parse(result.stdout)
|
|
80
|
+
assert.ok(catalog.some((m:{cli:string;model:string})=>m.cli==='claude'&&m.model==='sonnet'))
|
|
81
|
+
assert.ok(!catalog.some((m:{model:string})=>m.model==='host-only'))
|
|
60
82
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
61
83
|
})
|