@jc_stack/ez-agents 0.1.0-beta.40 → 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/AGENTS.md +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/docs/responsive-channels.md +5 -4
- package/docs/scheduling.md +14 -10
- package/package.json +1 -1
- package/src/host-executor.ts +8 -6
- package/src/index.ts +7 -7
- package/src/schedule-cli.ts +2 -2
- package/src/scheduler.ts +18 -2
- package/test/host-executor.test.ts +11 -6
- package/test/schedule-cli.test.ts +4 -1
- package/test/scheduler-host.test.ts +16 -7
- package/test/scheduler-relay.test.ts +7 -14
- package/test/scheduler.test.ts +42 -15
- package/src/task-workspace.ts +0 -14
package/AGENTS.md
CHANGED
|
@@ -121,7 +121,7 @@ round merely to seek more criticism, tests or hypothetical edge cases.
|
|
|
121
121
|
|
|
122
122
|
## 6. Concurrency & Workspace Invariants
|
|
123
123
|
- **1 Writer Job per Workspace:** The agent's Markdown folder (`./agent/`) is its mind. Never run concurrent background processes writing to the same workspace simultaneously.
|
|
124
|
-
- Main-conversation
|
|
124
|
+
- Main-conversation and owner-scheduled jobs write the same mind workspace one at a time; the delivery socket carries every cross-process enqueue/receipt, never control/ files. Scheduled jobs keep separate native CLI sessions and control-side task state. Restricted contact tasks retain their separate authorization boundary. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
|
|
125
125
|
|
|
126
126
|
## 7. Fail-Closed Authority (Channel Access ≠ Execution)
|
|
127
127
|
- Incoming messages outside the approved owner binding must **never** spawn the executor. A first DM or group message records a pending request only. Explicitly approved owner groups grant owner access to all human members in that exact chat; bots and anonymous posts are ignored.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.1.0-beta.41
|
|
10
|
+
|
|
11
|
+
- Run owner-scheduled tasks from the bound agent workspace so native instructions
|
|
12
|
+
and relative context are available, while serializing writes with owner turns.
|
|
13
|
+
|
|
3
14
|
## 0.1.0-beta.40
|
|
4
15
|
|
|
5
16
|
- Apply eligible npm releases under each agent's saved automatic update policy
|
package/README.md
CHANGED
|
@@ -327,6 +327,6 @@ main/plugin replacement. See [upgrade setup, tools and recovery](docs/upgrades.m
|
|
|
327
327
|
|
|
328
328
|
The core `ezenciel-agents-schedule` CLI accepts instruction text for one-off dates,
|
|
329
329
|
intervals and timezone-aware cron schedules. `create --now` delegates a task to a
|
|
330
|
-
separate CLI session
|
|
330
|
+
separate CLI session queued with owner turns in the bound workspace. Long work has
|
|
331
331
|
no production wall-clock timeout; goals and subagents remain native executor
|
|
332
332
|
features. See [scheduling, recovery and QA](docs/scheduling.md).
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
Owner input passes unchanged to the selected engine. The agent decides when to
|
|
4
4
|
delegate or schedule long work to remain available. ez queues foreground input
|
|
5
5
|
while a foreground run is active; it does not create a separate busy-reply agent.
|
|
6
|
-
|
|
6
|
+
Owner-scheduled turns use the bound mind workspace under the same single-writer
|
|
7
|
+
limit as foreground turns.
|
|
7
8
|
|
|
8
|
-
Background tasks receive literal task text. Their
|
|
9
|
-
role instructions or copied identity files; native workspace instructions
|
|
10
|
-
existing Markdown provide context. The agent chooses what to read and when to use
|
|
9
|
+
Background tasks receive literal task text. Their native sessions contain no
|
|
10
|
+
generated role instructions or copied identity files; native workspace instructions
|
|
11
|
+
and existing Markdown provide context. The agent chooses what to read and when to use
|
|
11
12
|
the message CLI. Native final text is not automatically delivered to Telegram.
|
|
12
13
|
|
|
13
14
|
Restricted correspondence receives a typed activation event and scoped tools.
|
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.
|
|
@@ -40,18 +44,18 @@ available through `ezenciel-agents-schedule context`.
|
|
|
40
44
|
## Execution and authority
|
|
41
45
|
|
|
42
46
|
Due occurrences enter the durable queue with stable IDs and literal task text.
|
|
43
|
-
|
|
44
|
-
files or role instructions are generated
|
|
45
|
-
provides context
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
the main conversation. Foreground inputs queue while a foreground turn runs;
|
|
47
|
+
Owner-scheduled runs use fresh native sessions in the bound agent workspace.
|
|
48
|
+
No identity files or role instructions are generated. Existing workspace Markdown
|
|
49
|
+
provides context, including native `AGENTS.md` discovery and relative links; the
|
|
50
|
+
engine chooses what to read. Per-run native state stays under `control/`.
|
|
51
|
+
|
|
52
|
+
One writer runs in the agent workspace: owner schedules and foreground turns
|
|
53
|
+
queue behind each other. Foreground inputs queue while another turn runs;
|
|
51
54
|
ez does not create another reply agent. The agent can delegate or schedule long
|
|
52
55
|
work and return to chat. It decides when to send through the message CLI.
|
|
53
56
|
A recurring schedule has at most one pending or active occurrence. Shared
|
|
54
|
-
provider resources still need writer coordination.
|
|
57
|
+
provider resources still need writer coordination. The former four background
|
|
58
|
+
slots are no longer used for owner schedules.
|
|
55
59
|
|
|
56
60
|
Production relay/host execution has no wall-clock timeout. The old
|
|
57
61
|
`EZ_EXECUTOR_TIMEOUT_SECONDS` setting is ignored. Individual network/tool waits
|
|
@@ -70,7 +74,7 @@ goal is not reported as successful. Native RPC requests have a response deadline
|
|
|
70
74
|
running tasks do not.
|
|
71
75
|
Each scheduled task has its own Codex state under `control/cli/codex/tasks/RUN_ID`,
|
|
72
76
|
with a snapshot of the agent's Codex configuration and the existing auth link.
|
|
73
|
-
Foreground chat and
|
|
77
|
+
Foreground chat and scheduled tasks do not initialize or migrate one shared
|
|
74
78
|
native database concurrently.
|
|
75
79
|
|
|
76
80
|
The foreground chat still uses `codex exec`. That invocation exits after one
|
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/host-executor.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider,
|
|
|
11
11
|
import { parseIsolationClass, type IsolationClass } from './isolation.js'
|
|
12
12
|
import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
|
|
13
13
|
import type { ChildProcess } from 'node:child_process'
|
|
14
|
-
import { taskWorkspace } from './task-workspace.js'
|
|
15
14
|
import { packageVersion } from './version.js'
|
|
16
15
|
import { installedPluginVersions } from './software-status.js'
|
|
17
16
|
import { processSnapshot } from './process-tree.js'
|
|
@@ -52,6 +51,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
52
51
|
let catalogRefresh: Promise<void> | undefined
|
|
53
52
|
const locks: string[] = []
|
|
54
53
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
54
|
+
const activeWorkspaces = new Set<HostBinding>()
|
|
55
55
|
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
56
56
|
const catalog = async (agent: HostBinding) => {
|
|
57
57
|
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
|
|
@@ -158,11 +158,13 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
158
158
|
}
|
|
159
159
|
const sharedWorkspace=sharedWorkspaces.get(agent)
|
|
160
160
|
const base=path.join(directory,id)
|
|
161
|
-
|
|
161
|
+
if (activeWorkspaces.has(agent)) continue
|
|
162
|
+
const releaseWorkspace = agent.toolsHome
|
|
162
163
|
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
163
|
-
if (
|
|
164
|
+
if (agent.toolsHome && !releaseWorkspace) continue
|
|
165
|
+
activeWorkspaces.add(agent)
|
|
164
166
|
try { await rename(base+'.request.json',base+'.running.json') }
|
|
165
|
-
catch (error) { await releaseWorkspace?.(); throw error }
|
|
167
|
+
catch (error) { activeWorkspaces.delete(agent); await releaseWorkspace?.(); throw error }
|
|
166
168
|
const task=(async()=>{
|
|
167
169
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
168
170
|
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
@@ -193,7 +195,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
193
195
|
// transient native-client probe failure must not reject a model
|
|
194
196
|
// that the host just advertised to the relay and application.
|
|
195
197
|
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
196
|
-
const options:ExecutorOptions={workspace:
|
|
198
|
+
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
197
199
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
198
200
|
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
199
201
|
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
@@ -218,7 +220,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
218
220
|
await rm(base+'.process.json',{force:true})
|
|
219
221
|
await rm(base+'.cancel',{force:true})
|
|
220
222
|
active.delete(base)
|
|
221
|
-
await releaseWorkspace?.()
|
|
223
|
+
try { await releaseWorkspace?.() } finally { activeWorkspaces.delete(agent) }
|
|
222
224
|
}
|
|
223
225
|
})()
|
|
224
226
|
tasks.add(task); void task.finally(()=>tasks.delete(task))
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,6 @@ import { randomUUID } from 'node:crypto'
|
|
|
12
12
|
import { stat } from 'node:fs/promises'
|
|
13
13
|
import { Scheduler } from './scheduler.js'
|
|
14
14
|
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
15
|
-
import { taskWorkspace } from './task-workspace.js'
|
|
16
15
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
17
16
|
import {removeTaskAttachments} from './task-attachments.js'
|
|
18
17
|
import { dirname, join, basename } from 'node:path'
|
|
@@ -206,7 +205,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
206
205
|
return
|
|
207
206
|
}
|
|
208
207
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
209
|
-
|
|
208
|
+
// Owner turns and schedules write the same mind. Keep one native writer.
|
|
209
|
+
if (activeChild || background.size) return
|
|
210
210
|
let texts = run.texts
|
|
211
211
|
if (run.external) {
|
|
212
212
|
// Availability failures leave durable queued work for a later check.
|
|
@@ -240,7 +240,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
240
240
|
: await control.executionSession(started.execution!)
|
|
241
241
|
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
242
242
|
const { child, cleanup } = await launch(texts, {
|
|
243
|
-
workspace:
|
|
243
|
+
workspace: config.workspace,
|
|
244
244
|
timeoutMs: config.executorTimeoutMs,
|
|
245
245
|
repairEnabled: config.repairEnabled,
|
|
246
246
|
runId: started.id,
|
|
@@ -319,7 +319,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
319
319
|
}
|
|
320
320
|
})
|
|
321
321
|
console.info('run ended', { run_id: started.id, code })
|
|
322
|
-
const next = await runs.nextQueued(
|
|
322
|
+
const next = await runs.nextQueued()
|
|
323
323
|
if (next && !shuttingDown) await startJob(next)
|
|
324
324
|
})().catch((error) => console.error('Run completion failed', error.message))
|
|
325
325
|
})
|
|
@@ -336,7 +336,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
336
336
|
if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
|
|
337
337
|
setImmediate(() => {
|
|
338
338
|
void runs
|
|
339
|
-
.nextQueued(
|
|
339
|
+
.nextQueued()
|
|
340
340
|
.then((next) => next && startJob(next))
|
|
341
341
|
.catch(console.error)
|
|
342
342
|
})
|
|
@@ -571,7 +571,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
571
571
|
])
|
|
572
572
|
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
573
573
|
} else if (item.type === 'document' && item.documentPath) {
|
|
574
|
-
const docPath = await workspaceFile(
|
|
574
|
+
const docPath = await workspaceFile(config.workspace, item.documentPath)
|
|
575
575
|
await paceSend()
|
|
576
576
|
await authorizeChannelDelivery()
|
|
577
577
|
attemptedDelivery = true
|
|
@@ -1206,7 +1206,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1206
1206
|
}
|
|
1207
1207
|
const currentRunning = await runs.running(false)
|
|
1208
1208
|
if (!currentRunning) {
|
|
1209
|
-
const pendingRun = await runs.nextQueued(
|
|
1209
|
+
const pendingRun = await runs.nextQueued()
|
|
1210
1210
|
if (pendingRun) {
|
|
1211
1211
|
console.info('Processing queued run on startup:', pendingRun.id)
|
|
1212
1212
|
void startJob(pendingRun)
|
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
|
}
|
|
@@ -284,18 +284,23 @@ test('one installed CLI executes two agent bindings with separate minds and sani
|
|
|
284
284
|
assert.match(output, /"stream":"exit","code":0/)
|
|
285
285
|
return output.trim().split('\n').map(line=>JSON.parse(line))
|
|
286
286
|
}
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
assert.
|
|
292
|
-
assert.match(chat.args.at(-1),/ezenciel-agents-message/)
|
|
287
|
+
// Another agent remains independent; this agent's queued schedule and chat
|
|
288
|
+
// wait behind its active owner schedule in the same mind.
|
|
289
|
+
await completed(otherDirectory,'r_other_shared')
|
|
290
|
+
assert.ok(await readFile(path.join(directory,'r_schedule_queued.request.json')))
|
|
291
|
+
assert.ok(await readFile(path.join(directory,'tg_42.request.json')))
|
|
293
292
|
assert.doesNotMatch(await readFile(path.join(directory,'r_hold.events'),'utf8'),/"stream":"exit"/)
|
|
294
293
|
await readFile(path.join(directory,'r_hold.running.json'))
|
|
295
294
|
await writeFile(path.join(directory,'r_hold.cancel'),'')
|
|
296
295
|
let heldOutput=''
|
|
297
296
|
for(let n=0;n<200;n++){heldOutput=await readFile(path.join(directory,'r_hold.events'),'utf8');if(heldOutput.includes('"stream":"exit"'))break;await new Promise(r=>setTimeout(r,20))}
|
|
298
297
|
assert.match(heldOutput, /"stream":"exit","code":1/)
|
|
298
|
+
const [scheduleEvents,chatEvents]=await Promise.all([completed(directory,'r_schedule_queued'),completed(directory,'tg_42')])
|
|
299
|
+
const scheduled=JSON.parse(scheduleEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
300
|
+
const chat=JSON.parse(chatEvents.filter(e=>e.stream==='stdout').map(e=>e.text).join(''))
|
|
301
|
+
assert.equal(scheduled.cwd,await realpath(agents[0].workspace))
|
|
302
|
+
assert.match(chat.args.at(-1),/^Chat while scheduled work runs/)
|
|
303
|
+
assert.match(chat.args.at(-1),/ezenciel-agents-message/)
|
|
299
304
|
} finally {
|
|
300
305
|
abort.abort();await server
|
|
301
306
|
EXECUTOR_REGISTRY.grok.command=old
|
|
@@ -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)
|
|
@@ -11,11 +11,12 @@ import { RunStore } from '../src/runs.js'
|
|
|
11
11
|
|
|
12
12
|
const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<250;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Host probe timed out')}
|
|
13
13
|
const stdout=(events:string) => events.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(event=>event.stream==='stdout').map(event=>event.text).join('')
|
|
14
|
-
test('host transport
|
|
14
|
+
test('host transport serializes owner schedules and chat in the bound mind',async()=>{
|
|
15
15
|
const root=await mkdtemp(join(tmpdir(),'ez-scheduler-host-')),workspace=join(root,'agent'),controlDir=join(root,'control')
|
|
16
16
|
await mkdir(workspace);await mkdir(controlDir)
|
|
17
|
+
await writeFile(join(workspace,'AGENTS.md'),'bound-owner-instructions')
|
|
17
18
|
const script=join(root,'fixture.mjs')
|
|
18
|
-
await writeFile(script,`console.log(JSON.stringify({cwd:process.cwd(),token:process.env.TELEGRAM_BOT_TOKEN,run:process.env.EZ_RUN_ID}));if(process.env.EZ_RUN_ID.startsWith('r_schedule_'))setInterval(()=>{},1000);`)
|
|
19
|
+
await writeFile(script,`import {readFileSync} from 'node:fs';console.log(JSON.stringify({cwd:process.cwd(),agents:readFileSync('AGENTS.md','utf8'),token:process.env.TELEGRAM_BOT_TOKEN,run:process.env.EZ_RUN_ID}));if(process.env.EZ_RUN_ID.startsWith('r_schedule_'))setInterval(()=>{},1000);`)
|
|
19
20
|
const old={...EXECUTOR_REGISTRY.grok},token=process.env.TELEGRAM_BOT_TOKEN
|
|
20
21
|
EXECUTOR_REGISTRY.grok.command=process.execPath;EXECUTOR_REGISTRY.grok.buildArgs=()=>[script]
|
|
21
22
|
process.env.TELEGRAM_BOT_TOKEN='never-in-child'
|
|
@@ -31,21 +32,29 @@ test('host transport reserves separate task and main lanes, pins directories, an
|
|
|
31
32
|
await submit(id)
|
|
32
33
|
await until(async()=>Boolean(await exists(id+'.process.json')))
|
|
33
34
|
await submit('tg_1')
|
|
34
|
-
await
|
|
35
|
+
await new Promise(resolve=>setTimeout(resolve,300))
|
|
36
|
+
assert.ok(await exists('tg_1.request.json'))
|
|
37
|
+
assert.equal(await exists('tg_1.events'),'')
|
|
35
38
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
36
39
|
const scheduled=JSON.parse(stdout(await exists(id+'.events')))
|
|
37
|
-
|
|
38
|
-
assert.equal(scheduled.
|
|
39
|
-
assert.equal(scheduled.token,undefined)
|
|
40
|
+
assert.equal(scheduled.cwd,await realpath(workspace))
|
|
41
|
+
assert.equal(scheduled.agents,'bound-owner-instructions')
|
|
42
|
+
assert.equal(scheduled.token,undefined)
|
|
40
43
|
// An unknown scheduled run must not unwind the shared host service.
|
|
41
44
|
await submit('r_schedule_corrupt')
|
|
42
45
|
await until(async()=>(await exists('r_schedule_corrupt.events')).includes('"stream":"exit","code":1'))
|
|
43
46
|
assert.ok(!(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
44
47
|
await ownerRun(controlDir,'tg_2')
|
|
45
48
|
await submit('tg_2')
|
|
46
|
-
|
|
49
|
+
assert.ok(await exists('tg_2.request.json'))
|
|
47
50
|
await writeFile(join(dir,id+'.cancel'),'')
|
|
48
51
|
await until(async()=>(await exists(id+'.events')).includes('"stream":"exit"'))
|
|
52
|
+
await until(async()=>(await exists('tg_1.events')).includes('"stream":"exit","code":0'))
|
|
53
|
+
await until(async()=>(await exists('tg_2.events')).includes('"stream":"exit","code":0'))
|
|
54
|
+
const main=JSON.parse(stdout(await exists('tg_1.events')))
|
|
55
|
+
assert.equal(main.cwd,await realpath(workspace))
|
|
56
|
+
assert.equal(main.agents,'bound-owner-instructions')
|
|
57
|
+
assert.equal(main.token,undefined)
|
|
49
58
|
}finally{
|
|
50
59
|
abort.abort();await server
|
|
51
60
|
Object.assign(EXECUTOR_REGISTRY.grok,old)
|
|
@@ -44,7 +44,7 @@ test('a wrapper-shaped run id has no maintenance treatment and a stale notice is
|
|
|
44
44
|
}finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
test('chat
|
|
47
|
+
test('owner chat queues behind a scheduled CLI in the same workspace; targeted cancellation',async()=>{
|
|
48
48
|
const dir=await mkdtemp(join(tmpdir(),'ez-scheduler-relay-')), children:ReturnType<typeof spawn>[]=[]
|
|
49
49
|
const runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)
|
|
50
50
|
const replies:string[]=[], workspaces:string[]=[]
|
|
@@ -68,27 +68,20 @@ test('chat replies through the real ingress/outbox while scheduled CLI remains a
|
|
|
68
68
|
assert.equal(background.status,'running')
|
|
69
69
|
const update:Update={update_id:123,message:{message_id:123,date:0,text:'What is 17 × 19?',from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
|
|
70
70
|
await relay.bot.handleUpdate(update);await relay.drainInbox(true);await relay.drainOutbox()
|
|
71
|
-
assert.ok(replies.includes('323'))
|
|
71
|
+
assert.ok(!replies.includes('323'))
|
|
72
72
|
assert.equal((await runs.get(background.id))?.status,'running')
|
|
73
73
|
assert.equal(children[0].exitCode,null)
|
|
74
|
-
assert.
|
|
74
|
+
assert.deepEqual(workspaces,[dir])
|
|
75
75
|
// Pausing future dispatch doesn't kill active work; cancelling this run does.
|
|
76
76
|
await scheduler.enable('slow',false);await relay.drainSources()
|
|
77
77
|
assert.equal(children[0].exitCode,null)
|
|
78
78
|
await scheduler.cancel(background.id);await relay.drainSources()
|
|
79
79
|
await until(async()=> (await runs.get(background.id))?.status==='cancelled')
|
|
80
|
+
await until(async()=>children.length===2)
|
|
81
|
+
await relay.drainOutbox()
|
|
82
|
+
assert.ok(replies.includes('323'))
|
|
83
|
+
assert.deepEqual(workspaces,[dir,dir])
|
|
80
84
|
assert.equal((await runs.list()).filter(r=>r.scheduled).length,1)
|
|
81
|
-
for(let n=0;n<5;n++)await scheduler.save({id:'pool_'+n,name:'Pool',text:'Long work',trigger:{at:new Date(Date.now()+2000).toISOString()},enabled:true,owner,execution})
|
|
82
|
-
await scheduler.tick(owner,runs,Date.now()+3000);await relay.drainSources()
|
|
83
|
-
const queued=(await runs.list()).find(r=>r.scheduled && r.status==='queued')!
|
|
84
|
-
assert.ok(queued)
|
|
85
|
-
assert.equal((await runs.list()).filter(r=>r.scheduled && r.status==='running').length,4)
|
|
86
|
-
await scheduler.enable(queued.scheduled!.id,false);await relay.drainSources()
|
|
87
|
-
assert.equal((await runs.get(queued.id))?.status,'queued')
|
|
88
|
-
await scheduler.enable(queued.scheduled!.id,true);await scheduler.cancel(queued.id);await relay.drainSources()
|
|
89
|
-
assert.equal((await runs.get(queued.id))?.status,'cancelled')
|
|
90
|
-
await relay.bot.handleUpdate({...update,update_id:124,message:{...update.message!,message_id:124,text:'/stop'}} as Update)
|
|
91
|
-
await until(async()=>!(await runs.list()).some(r=>r.scheduled && r.status==='running'))
|
|
92
85
|
}finally{
|
|
93
86
|
await relay.stop()
|
|
94
87
|
for(const child of children)if(child.exitCode===null && child.signalCode===null)await once(child,'close')
|
package/test/scheduler.test.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import test from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtemp, rm, writeFile, readFile, stat
|
|
3
|
+
import { mkdtemp, rm, writeFile, readFile, stat } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
7
|
import { Scheduler, scheduledRunId } from '../src/scheduler.js'
|
|
8
8
|
import { RunStore } from '../src/runs.js'
|
|
9
9
|
import { nextOccurrence, validateTrigger, type Trigger } from '../src/schedule-time.js'
|
|
10
|
-
import { taskWorkspace } from '../src/task-workspace.js'
|
|
11
10
|
|
|
12
11
|
const next=(t:Trigger,after:string)=>{
|
|
13
12
|
const at=nextOccurrence(validateTrigger(t),Date.parse(after));return at===null ? null : new Date(at).toISOString()
|
|
@@ -56,6 +55,47 @@ test('missed recurrences coalesce; an active occurrence cannot overlap another',
|
|
|
56
55
|
await f.scheduler.tick(f.owner,f.runs,f.now+800000)
|
|
57
56
|
assert.equal((await f.runs.list()).length,2)
|
|
58
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
|
+
})
|
|
59
99
|
test('pause, edit, removal, owner revocation, corrupt records and traversal fail closed',async t=>{
|
|
60
100
|
const f=await fixture(t), s=await f.scheduler.save(f.input)
|
|
61
101
|
await f.scheduler.enable(s.id,false);await f.scheduler.tick(f.owner,f.runs,f.now)
|
|
@@ -76,19 +116,6 @@ test('pause, edit, removal, owner revocation, corrupt records and traversal fail
|
|
|
76
116
|
await assert.rejects(f.scheduler.cancel('../bad'))
|
|
77
117
|
await f.scheduler.cancel(run.id);assert.equal(await f.scheduler.cancelled(run.id),true)
|
|
78
118
|
})
|
|
79
|
-
test('task workspaces are distinct and cannot escape through symlinks',async t=>{
|
|
80
|
-
const f=await fixture(t)
|
|
81
|
-
await writeFile(join(f.dir,'private.md'),'Owner context')
|
|
82
|
-
const first=await taskWorkspace(f.dir,'r_one'),second=await taskWorkspace(f.dir,'r_two')
|
|
83
|
-
assert.notEqual(first,second)
|
|
84
|
-
await assert.rejects(readFile(join(first,'private.md'),'utf8'),{code:'ENOENT'})
|
|
85
|
-
await assert.rejects(readFile(join(first,'AGENTS.md'),'utf8'),{code:'ENOENT'})
|
|
86
|
-
await assert.rejects(taskWorkspace(f.dir,'../escape'))
|
|
87
|
-
const other=join(f.dir,'other');await mkdir(other)
|
|
88
|
-
await symlink(other,join(f.dir,'work/tasks/r_link'))
|
|
89
|
-
await assert.rejects(taskWorkspace(f.dir,'r_link'))
|
|
90
|
-
})
|
|
91
|
-
|
|
92
119
|
test('startup quarantines an interrupted spawn before PID persistence; explicit edit releases its schedule',async t=>{
|
|
93
120
|
const f=await fixture(t),s=await f.scheduler.save({...f.input,trigger:{everySeconds:60,start:new Date(f.now).toISOString()}})
|
|
94
121
|
await f.scheduler.tick(f.owner,f.runs,f.now)
|
package/src/task-workspace.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { mkdir, lstat } from 'node:fs/promises'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { assertId } from './identity.js'
|
|
4
|
-
|
|
5
|
-
// Fixed, agent-bound path under the control directory, never the workspace
|
|
6
|
-
// mind folder and never a caller-provided cwd. No shared mutable task files.
|
|
7
|
-
export async function taskWorkspace(baseDir: string, id: string): Promise<string> {
|
|
8
|
-
const dirs=[join(baseDir,'work'),join(baseDir,'work','tasks'),join(baseDir,'work','tasks',assertId(id))]
|
|
9
|
-
for(const dir of dirs){
|
|
10
|
-
await mkdir(dir,{recursive:true,mode:0o700})
|
|
11
|
-
if(!(await lstat(dir)).isDirectory())throw new Error('Task workspace must not be a symlink')
|
|
12
|
-
}
|
|
13
|
-
return dirs[2]
|
|
14
|
-
}
|