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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +139 -0
- package/dist/cli.js.map +1 -0
- package/dist/content/agent-docs/issue-tracker.md +22 -0
- package/dist/content/agent-docs/sandcastle-windows-cleanup.md +45 -0
- package/dist/content/agent-docs/triage-labels.md +101 -0
- package/dist/content/principles/README.md +39 -0
- package/dist/content/principles/architecture.md +124 -0
- package/dist/content/principles/claude-code-modes.md +47 -0
- package/dist/content/principles/clean-code.md +102 -0
- package/dist/content/principles/context-budget.md +81 -0
- package/dist/content/principles/cqrs.md +70 -0
- package/dist/content/principles/domain-modeling.md +62 -0
- package/dist/content/principles/frontend-organization.md +120 -0
- package/dist/content/principles/language-and-types.md +85 -0
- package/dist/content/principles/linting-and-tooling.md +122 -0
- package/dist/content/principles/personal-use-tradeoffs.md +55 -0
- package/dist/content/principles/testing.md +89 -0
- package/dist/orchestrator/blocked-by.d.ts +17 -0
- package/dist/orchestrator/blocked-by.d.ts.map +1 -0
- package/dist/orchestrator/blocked-by.js +48 -0
- package/dist/orchestrator/blocked-by.js.map +1 -0
- package/dist/orchestrator/ci-gate.d.ts +28 -0
- package/dist/orchestrator/ci-gate.d.ts.map +1 -0
- package/dist/orchestrator/ci-gate.js +198 -0
- package/dist/orchestrator/ci-gate.js.map +1 -0
- package/dist/orchestrator/main.d.ts +10 -0
- package/dist/orchestrator/main.d.ts.map +1 -0
- package/dist/orchestrator/main.js +883 -0
- package/dist/orchestrator/main.js.map +1 -0
- package/dist/orchestrator/prereqs.d.ts +30 -0
- package/dist/orchestrator/prereqs.d.ts.map +1 -0
- package/dist/orchestrator/prereqs.js +191 -0
- package/dist/orchestrator/prereqs.js.map +1 -0
- package/dist/orchestrator/rejection.d.ts +60 -0
- package/dist/orchestrator/rejection.d.ts.map +1 -0
- package/dist/orchestrator/rejection.js +187 -0
- package/dist/orchestrator/rejection.js.map +1 -0
- package/dist/orchestrator/reviewer.d.ts +75 -0
- package/dist/orchestrator/reviewer.d.ts.map +1 -0
- package/dist/orchestrator/reviewer.js +260 -0
- package/dist/orchestrator/reviewer.js.map +1 -0
- package/dist/orchestrator/ship.d.ts +19 -0
- package/dist/orchestrator/ship.d.ts.map +1 -0
- package/dist/orchestrator/ship.js +73 -0
- package/dist/orchestrator/ship.js.map +1 -0
- package/dist/orchestrator/sibling-context.d.ts +16 -0
- package/dist/orchestrator/sibling-context.d.ts.map +1 -0
- package/dist/orchestrator/sibling-context.js +61 -0
- package/dist/orchestrator/sibling-context.js.map +1 -0
- package/dist/orchestrator/splits.d.ts +60 -0
- package/dist/orchestrator/splits.d.ts.map +1 -0
- package/dist/orchestrator/splits.js +149 -0
- package/dist/orchestrator/splits.js.map +1 -0
- package/dist/orchestrator/status.d.ts +13 -0
- package/dist/orchestrator/status.d.ts.map +1 -0
- package/dist/orchestrator/status.js +43 -0
- package/dist/orchestrator/status.js.map +1 -0
- package/dist/orchestrator/summary.d.ts +33 -0
- package/dist/orchestrator/summary.d.ts.map +1 -0
- package/dist/orchestrator/summary.js +59 -0
- package/dist/orchestrator/summary.js.map +1 -0
- package/dist/orchestrator/sweep.d.ts +18 -0
- package/dist/orchestrator/sweep.d.ts.map +1 -0
- package/dist/orchestrator/sweep.js +79 -0
- package/dist/orchestrator/sweep.js.map +1 -0
- package/dist/orchestrator/teardown.d.ts +12 -0
- package/dist/orchestrator/teardown.d.ts.map +1 -0
- package/dist/orchestrator/teardown.js +42 -0
- package/dist/orchestrator/teardown.js.map +1 -0
- package/dist/orchestrator/worktree-cleanup.d.ts +2 -0
- package/dist/orchestrator/worktree-cleanup.d.ts.map +1 -0
- package/dist/orchestrator/worktree-cleanup.js +39 -0
- package/dist/orchestrator/worktree-cleanup.js.map +1 -0
- package/dist/prompts/implementer.md.tpl +85 -0
- package/dist/prompts/reviewer.md.tpl +118 -0
- package/dist/render-prompt.d.ts +22 -0
- package/dist/render-prompt.d.ts.map +1 -0
- package/dist/render-prompt.js +64 -0
- package/dist/render-prompt.js.map +1 -0
- package/dist/stage.d.ts +43 -0
- package/dist/stage.d.ts.map +1 -0
- package/dist/stage.js +105 -0
- package/dist/stage.js.map +1 -0
- package/docker/Dockerfile +42 -0
- 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
|