@valescoagency/runway 0.9.0 → 0.10.1

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.
@@ -21,9 +21,13 @@ const SCHEMA = `
21
21
  parent_span_id TEXT,
22
22
  issue_identifier TEXT NOT NULL,
23
23
  issue_id TEXT,
24
+ issue_title TEXT,
25
+ issue_labels TEXT,
24
26
  branch TEXT,
25
27
  outcome_kind TEXT,
26
28
  outcome_detail TEXT,
29
+ pr_url TEXT,
30
+ hitl_reason TEXT,
27
31
  start_time_unix_nano TEXT NOT NULL,
28
32
  end_time_unix_nano TEXT NOT NULL,
29
33
  status_code INTEGER,
@@ -38,6 +42,9 @@ const SCHEMA = `
38
42
  CREATE INDEX IF NOT EXISTS idx_issue_processes_trace_id
39
43
  ON issue_processes(trace_id);
40
44
 
45
+ CREATE INDEX IF NOT EXISTS idx_issue_processes_span_id
46
+ ON issue_processes(span_id);
47
+
41
48
  CREATE TABLE IF NOT EXISTS raw_spans (
42
49
  trace_id TEXT NOT NULL,
43
50
  span_id TEXT NOT NULL,
@@ -63,6 +70,128 @@ const SCHEMA = `
63
70
  CREATE INDEX IF NOT EXISTS idx_agent_iterations_issue_process
64
71
  ON agent_iterations(trace_id, issue_process_id, iteration_index);
65
72
  `;
73
+ const DEFAULT_AGGREGATE_WINDOW = 30;
74
+ /**
75
+ * VA-399: SQL VIEW that computes the evaluator-facing aggregates over
76
+ * the last N drains. N is interpolated at view-creation time because
77
+ * SQLite views can't take parameters — when the dashboard process
78
+ * starts with a different `DASHBOARD_AGGREGATE_WINDOW`, the view is
79
+ * dropped and recreated with the new LIMIT.
80
+ *
81
+ * Median uses the "average of the two middle values when N is even,
82
+ * the middle value when N is odd" convention; p95 uses the
83
+ * nearest-rank method (smallest observed value whose rank meets-or-
84
+ * exceeds 95%). See `read-model.md` for the field-by-field contract.
85
+ *
86
+ * `reviewer_rejection_rate` keys on the detail prefix emitted by
87
+ * `src/review.ts` ("Sub-agent review rejected: ..."). It's a subset
88
+ * of `hitl_escape_rate` — a review rejection routes to HITL, so both
89
+ * rates count the same row.
90
+ */
91
+ function aggregatesViewDdl(windowDrains) {
92
+ // windowDrains is the only spot we interpolate rather than
93
+ // parameter-bind (CREATE VIEW can't take params). Coerce to a
94
+ // positive integer so a hostile env var can't smuggle SQL through.
95
+ const n = Math.max(1, Math.floor(windowDrains));
96
+ return `
97
+ DROP VIEW IF EXISTS evaluator_aggregates_v1;
98
+ CREATE VIEW evaluator_aggregates_v1 AS
99
+ WITH recent_drains AS (
100
+ SELECT trace_id
101
+ FROM drains
102
+ ORDER BY CAST(start_time_unix_nano AS INTEGER) DESC
103
+ LIMIT ${n}
104
+ ),
105
+ process_rows AS (
106
+ SELECT
107
+ ip.trace_id,
108
+ ip.span_id,
109
+ ip.outcome_kind,
110
+ COALESCE(ip.outcome_detail, '') AS outcome_detail,
111
+ CASE
112
+ WHEN instr(ip.issue_identifier, '-') > 0
113
+ THEN substr(ip.issue_identifier, 1, instr(ip.issue_identifier, '-') - 1)
114
+ ELSE ip.issue_identifier
115
+ END AS category,
116
+ (CAST(ip.end_time_unix_nano AS INTEGER) - CAST(ip.start_time_unix_nano AS INTEGER)) / 1000000 AS wall_time_ms,
117
+ (
118
+ SELECT COUNT(*) FROM agent_iterations a
119
+ WHERE a.trace_id = ip.trace_id AND a.issue_process_id = ip.span_id
120
+ ) AS iteration_count
121
+ FROM issue_processes ip
122
+ WHERE ip.trace_id IN (SELECT trace_id FROM recent_drains)
123
+ ),
124
+ wt_ranked AS (
125
+ SELECT
126
+ category,
127
+ wall_time_ms,
128
+ ROW_NUMBER() OVER (PARTITION BY category ORDER BY wall_time_ms) AS rn,
129
+ COUNT(*) OVER (PARTITION BY category) AS cnt
130
+ FROM process_rows
131
+ ),
132
+ it_ranked AS (
133
+ SELECT
134
+ category,
135
+ iteration_count,
136
+ ROW_NUMBER() OVER (PARTITION BY category ORDER BY iteration_count) AS rn,
137
+ COUNT(*) OVER (PARTITION BY category) AS cnt
138
+ FROM process_rows
139
+ ),
140
+ wt_median AS (
141
+ SELECT category, AVG(wall_time_ms * 1.0) AS value
142
+ FROM wt_ranked
143
+ WHERE rn IN ((cnt + 1) / 2, (cnt / 2) + 1)
144
+ GROUP BY category
145
+ ),
146
+ wt_p95 AS (
147
+ SELECT category, MIN(wall_time_ms) AS value
148
+ FROM wt_ranked
149
+ WHERE rn >= (cnt * 95 + 99) / 100
150
+ GROUP BY category
151
+ ),
152
+ it_median AS (
153
+ SELECT category, AVG(iteration_count * 1.0) AS value
154
+ FROM it_ranked
155
+ WHERE rn IN ((cnt + 1) / 2, (cnt / 2) + 1)
156
+ GROUP BY category
157
+ ),
158
+ it_p95 AS (
159
+ SELECT category, MIN(iteration_count) AS value
160
+ FROM it_ranked
161
+ WHERE rn >= (cnt * 95 + 99) / 100
162
+ GROUP BY category
163
+ ),
164
+ rates AS (
165
+ SELECT
166
+ category,
167
+ COUNT(*) AS sample_size,
168
+ AVG(CASE WHEN outcome_kind = 'hitl' AND outcome_detail LIKE 'Sub-agent review rejected%'
169
+ THEN 1.0 ELSE 0.0 END) AS reviewer_rejection_rate,
170
+ AVG(CASE WHEN outcome_kind = 'reverted' THEN 1.0 ELSE 0.0 END) AS revert_rate,
171
+ AVG(CASE WHEN outcome_kind = 'hitl' THEN 1.0 ELSE 0.0 END) AS hitl_escape_rate,
172
+ AVG(CASE WHEN outcome_kind = 'errored' THEN 1.0 ELSE 0.0 END) AS infra_error_rate
173
+ FROM process_rows
174
+ GROUP BY category
175
+ )
176
+ SELECT
177
+ r.category AS category,
178
+ r.sample_size AS sample_size,
179
+ itm.value AS median_iteration_count,
180
+ itp.value AS p95_iteration_count,
181
+ wtm.value AS median_wall_time_ms,
182
+ wtp.value AS p95_wall_time_ms,
183
+ r.reviewer_rejection_rate AS reviewer_rejection_rate,
184
+ r.revert_rate AS revert_rate,
185
+ r.hitl_escape_rate AS hitl_escape_rate,
186
+ r.infra_error_rate AS infra_error_rate
187
+ FROM rates r
188
+ LEFT JOIN wt_median wtm ON wtm.category = r.category
189
+ LEFT JOIN wt_p95 wtp ON wtp.category = r.category
190
+ LEFT JOIN it_median itm ON itm.category = r.category
191
+ LEFT JOIN it_p95 itp ON itp.category = r.category
192
+ ORDER BY r.category;
193
+ `;
194
+ }
66
195
  /**
67
196
  * Open (or create) a SQLite database at `path` and return a typed
68
197
  * `Storage` handle. Pass `:memory:` for tests — the in-memory db
@@ -72,9 +201,35 @@ const SCHEMA = `
72
201
  * OTel SDK retrying a flush) don't blow up the receiver — last writer
73
202
  * wins on (trace_id, span_id).
74
203
  */
75
- export function createStorage(path) {
204
+ export function createStorage(path, opts = {}) {
76
205
  const db = new DatabaseSync(path);
77
206
  db.exec(SCHEMA);
207
+ // VA-387: idempotent column adds for DBs created against an older
208
+ // schema. `CREATE TABLE IF NOT EXISTS` won't migrate an existing
209
+ // table; SQLite has no `ADD COLUMN IF NOT EXISTS`, so we swallow
210
+ // the duplicate-column error individually. Runs BEFORE VA-399's
211
+ // view install — `evaluator_aggregates_v1` reads from
212
+ // `issue_processes`, so the columns it may query must exist first.
213
+ for (const sql of [
214
+ `ALTER TABLE issue_processes ADD COLUMN issue_title TEXT`,
215
+ `ALTER TABLE issue_processes ADD COLUMN issue_labels TEXT`,
216
+ `ALTER TABLE issue_processes ADD COLUMN pr_url TEXT`,
217
+ `ALTER TABLE issue_processes ADD COLUMN hitl_reason TEXT`,
218
+ ]) {
219
+ try {
220
+ db.exec(sql);
221
+ }
222
+ catch {
223
+ // Column already present — fresh CREATE TABLE path, or a prior
224
+ // dashboard boot ran the same migration.
225
+ }
226
+ }
227
+ // VA-399: install the evaluator-facing read-model view after the
228
+ // base tables exist (and after VA-387's column migrations above),
229
+ // but before any prepared statement is created — a
230
+ // `SELECT FROM evaluator_aggregates_v1` would otherwise race the
231
+ // DDL on first use.
232
+ db.exec(aggregatesViewDdl(opts.aggregateWindowDrains ?? DEFAULT_AGGREGATE_WINDOW));
78
233
  const insertDrain = db.prepare(`
79
234
  INSERT INTO drains (
80
235
  trace_id, span_id, start_time_unix_nano, end_time_unix_nano,
@@ -93,16 +248,21 @@ export function createStorage(path) {
93
248
  const insertIssueProcess = db.prepare(`
94
249
  INSERT INTO issue_processes (
95
250
  trace_id, span_id, parent_span_id, issue_identifier, issue_id,
96
- branch, outcome_kind, outcome_detail,
251
+ issue_title, issue_labels, branch, outcome_kind, outcome_detail,
252
+ pr_url, hitl_reason,
97
253
  start_time_unix_nano, end_time_unix_nano, status_code, status_message
98
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
254
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
99
255
  ON CONFLICT (trace_id, span_id) DO UPDATE SET
100
256
  parent_span_id = excluded.parent_span_id,
101
257
  issue_identifier = excluded.issue_identifier,
102
258
  issue_id = excluded.issue_id,
259
+ issue_title = excluded.issue_title,
260
+ issue_labels = excluded.issue_labels,
103
261
  branch = excluded.branch,
104
262
  outcome_kind = excluded.outcome_kind,
105
263
  outcome_detail = excluded.outcome_detail,
264
+ pr_url = excluded.pr_url,
265
+ hitl_reason = excluded.hitl_reason,
106
266
  start_time_unix_nano = excluded.start_time_unix_nano,
107
267
  end_time_unix_nano = excluded.end_time_unix_nano,
108
268
  status_code = excluded.status_code,
@@ -130,35 +290,40 @@ export function createStorage(path) {
130
290
  `);
131
291
  // Two list variants instead of one with conditional SQL — keeps
132
292
  // each prepared statement static.
133
- const listAll = db.prepare(`
134
- SELECT
293
+ const ISSUE_PROCESS_COLUMNS = `
135
294
  trace_id, span_id, parent_span_id, issue_identifier, issue_id,
136
- branch, outcome_kind, outcome_detail,
295
+ issue_title, issue_labels, branch, outcome_kind, outcome_detail,
296
+ pr_url, hitl_reason,
137
297
  start_time_unix_nano, end_time_unix_nano, status_code, status_message,
138
298
  inserted_at
299
+ `;
300
+ const listAll = db.prepare(`
301
+ SELECT ${ISSUE_PROCESS_COLUMNS}
139
302
  FROM issue_processes
140
303
  ORDER BY inserted_at DESC, span_id DESC
141
304
  LIMIT ?
142
305
  `);
143
306
  const listByTrace = db.prepare(`
144
- SELECT
145
- trace_id, span_id, parent_span_id, issue_identifier, issue_id,
146
- branch, outcome_kind, outcome_detail,
147
- start_time_unix_nano, end_time_unix_nano, status_code, status_message,
148
- inserted_at
307
+ SELECT ${ISSUE_PROCESS_COLUMNS}
149
308
  FROM issue_processes
150
309
  WHERE trace_id = ?
151
310
  ORDER BY inserted_at DESC, span_id DESC
152
311
  LIMIT ?
153
312
  `);
154
313
  const getProcessStmt = db.prepare(`
155
- SELECT
156
- trace_id, span_id, parent_span_id, issue_identifier, issue_id,
157
- branch, outcome_kind, outcome_detail,
158
- start_time_unix_nano, end_time_unix_nano, status_code, status_message,
159
- inserted_at
314
+ SELECT ${ISSUE_PROCESS_COLUMNS}
160
315
  FROM issue_processes
161
316
  WHERE trace_id = ? AND span_id = ?
317
+ `);
318
+ // VA-387: span_id is unique in practice (random 64-bit ids); the
319
+ // detail route at `/issue-processes/:id` keys on span_id alone so
320
+ // operators don't have to type the trace_id in URLs.
321
+ const getProcessBySpanStmt = db.prepare(`
322
+ SELECT ${ISSUE_PROCESS_COLUMNS}
323
+ FROM issue_processes
324
+ WHERE span_id = ?
325
+ ORDER BY inserted_at DESC
326
+ LIMIT 1
162
327
  `);
163
328
  const listIterations = db.prepare(`
164
329
  SELECT
@@ -169,11 +334,16 @@ export function createStorage(path) {
169
334
  WHERE trace_id = ? AND issue_process_id = ?
170
335
  ORDER BY iteration_index ASC
171
336
  `);
337
+ const selectAggregates = db.prepare(`SELECT * FROM evaluator_aggregates_v1`);
172
338
  const saveDrain = (d) => {
173
339
  insertDrain.run(d.traceId, d.spanId, d.startTimeUnixNano, d.endTimeUnixNano, asInt(d.attempts), asInt(d.opened), asInt(d.hitl), asInt(d.errored), asInt(d.statusCode), d.statusMessage);
174
340
  };
175
341
  const saveIssueProcess = (p) => {
176
- insertIssueProcess.run(p.traceId, p.spanId, p.parentSpanId, p.issueIdentifier, p.issueId, p.branch, p.outcomeKind, p.outcomeDetail, p.startTimeUnixNano, p.endTimeUnixNano, asInt(p.statusCode), p.statusMessage);
342
+ insertIssueProcess.run(p.traceId, p.spanId, p.parentSpanId, p.issueIdentifier, p.issueId, p.issueTitle,
343
+ // VA-387: labels round-trip as a JSON array string. Keeping them
344
+ // in one column avoids a label-many-to-many table for a feature
345
+ // that's read-only on the dashboard side.
346
+ p.issueLabels.length === 0 ? null : JSON.stringify(p.issueLabels), p.branch, p.outcomeKind, p.outcomeDetail, p.prUrl, p.hitlReason, p.startTimeUnixNano, p.endTimeUnixNano, asInt(p.statusCode), p.statusMessage);
177
347
  };
178
348
  const saveAgentIteration = (a) => {
179
349
  insertAgentIteration.run(a.traceId, a.spanId, a.issueProcessSpanId, asInt(a.iterationIndex), a.startTimeUnixNano, a.endTimeUnixNano, a.sandcastleRunId, a.exitStatus);
@@ -192,6 +362,10 @@ export function createStorage(path) {
192
362
  const row = getProcessStmt.get(traceId, spanId);
193
363
  return row ? rowToIssueProcess(row) : undefined;
194
364
  };
365
+ const getIssueProcessBySpanId = (spanId) => {
366
+ const row = getProcessBySpanStmt.get(spanId);
367
+ return row ? rowToIssueProcess(row) : undefined;
368
+ };
195
369
  const listAgentIterations = (traceId, issueProcessSpanId) => {
196
370
  const rows = listIterations.all(traceId, issueProcessSpanId);
197
371
  return rows.map(rowToAgentIteration);
@@ -227,6 +401,7 @@ export function createStorage(path) {
227
401
  .all(traceId, issueProcessSpanId, ...names);
228
402
  return rows.map(rowToPhaseSpan);
229
403
  };
404
+ const listAggregates = () => selectAggregates.all().map(rowToAggregate);
230
405
  const close = () => {
231
406
  db.close();
232
407
  };
@@ -237,8 +412,10 @@ export function createStorage(path) {
237
412
  saveRawSpan,
238
413
  listIssueProcesses,
239
414
  getIssueProcess,
415
+ getIssueProcessBySpanId,
240
416
  listAgentIterations,
241
417
  listPhaseSpans,
418
+ listAggregates,
242
419
  close,
243
420
  };
244
421
  }
@@ -256,9 +433,13 @@ function rowToIssueProcess(row) {
256
433
  parentSpanId: nullableStr(r.parent_span_id),
257
434
  issueIdentifier: String(r.issue_identifier ?? ""),
258
435
  issueId: nullableStr(r.issue_id),
436
+ issueTitle: nullableStr(r.issue_title),
437
+ issueLabels: parseLabels(r.issue_labels),
259
438
  branch: nullableStr(r.branch),
260
439
  outcomeKind: nullableStr(r.outcome_kind),
261
440
  outcomeDetail: nullableStr(r.outcome_detail),
441
+ prUrl: nullableStr(r.pr_url),
442
+ hitlReason: nullableStr(r.hitl_reason),
262
443
  startTimeUnixNano: String(r.start_time_unix_nano ?? ""),
263
444
  endTimeUnixNano: String(r.end_time_unix_nano ?? ""),
264
445
  statusCode: nullableNum(r.status_code),
@@ -266,6 +447,26 @@ function rowToIssueProcess(row) {
266
447
  insertedAt: String(r.inserted_at ?? ""),
267
448
  };
268
449
  }
450
+ /**
451
+ * VA-387: decode the JSON-encoded `issue_labels` column back into a
452
+ * string array. A row stored before the column existed (or one with
453
+ * NULL / malformed JSON) collapses to an empty list.
454
+ */
455
+ function parseLabels(v) {
456
+ if (v === null || v === undefined)
457
+ return [];
458
+ if (typeof v !== "string")
459
+ return [];
460
+ try {
461
+ const parsed = JSON.parse(v);
462
+ if (!Array.isArray(parsed))
463
+ return [];
464
+ return parsed.filter((x) => typeof x === "string");
465
+ }
466
+ catch {
467
+ return [];
468
+ }
469
+ }
269
470
  function rowToAgentIteration(row) {
270
471
  const r = row;
271
472
  return {
@@ -302,3 +503,18 @@ function nullableNum(v) {
302
503
  const n = Number(v);
303
504
  return Number.isFinite(n) ? n : null;
304
505
  }
506
+ function rowToAggregate(row) {
507
+ const r = row;
508
+ return {
509
+ category: String(r.category ?? ""),
510
+ sampleSize: Number(r.sample_size ?? 0),
511
+ medianIterationCount: nullableNum(r.median_iteration_count),
512
+ p95IterationCount: nullableNum(r.p95_iteration_count),
513
+ medianWallTimeMs: nullableNum(r.median_wall_time_ms),
514
+ p95WallTimeMs: nullableNum(r.p95_wall_time_ms),
515
+ reviewerRejectionRate: Number(r.reviewer_rejection_rate ?? 0),
516
+ revertRate: Number(r.revert_rate ?? 0),
517
+ hitlEscapeRate: Number(r.hitl_escape_rate ?? 0),
518
+ infraErrorRate: Number(r.infra_error_rate ?? 0),
519
+ };
520
+ }
@@ -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.