sandcastle-drain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +139 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/content/agent-docs/issue-tracker.md +22 -0
  8. package/dist/content/agent-docs/sandcastle-windows-cleanup.md +45 -0
  9. package/dist/content/agent-docs/triage-labels.md +101 -0
  10. package/dist/content/principles/README.md +39 -0
  11. package/dist/content/principles/architecture.md +124 -0
  12. package/dist/content/principles/claude-code-modes.md +47 -0
  13. package/dist/content/principles/clean-code.md +102 -0
  14. package/dist/content/principles/context-budget.md +81 -0
  15. package/dist/content/principles/cqrs.md +70 -0
  16. package/dist/content/principles/domain-modeling.md +62 -0
  17. package/dist/content/principles/frontend-organization.md +120 -0
  18. package/dist/content/principles/language-and-types.md +85 -0
  19. package/dist/content/principles/linting-and-tooling.md +122 -0
  20. package/dist/content/principles/personal-use-tradeoffs.md +55 -0
  21. package/dist/content/principles/testing.md +89 -0
  22. package/dist/orchestrator/blocked-by.d.ts +17 -0
  23. package/dist/orchestrator/blocked-by.d.ts.map +1 -0
  24. package/dist/orchestrator/blocked-by.js +48 -0
  25. package/dist/orchestrator/blocked-by.js.map +1 -0
  26. package/dist/orchestrator/ci-gate.d.ts +28 -0
  27. package/dist/orchestrator/ci-gate.d.ts.map +1 -0
  28. package/dist/orchestrator/ci-gate.js +198 -0
  29. package/dist/orchestrator/ci-gate.js.map +1 -0
  30. package/dist/orchestrator/main.d.ts +10 -0
  31. package/dist/orchestrator/main.d.ts.map +1 -0
  32. package/dist/orchestrator/main.js +883 -0
  33. package/dist/orchestrator/main.js.map +1 -0
  34. package/dist/orchestrator/prereqs.d.ts +30 -0
  35. package/dist/orchestrator/prereqs.d.ts.map +1 -0
  36. package/dist/orchestrator/prereqs.js +191 -0
  37. package/dist/orchestrator/prereqs.js.map +1 -0
  38. package/dist/orchestrator/rejection.d.ts +60 -0
  39. package/dist/orchestrator/rejection.d.ts.map +1 -0
  40. package/dist/orchestrator/rejection.js +187 -0
  41. package/dist/orchestrator/rejection.js.map +1 -0
  42. package/dist/orchestrator/reviewer.d.ts +75 -0
  43. package/dist/orchestrator/reviewer.d.ts.map +1 -0
  44. package/dist/orchestrator/reviewer.js +260 -0
  45. package/dist/orchestrator/reviewer.js.map +1 -0
  46. package/dist/orchestrator/ship.d.ts +19 -0
  47. package/dist/orchestrator/ship.d.ts.map +1 -0
  48. package/dist/orchestrator/ship.js +73 -0
  49. package/dist/orchestrator/ship.js.map +1 -0
  50. package/dist/orchestrator/sibling-context.d.ts +16 -0
  51. package/dist/orchestrator/sibling-context.d.ts.map +1 -0
  52. package/dist/orchestrator/sibling-context.js +61 -0
  53. package/dist/orchestrator/sibling-context.js.map +1 -0
  54. package/dist/orchestrator/splits.d.ts +60 -0
  55. package/dist/orchestrator/splits.d.ts.map +1 -0
  56. package/dist/orchestrator/splits.js +149 -0
  57. package/dist/orchestrator/splits.js.map +1 -0
  58. package/dist/orchestrator/status.d.ts +13 -0
  59. package/dist/orchestrator/status.d.ts.map +1 -0
  60. package/dist/orchestrator/status.js +43 -0
  61. package/dist/orchestrator/status.js.map +1 -0
  62. package/dist/orchestrator/summary.d.ts +33 -0
  63. package/dist/orchestrator/summary.d.ts.map +1 -0
  64. package/dist/orchestrator/summary.js +59 -0
  65. package/dist/orchestrator/summary.js.map +1 -0
  66. package/dist/orchestrator/sweep.d.ts +18 -0
  67. package/dist/orchestrator/sweep.d.ts.map +1 -0
  68. package/dist/orchestrator/sweep.js +79 -0
  69. package/dist/orchestrator/sweep.js.map +1 -0
  70. package/dist/orchestrator/teardown.d.ts +12 -0
  71. package/dist/orchestrator/teardown.d.ts.map +1 -0
  72. package/dist/orchestrator/teardown.js +42 -0
  73. package/dist/orchestrator/teardown.js.map +1 -0
  74. package/dist/orchestrator/worktree-cleanup.d.ts +2 -0
  75. package/dist/orchestrator/worktree-cleanup.d.ts.map +1 -0
  76. package/dist/orchestrator/worktree-cleanup.js +39 -0
  77. package/dist/orchestrator/worktree-cleanup.js.map +1 -0
  78. package/dist/prompts/implementer.md.tpl +85 -0
  79. package/dist/prompts/reviewer.md.tpl +118 -0
  80. package/dist/render-prompt.d.ts +22 -0
  81. package/dist/render-prompt.d.ts.map +1 -0
  82. package/dist/render-prompt.js +64 -0
  83. package/dist/render-prompt.js.map +1 -0
  84. package/dist/stage.d.ts +43 -0
  85. package/dist/stage.d.ts.map +1 -0
  86. package/dist/stage.js +105 -0
  87. package/dist/stage.js.map +1 -0
  88. package/docker/Dockerfile +42 -0
  89. package/package.json +48 -0
