@jc_stack/ez-agents 0.1.0-beta.41 → 0.1.0-beta.42
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/CHANGELOG.md +6 -0
- package/docs/scheduling.md +4 -0
- package/package.json +1 -1
- package/src/schedule-cli.ts +2 -2
- package/src/scheduler.ts +18 -2
- package/test/schedule-cli.test.ts +4 -1
- package/test/scheduler.test.ts +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.42
|
|
4
|
+
|
|
5
|
+
- Keep recurring schedule edits at their next pending or future occurrence, so
|
|
6
|
+
an old start date cannot replay historical work. Schedule readback now shows
|
|
7
|
+
the same pending cursor used by dispatch.
|
|
8
|
+
|
|
3
9
|
## 0.1.0-beta.41
|
|
4
10
|
|
|
5
11
|
- Run owner-scheduled tasks from the bound agent workspace so native instructions
|
package/docs/scheduling.md
CHANGED
|
@@ -21,6 +21,10 @@ ezenciel-agents-schedule cancel RUN_ID
|
|
|
21
21
|
|
|
22
22
|
Use `--text-file` for longer instructions. `edit ID` replaces the complete schedule
|
|
23
23
|
with a new revision; use the same trigger/name/text flags as `create`.
|
|
24
|
+
An edit keeps a future pending occurrence when its trigger is unchanged;
|
|
25
|
+
otherwise the edited rule begins at its next future occurrence. Editing does
|
|
26
|
+
not replay missed work from an old start date. Use `--now` to request a new
|
|
27
|
+
one-time run explicitly. `show` reports the pending occurrence used by dispatch.
|
|
24
28
|
Intervals use `--every-seconds` (minimum 60), optional `--start`, and optional
|
|
25
29
|
`--until`. Cron also supports start/end bounds. All absolute timestamps require
|
|
26
30
|
an explicit offset. `--now` starts on the next relay tick, not synchronously.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.42",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
|
package/src/schedule-cli.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { initialPreset, isPreset } from './ai.js'
|
|
|
11
11
|
import { executionOverrides } from './model-policy.js'
|
|
12
12
|
import { holdsSchedule, Scheduler } from './scheduler.js'
|
|
13
13
|
import { ownsRun } from './identity.js'
|
|
14
|
-
import {
|
|
14
|
+
import { type Trigger } from './schedule-time.js'
|
|
15
15
|
|
|
16
16
|
async function main() {
|
|
17
17
|
const { values:v, positionals:[action='list',id] } = parseArgs({allowPositionals:true,options:{
|
|
@@ -69,7 +69,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
69
69
|
const held=(await runs.listBestEffort()).filter(r=>holdsSchedule(s,r))
|
|
70
70
|
const interruptedRunIds=held.filter(r=>r.interrupted).map(r=>r.id)
|
|
71
71
|
const failedReviewRunIds=held.filter(r=>!r.interrupted).map(r=>r.id)
|
|
72
|
-
const next=s.enabled && !held.length ?
|
|
72
|
+
const next=s.enabled && !held.length ? await scheduler.pendingOccurrence(s) : null
|
|
73
73
|
return {...s,interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
|
|
74
74
|
...(held.length ? {recovery:'Inspect the failed run and explicitly edit this schedule to resume; pause/resume does not clear the stop.'} : {})}
|
|
75
75
|
}
|
package/src/scheduler.ts
CHANGED
|
@@ -75,7 +75,7 @@ export class Scheduler {
|
|
|
75
75
|
}
|
|
76
76
|
return result
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
async pendingOccurrence(s: Schedule): Promise<number | null> {
|
|
79
79
|
try {
|
|
80
80
|
const saved = JSON.parse(await readFile(join(this.dir,`${s.id}.${s.revision}.cursor`),'utf8'))
|
|
81
81
|
if (saved.next !== null && !Number.isFinite(saved.next)) throw new Error('Invalid schedule cursor')
|
|
@@ -114,7 +114,23 @@ export class Scheduler {
|
|
|
114
114
|
if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
|
|
115
115
|
assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
|
|
116
116
|
const s: Schedule = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
117
|
-
|
|
117
|
+
const now = Date.now()
|
|
118
|
+
let next = nextOccurrence(s.trigger,now-1)
|
|
119
|
+
if (next === null) throw new Error('Schedule has no future occurrence within eight years')
|
|
120
|
+
if (!exclusive) {
|
|
121
|
+
let previous: Schedule | null = null
|
|
122
|
+
try { previous = await this.get(s.id) }
|
|
123
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
124
|
+
if (previous) {
|
|
125
|
+
if (sameOwner(previous.owner,s.owner) && JSON.stringify(previous.trigger) === JSON.stringify(s.trigger)) {
|
|
126
|
+
const pending = await this.pendingOccurrence(previous)
|
|
127
|
+
if (pending !== null && pending >= now) next = pending
|
|
128
|
+
}
|
|
129
|
+
// Publish the cursor first so a crash cannot expose a new revision with
|
|
130
|
+
// a missing cursor that falls back to the trigger's historical start.
|
|
131
|
+
await atomic(join(this.dir,`${s.id}.${s.revision}.cursor`),{next})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
118
134
|
await atomic(join(this.dir,s.id+'.json'),s,exclusive)
|
|
119
135
|
return s
|
|
120
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 } from 'node:fs/promises'
|
|
5
|
+
import { 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'
|
|
@@ -52,6 +52,9 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
52
52
|
await runs.patch(task.id,{status:'running'})
|
|
53
53
|
await assert.rejects(exec(process.execPath,[bin,'list'],{env:{...env,EZ_RUN_ID:task.id}}),/owner-authorized/)
|
|
54
54
|
const schedule=JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout)
|
|
55
|
+
const pending=Date.parse('2027-09-22T05:00:00Z')
|
|
56
|
+
await writeFile(join(dir,'schedules',`test.${schedule.revision}.cursor`),JSON.stringify({next:pending}))
|
|
57
|
+
assert.equal(JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout).nextEligibleAt,new Date(pending).toISOString())
|
|
55
58
|
const interrupted=await runs.create({chatId:101,telegramUserId:101,texts:['old'],execution,scheduled:{id:'test',revision:schedule.revision,dueAt:new Date().toISOString(),pairedAt:schedule.owner.pairedAt}})
|
|
56
59
|
await runs.patch(interrupted.id,{status:'failed',interrupted:true})
|
|
57
60
|
const held=JSON.parse((await exec(process.execPath,[bin,'show','test'],{env})).stdout)
|
package/test/scheduler.test.ts
CHANGED
|
@@ -55,6 +55,47 @@ test('missed recurrences coalesce; an active occurrence cannot overlap another',
|
|
|
55
55
|
await f.scheduler.tick(f.owner,f.runs,f.now+800000)
|
|
56
56
|
assert.equal((await f.runs.list()).length,2)
|
|
57
57
|
})
|
|
58
|
+
test('recurring edits preserve a future cursor or start the changed rule in the future',async t=>{
|
|
59
|
+
const f=await fixture(t), start=new Date(Date.now()-86400000).toISOString()
|
|
60
|
+
const original=await f.scheduler.save({...f.input,trigger:{everySeconds:60,start}},true)
|
|
61
|
+
const future=nextOccurrence(original.trigger,Date.now()+120000)!
|
|
62
|
+
await writeFile(join(f.dir,`schedules/${original.id}.${original.revision}.cursor`),JSON.stringify({next:future}))
|
|
63
|
+
const same=await f.scheduler.save({...f.input,text:'Edited text',trigger:original.trigger})
|
|
64
|
+
assert.notEqual(same.revision,original.revision)
|
|
65
|
+
assert.equal(await f.scheduler.pendingOccurrence(same),future)
|
|
66
|
+
await f.scheduler.tick(f.owner,f.runs,future-1)
|
|
67
|
+
assert.equal((await f.runs.list()).length,0)
|
|
68
|
+
await f.scheduler.tick(f.owner,f.runs,future)
|
|
69
|
+
const [first]=await f.runs.list()
|
|
70
|
+
assert.equal(first.scheduled?.dueAt,new Date(future).toISOString())
|
|
71
|
+
assert.equal(first.texts[1],'Edited text')
|
|
72
|
+
await f.runs.patch(first.id,{status:'completed'})
|
|
73
|
+
|
|
74
|
+
const changed=await f.scheduler.save({...f.input,text:'Changed cadence',trigger:{everySeconds:120,start}})
|
|
75
|
+
const nextDue=await f.scheduler.pendingOccurrence(changed)
|
|
76
|
+
assert.ok(nextDue !== null && nextDue >= Date.now())
|
|
77
|
+
await f.scheduler.tick(f.owner,f.runs,nextDue-1)
|
|
78
|
+
assert.equal((await f.runs.list()).length,1,'edit must not replay the old start')
|
|
79
|
+
await f.scheduler.tick(f.owner,f.runs,nextDue)
|
|
80
|
+
const second=(await f.runs.list()).find(r=>r.scheduled?.revision===changed.revision)
|
|
81
|
+
assert.equal(second?.scheduled?.dueAt,new Date(nextDue).toISOString())
|
|
82
|
+
assert.equal(second?.texts[1],'Changed cadence')
|
|
83
|
+
})
|
|
84
|
+
test('an explicit one-time edit still dispatches at its requested time',async t=>{
|
|
85
|
+
const f=await fixture(t), first=await f.scheduler.save(f.input,true)
|
|
86
|
+
const due=Date.now()+2000
|
|
87
|
+
const edited=await f.scheduler.save({...f.input,trigger:{at:new Date(due).toISOString()},text:'Run now'})
|
|
88
|
+
assert.notEqual(first.revision,edited.revision)
|
|
89
|
+
await f.scheduler.tick(f.owner,f.runs,due)
|
|
90
|
+
assert.equal((await f.runs.list()).find(r=>r.scheduled?.revision===edited.revision)?.texts[1],'Run now')
|
|
91
|
+
})
|
|
92
|
+
test('an unreadable prior cursor rejects an edit without publishing its revision',async t=>{
|
|
93
|
+
const f=await fixture(t), start=new Date(Date.now()-86400000).toISOString()
|
|
94
|
+
const saved=await f.scheduler.save({...f.input,trigger:{everySeconds:60,start}},true)
|
|
95
|
+
await writeFile(join(f.dir,`schedules/${saved.id}.${saved.revision}.cursor`),'{broken')
|
|
96
|
+
await assert.rejects(f.scheduler.save({...f.input,text:'Edited',trigger:saved.trigger}))
|
|
97
|
+
assert.equal((await f.scheduler.get(saved.id)).revision,saved.revision)
|
|
98
|
+
})
|
|
58
99
|
test('pause, edit, removal, owner revocation, corrupt records and traversal fail closed',async t=>{
|
|
59
100
|
const f=await fixture(t), s=await f.scheduler.save(f.input)
|
|
60
101
|
await f.scheduler.enable(s.id,false);await f.scheduler.tick(f.owner,f.runs,f.now)
|