opencode-magi 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,6 +151,12 @@ function ciReportText(input) {
151
151
  const scopeInside = input.report.scopeInside.length;
152
152
  return `CI report for ${input.pr}: ${failed} failed, ${scopeInside} scope-in, ${rerun} rerun, ${recovered} recovered, ${unresolved} unresolved.`;
153
153
  }
154
+ function ciClassifierCompletedText(input) {
155
+ const summary = input.checks
156
+ .map((check) => `${check.name}: ${check.classification} - ${check.reason}`)
157
+ .join("; ");
158
+ return `**CI classifier ${input.reviewer}** completed for ${input.pr}: ${summary}`;
159
+ }
154
160
  function closeReconsiderationText(input) {
155
161
  if (input.to === "MERGE") {
156
162
  return `**Reviewer ${input.reviewer}** changed their close request to approval for ${input.pr}.`;
@@ -403,6 +409,8 @@ function extractQuestionRequest(properties) {
403
409
  export class MagiRunManager {
404
410
  input;
405
411
  active = new Map();
412
+ activePrRuns = 0;
413
+ activeTriageRuns = 0;
406
414
  countedToolParts = new Map();
407
415
  controllers = new Map();
408
416
  eventLastUpdates = new Map();
@@ -410,6 +418,8 @@ export class MagiRunManager {
410
418
  runPaths = new Map();
411
419
  outputDirs = new Set();
412
420
  sessionToRun = new Map();
421
+ prQueue = [];
422
+ triageQueue = [];
413
423
  constructor(input) {
414
424
  this.input = input;
415
425
  }
@@ -453,13 +463,19 @@ export class MagiRunManager {
453
463
  await this.notify(state, `Started Magi review for ${prMarkdownLink(state)}.`);
454
464
  const controller = new AbortController();
455
465
  this.controllers.set(runId, controller);
456
- void this.executeReview({
466
+ const execute = () => this.executeReview({
457
467
  ...input,
458
468
  runId,
459
469
  signal: controller.signal,
460
- }).catch(async (error) => {
461
- await this.failRun(runId, error);
462
470
  });
471
+ if (input.sync)
472
+ return this.executeSync(state, controller, execute, input.timeoutMs);
473
+ this.prQueue.push({
474
+ execute,
475
+ repository: input.repository,
476
+ runId,
477
+ });
478
+ this.drainPrQueue();
463
479
  return state;
464
480
  }
465
481
  async startMerge(input) {
@@ -511,13 +527,19 @@ export class MagiRunManager {
511
527
  await this.notify(state, `Started Magi merge for ${prMarkdownLink(state)}.`);
512
528
  const controller = new AbortController();
513
529
  this.controllers.set(runId, controller);
514
- void this.executeMerge({
530
+ const execute = () => this.executeMerge({
515
531
  ...input,
516
532
  runId,
517
533
  signal: controller.signal,
518
- }).catch(async (error) => {
519
- await this.failRun(runId, error);
520
534
  });
535
+ if (input.sync)
536
+ return this.executeSync(state, controller, execute, input.timeoutMs);
537
+ this.prQueue.push({
538
+ execute,
539
+ repository: input.repository,
540
+ runId,
541
+ });
542
+ this.drainPrQueue();
521
543
  return state;
522
544
  }
523
545
  async startTriage(input) {
@@ -568,17 +590,70 @@ export class MagiRunManager {
568
590
  await this.notify(state, `Started Magi triage for ${issueMarkdownLink(state)}.`);
569
591
  const controller = new AbortController();
570
592
  this.controllers.set(runId, controller);
571
- void this.executeTriage({
593
+ const execute = () => this.executeTriage({
572
594
  ...input,
573
595
  runId,
574
596
  signal: controller.signal,
575
- }).catch(async (error) => {
576
- await this.failRun(runId, error);
577
597
  });
598
+ if (input.sync)
599
+ return this.executeSync(state, controller, execute, input.timeoutMs);
600
+ this.triageQueue.push({
601
+ execute,
602
+ repository: input.repository,
603
+ runId,
604
+ });
605
+ this.drainTriageQueue();
578
606
  return state;
579
607
  }
608
+ drainPrQueue() {
609
+ while (this.prQueue.length) {
610
+ const next = this.prQueue[0];
611
+ if (!next)
612
+ return;
613
+ if (this.activePrRuns >= next.repository.concurrency.runs)
614
+ return;
615
+ this.prQueue.shift();
616
+ const state = this.active.get(next.runId);
617
+ if (!state || state.status === "cancelled")
618
+ continue;
619
+ this.activePrRuns += 1;
620
+ void next
621
+ .execute()
622
+ .catch(async (error) => {
623
+ await this.failRun(next.runId, error);
624
+ })
625
+ .finally(() => {
626
+ this.activePrRuns -= 1;
627
+ this.drainPrQueue();
628
+ });
629
+ }
630
+ }
631
+ drainTriageQueue() {
632
+ while (this.triageQueue.length) {
633
+ const next = this.triageQueue[0];
634
+ if (!next)
635
+ return;
636
+ const limit = next.repository.triage?.concurrency.runs ?? 1;
637
+ if (this.activeTriageRuns >= limit)
638
+ return;
639
+ this.triageQueue.shift();
640
+ const state = this.active.get(next.runId);
641
+ if (!state || state.status === "cancelled")
642
+ continue;
643
+ this.activeTriageRuns += 1;
644
+ void next
645
+ .execute()
646
+ .catch(async (error) => {
647
+ await this.failRun(next.runId, error);
648
+ })
649
+ .finally(() => {
650
+ this.activeTriageRuns -= 1;
651
+ this.drainTriageQueue();
652
+ });
653
+ }
654
+ }
580
655
  async status(input = {}) {
581
- const timeoutMs = Math.min(input.timeoutMs ?? 60_000, 600_000);
656
+ const timeoutMs = input.timeoutMs;
582
657
  const startedAt = Date.now();
583
658
  while (input.block) {
584
659
  const states = await this.filteredStates(input);
@@ -586,7 +661,7 @@ export class MagiRunManager {
586
661
  hasAllRequestedPrStates(states, input.pr) &&
587
662
  states.every((state) => !isActiveStatus(state.status)))
588
663
  return states;
589
- if (Date.now() - startedAt >= timeoutMs)
664
+ if (timeoutMs != null && Date.now() - startedAt >= timeoutMs)
590
665
  return states;
591
666
  await new Promise((resolve) => setTimeout(resolve, 1_000));
592
667
  }
@@ -638,53 +713,7 @@ export class MagiRunManager {
638
713
  state.status = "cancelled";
639
714
  state.phase = "cancelled";
640
715
  state.completedAt = now();
641
- if (state.editor?.status === "pending" ||
642
- state.editor?.status === "running" ||
643
- state.editor?.status === "repairing") {
644
- state.editor.status = "cancelled";
645
- }
646
- if (state.editor?.sessionId) {
647
- await this.input.client.session
648
- .abort?.({ path: { id: state.editor.sessionId } })
649
- .catch(() => undefined);
650
- }
651
- if (state.triageCreator?.status === "pending" ||
652
- state.triageCreator?.status === "running" ||
653
- state.triageCreator?.status === "repairing" ||
654
- state.triageCreator?.status === "blocked") {
655
- state.triageCreator.status = "cancelled";
656
- }
657
- if (state.triageCreator?.sessionId) {
658
- await this.input.client.session
659
- .abort?.({ path: { id: state.triageCreator.sessionId } })
660
- .catch(() => undefined);
661
- }
662
- for (const reviewer of Object.values(state.reviewers)) {
663
- if (reviewer.status === "pending" ||
664
- reviewer.status === "running" ||
665
- reviewer.status === "repairing" ||
666
- reviewer.status === "blocked") {
667
- reviewer.status = "cancelled";
668
- }
669
- if (reviewer.sessionId) {
670
- await this.input.client.session
671
- .abort?.({ path: { id: reviewer.sessionId } })
672
- .catch(() => undefined);
673
- }
674
- }
675
- for (const classifier of Object.values(state.ciClassifiers ?? {})) {
676
- if (classifier.status === "pending" ||
677
- classifier.status === "running" ||
678
- classifier.status === "repairing" ||
679
- classifier.status === "blocked") {
680
- classifier.status = "cancelled";
681
- }
682
- if (classifier.sessionId) {
683
- await this.input.client.session
684
- .abort?.({ path: { id: classifier.sessionId } })
685
- .catch(() => undefined);
686
- }
687
- }
716
+ await this.finishActiveAgents(state, "cancelled");
688
717
  if (state.worktreePath) {
689
718
  await removeWorktree(this.input.exec, state.worktreePath).catch(() => undefined);
690
719
  }
@@ -1185,6 +1214,26 @@ export class MagiRunManager {
1185
1214
  ...Object.entries(state.reviewers),
1186
1215
  ];
1187
1216
  }
1217
+ isActiveAgent(agent) {
1218
+ return (agent.status === "pending" ||
1219
+ agent.status === "running" ||
1220
+ agent.status === "repairing" ||
1221
+ agent.status === "blocked");
1222
+ }
1223
+ async finishActiveAgents(state, status, error) {
1224
+ for (const [, agent] of this.agentEntries(state)) {
1225
+ if (this.isActiveAgent(agent)) {
1226
+ agent.status = status;
1227
+ if (error != null)
1228
+ agent.error = error;
1229
+ }
1230
+ if (agent.sessionId) {
1231
+ await this.input.client.session
1232
+ .abort?.({ path: { id: agent.sessionId } })
1233
+ .catch(() => undefined);
1234
+ }
1235
+ }
1236
+ }
1188
1237
  selectPendingAgent(state, kind, key, requestId) {
1189
1238
  const entries = key
1190
1239
  ? this.agentState(state, key)
@@ -1210,6 +1259,41 @@ export class MagiRunManager {
1210
1259
  hasBlockedAgents(state) {
1211
1260
  return this.agentEntries(state).some(([, agent]) => agent.status === "blocked");
1212
1261
  }
1262
+ async executeSync(state, controller, execute, timeoutMs) {
1263
+ let timeout;
1264
+ const timeoutPromise = timeoutMs == null
1265
+ ? undefined
1266
+ : new Promise((resolve) => {
1267
+ timeout = setTimeout(() => resolve("timeout"), timeoutMs);
1268
+ });
1269
+ try {
1270
+ const result = await (timeoutPromise
1271
+ ? Promise.race([
1272
+ execute().then(() => "completed"),
1273
+ timeoutPromise,
1274
+ ])
1275
+ : execute().then(() => "completed"));
1276
+ if (result === "timeout") {
1277
+ const timeoutSeconds = (timeoutMs ?? 0) / 1_000;
1278
+ controller.abort();
1279
+ await this.failRun(state.runId, new Error(`Magi sync run timed out after ${timeoutSeconds} seconds.`));
1280
+ }
1281
+ }
1282
+ catch (error) {
1283
+ controller.abort();
1284
+ await this.failRun(state.runId, error);
1285
+ }
1286
+ finally {
1287
+ if (timeout)
1288
+ clearTimeout(timeout);
1289
+ }
1290
+ return (await this.readStateByRunId(state.runId)) ?? state;
1291
+ }
1292
+ assertSuccessfulSyncFollowUp(state) {
1293
+ if (state.status === "completed")
1294
+ return;
1295
+ throw new Error(`Synchronous follow-up ${state.command} run ${state.runId} finished with status ${state.status}.`);
1296
+ }
1213
1297
  async executeReview(input) {
1214
1298
  const result = await runReview({
1215
1299
  approvalPolicy: input.repository.merge.approvalPolicy,
@@ -1219,6 +1303,7 @@ export class MagiRunManager {
1219
1303
  dryRun: input.dryRun,
1220
1304
  exec: withGitHubApiRetry(this.input.exec, input.config.github?.apiRetryAttempts ?? 3),
1221
1305
  onProgress: (progress) => this.applyReviewProgress(input.runId, progress),
1306
+ parentSessionId: input.parentSessionId,
1222
1307
  pr: input.pr,
1223
1308
  repository: input.repository,
1224
1309
  runId: input.runId,
@@ -1274,6 +1359,7 @@ export class MagiRunManager {
1274
1359
  dryRun: input.dryRun,
1275
1360
  exec: withGitHubApiRetry(this.input.exec, input.config.github?.apiRetryAttempts ?? 3),
1276
1361
  onProgress: (progress) => this.applyMergeProgress(input.runId, progress),
1362
+ parentSessionId: input.parentSessionId,
1277
1363
  pr: input.pr,
1278
1364
  repository: input.repository,
1279
1365
  runId: input.runId,
@@ -1315,6 +1401,7 @@ export class MagiRunManager {
1315
1401
  exec: withGitHubApiRetry(this.input.exec, input.config.github?.apiRetryAttempts ?? 3),
1316
1402
  issue: input.issue,
1317
1403
  onProgress: (progress) => this.applyTriageProgress(input.runId, progress),
1404
+ parentSessionId: input.parentSessionId,
1318
1405
  repository: input.repository,
1319
1406
  runId: input.runId,
1320
1407
  signal: input.signal,
@@ -1347,24 +1434,32 @@ export class MagiRunManager {
1347
1434
  : undefined;
1348
1435
  const triageAutomation = input.repository.triage?.automation;
1349
1436
  if (followUpPr != null && triageAutomation?.merge) {
1350
- await this.startMerge({
1437
+ const followUp = await this.startMerge({
1351
1438
  config: input.config,
1352
1439
  dryRun: input.dryRun,
1353
1440
  parentSessionId: input.parentSessionId,
1354
1441
  pr: followUpPr,
1355
1442
  repository: input.repository,
1356
1443
  signal: input.signal,
1444
+ sync: input.sync,
1445
+ timeoutMs: input.timeoutMs,
1357
1446
  });
1447
+ if (input.sync)
1448
+ this.assertSuccessfulSyncFollowUp(followUp);
1358
1449
  }
1359
1450
  else if (followUpPr != null && triageAutomation?.review) {
1360
- await this.startReview({
1451
+ const followUp = await this.startReview({
1361
1452
  config: input.config,
1362
1453
  dryRun: input.dryRun,
1363
1454
  parentSessionId: input.parentSessionId,
1364
1455
  pr: followUpPr,
1365
1456
  repository: input.repository,
1366
1457
  signal: input.signal,
1458
+ sync: input.sync,
1459
+ timeoutMs: input.timeoutMs,
1367
1460
  });
1461
+ if (input.sync)
1462
+ this.assertSuccessfulSyncFollowUp(followUp);
1368
1463
  }
1369
1464
  this.active.delete(input.runId);
1370
1465
  this.controllers.delete(input.runId);
@@ -1405,57 +1500,69 @@ export class MagiRunManager {
1405
1500
  state.worktreePath = progress.worktreePath;
1406
1501
  state.worktreeBranch = progress.branch;
1407
1502
  }
1503
+ if (progress.type === "triage_session") {
1504
+ if (progress.options)
1505
+ this.input.setSessionOptions?.(progress.sessionId, progress.options);
1506
+ state.sessionIds = {
1507
+ ...state.sessionIds,
1508
+ [progress.key]: progress.sessionId,
1509
+ };
1510
+ this.sessionToRun.set(progress.sessionId, {
1511
+ agent: progress.agent,
1512
+ runId,
1513
+ });
1514
+ }
1408
1515
  if (progress.type === "triage_agent_started") {
1409
- const reviewer = state.reviewers[progress.reviewer];
1410
- if (reviewer)
1411
- reviewer.status = "running";
1516
+ const voter = state.reviewers[progress.voter];
1517
+ if (voter)
1518
+ voter.status = "running";
1412
1519
  }
1413
1520
  if (progress.type === "triage_agent_session") {
1414
- const reviewer = state.reviewers[progress.reviewer];
1415
- if (reviewer) {
1521
+ const voter = state.reviewers[progress.voter];
1522
+ if (voter) {
1416
1523
  if (progress.options)
1417
1524
  this.input.setSessionOptions?.(progress.sessionId, progress.options);
1418
- reviewer.sessionId = progress.sessionId;
1419
- reviewer.status = "running";
1420
- reviewer.lastUpdate = now();
1525
+ voter.sessionId = progress.sessionId;
1526
+ voter.status = "running";
1527
+ voter.lastUpdate = now();
1421
1528
  this.sessionToRun.set(progress.sessionId, {
1422
- agent: progress.reviewer,
1529
+ agent: progress.voter,
1423
1530
  runId,
1424
1531
  });
1425
1532
  }
1426
1533
  }
1427
1534
  if (progress.type === "triage_agent_repair") {
1428
- const reviewer = state.reviewers[progress.reviewer];
1429
- if (reviewer) {
1430
- reviewer.status = "repairing";
1431
- reviewer.repairAttempts += 1;
1432
- reviewer.lastUpdate = now();
1535
+ const voter = state.reviewers[progress.voter];
1536
+ if (voter) {
1537
+ voter.status = "repairing";
1538
+ voter.repairAttempts += 1;
1539
+ voter.lastUpdate = now();
1433
1540
  }
1434
1541
  }
1435
1542
  if (progress.type === "triage_agent_response") {
1436
- const reviewer = state.reviewers[progress.reviewer];
1437
- if (reviewer) {
1438
- reviewer.sessionId = progress.sessionId;
1439
- reviewer.lastUpdate = now();
1543
+ const voter = state.reviewers[progress.voter];
1544
+ if (voter) {
1545
+ voter.sessionId = progress.sessionId;
1546
+ voter.lastUpdate = now();
1440
1547
  }
1441
1548
  }
1442
1549
  if (progress.type === "triage_agent_completed") {
1443
- const reviewer = state.reviewers[progress.reviewer];
1444
- if (reviewer) {
1445
- reviewer.sessionId = progress.sessionId;
1446
- reviewer.status = "completed";
1447
- reviewer.verdict = progress.vote;
1448
- reviewer.rawPath = join(state.outputDir, `${progress.reviewer}.${progress.phase}.raw.txt`);
1449
- reviewer.parsedPath = join(state.outputDir, `${progress.reviewer}.${progress.phase}.json`);
1450
- reviewer.lastUpdate = now();
1550
+ const voter = state.reviewers[progress.voter];
1551
+ if (voter) {
1552
+ voter.sessionId = progress.sessionId;
1553
+ voter.status = "completed";
1554
+ voter.verdict = progress.vote;
1555
+ voter.rawPath = join(state.outputDir, `${progress.voter}.${progress.phase}.raw.txt`);
1556
+ voter.parsedPath = join(state.outputDir, `${progress.voter}.${progress.phase}.json`);
1557
+ voter.lastUpdate = now();
1451
1558
  }
1452
1559
  }
1453
1560
  if (progress.type === "triage_agent_failed") {
1454
- const reviewer = state.reviewers[progress.reviewer];
1455
- if (reviewer) {
1456
- reviewer.status = "failed";
1457
- reviewer.error = redactSecrets(progress.error);
1458
- reviewer.lastUpdate = now();
1561
+ const voter = state.reviewers[progress.voter];
1562
+ if (voter) {
1563
+ voter.status = "failed";
1564
+ voter.error = redactSecrets(progress.error);
1565
+ voter.lastUpdate = now();
1459
1566
  }
1460
1567
  }
1461
1568
  if (progress.type === "triage_creator_started") {
@@ -1509,16 +1616,16 @@ export class MagiRunManager {
1509
1616
  }));
1510
1617
  }
1511
1618
  if (progress.type === "triage_agent_started") {
1512
- await this.notify(state, `**Triage agent ${progress.reviewer}** started ${progress.phase} for ${issue}.`);
1619
+ await this.notify(state, `**Triage agent ${progress.voter}** started ${progress.phase} for ${issue}.`);
1513
1620
  }
1514
1621
  if (progress.type === "triage_agent_repair") {
1515
- await this.notify(state, `**Triage agent ${progress.reviewer}** started JSON regeneration for ${issue}.`);
1622
+ await this.notify(state, `**Triage agent ${progress.voter}** started JSON regeneration for ${issue}.`);
1516
1623
  }
1517
1624
  if (progress.type === "triage_agent_completed") {
1518
- await this.notify(state, `**Triage agent ${progress.reviewer}** completed ${progress.phase} for ${issue}: ${progress.vote}.`);
1625
+ await this.notify(state, `**Triage agent ${progress.voter}** completed ${progress.phase} for ${issue}: ${progress.vote}.`);
1519
1626
  }
1520
1627
  if (progress.type === "triage_agent_failed") {
1521
- await this.notify(state, `**Triage agent ${progress.reviewer}** failed ${progress.phase} for ${issue}: ${redactSecrets(progress.error)}`);
1628
+ await this.notify(state, `**Triage agent ${progress.voter}** failed ${progress.phase} for ${issue}: ${redactSecrets(progress.error)}`);
1522
1629
  }
1523
1630
  if (progress.type === "comment_posting") {
1524
1631
  await this.notify(state, `Posting triage comment for ${issue}.`);
@@ -1603,9 +1710,8 @@ export class MagiRunManager {
1603
1710
  if (progress.type === "ci_classifier_completed") {
1604
1711
  const classifier = state.ciClassifiers?.[progress.reviewer];
1605
1712
  if (classifier) {
1606
- classifier.classification = progress.classification;
1713
+ classifier.checks = progress.checks;
1607
1714
  classifier.rawPath = progress.rawPath;
1608
- classifier.reason = progress.reason;
1609
1715
  classifier.sessionId = progress.sessionId;
1610
1716
  classifier.status = "completed";
1611
1717
  classifier.lastUpdate = now();
@@ -1713,7 +1819,11 @@ export class MagiRunManager {
1713
1819
  await this.notify(state, `**CI classifier ${progress.reviewer}** started JSON regeneration for ${prMarkdownLink(state)}.`);
1714
1820
  }
1715
1821
  if (progress.type === "ci_classifier_completed") {
1716
- await this.notify(state, `**CI classifier ${progress.reviewer}** completed for ${prMarkdownLink(state)}: ${progress.classification} - ${progress.reason}`);
1822
+ await this.notify(state, ciClassifierCompletedText({
1823
+ checks: progress.checks,
1824
+ pr: prMarkdownLink(state),
1825
+ reviewer: progress.reviewer,
1826
+ }));
1717
1827
  }
1718
1828
  if (progress.type === "ci_classifier_failed") {
1719
1829
  await this.notify(state, `**CI classifier ${progress.reviewer}** failed for ${prMarkdownLink(state)}: ${redactSecrets(progress.error)}`);
@@ -1882,46 +1992,7 @@ export class MagiRunManager {
1882
1992
  state.phase = "failed";
1883
1993
  state.completedAt = now();
1884
1994
  state.error = errorMessage(error);
1885
- if (state.editor?.status === "pending" ||
1886
- state.editor?.status === "running" ||
1887
- state.editor?.status === "repairing" ||
1888
- state.editor?.status === "blocked") {
1889
- state.editor.status = "failed";
1890
- state.editor.error = state.error;
1891
- }
1892
- if (state.editor?.sessionId) {
1893
- await this.input.client.session
1894
- .abort?.({ path: { id: state.editor.sessionId } })
1895
- .catch(() => undefined);
1896
- }
1897
- for (const reviewer of Object.values(state.reviewers)) {
1898
- if (reviewer.status === "pending" ||
1899
- reviewer.status === "running" ||
1900
- reviewer.status === "repairing" ||
1901
- reviewer.status === "blocked") {
1902
- reviewer.status = "failed";
1903
- reviewer.error = state.error;
1904
- }
1905
- if (reviewer.sessionId) {
1906
- await this.input.client.session
1907
- .abort?.({ path: { id: reviewer.sessionId } })
1908
- .catch(() => undefined);
1909
- }
1910
- }
1911
- for (const classifier of Object.values(state.ciClassifiers ?? {})) {
1912
- if (classifier.status === "pending" ||
1913
- classifier.status === "running" ||
1914
- classifier.status === "repairing" ||
1915
- classifier.status === "blocked") {
1916
- classifier.status = "failed";
1917
- classifier.error = state.error;
1918
- }
1919
- if (classifier.sessionId) {
1920
- await this.input.client.session
1921
- .abort?.({ path: { id: classifier.sessionId } })
1922
- .catch(() => undefined);
1923
- }
1924
- }
1995
+ await this.finishActiveAgents(state, "failed", state.error);
1925
1996
  await this.persist(state);
1926
1997
  await this.notify(state, `Magi ${state.command} failed for ${runLabel(state)}: ${state.error}`, { reply: true });
1927
1998
  this.active.delete(runId);