@valescoagency/runway 0.10.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.
package/README.md CHANGED
@@ -392,7 +392,7 @@ These are tractable, just not v1.
392
392
 
393
393
  ## Status
394
394
 
395
- 0.10.0 — production-shaped and dogfooded against live Linear queues.
395
+ 0.10.1 — production-shaped and dogfooded against live Linear queues.
396
396
  The end-to-end pipeline (init → run → review → PR) is stable; surface
397
397
  may still shift as the orchestrator's policy and iteration mechanics
398
398
  mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
@@ -2,6 +2,7 @@ import { Effect, Layer, Logger, RateLimiter } from "effect";
2
2
  import { ConfigLive, ConfigTag } from "../config.js";
3
3
  import { createLinearGateway } from "../linear.js";
4
4
  import { createGithubGateway } from "../github.js";
5
+ import { remoteRefExists } from "../git.js";
5
6
  import { assertSandcastleInitialised, drainQueue, } from "../orchestrator.js";
6
7
  import { TelemetryLive } from "../telemetry.js";
7
8
  export function parseRunArgs(argv) {
@@ -140,7 +141,14 @@ ENVIRONMENT
140
141
  and targets with PRs). Detected from
141
142
  origin/HEAD when unset.
142
143
  RUNWAY_READY_STATUS default "Todo"
143
- RUNWAY_IN_PROGRESS_STATUS default "In Progress"
144
+ RUNWAY_IN_PROGRESS_STATUS default "In Progress" — also the
145
+ auxiliary drain bucket (VA-421): runway
146
+ accepts issues in this status when no
147
+ agent/<id> branch exists on origin, so
148
+ Linear's GitHub auto-transitions (e.g.
149
+ an unrelated PR mentioning the issue in
150
+ its body) can't silently drop the issue
151
+ from the queue.
144
152
  RUNWAY_IN_REVIEW_STATUS default "In Review"
145
153
  RUNWAY_HITL_LABEL default "ready-for-human"
146
154
  RUNWAY_MAX_ITERATIONS default 5 — outer impl re-prompt loop
@@ -196,7 +204,14 @@ export async function runCommand(argv) {
196
204
  limit: 30,
197
205
  interval: "1 minute",
198
206
  });
199
- const linear = createLinearGateway(config, linearLimiter);
207
+ // VA-421: inject a git-side predicate so `fetchReady` can accept
208
+ // In-Progress issues whose `agent/<id>` branch hasn't yet been
209
+ // pushed to origin. Closes the Linear-auto-transition loophole
210
+ // where an unrelated PR-body mention silently drops an issue from
211
+ // the drain queue.
212
+ const linear = createLinearGateway(config, linearLimiter, {
213
+ remoteAgentBranchExists: (branch) => Effect.runPromise(remoteRefExists(cwd, branch)),
214
+ });
200
215
  const github = createGithubGateway();
201
216
  return yield* drainQueue({ config, linear, github, cwd }, { max: opts.max, allowPaths: opts.allowPaths });
202
217
  }).pipe(Effect.scoped, Effect.provide(MainLayer));
package/dist/git.js CHANGED
@@ -77,6 +77,28 @@ export const captureCommitLog = (repoPath, base, branch) => runExecaScoped("git"
77
77
  const raw = res.stdout;
78
78
  return typeof raw === "string" ? raw : "";
79
79
  }));
80
+ /**
81
+ * VA-421: Whether a branch ref exists on `origin`. Used by
82
+ * `fetchReady` to distinguish "an agent branch has been pushed (real
83
+ * work in flight, leave alone)" from "Linear auto-transitioned an
84
+ * issue to In-Progress without any work actually starting (drain
85
+ * it)". `git ls-remote --heads origin <branch>` is the cheapest
86
+ * authoritative check — one round-trip, no clone needed, SSH
87
+ * multiplexing usually caches the connection.
88
+ *
89
+ * Non-zero exit (network error, no remote configured, auth failure)
90
+ * surfaces as an Effect failure so the caller can route it through
91
+ * the usual error path rather than silently falling back to "doesn't
92
+ * exist" — operator should fix the env, not have runway quietly
93
+ * misclassify candidates.
94
+ */
95
+ export const remoteRefExists = (repoPath, branch) => runExecaScoped("git", ["ls-remote", "--heads", "origin", branch], { cwd: repoPath }, (err) => ({
96
+ message: err instanceof Error ? err.message : String(err),
97
+ })).pipe(Effect.map((res) => {
98
+ const raw = res.stdout;
99
+ const out = typeof raw === "string" ? raw : "";
100
+ return out.trim().length > 0;
101
+ }));
80
102
  /**
81
103
  * VA-358: Whether the agent branch has any commits beyond `base`.
82
104
  * Used by failure-routing to distinguish "agent crashed mid-run,
package/dist/linear.js CHANGED
@@ -232,7 +232,7 @@ function classifyLinearError(err, context) {
232
232
  * Production: `commands/run.ts` builds a limiter inside `Effect.scoped`
233
233
  * and passes it here.
234
234
  */
