opencode-magi 0.0.0-dev-20260721055255 → 0.0.0-dev-20260722111427

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.
@@ -52,6 +52,14 @@ export const MergeQueueStatusDocument = gql `
52
52
  mergeQueueEntry {
53
53
  id
54
54
  }
55
+ timelineItems(last: 1, itemTypes: REMOVED_FROM_MERGE_QUEUE_EVENT) {
56
+ nodes {
57
+ ... on RemovedFromMergeQueueEvent {
58
+ createdAt
59
+ reason
60
+ }
61
+ }
62
+ }
55
63
  }
56
64
  }
57
65
  }
package/dist/magi.js CHANGED
@@ -217,27 +217,69 @@ export class Magi {
217
217
  }
218
218
  }
219
219
  async promptSession(sessionID, text, signal) {
220
- const result = await this.input.client.session.prompt({
221
- parts: [{ text, type: "text" }],
222
- sessionID,
223
- }, { signal });
224
- if (result.error) {
225
- if (!isUndefined(result.response) && result.response.statusText)
226
- throw new Error(result.response.statusText);
227
- else if (result.error instanceof Error)
228
- throw new Error(result.error.message);
229
- else
230
- throw new Error(JSON.stringify(result.error));
220
+ const controller = new AbortController();
221
+ const events = await this.input.client.event.subscribe(undefined, {
222
+ signal: controller.signal,
223
+ });
224
+ try {
225
+ const result = await Promise.race([
226
+ this.input.client.session.prompt({
227
+ parts: [{ text, type: "text" }],
228
+ sessionID,
229
+ }, { signal }),
230
+ this.monitorSession(sessionID, events.stream),
231
+ ]);
232
+ if (result.error) {
233
+ if (!isUndefined(result.response) && result.response.statusText)
234
+ throw new Error(result.response.statusText);
235
+ else if (result.error instanceof Error)
236
+ throw new Error(result.error.message);
237
+ else
238
+ throw new Error(JSON.stringify(result.error));
239
+ }
240
+ else {
241
+ const output = result.data.parts
242
+ .filter((part) => part.type === "text")
243
+ .map((part) => part.text)
244
+ .join("\n");
245
+ if (!output)
246
+ throw new Error("OpenCode session.prompt did not return text output.");
247
+ return output;
248
+ }
231
249
  }
232
- else {
233
- const output = result.data.parts
234
- .filter((part) => part.type === "text")
235
- .map((part) => part.text)
236
- .join("\n");
237
- if (!output)
238
- throw new Error("OpenCode session.prompt did not return text output.");
239
- return output;
250
+ catch (e) {
251
+ if (e instanceof MagiError)
252
+ await this.input.client.session.abort({ sessionID });
253
+ throw e;
254
+ }
255
+ finally {
256
+ controller.abort();
257
+ }
258
+ }
259
+ async monitorSession(sessionID, events) {
260
+ for await (const ev of events) {
261
+ if (ev.type === "permission.asked" &&
262
+ ev.properties.sessionID === sessionID) {
263
+ const { id, patterns, permission } = ev.properties;
264
+ const result = await this.input.client.permission.reply({
265
+ reply: "reject",
266
+ requestID: id,
267
+ });
268
+ if (result.error)
269
+ throw new MagiError("blocked", "Could not reject permission request.");
270
+ throw new MagiError("blocked", `OpenCode session requested ${permission} permission for ${patterns.join(", ")}.`);
271
+ }
272
+ if (ev.type === "question.asked" &&
273
+ ev.properties.sessionID === sessionID) {
274
+ const result = await this.input.client.question.reject({
275
+ requestID: ev.properties.id,
276
+ });
277
+ if (result.error)
278
+ throw new MagiError("blocked", "Could not reject user question.");
279
+ throw new MagiError("blocked", "OpenCode session requested user input.");
280
+ }
240
281
  }
282
+ return new Promise(() => { });
241
283
  }
242
284
  async deleteSessions(state) {
243
285
  const sessionIds = this.getSessionIds(state);
@@ -43,7 +43,11 @@ export async function edit() {
43
43
  }
44
44
  return output;
45
45
  }, {
46
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to edit. Retrying...`),
46
+ error: async (e, count) => {
47
+ if (e instanceof MagiError)
48
+ throw e;
49
+ await this.updateEvent(`Attempt ${count} failed to edit. Retrying...`);
50
+ },
47
51
  retries: this.config.output.repairAttempts,
