@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +26 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +28 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +62 -22
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +16 -15
- package/package.json +4 -2
- package/src/agent-guidance.ts +2 -2
- package/src/ai-cli.ts +4 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +63 -10
- package/src/host-executor-client.ts +23 -8
- package/src/host-executor.ts +63 -32
- package/src/index.ts +157 -35
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +67 -3
- package/src/schedule-cli.ts +24 -15
- package/src/scheduled-tasks.ts +53 -17
- package/src/scheduler.ts +7 -8
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/runtime.mjs +25 -9
- package/src/updates/supervisor.mjs +105 -16
- package/src/workspace.ts +12 -1
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +72 -4
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +71 -3
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +136 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +45 -15
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +55 -12
- package/test/workspace.test.ts +6 -0
|
@@ -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=async state=>{const file=path.join(f.fake,'docker');await fs.writeFile(file+'.next',`#!${process.execPath}\nconst a=process.argv.slice(2);if(a[0]==='container'&&a[1]==='inspect'){${state}}\n`,{mode:0o700});await fs.rename(file+'.next',file);};
|
|
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,136 @@
|
|
|
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
|
+
const delivered=await first.enqueueMessage(scheduled.id,'Delivered before replacement')
|
|
60
|
+
await first.claimOutbox(delivered.id)
|
|
61
|
+
await first.markOutboxSent(delivered.id,[17])
|
|
62
|
+
await before.stop()
|
|
63
|
+
assert.equal((await stat(join(dir, 'run-handoff.json'))).mode & 0o777, 0o600)
|
|
64
|
+
const handoff = JSON.parse(await readFile(join(dir, 'run-handoff.json'), 'utf8'))
|
|
65
|
+
assert.ok(Date.now() - Date.parse(handoff.writtenAt) < 60_000)
|
|
66
|
+
assert.deepEqual(handoff.runs.map((run: { id: string; status: string }) => [run.id, run.status]).sort(),
|
|
67
|
+
[[foreground.id, 'running'], [scheduled.id, 'running']].sort())
|
|
68
|
+
assert.deepEqual(await files(), [foreground.id + '.events', foreground.id + '.running.json', scheduled.id + '.events', scheduled.id + '.running.json'].sort(),
|
|
69
|
+
'the stop must not cancel, finish or resubmit host work')
|
|
70
|
+
|
|
71
|
+
const after = createRelay(config(next, root)), runs = new RunStore(next)
|
|
72
|
+
after.bot.botInfo = { id: 999, is_bot: true, first_name: 'Fixture', username: 'fixture_bot' } as typeof after.bot.botInfo
|
|
73
|
+
after.bot.api.config.use(async () => ({ ok: true, result: { message_id: 42 } }) as never)
|
|
74
|
+
try {
|
|
75
|
+
await after.adoptHandoff()
|
|
76
|
+
await assert.rejects(readFile(join(dir, 'run-handoff.json')), { code: 'ENOENT' })
|
|
77
|
+
assert.deepEqual((await runs.list()).map(run => run.status), ['running', 'running'])
|
|
78
|
+
assert.deepEqual(await runs.waitForDelivery(delivered.id,100),await first.waitForDelivery(delivered.id,100))
|
|
79
|
+
// The new ledger accepts the run's result delivery and holds its recurring schedule.
|
|
80
|
+
assert.ok(await runs.enqueueMessage(scheduled.id, 'Result after replacement'))
|
|
81
|
+
await scheduler.tick(owner, runs, due + 60_000); await after.drainSources()
|
|
82
|
+
assert.equal((await runs.list()).length, 2, 'the next recurrence waits for the adopted occurrence')
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
84
|
+
assert.ok(!(await files()).some(file => file.endsWith('.request.json') || file.endsWith('.cancel')), 'adoption never resubmits or cancels')
|
|
85
|
+
await appendFile(join(host, foreground.id + '.events'), JSON.stringify({ stream: 'exit', code: 0 }) + '\n')
|
|
86
|
+
await rm(join(host, foreground.id + '.running.json'))
|
|
87
|
+
await until(async () => (await runs.get(foreground.id))?.status === 'completed')
|
|
88
|
+
assert.equal((await new ControlStore(next, 1000).executionSession(execution)).hasStarted, true)
|
|
89
|
+
// /stop reaches the adopted run through the existing cancel path.
|
|
90
|
+
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)
|
|
91
|
+
await until(async () => (await runs.get(scheduled.id))?.status === 'cancelled')
|
|
92
|
+
assert.ok((await files()).includes(scheduled.id + '.cancel'), 'the host is asked to stop the adopted executor')
|
|
93
|
+
assert.equal(holdsSchedule(saved, (await runs.get(scheduled.id))!), false)
|
|
94
|
+
} finally { await after.stop() }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('adoption fails closed for unknown, invalid, stale or corrupt handoff records and never launches', async t => {
|
|
98
|
+
const { root, dir, files, owner } = await fixture(t)
|
|
99
|
+
const scheduler = new Scheduler(dir)
|
|
100
|
+
const saved = await scheduler.save({ id: 'gone', name: 'Gone', text: 'work', owner, enabled: true, trigger: { everySeconds: 60, start: new Date(Date.now() + 1000).toISOString() },
|
|
101
|
+
execution: { sessionId: '00000000-0000-4000-8000-000000000000', preset: { ...initialPreset('grok'), model: 'fixture-model' } } }, true)
|
|
102
|
+
const lost = { version: 1, id: 'r_schedule_lost', chatId: 101, telegramUserId: 101, texts: ['work'], status: 'running', createdAt: new Date().toISOString(),
|
|
103
|
+
scheduled: { id: saved.id, revision: saved.revision, dueAt: new Date().toISOString(), pairedAt: owner.pairedAt } }
|
|
104
|
+
const write = (runs: unknown[], writtenAt = new Date().toISOString()) => writeFile(join(dir, 'run-handoff.json'), JSON.stringify({ version: 1, writtenAt, runs }))
|
|
105
|
+
await write([lost, { ...lost, id: '../escape' }, { ...lost, id: 'r_done', status: 'completed' }])
|
|
106
|
+
let launches = 0
|
|
107
|
+
const relay = createRelay(config(dir, root), async () => { launches++; throw new Error('Must not launch') }), runs = new RunStore(dir)
|
|
108
|
+
try {
|
|
109
|
+
await relay.adoptHandoff()
|
|
110
|
+
await until(async () => (await runs.get(lost.id))?.status === 'failed')
|
|
111
|
+
const failed = (await runs.get(lost.id))!
|
|
112
|
+
assert.match(failed.failure?.error ?? '', /outcome is unknown/)
|
|
113
|
+
assert.equal(failed.failureReason, 'handoff-outcome-unknown')
|
|
114
|
+
assert.equal(failed.interrupted, true)
|
|
115
|
+
assert.equal(holdsSchedule(saved, failed), true, 'an unknown outcome holds its schedule until an explicit edit')
|
|
116
|
+
assert.deepEqual((await runs.list()).map(run => run.id), [lost.id])
|
|
117
|
+
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')]) {
|
|
118
|
+
await stale()
|
|
119
|
+
await relay.adoptHandoff()
|
|
120
|
+
await assert.rejects(readFile(join(dir, 'run-handoff.json')), { code: 'ENOENT' })
|
|
121
|
+
}
|
|
122
|
+
await relay.drainSources()
|
|
123
|
+
assert.equal(launches, 0)
|
|
124
|
+
assert.deepEqual((await runs.list()).map(run => run.id), [lost.id])
|
|
125
|
+
assert.deepEqual(await files(), [])
|
|
126
|
+
} finally { await relay.stop() }
|
|
127
|
+
// A relay without host transport removes a record it cannot adopt.
|
|
128
|
+
delete process.env.EZ_EXECUTOR_TRANSPORT
|
|
129
|
+
await write([{ ...lost, id: 'r_schedule_local' }])
|
|
130
|
+
const local = createRelay(config(dir, root))
|
|
131
|
+
try {
|
|
132
|
+
await local.adoptHandoff()
|
|
133
|
+
await assert.rejects(readFile(join(dir, 'run-handoff.json')), { code: 'ENOENT' })
|
|
134
|
+
assert.equal(await runs.get('r_schedule_local'), null)
|
|
135
|
+
} finally { await local.stop() }
|
|
136
|
+
})
|
|
@@ -2,7 +2,7 @@ import test from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
import { execFile } from 'node:child_process'
|
|
4
4
|
import { promisify } from 'node:util'
|
|
5
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
@@ -11,6 +11,7 @@ import { RunStore } from '../src/runs.js'
|
|
|
11
11
|
import { initialPreset } from '../src/ai.js'
|
|
12
12
|
import { serveTestLedger } from './helpers/ledger.js'
|
|
13
13
|
import { Scheduler } from '../src/scheduler.js'
|
|
14
|
+
import { Scripts } from '../src/scripts.js'
|
|
14
15
|
import { callDeliverySocket, socketPathFor } from '../src/delivery-socket.js'
|
|
15
16
|
const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
|
|
16
17
|
test('public scheduler CLI saves literal text, reads back, edits, pauses, and rejects external or finished callers',async t=>{
|
|
@@ -66,6 +67,28 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
66
67
|
await assert.rejects(exec(process.execPath,[bin,'list'],{env}),/owner-authorized/)
|
|
67
68
|
})
|
|
68
69
|
|
|
70
|
+
test('edit keeps a paused agent or script schedule paused and an enabled one enabled',async t=>{
|
|
71
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-schedule-edit-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
72
|
+
const ledger=await serveTestLedger(dir);t.after(()=>ledger.stop())
|
|
73
|
+
const control=new ControlStore(dir,1000),workspace=join(dir,'workspace')
|
|
74
|
+
await control.requestPairing(101,101);await control.approveOwner(101)
|
|
75
|
+
await mkdir(workspace);await writeFile(join(workspace,'guard.mjs'),'')
|
|
76
|
+
await new Scripts(dir).save({id:'guard',owner:(await control.status()).owner!,workspace,entry:'guard.mjs',interpreter:'node',args:[],timeoutSeconds:60},true)
|
|
77
|
+
const env={...process.env,EZ_CONTROL_DIR:dir,EZ_RUN_ID:''}
|
|
78
|
+
const cli=async(...args:string[])=>JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
|
|
79
|
+
const tasks={agent:(text:string)=>['--model','gpt-6-astra','--text',text],script:(name:string)=>['--script','guard','--name',name]}
|
|
80
|
+
for(const [id,task] of Object.entries(tasks)){
|
|
81
|
+
await cli('create',id,...task('Original'),'--every-seconds','3600')
|
|
82
|
+
await cli('pause',id)
|
|
83
|
+
// Changing only the instructions, even with --now, must not resume or dispatch it.
|
|
84
|
+
const paused=await cli('edit',id,...task('Changed'),'--now')
|
|
85
|
+
assert.equal(paused.enabled,false);assert.equal(paused.nextEligibleAt,null)
|
|
86
|
+
assert.equal((await cli('show',id)).enabled,false)
|
|
87
|
+
await cli('resume',id)
|
|
88
|
+
assert.equal((await cli('edit',id,...task('Again'),'--every-seconds','3600')).enabled,true)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
69
92
|
test('executor PATH exposes the extensionless scheduler command',async()=>{
|
|
70
93
|
const command=fileURLToPath(new URL('../bin/ezenciel-agents-schedule',import.meta.url))
|
|
71
94
|
assert.match((await exec(command,['--help'])).stdout,/Creates a scheduled task/)
|
|
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
|
|
|
7
7
|
import { Scheduler } from '../src/scheduler.js'
|
|
8
8
|
import { scheduledTaskDetailText, scheduledTasksText } from '../src/scheduled-tasks.js'
|
|
9
9
|
import { RunStore } from '../src/runs.js'
|
|
10
|
+
import type { Trigger } from '../src/schedule-time.js'
|
|
10
11
|
|
|
11
12
|
const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
|
|
12
13
|
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex', model: 'fixture-model' } }
|
|
@@ -37,10 +38,10 @@ test('scheduled task view is read-only, owner-bound, and shows active task promp
|
|
|
37
38
|
scheduled: { id: saved.id, revision: saved.revision, dueAt: '2026-01-01T05:00:00.000Z', pairedAt: owner.pairedAt },
|
|
38
39
|
})
|
|
39
40
|
await runs.patch(run.id, { status: 'completed', endedAt: '2026-01-01T05:04:00.000Z' })
|
|
40
|
-
const active = await scheduler.
|
|
41
|
+
const active = await scheduler.listTasksReadOnly(await runs.list())
|
|
41
42
|
const text = scheduledTasksText(active, owner)
|
|
42
43
|
|
|
43
|
-
assert.equal(text, '📅 Scheduled tasks · 1 active\n\
|
|
44
|
+
assert.equal(text, '📅 Scheduled tasks · 1 active · 0 paused\n\nNext/last times UTC. Use /tasks 1 for task details.\n\n1 · Daily report\n ● Active\n Schedule · Weekdays at 09:00 · Asia/Dubai\n Next · Thu, Jan 1 · 05:00 UTC\n Last · ✓ Completed · Thu, Jan 1 · 05:04 UTC\n Execution · codex (default login) · fixture-model · default effort\n Read the ledger and send the owner a concise report.')
|
|
44
45
|
assert.match(scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!, 1), /Last run\n✓ Completed · Thu, Jan 1 · 05:04 UTC/)
|
|
45
46
|
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
46
47
|
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
@@ -61,29 +62,37 @@ test('active view follows existing cursor and run state without changing schedul
|
|
|
61
62
|
id==='interrupted'?{everySeconds:60,start:new Date(due).toISOString()}:{at:new Date(due).toISOString()}})
|
|
62
63
|
}
|
|
63
64
|
await scheduler.save({...common,id:'recurring',name:'recurring',trigger:{everySeconds:60,start:new Date(due).toISOString()}})
|
|
64
|
-
const names = async () => (await scheduler.
|
|
65
|
-
assert.deepEqual(await names(),['expired','interrupted','once','recurring','review'])
|
|
65
|
+
const names = async () => (await scheduler.listTasksReadOnly(await runs.list())).map(s=>s.id).sort()
|
|
66
|
+
assert.deepEqual(await names(),['expired','interrupted','once','paused','recurring','review'])
|
|
67
|
+
const paused = (await scheduler.listTasksReadOnly(await runs.list())).find(s=>s.id==='paused')!
|
|
68
|
+
assert.equal(paused.enabled,false)
|
|
69
|
+
assert.equal(paused.nextAt,null)
|
|
70
|
+
assert.match(scheduledTasksText([paused],owner),/0 active · 1 paused[\s\S]*⏸ Paused/)
|
|
71
|
+
assert.doesNotMatch(scheduledTasksText([paused],{...owner,pairedAt:'different'}),/⏸ Paused/)
|
|
72
|
+
assert.match(scheduledTaskDetailText({...paused,runState:'running'},1),/▶ Running · ⏸ Paused/)
|
|
73
|
+
assert.match(scheduledTaskDetailText({...paused,runState:'queued'},1),/◌ Queued · ⏸ Paused/)
|
|
66
74
|
await scheduler.tick(owner,runs,due)
|
|
75
|
+
assert.ok(!(await runs.list()).some(r=>r.scheduled?.id==='paused'))
|
|
67
76
|
// The empty conditional review consumes its occurrence without creating work.
|
|
68
|
-
assert.deepEqual(await names(),['expired','interrupted','once','recurring'])
|
|
77
|
+
assert.deepEqual(await names(),['expired','interrupted','once','paused','recurring'])
|
|
69
78
|
const queued = (await runs.list()).find(r=>r.scheduled?.id==='once')!
|
|
70
|
-
const view = (await scheduler.
|
|
79
|
+
const view = (await scheduler.listTasksReadOnly(await runs.list())).find(s=>s.id==='once')!
|
|
71
80
|
assert.equal(view.nextAt,null)
|
|
72
81
|
assert.equal(view.runState,'queued')
|
|
73
82
|
assert.match(scheduledTasksText([view],owner),/Queued/)
|
|
74
83
|
await runs.patch(queued.id,{status:'running'})
|
|
75
|
-
assert.equal((await scheduler.
|
|
84
|
+
assert.equal((await scheduler.listTasksReadOnly(await runs.list())).find(s=>s.id==='once')!.runState,'running')
|
|
76
85
|
for (const run of await runs.list()) await runs.patch(run.id,run.scheduled?.id==='interrupted'
|
|
77
86
|
? {status:'failed',interrupted:true} : {status:'completed'})
|
|
78
87
|
const before = await readdir(join(dir,'schedules'))
|
|
79
|
-
assert.deepEqual(await names(),['recurring'])
|
|
88
|
+
assert.deepEqual(await names(),['paused','recurring'])
|
|
80
89
|
assert.deepEqual(await readdir(join(dir,'schedules')),before)
|
|
81
90
|
// A held recurring revision remains hidden even when its cursor has a later occurrence.
|
|
82
91
|
const interrupted = await scheduler.get('interrupted')
|
|
83
92
|
assert.equal('everySeconds' in interrupted.trigger && interrupted.trigger.everySeconds,60)
|
|
84
93
|
// A new schedule revision has its own occurrence; old terminal runs cannot hide it.
|
|
85
94
|
await scheduler.save({...common,id:'once',name:'once',trigger:{at:new Date(due+60_000).toISOString()}})
|
|
86
|
-
assert.deepEqual(await names(),['once','recurring'])
|
|
95
|
+
assert.deepEqual(await names(),['once','paused','recurring'])
|
|
87
96
|
})
|
|
88
97
|
|
|
89
98
|
test('prompt preview is one bounded Unicode sentence and preserves stored input', async t => {
|
|
@@ -94,15 +103,15 @@ test('prompt preview is one bounded Unicode sentence and preserves stored input'
|
|
|
94
103
|
const saved=await scheduler.save({id:'preview',name:'Preview',text,owner,
|
|
95
104
|
execution:{...execution,preset:{...execution.preset,model:'gpt-5.6-terra',effort:'high'}},
|
|
96
105
|
enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
|
|
97
|
-
const output=scheduledTasksText(await scheduler.
|
|
106
|
+
const output=scheduledTasksText(await scheduler.listTasksReadOnly([]),owner)
|
|
98
107
|
assert.match(output,/gpt-5.6-terra · high/)
|
|
99
108
|
assert.match(output,/Next · Fri, Jan 1 · 00:00 UTC/)
|
|
100
109
|
assert.equal(Array.from(output.split('\n').at(-1)!.trim()).length,140)
|
|
101
110
|
assert.ok(output.endsWith('…'))
|
|
102
111
|
assert.doesNotMatch(output,/Do not display/)
|
|
103
112
|
assert.equal((await scheduler.get(saved.id)).text,text)
|
|
104
|
-
const other=scheduledTasksText(await scheduler.
|
|
105
|
-
assert.match(other,/No
|
|
113
|
+
const other=scheduledTasksText(await scheduler.listTasksReadOnly([]),{...owner,pairedAt:'other-pairing'})
|
|
114
|
+
assert.match(other,/No scheduled tasks/)
|
|
106
115
|
assert.doesNotMatch(other,/Preview|🧪/)
|
|
107
116
|
})
|
|
108
117
|
|
|
@@ -117,8 +126,8 @@ test('a legacy invalid selection does not prevent the active menu from rendering
|
|
|
117
126
|
saved.execution.preset = {...saved.execution.preset,model:'gpt-5.6-terra',effort:'max'}
|
|
118
127
|
await writeFile(file,JSON.stringify(saved))
|
|
119
128
|
|
|
120
|
-
const active = await scheduler.
|
|
121
|
-
assert.match(scheduledTasksText(active,owner),/codex · gpt-5.6-terra · max/)
|
|
129
|
+
const active = await scheduler.listTasksReadOnly([])
|
|
130
|
+
assert.match(scheduledTasksText(active,owner),/codex \(default login\) · gpt-5.6-terra · max/)
|
|
122
131
|
})
|
|
123
132
|
|
|
124
133
|
test('detail view truncates long instructions and tolerates invalid timestamps', async t => {
|
|
@@ -127,7 +136,7 @@ test('detail view truncates long instructions and tolerates invalid timestamps',
|
|
|
127
136
|
const scheduler = new Scheduler(dir)
|
|
128
137
|
const saved = await scheduler.save({id:'long',name:'Long task',text:'/goal '+'x'.repeat(5000),owner,
|
|
129
138
|
execution,enabled:true,trigger:{at:'2027-01-01T00:00:00Z'}})
|
|
130
|
-
const active = await scheduler.
|
|
139
|
+
const active = await scheduler.listTasksReadOnly([])
|
|
131
140
|
const detail = scheduledTaskDetailText(active.find(schedule => schedule.id === saved.id)!,1,1)
|
|
132
141
|
assert.match(detail,/📅 1 of 1 · Long task/)
|
|
133
142
|
assert.match(detail,/truncated; full text lives in the workspace/)
|
|
@@ -135,3 +144,24 @@ test('detail view truncates long instructions and tolerates invalid timestamps',
|
|
|
135
144
|
const broken = {...active.find(schedule => schedule.id === saved.id)!,nextAt:'not-a-date',lastRun:{status:'completed',at:'also-bad',currentRevision:true}} as never
|
|
136
145
|
assert.doesNotMatch(scheduledTaskDetailText(broken,1,1),/NaN/)
|
|
137
146
|
})
|
|
147
|
+
|
|
148
|
+
test('frequency describes saved rules without inventing a uniform interval for arbitrary cron', async t => {
|
|
149
|
+
const dir=await mkdtemp(join(tmpdir(),'ez-schedule-frequency-'));t.after(()=>rm(dir,{recursive:true,force:true}))
|
|
150
|
+
const scheduler=new Scheduler(dir),start='2027-01-01T00:00:00Z',timezone='America/New_York'
|
|
151
|
+
const cases:[Trigger,string][]=[
|
|
152
|
+
[{at:start},'Once'],[{everySeconds:60,start},'Every minute'],[{everySeconds:90,start},'Every 90 seconds'],
|
|
153
|
+
[{everySeconds:3600,start},'Every hour'],[{everySeconds:172800,start},'Every 2 days'],
|
|
154
|
+
[{cron:'*/15 * * * *',timezone,start},'Every 15 minutes · America/New_York'],
|
|
155
|
+
[{cron:'*/30 * * * *',timezone,start},'Every 30 minutes · America/New_York'],
|
|
156
|
+
[{cron:'15 * * * *',timezone,start},'Hourly at :15 · America/New_York'],
|
|
157
|
+
[{cron:'30 9 * * *',timezone,start},'Daily at 09:30 · America/New_York'],
|
|
158
|
+
[{cron:'*/7 * * * *',timezone,start},'Cron */7 * * * * · America/New_York'],
|
|
159
|
+
[{cron:'0 9 1 * 1',timezone,start},'Cron 0 9 1 * 1 · America/New_York'],
|
|
160
|
+
]
|
|
161
|
+
for(const [i,[trigger,label]] of cases.entries()){
|
|
162
|
+
const saved=await scheduler.save({id:`t-${i}`,name:`Task ${i}`,text:'Inspect saved work.',owner,execution,enabled:true,trigger})
|
|
163
|
+
const active=(await scheduler.listTasksReadOnly([])).find(s=>s.id===saved.id)!
|
|
164
|
+
assert.ok(scheduledTasksText([active],owner).includes(`Schedule · ${label}\n`))
|
|
165
|
+
assert.ok(scheduledTaskDetailText(active,1).includes(`Schedule\n${label}\n`))
|
|
166
|
+
}
|
|
167
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { queueSessionClose } from '../src/session-close.js'
|
|
4
|
+
import type { RunStore } from '../src/runs.js'
|
|
5
|
+
import type { Owner, SessionState } from '../src/control-state.js'
|
|
6
|
+
|
|
7
|
+
test('a private application scope never gets an owner-authority close turn', async () => {
|
|
8
|
+
const created: unknown[] = []
|
|
9
|
+
const runs = { create: async (input: unknown) => { created.push(input); return input } } as unknown as RunStore
|
|
10
|
+
const owner = { id: 'owner', telegramUserId: 42, pairedAt: new Date(0).toISOString() } as Owner
|
|
11
|
+
const session: SessionState = { sessionId: 'scoped', hasStarted: true, preset: { id: 'p', cli: 'codex', model: 'm' } as SessionState['preset'], applicationScope: 'a'.repeat(64) }
|
|
12
|
+
assert.equal(await queueSessionClose(runs, owner, session), undefined)
|
|
13
|
+
assert.equal(created.length, 0)
|
|
14
|
+
const { applicationScope: _, ...ownerSession } = session
|
|
15
|
+
assert.ok(await queueSessionClose(runs, owner, ownerSession))
|
|
16
|
+
assert.equal(created.length, 1)
|
|
17
|
+
})
|