@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48

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.
Files changed (91) hide show
  1. package/.dockerignore +2 -0
  2. package/.env.example +3 -0
  3. package/AGENTS.md +2 -2
  4. package/CHANGELOG.md +38 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/Dockerfile +18 -10
  7. package/THIRD_PARTY_NOTICES.md +6 -3
  8. package/bin/ez +7 -0
  9. package/docker/broker-readiness.mjs +54 -0
  10. package/docker/broker-update-smoke.mjs +86 -0
  11. package/docker/engine-pnpm-workspace.yaml +4 -0
  12. package/docker/run.ts +1 -1
  13. package/docker/smoke.mjs +1 -1
  14. package/docs/application-channel.md +32 -1
  15. package/docs/architecture/ai-selection.md +26 -5
  16. package/docs/architecture/authority-boundaries.md +2 -2
  17. package/docs/development-and-testing.md +1 -1
  18. package/docs/docker-runtime.md +39 -0
  19. package/docs/isolated-plugin-update-readiness.md +66 -0
  20. package/docs/plugin-guide.md +1 -1
  21. package/docs/plugins.md +1 -1
  22. package/docs/releasing.md +5 -1
  23. package/docs/scheduling.md +117 -9
  24. package/docs/selective-monitoring.md +13 -0
  25. package/docs/upgrades.md +38 -1
  26. package/docs/work-preflight.md +9 -0
  27. package/package.json +4 -3
  28. package/pnpm-workspace.yaml +7 -0
  29. package/scripts/smoke-scheduler.ts +4 -1
  30. package/src/ai.ts +41 -4
  31. package/src/application-channel.ts +43 -3
  32. package/src/application-cli.ts +7 -1
  33. package/src/application-origin.ts +2 -0
  34. package/src/audio.ts +37 -22
  35. package/src/client-defaults.ts +3 -2
  36. package/src/config.ts +9 -0
  37. package/src/control-state.ts +3 -1
  38. package/src/delivery-socket-client.d.mts +2 -0
  39. package/src/delivery-socket-client.mjs +2 -0
  40. package/src/delivery-socket.ts +38 -1
  41. package/src/event-sources.ts +2 -2
  42. package/src/executor.ts +72 -1
  43. package/src/files.ts +47 -3
  44. package/src/host-executor.ts +40 -26
  45. package/src/index.ts +42 -17
  46. package/src/install-tools.mjs +18 -9
  47. package/src/menu.ts +4 -2
  48. package/src/model-policy.ts +4 -0
  49. package/src/plugin-broker.mjs +47 -4
  50. package/src/plugins/manager.mjs +11 -4
  51. package/src/run-evidence.ts +78 -0
  52. package/src/runs.ts +16 -2
  53. package/src/schedule-cli.ts +80 -10
  54. package/src/schedule-preflight.ts +23 -0
  55. package/src/scheduled-tasks.ts +16 -12
  56. package/src/scheduler.ts +102 -16
  57. package/src/scripts.ts +167 -0
  58. package/src/setup.ts +2 -2
  59. package/src/task-capability.ts +17 -1
  60. package/src/task-executor.ts +11 -8
  61. package/src/task-mcp.ts +20 -8
  62. package/src/tasks.ts +89 -9
  63. package/src/updates/runtime.mjs +53 -11
  64. package/src/updates/supervisor.mjs +23 -13
  65. package/test/ai.test.ts +85 -5
  66. package/test/application-channel.test.ts +16 -1
  67. package/test/audio.test.ts +19 -0
  68. package/test/channel-owner.test.ts +2 -2
  69. package/test/config.test.ts +8 -0
  70. package/test/desktop-bridge.test.ts +2 -1
  71. package/test/executor.test.ts +37 -0
  72. package/test/failure.test.ts +4 -4
  73. package/test/files.test.ts +44 -0
  74. package/test/host-executor.test.ts +49 -6
  75. package/test/install-json.test.mjs +21 -0
  76. package/test/install-tools.test.mjs +6 -5
  77. package/test/native-tasks.test.ts +1 -1
  78. package/test/plugin-broker.test.mjs +64 -0
  79. package/test/plugin-manager.test.mjs +20 -0
  80. package/test/private-speech-config.test.ts +22 -0
  81. package/test/run-evidence.test.ts +78 -0
  82. package/test/schedule-cli.test.ts +47 -3
  83. package/test/schedule-preflight.test.ts +49 -0
  84. package/test/scheduled-tasks.test.ts +2 -2
  85. package/test/scheduler-host.test.ts +21 -3
  86. package/test/scheduler-relay.test.ts +84 -4
  87. package/test/scheduler.test.ts +21 -1
  88. package/test/script-schedules.test.ts +219 -0
  89. package/test/task-native.test.ts +50 -12
  90. package/test/tasks.test.ts +97 -4
  91. package/test/updates.test.mjs +77 -18