235
- export function createLinearGateway(config, limiter = null) {
235
+ export function createLinearGateway(config, limiter = null, opts = {}) {
236
236
  const client = new LinearClient({ apiKey: Redacted.value(config.linearApiKey) });
237
237
  const gate = (eff) => (limiter ? limiter(eff) : eff);
238
238
  async function findStateId(teamId, name) {
@@ -300,18 +300,40 @@ export function createLinearGateway(config, limiter = null) {
300
300
  const projectId = config.linearProject
301
301
  ? await findProjectId(config.linearProject)
302
302
  : null;
303
- const issues = await client.issues({
304
- filter: {
305
- team: { id: { eq: teamId } },
306
- state: { id: { eq: readyStateId } },
307
- ...(projectId ? { project: { id: { eq: projectId } } } : {}),
308
- },
303
+ // VA-421: optionally accept In-Progress issues whose
304
+ // `agent/<id>` branch hasn't been pushed to origin yet.
305
+ // Linear's GitHub integration auto-transitions issues to
306
+ // In-Progress on unrelated PR-body mentions, which used
307
+ // to silently drop them from the drain queue. The
308
+ // git-side check is the authoritative signal that no
309
+ // successful drain attempt is in flight.
310
+ const inProgressStateId = opts.remoteAgentBranchExists
311
+ ? await findStateId(teamId, config.inProgressStatus)
312
+ : null;
313
+ const issuesFilter = (stateId) => ({
314
+ team: { id: { eq: teamId } },
315
+ state: { id: { eq: stateId } },
316
+ ...(projectId ? { project: { id: { eq: projectId } } } : {}),
317
+ });
318
+ const todoBatch = await client.issues({
319
+ filter: issuesFilter(readyStateId),
309
320
  // VA-420: order doesn't matter at the SDK call — Linear's
310
321
  // `IssueOrderBy` enum doesn't expose `priority`, and the
311
322
  // SDK type for this argument hides the ASC/DESC direction
312
323
  // anyway. We re-sort the candidate set in JS below.
313
324
  orderBy: "createdAt",
314
325
  });
326
+ const inProgressBatch = inProgressStateId
327
+ ? await client.issues({
328
+ filter: issuesFilter(inProgressStateId),
329
+ orderBy: "createdAt",
330
+ })
331
+ : { nodes: [] };
332
+ const tagged = [
333
+ ...todoBatch.nodes.map((raw) => ({ raw, kind: "todo" })),
334
+ ...inProgressBatch.nodes.map((raw) => ({ raw, kind: "inProgress" })),
335
+ ];
336
+ const issues = { nodes: tagged };
315
337
  // VA-420: drain by Linear priority first, then by createdAt
316
338
  // within a priority bucket. Linear's priority encoding is
317
339
  // non-monotonic — `1 = Urgent, 2 = High, 3 = Medium,
@@ -320,12 +342,12 @@ export function createLinearGateway(config, limiter = null) {
320
342
  // (operator intuition: Urgent first, unprioritised last).
321
343
  // Within a bucket, oldest-first restores the FIFO intent.
322
344
  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;
345
+ const pa = a.raw.priority === 0 ? Infinity : a.raw.priority;
346
+ const pb = b.raw.priority === 0 ? Infinity : b.raw.priority;
325
347
  if (pa !== pb)
326
348
  return pa - pb;
327
- return (new Date(a.createdAt).getTime() -
328
- new Date(b.createdAt).getTime());
349
+ return (new Date(a.raw.createdAt).getTime() -
350
+ new Date(b.raw.createdAt).getTime());
329
351
  });
330
352
  // VA-360: validate every issue node through the schema —
331
353
  // a single drifted issue surfaces as `LinearSchemaError`
@@ -340,13 +362,24 @@ export function createLinearGateway(config, limiter = null) {
340
362
  // candidate go through the rate limiter and the retry
341
363
  // policy alongside everything else.
342
364
  const eligible = [];
343
- for (const raw of candidates) {
365
+ for (const { raw, kind } of candidates) {
344
366
  const i = decodeIssueNode(raw);
345
367
  const labels = await fetchIssueLabelNames(raw);
346
368
  if (labels.includes(config.hitlLabel))
347
369
  continue;
348
370
  if (await hasActiveBlocker(raw))
349
371
  continue;
372
+ // VA-421: In-Progress candidates pass only if no
373
+ // `agent/<id>` ref exists on origin — that's the
374
+ // authoritative "no drain attempt in flight" signal,
375
+ // immune to Linear's auto-transitions. Todo candidates
376
+ // skip this check (no behavior change for the common
377
+ // path).
378
+ if (kind === "inProgress" && opts.remoteAgentBranchExists) {
379
+ const branch = `agent/${i.identifier.toLowerCase()}`;
380
+ if (await opts.remoteAgentBranchExists(branch))
381
+ continue;
382
+ }
350
383
  eligible.push({
351
384
  id: i.id,
352
385
  identifier: i.identifier,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Linear-driven orchestrator + scaffolder for coding agents on Sandcastle. `runway init` scaffolds a target repo (sandcastle + varlock + 1Password); `runway run` drains a Linear queue against it; `runway doctor`, `runway upgrade`, `runway upgrade-repo` round out the lifecycle.",
5
5
  "license": "MIT",
6
6
  "author": {