@valescoagency/runway 0.14.3 → 0.16.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 +15 -2
- package/dist/cli.js +7 -0
- package/dist/commands/planner.js +265 -0
- package/dist/commands/run.js +35 -3
- package/dist/config.js +27 -0
- package/dist/dashboard/projector.js +177 -0
- package/dist/dashboard/server.js +234 -3
- package/dist/dashboard/storage.js +354 -0
- package/dist/dashboard/views.js +141 -0
- package/dist/github.js +350 -0
- package/dist/orchestrator.js +19 -1
- package/dist/planner-capability-mode.js +42 -0
- package/dist/planner-ci.js +283 -0
- package/dist/planner-drain.js +224 -0
- package/dist/planner-http.js +374 -0
- package/dist/planner-mergeability.js +344 -0
- package/dist/planner-queue-selection.js +104 -0
- package/dist/planner-reviewer.js +433 -0
- package/dist/planner-storage.js +339 -0
- package/dist/planner.js +150 -0
- package/dist/prompts.js +99 -0
- package/package.json +1 -1
- package/prompts/shepherd-ci-fix.md +47 -0
- package/prompts/shepherd-rebase.md +40 -0
- package/prompts/shepherd-review-fix.md +40 -0
- package/prompts/shepherd-review-respond.md +31 -0
- package/templates/com.valescoagency.runway-planner.plist +88 -0
package/README.md
CHANGED
|
@@ -8,13 +8,14 @@ coding-agent runs, then **drain** a Linear queue against it. Wraps
|
|
|
8
8
|
inside Docker), [varlock](https://varlock.dev) + 1Password for
|
|
9
9
|
zero-secrets-at-rest, and the `gh` CLI for PR creation.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Eight commands
|
|
12
12
|
|
|
13
13
|
| | |
|
|
14
14
|
|---|---|
|
|
15
15
|
| `runway dash` | Bring up the operations dashboard (`up` / `logs` / `stop`). Wraps the published `ghcr.io/valescoagency/runway-dashboard` image so any runway-using project can run the dashboard without cloning runway. Ports bind to `127.0.0.1` only. |
|
|
16
16
|
| `runway doctor` | Read-only preflight diagnostic: host tooling, env vars, repo state, and the agent docker image. Use when something stopped working and you want a sanity report. `--json` for CI / scripted health checks. |
|
|
17
17
|
| `runway init` | Scaffold the cwd repo for runway: write `.sandcastle/Dockerfile` + (tier 2) `.env.schema` with op:// references. Run **once per target repo**. |
|
|
18
|
+
| `runway planner` | Run the planner daemon — a long-running host process that will own cross-PR shepherding and queue selection per [ADR 0004](docs/adr/0004-planner-daemon-architecture.md). VA-472 epic in flight; VA-473 ships the foreground scaffold (`runway planner run` boots a tick loop, no capabilities wired yet). |
|
|
18
19
|
| `runway review` | Run an IRA retrospective pass. `runway review run --drain <trace-id> --issue <id>` grades one issue-process post-drain — loads the captured agent + reviewer reports from the dashboard, fetches hindsight (PR merge state, human review-thread comments, Linear follow-ups in the past 48h), pulls rolling norms for the issue's category (last 30 drains, via the VA-399 read-model), asks an Anthropic model for a structured `Run Review` with absolute + relative grading axes, writes it to a `runway-meta` Linear project + the dashboard's `meta_reviews` table. Scheduler-agnostic; usually invoked from cron or GitHub Actions ~18h after a drain. The drain-age delay is enforced by `RUNWAY_REVIEW_DELAY_HOURS` (default 18, constrained to the 12–24h band) — pass `--force` to override for one-off operator bypass. **`runway review drain --id <trace-id>`** (VA-406) grades a whole drain: reads every per-issue Run Review for the drain, asks the model for a structured `Drain Review` covering composition / sequencing / cross-issue patterns, files it in `runway-meta`. When the model marks a finding `severity: critical` (drain-unsafe or captured-data-lost), the IRA also escalates a `Bug` + `runway-meta-promoted` issue into the runway-repo project (set `RUNWAY_REPO_PROJECT_NAME` to scope by project; otherwise team-level). The Drain Review fires automatically in-process the moment every Run Review for a drain has landed — manual invocation is for scheduled / catch-up runs. |
|
|
19
20
|
| `runway run` | Drain a Linear queue. For each issue carrying the `ready-for-agent` label: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
|
|
20
21
|
| `runway upgrade` | Update the runway CLI itself: `git pull` the local clone, `pnpm install`, typecheck. `--check` for a dry-run, `--force` to override dirty/branch refusals. |
|
|
@@ -218,6 +219,18 @@ export LINEAR_API_KEY=lin_api_...
|
|
|
218
219
|
# export RUNWAY_READY_LABEL="ready-for-agent"
|
|
219
220
|
# export RUNWAY_HITL_LABEL="ready-for-human"
|
|
220
221
|
# export RUNWAY_MAX_ITERATIONS=5
|
|
222
|
+
# VA-482: the four RUNWAY_SHEPHERD_* env vars have been retired.
|
|
223
|
+
# Mergeability / CI / reviewer-feedback work now flows through the
|
|
224
|
+
# `runway planner` daemon (capabilities VA-477..479). Boot the
|
|
225
|
+
# planner separately (`runway planner run`) and configure it with
|
|
226
|
+
# the RUNWAY_PLANNER_* env vars; the planner reads from the
|
|
227
|
+
# dashboard's SQLite file and emits OTLP back to the dashboard.
|
|
228
|
+
# export RUNWAY_PR_REVIEWER_BOT_LOGIN="runway-reviewer-bot"
|
|
229
|
+
# VA-463 / VA-479: GitHub login of the adversarial PR-reviewer
|
|
230
|
+
# agent. Consumed by the planner's reviewer dispatcher to
|
|
231
|
+
# distinguish bot CHANGES-REQUESTED comments from human feedback
|
|
232
|
+
# (the latter routes through the LLM interpretation path).
|
|
233
|
+
# Unset → every comment lands on the human-feedback path.
|
|
221
234
|
# export RUNWAY_COMMENT_AUTHOR_ALLOWLIST="Reviewer Bot,Jane Reviewer"
|
|
222
235
|
# optional, comma-separated Linear user names whose comments on a
|
|
223
236
|
# re-queued issue surface as "Review feedback from prior attempts"
|
|
@@ -541,7 +554,7 @@ These are tractable, just not v1.
|
|
|
541
554
|
|
|
542
555
|
## Status
|
|
543
556
|
|
|
544
|
-
0.
|
|
557
|
+
0.15.0 — production-shaped and dogfooded against live Linear queues.
|
|
545
558
|
The end-to-end pipeline (init → run → review → PR) is stable; surface
|
|
546
559
|
may still shift as the orchestrator's policy and iteration mechanics
|
|
547
560
|
mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { dashCommand, printDashUsage } from "./commands/dash.js";
|
|
3
3
|
import { doctorCommand, printDoctorUsage } from "./commands/doctor.js";
|
|
4
4
|
import { initCommand, printInitUsage } from "./commands/init.js";
|
|
5
|
+
import { plannerCommand, printPlannerUsage } from "./commands/planner.js";
|
|
5
6
|
import { reviewCommand, printReviewUsage } from "./commands/review.js";
|
|
6
7
|
import { runCommand, printRunUsage } from "./commands/run.js";
|
|
7
8
|
import { upgradeCommand, printUpgradeUsage } from "./commands/upgrade.js";
|
|
@@ -25,6 +26,12 @@ const SUBCOMMANDS = [
|
|
|
25
26
|
run: initCommand,
|
|
26
27
|
help: printInitUsage,
|
|
27
28
|
},
|
|
29
|
+
{
|
|
30
|
+
name: "planner",
|
|
31
|
+
summary: "Run the planner daemon (queue selection + PR shepherding).",
|
|
32
|
+
run: plannerCommand,
|
|
33
|
+
help: printPlannerUsage,
|
|
34
|
+
},
|
|
28
35
|
{
|
|
29
36
|
name: "review",
|
|
30
37
|
summary: "Run an IRA retrospective pass (run / drain / weekly).",
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Deferred, Effect, Layer, Logger, Runtime } from "effect";
|
|
5
|
+
import { ConfigLive, ConfigTag } from "../config.js";
|
|
6
|
+
import { installSignalHandlers, makeHealthReader, makePlannerLoop, makePlannerState, } from "../planner.js";
|
|
7
|
+
import { PLANNER_CAPABILITY_MODE_CHANGED_LOG, PLANNER_RECOMMENDATION_ACCEPTED_LOG, PLANNER_RECOMMENDATION_REJECTED_LOG, } from "../dashboard/projector.js";
|
|
8
|
+
import { createGithubGateway } from "../github.js";
|
|
9
|
+
import { startPlannerHttpServer, } from "../planner-http.js";
|
|
10
|
+
import { makeAnthropicCommentInterpreter } from "../planner-reviewer.js";
|
|
11
|
+
import { createPlannerReadClient } from "../planner-storage.js";
|
|
12
|
+
import { TelemetryLive } from "../telemetry.js";
|
|
13
|
+
export function printPlannerUsage() {
|
|
14
|
+
console.log(`runway planner — long-running daemon for queue selection and PR shepherding
|
|
15
|
+
|
|
16
|
+
Sub-1 scaffold (VA-473): boots the tick loop with no capabilities wired.
|
|
17
|
+
Capabilities arrive in VA-474..VA-482 per ADR 0004.
|
|
18
|
+
|
|
19
|
+
USAGE
|
|
20
|
+
runway planner run
|
|
21
|
+
|
|
22
|
+
VERBS
|
|
23
|
+
run Boot the planner in the foreground. Emits a planner.tick
|
|
24
|
+
heartbeat each RUNWAY_PLANNER_TICK_SECONDS. SIGINT / SIGTERM
|
|
25
|
+
triggers graceful shutdown (OTLP flush, then exit).
|
|
26
|
+
|
|
27
|
+
OPTIONS
|
|
28
|
+
--help, -h Show this help.
|
|
29
|
+
|
|
30
|
+
ENDPOINTS (VA-475 / VA-481)
|
|
31
|
+
GET /planner/health liveness probe
|
|
32
|
+
GET /planner/recommendations[?status=&limit=N] list (status: proposed|accepted|rejected|all)
|
|
33
|
+
GET /planner/recommendations/:id single
|
|
34
|
+
POST /planner/recommendations/:id/accept emits accepted
|
|
35
|
+
POST /planner/recommendations/:id/reject emits rejected (requires { reason })
|
|
36
|
+
GET /planner/actions[?status=] list (status: started|completed|failed|all)
|
|
37
|
+
GET /planner/capabilities per-capability modes (VA-481)
|
|
38
|
+
PUT /planner/capabilities/:capability body { mode: "manual"|"suggest"|"auto" }
|
|
39
|
+
|
|
40
|
+
Auth is network-layer (Tailscale ACLs). Same-origin-only CORS —
|
|
41
|
+
browsers at a different origin are rejected; route through the
|
|
42
|
+
dashboard's backend.
|
|
43
|
+
|
|
44
|
+
ENVIRONMENT
|
|
45
|
+
RUNWAY_PLANNER_TICK_SECONDS default 30 — tick cadence.
|
|
46
|
+
RUNWAY_PLANNER_HOST default 127.0.0.1 — HTTP bind host.
|
|
47
|
+
Set to a tailnet IP to expose the API
|
|
48
|
+
across your Tailscale network.
|
|
49
|
+
RUNWAY_PLANNER_PORT default 3002 — HTTP bind port.
|
|
50
|
+
RUNWAY_PLANNER_SQLITE_PATH optional — path to the dashboard's
|
|
51
|
+
SQLite file (the planner opens it
|
|
52
|
+
read-only). When unset, GET endpoints
|
|
53
|
+
return 503; POST endpoints still emit
|
|
54
|
+
OTLP (the dashboard validates on
|
|
55
|
+
receive).
|
|
56
|
+
|
|
57
|
+
LINEAR_API_KEY required (planner reads the queue).
|
|
58
|
+
OTEL_EXPORTER_OTLP_ENDPOINT optional — when set, planner emissions
|
|
59
|
+
stream to the dashboard's OTLP receiver.
|
|
60
|
+
|
|
61
|
+
SUPERVISION
|
|
62
|
+
Mac mini operators: install the bundled launchd plist at
|
|
63
|
+
~/Library/LaunchAgents/com.valescoagency.runway-planner.plist
|
|
64
|
+
(template ships at templates/com.valescoagency.runway-planner.plist
|
|
65
|
+
in the runway package) and run \`launchctl load\` once. launchd
|
|
66
|
+
restarts the planner on crash and on login.
|
|
67
|
+
|
|
68
|
+
REFERENCES
|
|
69
|
+
ADR 0004 — Planner daemon architecture.
|
|
70
|
+
Epic VA-472 — Planner daemon (replatform shepherd).
|
|
71
|
+
`);
|
|
72
|
+
}
|
|
73
|
+
export function parsePlannerArgs(argv) {
|
|
74
|
+
if (argv.length === 0) {
|
|
75
|
+
throw new Error("missing verb — expected `run`. Run `runway planner --help`.");
|
|
76
|
+
}
|
|
77
|
+
const [verbRaw, ...rest] = argv;
|
|
78
|
+
if (verbRaw === "--help" || verbRaw === "-h") {
|
|
79
|
+
printPlannerUsage();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
if (verbRaw !== "run") {
|
|
83
|
+
throw new Error(`unknown verb "${verbRaw}" — expected \`run\`. Run \`runway planner --help\`.`);
|
|
84
|
+
}
|
|
85
|
+
for (const arg of rest) {
|
|
86
|
+
if (arg === "--help" || arg === "-h") {
|
|
87
|
+
printPlannerUsage();
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
91
|
+
}
|
|
92
|
+
return { verb: "run" };
|
|
93
|
+
}
|
|
94
|
+
export async function plannerCommand(argv) {
|
|
95
|
+
parsePlannerArgs(argv);
|
|
96
|
+
const LoggerLive = process.env.RUNWAY_JSON_LOGS === "1"
|
|
97
|
+
? Logger.replace(Logger.defaultLogger, Logger.jsonLogger)
|
|
98
|
+
: Layer.empty;
|
|
99
|
+
const MainLayer = Layer.mergeAll(ConfigLive, TelemetryLive, LoggerLive);
|
|
100
|
+
const program = Effect.gen(function* () {
|
|
101
|
+
const config = yield* ConfigTag;
|
|
102
|
+
yield* Effect.logInfo(`[runway planner] boot host=${config.plannerHost} port=${config.plannerPort} tick=${config.plannerTickSeconds}s sqlite=${config.plannerSqlitePath ?? "(unset — GET endpoints will 503)"}`);
|
|
103
|
+
const shutdownSignal = yield* Deferred.make();
|
|
104
|
+
yield* installSignalHandlers(shutdownSignal);
|
|
105
|
+
const stateRef = yield* makePlannerState(Date.now());
|
|
106
|
+
// Capture the current runtime so the HTTP request handlers (which
|
|
107
|
+
// are non-Effect functions) can fire-and-forget OTLP log emissions
|
|
108
|
+
// through the same TelemetryLive layer the loop uses.
|
|
109
|
+
const runtime = yield* Effect.runtime();
|
|
110
|
+
const runFork = Runtime.runFork(runtime);
|
|
111
|
+
const emitter = makeDecisionEmitter(runFork);
|
|
112
|
+
// Open the read-only SQLite client when configured. Absent path
|
|
113
|
+
// is non-fatal — the daemon stays useful for POST endpoints (the
|
|
114
|
+
// dashboard validates against its own storage on receive); GET
|
|
115
|
+
// endpoints will return 503 with a clear remediation message.
|
|
116
|
+
const readClient = yield* Effect.acquireRelease(Effect.sync(() => config.plannerSqlitePath
|
|
117
|
+
? createPlannerReadClient({ sqlitePath: config.plannerSqlitePath })
|
|
118
|
+
: null), (client) => Effect.sync(() => client?.close()));
|
|
119
|
+
const httpServer = yield* Effect.acquireRelease(Effect.promise(() => startPlannerHttpServer({
|
|
120
|
+
host: config.plannerHost,
|
|
121
|
+
port: config.plannerPort,
|
|
122
|
+
readClient,
|
|
123
|
+
getHealth: makeHealthReader(stateRef, packageVersion()),
|
|
124
|
+
emitter,
|
|
125
|
+
})), (server) => Effect.promise(() => server.close()));
|
|
126
|
+
yield* Effect.logInfo(`[runway planner] http listening on ${config.plannerHost}:${httpServer.port}`);
|
|
127
|
+
// VA-476: queue-selection capability is wired when we have a
|
|
128
|
+
// SQLite read client. Absent → tick body skips it (the daemon
|
|
129
|
+
// still serves /planner/health and the POST endpoints, just no
|
|
130
|
+
// recommendations get proposed).
|
|
131
|
+
//
|
|
132
|
+
// VA-477: mergeability capability needs both the SQLite read
|
|
133
|
+
// client AND a `baseBranch` configured. Without baseBranch the
|
|
134
|
+
// rebase-target is unknown, so the capability is dormant rather
|
|
135
|
+
// than rebasing against a guessed default.
|
|
136
|
+
const nowNano = () => String(Date.now() * 1_000_000);
|
|
137
|
+
const capabilities = readClient
|
|
138
|
+
? {
|
|
139
|
+
queueSelection: {
|
|
140
|
+
readClient,
|
|
141
|
+
readyLabel: config.readyLabel,
|
|
142
|
+
now: nowNano,
|
|
143
|
+
},
|
|
144
|
+
// VA-480: drain spawn doesn't need baseBranch — it just
|
|
145
|
+
// shells out to `runway run --target` which resolves its
|
|
146
|
+
// own base branch. Wire it whenever the read client is
|
|
147
|
+
// available so queue-selection accepts can complete the
|
|
148
|
+
// recommendation → action lifecycle regardless of whether
|
|
149
|
+
// shepherd capabilities are configured.
|
|
150
|
+
drain: {
|
|
151
|
+
readClient,
|
|
152
|
+
cwd: process.cwd(),
|
|
153
|
+
now: nowNano,
|
|
154
|
+
},
|
|
155
|
+
...(config.baseBranch
|
|
156
|
+
? {
|
|
157
|
+
mergeability: {
|
|
158
|
+
readClient,
|
|
159
|
+
github: createGithubGateway(),
|
|
160
|
+
config,
|
|
161
|
+
cwd: process.cwd(),
|
|
162
|
+
baseBranch: config.baseBranch,
|
|
163
|
+
now: nowNano,
|
|
164
|
+
},
|
|
165
|
+
// VA-478: CI capability shares the same gating
|
|
166
|
+
// (SQLite + baseBranch) so all per-PR shepherd
|
|
167
|
+
// capabilities light up or stay dark together.
|
|
168
|
+
ci: {
|
|
169
|
+
readClient,
|
|
170
|
+
github: createGithubGateway(),
|
|
171
|
+
config,
|
|
172
|
+
cwd: process.cwd(),
|
|
173
|
+
baseBranch: config.baseBranch,
|
|
174
|
+
now: nowNano,
|
|
175
|
+
},
|
|
176
|
+
// VA-479: reviewer capability. LLM interpreter is
|
|
177
|
+
// wired only when ANTHROPIC_API_KEY is in the env;
|
|
178
|
+
// absent → verbatim-comment fallback (operator sees
|
|
179
|
+
// the raw body in evidence).
|
|
180
|
+
reviewer: {
|
|
181
|
+
readClient,
|
|
182
|
+
github: createGithubGateway(),
|
|
183
|
+
config,
|
|
184
|
+
cwd: process.cwd(),
|
|
185
|
+
baseBranch: config.baseBranch,
|
|
186
|
+
interpretComment: process.env.ANTHROPIC_API_KEY
|
|
187
|
+
? makeAnthropicCommentInterpreter({
|
|
188
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
189
|
+
})
|
|
190
|
+
: undefined,
|
|
191
|
+
now: nowNano,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
: {}),
|
|
195
|
+
}
|
|
196
|
+
: {};
|
|
197
|
+
yield* makePlannerLoop(shutdownSignal, stateRef, capabilities);
|
|
198
|
+
}).pipe(Effect.scoped, Effect.provide(MainLayer));
|
|
199
|
+
await Effect.runPromise(program);
|
|
200
|
+
console.log("[runway planner] shutdown complete");
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* VA-475: turn the captured Effect runtime into a synchronous emitter
|
|
205
|
+
* the HTTP handlers can call without re-entering Effect.gen. Each
|
|
206
|
+
* call schedules a single log emission on the runtime; the
|
|
207
|
+
* TelemetryLive layer flushes it through the OTLP exporter to the
|
|
208
|
+
* dashboard's projector.
|
|
209
|
+
*/
|
|
210
|
+
export function makeDecisionEmitter(runFork) {
|
|
211
|
+
return {
|
|
212
|
+
emitAccepted: ({ recommendationId, source, reason, timestampUnixNano }) => {
|
|
213
|
+
const attrs = {
|
|
214
|
+
recommendationId,
|
|
215
|
+
source,
|
|
216
|
+
};
|
|
217
|
+
if (reason)
|
|
218
|
+
attrs.reason = reason;
|
|
219
|
+
runFork(Effect.logInfo(PLANNER_RECOMMENDATION_ACCEPTED_LOG).pipe(Effect.annotateLogs(attrs),
|
|
220
|
+
// Force the log timestamp so the dashboard projector sees
|
|
221
|
+
// the operator-perceived decision moment, not the OTLP
|
|
222
|
+
// emit moment (close in practice; explicit is clearer).
|
|
223
|
+
Effect.annotateLogs({ timestampUnixNano })));
|
|
224
|
+
},
|
|
225
|
+
emitRejected: ({ recommendationId, source, reason, timestampUnixNano }) => {
|
|
226
|
+
runFork(Effect.logInfo(PLANNER_RECOMMENDATION_REJECTED_LOG).pipe(Effect.annotateLogs({
|
|
227
|
+
recommendationId,
|
|
228
|
+
source,
|
|
229
|
+
reason,
|
|
230
|
+
timestampUnixNano,
|
|
231
|
+
})));
|
|
232
|
+
},
|
|
233
|
+
emitCapabilityModeChanged: ({ capability, mode, updatedBy, timestampUnixNano, }) => {
|
|
234
|
+
runFork(Effect.logInfo(PLANNER_CAPABILITY_MODE_CHANGED_LOG).pipe(Effect.annotateLogs({
|
|
235
|
+
capability,
|
|
236
|
+
mode,
|
|
237
|
+
updatedBy,
|
|
238
|
+
timestampUnixNano,
|
|
239
|
+
})));
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Resolve `package.json#version` for the health endpoint. Reads
|
|
245
|
+
* synchronously at boot — runtime cost is one stat + parse. Cached
|
|
246
|
+
* after first call.
|
|
247
|
+
*/
|
|
248
|
+
let cachedVersion = null;
|
|
249
|
+
function packageVersion() {
|
|
250
|
+
if (cachedVersion !== null)
|
|
251
|
+
return cachedVersion;
|
|
252
|
+
try {
|
|
253
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
254
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf8"));
|
|
255
|
+
cachedVersion = String(pkg.version ?? "unknown");
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
// Surface the read failure to stderr so an operator running the
|
|
259
|
+
// foreground daemon can see what's wrong; the "unknown" fallback
|
|
260
|
+
// keeps the health endpoint usable.
|
|
261
|
+
console.error("[runway planner] could not resolve package version:", err instanceof Error ? err.message : String(err));
|
|
262
|
+
cachedVersion = "unknown";
|
|
263
|
+
}
|
|
264
|
+
return cachedVersion;
|
|
265
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -87,6 +87,23 @@ export function parseRunArgs(argv) {
|
|
|
87
87
|
}
|
|
88
88
|
opts.reviewRetries = n;
|
|
89
89
|
}
|
|
90
|
+
else if (a === "--target") {
|
|
91
|
+
const v = argv[i + 1];
|
|
92
|
+
if (!v)
|
|
93
|
+
throw new Error("--target requires a Linear issue identifier");
|
|
94
|
+
if (!/^[A-Z]+-\d+$/.test(v)) {
|
|
95
|
+
throw new Error(`--target must be a Linear issue identifier like VA-123, got "${v}"`);
|
|
96
|
+
}
|
|
97
|
+
opts.target = v;
|
|
98
|
+
i += 1;
|
|
99
|
+
}
|
|
100
|
+
else if (a?.startsWith("--target=")) {
|
|
101
|
+
const v = a.slice("--target=".length);
|
|
102
|
+
if (!/^[A-Z]+-\d+$/.test(v)) {
|
|
103
|
+
throw new Error(`--target must be a Linear issue identifier like VA-123, got "${v}"`);
|
|
104
|
+
}
|
|
105
|
+
opts.target = v;
|
|
106
|
+
}
|
|
90
107
|
else if (a === "--help" || a === "-h") {
|
|
91
108
|
printRunUsage();
|
|
92
109
|
process.exit(0);
|
|
@@ -129,6 +146,15 @@ OPTIONS
|
|
|
129
146
|
and re-runs review. N caps the extra impl+review
|
|
130
147
|
pairs per drain pickup. 0 disables retries entirely.
|
|
131
148
|
Overrides RUNWAY_REVIEW_RETRIES. Default: 1.
|
|
149
|
+
--target VA-NNN
|
|
150
|
+
Restrict the drain to one Linear issue. Skips the
|
|
151
|
+
FIFO pick and claims this identifier instead. Used
|
|
152
|
+
by the planner daemon (VA-480) when accepting a
|
|
153
|
+
queue-selection recommendation; manual operators
|
|
154
|
+
can also use it to re-attempt a specific issue
|
|
155
|
+
without picking up everything else in the queue.
|
|
156
|
+
Fails fast if the issue isn't currently in the
|
|
157
|
+
ready queue.
|
|
132
158
|
--help, -h Show this help.
|
|
133
159
|
|
|
134
160
|
ENVIRONMENT
|
|
@@ -212,20 +238,26 @@ export async function runCommand(argv) {
|
|
|
212
238
|
});
|
|
213
239
|
const linear = createLinearGateway(config, linearLimiter);
|
|
214
240
|
const github = createGithubGateway();
|
|
215
|
-
return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
|
|
241
|
+
return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths, target: opts.target });
|
|
216
242
|
}).pipe(Effect.scoped, Effect.provide(MainLayer));
|
|
217
243
|
const result = await Effect.runPromise(program);
|
|
218
244
|
console.log(`[runway] done — attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored} skipped=${result.skipped}`);
|
|
245
|
+
// VA-480: when `--target` was specified but no issue was attempted,
|
|
246
|
+
// the target wasn't in the ready queue. Surface as a non-zero
|
|
247
|
+
// exit so the planner's drain-spawn dispatcher routes the action
|
|
248
|
+
// to `action.failed` instead of `action.completed`. The drainQueue
|
|
249
|
+
// Effect already logged the warning; this is the exit-code half.
|
|
250
|
+
const targetMissed = opts.target !== undefined && result.attempts === 0;
|
|
219
251
|
// Single-line, parser-friendly completion marker. Background
|
|
220
252
|
// watchers (Claude Code's `run_in_background` bash task, CI,
|
|
221
253
|
// scripts) can grep for `[runway:exit]` instead of guessing
|
|
222
254
|
// whether the drain is still in flight.
|
|
223
|
-
console.log(`[runway:exit] status
|
|
255
|
+
console.log(`[runway:exit] status=${targetMissed ? "target_not_in_queue" : "success"} attempts=${result.attempts} opened=${result.opened} hitl=${result.hitl} errored=${result.errored} skipped=${result.skipped}`);
|
|
224
256
|
// Hard exit so any lingering handle (OTel BatchSpanProcessor's
|
|
225
257
|
// interval when OTEL_EXPORTER_OTLP_ENDPOINT is set, a Docker
|
|
226
258
|
// stream Sandcastle left open, etc.) can't keep the process — and
|
|
227
259
|
// the background task that launched it — alive after the drain is
|
|
228
260
|
// logically done. By this point `Effect.scoped` has already torn
|
|
229
261
|
// down its finalizers.
|
|
230
|
-
process.exit(0);
|
|
262
|
+
process.exit(targetMissed ? 1 : 0);
|
|
231
263
|
}
|
package/dist/config.js
CHANGED
|
@@ -45,6 +45,28 @@ const configEffect = EConfig.all({
|
|
|
45
45
|
metaFilterThresholdsJson: EConfig.option(EConfig.string("RUNWAY_META_FILTER_THRESHOLDS")),
|
|
46
46
|
runwayRepoProjectName: EConfig.option(EConfig.string("RUNWAY_REPO_PROJECT_NAME")),
|
|
47
47
|
metaWeeklyModel: EConfig.option(EConfig.string("RUNWAY_META_WEEKLY_MODEL")),
|
|
48
|
+
// VA-482: inline shepherd is retired — the four `RUNWAY_SHEPHERD_*`
|
|
49
|
+
// env vars (ENABLED / POLL_INTERVAL_SECONDS / MAX_ITERATIONS /
|
|
50
|
+
// MAX_WALL_SECONDS) are intentionally removed. Their work moved to
|
|
51
|
+
// the long-running `runway planner` daemon (capabilities VA-477..479)
|
|
52
|
+
// which has its own tick + budget knobs (`RUNWAY_PLANNER_*`).
|
|
53
|
+
// `RUNWAY_PR_REVIEWER_BOT_LOGIN` is preserved because the planner's
|
|
54
|
+
// reviewer dispatcher still consumes it.
|
|
55
|
+
prReviewerBotLogin: EConfig.option(EConfig.string("RUNWAY_PR_REVIEWER_BOT_LOGIN")),
|
|
56
|
+
// VA-473: planner daemon scaffold. Tick cadence + bind address for
|
|
57
|
+
// the long-running process. No capabilities wired yet — these
|
|
58
|
+
// values are consumed by `runway planner run` (heartbeat loop) and
|
|
59
|
+
// by the HTTP listener wired in VA-475.
|
|
60
|
+
plannerTickSeconds: EConfig.integer("RUNWAY_PLANNER_TICK_SECONDS").pipe(EConfig.withDefault(30), EConfig.validate({
|
|
61
|
+
message: "RUNWAY_PLANNER_TICK_SECONDS must be a positive integer",
|
|
62
|
+
validation: (n) => n > 0,
|
|
63
|
+
})),
|
|
64
|
+
plannerHost: EConfig.string("RUNWAY_PLANNER_HOST").pipe(EConfig.withDefault("127.0.0.1")),
|
|
65
|
+
plannerPort: EConfig.integer("RUNWAY_PLANNER_PORT").pipe(EConfig.withDefault(3002), EConfig.validate({
|
|
66
|
+
message: "RUNWAY_PLANNER_PORT must be a positive integer",
|
|
67
|
+
validation: (n) => n > 0 && n < 65536,
|
|
68
|
+
})),
|
|
69
|
+
plannerSqlitePath: EConfig.option(EConfig.string("RUNWAY_PLANNER_SQLITE_PATH")),
|
|
48
70
|
}).pipe(Effect.map((raw) => ({
|
|
49
71
|
linearApiKey: raw.linearApiKey,
|
|
50
72
|
opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
|
|
@@ -66,6 +88,11 @@ const configEffect = EConfig.all({
|
|
|
66
88
|
metaFilterThresholds: parseMetaFilterThresholdsJson(Option.getOrUndefined(raw.metaFilterThresholdsJson)),
|
|
67
89
|
runwayRepoProjectName: Option.getOrUndefined(raw.runwayRepoProjectName),
|
|
68
90
|
metaWeeklyModel: Option.getOrUndefined(raw.metaWeeklyModel),
|
|
91
|
+
prReviewerBotLogin: Option.getOrUndefined(raw.prReviewerBotLogin),
|
|
92
|
+
plannerTickSeconds: raw.plannerTickSeconds,
|
|
93
|
+
plannerHost: raw.plannerHost,
|
|
94
|
+
plannerPort: raw.plannerPort,
|
|
95
|
+
plannerSqlitePath: Option.getOrUndefined(raw.plannerSqlitePath),
|
|
69
96
|
})));
|
|
70
97
|
/**
|
|
71
98
|
* VA-359: Context tag for the resolved RunwayConfig. Provided by
|
|
@@ -148,6 +148,183 @@ function strArrayAttr(v) {
|
|
|
148
148
|
* in lock-step with `orchestrator.ts`.
|
|
149
149
|
*/
|
|
150
150
|
export const DRAIN_STARTED_LOG = "drain.started";
|
|
151
|
+
/**
|
|
152
|
+
* VA-474: log body strings the planner daemon emits for its six
|
|
153
|
+
* event types (ADR 0004 § Writer model). Kept in lock-step with the
|
|
154
|
+
* planner's `Effect.logInfo` call sites — drift between projector
|
|
155
|
+
* and planner means events stop projecting into the read-model.
|
|
156
|
+
*/
|
|
157
|
+
export const PLANNER_RECOMMENDATION_PROPOSED_LOG = "planner.recommendation.proposed";
|
|
158
|
+
export const PLANNER_RECOMMENDATION_ACCEPTED_LOG = "planner.recommendation.accepted";
|
|
159
|
+
export const PLANNER_RECOMMENDATION_REJECTED_LOG = "planner.recommendation.rejected";
|
|
160
|
+
export const PLANNER_ACTION_STARTED_LOG = "planner.action.started";
|
|
161
|
+
export const PLANNER_ACTION_COMPLETED_LOG = "planner.action.completed";
|
|
162
|
+
export const PLANNER_ACTION_FAILED_LOG = "planner.action.failed";
|
|
163
|
+
/**
|
|
164
|
+
* VA-481: emitted by the planner's HTTP PUT /planner/capabilities/:capability
|
|
165
|
+
* handler when an operator (or the planner's auto-promotion logic)
|
|
166
|
+
* changes a capability's mode (manual / suggest / auto). The dashboard
|
|
167
|
+
* projects each event into the `planner_capability_modes` table; the
|
|
168
|
+
* planner reads the table on every tick so toggles take effect on
|
|
169
|
+
* the next iteration without a daemon restart.
|
|
170
|
+
*/
|
|
171
|
+
export const PLANNER_CAPABILITY_MODE_CHANGED_LOG = "planner.capability.mode_changed";
|
|
172
|
+
export const PLANNER_CAPABILITY_MODES = new Set([
|
|
173
|
+
"manual",
|
|
174
|
+
"suggest",
|
|
175
|
+
"auto",
|
|
176
|
+
]);
|
|
177
|
+
const PLANNER_CAPABILITIES = new Set([
|
|
178
|
+
"queue-selection",
|
|
179
|
+
"shepherd-mergeability",
|
|
180
|
+
"shepherd-ci",
|
|
181
|
+
"shepherd-reviewer",
|
|
182
|
+
]);
|
|
183
|
+
/**
|
|
184
|
+
* VA-474: scan an OTLP logs payload for the six planner-event
|
|
185
|
+
* markers. Records that fail validation (missing required
|
|
186
|
+
* attribute, unknown capability, unparseable `evidence`) are
|
|
187
|
+
* dropped — the projector never crashes on a bad payload, but it
|
|
188
|
+
* also never fabricates fields the emitter didn't send.
|
|
189
|
+
*/
|
|
190
|
+
export function extractPlannerMarkers(payload) {
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const rl of payload.resourceLogs ?? []) {
|
|
193
|
+
for (const sl of rl.scopeLogs ?? []) {
|
|
194
|
+
for (const rec of sl.logRecords ?? []) {
|
|
195
|
+
const body = rec.body?.stringValue;
|
|
196
|
+
if (!body || !body.startsWith("planner."))
|
|
197
|
+
continue;
|
|
198
|
+
const ts = rec.timeUnixNano ?? rec.observedTimeUnixNano;
|
|
199
|
+
if (!ts)
|
|
200
|
+
continue;
|
|
201
|
+
const attrs = attributesToStringMap(rec.attributes);
|
|
202
|
+
if (body === PLANNER_RECOMMENDATION_PROPOSED_LOG) {
|
|
203
|
+
const recommendationId = attrs.recommendationId;
|
|
204
|
+
const capability = attrs.capability;
|
|
205
|
+
const target = attrs.target;
|
|
206
|
+
const summary = attrs.summary;
|
|
207
|
+
if (!recommendationId ||
|
|
208
|
+
!capability ||
|
|
209
|
+
!PLANNER_CAPABILITIES.has(capability) ||
|
|
210
|
+
!target ||
|
|
211
|
+
!summary) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
let evidence = {};
|
|
215
|
+
const rawEvidence = attrs.evidence;
|
|
216
|
+
if (rawEvidence) {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(rawEvidence);
|
|
219
|
+
if (parsed &&
|
|
220
|
+
typeof parsed === "object" &&
|
|
221
|
+
!Array.isArray(parsed)) {
|
|
222
|
+
evidence = parsed;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Malformed evidence JSON is non-fatal — emit with {}
|
|
227
|
+
// so the recommendation still lands.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
out.push({
|
|
231
|
+
kind: "recommendation.proposed",
|
|
232
|
+
recommendationId,
|
|
233
|
+
capability: capability,
|
|
234
|
+
target,
|
|
235
|
+
summary,
|
|
236
|
+
evidence,
|
|
237
|
+
autoApproveEligible: attrs.autoApproveEligible === "true",
|
|
238
|
+
timestampUnixNano: ts,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
else if (body === PLANNER_RECOMMENDATION_ACCEPTED_LOG ||
|
|
242
|
+
body === PLANNER_RECOMMENDATION_REJECTED_LOG) {
|
|
243
|
+
const recommendationId = attrs.recommendationId;
|
|
244
|
+
const source = attrs.source;
|
|
245
|
+
if (!recommendationId ||
|
|
246
|
+
(source !== "operator" && source !== "auto")) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const status = body === PLANNER_RECOMMENDATION_ACCEPTED_LOG
|
|
250
|
+
? "accepted"
|
|
251
|
+
: "rejected";
|
|
252
|
+
out.push({
|
|
253
|
+
kind: status === "accepted"
|
|
254
|
+
? "recommendation.accepted"
|
|
255
|
+
: "recommendation.rejected",
|
|
256
|
+
recommendationId,
|
|
257
|
+
status,
|
|
258
|
+
source,
|
|
259
|
+
reason: attrs.reason ?? null,
|
|
260
|
+
timestampUnixNano: ts,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
else if (body === PLANNER_ACTION_STARTED_LOG) {
|
|
264
|
+
const actionId = attrs.actionId;
|
|
265
|
+
const capability = attrs.capability;
|
|
266
|
+
const target = attrs.target;
|
|
267
|
+
if (!actionId ||
|
|
268
|
+
!capability ||
|
|
269
|
+
!PLANNER_CAPABILITIES.has(capability) ||
|
|
270
|
+
!target) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
out.push({
|
|
274
|
+
kind: "action.started",
|
|
275
|
+
actionId,
|
|
276
|
+
recommendationId: attrs.recommendationId ?? null,
|
|
277
|
+
capability: capability,
|
|
278
|
+
target,
|
|
279
|
+
timestampUnixNano: ts,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
else if (body === PLANNER_ACTION_COMPLETED_LOG) {
|
|
283
|
+
const actionId = attrs.actionId;
|
|
284
|
+
const outcome = attrs.outcome;
|
|
285
|
+
if (!actionId || !outcome)
|
|
286
|
+
continue;
|
|
287
|
+
out.push({
|
|
288
|
+
kind: "action.completed",
|
|
289
|
+
actionId,
|
|
290
|
+
outcome,
|
|
291
|
+
timestampUnixNano: ts,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
else if (body === PLANNER_ACTION_FAILED_LOG) {
|
|
295
|
+
const actionId = attrs.actionId;
|
|
296
|
+
const error = attrs.error;
|
|
297
|
+
if (!actionId || !error)
|
|
298
|
+
continue;
|
|
299
|
+
out.push({
|
|
300
|
+
kind: "action.failed",
|
|
301
|
+
actionId,
|
|
302
|
+
error,
|
|
303
|
+
timestampUnixNano: ts,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
else if (body === PLANNER_CAPABILITY_MODE_CHANGED_LOG) {
|
|
307
|
+
const capability = attrs.capability;
|
|
308
|
+
const mode = attrs.mode;
|
|
309
|
+
if (!capability ||
|
|
310
|
+
!PLANNER_CAPABILITIES.has(capability) ||
|
|
311
|
+
!mode ||
|
|
312
|
+
!PLANNER_CAPABILITY_MODES.has(mode)) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
out.push({
|
|
316
|
+
kind: "capability.mode_changed",
|
|
317
|
+
capability: capability,
|
|
318
|
+
mode: mode,
|
|
319
|
+
updatedBy: attrs.updatedBy ?? "operator",
|
|
320
|
+
timestampUnixNano: ts,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
151
328
|
/**
|
|
152
329
|
* VA-455: scan an OTLP logs payload for `drain.started` markers.
|
|
153
330
|
* Each match becomes an `ActiveDrainMarker` carrying the drain's
|