@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +15 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +18 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +55 -18
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +9 -13
- package/package.json +1 -1
- package/src/agent-guidance.ts +2 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- package/src/executor.ts +9 -0
- package/src/host-executor-client.ts +19 -7
- package/src/index.ts +125 -21
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/runs.ts +61 -3
- package/src/schedule-cli.ts +18 -12
- package/src/scheduled-tasks.ts +42 -8
- package/src/scheduler.ts +1 -1
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/supervisor.mjs +1 -1
- package/src/workspace.ts +12 -1
- package/test/ai.test.ts +48 -2
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/intake-relay.test.ts +63 -2
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +132 -0
- package/test/scheduled-tasks.test.ts +24 -2
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +19 -10
- package/test/workspace.test.ts +6 -0
package/test/ai.test.ts
CHANGED
|
@@ -106,13 +106,17 @@ test('OpenCode keeps same-provider models together and separates providers encod
|
|
|
106
106
|
const other = { ...first, id: 'other', name: 'Other', model: 'openrouter/other' }
|
|
107
107
|
const choice = await store.captureChoice(first)
|
|
108
108
|
await store.saveNativeSession(choice.sessionId, 'ses_original')
|
|
109
|
-
const
|
|
109
|
+
const closed: string[] = []
|
|
110
|
+
const menu = createAiMenu(store, 'opencode', async () => [first, second, other].map(p => ({ ...p, efforts: [] })), dir, undefined, async () => true,
|
|
111
|
+
undefined, async (session) => { if (session) closed.push(session.sessionId) })
|
|
110
112
|
await store.savePreset(second)
|
|
111
113
|
assert.equal((await menu.select(second, choice.sessionId)).fresh, false)
|
|
114
|
+
assert.deepEqual(closed, [])
|
|
112
115
|
assert.equal((await store.captureChoice(first)).sessionId, choice.sessionId)
|
|
113
116
|
await store.savePreset(other)
|
|
114
117
|
assert.equal(await store.selectPreset(other.id, choice.sessionId), false)
|
|
115
118
|
assert.equal((await menu.select(other, choice.sessionId)).fresh, true)
|
|
119
|
+
assert.deepEqual(closed, [choice.sessionId]) // switching provider closes the old conversation once
|
|
116
120
|
const next = await store.captureChoice(first)
|
|
117
121
|
assert.notEqual(next.sessionId, choice.sessionId)
|
|
118
122
|
assert.equal((await store.executionSession(next)).nativeSessionId, undefined)
|
|
@@ -519,7 +523,7 @@ test('opencode model and variant selections validate against the installed catal
|
|
|
519
523
|
await validateSelection({ id: 'default', name: 'Default', cli: 'opencode', model: 'opencode/big-pickle' }, catalog, available)
|
|
520
524
|
await assert.rejects(
|
|
521
525
|
validateSelection({ id: 'bad', name: 'Bad', cli: 'opencode', model: 'opencode/ling-free', effort: 'max' }, catalog, available),
|
|
522
|
-
/
|
|
526
|
+
/Effort max is not supported.*low, medium/)
|
|
523
527
|
await assert.rejects(
|
|
524
528
|
validateSelection({ id: 'missing', name: 'Missing', cli: 'opencode', model: 'opencode/unknown' }, catalog, available),
|
|
525
529
|
/installed client catalog/)
|
|
@@ -672,3 +676,45 @@ test('Claude catalog projects only aliases and efforts from the installed native
|
|
|
672
676
|
await validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus', effort: 'xhigh' }, models, async () => true)
|
|
673
677
|
await assert.rejects(validateSelection({ id: 'c', name: 'c', cli: 'claude', model: 'opus-guess' }, models, async () => true), /not in the installed client catalog/)
|
|
674
678
|
})
|
|
679
|
+
|
|
680
|
+
test('Telegram Sonnet choice survives restart and reaches the native launch without Opus substitution', async () => {
|
|
681
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-sonnet-selection-'))
|
|
682
|
+
const priorPath = process.env.PATH
|
|
683
|
+
try {
|
|
684
|
+
const store = new ControlStore(dir, 1000)
|
|
685
|
+
await store.captureChoice({id:'opus',name:'Opus',cli:'claude',model:'opus',effort:'xhigh'})
|
|
686
|
+
const models = [{cli:'claude',model:'opus',name:'Opus',efforts:['xhigh']},
|
|
687
|
+
{cli:'claude',model:'sonnet',name:'Sonnet',efforts:['low','medium','high']}]
|
|
688
|
+
const menu = createAiMenu(store, 'claude', async()=>models, dir, undefined, async()=>true)
|
|
689
|
+
const replies: Array<{text:string; buttons:Array<{text:string;callback_data:string}>}> = []
|
|
690
|
+
const context = (data?:string) => ({callbackQuery:data ? {data} : undefined,answerCallbackQuery:async()=>({}),
|
|
691
|
+
reply:async(text:string, options?:{reply_markup?:{inline_keyboard?:Array<Array<{text:string;callback_data:string}>>}})=>{
|
|
692
|
+
replies.push({text,buttons:options?.reply_markup?.inline_keyboard?.flat() ?? []});return {} as never
|
|
693
|
+
}})
|
|
694
|
+
const click = async(text:string) => menu.handle(context(replies.at(-1)!.buttons.find(b=>b.text===text)!.callback_data) as never)
|
|
695
|
+
await menu.list(context() as never)
|
|
696
|
+
await click('claude');await click('Sonnet');await click('high')
|
|
697
|
+
const restarted = new ControlStore(dir,1000)
|
|
698
|
+
const captured = await restarted.captureChoice(menu.initial)
|
|
699
|
+
assert.equal(captured.preset.model,'sonnet');assert.equal(captured.preset.effort,'high')
|
|
700
|
+
const before = await restarted.status()
|
|
701
|
+
await assert.rejects(menu.saveSelection(models[1], 'xhigh'), /Effort xhigh is not supported.*low, medium, high/)
|
|
702
|
+
await assert.rejects(validateSelection({id:'s',name:'Sonnet',cli:'claude',model:'sonnet',effort:'high'}, [], async()=>true), /No models advertised for claude.*current selection unchanged/)
|
|
703
|
+
assert.deepEqual(await restarted.status(), before)
|
|
704
|
+
const {ownerRun} = await import('./helpers/owner-run.js')
|
|
705
|
+
const {startExecutorJob} = await import('../src/executor.js')
|
|
706
|
+
await mkdir(join(dir,'bin'))
|
|
707
|
+
await writeFile(join(dir,'bin','claude'), `#!${process.execPath}\nrequire('fs').writeFileSync('launched.json',JSON.stringify(process.argv.slice(2)));require('fs').readFileSync(0,'utf8')`, {mode:0o700})
|
|
708
|
+
process.env.PATH = join(dir,'bin') + ':' + priorPath
|
|
709
|
+
await ownerRun(dir,'r_sonnet_selected')
|
|
710
|
+
const job = await startExecutorJob(['synthetic request'], {workspace:dir,controlDir:dir,binDir:join(dir,'bin'),runId:'r_sonnet_selected',timeoutMs:5000,repairEnabled:false,
|
|
711
|
+
cli:captured.preset.cli,model:captured.preset.model,effort:captured.preset.effort,sessionId:captured.sessionId})
|
|
712
|
+
const closed = new Promise<number|null>(resolve=>job.child.once('close',resolve))
|
|
713
|
+
assert.equal(await closed,0)
|
|
714
|
+
assert.deepEqual(JSON.parse(await readFile(join(dir,'launched.json'),'utf8')).slice(-4), ['--model','sonnet','--effort','high'])
|
|
715
|
+
await job.cleanup()
|
|
716
|
+
} finally {
|
|
717
|
+
if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath
|
|
718
|
+
await rm(dir,{recursive:true,force:true})
|
|
719
|
+
}
|
|
720
|
+
})
|
|
@@ -450,3 +450,27 @@ test('administrator task grant replacement preserves the runtime file owner', {s
|
|
|
450
450
|
const disabled=await stat(file);assert.equal(disabled.uid,20000);assert.equal(disabled.gid,20000)
|
|
451
451
|
}finally{await rm(dir,{recursive:true,force:true})}
|
|
452
452
|
})
|
|
453
|
+
|
|
454
|
+
test('empty server Claude catalog rejects Sonnet chat selection without changing Opus xhigh', async t => {
|
|
455
|
+
const root = await mkdtemp(join(tmpdir(),'ez-app-missing-claude-'))
|
|
456
|
+
t.after(()=>rm(root,{recursive:true,force:true}))
|
|
457
|
+
const owned = await owner(root), control = new ControlStore(root,1000)
|
|
458
|
+
const initial = {id:'opus',name:'Opus',cli:'claude',model:'opus',effort:'xhigh'}
|
|
459
|
+
await control.captureChoice(initial)
|
|
460
|
+
const {createAiMenu} = await import('../src/menu.js')
|
|
461
|
+
let models: import('../src/ai.js').ModelChoice[] = []
|
|
462
|
+
const menu = createAiMenu(control,'claude',async()=>models,root,undefined,async()=>true)
|
|
463
|
+
const channel = new ApplicationChannel({controlDir:root,initial,wake:()=>{},cancel:async()=>{},aiControls:menu})
|
|
464
|
+
const binding = (await channel.bindings.register('app',token(),owned,true))!
|
|
465
|
+
const before = await control.status()
|
|
466
|
+
const input = {action:'model',expectedSession:before.activeSession!.sessionId,cli:'claude',model:'sonnet',effort:'high'}
|
|
467
|
+
await assert.rejects(channel.changeControls(binding.bindingId,input), /No models advertised for claude.*current selection unchanged/)
|
|
468
|
+
assert.deepEqual(await control.status(),before)
|
|
469
|
+
models = [{cli:'claude',model:'sonnet',name:'Sonnet',efforts:['low','medium','high']}]
|
|
470
|
+
await assert.rejects(channel.changeControls(binding.bindingId,{...input,effort:'xhigh'}), /Effort xhigh is not supported.*low, medium, high/)
|
|
471
|
+
assert.deepEqual(await control.status(),before)
|
|
472
|
+
await channel.changeControls(binding.bindingId,input)
|
|
473
|
+
const admitted = await channel.submit(binding.bindingId,{requestId:'sonnet',scope:'chat',text:'Synthetic request',followTelegram:true})
|
|
474
|
+
assert.equal(admitted.execution!.preset.model,'sonnet')
|
|
475
|
+
assert.equal(admitted.execution!.preset.effort,'high')
|
|
476
|
+
})
|
|
@@ -53,7 +53,8 @@ test('private scope reset and model controls preserve running sessions and share
|
|
|
53
53
|
{cli:'codex',model:'fixture-model',name:'Fixture',efforts:['low','high']},
|
|
54
54
|
{cli:'opencode',model:'fixture-other',name:'Other',efforts:[]},
|
|
55
55
|
],root,join(root,'native-home'),async()=>true)
|
|
56
|
-
const
|
|
56
|
+
const closed:string[] = []
|
|
57
|
+
const channel = new ApplicationChannel({controlDir:root,initial:menu.initial,aiControls:menu,wake:()=>{},cancel:async()=>{},closeSession:async session=>{if(session)closed.push(session.sessionId)}})
|
|
57
58
|
t.after(async()=>{await channel.stop();await rm(root,{recursive:true,force:true})})
|
|
58
59
|
const token = randomBytes(32).toString('base64url')
|
|
59
60
|
const binding = (await channel.bindings.register('private',token,owner))!
|
|
@@ -77,6 +78,7 @@ test('private scope reset and model controls preserve running sessions and share
|
|
|
77
78
|
assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,200)
|
|
78
79
|
const next = await channel.scopeControls(binding.bindingId,'exercise')
|
|
79
80
|
assert.notEqual(next.activeSessionId,before.activeSessionId)
|
|
81
|
+
assert.deepEqual(closed,[]) // a private scope runs under its binding, so no owner-authority close turn
|
|
80
82
|
assert.equal(next.ai.selectedId,menu.initial.id)
|
|
81
83
|
assert.equal((await call({action:'new',expectedSession:before.activeSessionId})).status,400)
|
|
82
84
|
assert.equal((await control.executionSession(run.execution!)).nativeSessionId,'native-private')
|
|
@@ -89,6 +91,7 @@ test('private scope reset and model controls preserve running sessions and share
|
|
|
89
91
|
assert.deepEqual((await control.status()).ai!.presets,presets)
|
|
90
92
|
assert.equal((await call({action:'model',expectedSession:next.activeSessionId,cli:'codex',model:'fixture-model',effort:'high'})).status,200)
|
|
91
93
|
const updated = await channel.submit(binding.bindingId,{requestId:'updated-model',scope:'exercise',text:'same engine'})
|
|
94
|
+
assert.deepEqual(closed,[])
|
|
92
95
|
assert.equal(updated.execution!.sessionId,next.activeSessionId)
|
|
93
96
|
assert.equal(updated.execution!.preset.effort,'high')
|
|
94
97
|
assert.deepEqual((await new RunStore(root).get(after.id))!.execution,after.execution)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto'
|
|
4
4
|
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { join } from 'node:path'
|
|
@@ -17,6 +17,8 @@ import { atomicTaskFile, Tasks } from '../src/tasks.js'
|
|
|
17
17
|
import { EventSources } from '../src/event-sources.js'
|
|
18
18
|
import { ownerRun } from './helpers/owner-run.js'
|
|
19
19
|
import { packageVersion } from '../src/version.js'
|
|
20
|
+
import { SESSION_CLOSE_PROMPT } from '../src/session-close.js'
|
|
21
|
+
import type { ExecutorOptions } from '../src/executor.js'
|
|
20
22
|
import type { Config } from '../src/config.js'
|
|
21
23
|
|
|
22
24
|
const message = (id: number, text = 'hello'): Update => ({
|
|
@@ -32,6 +34,7 @@ const message = (id: number, text = 'hello'): Update => ({
|
|
|
32
34
|
const fixture = async (overrides: Partial<Config> = {}) => {
|
|
33
35
|
const dir = await mkdtemp(join(tmpdir(), 'ez-intake-relay-'))
|
|
34
36
|
const launched: string[][] = []
|
|
37
|
+
const launchOptions: ExecutorOptions[] = []
|
|
35
38
|
const replies: string[] = []
|
|
36
39
|
const members = new Map<number, string>()
|
|
37
40
|
const keyboards: { text: string; callback_data: string }[][][] = []
|
|
@@ -47,8 +50,9 @@ const fixture = async (overrides: Partial<Config> = {}) => {
|
|
|
47
50
|
...overrides,
|
|
48
51
|
}
|
|
49
52
|
const make = () => {
|
|
50
|
-
const relay = createRelay(config, async (texts) => {
|
|
53
|
+
const relay = createRelay(config, async (texts, options) => {
|
|
51
54
|
launched.push(texts)
|
|
55
|
+
launchOptions.push(options)
|
|
52
56
|
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 50)'])
|
|
53
57
|
children.push(child)
|
|
54
58
|
await once(child, 'spawn')
|
|
@@ -79,6 +83,7 @@ const fixture = async (overrides: Partial<Config> = {}) => {
|
|
|
79
83
|
return {
|
|
80
84
|
dir,
|
|
81
85
|
launched,
|
|
86
|
+
launchOptions,
|
|
82
87
|
replies,
|
|
83
88
|
members,
|
|
84
89
|
keyboards,
|
|
@@ -336,6 +341,35 @@ test('new conversation leaves already accepted messages pinned to the old conver
|
|
|
336
341
|
} finally { await f.close() }
|
|
337
342
|
})
|
|
338
343
|
|
|
344
|
+
test('new conversation gives the started one a single silent close turn in its own native session', async () => {
|
|
345
|
+
const f = await fixture()
|
|
346
|
+
try {
|
|
347
|
+
const runs = new RunStore(f.dir), store = new ControlStore(f.dir, 1000)
|
|
348
|
+
const settled = async () => {
|
|
349
|
+
for (let i = 0; i < 200 && (await runs.list()).some((r) => ['queued', 'running'].includes(r.status)); i++)
|
|
350
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
351
|
+
}
|
|
352
|
+
await f.relay.bot.handleUpdate(message(1, '/new'))
|
|
353
|
+
assert.equal(f.launched.length, 0) // an unstarted conversation has nothing to keep
|
|
354
|
+
await f.relay.bot.handleUpdate(message(2, 'My sister is called Ana'))
|
|
355
|
+
await f.relay.drainInbox(true)
|
|
356
|
+
await settled()
|
|
357
|
+
const first = (await store.getActiveSession())!
|
|
358
|
+
assert.equal(first.hasStarted, true)
|
|
359
|
+
await f.relay.bot.handleUpdate(message(3, '/new'))
|
|
360
|
+
await settled()
|
|
361
|
+
assert.deepEqual(f.launched.at(-1), [SESSION_CLOSE_PROMPT])
|
|
362
|
+
const close = (await runs.list()).find((r) => r.sessionClose)!
|
|
363
|
+
assert.equal(close.execution!.sessionId, first.sessionId)
|
|
364
|
+
assert.equal(close.chatId, undefined); assert.equal(close.messageId, undefined)
|
|
365
|
+
const options = f.launchOptions.at(-1)!
|
|
366
|
+
assert.equal(options.isResume, true)
|
|
367
|
+
assert.equal(options.promptSuffix, '') // no reply mechanism: nothing is delivered
|
|
368
|
+
assert.notEqual((await store.getActiveSession())!.sessionId, first.sessionId)
|
|
369
|
+
assert.equal((await runs.list()).filter((r) => r.sessionClose).length, 1)
|
|
370
|
+
} finally { await f.close() }
|
|
371
|
+
})
|
|
372
|
+
|
|
339
373
|
test('accepted DM survives relay restart and duplicate delivery creates exactly one run', async () => {
|
|
340
374
|
const f = await fixture()
|
|
341
375
|
try {
|
|
@@ -752,3 +786,30 @@ test('tasks command never reaches the executor on invalid input or foreign sende
|
|
|
752
786
|
assert.equal(f.launched.length, 0)
|
|
753
787
|
} finally { await f.close() }
|
|
754
788
|
})
|
|
789
|
+
|
|
790
|
+
test('tasks list and detail show saved frequency and ordered setup path without changing or launching work',async()=>{
|
|
791
|
+
const f=await fixture()
|
|
792
|
+
try{
|
|
793
|
+
const {Scheduler}=await import('../src/scheduler.js')
|
|
794
|
+
const control=new ControlStore(f.dir,1000),scheduler=new Scheduler(f.dir)
|
|
795
|
+
const preset={id:'primary',name:'Primary',cli:'claude',model:'opus',effort:'high'}
|
|
796
|
+
const fallbacks=[
|
|
797
|
+
{...preset,id:'second',authProfile:'claude2'},
|
|
798
|
+
{id:'third',name:'Third',cli:'codex',authProfile:'work',model:'gpt-6-sol',effort:'xhigh'},
|
|
799
|
+
{id:'fourth',name:'Fourth',cli:'pi',model:'anthropic/claude-sonnet',effort:'medium'}]
|
|
800
|
+
const saved=await scheduler.save({id:'analyst',name:'Analyst',text:'Read the saved role.',owner:(await control.status()).owner!,
|
|
801
|
+
execution:{sessionId:randomUUID(),preset,fallbacks},
|
|
802
|
+
enabled:true,trigger:{cron:'*/30 * * * *',timezone:'America/New_York',start:'2027-01-01T00:00:00Z'}})
|
|
803
|
+
const path='claude (default login) · opus · high → claude@claude2 · opus · high → codex@work · gpt-6-sol · xhigh → pi · anthropic/claude-sonnet · medium'
|
|
804
|
+
await f.relay.bot.handleUpdate(message(1,'/tasks'))
|
|
805
|
+
const list=f.replies.at(-1)!
|
|
806
|
+
assert.ok(list.includes('Schedule · Every 30 minutes · America/New_York'))
|
|
807
|
+
assert.ok(list.includes(`Execution · ${path}`))
|
|
808
|
+
await f.relay.bot.handleUpdate(message(2,'/tasks 1'))
|
|
809
|
+
const detail=f.replies.at(-1)!
|
|
810
|
+
assert.ok(detail.includes('Schedule\nEvery 30 minutes · America/New_York'))
|
|
811
|
+
assert.ok(detail.includes(`Execution path\n${path}`))
|
|
812
|
+
assert.deepEqual(await scheduler.get(saved.id),saved)
|
|
813
|
+
assert.equal(f.launched.length,0)
|
|
814
|
+
}finally{await f.close()}
|
|
815
|
+
})
|
|
@@ -17,7 +17,7 @@ test('engine defaults stay omitted and explicit native settings survive every ad
|
|
|
17
17
|
const preset={id:'chosen',name:'Chosen',cli,model:'native-model',effort:'ultra'}
|
|
18
18
|
assert.deepEqual(executionDefaults(cli,preset),preset)
|
|
19
19
|
await validateSelection(preset,[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true)
|
|
20
|
-
await assert.rejects(validateSelection({...preset,effort:'unsupported'},[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true),/
|
|
20
|
+
await assert.rejects(validateSelection({...preset,effort:'unsupported'},[{cli,model:'native-model',name:'Native',efforts:['ultra']}],async()=>true),/Effort unsupported is not supported.*Supported efforts: ultra/)
|
|
21
21
|
assert.throws(()=>executionDefaults(cli,{effort:'bad option'}),/Invalid reasoning effort/)
|
|
22
22
|
}
|
|
23
23
|
assert.deepEqual(executionOverrides('codex',{model:'old',effort:'max'},'new'),{model:'new',effort:undefined})
|
|
@@ -103,7 +103,8 @@ test('isolated broker authenticates an owned run, pins plugin revision, and writ
|
|
|
103
103
|
assert.deepEqual(JSON.parse(listed.stdout), { sample: 'sample' });
|
|
104
104
|
const detailed = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'list', '--details'] });
|
|
105
105
|
assert.equal(detailed.ok, true);
|
|
106
|
-
assert.deepEqual(JSON.parse(detailed.stdout).sample.
|
|
106
|
+
assert.deepEqual(JSON.parse(detailed.stdout).sample.aliases, ['sample']);
|
|
107
|
+
assert.equal(JSON.parse(detailed.stdout).sample.example, 'ez sample --help');
|
|
107
108
|
assert.deepEqual(JSON.parse(detailed.stdout).sample.skillReads, [['ez', 'tools', 'skill', 'sample', 'SKILL.md']]);
|
|
108
109
|
const skill = await request(f.socket, { version: 1, id: randomUUID(), operation: 'manager', runId: f.runId, args: ['tools', 'skill', 'sample', 'SKILL.md'] });
|
|
109
110
|
assert.equal(skill.code, 0);
|
|
@@ -249,3 +250,49 @@ test('task launch broker admits only its fixed application command and writes no
|
|
|
249
250
|
const revoked=await request(f.socket,{version:1,id:randomUUID(),operation:'task-launch',runId:taskRun,stdin:JSON.stringify(input)});assert.equal(revoked.ok,false);
|
|
250
251
|
}finally{abort.abort();await serving;process.env.PATH=oldPath;await ledger.stop();provider.closeAllConnections();await new Promise(r=>provider.close(r));}
|
|
251
252
|
});
|
|
253
|
+
|
|
254
|
+
// Native engines and Node scripts wire child stdin as a socket they may never
|
|
255
|
+
// close. The isolated client must reach the broker instead of waiting for EOF,
|
|
256
|
+
// while pipes and files still deliver their complete input.
|
|
257
|
+
test('isolated client does not wait forever on an inherited open stdin socket', async t => {
|
|
258
|
+
const { createServer: createSocketServer } = await import('node:net');
|
|
259
|
+
const { spawn } = await import('node:child_process');
|
|
260
|
+
const { once } = await import('node:events');
|
|
261
|
+
const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'e-')));
|
|
262
|
+
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
263
|
+
const socket = path.join(root, 'b.sock'), invokes = [];
|
|
264
|
+
const broker = createSocketServer(connection => {
|
|
265
|
+
let buffer = '';
|
|
266
|
+
connection.on('data', chunk => {
|
|
267
|
+
buffer += chunk;
|
|
268
|
+
if (!buffer.includes('\n')) return;
|
|
269
|
+
const frame = JSON.parse(buffer);
|
|
270
|
+
if (frame.operation === 'invoke') invokes.push(frame);
|
|
271
|
+
connection.end(JSON.stringify(frame.operation === 'resolve' ? { ok: true, revision, capability: 'c'.repeat(64) } : { ok: true, code: 0, stdout: 'ok' }) + '\n');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
await new Promise(resolve => broker.listen(socket, resolve));
|
|
275
|
+
t.after(() => broker.close());
|
|
276
|
+
const client = new URL('../bin/ez', import.meta.url).pathname;
|
|
277
|
+
const env = { PATH: process.env.PATH, EZ_PLUGIN_BROKER_SOCKET: socket, EZ_RUN_ID: 'r_client' };
|
|
278
|
+
const finish = async child => {
|
|
279
|
+
let stdout = '';
|
|
280
|
+
child.stdout.on('data', chunk => stdout += chunk);
|
|
281
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
|
|
282
|
+
const [code, signal] = await once(child, 'close');
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
return { code, signal, stdout };
|
|
285
|
+
};
|
|
286
|
+
const open = spawn(process.execPath, [client, 'sample', 'read'], { env });
|
|
287
|
+
t.after(() => open.kill('SIGKILL'));
|
|
288
|
+
assert.deepEqual(await finish(open), { code: 0, signal: null, stdout: 'ok' });
|
|
289
|
+
assert.deepEqual(invokes.at(-1).args, ['read']);
|
|
290
|
+
assert.equal(invokes.at(-1).stdin, undefined);
|
|
291
|
+
const supplied = spawn(process.execPath, [client, 'sample', 'write'], { env });
|
|
292
|
+
supplied.stdin.end('payload');
|
|
293
|
+
assert.equal((await finish(supplied)).code, 0);
|
|
294
|
+
assert.equal(invokes.at(-1).stdin, 'payload');
|
|
295
|
+
const piped = spawn('/bin/sh', ['-c', '(sleep 1.5; printf late) | "$0" "$1" sample pipe', process.execPath, client], { env, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
296
|
+
assert.equal((await finish(piped)).code, 0);
|
|
297
|
+
assert.equal(invokes.at(-1).stdin, 'late');
|
|
298
|
+
});
|
|
@@ -7,6 +7,7 @@ import {execFile,spawn} from 'node:child_process';
|
|
|
7
7
|
import {once} from 'node:events';
|
|
8
8
|
import {promisify} from 'node:util';
|
|
9
9
|
import {snapshot,init as initManager,validate,compose,locked,bindToolDiscovery,prepareCommand} from '../src/plugins/manager.mjs';
|
|
10
|
+
import {TOOLS_LINE} from '../src/tools-line.mjs';
|
|
10
11
|
import { ControlStore, ownerId, ownerEpoch } from '../src/control-state.js';
|
|
11
12
|
import { ApplicationBindings } from '../src/application-channel.js';
|
|
12
13
|
import { RunStore } from '../src/runs.js';
|
|
@@ -33,6 +34,16 @@ for(const cleanup of ['success','failure','already-removed']) test(`cancel remov
|
|
|
33
34
|
if(cleanup==='failure')assert.match(stderr,/cleanup failed/);
|
|
34
35
|
assert.deepEqual((await fs.readFile(log,'utf8')).trim().split('\n').map(JSON.parse),[['run'],['container','rm','--force','exact-test-container']]);
|
|
35
36
|
});
|
|
37
|
+
// Compose --rm only removes a container that ran. Compose dying between create
|
|
38
|
+
// and start (signal, broken output pipe, error) must not strand it in Created.
|
|
39
|
+
for(const exit of ['signal','failure','success']) test(`compose exit by ${exit} without manager cancellation ${exit==='success'?'leaves no cleanup call':'removes its created container'}`,async t=>{
|
|
40
|
+
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-exit-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
41
|
+
const log=path.join(root,'calls.jsonl');
|
|
42
|
+
await fs.writeFile(path.join(root,'docker'),`#!${process.execPath}\nconst fs=require('fs'),a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(log)},JSON.stringify(a)+'\\n');if(a[0]==='container')process.exit(0);console.error('Container exact-test-container Created');${exit==='signal'?"process.kill(process.pid,'SIGKILL')":exit==='failure'?'process.exit(1)':'process.exit(0)'}\n`,{mode:0o700});
|
|
43
|
+
const {stdout}=await exec(process.execPath,['--input-type=module','-e',`import {run} from ${JSON.stringify(new URL('../src/plugins/manager.mjs',import.meta.url).href)};console.log((await run(['run'],{container:'exact-test-container',capture:true})).code)`],{env:{...process.env,PATH:root+path.delimiter+process.env.PATH}});
|
|
44
|
+
assert.equal(stdout.trim(),exit==='signal'?'130':exit==='failure'?'1':'0');
|
|
45
|
+
assert.deepEqual((await fs.readFile(log,'utf8')).trim().split('\n').map(JSON.parse),[['run'],...(exit==='success'?[]:[['container','rm','--force','exact-test-container']])]);
|
|
46
|
+
});
|
|
36
47
|
async function fixture(t) {
|
|
37
48
|
const root=await fs.mkdtemp(path.join(tmpdir(),'ez-tools-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
38
49
|
const source=path.join(root,'source'),home=path.join(root,'tools'),deploymentDir=path.join(root,'deployment'),workspace=path.join(deploymentDir,'mind'),control=path.join(deploymentDir,'control'),hostConfig=path.join(deploymentDir,'host-executor.json'),fake=path.join(root,'fake');
|
|
@@ -83,56 +94,43 @@ test('catalog paths resolve relative to the catalog and pin each new agent indep
|
|
|
83
94
|
assert.notEqual(first.sample.revision,next.catalog.sample.revision);
|
|
84
95
|
assert.deepEqual(JSON.parse((await f.call('plugins','available')).stdout),first);
|
|
85
96
|
});
|
|
86
|
-
test('native
|
|
87
|
-
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),
|
|
88
|
-
await fs.writeFile(instructions,'Owner mandate\n');
|
|
97
|
+
test('init never writes native instructions; the retired footer becomes the static tools line once',async t=>{
|
|
98
|
+
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),override=path.join(f.workspace,'AGENTS.override.md');
|
|
89
99
|
await init(f.home,f.workspace);
|
|
90
|
-
await assert.rejects(fs.access(
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await fs.
|
|
97
|
-
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(
|
|
98
|
-
await fs.writeFile(
|
|
99
|
-
|
|
100
|
+
await assert.rejects(fs.access(instructions),{code:'ENOENT'});
|
|
101
|
+
const footer='<!-- ez tools: begin -->\nUse `/x/bin/ez`.\n<!-- ez tools: end -->';
|
|
102
|
+
await fs.writeFile(instructions,`Owner mandate\n\n${footer}\n\nOwner tail\n`);await fs.writeFile(override,`Override mandate\n${footer}\n`);
|
|
103
|
+
await bindToolDiscovery(f.home,f.workspace);
|
|
104
|
+
const retired=await fs.readFile(instructions,'utf8');
|
|
105
|
+
assert.equal(retired,`Owner mandate\n\n${TOOLS_LINE}\n\nOwner tail\n`);
|
|
106
|
+
assert.equal(await fs.readFile(override,'utf8'),`Override mandate\n${TOOLS_LINE}\n`);
|
|
107
|
+
await bindToolDiscovery(f.home,f.workspace);assert.equal(await fs.readFile(instructions,'utf8'),retired); // once: idempotent
|
|
108
|
+
await fs.writeFile(instructions,`${TOOLS_LINE}\n${footer}\n`);await bindToolDiscovery(f.home,f.workspace);
|
|
109
|
+
assert.equal(await fs.readFile(instructions,'utf8'),`${TOOLS_LINE}\n\n`); // never duplicates the line
|
|
100
110
|
await fs.writeFile(instructions,'<!-- ez tools: begin -->broken');
|
|
101
111
|
await assert.rejects(bindToolDiscovery(f.home,f.workspace),/Malformed/);
|
|
102
112
|
assert.equal(await fs.readFile(instructions,'utf8'),'<!-- ez tools: begin -->broken');
|
|
113
|
+
const notes=path.join(f.workspace,'TOOLS.md');await fs.writeFile(notes,footer);
|
|
103
114
|
await fs.rm(instructions);await fs.symlink(notes,instructions);
|
|
104
115
|
await assert.rejects(bindToolDiscovery(f.home,f.workspace),/regular file/);
|
|
105
|
-
assert.equal(await fs.readFile(notes,'utf8'),
|
|
106
|
-
});
|
|
107
|
-
test('
|
|
108
|
-
const f=await fixture(t)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const instructions=await fs.readFile(path.join(f.workspace,'AGENTS.md'),'utf8');assert.match(instructions,/Use `ez`/);assert.match(instructions,/`tools list --details`/);assert.doesNotMatch(instructions,new RegExp(f.home.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')));
|
|
112
|
-
});
|
|
113
|
-
test('discovery rejects an over-budget launcher before rewriting instructions',async t=>{
|
|
114
|
-
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'h'.repeat(200),'n'.repeat(200));
|
|
115
|
-
await fs.mkdir(longHome,{recursive:true});await fs.writeFile(instructions,'Owner mandate\n');
|
|
116
|
-
await assert.rejects(bindToolDiscovery(longHome,f.workspace),/600-byte core budget/);
|
|
117
|
-
assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
|
|
118
|
-
});
|
|
119
|
-
test('standalone init rejects over-budget guidance before creating activation state',async t=>{
|
|
120
|
-
const f=await fixture(t),instructions=path.join(f.workspace,'AGENTS.md'),longHome=path.join(f.root,'i'.repeat(200),'j'.repeat(200)),catalog=path.join(f.root,'empty-catalog.json');
|
|
121
|
-
await fs.writeFile(catalog,'{}');await fs.writeFile(instructions,'Owner mandate\n');
|
|
122
|
-
await assert.rejects(initManager(longHome,f.workspace,catalog,undefined,true),/600-byte core budget/);
|
|
123
|
-
assert.deepEqual(await fs.readdir(longHome),[]);
|
|
124
|
-
assert.equal(await fs.readFile(instructions,'utf8'),'Owner mandate\n');
|
|
116
|
+
assert.equal(await fs.readFile(notes,'utf8'),footer);
|
|
117
|
+
});
|
|
118
|
+
test('manifest example is one line starting with a declared command',async t=>{
|
|
119
|
+
const f=await fixture(t),p=await snapshot(f.source);
|
|
120
|
+
assert.doesNotThrow(()=>validate({...f.manifest,example:'sample search "q" --limit 3'},f.deployment,p.files));
|
|
121
|
+
for(const example of ['other --help','sample\nsecond','x'.repeat(161),3]) assert.throws(()=>validate({...f.manifest,example},f.deployment,p.files),/Manifest example/);
|
|
125
122
|
});
|
|
126
123
|
test('installed snippets follow install, upgrade and uninstall without files or Docker reads',async t=>{
|
|
127
124
|
const f=await fixture(t);await init(f.home,f.workspace);
|
|
128
125
|
const details=async()=>JSON.parse((await f.call('tools','list','--details')).stdout);
|
|
129
126
|
assert.deepEqual(await details(),{});
|
|
130
127
|
for(const description of ['Synthetic capability','Updated capability']) {
|
|
131
|
-
|
|
128
|
+
const example=description==='Updated capability'?'sample run --json':undefined;
|
|
129
|
+
await fs.writeFile(path.join(f.source,'ez-plugin.json'),JSON.stringify({...f.manifest,description,example}));
|
|
132
130
|
const p=await snapshot(f.source);
|
|
133
131
|
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
134
132
|
const before=await fs.readFile(f.log,'utf8'),index=await details();
|
|
135
|
-
assert.equal(index.sample.
|
|
133
|
+
assert.deepEqual(index.sample.aliases,['sample']);assert.equal(index.sample.purpose,description);assert.equal(index.sample.example,`ez ${example??'sample --help'}`);
|
|
136
134
|
assert.equal(await fs.readFile(index.sample.skills[0],'utf8'),'Synthetic');
|
|
137
135
|
assert.deepEqual(index.sample.skillReads,[['ez','tools','skill','sample','SKILL.md']]);
|
|
138
136
|
assert.deepEqual(JSON.parse((await f.call('tools','skill','sample','SKILL.md')).stdout),{plugin:'sample',revision:p.revision,path:'SKILL.md',content:'Synthetic'});
|
|
@@ -278,6 +276,24 @@ test('an unreadable invocation lease pid falls through to container verification
|
|
|
278
276
|
await assert.rejects(locked(f.home,async()=>{}),/Stale command invocation lease/);
|
|
279
277
|
} finally {process.kill=kill0;process.env.PATH=path0;}
|
|
280
278
|
});
|
|
279
|
+
test('a lease from another PID namespace is live while its call container runs, then drains',async t=>{
|
|
280
|
+
const f=await fixture(t);
|
|
281
|
+
const directory=path.join(f.home,'command-invocations');await fs.mkdir(directory,{recursive:true});
|
|
282
|
+
const lease=path.join(directory,'00000000-0000-4000-8000-000000000001.json');
|
|
283
|
+
// Isolated broker lease: its pid means nothing in this namespace (EPERM/ESRCH or an unrelated process).
|
|
284
|
+
await fs.writeFile(lease,JSON.stringify({pid:1,container:'broker-call',pidNamespace:'pid:[4026531999]'}),{mode:0o600});
|
|
285
|
+
const path0=process.env.PATH;process.env.PATH=f.fake+path.delimiter+path0;t.after(()=>{process.env.PATH=path0;});
|
|
286
|
+
const docker=state=>fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nconst a=process.argv.slice(2);if(a[0]==='container'&&a[1]==='inspect'){${state}}\n`,{mode:0o700});
|
|
287
|
+
await docker(`console.log('true')`);
|
|
288
|
+
await assert.rejects(locked(f.home,async()=>{}),/commands are active/); // never misreported as stale
|
|
289
|
+
let activated=false;const drain=locked(f.home,async()=>{activated=true;},{drainInvocations:true});
|
|
290
|
+
await new Promise(resolve=>setTimeout(resolve,100));assert.equal(activated,false);
|
|
291
|
+
await docker(`console.error('Error: No such object: broker-call');process.exit(1)`);
|
|
292
|
+
await drain;assert.equal(activated,true);await assert.rejects(fs.access(lease),{code:'ENOENT'});
|
|
293
|
+
await fs.writeFile(lease,JSON.stringify({pid:1,container:'broker-call',pidNamespace:'pid:[4026531999]'}),{mode:0o600});
|
|
294
|
+
await docker(`console.log('false')`);
|
|
295
|
+
await assert.rejects(locked(f.home,async()=>{}),/Stale command invocation lease/); // stopped but not removed: inspect first
|
|
296
|
+
});
|
|
281
297
|
test('changed source, symlinks, reserved aliases, arbitrary Docker fields rejected',async t=>{
|
|
282
298
|
const f=await fixture(t),p=await snapshot(f.source);await init(f.home,f.workspace);
|
|
283
299
|
await fs.writeFile(path.join(f.source,'client.mjs'),'changed');await assert.rejects(f.call('plugins','install','sample','--source',f.source,'--revision',p.revision));
|
|
@@ -532,9 +548,7 @@ test('standalone CLI has discoverable setup, independent guidance and status wit
|
|
|
532
548
|
const help=JSON.parse((await exec(process.execPath,[bin,'--help'])).stdout);
|
|
533
549
|
assert.match(help.usage,/--standalone/);
|
|
534
550
|
await exec(process.execPath,[bin,'init','--standalone','--home',f.home,'--workspace',f.workspace],{env:f.env});
|
|
535
|
-
|
|
536
|
-
assert.match(notes,/tools list --details/);
|
|
537
|
-
assert.doesNotMatch(notes,/Finish the main Telegram|ezenciel-agents-message/);
|
|
551
|
+
await assert.rejects(fs.access(path.join(f.workspace,'AGENTS.md')),{code:'ENOENT'}); // instructions stay owner-written
|
|
538
552
|
const launcher=path.join(f.home,'bin','ez');
|
|
539
553
|
const status=JSON.parse((await exec(launcher,['status'],{cwd:f.root,env:f.env})).stdout);
|
|
540
554
|
assert.equal(status.main,null);assert.deepEqual(status.plugins,[]);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { appendFile, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import type { Update } from 'grammy/types'
|
|
7
|
+
import { createRelay } from '../src/index.js'
|
|
8
|
+
import { ControlStore } from '../src/control-state.js'
|
|
9
|
+
import { RunStore } from '../src/runs.js'
|
|
10
|
+
import { Scheduler, holdsSchedule } from '../src/scheduler.js'
|
|
11
|
+
import { initialPreset } from '../src/ai.js'
|
|
12
|
+
|
|
13
|
+
const until = async (check: () => Promise<boolean>) => {
|
|
14
|
+
for (let i = 0; i < 400; i++) { if (await check()) return; await new Promise(r => setTimeout(r, 25)) }
|
|
15
|
+
throw new Error('Timed out')
|
|
16
|
+
}
|
|
17
|
+
const config = (controlDir: string, workspace: string) =>
|
|
18
|
+
({ workspace, controlDir, pairingTtlMs: 1000, executorTimeoutMs: 0, executorCli: 'grok', telegramBotToken: 'fixture' })
|
|
19
|
+
|
|
20
|
+
// The test is the host transport: it claims requests, streams events and
|
|
21
|
+
// keeps the heartbeat fresh. A second path to the same control directory gives
|
|
22
|
+
// the next relay an empty memory ledger, as a replacement process would have.
|
|
23
|
+
const fixture = async (t: test.TestContext) => {
|
|
24
|
+
const root = await mkdtemp(join(tmpdir(), 'ez-run-handoff-')), dir = join(root, 'control'), next = join(root, 'next-control')
|
|
25
|
+
const host = join(dir, 'host-executor')
|
|
26
|
+
await mkdir(host, { recursive: true, mode: 0o700 }); await symlink(dir, next)
|
|
27
|
+
const transport = process.env.EZ_EXECUTOR_TRANSPORT
|
|
28
|
+
process.env.EZ_EXECUTOR_TRANSPORT = 'host'
|
|
29
|
+
const beat = setInterval(() => void writeFile(join(host, 'beat.tmp'), JSON.stringify({ at: Date.now() }))
|
|
30
|
+
.then(() => rename(join(host, 'beat.tmp'), join(host, 'heartbeat.json'))).catch(() => {}), 200)
|
|
31
|
+
t.after(async () => {
|
|
32
|
+
clearInterval(beat)
|
|
33
|
+
if (transport === undefined) delete process.env.EZ_EXECUTOR_TRANSPORT; else process.env.EZ_EXECUTOR_TRANSPORT = transport
|
|
34
|
+
await rm(root, { recursive: true, force: true })
|
|
35
|
+
})
|
|
36
|
+
const files = async () => (await readdir(host)).filter(file => file !== 'heartbeat.json' && !file.endsWith('.tmp')).sort()
|
|
37
|
+
const control = new ControlStore(dir, 1000)
|
|
38
|
+
await control.requestPairing(101, 101); await control.approveOwner(101)
|
|
39
|
+
return { root, dir, next, host, files, control, owner: (await control.status()).owner! }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('a graceful relay stop hands running host work to the next relay, which records it without relaunching', async t => {
|
|
43
|
+
const { root, dir, next, host, files, control, owner } = await fixture(t)
|
|
44
|
+
const scheduler = new Scheduler(dir), first = new RunStore(dir)
|
|
45
|
+
const execution = await control.captureChoice({ ...initialPreset('grok'), model: 'fixture-model' })
|
|
46
|
+
const due = Date.now() + 1000
|
|
47
|
+
const saved = await scheduler.save({ id: 'long', name: 'Long', text: 'Long work', owner, execution, enabled: true,
|
|
48
|
+
trigger: { everySeconds: 60, start: new Date(due).toISOString() } }, true)
|
|
49
|
+
await scheduler.tick(owner, first, due)
|
|
50
|
+
const foreground = await first.create({ chatId: 101, telegramUserId: 101, texts: ['chat'], execution })
|
|
51
|
+
const before = createRelay(config(dir, root))
|
|
52
|
+
await before.drainSources()
|
|
53
|
+
const scheduled = (await first.list()).find(run => run.scheduled)!
|
|
54
|
+
for (const id of [foreground.id, scheduled.id]) {
|
|
55
|
+
await until(async () => (await files()).includes(id + '.request.json'))
|
|
56
|
+
await rename(join(host, id + '.request.json'), join(host, id + '.running.json'))
|
|
57
|
+
await appendFile(join(host, id + '.events'), JSON.stringify({ stream: 'stdout', text: 'working\n' }) + '\n')
|
|
58
|
+
}
|
|
59
|
+
await before.stop()
|
|
60
|
+
assert.equal((await stat(join(dir, 'run-handoff.json'))).mode & 0o777, 0o600)
|
|
61
|
+
const handoff = JSON.parse(await readFile(join(dir, 'run-handoff.json'), 'utf8'))
|
|
62
|
+
assert.ok(Date.now() - Date.parse(handoff.writtenAt) < 60_000)
|
|
63
|
+
assert.deepEqual(handoff.runs.map((run: { id: string; status: string }) => [run.id, run.status]).sort(),
|
|
64
|
+
[[foreground.id, 'running'], [scheduled.id, 'running']].sort())
|
|
65
|
+
assert.deepEqual(await files(), [foreground.id + '.events', foreground.id + '.running.json', scheduled.id + '.events', scheduled.id + '.running.json'].sort(),
|
|
66
|
+
'the stop must not cancel, finish or resubmit host work')
|
|
67
|
+
|
|
68
|
+
const after = createRelay(config(next, root)), runs = new RunStore(next)
|
|
69
|
+
after.bot.botInfo = { id: 999, is_bot: true, first_name: 'Fixture', username: 'fixture_bot' } as typeof after.bot.botInfo
|
|
70
|
+
after.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
|
|
71
|
+
try {
|
|
72
|
+
await after.adoptHandoff()
|
|
73
|
+
await assert.rejects(readFile(join(dir, 'run-handoff.json')), { code: 'ENOENT' })
|
|
74
|
+
assert.deepEqual((await runs.list()).map(run => run.status), ['running', 'running'])
|
|
75
|
+
// The new ledger accepts the run's result delivery and holds its recurring schedule.
|
|
76
|
+
assert.ok(await runs.enqueueMessage(scheduled.id, 'Result after replacement'))
|
|
77
|
+
await scheduler.tick(owner, runs, due + 60_000); await after.drainSources()
|
|
78
|
+
assert.equal((await runs.list()).length, 2, 'the next recurrence waits for the adopted occurrence')
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
80
|
+
assert.ok(!(await files()).some(file => file.endsWith('.request.json') || file.endsWith('.cancel')), 'adoption never resubmits or cancels')
|
|
81
|
+
await appendFile(join(host, foreground.id + '.events'), JSON.stringify({ stream: 'exit', code: 0 }) + '\n')
|
|
82
|
+
await rm(join(host, foreground.id + '.running.json'))
|
|
83
|
+
await until(async () => (await runs.get(foreground.id))?.status === 'completed')
|
|
84
|
+
assert.equal((await new ControlStore(next, 1000).executionSession(execution)).hasStarted, true)
|
|
85
|
+
// /stop reaches the adopted run through the existing cancel path.
|
|
86
|
+
await after.bot.handleUpdate({ update_id: 1, message: { message_id: 1, date: 0, text: '/stop', from: { id: 101, is_bot: false, first_name: 'Owner' }, chat: { id: 101, type: 'private', first_name: 'Owner' } } } as Update)
|
|
87
|
+
await until(async () => (await runs.get(scheduled.id))?.status === 'cancelled')
|
|
88
|
+
assert.ok((await files()).includes(scheduled.id + '.cancel'), 'the host is asked to stop the adopted executor')
|
|
89
|
+
assert.equal(holdsSchedule(saved, (await runs.get(scheduled.id))!), false)
|
|
90
|
+
} finally { await after.stop() }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('adoption fails closed for unknown, invalid, stale or corrupt handoff records and never launches', async t => {
|
|
94
|
+
const { root, dir, files, owner } = await fixture(t)
|
|
95
|
+
const scheduler = new Scheduler(dir)
|
|
96
|
+
const saved = await scheduler.save({ id: 'gone', name: 'Gone', text: 'work', owner, enabled: true, trigger: { everySeconds: 60, start: new Date(Date.now() + 1000).toISOString() },
|
|
97
|
+
execution: { sessionId: '00000000-0000-4000-8000-000000000000', preset: { ...initialPreset('grok'), model: 'fixture-model' } } }, true)
|
|
98
|
+
const lost = { version: 1, id: 'r_schedule_lost', chatId: 101, telegramUserId: 101, texts: ['work'], status: 'running', createdAt: new Date().toISOString(),
|
|
99
|
+
scheduled: { id: saved.id, revision: saved.revision, dueAt: new Date().toISOString(), pairedAt: owner.pairedAt } }
|
|
100
|
+
const write = (runs: unknown[], writtenAt = new Date().toISOString()) => writeFile(join(dir, 'run-handoff.json'), JSON.stringify({ version: 1, writtenAt, runs }))
|
|
101
|
+
await write([lost, { ...lost, id: '../escape' }, { ...lost, id: 'r_done', status: 'completed' }])
|
|
102
|
+
let launches = 0
|
|
103
|
+
const relay = createRelay(config(dir, root), async () => { launches++; throw new Error('Must not launch') }), runs = new RunStore(dir)
|
|
104
|
+
try {
|
|
105
|
+
await relay.adoptHandoff()
|
|
106
|
+
await until(async () => (await runs.get(lost.id))?.status === 'failed')
|
|
107
|
+
const failed = (await runs.get(lost.id))!
|
|
108
|
+
assert.match(failed.failure?.error ?? '', /outcome is unknown/)
|
|
109
|
+
assert.equal(failed.failureReason, 'handoff-outcome-unknown')
|
|
110
|
+
assert.equal(failed.interrupted, true)
|
|
111
|
+
assert.equal(holdsSchedule(saved, failed), true, 'an unknown outcome holds its schedule until an explicit edit')
|
|
112
|
+
assert.deepEqual((await runs.list()).map(run => run.id), [lost.id])
|
|
113
|
+
for (const stale of [() => write([{ ...lost, id: 'r_schedule_stale' }], new Date(Date.now() - 25 * 3600_000).toISOString()), () => writeFile(join(dir, 'run-handoff.json'), '{not json')]) {
|
|
114
|
+
await stale()
|
|
115
|
+
await relay.adoptHandoff()
|
|
116
|
+
await assert.rejects(readFile(join(dir, 'run-handoff.json')), { code: 'ENOENT' })
|
|
117
|
+
}
|
|
118
|
+
await relay.drainSources()
|
|
119
|
+
assert.equal(launches, 0)
|
|
120
|
+
assert.deepEqual((await runs.list()).map(run => run.id), [lost.id])
|
|
121
|
+
assert.deepEqual(await files(), [])
|
|
122
|
+
} finally { await relay.stop() }
|
|
123
|
+
// A relay without host transport removes a record it cannot adopt.
|
|
124
|
+
delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
125
|
+
await write([{ ...lost, id: 'r_schedule_local' }])
|
|
126
|
+
const local = createRelay(config(dir, root))
|
|
127
|
+
try {
|
|
128
|
+
await local.adoptHandoff()
|
|
129
|
+
await assert.rejects(readFile(join(dir, 'run-handoff.json')), { code: 'ENOENT' })
|
|
130
|
+
assert.equal(await runs.get('r_schedule_local'), null)
|
|
131
|
+
} finally { await local.stop() }
|
|
132
|
+
})
|