@gh-symphony/cli 0.0.14 → 0.0.16

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.
Files changed (102) hide show
  1. package/dist/chunk-5NV3LSAJ.js +11 -0
  2. package/dist/chunk-6HBZC3BE.js +468 -0
  3. package/dist/chunk-76QPITKI.js +109 -0
  4. package/dist/chunk-EFMFGOWM.js +3575 -0
  5. package/dist/chunk-IWR4UQEJ.js +2250 -0
  6. package/dist/chunk-JO3AXHQI.js +130 -0
  7. package/dist/chunk-MHIWAIVD.js +876 -0
  8. package/dist/chunk-MVRF7BES.js +68 -0
  9. package/dist/chunk-ROGRTUFI.js +108 -0
  10. package/dist/chunk-TF3QNWNC.js +1121 -0
  11. package/dist/chunk-TH5QPO3Y.js +67 -0
  12. package/dist/config-cmd-AZ7POMAA.js +110 -0
  13. package/dist/index.d.ts +5 -4
  14. package/dist/index.js +568 -356
  15. package/dist/init-EZXQAXZM.js +17 -0
  16. package/dist/logs-6LNGT2GF.js +188 -0
  17. package/dist/project-557FE2GD.js +679 -0
  18. package/dist/recover-LVBI2TGH.js +131 -0
  19. package/dist/repo-R3XBIVAX.js +121 -0
  20. package/dist/run-WITYAYFZ.js +108 -0
  21. package/dist/start-JUFKNL3N.js +16 -0
  22. package/dist/status-3WK5BWRZ.js +11 -0
  23. package/dist/stop-AA3AP5M6.js +9 -0
  24. package/dist/version-VBB62JWI.js +30 -0
  25. package/dist/worker-entry.js +1828 -0
  26. package/package.json +9 -4
  27. package/dist/ansi.d.ts +0 -15
  28. package/dist/ansi.js +0 -53
  29. package/dist/commands/config-cmd.d.ts +0 -3
  30. package/dist/commands/config-cmd.js +0 -90
  31. package/dist/commands/help.d.ts +0 -3
  32. package/dist/commands/help.js +0 -55
  33. package/dist/commands/init.d.ts +0 -34
  34. package/dist/commands/init.js +0 -477
  35. package/dist/commands/logs.d.ts +0 -3
  36. package/dist/commands/logs.js +0 -184
  37. package/dist/commands/project.d.ts +0 -3
  38. package/dist/commands/project.js +0 -649
  39. package/dist/commands/recover.d.ts +0 -3
  40. package/dist/commands/recover.js +0 -119
  41. package/dist/commands/repo.d.ts +0 -3
  42. package/dist/commands/repo.js +0 -103
  43. package/dist/commands/run.d.ts +0 -3
  44. package/dist/commands/run.js +0 -95
  45. package/dist/commands/start.d.ts +0 -20
  46. package/dist/commands/start.js +0 -344
  47. package/dist/commands/status-refresh.d.ts +0 -9
  48. package/dist/commands/status-refresh.js +0 -27
  49. package/dist/commands/status.d.ts +0 -3
  50. package/dist/commands/status.js +0 -237
  51. package/dist/commands/stop.d.ts +0 -3
  52. package/dist/commands/stop.js +0 -92
  53. package/dist/commands/version.d.ts +0 -3
  54. package/dist/commands/version.js +0 -21
  55. package/dist/completion.d.ts +0 -1
  56. package/dist/completion.js +0 -204
  57. package/dist/config.d.ts +0 -38
  58. package/dist/config.js +0 -82
  59. package/dist/context/context-types.d.ts +0 -36
  60. package/dist/context/context-types.js +0 -1
  61. package/dist/context/generate-context-yaml.d.ts +0 -15
  62. package/dist/context/generate-context-yaml.js +0 -129
  63. package/dist/dashboard/renderer.d.ts +0 -9
  64. package/dist/dashboard/renderer.js +0 -220
  65. package/dist/detection/environment-detector.d.ts +0 -11
  66. package/dist/detection/environment-detector.js +0 -140
  67. package/dist/github/client.d.ts +0 -71
  68. package/dist/github/client.js +0 -348
  69. package/dist/github/gh-auth.d.ts +0 -34
  70. package/dist/github/gh-auth.js +0 -110
  71. package/dist/mapping/smart-defaults.d.ts +0 -17
  72. package/dist/mapping/smart-defaults.js +0 -86
  73. package/dist/orchestrator-runtime.d.ts +0 -1
  74. package/dist/orchestrator-runtime.js +0 -4
  75. package/dist/orchestrator-status-endpoint.d.ts +0 -5
  76. package/dist/orchestrator-status-endpoint.js +0 -27
  77. package/dist/project-selection.d.ts +0 -8
  78. package/dist/project-selection.js +0 -56
  79. package/dist/skills/skill-writer.d.ts +0 -14
  80. package/dist/skills/skill-writer.js +0 -62
  81. package/dist/skills/templates/commit.d.ts +0 -2
  82. package/dist/skills/templates/commit.js +0 -45
  83. package/dist/skills/templates/document.d.ts +0 -7
  84. package/dist/skills/templates/document.js +0 -16
  85. package/dist/skills/templates/gh-project.d.ts +0 -2
  86. package/dist/skills/templates/gh-project.js +0 -88
  87. package/dist/skills/templates/gh-symphony.d.ts +0 -2
  88. package/dist/skills/templates/gh-symphony.js +0 -125
  89. package/dist/skills/templates/index.d.ts +0 -8
  90. package/dist/skills/templates/index.js +0 -28
  91. package/dist/skills/templates/land.d.ts +0 -2
  92. package/dist/skills/templates/land.js +0 -59
  93. package/dist/skills/templates/pull.d.ts +0 -2
  94. package/dist/skills/templates/pull.js +0 -41
  95. package/dist/skills/templates/push.d.ts +0 -2
  96. package/dist/skills/templates/push.js +0 -36
  97. package/dist/skills/types.d.ts +0 -23
  98. package/dist/skills/types.js +0 -1
  99. package/dist/workflow/generate-reference-workflow.d.ts +0 -9
  100. package/dist/workflow/generate-reference-workflow.js +0 -261
  101. package/dist/workflow/generate-workflow-md.d.ts +0 -12
  102. package/dist/workflow/generate-workflow-md.js +0 -134
