@jc_stack/ez-agents 0.1.0-beta.52 → 0.1.0-beta.58
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/README.md +4 -1
- package/docs/architecture/ai-selection.md +6 -2
- package/docs/docker-runtime.md +48 -0
- package/docs/scheduling.md +124 -16
- package/docs/upgrades.md +9 -4
- package/package.json +1 -1
- package/src/ai-cli.ts +31 -5
- package/src/ai.ts +17 -1
- package/src/codex-session.ts +23 -2
- package/src/control-state.ts +173 -8
- package/src/delivery-socket.ts +18 -1
- package/src/executor.ts +11 -4
- package/src/host-executor.ts +17 -6
- package/src/index.ts +358 -269
- package/src/run-telemetry.ts +163 -0
- package/src/running-work.ts +123 -0
- package/src/runs.ts +7 -2
- package/src/schedule-cli.ts +55 -16
- package/src/scheduler.ts +126 -29
- package/src/setup-fallback.ts +57 -18
- package/src/updates/supervisor.mjs +23 -8
- package/test/chat-fallback.test.ts +358 -0
- package/test/codex-session.test.ts +1 -1
- package/test/control-state.test.ts +62 -0
- package/test/executor.test.ts +2 -1
- package/test/host-executor.test.ts +41 -0
- package/test/run-handoff.test.ts +54 -0
- package/test/running-work.test.ts +175 -0
- package/test/schedule-cli.test.ts +55 -6
- package/test/scheduler.test.ts +128 -1
- package/test/script-schedules.test.ts +3 -3
- package/test/setup-fallback.test.ts +211 -14
- package/test/updates.test.mjs +25 -4
- package/test/upgrade-pause.test.ts +65 -2
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { dispatchChannel } from './channel-backend.js'
|
|
|
11
11
|
import { randomUUID } from 'node:crypto'
|
|
12
12
|
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
|
13
13
|
import { Scheduler } from './scheduler.js'
|
|
14
|
-
import { Scripts
|
|
14
|
+
import { Scripts } from './scripts.js'
|
|
15
15
|
import { redactFailure } from './failure.js'
|
|
16
16
|
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
17
17
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
@@ -25,12 +25,13 @@ import { isOwner, ownsRun } from './identity.js'
|
|
|
25
25
|
import type { Update } from 'grammy/types'
|
|
26
26
|
import { InboxStore, type IncomingItem } from './inbox.js'
|
|
27
27
|
import { loadConfig, type Config } from './config.js'
|
|
28
|
-
import { ControlStore, telegramOwner, ownerId, ownerEpoch, type SessionState } from './control-state.js'
|
|
28
|
+
import { ControlStore, telegramOwner, ownerId, ownerEpoch, type Owner, type SessionState } from './control-state.js'
|
|
29
29
|
import { ApprovalStore } from './approval.js'
|
|
30
30
|
import { CliUnavailableError, startExecutorJob, attachHostJob, terminateJob, opencodeDataHome, claudeConfigHome, usesFileTransport } from './executor.js'
|
|
31
|
-
import { attemptFailure, continuationLine, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
|
|
31
|
+
import { attemptFailure, continuationLine, nextSetup, quotaScope, quotaWait, quotaCategory, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
|
|
32
|
+
import { telemetryObserver } from './run-telemetry.js'
|
|
32
33
|
import { assertScheduledModel } from './model-policy.js'
|
|
33
|
-
import { RunStore, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
|
|
34
|
+
import { RunStore, activeSetup, saveRunHandoff, takeRunHandoff, type RunRecord } from './runs.js'
|
|
34
35
|
import { splitTelegramText } from './reply.js'
|
|
35
36
|
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
36
37
|
import { sanitizeFileName, stageChatAttachment, workspaceFile } from './files.js'
|
|
@@ -95,6 +96,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
95
96
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
96
97
|
let activeBackend = false
|
|
97
98
|
const ownerStopped = new WeakSet<ChildProcess>()
|
|
99
|
+
// Runs the owner stopped; an ordered run never advances to its next setup.
|
|
100
|
+
const stoppedRuns = new Set<string>()
|
|
98
101
|
// Host-transport executors outlive this relay; a graceful stop hands them off.
|
|
99
102
|
const hostTransport = async () => !config.channelBackendUrl && await usesFileTransport(config.controlDir)
|
|
100
103
|
const childRuns = new WeakMap<ChildProcess, string>()
|
|
@@ -111,7 +114,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
111
114
|
nextSendAt = Date.now() + 1000
|
|
112
115
|
}
|
|
113
116
|
let startLock: Promise<void> = Promise.resolve()
|
|
114
|
-
const withStartLock = (work: () => Promise<
|
|
117
|
+
const withStartLock = <T>(work: () => Promise<T>): Promise<T> => {
|
|
115
118
|
const next = startLock.then(work, work)
|
|
116
119
|
startLock = next.then(
|
|
117
120
|
() => undefined,
|
|
@@ -150,8 +153,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
150
153
|
const child = background.get(id)
|
|
151
154
|
if (child) { await scheduler.cancel(id); terminateJob(child); return }
|
|
152
155
|
if (!activeChild || (await runs.running(false))?.id !== id) throw new Error('Application cancellation unavailable')
|
|
156
|
+
stoppedRuns.add(id)
|
|
153
157
|
ownerStopped.add(activeChild)
|
|
154
|
-
terminateJob(activeChild)
|
|
158
|
+
void terminateJob(activeChild).then(stopped => { if (!stopped) stoppedRuns.delete(id) })
|
|
155
159
|
}
|
|
156
160
|
}),
|
|
157
161
|
})
|
|
@@ -188,6 +192,17 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
188
192
|
const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat,
|
|
189
193
|
async(chatId,text,bytes,filename)=>[(await bot!.api.sendDocument(chatId,new InputFile(bytes,filename),{caption:text})).message_id]) : null
|
|
190
194
|
|
|
195
|
+
const setupAvailability = async (owner: Owner, runId?: string) => {
|
|
196
|
+
const health = await control.scheduledSetupHealth(owner)
|
|
197
|
+
// Only one waiting occurrence probes a login until structured work is accepted.
|
|
198
|
+
for (const active of await runs.list()) {
|
|
199
|
+
if (active.id === runId || active.status !== 'running' || !active.quotaWait) continue
|
|
200
|
+
const preset = activeSetup(active)
|
|
201
|
+
if (preset) health[quotaScope(preset)] = { ...health[quotaScope(preset)], manualRetry: true }
|
|
202
|
+
}
|
|
203
|
+
return health
|
|
204
|
+
}
|
|
205
|
+
|
|
191
206
|
const startJob = async (run: RunRecord): Promise<void> => {
|
|
192
207
|
await withStartLock(async () => {
|
|
193
208
|
if (shuttingDown || activeBackend) return
|
|
@@ -253,235 +268,326 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
253
268
|
await releaseExternal(run)
|
|
254
269
|
return
|
|
255
270
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
clearInterval(activeTypingTimer)
|
|
260
|
-
activeTypingTimer = null
|
|
261
|
-
}
|
|
262
|
-
if (run.scheduled) background.delete(run.id)
|
|
263
|
-
const unavailable = error instanceof SetupsUnavailableError
|
|
264
|
-
await runs.patch(run.id, { status: 'failed', failureReason: unavailable ? 'setups-unavailable' : 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
265
|
-
await releaseExternal(run)
|
|
266
|
-
console.error('run start failed', run.id, safeError(error))
|
|
267
|
-
if (bot && !run.application && !run.delivery && run.chatId !== undefined)
|
|
268
|
-
await sendChat(run.chatId, unavailable ? `Run ${run.id} could not start. ${safeError(error)}` : `Run ${run.id} failed to start. Check the local relay log.`)
|
|
269
|
-
setImmediate(() => {
|
|
270
|
-
void runs
|
|
271
|
-
.nextQueued()
|
|
272
|
-
.then((next) => next && startJob(next))
|
|
273
|
-
.catch(console.error)
|
|
274
|
-
})
|
|
271
|
+
if (run.scheduled && !run.script) {
|
|
272
|
+
const waiting = quotaWait(setupCandidates(run.execution!), await setupAvailability(owner, run.id))
|
|
273
|
+
if (waiting) { await runs.patch(run.id, { quotaWait: waiting }); return }
|
|
275
274
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
275
|
+
await watchRun(run, texts, owner)
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const cancelUnobservedHostRun = async (id: string): Promise<void> => {
|
|
280
|
+
const base = join(config.controlDir, 'host-executor', id)
|
|
281
|
+
const exists = (suffix: string) => stat(base + suffix).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e })
|
|
282
|
+
if (await exists('.request.json') || await exists('.running.json')) await writeFile(base + '.cancel', '', { mode: 0o600 })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// First launches, adopted host sessions and fallback launches share one watcher.
|
|
286
|
+
const watchRun = async (run: RunRecord, texts: string[], owner: Owner, adopted = false): Promise<void> => {
|
|
287
|
+
// Shared by a first launch and a fallback launch that cannot start.
|
|
288
|
+
const startFailed = async (error: unknown) => {
|
|
289
|
+
if (!run.scheduled && activeTypingTimer) {
|
|
290
|
+
clearInterval(activeTypingTimer)
|
|
291
|
+
activeTypingTimer = null
|
|
292
|
+
}
|
|
293
|
+
if (run.scheduled) background.delete(run.id)
|
|
294
|
+
// A later ordered setup can fail after an earlier attempt held the slot.
|
|
295
|
+
else if (activeChild && childRuns.get(activeChild) === run.id) activeChild = null
|
|
296
|
+
stoppedRuns.delete(run.id)
|
|
297
|
+
const unavailable = error instanceof SetupsUnavailableError
|
|
298
|
+
if (unavailable && run.scheduled && run.execution) {
|
|
299
|
+
const owner = (await control.status()).owner
|
|
300
|
+
if (owner && ownsRun(owner, run) && await scheduler.current(run, owner) && !await scheduler.cancelled(run.id)) {
|
|
301
|
+
const waiting = quotaWait(setupCandidates(run.execution), await setupAvailability(owner, run.id))
|
|
302
|
+
if (waiting) {
|
|
303
|
+
await runs.patch(run.id, { status: 'queued', quotaWait: waiting, pid: undefined })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
298
306
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
307
|
+
}
|
|
308
|
+
await runs.patch(run.id, { status: 'failed', failureReason: unavailable ? 'setups-unavailable' : 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
309
|
+
await releaseExternal(run)
|
|
310
|
+
console.error('run start failed', run.id, safeError(error))
|
|
311
|
+
if (bot && !run.application && !run.delivery && run.chatId !== undefined)
|
|
312
|
+
await sendChat(run.chatId, unavailable ? `Run ${run.id} could not start. ${safeError(error)}` : `Run ${run.id} failed to start. Check the local relay log.`)
|
|
313
|
+
setImmediate(() => {
|
|
314
|
+
void runs
|
|
315
|
+
.nextQueued()
|
|
316
|
+
.then((next) => next && startJob(next))
|
|
317
|
+
.catch(console.error)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const launchStarted = performance.now()
|
|
322
|
+
const started = adopted ? run : await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
323
|
+
// A registered script occurrence: no model, prompt or native session.
|
|
324
|
+
const script = run.script && run.scheduled
|
|
325
|
+
? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
|
|
326
|
+
: undefined
|
|
327
|
+
if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
|
|
328
|
+
if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
329
|
+
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
|
|
330
|
+
const session = startsOwnSession
|
|
331
|
+
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
332
|
+
: await control.executionSession(started.execution!)
|
|
333
|
+
const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : (adopted ? activeSetup(started) : started.execution!.preset)
|
|
334
|
+
if (!selected) throw new Error('Handed-off run has no authorized saved setup')
|
|
335
|
+
if (run.scheduled && !script) assertScheduledModel(selected.model)
|
|
336
|
+
// Owner-enabled telemetry is fixed for the run once it starts.
|
|
337
|
+
const telemetryOn = !script && !run.taskId && (adopted ? Boolean(run.telemetry) : Boolean((await control.status()).runTelemetry))
|
|
338
|
+
if (telemetryOn && !adopted) await runs.patch(run.id, { telemetry: true })
|
|
339
|
+
// A scheduled occurrence, or chat/application work with a captured chain,
|
|
340
|
+
// may name ordered fallback setups. They share this run, workspace and
|
|
341
|
+
// execution slot; each is tried at most once. Telemetry also records attempts.
|
|
342
|
+
const chat = !startsOwnSession
|
|
343
|
+
const ordered = Boolean(!script && !run.taskId && (run.scheduled || started.execution?.fallbacks?.length || telemetryOn))
|
|
344
|
+
const candidates = run.scheduled && !script ? setupCandidates(started.execution!, await control.scheduledSetupHealth(owner))
|
|
345
|
+
: ordered ? [started.execution!.preset, ...started.execution!.fallbacks ?? []] : [selected]
|
|
346
|
+
const runCancelled = async () => stoppedRuns.has(run.id) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
347
|
+
// Authority is rechecked before every later setup.
|
|
348
|
+
const authorized = async (current: Owner) => ownsRun(current, run) && (run.scheduled ? await scheduler.current(run, current)
|
|
349
|
+
: !executionBlockReason(run, current) && (!(run.application || run.delivery) || await applicationChannel.bindings.authorize(run).then(() => true, () => false)))
|
|
350
|
+
let attempts: SetupAttempt[] = [...run.attempts ?? []]
|
|
351
|
+
// Keep audit history and partial-work facts; a wake starts a new attempt round.
|
|
352
|
+
const roundStart = adopted ? run.attemptRound ?? 0 : attempts.length
|
|
353
|
+
if (ordered && !adopted) await runs.patch(run.id, { attemptRound: roundStart })
|
|
354
|
+
const settle = (change: Partial<SetupAttempt>) => {
|
|
355
|
+
attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
|
|
356
|
+
}
|
|
357
|
+
// Advance only after an eligible failure (see attemptFailure) of a current,
|
|
358
|
+
// uncancelled occurrence. Setups in an exhausted scope are skipped and recorded.
|
|
359
|
+
// Owner-present work (chat, application, event sources) never refuses on
|
|
360
|
+
// recorded quota health alone: when every saved login is cooling down, it
|
|
361
|
+
// tries them in order instead.
|
|
362
|
+
let ignoreHealth = false
|
|
363
|
+
const advance = async (): Promise<AiPreset | undefined> => {
|
|
364
|
+
let preset: AiPreset | undefined
|
|
365
|
+
const current = !shuttingDown && !await runCancelled() ? (await control.status()).owner : undefined
|
|
366
|
+
if (current && await authorized(current)) {
|
|
367
|
+
const round = attempts.slice(roundStart)
|
|
368
|
+
let next = nextSetup(candidates, round, ignoreHealth ? {} : await setupAvailability(current, run.id))
|
|
369
|
+
if (!next.preset && !run.scheduled && !round.length && !ignoreHealth) { ignoreHealth = true; next = nextSetup(candidates, round) }
|
|
370
|
+
attempts = [...attempts, ...next.skipped]
|
|
371
|
+
if (!next.preset) {
|
|
372
|
+
await runs.patch(run.id, { attempts })
|
|
373
|
+
// Owner-present work whose setup already began work ends as that
|
|
374
|
+
// run's failure; it did start.
|
|
375
|
+
if (!run.scheduled && round.some(a => a.category?.endsWith('after-work-began'))) return undefined
|
|
376
|
+
throw new SetupsUnavailableError(unavailableSummary(attempts))
|
|
312
377
|
}
|
|
313
|
-
|
|
314
|
-
return preset
|
|
378
|
+
preset = next.preset
|
|
315
379
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
380
|
+
await runs.patch(run.id, { attempts })
|
|
381
|
+
return preset
|
|
382
|
+
}
|
|
383
|
+
// A scheduled attempt is a fresh native session: a rejected Claude attempt
|
|
384
|
+
// already claimed its ID, and a session cannot resume under another login's
|
|
385
|
+
// home. An ordered chat attempt resumes its own login's conversation session.
|
|
386
|
+
type Launched = { child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset; resumed?: boolean }
|
|
387
|
+
const launchSetup = async (preset: AiPreset): Promise<Launched> => {
|
|
388
|
+
for (;;) {
|
|
389
|
+
const setup = preset
|
|
390
|
+
const engine = chat && ordered ? await control.engineSession(session.sessionId, setup) : undefined
|
|
391
|
+
const sessionId = engine ? engine.nativeSessionId || engine.sessionId : attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
|
|
392
|
+
const resumed = engine ? engine.hasStarted : session.hasStarted
|
|
393
|
+
const continuation = continuationLine(started.id, attempts)
|
|
394
|
+
if (ordered) {
|
|
395
|
+
attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', sessionId, startedAt: new Date().toISOString() }]
|
|
396
|
+
await runs.patch(run.id, { attempts })
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
return { ...await launch(continuation ? [...texts, continuation] : texts, script ? {
|
|
400
|
+
workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
|
|
401
|
+
controlDir: config.controlDir, binDir,
|
|
402
|
+
script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
|
|
403
|
+
} : {
|
|
404
|
+
workspace: config.workspace,
|
|
405
|
+
timeoutMs: config.executorTimeoutMs,
|
|
406
|
+
repairEnabled: config.repairEnabled,
|
|
407
|
+
runId: started.id,
|
|
408
|
+
controlDir: config.controlDir,
|
|
409
|
+
binDir,
|
|
410
|
+
cli: preset.cli,
|
|
411
|
+
model: preset.model,
|
|
412
|
+
effort: preset.effort,
|
|
413
|
+
provider: preset.provider,
|
|
414
|
+
authProfile: preset.authProfile,
|
|
415
|
+
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
416
|
+
codexSandbox: !run.taskId && preset.cli === 'codex' ? config.codexSandbox : undefined,
|
|
417
|
+
sessionId,
|
|
418
|
+
isResume: resumed,
|
|
419
|
+
promptSuffix: runPromptSuffix(started),
|
|
420
|
+
taskRun: Boolean(run.taskId),
|
|
421
|
+
nativeSession: Boolean(run.scheduled),
|
|
422
|
+
structuredOutput: ordered,
|
|
423
|
+
telemetry: telemetryOn,
|
|
424
|
+
onSession: run.external || run.taskId ? undefined : async (id) => {
|
|
425
|
+
if (ordered) attempts = attempts.map(a => a.sessionId === sessionId ? { ...a, sessionId: id } : a)
|
|
426
|
+
await runs.patch(run.id, { nativeSessionId: id, ...(ordered ? { attempts } : {}) })
|
|
427
|
+
if (engine) await control.saveEngineSession(session.sessionId, setup, id)
|
|
428
|
+
else if (!run.scheduled) await control.saveNativeSession(session.sessionId, id)
|
|
429
|
+
},
|
|
430
|
+
}), preset, resumed }
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (ordered && !(error instanceof CliUnavailableError)) {
|
|
433
|
+
settle({ outcome: 'failed', category: 'uncertain' })
|
|
324
434
|
await runs.patch(run.id, { attempts })
|
|
325
435
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
} : {
|
|
332
|
-
workspace: config.workspace,
|
|
333
|
-
timeoutMs: config.executorTimeoutMs,
|
|
334
|
-
repairEnabled: config.repairEnabled,
|
|
335
|
-
runId: started.id,
|
|
336
|
-
controlDir: config.controlDir,
|
|
337
|
-
binDir,
|
|
338
|
-
cli: preset.cli,
|
|
339
|
-
model: preset.model,
|
|
340
|
-
effort: preset.effort,
|
|
341
|
-
provider: preset.provider,
|
|
342
|
-
authProfile: preset.authProfile,
|
|
343
|
-
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
344
|
-
codexSandbox: !run.taskId && preset.cli === 'codex' ? config.codexSandbox : undefined,
|
|
345
|
-
sessionId,
|
|
346
|
-
isResume: session.hasStarted,
|
|
347
|
-
promptSuffix: runPromptSuffix(started),
|
|
348
|
-
taskRun: Boolean(run.taskId),
|
|
349
|
-
nativeSession: Boolean(run.scheduled),
|
|
350
|
-
onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
|
|
351
|
-
}), preset }
|
|
352
|
-
} catch (error) {
|
|
353
|
-
if (ordered && !(error instanceof CliUnavailableError)) {
|
|
354
|
-
settle({ outcome: 'failed', category: 'uncertain' })
|
|
355
|
-
await runs.patch(run.id, { attempts })
|
|
356
|
-
}
|
|
357
|
-
if (!ordered || !(error instanceof CliUnavailableError)) throw error
|
|
358
|
-
settle({ outcome: 'failed', category: 'cli-unavailable' })
|
|
359
|
-
const next = await advance()
|
|
360
|
-
if (!next) throw error
|
|
361
|
-
preset = next
|
|
362
|
-
}
|
|
436
|
+
if (!ordered || !(error instanceof CliUnavailableError)) throw error
|
|
437
|
+
settle({ outcome: 'failed', category: 'cli-unavailable' })
|
|
438
|
+
const next = await advance()
|
|
439
|
+
if (!next) throw error
|
|
440
|
+
preset = next
|
|
363
441
|
}
|
|
364
442
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
443
|
+
}
|
|
444
|
+
const watch = async ({ child, cleanup, preset, resumed }: Launched): Promise<void> => {
|
|
445
|
+
const executionStarted = performance.now()
|
|
446
|
+
// Attach before disk writes: a fast child can close while PID persistence
|
|
447
|
+
// is pending, and Node drains its remaining pipes during process close.
|
|
448
|
+
let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
|
|
449
|
+
// Script output is bounded diagnostics for run inspection, never chat.
|
|
450
|
+
if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
|
|
451
|
+
// Structured startup events decide whether a failed setup may advance.
|
|
452
|
+
const startup = ordered ? startupObserver() : undefined
|
|
453
|
+
let probing = Boolean(run.quotaWait)
|
|
454
|
+
// A resumed Codex session reports its session-wide total.
|
|
455
|
+
const telemetry = telemetryOn ? telemetryObserver({ cumulativeSession: Boolean(resumed) && ['codex', 'codex-gui'].includes(preset.cli) }) : undefined
|
|
456
|
+
if (telemetry) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => telemetry.write(chunk))
|
|
457
|
+
if (startup) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
458
|
+
startup.write(chunk)
|
|
459
|
+
if (probing && startup.accepted()) {
|
|
460
|
+
probing = false
|
|
461
|
+
void runs.patch(run.id, { quotaWait: undefined }).catch(console.error)
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, Math.max(0,
|
|
465
|
+
script.registration.timeoutSeconds * 1000 - (adopted ? Date.now() - Date.parse(started.startedAt ?? started.createdAt) : 0))) : undefined
|
|
466
|
+
child.once('close', () => clearTimeout(scriptTimer))
|
|
467
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
468
|
+
errorTail = (errorTail + chunk).slice(-16384)
|
|
469
|
+
if (script) outputTail = (outputTail + chunk).slice(-16384)
|
|
470
|
+
if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
|
|
471
|
+
if (chunk.includes('Host executor client interrupted by')) {
|
|
472
|
+
failureReason = 'host-executor-transport-interrupted'
|
|
473
|
+
interrupted = true
|
|
474
|
+
}
|
|
475
|
+
if (chunk.includes('outcome is unknown')) {
|
|
476
|
+
failureReason = 'handoff-outcome-unknown'
|
|
477
|
+
interrupted = true
|
|
478
|
+
}
|
|
479
|
+
if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
|
|
480
|
+
})
|
|
481
|
+
console.info('run timing', { run_id: run.id, phase: 'launch',
|
|
482
|
+
queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
|
|
483
|
+
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
484
|
+
if (run.scheduled) background.set(run.id,child)
|
|
485
|
+
else activeChild = child
|
|
486
|
+
childRuns.set(child, run.id)
|
|
487
|
+
// A stop that arrived while this setup was launching reached only the
|
|
488
|
+
// previous attempt; stop the new one before it does any work.
|
|
489
|
+
if (await runCancelled()) { ownerStopped.add(child); void terminateJob(child) }
|
|
490
|
+
const finished = new Promise<number | null>((resolve) => {
|
|
491
|
+
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
492
|
+
else child.once('close', resolve)
|
|
493
|
+
})
|
|
494
|
+
await runs.patch(started.id, { pid: child.pid })
|
|
495
|
+
console.info('run started', {
|
|
496
|
+
run_id: started.id,
|
|
497
|
+
pid: child.pid,
|
|
498
|
+
cli: script ? 'script' : preset.cli,
|
|
499
|
+
...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
|
|
500
|
+
...(ordered ? { setup: attempts.length } : {}),
|
|
501
|
+
session: session.sessionId,
|
|
502
|
+
isResume: session.hasStarted,
|
|
503
|
+
})
|
|
407
504
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
505
|
+
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
506
|
+
if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
507
|
+
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
|
|
508
|
+
}, 4000)
|
|
412
509
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
510
|
+
const free = () => {
|
|
511
|
+
if (run.scheduled) background.delete(run.id)
|
|
512
|
+
else {
|
|
513
|
+
activeChild = null
|
|
514
|
+
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
515
|
+
activeTypingTimer = null
|
|
420
516
|
}
|
|
421
|
-
const finalize = async (code: number | null, cancelled: boolean, failed: boolean) => {
|
|
422
|
-
await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
|
|
423
|
-
...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
424
|
-
...(failed ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(timedOut ? `Script exceeded its ${script!.registration.timeoutSeconds}s timeout and was terminated` : errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
425
|
-
await releaseExternal(run)
|
|
426
|
-
}
|
|
427
|
-
const completion = finished.then((code) => {
|
|
428
|
-
console.info('run timing', { run_id: run.id, phase: 'execution',
|
|
429
|
-
execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
|
|
430
|
-
return (async () => {
|
|
431
|
-
let eligible = false
|
|
432
|
-
await withStartLock(async () => {
|
|
433
|
-
try {
|
|
434
|
-
await cleanup()
|
|
435
|
-
// The next relay records a handed-off run's result.
|
|
436
|
-
if (handedOff.has(child)) return
|
|
437
|
-
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
438
|
-
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
439
|
-
if (timedOut) failureReason = 'script-timeout'
|
|
440
|
-
const observed = startup?.result()
|
|
441
|
-
const rejected = observed?.rejection && !observed.workBegan ? observed.rejection : undefined
|
|
442
|
-
// A rejection before work is a failure even when the CLI exits 0.
|
|
443
|
-
const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected))
|
|
444
|
-
if (startup) {
|
|
445
|
-
const { continues, ...failure } = failed ? attemptFailure(observed!) : { continues: false }
|
|
446
|
-
settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code, ...failure })
|
|
447
|
-
await runs.patch(run.id, { attempts })
|
|
448
|
-
eligible = continues && !interrupted
|
|
449
|
-
}
|
|
450
|
-
if (!eligible) await finalize(code, cancelled, failed)
|
|
451
|
-
} catch (error) {
|
|
452
|
-
eligible = false
|
|
453
|
-
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
454
|
-
console.error('Session completion failed', safeError(error))
|
|
455
|
-
} finally {
|
|
456
|
-
// An advancing occurrence keeps its concurrency slot for the next setup.
|
|
457
|
-
if (!eligible) free()
|
|
458
|
-
}
|
|
459
|
-
})
|
|
460
|
-
console.info('run ended', { run_id: started.id, code, ...(eligible ? { advancing: true } : {}) })
|
|
461
|
-
// Re-check cancellation and shutdown under the start lock, so a stop
|
|
462
|
-
// that already snapshotted children never misses the next setup.
|
|
463
|
-
if (eligible) return withStartLock(async () => {
|
|
464
|
-
try {
|
|
465
|
-
const next = await advance()
|
|
466
|
-
if (next) return await watch(await launchSetup(next))
|
|
467
|
-
const cancelled = await scheduler.cancelled(run.id)
|
|
468
|
-
await finalize(code, cancelled, !cancelled)
|
|
469
|
-
free()
|
|
470
|
-
setImmediate(() => { void runs.nextQueued().then(queued => { if (queued && !shuttingDown) return startJob(queued) }).catch(console.error) })
|
|
471
|
-
} catch (error) { await startFailed(error) }
|
|
472
|
-
})
|
|
473
|
-
const next = await runs.nextQueued()
|
|
474
|
-
if (next && !shuttingDown) await startJob(next)
|
|
475
|
-
})().catch((error) => console.error('Run completion failed', error.message))
|
|
476
|
-
})
|
|
477
|
-
completions.add(completion)
|
|
478
|
-
void completion.finally(() => completions.delete(completion))
|
|
479
517
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
518
|
+
const finalize = async (code: number | null, cancelled: boolean, failed: boolean) => {
|
|
519
|
+
stoppedRuns.delete(run.id)
|
|
520
|
+
await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', quotaWait: undefined, endedAt: new Date().toISOString(), exitCode: code,
|
|
521
|
+
...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
522
|
+
...(failed ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(timedOut ? `Script exceeded its ${script!.registration.timeoutSeconds}s timeout and was terminated` : errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
523
|
+
await releaseExternal(run)
|
|
524
|
+
}
|
|
525
|
+
const completion = finished.then((code) => {
|
|
526
|
+
console.info('run timing', { run_id: run.id, phase: 'execution',
|
|
527
|
+
execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
|
|
528
|
+
return (async () => {
|
|
529
|
+
let eligible = false
|
|
530
|
+
await withStartLock(async () => {
|
|
531
|
+
try {
|
|
532
|
+
await cleanup()
|
|
533
|
+
// The next relay records a handed-off run's result.
|
|
534
|
+
if (handedOff.has(child)) return
|
|
535
|
+
const observed = startup?.result()
|
|
536
|
+
if (chat && ordered) await control.settleEngineSession(session.sessionId, preset, code === 0 || Boolean(observed?.workBegan))
|
|
537
|
+
else if (code === 0 && chat) await control.markSessionStarted(session.sessionId)
|
|
538
|
+
const cancelled = !timedOut && (ownerStopped.has(child) || await runCancelled())
|
|
539
|
+
if (timedOut) failureReason = 'script-timeout'
|
|
540
|
+
const rejected = observed?.rejection && !observed.workBegan ? observed.rejection : undefined
|
|
541
|
+
// A rejection before work is a failure even when the CLI exits 0.
|
|
542
|
+
const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected) || quotaCategory(observed?.rejection?.category))
|
|
543
|
+
if (startup) {
|
|
544
|
+
const { continues, ...failure } = failed ? attemptFailure(observed!) : { continues: false }
|
|
545
|
+
settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code, ...failure, ...(telemetry ? { telemetry: telemetry.result() } : {}) })
|
|
546
|
+
if (!cancelled) await control.recordSetupOutcome(owner, attempts.at(-1)!)
|
|
547
|
+
await runs.patch(run.id, { attempts })
|
|
548
|
+
eligible = continues && !interrupted
|
|
549
|
+
}
|
|
550
|
+
if (!eligible) await finalize(code, cancelled, failed)
|
|
551
|
+
} catch (error) {
|
|
552
|
+
eligible = false
|
|
553
|
+
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
554
|
+
console.error('Session completion failed', safeError(error))
|
|
555
|
+
} finally {
|
|
556
|
+
// An advancing occurrence keeps its concurrency slot for the next setup.
|
|
557
|
+
if (!eligible) free()
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
console.info('run ended', { run_id: started.id, code, ...(eligible ? { advancing: true } : {}) })
|
|
561
|
+
// Re-check cancellation and shutdown under the start lock, so a stop
|
|
562
|
+
// that already snapshotted children never misses the next setup.
|
|
563
|
+
if (eligible) return withStartLock(async () => {
|
|
564
|
+
try {
|
|
565
|
+
const next = await advance()
|
|
566
|
+
if (next) return await watch(await launchSetup(next))
|
|
567
|
+
const cancelled = await runCancelled()
|
|
568
|
+
await finalize(code, cancelled, !cancelled)
|
|
569
|
+
free()
|
|
570
|
+
setImmediate(() => { void runs.nextQueued().then(queued => { if (queued && !shuttingDown) return startJob(queued) }).catch(console.error) })
|
|
571
|
+
} catch (error) { await startFailed(error) }
|
|
572
|
+
})
|
|
573
|
+
const next = await runs.nextQueued()
|
|
574
|
+
if (next && !shuttingDown) await startJob(next)
|
|
575
|
+
})().catch((error) => console.error('Run completion failed', error.message))
|
|
576
|
+
})
|
|
577
|
+
completions.add(completion)
|
|
578
|
+
void completion.finally(() => completions.delete(completion))
|
|
483
579
|
}
|
|
484
|
-
|
|
580
|
+
const first = ordered && !adopted ? await advance() : selected
|
|
581
|
+
if (!first) throw new Error('Scheduled execution authority revoked')
|
|
582
|
+
await watch(adopted ? { child: await attachHostJob(config.controlDir, run.id), cleanup: async () => {}, preset: selected } : await launchSetup(first))
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (adopted) {
|
|
585
|
+
await cancelUnobservedHostRun(run.id).catch(() => {})
|
|
586
|
+
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString(), interrupted: true, failureReason: 'handoff-outcome-unknown', failure: await failureEvidence(config.controlDir, `Handed-off run outcome is unknown: ${safeError(error)}`) })
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
await startFailed(error)
|
|
590
|
+
}
|
|
485
591
|
}
|
|
486
592
|
|
|
487
593
|
const unavailableSources = new Set<string>()
|
|
@@ -775,7 +881,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
775
881
|
// the single delivery executor; the socket only admits work into it.
|
|
776
882
|
let drainingHandoff=false
|
|
777
883
|
let deliveryServer: { socketPath: string; stop: () => Promise<void> } | null = null
|
|
778
|
-
const
|
|
884
|
+
const ledgerHandler = createLedgerHandler(config.controlDir, {
|
|
779
885
|
wake: () => { void drainOutbox() },
|
|
780
886
|
status: () => ({
|
|
781
887
|
polling: Boolean(bot?.isRunning()),
|
|
@@ -784,6 +890,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
784
890
|
version: packageVersion,
|
|
785
891
|
}),
|
|
786
892
|
})
|
|
893
|
+
// Read idle status only after admissions already inside startJob finish.
|
|
894
|
+
// The updater closes admission before asking for this barrier.
|
|
895
|
+
const handleDeliveryOp: typeof ledgerHandler = op => ['status', 'retryScheduleQuota'].includes(op.op)
|
|
896
|
+
? withStartLock(() => ledgerHandler(op)).then(result => {
|
|
897
|
+
if (op.op === 'retryScheduleQuota') void drainSources().catch(console.error)
|
|
898
|
+
return result
|
|
899
|
+
})
|
|
900
|
+
: ledgerHandler(op)
|
|
787
901
|
|
|
788
902
|
const checkOwner = async (ctx: Context): Promise<boolean> => {
|
|
789
903
|
if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
|
|
@@ -815,6 +929,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
815
929
|
{ command: 'status', description: 'Work, queue and delivery health' },
|
|
816
930
|
{ command: 'stop', description: 'Stop active work; keep queued messages' },
|
|
817
931
|
{ command: 'cancel', description: 'Cancel pending messages; keep active work' },
|
|
932
|
+
{ command: 'retry_quota', description: 'Retry quota-blocked work after a reset or added credits' },
|
|
818
933
|
{ command: 'retry', description: 'Retry the latest failed incoming batch' },
|
|
819
934
|
{ command: 'new', description: 'New conversation; keep workspace files' },
|
|
820
935
|
]
|
|
@@ -882,6 +997,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
882
997
|
`Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
|
|
883
998
|
`Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
|
|
884
999
|
`Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
|
|
1000
|
+
...all.filter(r => r.status === 'queued' && r.quotaWait).map(r => `Waiting for quota: ${r.scheduled?.id ?? r.id}${r.quotaWait!.retryAt ? ` · next check ${r.quotaWait!.retryAt}` : ' · awaiting availability or /retry_quota'}`),
|
|
885
1001
|
`Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
|
|
886
1002
|
...(attention.length ? ['', 'Needs attention', ...attention] : []),
|
|
887
1003
|
'',
|
|
@@ -898,7 +1014,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
898
1014
|
if (activeChild) children.unshift([undefined, activeChild])
|
|
899
1015
|
let requested = true
|
|
900
1016
|
for (const [id, child] of children) {
|
|
901
|
-
|
|
1017
|
+
const runId = childRuns.get(child)
|
|
1018
|
+
if (runId) stoppedRuns.add(runId)
|
|
1019
|
+
if (!await terminateJob(child)) { requested = false; if (runId) stoppedRuns.delete(runId); continue }
|
|
902
1020
|
ownerStopped.add(child)
|
|
903
1021
|
if (id) await scheduler.cancel(id)
|
|
904
1022
|
}
|
|
@@ -1024,6 +1142,16 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1024
1142
|
.map((c) => `/${c.command} — ${c.description}`).join('\n'))
|
|
1025
1143
|
return
|
|
1026
1144
|
}
|
|
1145
|
+
if (typeof text === 'string' && /^\/retry[_-]quota(?:\s|$)/.test(text)) {
|
|
1146
|
+
const id = text.split(/\s+/)[1]
|
|
1147
|
+
try {
|
|
1148
|
+
if (text.split(/\s+/).length > 2) throw new Error('Usage: /retry_quota [RUN_ID]')
|
|
1149
|
+
const result = await withStartLock(async () => scheduler.retryQuota((await control.status()).owner!, runs, id))
|
|
1150
|
+
void drainSources().catch(console.error)
|
|
1151
|
+
await ctx.reply(result.runIds.length ? `Quota retry queued for ${result.runIds.length} run(s).` : 'No queued quota-blocked work. Use /retry_quota RUN_ID for an earlier quota failure.')
|
|
1152
|
+
} catch (error) { await ctx.reply(safeError(error)) }
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
1027
1155
|
if (text === '/retry') {
|
|
1028
1156
|
const id = await inbox.retryLatest(ctx.from.id, ctx.chat.id, (await control.status()).owner)
|
|
1029
1157
|
if (id) scheduleIntake()
|
|
@@ -1305,59 +1433,20 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1305
1433
|
// Admit every record before the first attach, so no trigger races a hold.
|
|
1306
1434
|
const admitted = (await Promise.all(handed.map(run => runs.adopt(run).catch(() => null)))).filter((run): run is RunRecord => run !== null)
|
|
1307
1435
|
for (const run of admitted) await withStartLock(async () => {
|
|
1308
|
-
let
|
|
1436
|
+
let owner: Owner
|
|
1309
1437
|
try {
|
|
1438
|
+
const current = (await control.status()).owner
|
|
1439
|
+
if (!current || !ownsRun(current, run)) throw new Error('Handed-off run authority revoked')
|
|
1440
|
+
owner = current
|
|
1310
1441
|
// The foreground lane is serial; a second foreground handoff is corrupt.
|
|
1311
1442
|
if (!run.scheduled && activeChild) throw new Error('A second handed-off foreground run was cancelled; its outcome is unknown')
|
|
1312
|
-
child = await attachHostJob(config.controlDir, run.id)
|
|
1313
1443
|
} catch (error) {
|
|
1314
1444
|
// Never leave an unobserved executor: ask the host to stop it.
|
|
1315
|
-
await
|
|
1316
|
-
.then(() => writeFile(join(config.controlDir, 'host-executor', run.id + '.cancel'), '', { mode: 0o600 })).catch(() => {})
|
|
1445
|
+
await cancelUnobservedHostRun(run.id).catch(() => {})
|
|
1317
1446
|
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString(), interrupted: true, failureReason: 'handoff-outcome-unknown', failure: await failureEvidence(config.controlDir, safeError(error)) })
|
|
1318
1447
|
return
|
|
1319
1448
|
}
|
|
1320
|
-
|
|
1321
|
-
const closed = new Promise<number | null>(resolve => child.once('close', resolve))
|
|
1322
|
-
let errorTail = '', outputTail = '', timedOut = false
|
|
1323
|
-
child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { if (run.script) outputTail = (outputTail + chunk).slice(-16384) })
|
|
1324
|
-
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
1325
|
-
errorTail = (errorTail + chunk).slice(-16384)
|
|
1326
|
-
if (run.script) outputTail = (outputTail + chunk).slice(-16384)
|
|
1327
|
-
})
|
|
1328
|
-
if (run.scheduled) background.set(run.id, child)
|
|
1329
|
-
else activeChild = child
|
|
1330
|
-
childRuns.set(child, run.id)
|
|
1331
|
-
await runs.patch(run.id, { pid: child.pid })
|
|
1332
|
-
console.info('run adopted', { run_id: run.id, pid: child.pid })
|
|
1333
|
-
// A script keeps its registered bound from its original start.
|
|
1334
|
-
const limit = run.script ? await new Scripts(config.controlDir).get(run.script.id).then(script => script.timeoutSeconds, () => DEFAULT_SCRIPT_TIMEOUT_SECONDS) : 0
|
|
1335
|
-
const timer = limit ? setTimeout(() => { timedOut = true; terminateJob(child) }, Math.max(0, Date.parse(run.startedAt ?? run.createdAt) + limit * 1000 - Date.now())) : undefined
|
|
1336
|
-
const completion = closed.then(code => withStartLock(async () => {
|
|
1337
|
-
clearTimeout(timer)
|
|
1338
|
-
if (run.scheduled) background.delete(run.id)
|
|
1339
|
-
else if (activeChild === child) activeChild = null
|
|
1340
|
-
if (handedOff.has(child)) return
|
|
1341
|
-
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
1342
|
-
const failed = !cancelled && code !== 0
|
|
1343
|
-
const status = cancelled ? 'cancelled' : failed ? 'failed' : 'completed'
|
|
1344
|
-
const unknown = errorTail.includes('outcome is unknown'), transport = errorTail.includes('Host executor client interrupted by')
|
|
1345
|
-
const failureReason = timedOut ? 'script-timeout' : unknown ? 'handoff-outcome-unknown' : transport ? 'host-executor-transport-interrupted'
|
|
1346
|
-
: errorTail.includes('Host CLI executor is offline') ? 'host-executor-offline' : 'executor-exit'
|
|
1347
|
-
if (code === 0 && run.execution && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(run.execution.sessionId)
|
|
1348
|
-
const at = new Date().toISOString(), last = run.attempts?.at(-1)
|
|
1349
|
-
await runs.patch(run.id, { status, endedAt: at, exitCode: code,
|
|
1350
|
-
...(last?.outcome === 'running' ? { attempts: [...run.attempts!.slice(0, -1), { ...last, outcome: status, exitCode: code, endedAt: at }] } : {}),
|
|
1351
|
-
...(run.script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
1352
|
-
...(failed ? { failureReason, interrupted: unknown || transport, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
1353
|
-
await releaseExternal(run)
|
|
1354
|
-
console.info('run ended', { run_id: run.id, code, adopted: true })
|
|
1355
|
-
})).then(async () => {
|
|
1356
|
-
const next = await runs.nextQueued()
|
|
1357
|
-
if (next && !shuttingDown) await startJob(next)
|
|
1358
|
-
}).catch(error => console.error('Adopted run completion failed', safeError(error)))
|
|
1359
|
-
completions.add(completion)
|
|
1360
|
-
void completion.finally(() => completions.delete(completion))
|
|
1449
|
+
await watchRun(run, run.texts, owner, true)
|
|
1361
1450
|
}).catch(error => console.error('Run adoption failed', run.id, safeError(error)))
|
|
1362
1451
|
}
|
|
1363
1452
|
|