@valescoagency/runway 0.9.0 → 0.10.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.
@@ -60,7 +60,8 @@ export function renderListView(rows) {
60
60
  function renderRow(r) {
61
61
  const kind = r.outcomeKind ?? "pending";
62
62
  const outcomeCls = `outcome outcome-${escapeHtml(kind)}`;
63
- const href = `/issue/${encodeURIComponent(r.traceId)}/${encodeURIComponent(r.spanId)}`;
63
+ // VA-387: canonical detail link uses the span_id alone.
64
+ const href = `/issue-processes/${encodeURIComponent(r.spanId)}`;
64
65
  return `<tr>
65
66
  <td class="id"><a href="${escapeHtml(href)}">${escapeHtml(r.issueIdentifier)}</a></td>
66
67
  <td class="${outcomeCls}">${escapeHtml(kind)}</td>
@@ -83,6 +84,18 @@ export function renderDetailView(vm) {
83
84
  const ip = vm.issueProcess;
84
85
  const kind = ip.outcomeKind ?? "pending";
85
86
  const outcomeCls = `outcome outcome-${escapeHtml(kind)}`;
87
+ const titleLine = ip.issueTitle
88
+ ? `<div class="title">${escapeHtml(ip.issueTitle)}</div>`
89
+ : "";
90
+ const prLine = ip.prUrl
91
+ ? `<div><span class="label">PR:</span><a href="${escapeHtml(ip.prUrl)}" rel="noopener noreferrer" target="_blank">${escapeHtml(ip.prUrl)}</a></div>`
92
+ : "";
93
+ // HITL reason is only rendered when present (HITL or errored runs).
94
+ // For opened/reverted outcomes we omit the row entirely rather than
95
+ // showing an empty label.
96
+ const hitlLine = ip.hitlReason
97
+ ? `<div><span class="label">HITL reason:</span><span class="detail">${escapeHtml(ip.hitlReason)}</span></div>`
98
+ : "";
86
99
  return `<!doctype html>
87
100
  <html lang="en">
88
101
  <head>
@@ -90,6 +103,7 @@ export function renderDetailView(vm) {
90
103
  <title>${escapeHtml(ip.issueIdentifier)} · runway dashboard</title>
91
104
  <style>${SHARED_STYLE}
92
105
  .breadcrumb { color: #9ca3af; margin-bottom: 16px; font-size: 12px; }
106
+ .title { font-size: 14px; color: #d4d4d8; margin: -8px 0 12px; }
93
107
  .meta { margin: 4px 0 16px; }
94
108
  .meta .label { color: #9ca3af; margin-right: 4px; }
95
109
  .timeline { position: relative; height: 28px; background: #18181b;
@@ -113,8 +127,11 @@ export function renderDetailView(vm) {
113
127
  <body>
114
128
  <div class="breadcrumb"><a href="/">← all issue processes</a></div>
115
129
  <h1>${escapeHtml(ip.issueIdentifier)} · <span class="${outcomeCls}">${escapeHtml(kind)}</span></h1>
130
+ ${titleLine}
116
131
  <div class="meta">
117
132
  <div><span class="label">branch:</span><code>${escapeHtml(ip.branch ?? "—")}</code></div>
133
+ ${prLine}
134
+ ${hitlLine}
118
135
  <div><span class="label">detail:</span><span class="detail">${escapeHtml(ip.outcomeDetail ?? "")}</span></div>
119
136
  <div><span class="label">seen at:</span>${escapeHtml(ip.insertedAt)}</div>
120
137
  </div>
package/dist/finalize.js CHANGED
@@ -1,10 +1,23 @@
1
1
  import { Effect } from "effect";
2
+ import { rebaseOntoBase } from "./git.js";
2
3
  /**
3
- * Push the agent branch, open the PR, transition the Linear issue to
4
- * the in-review status, and link the PR back on the issue.
4
+ * VA-419: rebase the agent branch onto the latest `origin/<baseBranch>`,
5
+ * then push and open the PR. If the rebase hits a conflict, restore
6
+ * pre-rebase state and surface a `rebase-conflict` outcome — the
7
+ * orchestrator routes it to HITL so the operator can reconcile
8
+ * manually (or let VA-417 reset the branch on the next drain).
5
9
  */
6
10
  export const finalize = (issue, deps, branch) => Effect.gen(function* () {
7
11
  const { config, cwd, baseBranch, linear, github } = deps;
12
+ const rebase = yield* rebaseOntoBase(cwd, baseBranch, branch).pipe(Effect.withSpan("rebaseOntoBase"));
13
+ if (rebase.kind === "conflict") {
14
+ return {
15
+ kind: "rebase-conflict",
16
+ baseBranch,
17
+ conflictedFiles: rebase.conflictedFiles,
18
+ reviewVerdict: "REVIEW: APPROVED",
19
+ };
20
+ }
8
21
  yield* github.pushBranch(cwd, branch).pipe(Effect.withSpan("pushBranch"));
9
22
  const prBody = buildPrBody(issue);
10
23
  const prUrl = yield* github
@@ -20,6 +33,25 @@ export const finalize = (issue, deps, branch) => Effect.gen(function* () {
20
33
  yield* linear.comment(issue.id, `Runway opened a PR for review: ${prUrl}`);
21
34
  return { kind: "opened", detail: prUrl };
22
35
  });
36
+ /**
37
+ * VA-419: format the HITL message body for a rebase-conflict outcome.
38
+ * Names the base branch and the conflicted file list, and quotes the
39
+ * reviewer's APPROVED verdict so the operator knows the agent's diff
40
+ * was good before the conflict surfaced.
41
+ */
42
+ export function formatRebaseConflictReason(args) {
43
+ const fileLines = args.conflictedFiles.length
44
+ ? args.conflictedFiles.map((f) => ` - ${f}`).join("\n")
45
+ : " (no conflicted files reported by git — inspect manually)";
46
+ return [
47
+ `Upstream base \`${args.baseBranch}\` advanced during this drain; the rebase`,
48
+ "onto the latest base produced conflicts in:",
49
+ fileLines,
50
+ `Review was APPROVED before the rebase (\`${args.reviewVerdict}\`); the agent's diff`,
51
+ "is good but needs operator reconciliation against the new base. Re-run runway",
52
+ "after rebasing manually, or let VA-417 handle it on the next drain.",
53
+ ].join("\n");
54
+ }
23
55
  // VA-412: `Closes` (not `Refs`) is the Linear GitHub-integration magic
24
56
  // word that auto-transitions the issue to Done on PR merge. `Refs`
25
57
  // only attaches the PR to the issue and leaves it stuck In Progress.
package/dist/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Data, Effect } from "effect";
1
+ import { Data, Effect, Option } from "effect";
2
2
  import { runExecaScoped } from "./subprocess.js";
3
3
  /**
4
4
  * VA-358: thin typed error for `detectBaseBranch`. We don't model
@@ -95,23 +95,82 @@ export const hasCommits = (repoPath, base, branch) => runExecaScoped("git", ["re
95
95
  return Number.parseInt(out.trim(), 10) > 0;
96
96
  }));
97
97
  /**
98
- * VA-366: prune an agent/<issue> branch from a prior failed attempt
99
- * so the next worktree creation forks from current `base` HEAD
100
- * instead of reusing a stale tip.
98
+ * VA-419: fetch the latest `origin/<baseBranch>` and rebase the agent
99
+ * branch onto it. Closes the upstream-conflict-after-approval gap
100
+ * without this step, a PR opened off `agent/<id>` after another PR
101
+ * merged to base mid-drain can land in `CONFLICTING` state on
102
+ * creation.
103
+ *
104
+ * Layout: this runs in the host repo (`repoPath`), not in a sandcastle
105
+ * worktree. The agent branch is a local ref; `git rebase <upstream>
106
+ * <branch>` checks it out before replaying commits. Host cwd HEAD is
107
+ * left wherever the rebase put it (typically on the agent branch on
108
+ * success, or restored by `--abort` on conflict) — finalize's push
109
+ * only cares about the ref, not the working-tree HEAD.
110
+ *
111
+ * The fetch step may fail (network blip, gh auth expired). We let
112
+ * that error bubble — finalize routes it through the existing
113
+ * processIssue failure path. A non-zero rebase exit is treated as
114
+ * "conflict" regardless of cause, since the safe response is the
115
+ * same: abort and route to HITL.
116
+ */
117
+ export const rebaseOntoBase = (repoPath, baseBranch, branch) => Effect.gen(function* () {
118
+ yield* runExecaScoped("git", ["fetch", "origin", baseBranch], { cwd: repoPath }, (err) => ({
119
+ message: err instanceof Error ? err.message : String(err),
120
+ }));
121
+ const rebase = yield* runExecaScoped("git", ["rebase", `origin/${baseBranch}`, branch], { cwd: repoPath, reject: false }, (err) => ({
122
+ message: err instanceof Error ? err.message : String(err),
123
+ }));
124
+ if (rebase.exitCode === 0) {
125
+ return { kind: "ok" };
126
+ }
127
+ // Collect the unmerged file list before aborting — `--diff-filter=U`
128
+ // matches only files git left in conflict state.
129
+ const conflictedFiles = yield* runExecaScoped("git", ["diff", "--name-only", "--diff-filter=U"], { cwd: repoPath, reject: false }, (err) => ({
130
+ message: err instanceof Error ? err.message : String(err),
131
+ })).pipe(Effect.map((res) => {
132
+ const raw = res.stdout;
133
+ const out = typeof raw === "string" ? raw : "";
134
+ return out
135
+ .split("\n")
136
+ .map((s) => s.trim())
137
+ .filter((s) => s.length > 0);
138
+ }), Effect.catchAll(() => Effect.succeed([])));
139
+ yield* runExecaScoped("git", ["rebase", "--abort"], { cwd: repoPath, reject: false }, (err) => ({
140
+ message: err instanceof Error ? err.message : String(err),
141
+ })).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
142
+ return { kind: "conflict", conflictedFiles };
143
+ });
144
+ /**
145
+ * VA-417: typed failure when neither `origin/<base>` nor local `<base>`
146
+ * resolves to a SHA in the local refs database. The orchestrator dies
147
+ * the drain on this — the alternative (silently skip the reset) would
148
+ * re-trigger the no-op-on-retry pattern this fix exists to close.
149
+ */
150
+ export class AgentBranchResetTargetUnreachable extends Data.TaggedError("AgentBranchResetTargetUnreachable") {
151
+ }
152
+ /**
153
+ * VA-366 + VA-417: ensure an `agent/<issue>` branch starts from a
154
+ * known-good base before sandcastle's worktree creation reuses it.
101
155
  *
102
156
  * Logic:
103
- * 1. If the branch doesn't exist → no-op.
104
- * 2. If it has commits ahead of `base` (`hasCommits` returns true)
105
- * preserve. Agent did real work; resetting would discard it.
106
- * 3. Otherwise (branch present, no commits ahead) → `git branch -D`.
107
- * `-D` (not `-d`) because the branch tip equals or trails base
108
- * after the failed attempt, and `-d`'s merge check can refuse
109
- * otherwise-equivalent tips.
157
+ * 1. Branch doesn't exist → no-op (first-attempt case).
158
+ * 2. Branch exists, zero commits ahead of `base` VA-366: delete
159
+ * so the next worktree creation forks from current base HEAD
160
+ * instead of an old base tip.
161
+ * 3. Branch exists with commits ahead of `base` VA-417: those
162
+ * commits come from a prior attempt that review rejected
163
+ * (`fetchReady` filters out open PRs / non-Todo states, so any
164
+ * surviving commits here are from a HITL'd retry). Detach any
165
+ * worktree still attached to the branch, then reset the ref to
166
+ * `origin/<base>` (or local `<base>` as fallback) so the impl
167
+ * agent doesn't treat the rejected diff as authoritative.
110
168
  *
111
- * Best-effort: this is a *retry hygiene* operation, not a correctness
112
- * guarantee. If git fails, we log and continue — sandcastle's
113
- * worktree creation will still work; the agent just sees the stale
114
- * branch state that motivated this fix.
169
+ * Steps 1 + 2 stay best-effort git failures inside them log and
170
+ * continue (the existing VA-366 contract). Step 3's reset-target
171
+ * resolution surfaces a typed `AgentBranchResetTargetUnreachable`;
172
+ * the orchestrator dies on it so the operator fixes the env rather
173
+ * than re-trigger the no-op pattern.
115
174
  */
116
175
  export const pruneStaleAgentBranch = (repoPath, base, branch) => Effect.gen(function* () {
117
176
  // 1. Existence check.
@@ -120,14 +179,103 @@ export const pruneStaleAgentBranch = (repoPath, base, branch) => Effect.gen(func
120
179
  })).pipe(Effect.map((res) => res.exitCode === 0), Effect.catchAll(() => Effect.succeed(false)));
121
180
  if (!exists)
122
181
  return;
123
- // 2. Preserve if the branch carries real work.
124
- const hasWork = yield* hasCommits(repoPath, base, branch).pipe(
125
- // If we can't tell, prefer the safe option — keep the branch.
126
- Effect.catchAll(() => Effect.succeed(true)));
127
- if (hasWork)
182
+ // 2. Ahead-count. `Effect.option` distinguishes "definitively zero
183
+ // / non-zero" from "couldn't determine"; in the latter case we
184
+ // bail to avoid acting destructively on uncertain state.
185
+ const hasWorkOpt = yield* hasCommits(repoPath, base, branch).pipe(Effect.option);
186
+ if (Option.isNone(hasWorkOpt))
187
+ return;
188
+ if (!hasWorkOpt.value) {
189
+ // VA-366: branch is at-or-behind base — delete so sandcastle's
190
+ // next worktree creation forks from current base HEAD.
191
+ yield* runExecaScoped("git", ["branch", "-D", branch], { cwd: repoPath, reject: false }, (err) => ({
192
+ message: err instanceof Error ? err.message : String(err),
193
+ })).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
194
+ return;
195
+ }
196
+ // 3. VA-417: commits ahead of base come from a HITL-rejected
197
+ // prior attempt. Resolve the reset target first so we fail loudly
198
+ // (typed) before touching any worktree state.
199
+ const target = yield* resolveResetTarget(repoPath, base);
200
+ // 3a. Detach any worktree still attached to this branch — its
201
+ // working tree would otherwise diverge from the new ref and
202
+ // sandcastle would reuse it dirty (ADR 0003 reuse-with-warning).
203
+ yield* removeAttachedWorktree(repoPath, branch);
204
+ // 3b. Move the ref. `update-ref` (rather than `branch -f`) works
205
+ // regardless of whether step 3a actually succeeded in detaching
206
+ // every worktree; sandcastle's collision check still has to pass
207
+ // before any agent code runs.
208
+ yield* runExecaScoped("git", ["update-ref", `refs/heads/${branch}`, target], { cwd: repoPath, reject: false }, (err) => ({
209
+ message: err instanceof Error ? err.message : String(err),
210
+ })).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
211
+ });
212
+ /**
213
+ * VA-417: resolve the SHA we'll point the agent branch at. Try
214
+ * `origin/<base>` first (canonical source for HITL-retry resets);
215
+ * fall back to local `<base>` so a fresh / offline clone with no
216
+ * configured remote can still recover. Surface a typed error if
217
+ * neither resolves — silent skipping would re-trigger the no-op
218
+ * pattern.
219
+ */
220
+ const resolveResetTarget = (repoPath, base) => Effect.gen(function* () {
221
+ const originRef = `refs/remotes/origin/${base}`;
222
+ const originReachable = yield* runExecaScoped("git", ["rev-parse", "--verify", originRef], { cwd: repoPath, reject: false }, (err) => ({
223
+ message: err instanceof Error ? err.message : String(err),
224
+ })).pipe(Effect.map((res) => res.exitCode === 0), Effect.catchAll(() => Effect.succeed(false)));
225
+ if (originReachable)
226
+ return originRef;
227
+ const localRef = `refs/heads/${base}`;
228
+ const localReachable = yield* runExecaScoped("git", ["rev-parse", "--verify", localRef], { cwd: repoPath, reject: false }, (err) => ({
229
+ message: err instanceof Error ? err.message : String(err),
230
+ })).pipe(Effect.map((res) => res.exitCode === 0), Effect.catchAll(() => Effect.succeed(false)));
231
+ if (localReachable)
232
+ return localRef;
233
+ return yield* Effect.fail(new AgentBranchResetTargetUnreachable({
234
+ base,
235
+ message: `Cannot reset stale agent branch: neither ${originRef} nor ` +
236
+ `${localRef} resolves in ${repoPath}. Run ` +
237
+ `\`git fetch origin ${base}\` (or set RUNWAY_BASE_BRANCH to ` +
238
+ `a branch that exists locally) and re-run.`,
239
+ }));
240
+ });
241
+ /**
242
+ * VA-417: if a worktree is checked out on `branch`, remove it so the
243
+ * branch ref can move freely. Best-effort — sandcastle's `pruneStale`
244
+ * runs later and will collide on a stale managed worktree, so the
245
+ * worst case here is "we tried, git refused, sandcastle warns".
246
+ */
247
+ const removeAttachedWorktree = (repoPath, branch) => Effect.gen(function* () {
248
+ const list = yield* runExecaScoped("git", ["worktree", "list", "--porcelain"], { cwd: repoPath, reject: false }, (err) => ({
249
+ message: err instanceof Error ? err.message : String(err),
250
+ })).pipe(Effect.map((res) => (typeof res.stdout === "string" ? res.stdout : "")), Effect.catchAll(() => Effect.succeed("")));
251
+ const path = parseWorktreePathForBranch(list, branch);
252
+ if (!path)
128
253
  return;
129
- // 3. Stale delete so the next worktree creation forks from base HEAD.
130
- yield* runExecaScoped("git", ["branch", "-D", branch], { cwd: repoPath, reject: false }, (err) => ({
254
+ yield* runExecaScoped("git", ["worktree", "remove", "--force", path], { cwd: repoPath, reject: false }, (err) => ({
131
255
  message: err instanceof Error ? err.message : String(err),
132
256
  })).pipe(Effect.catchAll(() => Effect.succeed(undefined)));
133
257
  });
258
+ /**
259
+ * Parse `git worktree list --porcelain` output. Each entry is a
260
+ * `worktree <path>` line followed by metadata (`HEAD`, `branch
261
+ * refs/heads/<name>`, …) terminated by a blank line. Return the path
262
+ * of the first entry whose branch matches `branch`, or null.
263
+ */
264
+ function parseWorktreePathForBranch(porcelain, branch) {
265
+ const refPrefix = "branch refs/heads/";
266
+ let currentPath = null;
267
+ for (const line of porcelain.split("\n")) {
268
+ if (line.startsWith("worktree ")) {
269
+ currentPath = line.slice("worktree ".length).trim();
270
+ }
271
+ else if (line.startsWith(refPrefix)) {
272
+ const name = line.slice(refPrefix.length).trim();
273
+ if (name === branch && currentPath)
274
+ return currentPath;
275
+ }
276
+ else if (line.trim() === "") {
277
+ currentPath = null;
278
+ }
279
+ }
280
+ return null;
281
+ }
package/dist/implement.js CHANGED
@@ -45,6 +45,7 @@ export function parseImplVerdict(result) {
45
45
  */
46
46
  export const runImplementLoop = (issue, deps, branch) => Effect.gen(function* () {
47
47
  const { config, cwd, baseBranch, policy, priorFeedback } = deps;
48
+ const inRunReviewerFeedback = deps.inRunReviewerFeedback ?? "";
48
49
  const maxIters = Math.max(1, config.maxIterations);
49
50
  let prevSummary = "";
50
51
  let implementResult;
@@ -60,6 +61,11 @@ export const runImplementLoop = (issue, deps, branch) => Effect.gen(function* ()
60
61
  // and iteration N+1, since the implementer keeps drifting
61
62
  // toward the same code paths until corrected.
62
63
  priorReviewFeedback: priorFeedback,
64
+ // VA-418: in-run rejection feedback (empty on first impl
65
+ // pass, populated on a review-rejection retry). Independent
66
+ // slot from `priorReviewFeedback` so the agent reads it as
67
+ // the higher-trust signal.
68
+ inRunReviewerFeedback,
63
69
  // VA-417: pass the actual base branch so the prompt
64
70
  // renders the real name (`main`, `master`, etc.) rather
65
71
  // than a hardcoded default.
package/dist/linear.js CHANGED
@@ -10,6 +10,12 @@ const IssueNodeSchema = Schema.Struct({
10
10
  identifier: Schema.String,
11
11
  title: Schema.String,
12
12
  description: Schema.NullOr(Schema.String),
13
+ // VA-420: drain order is `priority ASC (Urgent=1 → Low=4, No-priority=0
14
+ // sorted last), then createdAt ASC`. The sort reads this off the raw
15
+ // SDK node; the schema entry narrows the field at the seam so a Linear
16
+ // API drift surfaces as `LinearSchemaError` instead of a silent NaN
17
+ // comparison.
18
+ priority: Schema.Number,
13
19
  });
14
20
  const WorkflowStateNodeSchema = Schema.Struct({
15
21
  id: Schema.String,
@@ -82,14 +88,14 @@ const isTerminalStateType = (type) => TERMINAL_STATE_TYPES.has(type);
82
88
  * the SDK's chained `labels()` call rather than asking for labels
83
89
  * inline at the candidate-fetch site so the schema for `IssueNode`
84
90
  * stays narrow.
91
+ *
92
+ * VA-387: pulls all label names so callers that need the full list
93
+ * (the dashboard's `runway.issue.labels` attribute) don't have to
94
+ * re-issue the SDK call.
85
95
  */
86
- async function hasHitlLabel(issue, hitlLabel) {
96
+ async function fetchIssueLabelNames(issue) {
87
97
  const labels = await issue.labels();
88
- for (const raw of labels.nodes) {
89
- if (decodeIssueLabelNode(raw).name === hitlLabel)
90
- return true;
91
- }
92
- return false;
98
+ return labels.nodes.map((raw) => decodeIssueLabelNode(raw).name);
93
99
  }
94
100
  /**
95
101
  * VA-394: returns true when `issue` has at least one `inverseRelations`
@@ -300,9 +306,27 @@ export function createLinearGateway(config, limiter = null) {
300
306
  state: { id: { eq: readyStateId } },
301
307
  ...(projectId ? { project: { id: { eq: projectId } } } : {}),
302
308
  },
303
- // Stable order: oldest first so the queue drains FIFO.
309
+ // VA-420: order doesn't matter at the SDK call — Linear's
310
+ // `IssueOrderBy` enum doesn't expose `priority`, and the
311
+ // SDK type for this argument hides the ASC/DESC direction
312
+ // anyway. We re-sort the candidate set in JS below.
304
313
  orderBy: "createdAt",
305
314
  });
315
+ // VA-420: drain by Linear priority first, then by createdAt
316
+ // within a priority bucket. Linear's priority encoding is
317
+ // non-monotonic — `1 = Urgent, 2 = High, 3 = Medium,
318
+ // 4 = Low, 0 = No priority` — so `0` is remapped to
319
+ // `Infinity` to sit behind every prioritised bucket
320
+ // (operator intuition: Urgent first, unprioritised last).
321
+ // Within a bucket, oldest-first restores the FIFO intent.
322
+ const candidates = [...issues.nodes].sort((a, b) => {
323
+ const pa = a.priority === 0 ? Infinity : a.priority;
324
+ const pb = b.priority === 0 ? Infinity : b.priority;
325
+ if (pa !== pb)
326
+ return pa - pb;
327
+ return (new Date(a.createdAt).getTime() -
328
+ new Date(b.createdAt).getTime());
329
+ });
306
330
  // VA-360: validate every issue node through the schema —
307
331
  // a single drifted issue surfaces as `LinearSchemaError`
308
332
  // instead of a downstream `cannot read property X`.
@@ -316,9 +340,10 @@ export function createLinearGateway(config, limiter = null) {
316
340
  // candidate go through the rate limiter and the retry
317
341
  // policy alongside everything else.
318
342
  const eligible = [];
319
- for (const raw of issues.nodes) {
343
+ for (const raw of candidates) {
320
344
  const i = decodeIssueNode(raw);
321
- if (await hasHitlLabel(raw, config.hitlLabel))
345
+ const labels = await fetchIssueLabelNames(raw);
346
+ if (labels.includes(config.hitlLabel))
322
347
  continue;
323
348
  if (await hasActiveBlocker(raw))
324
349
  continue;
@@ -327,6 +352,7 @@ export function createLinearGateway(config, limiter = null) {
327
352
  identifier: i.identifier,
328
353
  title: i.title,
329
354
  description: i.description ?? "",
355
+ labels,
330
356
  });
331
357
  }
332
358
  return eligible;
@@ -5,9 +5,9 @@ import { detectBaseBranch, pruneStaleAgentBranch } from "./git.js";
5
5
  import { loadPolicy } from "./policy.js";
6
6
  import { flagHitl, handleProcessFailure } from "./hitl.js";
7
7
  import { runImplementLoop } from "./implement.js";
8
- import { formatPriorFeedback } from "./prompts.js";
8
+ import { formatInRunReviewerFeedback, formatPriorFeedback, } from "./prompts.js";
9
9
  import { runReviewPass } from "./review.js";
10
- import { finalize } from "./finalize.js";
10
+ import { finalize, formatRebaseConflictReason } from "./finalize.js";
11
11
  // Re-exports so existing callers (commands/run.ts) and tests
12
12
  // (orchestrator.test.ts) keep working without import churn. The
13
13
  // verdict parsers and the ADT now live alongside the phase code
@@ -104,6 +104,12 @@ export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
104
104
  // the processIssue span's attributes. Compute it inside the
105
105
  // span scope (so `annotateCurrentSpan` targets `processIssue`,
106
106
  // not `drainQueue`) and route afterwards.
107
+ //
108
+ // VA-387: annotate `runway.pr.url` on opened outcomes and
109
+ // `runway.hitl.reason` on HITL outcomes (both planned and
110
+ // failure-routed) so the detail pane can render the GitHub
111
+ // link / human-review reason without re-parsing the generic
112
+ // `runway.outcome.detail` string.
107
113
  const resolved = yield* Effect.gen(function* () {
108
114
  const processed = yield* processIssue(issue, runDeps).pipe(Effect.either);
109
115
  if (processed._tag === "Right") {
@@ -111,6 +117,9 @@ export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
111
117
  yield* Effect.annotateCurrentSpan({
112
118
  "runway.outcome.kind": result.kind,
113
119
  "runway.outcome.detail": result.detail,
120
+ ...(result.kind === "opened"
121
+ ? { "runway.pr.url": result.detail }
122
+ : { "runway.hitl.reason": result.detail }),
114
123
  });
115
124
  return { errored: false, outcome: result };
116
125
  }
@@ -123,12 +132,22 @@ export const drainQueue = (deps, opts = {}) => Effect.gen(function* () {
123
132
  // from "crashed and routed to HITL" (kind=hitl,
124
133
  // errored=true) on the dashboard row.
125
134
  "runway.outcome.errored": true,
135
+ // VA-387: only "errored" failure outcomes land on HITL.
136
+ // "reverted" outcomes go back to Todo and never reach a
137
+ // human, so `runway.hitl.reason` would be misleading.
138
+ ...(failureOutcome.kind === "errored"
139
+ ? { "runway.hitl.reason": failureOutcome.detail }
140
+ : {}),
126
141
  });
127
142
  return { errored: true, outcome: failureOutcome };
128
143
  }).pipe(Effect.withSpan("processIssue", {
129
144
  attributes: {
130
145
  "runway.issue.identifier": issue.identifier,
131
146
  "runway.issue.id": issue.id,
147
+ "runway.issue.title": issue.title,
148
+ // OTel encodes string[] as arrayValue on the wire. The
149
+ // projector decodes it back via `attrMap`.
150
+ "runway.issue.labels": [...issue.labels],
132
151
  "runway.branch": branch,
133
152
  },
134
153
  }), Effect.annotateLogs({
@@ -187,23 +206,85 @@ const processIssue = (issue, deps) => Effect.gen(function* () {
187
206
  : linear.fetchComments(issue.id).pipe(Effect.map((cs) => formatPriorFeedback(cs, commentAuthorAllowlist)), Effect.catchAll((err) => Effect.logWarning(`${issue.identifier}: fetchComments failed; proceeding with description-only context (${err.message ?? String(err)})`).pipe(Effect.as("")))));
188
207
  yield* linear.transition(issue.id, config.inProgressStatus);
189
208
  yield* linear.comment(issue.id, `Runway picked up this issue. Branch: \`${branch}\`.`);
190
- // VA-366: if a prior attempt left an `agent/<id>` branch behind
191
- // with zero commits ahead of base (typical of a startup-failure
192
- // retry), delete it so sandcastle's worktree creation forks from
193
- // current `baseBranch` HEAD. Preserves the branch when it carries
194
- // real work. Best-effort failures here don't abort the issue.
195
- yield* pruneStaleAgentBranch(deps.cwd, deps.baseBranch, branch).pipe(Effect.catchAll((err) => Effect.logWarning(`${issue.identifier}: pruneStaleAgentBranch failed (continuing): ${err.message}`)));
196
- const impl = yield* runImplementLoop(issue, { ...deps, priorFeedback }, branch);
197
- if (impl.kind === "hitl") {
198
- yield* flagHitl(issue, deps, impl.reason);
199
- return { kind: "hitl", detail: impl.reason };
200
- }
201
- const review = yield* runReviewPass(issue, deps, branch);
202
- if (review.kind === "hitl") {
203
- yield* flagHitl(issue, deps, review.reason);
204
- return { kind: "hitl", detail: review.reason };
209
+ // VA-366 + VA-417: keep the agent branch from carrying state
210
+ // forward across drains.
211
+ // - VA-366: if a prior attempt left `agent/<id>` behind with no
212
+ // commits (typical of a startup-failure retry), delete it.
213
+ // - VA-417: if `agent/<id>` carries commits from a prior attempt
214
+ // that review rejected, reset it to `origin/<baseBranch>` so
215
+ // the impl agent doesn't observe the rejected diff and signal
216
+ // `IMPL: DONE` without modification.
217
+ // The function fails ONLY with `AgentBranchResetTargetUnreachable`
218
+ // (origin/<base> AND local <base> both missing — env/config
219
+ // issue). `Effect.orDie` kills the drain so the operator fixes
220
+ // the env rather than re-trigger the no-op pattern; other git
221
+ // failures inside the function are already swallowed there.
222
+ yield* pruneStaleAgentBranch(deps.cwd, deps.baseBranch, branch).pipe(Effect.orDie);
223
+ // VA-418: in-run review-rejection retry loop. Each iteration runs
224
+ // impl, then review; on `rejected-retry` (and remaining budget),
225
+ // we append the rejection reason to `inRunRejections`, re-render
226
+ // the impl prompt with the full chain in the
227
+ // `{{IN_RUN_REVIEWER_FEEDBACK}}` slot, and try again. `attempt`
228
+ // counts the impl+review pairs we've launched; the budget is
229
+ // `1 + config.reviewRetries`, matching VA-414's pattern (default
230
+ // 1 means up to 2 total pairs per drain pickup). `0` disables the
231
+ // loop entirely: every rejection escalates immediately.
232
+ const retryBudget = Math.max(0, config.reviewRetries);
233
+ const inRunRejections = [];
234
+ let attempt = 0;
235
+ while (true) {
236
+ attempt += 1;
237
+ const impl = yield* runImplementLoop(issue, {
238
+ ...deps,
239
+ priorFeedback,
240
+ inRunReviewerFeedback: formatInRunReviewerFeedback(inRunRejections),
241
+ }, branch);
242
+ if (impl.kind === "hitl") {
243
+ yield* flagHitl(issue, deps, impl.reason);
244
+ return { kind: "hitl", detail: impl.reason };
245
+ }
246
+ const review = yield* runReviewPass(issue, deps, branch);
247
+ if (review.kind === "approved") {
248
+ // VA-419: finalize may report `rebase-conflict` when the base
249
+ // branch advanced during this drain and the post-review rebase
250
+ // hit a conflict. Route to HITL with the conflicted-file list
251
+ // — reviewer's APPROVED verdict is preserved in the message so
252
+ // the operator knows the diff was good before the conflict
253
+ // surfaced.
254
+ const finalized = yield* finalize(issue, deps, branch);
255
+ if (finalized.kind === "rebase-conflict") {
256
+ const reason = formatRebaseConflictReason(finalized);
257
+ yield* flagHitl(issue, deps, reason);
258
+ return { kind: "hitl", detail: reason };
259
+ }
260
+ return finalized;
261
+ }
262
+ if (review.kind === "hitl") {
263
+ // Crashed / truncated marker — distinct from a real rejection.
264
+ yield* flagHitl(issue, deps, review.reason);
265
+ return { kind: "hitl", detail: review.reason };
266
+ }
267
+ if (review.kind === "rejected-hitl") {
268
+ // Reviewer judged the rejection to need human input. Escalate
269
+ // immediately — no more impl attempts this drain.
270
+ const reason = `Sub-agent review rejected (needs human judgment): ${review.reason}`;
271
+ yield* flagHitl(issue, deps, reason);
272
+ return { kind: "hitl", detail: reason };
273
+ }
274
+ // rejected-retry: mechanically fixable. Hand the reason back to
275
+ // the impl agent on the next attempt, unless the retry budget is
276
+ // exhausted.
277
+ inRunRejections.push(review.reason);
278
+ if (attempt > retryBudget) {
279
+ // Budget exhausted. Escalate to HITL with a message that
280
+ // names the FINAL rejection reason plus the chain length so
281
+ // the operator can see how many attempts we burned through.
282
+ const reason = `Ran out of review retries (${attempt} attempt${attempt === 1 ? "" : "s"}); last rejection: ${review.reason}`;
283
+ yield* flagHitl(issue, deps, reason);
284
+ return { kind: "hitl", detail: reason };
285
+ }
286
+ yield* Effect.logInfo(`${issue.identifier}: review rejected-retry; retrying impl with reviewer feedback (attempt ${attempt + 1} of ${retryBudget + 1})`);
205
287
  }
206
- return yield* finalize(issue, deps, branch);
207
288
  });
208
289
  /**
209
290
  * VA-355: render a per-issue verdict trail at the end of the drain so
package/dist/prompts.js CHANGED
@@ -55,6 +55,12 @@ function implementVars(args) {
55
55
  ISSUE_DESCRIPTION: args.issue.description || "(no description)",
56
56
  PREVIOUS_ITERATIONS: args.previousIterations,
57
57
  PRIOR_REVIEW_FEEDBACK: args.priorReviewFeedback,
58
+ // VA-418: empty string when the orchestrator has no in-run
59
+ // rejection to surface (the common case — first attempt, or a
60
+ // run with retries disabled). The `{{IN_RUN_REVIEWER_FEEDBACK}}`
61
+ // slot expands to empty and the section header disappears from
62
+ // the rendered prompt.
63
+ IN_RUN_REVIEWER_FEEDBACK: args.inRunReviewerFeedback ?? "",
58
64
  POLICY_FORBIDDEN_BULLET: renderForbiddenPathsBullet(args.policy),
59
65
  // VA-417: fall back to "main" if the caller didn't pass one, so
60
66
  // older test fixtures keep working. Production always passes the
@@ -132,6 +138,40 @@ export function formatPriorFeedback(comments, allowlist) {
132
138
  "",
133
139
  ].join("\n");
134
140
  }
141
+ /**
142
+ * VA-418: format the reviewer's in-run rejection text into the block
143
+ * the implement prompt renders into `{{IN_RUN_REVIEWER_FEEDBACK}}`.
144
+ * Higher-trust framing than VA-383's cross-drain feedback — this is
145
+ * the reviewer's verdict on the diff the agent JUST produced in this
146
+ * drain, not stale guidance from a prior runway invocation.
147
+ *
148
+ * `rejections` is the chronological list of rejection reasons from
149
+ * every prior review pass in the current drain. The most recent
150
+ * rejection is the load-bearing one; earlier rejections are included
151
+ * so the agent can see what it has already tried + been told to fix.
152
+ *
153
+ * Returns the empty string when there are no rejections to surface —
154
+ * the slot expands to empty and the section disappears from the
155
+ * rendered prompt.
156
+ */
157
+ export function formatInRunReviewerFeedback(rejections) {
158
+ if (rejections.length === 0)
159
+ return "";
160
+ const numbered = rejections.map((r, i) => `${i + 1}. ${r.trim()}`);
161
+ return [
162
+ "# Reviewer feedback from this run",
163
+ "",
164
+ "The previous iteration of this drain produced commits that review",
165
+ "rejected. Every blocker below is from review of YOUR work this run —",
166
+ "the reviewer judged each fix to be mechanical (no human judgment",
167
+ "required). Address every blocker before signaling `IMPL: DONE` again.",
168
+ "",
169
+ "Rejections so far this drain (most recent last):",
170
+ "",
171
+ numbered.join("\n"),
172
+ "",
173
+ ].join("\n");
174
+ }
135
175
  /**
136
176
  * VA-352 (absorbed from policy.ts in VA-361): render the bullet
137
177
  * sentence the impl prompt shows the agent. Stable formatting so a