@@ -0,0 +1,883 @@
1
+ /**
2
+ * Drains the queue of `sandcastle`-labeled GitHub issues by running the agent
3
+ * once per issue. See README.md for the wrapper design and
4
+ * src/content/agent-docs/triage-labels.md for the label state machine.
5
+ *
6
+ * Invoked by `src/cli.ts` as the `drain` subcommand. `runDrain` is the only
7
+ * export the CLI calls; the rest of the file is internal to drain orchestration.
8
+ */
9
+ import { run, claudeCode } from '@ai-hero/sandcastle';
10
+ import { docker } from '@ai-hero/sandcastle/sandboxes/docker';
11
+ import { execa } from 'execa';
12
+ import { existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { HOST_CREDS_PATH, IMAGE_NAME, REPO_ROOT, SANDBOX_CREDS_PATH, } from './prereqs.js';
15
+ import { STAGED_DIR_RELATIVE, STAGED_SANDBOX_PATH } from '../stage.js';
16
+ import { renderPrompt } from '../render-prompt.js';
17
+ import { containsRateLimit, determineRunStatus, isRateLimitError, } from './status.js';
18
+ import { buildSiblingContextBlock, estimateTokens, summarizeBranch, } from './sibling-context.js';
19
+ import { formatSummary } from './summary.js';
20
+ import { tryRecoverCommits } from './teardown.js';
21
+ import { removeWorktreeDir } from './worktree-cleanup.js';
22
+ import { formatReviewerComment, formatReviewerErrorComment, runReviewer } from './reviewer.js';
23
+ import { detectPackageManager, formatCiSection, runCiGate } from './ci-gate.js';
24
+ import { shipBranch } from './ship.js';
25
+ import { sweepBranch } from './sweep.js';
26
+ import { buildFollowUpBody, buildFollowUpTitle, buildOriginalIssueRejectionComment, createRejectionTag, listRejectionTagsForIssue, nextAttemptNumber, PRIORITY_LABEL, rejectionTagName, sortQueue, } from './rejection.js';
27
+ import { parseBlockedBy } from './blocked-by.js';
28
+ import { buildOriginalIssueSplitComment, buildSplitErrorComment, formatSplitsLogLine, OVERSIZED_LABEL, readSplitsFile, } from './splits.js';
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+ const QUEUE_LABEL = 'sandcastle';
33
+ const IN_PROGRESS_LABEL = 'in-progress';
34
+ const BLOCKED_LABEL = 'blocked';
35
+ const RETRY_LABEL = 'retry';
36
+ const NEEDS_REVIEW_LABEL = 'needs-review';
37
+ const NEEDS_INFO_LABEL = 'needs-info';
38
+ const SKIPPED_THIS_RUN_LABEL = 'skipped-this-run';
39
+ // Idle timeout: 10 minutes of silence kills the run. Wall-clock cap: 90 minutes.
40
+ const IDLE_TIMEOUT_SECONDS = 600;
41
+ const WALL_CLOCK_TIMEOUT_MS = 90 * 60 * 1000;
42
+ // One initial attempt + one auto-retry on idle/wall-clock timeout with no
43
+ // commits. Idle/abort failures are typically transient (model blip, network
44
+ // stall); retrying once handles them without the human having to re-queue.
45
+ // Anything other than `failed (timeout)` does not retry — see processIssue.
46
+ const MAX_ATTEMPTS_PER_ISSUE = 2;
47
+ // Reviewer is read-only and bounded to a tighter budget than the implementer:
48
+ // reading the principles + diff + emitting a verdict shouldn't take 90 minutes.
49
+ const REVIEWER_IDLE_TIMEOUT_SECONDS = 300;
50
+ const REVIEWER_WALL_CLOCK_TIMEOUT_MS = 30 * 60 * 1000;
51
+ // ---------------------------------------------------------------------------
52
+ // gh helpers — best-effort, never abort the loop on label or comment failure
53
+ // ---------------------------------------------------------------------------
54
+ async function gh(args, options = {}) {
55
+ const result = await execa('gh', args, {
56
+ cwd: REPO_ROOT,
57
+ input: options.input,
58
+ reject: false,
59
+ });
60
+ if (result.exitCode !== 0) {
61
+ throw new Error(`gh ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
62
+ }
63
+ return result.stdout;
64
+ }
65
+ async function tryGh(args, context, options = {}) {
66
+ try {
67
+ await gh(args, options);
68
+ }
69
+ catch (err) {
70
+ console.error(`[wrapper] ${context} failed (continuing):`, err.message);
71
+ }
72
+ }
73
+ async function fetchQueue() {
74
+ const raw = await gh([
75
+ 'issue',
76
+ 'list',
77
+ '--label',
78
+ QUEUE_LABEL,
79
+ '--state',
80
+ 'open',
81
+ '--json',
82
+ 'number,title,labels,body',
83
+ '--limit',
84
+ '200',
85
+ ]);
86
+ const issues = JSON.parse(raw);
87
+ const mapped = issues
88
+ .map((i) => ({
89
+ number: i.number,
90
+ title: i.title,
91
+ labels: i.labels.map((l) => l.name),
92
+ body: i.body ?? '',
93
+ }))
94
+ .filter((i) => !i.labels.includes(IN_PROGRESS_LABEL) && !i.labels.includes(BLOCKED_LABEL));
95
+ // `priority`-labeled issues run first so a rejection-loop follow-up jumps
96
+ // ahead of pending queue work. Tie-broken by issue number for stability.
97
+ return sortQueue(mapped);
98
+ }
99
+ async function addLabel(issue, label) {
100
+ await tryGh(['issue', 'edit', String(issue), '--add-label', label], `add label "${label}" to #${issue}`);
101
+ }
102
+ async function removeLabel(issue, label) {
103
+ await tryGh(['issue', 'edit', String(issue), '--remove-label', label], `remove label "${label}" from #${issue}`);
104
+ }
105
+ async function postComment(issue, body) {
106
+ await tryGh(['issue', 'comment', String(issue), '--body-file', '-'], `comment on #${issue}`, {
107
+ input: body,
108
+ });
109
+ }
110
+ async function closeIssue(issue) {
111
+ await tryGh(['issue', 'close', String(issue)], `close #${issue}`);
112
+ }
113
+ // Surfaces a skip decision on the GitHub issue so the user doesn't have to
114
+ // read the orchestrator transcript to discover what happened. Best-effort —
115
+ // every step routes through `tryGh`, so a single failure (label race, network
116
+ // blip) is logged and the drain continues. `removeSandcastle` is opt-in
117
+ // because most skip paths leave the queue label on so the next drain retries.
118
+ async function markSkipped(issue, reason, opts) {
119
+ await postComment(issue, `**Sandcastle-drain skipped this issue.** ${reason}`);
120
+ await addLabel(issue, SKIPPED_THIS_RUN_LABEL);
121
+ if (opts.removeSandcastle) {
122
+ await removeLabel(issue, QUEUE_LABEL);
123
+ }
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // git helpers
127
+ // ---------------------------------------------------------------------------
128
+ async function branchExists(branch) {
129
+ const result = await execa('git', ['rev-parse', '--verify', branch], {
130
+ cwd: REPO_ROOT,
131
+ reject: false,
132
+ });
133
+ return result.exitCode === 0;
134
+ }
135
+ async function deleteBranch(branch) {
136
+ // -D = force delete; the branch is unmerged by definition (it's the agent's
137
+ // rejected work). The user explicitly opted in via the `retry` label.
138
+ await execa('git', ['branch', '-D', branch], { cwd: REPO_ROOT, reject: false });
139
+ }
140
+ // A "branch with zero commits ahead of main" carries no work to preserve, so
141
+ // the conservative skip-on-existing-branch path can safely discard it. Used
142
+ // to auto-recover from a prior drain that created the branch but died before
143
+ // committing anything.
144
+ async function branchIsEmpty(branch) {
145
+ const result = await execa('git', ['rev-list', '--count', `main..${branch}`], {
146
+ cwd: REPO_ROOT,
147
+ reject: false,
148
+ });
149
+ return result.exitCode === 0 && result.stdout.trim() === '0';
150
+ }
151
+ async function remoteBranchExists(branch) {
152
+ // Defensive check: if the agent ignored its instructions and pushed, this
153
+ // succeeds. We don't fetch first — just check what's already in
154
+ // `refs/remotes/origin/` locally. If the agent ran `git push` from the
155
+ // sandbox, it will have updated the local remote ref via the same shared
156
+ // .git directory.
157
+ const result = await execa('git', ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], {
158
+ cwd: REPO_ROOT,
159
+ reject: false,
160
+ });
161
+ return result.exitCode === 0;
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // Per-issue flow
165
+ // ---------------------------------------------------------------------------
166
+ function lastLines(text, n) {
167
+ const lines = text.split(/\r?\n/);
168
+ return lines.slice(Math.max(0, lines.length - n)).join('\n');
169
+ }
170
+ function buildStatusComment(args) {
171
+ const { status, branch, commits, stdout, logFilePath, pushedWarning, siblingContext, ciResult, attempts, } = args;
172
+ const lines = [];
173
+ lines.push(`**sandcastle-drain run:** \`${status}\``);
174
+ if (attempts && attempts.current > 1) {
175
+ lines.push(`**Attempts:** ${attempts.current} of ${attempts.max}`);
176
+ }
177
+ if (branch)
178
+ lines.push(`**Branch:** \`${branch}\``);
179
+ lines.push(`**Commits:** ${commits.length}${commits.length > 0 ? ` (${commits.map((c) => `\`${c.sha.slice(0, 7)}\``).join(', ')})` : ''}`);
180
+ if (siblingContext && siblingContext.count > 0) {
181
+ lines.push(`**Sibling context:** ${siblingContext.count} sibling(s), ~${siblingContext.tokens} tokens`);
182
+ }
183
+ if (logFilePath)
184
+ lines.push(`**Log:** \`${logFilePath}\``);
185
+ if (ciResult) {
186
+ lines.push('');
187
+ lines.push(formatCiSection(ciResult));
188
+ }
189
+ if (pushedWarning) {
190
+ lines.push('');
191
+ lines.push('> :warning: **The agent pushed this branch to the remote.** It was instructed not to. Investigate before merging — this is a wrapper or prompt regression.');
192
+ }
193
+ lines.push('');
194
+ lines.push('<details><summary>Last ~50 lines of agent output</summary>');
195
+ lines.push('');
196
+ lines.push('```');
197
+ lines.push(lastLines(stdout, 50));
198
+ lines.push('```');
199
+ lines.push('');
200
+ lines.push('</details>');
201
+ return lines.join('\n');
202
+ }
203
+ // Removes the per-issue worktree dir from disk. Safe to call when the dir
204
+ // doesn't exist. Failures are logged, never thrown — cleanup must not mask the
205
+ // real run outcome. Used both for pre-flight orphan removal and post-run
206
+ // cleanup so a clean run leaves no disk residue.
207
+ async function cleanupWorktree(worktreePath) {
208
+ if (!existsSync(worktreePath))
209
+ return;
210
+ try {
211
+ await removeWorktreeDir(worktreePath);
212
+ await execa('git', ['worktree', 'prune'], { cwd: REPO_ROOT, reject: false });
213
+ }
214
+ catch (err) {
215
+ console.error(`[wrapper] worktree cleanup failed for ${worktreePath}:`, err);
216
+ }
217
+ }
218
+ async function runAndPostReviewer(args) {
219
+ const reviewerLogPath = join(REPO_ROOT, '.sandcastle-drain', 'logs', `issue-${args.issueNumber}-reviewer.log`);
220
+ console.log(`[wrapper] invoking reviewer for #${args.issueNumber} on ${args.branch}`);
221
+ let runResult;
222
+ try {
223
+ runResult = await runReviewer({
224
+ imageName: IMAGE_NAME,
225
+ hostCredsPath: HOST_CREDS_PATH,
226
+ sandboxCredsPath: SANDBOX_CREDS_PATH,
227
+ stagedHostPath: join(REPO_ROOT, STAGED_DIR_RELATIVE),
228
+ ghToken: args.ghToken,
229
+ issueNumber: args.issueNumber,
230
+ branch: args.branch,
231
+ reviewerLogPath,
232
+ idleTimeoutSeconds: REVIEWER_IDLE_TIMEOUT_SECONDS,
233
+ wallClockTimeoutMs: REVIEWER_WALL_CLOCK_TIMEOUT_MS,
234
+ });
235
+ }
236
+ catch (err) {
237
+ // Defensive: runReviewer already catches its own throws, but never let a
238
+ // reviewer failure mask the implementer's commits or block label cleanup.
239
+ console.error(`[wrapper] reviewer threw unexpectedly:`, err.message);
240
+ await postComment(args.issueNumber, formatReviewerErrorComment({ reason: `reviewer wrapper threw: ${err.message}` }));
241
+ return undefined;
242
+ }
243
+ if (runResult.output !== undefined) {
244
+ await postComment(args.issueNumber, formatReviewerComment(runResult.output));
245
+ console.log(`[wrapper] reviewer for #${args.issueNumber}: ${runResult.output.verdict} (${runResult.output.findings.length} findings)`);
246
+ return runResult.output;
247
+ }
248
+ await postComment(args.issueNumber, formatReviewerErrorComment({
249
+ reason: runResult.parseError ?? 'unknown',
250
+ logFilePath: runResult.logFilePath,
251
+ }));
252
+ console.error(`[wrapper] reviewer for #${args.issueNumber} produced no verdict: ${runResult.parseError ?? 'unknown'}`);
253
+ return undefined;
254
+ }
255
+ /**
256
+ * Reads the diff for the branch — both the list of touched files and the
257
+ * commit titles — for inclusion in the follow-up issue body. Returns empty
258
+ * arrays on git failure rather than throwing; the follow-up still goes out
259
+ * with less context, which is better than no follow-up at all.
260
+ */
261
+ async function summarizeBranchForRejection(branch) {
262
+ const namesResult = await execa('git', ['diff', '--name-only', `main..${branch}`], {
263
+ cwd: REPO_ROOT,
264
+ reject: false,
265
+ });
266
+ const changedFiles = namesResult.exitCode === 0 ? namesResult.stdout.split(/\r?\n/).filter((s) => s.length > 0) : [];
267
+ const logResult = await execa('git', ['log', `main..${branch}`, '--format=%s'], {
268
+ cwd: REPO_ROOT,
269
+ reject: false,
270
+ });
271
+ const commitTitles = logResult.exitCode === 0 ? logResult.stdout.split(/\r?\n/).filter((s) => s.length > 0) : [];
272
+ return { changedFiles, commitTitles };
273
+ }
274
+ /**
275
+ * Creates the follow-up issue via `gh issue create`. Returns the new issue's
276
+ * number + URL on success, or `undefined` if the create failed (in which case
277
+ * the wrapper still tags + discards the branch — the human can re-file from
278
+ * the comment trail).
279
+ */
280
+ async function createFollowUpIssue(args) {
281
+ try {
282
+ const stdout = await gh([
283
+ 'issue',
284
+ 'create',
285
+ '--title',
286
+ args.title,
287
+ '--body-file',
288
+ '-',
289
+ '--label',
290
+ QUEUE_LABEL,
291
+ '--label',
292
+ PRIORITY_LABEL,
293
+ ], { input: args.body });
294
+ // `gh issue create` prints the URL of the new issue on the last line.
295
+ const url = stdout
296
+ .split(/\r?\n/)
297
+ .map((s) => s.trim())
298
+ .filter((s) => s.length > 0)
299
+ .pop();
300
+ if (!url)
301
+ return undefined;
302
+ const m = /\/issues\/(\d+)$/.exec(url);
303
+ if (!m)
304
+ return undefined;
305
+ return { number: Number(m[1]), url };
306
+ }
307
+ catch (err) {
308
+ console.error(`[wrapper] gh issue create failed:`, err.message);
309
+ return undefined;
310
+ }
311
+ }
312
+ /**
313
+ * Reviewer-FAIL outcome: tag the work, discard the branch, file a priority
314
+ * follow-up, comment on the original. Returns `true` if the tag landed —
315
+ * which is the load-bearing step. Follow-up creation failing is logged but
316
+ * doesn't unwind the tag/discard: the commits are preserved at the tag and
317
+ * the human can re-file by hand.
318
+ */
319
+ async function handleRejection(args) {
320
+ const existingTags = await listRejectionTagsForIssue(args.issue.number, REPO_ROOT);
321
+ const attempt = nextAttemptNumber(args.issue.number, existingTags);
322
+ const tag = rejectionTagName(args.issue.number, attempt);
323
+ try {
324
+ await createRejectionTag({
325
+ tag,
326
+ branch: args.branch,
327
+ cwd: REPO_ROOT,
328
+ message: `Rejected by sandcastle-drain reviewer (attempt ${attempt}, issue #${args.issue.number}). ${args.reviewerOutput.summary}`,
329
+ });
330
+ console.log(`[wrapper] tagged ${args.branch} as ${tag}`);
331
+ }
332
+ catch (err) {
333
+ console.error(`[wrapper] failed to tag rejected branch ${args.branch} as ${tag}:`, err.message);
334
+ return false;
335
+ }
336
+ // Capture diff/log summary *before* deleting the branch — main..branch
337
+ // ranges resolve through the branch ref. The tag works just as well, but
338
+ // pulling it from the branch keeps the helper independent of tagging order.
339
+ const branchSummary = await summarizeBranchForRejection(args.branch);
340
+ if (await branchExists(args.branch)) {
341
+ await deleteBranch(args.branch);
342
+ console.log(`[wrapper] discarded branch ${args.branch}`);
343
+ }
344
+ const followUpBody = buildFollowUpBody({
345
+ originalIssueNumber: args.issue.number,
346
+ rejectionTag: tag,
347
+ attempt: attempt + 1,
348
+ reviewerOutput: args.reviewerOutput,
349
+ changedFiles: branchSummary.changedFiles,
350
+ commitTitles: branchSummary.commitTitles,
351
+ });
352
+ const followUpTitle = buildFollowUpTitle(args.issue.number, args.issue.title);
353
+ const followUp = await createFollowUpIssue({ title: followUpTitle, body: followUpBody });
354
+ if (followUp) {
355
+ console.log(`[wrapper] filed follow-up #${followUp.number} for rejected #${args.issue.number}`);
356
+ }
357
+ await postComment(args.issue.number, buildOriginalIssueRejectionComment({
358
+ rejectionTag: tag,
359
+ attempt,
360
+ reviewerSummary: args.reviewerOutput.summary,
361
+ followUpIssueNumber: followUp?.number,
362
+ followUpIssueUrl: followUp?.url,
363
+ }));
364
+ // Close the original — the follow-up is the active work item. If the
365
+ // follow-up create failed, leave the original open so a human can re-file
366
+ // by hand from the comment trail.
367
+ if (followUp) {
368
+ await closeIssue(args.issue.number);
369
+ console.log(`[wrapper] closed #${args.issue.number} (superseded by #${followUp.number})`);
370
+ }
371
+ return true;
372
+ }
373
+ /**
374
+ * Files a single split as a new GitHub issue with `sandcastle` + `priority`
375
+ * labels, mirroring `createFollowUpIssue()`. Returns the created issue's
376
+ * number + URL on success, or `undefined` on `gh` failure (logged, never
377
+ * thrown — partial split filing is better than no split filing).
378
+ */
379
+ async function createSplitIssue(args) {
380
+ try {
381
+ const stdout = await gh([
382
+ 'issue',
383
+ 'create',
384
+ '--title',
385
+ args.split.title,
386
+ '--body-file',
387
+ '-',
388
+ '--label',
389
+ QUEUE_LABEL,
390
+ '--label',
391
+ PRIORITY_LABEL,
392
+ ], { input: args.split.body });
393
+ const url = stdout
394
+ .split(/\r?\n/)
395
+ .map((s) => s.trim())
396
+ .filter((s) => s.length > 0)
397
+ .pop();
398
+ if (!url)
399
+ return undefined;
400
+ const m = /\/issues\/(\d+)$/.exec(url);
401
+ if (!m)
402
+ return undefined;
403
+ return { number: Number(m[1]), url, title: args.split.title };
404
+ }
405
+ catch (err) {
406
+ console.error(`[wrapper] gh issue create (split for #${args.parentIssue}) failed:`, err.message);
407
+ return undefined;
408
+ }
409
+ }
410
+ /**
411
+ * Split-protocol outcome: file each entry from `.sandcastle-drain/splits.json` as a
412
+ * priority follow-up, comment on the parent linking them, apply `oversized`.
413
+ * Returns the count + follow-up numbers, or `undefined` when the splits file
414
+ * was malformed or every gh-create failed.
415
+ *
416
+ * Suppressed by the caller when the rejection loop fires — a rejected run's
417
+ * follow-up subsumes any split intent on the same partial work.
418
+ */
419
+ async function processSplits(args) {
420
+ if (!args.splitsResult.ok) {
421
+ await postComment(args.parentIssue, buildSplitErrorComment({ reason: args.splitsResult.reason }));
422
+ console.error(`[wrapper] splits.json on #${args.parentIssue} was malformed: ${args.splitsResult.reason}`);
423
+ return undefined;
424
+ }
425
+ const created = [];
426
+ for (const split of args.splitsResult.value) {
427
+ const result = await createSplitIssue({ parentIssue: args.parentIssue, split });
428
+ if (result)
429
+ created.push(result);
430
+ }
431
+ if (created.length === 0) {
432
+ console.error(`[wrapper] all split issue creates failed for #${args.parentIssue}; skipping comment/label`);
433
+ return undefined;
434
+ }
435
+ await postComment(args.parentIssue, buildOriginalIssueSplitComment({ parentIssue: args.parentIssue, splits: created }));
436
+ await addLabel(args.parentIssue, OVERSIZED_LABEL);
437
+ console.log(formatSplitsLogLine({ parentIssue: args.parentIssue, splits: created }));
438
+ return {
439
+ count: created.length,
440
+ followUpNumbers: created.map((c) => c.number),
441
+ };
442
+ }
443
+ /**
444
+ * Auto-merge the slice: push, open PR, squash, then sweep the worktree.
445
+ * Returns true on success. Any failure falls back to the manual `needs-review`
446
+ * path — push errors, merge conflicts, and sweep failures are noisy in the log
447
+ * but do not abort the drain. The branch is left in place so the human can
448
+ * inspect / retry with `npm run ship <N>`.
449
+ */
450
+ async function tryAutoMerge(issueNumber) {
451
+ try {
452
+ console.log(`[wrapper] auto-ship #${issueNumber}`);
453
+ await shipBranch({ issue: issueNumber });
454
+ }
455
+ catch (err) {
456
+ console.error(`[wrapper] auto-ship failed for #${issueNumber}:`, err.message);
457
+ return false;
458
+ }
459
+ try {
460
+ console.log(`[wrapper] auto-sweep #${issueNumber}`);
461
+ await sweepBranch({ issue: issueNumber });
462
+ }
463
+ catch (err) {
464
+ // Ship succeeded → merge is on main, the issue auto-closed via `Closes #N`.
465
+ // Sweep failing is local-cleanup-only; the user can rerun `npm run sweep`.
466
+ console.error(`[wrapper] auto-sweep failed for #${issueNumber} (ship succeeded, branch is merged):`, err.message);
467
+ }
468
+ return true;
469
+ }
470
+ async function processIssue(issue, ghToken, siblings, failedThisRun) {
471
+ const branch = `agent/issue-${issue.number}`;
472
+ console.log(`\n[wrapper] === Issue #${issue.number}: ${issue.title} ===`);
473
+ // (a.pre) Dependency skip: if this issue's `## Blocked by` section names any
474
+ // issue that failed to land earlier in *this* drain run, mark it skipped on
475
+ // GitHub and leave `sandcastle` on so the next drain retries once the blocker
476
+ // is resolved. Only failures from this run count; stale references to long-
477
+ // closed issues never enter `failedThisRun`.
478
+ if (failedThisRun.size > 0) {
479
+ const blockers = parseBlockedBy(issue.body);
480
+ const failedBlockers = blockers.filter((n) => failedThisRun.has(n));
481
+ if (failedBlockers.length > 0) {
482
+ const first = failedBlockers[0];
483
+ console.log(`[wrapper] skipping #${issue.number} — blocked by failed #${first} this run`);
484
+ await markSkipped(issue.number, `Blocked by #${first}, which did not land in this drain run. Re-queue after the blocker is resolved.`, { removeSandcastle: false });
485
+ return {
486
+ issue: issue.number,
487
+ status: `skipped (blocked by #${first})`,
488
+ commitCount: 0,
489
+ };
490
+ }
491
+ }
492
+ // (a) Honor `retry` — discard prior branch and clear the label so the next
493
+ // queue fetch doesn't keep re-triggering it.
494
+ if (issue.labels.includes(RETRY_LABEL)) {
495
+ console.log(`[wrapper] retry label set; discarding prior branch ${branch} if any`);
496
+ if (await branchExists(branch))
497
+ await deleteBranch(branch);
498
+ await removeLabel(issue.number, RETRY_LABEL);
499
+ }
500
+ // (b) Existing-branch handling — preserve possibly-good prior work, but
501
+ // auto-discard a branch with zero commits ahead of main (no work to lose).
502
+ // A prior drain that created the branch and died before its first commit
503
+ // would otherwise sit stuck until the user applied `retry` manually.
504
+ if (await branchExists(branch)) {
505
+ if (await branchIsEmpty(branch)) {
506
+ await deleteBranch(branch);
507
+ console.log(`[wrapper] discarded empty stale branch ${branch} from prior run`);
508
+ }
509
+ else {
510
+ console.log(`[wrapper] branch ${branch} already exists; skipping (add 'retry' label to discard and re-run)`);
511
+ await markSkipped(issue.number, `Branch \`${branch}\` already exists from a prior run — preserved to avoid losing possibly-good work. Add the \`retry\` label alongside \`sandcastle\` to discard the branch and re-run.`, { removeSandcastle: false });
512
+ return { issue: issue.number, status: 'skipped (existing branch)', commitCount: 0 };
513
+ }
514
+ }
515
+ // (b.5) Clean up any orphaned worktree dir from a prior failed run. Without
516
+ // this, sandcastle's WorktreeManager hits "Function not implemented" on
517
+ // Windows when git tries to delete a pnpm-installed worktree dir.
518
+ const worktreePath = join(REPO_ROOT, '.sandcastle-drain', 'worktrees', `agent-issue-${issue.number}`);
519
+ if (existsSync(worktreePath)) {
520
+ console.log(`[wrapper] cleaning orphaned worktree dir ${worktreePath}`);
521
+ await cleanupWorktree(worktreePath);
522
+ }
523
+ // (c) Mark in-progress.
524
+ await addLabel(issue.number, IN_PROGRESS_LABEL);
525
+ // Build the sibling-context block once so we can both pass it to the agent
526
+ // and surface its size in logs / status comment for bloat monitoring.
527
+ const siblingContextBlock = buildSiblingContextBlock(siblings);
528
+ const siblingContextTokens = estimateTokens(siblingContextBlock);
529
+ if (siblings.length > 0) {
530
+ console.log(`[wrapper] sibling context: ${siblings.length} sibling(s), ~${siblingContextTokens} tokens`);
531
+ }
532
+ // (d) Run the agent — with one auto-retry on idle/wall-clock timeout. The
533
+ // retry condition is narrow on purpose: only `failed (timeout)` retries.
534
+ // Bail-outs, unknown errors, and rate limits are not transient and must not
535
+ // re-burn quota. Each attempt re-creates the branch from scratch.
536
+ let result;
537
+ let runError;
538
+ let commits = [];
539
+ let completionSignal;
540
+ let stdout = '';
541
+ let logFilePath;
542
+ let windowsTeardownThrew = false;
543
+ let status = 'failed (unknown)';
544
+ let attempt = 1;
545
+ while (attempt <= MAX_ATTEMPTS_PER_ISSUE) {
546
+ if (attempt > 1) {
547
+ console.log(`[wrapper] Issue #${issue.number} attempt ${attempt}/${MAX_ATTEMPTS_PER_ISSUE}: prior attempt failed (timeout), retrying`);
548
+ }
549
+ result = undefined;
550
+ runError = undefined;
551
+ try {
552
+ const prompt = await renderPrompt('implementer', {
553
+ ISSUE_NUMBER: String(issue.number),
554
+ ISSUE_TITLE: issue.title,
555
+ SIBLING_CONTEXT: siblingContextBlock,
556
+ });
557
+ result = await run({
558
+ agent: claudeCode('claude-opus-4-7'),
559
+ sandbox: docker({
560
+ imageName: IMAGE_NAME,
561
+ mounts: [
562
+ { hostPath: HOST_CREDS_PATH, sandboxPath: SANDBOX_CREDS_PATH },
563
+ {
564
+ hostPath: join(REPO_ROOT, STAGED_DIR_RELATIVE),
565
+ sandboxPath: STAGED_SANDBOX_PATH,
566
+ readonly: true,
567
+ },
568
+ ],
569
+ // GH_TOKEN gives the in-sandbox `gh` (used by the prompt's
570
+ // `!gh issue view ...` block, and by any agent-side `gh issue comment`)
571
+ // the same auth as the host. Without it, gh inside the container
572
+ // hits its "please run gh auth login" path.
573
+ env: { GH_TOKEN: ghToken },
574
+ }),
575
+ prompt,
576
+ branchStrategy: { type: 'branch', branch },
577
+ idleTimeoutSeconds: IDLE_TIMEOUT_SECONDS,
578
+ signal: AbortSignal.timeout(WALL_CLOCK_TIMEOUT_MS),
579
+ });
580
+ }
581
+ catch (err) {
582
+ runError = err;
583
+ console.error(`[wrapper] sandcastle.run() threw:`, err);
584
+ }
585
+ commits = result?.commits ?? [];
586
+ completionSignal = result?.completionSignal;
587
+ stdout =
588
+ result?.stdout ?? (runError instanceof Error ? runError.message : String(runError ?? ''));
589
+ // sandcastle may overwrite the same log file across attempts on the same
590
+ // drain. We surface only the latest attempt's logFilePath in the status
591
+ // comment — the wrapper stdout's `attempt 2/2` boundary marks where in
592
+ // the file the second attempt begins.
593
+ logFilePath = result?.logFilePath;
594
+ // Windows teardown path: sandcastle.run() throws *after* the agent commits
595
+ // because its WorktreeManager hits the pnpm-symlinks "Function not
596
+ // implemented" landmine. `result` is undefined but the commits are on the
597
+ // branch — read them back so this run is labeled ok (windows-teardown).
598
+ const recovered = await tryRecoverCommits({ result, runError, branch, cwd: REPO_ROOT });
599
+ if (recovered.length > 0)
600
+ commits = recovered;
601
+ windowsTeardownThrew = recovered.length > 0;
602
+ status = determineRunStatus({
603
+ commits,
604
+ completionSignal,
605
+ runError,
606
+ stdout,
607
+ windowsTeardownThrew,
608
+ });
609
+ // Retry decision: only on `failed (timeout)` with no commits, and only
610
+ // when we have attempts left. Rate-limit short-circuits below; everything
611
+ // else is terminal for this issue.
612
+ const shouldRetry = status === 'failed (timeout)' &&
613
+ attempt < MAX_ATTEMPTS_PER_ISSUE &&
614
+ !isRateLimitError(runError) &&
615
+ !containsRateLimit(stdout);
616
+ if (!shouldRetry)
617
+ break;
618
+ // Between-attempt cleanup mirrors the manual `retry` label path: discard
619
+ // the branch sandcastle created (no commits, nothing to preserve) and
620
+ // wipe the worktree dir so attempt 2 starts from a fresh checkout off
621
+ // main.
622
+ if (await branchExists(branch))
623
+ await deleteBranch(branch);
624
+ await cleanupWorktree(worktreePath);
625
+ attempt += 1;
626
+ }
627
+ // (f.5) Capture `.sandcastle-drain/splits.json` from the worktree BEFORE any
628
+ // cleanup destroys it. The implementer writes this file when the issue
629
+ // didn't fit in one run and named follow-ups for the wrapper to file. We
630
+ // only read it here; the actual issue-filing happens after the reviewer
631
+ // comment posts so the audit trail is in order.
632
+ const splitsResult = await readSplitsFile(worktreePath);
633
+ // (g) Defensive push check.
634
+ const pushed = await remoteBranchExists(branch);
635
+ // (g.4) Pre-gate cleanup. Sandcastle's worktree teardown hits ENOSYS on
636
+ // Windows pnpm symlink farms, leaving the dir + a half-broken node_modules
637
+ // behind. Cleaning before the gate forces it down the fresh-worktree +
638
+ // pnpm install path, and also clears the way for the reviewer at (e.5).
639
+ if (commits.length > 0) {
640
+ await cleanupWorktree(worktreePath);
641
+ }
642
+ // (g.5) CI gate — runs only when commits exist. On failure the issue goes
643
+ // to `needs-info` instead of `needs-review`, with the CI output attached.
644
+ let ciResult;
645
+ if (commits.length > 0) {
646
+ console.log(`[wrapper] running CI gate for #${issue.number}`);
647
+ try {
648
+ ciResult = await runCiGate({
649
+ issue: issue.number,
650
+ branch,
651
+ repoRoot: REPO_ROOT,
652
+ worktreePath,
653
+ });
654
+ console.log(`[wrapper] CI gate: ${ciResult.ok ? 'PASS' : `FAIL (${ciResult.packageManager} ${ciResult.failedCheck})`}`);
655
+ }
656
+ catch (err) {
657
+ console.error(`[wrapper] CI gate threw — treating as failure:`, err);
658
+ ciResult = {
659
+ ok: false,
660
+ failedCheck: 'install',
661
+ runs: [],
662
+ logPath: '<ci-gate threw before logging>',
663
+ packageManager: detectPackageManager(REPO_ROOT),
664
+ };
665
+ }
666
+ }
667
+ // (e) Status comment — best effort, posted regardless of outcome.
668
+ const comment = buildStatusComment({
669
+ status,
670
+ branch: commits.length > 0 ? branch : undefined,
671
+ commits,
672
+ stdout,
673
+ logFilePath,
674
+ pushedWarning: pushed,
675
+ siblingContext: { count: siblings.length, tokens: siblingContextTokens },
676
+ ciResult,
677
+ attempts: { current: attempt, max: MAX_ATTEMPTS_PER_ISSUE },
678
+ });
679
+ await postComment(issue.number, comment);
680
+ // (e.5) Reviewer pass — only when the implementer made commits and the run
681
+ // didn't hit a rate limit. The reviewer is advisory for the rubric, but its
682
+ // PASS verdict combined with a green CI gate also unlocks the auto-merge
683
+ // path at (e.6). Skipped on rate-limit to avoid burning more quota; skipped
684
+ // on no-commits because there's nothing to review.
685
+ let reviewerOutput;
686
+ if (commits.length > 0 && !isRateLimitError(runError) && !containsRateLimit(stdout)) {
687
+ reviewerOutput = await runAndPostReviewer({
688
+ issueNumber: issue.number,
689
+ branch,
690
+ ghToken,
691
+ });
692
+ }
693
+ const reviewerVerdict = reviewerOutput?.verdict;
694
+ // (e.6) Auto-merge gate: CI green AND reviewer PASS → push, merge, sweep.
695
+ // Any other combination (reviewer FAIL, parse error, throw, CI red) falls
696
+ // through to the manual `needs-review` / `needs-info` label paths below.
697
+ let autoMerged = false;
698
+ if (commits.length > 0 && ciResult?.ok === true && reviewerVerdict === 'PASS') {
699
+ autoMerged = await tryAutoMerge(issue.number);
700
+ }
701
+ // (e.7) Rejection loop: reviewer FAIL on commits → tag the work as
702
+ // `rejected/issue-N-attempt-K`, discard the branch, and file a priority
703
+ // follow-up issue carrying the reviewer findings forward. The original
704
+ // issue is closed out with a pointer comment.
705
+ let rejected = false;
706
+ if (commits.length > 0 && reviewerOutput?.verdict === 'FAIL') {
707
+ rejected = await handleRejection({
708
+ issue,
709
+ branch,
710
+ commits,
711
+ reviewerOutput,
712
+ });
713
+ }
714
+ // (e.8) Split protocol: act on `.sandcastle-drain/splits.json` captured at (f.5).
715
+ // Files each entry as a `sandcastle` + `priority` follow-up so the next
716
+ // drain iteration picks them up. Suppressed when rejection fired — the
717
+ // rejection follow-up already subsumes any split intent on rejected work.
718
+ let split;
719
+ if (!rejected && splitsResult !== undefined) {
720
+ split = await processSplits({
721
+ parentIssue: issue.number,
722
+ splitsResult,
723
+ });
724
+ }
725
+ // (f) Apply outcome labels. Always remove `sandcastle` so the wrapper
726
+ // never silently re-queues the issue — the user re-applies `sandcastle`
727
+ // (with `retry` for fresh-start) when they're ready.
728
+ await removeLabel(issue.number, SKIPPED_THIS_RUN_LABEL);
729
+ await removeLabel(issue.number, IN_PROGRESS_LABEL);
730
+ await removeLabel(issue.number, QUEUE_LABEL);
731
+ if (autoMerged) {
732
+ // Squash-merge with `Closes #N` body has auto-closed the issue. No further
733
+ // labels needed — `needs-review` would be misleading since there's nothing
734
+ // left to review.
735
+ }
736
+ else if (rejected) {
737
+ // Rejection loop already commented on the original issue and filed a
738
+ // follow-up. The original needs no further state — the follow-up is now
739
+ // the active work item.
740
+ }
741
+ else if (commits.length > 0 && ciResult?.ok === true) {
742
+ await addLabel(issue.number, NEEDS_REVIEW_LABEL);
743
+ }
744
+ else {
745
+ // Three paths funnel here:
746
+ // 1. No commits + bail-out (COMPLETE without commits) or hard failure.
747
+ // 2. Commits exist but the CI gate is red.
748
+ // 3. Commits exist but the CI gate threw before deciding.
749
+ // All want a human eye.
750
+ await addLabel(issue.number, NEEDS_INFO_LABEL);
751
+ }
752
+ // (h) Post-run worktree cleanup. The git branch is the durable artifact;
753
+ // the worktree dir is a build cache that, on Windows + pnpm, accumulates
754
+ // symlink farms that defeat next-run cleanup. Run before the rate-limit
755
+ // throw so cleanup happens even when the loop is about to abort.
756
+ await cleanupWorktree(worktreePath);
757
+ // Surface rate-limit upstream so the loop can break — even when commits
758
+ // exist (status is partial-work, but we still don't drain the next issue).
759
+ if (isRateLimitError(runError) || containsRateLimit(stdout)) {
760
+ throw new RateLimitError();
761
+ }
762
+ return {
763
+ issue: issue.number,
764
+ status,
765
+ // After auto-merge or rejection, the branch is gone — omit it from the
766
+ // summary so the per-issue line and review hint don't point at a
767
+ // dangling ref.
768
+ branch: commits.length > 0 && !autoMerged && !rejected ? branch : undefined,
769
+ commitCount: commits.length,
770
+ ciOk: ciResult?.ok,
771
+ autoMerged,
772
+ rejected,
773
+ split,
774
+ attempt,
775
+ };
776
+ }
777
+ class RateLimitError extends Error {
778
+ constructor() {
779
+ super('rate-limit detected; ending drain');
780
+ this.name = 'RateLimitError';
781
+ }
782
+ }
783
+ // ---------------------------------------------------------------------------
784
+ // Main
785
+ // ---------------------------------------------------------------------------
786
+ function printSummary(summaries) {
787
+ console.log(formatSummary(summaries));
788
+ }
789
+ async function drainQueue(initial, ghToken) {
790
+ const summaries = [];
791
+ const siblings = [];
792
+ // Issues that did not auto-merge this run. Dependents named in their bodies'
793
+ // `## Blocked by` section will be skipped — we won't build on a foundation
794
+ // that didn't land. Survives the mid-loop refetch below.
795
+ const failedThisRun = new Set();
796
+ let queue = [...initial];
797
+ let i = 0;
798
+ while (i < queue.length) {
799
+ const issue = queue[i];
800
+ try {
801
+ const summary = await processIssue(issue, ghToken, siblings, failedThisRun);
802
+ summaries.push(summary);
803
+ // "Did not land" = anything except a clean auto-merge. Rejected,
804
+ // needs-review, needs-info, CI-red, timeout — all block dependents this
805
+ // run. Skipped issues themselves don't poison the set: they never ran.
806
+ if (!summary.autoMerged && !summary.status.startsWith('skipped')) {
807
+ failedThisRun.add(issue.number);
808
+ }
809
+ // Capture sibling context for subsequent iterations. Only branches with
810
+ // commits AND a passing CI gate are useful — a no-commit run has nothing
811
+ // for siblings to reuse, and a CI-broken branch would propagate red work
812
+ // into the next agent's prompt.
813
+ if (summary.commitCount > 0 && summary.branch && summary.ciOk !== false) {
814
+ siblings.push(await summarizeBranch({
815
+ issue: summary.issue,
816
+ branch: summary.branch,
817
+ baseBranch: 'main',
818
+ cwd: REPO_ROOT,
819
+ }));
820
+ }
821
+ // After a rejection or split, refetch so the priority follow-ups just
822
+ // filed by `handleRejection()` / `processSplits()` land at the front of
823
+ // the remaining queue. `fetchQueue` naturally excludes already-
824
+ // processed issues (the wrapper removed their `sandcastle` label) and
825
+ // `sortQueue` floats `priority` first. Splice instead of replace so
826
+ // already-iterated indices stay valid.
827
+ const filedFollowUps = summary.rejected || (summary.split && summary.split.count > 0);
828
+ if (filedFollowUps) {
829
+ const reason = summary.rejected ? 'rejection' : 'split';
830
+ try {
831
+ const refreshed = await fetchQueue();
832
+ const refreshedList = refreshed.map((r) => '#' + r.number).join(', ') || '(empty)';
833
+ console.log(`[wrapper] refetched queue after ${reason} of #${issue.number}: ${refreshed.length} issue(s) — ${refreshedList}`);
834
+ queue = [...queue.slice(0, i + 1), ...refreshed];
835
+ }
836
+ catch (err) {
837
+ // Refetch is best-effort. On failure we keep the existing tail —
838
+ // the new follow-ups surface on the next `npm run drain`.
839
+ console.error(`[wrapper] refetch after ${reason} failed (continuing with existing queue):`, err.message);
840
+ }
841
+ }
842
+ }
843
+ catch (err) {
844
+ if (err instanceof RateLimitError) {
845
+ console.error(`[wrapper] Rate limit detected on #${issue.number}; stopping drain.`);
846
+ // Mark remaining issues as skipped in the summary for visibility, and
847
+ // surface the skip on GitHub so the user doesn't have to read the
848
+ // orchestrator transcript to learn which issues the rate limit ate.
849
+ const remaining = queue.slice(i + 1);
850
+ for (const r of remaining) {
851
+ await markSkipped(r.number, 'sandcastle-drain hit the model rate limit before reaching this issue. Re-run drain after the limit clears.', { removeSandcastle: false });
852
+ summaries.push({ issue: r.number, status: 'skipped (rate-limited)', commitCount: 0 });
853
+ }
854
+ break;
855
+ }
856
+ // Anything else: log, continue. The per-issue try/catches inside
857
+ // processIssue should normally swallow this.
858
+ console.error(`[wrapper] Unexpected error on #${issue.number}:`, err);
859
+ summaries.push({ issue: issue.number, status: 'failed (unknown)', commitCount: 0 });
860
+ failedThisRun.add(issue.number);
861
+ }
862
+ i += 1;
863
+ }
864
+ return summaries;
865
+ }
866
+ /**
867
+ * Drains the queue once. Expects `runAllPrereqs()` to have already succeeded —
868
+ * the CLI calls that up-front so all three subcommands share the same probes.
869
+ * On an empty queue, returns immediately; otherwise iterates the prioritized
870
+ * queue and prints a summary at the end.
871
+ */
872
+ export async function runDrain(args) {
873
+ console.log('[wrapper] sandcastle-drain starting');
874
+ const queue = await fetchQueue();
875
+ if (queue.length === 0) {
876
+ console.log('[wrapper] Queue empty');
877
+ return;
878
+ }
879
+ console.log(`[wrapper] Queue: ${queue.length} issue(s) — ${queue.map((i) => `#${i.number}`).join(', ')}`);
880
+ const summaries = await drainQueue(queue, args.token);
881
+ printSummary(summaries);
882
+ }
883
+ //# sourceMappingURL=main.js.map