@@ -182,3 +182,67 @@ test('broker rejects resolve and invoke for a revoked run', async t => {
182
182
  await serving;
183
183
  }
184
184
  });
185
+
186
+ test('scheduled preflight uses authoritative configuration and rejects writes or stale revisions', async t => {
187
+ const f = await fixture(t);
188
+ const { Scheduler } = await import('../src/scheduler.js');
189
+ const { createLedgerHandler, serveDeliverySocket } = await import('../src/delivery-socket.js');
190
+ const control = new ControlStore(f.controlDir, 1000);
191
+ const owner = (await control.status()).owner;
192
+ const scheduler = new Scheduler(f.controlDir);
193
+ const schedule = await scheduler.save({id:'check', name:'Check', text:'Saved worker', enabled:true, owner,
194
+ execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},
195
+ trigger:{everySeconds:60,start:new Date().toISOString()},preflight:{on:'eligible',checks:[{alias:'sample',args:['queue']}]}});
196
+ const ledger = await serveDeliverySocket(f.controlDir, createLedgerHandler(f.controlDir,{wake:()=>{},status:()=>({polling:false,applicationOnly:true,telegramConfigured:false,version:'fixture'})}));
197
+ t.after(()=>ledger.stop());
198
+ await fs.writeFile(path.join(f.fake,'docker'), `#!${process.execPath}\nprocess.stdout.write(JSON.stringify({schemaVersion:1,eligible:true,count:1,fingerprint:'a'.repeat(64),observedAt:new Date().toISOString()}));\n`, {mode:0o700});
199
+ const previousPath=process.env.PATH;process.env.PATH=f.fake+path.delimiter+previousPath;
200
+ const binding=await loadPluginBrokerBinding({home:f.home,workspace:f.workspace,controlDir:f.controlDir,socket:f.socket,hostConfig:f.hostConfig,timeoutMs:10000});
201
+ const abort=new AbortController(),serving=servePluginBroker(binding,abort.signal);
202
+ try {
203
+ await waitForSocket(f.socket);
204
+ const payload={version:1,id:randomUUID(),operation:'preflight',scheduleId:schedule.id,revision:schedule.revision};
205
+ assert.equal((await request(f.socket,{...payload,args:['write']})).ok,false);
206
+ assert.equal((await request(f.socket,{...payload,revision:randomUUID()})).ok,false);
207
+ assert.equal((await request(f.socket,payload)).ok,false); // undeclared read-only command
208
+ const file=path.join(f.home,'registry.json'),registry=JSON.parse(await fs.readFile(file,'utf8'));
209
+ registry.plugins.sample.manifest.commands.sample.exposure={receivesExternalContent:true,changesRecords:false,sendsExternally:false,requiresReview:false};
210
+ await fs.writeFile(file,JSON.stringify(registry));
211
+ const accepted=await request(f.socket,payload);assert.equal(accepted.ok,true);assert.equal(accepted.result.eligible,true);
212
+ await scheduler.save({...schedule,enabled:false});
213
+ assert.equal((await request(f.socket,payload)).ok,false);
214
+ } finally {abort.abort();await serving;process.env.PATH=previousPath;}
215
+ });
216
+
217
+ test('task launch broker admits only its fixed application command and writes no bearer into receipts',async t=>{
218
+ const {createServer}=await import('node:http');
219
+ const {Tasks}=await import('../src/tasks.js');const {EventSources}=await import('../src/event-sources.js');
220
+ const {ApplicationBindings}=await import('../src/application-channel.js');const {serveTestLedger}=await import('./helpers/ledger.js');
221
+ const f=await fixture(t), runs=new RunStore(f.controlDir), control=new ControlStore(f.controlDir,900000);
222
+ const sourceSocket=path.join(f.root,'source.sock');const provider=createServer(async(req,res)=>{
223
+ let raw='';for await(const c of req)raw+=c;const v=JSON.parse(raw);
224
+ res.end(JSON.stringify({ok:true,data:v.command==='events-head'?{cursor:0,accountId:'fixture-account',taskProtocol:'message-v1'}:{}}));
225
+ });await new Promise(r=>provider.listen(sourceSocket,r));
226
+ const tasks=new Tasks(f.controlDir),owner=(await control.status()).owner;
227
+ await new EventSources(f.controlDir).register('fixture',sourceSocket,owner);
228
+ const task=await tasks.ownerCall(f.runId,'start',{sourceId:'fixture',conversationId:'discussion-a',purpose:'Synthetic discussion',context:'Only synthetic records',hours:1});
229
+ const taskRun='event_'+hash(task.id);await runs.patch(taskRun,{status:'running'});
230
+ const bindings=new ApplicationBindings(f.controlDir);await bindings.register('browser','z'.repeat(48),owner);
231
+ await bindings.taskLaunch('browser',{command:'sample',args:['launch','--task'],tasks:[task.id]});
232
+ const token='c'.repeat(64),ticketHash=hash(token),grant=await tasks.workerCall(taskRun,'application_launch',{application:'browser',ticketHash});
233
+ const input={taskId:task.id,taskToken:token,expiresAt:grant.expiresAt};
234
+ const ledger=await serveTestLedger(f.controlDir);
235
+ const binding=await loadPluginBrokerBinding({home:f.home,workspace:f.workspace,controlDir:f.controlDir,socket:f.socket,hostConfig:f.hostConfig,timeoutMs:10000});
236
+ const oldPath=process.env.PATH;process.env.PATH=f.fake+path.delimiter+oldPath;
237
+ const abort=new AbortController(),serving=servePluginBroker(binding,abort.signal);
238
+ try{
239
+ await waitForSocket(f.socket);
240
+ const denied=await request(f.socket,{version:1,id:randomUUID(),operation:'resolve',runId:taskRun,alias:'sample'});assert.equal(denied.ok,false);
241
+ const launch=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});
242
+ assert.equal(launch.ok,true,launch.error);assert.equal(launch.stdout,'broker-ok');assert.equal(launch.receipt.status,'completed');
243
+ const receipt=await fs.readFile(launch.receiptPath,'utf8');assert(!receipt.includes(token));assert(!receipt.includes('taskToken'));
244
+ const wrong=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:f.runId,stdin:JSON.stringify(input)});assert.equal(wrong.ok,false);
245
+ await bindings.taskLaunch('browser',null);
246
+ const revoked=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});assert.equal(revoked.ok,false);
247
+ }finally{abort.abort();await serving;process.env.PATH=oldPath;await ledger.stop();provider.closeAllConnections();await new Promise(r=>provider.close(r));}
248
+ });
@@ -652,3 +652,23 @@ test('writable folders require an explicit per-folder grant and can be revoked',
652
652
  assert.equal((await compose(config,record)).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
653
653
  await assert.rejects(f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files','--writable'),/Supply/);
654
654
  });
655
+
656
+ test('command admission waits for a live short lock without removing it',async t=>{
657
+ const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
658
+ let release,started;
659
+ const ready=new Promise(resolve=>started=resolve),hold=new Promise(resolve=>release=resolve);
660
+ const writer=locked(f.home,async()=>{started();await hold;});
661
+ await ready;
662
+ const call=f.call('sample','read');
663
+ await new Promise(resolve=>setTimeout(resolve,100));
664
+ assert.equal(JSON.parse(await fs.readFile(path.join(f.home,'registry.lock'),'utf8')).pid,process.pid);
665
+ release();await writer;
666
+ assert.deepEqual(JSON.parse((await call).stdout),['read']);
667
+ });
668
+
669
+ test('bounded lock admission times out without stealing an interrupted lock',async t=>{
670
+ const f=await fixture(t),file=path.join(f.home,'registry.lock');
671
+ await fs.mkdir(f.home,{recursive:true});await fs.writeFile(file,'preserved');
672
+ await assert.rejects(locked(f.home,async()=>assert.fail('must not enter'),{waitMs:50}),/Registry busy/);
673
+ assert.equal(await fs.readFile(file,'utf8'),'preserved');
674
+ });
@@ -0,0 +1,22 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {mkdtempSync,writeFileSync,openSync,closeSync,rmSync} from 'node:fs'
4
+ import {spawnSync} from 'node:child_process'
5
+ import {join} from 'node:path'
6
+ import {loadConfig} from '../src/config.js'
7
+
8
+ test('installed private descriptor admits speech selectors and keeps speech keys out of the executor', () => {
9
+ const dir=mkdtempSync('/tmp/ez-speech-env-'),file=join(dir,'relay.env'),key='synthetic-private-speech-marker'
10
+ try {
11
+ writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=invalid-private-selector\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
12
+ let fd=openSync(file,'r')
13
+ const invalid=spawnSync(process.execPath,['--import','tsx','docker/run.ts','start'],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH,EZ_APPLICATION_PORT:'8787'},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
14
+ assert.equal(invalid.status,1);assert.match(invalid.stderr,/Invalid EZ_SPEECH_PROVIDER/);assert.ok(!invalid.stderr.includes(key))
15
+ writeFileSync(file,`OPENROUTER_API_KEY=${key}\nEZ_SPEECH_PROVIDER=openrouter\nEZ_SPEECH_MODEL=fish-audio/s2.1-pro-free:free\n`,{mode:0o600})
16
+ fd=openSync(file,'r')
17
+ const child=spawnSync(process.execPath,['--import','tsx','docker/run.ts','exec',process.execPath,'-e',"if(process.env.OPENROUTER_API_KEY||process.env.EZ_SPEECH_PROVIDER)process.exit(91);console.log('isolated')"],{encoding:'utf8',timeout:15000,env:{PATH:process.env.PATH},stdio:['ignore','pipe','pipe',fd]});closeSync(fd)
18
+ assert.equal(child.status,0,child.stderr);assert.equal(child.stdout.trim(),'isolated')
19
+ const config=loadConfig({EZ_APPLICATION_PORT:'8787',OPENROUTER_API_KEY:key,EZ_SPEECH_PROVIDER:'openrouter',EZ_SPEECH_MODEL:'fish-audio/s2.1-pro-free:free'})
20
+ assert.equal(config.openrouterApiKey,key);assert.equal(config.speechProvider,'openrouter');assert.equal(config.speechModel,'fish-audio/s2.1-pro-free:free')
21
+ } finally {rmSync(dir,{recursive:true,force:true})}
22
+ })
@@ -0,0 +1,78 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createHash } from 'node:crypto'
4
+ import { mkdtemp, rm } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { execFile } from 'node:child_process'
8
+ import { promisify } from 'node:util'
9
+ import { ownerRun } from './helpers/owner-run.js'
10
+ import { serveTestLedger } from './helpers/ledger.js'
11
+ import { RunStore } from '../src/runs.js'
12
+ import { ControlStore } from '../src/control-state.js'
13
+ import { runEvidence, projectRunEvidence } from '../src/run-evidence.js'
14
+
15
+ async function fixture(t: test.TestContext) {
16
+ const dir = await mkdtemp(join(tmpdir(), 'ez-evidence-'))
17
+ await ownerRun(dir, 'owner_run')
18
+ const ledger = await serveTestLedger(dir)
19
+ t.after(async () => { await ledger.stop(); await rm(dir, { recursive: true, force: true }) })
20
+ return { dir, runs: new RunStore(dir), owner: (await new ControlStore(dir, 1000).status()).owner! }
21
+ }
22
+
23
+ test('CLI projects operational proof without sessions, failure prose or delivered text', async t => {
24
+ const { dir, runs } = await fixture(t)
25
+ const secret = 'private-session-or-token'
26
+ await runs.patch('owner_run', { nativeSessionId: secret, failureReason: secret,
27
+ failure: { error: secret, relayVersion: '0.1.0-beta.47' } })
28
+ const sent = await runs.enqueueMessage('owner_run', 'private financial report')
29
+ await runs.claimOutbox(sent.id); await runs.markOutboxSent(sent.id, [7])
30
+ const uncertain = await runs.enqueueMessage('owner_run', 'uncertain report')
31
+ await runs.claimOutbox(uncertain.id); await runs.failOutbox(uncertain.id, secret, true)
32
+ const control = await new ControlStore(dir, 1000).status()
33
+ const { stdout } = await promisify(execFile)(process.execPath, ['bin/ezenciel-agents-schedule.mjs', 'evidence'], {
34
+ env: { ...process.env, EZ_CONTROL_DIR: dir, EZ_RUN_ID: 'owner_run' },
35
+ })
36
+ const result = JSON.parse(stdout)
37
+ assert.equal(result.runs.length, 1)
38
+ assert.ok(!stdout.includes(secret) && !stdout.includes('private financial report') && !stdout.includes('uncertain report'))
39
+ const receipts = result.runs[0].deliveries
40
+ assert.equal(receipts.find((r: any) => r.id === sent.id).receiptVerified, true)
41
+ assert.equal(receipts.find((r: any) => r.id === sent.id).content.sha256, createHash('sha256').update('private financial report').digest('hex'))
42
+ assert.equal(receipts.find((r: any) => r.id === uncertain.id).state, 'unknown')
43
+ assert.equal(receipts.find((r: any) => r.id === uncertain.id).receiptVerified, false)
44
+ assert.equal(result.runs[0].toolDelegationMetadata.state, 'unavailable')
45
+ assert.deepEqual(await new ControlStore(dir, 1000).status(), control)
46
+ })
47
+
48
+ test('pages require an unchanged snapshot and exact identity', async t => {
49
+ const { dir, runs } = await fixture(t)
50
+ await ownerRun(dir, 'second_run')
51
+ const first = await runEvidence(dir, { limit: 1 })
52
+ assert.equal(first.coverage.total, 2)
53
+ assert.equal(first.coverage.nextOffset, 1)
54
+ const second = await runEvidence(dir, { limit: 1, offset: 1, expected: first.snapshotSha256 })
55
+ assert.equal(second.coverage.nextOffset, null)
56
+ assert.notEqual(second.runs[0].id, first.runs[0].id)
57
+ await assert.rejects(runEvidence(dir, { offset: 1 }), /requires/)
58
+ await runs.patch('second_run', { status: 'completed' })
59
+ await assert.rejects(runEvidence(dir, { offset: 1, expected: first.snapshotSha256 }), /changed/)
60
+ await assert.rejects(runEvidence(dir, { runId: '../owner_run' }), /identifier/)
61
+ for (const options of [{ limit: 101 }, { offset: -1 }, { offset: 0.5 }, { expected: 'bad' }])
62
+ await assert.rejects(runEvidence(dir, options))
63
+ })
64
+
65
+ test('revoked owners, restricted callers and unrelated bindings are excluded', async t => {
66
+ const { dir, runs, owner } = await fixture(t)
67
+ const stranger = await runs.create({ id: 'other_owner', chatId: 202, telegramUserId: 202, texts: ['secret'] })
68
+ const external = await ownerRun(dir, 'external', { sourceId: 'test', bindingId: 'binding', eventIds: ['1'] })
69
+ const old = { ...(await runs.get('owner_run'))!, id: 'old_run', scheduled: { id: 'schedule', revision: 'rev', dueAt: '2026-01-01T00:00:00Z', pairedAt: 'old-pairing' } }
70
+ const app = { ...(await runs.get('owner_run'))!, id: 'app_run', delivery: { bindingId: 'private', scope: 'private' } }
71
+ assert.deepEqual(projectRunEvidence([stranger, external, old, app], [], owner), [])
72
+ await assert.rejects(runEvidence(dir, { callerRunId: 'external' }), /blocked/)
73
+ await runs.patch('owner_run', { replyOnly: true })
74
+ await assert.rejects(runEvidence(dir, { callerRunId: 'owner_run' }), /Telegram owner/)
75
+ await assert.rejects(runEvidence(dir, { runId: 'other_owner' }), /Unknown/)
76
+ await new ControlStore(dir, 1000).revokeOwner()
77
+ await assert.rejects(runEvidence(dir), /No authorized owner/)
78
+ })
@@ -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
- const args=['create','test','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
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,undefined);assert.equal(saved.execution.preset.effort,undefined)
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})
@@ -0,0 +1,49 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {mkdtemp,rm,readFile} from 'node:fs/promises'
4
+ import {join} from 'node:path'
5
+ import {tmpdir} from 'node:os'
6
+ import {randomUUID} from 'node:crypto'
7
+ import {Scheduler} from '../src/scheduler.js'
8
+ import {RunStore} from '../src/runs.js'
9
+ import {validPreflight,combinePreflight} from '../src/schedule-preflight.js'
10
+ const packet=(eligible:boolean,fingerprint='a'.repeat(64))=>({eligible,fingerprint,count:eligible?1:0,observedAt:new Date().toISOString()})
11
+ test('preflight skips empty/unchanged work, admits saved worker once, and fails closed',async t=>{
12
+ const dir=await mkdtemp(join(tmpdir(),'schedule-preflight-'));t.after(()=>rm(dir,{recursive:true,force:true}))
13
+ let result=packet(false),failed=false
14
+ const scheduler=new Scheduler(dir,async()=>{if(failed)throw Error('Provider unavailable');return result}),runs=new RunStore(dir)
15
+ const owner={telegramUserId:42,telegramChatId:42,pairedAt:new Date().toISOString()},start=Date.now()+10000
16
+ const input={id:'preflight',name:'Check',text:'Do role work',enabled:true,owner,execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},trigger:{everySeconds:60,start:new Date(start).toISOString()},preflight:{on:'changed' as const,checks:[{alias:'provider-work',args:['--field','Role=RoleX']}]}}
17
+ const s=await scheduler.save(input)
18
+ await scheduler.tick(owner,runs,start);assert.equal((await runs.list()).length,0)
19
+ result=packet(true,'b'.repeat(64));await scheduler.tick(owner,runs,start+60000)
20
+ const [run]=await runs.list();assert.deepEqual(run.execution,s.execution)
21
+ await scheduler.tick(owner,runs,start+120000);assert.equal((await runs.list()).length,1)
22
+ await runs.patch(run.id,{status:'completed'});await scheduler.tick(owner,runs,start+180000);assert.equal((await runs.list()).length,1)
23
+ failed=true;await scheduler.tick(owner,runs,start+240000);assert.equal((await runs.list()).length,1);assert.equal((await scheduler.preflightReceipt(s)).state,'unavailable')
24
+ failed=false;result=packet(false);await assert.rejects(()=>scheduler.trigger(s.id,s.revision,'empty',owner,runs),/no eligible/)
25
+ result=packet(true,'c'.repeat(64));await scheduler.tick(owner,runs,start+300000);assert.equal((await runs.list()).length,2)
26
+ })
27
+ test('only bounded literal commands and fresh typed packets enter admission',()=>{
28
+ assert.equal(validPreflight({on:'eligible',checks:[{alias:'queue',args:['x']}] }),true)
29
+ assert.equal(validPreflight({on:'eligible',checks:[{alias:'queue',args:['x\nwrite']}] }),false)
30
+ assert.throws(()=>combinePreflight([{schemaVersion:1,...packet(true),eligible:'true'}]),/Invalid/)
31
+ assert.throws(()=>combinePreflight([{schemaVersion:1,...packet(true),observedAt:'2000-01-01T00:00:00Z'}]),/stale/)
32
+ assert.equal(combinePreflight([{schemaVersion:1,...packet(false)},{schemaVersion:1,...packet(true)}]).eligible,true)
33
+ })
34
+
35
+ test('manual preflight before the first tick preserves future recurrence',async t=>{
36
+ const dir=await mkdtemp(join(tmpdir(),'schedule-preflight-'));t.after(()=>rm(dir,{recursive:true,force:true}))
37
+ const scheduler=new Scheduler(dir,async()=>packet(true)),runs=new RunStore(dir)
38
+ const owner={telegramUserId:42,telegramChatId:42,pairedAt:new Date().toISOString()}
39
+ const s=await scheduler.save({id:'manual',name:'Manual',text:'Work',enabled:true,owner,
40
+ execution:{sessionId:randomUUID(),preset:{id:'test',name:'test',cli:'codex',model:'fixture-model'}},
41
+ trigger:{everySeconds:60,start:new Date(Date.now()+60000).toISOString()},
42
+ preflight:{on:'eligible',checks:[{alias:'provider-work',args:[]}]}})
43
+ const next=await scheduler.pendingOccurrence(s)
44
+ const manual=await scheduler.trigger(s.id,s.revision,'first',owner,runs)
45
+ assert.equal(await scheduler.pendingOccurrence(s),next)
46
+ await runs.patch(manual.id,{status:'completed'})
47
+ await scheduler.tick(owner,runs,next!)
48
+ assert.equal((await runs.list()).length,2)
49
+ })
@@ -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 · client default · default effort\n Read the ledger and send the owner a concise report.')
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,17 +20,32 @@ 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')))
45
+ const parallel='r_schedule_parallel'
46
+ await runs.create({id:parallel,chatId:101,telegramUserId:101,texts:['parallel'],execution:{sessionId:randomUUID(),preset:{id:'fixture',name:'Fixture',cli:'grok',model:'fixture-model'}},scheduled:{id:'parallel',revision:'v',dueAt:new Date().toISOString(),pairedAt:'paired'}})
47
+ await runs.patch(parallel,{status:'running'});await submit(parallel)
48
+ await until(async()=>Boolean(await exists(parallel+'.process.json')))
34
49
  await submit('tg_1')
