@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 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
@@ -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.41",
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.",
@@ -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 { nextOccurrence, type Trigger } from './schedule-time.js'
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 ? nextOccurrence(s.trigger,Date.now()) : null
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
- private async pendingOccurrence(s: Schedule): Promise<number | null> {
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
- if (nextOccurrence(s.trigger,Date.now()-1) === null) throw new Error('Schedule has no future occurrence within eight years')
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)
@@ -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)