taskode 0.4.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,655 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { applyWorkspaceChanges, buildRunArtifacts } from './review.js';
4
+
5
+ export class Orchestrator {
6
+ constructor({ config, tracker, workspaceManager, agentRunner, store, workflowRuntime = null }) {
7
+ this.config = config;
8
+ this.tracker = tracker;
9
+ this.workspaceManager = workspaceManager;
10
+ this.agentRunner = agentRunner;
11
+ this.store = store;
12
+ this.workflowRuntime = workflowRuntime;
13
+ this.timer = null;
14
+ this.timerIntervalMs = null;
15
+ this.lastWorkflowReloadError = null;
16
+
17
+ const persistedRuntime = store.loadRuntime();
18
+ this.runtime = normalizeRuntime(config, persistedRuntime, store.listRetries());
19
+ this.recoverPersistedRuns();
20
+ }
21
+
22
+ start() {
23
+ if (this.timer) return;
24
+ this.tick();
25
+ this.syncTimerInterval();
26
+ }
27
+
28
+ stop() {
29
+ if (this.timer) clearInterval(this.timer);
30
+ this.timer = null;
31
+ this.timerIntervalMs = null;
32
+ this.store.saveRuntime(this.runtime);
33
+ }
34
+
35
+ getState() {
36
+ return {
37
+ ...this.runtime,
38
+ running: Object.values(this.runtime.running),
39
+ claimed: Object.values(this.runtime.claimed),
40
+ retries: Object.values(this.runtime.retries),
41
+ workflow: this.workflowRuntime?.getMetadata?.() || null
42
+ };
43
+ }
44
+
45
+ async tick() {
46
+ this.runtime.last_tick_at = new Date().toISOString();
47
+
48
+ try {
49
+ this.refreshWorkflowConfig();
50
+ await this.dispatchDueRetries();
51
+
52
+ const issues = await this.tracker.fetchCandidates();
53
+ const sorted = issues.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));
54
+
55
+ for (const issue of sorted) {
56
+ if (this.runtime.claimed[issue.id]) continue;
57
+
58
+ const task = this.store.getTask(issue.identifier);
59
+ if (issue.blocked_by?.length) {
60
+ if (task && task.status !== 'blocked') {
61
+ this.store.updateTask(task.id, { status: 'blocked' });
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (task?.status === 'blocked') {
67
+ this.store.updateTask(task.id, { status: 'todo' });
68
+ }
69
+
70
+ if (task && ['review', 'done', 'cancelled'].includes(task.status)) continue;
71
+
72
+ if (Object.keys(this.runtime.running).length >= this.config.maxConcurrentAgents) continue;
73
+ if (!stateSlotsAvailable(issue, this.runtime.running, this.config.maxConcurrentAgentsByState, this.config.maxConcurrentAgents)) continue;
74
+ if (!this.workerSlotsAvailable()) continue;
75
+
76
+ await this.dispatch(issue, false);
77
+ }
78
+
79
+ await this.reconcileRunningIssues();
80
+ this.store.saveRuntime(this.runtime);
81
+ } catch (error) {
82
+ this.store.appendSystemLog(`tick error: ${error.message}`);
83
+ }
84
+ }
85
+
86
+ async approveRun(runId) {
87
+ const run = this.store.getRun(runId);
88
+ if (!run) throw new Error(`Run not found: ${runId}`);
89
+ if (run.status !== 'succeeded') throw new Error('Only succeeded runs can be approved');
90
+
91
+ const task = this.store.getTask(run.issueIdentifier);
92
+ if (!run.workspacePath) throw new Error(`Run ${runId} has no workspace`);
93
+
94
+ const changes = applyWorkspaceChanges({
95
+ projectRoot: this.config.projectRoot,
96
+ workspacePath: run.workspacePath,
97
+ changes: run.changedFiles,
98
+ ignoreNames: this.config.workspaceIgnoreNames
99
+ });
100
+
101
+ this.store.updateRun(run.id, {
102
+ reviewStatus: 'approved',
103
+ appliedAt: new Date().toISOString(),
104
+ changedFiles: changes
105
+ });
106
+
107
+ if (task) {
108
+ this.store.updateTask(task.id, { status: 'done' });
109
+ }
110
+
111
+ await this.onRunApproved(run, task);
112
+
113
+ if (this.config.review.autoCleanupApproved) {
114
+ await this.workspaceManager.cleanupByPath(run.workspacePath);
115
+ }
116
+
117
+ this.store.appendAudit({
118
+ action: 'run_approved',
119
+ actor: 'system',
120
+ metadata: { runId: run.id, taskId: task?.id || null }
121
+ });
122
+
123
+ return this.store.getRun(run.id);
124
+ }
125
+
126
+ async rejectRun(runId, reason = '') {
127
+ const run = this.store.getRun(runId);
128
+ if (!run) throw new Error(`Run not found: ${runId}`);
129
+
130
+ const task = this.store.getTask(run.issueIdentifier);
131
+ this.store.updateRun(run.id, { reviewStatus: 'rejected' });
132
+
133
+ if (task) {
134
+ this.store.updateTask(task.id, { status: 'todo' });
135
+ }
136
+
137
+ await this.onRunRejected(run, task, reason);
138
+
139
+ this.store.appendAudit({
140
+ action: 'run_rejected',
141
+ actor: 'system',
142
+ metadata: { runId: run.id, taskId: task?.id || null, reason }
143
+ });
144
+
145
+ return this.store.getRun(run.id);
146
+ }
147
+
148
+ async requeueTask(taskId) {
149
+ const task = this.store.getTask(taskId);
150
+ if (!task) throw new Error(`Task not found: ${taskId}`);
151
+
152
+ this.store.updateTask(task.id, { status: 'todo' });
153
+ this.store.removeRetry(task.externalId || task.id);
154
+
155
+ this.store.appendAudit({
156
+ action: 'task_requeued',
157
+ actor: 'system',
158
+ metadata: { taskId: task.id }
159
+ });
160
+
161
+ return this.store.getTask(task.id);
162
+ }
163
+
164
+ recoverPersistedRuns() {
165
+ const staleRuns = Object.values(this.runtime.running);
166
+ if (!staleRuns.length) return;
167
+
168
+ for (const staleRun of staleRuns) {
169
+ const now = new Date().toISOString();
170
+ const attempt = staleRun.attempt || 1;
171
+ const task = this.store.getTask(staleRun.issue_identifier);
172
+
173
+ if (staleRun.run_id) {
174
+ this.store.updateRun(staleRun.run_id, {
175
+ status: 'interrupted',
176
+ summary: 'Run interrupted by orchestrator restart',
177
+ finishedAt: now,
178
+ error: 'orchestrator restart'
179
+ });
180
+ }
181
+
182
+ if (task && !['done', 'review', 'cancelled'].includes(task.status)) {
183
+ this.store.updateTask(task.id, { status: 'todo' });
184
+ }
185
+
186
+ if (attempt < this.config.maxRetryAttempts) {
187
+ const retryEntry = {
188
+ issue_id: staleRun.issue_id,
189
+ identifier: staleRun.issue_identifier,
190
+ attempt,
191
+ due_at_ms: Date.now(),
192
+ error: 'orchestrator restart'
193
+ };
194
+ this.runtime.retries[staleRun.issue_id] = retryEntry;
195
+ this.store.setRetry(staleRun.issue_id, retryEntry);
196
+ }
197
+
198
+ delete this.runtime.claimed[staleRun.issue_id];
199
+ delete this.runtime.running[staleRun.issue_id];
200
+ }
201
+
202
+ this.store.appendSystemLog(`Recovered ${staleRuns.length} interrupted runs after restart`);
203
+ this.store.saveRuntime(this.runtime);
204
+ }
205
+
206
+ async dispatchDueRetries() {
207
+ const dueRetries = Object.values(this.runtime.retries)
208
+ .filter((entry) => entry.due_at_ms <= Date.now())
209
+ .sort((a, b) => a.due_at_ms - b.due_at_ms);
210
+
211
+ for (const retry of dueRetries) {
212
+ if (Object.keys(this.runtime.running).length >= this.config.maxConcurrentAgents) return;
213
+ const issues = await this.tracker.fetchByIds([retry.issue_id]);
214
+ const issue = issues[0];
215
+
216
+ if (!issue) {
217
+ delete this.runtime.retries[retry.issue_id];
218
+ this.store.removeRetry(retry.issue_id);
219
+ continue;
220
+ }
221
+
222
+ if (!stateSlotsAvailable(issue, this.runtime.running, this.config.maxConcurrentAgentsByState, this.config.maxConcurrentAgents)) {
223
+ continue;
224
+ }
225
+
226
+ await this.dispatch(issue, true);
227
+ }
228
+ }
229
+
230
+ async dispatch(issue, fromRetry) {
231
+ const preferredWorkerHost = fromRetry ? this.runtime.retries[issue.id]?.worker_host || null : null;
232
+ const workerHost = this.selectWorkerHost(preferredWorkerHost);
233
+ if (workerHost === NO_WORKER_CAPACITY) {
234
+ return false;
235
+ }
236
+
237
+ const attempt = fromRetry ? (this.runtime.retries[issue.id]?.attempt || 1) + 1 : 1;
238
+ const run = this.store.createRun({
239
+ taskId: issue.id,
240
+ issueIdentifier: issue.identifier,
241
+ attempt,
242
+ status: 'running',
243
+ summary: `Running ${issue.identifier}`
244
+ });
245
+
246
+ this.runtime.claimed[issue.id] = { issue_id: issue.id, identifier: issue.identifier };
247
+ this.runtime.running[issue.id] = {
248
+ issue_id: issue.id,
249
+ issue_identifier: issue.identifier,
250
+ attempt,
251
+ run_id: run.id,
252
+ workspace_key: null,
253
+ issue_state: issue.state || null,
254
+ worker_host: workerHost,
255
+ started_at: new Date().toISOString()
256
+ };
257
+ this.store.updateRunSession(run.id, { workerHost });
258
+
259
+ const task = this.store.getTask(issue.identifier);
260
+ if (task) {
261
+ this.store.updateTask(task.id, { status: 'in_progress' });
262
+ }
263
+
264
+ if (fromRetry) this.runtime.metrics.retried += 1;
265
+ this.runtime.metrics.dispatched += 1;
266
+
267
+ this.store.appendAudit({
268
+ action: 'dispatch',
269
+ actor: 'system',
270
+ metadata: { issueId: issue.id, runId: run.id, attempt }
271
+ });
272
+
273
+ this.executeIssue(issue, run, workerHost).catch((error) => {
274
+ this.store.appendRunLog(run.id, `runner crash: ${error.message}`);
275
+ });
276
+ return true;
277
+ }
278
+
279
+ async executeIssue(issue, run, workerHost) {
280
+ let workspace = null;
281
+ let result = null;
282
+
283
+ try {
284
+ workspace = await this.workspaceManager.ensure(issue, workerHost);
285
+
286
+ if (this.runtime.running[issue.id]) {
287
+ this.runtime.running[issue.id].workspace_key = workspace.workspace_key;
288
+ this.runtime.running[issue.id].worker_host = workerHost;
289
+ }
290
+
291
+ this.store.updateRun(run.id, {
292
+ workspacePath: workspace.path,
293
+ workspaceKey: workspace.workspace_key
294
+ });
295
+
296
+ const prompt = renderPrompt(this.config.promptTemplate, issue);
297
+ this.store.appendRunLog(run.id, `workspace: ${workspace.path}`);
298
+ await this.workspaceManager.runBeforeRunHook(workspace.path, issue, workerHost);
299
+
300
+ result = await this.agentRunner.run({
301
+ issue,
302
+ workspacePath: workspace.path,
303
+ prompt,
304
+ runId: run.id,
305
+ tracker: this.tracker,
306
+ workerHost
307
+ });
308
+ } catch (error) {
309
+ result = {
310
+ status: 'failed',
311
+ code: 1,
312
+ output: '',
313
+ error: error.message,
314
+ durationMs: 0,
315
+ telemetry: {}
316
+ };
317
+ } finally {
318
+ if (workspace?.path) {
319
+ await this.workspaceManager.runAfterRunHook(workspace.path, issue, workerHost);
320
+ }
321
+ }
322
+
323
+ await this.completeRun(issue, run, workspace, result);
324
+ }
325
+
326
+ async completeRun(issue, run, workspace, result) {
327
+ const finishedAt = new Date().toISOString();
328
+ const task = this.store.getTask(issue.identifier);
329
+ const logFile = writeLogFile(this.config.logsRoot, run.id, result);
330
+ const artifacts = buildRunArtifacts({
331
+ projectRoot: this.config.projectRoot,
332
+ workspacePath: workspace?.path,
333
+ logsRoot: this.config.logsRoot,
334
+ runId: run.id,
335
+ ignoreNames: this.config.workspaceIgnoreNames
336
+ });
337
+
338
+ this.store.updateRunSession(run.id, result.telemetry || {});
339
+
340
+ if (result.status === 'succeeded') {
341
+ this.runtime.codex_totals = addTokenTotals(this.runtime.codex_totals, result.telemetry);
342
+ if (result.telemetry?.rateLimits) {
343
+ this.runtime.codex_rate_limits = result.telemetry.rateLimits;
344
+ }
345
+ this.runtime.metrics.succeeded += 1;
346
+ this.store.updateRun(run.id, {
347
+ status: 'succeeded',
348
+ summary: `Issue ${issue.identifier} completed in ${result.durationMs}ms`,
349
+ finishedAt,
350
+ workspacePath: workspace?.path || null,
351
+ workspaceKey: workspace?.workspace_key || null,
352
+ logFile,
353
+ diffFile: artifacts.diffFile,
354
+ changedFiles: artifacts.changedFiles,
355
+ diffSummary: artifacts.diffSummary,
356
+ reviewStatus: 'pending',
357
+ error: null
358
+ });
359
+
360
+ if (task) {
361
+ this.store.updateTask(task.id, { status: 'review' });
362
+ }
363
+
364
+ delete this.runtime.retries[issue.id];
365
+ this.store.removeRetry(issue.id);
366
+ await this.onIssueSuccess(issue, run);
367
+ } else {
368
+ this.runtime.codex_totals = addTokenTotals(this.runtime.codex_totals, result.telemetry);
369
+ if (result.telemetry?.rateLimits) {
370
+ this.runtime.codex_rate_limits = result.telemetry.rateLimits;
371
+ }
372
+ this.runtime.metrics.failed += 1;
373
+ const attempt = this.runtime.running[issue.id]?.attempt || 1;
374
+ const backoffMs = Math.min(this.config.maxRetryBackoffMs, 1000 * (2 ** attempt));
375
+ const workerHost = this.runtime.running[issue.id]?.worker_host || null;
376
+
377
+ this.store.updateRun(run.id, {
378
+ status: 'failed',
379
+ summary: `Issue ${issue.identifier} failed: ${result.error || 'unknown error'}`,
380
+ finishedAt,
381
+ workspacePath: workspace?.path || null,
382
+ workspaceKey: workspace?.workspace_key || null,
383
+ logFile,
384
+ diffFile: artifacts.diffFile,
385
+ changedFiles: artifacts.changedFiles,
386
+ diffSummary: artifacts.diffSummary,
387
+ reviewStatus: 'none',
388
+ error: result.error || 'unknown error'
389
+ });
390
+
391
+ if (attempt >= this.config.maxRetryAttempts) {
392
+ delete this.runtime.retries[issue.id];
393
+ this.store.removeRetry(issue.id);
394
+ if (task) {
395
+ this.store.updateTask(task.id, { status: 'failed' });
396
+ }
397
+ await this.onIssueFailure(issue, run, `max retry reached (${attempt})`);
398
+ } else {
399
+ const retryEntry = {
400
+ issue_id: issue.id,
401
+ identifier: issue.identifier,
402
+ attempt,
403
+ due_at_ms: Date.now() + backoffMs,
404
+ error: result.error || 'unknown error',
405
+ worker_host: workerHost
406
+ };
407
+
408
+ this.runtime.retries[issue.id] = retryEntry;
409
+ this.store.setRetry(issue.id, retryEntry);
410
+
411
+ if (task) {
412
+ this.store.updateTask(task.id, { status: 'todo' });
413
+ }
414
+
415
+ await this.onIssueFailure(issue, run, `retry in ${backoffMs}ms`);
416
+ }
417
+ }
418
+
419
+ delete this.runtime.running[issue.id];
420
+ delete this.runtime.claimed[issue.id];
421
+
422
+ this.store.appendAudit({
423
+ action: 'run_finished',
424
+ actor: 'system',
425
+ metadata: { issueId: issue.id, runId: run.id, status: result.status }
426
+ });
427
+ }
428
+
429
+ async onIssueSuccess(issue, run) {
430
+ if (this.config.tracker.kind === 'linear') {
431
+ await this.tracker.writeComment(issue.id, `Taskode run ${run.id} succeeded and is ready for review.`);
432
+ await this.tracker.transitionState(issue.id, 'Human Review');
433
+ return;
434
+ }
435
+
436
+ if (this.config.tracker.kind === 'github') {
437
+ await this.tracker.writeComment(issue.id, `Taskode run ${run.id} succeeded and is ready for review.`);
438
+ }
439
+ }
440
+
441
+ async onIssueFailure(issue, run, note) {
442
+ if (this.config.tracker.kind === 'linear') {
443
+ await this.tracker.writeComment(issue.id, `Taskode run ${run.id} failed: ${note}`);
444
+ await this.tracker.transitionState(issue.id, 'Rework');
445
+ return;
446
+ }
447
+
448
+ if (this.config.tracker.kind === 'github') {
449
+ await this.tracker.writeComment(issue.id, `Taskode run ${run.id} failed: ${note}`);
450
+ }
451
+ }
452
+
453
+ async onRunApproved(run, task) {
454
+ if (this.config.tracker.kind === 'linear') {
455
+ await this.tracker.writeComment(run.taskId, `Taskode run ${run.id} was approved and applied.`);
456
+ await this.tracker.transitionState(run.taskId, 'Done');
457
+ return;
458
+ }
459
+
460
+ if (this.config.tracker.kind === 'github') {
461
+ await this.tracker.writeComment(run.taskId, `Taskode run ${run.id} was approved and applied.`);
462
+ await this.tracker.transitionState(run.taskId, 'Done');
463
+ return;
464
+ }
465
+
466
+ if (task) {
467
+ this.store.appendAudit({
468
+ action: 'local_task_done',
469
+ actor: 'system',
470
+ metadata: { taskId: task.id, runId: run.id }
471
+ });
472
+ }
473
+ }
474
+
475
+ async onRunRejected(run, task, reason) {
476
+ const note = reason ? `: ${reason}` : '';
477
+
478
+ if (this.config.tracker.kind === 'linear') {
479
+ await this.tracker.writeComment(run.taskId, `Taskode run ${run.id} was rejected${note}`);
480
+ await this.tracker.transitionState(run.taskId, 'Rework');
481
+ return;
482
+ }
483
+
484
+ if (this.config.tracker.kind === 'github') {
485
+ await this.tracker.writeComment(run.taskId, `Taskode run ${run.id} was rejected${note}`);
486
+ }
487
+
488
+ if (task) {
489
+ this.store.appendAudit({
490
+ action: 'local_task_rework',
491
+ actor: 'system',
492
+ metadata: { taskId: task.id, runId: run.id, reason }
493
+ });
494
+ }
495
+ }
496
+
497
+ async reconcileRunningIssues() {
498
+ const runningIds = Object.keys(this.runtime.running);
499
+ if (!runningIds.length) return;
500
+
501
+ const trackerIssues = await this.tracker.fetchByIds(runningIds);
502
+ const trackerStateById = new Map(trackerIssues.map((issue) => [issue.id, issue]));
503
+
504
+ for (const issueId of runningIds) {
505
+ const issue = trackerStateById.get(issueId);
506
+ if (!issue) continue;
507
+
508
+ if (this.config.tracker.terminalStates.includes(issue.state)) {
509
+ const running = this.runtime.running[issueId];
510
+ this.runtime.metrics.stopped += 1;
511
+ delete this.runtime.running[issueId];
512
+ delete this.runtime.claimed[issueId];
513
+ delete this.runtime.retries[issueId];
514
+ this.store.removeRetry(issueId);
515
+
516
+ const task = this.store.getTask(issue.identifier);
517
+ if (task) {
518
+ this.store.updateTask(task.id, { status: 'done' });
519
+ }
520
+
521
+ if (running?.workspace_key) {
522
+ await this.workspaceManager.cleanup({
523
+ workspaceKey: running.workspace_key,
524
+ identifier: issue.identifier,
525
+ workerHost: running.worker_host || null
526
+ });
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ refreshWorkflowConfig() {
533
+ if (!this.workflowRuntime) return;
534
+
535
+ const { changed, error } = this.workflowRuntime.refresh();
536
+
537
+ if (error && error.message !== this.lastWorkflowReloadError) {
538
+ this.lastWorkflowReloadError = error.message;
539
+ this.store.appendSystemLog(`workflow reload failed: ${error.message}`);
540
+ return;
541
+ }
542
+
543
+ if (!error && this.lastWorkflowReloadError) {
544
+ this.store.appendSystemLog('workflow reload recovered');
545
+ this.lastWorkflowReloadError = null;
546
+ }
547
+
548
+ if (changed) {
549
+ this.runtime.poll_interval_ms = this.config.pollIntervalMs;
550
+ this.runtime.max_concurrent_agents = this.config.maxConcurrentAgents;
551
+ this.store.appendSystemLog(`workflow reloaded: ${this.config.workflowPath}`);
552
+ this.syncTimerInterval();
553
+ }
554
+ }
555
+
556
+ syncTimerInterval() {
557
+ if (!this.timer && this.timerIntervalMs === this.config.pollIntervalMs) return;
558
+ if (this.timer) clearInterval(this.timer);
559
+ this.timerIntervalMs = this.config.pollIntervalMs;
560
+ this.timer = setInterval(() => this.tick(), this.timerIntervalMs);
561
+ }
562
+
563
+ workerSlotsAvailable(preferredWorkerHost = null) {
564
+ return this.selectWorkerHost(preferredWorkerHost) !== NO_WORKER_CAPACITY;
565
+ }
566
+
567
+ selectWorkerHost(preferredWorkerHost = null) {
568
+ const hosts = this.config.worker?.sshHosts || [];
569
+ if (!hosts.length) {
570
+ return null;
571
+ }
572
+
573
+ const availableHosts = hosts.filter((host) => this.workerHostSlotsAvailable(host));
574
+ if (!availableHosts.length) {
575
+ return NO_WORKER_CAPACITY;
576
+ }
577
+
578
+ if (preferredWorkerHost && availableHosts.includes(preferredWorkerHost)) {
579
+ return preferredWorkerHost;
580
+ }
581
+
582
+ return availableHosts
583
+ .map((host, index) => ({ host, index, used: this.runningWorkerHostCount(host) }))
584
+ .sort((left, right) => left.used - right.used || left.index - right.index)[0]
585
+ .host;
586
+ }
587
+
588
+ workerHostSlotsAvailable(workerHost) {
589
+ const limit = this.config.worker?.maxConcurrentAgentsPerHost;
590
+ if (!limit) {
591
+ return true;
592
+ }
593
+ return this.runningWorkerHostCount(workerHost) < limit;
594
+ }
595
+
596
+ runningWorkerHostCount(workerHost) {
597
+ return Object.values(this.runtime.running).filter((entry) => entry.worker_host === workerHost).length;
598
+ }
599
+ }
600
+
601
+ function normalizeRuntime(config, persistedRuntime, retries) {
602
+ return {
603
+ poll_interval_ms: config.pollIntervalMs,
604
+ max_concurrent_agents: config.maxConcurrentAgents,
605
+ running: { ...(persistedRuntime.running || {}) },
606
+ claimed: { ...(persistedRuntime.claimed || {}) },
607
+ retries: retries || {},
608
+ last_tick_at: persistedRuntime.last_tick_at || null,
609
+ codex_totals: persistedRuntime.codex_totals || {
610
+ input_tokens: 0,
611
+ output_tokens: 0,
612
+ total_tokens: 0
613
+ },
614
+ codex_rate_limits: persistedRuntime.codex_rate_limits || null,
615
+ metrics: {
616
+ dispatched: Number(persistedRuntime.metrics?.dispatched || 0),
617
+ succeeded: Number(persistedRuntime.metrics?.succeeded || 0),
618
+ failed: Number(persistedRuntime.metrics?.failed || 0),
619
+ stopped: Number(persistedRuntime.metrics?.stopped || 0),
620
+ retried: Number(persistedRuntime.metrics?.retried || 0)
621
+ }
622
+ };
623
+ }
624
+
625
+ function renderPrompt(template, issue) {
626
+ return template
627
+ .replaceAll('{{ issue.identifier }}', issue.identifier || issue.id)
628
+ .replaceAll('{{ issue.title }}', issue.title || '')
629
+ .replaceAll('{{ issue.description }}', issue.description || '');
630
+ }
631
+
632
+ function writeLogFile(logsRoot, runId, result) {
633
+ fs.mkdirSync(logsRoot, { recursive: true });
634
+ const logFile = path.join(logsRoot, `${runId}.log`);
635
+ const body = [result.output, result.error].filter(Boolean).join('\n').trim();
636
+ fs.writeFileSync(logFile, body ? `${body}\n` : '', 'utf8');
637
+ return logFile;
638
+ }
639
+
640
+ function stateSlotsAvailable(issue, running, limits, fallbackLimit) {
641
+ const normalizedState = String(issue.state || '').trim().toLowerCase();
642
+ const limit = Number(limits?.[normalizedState] || fallbackLimit);
643
+ const used = Object.values(running).filter((entry) => String(entry.issue_state || '').trim().toLowerCase() === normalizedState).length;
644
+ return limit > used;
645
+ }
646
+
647
+ function addTokenTotals(currentTotals, telemetry) {
648
+ return {
649
+ input_tokens: Number(currentTotals?.input_tokens || 0) + Number(telemetry?.inputTokens || 0),
650
+ output_tokens: Number(currentTotals?.output_tokens || 0) + Number(telemetry?.outputTokens || 0),
651
+ total_tokens: Number(currentTotals?.total_tokens || 0) + Number(telemetry?.totalTokens || 0)
652
+ };
653
+ }
654
+
655
+ const NO_WORKER_CAPACITY = Symbol('no-worker-capacity');