smol-symphony 0.1.0

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.
@@ -0,0 +1,683 @@
1
+ // Orchestrator (SPEC §7, §8, §14, §16). Owns the single-authority runtime state and
2
+ // drives the poll-and-dispatch tick, retries, reconciliation, and worker exit handling.
3
+ import { validateDispatch, WorkflowError } from './workflow.js';
4
+ import { ADAPTERS, assertHostCredentialReadable, isKnownAdapter } from './agent/adapters.js';
5
+ import { pickTerminalTarget } from './mcp.js';
6
+ import { withIssue, log } from './logging.js';
7
+ const CONTINUATION_DELAY_MS = 1_000;
8
+ const FAILURE_BASE_MS = 10_000;
9
+ export class Orchestrator {
10
+ cfg;
11
+ workflowDef;
12
+ workflowSrc;
13
+ tracker;
14
+ workspaces;
15
+ runner;
16
+ running = new Map();
17
+ claimed = new Set();
18
+ retryAttempts = new Map();
19
+ completed = new Set();
20
+ codexTotals = {
21
+ input_tokens: 0,
22
+ output_tokens: 0,
23
+ total_tokens: 0,
24
+ seconds_running: 0,
25
+ };
26
+ codexRateLimits = null;
27
+ tickTimer = null;
28
+ stopped = false;
29
+ refreshRequested = false;
30
+ // Latest dispatch validation error, if any (operator-visible).
31
+ lastValidationError = null;
32
+ // Optional callback used to propagate reloaded config to components that hold their own
33
+ // tracker/runner/workspace state (so prompt body, hooks, smolvm config, etc., take effect
34
+ // on the next dispatch — see §6.2).
35
+ onConfigReloaded;
36
+ constructor(cfg, workflowDef, workflowSrc, tracker, workspaces, runner) {
37
+ this.cfg = cfg;
38
+ this.workflowDef = workflowDef;
39
+ this.workflowSrc = workflowSrc;
40
+ this.tracker = tracker;
41
+ this.workspaces = workspaces;
42
+ this.runner = runner;
43
+ workflowSrc.onChange((next) => {
44
+ if ('error' in next) {
45
+ this.lastValidationError = next.error.message;
46
+ log.warn('workflow reload error', { error: next.error.message });
47
+ return;
48
+ }
49
+ this.cfg = next.config;
50
+ this.workflowDef = next.definition;
51
+ this.lastValidationError = null;
52
+ this.onConfigReloaded?.(next.config, next.definition);
53
+ log.info('runtime config reloaded', {
54
+ poll_interval_ms: next.config.polling.interval_ms,
55
+ max_concurrent_agents: next.config.agent.max_concurrent_agents,
56
+ });
57
+ });
58
+ }
59
+ /** Register a callback invoked after every successful workflow reload. */
60
+ setOnConfigReloaded(cb) {
61
+ this.onConfigReloaded = cb;
62
+ }
63
+ async start() {
64
+ const validation = validateDispatch(this.cfg);
65
+ if (validation) {
66
+ log.error('startup validation failed', { error: validation });
67
+ throw new WorkflowError('workflow_parse_error', validation);
68
+ }
69
+ // Fail fast when symphony will auto-stage credentials but the host file the
70
+ // adapter needs is missing. Operators who set acp.command explicitly own their
71
+ // own credential plumbing, so skip the check in that branch.
72
+ if (this.cfg.acp.command === null && isKnownAdapter(this.cfg.acp.adapter)) {
73
+ const profile = ADAPTERS[this.cfg.acp.adapter];
74
+ try {
75
+ await assertHostCredentialReadable(profile);
76
+ }
77
+ catch (err) {
78
+ log.error('startup credential check failed', { error: err.message });
79
+ throw new WorkflowError('missing_host_credential', err.message);
80
+ }
81
+ }
82
+ await this.startupTerminalCleanup();
83
+ this.scheduleTick(0);
84
+ }
85
+ async stop() {
86
+ this.stopped = true;
87
+ if (this.tickTimer) {
88
+ clearTimeout(this.tickTimer);
89
+ this.tickTimer = null;
90
+ }
91
+ for (const e of this.retryAttempts.values())
92
+ clearTimeout(e.timer_handle);
93
+ this.retryAttempts.clear();
94
+ // Signal cancel on all running entries.
95
+ for (const e of this.running.values())
96
+ e.cancel();
97
+ this.running.clear();
98
+ this.claimed.clear();
99
+ }
100
+ /** Operator trigger for an immediate poll cycle (§13.7 /refresh). */
101
+ triggerRefresh() {
102
+ if (this.refreshRequested)
103
+ return { queued: true, coalesced: true };
104
+ this.refreshRequested = true;
105
+ if (this.tickTimer)
106
+ clearTimeout(this.tickTimer);
107
+ this.tickTimer = setTimeout(() => void this.tick(), 0);
108
+ return { queued: true, coalesced: false };
109
+ }
110
+ scheduleTick(delayMs) {
111
+ if (this.stopped)
112
+ return;
113
+ if (this.tickTimer)
114
+ clearTimeout(this.tickTimer);
115
+ this.tickTimer = setTimeout(() => void this.tick(), delayMs);
116
+ }
117
+ async tick() {
118
+ if (this.stopped)
119
+ return;
120
+ this.refreshRequested = false;
121
+ try {
122
+ await this.reconcile();
123
+ }
124
+ catch (err) {
125
+ log.warn('reconcile error', { error: err.message });
126
+ }
127
+ const validation = validateDispatch(this.cfg);
128
+ if (validation) {
129
+ this.lastValidationError = validation;
130
+ log.warn('dispatch validation failed; skipping dispatch', { error: validation });
131
+ this.scheduleTick(this.cfg.polling.interval_ms);
132
+ return;
133
+ }
134
+ this.lastValidationError = null;
135
+ let candidates;
136
+ let snapshotTrackerRoot;
137
+ let snapshotTerminalTarget;
138
+ try {
139
+ // Atomic fetch: the tracker returns the issues AND the root/terminal_states
140
+ // it used during the scan. That's the snapshot we pin onto each RunningEntry,
141
+ // so a workflow reload that races the dispatch loop can't cause `mark_done`
142
+ // to operate against a different tracker config than where the issue lives.
143
+ const result = await this.tracker.fetchCandidateIssues();
144
+ candidates = result.issues;
145
+ snapshotTrackerRoot = result.root;
146
+ snapshotTerminalTarget = pickTerminalTarget(result.terminalStates);
147
+ }
148
+ catch (err) {
149
+ log.warn('candidate fetch failed', { error: err.message });
150
+ this.scheduleTick(this.cfg.polling.interval_ms);
151
+ return;
152
+ }
153
+ const sorted = this.sortForDispatch(candidates);
154
+ for (const issue of sorted) {
155
+ if (this.availableGlobalSlots() <= 0)
156
+ break;
157
+ if (!this.isEligible(issue))
158
+ continue;
159
+ void this.dispatchIssue(issue, null, {
160
+ trackerRoot: snapshotTrackerRoot,
161
+ terminalTarget: snapshotTerminalTarget,
162
+ });
163
+ }
164
+ this.scheduleTick(this.cfg.polling.interval_ms);
165
+ }
166
+ /** §8.5: stall detection + tracker state refresh for running issues. */
167
+ async reconcile() {
168
+ // Part A: stall detection.
169
+ if (this.cfg.acp.stall_timeout_ms > 0) {
170
+ const now = Date.now();
171
+ for (const [issueId, entry] of this.running) {
172
+ // Skip stall detection for issues awaiting human steering: the agent is
173
+ // intentionally paused while the human composes a reply, and the wait can
174
+ // legitimately exceed stall_timeout_ms. The cancel signal still applies
175
+ // (the runner's awaitSteeringReply respects it) for non-stall reasons like
176
+ // terminal-state transitions or operator-initiated cancels.
177
+ if (entry.steering_requested)
178
+ continue;
179
+ const ref = entry.last_codex_timestamp ?? entry.started_at;
180
+ const elapsed = now - Date.parse(ref);
181
+ if (Number.isFinite(elapsed) && elapsed > this.cfg.acp.stall_timeout_ms) {
182
+ log.warn('stall detected', {
183
+ issue_id: issueId,
184
+ issue_identifier: entry.identifier,
185
+ elapsed_ms: elapsed,
186
+ });
187
+ this.terminateRunning(issueId, false, `stalled after ${elapsed}ms`);
188
+ }
189
+ }
190
+ }
191
+ // Part B: tracker state refresh.
192
+ const ids = [...this.running.keys()];
193
+ if (ids.length === 0)
194
+ return;
195
+ let refreshed;
196
+ try {
197
+ refreshed = await this.tracker.fetchIssueStatesByIds(ids);
198
+ }
199
+ catch (err) {
200
+ log.debug('state refresh failed; keep workers running', { error: err.message });
201
+ return;
202
+ }
203
+ const byId = new Map(refreshed.map((i) => [i.id, i]));
204
+ const terminal = new Set(this.cfg.tracker.terminal_states.map((s) => s.toLowerCase()));
205
+ const active = new Set(this.cfg.tracker.active_states.map((s) => s.toLowerCase()));
206
+ for (const id of ids) {
207
+ const fresh = byId.get(id);
208
+ if (!fresh) {
209
+ // Missing from tracker — non-active, no cleanup (§8.5 part B "neither" branch).
210
+ this.terminateRunning(id, false, 'tracker_state_missing');
211
+ continue;
212
+ }
213
+ const s = fresh.state.toLowerCase();
214
+ if (terminal.has(s)) {
215
+ this.terminateRunning(id, true, 'tracker_state_terminal');
216
+ }
217
+ else if (active.has(s)) {
218
+ const entry = this.running.get(id);
219
+ if (entry)
220
+ entry.issue = fresh;
221
+ }
222
+ else {
223
+ this.terminateRunning(id, false, 'tracker_state_non_active');
224
+ }
225
+ }
226
+ }
227
+ terminateRunning(issueId, cleanupWorkspace, reason) {
228
+ const entry = this.running.get(issueId);
229
+ if (!entry)
230
+ return;
231
+ if (cleanupWorkspace)
232
+ entry.cleanup_workspace_on_exit = true;
233
+ entry.cancel();
234
+ log.info('reconciliation terminating run', {
235
+ issue_id: issueId,
236
+ issue_identifier: entry.identifier,
237
+ reason,
238
+ cleanup_workspace: cleanupWorkspace,
239
+ });
240
+ }
241
+ /** §8.2 candidate eligibility. */
242
+ isEligible(issue) {
243
+ return this.eligibilityReason(issue, /*ignoreOwnClaim*/ false) === null;
244
+ }
245
+ // Returns null when eligible, otherwise a short reason string. The `ignoreOwnClaim`
246
+ // form is used by the retry path so the issue's own claim/retry entry does not block
247
+ // its own redispatch.
248
+ eligibilityReason(issue, ignoreOwnClaim) {
249
+ if (!issue.id || !issue.identifier || !issue.title || !issue.state) {
250
+ return 'missing required issue fields';
251
+ }
252
+ const state = issue.state.toLowerCase();
253
+ const active = new Set(this.cfg.tracker.active_states.map((s) => s.toLowerCase()));
254
+ const terminal = new Set(this.cfg.tracker.terminal_states.map((s) => s.toLowerCase()));
255
+ if (!active.has(state) || terminal.has(state))
256
+ return 'state not active';
257
+ if (this.running.has(issue.id))
258
+ return 'already running';
259
+ if (!ignoreOwnClaim && this.claimed.has(issue.id))
260
+ return 'already claimed';
261
+ if (!this.hasPerStateSlot(issue.state))
262
+ return 'no per-state slot';
263
+ if (state === 'todo' && this.hasNonTerminalBlocker(issue))
264
+ return 'has non-terminal blocker';
265
+ return null;
266
+ }
267
+ hasNonTerminalBlocker(issue) {
268
+ const terminal = new Set(this.cfg.tracker.terminal_states.map((s) => s.toLowerCase()));
269
+ for (const b of issue.blocked_by) {
270
+ if (!b.state)
271
+ return true;
272
+ if (!terminal.has(b.state.toLowerCase()))
273
+ return true;
274
+ }
275
+ return false;
276
+ }
277
+ availableGlobalSlots() {
278
+ return Math.max(0, this.cfg.agent.max_concurrent_agents - this.running.size);
279
+ }
280
+ /** §8.3: per-state slot accounting using current running entries. */
281
+ hasPerStateSlot(stateName) {
282
+ const cap = this.cfg.agent.max_concurrent_agents_by_state[stateName.toLowerCase()];
283
+ if (!cap)
284
+ return this.availableGlobalSlots() > 0;
285
+ let inState = 0;
286
+ for (const e of this.running.values()) {
287
+ if (e.issue.state.toLowerCase() === stateName.toLowerCase())
288
+ inState++;
289
+ }
290
+ return inState < cap && this.availableGlobalSlots() > 0;
291
+ }
292
+ /** §8.2 sort: priority ASC (null last), then created_at ASC, then identifier. */
293
+ sortForDispatch(issues) {
294
+ return [...issues].sort((a, b) => {
295
+ const pa = a.priority ?? Number.POSITIVE_INFINITY;
296
+ const pb = b.priority ?? Number.POSITIVE_INFINITY;
297
+ if (pa !== pb)
298
+ return pa - pb;
299
+ const ca = a.created_at ? Date.parse(a.created_at) : Number.POSITIVE_INFINITY;
300
+ const cb = b.created_at ? Date.parse(b.created_at) : Number.POSITIVE_INFINITY;
301
+ if (Number.isFinite(ca) && Number.isFinite(cb) && ca !== cb)
302
+ return ca - cb;
303
+ return a.identifier.localeCompare(b.identifier);
304
+ });
305
+ }
306
+ /** §16.4 dispatch_issue */
307
+ async dispatchIssue(issue, attempt, snapshot) {
308
+ if (this.running.has(issue.id))
309
+ return;
310
+ this.claimed.add(issue.id);
311
+ this.retryAttempts.delete(issue.id);
312
+ const cancel = { cancelled: false };
313
+ const startedAt = new Date().toISOString();
314
+ const workspacePath = this.workspaces.workspacePathFor(issue.identifier);
315
+ // Snapshot tracker.root and the terminal target BEFORE workspace setup,
316
+ // before_run, or smolvm bring-up. A WORKFLOW.md reload during that window
317
+ // (or even between fetchCandidateIssues returning and this iteration of
318
+ // the dispatch loop) can mutate the live tracker config; pinning here closes
319
+ // that window. When the caller supplies a snapshot (the tick/retry path
320
+ // does — it captured at the fetch atomically), prefer those values; the
321
+ // optional fallback reads the live config for completeness.
322
+ const trackerRootAtDispatch = snapshot?.trackerRoot ?? (this.tracker.currentRoot ? this.tracker.currentRoot() : null);
323
+ const terminalTargetAtDispatch = snapshot?.terminalTarget ?? pickTerminalTarget(this.cfg.tracker.terminal_states);
324
+ const entry = {
325
+ issue_id: issue.id,
326
+ identifier: issue.identifier,
327
+ issue,
328
+ session_id: null,
329
+ thread_id: null,
330
+ turn_id: null,
331
+ codex_app_server_pid: null,
332
+ last_codex_event: null,
333
+ last_codex_timestamp: null,
334
+ last_codex_message: null,
335
+ codex_input_tokens: 0,
336
+ codex_output_tokens: 0,
337
+ codex_total_tokens: 0,
338
+ last_reported_input_tokens: 0,
339
+ last_reported_output_tokens: 0,
340
+ last_reported_total_tokens: 0,
341
+ turn_count: 0,
342
+ retry_attempt: attempt,
343
+ started_at: startedAt,
344
+ workspace_path: workspacePath,
345
+ cancel: () => {
346
+ cancel.cancelled = true;
347
+ },
348
+ recent_events: [],
349
+ last_error: null,
350
+ cleanup_workspace_on_exit: false,
351
+ mcp_token: null,
352
+ tracker_root_at_dispatch: trackerRootAtDispatch,
353
+ terminal_target_at_dispatch: terminalTargetAtDispatch,
354
+ marked_done: false,
355
+ steering_requested: false,
356
+ steering_question: null,
357
+ steering_context: null,
358
+ };
359
+ this.running.set(issue.id, entry);
360
+ const logger = withIssue({ issue_id: issue.id, issue_identifier: issue.identifier });
361
+ logger.info('agent attempt started', { attempt });
362
+ void this.runWorker(issue, attempt, entry, cancel);
363
+ }
364
+ async runWorker(issue, attempt, entry, cancelSignal) {
365
+ const logger = withIssue({ issue_id: issue.id, issue_identifier: issue.identifier });
366
+ let ok = false;
367
+ let reason = 'unknown';
368
+ try {
369
+ const result = await this.runner.runAttempt(issue, attempt, cancelSignal, entry);
370
+ ok = result.ok;
371
+ reason = result.reason;
372
+ if (result.threadId)
373
+ entry.thread_id = result.threadId;
374
+ entry.turn_count = result.turnsCompleted;
375
+ }
376
+ catch (err) {
377
+ ok = false;
378
+ reason = err.message;
379
+ logger.error('worker threw', { error: reason });
380
+ }
381
+ this.onWorkerExit(issue.id, ok, reason, entry);
382
+ }
383
+ /** §16.6 on_worker_exit */
384
+ onWorkerExit(issueId, normal, reason, entry) {
385
+ this.running.delete(issueId);
386
+ const elapsedMs = Date.now() - Date.parse(entry.started_at);
387
+ if (Number.isFinite(elapsedMs)) {
388
+ this.codexTotals.seconds_running += elapsedMs / 1000;
389
+ }
390
+ const identifier = entry.identifier;
391
+ const logger = withIssue({ issue_id: issueId, issue_identifier: identifier });
392
+ if (entry.cleanup_workspace_on_exit) {
393
+ // Workspace removal is deferred until the worker has fully unwound (including
394
+ // after_run hook execution) so we never delete the dir while the agent is still
395
+ // inside it.
396
+ this.workspaces
397
+ .remove(entry.identifier, this.cfg.hooks)
398
+ .catch((err) => logger.warn('workspace removal failed', { error: err.message }));
399
+ }
400
+ // §14.2: if the service was stopped while this worker was unwinding, do not schedule
401
+ // a new retry — that would leave a live timer behind even though stop() was called.
402
+ if (this.stopped) {
403
+ this.claimed.delete(issueId);
404
+ return;
405
+ }
406
+ if (normal) {
407
+ this.completed.add(issueId);
408
+ logger.info('worker exited (normal)', { reason });
409
+ this.scheduleRetry(issueId, {
410
+ identifier,
411
+ attempt: 1,
412
+ delayMs: CONTINUATION_DELAY_MS,
413
+ error: null,
414
+ });
415
+ }
416
+ else {
417
+ const nextAttempt = entry.retry_attempt !== null && entry.retry_attempt !== undefined ? entry.retry_attempt + 1 : 1;
418
+ const delayMs = Math.min(FAILURE_BASE_MS * Math.pow(2, nextAttempt - 1), this.cfg.agent.max_retry_backoff_ms);
419
+ logger.warn('worker exited (abnormal)', { reason, next_attempt: nextAttempt, delay_ms: delayMs });
420
+ this.scheduleRetry(issueId, {
421
+ identifier,
422
+ attempt: nextAttempt,
423
+ delayMs,
424
+ error: reason,
425
+ });
426
+ }
427
+ }
428
+ /** §8.4 retry queue. */
429
+ scheduleRetry(issueId, sched) {
430
+ if (this.stopped)
431
+ return;
432
+ const existing = this.retryAttempts.get(issueId);
433
+ if (existing)
434
+ clearTimeout(existing.timer_handle);
435
+ const dueAt = Date.now() + sched.delayMs;
436
+ const handle = setTimeout(() => void this.onRetryTimer(issueId), sched.delayMs);
437
+ this.retryAttempts.set(issueId, {
438
+ issue_id: issueId,
439
+ identifier: sched.identifier,
440
+ attempt: sched.attempt,
441
+ due_at_ms: dueAt,
442
+ timer_handle: handle,
443
+ error: sched.error,
444
+ });
445
+ this.claimed.add(issueId);
446
+ }
447
+ /** §16.6 on_retry_timer */
448
+ async onRetryTimer(issueId) {
449
+ if (this.stopped)
450
+ return;
451
+ const entry = this.retryAttempts.get(issueId);
452
+ if (!entry)
453
+ return;
454
+ this.retryAttempts.delete(issueId);
455
+ let candidates;
456
+ let snapshotTrackerRoot;
457
+ let snapshotTerminalTarget;
458
+ try {
459
+ const result = await this.tracker.fetchCandidateIssues();
460
+ candidates = result.issues;
461
+ snapshotTrackerRoot = result.root;
462
+ snapshotTerminalTarget = pickTerminalTarget(result.terminalStates);
463
+ }
464
+ catch (err) {
465
+ log.debug('retry poll failed', {
466
+ issue_id: issueId,
467
+ issue_identifier: entry.identifier,
468
+ error: err.message,
469
+ });
470
+ this.scheduleRetry(issueId, {
471
+ identifier: entry.identifier,
472
+ attempt: entry.attempt + 1,
473
+ delayMs: Math.min(FAILURE_BASE_MS * Math.pow(2, entry.attempt), this.cfg.agent.max_retry_backoff_ms),
474
+ error: 'retry poll failed',
475
+ });
476
+ return;
477
+ }
478
+ const issue = candidates.find((i) => i.id === issueId);
479
+ if (!issue) {
480
+ this.claimed.delete(issueId);
481
+ log.info('retry releasing claim (not in candidates)', {
482
+ issue_id: issueId,
483
+ issue_identifier: entry.identifier,
484
+ });
485
+ return;
486
+ }
487
+ // Re-apply full candidate eligibility, ignoring this issue's own claim. This catches
488
+ // late-breaking issues like a new non-terminal blocker on a Todo or a state change to
489
+ // an inactive value that slipped past the candidate filter on edge tracker shapes.
490
+ const reason = this.eligibilityReason(issue, true);
491
+ if (reason !== null) {
492
+ if (reason === 'no per-state slot') {
493
+ this.scheduleRetry(issueId, {
494
+ identifier: issue.identifier,
495
+ attempt: entry.attempt + 1,
496
+ delayMs: Math.min(FAILURE_BASE_MS * Math.pow(2, entry.attempt), this.cfg.agent.max_retry_backoff_ms),
497
+ error: 'no available orchestrator slots',
498
+ });
499
+ return;
500
+ }
501
+ // For non-slot reasons (blocker, missing fields, non-active state), the right action
502
+ // is to release the claim rather than spin on it.
503
+ this.claimed.delete(issueId);
504
+ log.info('retry releasing claim (ineligible)', {
505
+ issue_id: issueId,
506
+ issue_identifier: entry.identifier,
507
+ reason,
508
+ });
509
+ return;
510
+ }
511
+ void this.dispatchIssue(issue, entry.attempt, {
512
+ trackerRoot: snapshotTrackerRoot,
513
+ terminalTarget: snapshotTerminalTarget,
514
+ });
515
+ }
516
+ /** §8.6 startup terminal workspace cleanup. */
517
+ async startupTerminalCleanup() {
518
+ try {
519
+ const terminals = await this.tracker.fetchIssuesByStates(this.cfg.tracker.terminal_states);
520
+ for (const issue of terminals) {
521
+ try {
522
+ await this.workspaces.remove(issue.identifier, this.cfg.hooks);
523
+ }
524
+ catch (err) {
525
+ log.warn('terminal cleanup failed for issue', {
526
+ issue_identifier: issue.identifier,
527
+ error: err.message,
528
+ });
529
+ }
530
+ }
531
+ }
532
+ catch (err) {
533
+ log.warn('startup terminal cleanup fetch failed', { error: err.message });
534
+ }
535
+ }
536
+ // Public hooks the runner uses to feed events back.
537
+ reportTokenUsage(issueId, usage) {
538
+ const e = this.running.get(issueId);
539
+ if (!e)
540
+ return;
541
+ // §13.5: prefer absolute totals; track deltas to avoid double-counting.
542
+ const dIn = Math.max(0, usage.input_tokens - e.last_reported_input_tokens);
543
+ const dOut = Math.max(0, usage.output_tokens - e.last_reported_output_tokens);
544
+ const dTot = Math.max(0, usage.total_tokens - e.last_reported_total_tokens);
545
+ e.codex_input_tokens = usage.input_tokens;
546
+ e.codex_output_tokens = usage.output_tokens;
547
+ e.codex_total_tokens = usage.total_tokens;
548
+ e.last_reported_input_tokens = usage.input_tokens;
549
+ e.last_reported_output_tokens = usage.output_tokens;
550
+ e.last_reported_total_tokens = usage.total_tokens;
551
+ this.codexTotals.input_tokens += dIn;
552
+ this.codexTotals.output_tokens += dOut;
553
+ this.codexTotals.total_tokens += dTot;
554
+ }
555
+ reportRateLimits(_issueId, snapshot) {
556
+ this.codexRateLimits = snapshot;
557
+ }
558
+ reportRuntimeEvent(issueId, ev) {
559
+ const e = this.running.get(issueId);
560
+ if (!e)
561
+ return;
562
+ e.last_codex_event = ev.event;
563
+ e.last_codex_timestamp = ev.at;
564
+ e.last_codex_message = ev.message;
565
+ e.recent_events.push(ev);
566
+ if (e.recent_events.length > 50)
567
+ e.recent_events.shift();
568
+ }
569
+ reportSessionStarted(issueId, info) {
570
+ const e = this.running.get(issueId);
571
+ if (!e)
572
+ return;
573
+ e.session_id = info.sessionId;
574
+ e.thread_id = info.threadId;
575
+ e.codex_app_server_pid = info.pid;
576
+ }
577
+ reportTurnStarted(issueId, turnNumber) {
578
+ const e = this.running.get(issueId);
579
+ if (!e)
580
+ return;
581
+ e.turn_count = turnNumber;
582
+ }
583
+ /** §13.3 snapshot. */
584
+ snapshot() {
585
+ const generatedAt = new Date().toISOString();
586
+ const liveExtraSeconds = [...this.running.values()]
587
+ .map((e) => (Date.now() - Date.parse(e.started_at)) / 1000)
588
+ .reduce((a, b) => a + b, 0);
589
+ return {
590
+ generated_at: generatedAt,
591
+ counts: { running: this.running.size, retrying: this.retryAttempts.size },
592
+ running: [...this.running.values()].map((e) => ({
593
+ issue_id: e.issue_id,
594
+ issue_identifier: e.identifier,
595
+ issue_title: e.issue.title ?? '',
596
+ issue_body: e.issue.description ?? '',
597
+ state: e.issue.state,
598
+ session_id: e.session_id,
599
+ turn_count: e.turn_count,
600
+ last_event: e.last_codex_event,
601
+ last_message: e.last_codex_message,
602
+ started_at: e.started_at,
603
+ last_event_at: e.last_codex_timestamp,
604
+ tokens: {
605
+ input_tokens: e.codex_input_tokens,
606
+ output_tokens: e.codex_output_tokens,
607
+ total_tokens: e.codex_total_tokens,
608
+ },
609
+ steering_requested: e.steering_requested,
610
+ steering_question: e.steering_question,
611
+ steering_context: e.steering_context,
612
+ marked_done: e.marked_done,
613
+ })),
614
+ retrying: [...this.retryAttempts.values()].map((r) => ({
615
+ issue_id: r.issue_id,
616
+ issue_identifier: r.identifier,
617
+ attempt: r.attempt,
618
+ due_at: new Date(r.due_at_ms).toISOString(),
619
+ error: r.error,
620
+ })),
621
+ codex_totals: {
622
+ ...this.codexTotals,
623
+ seconds_running: this.codexTotals.seconds_running + liveExtraSeconds,
624
+ },
625
+ rate_limits: this.codexRateLimits,
626
+ };
627
+ }
628
+ /** Issue-detail view used by the HTTP /api/v1/<identifier> endpoint. */
629
+ detailByIdentifier(identifier) {
630
+ let entry = null;
631
+ for (const e of this.running.values()) {
632
+ if (e.identifier === identifier) {
633
+ entry = e;
634
+ break;
635
+ }
636
+ }
637
+ let retry = null;
638
+ for (const r of this.retryAttempts.values()) {
639
+ if (r.identifier === identifier) {
640
+ retry = r;
641
+ break;
642
+ }
643
+ }
644
+ if (!entry && !retry)
645
+ return null;
646
+ return {
647
+ issue_identifier: identifier,
648
+ issue_id: entry?.issue_id ?? retry?.issue_id ?? null,
649
+ status: entry ? 'running' : 'retrying',
650
+ workspace: entry ? { path: entry.workspace_path } : null,
651
+ attempts: {
652
+ current_retry_attempt: retry?.attempt ?? null,
653
+ },
654
+ running: entry
655
+ ? {
656
+ session_id: entry.session_id,
657
+ turn_count: entry.turn_count,
658
+ state: entry.issue.state,
659
+ started_at: entry.started_at,
660
+ last_event: entry.last_codex_event,
661
+ last_message: entry.last_codex_message,
662
+ last_event_at: entry.last_codex_timestamp,
663
+ tokens: {
664
+ input_tokens: entry.codex_input_tokens,
665
+ output_tokens: entry.codex_output_tokens,
666
+ total_tokens: entry.codex_total_tokens,
667
+ },
668
+ }
669
+ : null,
670
+ retry: retry
671
+ ? {
672
+ attempt: retry.attempt,
673
+ due_at: new Date(retry.due_at_ms).toISOString(),
674
+ error: retry.error,
675
+ }
676
+ : null,
677
+ recent_events: entry?.recent_events ?? [],
678
+ last_error: entry?.last_error ?? retry?.error ?? null,
679
+ tracked: {},
680
+ };
681
+ }
682
+ }
683
+ //# sourceMappingURL=orchestrator.js.map