@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.
- package/README.md +1 -1
- package/dist/cli.js +1 -0
- package/dist/commands/run.js +47 -0
- package/dist/config.js +8 -0
- package/dist/dashboard/otlp.js +16 -2
- package/dist/dashboard/projector.js +12 -0
- package/dist/dashboard/server.js +60 -4
- package/dist/dashboard/storage.js +233 -17
- package/dist/dashboard/views.js +18 -1
- package/dist/finalize.js +34 -2
- package/dist/git.js +170 -22
- package/dist/implement.js +6 -0
- package/dist/linear.js +35 -9
- package/dist/orchestrator.js +99 -18
- package/dist/prompts.js +40 -0
- package/dist/review.js +32 -18
- package/package.json +1 -1
- package/prompts/implement.md +11 -0
- package/prompts/review.md +48 -6
package/dist/dashboard/views.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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-
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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.
|
|
104
|
-
* 2.
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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.
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
if (
|
|
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
|
-
|
|
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
|
|
96
|
+
async function fetchIssueLabelNames(issue) {
|
|
87
97
|
const labels = await issue.labels();
|
|
88
|
-
|
|
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
|
-
//
|
|
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
|
|
343
|
+
for (const raw of candidates) {
|
|
320
344
|
const i = decodeIssueNode(raw);
|
|
321
|
-
|
|
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;
|
package/dist/orchestrator.js
CHANGED
|
@@ -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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|