@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.47
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/.dockerignore +2 -0
- package/CHANGELOG.md +17 -0
- package/CONTRIBUTING.md +1 -1
- package/Dockerfile +13 -9
- package/docker/engine-pnpm-workspace.yaml +4 -0
- package/docker/smoke.mjs +1 -1
- package/docs/architecture/ai-selection.md +8 -2
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/development-and-testing.md +1 -1
- package/docs/docker-runtime.md +10 -0
- package/docs/plugin-guide.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/releasing.md +1 -1
- package/docs/scheduling.md +17 -3
- package/docs/upgrades.md +3 -1
- package/package.json +4 -3
- package/pnpm-workspace.yaml +7 -0
- package/scripts/smoke-scheduler.ts +4 -1
- package/src/ai.ts +20 -2
- package/src/delivery-socket.ts +15 -1
- package/src/host-executor.ts +5 -0
- package/src/index.ts +2 -0
- package/src/install-tools.mjs +11 -5
- package/src/model-policy.ts +4 -0
- package/src/runs.ts +4 -1
- package/src/schedule-cli.ts +12 -3
- package/src/scheduler.ts +28 -3
- package/src/task-executor.ts +2 -2
- package/src/updates/runtime.mjs +7 -4
- package/src/updates/supervisor.mjs +23 -13
- package/test/ai.test.ts +45 -5
- package/test/channel-owner.test.ts +2 -2
- package/test/desktop-bridge.test.ts +2 -1
- package/test/failure.test.ts +4 -4
- package/test/host-executor.test.ts +4 -4
- package/test/install-tools.test.mjs +6 -5
- package/test/native-tasks.test.ts +1 -1
- package/test/schedule-cli.test.ts +47 -3
- package/test/scheduled-tasks.test.ts +2 -2
- package/test/scheduler-host.test.ts +14 -3
- package/test/scheduler-relay.test.ts +47 -2
- package/test/scheduler.test.ts +21 -1
- package/test/tasks.test.ts +1 -1
- package/test/updates.test.mjs +32 -9
package/src/task-executor.ts
CHANGED
|
@@ -11,9 +11,9 @@ import { executorEnvironment, executorInvocation, terminateJob, type ExecutorOpt
|
|
|
11
11
|
|
|
12
12
|
// This adapter is deliberately version-pinned: a new native tool default needs
|
|
13
13
|
// a fresh tool-inventory audit before external correspondence can use it.
|
|
14
|
-
export const TASK_CODEX_VERSION = '0.
|
|
14
|
+
export const TASK_CODEX_VERSION = '0.160.0'
|
|
15
15
|
export const taskDisabledFeatures = ['apps', 'browser_use', 'computer_use', 'in_app_browser', 'image_generation',
|
|
16
|
-
'memories', 'multi_agent', 'multi_agent_v2', 'hooks', 'shell_tool', 'unified_exec', 'code_mode', 'code_mode_host',
|
|
16
|
+
'memories', 'goals', 'multi_agent', 'multi_agent_v2', 'hooks', 'shell_tool', 'unified_exec', 'code_mode', 'code_mode_host',
|
|
17
17
|
'skill_search', 'skill_mcp_dependency_install', 'tool_suggest', 'workspace_dependencies', 'view_image']
|
|
18
18
|
// Model catalog defaults can override disabled feature flags (for example,
|
|
19
19
|
// code-only tools and v2 collaboration). Use the audited direct-tool surface.
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { latestCodexVersion } from '../install-tools.mjs';
|
|
1
2
|
import { sharedIdentity } from '../plugins/shared.mjs';
|
|
2
3
|
import * as fs from 'node:fs/promises';
|
|
3
4
|
import { createWriteStream } from 'node:fs';
|
|
@@ -113,13 +114,15 @@ export async function perform(home,job,hooks) {
|
|
|
113
114
|
await run(process.execPath,['--import',path.join(root,'node_modules/tsx/dist/loader.mjs'),path.join(root,'bin/ezenciel-agents.mjs'),'--version'],{cwd:root});
|
|
114
115
|
try { await replaceGuidance(root, config.workspace, run); }
|
|
115
116
|
catch(error) { console.error(`Workspace guidance replacement failed; upgrade continues: ${error.message}`); }
|
|
116
|
-
//
|
|
117
|
-
//
|
|
117
|
+
// Resolve managed Codex at activation; old deployment pins cannot override latest.
|
|
118
|
+
// Preserve unrelated operator build arguments.
|
|
118
119
|
const deployment=JSON.parse(await run('docker',[...relayArgs(config),'config','--format','json'],{stdoutOnly:true}));
|
|
119
120
|
const args=deployment.services?.relay?.build?.args||{};
|
|
120
121
|
if(typeof args!=='object'||Array.isArray(args))throw Error('Invalid relay build arguments');
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
job.codexVersion=await latestCodexVersion(run,job.packageManager);await save();
|
|
123
|
+
const effective={...args,CODEX_CLI_VERSION:job.codexVersion};
|
|
124
|
+
const buildArgs=Object.keys(effective).sort().flatMap(key=>{
|
|
125
|
+
const value=effective[key];
|
|
123
126
|
if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)||typeof value!=='string'||value.includes('\0'))throw Error('Unresolved relay build argument');
|
|
124
127
|
return ['--build-arg',`${key}=${value}`];
|
|
125
128
|
});
|
|
@@ -38,6 +38,20 @@ export async function idle(control) {
|
|
|
38
38
|
const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
|
|
39
39
|
return !status||status.running===0;
|
|
40
40
|
}
|
|
41
|
+
// Pause admission only after work is idle, and release it if a turn raced the
|
|
42
|
+
// check. A queued upgrade must never hold the owner's chat behind a long turn.
|
|
43
|
+
export async function withIdleUpgrade(control,job,signal,apply,{isIdle=idle,wait=sleep}={}) {
|
|
44
|
+
if(signal.aborted||!await isIdle(control))return;
|
|
45
|
+
const pause=path.join(control,'upgrade-pause.json');
|
|
46
|
+
await atomic(pause,{id:job.id});
|
|
47
|
+
try {
|
|
48
|
+
for(let n=0;n<3;n++) {
|
|
49
|
+
await wait(500);
|
|
50
|
+
if(signal.aborted||!await isIdle(control))return;
|
|
51
|
+
}
|
|
52
|
+
if(!signal.aborted)return await apply();
|
|
53
|
+
} finally {await fs.rm(pause,{force:true});}
|
|
54
|
+
}
|
|
41
55
|
export async function queueAutomatic(home,available) {
|
|
42
56
|
const existing=await jobs(home);
|
|
43
57
|
if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
|
|
@@ -103,19 +117,15 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
103
117
|
if(!isolated && child?.exitCode!==null&&child?.exitCode!==undefined)throw Error('Host transport exited; supervisor service should restart');
|
|
104
118
|
const pending=(await jobs(home)).find(j=>j.status==='queued');
|
|
105
119
|
if(pending) {
|
|
106
|
-
await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
// A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
|
|
116
|
-
const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
|
|
117
|
-
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;
|
|
118
|
-
} finally {await fs.rm(pause,{force:true});}
|
|
120
|
+
const result=await withIdleUpgrade(agent.controlDir,pending,signal,async()=>{
|
|
121
|
+
try {
|
|
122
|
+
return await locked(home,async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost});});
|
|
123
|
+
} catch(error) {
|
|
124
|
+
// A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
|
|
125
|
+
const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
|
|
126
|
+
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;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
119
129
|
if(result?.status==='completed') {
|
|
120
130
|
if(pending.target==='main')return;
|
|
121
131
|
nextCheck=0;
|
package/test/ai.test.ts
CHANGED
|
@@ -4,13 +4,49 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
|
-
import { initialPreset, chatPreset, readModels, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
|
|
7
|
+
import { initialPreset, chatPreset, readModels, readCodexCatalog, isPreset, readOpencodeModels, readCuratedModels, validateSelection, opencodeProviderAllowlist } from '../src/ai.js'
|
|
8
8
|
import { createAiMenu } from '../src/menu.js'
|
|
9
9
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
10
10
|
import { InboxStore } from '../src/inbox.js'
|
|
11
11
|
import { executionDefaults } from '../src/model-policy.js'
|
|
12
12
|
import type { Update } from 'grammy/types'
|
|
13
13
|
|
|
14
|
+
const noCodex = async (): Promise<string> => { throw new Error('Native discovery unavailable') }
|
|
15
|
+
|
|
16
|
+
test('Codex discovery refreshes through the bound native CLI without curated overrides or a turn', async () => {
|
|
17
|
+
const home = await mkdtemp(join(tmpdir(), 'ez-native-catalog-'))
|
|
18
|
+
const previousToken = process.env.TELEGRAM_BOT_TOKEN
|
|
19
|
+
try {
|
|
20
|
+
process.env.TELEGRAM_BOT_TOKEN = 'must-not-reach-native-client'
|
|
21
|
+
await writeFile(join(home, 'models_cache.json'), JSON.stringify({ models: [
|
|
22
|
+
{ slug: 'old-model', visibility: 'list' },
|
|
23
|
+
] }))
|
|
24
|
+
const calls: string[][] = []
|
|
25
|
+
const run = async (args: string[], env: NodeJS.ProcessEnv) => {
|
|
26
|
+
calls.push(args)
|
|
27
|
+
assert.equal(env.CODEX_HOME, home)
|
|
28
|
+
assert.equal(env.TELEGRAM_BOT_TOKEN, undefined)
|
|
29
|
+
return JSON.stringify({ models: [
|
|
30
|
+
{ slug: 'new-model', display_name: 'New model', visibility: 'list',
|
|
31
|
+
supported_reasoning_levels: [{ effort: 'ultra' }], model_messages: 'untrusted', api_key: 'secret' },
|
|
32
|
+
{ slug: 'hidden-model', visibility: 'hide' },
|
|
33
|
+
] })
|
|
34
|
+
}
|
|
35
|
+
assert.deepEqual(await readModels(home, async cli => cli === 'codex', home,
|
|
36
|
+
undefined, undefined, undefined, undefined, run), [
|
|
37
|
+
{ cli: 'codex', model: 'new-model', name: 'New model', efforts: ['ultra'] },
|
|
38
|
+
])
|
|
39
|
+
assert.deepEqual(calls, [['debug', 'models']])
|
|
40
|
+
for (const unavailable of [noCodex, async () => 'not json', async () => '{"models":[]}']) {
|
|
41
|
+
assert.deepEqual(await readCodexCatalog(home, unavailable), { models: [{ slug: 'old-model', visibility: 'list' }] })
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
if (previousToken === undefined) delete process.env.TELEGRAM_BOT_TOKEN
|
|
45
|
+
else process.env.TELEGRAM_BOT_TOKEN = previousToken
|
|
46
|
+
await rm(home, { recursive: true, force: true })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
14
50
|
test('same-client model and default changes preserve history; provider changes require a fresh conversation', async () => {
|
|
15
51
|
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-model-switch-'))
|
|
16
52
|
try {
|
|
@@ -173,7 +209,7 @@ test('model catalog projects native metadata only, excluding hidden entries and
|
|
|
173
209
|
supported_reasoning_levels: [{ effort: 'high' }, { effort: 'xhigh' }, { effort: 'max' }] },
|
|
174
210
|
{ slug: 'hidden-model', visibility: 'hide' },
|
|
175
211
|
] }))
|
|
176
|
-
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
|
|
212
|
+
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', undefined, undefined, undefined, undefined, undefined, noCodex), [
|
|
177
213
|
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
178
214
|
{ cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh', 'max'] },
|
|
179
215
|
])
|
|
@@ -258,7 +294,7 @@ test('model catalog can read an agent-bound Codex home', async () => {
|
|
|
258
294
|
{ slug: 'gpt-6-astra', display_name: 'GPT-6 Astra', visibility: 'list',
|
|
259
295
|
supported_reasoning_levels: [{ effort: 'low' }] },
|
|
260
296
|
] }))
|
|
261
|
-
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome), [
|
|
297
|
+
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', codexHome, undefined, undefined, undefined, undefined, noCodex), [
|
|
262
298
|
{ cli: 'codex', model: 'gpt-6-astra', name: 'GPT-6 Astra', efforts: ['low'] },
|
|
263
299
|
])
|
|
264
300
|
} finally {
|
|
@@ -270,7 +306,10 @@ test('model catalog can read an agent-bound Codex home', async () => {
|
|
|
270
306
|
test('Choose AI lists the agent-bound Codex home instead of only the desktop fallback', async () => {
|
|
271
307
|
const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-codex-home-'))
|
|
272
308
|
const codexHome = await mkdtemp(join(tmpdir(), 'ez-ai-menu-cache-'))
|
|
309
|
+
const previousPath = process.env.PATH
|
|
273
310
|
try {
|
|
311
|
+
await writeFile(join(codexHome, 'codex'), '#!/bin/sh\nexit 1\n', { mode: 0o700 })
|
|
312
|
+
process.env.PATH = `${codexHome}:${previousPath ?? ''}`
|
|
274
313
|
await writeFile(join(codexHome, 'models_cache.json'), JSON.stringify({ models: [
|
|
275
314
|
{ slug: 'fixture-bound-model', display_name: 'Bound Fixture', visibility: 'list',
|
|
276
315
|
supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
@@ -298,6 +337,7 @@ test('Choose AI lists the agent-bound Codex home instead of only the desktop fal
|
|
|
298
337
|
await menu.handle(context(client.callback_data) as never)
|
|
299
338
|
assert.deepEqual(replies.at(-1)!.buttons.map((button) => button.text), ['Bound Fixture', 'Back to clients'])
|
|
300
339
|
} finally {
|
|
340
|
+
process.env.PATH = previousPath
|
|
301
341
|
await rm(dir, { recursive: true, force: true })
|
|
302
342
|
await rm(codexHome, { recursive: true, force: true })
|
|
303
343
|
}
|
|
@@ -522,7 +562,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
|
|
|
522
562
|
const installedOnly = async (cli: string) => cli === 'codex' || cli === 'pi'
|
|
523
563
|
const noRunner = async (_args: string[]): Promise<string> => { throw new Error('no native CLI in test') }
|
|
524
564
|
const catalog = await readModels(home, installedOnly, join(home, '.codex'),
|
|
525
|
-
noRunner, undefined, undefined, control)
|
|
565
|
+
noRunner, undefined, undefined, control, noCodex)
|
|
526
566
|
assert.deepEqual(catalog.filter((m) => m.cli === 'pi'), [
|
|
527
567
|
{ cli: 'pi', model: 'opencode-go/muse-spark-1.3-contributor', name: 'Pi · Muse Spark 1.3', efforts: ['xhigh'] },
|
|
528
568
|
{ cli: 'pi', name: 'Pi · client default', efforts: [] },
|
|
@@ -538,7 +578,7 @@ test('model catalog merges user-curated entries ahead of discovered ones', async
|
|
|
538
578
|
assert.deepEqual(await readCuratedModels(join(control, 'missing-dir')), [])
|
|
539
579
|
await writeFile(join(control, 'ai-models.json'), 'not json')
|
|
540
580
|
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex', join(home, '.codex'),
|
|
541
|
-
noRunner, undefined, undefined, control), [
|
|
581
|
+
noRunner, undefined, undefined, control, noCodex), [
|
|
542
582
|
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
543
583
|
])
|
|
544
584
|
} finally {
|
|
@@ -85,7 +85,7 @@ test('web owner uses the standard schedule CLI, executor and outbox with Telegra
|
|
|
85
85
|
await writeFile(fixture,`import {spawnSync} from 'node:child_process';
|
|
86
86
|
if(process.env.TELEGRAM_BOT_TOKEN)throw Error('secret leak');
|
|
87
87
|
const scheduled=process.env.EZ_RUN_ID.startsWith('r_schedule_');
|
|
88
|
-
if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
|
|
88
|
+
if(!scheduled){const r=spawnSync(process.execPath,[${JSON.stringify(scheduleCli)},'create','followup','--model','fixture-model','--now','--name','Followup','--text','Finish the requested work'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr)}
|
|
89
89
|
const r=spawnSync(process.execPath,[${JSON.stringify(messageCli)},'--text',scheduled?'Scheduled reply':'Chat reply'],{env:process.env,encoding:'utf8'});if(r.status)throw Error(r.stderr);`)
|
|
90
90
|
EXECUTOR_REGISTRY.grok={...original,command:process.execPath,buildArgs:()=>['--import',require.resolve('tsx'),fixture]}
|
|
91
91
|
const config=loadConfig({EZ_APPLICATION_PORT:'8787',EZ_CONTROL_DIR:root,EZ_AGENT_WORKSPACE:root,EZ_EXECUTOR_CLI:'grok'})
|
|
@@ -151,7 +151,7 @@ for (const mode of ['owner','web','telegram']) test(`${mode} revocation stops it
|
|
|
151
151
|
await runs.create({id:'r_telegram',chatId:42,telegramUserId:42,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),texts:['Wait'],execution:await control.captureChoice(initialPreset('grok'))})
|
|
152
152
|
} else {
|
|
153
153
|
const binding=(await relay.applicationChannel.bindings.register('web',secret(),owner))!
|
|
154
|
-
await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice(initialPreset('grok'))})
|
|
154
|
+
await scheduler.save({id:'wait',name:'Wait',text:'Wait',enabled:true,trigger:{at:new Date(Date.now()+1000).toISOString()},owner,delivery:{bindingId:binding.bindingId,scope:'main'},execution:await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})})
|
|
155
155
|
await scheduler.tick(owner,runs,Date.now()+2000)
|
|
156
156
|
}
|
|
157
157
|
await relay.drainSources()
|
|
@@ -52,7 +52,8 @@ test('codex-gui is a distinct preset and catalog entry', async () => {
|
|
|
52
52
|
await writeFile(path.join(home, '.codex/models_cache.json'), JSON.stringify({ models: [
|
|
53
53
|
{ slug: 'fixture-model', display_name: 'Fixture', visibility: 'list', supported_reasoning_levels: [{ effort: 'medium' }] },
|
|
54
54
|
] }))
|
|
55
|
-
const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui'
|
|
55
|
+
const models = await readModels(home, async (cli) => cli === 'codex' || cli === 'codex-gui', undefined, undefined, undefined, undefined, undefined,
|
|
56
|
+
async () => { throw new Error('Native discovery unavailable') })
|
|
56
57
|
assert.ok(models.some((model) => model.cli === 'codex' && model.model === 'fixture-model'))
|
|
57
58
|
assert.ok(models.some((model) => model.cli === 'codex-gui' && model.model === 'fixture-model'))
|
|
58
59
|
} finally { await rm(home, { recursive: true, force: true }) }
|
package/test/failure.test.ts
CHANGED
|
@@ -100,7 +100,7 @@ test('failure capture, diagnosis, verified recovery and conditional quiet next t
|
|
|
100
100
|
})
|
|
101
101
|
try{
|
|
102
102
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
103
|
-
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok')),start=Date.now()+2000
|
|
103
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),start=Date.now()+2000
|
|
104
104
|
await scheduler.save({id:'review',name:'Review failures',text:'Review failures',trigger:{everySeconds:60,start:new Date(start).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
|
|
105
105
|
await scheduler.tick(owner,runs,start)
|
|
106
106
|
assert.equal((await runs.list()).length,0,'no model job for an empty inbox')
|
|
@@ -251,7 +251,7 @@ for (const intake of [true,false]) test(`relay shutdown terminates an in-flight
|
|
|
251
251
|
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
252
252
|
if(intake) await relay.bot.handleUpdate({update_id:91,message:{message_id:91,date:0,text:'fixture',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
|
|
253
253
|
else {
|
|
254
|
-
const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice(initialPreset('grok'))
|
|
254
|
+
const scheduler=new Scheduler(dir),at=Date.now()+1000,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
255
255
|
await scheduler.save({id:'fixture',name:'Fixture',text:'fixture',trigger:{at:new Date(at).toISOString()},enabled:true,owner,execution})
|
|
256
256
|
await scheduler.tick(owner,runs,at)
|
|
257
257
|
}
|
|
@@ -276,7 +276,7 @@ test('group members can inspect failures and wake review without exposing other
|
|
|
276
276
|
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
277
277
|
const runs=new RunStore(dir),control=new ControlStore(dir,900000),scheduler=new Scheduler(dir)
|
|
278
278
|
await control.requestPairing(101,-123,'Fixture');const owner=await control.approveOwner(-123,true)
|
|
279
|
-
const execution=await control.captureChoice(initialPreset('grok'))
|
|
279
|
+
const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
280
280
|
await runs.create({id:'tg_1',chatId:-123,telegramUserId:202,texts:['failed'],execution})
|
|
281
281
|
await runs.patch('tg_1',{status:'failed',endedAt:new Date().toISOString()})
|
|
282
282
|
await runs.create({id:'tg_2',chatId:-124,telegramUserId:202,texts:['private'],execution})
|
|
@@ -343,7 +343,7 @@ test('failed reviewer readback exposes its stop through show, list and resume un
|
|
|
343
343
|
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
344
344
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
345
345
|
await control.requestPairing(101,101);const owner=await control.approveOwner(101)
|
|
346
|
-
const execution=await control.captureChoice(initialPreset('grok')),now=Date.now()+1000
|
|
346
|
+
const execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'}),now=Date.now()+1000
|
|
347
347
|
const s=await scheduler.save({id:'review',name:'Review',text:'Review failures',trigger:{everySeconds:60,start:new Date(now).toISOString()},when:'unreviewed-failures',enabled:true,owner,execution})
|
|
348
348
|
await runs.create({id:'r_original',chatId:101,telegramUserId:101,texts:['Work'],execution});await runs.patch('r_original',{status:'failed'})
|
|
349
349
|
await scheduler.tick(owner,runs,now)
|
|
@@ -265,13 +265,13 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
265
265
|
await assert.rejects(serveHostExecutor({cli:'grok',agents},new AbortController().signal),/already running/)
|
|
266
266
|
const directory=path.join(agents[0].controlDir,'host-executor')
|
|
267
267
|
const runs=new RunStore(agents[0].controlDir)
|
|
268
|
-
await runs.create({id:'r_hold',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
268
|
+
await runs.create({id:'r_hold',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'held',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
269
269
|
await runs.patch('r_hold',{status:'running'})
|
|
270
|
-
await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
|
|
270
|
+
await writeFile(path.join(directory,'r_hold.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model'}}))
|
|
271
271
|
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'r_hold.process.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
272
|
-
await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
272
|
+
await new RunStore(agents[0].controlDir).create({id:'r_schedule_queued',execution:{sessionId:crypto.randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},chatId:101,telegramUserId:101,texts:['test'],scheduled:{id:'shared',revision:'v1',dueAt:new Date().toISOString(),pairedAt:new Date().toISOString()}})
|
|
273
273
|
await new RunStore(agents[0].controlDir).patch('r_schedule_queued',{status:'running'})
|
|
274
|
-
await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',sharedWorkspace:'/wrong'}}))
|
|
274
|
+
await writeFile(path.join(directory,'r_schedule_queued.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok',model:'fixture-model',sharedWorkspace:'/wrong'}}))
|
|
275
275
|
const otherDirectory=path.join(agents[1].controlDir,'host-executor')
|
|
276
276
|
await ownerRun(agents[1].controlDir,'r_other_shared')
|
|
277
277
|
await writeFile(path.join(otherDirectory,'r_other_shared.request.json'),JSON.stringify({texts:['test'],options:{cli:'grok'}}))
|
|
@@ -79,19 +79,20 @@ test('isolated deployments need no host ledger endpoint',async t=>{
|
|
|
79
79
|
});
|
|
80
80
|
test('same-artifact build retries do not spawn a second Docker build and reuse verified completed image',async t=>{
|
|
81
81
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
82
|
-
let started,finish,count=0;const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
|
|
83
|
-
const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
|
|
82
|
+
let started,finish,count=0,latest='0.160.0';const running=new Promise(r=>started=r),held=new Promise(r=>finish=r);
|
|
83
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return JSON.stringify(latest);if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build'){count++;started();await held;}return 'sha256:fixture';};
|
|
84
84
|
const first=build({home,source},invoke);await running;
|
|
85
85
|
assert.equal((await build({home,source},invoke)).state,'busy-or-interrupted');assert.equal(count,1);
|
|
86
86
|
finish();const result=await first;assert.equal(result.state,'completed');
|
|
87
87
|
assert.equal((await build({home,source},invoke)).reused,true);assert.equal(count,1);
|
|
88
88
|
await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');await build({home,source},invoke);assert.equal(count,2);
|
|
89
|
+
latest='0.160.1';const newer=await build({home,source},invoke);assert.equal(count,3);assert.equal(newer.codexVersion,latest);assert.notEqual(newer.image,result.image);
|
|
89
90
|
});
|
|
90
91
|
test('source checkouts build RCs only: clean reviewed commit plus increasing label',async t=>{
|
|
91
92
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
92
93
|
let inside=true,status='',built=[],toplevel=source;const sha='a'.repeat(40);
|
|
93
94
|
const invoke=async(cmd,args)=>{
|
|
94
|
-
if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
|
|
95
|
+
if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);
|
|
95
96
|
if(cmd==='git'){
|
|
96
97
|
if(!inside)throw Error('not a git repository');
|
|
97
98
|
if(args[2]==='rev-parse'&&args[3]==='--show-toplevel')return toplevel;
|
|
@@ -120,13 +121,13 @@ test('source checkouts build RCs only: clean reviewed commit plus increasing lab
|
|
|
120
121
|
});
|
|
121
122
|
test('a failed build releases its own lock and leaves a failure receipt for diagnosis',async t=>{
|
|
122
123
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
123
|
-
const invoke=async(cmd)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
|
|
124
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);throw Error('Synthetic build failed');};
|
|
124
125
|
await assert.rejects(build({home,source},invoke),/Synthetic/);
|
|
125
126
|
const [id]=await fs.readdir(path.join(home,'builds'));const dir=path.join(home,'builds',id);
|
|
126
127
|
assert.equal(JSON.parse(await fs.readFile(path.join(dir,'status.json'))).state,'failed');await assert.rejects(fs.access(path.join(dir,'lock')));
|
|
127
128
|
});
|
|
128
129
|
test('source changes during a build cannot produce a reusable success receipt',async t=>{
|
|
129
130
|
const home=await fixture(t),source=path.join(home,'source');await fs.mkdir(source);await fs.writeFile(path.join(source,'package.json'),'{}');
|
|
130
|
-
const invoke=async(cmd,args)=>{if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
|
|
131
|
+
const invoke=async(cmd,args)=>{if(cmd==='npm'&&args?.[0]==='view')return '"0.160.0"';if(cmd==='npm')return JSON.stringify([{files:[{path:'package.json'}]}]);if(args[0]==='build')await fs.writeFile(path.join(source,'package.json'),'{"changed":true}');return 'image';};
|
|
131
132
|
await assert.rejects(build({home,source},invoke),/changed during build/);
|
|
132
133
|
});
|
|
@@ -20,7 +20,7 @@ test('native tasks use verified control binding, sanitized environment and uncha
|
|
|
20
20
|
const denied=await nativeTasks(home,['list']);assert.notEqual(denied.code,0);assert.match(denied.stderr,/Pair an owner/)
|
|
21
21
|
const control=new ControlStore(controlDir,1000);await control.requestPairing(101,101);await control.approveOwner(101)
|
|
22
22
|
const text='Read scan; $(must-not-run) /goal literal'
|
|
23
|
-
const saved=await nativeTasks(home,['create','native-fixture','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
|
|
23
|
+
const saved=await nativeTasks(home,['create','native-fixture','--model','fixture-model','--now','--text',text]);assert.equal(saved.code,0,saved.stderr)
|
|
24
24
|
const value=JSON.parse(saved.stdout);assert.equal(value.text,text);assert.equal(value.execution.preset.cli,'grok')
|
|
25
25
|
assert.equal(JSON.parse((await nativeTasks(home,['show','native-fixture'])).stdout).text,text)
|
|
26
26
|
assert.equal((await nativeTasks(home,['remove','native-fixture'])).code,0)
|
|
@@ -10,6 +10,8 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
10
10
|
import { RunStore } from '../src/runs.js'
|
|
11
11
|
import { initialPreset } from '../src/ai.js'
|
|
12
12
|
import { serveTestLedger } from './helpers/ledger.js'
|
|
13
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
14
|
+
import { callDeliverySocket, socketPathFor } from '../src/delivery-socket.js'
|
|
13
15
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
14
16
|
test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
|
|
15
17
|
const dir=await mkdtemp(join(tmpdir(),'ez-schedule-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
@@ -25,10 +27,11 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
25
27
|
const context=JSON.parse((await exec(process.execPath,[bin,'context'],{env})).stdout)
|
|
26
28
|
assert.deepEqual(context.run.texts,['owner request']);assert.equal(context.busyReplies,undefined)
|
|
27
29
|
await assert.rejects(exec(process.execPath,[bin,'context'],{env:{...env,EZ_RUN_ID:''}}),/active owner run/)
|
|
28
|
-
|
|
30
|
+
await assert.rejects(exec(process.execPath,[bin,'create','missing-model','--now','--text','Do work'],{env}),/saved explicit model/)
|
|
31
|
+
const args=['create','test','--model','fixture-model','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
|
|
29
32
|
const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
30
33
|
assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'grok')
|
|
31
|
-
assert.equal(saved.execution.preset.model,
|
|
34
|
+
assert.equal(saved.execution.preset.model,'fixture-model');assert.equal(saved.execution.preset.effort,undefined)
|
|
32
35
|
await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--model','gpt-5.6-terra','--effort','bad option'],{env}),/Invalid reasoning effort/)
|
|
33
36
|
const astra=JSON.parse((await exec(process.execPath,[bin,'create','astra','--at','2027-09-09T10:00:00+04:00','--text','Astra task','--model','gpt-6-astra'],{env})).stdout)
|
|
34
37
|
assert.equal(astra.execution.preset.model,'gpt-6-astra');assert.equal(astra.execution.preset.effort,undefined)
|
|
@@ -68,6 +71,47 @@ test('executor PATH exposes the extensionless scheduler command',async()=>{
|
|
|
68
71
|
assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
|
|
69
72
|
})
|
|
70
73
|
|
|
74
|
+
test('trigger uses saved task settings after a chat switch, preserves cadence and reconciles its request key',async t=>{
|
|
75
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-trigger-cli-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
76
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
77
|
+
const control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
78
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
79
|
+
const owner=(await control.status()).owner!
|
|
80
|
+
const execution={sessionId:crypto.randomUUID(),preset:{id:'task',name:'Saved task',cli:'codex',model:'gpt-6-astra',effort:'medium'}}
|
|
81
|
+
const saved=await scheduler.save({id:'daily',name:'Daily',text:'Literal /goal task',owner,execution,enabled:true,trigger:{cron:'5 7 * * 1-5',timezone:'America/New_York',start:'2027-01-01T00:00:00Z'}},true)
|
|
82
|
+
const next=await scheduler.pendingOccurrence(saved)
|
|
83
|
+
const chat=await control.captureChoice({...initialPreset('codex'),model:'fixture-model'})
|
|
84
|
+
await control.savePreset({id:'chat',name:'Chat',cli:'codex',model:'gpt-6-luna',effort:'max'})
|
|
85
|
+
await control.selectPreset('chat',chat.sessionId)
|
|
86
|
+
assert.equal((await control.status()).ai?.selectedId,'chat')
|
|
87
|
+
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
|
|
88
|
+
const args=[bin,'trigger','daily','--key','smoke-1']
|
|
89
|
+
const first=JSON.parse((await exec(process.execPath,args,{env})).stdout)
|
|
90
|
+
assert.equal(first.status,'queued');assert.deepEqual(first.execution.preset,execution.preset)
|
|
91
|
+
assert.notEqual(first.execution.sessionId,execution.sessionId)
|
|
92
|
+
assert.equal(first.scheduled.id,saved.id);assert.equal(first.scheduled.revision,saved.revision)
|
|
93
|
+
assert.equal(first.texts[1],saved.text)
|
|
94
|
+
assert.deepEqual(await scheduler.get('daily'),saved);assert.equal(await scheduler.pendingOccurrence(saved),next)
|
|
95
|
+
const retry=JSON.parse((await exec(process.execPath,args,{env})).stdout)
|
|
96
|
+
assert.equal(retry.id,first.id);assert.equal((await runs.list()).length,1)
|
|
97
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','different'],{env}),/queued or running/)
|
|
98
|
+
await assert.rejects(exec(process.execPath,[...args,'--model','gpt-6-luna'],{env}),/overrides are not allowed/)
|
|
99
|
+
await runs.patch(first.id,{status:'completed'})
|
|
100
|
+
const parallel=await Promise.allSettled(['smoke-2','smoke-3'].map(key=>callDeliverySocket(socketPathFor(dir),{op:'triggerSchedule',payload:{scheduleId:'daily',revision:saved.revision,key}})))
|
|
101
|
+
assert.equal(parallel.filter(r=>r.status==='fulfilled').length,1,'concurrent trigger keys cannot overlap')
|
|
102
|
+
for(const run of await runs.list())await runs.patch(run.id,{status:'completed'})
|
|
103
|
+
const restricted=await runs.create({chatId:101,telegramUserId:101,texts:[],execution,external:{sourceId:'source',bindingId:'binding',eventIds:['event']}})
|
|
104
|
+
await runs.patch(restricted.id,{status:'running'})
|
|
105
|
+
await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'triggerSchedule',payload:{scheduleId:'daily',revision:saved.revision,key:'restricted',callerRunId:restricted.id}}),/blocked/)
|
|
106
|
+
await assert.rejects(callDeliverySocket(socketPathFor(dir),{op:'triggerSchedule',payload:{scheduleId:'daily',revision:'stale',key:'stale'}}),/changed/)
|
|
107
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','../bad'],{env}),/Invalid record identifier/)
|
|
108
|
+
await scheduler.enable('daily',false)
|
|
109
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','paused'],{env}),/disabled/)
|
|
110
|
+
await scheduler.enable('daily',true)
|
|
111
|
+
await control.revokeOwner();await control.requestPairing(202,202);await control.approveOwner(202)
|
|
112
|
+
await assert.rejects(exec(process.execPath,[bin,'trigger','daily','--key','wrong-owner'],{env}),/ownership mismatch/)
|
|
113
|
+
})
|
|
114
|
+
|
|
71
115
|
|
|
72
116
|
test('deferred literal input retains owner-scoped conversation through source metadata',async t=>{
|
|
73
117
|
const {Scheduler}=await import('../src/scheduler.js')
|
|
@@ -75,7 +119,7 @@ test('deferred literal input retains owner-scoped conversation through source me
|
|
|
75
119
|
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
76
120
|
const control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
77
121
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
78
|
-
const execution=await control.captureChoice(initialPreset('codex'))
|
|
122
|
+
const execution=await control.captureChoice({...initialPreset('codex'),model:'fixture-model'})
|
|
79
123
|
await runs.create({id:'tg_1',chatId:101,telegramUserId:101,texts:['Use the blue ledger'],execution})
|
|
80
124
|
await runs.patch('tg_1',{status:'completed'})
|
|
81
125
|
await runs.create({id:'tg_2',chatId:101,telegramUserId:101,texts:['Do the same for March'],execution})
|
|
@@ -9,7 +9,7 @@ import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-ta
|
|
|
9
9
|
import { RunStore } from '../src/runs.js'
|
|
10
10
|
|
|
11
11
|
const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
|
|
12
|
-
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
|
|
12
|
+
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex', model: 'fixture-model' } }
|
|
13
13
|
|
|
14
14
|
test('scheduled task view is read-only, owner-bound, and shows active task prompts and effective AI settings', async (t) => {
|
|
15
15
|
const dir = await mkdtemp(join(tmpdir(), 'ez-scheduled-tasks-'))
|
|
@@ -40,7 +40,7 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
|
|
|
40
40
|
const active = await scheduler.listActiveReadOnly(await runs.list())
|
|
41
41
|
const text = scheduledTasksText(active, owner)
|
|
42
42
|
|
|
43
|
-
assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex ·
|
|
43
|
+
assert.equal(text, '📅 Scheduled tasks · 1 active\n\nAll times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n codex · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
|
|
44
44
|
assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
|
|
45
45
|
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
46
46
|
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
@@ -20,14 +20,25 @@ test('host transport keeps chat responsive beside a schedule in the bound mind',
|
|
|
20
20
|
const old={...EXECUTOR_REGISTRY.grok},token=process.env.TELEGRAM_BOT_TOKEN
|
|
21
21
|
EXECUTOR_REGISTRY.grok.command=process.execPath;EXECUTOR_REGISTRY.grok.buildArgs=()=>[script]
|
|
22
22
|
process.env.TELEGRAM_BOT_TOKEN='never-in-child'
|
|
23
|
-
const abort=new AbortController(),server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal)
|
|
23
|
+
const abort=new AbortController(),server=serveHostExecutor({cli:'grok',agents:[{name:'test',workspace,controlDir,binDir:root}]},abort.signal,undefined,async()=>[{cli:'grok',model:'fixture-model',name:'Fixture',efforts:[]}])
|
|
24
24
|
const dir=join(controlDir,'host-executor'),runs=new RunStore(controlDir),id='r_schedule_fixture'
|
|
25
25
|
const exists=async(file:string)=>readFile(join(dir,file),'utf8').catch(()=>'')
|
|
26
26
|
try{
|
|
27
27
|
await until(async()=>Boolean(await exists('heartbeat.json')))
|
|
28
|
-
await runs.create({id,chatId:101,telegramUserId:101,texts:['slow'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok'}},scheduled:{id:'s',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
29
|
-
const submit=async(id:string)=>writeFile(join(dir,id+'.request.json'),JSON.stringify({texts:['fixture'],options:{workspace:'/evil',controlDir:'/evil',cli:'grok',timeoutMs:1}}))
|
|
28
|
+
await runs.create({id,chatId:101,telegramUserId:101,texts:['slow'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},scheduled:{id:'s',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
29
|
+
const submit=async(id:string)=>writeFile(join(dir,id+'.request.json'),JSON.stringify({texts:['fixture'],options:{workspace:'/evil',controlDir:'/evil',cli:'grok',model:'fixture-model',timeoutMs:1}}))
|
|
30
30
|
await ownerRun(controlDir,'tg_1')
|
|
31
|
+
for(const [badId,savedModel,requestedModel,reason] of [
|
|
32
|
+
['r_schedule_missing',undefined,undefined,'saved explicit model'],
|
|
33
|
+
['r_schedule_mismatch','fixture-model','wrong-model','does not match'],
|
|
34
|
+
] as const){
|
|
35
|
+
await runs.create({id:badId,chatId:101,telegramUserId:101,texts:['blocked'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:savedModel}},scheduled:{id:badId,revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
|
|
36
|
+
await runs.patch(badId,{status:'running'})
|
|
37
|
+
await writeFile(join(dir,badId+'.request.json'),JSON.stringify({texts:['blocked'],options:{cli:'grok',model:requestedModel}}))
|
|
38
|
+
await until(async()=> (await exists(badId+'.events')).includes('"stream":"exit"'))
|
|
39
|
+
assert.match(await exists(badId+'.events'),new RegExp(reason))
|
|
40
|
+
assert.equal(await exists(badId+'.process.json'),'','host must reject before engine spawn')
|
|
41
|
+
}
|
|
31
42
|
await runs.patch(id,{status:'running'})
|
|
32
43
|
await submit(id)
|
|
33
44
|
await until(async()=>Boolean(await exists(id+'.process.json')))
|
|
@@ -11,9 +11,54 @@ import { ControlStore } from '../src/control-state.js'
|
|
|
11
11
|
import { RunStore } from '../src/runs.js'
|
|
12
12
|
import { Scheduler } from '../src/scheduler.js'
|
|
13
13
|
import { initialPreset } from '../src/ai.js'
|
|
14
|
+
import type { ExecutorOptions } from '../src/executor.js'
|
|
14
15
|
|
|
15
16
|
const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<200;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
|
|
16
17
|
|
|
18
|
+
test('executor admission rejects a legacy queued schedule with no model before spawning',async()=>{
|
|
19
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-missing-task-model-')),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
20
|
+
let launches=0
|
|
21
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async()=>{
|
|
22
|
+
launches++;throw new Error('Must not launch')
|
|
23
|
+
})
|
|
24
|
+
relay.bot.api.config.use(async()=>({ok:true,result:{message_id:42}}) as never)
|
|
25
|
+
try{
|
|
26
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
27
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('codex'),model:'saved-model'})
|
|
28
|
+
const due=Date.now()+2000
|
|
29
|
+
const saved=await scheduler.save({id:'legacy',name:'Legacy',text:'Work',owner,execution,enabled:true,trigger:{at:new Date(due).toISOString()}},true)
|
|
30
|
+
const run=await runs.create({id:'r_schedule_legacy',chatId:101,telegramUserId:101,texts:['Work'],
|
|
31
|
+
execution:{...execution,preset:{...execution.preset,model:undefined}},
|
|
32
|
+
scheduled:{id:saved.id,revision:saved.revision,dueAt:new Date(due).toISOString(),pairedAt:owner.pairedAt}})
|
|
33
|
+
await relay.drainSources()
|
|
34
|
+
assert.equal((await runs.get(run.id))?.status,'failed');assert.equal(launches,0)
|
|
35
|
+
assert.equal((await scheduler.get(saved.id)).execution.preset.model,'saved-model')
|
|
36
|
+
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('manual task trigger launches its saved model in a fresh native session after chat switches AI',async()=>{
|
|
40
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-trigger-launch-')),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
|
|
41
|
+
let launched:ExecutorOptions | undefined
|
|
42
|
+
const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
|
|
43
|
+
launched=options
|
|
44
|
+
const child=spawn(process.execPath,['-e','setTimeout(()=>{},10)'],{detached:process.platform!=='win32'})
|
|
45
|
+
await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
|
|
46
|
+
})
|
|
47
|
+
try{
|
|
48
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
49
|
+
const owner=(await control.status()).owner!,chat=await control.captureChoice(initialPreset('codex'))
|
|
50
|
+
await control.savePreset({id:'chat',name:'Chat',cli:'codex',model:'gpt-6-luna',effort:'max'})
|
|
51
|
+
await control.selectPreset('chat',chat.sessionId)
|
|
52
|
+
const saved=await scheduler.save({id:'daily',name:'Daily',text:'Saved work',owner,execution:{sessionId:chat.sessionId,preset:{id:'daily',name:'Daily model',cli:'codex',model:'gpt-6-astra',effort:'medium'}},enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
|
|
53
|
+
const run=await scheduler.trigger(saved.id,saved.revision,'launch-smoke',owner,runs)
|
|
54
|
+
await relay.drainSources();await until(async()=> (await runs.get(run.id))?.status==='completed')
|
|
55
|
+
assert.equal(launched?.model,'gpt-6-astra');assert.equal(launched?.effort,'medium')
|
|
56
|
+
assert.equal(launched?.nativeSession,true);assert.equal(launched?.isResume,false)
|
|
57
|
+
assert.notEqual(launched?.sessionId,chat.sessionId)
|
|
58
|
+
assert.equal((await control.status()).ai?.selectedId,'chat')
|
|
59
|
+
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
60
|
+
})
|
|
61
|
+
|
|
17
62
|
// Adversarial: the relay must not reserve wrapper semantics for an
|
|
18
63
|
// update-shaped run id or a stale attention notice.
|
|
19
64
|
test('a wrapper-shaped run id has no maintenance treatment and a stale notice is inert',async()=>{
|
|
@@ -55,7 +100,7 @@ test('Slack application replies beside a schedule and keeps both streams serial'
|
|
|
55
100
|
})
|
|
56
101
|
try{
|
|
57
102
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
58
|
-
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
|
|
103
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
59
104
|
const due=Date.now()+10000
|
|
60
105
|
for(const id of ['first','second'])await scheduler.save({id,name:id,text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
|
|
61
106
|
await scheduler.tick(owner,runs,due);await relay.drainSources()
|
|
@@ -103,7 +148,7 @@ test('owner chat replies while a scheduled CLI keeps running; targeted cancellat
|
|
|
103
148
|
relay.bot.api.config.use(async(_prev,method,payload)=>{if(method==='sendMessage')replies.push((payload as {text:string}).text);return {ok:true,result:{message_id:42}} as never})
|
|
104
149
|
try{
|
|
105
150
|
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
106
|
-
const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset('grok'))
|
|
151
|
+
const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'fixture-model'})
|
|
107
152
|
const due=Date.now()+2000
|
|
108
153
|
await scheduler.save({id:'slow',name:'Slow',text:'Long work',trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
|
|
109
154
|
await scheduler.tick(owner,runs,due);await relay.drainSources()
|
package/test/scheduler.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ const fixture=async(t:any)=>{
|
|
|
28
28
|
const dir=await mkdtemp(join(tmpdir(),'ez-schedules-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
29
29
|
const scheduler=new Scheduler(dir), runs=new RunStore(dir), now=Date.now()+10000
|
|
30
30
|
const owner={telegramUserId:101,telegramChatId:101,pairedAt:new Date().toISOString()}
|
|
31
|
-
const input={id:'test',name:'Test',text:'Do the work',owner,execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'codex'}},enabled:true,trigger:{at:new Date(now).toISOString()}}
|
|
31
|
+
const input={id:'test',name:'Test',text:'Do the work',owner,execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'codex',model:'fixture-model'}},enabled:true,trigger:{at:new Date(now).toISOString()}}
|
|
32
32
|
return {dir,scheduler,runs,now,owner,input}
|
|
33
33
|
}
|
|
34
34
|
test('durable dispatch survives cursor-write crash without duplicating an occurrence',async t=>{
|
|
@@ -158,3 +158,23 @@ test('ordinary recurring work still runs after a non-interrupted failure', async
|
|
|
158
158
|
await f.scheduler.tick(f.owner,f.runs,f.now+60000)
|
|
159
159
|
assert.equal((await f.runs.list()).length,2)
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
test('missing models cannot be saved, dispatched or triggered; legacy settings remain readable for repair',async t=>{
|
|
163
|
+
const f=await fixture(t)
|
|
164
|
+
const missing={...f.input,execution:{...f.input.execution,preset:{...f.input.execution.preset,model:undefined}}}
|
|
165
|
+
await assert.rejects(f.scheduler.save(missing),/saved explicit model/)
|
|
166
|
+
const saved=await f.scheduler.save(f.input,true),next=await f.scheduler.pendingOccurrence(saved)
|
|
167
|
+
const file=join(f.dir,'schedules/test.json')
|
|
168
|
+
await writeFile(file,JSON.stringify({...saved,execution:missing.execution}))
|
|
169
|
+
const legacy=await f.scheduler.get(saved.id)
|
|
170
|
+
assert.equal(legacy.execution.preset.model,undefined)
|
|
171
|
+
await f.scheduler.tick(f.owner,f.runs,f.now)
|
|
172
|
+
assert.equal((await f.runs.list()).length,0)
|
|
173
|
+
assert.equal(await f.scheduler.pendingOccurrence(legacy),next,'blocked dispatch must preserve the pending occurrence')
|
|
174
|
+
await assert.rejects(f.scheduler.trigger(saved.id,saved.revision,'legacy',f.owner,f.runs),/saved explicit model/)
|
|
175
|
+
const repaired=await f.scheduler.save({...legacy,execution:f.input.execution})
|
|
176
|
+
await f.scheduler.tick(f.owner,f.runs,f.now)
|
|
177
|
+
const [run]=await f.runs.list()
|
|
178
|
+
assert.equal(run.execution?.preset.model,f.input.execution.preset.model)
|
|
179
|
+
assert.equal(run.scheduled?.revision,repaired.revision)
|
|
180
|
+
})
|
package/test/tasks.test.ts
CHANGED
|
@@ -247,7 +247,7 @@ test('approved initial task crosses the real host file client and uses a fresh r
|
|
|
247
247
|
const { isHostRunId } = await import('../src/host-executor-protocol.js')
|
|
248
248
|
const f = await fixture(t), { run } = await f.activate()
|
|
249
249
|
assert.ok(isHostRunId(run.id))
|
|
250
|
-
await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.
|
|
250
|
+
await writeFile(join(f.dir, 'codex'), `#!${process.execPath}\nif(process.argv[2]==='--version')console.log('codex-cli 0.160.0');else if(process.argv[2]==='debug')console.log(JSON.stringify({models:[{slug:'fixture',tool_mode:'code_mode_only',apply_patch_tool_type:'freeform',multi_agent_version:'v2'}]}));else console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),control:process.env.EZ_CONTROL_DIR}));`, { mode: 0o700 })
|
|
251
251
|
const priorPath = process.env.PATH
|
|
252
252
|
process.env.PATH = `${f.dir}:${priorPath}`
|
|
253
253
|
const abort = new AbortController(), host = serveHostExecutor({ cli: 'codex', agents: [{ name: 'test', workspace: f.dir, controlDir: f.dir, binDir: f.dir }] }, abort.signal)
|