patchrelay 0.48.0 → 0.49.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
@@ -119,8 +119,8 @@ See the [merge-steward package README](./packages/merge-steward/README.md) for t
119
119
  - [Prompting](./docs/prompting.md) — how workflow files and the built-in scaffold compose
120
120
  - [Secrets](./docs/secrets.md) — systemd credentials, resolution order
121
121
  - [review-quill reference](./docs/review-quill.md) · [merge-steward reference](./docs/merge-steward.md)
122
- - [Design docs](./docs/design-docs/index.md) · [Core beliefs](./docs/design-docs/core-beliefs.md)
123
- - [Security policy](./SECURITY.md)
122
+ - [Product specs](./docs/product-specs/index.md) · [Design docs](./docs/design-docs/index.md) · [Core beliefs](./docs/design-docs/core-beliefs.md)
123
+ - [Contributing](./CONTRIBUTING.md) · [Security policy](./SECURITY.md)
124
124
 
125
125
  ## Status
126
126
 
@@ -104,6 +104,17 @@ function resolvePlanRunType(params) {
104
104
  }
105
105
  export function buildAgentSessionPlan(params) {
106
106
  if (params.issueClass === "orchestration") {
107
+ const settling = params.orchestrationSettleUntil
108
+ ? Number.isFinite(Date.parse(params.orchestrationSettleUntil)) && Date.parse(params.orchestrationSettleUntil) > Date.now()
109
+ : false;
110
+ if (settling) {
111
+ return [
112
+ { content: "Wait for child set to settle", status: "inProgress" },
113
+ { content: "Review umbrella goal and child set", status: "pending" },
114
+ { content: "Wait for or inspect child progress", status: "pending" },
115
+ { content: "Audit delivered outcome", status: "pending" },
116
+ ];
117
+ }
107
118
  switch (params.factoryState) {
108
119
  case "done":
109
120
  return setStatuses(orchestrationPlan(), ["completed", "completed", "completed", "completed"]);
@@ -181,6 +192,7 @@ export function buildAgentSessionPlanForIssue(issue, options) {
181
192
  ciRepairAttempts: issue.ciRepairAttempts,
182
193
  queueRepairAttempts: issue.queueRepairAttempts,
183
194
  ...(issue.issueClass ? { issueClass: issue.issueClass } : {}),
195
+ ...(issue.orchestrationSettleUntil ? { orchestrationSettleUntil: issue.orchestrationSettleUntil } : {}),
184
196
  ...(issue.pendingRunType ? { pendingRunType: issue.pendingRunType } : {}),
185
197
  ...(options?.activeRunType ? { activeRunType: options.activeRunType } : {}),
186
198
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.48.0",
4
- "commit": "a606bc8b729a",
5
- "builtAt": "2026-04-18T14:29:29.224Z"
3
+ "version": "0.49.1",
4
+ "commit": "b14320b27556",
5
+ "builtAt": "2026-04-19T11:22:46.139Z"
6
6
  }
package/dist/cli/data.js CHANGED
@@ -9,6 +9,8 @@ import { buildManualRetryAttemptReset, resolveRetryTarget } from "../manual-issu
9
9
  import { WorktreeManager } from "../worktree-manager.js";
10
10
  import { parseDelegationObservedPayload, parseRunReleasedAuthorityPayload } from "../delegation-audit.js";
11
11
  import { CliOperatorApiClient } from "./operator-client.js";
12
+ import { resolveEffectiveActiveRun } from "../effective-active-run.js";
13
+ import { derivePatchRelayWaitingReason } from "../waiting-reason.js";
12
14
  function safeJsonParse(value) {
13
15
  if (!value)
14
16
  return undefined;
@@ -105,8 +107,11 @@ export class CliDataAccess extends CliOperatorApiClient {
105
107
  if (!issue)
106
108
  return undefined;
107
109
  const dbIssue = this.db.issues.getIssueByKey(issueKey);
108
- const activeRun = dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined;
109
110
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
111
+ const activeRun = resolveEffectiveActiveRun({
112
+ activeRun: dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined,
113
+ latestRun,
114
+ });
110
115
  const latestReport = normalizeStageReport(latestRun?.reportJson, latestRun?.status);
111
116
  const latestSummary = safeJsonParse(latestRun?.summaryJson);
112
117
  const completionCheck = latestRun ? extractCompletionCheck(latestRun) : undefined;
@@ -139,7 +144,10 @@ export class CliDataAccess extends CliOperatorApiClient {
139
144
  if (!issue)
140
145
  return undefined;
141
146
  const dbIssue = this.db.issues.getIssueByKey(issueKey);
142
- const run = dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined;
147
+ const run = resolveEffectiveActiveRun({
148
+ activeRun: dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined,
149
+ latestRun: this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId),
150
+ });
143
151
  if (!run)
144
152
  return undefined;
145
153
  const live = run.threadId &&
@@ -469,19 +477,36 @@ export class CliDataAccess extends CliOperatorApiClient {
469
477
  ORDER BY i.updated_at DESC, i.issue_key ASC
470
478
  `)
471
479
  .all(...values);
472
- const items = rows.map((row) => ({
473
- ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
474
- ...(row.title !== null ? { title: String(row.title) } : {}),
475
- projectId: String(row.project_id),
476
- ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
477
- ...(row.session_state !== null ? { sessionState: String(row.session_state) } : {}),
478
- factoryState: String(row.factory_state ?? "delegated"),
479
- ...(row.waiting_reason !== null ? { waitingReason: String(row.waiting_reason) } : {}),
480
- ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
481
- ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
482
- ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
483
- updatedAt: String(row.updated_at),
484
- }));
480
+ const items = rows.map((row) => {
481
+ const detachedActiveRun = row.active_run_type === null
482
+ && (row.latest_run_status === "queued" || row.latest_run_status === "running");
483
+ const activeRunType = row.active_run_type !== null
484
+ ? String(row.active_run_type)
485
+ : detachedActiveRun && row.latest_run_type !== null
486
+ ? String(row.latest_run_type)
487
+ : undefined;
488
+ const waitingReason = detachedActiveRun
489
+ ? derivePatchRelayWaitingReason({
490
+ activeRunId: 1,
491
+ factoryState: String(row.factory_state ?? "delegated"),
492
+ })
493
+ : row.waiting_reason !== null
494
+ ? String(row.waiting_reason)
495
+ : undefined;
496
+ return {
497
+ ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
498
+ ...(row.title !== null ? { title: String(row.title) } : {}),
499
+ projectId: String(row.project_id),
500
+ ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
501
+ ...(row.session_state !== null ? { sessionState: detachedActiveRun ? "running" : String(row.session_state) } : {}),
502
+ factoryState: String(row.factory_state ?? "delegated"),
503
+ ...(waitingReason ? { waitingReason } : {}),
504
+ ...(activeRunType ? { activeRunType } : {}),
505
+ ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
506
+ ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
507
+ updatedAt: String(row.updated_at),
508
+ };
509
+ });
485
510
  return items.filter((item) => {
486
511
  if (options?.active && !item.activeRunType)
487
512
  return false;
@@ -28,6 +28,14 @@ export class IssueStore {
28
28
  sets.push("issue_class_source = @issueClassSource");
29
29
  values.issueClassSource = params.issueClassSource;
30
30
  }
31
+ if (params.parentLinearIssueId !== undefined) {
32
+ sets.push("parent_linear_issue_id = @parentLinearIssueId");
33
+ values.parentLinearIssueId = params.parentLinearIssueId;
34
+ }
35
+ if (params.parentIssueKey !== undefined) {
36
+ sets.push("parent_issue_key = @parentIssueKey");
37
+ values.parentIssueKey = params.parentIssueKey;
38
+ }
31
39
  if (params.issueKey !== undefined) {
32
40
  sets.push("issue_key = COALESCE(@issueKey, issue_key)");
33
41
  values.issueKey = params.issueKey;
@@ -224,12 +232,16 @@ export class IssueStore {
224
232
  sets.push("last_zombie_recovery_at = @lastZombieRecoveryAt");
225
233
  values.lastZombieRecoveryAt = params.lastZombieRecoveryAt;
226
234
  }
235
+ if (params.orchestrationSettleUntil !== undefined) {
236
+ sets.push("orchestration_settle_until = @orchestrationSettleUntil");
237
+ values.orchestrationSettleUntil = params.orchestrationSettleUntil;
238
+ }
227
239
  this.connection.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`).run(values);
228
240
  }
229
241
  else {
230
242
  this.connection.prepare(`
231
243
  INSERT INTO issues (
232
- project_id, linear_issue_id, delegated_to_patchrelay, issue_class, issue_class_source, issue_key, title, description, url,
244
+ project_id, linear_issue_id, delegated_to_patchrelay, issue_class, issue_class_source, parent_linear_issue_id, parent_issue_key, issue_key, title, description, url,
233
245
  priority, estimate,
234
246
  current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
235
247
  branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
@@ -239,10 +251,10 @@ export class IssueStore {
239
251
  last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
240
252
  last_queue_signal_at, last_queue_incident_json,
241
253
  last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
242
- ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
254
+ ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at, orchestration_settle_until,
243
255
  updated_at
244
256
  ) VALUES (
245
- @projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @issueKey, @title, @description, @url,
257
+ @projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @parentLinearIssueId, @parentIssueKey, @issueKey, @title, @description, @url,
246
258
  @priority, @estimate,
247
259
  @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
248
260
  @branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
@@ -252,7 +264,7 @@ export class IssueStore {
252
264
  @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
253
265
  @lastQueueSignalAt, @lastQueueIncidentJson,
254
266
  @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
255
- @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt,
267
+ @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt, @orchestrationSettleUntil,
256
268
  @now
257
269
  )
258
270
  `).run({
@@ -261,6 +273,8 @@ export class IssueStore {
261
273
  delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
262
274
  issueClass: params.issueClass ?? null,
263
275
  issueClassSource: params.issueClassSource ?? null,
276
+ parentLinearIssueId: params.parentLinearIssueId ?? null,
277
+ parentIssueKey: params.parentIssueKey ?? null,
264
278
  issueKey: params.issueKey ?? null,
265
279
  title: params.title ?? null,
266
280
  description: params.description ?? null,
@@ -310,6 +324,7 @@ export class IssueStore {
310
324
  reviewFixAttempts: params.reviewFixAttempts ?? 0,
311
325
  zombieRecoveryAttempts: params.zombieRecoveryAttempts ?? 0,
312
326
  lastZombieRecoveryAt: params.lastZombieRecoveryAt ?? null,
327
+ orchestrationSettleUntil: params.orchestrationSettleUntil ?? null,
313
328
  now,
314
329
  });
315
330
  }
@@ -456,6 +471,67 @@ export class IssueStore {
456
471
  linearIssueId: String(row.linear_issue_id),
457
472
  }));
458
473
  }
474
+ replaceIssueParentLink(params) {
475
+ const now = isoNow();
476
+ this.connection
477
+ .prepare("DELETE FROM issue_children WHERE project_id = ? AND child_linear_issue_id = ?")
478
+ .run(params.projectId, params.childLinearIssueId);
479
+ if (!params.parentLinearIssueId) {
480
+ return;
481
+ }
482
+ this.connection.prepare(`
483
+ INSERT INTO issue_children (
484
+ project_id,
485
+ parent_linear_issue_id,
486
+ child_linear_issue_id,
487
+ updated_at
488
+ ) VALUES (?, ?, ?, ?)
489
+ `).run(params.projectId, params.parentLinearIssueId, params.childLinearIssueId, now);
490
+ }
491
+ listChildLinks(projectId, parentLinearIssueId) {
492
+ const rows = this.connection.prepare(`
493
+ SELECT project_id, parent_linear_issue_id, child_linear_issue_id, updated_at
494
+ FROM issue_children
495
+ WHERE project_id = ? AND parent_linear_issue_id = ?
496
+ ORDER BY child_linear_issue_id ASC
497
+ `).all(projectId, parentLinearIssueId);
498
+ return rows.map((row) => ({
499
+ projectId: String(row.project_id),
500
+ parentLinearIssueId: String(row.parent_linear_issue_id),
501
+ childLinearIssueId: String(row.child_linear_issue_id),
502
+ updatedAt: String(row.updated_at),
503
+ }));
504
+ }
505
+ listChildIssues(projectId, parentLinearIssueId) {
506
+ const rows = this.connection.prepare(`
507
+ SELECT child.*
508
+ FROM issue_children edges
509
+ JOIN issues child
510
+ ON child.project_id = edges.project_id
511
+ AND child.linear_issue_id = edges.child_linear_issue_id
512
+ WHERE edges.project_id = ? AND edges.parent_linear_issue_id = ?
513
+ ORDER BY COALESCE(child.issue_key, child.linear_issue_id) ASC
514
+ `).all(projectId, parentLinearIssueId);
515
+ return rows.map(mapIssueRow);
516
+ }
517
+ countOpenChildIssues(projectId, parentLinearIssueId) {
518
+ const row = this.connection.prepare(`
519
+ SELECT COUNT(*) AS count
520
+ FROM issue_children edges
521
+ LEFT JOIN issues child
522
+ ON child.project_id = edges.project_id
523
+ AND child.linear_issue_id = edges.child_linear_issue_id
524
+ WHERE edges.project_id = ? AND edges.parent_linear_issue_id = ?
525
+ AND (
526
+ child.linear_issue_id IS NULL
527
+ OR (
528
+ COALESCE(child.current_linear_state_type, '') NOT IN ('completed', 'canceled')
529
+ AND LOWER(TRIM(COALESCE(child.current_linear_state, ''))) NOT IN ('done', 'duplicate', 'canceled')
530
+ )
531
+ )
532
+ `).get(projectId, parentLinearIssueId);
533
+ return Number(row?.count ?? 0);
534
+ }
459
535
  countUnresolvedBlockers(projectId, linearIssueId) {
460
536
  const row = this.connection.prepare(`
461
537
  SELECT COUNT(*) AS count
@@ -493,6 +569,10 @@ export function mapIssueRow(row) {
493
569
  ...(row.issue_class_source !== null && row.issue_class_source !== undefined
494
570
  ? { issueClassSource: String(row.issue_class_source) }
495
571
  : {}),
572
+ ...(row.parent_linear_issue_id !== null && row.parent_linear_issue_id !== undefined
573
+ ? { parentLinearIssueId: String(row.parent_linear_issue_id) }
574
+ : {}),
575
+ ...(row.parent_issue_key !== null && row.parent_issue_key !== undefined ? { parentIssueKey: String(row.parent_issue_key) } : {}),
496
576
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
497
577
  ...(row.title !== null ? { title: String(row.title) } : {}),
498
578
  ...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
@@ -583,5 +663,8 @@ export function mapIssueRow(row) {
583
663
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
584
664
  zombieRecoveryAttempts: Number(row.zombie_recovery_attempts ?? 0),
585
665
  ...(row.last_zombie_recovery_at !== null && row.last_zombie_recovery_at !== undefined ? { lastZombieRecoveryAt: String(row.last_zombie_recovery_at) } : {}),
666
+ ...(row.orchestration_settle_until !== null && row.orchestration_settle_until !== undefined
667
+ ? { orchestrationSettleUntil: String(row.orchestration_settle_until) }
668
+ : {}),
586
669
  };
587
670
  }
@@ -6,6 +6,8 @@ CREATE TABLE IF NOT EXISTS issues (
6
6
  delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
7
7
  issue_class TEXT,
8
8
  issue_class_source TEXT,
9
+ parent_linear_issue_id TEXT,
10
+ parent_issue_key TEXT,
9
11
  issue_key TEXT,
10
12
  title TEXT,
11
13
  url TEXT,
@@ -32,6 +34,7 @@ CREATE TABLE IF NOT EXISTS issues (
32
34
  last_blocking_review_head_sha TEXT,
33
35
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
34
36
  queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
37
+ orchestration_settle_until TEXT,
35
38
  updated_at TEXT NOT NULL,
36
39
  UNIQUE(project_id, linear_issue_id)
37
40
  );
@@ -218,6 +221,14 @@ CREATE TABLE IF NOT EXISTS issue_dependencies (
218
221
  PRIMARY KEY (project_id, linear_issue_id, blocker_linear_issue_id)
219
222
  );
220
223
 
224
+ CREATE TABLE IF NOT EXISTS issue_children (
225
+ project_id TEXT NOT NULL,
226
+ parent_linear_issue_id TEXT NOT NULL,
227
+ child_linear_issue_id TEXT NOT NULL,
228
+ updated_at TEXT NOT NULL,
229
+ PRIMARY KEY (project_id, parent_linear_issue_id, child_linear_issue_id)
230
+ );
231
+
221
232
  CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
222
233
  CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
223
234
  CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
@@ -238,6 +249,8 @@ CREATE INDEX IF NOT EXISTS idx_linear_catalog_teams_installation ON linear_catal
238
249
  CREATE INDEX IF NOT EXISTS idx_linear_catalog_projects_installation ON linear_catalog_projects(installation_id, project_name);
239
250
  CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(project_id, linear_issue_id);
240
251
  CREATE INDEX IF NOT EXISTS idx_issue_dependencies_blocker ON issue_dependencies(project_id, blocker_linear_issue_id);
252
+ CREATE INDEX IF NOT EXISTS idx_issue_children_parent ON issue_children(project_id, parent_linear_issue_id);
253
+ CREATE INDEX IF NOT EXISTS idx_issue_children_child ON issue_children(project_id, child_linear_issue_id);
241
254
  `;
242
255
  export function runPatchRelayMigrations(connection) {
243
256
  connection.exec(schema);
@@ -246,6 +259,18 @@ export function runPatchRelayMigrations(connection) {
246
259
  addColumnIfMissing(connection, "issues", "delegated_to_patchrelay", "INTEGER NOT NULL DEFAULT 1");
247
260
  addColumnIfMissing(connection, "issues", "issue_class", "TEXT");
248
261
  addColumnIfMissing(connection, "issues", "issue_class_source", "TEXT");
262
+ addColumnIfMissing(connection, "issues", "parent_linear_issue_id", "TEXT");
263
+ addColumnIfMissing(connection, "issues", "parent_issue_key", "TEXT");
264
+ addColumnIfMissing(connection, "issues", "orchestration_settle_until", "TEXT");
265
+ // Earlier releases persisted derived classifications as "explicit", which
266
+ // made bad umbrella guesses sticky forever. We do not have a user-authored
267
+ // explicit classification path yet, so downgrade old rows back to heuristic
268
+ // and let current classification logic recompute them.
269
+ connection.prepare(`
270
+ UPDATE issues
271
+ SET issue_class_source = 'heuristic'
272
+ WHERE issue_class_source = 'explicit'
273
+ `).run();
249
274
  // Add pending_merge_prep column for merge queue stewardship
250
275
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
251
276
  // Add merge_prep_attempts for retry budget / escalation
@@ -330,6 +355,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
330
355
  delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
331
356
  issue_class TEXT,
332
357
  issue_class_source TEXT,
358
+ parent_linear_issue_id TEXT,
359
+ parent_issue_key TEXT,
333
360
  issue_key TEXT,
334
361
  title TEXT,
335
362
  description TEXT,
@@ -379,6 +406,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
379
406
  review_fix_attempts INTEGER NOT NULL DEFAULT 0,
380
407
  zombie_recovery_attempts INTEGER NOT NULL DEFAULT 0,
381
408
  last_zombie_recovery_at TEXT,
409
+ orchestration_settle_until TEXT,
382
410
  updated_at TEXT NOT NULL,
383
411
  UNIQUE(project_id, linear_issue_id)
384
412
  );
@@ -390,6 +418,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
390
418
  delegated_to_patchrelay,
391
419
  issue_class,
392
420
  issue_class_source,
421
+ parent_linear_issue_id,
422
+ parent_issue_key,
393
423
  issue_key,
394
424
  title,
395
425
  description,
@@ -439,6 +469,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
439
469
  review_fix_attempts,
440
470
  zombie_recovery_attempts,
441
471
  last_zombie_recovery_at,
472
+ orchestration_settle_until,
442
473
  updated_at
443
474
  )
444
475
  SELECT
@@ -448,6 +479,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
448
479
  COALESCE(delegated_to_patchrelay, 1),
449
480
  issue_class,
450
481
  issue_class_source,
482
+ parent_linear_issue_id,
483
+ parent_issue_key,
451
484
  issue_key,
452
485
  title,
453
486
  description,
@@ -497,6 +530,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
497
530
  COALESCE(review_fix_attempts, 0),
498
531
  COALESCE(zombie_recovery_attempts, 0),
499
532
  last_zombie_recovery_at,
533
+ orchestration_settle_until,
500
534
  updated_at
501
535
  FROM issues;
502
536
 
package/dist/db.js CHANGED
@@ -154,6 +154,18 @@ export class PatchRelayDatabase {
154
154
  listDependents(projectId, blockerLinearIssueId) {
155
155
  return this.issues.listDependents(projectId, blockerLinearIssueId);
156
156
  }
157
+ replaceIssueParentLink(params) {
158
+ this.issues.replaceIssueParentLink(params);
159
+ }
160
+ listChildLinks(projectId, parentLinearIssueId) {
161
+ return this.issues.listChildLinks(projectId, parentLinearIssueId);
162
+ }
163
+ listChildIssues(projectId, parentLinearIssueId) {
164
+ return this.issues.listChildIssues(projectId, parentLinearIssueId);
165
+ }
166
+ countOpenChildIssues(projectId, parentLinearIssueId) {
167
+ return this.issues.countOpenChildIssues(projectId, parentLinearIssueId);
168
+ }
157
169
  getLatestGitHubCiSnapshot(projectId, linearIssueId) {
158
170
  return this.issues.getLatestGitHubCiSnapshot(projectId, linearIssueId);
159
171
  }
@@ -0,0 +1,15 @@
1
+ function isActiveRunStatus(status) {
2
+ return status === "queued" || status === "running";
3
+ }
4
+ export function hasDetachedActiveLatestRun(params) {
5
+ return params.activeRunId === undefined
6
+ && params.latestRun !== undefined
7
+ && isActiveRunStatus(params.latestRun.status);
8
+ }
9
+ export function resolveEffectiveActiveRun(params) {
10
+ if (params.activeRun)
11
+ return params.activeRun;
12
+ if (params.latestRun && isActiveRunStatus(params.latestRun.status))
13
+ return params.latestRun;
14
+ return undefined;
15
+ }
@@ -5,6 +5,7 @@ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
5
5
  import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
6
6
  import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
7
7
  import { getReviewFixBudget } from "./run-budgets.js";
8
+ import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
8
9
  import { execCommand } from "./utils.js";
9
10
  function isFailingCheckStatus(status) {
10
11
  return status === "failed" || status === "failure";
@@ -170,6 +171,24 @@ export class IdleIssueReconciler {
170
171
  }
171
172
  }
172
173
  }
174
+ const now = Date.now();
175
+ for (const issue of this.db.issues.listIssues()) {
176
+ if (issue.issueClass !== "orchestration"
177
+ || !issue.orchestrationSettleUntil
178
+ || issue.activeRunId !== undefined
179
+ || !issue.delegatedToPatchRelay) {
180
+ continue;
181
+ }
182
+ const settleAt = Date.parse(issue.orchestrationSettleUntil);
183
+ if (!Number.isFinite(settleAt) || settleAt > now) {
184
+ continue;
185
+ }
186
+ queueSettledOrchestrationIssue({
187
+ db: this.db,
188
+ issue,
189
+ enqueueIssue: this.deps.enqueueIssue,
190
+ });
191
+ }
173
192
  }
174
193
  shouldProbeTerminalIssueFromGitHub(issue) {
175
194
  if (issue.prNumber === undefined)
@@ -18,10 +18,14 @@ function looksLikeUmbrellaText(issue) {
18
18
  ].some((token) => haystack.includes(token));
19
19
  }
20
20
  export function classifyIssue(params) {
21
- if (params.issue.issueClass === "implementation" || params.issue.issueClass === "orchestration") {
21
+ if (params.issue.issueClassSource === "explicit"
22
+ && (params.issue.issueClass === "implementation" || params.issue.issueClass === "orchestration")) {
22
23
  return { issueClass: params.issue.issueClass, issueClassSource: "explicit" };
23
24
  }
24
- if (params.trackedDependentCount > 0) {
25
+ if (params.issue.parentLinearIssueId) {
26
+ return { issueClass: "implementation", issueClassSource: "hierarchy" };
27
+ }
28
+ if (params.childIssueCount > 0) {
25
29
  return { issueClass: "orchestration", issueClassSource: "hierarchy" };
26
30
  }
27
31
  if (looksLikeUmbrellaText(params.issue)) {
@@ -94,6 +94,7 @@ export class IssueOverviewQuery {
94
94
  blockedByKeys,
95
95
  factoryState: issueRecord?.factoryState ?? "delegated",
96
96
  pendingRunType: issueRecord?.pendingRunType,
97
+ orchestrationSettleUntil: issueRecord?.orchestrationSettleUntil,
97
98
  prNumber: session.prNumber,
98
99
  prState: issueRecord?.prState,
99
100
  prHeadSha: issueRecord?.prHeadSha ?? session.prHeadSha,
@@ -128,6 +129,7 @@ export class IssueOverviewQuery {
128
129
  blockedByCount: unresolvedBlockedBy.length,
129
130
  hasPendingWake: this.db.issueSessions.peekIssueSessionWake(session.projectId, session.linearIssueId) !== undefined,
130
131
  hasLegacyPendingRun: issueRecord?.pendingRunType !== undefined,
132
+ orchestrationSettleUntil: issueRecord?.orchestrationSettleUntil,
131
133
  ...(session.prNumber !== undefined ? { prNumber: session.prNumber } : {}),
132
134
  ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
133
135
  ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
@@ -88,6 +88,12 @@ export function isIssueSessionReadyForExecution(params) {
88
88
  return false;
89
89
  if (params.blockedByCount > 0)
90
90
  return false;
91
+ if (params.orchestrationSettleUntil) {
92
+ const settleAt = Date.parse(params.orchestrationSettleUntil);
93
+ if (Number.isFinite(settleAt) && settleAt > Date.now()) {
94
+ return false;
95
+ }
96
+ }
91
97
  if (params.sessionState === "done" || params.sessionState === "waiting_input") {
92
98
  return false;
93
99
  }
@@ -2,6 +2,16 @@ import { refreshLinearOAuthToken } from "./linear-oauth.js";
2
2
  import { decryptSecret, encryptSecret } from "./token-crypto.js";
3
3
  const LINEAR_ISSUE_SELECTION = `
4
4
  id
5
+ parent {
6
+ id
7
+ identifier
8
+ title
9
+ state {
10
+ id
11
+ name
12
+ type
13
+ }
14
+ }
5
15
  identifier
6
16
  title
7
17
  description
@@ -334,6 +344,9 @@ export class LinearGraphqlClient {
334
344
  }));
335
345
  return {
336
346
  id: issue.id,
347
+ ...(issue.parent?.id ? { parentId: issue.parent.id } : {}),
348
+ ...(issue.parent?.identifier ? { parentIdentifier: issue.parent.identifier } : {}),
349
+ ...(issue.parent?.title ? { parentTitle: issue.parent.title } : {}),
337
350
  ...(issue.identifier ? { identifier: issue.identifier } : {}),
338
351
  ...(issue.title ? { title: issue.title } : {}),
339
352
  ...(issue.description ? { description: issue.description } : {}),
@@ -40,6 +40,7 @@ function renderStatusComment(db, issue, trackedIssue, options) {
40
40
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
41
41
  factoryState: issue.factoryState,
42
42
  pendingRunType: issue.pendingRunType,
43
+ orchestrationSettleUntil: issue.orchestrationSettleUntil,
43
44
  ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
44
45
  ...(issue.prState ? { prState: issue.prState } : {}),
45
46
  prHeadSha: issue.prHeadSha,
@@ -168,6 +168,9 @@ export async function handleNoPrCompletionCheck(params) {
168
168
  });
169
169
  return;
170
170
  }
171
+ const orchestrationOpenChildren = params.issue.issueClass === "orchestration"
172
+ ? params.db.issues.countOpenChildIssues(params.run.projectId, params.run.linearIssueId)
173
+ : 0;
171
174
  const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
172
175
  params.db.runs.finishRun(params.run.id, completedRunUpdate);
173
176
  params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
@@ -176,9 +179,10 @@ export async function handleNoPrCompletionCheck(params) {
176
179
  projectId: params.run.projectId,
177
180
  linearIssueId: params.run.linearIssueId,
178
181
  activeRunId: null,
179
- factoryState: "done",
182
+ factoryState: params.issue.issueClass === "orchestration" && orchestrationOpenChildren > 0 ? "delegated" : "done",
180
183
  pendingRunType: null,
181
184
  pendingRunContextJson: null,
185
+ orchestrationSettleUntil: null,
182
186
  lastGitHubFailureSource: null,
183
187
  lastGitHubFailureHeadSha: null,
184
188
  lastGitHubFailureSignature: null,
@@ -203,8 +207,12 @@ export async function handleNoPrCompletionCheck(params) {
203
207
  fallbackIssue: params.issue,
204
208
  level: "info",
205
209
  status: "completion_check_done",
206
- summary: "No PR found; confirmed done",
207
- detail: completionCheck.summary,
210
+ summary: params.issue.issueClass === "orchestration" && orchestrationOpenChildren > 0
211
+ ? "No PR found; orchestration will wait on child deliveries"
212
+ : "No PR found; confirmed done",
213
+ detail: params.issue.issueClass === "orchestration" && orchestrationOpenChildren > 0
214
+ ? `${completionCheck.summary} Waiting on ${orchestrationOpenChildren} open child issue(s) before final convergence.`
215
+ : completionCheck.summary,
208
216
  activity: buildCompletionCheckActivity("done", completionCheck),
209
217
  });
210
218
  const doneIssue = params.db.issues.getIssue(params.run.projectId, params.run.linearIssueId) ?? params.issue;