@valescoagency/runway 0.14.2 → 0.15.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.
@@ -0,0 +1,707 @@
1
+ import { claudeCode } from "@ai-hero/sandcastle";
2
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
3
+ import { Duration, Effect } from "effect";
4
+ import { renderShepherdCiFixPrompt, renderShepherdRebasePrompt, renderShepherdReviewFixPrompt, renderShepherdReviewRespondPrompt, } from "./prompts.js";
5
+ import { dockerEnv, runSandcastle, stringifyResult, } from "./sandcastle.js";
6
+ /**
7
+ * VA-460: "PR has been calm long enough — presume ready for human
8
+ * merge" default. Not a budget; just the natural-exit threshold for
9
+ * the loop when nothing is happening. Override via
10
+ * `ShepherdDeps.maxQuietPolls` for tests.
11
+ */
12
+ const DEFAULT_MAX_QUIET_POLLS = 5;
13
+ const DEFAULT_NO_OP_MERGE_CHECK = () => Effect.succeed(false);
14
+ /**
15
+ * VA-460: named no-op subscription stubs. VA-461..463 each replace
16
+ * one of these with a real handler factory.
17
+ *
18
+ * A. mergeabilityStub — replaced by `makeMergeabilityWatcher` (VA-461)
19
+ * B. ciStub — replaced by VA-462 (check-runs watcher)
20
+ * C. reviewerStub — replaced by VA-463 (PR comment watcher)
21
+ */
22
+ export const mergeabilityStub = () => Effect.succeed({ kind: "quiet" });
23
+ export const ciStub = () => Effect.succeed({ kind: "quiet" });
24
+ export const reviewerStub = () => Effect.succeed({ kind: "quiet" });
25
+ export const DEFAULT_SUBSCRIPTIONS = [
26
+ mergeabilityStub,
27
+ ciStub,
28
+ reviewerStub,
29
+ ];
30
+ /**
31
+ * VA-461: real merge check, wired to the github gateway's
32
+ * `getPullRequest`. Drop-in replacement for the scaffolding's
33
+ * `DEFAULT_NO_OP_MERGE_CHECK`.
34
+ */
35
+ export function makeIsMerged(github) {
36
+ return (ctx) => Effect.map(github.getPullRequest({ repoPath: ctx.cwd, prNumber: ctx.prNumber }), (pr) => pr.merged);
37
+ }
38
+ /**
39
+ * Production rebase-agent invocation: render the prompt, run one
40
+ * sandcastle iteration, parse the agent's `IMPL:` verdict. The
41
+ * shepherd's `fix` action wraps this so tests can stub the whole
42
+ * function via `MergeabilityWatcherDeps.invokeRebaseAgent`.
43
+ */
44
+ export const defaultInvokeRebaseAgent = (args) => Effect.gen(function* () {
45
+ const prompt = yield* Effect.promise(() => renderShepherdRebasePrompt({
46
+ issue: args.issue,
47
+ branch: args.branch,
48
+ baseBranch: args.baseBranch,
49
+ mergeableState: args.mergeableState,
50
+ conflictFiles: args.conflictFiles,
51
+ }));
52
+ const result = yield* runSandcastle({
53
+ agent: claudeCode("claude-opus-4-7"),
54
+ sandbox: docker({ env: dockerEnv(args.config) }),
55
+ cwd: args.cwd,
56
+ prompt,
57
+ // VA-456: rebase agent operates ON the impl branch.
58
+ branchStrategy: { type: "branch", branch: args.branch },
59
+ maxIterations: 1,
60
+ name: `shepherd-rebase-${args.issue.identifier}`,
61
+ });
62
+ return parseRebaseVerdict(stringifyResult(result));
63
+ });
64
+ /**
65
+ * VA-461: parse the rebase agent's `IMPL: DONE` / `IMPL: BLOCKED`
66
+ * verdict. Mirrors the shape `parseImplVerdict` (src/implement.ts)
67
+ * uses for the main impl loop.
68
+ */
69
+ export function parseRebaseVerdict(output) {
70
+ const blocked = output.match(/^IMPL:\s*BLOCKED(?:\s*[—-]\s*(.*))?$/im);
71
+ if (blocked) {
72
+ const reason = (blocked[1] ?? "").trim();
73
+ const files = extractConflictFilesFromReason(reason);
74
+ return { kind: "blocked", reason, conflictFiles: files };
75
+ }
76
+ if (/^IMPL:\s*DONE\b/im.test(output)) {
77
+ return { kind: "done" };
78
+ }
79
+ // No marker at all is treated as BLOCKED with a synthetic reason —
80
+ // the loop should escalate rather than silently believe the rebase
81
+ // succeeded.
82
+ return {
83
+ kind: "blocked",
84
+ reason: "rebase agent produced no IMPL: marker",
85
+ conflictFiles: [],
86
+ };
87
+ }
88
+ /**
89
+ * Production CI-fix invocation: render the prompt, run one sandcastle
90
+ * iteration, parse the verdict. Tests stub via the watcher deps.
91
+ */
92
+ export const defaultInvokeCiFixAgent = (args) => Effect.gen(function* () {
93
+ const prompt = yield* Effect.promise(() => renderShepherdCiFixPrompt({
94
+ issue: args.issue,
95
+ branch: args.branch,
96
+ checkName: args.checkName,
97
+ logTail: args.logTail,
98
+ }));
99
+ const result = yield* runSandcastle({
100
+ agent: claudeCode("claude-opus-4-7"),
101
+ sandbox: docker({ env: dockerEnv(args.config) }),
102
+ cwd: args.cwd,
103
+ prompt,
104
+ branchStrategy: { type: "branch", branch: args.branch },
105
+ maxIterations: 1,
106
+ name: `shepherd-ci-fix-${args.issue.identifier}-${sanitiseCheckName(args.checkName)}`,
107
+ });
108
+ return parseCiFixVerdict(stringifyResult(result));
109
+ });
110
+ /**
111
+ * Parse the CI-fix agent's verdict. Mirrors `parseRebaseVerdict` — same
112
+ * IMPL: DONE / IMPL: BLOCKED markers, same fallback to BLOCKED when
113
+ * no marker fires.
114
+ */
115
+ export function parseCiFixVerdict(output) {
116
+ const blocked = output.match(/^IMPL:\s*BLOCKED(?:\s*[—-]\s*(.*))?$/im);
117
+ if (blocked) {
118
+ return { kind: "blocked", reason: (blocked[1] ?? "").trim() };
119
+ }
120
+ if (/^IMPL:\s*DONE\b/im.test(output)) {
121
+ return { kind: "done" };
122
+ }
123
+ return {
124
+ kind: "blocked",
125
+ reason: "ci-fix agent produced no IMPL: marker",
126
+ };
127
+ }
128
+ /**
129
+ * VA-462: stable hash for a log tail used as the de-dup key. Just a
130
+ * cheap DJB2 — collisions don't cause correctness issues here (worst
131
+ * case is one extra re-impl), and we want determinism without a crypto
132
+ * import.
133
+ */
134
+ function hashLogTail(s) {
135
+ let hash = 5381;
136
+ for (let i = 0; i < s.length; i++) {
137
+ hash = ((hash << 5) + hash + s.charCodeAt(i)) | 0;
138
+ }
139
+ return String(hash);
140
+ }
141
+ function sanitiseCheckName(name) {
142
+ return name.replace(/[^a-z0-9_-]+/gi, "-").slice(0, 40);
143
+ }
144
+ const DEFAULT_MAX_CONSECUTIVE_SAME_FAILURE = 3;
145
+ export function makeCiWatcher(deps) {
146
+ const invoke = deps.invokeCiFixAgent ?? defaultInvokeCiFixAgent;
147
+ const maxConsecutive = deps.maxConsecutiveSameFailure ?? DEFAULT_MAX_CONSECUTIVE_SAME_FAILURE;
148
+ // Per-PR-shepherd state. The closure lives for the duration of one
149
+ // runShepherdPass invocation; a new PR opens fresh history.
150
+ const failureHistory = new Map();
151
+ return (ctx) => Effect.gen(function* () {
152
+ const pr = yield* deps.github.getPullRequest({
153
+ repoPath: ctx.cwd,
154
+ prNumber: ctx.prNumber,
155
+ });
156
+ // Merged PRs short-circuit before we burn an API call on
157
+ // check-runs. The merge-check upstream is the canonical exit;
158
+ // this is defense-in-depth.
159
+ if (pr.merged)
160
+ return { kind: "quiet" };
161
+ const checkRuns = yield* deps.github.getCheckRuns({
162
+ repoPath: ctx.cwd,
163
+ sha: pr.headSha,
164
+ });
165
+ const failing = checkRuns.filter((c) => c.status === "completed" && c.conclusion === "failure");
166
+ if (failing.length === 0)
167
+ return { kind: "quiet" };
168
+ // Branch-protection lookup can return null (no admin / no
169
+ // protection rule); the watcher then treats every check as
170
+ // required to err on the side of acting on failures rather
171
+ // than swallowing them silently.
172
+ const required = yield* deps.github.getRequiredChecks({
173
+ repoPath: ctx.cwd,
174
+ baseBranch: ctx.baseBranch,
175
+ });
176
+ const requiredFailing = required === null
177
+ ? failing
178
+ : failing.filter((c) => required.includes(c.name));
179
+ if (requiredFailing.length === 0) {
180
+ // Optional failures: surface in logs so the operator sees
181
+ // them but don't burn iteration budget. The AC pinpoints
182
+ // exactly this behavior.
183
+ const names = failing.map((c) => c.name).join(", ");
184
+ yield* Effect.logInfo(`${ctx.issue.identifier}: shepherd.ci.optional_failure pr=${pr.number} checks=${names} (no action)`);
185
+ return { kind: "quiet" };
186
+ }
187
+ // First required failure wins for this tick. The next iteration
188
+ // (after the fix lands) re-polls and picks up whatever's still
189
+ // broken.
190
+ const first = requiredFailing[0];
191
+ const logTail = first.runId !== null
192
+ ? yield* deps.github.getCheckRunLogTail({
193
+ repoPath: ctx.cwd,
194
+ runId: first.runId,
195
+ lines: 50,
196
+ })
197
+ : "";
198
+ const errorHash = hashLogTail(logTail);
199
+ const prior = failureHistory.get(first.name);
200
+ // Rule 1: identical failure on the same SHA → quiet (already acted).
201
+ if (prior &&
202
+ prior.errorHash === errorHash &&
203
+ prior.sha === pr.headSha) {
204
+ return { kind: "quiet" };
205
+ }
206
+ // Rule 2: identical failure on a NEW SHA → escalate after the
207
+ // configured threshold.
208
+ if (prior && prior.errorHash === errorHash) {
209
+ const newCount = prior.count + 1;
210
+ failureHistory.set(first.name, {
211
+ errorHash,
212
+ count: newCount,
213
+ sha: pr.headSha,
214
+ });
215
+ if (newCount >= maxConsecutive) {
216
+ const lastLine = lastNonEmptyLine(logTail);
217
+ return {
218
+ kind: "hitl",
219
+ reason: `Repeated CI failure in \`${first.name}\`: ${lastLine}`,
220
+ };
221
+ }
222
+ // else: fall through to fix
223
+ }
224
+ else {
225
+ // Rule 3: fresh failure window.
226
+ failureHistory.set(first.name, {
227
+ errorHash,
228
+ count: 1,
229
+ sha: pr.headSha,
230
+ });
231
+ }
232
+ const checkName = first.name;
233
+ return {
234
+ kind: "fix",
235
+ description: `Re-impl after CI failure in \`${checkName}\``,
236
+ action: Effect.gen(function* () {
237
+ const result = yield* invoke({
238
+ issue: ctx.issue,
239
+ branch: ctx.branch,
240
+ checkName,
241
+ logTail,
242
+ cwd: ctx.cwd,
243
+ config: deps.config,
244
+ });
245
+ if (result.kind === "blocked") {
246
+ return {
247
+ kind: "hitl",
248
+ reason: `CI fix BLOCKED in \`${checkName}\`: ${result.reason}`,
249
+ };
250
+ }
251
+ return {
252
+ kind: "fixed",
253
+ detail: `Re-impl after \`${checkName}\` failure`,
254
+ };
255
+ }),
256
+ };
257
+ });
258
+ }
259
+ function lastNonEmptyLine(s) {
260
+ const lines = s.split("\n");
261
+ for (let i = lines.length - 1; i >= 0; i--) {
262
+ const line = lines[i].trim();
263
+ if (line.length > 0)
264
+ return line;
265
+ }
266
+ return "(empty log)";
267
+ }
268
+ /**
269
+ * VA-463: parse a PR-reviewer-bot comment for its `REVIEW: ...` line.
270
+ * Returns `{ kind: "none" }` when the body has no marker (e.g., a
271
+ * status update comment from the bot before the verdict). The regex
272
+ * matches the LAST occurrence — the prompt structure has the verdict
273
+ * line at the end of the comment body.
274
+ */
275
+ export function parsePrReviewVerdict(body) {
276
+ const matches = Array.from(body.matchAll(/^REVIEW:\s*(APPROVED|CHANGES-REQUESTED|NEEDS-DISCUSSION)(?:\s*[—-]\s*(.*))?$/gim));
277
+ if (matches.length === 0)
278
+ return { kind: "none" };
279
+ const last = matches[matches.length - 1];
280
+ const kind = last[1].toUpperCase();
281
+ const detail = (last[2] ?? "").trim();
282
+ if (kind === "APPROVED")
283
+ return { kind: "approved" };
284
+ if (kind === "CHANGES-REQUESTED") {
285
+ return { kind: "changes-requested", fix: detail };
286
+ }
287
+ return { kind: "needs-discussion", decision: detail };
288
+ }
289
+ /**
290
+ * Production reviewer-response invocation: pick the right template
291
+ * (parsed CHANGES-REQUESTED fix vs. verbatim human comment), run one
292
+ * sandcastle iteration, parse the IMPL: verdict.
293
+ */
294
+ export const defaultInvokeReviewerResponseAgent = (args) => Effect.gen(function* () {
295
+ const prompt = yield* Effect.promise(() => args.mode.kind === "fix-request"
296
+ ? renderShepherdReviewFixPrompt({
297
+ issue: args.issue,
298
+ branch: args.branch,
299
+ fixRequest: args.mode.fix,
300
+ })
301
+ : renderShepherdReviewRespondPrompt({
302
+ issue: args.issue,
303
+ branch: args.branch,
304
+ commentAuthor: args.mode.commentAuthor,
305
+ commentSource: args.mode.commentSource,
306
+ commentBody: args.mode.commentBody,
307
+ }));
308
+ const result = yield* runSandcastle({
309
+ agent: claudeCode("claude-opus-4-7"),
310
+ sandbox: docker({ env: dockerEnv(args.config) }),
311
+ cwd: args.cwd,
312
+ prompt,
313
+ branchStrategy: { type: "branch", branch: args.branch },
314
+ maxIterations: 1,
315
+ name: `shepherd-review-${args.issue.identifier}-${args.mode.kind}`,
316
+ });
317
+ return parseReviewerResponseVerdict(stringifyResult(result));
318
+ });
319
+ export function parseReviewerResponseVerdict(output) {
320
+ const blocked = output.match(/^IMPL:\s*BLOCKED(?:\s*[—-]\s*(.*))?$/im);
321
+ if (blocked) {
322
+ return { kind: "blocked", reason: (blocked[1] ?? "").trim() };
323
+ }
324
+ if (/^IMPL:\s*DONE\b/im.test(output)) {
325
+ return { kind: "done" };
326
+ }
327
+ return {
328
+ kind: "blocked",
329
+ reason: "reviewer-response agent produced no IMPL: marker",
330
+ };
331
+ }
332
+ /**
333
+ * VA-463: subscription C — reviewer-comment responder.
334
+ *
335
+ * Polls PR comments (top-level + review thread + review summaries) on
336
+ * every shepherd tick. Partitions by author:
337
+ *
338
+ * * Adversarial reviewer bot (`config.prReviewerBotLogin`): parse
339
+ * for `REVIEW:` marker. APPROVED → quiet. CHANGES-REQUESTED →
340
+ * fix-request prompt with the parsed fix line. NEEDS-DISCUSSION
341
+ * → immediate hitl with the canonical reason verbatim.
342
+ * * Anyone else (human): pass the comment body verbatim to the
343
+ * respond prompt; agent decides actionable vs. not. BLOCKED →
344
+ * hitl with the agent's reason.
345
+ *
346
+ * Dedup: per-shepherd-pass Set of responded comment IDs. The first
347
+ * unhandled comment each tick wins; subsequent comments wait for the
348
+ * next iteration. The next pass picks up whatever's still unhandled.
349
+ *
350
+ * Initial snapshot: the first poll records every existing comment ID
351
+ * as already-seen WITHOUT acting on them — only comments posted AFTER
352
+ * the shepherd loop started should trigger work. The PR was just
353
+ * opened; pre-existing comments are noise from prior runs.
354
+ */
355
+ export function makeReviewerWatcher(deps) {
356
+ const invoke = deps.invokeReviewerResponseAgent ?? defaultInvokeReviewerResponseAgent;
357
+ const respondedTo = new Set();
358
+ let primed = false;
359
+ return (ctx) => Effect.gen(function* () {
360
+ const pr = yield* deps.github.getPullRequest({
361
+ repoPath: ctx.cwd,
362
+ prNumber: ctx.prNumber,
363
+ });
364
+ if (pr.merged)
365
+ return { kind: "quiet" };
366
+ const comments = yield* deps.github.getPullRequestComments({
367
+ repoPath: ctx.cwd,
368
+ prNumber: ctx.prNumber,
369
+ });
370
+ // Initial-snapshot priming: on the first poll, snapshot every
371
+ // existing comment ID into the responded set so we only act on
372
+ // NEW comments posted during this shepherd loop's lifetime.
373
+ // Pre-existing comments (from prior runway runs, manual reviews,
374
+ // etc.) are noise.
375
+ if (!primed) {
376
+ for (const c of comments)
377
+ respondedTo.add(c.id);
378
+ primed = true;
379
+ return { kind: "quiet" };
380
+ }
381
+ // First unhandled comment wins. Sorted ascending by createdAt
382
+ // already (gateway contract), so this picks the oldest unseen
383
+ // one — gives human reviewers a chance to be answered in order.
384
+ const next = comments.find((c) => !respondedTo.has(c.id));
385
+ if (!next)
386
+ return { kind: "quiet" };
387
+ // Mark before processing so a poll mid-action doesn't double-fire.
388
+ respondedTo.add(next.id);
389
+ const botLogin = deps.config.prReviewerBotLogin?.trim();
390
+ const isBot = Boolean(botLogin) && next.authorLogin === botLogin;
391
+ if (isBot) {
392
+ const verdict = parsePrReviewVerdict(next.body);
393
+ if (verdict.kind === "approved" || verdict.kind === "none") {
394
+ // APPROVED is a positive signal — the shepherd keeps
395
+ // watching CI/mergeability but doesn't fire. NONE is the
396
+ // bot posting a status update without a verdict; same
397
+ // treatment.
398
+ return { kind: "quiet" };
399
+ }
400
+ if (verdict.kind === "needs-discussion") {
401
+ return {
402
+ kind: "hitl",
403
+ reason: `Reviewer flagged needs-discussion: ${verdict.decision || "(no detail provided)"}`,
404
+ };
405
+ }
406
+ // CHANGES-REQUESTED → fix-request prompt path.
407
+ return buildFixSignal(ctx, deps, invoke, {
408
+ kind: "fix-request",
409
+ fix: verdict.fix,
410
+ }, next);
411
+ }
412
+ // Human (non-bot) comment path. Pass the body verbatim — the
413
+ // agent decides whether it's actionable.
414
+ return buildFixSignal(ctx, deps, invoke, {
415
+ kind: "verbatim",
416
+ commentAuthor: next.authorLogin,
417
+ commentSource: next.source,
418
+ commentBody: next.body,
419
+ }, next);
420
+ });
421
+ }
422
+ function buildFixSignal(ctx, deps, invoke, mode, comment) {
423
+ const summary = mode.kind === "fix-request"
424
+ ? `CHANGES-REQUESTED — ${truncate(mode.fix, 60)}`
425
+ : `${comment.authorLogin || "unknown"} (${comment.source})`;
426
+ return {
427
+ kind: "fix",
428
+ description: `Respond to PR comment: ${summary}`,
429
+ action: Effect.gen(function* () {
430
+ const result = yield* invoke({
431
+ issue: ctx.issue,
432
+ branch: ctx.branch,
433
+ cwd: ctx.cwd,
434
+ config: deps.config,
435
+ mode,
436
+ });
437
+ if (result.kind === "blocked") {
438
+ return {
439
+ kind: "hitl",
440
+ reason: mode.kind === "fix-request"
441
+ ? `Reviewer CHANGES-REQUESTED BLOCKED: ${result.reason}`
442
+ : `Reviewer comment BLOCKED (${comment.authorLogin || "unknown"}): ${result.reason}`,
443
+ };
444
+ }
445
+ return {
446
+ kind: "fixed",
447
+ detail: mode.kind === "fix-request"
448
+ ? `Addressed reviewer fix-request`
449
+ : `Responded to ${comment.authorLogin || "reviewer"} comment`,
450
+ };
451
+ }),
452
+ };
453
+ }
454
+ function truncate(s, n) {
455
+ const flat = s.replace(/\s+/g, " ").trim();
456
+ if (flat.length <= n)
457
+ return flat;
458
+ return `${flat.slice(0, n - 1)}…`;
459
+ }
460
+ function extractConflictFilesFromReason(reason) {
461
+ // Format we instruct the agent to produce: "Mergeability: conflict
462
+ // in <file1>, <file2> required human resolution". Anything else is
463
+ // parsed loosely — we accept comma-separated lists after "in" /
464
+ // "conflicts in" / "files".
465
+ // File paths legitimately contain `.` (extensions), so the inner
466
+ // character class only excludes `;` and newlines. The terminator is
467
+ // either " required <word>" or end-of-string.
468
+ const m = reason.match(/(?:conflict|conflicts|files)\s+(?:in\s+)?([^;\n]+?)(?:\s+required\s|\s*$)/i);
469
+ if (!m)
470
+ return [];
471
+ return m[1]
472
+ .split(",")
473
+ .map((s) => s.trim())
474
+ .filter(Boolean);
475
+ }
476
+ export function makeMergeabilityWatcher(deps) {
477
+ const invoke = deps.invokeRebaseAgent ?? defaultInvokeRebaseAgent;
478
+ return (ctx) => Effect.gen(function* () {
479
+ const pr = yield* deps.github.getPullRequest({
480
+ repoPath: ctx.cwd,
481
+ prNumber: ctx.prNumber,
482
+ });
483
+ // `merged` is handled by the merge-check upstream of subscription
484
+ // polls; defensive return here keeps the watcher idempotent if
485
+ // its merge-check sibling races slow.
486
+ if (pr.merged)
487
+ return { kind: "quiet" };
488
+ // Only `dirty` and `behind` warrant action. `unknown` happens
489
+ // briefly after a push while GH computes — treat as quiet so
490
+ // the next tick re-evaluates against settled state.
491
+ if (pr.mergeableState !== "dirty" && pr.mergeableState !== "behind") {
492
+ return { kind: "quiet" };
493
+ }
494
+ // Pre-detect conflict files locally. `behind` typically returns
495
+ // an empty list (clean fast-forward); `dirty` returns the paths
496
+ // the agent will need to resolve.
497
+ const conflictFiles = yield* deps.github.getConflictFiles({
498
+ repoPath: ctx.cwd,
499
+ baseBranch: ctx.baseBranch,
500
+ branch: ctx.branch,
501
+ });
502
+ const mergeableState = pr.mergeableState;
503
+ return {
504
+ kind: "fix",
505
+ description: `Rebase ${ctx.branch} onto origin/${ctx.baseBranch} (mergeable_state=${mergeableState})`,
506
+ action: Effect.gen(function* () {
507
+ const result = yield* invoke({
508
+ issue: ctx.issue,
509
+ branch: ctx.branch,
510
+ baseBranch: ctx.baseBranch,
511
+ mergeableState,
512
+ conflictFiles,
513
+ cwd: ctx.cwd,
514
+ config: deps.config,
515
+ });
516
+ if (result.kind === "blocked") {
517
+ const files = result.conflictFiles.length > 0
518
+ ? result.conflictFiles
519
+ : conflictFiles;
520
+ const fileList = files.length > 0 ? files.join(", ") : "(unknown)";
521
+ return {
522
+ kind: "hitl",
523
+ reason: `Mergeability: conflict in ${fileList} required human resolution`,
524
+ };
525
+ }
526
+ return {
527
+ kind: "fixed",
528
+ detail: `Rebased ${ctx.branch} onto origin/${ctx.baseBranch}`,
529
+ };
530
+ }),
531
+ };
532
+ });
533
+ }
534
+ /**
535
+ * Parse the PR number from a GitHub PR URL
536
+ * (`https://github.com/<owner>/<repo>/pull/<n>`). Throws when the URL
537
+ * doesn't match — finalize only hands us GitHub PR URLs, so a parse
538
+ * failure is a precondition violation, not a runtime branch worth
539
+ * handling gracefully.
540
+ */
541
+ export function parsePrNumber(prUrl) {
542
+ const m = prUrl.match(/\/pull\/(\d+)(?:[/?#]|$)/);
543
+ if (!m) {
544
+ throw new Error(`shepherd: cannot parse PR number from ${prUrl}`);
545
+ }
546
+ return Number.parseInt(m[1], 10);
547
+ }
548
+ export const runShepherdPass = (issue, deps, branch, prUrl) => Effect.gen(function* () {
549
+ const prNumber = parsePrNumber(prUrl);
550
+ const isMerged = deps.isMerged ?? DEFAULT_NO_OP_MERGE_CHECK;
551
+ const subscriptions = deps.subscriptions ?? DEFAULT_SUBSCRIPTIONS;
552
+ const maxQuietPolls = deps.maxQuietPolls ?? DEFAULT_MAX_QUIET_POLLS;
553
+ const maxFixAttempts = deps.maxFixAttempts ?? deps.config.shepherdMaxIterations;
554
+ const maxWallSeconds = deps.maxWallSeconds ?? deps.config.shepherdMaxWallSeconds;
555
+ const pollSeconds = deps.pollIntervalSeconds ?? deps.config.shepherdPollIntervalSeconds;
556
+ const startMs = Date.now();
557
+ let fixCount = 0;
558
+ let quietStreak = 0;
559
+ let lastAction = "(none)";
560
+ let pollIndex = 0;
561
+ // VA-465: emit the start marker with structured attributes the
562
+ // dashboard's `extractShepherdMarkers` reads. The body is the
563
+ // routing key; attributes carry context.
564
+ yield* Effect.logInfo("shepherd.start").pipe(Effect.annotateLogs({
565
+ pr: String(prNumber),
566
+ branch,
567
+ issue: issue.identifier,
568
+ maxFixAttempts: String(maxFixAttempts),
569
+ maxWallSeconds: String(maxWallSeconds),
570
+ }));
571
+ while (true) {
572
+ pollIndex++;
573
+ // VA-464: wall-clock budget — checked at the top of every poll
574
+ // so a stuck subscription action that takes minutes can still
575
+ // be capped by it. Fires before merge / subscription work to
576
+ // avoid spending one more API call on an already-doomed loop.
577
+ const elapsedMs = Date.now() - startMs;
578
+ if (elapsedMs >= maxWallSeconds * 1000) {
579
+ const reason = `Shepherd wall-clock budget exhausted (${maxWallSeconds}s) — last action: ${lastAction}`;
580
+ yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
581
+ pr: String(prNumber),
582
+ outcome: "hitl",
583
+ reason,
584
+ }));
585
+ return { kind: "hitl", reason };
586
+ }
587
+ const ctx = {
588
+ issue,
589
+ branch,
590
+ prUrl,
591
+ prNumber,
592
+ cwd: deps.cwd,
593
+ baseBranch: deps.baseBranch,
594
+ config: deps.config,
595
+ github: deps.github,
596
+ iteration: pollIndex,
597
+ };
598
+ const merged = yield* isMerged(ctx);
599
+ if (merged) {
600
+ // VA-465: merged is the success terminal. Use the canonical
601
+ // `shepherd.ended` body so the projector dispatches via
602
+ // `markShepherdEnded` (outcome=merged).
603
+ yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
604
+ pr: String(prNumber),
605
+ outcome: "merged",
606
+ }));
607
+ return { kind: "merged" };
608
+ }
609
+ // First non-quiet signal wins — A/B/C are checked in declaration
610
+ // order, so callers control precedence by ordering the array.
611
+ let firedFix = false;
612
+ let subscriptionName = "(unknown)";
613
+ for (let i = 0; i < subscriptions.length; i++) {
614
+ const sub = subscriptions[i];
615
+ const signal = yield* sub(ctx);
616
+ if (signal.kind === "hitl") {
617
+ yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
618
+ pr: String(prNumber),
619
+ outcome: "hitl",
620
+ reason: signal.reason,
621
+ subscription: `subscription[${i}]`,
622
+ }));
623
+ return { kind: "hitl", reason: signal.reason };
624
+ }
625
+ if (signal.kind === "fix") {
626
+ // VA-464: each fix counts against `maxFixAttempts`. Check
627
+ // BEFORE running the action so the count and the exhaustion
628
+ // exit are deterministic. The Nth fix is the LAST one we
629
+ // run — the (N+1)th attempt becomes the budget-exhausted
630
+ // hitl.
631
+ subscriptionName = `subscription[${i}]`;
632
+ if (fixCount >= maxFixAttempts) {
633
+ const reason = `Shepherd iteration budget exhausted (${maxFixAttempts}) — last action: ${lastAction}`;
634
+ yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
635
+ pr: String(prNumber),
636
+ outcome: "hitl",
637
+ reason,
638
+ }));
639
+ return { kind: "hitl", reason };
640
+ }
641
+ fixCount++;
642
+ lastAction = `${subscriptionName} on "${signal.description}"`;
643
+ // VA-465: iteration.start marker — drives the sub-card's
644
+ // "acting" state on the dashboard.
645
+ yield* Effect.logInfo("shepherd.iteration.start").pipe(Effect.annotateLogs({
646
+ pr: String(prNumber),
647
+ subscription: subscriptionName,
648
+ iteration: String(fixCount),
649
+ maxIterations: String(maxFixAttempts),
650
+ description: signal.description,
651
+ }));
652
+ const out = yield* signal.action;
653
+ if (out.kind === "hitl") {
654
+ // Iteration-end (hitl) AND ended (hitl) both fire — the
655
+ // first refreshes the row's last_seen + description;
656
+ // the second terminates it.
657
+ yield* Effect.logInfo("shepherd.iteration.end").pipe(Effect.annotateLogs({
658
+ pr: String(prNumber),
659
+ outcome: "hitl",
660
+ detail: out.reason,
661
+ }));
662
+ yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
663
+ pr: String(prNumber),
664
+ outcome: "hitl",
665
+ reason: out.reason,
666
+ }));
667
+ return { kind: "hitl", reason: out.reason };
668
+ }
669
+ yield* Effect.logInfo("shepherd.iteration.end").pipe(Effect.annotateLogs({
670
+ pr: String(prNumber),
671
+ outcome: "fixed",
672
+ detail: out.detail,
673
+ }));
674
+ firedFix = true;
675
+ break;
676
+ }
677
+ }
678
+ if (firedFix) {
679
+ // A fix just landed — reset the quiet streak; the new commit
680
+ // may produce CI activity / mergeability changes shortly.
681
+ quietStreak = 0;
682
+ }
683
+ else {
684
+ quietStreak++;
685
+ if (quietStreak >= maxQuietPolls) {
686
+ const reason = `shepherd quiet for ${maxQuietPolls} consecutive poll${maxQuietPolls === 1 ? "" : "s"} ` +
687
+ `with no merge and no actionable signals — PR ready for human merge`;
688
+ yield* Effect.logInfo("shepherd.ended").pipe(Effect.annotateLogs({
689
+ pr: String(prNumber),
690
+ outcome: "ready",
691
+ reason,
692
+ }));
693
+ return { kind: "ready", reason };
694
+ }
695
+ }
696
+ // Sleep between polls. Cap the sleep against the wall-clock
697
+ // budget so the next poll runs at the right moment even when
698
+ // the budget is about to fire.
699
+ const remainingMs = maxWallSeconds * 1000 - (Date.now() - startMs);
700
+ if (remainingMs <= 0)
701
+ continue; // Wall-clock check at top will fire.
702
+ const sleepMs = Math.min(pollSeconds * 1000, remainingMs);
703
+ if (sleepMs > 0) {
704
+ yield* Effect.sleep(Duration.millis(sleepMs));
705
+ }
706
+ }
707
+ }).pipe(Effect.withSpan("shepherd"));