@jc_stack/ez-agents 0.1.0-beta.49 → 0.1.0-beta.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +15 -0
- package/README.md +9 -5
- package/bin/ez +16 -7
- package/docs/architecture/ai-selection.md +4 -0
- package/docs/docker-runtime.md +18 -2
- package/docs/plugin-guide.md +3 -3
- package/docs/plugins.md +13 -10
- package/docs/scheduling.md +55 -18
- package/docs/setup.md +2 -2
- package/docs/standalone-cli.md +4 -4
- package/docs/upgrades.md +9 -13
- package/package.json +1 -1
- package/src/agent-guidance.ts +2 -2
- package/src/ai.ts +17 -5
- package/src/application-channel.ts +12 -8
- package/src/executor.ts +9 -0
- package/src/host-executor-client.ts +19 -7
- package/src/index.ts +125 -21
- package/src/menu.ts +3 -2
- package/src/plugins/manager.mjs +43 -40
- package/src/runs.ts +61 -3
- package/src/schedule-cli.ts +18 -12
- package/src/scheduled-tasks.ts +42 -8
- package/src/scheduler.ts +1 -1
- package/src/session-close.ts +17 -0
- package/src/setup-fallback.ts +62 -25
- package/src/tools-line.d.mts +1 -0
- package/src/tools-line.mjs +3 -0
- package/src/updates/binding.mjs +4 -10
- package/src/updates/control.mjs +3 -2
- package/src/updates/supervisor.mjs +1 -1
- package/src/workspace.ts +12 -1
- package/test/ai.test.ts +48 -2
- package/test/application-channel.test.ts +24 -0
- package/test/application-controls.test.ts +4 -1
- package/test/intake-relay.test.ts +63 -2
- package/test/model-policy.test.ts +1 -1
- package/test/plugin-broker.test.mjs +48 -1
- package/test/plugin-manager.test.mjs +52 -38
- package/test/run-handoff.test.ts +132 -0
- package/test/scheduled-tasks.test.ts +24 -2
- package/test/session-close.test.ts +17 -0
- package/test/setup-fallback.test.ts +194 -16
- package/test/updates.test.mjs +19 -10
- package/test/workspace.test.ts +6 -0
package/src/runs.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { validApplicationOrigin, type ApplicationOrigin } from './application-origin.js'
|
|
2
2
|
import { type FailureEvidence, type FailureReview, validFailureReview, failureStamp, failureEvidence } from './failure.js'
|
|
3
3
|
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { isHostRunId } from './host-executor-protocol.js'
|
|
4
7
|
import { validOrigin, type ExternalOrigin } from './event-sources.js'
|
|
5
8
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
6
9
|
import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
|
|
@@ -36,6 +39,8 @@ export type RunRecord = {
|
|
|
36
39
|
// Ordered task setups tried for this occurrence; the last one is active.
|
|
37
40
|
attempts?: SetupAttempt[]
|
|
38
41
|
replyOnly?: boolean
|
|
42
|
+
// Silent end-of-conversation turn in a replaced chat session (session-close.ts).
|
|
43
|
+
sessionClose?: true
|
|
39
44
|
exitCode?: number | null
|
|
40
45
|
failureReason?: string
|
|
41
46
|
failure?: FailureEvidence
|
|
@@ -86,9 +91,10 @@ export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...ou
|
|
|
86
91
|
// Stateless pipe: the relay owns the ledger in process memory. Cross-process
|
|
87
92
|
// producers (engine children, plugin children, host executor) reach it through
|
|
88
93
|
// the delivery socket (see delivery-socket.ts), never through control/ files.
|
|
89
|
-
// A restart drops in-flight runs and queued items by design
|
|
90
|
-
//
|
|
91
|
-
// terminal records so memory
|
|
94
|
+
// A restart drops in-flight runs and queued items by design (except handed-off
|
|
95
|
+
// host-transport runs, below); in-flight sends report unknown rather than
|
|
96
|
+
// claiming success. Long-lived relays cap retained terminal records so memory
|
|
97
|
+
// stays bounded.
|
|
92
98
|
const MAX_TERMINAL_RUNS = 2000
|
|
93
99
|
const MAX_TERMINAL_OUTBOX = 2000
|
|
94
100
|
|
|
@@ -142,6 +148,47 @@ export const activeSetup = (run: Pick<RunRecord, 'attempts' | 'execution'>): AiP
|
|
|
142
148
|
|
|
143
149
|
export const newRunId = (): string => `r_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}`
|
|
144
150
|
|
|
151
|
+
// The one exception to memory-only runs: a graceful relay stop hands its
|
|
152
|
+
// running host-transport runs to the next relay, whose executors outlive it.
|
|
153
|
+
// The next relay adopts and watches them; it never relaunches one.
|
|
154
|
+
const handoffFile = (controlDir: string): string => join(controlDir, 'run-handoff.json')
|
|
155
|
+
const handedRun = (value: unknown): value is RunRecord => {
|
|
156
|
+
const run = value as RunRecord
|
|
157
|
+
return Boolean(run && typeof run === 'object' && (run.version === 1 || run.version === 2) && typeof run.id === 'string' && isHostRunId(run.id) &&
|
|
158
|
+
run.status === 'running' && Array.isArray(run.texts) && run.texts.every(text => typeof text === 'string') && Number.isFinite(Date.parse(run.createdAt)) &&
|
|
159
|
+
(run.execution === undefined || isExecutionChoice(run.execution)) && (run.scheduled === undefined || validScheduledOrigin(run.scheduled)) &&
|
|
160
|
+
(run.external === undefined || validOrigin(run.external)) && (run.application === undefined || validApplicationOrigin(run.application)))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const saveRunHandoff = async (controlDir: string, records: RunRecord[]): Promise<void> => {
|
|
164
|
+
const file = handoffFile(controlDir), temporary = `${file}.${process.pid}.tmp`
|
|
165
|
+
await writeFile(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), runs: records }), { mode: 0o600 })
|
|
166
|
+
await rename(temporary, file)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const HANDOFF_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
170
|
+
|
|
171
|
+
// Reads and removes the record. An unreadable, corrupt or stale record adopts
|
|
172
|
+
// nothing: those executors finish unobserved, like a restart without handoff.
|
|
173
|
+
export const takeRunHandoff = async (controlDir: string, now = Date.now()): Promise<RunRecord[]> => {
|
|
174
|
+
try {
|
|
175
|
+
let raw: string
|
|
176
|
+
try { raw = await readFile(handoffFile(controlDir), 'utf8') }
|
|
177
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
|
178
|
+
await rm(handoffFile(controlDir))
|
|
179
|
+
const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown }
|
|
180
|
+
const age = now - Date.parse(typeof value.writtenAt === 'string' ? value.writtenAt : '')
|
|
181
|
+
if (value.version !== 1 || !(age >= -60_000 && age <= HANDOFF_MAX_AGE_MS)) throw new Error('Invalid or stale run handoff')
|
|
182
|
+
if (!Array.isArray(value.runs) || value.runs.length > 100) throw new Error('Invalid run handoff')
|
|
183
|
+
const valid = value.runs.filter(handedRun)
|
|
184
|
+
if (valid.length !== value.runs.length) console.error('Ignoring invalid handed-off runs', value.runs.length - valid.length)
|
|
185
|
+
return [...new Map(valid.map(run => [run.id, run])).values()]
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error('Ignoring unreadable run handoff', error instanceof Error ? error.message : 'Unknown error')
|
|
188
|
+
return []
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
145
192
|
export class RunStore {
|
|
146
193
|
constructor(private readonly controlDir: string) {}
|
|
147
194
|
|
|
@@ -162,6 +209,7 @@ export class RunStore {
|
|
|
162
209
|
application?: ApplicationOrigin
|
|
163
210
|
delivery?: { bindingId: string; scope: string }
|
|
164
211
|
script?: ScriptRunRef
|
|
212
|
+
sessionClose?: true
|
|
165
213
|
}, exclusiveSchedule = false): Promise<RunRecord> {
|
|
166
214
|
const store = runsFor(this.controlDir)
|
|
167
215
|
if (input.id) {
|
|
@@ -194,6 +242,7 @@ export class RunStore {
|
|
|
194
242
|
delivery: input.delivery,
|
|
195
243
|
scheduled: input.scheduled,
|
|
196
244
|
...(input.script ? { script: input.script } : {}),
|
|
245
|
+
...(input.sessionClose ? { sessionClose: true as const } : {}),
|
|
197
246
|
status: 'queued',
|
|
198
247
|
createdAt: new Date().toISOString(),
|
|
199
248
|
}
|
|
@@ -207,6 +256,15 @@ export class RunStore {
|
|
|
207
256
|
return runsFor(this.controlDir).get(id) ?? null
|
|
208
257
|
}
|
|
209
258
|
|
|
259
|
+
// Re-admit a run from the previous relay's handoff; its executor is already running.
|
|
260
|
+
async adopt(run: RunRecord): Promise<RunRecord> {
|
|
261
|
+
if (!handedRun(run)) throw new Error('Invalid handed-off run')
|
|
262
|
+
const existing = runsFor(this.controlDir).get(run.id)
|
|
263
|
+
if (existing && existing.status !== 'running') throw new Error('Handed-off run already settled')
|
|
264
|
+
runsFor(this.controlDir).set(run.id, run)
|
|
265
|
+
return run
|
|
266
|
+
}
|
|
267
|
+
|
|
210
268
|
async patch(
|
|
211
269
|
id: string,
|
|
212
270
|
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut' | 'attempts'>>,
|
package/src/schedule-cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { ControlStore, sameOwner } from './control-state.js'
|
|
|
8
8
|
import { ApplicationBindings } from './application-channel.js'
|
|
9
9
|
import type { RunRecord } from './runs.js'
|
|
10
10
|
import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
|
|
11
|
-
import { initialPreset, isPreset, presetLabel,
|
|
11
|
+
import { initialPreset, isPreset, presetLabel, type AiPreset } from './ai.js'
|
|
12
12
|
import { executionOverrides } from './model-policy.js'
|
|
13
13
|
import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
|
|
14
14
|
import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
|
|
@@ -17,17 +17,18 @@ import { provisionedHome } from './auth-profile.js'
|
|
|
17
17
|
import { ownsRun } from './identity.js'
|
|
18
18
|
import { type Trigger } from './schedule-time.js'
|
|
19
19
|
|
|
20
|
-
//
|
|
20
|
+
// Each fallback selects its own native engine, credential home and model.
|
|
21
21
|
const fallbackSetup = (spec: string, index: number): AiPreset => {
|
|
22
22
|
const fields = Object.fromEntries(spec.split(',').map(part => {
|
|
23
23
|
const at = part.indexOf('=')
|
|
24
|
-
if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
|
|
24
|
+
if (at < 1) throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
|
|
25
25
|
return [part.slice(0, at), part.slice(at + 1)]
|
|
26
26
|
}))
|
|
27
|
-
if (Object.keys(fields).some(key => !['cli','model','effort','provider'].includes(key)) || !fields.cli || !fields.model)
|
|
28
|
-
throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID]')
|
|
27
|
+
if (Object.keys(fields).some(key => !['cli','model','auth-profile','effort','provider'].includes(key)) || !fields.cli || !fields.model)
|
|
28
|
+
throw new Error('Fallback uses cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID]')
|
|
29
29
|
const base = initialPreset(fields.cli)
|
|
30
|
-
const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {})
|
|
30
|
+
const preset = executionOverrides(base.cli, {...base, ...(fields.provider ? {provider: fields.provider} : {}),
|
|
31
|
+
...(fields['auth-profile'] !== undefined ? {authProfile: fields['auth-profile']} : {})}, fields.model, fields.effort)
|
|
31
32
|
const named = {...preset, id: `fallback-${index + 1}`, name: presetLabel(preset).slice(0, 80)}
|
|
32
33
|
if (!isPreset(named)) throw new Error('Invalid fallback setup')
|
|
33
34
|
return named
|
|
@@ -49,7 +50,7 @@ async function main() {
|
|
|
49
50
|
create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
|
|
50
51
|
--now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
|
|
51
52
|
[--cli EXECUTOR] [--auth-profile NAME] [--model MODEL] [--effort <native-effort>]
|
|
52
|
-
[--fallback cli=EXECUTOR,model=MODEL[,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
|
|
53
|
+
[--fallback cli=EXECUTOR,model=MODEL[,auth-profile=NAME][,effort=EFFORT][,provider=ID] ... | --clear-fallbacks]
|
|
53
54
|
[--preflight-file FILE | --clear-preflight]
|
|
54
55
|
[--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
|
|
55
56
|
script list | show SCRIPT_ID | remove SCRIPT_ID
|
|
@@ -71,10 +72,14 @@ Failures default to unreviewed owner runs. Review records a diagnosis; it never
|
|
|
71
72
|
A conditional review schedule consumes no model run when there are no unreviewed failures.
|
|
72
73
|
New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
|
|
73
74
|
Every task requires a concrete saved model. Supply --model if the selected settings have none; existing tasks never fall back to chat or client defaults.
|
|
74
|
-
Fallbacks
|
|
75
|
-
rejected the turn before any work began
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
Fallbacks run in the saved order only when the previous setup's CLI is unavailable or its provider
|
|
76
|
+
rejected the turn before any work began, including an unavailable login. A Claude usage limit the run ended on
|
|
77
|
+
after work began continues on the next setup in a fresh session, told the stopped session and that its work may be
|
|
78
|
+
partly applied. Account/organization access denial, unknown errors, cancellation and other failures after work
|
|
79
|
+
began stop the chain; nothing is replayed.
|
|
80
|
+
A login or quota rejection skips later setups of the same native login profile
|
|
81
|
+
or upstream provider. Named profiles must be owner-provisioned logins for separate accounts; Ez never enables overage.
|
|
82
|
+
The run records each attempt, setup, failure category and any reported reset time.
|
|
78
83
|
Trigger runs an existing task once with its saved instructions, AI and delivery binding in a fresh session. Reuse the request key after an uncertain result; the regular schedule is unchanged.
|
|
79
84
|
Creates a scheduled task. Instructions are text, never shell commands.
|
|
80
85
|
Use --now to run once. Run completion is not delivery proof.
|
|
@@ -207,7 +212,8 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
207
212
|
const preset = executionOverrides(base.cli, v['auth-profile'] === undefined ? base : {...base, authProfile:v['auth-profile']}, v.model, v.effort)
|
|
208
213
|
if (!isPreset(preset)) throw new Error('Invalid task AI selection')
|
|
209
214
|
const fallbacks = v['clear-fallbacks'] ? undefined : v.fallback ? v.fallback.map(fallbackSetup) : previous?.fallbacks
|
|
210
|
-
|
|
215
|
+
for (const setup of [preset, ...fallbacks ?? []])
|
|
216
|
+
if (setup.authProfile !== undefined) await provisionedHome(config.controlDir, setup.cli, setup.authProfile)
|
|
211
217
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
212
218
|
preflight,
|
|
213
219
|
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { executionDefaults } from './model-policy.js'
|
|
2
|
-
import { presetLabel } from './ai.js'
|
|
2
|
+
import { presetLabel, type AiPreset } from './ai.js'
|
|
3
|
+
import { AUTH_PROFILE_CLIS } from './auth-profile.js'
|
|
3
4
|
import type { Owner } from './control-state.js'
|
|
4
5
|
import type { Schedule, ActiveSchedule } from './scheduler.js'
|
|
5
6
|
|
|
@@ -11,15 +12,45 @@ const ownsSchedule = (owner: Owner, schedule: Schedule) =>
|
|
|
11
12
|
// Saved schedules created under an earlier model policy remain inspectable. The
|
|
12
13
|
// executor will apply the same defaults (and enforce its current policy) when
|
|
13
14
|
// it starts the run; an outdated saved selection must not hide every menu row.
|
|
14
|
-
const displayedPreset = (
|
|
15
|
-
try { return executionDefaults(
|
|
16
|
-
catch { return
|
|
15
|
+
const displayedPreset = (preset: AiPreset) => {
|
|
16
|
+
try { return executionDefaults(preset.cli, preset) }
|
|
17
|
+
catch { return preset }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const setupLabel = (preset: AiPreset) => {
|
|
21
|
+
const label = presetLabel(displayedPreset(preset))
|
|
22
|
+
return AUTH_PROFILE_CLIS.includes(preset.cli) && preset.authProfile === undefined
|
|
23
|
+
? `${preset.cli} (default login)${label.slice(preset.cli.length)}` : label
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
// Script schedules have no AI preset; show what core actually invokes.
|
|
20
27
|
const engineLabel = (schedule: ActiveSchedule) => schedule.script
|
|
21
28
|
? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
|
|
22
|
-
:
|
|
29
|
+
: [schedule.execution!.preset, ...schedule.execution!.fallbacks ?? []].map(setupLabel).join(' → ')
|
|
30
|
+
|
|
31
|
+
const every = (count: number, unit: string) => `Every ${count === 1 ? '' : `${count} `}${unit}${count === 1 ? '' : 's'}`
|
|
32
|
+
const frequency = (schedule: Schedule) => {
|
|
33
|
+
const trigger = schedule.trigger
|
|
34
|
+
if ('at' in trigger) return 'Once'
|
|
35
|
+
if ('everySeconds' in trigger) {
|
|
36
|
+
const seconds = trigger.everySeconds
|
|
37
|
+
for (const [size, unit] of [[86400,'day'],[3600,'hour'],[60,'minute']] as const)
|
|
38
|
+
if (seconds % size === 0) return every(seconds / size, unit)
|
|
39
|
+
return every(seconds, 'second')
|
|
40
|
+
}
|
|
41
|
+
const cron = trigger.cron.trim().replace(/\s+/g, ' ')
|
|
42
|
+
const [minute, hour, day, month, weekday] = cron.split(' ')
|
|
43
|
+
let label = `Cron ${cron}`
|
|
44
|
+
if (`${hour} ${day} ${month} ${weekday}` === '* * * *') {
|
|
45
|
+
const step = /^\*\/(\d+)$/.exec(minute)
|
|
46
|
+
if (minute === '*') label = 'Every minute'
|
|
47
|
+
else if (step && Number(step[1]) > 0 && 60 % Number(step[1]) === 0) label = every(Number(step[1]), 'minute')
|
|
48
|
+
else if (/^\d+$/.test(minute)) label = `Hourly at :${minute.padStart(2,'0')}`
|
|
49
|
+
} else if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && day === '*' && month === '*' && ['*','1-5'].includes(weekday)) {
|
|
50
|
+
label = `${weekday === '*' ? 'Daily' : 'Weekdays'} at ${hour.padStart(2,'0')}:${minute.padStart(2,'0')}`
|
|
51
|
+
}
|
|
52
|
+
return `${label} · ${trigger.timezone}`
|
|
53
|
+
}
|
|
23
54
|
|
|
24
55
|
const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
|
|
25
56
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
|
@@ -52,8 +83,8 @@ export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =
|
|
|
52
83
|
export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
|
|
53
84
|
const owned = ownedScheduledTasks(schedules, owner)
|
|
54
85
|
if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
|
|
55
|
-
return [`📅 Scheduled tasks · ${owned.length} active`, '
|
|
56
|
-
`${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n ${engineLabel(schedule)}\n ${preview(schedule)}`
|
|
86
|
+
return [`📅 Scheduled tasks · ${owned.length} active`, 'Next/last times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
|
|
87
|
+
`${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Schedule · ${frequency(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n Execution · ${engineLabel(schedule)}\n ${preview(schedule)}`
|
|
57
88
|
)].join('\n\n')
|
|
58
89
|
}
|
|
59
90
|
|
|
@@ -64,13 +95,16 @@ export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number
|
|
|
64
95
|
`📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
|
|
65
96
|
currentState(schedule),
|
|
66
97
|
'',
|
|
98
|
+
'Schedule',
|
|
99
|
+
frequency(schedule),
|
|
100
|
+
'',
|
|
67
101
|
'Next run',
|
|
68
102
|
schedule.nextAt === null ? '—' : time(schedule.nextAt),
|
|
69
103
|
'',
|
|
70
104
|
'Last run',
|
|
71
105
|
lastRun(schedule),
|
|
72
106
|
'',
|
|
73
|
-
|
|
107
|
+
'Execution path',
|
|
74
108
|
engineLabel(schedule),
|
|
75
109
|
'',
|
|
76
110
|
schedule.script ? 'Script arguments' : 'Instructions',
|
package/src/scheduler.ts
CHANGED
|
@@ -144,7 +144,7 @@ export class Scheduler {
|
|
|
144
144
|
assertScheduledModel(preset.model)
|
|
145
145
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
146
146
|
}
|
|
147
|
-
const key = (p: AiPreset) => JSON.stringify([p.cli, p.provider, p.model, p.effort])
|
|
147
|
+
const key = (p: AiPreset) => JSON.stringify([p.cli, p.authProfile, p.provider, p.model, p.effort])
|
|
148
148
|
if (new Set(setups.map(key)).size !== setups.length) throw new Error('Task setups must be distinct')
|
|
149
149
|
s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
|
|
150
150
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionState, Owner } from './control-state.js'
|
|
2
|
+
import { ownerId, ownerEpoch } from './control-state.js'
|
|
3
|
+
import type { RunStore, RunRecord } from './runs.js'
|
|
4
|
+
|
|
5
|
+
// A chat conversation has no native end. Before one is replaced, its engine
|
|
6
|
+
// gets one silent turn in that same native session to keep durable facts in
|
|
7
|
+
// the workspace. Ez only transports this literal; the engine decides whether
|
|
8
|
+
// anything is written. No reply mechanism is offered, so nothing is delivered.
|
|
9
|
+
// Owner conversations only: a close turn carries owner authority, so a private
|
|
10
|
+
// application scope (which runs under its binding's narrower authority) gets none.
|
|
11
|
+
export const SESSION_CLOSE_PROMPT = '[conversation closing] This conversation is ending. Save anything a future conversation will need where AGENTS.md says to keep memory; otherwise do nothing.'
|
|
12
|
+
|
|
13
|
+
export async function queueSessionClose(runs: RunStore, owner: Owner | null, session: SessionState | null | undefined): Promise<RunRecord | undefined> {
|
|
14
|
+
if (!owner || !session?.hasStarted || !session.preset || session.applicationScope) return undefined
|
|
15
|
+
return runs.create({ ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner), texts: [SESSION_CLOSE_PROMPT],
|
|
16
|
+
execution: { sessionId: session.sessionId, preset: session.preset }, sessionClose: true })
|
|
17
|
+
}
|
package/src/setup-fallback.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { presetLabel, presetProvider, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
2
2
|
|
|
3
3
|
// Ordered task setups for one scheduled occurrence. Core advances only on typed
|
|
4
|
-
// evidence
|
|
5
|
-
//
|
|
6
|
-
|
|
4
|
+
// evidence: a missing CLI or a provider rejection before any model output or
|
|
5
|
+
// tool item, or a usage limit the run ended on after work began (continued in a
|
|
6
|
+
// fresh session that is told so). Anything else stops the chain.
|
|
7
|
+
export type StartupCategory = 'cli-unavailable' | 'login-unavailable' | 'quota' | 'provider-rejected' | 'access-denied'
|
|
7
8
|
export type StartupRejection = { category: StartupCategory; resetAt?: string }
|
|
8
|
-
export type AttemptCategory = StartupCategory | 'after-work-began' | 'uncertain' | 'same-quota-scope'
|
|
9
|
+
export type AttemptCategory = StartupCategory | 'quota-after-work-began' | 'after-work-began' | 'uncertain' | 'same-quota-scope' | 'same-login-scope'
|
|
9
10
|
export type SetupAttempt = {
|
|
10
11
|
preset: AiPreset
|
|
11
12
|
quotaScope: string
|
|
@@ -13,36 +14,54 @@ export type SetupAttempt = {
|
|
|
13
14
|
category?: AttemptCategory
|
|
14
15
|
resetAt?: string
|
|
15
16
|
exitCode?: number | null
|
|
17
|
+
sessionId?: string
|
|
16
18
|
startedAt?: string
|
|
17
19
|
endedAt?: string
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const categories: StartupCategory[] = ['cli-unavailable', 'quota', 'provider-rejected', 'access-denied']
|
|
21
|
-
|
|
22
|
+
const categories: StartupCategory[] = ['cli-unavailable', 'login-unavailable', 'quota', 'provider-rejected', 'access-denied']
|
|
23
|
+
|
|
24
|
+
// A failed attempt's category and reset time, and whether the occurrence
|
|
25
|
+
// continues on the next setup. Once work began only a quota limit the run ended
|
|
26
|
+
// on continues; access denial, other failures and unknown outcomes stop.
|
|
27
|
+
export const attemptFailure = ({ workBegan, rejection }: { workBegan: boolean; rejection?: StartupRejection }): { category: AttemptCategory; resetAt?: string; continues: boolean } => {
|
|
28
|
+
const resetAt = rejection?.resetAt ? { resetAt: rejection.resetAt } : {}
|
|
29
|
+
if (!workBegan) return rejection ? { category: rejection.category, ...resetAt, continues: rejection.category !== 'access-denied' } : { category: 'uncertain', continues: false }
|
|
30
|
+
return rejection?.category === 'quota' ? { category: 'quota-after-work-began', ...resetAt, continues: true } : { category: 'after-work-began', continues: false }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Transport fact for a setup that continues an occurrence after an earlier setup
|
|
34
|
+
// stopped at its usage limit once work had begun. Facts only, no guidance.
|
|
35
|
+
export const continuationLine = (runId: string, attempts: SetupAttempt[]): string | undefined => {
|
|
36
|
+
const limited = attempts.filter(a => a.category === 'quota-after-work-began')
|
|
37
|
+
return limited.length ? `[run ${runId} continues: ${limited.map(a => `${presetLabel(a.preset)} began work in native session ${a.sessionId} and stopped at its usage limit${a.resetAt ? ` (resets ${a.resetAt})` : ''}`).join('; ')}. That work may be partly applied.]` : undefined
|
|
38
|
+
}
|
|
22
39
|
|
|
23
40
|
export const setupCandidates = (execution: ExecutionChoice): AiPreset[] => [execution.preset, ...(execution.fallbacks ?? [])]
|
|
24
41
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
42
|
+
// The owner provisions native profiles for separate provider accounts. Models
|
|
43
|
+
// in one login share its quota scope; another named login may be tried once.
|
|
44
|
+
// OpenCode/Pi still share their upstream provider scope.
|
|
28
45
|
export const quotaScope = (preset: AiPreset): string => {
|
|
29
46
|
if (['opencode', 'pi'].includes(preset.cli)) {
|
|
30
47
|
const provider = preset.cli === 'pi' ? preset.model?.split('/')[0] : presetProvider(preset)
|
|
31
48
|
return provider ? `provider:${provider}` : preset.cli
|
|
32
49
|
}
|
|
33
|
-
|
|
50
|
+
const cli = preset.cli === 'codex-gui' ? 'codex' : preset.cli
|
|
51
|
+
return preset.authProfile ? `${cli}@${preset.authProfile}` : cli
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
// The next untried setup, skipping
|
|
37
|
-
// this occurrence. Each candidate is tried at most once.
|
|
54
|
+
// The next untried setup, skipping a login that already rejected authentication
|
|
55
|
+
// or reached its quota in this occurrence. Each candidate is tried at most once.
|
|
38
56
|
export const nextSetup = (candidates: AiPreset[], attempts: SetupAttempt[]): { skipped: SetupAttempt[]; preset?: AiPreset } => {
|
|
39
|
-
const exhausted = new
|
|
57
|
+
const exhausted = new Map(attempts.filter(a => ['quota', 'quota-after-work-began', 'login-unavailable'].includes(a.category!))
|
|
58
|
+
.map(a => [a.quotaScope, a.category === 'login-unavailable' ? 'same-login-scope' as const : 'same-quota-scope' as const]))
|
|
40
59
|
const skipped: SetupAttempt[] = []
|
|
41
60
|
for (const preset of candidates.slice(attempts.length)) {
|
|
42
61
|
const scope = quotaScope(preset)
|
|
43
62
|
if (!exhausted.has(scope)) return { skipped, preset }
|
|
44
63
|
const at = new Date().toISOString()
|
|
45
|
-
skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category:
|
|
64
|
+
skipped.push({ preset, quotaScope: scope, outcome: 'skipped', category: exhausted.get(scope), startedAt: at, endedAt: at })
|
|
46
65
|
}
|
|
47
66
|
return { skipped }
|
|
48
67
|
}
|
|
@@ -57,20 +76,23 @@ const epochSeconds = (value: unknown): string | undefined =>
|
|
|
57
76
|
|
|
58
77
|
const claudeErrors: Record<string, StartupCategory> = {
|
|
59
78
|
rate_limit: 'quota', billing_error: 'quota',
|
|
60
|
-
authentication_failed: '
|
|
79
|
+
authentication_failed: 'login-unavailable', oauth_org_not_allowed: 'access-denied', account_on_hold: 'access-denied',
|
|
61
80
|
verification_required: 'access-denied', cloud_credential_error: 'access-denied',
|
|
62
81
|
overloaded: 'provider-rejected', server_error: 'provider-rejected', model_not_found: 'provider-rejected', invalid_request: 'provider-rejected',
|
|
63
82
|
}
|
|
64
83
|
|
|
84
|
+
const httpRejection = (status: unknown): StartupCategory | undefined =>
|
|
85
|
+
typeof status !== 'number' ? undefined : status === 401 ? 'login-unavailable' : status === 429 ? 'quota' :
|
|
86
|
+
status === 403 ? 'access-denied' : [400, 404].includes(status) || status >= 500 ? 'provider-rejected' : undefined
|
|
87
|
+
|
|
65
88
|
// Codex app-server `codexErrorInfo` of a failed turn.
|
|
66
89
|
export const codexRejection = (info: unknown): StartupCategory | undefined => {
|
|
67
90
|
if (info === 'usageLimitExceeded' || info === 'rateLimitExceeded') return 'quota'
|
|
68
|
-
if (info === 'unauthorized') return '
|
|
91
|
+
if (info === 'unauthorized') return 'login-unavailable'
|
|
69
92
|
if (['serverOverloaded', 'internalServerError', 'badRequest', 'flexUnavailable'].includes(info as string)) return 'provider-rejected'
|
|
70
93
|
if (!info || typeof info !== 'object') return undefined
|
|
71
94
|
const status = (Object.values(info)[0] as { httpStatusCode?: unknown } | undefined)?.httpStatusCode
|
|
72
|
-
|
|
73
|
-
return status === 429 ? 'quota' : [401, 403].includes(status) ? 'access-denied' : [400, 404].includes(status) || status >= 500 ? 'provider-rejected' : undefined
|
|
95
|
+
return httpRejection(status)
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
// The latest reset among exhausted Codex rate-limit windows.
|
|
@@ -85,29 +107,44 @@ export const startupLine = (state: { workBegan: boolean; rejection?: StartupReje
|
|
|
85
107
|
// Reads structured stdout only: Ez transport summaries (`ez.startup`) and
|
|
86
108
|
// Claude stream-json envelopes. Model text is never inspected. Any event that
|
|
87
109
|
// is not known pre-work metadata counts as work, so ambiguity never advances.
|
|
110
|
+
// The rejection is the one the run ended on: later work supersedes it, except
|
|
111
|
+
// an access denial, which always stops the chain.
|
|
88
112
|
export const startupObserver = () => {
|
|
89
113
|
let buffer = '', workBegan = false, rejection: StartupRejection | undefined
|
|
114
|
+
const reject = (value: StartupRejection) => { if (rejection?.category !== 'access-denied') rejection = value }
|
|
115
|
+
const work = () => { workBegan = true; if (rejection?.category !== 'access-denied') rejection = undefined }
|
|
90
116
|
const line = (text: string) => {
|
|
91
117
|
if (!text.trim()) return
|
|
92
118
|
let event: any
|
|
93
|
-
try { event = JSON.parse(text) } catch {
|
|
119
|
+
try { event = JSON.parse(text) } catch { work(); return }
|
|
120
|
+
// A summary is the sender's current state; one without a rejection means
|
|
121
|
+
// work superseded an earlier one.
|
|
94
122
|
if (event?.type === 'ez.startup') {
|
|
95
123
|
if (event.workBegan === true) workBegan = true
|
|
96
|
-
const r = event.rejection
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
const r = event.rejection, reset = typeof r?.resetAt === 'string' ? Date.parse(r.resetAt) : NaN
|
|
125
|
+
// Re-render the reset time: it reaches a continuation prompt, and
|
|
126
|
+
// Date.parse also accepts arbitrary words around a date.
|
|
127
|
+
if (r && categories.includes(r.category) && (r.resetAt === undefined || Number.isFinite(reset)))
|
|
128
|
+
reject({ category: r.category, ...(r.resetAt === undefined ? {} : { resetAt: new Date(reset).toISOString() }) })
|
|
129
|
+
else if (rejection?.category !== 'access-denied') rejection = undefined
|
|
99
130
|
return
|
|
100
131
|
}
|
|
101
132
|
if (event?.type === 'thread.started' || event?.type === 'result' ||
|
|
102
133
|
(event?.type === 'system' && event.subtype === 'init')) return
|
|
103
134
|
if (event?.type === 'rate_limit_event') {
|
|
104
135
|
const info = event.rate_limit_info
|
|
105
|
-
if (info?.status === 'rejected')
|
|
136
|
+
if (info?.status === 'rejected') reject({ category: 'quota', ...(epochSeconds(info.resetsAt) ? { resetAt: epochSeconds(info.resetsAt) } : {}) })
|
|
106
137
|
return
|
|
107
138
|
}
|
|
139
|
+
// Native API retry/error metadata is not model output. Do not inspect error
|
|
140
|
+
// prose; only typed HTTP status can establish a rejected startup.
|
|
141
|
+
if (event?.type === 'system' && ['api_error', 'api_retry'].includes(event.subtype)) {
|
|
142
|
+
const category = httpRejection(event.subtype === 'api_retry' ? event.error_status : event.error?.status)
|
|
143
|
+
if (category) { reject({ category }); return }
|
|
144
|
+
}
|
|
108
145
|
const category = event?.type === 'assistant' && typeof event.error === 'string' ? claudeErrors[event.error] : undefined
|
|
109
|
-
if (category)
|
|
110
|
-
else
|
|
146
|
+
if (category) reject({ category, ...(category === 'quota' && rejection?.resetAt ? { resetAt: rejection.resetAt } : {}) })
|
|
147
|
+
else work()
|
|
111
148
|
}
|
|
112
149
|
return {
|
|
113
150
|
write: (chunk: string) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const TOOLS_LINE: string
|
package/src/updates/binding.mjs
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath
|
|
4
|
-
import { atomic } from '../plugins/manager.mjs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { atomic, bindToolDiscovery } from '../plugins/manager.mjs';
|
|
5
5
|
import { read } from './control.mjs';
|
|
6
6
|
|
|
7
|
-
async function packageToolDiscovery(packageRoot,home,workspace) {
|
|
8
|
-
const entry=path.join(packageRoot,'src/plugins/manager.mjs');
|
|
9
|
-
const manager=await import(pathToFileURL(entry).href);
|
|
10
|
-
if(typeof manager.bindToolDiscovery!=='function')throw Error('Selected package has no tool discovery binding');
|
|
11
|
-
await manager.bindToolDiscovery(home,workspace);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
7
|
export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new URL('../../',import.meta.url))) {
|
|
15
8
|
home=await fs.realpath(home);hostConfig=await fs.realpath(hostConfig);
|
|
16
9
|
const config=await read(path.join(home,'config.json')),host=await read(hostConfig),deploymentDir=path.dirname(hostConfig),agent=host.agents[0];
|
|
@@ -28,6 +21,7 @@ export async function bindUpdates(home,hostConfig,packageRoot=fileURLToPath(new
|
|
|
28
21
|
const dest=path.join(bin,name);await fs.rm(dest,{force:true});
|
|
29
22
|
await fs.writeFile(dest,`#!${process.execPath}\nimport fs from 'node:fs';import {spawn} from 'node:child_process';const c=JSON.parse(fs.readFileSync(${configFile}));const child=spawn(c.packageRoot+'/'+${JSON.stringify(entry)},process.argv.slice(2),{stdio:'inherit',env:{...process.env,EZ_DEPLOYMENT_DIR:c.deploymentDir,EZ_CONTROL_DIR:${JSON.stringify(agent.controlDir)}}});for(const s of ['SIGTERM','SIGINT'])process.on(s,()=>child.kill(s));child.on('error',e=>{console.error(e.message);process.exitCode=1});child.on('close',c=>process.exitCode=c??1);\n`,{mode:0o700});
|
|
30
23
|
}
|
|
31
|
-
|
|
24
|
+
// Idempotent: retires a former `ez tools` footer, never writes guidance.
|
|
25
|
+
await bindToolDiscovery(home,config.workspace);
|
|
32
26
|
return {ok:true,home,deploymentDir,packageRoot,policy:'Automatic compatible beta-channel updates by default; saved stable-only or manual policies take precedence. Local candidates require an explicit owner request.'};
|
|
33
27
|
}
|
package/src/updates/control.mjs
CHANGED
|
@@ -191,7 +191,8 @@ export async function submit(home,id,automatic) {
|
|
|
191
191
|
if(next.old.root!==job.previousRoot||next.old.pkg.version!==job.previousVersion)throw Error('Installation changed since preparation');
|
|
192
192
|
if(digest(await fs.readFile(path.join(dir,'candidate.tgz')))!==job.sha256)throw Error('Candidate changed');
|
|
193
193
|
job.status='queued';job.automatic=automatic;await atomic(path.join(dir,'job.json'),job);return job;
|
|
194
|
-
|
|
194
|
+
// Queueing writes only the job; activation drains plugin calls before switching.
|
|
195
|
+
},{allowInvocations:true,waitMs:5000});
|
|
195
196
|
}
|
|
196
197
|
export async function retryRecovery(home,id) {
|
|
197
198
|
const {directory}=await state(home),dir=jobPath(home,id);
|
|
@@ -201,7 +202,7 @@ export async function retryRecovery(home,id) {
|
|
|
201
202
|
if((await jobs(home)).some(j=>['queued','applying'].includes(j.status)))throw Error('An upgrade is already pending');
|
|
202
203
|
await requireSupervisor(directory);
|
|
203
204
|
job.status='queued';job.recoveryRequested=true;await atomic(path.join(dir,'job.json'),job);return {id,status:job.status,recoveryRequested:true};
|
|
204
|
-
});
|
|
205
|
+
},{allowInvocations:true,waitMs:5000});
|
|
205
206
|
}
|
|
206
207
|
export async function command(home,args) {
|
|
207
208
|
const [action,...rest]=args;
|
|
@@ -139,7 +139,7 @@ export async function supervise(deployment,signal,{discover=check}={}) {
|
|
|
139
139
|
const apply=async()=>{
|
|
140
140
|
try {
|
|
141
141
|
const activate=async()=>{const latest=await read(path.join(jobPath(home,pending.id),'job.json'));return perform(home,latest,{stopHost,startHost,signal,refreshPluginBroker});};
|
|
142
|
-
return pending.target==='main'?await locked(home,activate):await activate();
|
|
142
|
+
return pending.target==='main'?await locked(home,activate,{drainInvocations:true,signal}):await activate();
|
|
143
143
|
} catch(error) {
|
|
144
144
|
// A pre-switch rejection is terminal. Applying jobs keep their journal for recovery.
|
|
145
145
|
const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
|
package/src/workspace.ts
CHANGED
|
@@ -2,6 +2,17 @@ import { installAgentGuidance } from './agent-guidance.js'
|
|
|
2
2
|
import { link, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { TOOLS_LINE } from './tools-line.mjs'
|
|
6
|
+
|
|
7
|
+
// Engine-neutral memory: every native CLI loads AGENTS.md, so both read the
|
|
8
|
+
// same plain files. The agent owns the folder and the section after seeding.
|
|
9
|
+
const MEMORY_SEED = `## Memory
|
|
10
|
+
|
|
11
|
+
Keep durable facts (people, preferences, decisions, standing commitments) in
|
|
12
|
+
\`memory/\`, one fact per file, each listed on one line in \`memory/MEMORY.md\`.
|
|
13
|
+
Read \`memory/MEMORY.md\` when a conversation starts. Save or update a fact as
|
|
14
|
+
soon as you learn something a future conversation will need; skip what other
|
|
15
|
+
workspace files already hold.`
|
|
5
16
|
|
|
6
17
|
// Publish each complete seed exclusively. Reinstall never replaces the agent's mind.
|
|
7
18
|
export const initializeWorkspace = async (workspace: string, purposeFile: string | undefined = process.env.EZ_AGENT_PURPOSE_FILE): Promise<string[]> => {
|
|
@@ -22,7 +33,7 @@ export const initializeWorkspace = async (workspace: string, purposeFile: string
|
|
|
22
33
|
try {
|
|
23
34
|
const purpose = (await readFile(purposeFile, 'utf8')).trim()
|
|
24
35
|
if (!purpose) throw new Error('Purpose file must not be empty.')
|
|
25
|
-
await writeFile(temporary, `## Purpose\n\n${purpose}\n`, { mode: 0o600, flag: 'wx' })
|
|
36
|
+
await writeFile(temporary, `## Purpose\n\n${purpose}\n\n${MEMORY_SEED}\n\n${TOOLS_LINE}\n`, { mode: 0o600, flag: 'wx' })
|
|
26
37
|
try {
|
|
27
38
|
await link(temporary, target)
|
|
28
39
|
created.push(name)
|