@@ -0,0 +1,3575 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DEFAULT_WORKFLOW_LIFECYCLE,
4
+ WorkflowConfigStore,
5
+ buildHookEnv,
6
+ buildProjectSnapshot,
7
+ buildPromptVariables,
8
+ createDefaultWorkflowResolution,
9
+ createInvalidWorkflowResolution,
10
+ deriveIssueWorkspaceKey,
11
+ deriveIssueWorkspaceKeyFromIdentifier,
12
+ deriveLegacyIssueWorkspaceKey,
13
+ executeWorkspaceHook,
14
+ isFileMissing,
15
+ isMatchingIssueRun,
16
+ isOrchestratorChannelEvent,
17
+ isStateActive,
18
+ isStateTerminal,
19
+ mapIssueOrchestrationStateToStatus,
20
+ matchesWorkflowState,
21
+ parseRecentEvents,
22
+ readEnvFile,
23
+ readJsonFile,
24
+ renderPrompt,
25
+ resolveIssueWorkspaceDirectory,
26
+ safeReadDir,
27
+ scheduleRetryAt
28
+ } from "./chunk-TF3QNWNC.js";
29
+
30
+ // ../orchestrator/dist/service.js
31
+ import { mkdir as mkdir3, readFile as readFile3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
32
+ import { createWriteStream, mkdirSync } from "fs";
33
+ import { spawn as spawn2 } from "child_process";
34
+ import { join as join3 } from "path";
35
+ import { StringDecoder } from "string_decoder";
36
+ import { fileURLToPath } from "url";
37
+
38
+ // ../orchestrator/dist/git.js
39
+ import { spawn } from "child_process";
40
+ import { randomUUID } from "crypto";
41
+ import { access, mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
42
+ import { constants } from "fs";
43
+ import { join } from "path";
44
+ var workflowConfigStore = new WorkflowConfigStore();
45
+ var LOCK_RETRY_MS = 100;
46
+ var LOCK_STALE_MS = 30 * 60 * 1e3;
47
+ var LOCK_TIMEOUT_MS = 2 * 60 * 1e3;
48
+ async function cloneRepositoryForRun(input) {
49
+ const result = await syncRepositoryForRun(input);
50
+ return result.repositoryDirectory;
51
+ }
52
+ async function syncRepositoryForRun(input) {
53
+ await mkdir(input.targetDirectory, { recursive: true });
54
+ const repositoryDirectory = join(input.targetDirectory, "repository");
55
+ const lockDirectory = join(input.targetDirectory, "repository.lock");
56
+ return withRepositoryLock(lockDirectory, async () => {
57
+ let hasGit = false;
58
+ try {
59
+ await access(join(repositoryDirectory, ".git"), constants.R_OK);
60
+ hasGit = true;
61
+ } catch {
62
+ }
63
+ if (hasGit) {
64
+ try {
65
+ const beforeHead = await readGitHead(repositoryDirectory);
66
+ await runCommand("git", [
67
+ "-C",
68
+ repositoryDirectory,
69
+ "pull",
70
+ "--ff-only"
71
+ ]);
72
+ const afterHead = await readGitHead(repositoryDirectory);
73
+ return {
74
+ repositoryDirectory,
75
+ changed: beforeHead !== afterHead
76
+ };
77
+ } catch {
78
+ await rm(repositoryDirectory, { recursive: true, force: true });
79
+ }
80
+ } else {
81
+ await rm(repositoryDirectory, { recursive: true, force: true });
82
+ }
83
+ const tempRepositoryDirectory = join(input.targetDirectory, `repository.tmp-${process.pid}-${Date.now()}`);
84
+ await rm(tempRepositoryDirectory, { recursive: true, force: true });
85
+ try {
86
+ await runCommand("git", [
87
+ "clone",
88
+ "--depth",
89
+ "1",
90
+ input.repository.cloneUrl,
91
+ tempRepositoryDirectory
92
+ ]);
93
+ await rename(tempRepositoryDirectory, repositoryDirectory);
94
+ return {
95
+ repositoryDirectory,
96
+ changed: true
97
+ };
98
+ } finally {
99
+ await rm(tempRepositoryDirectory, { recursive: true, force: true });
100
+ }
101
+ });
102
+ }
103
+ async function ensureIssueWorkspaceRepository(input) {
104
+ return cloneRepositoryForRun({
105
+ repository: input.repository,
106
+ targetDirectory: input.issueWorkspacePath
107
+ });
108
+ }
109
+ async function loadRepositoryWorkflow(repositoryDirectory, _repository) {
110
+ const workflowPath = join(repositoryDirectory, "WORKFLOW.md");
111
+ try {
112
+ return await workflowConfigStore.load(workflowPath);
113
+ } catch (error) {
114
+ if (isMissingFileError(error)) {
115
+ return createDefaultWorkflowResolution();
116
+ }
117
+ return createInvalidWorkflowResolution(workflowPath, error instanceof Error ? error.message : "workflow_parse_error");
118
+ }
119
+ }
120
+ function runCommand(command, args) {
121
+ return new Promise((resolve4, reject) => {
122
+ const child = spawn(command, args, {
123
+ stdio: "pipe"
124
+ });
125
+ let stderr = "";
126
+ child.stderr?.on("data", (chunk) => {
127
+ stderr += String(chunk);
128
+ });
129
+ child.once("error", reject);
130
+ child.once("exit", (code) => {
131
+ if (code === 0) {
132
+ resolve4();
133
+ return;
134
+ }
135
+ reject(new Error(stderr.trim() || `${command} exited with code ${code ?? "unknown"}`));
136
+ });
137
+ });
138
+ }
139
+ async function readGitHead(repositoryDirectory) {
140
+ try {
141
+ return await runCommandCapture("git", [
142
+ "-C",
143
+ repositoryDirectory,
144
+ "rev-parse",
145
+ "HEAD"
146
+ ]);
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+ function runCommandCapture(command, args) {
152
+ return new Promise((resolve4, reject) => {
153
+ const child = spawn(command, args, {
154
+ stdio: ["ignore", "pipe", "pipe"]
155
+ });
156
+ let stdout = "";
157
+ let stderr = "";
158
+ child.stdout?.on("data", (chunk) => {
159
+ stdout += String(chunk);
160
+ });
161
+ child.stderr?.on("data", (chunk) => {
162
+ stderr += String(chunk);
163
+ });
164
+ child.once("error", reject);
165
+ child.once("exit", (code) => {
166
+ if (code === 0) {
167
+ resolve4(stdout.trim());
168
+ return;
169
+ }
170
+ reject(new Error(stderr.trim() || `${command} exited with code ${code ?? "unknown"}`));
171
+ });
172
+ });
173
+ }
174
+ async function withRepositoryLock(lockDirectory, fn) {
175
+ const ownerToken = await acquireRepositoryLock(lockDirectory);
176
+ try {
177
+ return await fn();
178
+ } finally {
179
+ await releaseRepositoryLock(lockDirectory, ownerToken);
180
+ }
181
+ }
182
+ async function acquireRepositoryLock(lockDirectory) {
183
+ const startedAt = Date.now();
184
+ const ownerToken = `${process.pid}:${randomUUID()}`;
185
+ for (; ; ) {
186
+ try {
187
+ await mkdir(lockDirectory);
188
+ await writeFile(join(lockDirectory, "owner"), `${ownerToken}
189
+ ${(/* @__PURE__ */ new Date()).toISOString()}
190
+ `, "utf8");
191
+ return ownerToken;
192
+ } catch (error) {
193
+ if (!isAlreadyExistsError(error)) {
194
+ throw error;
195
+ }
196
+ }
197
+ const stale = await isStaleLock(lockDirectory);
198
+ if (stale) {
199
+ await rm(lockDirectory, { recursive: true, force: true });
200
+ continue;
201
+ }
202
+ if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
203
+ throw new Error(`Timed out waiting for repository cache lock: ${lockDirectory}`);
204
+ }
205
+ await wait(LOCK_RETRY_MS);
206
+ }
207
+ }
208
+ async function releaseRepositoryLock(lockDirectory, ownerToken) {
209
+ try {
210
+ const owner = await readLockOwner(lockDirectory);
211
+ if (owner !== ownerToken) {
212
+ return;
213
+ }
214
+ } catch (error) {
215
+ if (isMissingFileError(error)) {
216
+ return;
217
+ }
218
+ throw error;
219
+ }
220
+ await rm(lockDirectory, { recursive: true, force: true });
221
+ }
222
+ async function isStaleLock(lockDirectory) {
223
+ try {
224
+ const details = await stat(lockDirectory);
225
+ return Date.now() - details.mtimeMs >= LOCK_STALE_MS;
226
+ } catch (error) {
227
+ if (isMissingFileError(error)) {
228
+ return false;
229
+ }
230
+ throw error;
231
+ }
232
+ }
233
+ function isAlreadyExistsError(error) {
234
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
235
+ }
236
+ async function readLockOwner(lockDirectory) {
237
+ await access(join(lockDirectory, "owner"), constants.R_OK);
238
+ const owner = await readFile(join(lockDirectory, "owner"), "utf8");
239
+ return owner.split("\n", 1)[0] || null;
240
+ }
241
+ function wait(ms) {
242
+ return new Promise((resolve4) => {
243
+ setTimeout(resolve4, ms);
244
+ });
245
+ }
246
+ function isMissingFileError(error) {
247
+ return Boolean(error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"));
248
+ }
249
+
250
+ // ../orchestrator/dist/fs-store.js
251
+ import { mkdir as mkdir2, open, rename as rename2, rm as rm2, stat as stat2, writeFile as writeFile2, appendFile } from "fs/promises";
252
+ import { dirname, join as join2, relative, resolve } from "path";
253
+ var OrchestratorFsStore = class {
254
+ runtimeRoot;
255
+ resolvedRuntimeRoot;
256
+ resolvedEventsMirrorRoot;
257
+ constructor(runtimeRoot, options = {}) {
258
+ this.runtimeRoot = runtimeRoot;
259
+ this.resolvedRuntimeRoot = resolve(runtimeRoot);
260
+ this.resolvedEventsMirrorRoot = options.eventsMirrorRoot ? resolve(options.eventsMirrorRoot) : null;
261
+ }
262
+ projectsRoot() {
263
+ return join2(this.runtimeRoot, "projects");
264
+ }
265
+ projectDir(projectId) {
266
+ return join2(this.projectsRoot(), projectId);
267
+ }
268
+ projectRunsDir(projectId) {
269
+ return join2(this.projectDir(projectId), "runs");
270
+ }
271
+ runDir(runId, projectId) {
272
+ if (!projectId) {
273
+ return join2(this.runtimeRoot, "projects", "__unknown__", "runs", runId);
274
+ }
275
+ return join2(this.projectRunsDir(projectId), runId);
276
+ }
277
+ async loadProjectConfig(projectId) {
278
+ return readJsonFile(join2(this.projectDir(projectId), "project.json"));
279
+ }
280
+ async saveProjectConfig(config) {
281
+ await writeJsonFile(join2(this.projectDir(config.projectId), "project.json"), config);
282
+ }
283
+ async loadProjectIssueOrchestrations(projectId) {
284
+ const issuesPath = join2(this.projectDir(projectId), "issues.json");
285
+ const issues = await readJsonFile(issuesPath);
286
+ if (issues) {
287
+ return issues.map((issue) => ({
288
+ ...issue,
289
+ completedOnce: issue.completedOnce ?? false
290
+ }));
291
+ }
292
+ const legacyLeases = await readJsonFile(join2(this.projectDir(projectId), "leases.json")) ?? [];
293
+ if (legacyLeases.length === 0) {
294
+ return [];
295
+ }
296
+ const migratedIssues = legacyLeases.map((lease) => ({
297
+ issueId: lease.issueId,
298
+ identifier: lease.issueIdentifier,
299
+ workspaceKey: deriveIssueWorkspaceKeyFromIdentifier(lease.issueIdentifier),
300
+ completedOnce: false,
301
+ state: lease.status === "active" ? "claimed" : "released",
302
+ currentRunId: lease.status === "active" ? lease.runId : null,
303
+ retryEntry: null,
304
+ updatedAt: lease.updatedAt
305
+ }));
306
+ await this.saveProjectIssueOrchestrations(projectId, migratedIssues);
307
+ return migratedIssues;
308
+ }
309
+ async saveProjectIssueOrchestrations(projectId, issues) {
310
+ await writeJsonFile(join2(this.projectDir(projectId), "issues.json"), issues);
311
+ }
312
+ async saveProjectStatus(status) {
313
+ await writeJsonFile(join2(this.projectDir(status.projectId), "status.json"), status);
314
+ }
315
+ async loadProjectStatus(projectId) {
316
+ return await readJsonFile(join2(this.projectDir(projectId), "status.json")) ?? null;
317
+ }
318
+ async loadRun(runId, projectId) {
319
+ const runDirectory = projectId !== void 0 ? this.runDir(runId, projectId) : await this.findRunDir(runId);
320
+ if (!runDirectory) {
321
+ return null;
322
+ }
323
+ return await readJsonFile(join2(runDirectory, "run.json")) ?? null;
324
+ }
325
+ async loadAllRuns() {
326
+ const projectIds = await safeReadDir(this.projectsRoot());
327
+ const runDirectories = await Promise.all(projectIds.map(async (projectId) => {
328
+ const entries = await safeReadDir(this.projectRunsDir(projectId));
329
+ return entries.map((entry) => this.runDir(entry, projectId));
330
+ }));
331
+ const runs = await Promise.all(runDirectories.flat().map((directory) => readJsonFile(join2(directory, "run.json"))));
332
+ return runs.filter((run) => Boolean(run));
333
+ }
334
+ async saveRun(run) {
335
+ await writeJsonFile(join2(this.runDir(run.runId, run.projectId), "run.json"), run);
336
+ }
337
+ async appendRunEvent(runId, event) {
338
+ const resolvedProjectId = "projectId" in event && typeof event.projectId === "string" ? event.projectId : void 0;
339
+ const runDirectory = resolvedProjectId !== void 0 ? this.runDir(runId, resolvedProjectId) : await this.findRunDir(runId);
340
+ if (!runDirectory) {
341
+ throw new Error(`Unable to resolve run directory for event append: ${runId}`);
342
+ }
343
+ const path = join2(runDirectory, "events.ndjson");
344
+ const resolvedPath = resolve(path);
345
+ const serializedEvent = JSON.stringify(event) + "\n";
346
+ await mkdir2(dirname(path), { recursive: true });
347
+ await appendFile(path, serializedEvent, {
348
+ encoding: "utf8",
349
+ mode: 420
350
+ });
351
+ const mirrorPath = this.resolveMirroredEventsPath(resolvedPath);
352
+ if (!mirrorPath) {
353
+ return;
354
+ }
355
+ try {
356
+ await mkdir2(dirname(mirrorPath), { recursive: true });
357
+ await appendFile(mirrorPath, serializedEvent, {
358
+ encoding: "utf8",
359
+ mode: 420
360
+ });
361
+ } catch (error) {
362
+ console.warn(`Failed to mirror orchestrator event log to ${mirrorPath}: ${error instanceof Error ? error.message : String(error)}`);
363
+ }
364
+ }
365
+ async loadRecentRunEvents(runId, limit = 20, projectId) {
366
+ const runDirectory = projectId !== void 0 ? this.runDir(runId, projectId) : await this.findRunDir(runId);
367
+ if (!runDirectory) {
368
+ return [];
369
+ }
370
+ const path = join2(runDirectory, "events.ndjson");
371
+ try {
372
+ if (limit <= 0) {
373
+ return [];
374
+ }
375
+ const handle = await open(path, "r");
376
+ try {
377
+ const stats = await handle.stat();
378
+ let position = stats.size;
379
+ let tail = Buffer.alloc(0);
380
+ while (position > 0) {
381
+ const readSize = Math.min(position, 4096);
382
+ position -= readSize;
383
+ const chunk = Buffer.allocUnsafe(readSize);
384
+ await handle.read(chunk, 0, readSize, position);
385
+ tail = Buffer.concat([chunk, tail]);
386
+ const events = parseRecentEvents(tail.toString("utf8"), limit, {
387
+ allowPartialFirstLine: position > 0
388
+ });
389
+ if (events.length >= limit) {
390
+ return events;
391
+ }
392
+ }
393
+ return parseRecentEvents(tail.toString("utf8"), limit, {
394
+ allowPartialFirstLine: false
395
+ });
396
+ } finally {
397
+ await handle.close();
398
+ }
399
+ } catch (error) {
400
+ if (isFileMissing(error)) {
401
+ return [];
402
+ }
403
+ throw error;
404
+ }
405
+ }
406
+ issueWorkspaceDir(projectId, workspaceKey) {
407
+ return join2(this.projectDir(projectId), "issues", workspaceKey);
408
+ }
409
+ async loadIssueWorkspace(projectId, workspaceKey) {
410
+ return await readJsonFile(join2(this.issueWorkspaceDir(projectId, workspaceKey), "workspace.json")) ?? null;
411
+ }
412
+ async loadIssueWorkspaces(projectId) {
413
+ const issuesDir = join2(this.projectDir(projectId), "issues");
414
+ const entries = await safeReadDir(issuesDir);
415
+ const records = await Promise.all(entries.map((entry) => this.loadIssueWorkspace(projectId, entry)));
416
+ return records.filter((record) => Boolean(record));
417
+ }
418
+ async saveIssueWorkspace(record) {
419
+ await writeJsonFile(join2(this.issueWorkspaceDir(record.projectId, record.workspaceKey), "workspace.json"), record);
420
+ }
421
+ async removeIssueWorkspace(projectId, workspaceKey) {
422
+ const dir = this.issueWorkspaceDir(projectId, workspaceKey);
423
+ await rm2(dir, { recursive: true, force: true });
424
+ }
425
+ async findRunDir(runId) {
426
+ const projectIds = await safeReadDir(this.projectsRoot());
427
+ for (const projectId of projectIds) {
428
+ const candidate = this.runDir(runId, projectId);
429
+ const run = await readJsonFile(join2(candidate, "run.json"));
430
+ if (run || await pathExists(join2(candidate, "events.ndjson"))) {
431
+ return candidate;
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+ resolveMirroredEventsPath(primaryPath) {
437
+ if (!this.resolvedEventsMirrorRoot) {
438
+ return null;
439
+ }
440
+ const relativePath = relative(this.resolvedRuntimeRoot, primaryPath);
441
+ if (relativePath.startsWith("..")) {
442
+ return null;
443
+ }
444
+ const mirrorPath = join2(this.resolvedEventsMirrorRoot, relativePath);
445
+ return mirrorPath === primaryPath ? null : mirrorPath;
446
+ }
447
+ };
448
+ async function writeJsonFile(path, value) {
449
+ await mkdir2(dirname(path), { recursive: true });
450
+ const temporaryPath = `${path}.tmp`;
451
+ await writeFile2(temporaryPath, JSON.stringify(value, null, 2) + "\n", "utf8");
452
+ await rename2(temporaryPath, path);
453
+ }
454
+ async function pathExists(path) {
455
+ try {
456
+ await stat2(path);
457
+ return true;
458
+ } catch (error) {
459
+ if (isFileMissing(error)) {
460
+ return false;
461
+ }
462
+ throw error;
463
+ }
464
+ }
465
+
466
+ // ../tracker-github/dist/adapter.js
467
+ var DEFAULT_API_URL = "https://api.github.com/graphql";
468
+ var DEFAULT_PAGE_SIZE = 25;
469
+ var DEFAULT_NETWORK_TIMEOUT_MS = 3e4;
470
+ var GitHubTrackerError = class extends Error {
471
+ };
472
+ var GitHubTrackerHttpError = class extends GitHubTrackerError {
473
+ status;
474
+ details;
475
+ constructor(message, status, details) {
476
+ super(message);
477
+ this.status = status;
478
+ this.details = details;
479
+ }
480
+ };
481
+ var GitHubTrackerQueryError = class extends GitHubTrackerError {
482
+ };
483
+ function normalizeProjectItem(projectId, item, lifecycle = DEFAULT_WORKFLOW_LIFECYCLE, priority = {}, rateLimits = null) {
484
+ if (item.content?.__typename !== "Issue") {
485
+ return null;
486
+ }
487
+ const fieldValues = extractFieldValues(item.fieldValues?.nodes ?? []);
488
+ const state = fieldValues[lifecycle.stateFieldName] ?? "Unknown";
489
+ const repository = item.content.repository;
490
+ const blockedBy = (item.content.blockedBy?.nodes ?? []).flatMap((node) => node ? [
491
+ {
492
+ id: node.id,
493
+ identifier: `${node.repository.owner.login}/${node.repository.name}#${node.number}`,
494
+ state: normalizeBlockerState(node.state, lifecycle)
495
+ }
496
+ ] : []);
497
+ return {
498
+ id: item.content.id,
499
+ identifier: `${repository.owner.login}/${repository.name}#${item.content.number}`,
500
+ number: item.content.number,
501
+ title: item.content.title,
502
+ description: item.content.body,
503
+ priority: resolvePriority(item, priority),
504
+ state,
505
+ branchName: null,
506
+ url: item.content.url,
507
+ labels: (item.content.labels?.nodes ?? []).flatMap((label) => label?.name ? [label.name.toLowerCase()] : []).sort(),
508
+ blockedBy,
509
+ createdAt: item.content.createdAt,
510
+ updatedAt: item.content.updatedAt ?? item.updatedAt,
511
+ repository: {
512
+ owner: repository.owner.login,
513
+ name: repository.name,
514
+ url: repository.url,
515
+ cloneUrl: deriveCloneUrl(repository.url)
516
+ },
517
+ tracker: {
518
+ adapter: "github-project",
519
+ bindingId: projectId,
520
+ itemId: item.id
521
+ },
522
+ metadata: fieldValues,
523
+ rateLimits
524
+ };
525
+ }
526
+ async function fetchProjectIssues(config, fetchImpl = fetch) {
527
+ const issues = [];
528
+ let cursor = null;
529
+ const priorityOptionIds = config.priorityFieldName ? await fetchPriorityOptionOrder(config, config.priorityFieldName, fetchImpl) : void 0;
530
+ const currentUserLogin = config.assignedOnly ? await fetchCurrentUserLogin(config, fetchImpl) : null;
531
+ let excludedCount = 0;
532
+ let latestRateLimits = null;
533
+ do {
534
+ const pageResult = await fetchProjectItemsPage(config, cursor, fetchImpl);
535
+ const page = pageResult.page;
536
+ latestRateLimits = pageResult.rateLimits ?? latestRateLimits;
537
+ const pageIssues = (page.nodes ?? []).flatMap((item) => {
538
+ if (!item) {
539
+ return [];
540
+ }
541
+ const normalized = normalizeProjectItem(config.projectId, item, config.lifecycle, {
542
+ fieldName: config.priorityFieldName,
543
+ optionIds: priorityOptionIds
544
+ }, latestRateLimits);
545
+ if (!normalized) {
546
+ return [];
547
+ }
548
+ if (currentUserLogin && !isIssueAssignedToLogin(item, currentUserLogin)) {
549
+ excludedCount += 1;
550
+ return [];
551
+ }
552
+ return [normalized];
553
+ });
554
+ issues.push(...pageIssues);
555
+ cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
556
+ } while (cursor);
557
+ if (currentUserLogin) {
558
+ emitAssignedOnlyFilterEvent({
559
+ projectId: config.projectId,
560
+ currentUserLogin,
561
+ includedCount: issues.length,
562
+ excludedCount
563
+ });
564
+ }
565
+ if (latestRateLimits) {
566
+ for (const issue of issues) {
567
+ issue.rateLimits = latestRateLimits;
568
+ }
569
+ }
570
+ return issues;
571
+ }
572
+ async function fetchIssueStatesByIds(config, issueIds, fetchImpl = fetch) {
573
+ if (issueIds.length === 0) {
574
+ return [];
575
+ }
576
+ const issues = [];
577
+ for (const issueIdBatch of chunkValues([...new Set(issueIds)], 100)) {
578
+ const result = await executeGraphQLQueryWithMetadata(config, ISSUE_STATES_BY_IDS_QUERY, {
579
+ issueIds: issueIdBatch
580
+ }, fetchImpl);
581
+ const data = result.data;
582
+ const rateLimits = result.rateLimits;
583
+ for (const node of data.nodes ?? []) {
584
+ const projectItem = await resolveIssueProjectItemForStateLookup(config, node, fetchImpl);
585
+ const normalized = normalizeIssueStateLookupNode(config.projectId, node, projectItem, config.lifecycle, rateLimits);
586
+ if (normalized) {
587
+ issues.push(normalized);
588
+ }
589
+ }
590
+ }
591
+ return issues;
592
+ }
593
+ async function fetchProjectItemsPage(config, cursor, fetchImpl) {
594
+ const result = await executeGraphQLQueryWithMetadata(config, PROJECT_ITEMS_QUERY, {
595
+ projectId: config.projectId,
596
+ cursor,
597
+ pageSize: config.pageSize ?? DEFAULT_PAGE_SIZE
598
+ }, fetchImpl);
599
+ const data = result.data;
600
+ const items = data.node?.items;
601
+ if (!items) {
602
+ throw new GitHubTrackerQueryError("GitHub GraphQL response did not include project items.");
603
+ }
604
+ return {
605
+ page: items,
606
+ rateLimits: result.rateLimits
607
+ };
608
+ }
609
+ var fetchGithubProjectIssues = fetchProjectIssues;
610
+ var fetchGithubIssueStatesByIds = fetchIssueStatesByIds;
611
+ async function fetchCurrentUserLogin(config, fetchImpl) {
612
+ const response = await fetchImpl(resolveRestUserApiUrl(config.apiUrl), {
613
+ method: "GET",
614
+ headers: {
615
+ authorization: `Bearer ${config.token}`,
616
+ "user-agent": "gh-symphony",
617
+ accept: "application/vnd.github+json"
618
+ },
619
+ signal: buildRequestSignal(config.timeoutMs)
620
+ });
621
+ if (!response.ok) {
622
+ const details = await response.text();
623
+ throw new GitHubTrackerHttpError(`GitHub REST request failed with status ${response.status}`, response.status, details);
624
+ }
625
+ const payload = await response.json();
626
+ if (!payload.login) {
627
+ throw new GitHubTrackerQueryError("GitHub REST response did not include the authenticated user login.");
628
+ }
629
+ return payload.login;
630
+ }
631
+ function isIssueAssignedToLogin(item, login) {
632
+ if (item.content?.__typename !== "Issue") {
633
+ return false;
634
+ }
635
+ return (item.content.assignees?.nodes ?? []).some((assignee) => assignee?.login === login);
636
+ }
637
+ function emitAssignedOnlyFilterEvent(input) {
638
+ console.info(JSON.stringify({
639
+ event: "tracker-assigned-only-filtered",
640
+ projectId: input.projectId,
641
+ currentUserLogin: input.currentUserLogin,
642
+ includedCount: input.includedCount,
643
+ excludedCount: input.excludedCount
644
+ }));
645
+ }
646
+ function extractFieldValues(nodes) {
647
+ return nodes.reduce((values, node) => {
648
+ const fieldName = node?.field?.name;
649
+ if (!fieldName) {
650
+ return values;
651
+ }
652
+ if (node.__typename === "ProjectV2ItemFieldSingleSelectValue" && node.name) {
653
+ values[fieldName] = node.name;
654
+ }
655
+ if (node.__typename === "ProjectV2ItemFieldTextValue" && node.text) {
656
+ values[fieldName] = node.text;
657
+ }
658
+ return values;
659
+ }, {});
660
+ }
661
+ function normalizeIssueStateLookupNode(projectId, issue, projectItem, lifecycle = DEFAULT_WORKFLOW_LIFECYCLE, rateLimits = null) {
662
+ if (issue?.__typename !== "Issue") {
663
+ return null;
664
+ }
665
+ if (!projectItem) {
666
+ return null;
667
+ }
668
+ const fieldValues = extractFieldValues(projectItem.fieldValues?.nodes ?? []);
669
+ const state = fieldValues[lifecycle.stateFieldName] ?? "Unknown";
670
+ const repository = issue.repository;
671
+ const identifier = `${repository.owner.login}/${repository.name}#${issue.number}`;
672
+ return {
673
+ id: issue.id,
674
+ identifier,
675
+ number: issue.number,
676
+ title: identifier,
677
+ description: null,
678
+ priority: null,
679
+ state,
680
+ branchName: null,
681
+ url: `${repository.url}/issues/${issue.number}`,
682
+ labels: [],
683
+ blockedBy: [],
684
+ createdAt: null,
685
+ updatedAt: projectItem.updatedAt ?? issue.updatedAt,
686
+ repository: {
687
+ owner: repository.owner.login,
688
+ name: repository.name,
689
+ url: repository.url,
690
+ cloneUrl: deriveCloneUrl(repository.url)
691
+ },
692
+ tracker: {
693
+ adapter: "github-project",
694
+ bindingId: projectId,
695
+ itemId: projectItem.id
696
+ },
697
+ metadata: fieldValues,
698
+ rateLimits
699
+ };
700
+ }
701
+ async function resolveIssueProjectItemForStateLookup(config, issue, fetchImpl) {
702
+ if (issue?.__typename !== "Issue") {
703
+ return null;
704
+ }
705
+ let connection = issue.projectItems;
706
+ let projectItem = findProjectItemByProjectId(connection?.nodes ?? [], config.projectId);
707
+ let cursor = connection?.pageInfo.endCursor ?? null;
708
+ while (!projectItem && connection?.pageInfo.hasNextPage) {
709
+ const nextPage = await fetchIssueProjectItemsPage(config, issue.id, cursor, fetchImpl);
710
+ projectItem = findProjectItemByProjectId(nextPage.nodes ?? [], config.projectId);
711
+ connection = nextPage;
712
+ cursor = nextPage.pageInfo.endCursor;
713
+ }
714
+ return projectItem;
715
+ }
716
+ async function fetchIssueProjectItemsPage(config, issueId, cursor, fetchImpl) {
717
+ const result = await executeGraphQLQueryWithMetadata(config, ISSUE_PROJECT_ITEMS_PAGE_QUERY, {
718
+ issueId,
719
+ cursor
720
+ }, fetchImpl);
721
+ const data = result.data;
722
+ const issue = data.node;
723
+ if (issue?.__typename !== "Issue" || !issue.projectItems) {
724
+ throw new GitHubTrackerQueryError("GitHub GraphQL response did not include issue project items.");
725
+ }
726
+ return issue.projectItems;
727
+ }
728
+ function findProjectItemByProjectId(nodes, projectId) {
729
+ return nodes.find((item) => item?.project?.id === projectId) ?? null;
730
+ }
731
+ function resolvePriority(item, priority) {
732
+ if (!priority.fieldName || !priority.optionIds) {
733
+ return null;
734
+ }
735
+ for (const node of item.fieldValues?.nodes ?? []) {
736
+ if (node?.__typename === "ProjectV2ItemFieldSingleSelectValue" && node.field?.name === priority.fieldName && node.optionId) {
737
+ return priority.optionIds[node.optionId] ?? null;
738
+ }
739
+ }
740
+ return null;
741
+ }
742
+ function extractPriorityOptionOrder(fields, priorityFieldName) {
743
+ for (const field of fields) {
744
+ if (isSingleSelectProjectField(field) && field.name === priorityFieldName) {
745
+ let nextPriority = 0;
746
+ const optionEntries = (field.options ?? []).flatMap((option) => {
747
+ if (!option?.id) {
748
+ return [];
749
+ }
750
+ const entry = [option.id, nextPriority];
751
+ nextPriority += 1;
752
+ return [entry];
753
+ });
754
+ return Object.fromEntries(optionEntries);
755
+ }
756
+ }
757
+ return void 0;
758
+ }
759
+ async function fetchPriorityOptionOrder(config, priorityFieldName, fetchImpl) {
760
+ const data = await executeGraphQLQuery(config, PROJECT_FIELDS_QUERY, { projectId: config.projectId }, fetchImpl);
761
+ return extractPriorityOptionOrder(data.node?.fields?.nodes ?? [], priorityFieldName);
762
+ }
763
+ function isSingleSelectProjectField(field) {
764
+ return field?.__typename === "ProjectV2SingleSelectField";
765
+ }
766
+ function deriveCloneUrl(repositoryUrl) {
767
+ if (repositoryUrl.startsWith("file://") || repositoryUrl.endsWith(".git")) {
768
+ return repositoryUrl;
769
+ }
770
+ return `${repositoryUrl}.git`;
771
+ }
772
+ function normalizeBlockerState(state, lifecycle) {
773
+ if (!state) {
774
+ return null;
775
+ }
776
+ const normalized = state.trim().toLowerCase();
777
+ if (normalized === "closed") {
778
+ return lifecycle.terminalStates[0] ?? state;
779
+ }
780
+ if (normalized === "open") {
781
+ return null;
782
+ }
783
+ return state;
784
+ }
785
+ function resolveRestUserApiUrl(apiUrl) {
786
+ const parsed = new URL(apiUrl ?? DEFAULT_API_URL);
787
+ const pathSegments = parsed.pathname.split("/").filter(Boolean);
788
+ if (pathSegments.at(-1) === "graphql") {
789
+ pathSegments.pop();
790
+ }
791
+ parsed.pathname = `/${pathSegments.join("/")}/user`.replace(/\/{2,}/g, "/");
792
+ parsed.search = "";
793
+ parsed.hash = "";
794
+ return parsed.toString();
795
+ }
796
+ function chunkValues(values, size) {
797
+ const chunks = [];
798
+ for (let index = 0; index < values.length; index += size) {
799
+ chunks.push(values.slice(index, index + size));
800
+ }
801
+ return chunks;
802
+ }
803
+ function buildRequestSignal(timeoutMs) {
804
+ return AbortSignal.timeout(resolveNetworkTimeoutMs(timeoutMs));
805
+ }
806
+ function resolveNetworkTimeoutMs(timeoutMs) {
807
+ if (timeoutMs !== void 0 && Number.isInteger(timeoutMs) && timeoutMs > 0) {
808
+ return timeoutMs;
809
+ }
810
+ return DEFAULT_NETWORK_TIMEOUT_MS;
811
+ }
812
+ async function executeGraphQLQuery(config, query, variables, fetchImpl) {
813
+ const result = await executeGraphQLQueryWithMetadata(config, query, variables, fetchImpl);
814
+ return result.data;
815
+ }
816
+ async function executeGraphQLQueryWithMetadata(config, query, variables, fetchImpl) {
817
+ const response = await fetchImpl(config.apiUrl ?? DEFAULT_API_URL, {
818
+ method: "POST",
819
+ headers: {
820
+ "content-type": "application/json",
821
+ authorization: `Bearer ${config.token}`
822
+ },
823
+ body: JSON.stringify({
824
+ query,
825
+ variables
826
+ }),
827
+ signal: buildRequestSignal(config.timeoutMs)
828
+ });
829
+ if (!response.ok) {
830
+ const details = await response.text();
831
+ throw new GitHubTrackerHttpError(`GitHub GraphQL request failed with status ${response.status}`, response.status, details);
832
+ }
833
+ const payload = await response.json();
834
+ if (payload.errors?.length) {
835
+ throw new GitHubTrackerQueryError(payload.errors.map((error) => error.message).join("; "));
836
+ }
837
+ if (!payload.data) {
838
+ throw new GitHubTrackerQueryError("GitHub GraphQL response did not include data.");
839
+ }
840
+ const data = payload.data;
841
+ return {
842
+ data,
843
+ rateLimits: extractGitHubRateLimits(response.headers)
844
+ };
845
+ }
846
+ function extractGitHubRateLimits(headers) {
847
+ if (!headers || typeof headers.get !== "function") {
848
+ return null;
849
+ }
850
+ const limit = parseIntegerHeader(headers.get("x-ratelimit-limit"));
851
+ const remaining = parseIntegerHeader(headers.get("x-ratelimit-remaining"));
852
+ const used = parseIntegerHeader(headers.get("x-ratelimit-used"));
853
+ const reset = parseIntegerHeader(headers.get("x-ratelimit-reset"));
854
+ const resource = headers.get("x-ratelimit-resource");
855
+ if (limit === null && remaining === null && used === null && reset === null && resource === null) {
856
+ return null;
857
+ }
858
+ return {
859
+ source: "github",
860
+ limit,
861
+ remaining,
862
+ used,
863
+ reset,
864
+ resetAt: reset === null ? null : new Date(reset * 1e3).toISOString(),
865
+ resource
866
+ };
867
+ }
868
+ function parseIntegerHeader(value) {
869
+ if (value === null) {
870
+ return null;
871
+ }
872
+ const parsed = Number.parseInt(value, 10);
873
+ return Number.isFinite(parsed) ? parsed : null;
874
+ }
875
+ var PROJECT_ITEMS_QUERY = `
876
+ query ProjectItems($projectId: ID!, $cursor: String, $pageSize: Int!) {
877
+ node(id: $projectId) {
878
+ __typename
879
+ ... on ProjectV2 {
880
+ items(first: $pageSize, after: $cursor) {
881
+ nodes {
882
+ id
883
+ updatedAt
884
+ fieldValues(first: 20) {
885
+ nodes {
886
+ __typename
887
+ ... on ProjectV2ItemFieldSingleSelectValue {
888
+ name
889
+ optionId
890
+ field {
891
+ ... on ProjectV2SingleSelectField {
892
+ name
893
+ }
894
+ }
895
+ }
896
+ ... on ProjectV2ItemFieldTextValue {
897
+ text
898
+ field {
899
+ ... on ProjectV2FieldCommon {
900
+ name
901
+ }
902
+ }
903
+ }
904
+ }
905
+ }
906
+ content {
907
+ __typename
908
+ ... on Issue {
909
+ id
910
+ number
911
+ title
912
+ body
913
+ url
914
+ createdAt
915
+ updatedAt
916
+ labels(first: 20) {
917
+ nodes {
918
+ name
919
+ }
920
+ }
921
+ assignees(first: 20) {
922
+ nodes {
923
+ login
924
+ }
925
+ }
926
+ repository {
927
+ name
928
+ url
929
+ owner {
930
+ login
931
+ }
932
+ }
933
+ blockedBy(first: 100) {
934
+ nodes {
935
+ id
936
+ number
937
+ state
938
+ repository {
939
+ name
940
+ owner {
941
+ login
942
+ }
943
+ }
944
+ }
945
+ }
946
+ }
947
+ }
948
+ }
949
+ pageInfo {
950
+ endCursor
951
+ hasNextPage
952
+ }
953
+ }
954
+ }
955
+ }
956
+ }
957
+ `;
958
+ var PROJECT_FIELDS_QUERY = `
959
+ query ProjectFields($projectId: ID!) {
960
+ node(id: $projectId) {
961
+ __typename
962
+ ... on ProjectV2 {
963
+ fields(first: 100) {
964
+ nodes {
965
+ __typename
966
+ ... on ProjectV2SingleSelectField {
967
+ name
968
+ options {
969
+ id
970
+ name
971
+ }
972
+ }
973
+ }
974
+ }
975
+ }
976
+ }
977
+ }
978
+ `;
979
+ var ISSUE_STATES_BY_IDS_QUERY = `
980
+ query IssueStatesByIds($issueIds: [ID!]!) {
981
+ nodes(ids: $issueIds) {
982
+ __typename
983
+ ... on Issue {
984
+ id
985
+ number
986
+ updatedAt
987
+ repository {
988
+ name
989
+ url
990
+ owner {
991
+ login
992
+ }
993
+ }
994
+ projectItems(first: 100, includeArchived: false) {
995
+ nodes {
996
+ id
997
+ updatedAt
998
+ project {
999
+ id
1000
+ }
1001
+ fieldValues(first: 20) {
1002
+ nodes {
1003
+ __typename
1004
+ ... on ProjectV2ItemFieldSingleSelectValue {
1005
+ name
1006
+ optionId
1007
+ field {
1008
+ ... on ProjectV2SingleSelectField {
1009
+ name
1010
+ }
1011
+ }
1012
+ }
1013
+ ... on ProjectV2ItemFieldTextValue {
1014
+ text
1015
+ field {
1016
+ ... on ProjectV2FieldCommon {
1017
+ name
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+ }
1023
+ }
1024
+ pageInfo {
1025
+ endCursor
1026
+ hasNextPage
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+ `;
1033
+ var ISSUE_PROJECT_ITEMS_PAGE_QUERY = `
1034
+ query IssueProjectItemsPage($issueId: ID!, $cursor: String) {
1035
+ node(id: $issueId) {
1036
+ __typename
1037
+ ... on Issue {
1038
+ id
1039
+ number
1040
+ updatedAt
1041
+ repository {
1042
+ name
1043
+ url
1044
+ owner {
1045
+ login
1046
+ }
1047
+ }
1048
+ projectItems(first: 100, after: $cursor, includeArchived: false) {
1049
+ nodes {
1050
+ id
1051
+ updatedAt
1052
+ project {
1053
+ id
1054
+ }
1055
+ fieldValues(first: 20) {
1056
+ nodes {
1057
+ __typename
1058
+ ... on ProjectV2ItemFieldSingleSelectValue {
1059
+ name
1060
+ optionId
1061
+ field {
1062
+ ... on ProjectV2SingleSelectField {
1063
+ name
1064
+ }
1065
+ }
1066
+ }
1067
+ ... on ProjectV2ItemFieldTextValue {
1068
+ text
1069
+ field {
1070
+ ... on ProjectV2FieldCommon {
1071
+ name
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+ pageInfo {
1079
+ endCursor
1080
+ hasNextPage
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+ `;
1087
+
1088
+ // ../tracker-github/dist/orchestrator-adapter.js
1089
+ import { createHash } from "crypto";
1090
+ var githubProjectTrackerAdapter = {
1091
+ async listIssues(project, dependencies = {}) {
1092
+ return listProjectIssues(project, dependencies);
1093
+ },
1094
+ async listIssuesByStates(project, states, dependencies = {}) {
1095
+ if (states.length === 0) {
1096
+ return [];
1097
+ }
1098
+ const issues = await listProjectIssues(project, dependencies);
1099
+ const normalizedStates = new Set(states.map((state) => state.trim().toLowerCase()));
1100
+ return issues.filter((issue) => normalizedStates.has(issue.state.trim().toLowerCase()));
1101
+ },
1102
+ async fetchIssueStatesByIds(project, issueIds, dependencies = {}) {
1103
+ if (issueIds.length === 0) {
1104
+ return [];
1105
+ }
1106
+ return fetchProjectIssueStatesByIds(project, issueIds, dependencies);
1107
+ },
1108
+ buildWorkerEnvironment(project) {
1109
+ return {
1110
+ GITHUB_PROJECT_ID: requireTrackerSetting(project.tracker, "projectId")
1111
+ };
1112
+ },
1113
+ reviveIssue(project, run) {
1114
+ return {
1115
+ id: run.issueId,
1116
+ identifier: run.issueIdentifier,
1117
+ number: parseIssueNumber(run.issueIdentifier),
1118
+ title: run.issueTitle ?? run.issueIdentifier,
1119
+ description: null,
1120
+ priority: null,
1121
+ state: run.issueState,
1122
+ branchName: null,
1123
+ url: null,
1124
+ labels: [],
1125
+ blockedBy: [],
1126
+ createdAt: null,
1127
+ updatedAt: null,
1128
+ repository: run.repository,
1129
+ tracker: {
1130
+ adapter: "github-project",
1131
+ bindingId: project.tracker.bindingId,
1132
+ itemId: run.issueId
1133
+ },
1134
+ metadata: {}
1135
+ };
1136
+ }
1137
+ };
1138
+ async function listProjectIssues(project, dependencies = {}) {
1139
+ const trackerConfig = resolveGitHubTrackerConfig(project, dependencies);
1140
+ const loadProjectIssues = () => fetchGithubProjectIssues(trackerConfig, dependencies.fetchImpl);
1141
+ return dependencies.projectItemsCache?.getOrLoad(buildProjectItemsCacheKey(trackerConfig, dependencies), loadProjectIssues) ?? loadProjectIssues();
1142
+ }
1143
+ async function fetchProjectIssueStatesByIds(project, issueIds, dependencies = {}) {
1144
+ const trackerConfig = resolveGitHubTrackerConfig(project, dependencies);
1145
+ return fetchGithubIssueStatesByIds(trackerConfig, [...issueIds], dependencies.fetchImpl);
1146
+ }
1147
+ function resolveGitHubTrackerConfig(project, dependencies = {}) {
1148
+ const token = dependencies.token ?? process.env.GITHUB_GRAPHQL_TOKEN;
1149
+ if (!token) {
1150
+ throw new Error("GITHUB_GRAPHQL_TOKEN environment variable is required. Run 'gh auth token' or set the variable.");
1151
+ }
1152
+ const githubProjectId = requireTrackerSetting(project.tracker, "projectId");
1153
+ return {
1154
+ projectId: githubProjectId,
1155
+ token,
1156
+ apiUrl: project.tracker.apiUrl,
1157
+ assignedOnly: readBooleanTrackerSetting(project.tracker, "assignedOnly"),
1158
+ priorityFieldName: readOptionalStringTrackerSetting(project.tracker, "priorityFieldName"),
1159
+ timeoutMs: readNumberTrackerSetting(project.tracker, "timeoutMs")
1160
+ };
1161
+ }
1162
+ function buildProjectItemsCacheKey(config, _dependencies) {
1163
+ return JSON.stringify({
1164
+ adapter: "github-project",
1165
+ apiUrl: config.apiUrl,
1166
+ assignedOnly: config.assignedOnly ?? false,
1167
+ priorityFieldName: config.priorityFieldName ?? null,
1168
+ projectId: config.projectId,
1169
+ timeoutMs: config.timeoutMs,
1170
+ tokenFingerprint: hashToken(config.token)
1171
+ });
1172
+ }
1173
+ function hashToken(token) {
1174
+ if (!token) {
1175
+ return null;
1176
+ }
1177
+ return createHash("sha256").update(token).digest("hex");
1178
+ }
1179
+ var trackerAdapters = {
1180
+ "github-project": githubProjectTrackerAdapter
1181
+ };
1182
+ function resolveTrackerAdapter(tracker) {
1183
+ const adapter = trackerAdapters[tracker.adapter];
1184
+ if (!adapter) {
1185
+ throw new Error(`Unsupported tracker adapter: ${tracker.adapter}`);
1186
+ }
1187
+ return adapter;
1188
+ }
1189
+ function requireTrackerSetting(tracker, key) {
1190
+ const value = tracker.settings?.[key];
1191
+ if (typeof value !== "string" || value.length === 0) {
1192
+ throw new Error(`Tracker adapter "${tracker.adapter}" requires the "${key}" setting.`);
1193
+ }
1194
+ return value;
1195
+ }
1196
+ function readBooleanTrackerSetting(tracker, key) {
1197
+ const value = tracker.settings?.[key];
1198
+ return value === true || value === "true";
1199
+ }
1200
+ function readNumberTrackerSetting(tracker, key) {
1201
+ const value = tracker.settings?.[key];
1202
+ if (value === void 0) {
1203
+ return void 0;
1204
+ }
1205
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
1206
+ return value;
1207
+ }
1208
+ if (typeof value === "string") {
1209
+ const parsed = Number(value);
1210
+ if (Number.isInteger(parsed) && parsed > 0) {
1211
+ return parsed;
1212
+ }
1213
+ }
1214
+ throw new Error(`Tracker adapter "${tracker.adapter}" requires the "${key}" setting to be a positive integer when provided.`);
1215
+ }
1216
+ function readOptionalStringTrackerSetting(tracker, key) {
1217
+ const value = tracker.settings?.[key];
1218
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1219
+ }
1220
+ function parseIssueNumber(identifier) {
1221
+ const match = identifier.match(/#(\d+)$/);
1222
+ return match ? Number.parseInt(match[1] ?? "0", 10) : 0;
1223
+ }
1224
+
1225
+ // ../tracker-file/dist/file-tracker-adapter.js
1226
+ import { readFile as readFile2 } from "fs/promises";
1227
+ function requireTrackerSetting2(project, key) {
1228
+ const value = project.tracker.settings?.[key];
1229
+ if (typeof value !== "string" || value.length === 0) {
1230
+ throw new Error(`Tracker adapter "file" requires the "${key}" setting.`);
1231
+ }
1232
+ return value;
1233
+ }
1234
+ function parseIssueNumber2(identifier) {
1235
+ const match = identifier.match(/#(\d+)$/);
1236
+ return match ? Number.parseInt(match[1] ?? "0", 10) : 0;
1237
+ }
1238
+ function isValidIssueShape(entry) {
1239
+ if (!entry || typeof entry !== "object")
1240
+ return false;
1241
+ const e = entry;
1242
+ return typeof e.id === "string" && typeof e.identifier === "string" && typeof e.state === "string" && e.repository !== null && typeof e.repository === "object" && e.tracker !== null && typeof e.tracker === "object";
1243
+ }
1244
+ var fileTrackerAdapter = {
1245
+ async listIssues(project) {
1246
+ const issuesPath = requireTrackerSetting2(project, "issuesPath");
1247
+ try {
1248
+ const raw = await readFile2(issuesPath, "utf-8");
1249
+ const parsed = JSON.parse(raw);
1250
+ if (!Array.isArray(parsed)) {
1251
+ throw new Error(`Expected an array of issues in ${issuesPath}, got ${typeof parsed}`);
1252
+ }
1253
+ const valid = [];
1254
+ for (let i = 0; i < parsed.length; i++) {
1255
+ if (isValidIssueShape(parsed[i])) {
1256
+ valid.push(parsed[i]);
1257
+ } else {
1258
+ process.stderr.write(`[tracker-file] Skipping invalid issue at index ${i} in ${issuesPath}
1259
+ `);
1260
+ }
1261
+ }
1262
+ return valid;
1263
+ } catch (err) {
1264
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
1265
+ return [];
1266
+ }
1267
+ if (err instanceof SyntaxError) {
1268
+ return [];
1269
+ }
1270
+ throw err;
1271
+ }
1272
+ },
1273
+ async listIssuesByStates(project, states) {
1274
+ if (states.length === 0) {
1275
+ return [];
1276
+ }
1277
+ const issues = await this.listIssues(project);
1278
+ const normalizedStates = new Set(states.map((state) => state.trim().toLowerCase()));
1279
+ return issues.filter((issue) => normalizedStates.has(issue.state.trim().toLowerCase()));
1280
+ },
1281
+ async fetchIssueStatesByIds(project, issueIds) {
1282
+ if (issueIds.length === 0) {
1283
+ return [];
1284
+ }
1285
+ const issues = await this.listIssues(project);
1286
+ const ids = new Set(issueIds);
1287
+ return issues.filter((issue) => ids.has(issue.id));
1288
+ },
1289
+ buildWorkerEnvironment(_project, _issue) {
1290
+ return {
1291
+ SYMPHONY_FILE_TRACKER: "true"
1292
+ };
1293
+ },
1294
+ reviveIssue(project, run) {
1295
+ return {
1296
+ id: run.issueId,
1297
+ identifier: run.issueIdentifier,
1298
+ number: parseIssueNumber2(run.issueIdentifier),
1299
+ title: run.issueTitle ?? run.issueIdentifier,
1300
+ description: null,
1301
+ priority: null,
1302
+ state: run.issueState,
1303
+ branchName: null,
1304
+ url: null,
1305
+ labels: [],
1306
+ blockedBy: [],
1307
+ createdAt: null,
1308
+ updatedAt: null,
1309
+ repository: run.repository,
1310
+ tracker: {
1311
+ adapter: "file",
1312
+ bindingId: project.tracker.bindingId,
1313
+ itemId: run.issueId
1314
+ },
1315
+ metadata: {}
1316
+ };
1317
+ }
1318
+ };
1319
+
1320
+ // ../orchestrator/dist/tracker-adapters.js
1321
+ var localAdapters = /* @__PURE__ */ new Map([
1322
+ ["file", fileTrackerAdapter]
1323
+ ]);
1324
+ function resolveTrackerAdapter2(tracker) {
1325
+ const local = localAdapters.get(tracker.adapter);
1326
+ if (local)
1327
+ return local;
1328
+ return resolveTrackerAdapter(tracker);
1329
+ }
1330
+
1331
+ // ../orchestrator/dist/service.js
1332
+ var DEFAULT_POLL_INTERVAL_MS = 3e4;
1333
+ var DEFAULT_CONCURRENCY = 3;
1334
+ var DEFAULT_RETRY_BACKOFF_MS = 3e4;
1335
+ var CONTINUATION_RETRY_DELAY_MS = 1e3;
1336
+ var DEFAULT_WORKER_COMMAND = "node packages/worker/dist/index.js";
1337
+ var DEFAULT_MAX_NONPRODUCTIVE_TURNS = 3;
1338
+ var STUCK_WORKER_TIMEOUT_MS = 30 * 60 * 1e3;
1339
+ function isUsableWorkflowResolution(resolution) {
1340
+ return resolution.isValid || resolution.usedLastKnownGood;
1341
+ }
1342
+ function parseTimestampMs(value) {
1343
+ if (!value) {
1344
+ return null;
1345
+ }
1346
+ const parsed = new Date(value).getTime();
1347
+ return Number.isFinite(parsed) ? parsed : null;
1348
+ }
1349
+ var OrchestratorService = class {
1350
+ store;
1351
+ projectConfig;
1352
+ dependencies;
1353
+ projectPollIntervals = /* @__PURE__ */ new Map();
1354
+ activeWorkerPids = /* @__PURE__ */ new Set();
1355
+ workerStderrBuffers = /* @__PURE__ */ new Map();
1356
+ workerStderrDecoders = /* @__PURE__ */ new Map();
1357
+ lastKnownGoodWorkflows = /* @__PURE__ */ new Map();
1358
+ lastReportedWorkflowErrors = /* @__PURE__ */ new Map();
1359
+ workflowResolutionCache = null;
1360
+ running = true;
1361
+ shuttingDown = false;
1362
+ shutdownPromise = null;
1363
+ sleepTimer = null;
1364
+ sleepResolver = null;
1365
+ reconcilePromise = Promise.resolve();
1366
+ reconcileRequested = false;
1367
+ constructor(store, projectConfig, dependencies = {}) {
1368
+ this.store = store;
1369
+ this.projectConfig = projectConfig;
1370
+ this.dependencies = dependencies;
1371
+ }
1372
+ async run(options = {}) {
1373
+ this.running = true;
1374
+ await this.runSerialized(() => this.performStartupCleanup(this.createTrackerDependencies()));
1375
+ while (this.running) {
1376
+ try {
1377
+ const snapshot = await this.runOnceInternal(options.issueIdentifier, this.createTrackerDependencies());
1378
+ await this.notifyTick(snapshot);
1379
+ } catch (error) {
1380
+ if (options.once) {
1381
+ throw error;
1382
+ }
1383
+ this.writeStderr(`[orchestrator] run loop failed for ${this.projectConfig.projectId}: ${this.formatErrorMessage(error)}`);
1384
+ }
1385
+ if (options.once || !this.running) {
1386
+ return;
1387
+ }
1388
+ await this.waitForNextPoll();
1389
+ }
1390
+ }
1391
+ async runOnce(options = {}) {
1392
+ return this.runOnceInternal(options.issueIdentifier, this.createTrackerDependencies());
1393
+ }
1394
+ async status() {
1395
+ return this.store.loadProjectStatus(this.projectConfig.projectId);
1396
+ }
1397
+ async statusForIssue(issueIdentifier) {
1398
+ const issueRecords = await this.store.loadProjectIssueOrchestrations(this.projectConfig.projectId);
1399
+ const issueRecord = issueRecords.find((record) => record.identifier === issueIdentifier);
1400
+ if (!issueRecord) {
1401
+ return null;
1402
+ }
1403
+ const currentRunCandidate = issueRecord.currentRunId ? await this.store.loadRun(issueRecord.currentRunId, this.projectConfig.projectId) : null;
1404
+ const currentRun = isMatchingIssueRun(currentRunCandidate, this.projectConfig.projectId, issueRecord.issueId, issueIdentifier) ? currentRunCandidate : await this.findLatestRunForIssue(issueRecord.issueId, issueIdentifier);
1405
+ const recentEvents = currentRun === null ? [] : await this.store.loadRecentRunEvents(currentRun.runId, 20, currentRun.projectId);
1406
+ const latestEventMessage = recentEvents[recentEvents.length - 1]?.message ?? null;
1407
+ const currentAttempt = currentRun?.attempt ?? issueRecord.retryEntry?.attempt ?? 0;
1408
+ return {
1409
+ issue_identifier: issueRecord.identifier,
1410
+ issue_id: issueRecord.issueId,
1411
+ status: currentRun?.status ?? mapIssueOrchestrationStateToStatus(issueRecord.state),
1412
+ workspace: {
1413
+ path: currentRun?.workingDirectory ?? null
1414
+ },
1415
+ attempts: {
1416
+ restart_count: Math.max(0, currentAttempt - 1),
1417
+ current_retry_attempt: currentAttempt
1418
+ },
1419
+ running: currentRun === null ? null : {
1420
+ session_id: currentRun.runtimeSession?.sessionId ?? null,
1421
+ turn_count: currentRun.turnCount ?? null,
1422
+ state: currentRun.issueState ?? null,
1423
+ started_at: currentRun.startedAt ?? null,
1424
+ last_event: currentRun.lastEvent ?? null,
1425
+ last_message: latestEventMessage,
1426
+ last_event_at: currentRun.lastEventAt ?? null,
1427
+ tokens: currentRun.tokenUsage ? {
1428
+ input_tokens: currentRun.tokenUsage.inputTokens,
1429
+ output_tokens: currentRun.tokenUsage.outputTokens,
1430
+ total_tokens: currentRun.tokenUsage.totalTokens
1431
+ } : null
1432
+ },
1433
+ retry: currentRun?.nextRetryAt ?? issueRecord.retryEntry?.dueAt ? {
1434
+ due_at: currentRun?.nextRetryAt ?? issueRecord.retryEntry?.dueAt ?? "",
1435
+ kind: currentRun?.retryKind ?? null,
1436
+ error: currentRun?.lastError ?? issueRecord.retryEntry?.error ?? null
1437
+ } : null,
1438
+ logs: {
1439
+ codex_session_logs: currentRun === null ? [] : [
1440
+ {
1441
+ label: "worker",
1442
+ path: join3(this.store.runDir(currentRun.runId, currentRun.projectId), "worker.log"),
1443
+ url: null
1444
+ }
1445
+ ]
1446
+ },
1447
+ recent_events: recentEvents,
1448
+ last_error: currentRun?.lastError ?? issueRecord.retryEntry?.error ?? null,
1449
+ tracked: {
1450
+ issue_orchestration_state: issueRecord.state,
1451
+ current_run_id: issueRecord.currentRunId,
1452
+ workspace_key: issueRecord.workspaceKey,
1453
+ run_phase: currentRun?.runPhase ?? null,
1454
+ execution_phase: currentRun?.executionPhase ?? null
1455
+ }
1456
+ };
1457
+ }
1458
+ async recover() {
1459
+ return this.runOnce();
1460
+ }
1461
+ requestReconcile() {
1462
+ this.reconcileRequested = true;
1463
+ this.cancelPendingSleep();
1464
+ }
1465
+ async shutdown() {
1466
+ if (this.shutdownPromise) {
1467
+ return this.shutdownPromise;
1468
+ }
1469
+ this.shuttingDown = true;
1470
+ this.shutdownPromise = (async () => {
1471
+ this.running = false;
1472
+ this.cancelPendingSleep();
1473
+ const workerPids = [...this.activeWorkerPids];
1474
+ for (const pid of workerPids) {
1475
+ this.sendSignal(pid, "SIGTERM");
1476
+ }
1477
+ if (workerPids.length === 0) {
1478
+ return;
1479
+ }
1480
+ let waitedMs = 0;
1481
+ while (this.activeWorkerPids.size > 0 && waitedMs < 1e4) {
1482
+ this.pruneExitedWorkerPids();
1483
+ if (this.activeWorkerPids.size === 0) {
1484
+ return;
1485
+ }
1486
+ await (this.dependencies.waitImpl ?? wait2)(100);
1487
+ waitedMs += 100;
1488
+ }
1489
+ for (const pid of [...this.activeWorkerPids]) {
1490
+ if (!this.isProcessRunning(pid)) {
1491
+ this.retireWorkerPid(pid);
1492
+ continue;
1493
+ }
1494
+ this.sendSignal(pid, "SIGKILL");
1495
+ this.retireWorkerPid(pid);
1496
+ }
1497
+ })();
1498
+ return this.shutdownPromise;
1499
+ }
1500
+ getEffectivePollIntervalMs() {
1501
+ if (this.dependencies.pollIntervalMs) {
1502
+ return this.dependencies.pollIntervalMs;
1503
+ }
1504
+ const configuredIntervals = [...this.projectPollIntervals.values()].filter((value) => Number.isFinite(value) && value > 0);
1505
+ return configuredIntervals.length ? Math.min(...configuredIntervals) : DEFAULT_POLL_INTERVAL_MS;
1506
+ }
1507
+ async reconcileProject(tenant, issueIdentifier, trackerDependencies = {}) {
1508
+ const trackerAdapter = resolveTrackerAdapter2(tenant.tracker);
1509
+ const now = this.now();
1510
+ let lastError = null;
1511
+ let dispatched = 0;
1512
+ let suppressed = 0;
1513
+ let recovered = 0;
1514
+ let pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
1515
+ let rateLimits = null;
1516
+ let issueRecords = await this.store.loadProjectIssueOrchestrations(tenant.projectId);
1517
+ const allRuns = (await this.store.loadAllRuns()).filter((run) => run.projectId === tenant.projectId);
1518
+ const activeRuns = allRuns.filter((run) => isActiveRunStatus(run.status));
1519
+ for (const run of activeRuns) {
1520
+ const outcome = await this.reconcileRun(tenant, run, issueRecords);
1521
+ issueRecords = outcome.issueRecords;
1522
+ if (outcome.recovered) {
1523
+ recovered += 1;
1524
+ }
1525
+ }
1526
+ const reconciledRuns = (await this.store.loadAllRuns()).filter((run) => run.projectId === tenant.projectId && isActiveRunStatus(run.status));
1527
+ const projectRunsAfterReconcile = (await this.store.loadAllRuns()).filter((run) => run.projectId === tenant.projectId);
1528
+ rateLimits = resolveProjectRateLimits(reconciledRuns, []);
1529
+ try {
1530
+ pollIntervalMs = await this.loadProjectPollInterval(tenant);
1531
+ const currentActiveRuns = (await this.store.loadAllRuns()).filter((run) => run.projectId === tenant.projectId && isActiveRunStatus(run.status));
1532
+ const { runs: syncedActiveRuns, issuesByIdentifier: syncedIssuesByIdentifier } = await this.syncActiveRunIssueStates(tenant, trackerAdapter, currentActiveRuns, now);
1533
+ const issues = await trackerAdapter.listIssues(tenant, trackerDependencies);
1534
+ const filteredIssues = issueIdentifier ? issues.filter((issue) => issue.identifier === issueIdentifier) : issues;
1535
+ const { candidates: actionableCandidates, lifecycle } = await this.resolveActionableCandidates(tenant, filteredIssues);
1536
+ const trackedIssuesByIdentifier = new Map(syncedIssuesByIdentifier);
1537
+ for (const issue of filteredIssues) {
1538
+ const existing = trackedIssuesByIdentifier.get(issue.identifier);
1539
+ trackedIssuesByIdentifier.set(issue.identifier, {
1540
+ ...existing ?? issue,
1541
+ ...issue,
1542
+ rateLimits: issue.rateLimits ?? existing?.rateLimits ?? null
1543
+ });
1544
+ }
1545
+ for (const [identifier, issue] of syncedIssuesByIdentifier) {
1546
+ const existing = trackedIssuesByIdentifier.get(identifier);
1547
+ if (!existing) {
1548
+ trackedIssuesByIdentifier.set(identifier, issue);
1549
+ continue;
1550
+ }
1551
+ trackedIssuesByIdentifier.set(identifier, {
1552
+ ...issue,
1553
+ ...existing,
1554
+ rateLimits: existing.rateLimits ?? issue.rateLimits ?? null
1555
+ });
1556
+ }
1557
+ rateLimits = resolveProjectRateLimits(syncedActiveRuns, trackedIssuesByIdentifier.values());
1558
+ const concurrency = await this.getProjectConcurrency(tenant);
1559
+ const currentlyActive = issueRecords.filter((record) => isIssueOrchestrationClaimed(record.state)).length;
1560
+ const availableSlots = Math.max(0, concurrency - currentlyActive);
1561
+ const unscheduledCandidates = actionableCandidates.filter((issue) => {
1562
+ if (hasConvergenceLockedRun(projectRunsAfterReconcile, issue.id, issue.state)) {
1563
+ return false;
1564
+ }
1565
+ return !issueRecords.some((record) => record.issueId === issue.id && isIssueOrchestrationClaimed(record.state));
1566
+ });
1567
+ const sortedCandidates = sortCandidatesForDispatch(unscheduledCandidates);
1568
+ const activeByState = /* @__PURE__ */ new Map();
1569
+ for (const run of syncedActiveRuns) {
1570
+ const state = run.issueState;
1571
+ const count = activeByState.get(state) ?? 0;
1572
+ activeByState.set(state, count + 1);
1573
+ }
1574
+ const maxConcurrentByState = await this.loadProjectMaxConcurrentByState(tenant);
1575
+ let slotsRemaining = availableSlots;
1576
+ for (const issue of sortedCandidates) {
1577
+ if (this.shuttingDown) {
1578
+ break;
1579
+ }
1580
+ if (slotsRemaining <= 0)
1581
+ break;
1582
+ if (isIssueBudgetExceeded(resolveIssueBudgetSnapshot(projectRunsAfterReconcile, issue.id), now)) {
1583
+ continue;
1584
+ }
1585
+ const stateLimit = maxConcurrentByState[issue.state];
1586
+ if (stateLimit !== void 0) {
1587
+ const activeInState = activeByState.get(issue.state) ?? 0;
1588
+ if (activeInState >= stateLimit) {
1589
+ continue;
1590
+ }
1591
+ }
1592
+ const preferredWorkspaceKey = deriveIssueWorkspaceKey({
1593
+ projectId: tenant.projectId,
1594
+ adapter: issue.tracker.adapter,
1595
+ issueSubjectId: issue.id
1596
+ }, issue.identifier);
1597
+ issueRecords = upsertIssueOrchestration(issueRecords, {
1598
+ issueId: issue.id,
1599
+ identifier: issue.identifier,
1600
+ workspaceKey: preferredWorkspaceKey,
1601
+ state: "claimed",
1602
+ currentRunId: null,
1603
+ retryEntry: null,
1604
+ updatedAt: now.toISOString()
1605
+ });
1606
+ let run;
1607
+ try {
1608
+ run = await this.startRun(tenant, issue);
1609
+ } catch (error) {
1610
+ issueRecords = releaseIssueOrchestration(issueRecords, issue.id, now);
1611
+ throw error;
1612
+ }
1613
+ issueRecords = upsertIssueOrchestration(issueRecords, {
1614
+ issueId: run.issueId,
1615
+ identifier: run.issueIdentifier,
1616
+ workspaceKey: run.issueWorkspaceKey ?? preferredWorkspaceKey,
1617
+ state: "running",
1618
+ currentRunId: run.runId,
1619
+ retryEntry: null,
1620
+ updatedAt: now.toISOString()
1621
+ });
1622
+ await this.store.saveRun(run);
1623
+ await this.store.appendRunEvent(run.runId, {
1624
+ at: now.toISOString(),
1625
+ event: "run-dispatched",
1626
+ projectId: tenant.projectId,
1627
+ issueIdentifier: issue.identifier,
1628
+ issueId: run.issueId,
1629
+ issueState: issue.state
1630
+ });
1631
+ this.logVerbose(`[dispatch] Issue ${issue.identifier} \u2192 run ${run.runId}`);
1632
+ dispatched += 1;
1633
+ slotsRemaining -= 1;
1634
+ activeByState.set(issue.state, (activeByState.get(issue.state) ?? 0) + 1);
1635
+ }
1636
+ for (const issueRecord of issueRecords) {
1637
+ if (!isIssueOrchestrationClaimed(issueRecord.state)) {
1638
+ continue;
1639
+ }
1640
+ const issue = trackedIssuesByIdentifier.get(issueRecord.identifier);
1641
+ if (!issue) {
1642
+ continue;
1643
+ }
1644
+ const persistedRun = issueRecord.currentRunId ? await this.store.loadRun(issueRecord.currentRunId, tenant.projectId) : null;
1645
+ const activeRun = syncedActiveRuns.find((run) => isMatchingIssueRun(run, tenant.projectId, issueRecord.issueId, issueRecord.identifier)) ?? persistedRun;
1646
+ const resolvedIssue = actionableCandidates.find((candidate) => candidate.identifier === issue.identifier);
1647
+ if (resolvedIssue) {
1648
+ continue;
1649
+ }
1650
+ if (activeRun?.processId) {
1651
+ this.sendSignal(activeRun.processId, "SIGTERM");
1652
+ this.retireWorkerPid(activeRun.processId);
1653
+ }
1654
+ if (activeRun) {
1655
+ const suppressedRun = {
1656
+ ...activeRun,
1657
+ status: "suppressed",
1658
+ processId: null,
1659
+ completedAt: now.toISOString(),
1660
+ updatedAt: now.toISOString(),
1661
+ runPhase: "canceled_by_reconciliation",
1662
+ lastError: "Run suppressed because the tracker state is no longer actionable."
1663
+ };
1664
+ await this.store.saveRun(suppressedRun);
1665
+ this.logVerbose(`[run-completed] ${suppressedRun.runId} status=${suppressedRun.status}`);
1666
+ }
1667
+ issueRecords = releaseIssueOrchestration(issueRecords, issueRecord.issueId, now);
1668
+ suppressed += 1;
1669
+ }
1670
+ const terminalIssuesByIdentifier = /* @__PURE__ */ new Map();
1671
+ for (const issue of trackedIssuesByIdentifier.values()) {
1672
+ if (!isStateTerminal(issue.state, lifecycle)) {
1673
+ continue;
1674
+ }
1675
+ terminalIssuesByIdentifier.set(issue.identifier, issue);
1676
+ }
1677
+ for (const issue of terminalIssuesByIdentifier.values()) {
1678
+ await this.cleanupTerminalIssueWorkspace(tenant, issue, now);
1679
+ }
1680
+ } catch (error) {
1681
+ lastError = error instanceof Error ? error.message : "Unknown orchestration error";
1682
+ }
1683
+ this.projectPollIntervals.set(tenant.projectId, pollIntervalMs);
1684
+ await this.store.saveProjectIssueOrchestrations(tenant.projectId, issueRecords);
1685
+ const allTenantRuns = (await this.store.loadAllRuns()).filter((run) => run.projectId === tenant.projectId);
1686
+ const latestRuns = allTenantRuns.filter((run) => isActiveRunStatus(run.status));
1687
+ rateLimits = rateLimits ?? resolveProjectRateLimits(latestRuns, []);
1688
+ const status = buildProjectSnapshot({
1689
+ project: tenant,
1690
+ activeRuns: latestRuns,
1691
+ allRuns: allTenantRuns,
1692
+ summary: { dispatched, suppressed, recovered },
1693
+ lastTickAt: now.toISOString(),
1694
+ lastError,
1695
+ rateLimits
1696
+ });
1697
+ await this.store.saveProjectStatus(status);
1698
+ return status;
1699
+ }
1700
+ async performStartupCleanup(trackerDependencies = {}) {
1701
+ const tenant = this.projectConfig;
1702
+ const now = this.now();
1703
+ const workspaceRecords = await this.store.loadIssueWorkspaces(tenant.projectId);
1704
+ if (workspaceRecords.length === 0) {
1705
+ return;
1706
+ }
1707
+ const trackerAdapter = resolveTrackerAdapter2(tenant.tracker);
1708
+ const workflowCache = /* @__PURE__ */ new Map();
1709
+ let issues;
1710
+ try {
1711
+ issues = await trackerAdapter.listIssuesByStates(tenant, await this.resolveStartupCleanupTerminalStates(tenant, workspaceRecords, workflowCache), trackerDependencies);
1712
+ } catch (error) {
1713
+ const message = error instanceof Error ? error.message : "Unknown tracker error";
1714
+ console.warn(`[orchestrator] Startup cleanup skipped for project ${tenant.projectId}: ${message}`);
1715
+ return;
1716
+ }
1717
+ const issuesById = new Map(issues.map((issue) => [issue.id, issue]));
1718
+ for (const workspaceRecord of workspaceRecords) {
1719
+ if (workspaceRecord.status === "removed") {
1720
+ continue;
1721
+ }
1722
+ const issue = issuesById.get(workspaceRecord.issueSubjectId);
1723
+ if (!issue) {
1724
+ continue;
1725
+ }
1726
+ try {
1727
+ const resolution = await this.loadStartupCleanupWorkflow(tenant, issue.repository, workflowCache);
1728
+ if (!resolution.isValid) {
1729
+ continue;
1730
+ }
1731
+ if (!isStateTerminal(issue.state, resolution.lifecycle)) {
1732
+ continue;
1733
+ }
1734
+ await this.cleanupTerminalIssueWorkspace(tenant, issue, now, resolution);
1735
+ } catch (error) {
1736
+ const message = error instanceof Error ? error.message : "Unknown startup cleanup error";
1737
+ console.warn(`[orchestrator] Startup cleanup skipped workspace for ${issue.identifier}: ${message}`);
1738
+ }
1739
+ }
1740
+ }
1741
+ async notifyTick(snapshot) {
1742
+ if (!this.dependencies.onTick) {
1743
+ return;
1744
+ }
1745
+ try {
1746
+ await this.dependencies.onTick(snapshot);
1747
+ } catch (error) {
1748
+ this.writeStderr(`[orchestrator] onTick callback failed: ${this.formatErrorMessage(error)}`);
1749
+ }
1750
+ }
1751
+ formatErrorMessage(error) {
1752
+ if (error instanceof Error) {
1753
+ return error.stack ?? error.message;
1754
+ }
1755
+ return String(error);
1756
+ }
1757
+ async resolveStartupCleanupTerminalStates(tenant, workspaceRecords, workflowCache) {
1758
+ const terminalStates = /* @__PURE__ */ new Map();
1759
+ const repositories = this.resolveStartupCleanupRepositories(tenant, workspaceRecords);
1760
+ for (const repository of repositories) {
1761
+ let resolution;
1762
+ try {
1763
+ resolution = await this.loadStartupCleanupWorkflow(tenant, repository, workflowCache);
1764
+ } catch {
1765
+ continue;
1766
+ }
1767
+ if (!isUsableWorkflowResolution(resolution)) {
1768
+ continue;
1769
+ }
1770
+ for (const state of resolution.lifecycle.terminalStates) {
1771
+ const normalizedState = state.trim().toLowerCase();
1772
+ if (!terminalStates.has(normalizedState)) {
1773
+ terminalStates.set(normalizedState, state);
1774
+ }
1775
+ }
1776
+ }
1777
+ if (terminalStates.size === 0) {
1778
+ for (const state of DEFAULT_WORKFLOW_LIFECYCLE.terminalStates) {
1779
+ terminalStates.set(state.trim().toLowerCase(), state);
1780
+ }
1781
+ }
1782
+ return [...terminalStates.values()];
1783
+ }
1784
+ resolveStartupCleanupRepositories(tenant, workspaceRecords) {
1785
+ const repositories = /* @__PURE__ */ new Map();
1786
+ for (const repository of tenant.repositories) {
1787
+ repositories.set(this.startupCleanupRepositoryKey(repository.owner, repository.name), repository);
1788
+ }
1789
+ for (const workspaceRecord of workspaceRecords) {
1790
+ const repository = this.parseWorkspaceRepositoryRef(workspaceRecord);
1791
+ if (!repository) {
1792
+ continue;
1793
+ }
1794
+ const key = this.startupCleanupRepositoryKey(repository.owner, repository.name);
1795
+ if (!repositories.has(key)) {
1796
+ repositories.set(key, repository);
1797
+ }
1798
+ }
1799
+ return [...repositories.values()];
1800
+ }
1801
+ parseWorkspaceRepositoryRef(workspaceRecord) {
1802
+ const match = workspaceRecord.issueIdentifier.match(/^([^/]+)\/([^#]+)#\d+$/);
1803
+ if (!match) {
1804
+ return null;
1805
+ }
1806
+ const owner = match[1];
1807
+ const name = match[2];
1808
+ if (!owner || !name) {
1809
+ return null;
1810
+ }
1811
+ return {
1812
+ owner,
1813
+ name,
1814
+ cloneUrl: workspaceRecord.repositoryPath
1815
+ };
1816
+ }
1817
+ startupCleanupRepositoryKey(owner, name) {
1818
+ return `${owner}/${name}`;
1819
+ }
1820
+ async loadStartupCleanupWorkflow(tenant, repository, workflowCache) {
1821
+ const cacheKey = this.workflowCacheKey(repository);
1822
+ const cachedResolution = workflowCache.get(cacheKey);
1823
+ if (cachedResolution) {
1824
+ return cachedResolution;
1825
+ }
1826
+ const resolutionPromise = tenant.repositories.some((candidate) => candidate.owner === repository.owner && candidate.name === repository.name) ? this.loadProjectWorkflow(tenant, repository) : loadRepositoryWorkflow(repository.cloneUrl, repository);
1827
+ workflowCache.set(cacheKey, resolutionPromise);
1828
+ return resolutionPromise;
1829
+ }
1830
+ async runSerialized(operation) {
1831
+ const previous = this.reconcilePromise;
1832
+ let release;
1833
+ this.reconcilePromise = new Promise((resolve4) => {
1834
+ release = resolve4;
1835
+ });
1836
+ await previous;
1837
+ try {
1838
+ return await operation();
1839
+ } finally {
1840
+ release();
1841
+ }
1842
+ }
1843
+ async runOnceInternal(issueIdentifier, trackerDependencies) {
1844
+ return this.runSerialized(async () => {
1845
+ const workflowResolutionCache = /* @__PURE__ */ new Map();
1846
+ this.workflowResolutionCache = workflowResolutionCache;
1847
+ try {
1848
+ return await this.reconcileProject(this.projectConfig, issueIdentifier, trackerDependencies);
1849
+ } finally {
1850
+ if (this.workflowResolutionCache === workflowResolutionCache) {
1851
+ this.workflowResolutionCache = null;
1852
+ }
1853
+ }
1854
+ });
1855
+ }
1856
+ createTrackerDependencies() {
1857
+ return {
1858
+ fetchImpl: this.dependencies.fetchImpl,
1859
+ projectItemsCache: createProjectItemsCache()
1860
+ };
1861
+ }
1862
+ async findLatestRunForIssue(issueId, issueIdentifier) {
1863
+ const matchingRuns = (await this.store.loadAllRuns()).filter((run) => run.projectId === this.projectConfig.projectId).filter((run) => run.issueId === issueId || run.issueIdentifier === issueIdentifier).sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
1864
+ return matchingRuns[0] ?? null;
1865
+ }
1866
+ async resolveActionableCandidates(tenant, issues) {
1867
+ const candidates = [];
1868
+ let lifecycle = null;
1869
+ for (const issue of issues) {
1870
+ const resolution = await this.loadProjectWorkflow(tenant, issue.repository);
1871
+ if (!isUsableWorkflowResolution(resolution)) {
1872
+ continue;
1873
+ }
1874
+ if (!lifecycle) {
1875
+ lifecycle = resolution.lifecycle;
1876
+ }
1877
+ if (!isStateActive(issue.state, resolution.lifecycle)) {
1878
+ continue;
1879
+ }
1880
+ if (matchesWorkflowState(issue.state, resolution.lifecycle.blockerCheckStates) && issue.blockedBy.length > 0) {
1881
+ const hasNonTerminalBlocker = issue.blockedBy.some((blockerRef) => {
1882
+ if (blockerRef.state && isStateTerminal(blockerRef.state, resolution.lifecycle)) {
1883
+ return false;
1884
+ }
1885
+ if (blockerRef.identifier) {
1886
+ const blockerIssue = issues.find((candidate) => candidate.identifier === blockerRef.identifier);
1887
+ if (blockerIssue?.state) {
1888
+ return !isStateTerminal(blockerIssue.state, resolution.lifecycle);
1889
+ }
1890
+ }
1891
+ return true;
1892
+ });
1893
+ if (hasNonTerminalBlocker) {
1894
+ continue;
1895
+ }
1896
+ }
1897
+ candidates.push(issue);
1898
+ }
1899
+ if (!lifecycle && tenant.repositories.length > 0) {
1900
+ const resolution = await this.loadProjectWorkflow(tenant, tenant.repositories[0]);
1901
+ if (isUsableWorkflowResolution(resolution)) {
1902
+ lifecycle = resolution.lifecycle;
1903
+ }
1904
+ }
1905
+ return {
1906
+ candidates,
1907
+ lifecycle: lifecycle ?? {
1908
+ stateFieldName: "Status",
1909
+ activeStates: ["Todo", "In Progress"],
1910
+ terminalStates: ["Done"],
1911
+ blockerCheckStates: ["Todo"]
1912
+ }
1913
+ };
1914
+ }
1915
+ async loadProjectWorkflow(tenant, repository) {
1916
+ const cacheKey = this.workflowCacheKey(repository);
1917
+ const pendingCache = this.workflowResolutionCache;
1918
+ if (pendingCache) {
1919
+ const cachedResolution = pendingCache.get(cacheKey);
1920
+ if (cachedResolution) {
1921
+ return cachedResolution;
1922
+ }
1923
+ const resolutionPromise = this.loadProjectWorkflowUncached(tenant, repository);
1924
+ pendingCache.set(cacheKey, resolutionPromise);
1925
+ return resolutionPromise;
1926
+ }
1927
+ return this.loadProjectWorkflowUncached(tenant, repository);
1928
+ }
1929
+ async loadProjectWorkflowUncached(tenant, repository) {
1930
+ const cacheRoot = join3(this.store.projectDir(tenant.projectId), "cache", repository.owner, repository.name);
1931
+ const { repositoryDirectory, changed } = await syncRepositoryForRun({
1932
+ repository,
1933
+ targetDirectory: cacheRoot
1934
+ });
1935
+ const resolution = await loadRepositoryWorkflow(repositoryDirectory, repository);
1936
+ return this.resolveWorkflowResolution(repository, cacheRoot, resolution, changed);
1937
+ }
1938
+ async startRun(tenant, issue, resumeContext) {
1939
+ if (this.shuttingDown || !this.running) {
1940
+ throw new Error("Orchestrator is shutting down and cannot start new runs.");
1941
+ }
1942
+ const trackerAdapter = resolveTrackerAdapter2(tenant.tracker);
1943
+ const now = this.now();
1944
+ const runId = createRunId(now, tenant.projectId, issue.identifier);
1945
+ const runDir = this.store.runDir(runId, tenant.projectId);
1946
+ const workspaceRuntimeDir = runDir;
1947
+ const issueSubjectId = issue.id;
1948
+ const identity = {
1949
+ projectId: tenant.projectId,
1950
+ adapter: issue.tracker.adapter,
1951
+ issueSubjectId
1952
+ };
1953
+ const preferredWorkspaceKey = deriveIssueWorkspaceKey(identity, issue.identifier);
1954
+ const legacyWorkspaceKey = deriveLegacyIssueWorkspaceKey(identity);
1955
+ const existingWorkspaceRecord = await this.store.loadIssueWorkspace(tenant.projectId, preferredWorkspaceKey) ?? (legacyWorkspaceKey === preferredWorkspaceKey ? null : await this.store.loadIssueWorkspace(tenant.projectId, legacyWorkspaceKey));
1956
+ const workspaceKey = existingWorkspaceRecord?.workspaceKey ?? preferredWorkspaceKey;
1957
+ const projectDir = this.store.projectDir(tenant.projectId);
1958
+ const issueWorkspacePath = resolveIssueWorkspaceDirectory(projectDir, workspaceKey);
1959
+ const repositoryDirectory = await ensureIssueWorkspaceRepository({
1960
+ repository: issue.repository,
1961
+ issueWorkspacePath
1962
+ });
1963
+ if (!existingWorkspaceRecord) {
1964
+ const workspaceRecord = {
1965
+ workspaceKey,
1966
+ projectId: tenant.projectId,
1967
+ adapter: issue.tracker.adapter,
1968
+ issueSubjectId,
1969
+ issueIdentifier: issue.identifier,
1970
+ workspacePath: issueWorkspacePath,
1971
+ repositoryPath: repositoryDirectory,
1972
+ status: "active",
1973
+ createdAt: now.toISOString(),
1974
+ updatedAt: now.toISOString(),
1975
+ lastError: null
1976
+ };
1977
+ await this.store.saveIssueWorkspace(workspaceRecord);
1978
+ const afterCreateResult = await this.runHook("after_create", tenant, repositoryDirectory, issue.repository, {
1979
+ projectId: tenant.projectId,
1980
+ workspaceKey,
1981
+ issueSubjectId,
1982
+ issueIdentifier: issue.identifier,
1983
+ workspacePath: issueWorkspacePath,
1984
+ repositoryPath: repositoryDirectory
1985
+ });
1986
+ if (afterCreateResult && afterCreateResult.outcome !== "success" && afterCreateResult.outcome !== "skipped") {
1987
+ await this.store.appendRunEvent(runId, {
1988
+ at: now.toISOString(),
1989
+ event: "hook-failed",
1990
+ projectId: tenant.projectId,
1991
+ hook: "after_create",
1992
+ error: afterCreateResult.error ?? null
1993
+ });
1994
+ }
1995
+ }
1996
+ const workflow = await this.loadProjectWorkflow(tenant, issue.repository);
1997
+ if (!isUsableWorkflowResolution(workflow)) {
1998
+ throw new Error(workflow.validationError ?? "Invalid repository WORKFLOW.md");
1999
+ }
2000
+ const allProjectRuns = (await this.store.loadAllRuns()).filter((run) => run.projectId === tenant.projectId);
2001
+ const issueBudgetSnapshot = resolveIssueBudgetSnapshot(allProjectRuns, issue.id);
2002
+ const promptVariables = buildPromptVariables(issue, {
2003
+ attempt: null
2004
+ // first execution
2005
+ });
2006
+ const renderedPrompt = renderPrompt(workflow.promptTemplate, promptVariables);
2007
+ await this.runHook("before_run", tenant, repositoryDirectory, issue.repository, {
2008
+ projectId: tenant.projectId,
2009
+ workspaceKey,
2010
+ issueSubjectId,
2011
+ issueIdentifier: issue.identifier,
2012
+ workspacePath: issueWorkspacePath,
2013
+ repositoryPath: repositoryDirectory,
2014
+ runId,
2015
+ state: issue.state
2016
+ });
2017
+ mkdirSync(runDir, { recursive: true });
2018
+ const workerLogStream = (this.dependencies.createWriteStreamImpl ?? createWriteStream)(join3(runDir, "worker.log"), {
2019
+ flags: "a"
2020
+ });
2021
+ let workerLogAvailable = true;
2022
+ let workerExited = false;
2023
+ let workerStderrFinalizing = false;
2024
+ let workerLogBackpressured = false;
2025
+ const resumeWorkerStderr = () => {
2026
+ if (!workerLogBackpressured) {
2027
+ return;
2028
+ }
2029
+ workerLogBackpressured = false;
2030
+ child.stderr?.resume?.();
2031
+ };
2032
+ const markWorkerLogUnavailable = (error) => {
2033
+ resumeWorkerStderr();
2034
+ if (!workerLogAvailable) {
2035
+ return;
2036
+ }
2037
+ workerLogAvailable = false;
2038
+ const message = error instanceof Error ? error.message : String(error ?? "unknown");
2039
+ this.writeStderr(`[orchestrator] failed to write worker log for ${runId}: ${message}`);
2040
+ };
2041
+ const child = (this.dependencies.spawnImpl ?? spawn2)("bash", ["-lc", resolveWorkerCommand()], {
2042
+ cwd: process.cwd(),
2043
+ env: this.buildProjectExecutionEnv(tenant.projectId, {
2044
+ GITHUB_GRAPHQL_TOKEN: process.env.GITHUB_GRAPHQL_TOKEN ?? "",
2045
+ CODEX_PROJECT_ID: tenant.projectId,
2046
+ PROJECT_ID: tenant.projectId,
2047
+ WORKING_DIRECTORY: repositoryDirectory,
2048
+ WORKSPACE_RUNTIME_DIR: workspaceRuntimeDir,
2049
+ SYMPHONY_RUN_ID: runId,
2050
+ SYMPHONY_ISSUE_STATE: issue.state,
2051
+ SYMPHONY_ISSUE_ID: issue.id,
2052
+ SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
2053
+ SYMPHONY_ISSUE_TITLE: issue.title,
2054
+ SYMPHONY_ISSUE_SUBJECT_ID: issueSubjectId,
2055
+ SYMPHONY_ISSUE_WORKSPACE_KEY: workspaceKey,
2056
+ SYMPHONY_TRACKER_ADAPTER: issue.tracker.adapter,
2057
+ SYMPHONY_TRACKER_BINDING_ID: issue.tracker.bindingId,
2058
+ SYMPHONY_TRACKER_ITEM_ID: issue.tracker.itemId,
2059
+ TARGET_REPOSITORY_CLONE_URL: issue.repository.cloneUrl,
2060
+ TARGET_REPOSITORY_OWNER: issue.repository.owner,
2061
+ TARGET_REPOSITORY_NAME: issue.repository.name,
2062
+ TARGET_REPOSITORY_URL: issue.repository.url,
2063
+ ...trackerAdapter.buildWorkerEnvironment(tenant, issue),
2064
+ SYMPHONY_RENDERED_PROMPT: renderedPrompt,
2065
+ SYMPHONY_WORKFLOW_PATH: workflow.workflowPath ?? "",
2066
+ SYMPHONY_AGENT_COMMAND: workflow.workflow.codex.command,
2067
+ SYMPHONY_APPROVAL_POLICY: workflow.workflow.codex.approvalPolicy ?? "",
2068
+ SYMPHONY_THREAD_SANDBOX: workflow.workflow.codex.threadSandbox ?? "",
2069
+ SYMPHONY_TURN_SANDBOX_POLICY: workflow.workflow.codex.turnSandboxPolicy ?? "",
2070
+ SYMPHONY_MAX_TURNS: String(workflow.workflow.agent.maxTurns),
2071
+ SYMPHONY_GLOBAL_MAX_TURNS: process.env.SYMPHONY_GLOBAL_MAX_TURNS ?? "",
2072
+ SYMPHONY_MAX_TOKENS: process.env.SYMPHONY_MAX_TOKENS ?? "",
2073
+ SYMPHONY_MAX_NONPRODUCTIVE_TURNS: process.env.SYMPHONY_MAX_NONPRODUCTIVE_TURNS ?? String(DEFAULT_MAX_NONPRODUCTIVE_TURNS),
2074
+ SYMPHONY_SESSION_TIMEOUT_MS: process.env.SYMPHONY_SESSION_TIMEOUT_MS ?? "",
2075
+ SYMPHONY_RESUME_THREAD_ID: resumeContext?.threadId ?? "",
2076
+ SYMPHONY_CUMULATIVE_TURN_COUNT: String(Math.max(0, resumeContext?.cumulativeTurnCount ?? issueBudgetSnapshot.cumulativeTurnCount)),
2077
+ SYMPHONY_CUMULATIVE_INPUT_TOKENS: String(issueBudgetSnapshot.tokenUsage.inputTokens),
2078
+ SYMPHONY_CUMULATIVE_OUTPUT_TOKENS: String(issueBudgetSnapshot.tokenUsage.outputTokens),
2079
+ SYMPHONY_CUMULATIVE_TOTAL_TOKENS: String(issueBudgetSnapshot.tokenUsage.totalTokens),
2080
+ SYMPHONY_LAST_TURN_SUMMARY: resumeContext?.lastTurnSummary ?? "",
2081
+ SYMPHONY_SESSION_STARTED_AT: issueBudgetSnapshot.sessionStartedAt ?? "",
2082
+ SYMPHONY_READ_TIMEOUT_MS: String(workflow.workflow.codex.readTimeoutMs),
2083
+ SYMPHONY_TURN_TIMEOUT_MS: String(workflow.workflow.codex.turnTimeoutMs)
2084
+ }),
2085
+ detached: true,
2086
+ stdio: ["ignore", "ignore", "pipe"]
2087
+ });
2088
+ const handleWorkerStderrChunk = (chunk) => {
2089
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
2090
+ if (workerLogAvailable) {
2091
+ try {
2092
+ if (!workerLogStream.write(buffer)) {
2093
+ workerLogBackpressured = true;
2094
+ child.stderr?.pause?.();
2095
+ }
2096
+ } catch (error) {
2097
+ markWorkerLogUnavailable(error);
2098
+ }
2099
+ }
2100
+ this.consumeWorkerStderrChunk(runId, buffer);
2101
+ };
2102
+ const drainWorkerStderr = () => {
2103
+ const stderr = child.stderr;
2104
+ if (!stderr || typeof stderr.read !== "function") {
2105
+ return;
2106
+ }
2107
+ let chunk;
2108
+ while ((chunk = stderr.read()) !== null) {
2109
+ handleWorkerStderrChunk(chunk);
2110
+ }
2111
+ };
2112
+ const completeWorkerStderrFinalization = (code, signal) => {
2113
+ if (workerExited) {
2114
+ return;
2115
+ }
2116
+ workerExited = true;
2117
+ workerStderrFinalizing = false;
2118
+ child.stderr?.removeListener("data", handleWorkerStderrChunk);
2119
+ this.flushWorkerStderrBuffer(runId);
2120
+ workerLogStream.end();
2121
+ if (child.pid) {
2122
+ this.retireWorkerPid(child.pid);
2123
+ }
2124
+ this.logVerbose(`[worker-exited] ${runId} (code=${code ?? "null"}, signal=${signal ?? "null"})`);
2125
+ };
2126
+ const finalizeWorkerStderr = (code, signal) => {
2127
+ if (workerExited || workerStderrFinalizing) {
2128
+ return;
2129
+ }
2130
+ workerStderrFinalizing = true;
2131
+ const stderr = child.stderr;
2132
+ const finish = () => {
2133
+ stderr?.removeListener("end", finish);
2134
+ stderr?.removeListener("close", finish);
2135
+ drainWorkerStderr();
2136
+ completeWorkerStderrFinalization(code, signal);
2137
+ };
2138
+ resumeWorkerStderr();
2139
+ drainWorkerStderr();
2140
+ if (!stderr) {
2141
+ completeWorkerStderrFinalization(code, signal);
2142
+ return;
2143
+ }
2144
+ if (stderr.readableEnded || stderr.readable === false) {
2145
+ finish();
2146
+ return;
2147
+ }
2148
+ stderr.once("end", finish);
2149
+ stderr.once("close", finish);
2150
+ };
2151
+ workerLogStream.on("error", (error) => {
2152
+ markWorkerLogUnavailable(error);
2153
+ });
2154
+ workerLogStream.on("drain", () => {
2155
+ resumeWorkerStderr();
2156
+ });
2157
+ child.stderr?.on("data", handleWorkerStderrChunk);
2158
+ if (child.pid) {
2159
+ this.activeWorkerPids.add(child.pid);
2160
+ this.logVerbose(`[worker-started] ${runId} (pid=${child.pid})`);
2161
+ }
2162
+ child.on?.("error", (error) => {
2163
+ const message = error instanceof Error ? error.message : String(error ?? "unknown");
2164
+ this.writeStderr(`[orchestrator] worker process error for ${runId}: ${message}`);
2165
+ finalizeWorkerStderr(null, null);
2166
+ });
2167
+ child.on?.("close", (code, signal) => {
2168
+ finalizeWorkerStderr(code, signal);
2169
+ });
2170
+ child.unref();
2171
+ return {
2172
+ runId,
2173
+ projectId: tenant.projectId,
2174
+ projectSlug: tenant.slug,
2175
+ issueId: issue.id,
2176
+ issueSubjectId,
2177
+ issueIdentifier: issue.identifier,
2178
+ issueTitle: issue.title,
2179
+ issueState: issue.state,
2180
+ repository: issue.repository,
2181
+ status: "running",
2182
+ attempt: 1,
2183
+ processId: child.pid ?? null,
2184
+ port: null,
2185
+ workingDirectory: repositoryDirectory,
2186
+ issueWorkspaceKey: workspaceKey,
2187
+ workspaceRuntimeDir,
2188
+ workflowPath: workflow.workflowPath,
2189
+ retryKind: null,
2190
+ threadId: null,
2191
+ cumulativeTurnCount: 0,
2192
+ lastTurnSummary: null,
2193
+ createdAt: now.toISOString(),
2194
+ updatedAt: now.toISOString(),
2195
+ startedAt: now.toISOString(),
2196
+ completedAt: null,
2197
+ lastError: null,
2198
+ nextRetryAt: null,
2199
+ runPhase: "preparing_workspace",
2200
+ rateLimits: issue.rateLimits ?? null
2201
+ };
2202
+ }
2203
+ async syncActiveRunIssueStates(tenant, trackerAdapter, activeRuns, now) {
2204
+ const activeIssueIds = [...new Set(activeRuns.map((run) => run.issueId))];
2205
+ if (activeIssueIds.length === 0) {
2206
+ return {
2207
+ runs: activeRuns,
2208
+ issuesByIdentifier: /* @__PURE__ */ new Map()
2209
+ };
2210
+ }
2211
+ const issues = await trackerAdapter.fetchIssueStatesByIds(tenant, activeIssueIds, {
2212
+ fetchImpl: this.dependencies.fetchImpl
2213
+ });
2214
+ const issuesByIdentifier = new Map(issues.map((issue) => [issue.identifier, issue]));
2215
+ const issueStateByIdentifier = new Map(issues.map((issue) => [issue.identifier, issue.state]));
2216
+ const syncedRuns = [];
2217
+ for (const run of activeRuns) {
2218
+ const currentTrackerState = issueStateByIdentifier.get(run.issueIdentifier);
2219
+ if (!currentTrackerState || currentTrackerState === run.issueState) {
2220
+ syncedRuns.push(run);
2221
+ continue;
2222
+ }
2223
+ const updatedRun = {
2224
+ ...run,
2225
+ issueState: currentTrackerState,
2226
+ updatedAt: now.toISOString()
2227
+ };
2228
+ await this.store.saveRun(updatedRun);
2229
+ syncedRuns.push(updatedRun);
2230
+ }
2231
+ return {
2232
+ runs: syncedRuns,
2233
+ issuesByIdentifier
2234
+ };
2235
+ }
2236
+ async reconcileRun(tenant, run, issueRecords) {
2237
+ const now = this.now();
2238
+ if (run.processId && this.isProcessRunning(run.processId)) {
2239
+ const retryPolicy = await this.loadRetryPolicy(tenant, run.repository);
2240
+ const configuredStallTimeoutMs = retryPolicy?.stallTimeoutMs ?? null;
2241
+ const lastActivityAtMs = parseTimestampMs(run.lastEventAt ?? run.startedAt);
2242
+ const startedAtMs = parseTimestampMs(run.startedAt);
2243
+ const elapsedSinceLastActivityMs = lastActivityAtMs === null ? null : now.getTime() - lastActivityAtMs;
2244
+ const runningSinceMs = startedAtMs === null ? null : now.getTime() - startedAtMs;
2245
+ const isStalledByWorkflowTimeout = configuredStallTimeoutMs !== null && configuredStallTimeoutMs > 0 && elapsedSinceLastActivityMs !== null && elapsedSinceLastActivityMs > configuredStallTimeoutMs;
2246
+ const isStalledByFallbackTimeout = runningSinceMs !== null && runningSinceMs > STUCK_WORKER_TIMEOUT_MS;
2247
+ if (isStalledByWorkflowTimeout || isStalledByFallbackTimeout) {
2248
+ const elapsedMs = isStalledByWorkflowTimeout ? elapsedSinceLastActivityMs : runningSinceMs;
2249
+ const timeoutMs = isStalledByWorkflowTimeout ? configuredStallTimeoutMs : STUCK_WORKER_TIMEOUT_MS;
2250
+ const elapsedSeconds = Math.round((elapsedMs ?? 0) / 1e3);
2251
+ const timeoutSeconds = Math.round((timeoutMs ?? 0) / 1e3);
2252
+ if (this.isVerboseLoggingEnabled()) {
2253
+ this.writeStderr(`[stall-detected] ${run.runId} (elapsed=${elapsedSeconds}s > ${timeoutSeconds}s)`);
2254
+ } else {
2255
+ this.writeStderr(`[orchestrator] stuck worker detected for ${run.runId} (elapsed ${elapsedSeconds}s > ${timeoutSeconds}s) \u2014 sending SIGTERM`);
2256
+ }
2257
+ this.sendSignal(run.processId, "SIGTERM");
2258
+ } else {
2259
+ const runningRecord = {
2260
+ ...run,
2261
+ status: "running",
2262
+ updatedAt: now.toISOString()
2263
+ };
2264
+ await this.store.saveRun(runningRecord);
2265
+ issueRecords = upsertIssueOrchestration(issueRecords, {
2266
+ issueId: run.issueId,
2267
+ identifier: run.issueIdentifier,
2268
+ workspaceKey: run.issueWorkspaceKey ?? deriveIssueWorkspaceKey({
2269
+ projectId: tenant.projectId,
2270
+ adapter: tenant.tracker.adapter,
2271
+ issueSubjectId: run.issueSubjectId
2272
+ }, run.issueIdentifier),
2273
+ state: "running",
2274
+ currentRunId: run.runId,
2275
+ retryEntry: null,
2276
+ updatedAt: now.toISOString()
2277
+ });
2278
+ return {
2279
+ issueRecords,
2280
+ recovered: false
2281
+ };
2282
+ }
2283
+ }
2284
+ if (run.processId) {
2285
+ this.retireWorkerPid(run.processId);
2286
+ }
2287
+ const workerInfo = await this.fetchWorkerRunInfo(run);
2288
+ const runWithTokens = {
2289
+ ...run,
2290
+ runtimeSession: buildRuntimeSession(run.runtimeSession, workerInfo.sessionId, workerInfo.threadId, run.status === "running" ? "failed" : run.runtimeSession?.status ?? null, run.runtimeSession?.startedAt ?? run.startedAt ?? now.toISOString(), now.toISOString(), workerInfo.exitClassification),
2291
+ threadId: workerInfo.threadId ?? run.threadId ?? null,
2292
+ cumulativeTurnCount: resolveCumulativeTurnCount(run, workerInfo.turnCount ?? null),
2293
+ tokenUsage: workerInfo.tokenUsage ?? run.tokenUsage,
2294
+ lastEvent: workerInfo.lastEvent ?? run.lastEvent,
2295
+ lastTurnSummary: resolveLastTurnSummary(run.lastTurnSummary, resolveLastTurnSummaryCandidate(workerInfo.lastEvent, workerInfo.lastError)),
2296
+ lastEventAt: workerInfo.lastEventAt ?? run.lastEventAt ?? void 0,
2297
+ lastEventAtSource: workerInfo.lastEventAtSource ?? run.lastEventAtSource ?? void 0,
2298
+ executionPhase: workerInfo.executionPhase ?? run.executionPhase ?? null,
2299
+ runPhase: workerInfo.runPhase ?? run.runPhase ?? null,
2300
+ rateLimits: workerInfo.rateLimits ?? run.rateLimits ?? null
2301
+ };
2302
+ const workerSessionId = workerInfo.sessionId;
2303
+ if (workerInfo.lastError) {
2304
+ await this.store.appendRunEvent(run.runId, {
2305
+ at: now.toISOString(),
2306
+ event: "worker-error",
2307
+ projectId: run.projectId,
2308
+ runId: run.runId,
2309
+ issueIdentifier: run.issueIdentifier,
2310
+ error: workerInfo.lastError,
2311
+ attempt: run.attempt
2312
+ });
2313
+ }
2314
+ if (run.status === "retrying" && run.nextRetryAt) {
2315
+ if (new Date(run.nextRetryAt).getTime() > now.getTime()) {
2316
+ return {
2317
+ issueRecords,
2318
+ recovered: false
2319
+ };
2320
+ }
2321
+ if (await this.resolveRetryRestartAction(tenant, run) === "release") {
2322
+ return this.releaseRetryingRun(runWithTokens, issueRecords, now);
2323
+ }
2324
+ return this.restartRun(tenant, run, issueRecords, now, workerSessionId);
2325
+ }
2326
+ if (workerInfo.exitClassification === "budget-exceeded" || workerInfo.exitClassification === "convergence-detected") {
2327
+ const completedRun = {
2328
+ ...runWithTokens,
2329
+ status: workerInfo.exitClassification === "budget-exceeded" ? "succeeded" : "failed",
2330
+ processId: null,
2331
+ updatedAt: now.toISOString(),
2332
+ completedAt: now.toISOString(),
2333
+ nextRetryAt: null,
2334
+ retryKind: null,
2335
+ lastError: workerInfo.exitClassification === "budget-exceeded" ? null : runWithTokens.lastError,
2336
+ runPhase: runWithTokens.runPhase ?? (workerInfo.exitClassification === "budget-exceeded" ? "succeeded" : "failed")
2337
+ };
2338
+ await this.store.saveRun(completedRun);
2339
+ this.logVerbose(`[run-completed] ${completedRun.runId} status=${completedRun.status}`);
2340
+ return {
2341
+ issueRecords: releaseIssueOrchestration(issueRecords, run.issueId, now),
2342
+ recovered: false
2343
+ };
2344
+ }
2345
+ if (run.issueWorkspaceKey) {
2346
+ const issueWorkspacePath = resolveIssueWorkspaceDirectory(this.store.projectDir(tenant.projectId), run.issueWorkspaceKey);
2347
+ await this.runHook("after_run", tenant, run.workingDirectory, run.repository, {
2348
+ projectId: run.projectId,
2349
+ workspaceKey: run.issueWorkspaceKey,
2350
+ issueSubjectId: run.issueSubjectId,
2351
+ issueIdentifier: run.issueIdentifier,
2352
+ workspacePath: issueWorkspacePath,
2353
+ repositoryPath: run.workingDirectory,
2354
+ runId: run.runId,
2355
+ state: run.issueState
2356
+ });
2357
+ }
2358
+ const retryKind = await this.classifyRetryKind(tenant, run);
2359
+ let nextRetryAt;
2360
+ if (retryKind === "continuation") {
2361
+ nextRetryAt = new Date(now.getTime() + CONTINUATION_RETRY_DELAY_MS).toISOString();
2362
+ } else {
2363
+ const retryOptions = await this.loadRetryPolicy(tenant, run.repository);
2364
+ const backoffMs = this.dependencies.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
2365
+ nextRetryAt = (retryOptions ? scheduleRetryAt(now, run.attempt + 1, retryOptions) : new Date(now.getTime() + backoffMs)).toISOString();
2366
+ }
2367
+ const retryRecord = {
2368
+ ...runWithTokens,
2369
+ status: "retrying",
2370
+ attempt: runWithTokens.attempt + 1,
2371
+ processId: null,
2372
+ updatedAt: now.toISOString(),
2373
+ nextRetryAt,
2374
+ retryKind,
2375
+ threadId: runWithTokens.threadId ?? runWithTokens.runtimeSession?.threadId ?? run.threadId ?? run.runtimeSession?.threadId ?? null,
2376
+ cumulativeTurnCount: runWithTokens.cumulativeTurnCount ?? run.cumulativeTurnCount ?? 0,
2377
+ lastTurnSummary: runWithTokens.lastTurnSummary ?? run.lastTurnSummary ?? null,
2378
+ runPhase: runWithTokens.runPhase ?? "failed",
2379
+ lastError: retryKind === "continuation" ? null : "Worker process exited unexpectedly."
2380
+ };
2381
+ await this.store.saveRun(retryRecord);
2382
+ this.logVerbose(`[retry-scheduled] ${retryRecord.runId} kind=${retryKind} attempt=${retryRecord.attempt} nextAt=${nextRetryAt}`);
2383
+ this.logVerbose(`[run-completed] ${retryRecord.runId} status=${retryRecord.status}`);
2384
+ issueRecords = upsertIssueOrchestration(issueRecords, {
2385
+ issueId: run.issueId,
2386
+ identifier: run.issueIdentifier,
2387
+ workspaceKey: run.issueWorkspaceKey ?? deriveIssueWorkspaceKey({
2388
+ projectId: tenant.projectId,
2389
+ adapter: tenant.tracker.adapter,
2390
+ issueSubjectId: run.issueSubjectId
2391
+ }, run.issueIdentifier),
2392
+ state: "retry_queued",
2393
+ completedOnce: retryKind === "continuation" ? true : void 0,
2394
+ currentRunId: run.runId,
2395
+ retryEntry: {
2396
+ attempt: retryRecord.attempt,
2397
+ dueAt: nextRetryAt,
2398
+ error: retryRecord.lastError
2399
+ },
2400
+ updatedAt: now.toISOString()
2401
+ });
2402
+ return {
2403
+ issueRecords,
2404
+ recovered: false
2405
+ };
2406
+ }
2407
+ now() {
2408
+ return this.dependencies.now?.() ?? /* @__PURE__ */ new Date();
2409
+ }
2410
+ isVerboseLoggingEnabled() {
2411
+ return this.dependencies.logLevel === "verbose";
2412
+ }
2413
+ writeStderr(message) {
2414
+ (this.dependencies.stderr ?? process.stderr).write(`${message}
2415
+ `);
2416
+ }
2417
+ consumeWorkerStderrChunk(runId, chunk) {
2418
+ let decoder = this.workerStderrDecoders.get(runId);
2419
+ if (!decoder) {
2420
+ decoder = new StringDecoder("utf8");
2421
+ this.workerStderrDecoders.set(runId, decoder);
2422
+ }
2423
+ const nextBuffer = (this.workerStderrBuffers.get(runId) ?? "") + decoder.write(chunk);
2424
+ const lines = nextBuffer.split("\n");
2425
+ this.workerStderrBuffers.set(runId, lines.pop() ?? "");
2426
+ for (const line of lines) {
2427
+ this.consumeWorkerStderrLine(runId, line);
2428
+ }
2429
+ }
2430
+ flushWorkerStderrBuffer(runId) {
2431
+ const decoder = this.workerStderrDecoders.get(runId);
2432
+ const remainder = (this.workerStderrBuffers.get(runId) ?? "") + (decoder?.end() ?? "");
2433
+ this.workerStderrBuffers.delete(runId);
2434
+ this.workerStderrDecoders.delete(runId);
2435
+ if (remainder && remainder.trim()) {
2436
+ this.consumeWorkerStderrLine(runId, remainder);
2437
+ }
2438
+ }
2439
+ consumeWorkerStderrLine(runId, line) {
2440
+ const trimmed = line.trim();
2441
+ if (!trimmed || !trimmed.startsWith("{")) {
2442
+ return;
2443
+ }
2444
+ try {
2445
+ const parsed = JSON.parse(trimmed);
2446
+ if (!isOrchestratorChannelEvent(parsed)) {
2447
+ return;
2448
+ }
2449
+ void this.runSerialized(() => this.applyWorkerChannelEvent(runId, parsed)).catch((error) => {
2450
+ const message = error instanceof Error ? error.message : String(error ?? "unknown");
2451
+ this.writeStderr(`[orchestrator] failed to apply worker channel event for ${runId}: ${message}`);
2452
+ });
2453
+ } catch {
2454
+ }
2455
+ }
2456
+ async applyWorkerChannelEvent(runId, event) {
2457
+ const run = await this.store.loadRun(runId, this.projectConfig.projectId);
2458
+ if (!run || !canApplyWorkerChannelUpdate(run.status) || run.issueId !== event.issueId) {
2459
+ return;
2460
+ }
2461
+ if (event.type === "heartbeat") {
2462
+ const nowIso2 = this.now().toISOString();
2463
+ const persistedLastEventAt = event.lastEventAt ?? run.lastEventAt ?? null;
2464
+ await this.store.saveRun({
2465
+ ...run,
2466
+ updatedAt: nowIso2,
2467
+ lastEvent: "heartbeat",
2468
+ lastTurnSummary: resolveLastTurnSummary(run.lastTurnSummary, event.lastError),
2469
+ lastEventAt: persistedLastEventAt,
2470
+ lastEventAtSource: event.lastEventAt != null ? "event-channel" : run.lastEventAtSource ?? null,
2471
+ tokenUsage: event.tokenUsage,
2472
+ rateLimits: event.rateLimits,
2473
+ runtimeSession: buildRuntimeSession(run.runtimeSession, resolveChannelSessionId(event.sessionInfo), event.sessionInfo?.threadId ?? null, "active", run.startedAt ?? run.runtimeSession?.startedAt ?? nowIso2, nowIso2, event.sessionInfo?.exitClassification ?? null),
2474
+ threadId: event.sessionInfo?.threadId ?? run.threadId ?? run.runtimeSession?.threadId ?? null,
2475
+ turnCount: event.sessionInfo && event.sessionInfo.turnCount != null ? event.sessionInfo.turnCount : run.turnCount,
2476
+ cumulativeTurnCount: resolveCumulativeTurnCount(run, event.sessionInfo?.turnCount ?? null),
2477
+ executionPhase: event.executionPhase ?? run.executionPhase,
2478
+ runPhase: event.runPhase ?? run.runPhase,
2479
+ lastError: event.lastError
2480
+ });
2481
+ return;
2482
+ }
2483
+ if (event.type === "turn_started") {
2484
+ await this.store.appendRunEvent(runId, {
2485
+ at: event.startedAt,
2486
+ event: "turn_started",
2487
+ projectId: run.projectId,
2488
+ issueIdentifier: run.issueIdentifier,
2489
+ issueId: run.issueId,
2490
+ sessionId: event.sessionId,
2491
+ threadId: event.threadId,
2492
+ turnId: event.turnId,
2493
+ turnCount: event.turnCount
2494
+ });
2495
+ return;
2496
+ }
2497
+ if (event.type === "turn_completed") {
2498
+ await this.store.appendRunEvent(runId, {
2499
+ at: event.completedAt,
2500
+ event: "turn_completed",
2501
+ projectId: run.projectId,
2502
+ issueIdentifier: run.issueIdentifier,
2503
+ issueId: run.issueId,
2504
+ sessionId: event.sessionId,
2505
+ threadId: event.threadId,
2506
+ turnId: event.turnId,
2507
+ turnCount: event.turnCount,
2508
+ startedAt: event.startedAt,
2509
+ durationMs: event.durationMs,
2510
+ tokenUsage: event.tokenUsage
2511
+ });
2512
+ return;
2513
+ }
2514
+ if (event.type === "turn_failed") {
2515
+ await this.store.appendRunEvent(runId, {
2516
+ at: event.failedAt,
2517
+ event: "turn_failed",
2518
+ projectId: run.projectId,
2519
+ issueIdentifier: run.issueIdentifier,
2520
+ issueId: run.issueId,
2521
+ sessionId: event.sessionId,
2522
+ threadId: event.threadId,
2523
+ turnId: event.turnId,
2524
+ turnCount: event.turnCount,
2525
+ startedAt: event.startedAt,
2526
+ durationMs: event.durationMs,
2527
+ tokenUsage: event.tokenUsage,
2528
+ error: event.error
2529
+ });
2530
+ return;
2531
+ }
2532
+ const nowIso = this.now().toISOString();
2533
+ await this.store.saveRun({
2534
+ ...run,
2535
+ updatedAt: nowIso,
2536
+ lastEvent: event.event ?? run.lastEvent ?? null,
2537
+ lastTurnSummary: resolveLastTurnSummary(run.lastTurnSummary, resolveLastTurnSummaryCandidate(event.event, event.lastError)),
2538
+ lastEventAt: event.lastEventAt,
2539
+ lastEventAtSource: "event-channel",
2540
+ tokenUsage: event.tokenUsage ?? run.tokenUsage,
2541
+ rateLimits: event.rateLimits ?? run.rateLimits ?? null,
2542
+ runtimeSession: buildRuntimeSession(run.runtimeSession, resolveChannelSessionId(event.sessionInfo), event.sessionInfo?.threadId ?? run.runtimeSession?.threadId ?? null, "active", run.startedAt ?? run.runtimeSession?.startedAt ?? nowIso, nowIso, event.sessionInfo?.exitClassification ?? null),
2543
+ threadId: event.sessionInfo?.threadId ?? run.threadId ?? run.runtimeSession?.threadId ?? null,
2544
+ turnCount: event.sessionInfo && event.sessionInfo.turnCount != null ? event.sessionInfo.turnCount : run.turnCount,
2545
+ cumulativeTurnCount: resolveCumulativeTurnCount(run, event.sessionInfo?.turnCount ?? null),
2546
+ executionPhase: event.executionPhase ?? run.executionPhase ?? null,
2547
+ runPhase: event.runPhase ?? run.runPhase ?? null,
2548
+ lastError: event.lastError ?? run.lastError
2549
+ });
2550
+ }
2551
+ logVerbose(message) {
2552
+ if (!this.isVerboseLoggingEnabled()) {
2553
+ return;
2554
+ }
2555
+ this.writeStderr(message);
2556
+ }
2557
+ async waitForNextPoll() {
2558
+ if (this.consumePendingReconcileRequest()) {
2559
+ return;
2560
+ }
2561
+ const customWait = this.dependencies.waitImpl;
2562
+ const pollIntervalMs = this.getEffectivePollIntervalMs();
2563
+ const waitPromise = this.createPendingSleepPromise();
2564
+ try {
2565
+ if (customWait) {
2566
+ await Promise.race([customWait(pollIntervalMs), waitPromise]);
2567
+ } else {
2568
+ this.sleepTimer = setTimeout(() => {
2569
+ this.sleepResolver?.();
2570
+ }, pollIntervalMs);
2571
+ await waitPromise;
2572
+ }
2573
+ } finally {
2574
+ this.cancelPendingSleep();
2575
+ }
2576
+ this.consumePendingReconcileRequest();
2577
+ }
2578
+ cancelPendingSleep() {
2579
+ if (this.sleepTimer) {
2580
+ clearTimeout(this.sleepTimer);
2581
+ this.sleepTimer = null;
2582
+ }
2583
+ this.sleepResolver?.();
2584
+ this.sleepResolver = null;
2585
+ }
2586
+ createPendingSleepPromise() {
2587
+ return new Promise((resolve4) => {
2588
+ this.sleepResolver = () => {
2589
+ this.sleepResolver = null;
2590
+ this.sleepTimer = null;
2591
+ resolve4();
2592
+ };
2593
+ });
2594
+ }
2595
+ consumePendingReconcileRequest() {
2596
+ if (!this.reconcileRequested) {
2597
+ return false;
2598
+ }
2599
+ this.reconcileRequested = false;
2600
+ return true;
2601
+ }
2602
+ /**
2603
+ * Classify whether a process exit should be treated as continuation retry
2604
+ * or failure retry. Continuation applies when the issue is still actionable
2605
+ * — the worker completed its session and the issue hasn't transitioned away.
2606
+ * Failure applies when we cannot confirm the issue is still actionable.
2607
+ */
2608
+ async classifyRetryKind(tenant, run) {
2609
+ try {
2610
+ const trackerAdapter = resolveTrackerAdapter2(tenant.tracker);
2611
+ const issues = await trackerAdapter.listIssues(tenant, {
2612
+ fetchImpl: this.dependencies.fetchImpl
2613
+ });
2614
+ const runIssue = issues.find((issue) => issue.identifier === run.issueIdentifier);
2615
+ if (!runIssue) {
2616
+ return "failure";
2617
+ }
2618
+ const resolution = await this.loadProjectWorkflow(tenant, run.repository);
2619
+ if (!isUsableWorkflowResolution(resolution)) {
2620
+ return "failure";
2621
+ }
2622
+ return isStateActive(runIssue.state, resolution.lifecycle) ? "continuation" : "failure";
2623
+ } catch {
2624
+ return "failure";
2625
+ }
2626
+ }
2627
+ async resolveRetryRestartAction(tenant, run) {
2628
+ try {
2629
+ if (isIssueBudgetExceeded(resolveIssueBudgetSnapshot((await this.store.loadAllRuns()).filter((candidate) => candidate.projectId === tenant.projectId), run.issueId), this.now())) {
2630
+ return "release";
2631
+ }
2632
+ const runIssue = await this.fetchTrackedIssueById(tenant, run.issueId);
2633
+ if (!runIssue) {
2634
+ return "release";
2635
+ }
2636
+ const resolution = await this.loadProjectWorkflow(tenant, run.repository);
2637
+ if (!isUsableWorkflowResolution(resolution)) {
2638
+ return "restart";
2639
+ }
2640
+ return isStateActive(runIssue.state, resolution.lifecycle) ? "restart" : "release";
2641
+ } catch {
2642
+ return "restart";
2643
+ }
2644
+ }
2645
+ async fetchTrackedIssueById(tenant, issueId) {
2646
+ const trackerAdapter = resolveTrackerAdapter2(tenant.tracker);
2647
+ const issues = await trackerAdapter.fetchIssueStatesByIds(tenant, [issueId], {
2648
+ fetchImpl: this.dependencies.fetchImpl
2649
+ });
2650
+ return issues[0] ?? null;
2651
+ }
2652
+ async fetchWorkerRunInfo(run) {
2653
+ const latestRun = await this.store.loadRun(run.runId, run.projectId) ?? run;
2654
+ const persistedTokenUsage = await this.readPersistedWorkerTokenUsage(latestRun);
2655
+ return {
2656
+ tokenUsage: persistedTokenUsage,
2657
+ sessionId: latestRun.runtimeSession?.sessionId ?? null,
2658
+ threadId: latestRun.threadId ?? latestRun.runtimeSession?.threadId ?? null,
2659
+ turnCount: latestRun.turnCount ?? null,
2660
+ exitClassification: latestRun.runtimeSession?.exitClassification ?? null,
2661
+ lastError: latestRun.lastError ?? null,
2662
+ lastEvent: latestRun.lastEvent ?? null,
2663
+ lastEventAt: latestRun.lastEventAt ?? null,
2664
+ lastEventAtSource: latestRun.lastEventAtSource ?? null,
2665
+ executionPhase: latestRun.executionPhase ?? null,
2666
+ runPhase: latestRun.runPhase ?? null,
2667
+ rateLimits: latestRun.rateLimits ?? null
2668
+ };
2669
+ }
2670
+ async readPersistedWorkerTokenUsage(run) {
2671
+ const artifactPaths = [
2672
+ join3(run.workspaceRuntimeDir, "token-usage.json"),
2673
+ join3(run.workspaceRuntimeDir, ".orchestrator", "runs", run.runId, "token-usage.json")
2674
+ ];
2675
+ for (const artifactPath of artifactPaths) {
2676
+ try {
2677
+ const raw = await readFile3(artifactPath, "utf8");
2678
+ const tokenUsage = JSON.parse(raw);
2679
+ if (hasTokenUsage(tokenUsage)) {
2680
+ return tokenUsage;
2681
+ }
2682
+ } catch {
2683
+ continue;
2684
+ }
2685
+ }
2686
+ return null;
2687
+ }
2688
+ /**
2689
+ * Execute a workspace lifecycle hook using the workflow configuration
2690
+ * loaded from the repository. Returns the hook result or null if the
2691
+ * workflow could not be loaded.
2692
+ */
2693
+ async runHook(kind, tenant, repositoryDirectory, repository, context, resolution) {
2694
+ try {
2695
+ const workflowResolution = resolution ?? await this.loadProjectWorkflow(tenant, repository);
2696
+ if (!isUsableWorkflowResolution(workflowResolution)) {
2697
+ return null;
2698
+ }
2699
+ const hookEnv = this.buildProjectExecutionEnv(tenant.projectId, buildHookEnv(context));
2700
+ return executeWorkspaceHook({
2701
+ kind,
2702
+ hooks: workflowResolution.workflow.hooks,
2703
+ repositoryPath: repositoryDirectory,
2704
+ env: hookEnv,
2705
+ timeoutMs: workflowResolution.workflow.hooks.timeoutMs
2706
+ });
2707
+ } catch {
2708
+ return null;
2709
+ }
2710
+ }
2711
+ readProjectEnv(projectId) {
2712
+ const envPath = join3(this.store.projectDir(projectId), ".env");
2713
+ try {
2714
+ return readEnvFile(envPath);
2715
+ } catch (error) {
2716
+ const message = error instanceof Error ? error.message : "Unknown error occurred.";
2717
+ (this.dependencies.stderr ?? process.stderr).write(`[warn] Failed to load project env for ${projectId} from ${envPath}: ${message}
2718
+ `);
2719
+ return {};
2720
+ }
2721
+ }
2722
+ buildProjectExecutionEnv(projectId, env) {
2723
+ const inheritedEnv = Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] === "string"));
2724
+ const explicitEnv = Object.fromEntries(Object.entries(env).filter((entry) => typeof entry[1] === "string"));
2725
+ return {
2726
+ ...this.readProjectEnv(projectId),
2727
+ ...inheritedEnv,
2728
+ ...explicitEnv
2729
+ };
2730
+ }
2731
+ async restartRun(tenant, run, issueRecords, now, sessionId) {
2732
+ const supersededRecord = {
2733
+ ...run,
2734
+ status: "failed",
2735
+ completedAt: now.toISOString(),
2736
+ updatedAt: now.toISOString(),
2737
+ lastError: "Superseded by recovered run."
2738
+ };
2739
+ await this.store.saveRun(supersededRecord);
2740
+ const issue = resolveTrackerAdapter2(tenant.tracker).reviveIssue(tenant, run);
2741
+ const restarted = await this.startRun(tenant, issue, {
2742
+ threadId: run.threadId ?? run.runtimeSession?.threadId ?? null,
2743
+ cumulativeTurnCount: resolvePersistedCumulativeTurnCount(run),
2744
+ lastTurnSummary: run.lastTurnSummary ?? null
2745
+ });
2746
+ const recoveredRecord = {
2747
+ ...restarted,
2748
+ attempt: run.attempt,
2749
+ retryKind: run.retryKind ?? "recovery",
2750
+ createdAt: run.createdAt,
2751
+ issueWorkspaceKey: run.issueWorkspaceKey,
2752
+ threadId: run.threadId ?? run.runtimeSession?.threadId ?? null,
2753
+ cumulativeTurnCount: resolvePersistedCumulativeTurnCount(run),
2754
+ lastTurnSummary: run.lastTurnSummary ?? null,
2755
+ turnCount: 0
2756
+ };
2757
+ await this.store.saveRun(recoveredRecord);
2758
+ await this.store.appendRunEvent(run.runId, {
2759
+ at: now.toISOString(),
2760
+ event: "run-recovered",
2761
+ projectId: run.projectId,
2762
+ issueIdentifier: run.issueIdentifier,
2763
+ issueId: run.issueId,
2764
+ sessionId: sessionId ?? void 0
2765
+ });
2766
+ return {
2767
+ issueRecords: upsertIssueOrchestration(issueRecords, {
2768
+ issueId: recoveredRecord.issueId,
2769
+ identifier: recoveredRecord.issueIdentifier,
2770
+ workspaceKey: recoveredRecord.issueWorkspaceKey ?? deriveIssueWorkspaceKey({
2771
+ projectId: tenant.projectId,
2772
+ adapter: tenant.tracker.adapter,
2773
+ issueSubjectId: recoveredRecord.issueSubjectId
2774
+ }, recoveredRecord.issueIdentifier),
2775
+ state: "running",
2776
+ currentRunId: recoveredRecord.runId,
2777
+ retryEntry: null,
2778
+ updatedAt: now.toISOString()
2779
+ }),
2780
+ recovered: true
2781
+ };
2782
+ }
2783
+ async releaseRetryingRun(run, issueRecords, now) {
2784
+ const suppressedRun = {
2785
+ ...run,
2786
+ status: "suppressed",
2787
+ processId: null,
2788
+ completedAt: now.toISOString(),
2789
+ updatedAt: now.toISOString(),
2790
+ nextRetryAt: null,
2791
+ runPhase: "canceled_by_reconciliation",
2792
+ lastError: "Retry canceled because the tracker issue is no longer actionable."
2793
+ };
2794
+ await this.store.saveRun(suppressedRun);
2795
+ this.logVerbose(`[run-completed] ${suppressedRun.runId} status=${suppressedRun.status}`);
2796
+ return {
2797
+ issueRecords: releaseIssueOrchestration(issueRecords, run.issueId, now),
2798
+ recovered: false
2799
+ };
2800
+ }
2801
+ async loadProjectPollInterval(tenant) {
2802
+ const intervals = await Promise.all(tenant.repositories.map(async (repository) => {
2803
+ const resolution = await this.loadProjectWorkflow(tenant, repository);
2804
+ return isUsableWorkflowResolution(resolution) ? resolution.workflow.polling.intervalMs : NaN;
2805
+ }));
2806
+ const validIntervals = intervals.filter((value) => Number.isFinite(value) && value > 0);
2807
+ return validIntervals.length ? Math.min(...validIntervals) : DEFAULT_POLL_INTERVAL_MS;
2808
+ }
2809
+ async loadProjectMaxConcurrentByState(tenant) {
2810
+ const result = {};
2811
+ const resolutions = await Promise.all(tenant.repositories.map(async (repository) => {
2812
+ try {
2813
+ return await this.loadProjectWorkflow(tenant, repository);
2814
+ } catch {
2815
+ return null;
2816
+ }
2817
+ }));
2818
+ for (const resolution of resolutions) {
2819
+ if (!resolution)
2820
+ continue;
2821
+ if (!isUsableWorkflowResolution(resolution))
2822
+ continue;
2823
+ const stateLimits = resolution.workflow.agent.maxConcurrentAgentsByState;
2824
+ for (const [state, limit] of Object.entries(stateLimits)) {
2825
+ const existing = result[state];
2826
+ const numLimit = typeof limit === "number" ? limit : Number(limit);
2827
+ result[state] = existing === void 0 ? numLimit : Math.min(existing, numLimit);
2828
+ }
2829
+ }
2830
+ return result;
2831
+ }
2832
+ async loadRetryPolicy(tenant, repository) {
2833
+ try {
2834
+ const resolution = await this.loadProjectWorkflow(tenant, repository);
2835
+ if (!isUsableWorkflowResolution(resolution)) {
2836
+ return null;
2837
+ }
2838
+ return {
2839
+ baseDelayMs: this.dependencies.retryBackoffMs ?? resolution.workflow.agent.retryBaseDelayMs,
2840
+ maxDelayMs: this.dependencies.retryBackoffMs ?? resolution.workflow.agent.maxRetryBackoffMs,
2841
+ stallTimeoutMs: resolution.workflow.codex.stallTimeoutMs
2842
+ };
2843
+ } catch {
2844
+ if (!this.dependencies.retryBackoffMs) {
2845
+ return null;
2846
+ }
2847
+ return {
2848
+ baseDelayMs: this.dependencies.retryBackoffMs,
2849
+ maxDelayMs: this.dependencies.retryBackoffMs,
2850
+ stallTimeoutMs: null
2851
+ };
2852
+ }
2853
+ }
2854
+ async getProjectConcurrency(project) {
2855
+ if (this.dependencies.concurrency !== void 0) {
2856
+ return this.dependencies.concurrency;
2857
+ }
2858
+ const limits = await Promise.all(project.repositories.map(async (repository) => {
2859
+ try {
2860
+ const resolution = await this.loadProjectWorkflow(project, repository);
2861
+ return isUsableWorkflowResolution(resolution) ? resolution.workflow.agent.maxConcurrentAgents : NaN;
2862
+ } catch {
2863
+ return NaN;
2864
+ }
2865
+ }));
2866
+ const validLimits = limits.filter((value) => Number.isFinite(value) && value >= 0);
2867
+ return validLimits.length ? Math.min(...validLimits) : DEFAULT_CONCURRENCY;
2868
+ }
2869
+ async resolveWorkflowResolution(repository, cacheRoot, resolution, changed) {
2870
+ const cacheKey = this.workflowCacheKey(repository);
2871
+ if (resolution.isValid) {
2872
+ const effectiveResolution = {
2873
+ ...resolution,
2874
+ isValid: true,
2875
+ usedLastKnownGood: false,
2876
+ validationError: null
2877
+ };
2878
+ let workflowPath = effectiveResolution.workflowPath;
2879
+ try {
2880
+ workflowPath = await this.persistLastKnownGoodWorkflow(cacheRoot, effectiveResolution) ?? effectiveResolution.workflowPath;
2881
+ } catch {
2882
+ workflowPath = effectiveResolution.workflowPath;
2883
+ }
2884
+ this.lastKnownGoodWorkflows.set(cacheKey, {
2885
+ ...effectiveResolution,
2886
+ workflowPath
2887
+ });
2888
+ this.lastReportedWorkflowErrors.delete(cacheKey);
2889
+ return effectiveResolution;
2890
+ }
2891
+ const cached = this.lastKnownGoodWorkflows.get(cacheKey);
2892
+ const message = resolution.validationError ?? "Invalid repository WORKFLOW.md";
2893
+ const previousMessage = this.lastReportedWorkflowErrors.get(cacheKey);
2894
+ if (changed || previousMessage !== message) {
2895
+ process.stderr.write(`[orchestrator] failed to reload WORKFLOW.md for ${repository.owner}/${repository.name}: ${message}
2896
+ `);
2897
+ this.lastReportedWorkflowErrors.set(cacheKey, message);
2898
+ }
2899
+ if (!cached) {
2900
+ return resolution;
2901
+ }
2902
+ return {
2903
+ ...cached,
2904
+ workflowPath: cached.workflowPath,
2905
+ isValid: false,
2906
+ usedLastKnownGood: true,
2907
+ validationError: message
2908
+ };
2909
+ }
2910
+ async persistLastKnownGoodWorkflow(cacheRoot, resolution) {
2911
+ if (!resolution.workflowPath) {
2912
+ return null;
2913
+ }
2914
+ const snapshotPath = this.lastKnownGoodWorkflowPath(cacheRoot);
2915
+ const markdown = await readFile3(resolution.workflowPath, "utf8");
2916
+ await mkdir3(join3(cacheRoot, "last-known-good"), { recursive: true });
2917
+ await writeFile3(snapshotPath, markdown, "utf8");
2918
+ return snapshotPath;
2919
+ }
2920
+ lastKnownGoodWorkflowPath(cacheRoot) {
2921
+ return join3(cacheRoot, "last-known-good", "WORKFLOW.md");
2922
+ }
2923
+ workflowCacheKey(repository) {
2924
+ return `${repository.owner}/${repository.name}:${this.normalizeRepositoryCloneUrl(repository.cloneUrl)}`;
2925
+ }
2926
+ normalizeRepositoryCloneUrl(cloneUrl) {
2927
+ if (cloneUrl.startsWith("file://")) {
2928
+ try {
2929
+ return fileURLToPath(cloneUrl);
2930
+ } catch {
2931
+ return cloneUrl;
2932
+ }
2933
+ }
2934
+ return cloneUrl;
2935
+ }
2936
+ isProcessRunning(processId) {
2937
+ if (this.dependencies.isProcessRunning) {
2938
+ return this.dependencies.isProcessRunning(processId);
2939
+ }
2940
+ try {
2941
+ process.kill(-processId, 0);
2942
+ return true;
2943
+ } catch {
2944
+ return false;
2945
+ }
2946
+ }
2947
+ sendSignal(processId, signal) {
2948
+ try {
2949
+ const kill = this.dependencies.killImpl;
2950
+ if (kill) {
2951
+ kill(processId, signal);
2952
+ } else {
2953
+ process.kill(-processId, signal);
2954
+ }
2955
+ } catch {
2956
+ this.retireWorkerPid(processId);
2957
+ }
2958
+ }
2959
+ pruneExitedWorkerPids() {
2960
+ for (const pid of [...this.activeWorkerPids]) {
2961
+ if (!this.isProcessRunning(pid)) {
2962
+ this.retireWorkerPid(pid);
2963
+ }
2964
+ }
2965
+ }
2966
+ retireWorkerPid(processId) {
2967
+ if (processId) {
2968
+ this.activeWorkerPids.delete(processId);
2969
+ }
2970
+ }
2971
+ /**
2972
+ * Clean up the issue workspace for a terminal issue.
2973
+ *
2974
+ * Runs the `before_remove` hook if configured. Hook failures are logged and
2975
+ * ignored so workspace cleanup still proceeds per spec 9.4. The workspace
2976
+ * directory is removed and the record set to `removed`. Orchestration
2977
+ * records (runs) are preserved.
2978
+ */
2979
+ async cleanupTerminalIssueWorkspace(tenant, issue, now, workflowResolution) {
2980
+ const issueSubjectId = issue.id;
2981
+ const identity = {
2982
+ projectId: tenant.projectId,
2983
+ adapter: issue.tracker.adapter,
2984
+ issueSubjectId
2985
+ };
2986
+ const preferredWorkspaceKey = deriveIssueWorkspaceKey(identity, issue.identifier);
2987
+ const legacyWorkspaceKey = deriveLegacyIssueWorkspaceKey(identity);
2988
+ const orchestrationRecord = (await this.store.loadProjectIssueOrchestrations(tenant.projectId)).find((record) => record.issueId === issue.id);
2989
+ const workspaceRecord = (orchestrationRecord ? await this.store.loadIssueWorkspace(tenant.projectId, orchestrationRecord.workspaceKey) : null) ?? await this.store.loadIssueWorkspace(tenant.projectId, preferredWorkspaceKey) ?? (legacyWorkspaceKey === preferredWorkspaceKey ? null : await this.store.loadIssueWorkspace(tenant.projectId, legacyWorkspaceKey));
2990
+ if (!workspaceRecord || workspaceRecord.status === "removed") {
2991
+ return;
2992
+ }
2993
+ const pendingRecord = {
2994
+ ...workspaceRecord,
2995
+ status: "cleanup_pending",
2996
+ updatedAt: now.toISOString()
2997
+ };
2998
+ await this.store.saveIssueWorkspace(pendingRecord);
2999
+ const hookResult = await this.runHook("before_remove", tenant, workspaceRecord.repositoryPath, issue.repository, {
3000
+ projectId: tenant.projectId,
3001
+ workspaceKey: workspaceRecord.workspaceKey,
3002
+ issueSubjectId,
3003
+ issueIdentifier: issue.identifier,
3004
+ workspacePath: workspaceRecord.workspacePath,
3005
+ repositoryPath: workspaceRecord.repositoryPath
3006
+ }, workflowResolution);
3007
+ if (hookResult && hookResult.outcome !== "success" && hookResult.outcome !== "skipped") {
3008
+ const errorMessage = hookResult.error ?? `before_remove hook ${hookResult.outcome}`;
3009
+ console.warn(`[orchestrator] before_remove hook failed for ${issue.identifier}; continuing cleanup: ${errorMessage}`);
3010
+ }
3011
+ try {
3012
+ await rm3(workspaceRecord.workspacePath, { recursive: true, force: true });
3013
+ } catch {
3014
+ }
3015
+ const removedRecord = {
3016
+ ...workspaceRecord,
3017
+ status: "removed",
3018
+ updatedAt: now.toISOString(),
3019
+ lastError: null
3020
+ };
3021
+ await this.store.saveIssueWorkspace(removedRecord);
3022
+ }
3023
+ };
3024
+ function hasTokenUsage(tokenUsage) {
3025
+ return Boolean(tokenUsage && (tokenUsage.inputTokens > 0 || tokenUsage.outputTokens > 0 || tokenUsage.totalTokens > 0));
3026
+ }
3027
+ function isRecord(value) {
3028
+ return !!value && typeof value === "object" && !Array.isArray(value);
3029
+ }
3030
+ function resolveProjectRateLimits(runs, issues) {
3031
+ let latestRunRateLimits = null;
3032
+ let latestRunTimestamp = -Infinity;
3033
+ for (const run of runs) {
3034
+ if (!isRecord(run.rateLimits)) {
3035
+ continue;
3036
+ }
3037
+ const timestamp = parseTimestampMs(run.lastEventAt ?? run.updatedAt ?? run.startedAt);
3038
+ const sortableTimestamp = timestamp ?? -Infinity;
3039
+ if (sortableTimestamp >= latestRunTimestamp) {
3040
+ latestRunTimestamp = sortableTimestamp;
3041
+ latestRunRateLimits = run.rateLimits;
3042
+ }
3043
+ }
3044
+ if (latestRunRateLimits) {
3045
+ return latestRunRateLimits;
3046
+ }
3047
+ for (const issue of issues) {
3048
+ if (isRecord(issue.rateLimits)) {
3049
+ return issue.rateLimits;
3050
+ }
3051
+ }
3052
+ return null;
3053
+ }
3054
+ function buildRuntimeSession(existing, sessionId, threadId, status, startedAt, updatedAt, exitClassification = void 0) {
3055
+ if (existing === void 0 && sessionId === null && threadId === null && status === null && (exitClassification === void 0 || exitClassification === null)) {
3056
+ return void 0;
3057
+ }
3058
+ return {
3059
+ sessionId: sessionId ?? existing?.sessionId ?? null,
3060
+ threadId: threadId ?? existing?.threadId ?? null,
3061
+ status: status ?? existing?.status ?? null,
3062
+ startedAt: existing?.startedAt ?? startedAt,
3063
+ updatedAt,
3064
+ exitClassification: exitClassification === void 0 ? existing?.exitClassification ?? null : exitClassification
3065
+ };
3066
+ }
3067
+ function resolvePersistedCumulativeTurnCount(run) {
3068
+ return run.cumulativeTurnCount ?? run.turnCount ?? 0;
3069
+ }
3070
+ function hasConvergenceLockedRun(runs, issueId, issueState) {
3071
+ const latestRun = runs.filter((run) => run.issueId === issueId).sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())[0];
3072
+ return latestRun?.runtimeSession?.exitClassification === "convergence-detected" && latestRun.issueState === issueState;
3073
+ }
3074
+ function resolveIssueBudgetSnapshot(runs, issueId) {
3075
+ const issueRuns = runs.filter((run) => run.issueId === issueId);
3076
+ const startedAtCandidates = issueRuns.map((run) => run.startedAt).filter((value) => typeof value === "string");
3077
+ return {
3078
+ cumulativeTurnCount: issueRuns.reduce((total, run) => total + resolvePersistedCumulativeTurnCount(run), 0),
3079
+ tokenUsage: issueRuns.reduce((total, run) => ({
3080
+ inputTokens: total.inputTokens + (run.tokenUsage?.inputTokens ?? 0),
3081
+ outputTokens: total.outputTokens + (run.tokenUsage?.outputTokens ?? 0),
3082
+ totalTokens: total.totalTokens + (run.tokenUsage?.totalTokens ?? 0)
3083
+ }), {
3084
+ inputTokens: 0,
3085
+ outputTokens: 0,
3086
+ totalTokens: 0
3087
+ }),
3088
+ sessionStartedAt: startedAtCandidates.sort((left, right) => left.localeCompare(right))[0] ?? null
3089
+ };
3090
+ }
3091
+ function isIssueBudgetExceeded(snapshot, now, env = process.env) {
3092
+ const globalMaxTurns = parsePositiveInteger(env.SYMPHONY_GLOBAL_MAX_TURNS ?? "");
3093
+ if (globalMaxTurns !== null && snapshot.cumulativeTurnCount >= globalMaxTurns) {
3094
+ return true;
3095
+ }
3096
+ const maxTokens = parsePositiveInteger(env.SYMPHONY_MAX_TOKENS ?? "");
3097
+ if (maxTokens !== null && snapshot.tokenUsage.totalTokens >= maxTokens) {
3098
+ return true;
3099
+ }
3100
+ const sessionTimeoutMs = parsePositiveInteger(env.SYMPHONY_SESSION_TIMEOUT_MS ?? "");
3101
+ if (sessionTimeoutMs === null || snapshot.sessionStartedAt === null) {
3102
+ return false;
3103
+ }
3104
+ return now.getTime() - new Date(snapshot.sessionStartedAt).getTime() >= sessionTimeoutMs;
3105
+ }
3106
+ function parsePositiveInteger(value) {
3107
+ const parsed = Number(value);
3108
+ if (!Number.isFinite(parsed) || parsed <= 0) {
3109
+ return null;
3110
+ }
3111
+ return Math.floor(parsed);
3112
+ }
3113
+ function resolveCumulativeTurnCount(run, turnCount) {
3114
+ const carriedTotal = resolvePersistedCumulativeTurnCount(run);
3115
+ if (turnCount === null) {
3116
+ return carriedTotal;
3117
+ }
3118
+ const previousSessionTurnCount = run.turnCount ?? 0;
3119
+ const baseTurnCount = Math.max(0, carriedTotal - previousSessionTurnCount);
3120
+ return baseTurnCount + turnCount;
3121
+ }
3122
+ function isTerminalTurnEvent(event) {
3123
+ return event === "turn/completed" || event === "turn/failed" || event === "turn/cancelled";
3124
+ }
3125
+ function resolveLastTurnSummaryCandidate(event, lastError) {
3126
+ if (typeof lastError === "string" && lastError.trim()) {
3127
+ return lastError.trim();
3128
+ }
3129
+ return typeof event === "string" && isTerminalTurnEvent(event) ? event : null;
3130
+ }
3131
+ function resolveLastTurnSummary(existing, candidate) {
3132
+ if (typeof candidate === "string" && candidate.trim()) {
3133
+ return candidate.trim();
3134
+ }
3135
+ return existing ?? null;
3136
+ }
3137
+ function canApplyWorkerChannelUpdate(status) {
3138
+ return status === "running" || status === "retrying";
3139
+ }
3140
+ function resolveChannelSessionId(sessionInfo) {
3141
+ if (!sessionInfo) {
3142
+ return null;
3143
+ }
3144
+ return sessionInfo.sessionId ?? (sessionInfo.threadId && sessionInfo.turnId ? `${sessionInfo.threadId}-${sessionInfo.turnId}` : null);
3145
+ }
3146
+ function resolveWorkerCommand() {
3147
+ if (process.env.SYMPHONY_WORKER_COMMAND) {
3148
+ return process.env.SYMPHONY_WORKER_COMMAND;
3149
+ }
3150
+ try {
3151
+ const workerUrl = import.meta.resolve("@gh-symphony/worker");
3152
+ return `node ${fileURLToPath(workerUrl)}`;
3153
+ } catch {
3154
+ try {
3155
+ const bundledWorker = join3(fileURLToPath(new URL(".", import.meta.url)), "worker-entry.js");
3156
+ return `node ${bundledWorker}`;
3157
+ } catch {
3158
+ return DEFAULT_WORKER_COMMAND;
3159
+ }
3160
+ }
3161
+ }
3162
+ function createStore(runtimeRoot = ".runtime", options = {}) {
3163
+ return new OrchestratorFsStore(runtimeRoot, options);
3164
+ }
3165
+ function sortCandidatesForDispatch(candidates) {
3166
+ return [...candidates].sort((a, b) => {
3167
+ if (a.priority !== b.priority) {
3168
+ if (a.priority === null)
3169
+ return 1;
3170
+ if (b.priority === null)
3171
+ return -1;
3172
+ return a.priority - b.priority;
3173
+ }
3174
+ if (a.createdAt !== b.createdAt) {
3175
+ if (a.createdAt === null)
3176
+ return 1;
3177
+ if (b.createdAt === null)
3178
+ return -1;
3179
+ return a.createdAt < b.createdAt ? -1 : 1;
3180
+ }
3181
+ return a.identifier.localeCompare(b.identifier);
3182
+ });
3183
+ }
3184
+ function createProjectItemsCache() {
3185
+ const entries = /* @__PURE__ */ new Map();
3186
+ return {
3187
+ getOrLoad(key, load) {
3188
+ const cached = entries.get(key);
3189
+ if (cached) {
3190
+ return cached;
3191
+ }
3192
+ const pending = load().catch((error) => {
3193
+ entries.delete(key);
3194
+ throw error;
3195
+ });
3196
+ entries.set(key, pending);
3197
+ return pending;
3198
+ }
3199
+ };
3200
+ }
3201
+ function wait2(ms) {
3202
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
3203
+ }
3204
+ function createRunId(now, projectId, issueIdentifier) {
3205
+ return [
3206
+ projectId,
3207
+ issueIdentifier.replace(/[^a-zA-Z0-9]+/g, "-"),
3208
+ now.getTime().toString(36)
3209
+ ].join("-");
3210
+ }
3211
+ function isIssueOrchestrationClaimed(state) {
3212
+ return state === "claimed" || state === "running" || state === "retry_queued";
3213
+ }
3214
+ function upsertIssueOrchestration(issueRecords, nextRecord) {
3215
+ const existingRecord = issueRecords.find((record) => record.issueId === nextRecord.issueId) ?? null;
3216
+ const remaining = issueRecords.filter((record) => record.issueId !== nextRecord.issueId);
3217
+ return [
3218
+ ...remaining,
3219
+ {
3220
+ ...nextRecord,
3221
+ completedOnce: nextRecord.completedOnce ?? existingRecord?.completedOnce ?? false
3222
+ }
3223
+ ];
3224
+ }
3225
+ function releaseIssueOrchestration(issueRecords, issueId, now) {
3226
+ return issueRecords.map((record) => record.issueId === issueId ? {
3227
+ ...record,
3228
+ state: "released",
3229
+ currentRunId: null,
3230
+ retryEntry: null,
3231
+ updatedAt: now.toISOString()
3232
+ } : record);
3233
+ }
3234
+ function isActiveRunStatus(status) {
3235
+ return status === "pending" || status === "starting" || status === "running" || status === "retrying";
3236
+ }
3237
+
3238
+ // ../orchestrator/dist/lock.js
3239
+ import { randomUUID as randomUUID2 } from "crypto";
3240
+ import { mkdir as mkdir4, open as open2, readFile as readFile4, rm as rm4 } from "fs/promises";
3241
+ import { dirname as dirname2, isAbsolute, join as join4, relative as relative2, resolve as resolve2 } from "path";
3242
+ import { setTimeout as delay } from "timers/promises";
3243
+ var LOCK_READ_RETRY_DELAY_MS = 10;
3244
+ var LOCK_READ_RETRY_LIMIT = 20;
3245
+ async function acquireProjectLock(input) {
3246
+ assertValidProjectId(input.projectId);
3247
+ const pid = input.pid ?? process.pid;
3248
+ const startedAt = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
3249
+ const ownerToken = `${pid}:${randomUUID2()}`;
3250
+ const lockPath = resolveProjectLockPath(input.runtimeRoot, input.projectId);
3251
+ const record = { ownerToken, pid, startedAt };
3252
+ let invalidReadAttempts = 0;
3253
+ for (; ; ) {
3254
+ try {
3255
+ await mkdir4(dirname2(lockPath), { recursive: true });
3256
+ const handle = await open2(lockPath, "wx");
3257
+ try {
3258
+ await handle.writeFile(JSON.stringify(record, null, 2) + "\n", "utf8");
3259
+ } finally {
3260
+ await handle.close();
3261
+ }
3262
+ return { lockPath, ownerToken, pid, startedAt };
3263
+ } catch (error) {
3264
+ if (!isAlreadyExistsError2(error)) {
3265
+ throw error;
3266
+ }
3267
+ }
3268
+ const existing = await readProjectLock(lockPath);
3269
+ if (existing.status === "missing") {
3270
+ invalidReadAttempts = 0;
3271
+ continue;
3272
+ }
3273
+ if (existing.status === "invalid") {
3274
+ invalidReadAttempts += 1;
3275
+ if (invalidReadAttempts >= LOCK_READ_RETRY_LIMIT) {
3276
+ throw new Error(`Project "${input.projectId}" lock file is unreadable at "${lockPath}".`);
3277
+ }
3278
+ await delay(LOCK_READ_RETRY_DELAY_MS);
3279
+ continue;
3280
+ }
3281
+ invalidReadAttempts = 0;
3282
+ if ((input.isProcessRunning ?? isProcessRunning)(existing.record.pid)) {
3283
+ throw new Error(`Project "${input.projectId}" is already running (PID ${existing.record.pid}).`);
3284
+ }
3285
+ await rm4(lockPath, { force: true });
3286
+ }
3287
+ }
3288
+ async function releaseProjectLock(lock) {
3289
+ if (!lock) {
3290
+ return;
3291
+ }
3292
+ try {
3293
+ const existing = await readProjectLock(lock.lockPath);
3294
+ if (existing.status !== "valid" || existing.record.ownerToken !== lock.ownerToken) {
3295
+ return;
3296
+ }
3297
+ } catch (error) {
3298
+ if (isMissingFileError2(error)) {
3299
+ return;
3300
+ }
3301
+ throw error;
3302
+ }
3303
+ await rm4(lock.lockPath, { force: true });
3304
+ }
3305
+ async function readProjectLock(lockPath) {
3306
+ try {
3307
+ const raw = await readFile4(lockPath, "utf8");
3308
+ const record = parseProjectLock(raw);
3309
+ if (!record) {
3310
+ return { status: "invalid" };
3311
+ }
3312
+ return { status: "valid", record };
3313
+ } catch (error) {
3314
+ if (isMissingFileError2(error)) {
3315
+ return { status: "missing" };
3316
+ }
3317
+ throw error;
3318
+ }
3319
+ }
3320
+ function assertValidProjectId(projectId) {
3321
+ if (projectId.length === 0 || projectId === "." || projectId === ".." || projectId.includes("/") || projectId.includes("\\")) {
3322
+ throw new Error(`Invalid project ID "${projectId}". Project IDs must not contain path separators or traversal segments.`);
3323
+ }
3324
+ }
3325
+ function resolveProjectLockPath(runtimeRoot, projectId) {
3326
+ const store = new OrchestratorFsStore(runtimeRoot);
3327
+ const projectsRoot = resolve2(runtimeRoot, "projects");
3328
+ const projectDir = resolve2(store.projectDir(projectId));
3329
+ const relativeProjectDir = relative2(projectsRoot, projectDir);
3330
+ if (relativeProjectDir.length === 0 || relativeProjectDir.startsWith("..") || isAbsolute(relativeProjectDir)) {
3331
+ throw new Error(`Invalid project ID "${projectId}". Project lock path must stay within "${projectsRoot}".`);
3332
+ }
3333
+ return join4(projectDir, ".lock");
3334
+ }
3335
+ function parseProjectLock(raw) {
3336
+ try {
3337
+ const parsed = JSON.parse(raw);
3338
+ if (typeof parsed.ownerToken !== "string" || typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.startedAt !== "string") {
3339
+ return null;
3340
+ }
3341
+ return {
3342
+ ownerToken: parsed.ownerToken,
3343
+ pid: parsed.pid,
3344
+ startedAt: parsed.startedAt
3345
+ };
3346
+ } catch {
3347
+ return null;
3348
+ }
3349
+ }
3350
+ function isProcessRunning(pid) {
3351
+ try {
3352
+ process.kill(pid, 0);
3353
+ return true;
3354
+ } catch (error) {
3355
+ return !(error && typeof error === "object" && "code" in error && error.code === "ESRCH");
3356
+ }
3357
+ }
3358
+ function isAlreadyExistsError2(error) {
3359
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
3360
+ }
3361
+ function isMissingFileError2(error) {
3362
+ return Boolean(error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"));
3363
+ }
3364
+
3365
+ // ../orchestrator/dist/index.js
3366
+ import { pathToFileURL } from "url";
3367
+ import { resolve as resolve3 } from "path";
3368
+ function resolveOrchestratorLogLevel(value) {
3369
+ if (!value || value === "normal") {
3370
+ return "normal";
3371
+ }
3372
+ if (value === "verbose") {
3373
+ return "verbose";
3374
+ }
3375
+ throw new Error(`Unsupported log level: ${value}. Supported values: normal, verbose.`);
3376
+ }
3377
+ async function runCli(argv, dependencies = {}) {
3378
+ const [command = "run-once", ...args] = argv;
3379
+ const parsed = parseArgs(args);
3380
+ if (parsed.projectId) {
3381
+ assertValidProjectId(parsed.projectId);
3382
+ }
3383
+ const runtimeRoot = resolve3(parsed.runtimeRoot ?? ".runtime");
3384
+ const stderr = dependencies.stderr ?? process.stderr;
3385
+ const eventsDir = resolveOptionalPath(parsed.eventsDir ?? process.env.SYMPHONY_EVENTS_DIR);
3386
+ const logLevel = resolveOrchestratorLogLevel(parsed.logLevel ?? process.env.SYMPHONY_LOG_LEVEL);
3387
+ const service = await dependencies.createService?.(runtimeRoot, parsed.projectId, {
3388
+ eventsDir,
3389
+ logLevel,
3390
+ stderr
3391
+ }) ?? await createServiceForRuntime(runtimeRoot, parsed.projectId, {
3392
+ eventsDir,
3393
+ logLevel,
3394
+ stderr
3395
+ });
3396
+ const stdout = dependencies.stdout ?? process.stdout;
3397
+ const exitProcess = dependencies.exitProcess ?? process.exit;
3398
+ const signalTarget = dependencies.signalTarget ?? process;
3399
+ switch (command) {
3400
+ case "run": {
3401
+ let lock = null;
3402
+ let cleanupPromise = null;
3403
+ let shuttingDownForSignal = false;
3404
+ const cleanup = async () => {
3405
+ if (cleanupPromise) {
3406
+ return cleanupPromise;
3407
+ }
3408
+ cleanupPromise = (async () => {
3409
+ let cleanupError;
3410
+ const shutdownPromise = service.shutdown();
3411
+ try {
3412
+ await shutdownPromise;
3413
+ } catch (error) {
3414
+ cleanupError = error;
3415
+ } finally {
3416
+ try {
3417
+ await (dependencies.releaseLock ?? releaseProjectLock)(lock);
3418
+ lock = null;
3419
+ } catch (lockError) {
3420
+ cleanupError ??= lockError;
3421
+ }
3422
+ }
3423
+ if (cleanupError) {
3424
+ throw cleanupError;
3425
+ }
3426
+ })();
3427
+ return cleanupPromise;
3428
+ };
3429
+ const handleSignal = (signal) => {
3430
+ shuttingDownForSignal = true;
3431
+ let exitCode = 0;
3432
+ void cleanup().catch((error) => {
3433
+ exitCode = 1;
3434
+ stderr.write(`Failed to shut down orchestrator after ${signal}: ${error instanceof Error ? error.message : String(error)}
3435
+ `);
3436
+ }).finally(() => {
3437
+ exitProcess(exitCode);
3438
+ });
3439
+ };
3440
+ const sigintHandler = () => handleSignal("SIGINT");
3441
+ const sigtermHandler = () => handleSignal("SIGTERM");
3442
+ try {
3443
+ if (parsed.projectId) {
3444
+ lock = await (dependencies.acquireLock ?? acquireProjectLock)({
3445
+ runtimeRoot,
3446
+ projectId: parsed.projectId
3447
+ });
3448
+ }
3449
+ signalTarget.once("SIGINT", sigintHandler);
3450
+ signalTarget.once("SIGTERM", sigtermHandler);
3451
+ await service.run({
3452
+ issueIdentifier: parsed.issueIdentifier
3453
+ });
3454
+ await cleanup();
3455
+ } finally {
3456
+ signalTarget.off("SIGINT", sigintHandler);
3457
+ signalTarget.off("SIGTERM", sigtermHandler);
3458
+ if (!shuttingDownForSignal) {
3459
+ await cleanup();
3460
+ }
3461
+ }
3462
+ return;
3463
+ }
3464
+ case "run-once":
3465
+ case "dispatch": {
3466
+ const result = await service.runOnce({
3467
+ issueIdentifier: parsed.issueIdentifier
3468
+ });
3469
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
3470
+ return;
3471
+ }
3472
+ case "run-issue": {
3473
+ if (!parsed.projectId || !parsed.issueIdentifier) {
3474
+ throw new Error("run-issue requires --project-id and --issue.");
3475
+ }
3476
+ const result = await service.runOnce({
3477
+ issueIdentifier: parsed.issueIdentifier
3478
+ });
3479
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
3480
+ return;
3481
+ }
3482
+ case "recover": {
3483
+ const result = await service.recover();
3484
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
3485
+ return;
3486
+ }
3487
+ case "status": {
3488
+ const result = await service.status();
3489
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
3490
+ return;
3491
+ }
3492
+ default:
3493
+ throw new Error(`Unsupported command: ${command}`);
3494
+ }
3495
+ }
3496
+ async function createServiceForRuntime(runtimeRoot, projectId, options) {
3497
+ if (!projectId) {
3498
+ throw new Error("Orchestrator CLI requires --project-id.");
3499
+ }
3500
+ const store = createStore(runtimeRoot, {
3501
+ eventsMirrorRoot: options?.eventsDir
3502
+ });
3503
+ const projectConfig = await store.loadProjectConfig(projectId);
3504
+ if (!projectConfig) {
3505
+ throw new Error(`Project config not found for "${projectId}".`);
3506
+ }
3507
+ return new OrchestratorService(store, projectConfig, options);
3508
+ }
3509
+ async function main() {
3510
+ await runCli(process.argv.slice(2));
3511
+ }
3512
+ function parseArgs(args) {
3513
+ const parsed = {};
3514
+ for (let index = 0; index < args.length; index += 1) {
3515
+ const argument = args[index];
3516
+ const value = args[index + 1];
3517
+ if (!argument?.startsWith("--")) {
3518
+ continue;
3519
+ }
3520
+ switch (argument) {
3521
+ case "--runtime-root":
3522
+ parsed.runtimeRoot = value;
3523
+ index += 1;
3524
+ break;
3525
+ case "--project":
3526
+ case "--project-id":
3527
+ parsed.projectId = value;
3528
+ index += 1;
3529
+ break;
3530
+ case "--issue":
3531
+ parsed.issueIdentifier = value;
3532
+ index += 1;
3533
+ break;
3534
+ case "--events-dir":
3535
+ if (!value || value.startsWith("-")) {
3536
+ throw new Error(`Option '${argument}' argument missing`);
3537
+ }
3538
+ parsed.eventsDir = value;
3539
+ index += 1;
3540
+ break;
3541
+ case "--log-level":
3542
+ if (!value || value.startsWith("-")) {
3543
+ throw new Error(`Option '${argument}' argument missing`);
3544
+ }
3545
+ parsed.logLevel = value;
3546
+ index += 1;
3547
+ break;
3548
+ default:
3549
+ throw new Error(`Unknown option: ${argument}`);
3550
+ }
3551
+ }
3552
+ return parsed;
3553
+ }
3554
+ function resolveOptionalPath(value) {
3555
+ if (!value || value.trim().length === 0) {
3556
+ return void 0;
3557
+ }
3558
+ return resolve3(value.trim());
3559
+ }
3560
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
3561
+ main().catch((error) => {
3562
+ process.stderr.write(`${error instanceof Error ? error.message : "Unknown error"}
3563
+ `);
3564
+ process.exitCode = 1;
3565
+ });
3566
+ }
3567
+
3568
+ export {
3569
+ OrchestratorService,
3570
+ createStore,
3571
+ acquireProjectLock,
3572
+ releaseProjectLock,
3573
+ resolveOrchestratorLogLevel,
3574
+ runCli
3575
+ };