@jc_stack/ez-agents 0.1.0-beta.47 → 0.1.0-beta.49
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/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +30 -0
- package/Dockerfile +5 -1
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +100 -0
- package/docker/run.ts +1 -1
- package/docker/upgrade-smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +23 -4
- package/docs/docker-runtime.md +57 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/releasing.md +4 -0
- package/docs/scheduling.md +141 -6
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +33 -4
- package/docs/work-preflight.md +9 -0
- package/package.json +1 -1
- package/src/ai-cli.ts +28 -15
- package/src/ai.ts +98 -15
- package/src/application-channel.ts +52 -12
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/auth-profile.ts +53 -0
- package/src/client-defaults.ts +3 -2
- package/src/codex-session.ts +14 -3
- package/src/config.ts +9 -0
- package/src/control-state.ts +31 -22
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +26 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +81 -7
- package/src/files.ts +47 -3
- package/src/host-executor.ts +68 -34
- package/src/index.ts +227 -110
- package/src/install-tools.mjs +8 -5
- package/src/menu.ts +9 -7
- package/src/plugin-broker.mjs +50 -14
- package/src/plugins/manager.mjs +34 -8
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +24 -2
- package/src/schedule-cli.ts +100 -13
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +85 -19
- package/src/scripts.ts +167 -0
- package/src/setup-fallback.ts +122 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +13 -9
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +107 -30
- package/src/updates/supervisor.mjs +27 -6
- package/test/ai.test.ts +40 -0
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/auth-profiles.test.ts +187 -0
- package/test/config.test.ts +8 -0
- package/test/executor.test.ts +37 -0
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +45 -2
- package/test/install-json.test.mjs +21 -0
- package/test/plugin-broker.test.mjs +67 -0
- package/test/plugin-manager.test.mjs +44 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduler-host.test.ts +7 -0
- package/test/scheduler-relay.test.ts +38 -3
- package/test/scheduler.test.ts +1 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/setup-fallback.test.ts +245 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +100 -11
package/src/host-executor.ts
CHANGED
|
@@ -7,9 +7,11 @@ import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath }
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { isHostRunId } from './host-executor-protocol.js'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
|
-
import { startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
|
|
10
|
+
import { CliUnavailableError, startExecutorJob, terminateJob, resolveExecutor, validateCodexProvider, type CodexProviderBinding, type ExecutorOptions } from './executor.js'
|
|
11
|
+
import { activeSetup } from './runs.js'
|
|
12
|
+
import { startupLine, startupObserver } from './setup-fallback.js'
|
|
11
13
|
import { parseIsolationClass, type IsolationClass } from './isolation.js'
|
|
12
|
-
import { readModels, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
|
|
14
|
+
import { readModels, sameEngine, validateSelection, validateOpencodeProviders, type ModelChoice } from './ai.js'
|
|
13
15
|
import type { ChildProcess } from 'node:child_process'
|
|
14
16
|
import { packageVersion } from './version.js'
|
|
15
17
|
import { installedPluginVersions } from './software-status.js'
|
|
@@ -48,11 +50,13 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
48
50
|
new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
|
|
49
51
|
throw new Error('Each agent requires a separate workspace and control directory')
|
|
50
52
|
const active = new Map<string, ChildProcess>()
|
|
53
|
+
// A fallback attempt reuses its run ID; claim it only after the prior attempt's cleanup.
|
|
54
|
+
const claimed = new Set<string>()
|
|
51
55
|
const tasks = new Set<Promise<void>>()
|
|
52
56
|
let catalogRefresh: Promise<void> | undefined
|
|
53
57
|
const locks: string[] = []
|
|
54
58
|
const sharedWorkspaces = new Map<HostBinding, string>()
|
|
55
|
-
const activeWorkspaces = new Map<HostBinding, { lanes:
|
|
59
|
+
const activeWorkspaces = new Map<HostBinding, { lanes: Map<string, boolean>; release?: () => Promise<void> }>()
|
|
56
60
|
const additionalWorkspaces = new Map<HostBinding, string[]>()
|
|
57
61
|
const catalog = async (agent: HostBinding) => {
|
|
58
62
|
const discovered = await readModels(undefined, undefined, path.join(agent.controlDir, 'cli', 'codex'),
|
|
@@ -148,6 +152,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
148
152
|
for (const file of await readdir(directory)) {
|
|
149
153
|
if (!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) continue
|
|
150
154
|
const id=file.slice(0,-13)
|
|
155
|
+
if (claimed.has(path.join(directory,id))) continue
|
|
151
156
|
let run
|
|
152
157
|
try {
|
|
153
158
|
run = await readRun(agent.controlDir, id)
|
|
@@ -161,29 +166,31 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
161
166
|
const base=path.join(directory,id)
|
|
162
167
|
const scheduled = Boolean(run?.scheduled)
|
|
163
168
|
let workspace = activeWorkspaces.get(agent)
|
|
164
|
-
if (workspace
|
|
169
|
+
if (!scheduled && workspace && [...workspace.lanes.values()].includes(false)) continue
|
|
165
170
|
if (!workspace) {
|
|
166
171
|
const release = agent.toolsHome
|
|
167
172
|
? await (await import('./plugins/workspace-lease.mjs')).workspaceLease(agent.toolsHome,{kind:'native',runId:id}) : undefined
|
|
168
173
|
if (agent.toolsHome && !release) continue
|
|
169
|
-
workspace = { lanes: new
|
|
174
|
+
workspace = { lanes: new Map(), release }
|
|
170
175
|
activeWorkspaces.set(agent, workspace)
|
|
171
176
|
}
|
|
172
|
-
workspace.lanes.
|
|
173
|
-
|
|
177
|
+
workspace.lanes.set(id, scheduled)
|
|
178
|
+
claimed.add(base)
|
|
179
|
+
// Keep plugin workspace invocations excluded until all native jobs finish.
|
|
174
180
|
const releaseWorkspace = async () => {
|
|
175
|
-
workspace.lanes.delete(
|
|
181
|
+
workspace.lanes.delete(id)
|
|
176
182
|
if (!workspace.lanes.size) {
|
|
177
183
|
activeWorkspaces.delete(agent)
|
|
178
184
|
await workspace.release?.()
|
|
179
185
|
}
|
|
180
186
|
}
|
|
181
187
|
try { await rename(base+'.request.json',base+'.running.json') }
|
|
182
|
-
catch (error) { await releaseWorkspace(); throw error }
|
|
188
|
+
catch (error) { claimed.delete(base); await releaseWorkspace(); throw error }
|
|
183
189
|
const task=(async()=>{
|
|
184
190
|
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
185
191
|
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
186
192
|
let writes=Promise.resolve()
|
|
193
|
+
let observeClaude=false
|
|
187
194
|
const emit=(event:unknown)=>{writes=writes.then(()=>appendFile(base+'.events',JSON.stringify(event)+'\n',{mode:0o600}))}
|
|
188
195
|
try {
|
|
189
196
|
try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
@@ -195,34 +202,58 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
195
202
|
await new Tasks(agent.controlDir).authorize(run, false)
|
|
196
203
|
} else await authorizeRun(agent.controlDir, path.basename(base))
|
|
197
204
|
const opts=request.options as ExecutorOptions
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
205
|
+
// Script occurrences come from the authorized run record, never the request.
|
|
206
|
+
if (run?.script) {
|
|
207
|
+
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),
|
|
209
|
+
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
210
|
+
script:{...run.script,scheduleId:run.scheduled.id,scheduleRevision:run.scheduled.revision,dueAt:run.scheduled.dueAt}}
|
|
211
|
+
job=await launch(request.texts,options)
|
|
212
|
+
} else {
|
|
213
|
+
const cli = opts.cli || installation.cli
|
|
214
|
+
if (run?.scheduled) {
|
|
215
|
+
const setup = activeSetup(run)
|
|
216
|
+
assertScheduledModel(setup?.model)
|
|
217
|
+
if (opts.model !== setup!.model || cli !== setup!.cli) throw new Error('Scheduled run model does not match its saved task model')
|
|
218
|
+
observeClaude = cli === 'claude'
|
|
219
|
+
}
|
|
220
|
+
resolveExecutor(cli)
|
|
221
|
+
// The credential home comes from the authorized run's captured
|
|
222
|
+
// choice; a request can never move it to another account.
|
|
223
|
+
const captured = run?.scheduled ? activeSetup(run) : run?.execution?.preset
|
|
224
|
+
if ((opts.authProfile !== undefined || (captured?.cli === cli && captured.authProfile !== undefined)) &&
|
|
225
|
+
!(captured && sameEngine(captured, {cli, authProfile: opts.authProfile})))
|
|
226
|
+
throw new Error('Run auth profile does not match its captured AI selection')
|
|
227
|
+
const provider = cli === 'codex' && opts.provider
|
|
228
|
+
? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
|
|
229
|
+
: undefined
|
|
230
|
+
if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
|
|
231
|
+
const model = opts.model
|
|
232
|
+
if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
|
|
233
|
+
if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
|
|
234
|
+
if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
|
|
235
|
+
throw new Error('Selected Codex provider is required for this model')
|
|
236
|
+
// Admission and execution must use the same catalog snapshot. A
|
|
237
|
+
// transient native-client probe failure must not reject a model
|
|
238
|
+
// that the host just advertised to the relay and application.
|
|
239
|
+
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,
|
|
241
|
+
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
242
|
+
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,authProfile:opts.authProfile,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
243
|
+
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
244
|
+
job=await launch(request.texts,options)
|
|
201
245
|
}
|
|
202
|
-
const cli = opts.cli || installation.cli
|
|
203
|
-
resolveExecutor(cli)
|
|
204
|
-
const provider = cli === 'codex' && opts.provider
|
|
205
|
-
? (agent.codexProviders ?? []).map(validateCodexProvider).find(candidate=>candidate.id===opts.provider)
|
|
206
|
-
: undefined
|
|
207
|
-
if (opts.provider && !provider) throw new Error('Selected Codex provider is not installed for this agent')
|
|
208
|
-
const model = opts.model
|
|
209
|
-
if (provider && !model) throw new Error('Selected Codex provider requires a declared model')
|
|
210
|
-
if (provider && model && !provider.models.includes(model)) throw new Error('Selected model is not declared for this Codex provider')
|
|
211
|
-
if (!provider && model && (agent.codexProviders ?? []).some(binding=>binding.models.includes(model)))
|
|
212
|
-
throw new Error('Selected Codex provider is required for this model')
|
|
213
|
-
// Admission and execution must use the same catalog snapshot. A
|
|
214
|
-
// transient native-client probe failure must not reject a model
|
|
215
|
-
// that the host just advertised to the relay and application.
|
|
216
|
-
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,provider:opts.provider,model,effort:opts.effort},JSON.parse(await readFile(path.join(directory,'models.json'),'utf8')))
|
|
217
|
-
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,sharedWorkspace,additionalWorkspaces:additionalWorkspaces.get(agent),cli,
|
|
218
|
-
runId:path.basename(base),timeoutMs:0,repairEnabled:opts.repairEnabled,
|
|
219
|
-
sessionId:opts.sessionId,isResume:opts.isResume,model,effort:opts.effort,provider:opts.provider,codexAutoCompactTokens:opts.codexAutoCompactTokens,codexProvider:provider,
|
|
220
|
-
promptSuffix:runPromptSuffix(run),taskRun:Boolean(run?.taskId),nativeSession:Boolean(run?.scheduled)}
|
|
221
|
-
job=await launch(request.texts,options)
|
|
222
246
|
active.set(base,job.child)
|
|
223
247
|
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
|
224
248
|
if(signal.aborted)terminateJob(job.child)
|
|
225
|
-
|
|
249
|
+
// Scheduled Claude streams every event; forward only the startup summary.
|
|
250
|
+
const startup = observeClaude ? startupObserver() : undefined
|
|
251
|
+
let summary = ''
|
|
252
|
+
job.child.stdout?.setEncoding('utf8').on('data',(chunk:string)=>{
|
|
253
|
+
if (!startup) return emit({stream:'stdout',text:chunk})
|
|
254
|
+
startup.write(chunk)
|
|
255
|
+
if (startup.summary() !== summary) { summary = startup.summary(); emit({stream:'stdout',text:summary+'\n'}) }
|
|
256
|
+
})
|
|
226
257
|
job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
|
|
227
258
|
cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
|
|
228
259
|
const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
|
|
@@ -230,7 +261,9 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
230
261
|
await job.cleanup()
|
|
231
262
|
job = undefined
|
|
232
263
|
emit({stream:'exit',code})
|
|
233
|
-
} catch (error) {
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error instanceof CliUnavailableError) emit({stream:'stdout',text:startupLine({workBegan:false,rejection:{category:'cli-unavailable'}})+'\n'})
|
|
266
|
+
emit({stream:'stderr',text:'Host CLI execution failed: '+redactFailure(error instanceof Error ? error.message : 'Unknown error')+'\n'}); emit({stream:'exit',code:1}) }
|
|
234
267
|
finally {
|
|
235
268
|
if(cancellation)clearInterval(cancellation)
|
|
236
269
|
await job?.cleanup().catch(() => {})
|
|
@@ -240,6 +273,7 @@ export const serveHostExecutor = async (installation: HostInstallation, signal:
|
|
|
240
273
|
await rm(base+'.cancel',{force:true})
|
|
241
274
|
active.delete(base)
|
|
242
275
|
await releaseWorkspace()
|
|
276
|
+
claimed.delete(base)
|
|
243
277
|
}
|
|
244
278
|
})()
|
|
245
279
|
tasks.add(task); void task.finally(()=>tasks.delete(task))
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { dispatchChannel } from './channel-backend.js'
|
|
|
11
11
|
import { randomUUID } from 'node:crypto'
|
|
12
12
|
import { stat } from 'node:fs/promises'
|
|
13
13
|
import { Scheduler } from './scheduler.js'
|
|
14
|
+
import { Scripts } from './scripts.js'
|
|
15
|
+
import { redactFailure } from './failure.js'
|
|
14
16
|
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
15
17
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
16
18
|
import {removeTaskAttachments} from './task-attachments.js'
|
|
@@ -25,7 +27,8 @@ import { InboxStore, type IncomingItem } from './inbox.js'
|
|
|
25
27
|
import { loadConfig, type Config } from './config.js'
|
|
26
28
|
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
27
29
|
import { ApprovalStore } from './approval.js'
|
|
28
|
-
import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
|
|
30
|
+
import { CliUnavailableError, startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
|
|
31
|
+
import { advances, nextSetup, quotaScope, setupCandidates, startupObserver, unavailableSummary, SetupsUnavailableError, type SetupAttempt } from './setup-fallback.js'
|
|
29
32
|
import { assertScheduledModel } from './model-policy.js'
|
|
30
33
|
import { RunStore, type RunRecord } from './runs.js'
|
|
31
34
|
import { splitTelegramText } from './reply.js'
|
|
@@ -36,7 +39,7 @@ import { normalizeReactionEmoji } from './reaction.js'
|
|
|
36
39
|
import { downloadTelegramFile } from './read-request.js'
|
|
37
40
|
import { createConversationMenu } from './conversation-menu.js'
|
|
38
41
|
import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
|
|
39
|
-
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
|
|
42
|
+
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset, type AiPreset } from './ai.js'
|
|
40
43
|
import { discoverDefaults } from './client-defaults.js'
|
|
41
44
|
import { runPromptSuffix } from './prompt-suffix.js'
|
|
42
45
|
import { softwareStatus } from './software-status.js'
|
|
@@ -44,7 +47,7 @@ import { softwareStatus } from './software-status.js'
|
|
|
44
47
|
export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
45
48
|
const safeError = (error: unknown): string => {
|
|
46
49
|
let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
|
47
|
-
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
|
|
50
|
+
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey, config.openrouterApiKey]) {
|
|
48
51
|
if (secret) message = message.replaceAll(secret, '[redacted]')
|
|
49
52
|
}
|
|
50
53
|
return message
|
|
@@ -60,7 +63,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
60
63
|
const scheduler = new Scheduler(config.controlDir)
|
|
61
64
|
const background = new Map<string, ChildProcess>()
|
|
62
65
|
const completions = new Set<Promise<void>>()
|
|
63
|
-
const
|
|
66
|
+
const speechOptions = { geminiApiKey: config.geminiApiKey, openrouterApiKey: config.openrouterApiKey, speechProvider: config.speechProvider, speechModel: config.speechModel }
|
|
67
|
+
const speechConfigured = config.speechProvider === 'openrouter' ? Boolean(config.openrouterApiKey) : Boolean(config.geminiApiKey)
|
|
68
|
+
const tasks = new Tasks(config.controlDir, speechConfigured ? (text) => synthesizeSpeech(text, { ...speechOptions, voice: config.speechVoiceEn, signal: AbortSignal.timeout(20000) }) : undefined)
|
|
64
69
|
const processTaskRequests = taskRequests(tasks)
|
|
65
70
|
let taskWork: Promise<void> | undefined
|
|
66
71
|
const drainTaskRequests = (): Promise<void> => {
|
|
@@ -103,8 +108,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
103
108
|
const applicationChannel = new ApplicationChannel({
|
|
104
109
|
controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
|
|
105
110
|
aiControls: aiMenu,
|
|
106
|
-
speech:
|
|
107
|
-
|
|
111
|
+
speech: speechConfigured ? (text, language) => synthesizeSpeech(text, {
|
|
112
|
+
...speechOptions,
|
|
108
113
|
voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
|
|
109
114
|
format: 'wav',
|
|
110
115
|
}) : undefined,
|
|
@@ -207,8 +212,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
207
212
|
}
|
|
208
213
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
209
214
|
// A long scheduled turn must not occupy the owner's chat stream.
|
|
210
|
-
//
|
|
211
|
-
if (run.scheduled ? background.size
|
|
215
|
+
// Foreground stays serial; scheduled jobs have independent native sessions.
|
|
216
|
+
if (run.scheduled ? background.size >= (config.scheduledConcurrency ?? 6) : activeChild !== null) return
|
|
212
217
|
let texts = run.texts
|
|
213
218
|
if (run.external) {
|
|
214
219
|
// Availability failures leave durable queued work for a later check.
|
|
@@ -232,111 +237,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
232
237
|
await releaseExternal(run)
|
|
233
238
|
return
|
|
234
239
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
238
|
-
if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
239
|
-
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
|
|
240
|
-
const session = startsOwnSession
|
|
241
|
-
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
242
|
-
: await control.executionSession(started.execution!)
|
|
243
|
-
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
244
|
-
if (run.scheduled) assertScheduledModel(selected.model)
|
|
245
|
-
const { child, cleanup } = await launch(texts, {
|
|
246
|
-
workspace: config.workspace,
|
|
247
|
-
timeoutMs: config.executorTimeoutMs,
|
|
248
|
-
repairEnabled: config.repairEnabled,
|
|
249
|
-
runId: started.id,
|
|
250
|
-
controlDir: config.controlDir,
|
|
251
|
-
binDir,
|
|
252
|
-
cli: selected.cli,
|
|
253
|
-
model: selected.model,
|
|
254
|
-
effort: selected.effort,
|
|
255
|
-
provider: selected.provider,
|
|
256
|
-
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
257
|
-
codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
|
|
258
|
-
sessionId: session.nativeSessionId || session.sessionId,
|
|
259
|
-
isResume: session.hasStarted,
|
|
260
|
-
promptSuffix: runPromptSuffix(started),
|
|
261
|
-
taskRun: Boolean(run.taskId),
|
|
262
|
-
nativeSession: Boolean(run.scheduled),
|
|
263
|
-
onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
|
|
264
|
-
})
|
|
265
|
-
const executionStarted = performance.now()
|
|
266
|
-
// Attach before disk writes: a fast child can close while PID persistence
|
|
267
|
-
// is pending, and Node drains its remaining pipes during process close.
|
|
268
|
-
let failureReason = 'executor-exit', errorTail = '', interrupted = false
|
|
269
|
-
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
270
|
-
errorTail = (errorTail + chunk).slice(-16384)
|
|
271
|
-
if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
|
|
272
|
-
if (chunk.includes('Host executor client interrupted by')) {
|
|
273
|
-
failureReason = 'host-executor-transport-interrupted'
|
|
274
|
-
interrupted = true
|
|
275
|
-
}
|
|
276
|
-
if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
|
|
277
|
-
})
|
|
278
|
-
console.info('run timing', { run_id: run.id, phase: 'launch',
|
|
279
|
-
queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
|
|
280
|
-
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
281
|
-
if (run.scheduled) background.set(run.id,child)
|
|
282
|
-
else activeChild = child
|
|
283
|
-
const finished = new Promise<number | null>((resolve) => {
|
|
284
|
-
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
285
|
-
else child.once('close', resolve)
|
|
286
|
-
})
|
|
287
|
-
await runs.patch(started.id, { pid: child.pid })
|
|
288
|
-
console.info('run started', {
|
|
289
|
-
run_id: started.id,
|
|
290
|
-
pid: child.pid,
|
|
291
|
-
cli: selected.cli,
|
|
292
|
-
session: session.sessionId,
|
|
293
|
-
isResume: session.hasStarted,
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
297
|
-
if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
298
|
-
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
|
|
299
|
-
}, 4000)
|
|
300
|
-
|
|
301
|
-
const completion = finished.then((code) => {
|
|
302
|
-
console.info('run timing', { run_id: run.id, phase: 'execution',
|
|
303
|
-
execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
|
|
304
|
-
return (async () => {
|
|
305
|
-
await withStartLock(async () => {
|
|
306
|
-
try {
|
|
307
|
-
await cleanup()
|
|
308
|
-
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
309
|
-
const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
310
|
-
await runs.patch(started.id, { status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString(), exitCode: code, ...(code !== 0 && !cancelled ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
311
|
-
await releaseExternal(run)
|
|
312
|
-
} catch (error) {
|
|
313
|
-
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() })
|
|
314
|
-
console.error('Session completion failed', safeError(error))
|
|
315
|
-
} finally {
|
|
316
|
-
if (run.scheduled) background.delete(run.id)
|
|
317
|
-
else {
|
|
318
|
-
activeChild = null
|
|
319
|
-
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
320
|
-
activeTypingTimer = null
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
})
|
|
324
|
-
console.info('run ended', { run_id: started.id, code })
|
|
325
|
-
const next = await runs.nextQueued()
|
|
326
|
-
if (next && !shuttingDown) await startJob(next)
|
|
327
|
-
})().catch((error) => console.error('Run completion failed', error.message))
|
|
328
|
-
})
|
|
329
|
-
completions.add(completion)
|
|
330
|
-
void completion.finally(() => completions.delete(completion))
|
|
331
|
-
} catch (error) {
|
|
240
|
+
// Shared by a first launch and a fallback launch that cannot start.
|
|
241
|
+
const startFailed = async (error: unknown) => {
|
|
332
242
|
if (!run.scheduled && activeTypingTimer) {
|
|
333
243
|
clearInterval(activeTypingTimer)
|
|
334
244
|
activeTypingTimer = null
|
|
335
245
|
}
|
|
336
|
-
|
|
246
|
+
if (run.scheduled) background.delete(run.id)
|
|
247
|
+
const unavailable = error instanceof SetupsUnavailableError
|
|
248
|
+
await runs.patch(run.id, { status: 'failed', failureReason: unavailable ? 'setups-unavailable' : 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
337
249
|
await releaseExternal(run)
|
|
338
250
|
console.error('run start failed', run.id, safeError(error))
|
|
339
|
-
if (bot && !run.application && !run.delivery && run.chatId !== undefined)
|
|
251
|
+
if (bot && !run.application && !run.delivery && run.chatId !== undefined)
|
|
252
|
+
await sendChat(run.chatId, unavailable ? `Run ${run.id} could not start. ${safeError(error)}` : `Run ${run.id} failed to start. Check the local relay log.`)
|
|
340
253
|
setImmediate(() => {
|
|
341
254
|
void runs
|
|
342
255
|
.nextQueued()
|
|
@@ -344,6 +257,210 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
344
257
|
.catch(console.error)
|
|
345
258
|
})
|
|
346
259
|
}
|
|
260
|
+
try {
|
|
261
|
+
const launchStarted = performance.now()
|
|
262
|
+
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
263
|
+
// A registered script occurrence: no model, prompt or native session.
|
|
264
|
+
const script = run.script && run.scheduled
|
|
265
|
+
? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
|
|
266
|
+
: undefined
|
|
267
|
+
if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
|
|
268
|
+
if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
269
|
+
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
|
|
270
|
+
const session = startsOwnSession
|
|
271
|
+
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
272
|
+
: await control.executionSession(started.execution!)
|
|
273
|
+
const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
274
|
+
if (run.scheduled && !script) assertScheduledModel(selected.model)
|
|
275
|
+
// A scheduled occurrence may name ordered fallback setups. They share this
|
|
276
|
+
// run, workspace and concurrency slot; each is tried at most once.
|
|
277
|
+
const candidates = run.scheduled && !script ? setupCandidates(started.execution!) : [selected]
|
|
278
|
+
const ordered = candidates.length > 1
|
|
279
|
+
let attempts: SetupAttempt[] = []
|
|
280
|
+
const settle = (change: Partial<SetupAttempt>) => {
|
|
281
|
+
attempts = [...attempts.slice(0, -1), { ...attempts[attempts.length - 1], ...change, endedAt: new Date().toISOString() }]
|
|
282
|
+
}
|
|
283
|
+
// Advance only after an eligible pre-work failure of a current, uncancelled
|
|
284
|
+
// occurrence. Setups in an exhausted quota scope are skipped and recorded.
|
|
285
|
+
const advance = async (): Promise<AiPreset | undefined> => {
|
|
286
|
+
let preset: AiPreset | undefined
|
|
287
|
+
const current = !shuttingDown && !await scheduler.cancelled(run.id) ? (await control.status()).owner : undefined
|
|
288
|
+
if (current && ownsRun(current, run) && await scheduler.current(run, current)) {
|
|
289
|
+
const next = nextSetup(candidates, attempts)
|
|
290
|
+
attempts = [...attempts, ...next.skipped]
|
|
291
|
+
if (!next.preset) {
|
|
292
|
+
await runs.patch(run.id, { attempts })
|
|
293
|
+
throw new SetupsUnavailableError(unavailableSummary(attempts))
|
|
294
|
+
}
|
|
295
|
+
preset = next.preset
|
|
296
|
+
}
|
|
297
|
+
await runs.patch(run.id, { attempts })
|
|
298
|
+
return preset
|
|
299
|
+
}
|
|
300
|
+
// Each attempt is a fresh native session; a rejected Claude attempt already claimed its ID.
|
|
301
|
+
const launchSetup = async (preset: AiPreset): Promise<{ child: ChildProcess; cleanup: () => Promise<void>; preset: AiPreset }> => {
|
|
302
|
+
for (;;) {
|
|
303
|
+
const sessionId = attempts.length ? randomUUID() : session.nativeSessionId || session.sessionId
|
|
304
|
+
if (ordered) {
|
|
305
|
+
attempts = [...attempts, { preset, quotaScope: quotaScope(preset), outcome: 'running', startedAt: new Date().toISOString() }]
|
|
306
|
+
await runs.patch(run.id, { attempts })
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
return { ...await launch(texts, script ? {
|
|
310
|
+
workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
|
|
311
|
+
controlDir: config.controlDir, binDir,
|
|
312
|
+
script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
|
|
313
|
+
} : {
|
|
314
|
+
workspace: config.workspace,
|
|
315
|
+
timeoutMs: config.executorTimeoutMs,
|
|
316
|
+
repairEnabled: config.repairEnabled,
|
|
317
|
+
runId: started.id,
|
|
318
|
+
controlDir: config.controlDir,
|
|
319
|
+
binDir,
|
|
320
|
+
cli: preset.cli,
|
|
321
|
+
model: preset.model,
|
|
322
|
+
effort: preset.effort,
|
|
323
|
+
provider: preset.provider,
|
|
324
|
+
authProfile: preset.authProfile,
|
|
325
|
+
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
326
|
+
codexSandbox: !run.taskId && preset.cli === 'codex' ? config.codexSandbox : undefined,
|
|
327
|
+
sessionId,
|
|
328
|
+
isResume: session.hasStarted,
|
|
329
|
+
promptSuffix: runPromptSuffix(started),
|
|
330
|
+
taskRun: Boolean(run.taskId),
|
|
331
|
+
nativeSession: Boolean(run.scheduled),
|
|
332
|
+
onSession: run.external || run.taskId ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
|
|
333
|
+
}), preset }
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (ordered && !(error instanceof CliUnavailableError)) {
|
|
336
|
+
settle({ outcome: 'failed', category: 'uncertain' })
|
|
337
|
+
await runs.patch(run.id, { attempts })
|
|
338
|
+
}
|
|
339
|
+
if (!ordered || !(error instanceof CliUnavailableError)) throw error
|
|
340
|
+
settle({ outcome: 'failed', category: 'cli-unavailable' })
|
|
341
|
+
const next = await advance()
|
|
342
|
+
if (!next) throw error
|
|
343
|
+
preset = next
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const watch = async ({ child, cleanup, preset }: Awaited<ReturnType<typeof launchSetup>>): Promise<void> => {
|
|
348
|
+
const executionStarted = performance.now()
|
|
349
|
+
// Attach before disk writes: a fast child can close while PID persistence
|
|
350
|
+
// is pending, and Node drains its remaining pipes during process close.
|
|
351
|
+
let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
|
|
352
|
+
// Script output is bounded diagnostics for run inspection, never chat.
|
|
353
|
+
if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
|
|
354
|
+
// Structured startup events decide whether a failed setup may advance.
|
|
355
|
+
const startup = ordered ? startupObserver() : undefined
|
|
356
|
+
if (startup) child.stdout?.setEncoding('utf8').on('data', startup.write)
|
|
357
|
+
const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
|
|
358
|
+
child.once('close', () => clearTimeout(scriptTimer))
|
|
359
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
360
|
+
errorTail = (errorTail + chunk).slice(-16384)
|
|
361
|
+
if (script) outputTail = (outputTail + chunk).slice(-16384)
|
|
362
|
+
if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
|
|
363
|
+
if (chunk.includes('Host executor client interrupted by')) {
|
|
364
|
+
failureReason = 'host-executor-transport-interrupted'
|
|
365
|
+
interrupted = true
|
|
366
|
+
}
|
|
367
|
+
if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
|
|
368
|
+
})
|
|
369
|
+
console.info('run timing', { run_id: run.id, phase: 'launch',
|
|
370
|
+
queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
|
|
371
|
+
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
372
|
+
if (run.scheduled) background.set(run.id,child)
|
|
373
|
+
else activeChild = child
|
|
374
|
+
const finished = new Promise<number | null>((resolve) => {
|
|
375
|
+
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
376
|
+
else child.once('close', resolve)
|
|
377
|
+
})
|
|
378
|
+
await runs.patch(started.id, { pid: child.pid })
|
|
379
|
+
console.info('run started', {
|
|
380
|
+
run_id: started.id,
|
|
381
|
+
pid: child.pid,
|
|
382
|
+
cli: script ? 'script' : preset.cli,
|
|
383
|
+
...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
|
|
384
|
+
...(ordered ? { setup: attempts.length } : {}),
|
|
385
|
+
session: session.sessionId,
|
|
386
|
+
isResume: session.hasStarted,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
390
|
+
if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
391
|
+
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
|
|
392
|
+
}, 4000)
|
|
393
|
+
|
|
394
|
+
const free = () => {
|
|
395
|
+
if (run.scheduled) background.delete(run.id)
|
|
396
|
+
else {
|
|
397
|
+
activeChild = null
|
|
398
|
+
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
399
|
+
activeTypingTimer = null
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const finalize = async (code: number | null, cancelled: boolean, failed: boolean) => {
|
|
403
|
+
await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
|
|
404
|
+
...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
405
|
+
...(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}`}`)) } : {}) })
|
|
406
|
+
await releaseExternal(run)
|
|
407
|
+
}
|
|
408
|
+
const completion = finished.then((code) => {
|
|
409
|
+
console.info('run timing', { run_id: run.id, phase: 'execution',
|
|
410
|
+
execution_ms: Math.round(performance.now() - executionStarted), exit_code: code })
|
|
411
|
+
return (async () => {
|
|
412
|
+
let eligible = false
|
|
413
|
+
await withStartLock(async () => {
|
|
414
|
+
try {
|
|
415
|
+
await cleanup()
|
|
416
|
+
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
417
|
+
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
418
|
+
if (timedOut) failureReason = 'script-timeout'
|
|
419
|
+
const observed = startup?.result()
|
|
420
|
+
const rejected = observed?.rejection && !observed.workBegan ? observed.rejection : undefined
|
|
421
|
+
// A rejection before work is a failure even when the CLI exits 0.
|
|
422
|
+
const failed = !cancelled && (code !== 0 || timedOut || Boolean(rejected))
|
|
423
|
+
if (startup) {
|
|
424
|
+
settle({ outcome: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', exitCode: code,
|
|
425
|
+
...(failed ? { category: rejected?.category ?? (observed!.workBegan ? 'after-work-began' : 'uncertain') } : {}),
|
|
426
|
+
...(failed && rejected?.resetAt ? { resetAt: rejected.resetAt } : {}) })
|
|
427
|
+
await runs.patch(run.id, { attempts })
|
|
428
|
+
eligible = failed && !interrupted && advances(rejected)
|
|
429
|
+
}
|
|
430
|
+
if (!eligible) await finalize(code, cancelled, failed)
|
|
431
|
+
} catch (error) {
|
|
432
|
+
eligible = false
|
|
433
|
+
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() })
|
|
434
|
+
console.error('Session completion failed', safeError(error))
|
|
435
|
+
} finally {
|
|
436
|
+
// An advancing occurrence keeps its concurrency slot for the next setup.
|
|
437
|
+
if (!eligible) free()
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
console.info('run ended', { run_id: started.id, code, ...(eligible ? { advancing: true } : {}) })
|
|
441
|
+
// Re-check cancellation and shutdown under the start lock, so a stop
|
|
442
|
+
// that already snapshotted children never misses the next setup.
|
|
443
|
+
if (eligible) return withStartLock(async () => {
|
|
444
|
+
try {
|
|
445
|
+
const next = await advance()
|
|
446
|
+
if (next) return await watch(await launchSetup(next))
|
|
447
|
+
const cancelled = await scheduler.cancelled(run.id)
|
|
448
|
+
await finalize(code, cancelled, !cancelled)
|
|
449
|
+
free()
|
|
450
|
+
setImmediate(() => { void runs.nextQueued().then(queued => { if (queued && !shuttingDown) return startJob(queued) }).catch(console.error) })
|
|
451
|
+
} catch (error) { await startFailed(error) }
|
|
452
|
+
})
|
|
453
|
+
const next = await runs.nextQueued()
|
|
454
|
+
if (next && !shuttingDown) await startJob(next)
|
|
455
|
+
})().catch((error) => console.error('Run completion failed', error.message))
|
|
456
|
+
})
|
|
457
|
+
completions.add(completion)
|
|
458
|
+
void completion.finally(() => completions.delete(completion))
|
|
459
|
+
}
|
|
460
|
+
await watch(await launchSetup(selected))
|
|
461
|
+
} catch (error) {
|
|
462
|
+
await startFailed(error)
|
|
463
|
+
}
|
|
347
464
|
})
|
|
348
465
|
}
|
|
349
466
|
|
|
@@ -587,8 +704,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
587
704
|
} else if (item.type === 'voice' && item.voiceText) {
|
|
588
705
|
await bot!.api.sendChatAction(item.chatId, 'record_voice')
|
|
589
706
|
const { buffer } = await synthesizeSpeech(item.voiceText, {
|
|
590
|
-
|
|
591
|
-
openaiApiKey: config.openaiApiKey,
|
|
707
|
+
...speechOptions,
|
|
592
708
|
voice: config.speechVoiceEn,
|
|
593
709
|
})
|
|
594
710
|
await paceSend()
|
|
@@ -719,7 +835,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
719
835
|
const delivery = await runs.deliveryStatus()
|
|
720
836
|
const ai = await control.aiState(aiMenu.initial)
|
|
721
837
|
const selected = ai.presets.find((p) => p.id === ai.selectedId)!
|
|
722
|
-
const discovered = await discoverDefaults(config.workspace, { codexHome, nativeCodexFallback: true })
|
|
838
|
+
const discovered = await discoverDefaults(config.workspace, { codexHome, claudeHome: claudeConfigHome(config.controlDir), nativeCodexFallback: true })
|
|
723
839
|
const displayedSelected = statusPreset(selected, discovered)
|
|
724
840
|
const scheduled = all.filter(run => run.scheduled && run.status === 'running').length
|
|
725
841
|
const queued = all.filter(run => run.status === 'queued').length
|
|
@@ -743,6 +859,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
743
859
|
'',
|
|
744
860
|
'Work',
|
|
745
861
|
`Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
|
|
862
|
+
`Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
|
|
746
863
|
`Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
|
|
747
864
|
`Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
|
|
748
865
|
...(attention.length ? ['', 'Needs attention', ...attention] : []),
|
package/src/install-tools.mjs
CHANGED
|
@@ -16,13 +16,14 @@ export const hostEnvironment=()=>Object.fromEntries(['HOME','PATH','LANG','LC_AL
|
|
|
16
16
|
async function atomic(file,value) {
|
|
17
17
|
const tmp=file+'.'+randomUUID()+'.tmp';await fs.writeFile(tmp,JSON.stringify(value)+'\n',{mode:0o600,flag:'wx'});await fs.rename(tmp,file);
|
|
18
18
|
}
|
|
19
|
-
export function run(command,args,{cwd,log,timeout=15000}={}) {
|
|
19
|
+
export function run(command,args,{cwd,log,timeout=15000,stdoutOnly=false}={}) {
|
|
20
20
|
return new Promise((resolve,reject)=>{
|
|
21
|
-
const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='';
|
|
22
|
-
|
|
21
|
+
const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='',stdout='';
|
|
22
|
+
child.stdout?.on('data',b=>{output=(output+b).slice(-200000);if(stdoutOnly)stdout=(stdout+b).slice(-200000);});
|
|
23
|
+
child.stderr?.on('data',b=>output=(output+b).slice(-200000));
|
|
23
24
|
const timer=setTimeout(()=>child.kill('SIGTERM'),timeout);
|
|
24
25
|
child.once('error',e=>{clearTimeout(timer);reject(e);});
|
|
25
|
-
child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve(output.trim());});
|
|
26
|
+
child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve((stdoutOnly?stdout:output).trim());});
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
export const defaultHome=()=>path.join(process.env.XDG_DATA_HOME||path.join(homedir(),'.local/share'),'ez');
|
|
@@ -115,7 +116,9 @@ export async function migrateLedger({ deployment } = {}) {
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
export async function latestCodexVersion(invoke,manager={command:"npm",args:[]}) {
|
|
118
|
-
const
|
|
119
|
+
const output=await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/'],{stdoutOnly:true});
|
|
120
|
+
let version;
|
|
121
|
+
try {version=JSON.parse(output);}catch {throw Error('Codex version discovery returned invalid JSON on stdout');}
|
|
119
122
|
if(typeof version!=='string'||!/^\d+\.\d+\.\d+$/.test(version))throw Error('Invalid latest Codex version');
|
|
120
123
|
return version;
|
|
121
124
|
}
|