48
52
  signal: this.context.abort,
49
53
  });
@@ -134,7 +138,11 @@ export async function resolveConflict() {
134
138
  responses: [],
135
139
  };
136
140
  }, {
137
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to resolve conflicts. Retrying...`),
141
+ error: async (e, count) => {
142
+ if (e instanceof MagiError)
143
+ throw e;
144
+ await this.updateEvent(`Attempt ${count} failed to resolve conflicts. Retrying...`);
145
+ },
138
146
  retries: this.config.output.repairAttempts,
139
147
  signal: this.context.abort,
140
148
  });
@@ -86,7 +86,11 @@ export async function postReviews() {
86
86
  throw new Error("Invalid output for operator.");
87
87
  return parsed.comment;
88
88
  }, {
89
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to post comment by operator. Retrying...`),
89
+ error: async (e, count) => {
90
+ if (e instanceof MagiError)
91
+ throw e;
92
+ await this.updateEvent(`Attempt ${count} failed to post comment by operator. Retrying...`);
93
+ },
90
94
  retries: this.config.output.repairAttempts,
91
95
  signal: this.context.abort,
92
96
  });
@@ -228,9 +232,10 @@ export async function automate() {
228
232
  if (action === "merge" && this.config.review.merge.queue) {
229
233
  const octokit = await this.magi.createOctokit(this.config, this.context.abort, account);
230
234
  const graphql = this.magi.createGraphql(octokit);
235
+ const enqueuedAt = new Date().toISOString();
231
236
  await graphql.enqueuePullRequest({ id: this.state.pr.metadata.node_id });
232
237
  await this.updateEvent(`Waiting for merge queue.`);
233
- const result = await waitMergeQueue.call(this);
238
+ const result = await waitMergeQueue.call(this, enqueuedAt);
234
239
  if (result === "CONFLICT")
235
240
  return result;
236
241
  }
@@ -354,8 +359,10 @@ function getCheckTime(check) {
354
359
  return Number.isNaN(time) ? prev : Math.max(prev, time);
355
360
  }, 0);
356
361
  }