35
50
  await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
36
51
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
@@ -49,6 +64,9 @@ test('host transport keeps chat responsive beside a schedule in the bound mind',
49
64
  assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
50
65
  await writeFile(join(dir,id+'.cancel'),'')
51
66
  await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
67
+ assert.ok(!(await exists(parallel+'.events')).includes('"stream":"exit"'))
68
+ await writeFile(join(dir,parallel+'.cancel'),'')
69
+ await until(async()=>(await exists(parallel+'.events')).includes('"stream":"exit"'))
52
70
  await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
53
71
  await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
54
72
  const main=JSON.parse(stdout(await exists('tg_1.events')))
@@ -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()=>{
@@ -44,10 +89,10 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
44
89
  }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
45
90
  })
46
91
 
47
- test('Slack application replies beside a schedule and keeps both streams serial',async()=>{
92
+ test('Slack application replies beside schedules configured to stay serial',async()=>{
48
93
  const dir=await mkdtemp(join(tmpdir(),'ez-slack-scheduled-')),runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
49
94
  const children=new Map<string,ReturnType<typeof spawn>>()
50
- const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async(_texts,options)=>{
95
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture',scheduledConcurrency:1},async(_texts,options)=>{
51
96
  const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
52
97
  children.set(options.runId,child);await once(child,'spawn')
53
98
  if(!options.runId.startsWith('r_schedule_'))await runs.enqueueMessage(options.runId,'Slack reply')
@@ -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()
@@ -131,3 +176,38 @@ test('owner chat replies while a scheduled CLI keeps running; targeted cancellat
131
176
  await rm(dir,{recursive:true,force:true})
132
177
  }
133
178
  })
179
+
180
+ for(const limit of [undefined,2])test(`scheduled admission bounds independent native jobs at ${limit ?? 6} and releases a cancelled slot`,async()=>{
181
+ const dir=await mkdtemp(join(tmpdir(),'ez-parallel-schedules-')),control=new ControlStore(dir,1000),runs=new RunStore(dir),scheduler=new Scheduler(dir)
182
+ const children=new Map<string,ReturnType<typeof spawn>>(),optionsSeen:ExecutorOptions[]=[]
183
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture',scheduledConcurrency:limit},async(_texts,options)=>{
184
+ optionsSeen.push(options)
185
+ const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:process.platform!=='win32'})
186
+ children.set(options.runId,child);await once(child,'spawn');return {child,cleanup:async()=>{},stdout:''}
187
+ })
188
+ try{
189
+ await control.requestPairing(101,101);await control.approveOwner(101)
190
+ const owner=(await control.status()).owner!,execution=await control.captureChoice({...initialPreset('grok'),model:'saved-model',effort:'medium'})
191
+ const jobs: Awaited<ReturnType<Scheduler['trigger']>>[]=[]
192
+ for(let n=0;n<(limit ?? 6)+1;n++){
193
+ const saved=await scheduler.save({id:`job${n}`,name:`Job${n}`,text:'Finite test',owner,execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}},true)
194
+ jobs.push(await scheduler.trigger(saved.id,saved.revision,`key${n}`,owner,runs))
195
+ }
196
+ await Promise.all([relay.drainSources(),relay.drainSources()])
197
+ assert.equal((await runs.list()).filter(r=>r.status==='running').length,limit ?? 6)
198
+ assert.equal((await runs.get(jobs.at(-1)!.id))?.status,'queued')
199
+ assert.equal(new Set(optionsSeen.map(o=>o.sessionId)).size,limit ?? 6)
200
+ for(const options of optionsSeen){assert.equal(options.model,'saved-model');assert.equal(options.effort,'medium');assert.equal(options.nativeSession,true);assert.equal(options.isResume,false)}
201
+ const foreground=await runs.create({id:'tg_parallel_chat',chatId:101,telegramUserId:101,texts:['Owner chat'],execution})
202
+ await relay.drainSources();assert.equal((await runs.get(foreground.id))?.status,'running')
203
+ assert.equal(children.size,(limit ?? 6)+1)
204
+ await scheduler.cancel(jobs[0].id);await relay.drainSources()
205
+ await until(async()=> (await runs.get(jobs[0].id))?.status==='cancelled')
206
+ await relay.drainSources();assert.equal((await runs.get(jobs.at(-1)!.id))?.status,'running')
207
+ for(const job of jobs.slice(1))assert.equal((await runs.get(job.id))?.status,'running')
208
+ }finally{
209
+ await relay.stop()
210
+ await until(async()=>!(await runs.list()).some(r=>r.status==='running'))
211
+ await rm(dir,{recursive:true,force:true})
212
+ }
213
+ })
@@ -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
+ })