@jc_stack/ez-agents 0.1.0-beta.50 → 0.1.0-beta.52
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 +17 -0
- package/docker/isolated-executor-smoke.mjs +35 -0
- package/docs/docker-runtime.md +12 -2
- package/docs/releasing.md +1 -0
- package/docs/scheduling.md +7 -4
- package/docs/upgrades.md +20 -2
- package/package.json +4 -2
- package/src/ai-cli.ts +4 -2
- package/src/control-state.ts +10 -4
- package/src/delivery-socket-client.mjs +15 -1
- package/src/delivery-socket.ts +18 -4
- package/src/executor.ts +54 -10
- package/src/host-executor-client.ts +4 -1
- package/src/host-executor.ts +63 -32
- package/src/index.ts +37 -19
- package/src/plugins/workspace-lease.mjs +1 -1
- package/src/runs.ts +8 -2
- package/src/schedule-cli.ts +6 -3
- package/src/scheduled-tasks.ts +12 -10
- package/src/scheduler.ts +6 -7
- package/src/updates/runtime.mjs +27 -10
- package/src/updates/supervisor.mjs +137 -18
- package/test/ai-cli.test.ts +23 -1
- package/test/ai.test.ts +24 -2
- package/test/delivery-transport.test.ts +41 -2
- package/test/executor.test.ts +32 -4
- package/test/host-executor.test.ts +70 -3
- package/test/intake-relay.test.ts +8 -1
- package/test/plugin-manager.test.mjs +1 -1
- package/test/run-handoff.test.ts +4 -0
- package/test/schedule-cli.test.ts +24 -1
- package/test/scheduled-tasks.test.ts +23 -15
- package/test/updates.test.mjs +64 -3
package/src/host-executor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Tasks } from './tasks.js'
|
|
|
3
3
|
import { ControlStore } from './control-state.js'
|
|
4
4
|
import { authorizeRun, readRun } from './delivery-socket.js'
|
|
5
5
|
import { runPromptSuffix } from './prompt-suffix.js'
|
|
6
|
-
import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
|
|
6
|
+
import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath, utimes, stat } from 'node:fs/promises'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { isHostRunId } from './host-executor-protocol.js'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
@@ -42,13 +42,16 @@ export const refreshAgentCatalog = async (agent: HostBinding,
|
|
|
42
42
|
const processStart = async (pid:number) => (await processSnapshot()).get(pid)?.birth
|
|
43
43
|
|
|
44
44
|
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal, launch = startExecutorJob,
|
|
45
|
-
readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]
|
|
45
|
+
readCatalog?: (agent: HostBinding) => Promise<ModelChoice[]>, retire?: AbortSignal) => {
|
|
46
46
|
if (installation.isolation !== undefined) parseIsolationClass(installation.isolation)
|
|
47
|
-
if (installation.isolation === 'isolated') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
|
|
47
|
+
if (installation.isolation === 'isolated' && process.env.EZ_CONTAINER_EXECUTOR !== '1') throw new Error('Isolated agents run the native CLI in the relay; do not start host transport')
|
|
48
48
|
resolveExecutor(installation.cli)
|
|
49
49
|
if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
|
|
50
50
|
new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
|
|
51
51
|
throw new Error('Each agent requires a separate workspace and control directory')
|
|
52
|
+
const containerId = process.env.EZ_CONTAINER_EXECUTOR === '1' ? process.env.HOSTNAME : undefined
|
|
53
|
+
const hostStarted = await processStart(process.pid)
|
|
54
|
+
let retiring = false
|
|
52
55
|
const active = new Map<string, ChildProcess>()
|
|
53
56
|
// A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
|
|
54
57
|
const claimed = new Set<string>()
|
|
@@ -74,9 +77,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
74
77
|
const freshCatalog = readCatalog ?? catalog
|
|
75
78
|
const beat = async (agent: HostBinding) => {
|
|
76
79
|
const directory = path.join(agent.controlDir, 'host-executor')
|
|
77
|
-
await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
|
|
80
|
+
await writeFile(path.join(directory, 'heartbeat.tmp'), JSON.stringify({ at: Date.now(), pid: process.pid, version: packageVersion, platform: process.platform, arch: process.arch, ...(process.env.EZ_CONTAINER_EXECUTOR === '1' ? {containerExecutor:true,containerId} : {}), plugins: await installedPluginVersions(agent.toolsHome) }), { mode: 0o600 })
|
|
78
81
|
await rename(path.join(directory, 'heartbeat.tmp'), path.join(directory, 'heartbeat.json'))
|
|
79
82
|
}
|
|
83
|
+
const recoverClaims = async (agent: HostBinding) => {
|
|
84
|
+
const directory=path.join(agent.controlDir,'host-executor')
|
|
85
|
+
for (const file of await readdir(directory)) if (file.endsWith('.running.json') && isHostRunId(file.slice(0,-13))) {
|
|
86
|
+
const base=path.join(directory,file.slice(0,-13))
|
|
87
|
+
if (claimed.has(base)) continue
|
|
88
|
+
let prior: {pid?:number;started?:string;hostPid?:number;hostStarted?:string;containerId?:string} | undefined
|
|
89
|
+
try { prior=JSON.parse(await readFile(base+'.process.json','utf8')) } catch(error) { if ((error as NodeJS.ErrnoException).code!=='ENOENT') throw error }
|
|
90
|
+
if(prior?.containerId && prior.containerId!==containerId){
|
|
91
|
+
const touched=await stat(base+'.events').then(s=>s.mtimeMs,()=>0)
|
|
92
|
+
if(Date.now()-touched<15000)continue
|
|
93
|
+
}
|
|
94
|
+
if (!prior?.containerId && prior?.hostPid && prior.hostStarted && await processStart(prior.hostPid)===prior.hostStarted) continue
|
|
95
|
+
if (!prior?.containerId && prior?.pid && await processStart(prior.pid)) {
|
|
96
|
+
if (!prior.hostPid) throw Error('Previous host CLI is still running; stop it before recovery')
|
|
97
|
+
if (prior.started && await processStart(prior.pid)===prior.started) terminateJob({pid:prior.pid,exitCode:null,signalCode:null} as ChildProcess)
|
|
98
|
+
}
|
|
99
|
+
await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
|
|
100
|
+
await rm(base+'.process.json',{force:true});await rm(base+'.running.json',{force:true})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
80
103
|
try {
|
|
81
104
|
for (const agent of installation.agents) {
|
|
82
105
|
if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
|
|
@@ -89,7 +112,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
89
112
|
if (!Array.isArray(agent.additionalWorkspaces) || agent.additionalWorkspaces.some(workspace=>typeof workspace!=='string' || !path.isAbsolute(workspace))) throw new Error('Additional workspaces require absolute paths')
|
|
90
113
|
additionalWorkspaces.set(agent,await Promise.all(agent.additionalWorkspaces.map(workspace=>realpath(workspace))))
|
|
91
114
|
}
|
|
92
|
-
if (agent.toolsHome) {
|
|
115
|
+
if (agent.toolsHome && !containerId) {
|
|
93
116
|
if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
|
|
94
117
|
const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
|
|
95
118
|
if (config.schemaVersion!==1 || config.workspace!==await realpath(agent.workspace)) throw new Error('Plugin registry belongs to another workspace')
|
|
@@ -99,6 +122,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
99
122
|
const lock=path.join(directory,'worker.lock')
|
|
100
123
|
try {
|
|
101
124
|
const prior=JSON.parse(await readFile(lock,'utf8'))
|
|
125
|
+
if(prior.containerId&&prior.containerId!==containerId)throw Error('Previous isolated worker still owns the claim lock')
|
|
102
126
|
const currentStart=await processStart(prior.pid)
|
|
103
127
|
if (!prior.started || !currentStart || prior.started===currentStart) {
|
|
104
128
|
try { process.kill(prior.pid,0); throw new Error('Host executor already running') }
|
|
@@ -107,34 +131,29 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
107
131
|
await rm(lock)
|
|
108
132
|
}
|
|
109
133
|
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
110
|
-
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid)}),{mode:0o600,flag:'wx'})
|
|
134
|
+
await writeFile(lock,JSON.stringify({pid:process.pid,started:await processStart(process.pid),...(containerId ? {containerId} : {})}),{mode:0o600,flag:'wx'})
|
|
111
135
|
locks.push(lock)
|
|
112
136
|
const models = await freshCatalog(agent)
|
|
113
137
|
await new ControlStore(agent.controlDir, 900000).normalizeProviderBindings(models)
|
|
114
138
|
const modelsFile=path.join(directory,'models.json')
|
|
115
139
|
await writeFile(modelsFile+'.tmp',JSON.stringify(models),{mode:0o600});await rename(modelsFile+'.tmp',modelsFile)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const base=path.join(directory,file.slice(0,-13))
|
|
119
|
-
try {
|
|
120
|
-
const prior=JSON.parse(await readFile(base+'.process.json','utf8'))
|
|
121
|
-
try { process.kill(prior.pid,0); throw new Error('Previous host CLI is still running; stop it before recovery') }
|
|
122
|
-
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
123
|
-
} catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
124
|
-
await rm(base+'.process.json',{force:true})
|
|
125
|
-
await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
|
|
126
|
-
await rm(path.join(directory,file))
|
|
127
|
-
}
|
|
128
|
-
if (agent.toolsHome) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
|
|
140
|
+
await recoverClaims(agent)
|
|
141
|
+
if (agent.toolsHome && !containerId) await (await import('./plugins/workspace-lease.mjs')).recoverNativeLease(agent.toolsHome)
|
|
129
142
|
}
|
|
130
143
|
let catalogAt=Date.now()
|
|
131
144
|
const catalogSeen=new Map<HostBinding,string>()
|
|
132
145
|
while (!signal.aborted) {
|
|
133
146
|
const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
|
|
134
|
-
if(parent) { try { process.kill(parent,0) } catch { break } }
|
|
147
|
+
if(parent && !retiring) { try { process.kill(parent,0) } catch { break } }
|
|
148
|
+
if (retire?.aborted && !retiring) {
|
|
149
|
+
retiring=true
|
|
150
|
+
await catalogRefresh
|
|
151
|
+
for (const lock of locks.splice(0)) await rm(lock,{force:true})
|
|
152
|
+
}
|
|
153
|
+
if (retiring && !tasks.size) break
|
|
135
154
|
// Keep transport liveness independent of native catalog I/O.
|
|
136
|
-
for (const agent of installation.agents) await beat(agent)
|
|
137
|
-
if(!catalogRefresh && Date.now()-catalogAt>30000){
|
|
155
|
+
if (!retiring) for (const agent of installation.agents) await beat(agent)
|
|
156
|
+
if(!retiring && !catalogRefresh && Date.now()-catalogAt>30000){
|
|
138
157
|
catalogAt=Date.now()
|
|
139
158
|
catalogRefresh=(async()=>{
|
|
140
159
|
for(const agent of installation.agents){
|
|
@@ -149,6 +168,8 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
149
168
|
}
|
|
150
169
|
for (const agent of installation.agents) {
|
|
151
170
|
const directory=path.join(agent.controlDir,'host-executor')
|
|
171
|
+
if (retiring) continue
|
|
172
|
+
await recoverClaims(agent)
|
|
152
173
|
for (const file of await readdir(directory)) {
|
|
153
174
|
if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
|
|
154
175
|
const id=file.slice(0,-13)
|
|
@@ -168,9 +189,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
168
189
|
let workspace = activeWorkspaces.get(agent)
|
|
169
190
|
if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
|
|
170
191
|
if (!workspace) {
|
|
171
|
-
const release = agent.toolsHome
|
|
192
|
+
const release = agent.toolsHome && !containerId
|
|
172
193
|
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
173
|
-
if (agent.toolsHome && !release) continue
|
|
194
|
+
if (agent.toolsHome && !containerId && !release) continue
|
|
174
195
|
workspace = { lanes: new Map(), release }
|
|
175
196
|
activeWorkspaces.set(agent, workspace)
|
|
176
197
|
}
|
|
@@ -184,7 +205,11 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
184
205
|
await workspace.release?.()
|
|
185
206
|
}
|
|
186
207
|
}
|
|
187
|
-
try {
|
|
208
|
+
try {
|
|
209
|
+
await writeFile(base+'.process.json',JSON.stringify({hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
|
|
210
|
+
await appendFile(base+'.events','',{mode:0o600})
|
|
211
|
+
await rename(base+'.request.json',base+'.running.json')
|
|
212
|
+
}
|
|
188
213
|
catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
|
|
189
214
|
const task=(async()=>{
|
|
190
215
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
@@ -205,7 +230,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
205
230
|
// Script occurrences come from the authorized run record, never the request.
|
|
206
231
|
if (run?.script) {
|
|
207
232
|
if (!run.scheduled) throw new Error('Script runs require a scheduled occurrence')
|
|
208
|
-
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
|
|
233
|
+
const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),
|
|
209
234
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
210
235
|
script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
|
|
211
236
|
job=await launch(request.texts,options)
|
|
@@ -237,14 +262,18 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
237
262
|
// transient native-client probe failure must not reject a model
|
|
238
263
|
// that the host just advertised to the relay and application.
|
|
239
264
|
if (cli !== installation.cli || opts.authProfile !== undefined) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,authProfile:opts.authProfile,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
240
|
-
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
265
|
+
const options:ExecutorOptions={catalogTransport:containerId?'local':'host',workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
241
266
|
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
242
267
|
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
243
268
|
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
244
269
|
job=await launch(request.texts,options)
|
|
245
270
|
}
|
|
246
271
|
active.set(base,job.child)
|
|
247
|
-
|
|
272
|
+
// Observe completion before process metadata I/O: a fast CLI may
|
|
273
|
+
// already have exited when launch returns, or while that I/O waits.
|
|
274
|
+
const closed=job.child.exitCode!==null||job.child.signalCode!==null
|
|
275
|
+
? Promise.resolve(job.child.exitCode??1)
|
|
276
|
+
: new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
|
|
248
277
|
if(signal.aborted)terminateJob(job.child)
|
|
249
278
|
// Scheduled Claude streams every event; forward only the startup summary.
|
|
250
279
|
const startup = observeClaude ? startupObserver() : undefined
|
|
@@ -255,8 +284,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
255
284
|
if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
|
|
256
285
|
})
|
|
257
286
|
job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
|
|
258
|
-
|
|
259
|
-
|
|
287
|
+
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid,started:job.child.pid ? await processStart(job.child.pid) : undefined,hostPid:process.pid,hostStarted,...(containerId ? {containerId} : {})}),{mode:0o600})
|
|
288
|
+
cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{}); const now=new Date();void utimes(base+'.events',now,now).catch(()=>{})},250)
|
|
289
|
+
const code=await closed
|
|
260
290
|
if (cancellation) clearInterval(cancellation)
|
|
261
291
|
await job.cleanup()
|
|
262
292
|
job = undefined
|
|
@@ -290,8 +320,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
290
320
|
}
|
|
291
321
|
|
|
292
322
|
if (process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
|
293
|
-
const abort=new AbortController()
|
|
323
|
+
const abort=new AbortController(),retire=new AbortController()
|
|
324
|
+
process.once('SIGUSR2',()=>retire.abort())
|
|
294
325
|
for(const signal of ['SIGTERM','SIGINT'] as const)process.once(signal,()=>abort.abort())
|
|
295
326
|
const config=JSON.parse(await readFile(process.argv[2],'utf8'))
|
|
296
|
-
await serveHostExecutor(config,abort.signal)
|
|
327
|
+
await serveHostExecutor(config,abort.signal,undefined,undefined,retire.signal)
|
|
297
328
|
}
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { InboxStore, type IncomingItem } from './inbox.js'
|
|
|
27
27
|
import { loadConfig, type Config } from './config.js'
|
|
28
28
|
import { ControlStore, telegramOwner, ownerId, ownerEpoch, type SessionState } from './control-state.js'
|
|
29
29
|
import { ApprovalStore } from './approval.js'
|
|
30
|
-
import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
|
|
30
|
+
import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome, usesFileTransport } from './executor.js'
|
|
31
31
|
import { attemptFailure, continuationLine, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
|
|
32
32
|
import { assertScheduledModel } from './model-policy.js'
|
|
33
33
|
import { RunStore, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
|
|
@@ -96,7 +96,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
96
96
|
let activeBackend = false
|
|
97
97
|
const ownerStopped = new WeakSet<ChildProcess>()
|
|
98
98
|
// Host-transport executors outlive this relay; a graceful stop hands them off.
|
|
99
|
-
const hostTransport =
|
|
99
|
+
const hostTransport = async () => !config.channelBackendUrl && await usesFileTransport(config.controlDir)
|
|
100
100
|
const childRuns = new WeakMap<ChildProcess, string>()
|
|
101
101
|
const handedOff = new WeakSet<ChildProcess>()
|
|
102
102
|
let activeChild: ChildProcess | null = null
|
|
@@ -668,8 +668,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
668
668
|
}
|
|
669
669
|
|
|
670
670
|
let outboxWork: Promise<void> | undefined
|
|
671
|
-
const drainOutbox = (onlyRunId?: string): Promise<void> => {
|
|
672
|
-
if (shuttingDown) return outboxWork ?? Promise.resolve()
|
|
671
|
+
const drainOutbox = (onlyRunId?: string, stopping = false): Promise<void> => {
|
|
672
|
+
if (shuttingDown && !stopping) return outboxWork ?? Promise.resolve()
|
|
673
673
|
return outboxWork ?? (outboxWork = (async () => {
|
|
674
674
|
for (const item of await runs.pendingOutbox()) {
|
|
675
675
|
if (onlyRunId && item.runId !== onlyRunId) continue
|
|
@@ -773,6 +773,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
773
773
|
// children, host executor, CLIs) enqueue and query the memory ledger
|
|
774
774
|
// through the shared handler in delivery-socket.ts. The pump below stays
|
|
775
775
|
// the single delivery executor; the socket only admits work into it.
|
|
776
|
+
let drainingHandoff=false
|
|
776
777
|
let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
|
|
777
778
|
const handleDeliveryOp = createLedgerHandler(config.controlDir, {
|
|
778
779
|
wake: () => { void drainOutbox() },
|
|
@@ -835,7 +836,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
835
836
|
const owner = (await control.status()).owner
|
|
836
837
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
837
838
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
838
|
-
const active = await scheduler.
|
|
839
|
+
const active = await scheduler.listTasksReadOnly(await runs.list())
|
|
839
840
|
return { owner, active, text: scheduledTasksText(active, owner) }
|
|
840
841
|
}
|
|
841
842
|
const replyScheduledTasks = async (ctx: Context, number?: number) => {
|
|
@@ -892,6 +893,18 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
892
893
|
: []),
|
|
893
894
|
].join('\n')
|
|
894
895
|
}
|
|
896
|
+
const stopActiveWork = async (): Promise<boolean> => {
|
|
897
|
+
const children: [string | undefined, ChildProcess][] = [...background]
|
|
898
|
+
if (activeChild) children.unshift([undefined, activeChild])
|
|
899
|
+
let requested = true
|
|
900
|
+
for (const [id, child] of children) {
|
|
901
|
+
if (!await terminateJob(child)) { requested = false; continue }
|
|
902
|
+
ownerStopped.add(child)
|
|
903
|
+
if (id) await scheduler.cancel(id)
|
|
904
|
+
}
|
|
905
|
+
return requested
|
|
906
|
+
}
|
|
907
|
+
|
|
895
908
|
const cancelPending = async () => {
|
|
896
909
|
let count = 0
|
|
897
910
|
await withStartLock(async () => {
|
|
@@ -1030,16 +1043,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1030
1043
|
if (text === '/stop') {
|
|
1031
1044
|
const running = await runs.running(false)
|
|
1032
1045
|
if ((running && running.pid) || background.size) {
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
} catch {}
|
|
1037
|
-
if (activeTypingTimer) {
|
|
1046
|
+
let requested = false
|
|
1047
|
+
try { requested = await stopActiveWork() } catch {}
|
|
1048
|
+
if (requested && activeTypingTimer) {
|
|
1038
1049
|
clearInterval(activeTypingTimer)
|
|
1039
1050
|
activeTypingTimer = null
|
|
1040
1051
|
}
|
|
1041
1052
|
await ctx.reply(
|
|
1042
|
-
'🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
|
|
1053
|
+
requested ? '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
|
|
1054
|
+
: 'A stop request failed; some work remains active.',
|
|
1043
1055
|
)
|
|
1044
1056
|
} else {
|
|
1045
1057
|
await ctx.reply('No active run in progress.')
|
|
@@ -1262,11 +1274,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1262
1274
|
} else if (action === 'stop') {
|
|
1263
1275
|
const running = await runs.running(false)
|
|
1264
1276
|
if ((running && running.pid) || background.size) {
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
await ctx.answerCallbackQuery({ text: '
|
|
1277
|
+
let requested = false
|
|
1278
|
+
try { requested = await stopActiveWork() } catch {}
|
|
1279
|
+
await ctx.answerCallbackQuery({ text: requested ? 'Stop requested' : 'Stop request failed' })
|
|
1268
1280
|
await ctx.reply(
|
|
1269
|
-
'🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
|
|
1281
|
+
requested ? '🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.'
|
|
1282
|
+
: 'A stop request failed; some work remains active.',
|
|
1270
1283
|
)
|
|
1271
1284
|
} else {
|
|
1272
1285
|
await ctx.answerCallbackQuery({ text: 'No run active' })
|
|
@@ -1288,7 +1301,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1288
1301
|
// unknown outcome is recorded interrupted, which holds its schedule.
|
|
1289
1302
|
const adoptHandoff = async (): Promise<void> => {
|
|
1290
1303
|
const handed = await takeRunHandoff(config.controlDir)
|
|
1291
|
-
if (!hostTransport) { if (handed.length) console.error('Ignoring run handoff without host transport', handed.length); return }
|
|
1304
|
+
if (!await hostTransport()) { if (handed.length) console.error('Ignoring run handoff without host transport', handed.length); return }
|
|
1292
1305
|
// Admit every record before the first attach, so no trigger races a hold.
|
|
1293
1306
|
const admitted = (await Promise.all(handed.map(run => runs.adopt(run).catch(() => null)))).filter((run): run is RunRecord => run !== null)
|
|
1294
1307
|
for (const run of admitted) await withStartLock(async () => {
|
|
@@ -1353,21 +1366,25 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1353
1366
|
shuttingDown = true
|
|
1354
1367
|
runtimeStarted = false
|
|
1355
1368
|
pollingAbort.abort()
|
|
1369
|
+
let handedRecords:RunRecord[]=[]
|
|
1356
1370
|
// Hand off running host work before any wait can outlast the stop grace
|
|
1357
1371
|
// period. The lock lets an in-flight launch register; later ones see shutdown.
|
|
1358
|
-
if (hostTransport) await withStartLock(async () => {
|
|
1372
|
+
if (await hostTransport()) await withStartLock(async () => {
|
|
1359
1373
|
const handoff = new Map<ChildProcess, RunRecord>()
|
|
1360
1374
|
for (const child of [...(activeChild ? [activeChild] : []), ...background.values()]) {
|
|
1361
1375
|
const run = childRuns.has(child) ? await runs.get(childRuns.get(child)!) : null
|
|
1362
1376
|
if (run?.status === 'running' && !ownerStopped.has(child) && child.exitCode === null && child.signalCode === null) handoff.set(child, run)
|
|
1363
1377
|
}
|
|
1364
1378
|
if (!handoff.size) return
|
|
1365
|
-
try { await saveRunHandoff(config.controlDir,
|
|
1379
|
+
try { handedRecords=[...handoff.values()];await saveRunHandoff(config.controlDir, handedRecords) }
|
|
1366
1380
|
catch (error) { console.error('Run handoff failed; stopping host work', safeError(error)); return }
|
|
1367
1381
|
// SIGKILL leaves no cancel marker, so the host keeps each executor running.
|
|
1368
1382
|
for (const child of handoff.keys()) { handedOff.add(child); child.kill('SIGKILL') }
|
|
1369
1383
|
})
|
|
1370
|
-
|
|
1384
|
+
drainingHandoff=handedRecords.length>0
|
|
1385
|
+
if(deliveryServer)await deliveryServer.stop()
|
|
1386
|
+
else if(drainingHandoff){await outboxWork?.catch(() => {});await drainOutbox(undefined,true)}
|
|
1387
|
+
if(handedRecords.length)await saveRunHandoff(config.controlDir,handedRecords)
|
|
1371
1388
|
deliveryServer = null
|
|
1372
1389
|
await applicationChannel.stop()
|
|
1373
1390
|
wakePollRetry?.()
|
|
@@ -1402,6 +1419,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1402
1419
|
throw new Error(`Another relay owns ${config.controlDir}; stop it before starting a second one`)
|
|
1403
1420
|
const deliveryTcpPort = Number(process.env.EZ_DELIVERY_TCP_PORT ?? '')
|
|
1404
1421
|
deliveryServer = await serveDeliverySocket(config.controlDir, handleDeliveryOp, {
|
|
1422
|
+
beforeClose:async()=>{if(drainingHandoff){await outboxWork?.catch(()=>{});await drainOutbox(undefined,true)}},
|
|
1405
1423
|
...(Number.isSafeInteger(deliveryTcpPort) && deliveryTcpPort >= 1024 && deliveryTcpPort <= 65535 ? { tcpPort: deliveryTcpPort } : {}),
|
|
1406
1424
|
})
|
|
1407
1425
|
// Reconcile, then adopt handed-off runs, before any intake can launch work.
|
|
@@ -23,7 +23,7 @@ export async function recoverNativeLease(home) {
|
|
|
23
23
|
export async function workspaceLease(home,owner={kind:'plugin'}) {
|
|
24
24
|
const file=path.join(home,'workspace-writer.lock'),temp=file+'.'+randomUUID()+'.tmp';
|
|
25
25
|
await fs.writeFile(temp,JSON.stringify({...owner,pid:process.pid}),{flag:'wx',mode:0o600});
|
|
26
|
-
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');return undefined;}
|
|
26
|
+
try {await fs.link(temp,file);}catch(error){if(error.code!=='EEXIST')throw error;let prior;try {prior=await leaseOwner(file);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}if(!prior.alive)throw Error('Stale workspace-writer.lock: inspect stopped owner and command containers, then restart for recovery');if(owner.kind==='native'&&prior.owner.kind==='native')return async()=>{};return undefined;}
|
|
27
27
|
finally {await fs.rm(temp);}
|
|
28
28
|
return async()=>{await fs.rm(file);};
|
|
29
29
|
}
|
package/src/runs.ts
CHANGED
|
@@ -162,7 +162,9 @@ const handedRun = (value: unknown): value is RunRecord => {
|
|
|
162
162
|
|
|
163
163
|
export const saveRunHandoff = async (controlDir: string, records: RunRecord[]): Promise<void> => {
|
|
164
164
|
const file = handoffFile(controlDir), temporary = `${file}.${process.pid}.tmp`
|
|
165
|
-
|
|
165
|
+
const ids=new Set(records.map(run=>run.id))
|
|
166
|
+
const receipts=[...outboxFor(controlDir).values()].filter(item=>item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))
|
|
167
|
+
await writeFile(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), runs: records, receipts }), { mode: 0o600 })
|
|
166
168
|
await rename(temporary, file)
|
|
167
169
|
}
|
|
168
170
|
|
|
@@ -176,12 +178,16 @@ export const takeRunHandoff = async (controlDir: string, now = Date.now()): Prom
|
|
|
176
178
|
try { raw = await readFile(handoffFile(controlDir), 'utf8') }
|
|
177
179
|
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error }
|
|
178
180
|
await rm(handoffFile(controlDir))
|
|
179
|
-
const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown }
|
|
181
|
+
const value = JSON.parse(raw) as { version?: unknown; writtenAt?: unknown; runs?: unknown; receipts?: unknown }
|
|
180
182
|
const age = now - Date.parse(typeof value.writtenAt === 'string' ? value.writtenAt : '')
|
|
181
183
|
if (value.version !== 1 || !(age >= -60_000 && age <= HANDOFF_MAX_AGE_MS)) throw new Error('Invalid or stale run handoff')
|
|
182
184
|
if (!Array.isArray(value.runs) || value.runs.length > 100) throw new Error('Invalid run handoff')
|
|
183
185
|
const valid = value.runs.filter(handedRun)
|
|
184
186
|
if (valid.length !== value.runs.length) console.error('Ignoring invalid handed-off runs', value.runs.length - valid.length)
|
|
187
|
+
const ids=new Set(valid.map(run=>run.id))
|
|
188
|
+
if(Array.isArray(value.receipts)&&value.receipts.length<=MAX_TERMINAL_OUTBOX){
|
|
189
|
+
for(const item of value.receipts as StoredOutboxItem[])if(item&&typeof item.id==='string'&&/^[a-zA-Z0-9_-]+$/.test(item.id)&&item.runId&&ids.has(item.runId)&&(item.state==='sent'||item.state==='failed'))outboxFor(controlDir).set(item.id,item)
|
|
190
|
+
}
|
|
185
191
|
return [...new Map(valid.map(run => [run.id, run])).values()]
|
|
186
192
|
} catch (error) {
|
|
187
193
|
console.error('Ignoring unreadable run handoff', error instanceof Error ? error.message : 'Unknown error')
|
package/src/schedule-cli.ts
CHANGED
|
@@ -83,7 +83,8 @@ The run records each attempt, setup, failure category and any reported reset tim
|
|
|
83
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.
|
|
84
84
|
Creates a scheduled task. Instructions are text, never shell commands.
|
|
85
85
|
Use --now to run once. Run completion is not delivery proof.
|
|
86
|
-
Edit replaces the full schedule
|
|
86
|
+
Edit replaces the full schedule but keeps its paused/enabled state; only pause/resume toggle it.
|
|
87
|
+
Pause/remove affect future work; cancel stops a particular run.
|
|
87
88
|
Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/weekday OR semantics.`);return}
|
|
88
89
|
const config=loadControlConfig(), control=new ControlStore(config.controlDir,config.pairingTtlMs)
|
|
89
90
|
const owner=(await control.status()).owner
|
|
@@ -195,13 +196,15 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
195
196
|
v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
|
|
196
197
|
if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
|
|
197
198
|
const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
|
|
199
|
+
// Edit keeps the paused/enabled state; only pause/resume toggle it.
|
|
200
|
+
const enabled = previousSchedule?.enabled ?? true
|
|
198
201
|
const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
|
|
199
202
|
const origin = caller?.application ?? caller?.delivery
|
|
200
203
|
const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
|
|
201
204
|
if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
|
|
202
205
|
if (v.script) {
|
|
203
206
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
|
|
204
|
-
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled
|
|
207
|
+
originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled,owner,
|
|
205
208
|
script:{id:v.script,args:v.arg ?? []}},action==='create'))
|
|
206
209
|
console.log(JSON.stringify(result,null,2));return
|
|
207
210
|
}
|
|
@@ -216,7 +219,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
|
|
|
216
219
|
if (setup.authProfile !== undefined) await provisionedHome(config.controlDir, setup.cli, setup.authProfile)
|
|
217
220
|
result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
|
|
218
221
|
preflight,
|
|
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
|
|
222
|
+
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,owner,
|
|
220
223
|
execution:{sessionId:previous?.sessionId || randomUUID(),preset,...(fallbacks ? {fallbacks} : {})}},action==='create'))
|
|
221
224
|
}else{
|
|
222
225
|
if(!id)throw new Error('ID required')
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { executionDefaults } from './model-policy.js'
|
|
|
2
2
|
import { presetLabel, type AiPreset } from './ai.js'
|
|
3
3
|
import { AUTH_PROFILE_CLIS } from './auth-profile.js'
|
|
4
4
|
import type { Owner } from './control-state.js'
|
|
5
|
-
import type { Schedule,
|
|
5
|
+
import type { Schedule, ScheduledTaskView } from './scheduler.js'
|
|
6
6
|
|
|
7
7
|
const ownsSchedule = (owner: Owner, schedule: Schedule) =>
|
|
8
8
|
schedule.owner.telegramUserId === owner.telegramUserId &&
|
|
@@ -24,7 +24,7 @@ const setupLabel = (preset: AiPreset) => {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Script schedules have no AI preset; show what core actually invokes.
|
|
27
|
-
const engineLabel = (schedule:
|
|
27
|
+
const engineLabel = (schedule: ScheduledTaskView) => schedule.script
|
|
28
28
|
? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
|
|
29
29
|
: [schedule.execution!.preset, ...schedule.execution!.fallbacks ?? []].map(setupLabel).join(' → ')
|
|
30
30
|
|
|
@@ -67,28 +67,30 @@ const preview = (schedule: Schedule) => {
|
|
|
67
67
|
return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const currentState = (schedule:
|
|
71
|
-
schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' :
|
|
70
|
+
const currentState = (schedule: ScheduledTaskView) =>
|
|
71
|
+
[schedule.runState === 'running' ? '▶ Running' : schedule.runState === 'queued' ? '◌ Queued' : undefined,
|
|
72
|
+
!schedule.enabled ? '⏸ Paused' : undefined].filter(Boolean).join(' · ') || '● Active'
|
|
72
73
|
|
|
73
|
-
const lastRun = (schedule:
|
|
74
|
+
const lastRun = (schedule: ScheduledTaskView) => {
|
|
74
75
|
if (!schedule.lastRun) return '— Not run yet'
|
|
75
76
|
const status = schedule.lastRun.status === 'completed' ? '✓ Completed' : schedule.lastRun.status === 'failed' ? '✕ Failed' : '⊘ Cancelled'
|
|
76
77
|
return `${status} · ${time(schedule.lastRun.at)}${schedule.lastRun.currentRevision ? '' : ' · previous version'}`
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
export const ownedScheduledTasks = (schedules:
|
|
80
|
+
export const ownedScheduledTasks = (schedules: ScheduledTaskView[], owner: Owner) =>
|
|
80
81
|
schedules.filter((schedule) => ownsSchedule(owner, schedule))
|
|
81
82
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
82
83
|
|
|
83
|
-
export const scheduledTasksText = (schedules:
|
|
84
|
+
export const scheduledTasksText = (schedules: ScheduledTaskView[], owner: Owner) => {
|
|
84
85
|
const owned = ownedScheduledTasks(schedules, owner)
|
|
85
|
-
if (!owned.length) return '📅 Scheduled tasks\n\nNo
|
|
86
|
-
|
|
86
|
+
if (!owned.length) return '📅 Scheduled tasks\n\nNo scheduled tasks.'
|
|
87
|
+
const paused = owned.filter(schedule => !schedule.enabled).length
|
|
88
|
+
return [`📅 Scheduled tasks · ${owned.length - paused} active · ${paused} paused`, 'Next/last times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
|
|
87
89
|
`${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)}`
|
|
88
90
|
)].join('\n\n')
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
export const scheduledTaskDetailText = (schedule:
|
|
93
|
+
export const scheduledTaskDetailText = (schedule: ScheduledTaskView, number: number, total?: number) => {
|
|
92
94
|
const instructions = schedule.script ? preview(schedule) : schedule.text.trim()
|
|
93
95
|
const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
|
|
94
96
|
return [
|
package/src/scheduler.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type ScheduledRunSummary = {
|
|
|
29
29
|
at: string
|
|
30
30
|
currentRevision: boolean
|
|
31
31
|
}
|
|
32
|
-
export type
|
|
32
|
+
export type ScheduledTaskView = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
|
|
33
33
|
export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
|
|
34
34
|
export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
|
|
35
35
|
const s = v as ScheduledOrigin
|
|
@@ -102,10 +102,9 @@ export class Scheduler {
|
|
|
102
102
|
return nextOccurrence(s.trigger,-1)
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
async
|
|
106
|
-
const active:
|
|
105
|
+
async listTasksReadOnly(runs: RunRecord[]): Promise<ScheduledTaskView[]> {
|
|
106
|
+
const active: ScheduledTaskView[] = []
|
|
107
107
|
for (const s of await this.listReadOnly()) {
|
|
108
|
-
if (!s.enabled) continue
|
|
109
108
|
const current = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.revision === s.revision && ownsRun(s.owner,r))
|
|
110
109
|
const history = runs.filter(r => r.scheduled?.id === s.id && r.scheduled.pairedAt === s.owner.pairedAt && ownsRun(s.owner,r))
|
|
111
110
|
.filter((r): r is RunRecord & { status: ScheduledRunSummary['status'] } =>
|
|
@@ -113,11 +112,11 @@ export class Scheduler {
|
|
|
113
112
|
.sort((a,b) => (b.endedAt ?? b.startedAt ?? b.createdAt).localeCompare(a.endedAt ?? a.startedAt ?? a.createdAt))
|
|
114
113
|
const last = history[0]
|
|
115
114
|
const runState = current.some(r => r.status === 'running') ? 'running' : current.some(r => r.status === 'queued') ? 'queued' : undefined
|
|
116
|
-
if (!runState && current.some(r => holdsSchedule(s,r))) continue
|
|
115
|
+
if (s.enabled && !runState && current.some(r => holdsSchedule(s,r))) continue
|
|
117
116
|
try {
|
|
118
|
-
const nextAt = await this.pendingOccurrence(s)
|
|
117
|
+
const nextAt = s.enabled ? await this.pendingOccurrence(s) : null
|
|
119
118
|
const scriptRevision = s.script ? await new Scripts(this.controlDir).get(s.script.id).then(r => r.revision, () => undefined) : undefined
|
|
120
|
-
if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
|
|
119
|
+
if (!s.enabled || runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
|
|
121
120
|
lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
|
|
122
121
|
} : {})})
|
|
123
122
|
}
|