357
- async function waitMergeQueue(leftMergeQueue = false) {
362
+ async function waitMergeQueue(enqueuedAt, leftMergeQueue = false, retries = 0) {
358
363
  this.context.abort.throwIfAborted();
364
+ if (!this.state.pr?.metadata)
365
+ throw new MagiError("blocked", "PR metadata not found.");
359
366
  const { repository } = await this.graphql.mergeQueueStatus({
360
367
  owner: this.config.github.owner,
361
368
  pr: this.number,
@@ -363,20 +370,33 @@ async function waitMergeQueue(leftMergeQueue = false) {
363
370
  });
364
371
  if (!repository?.pullRequest)
365
372
  throw new MagiError("blocked", "Could not fetch merge queue status.");
366
- const { isInMergeQueue, mergeQueueEntry, state } = repository.pullRequest;
373
+ const { isInMergeQueue, mergeQueueEntry, state, timelineItems } = repository.pullRequest;
367
374
  if (state === "MERGED")
368
375
  return "MERGED";
369
376
  const nextLeftMergeQueue = state === "OPEN" && !isInMergeQueue && !mergeQueueEntry;
370
377
  if (leftMergeQueue && nextLeftMergeQueue) {
371
378
  if (await isConflict.call(this)) {
372
379
  await this.updateState({ pr: { automation: "CONFLICT" } });
373
- await this.updateEvent(`Merge automation found conflicts.`);
380
+ await this.updateEvent("Merge automation found conflicts.");
374
381
  return "CONFLICT";
375
382
  }
383
+ const removedFromQueue = timelineItems.nodes?.at(-1);
384
+ if (removedFromQueue?.reason === "failed_checks" &&
385
+ Date.parse(removedFromQueue.createdAt) >= Date.parse(enqueuedAt)) {
386
+ if (retries >= this.config.review.checks.retryFailedJobs)
387
+ throw new MagiError("blocked", `PR left the merge queue after failed checks exceeded ${retries} retries.`);
388
+ const attempt = retries + 1;
389
+ const nextEnqueuedAt = new Date().toISOString();
390
+ await this.updateEvent(`Attempt ${attempt} failed to merge from the merge queue. Retrying...`);
391
+ await this.graphql.enqueuePullRequest({
392
+ id: this.state.pr.metadata.node_id,
393
+ });
394
+ return await waitMergeQueue.call(this, nextEnqueuedAt, false, attempt);
395
+ }
376
396
  throw new MagiError("blocked", `PR left the merge queue before merging.`);
377
397
  }
378
398
  await wait(30_000, this.context.abort);
379
- return await waitMergeQueue.call(this, nextLeftMergeQueue);
399
+ return await waitMergeQueue.call(this, enqueuedAt, nextLeftMergeQueue, retries);
380
400
  }
381
401
  async function isConflict() {
382
402
  if (!this.state.worktree)
@@ -113,7 +113,11 @@ export async function classifyChecks() {
113
113
  throw new Error(`Invalid output for reviewer ${id}.`);
114
114
  return parsed;
115
115
  }, {
116
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to classify CI checks with reviewer ${id}. Retrying...`),
116
+ error: async (e, count) => {
117
+ if (e instanceof MagiError)
118
+ throw e;
119
+ await this.updateEvent(`Attempt ${count} failed to classify CI checks with reviewer ${id}. Retrying...`);
120
+ },
117
121
  retries: this.config.output.repairAttempts,
118
122
  signal: this.context.abort,
119
123
  });
@@ -125,7 +125,11 @@ export async function review() {
125
125
  ]).join("\n\n"));
126
126
  return parsed;
127
127
  }, {
128
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to ${label} with reviewer ${id}. Retrying...`),
128
+ error: async (e, count) => {
129
+ if (e instanceof MagiError)
130
+ throw e;
131
+ await this.updateEvent(`Attempt ${count} failed to ${label} with reviewer ${id}. Retrying...`);
132
+ },
129
133
  retries: this.config.output.repairAttempts,
130
134
  signal: this.context.abort,
131
135
  });
@@ -208,7 +212,11 @@ export async function reconsiderClose() {
208
212
  validateInlineCommentTargets("initial", parsed, inlineCommentTargets);
209
213
  return parsed;
210
214
  }, {
211
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to reconsider close verdict with reviewer ${id}. Retrying...`),
215
+ error: async (e, count) => {
216
+ if (e instanceof MagiError)
217
+ throw e;
218
+ await this.updateEvent(`Attempt ${count} failed to reconsider close verdict with reviewer ${id}. Retrying...`);
219
+ },
212
220
  retries: this.config.output.repairAttempts,
213
221
  signal: this.context.abort,
214
222
  });
@@ -287,7 +295,11 @@ async function collectAcceptedFindings(findings) {
287
295
  throw new Error(`Missing finding vote: ${key}.`);
288
296
  return parsed;
289
297
  }, {
290
- error: (_, count) => this.updateEvent(`Attempt ${count} failed to validate review findings with reviewer ${id}. Retrying...`),
298
+ error: async (e, count) => {
299
+ if (e instanceof MagiError)
300
+ throw e;
301
+ await this.updateEvent(`Attempt ${count} failed to validate review findings with reviewer ${id}. Retrying...`);
302
+ },
291
303
  retries: this.config.output.repairAttempts,
292
304
  signal: this.context.abort,
293
305
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260721055255",
3
+ "version": "0.0.0-dev-20260722111427",
4
4
  "description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
5
5
  "license": "MIT",
6
6
  "author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",