patchrelay 0.47.2 → 0.49.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.
@@ -20,6 +20,22 @@ export class IssueStore {
20
20
  sets.push("delegated_to_patchrelay = @delegatedToPatchRelay");
21
21
  values.delegatedToPatchRelay = params.delegatedToPatchRelay ? 1 : 0;
22
22
  }
23
+ if (params.issueClass !== undefined) {
24
+ sets.push("issue_class = @issueClass");
25
+ values.issueClass = params.issueClass;
26
+ }
27
+ if (params.issueClassSource !== undefined) {
28
+ sets.push("issue_class_source = @issueClassSource");
29
+ values.issueClassSource = params.issueClassSource;
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
+ }
23
39
  if (params.issueKey !== undefined) {
24
40
  sets.push("issue_key = COALESCE(@issueKey, issue_key)");
25
41
  values.issueKey = params.issueKey;
@@ -216,12 +232,16 @@ export class IssueStore {
216
232
  sets.push("last_zombie_recovery_at = @lastZombieRecoveryAt");
217
233
  values.lastZombieRecoveryAt = params.lastZombieRecoveryAt;
218
234
  }
235
+ if (params.orchestrationSettleUntil !== undefined) {
236
+ sets.push("orchestration_settle_until = @orchestrationSettleUntil");
237
+ values.orchestrationSettleUntil = params.orchestrationSettleUntil;
238
+ }
219
239
  this.connection.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`).run(values);
220
240
  }
221
241
  else {
222
242
  this.connection.prepare(`
223
243
  INSERT INTO issues (
224
- project_id, linear_issue_id, delegated_to_patchrelay, 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,
225
245
  priority, estimate,
226
246
  current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
227
247
  branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
@@ -231,10 +251,10 @@ export class IssueStore {
231
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,
232
252
  last_queue_signal_at, last_queue_incident_json,
233
253
  last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
234
- 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,
235
255
  updated_at
236
256
  ) VALUES (
237
- @projectId, @linearIssueId, @delegatedToPatchRelay, @issueKey, @title, @description, @url,
257
+ @projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @parentLinearIssueId, @parentIssueKey, @issueKey, @title, @description, @url,
238
258
  @priority, @estimate,
239
259
  @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
240
260
  @branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
@@ -244,13 +264,17 @@ export class IssueStore {
244
264
  @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
245
265
  @lastQueueSignalAt, @lastQueueIncidentJson,
246
266
  @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
247
- @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt,
267
+ @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt, @orchestrationSettleUntil,
248
268
  @now
249
269
  )
250
270
  `).run({
251
271
  projectId: params.projectId,
252
272
  linearIssueId: params.linearIssueId,
253
273
  delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
274
+ issueClass: params.issueClass ?? null,
275
+ issueClassSource: params.issueClassSource ?? null,
276
+ parentLinearIssueId: params.parentLinearIssueId ?? null,
277
+ parentIssueKey: params.parentIssueKey ?? null,
254
278
  issueKey: params.issueKey ?? null,
255
279
  title: params.title ?? null,
256
280
  description: params.description ?? null,
@@ -300,6 +324,7 @@ export class IssueStore {
300
324
  reviewFixAttempts: params.reviewFixAttempts ?? 0,
301
325
  zombieRecoveryAttempts: params.zombieRecoveryAttempts ?? 0,
302
326
  lastZombieRecoveryAt: params.lastZombieRecoveryAt ?? null,
327
+ orchestrationSettleUntil: params.orchestrationSettleUntil ?? null,
303
328
  now,
304
329
  });
305
330
  }
@@ -446,6 +471,67 @@ export class IssueStore {
446
471
  linearIssueId: String(row.linear_issue_id),
447
472
  }));
448
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
+ }
449
535
  countUnresolvedBlockers(projectId, linearIssueId) {
450
536
  const row = this.connection.prepare(`
451
537
  SELECT COUNT(*) AS count
@@ -479,6 +565,14 @@ export function mapIssueRow(row) {
479
565
  projectId: String(row.project_id),
480
566
  linearIssueId: String(row.linear_issue_id),
481
567
  delegatedToPatchRelay: Number(row.delegated_to_patchrelay ?? 1) !== 0,
568
+ ...(row.issue_class !== null && row.issue_class !== undefined ? { issueClass: String(row.issue_class) } : {}),
569
+ ...(row.issue_class_source !== null && row.issue_class_source !== undefined
570
+ ? { issueClassSource: String(row.issue_class_source) }
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) } : {}),
482
576
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
483
577
  ...(row.title !== null ? { title: String(row.title) } : {}),
484
578
  ...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
@@ -569,5 +663,8 @@ export function mapIssueRow(row) {
569
663
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
570
664
  zombieRecoveryAttempts: Number(row.zombie_recovery_attempts ?? 0),
571
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
+ : {}),
572
669
  };
573
670
  }
@@ -4,6 +4,10 @@ CREATE TABLE IF NOT EXISTS issues (
4
4
  project_id TEXT NOT NULL,
5
5
  linear_issue_id TEXT NOT NULL,
6
6
  delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
7
+ issue_class TEXT,
8
+ issue_class_source TEXT,
9
+ parent_linear_issue_id TEXT,
10
+ parent_issue_key TEXT,
7
11
  issue_key TEXT,
8
12
  title TEXT,
9
13
  url TEXT,
@@ -30,6 +34,7 @@ CREATE TABLE IF NOT EXISTS issues (
30
34
  last_blocking_review_head_sha TEXT,
31
35
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
32
36
  queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
37
+ orchestration_settle_until TEXT,
33
38
  updated_at TEXT NOT NULL,
34
39
  UNIQUE(project_id, linear_issue_id)
35
40
  );
@@ -216,6 +221,14 @@ CREATE TABLE IF NOT EXISTS issue_dependencies (
216
221
  PRIMARY KEY (project_id, linear_issue_id, blocker_linear_issue_id)
217
222
  );
218
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
+
219
232
  CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
220
233
  CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
221
234
  CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
@@ -236,12 +249,28 @@ CREATE INDEX IF NOT EXISTS idx_linear_catalog_teams_installation ON linear_catal
236
249
  CREATE INDEX IF NOT EXISTS idx_linear_catalog_projects_installation ON linear_catalog_projects(installation_id, project_name);
237
250
  CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(project_id, linear_issue_id);
238
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);
239
254
  `;
240
255
  export function runPatchRelayMigrations(connection) {
241
256
  connection.exec(schema);
242
257
  // Clean up stale dedupe-only webhook records (no payload, never processable)
243
258
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
244
259
  addColumnIfMissing(connection, "issues", "delegated_to_patchrelay", "INTEGER NOT NULL DEFAULT 1");
260
+ addColumnIfMissing(connection, "issues", "issue_class", "TEXT");
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();
245
274
  // Add pending_merge_prep column for merge queue stewardship
246
275
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
247
276
  // Add merge_prep_attempts for retry budget / escalation
@@ -324,6 +353,10 @@ function removeRetiredIssueColumnsIfPresent(connection) {
324
353
  project_id TEXT NOT NULL,
325
354
  linear_issue_id TEXT NOT NULL,
326
355
  delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
356
+ issue_class TEXT,
357
+ issue_class_source TEXT,
358
+ parent_linear_issue_id TEXT,
359
+ parent_issue_key TEXT,
327
360
  issue_key TEXT,
328
361
  title TEXT,
329
362
  description TEXT,
@@ -373,6 +406,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
373
406
  review_fix_attempts INTEGER NOT NULL DEFAULT 0,
374
407
  zombie_recovery_attempts INTEGER NOT NULL DEFAULT 0,
375
408
  last_zombie_recovery_at TEXT,
409
+ orchestration_settle_until TEXT,
376
410
  updated_at TEXT NOT NULL,
377
411
  UNIQUE(project_id, linear_issue_id)
378
412
  );
@@ -382,6 +416,10 @@ function removeRetiredIssueColumnsIfPresent(connection) {
382
416
  project_id,
383
417
  linear_issue_id,
384
418
  delegated_to_patchrelay,
419
+ issue_class,
420
+ issue_class_source,
421
+ parent_linear_issue_id,
422
+ parent_issue_key,
385
423
  issue_key,
386
424
  title,
387
425
  description,
@@ -431,6 +469,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
431
469
  review_fix_attempts,
432
470
  zombie_recovery_attempts,
433
471
  last_zombie_recovery_at,
472
+ orchestration_settle_until,
434
473
  updated_at
435
474
  )
436
475
  SELECT
@@ -438,6 +477,10 @@ function removeRetiredIssueColumnsIfPresent(connection) {
438
477
  project_id,
439
478
  linear_issue_id,
440
479
  COALESCE(delegated_to_patchrelay, 1),
480
+ issue_class,
481
+ issue_class_source,
482
+ parent_linear_issue_id,
483
+ parent_issue_key,
441
484
  issue_key,
442
485
  title,
443
486
  description,
@@ -487,6 +530,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
487
530
  COALESCE(review_fix_attempts, 0),
488
531
  COALESCE(zombie_recovery_attempts, 0),
489
532
  last_zombie_recovery_at,
533
+ orchestration_settle_until,
490
534
  updated_at
491
535
  FROM issues;
492
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
  }
@@ -1,6 +1,7 @@
1
1
  import { resolveClosedPrDisposition, resolveClosedPrFactoryState } from "./pr-state.js";
2
2
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
3
3
  import { syncGitHubLinearSession } from "./github-linear-session-sync.js";
4
+ import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
4
5
  export async function handleGitHubTerminalPrEvent(params) {
5
6
  const { db, linearProvider, enqueueIssue, logger, codex, issue, event, config } = params;
6
7
  const eventType = event.triggerEvent === "pr_merged" ? "pr_merged" : "pr_closed";
@@ -66,6 +67,12 @@ export async function handleGitHubTerminalPrEvent(params) {
66
67
  }
67
68
  }
68
69
  if (event.triggerEvent === "pr_merged") {
70
+ wakeOrchestrationParentsForChildEvent({
71
+ db,
72
+ child: updatedIssue,
73
+ eventType: "child_delivered",
74
+ enqueueIssue,
75
+ });
69
76
  await completeLinearIssueAfterMerge(params, updatedIssue);
70
77
  }
71
78
  void syncGitHubLinearSession({
@@ -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)
@@ -0,0 +1,35 @@
1
+ function normalizeText(value) {
2
+ return value?.trim().toLowerCase() ?? "";
3
+ }
4
+ function looksLikeUmbrellaText(issue) {
5
+ const haystack = `${normalizeText(issue.title)}\n${normalizeText(issue.description)}`;
6
+ if (!haystack.trim())
7
+ return false;
8
+ return [
9
+ "umbrella",
10
+ "tracker",
11
+ "tracking",
12
+ "rollout",
13
+ "migration",
14
+ "convergence",
15
+ "audit",
16
+ "follow-up issues",
17
+ "planning/specification issue only",
18
+ ].some((token) => haystack.includes(token));
19
+ }
20
+ export function classifyIssue(params) {
21
+ if (params.issue.issueClassSource === "explicit"
22
+ && (params.issue.issueClass === "implementation" || params.issue.issueClass === "orchestration")) {
23
+ return { issueClass: params.issue.issueClass, issueClassSource: "explicit" };
24
+ }
25
+ if (params.issue.parentLinearIssueId) {
26
+ return { issueClass: "implementation", issueClassSource: "hierarchy" };
27
+ }
28
+ if (params.childIssueCount > 0) {
29
+ return { issueClass: "orchestration", issueClassSource: "hierarchy" };
30
+ }
31
+ if (looksLikeUmbrellaText(params.issue)) {
32
+ return { issueClass: "orchestration", issueClassSource: "heuristic" };
33
+ }
34
+ return { issueClass: "implementation", issueClassSource: "heuristic" };
35
+ }
@@ -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 } : {}),
@@ -52,7 +52,7 @@ export function deriveSessionWakePlan(issue, events) {
52
52
  case "delegated":
53
53
  if (!runType) {
54
54
  runType = "implementation";
55
- wakeReason = "delegated";
55
+ wakeReason = issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
56
56
  }
57
57
  if (payload?.promptContext !== undefined) {
58
58
  context.promptContext = payload.promptContext;
@@ -61,6 +61,16 @@ export function deriveSessionWakePlan(issue, events) {
61
61
  context.promptBody = payload.promptBody;
62
62
  }
63
63
  break;
64
+ case "child_changed":
65
+ case "child_delivered":
66
+ case "child_regressed":
67
+ if (!runType) {
68
+ runType = "implementation";
69
+ wakeReason = event.eventType;
70
+ }
71
+ Object.assign(context, payload ?? {});
72
+ resumeThread = true;
73
+ break;
64
74
  case "direct_reply": {
65
75
  if (!runType) {
66
76
  runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
@@ -98,7 +108,7 @@ export function deriveSessionWakePlan(issue, events) {
98
108
  case "operator_prompt": {
99
109
  if (!runType) {
100
110
  runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
101
- wakeReason = event.eventType;
111
+ wakeReason = issue.issueClass === "orchestration" ? "human_instruction" : event.eventType;
102
112
  }
103
113
  const text = typeof payload?.text === "string"
104
114
  ? payload.text
@@ -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,
@@ -1,4 +1,5 @@
1
1
  import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
2
+ import { wakeOrchestrationParentsForChildEvent } from "./orchestration-parent-wake.js";
2
3
  function shouldContinueForUnpublishedLocalChanges(message) {
3
4
  const normalized = message.trim().toLowerCase();
4
5
  if (!normalized)
@@ -167,6 +168,9 @@ export async function handleNoPrCompletionCheck(params) {
167
168
  });
168
169
  return;
169
170
  }
171
+ const orchestrationOpenChildren = params.issue.issueClass === "orchestration"
172
+ ? params.db.issues.countOpenChildIssues(params.run.projectId, params.run.linearIssueId)
173
+ : 0;
170
174
  const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
171
175
  params.db.runs.finishRun(params.run.id, completedRunUpdate);
172
176
  params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
@@ -175,9 +179,10 @@ export async function handleNoPrCompletionCheck(params) {
175
179
  projectId: params.run.projectId,
176
180
  linearIssueId: params.run.linearIssueId,
177
181
  activeRunId: null,
178
- factoryState: "done",
182
+ factoryState: params.issue.issueClass === "orchestration" && orchestrationOpenChildren > 0 ? "delegated" : "done",
179
183
  pendingRunType: null,
180
184
  pendingRunContextJson: null,
185
+ orchestrationSettleUntil: null,
181
186
  lastGitHubFailureSource: null,
182
187
  lastGitHubFailureHeadSha: null,
183
188
  lastGitHubFailureSignature: null,
@@ -202,10 +207,20 @@ export async function handleNoPrCompletionCheck(params) {
202
207
  fallbackIssue: params.issue,
203
208
  level: "info",
204
209
  status: "completion_check_done",
205
- summary: "No PR found; confirmed done",
206
- 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,
207
216
  activity: buildCompletionCheckActivity("done", completionCheck),
208
217
  });
218
+ const doneIssue = params.db.issues.getIssue(params.run.projectId, params.run.linearIssueId) ?? params.issue;
219
+ wakeOrchestrationParentsForChildEvent({
220
+ db: params.db,
221
+ child: doneIssue,
222
+ eventType: "child_delivered",
223
+ });
209
224
  return;
210
225
  }
211
226
  const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
@@ -0,0 +1,96 @@
1
+ import { classifyIssue } from "./issue-class.js";
2
+ export const ORCHESTRATION_SETTLE_WINDOW_MS = 10_000;
3
+ export function computeOrchestrationSettleUntil(now = Date.now()) {
4
+ return new Date(now + ORCHESTRATION_SETTLE_WINDOW_MS).toISOString();
5
+ }
6
+ function resolveOrchestrationIssueClass(db, issue) {
7
+ return classifyIssue({
8
+ issue,
9
+ childIssueCount: db.issues.listChildIssues(issue.projectId, issue.linearIssueId).length,
10
+ }).issueClass;
11
+ }
12
+ function unique(values) {
13
+ return [...new Set(values)];
14
+ }
15
+ function resolveParentIssueIds(db, child) {
16
+ const parentIds = [];
17
+ if (child.parentLinearIssueId) {
18
+ parentIds.push(child.parentLinearIssueId);
19
+ }
20
+ for (const blocker of db.issues.listIssueDependencies(child.projectId, child.linearIssueId)) {
21
+ parentIds.push(blocker.blockerLinearIssueId);
22
+ }
23
+ return unique(parentIds);
24
+ }
25
+ export function startOrchestrationSettleWindow(db, issue, now = Date.now()) {
26
+ const settleUntil = computeOrchestrationSettleUntil(now);
27
+ db.issues.upsertIssue({
28
+ projectId: issue.projectId,
29
+ linearIssueId: issue.linearIssueId,
30
+ orchestrationSettleUntil: settleUntil,
31
+ });
32
+ return settleUntil;
33
+ }
34
+ export function queueSettledOrchestrationIssue(params) {
35
+ params.db.issues.upsertIssue({
36
+ projectId: params.issue.projectId,
37
+ linearIssueId: params.issue.linearIssueId,
38
+ orchestrationSettleUntil: null,
39
+ });
40
+ params.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.issue.projectId, params.issue.linearIssueId, {
41
+ projectId: params.issue.projectId,
42
+ linearIssueId: params.issue.linearIssueId,
43
+ eventType: "delegated",
44
+ eventJson: JSON.stringify({
45
+ ...(params.promptContext
46
+ ? { promptContext: params.promptContext }
47
+ : { promptContext: "The orchestration child set has settled enough to begin planning." }),
48
+ }),
49
+ dedupeKey: `delegated:orchestration_settle:${params.issue.linearIssueId}`,
50
+ });
51
+ if (params.db.issueSessions.peekIssueSessionWake(params.issue.projectId, params.issue.linearIssueId)) {
52
+ params.enqueueIssue?.(params.issue.projectId, params.issue.linearIssueId);
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ export function wakeOrchestrationParentsForChildEvent(params) {
58
+ const parentIds = [];
59
+ for (const parentIssueId of resolveParentIssueIds(params.db, params.child)) {
60
+ const parent = params.db.issues.getIssue(params.child.projectId, parentIssueId);
61
+ if (!parent || !parent.delegatedToPatchRelay) {
62
+ continue;
63
+ }
64
+ if (resolveOrchestrationIssueClass(params.db, parent) !== "orchestration") {
65
+ continue;
66
+ }
67
+ // Before the umbrella has started its first turn, keep absorbing nearby
68
+ // child-set changes into the settle window instead of launching too early.
69
+ if (!parent.threadId && parent.activeRunId === undefined && parent.orchestrationSettleUntil) {
70
+ startOrchestrationSettleWindow(params.db, parent, params.now);
71
+ parentIds.push(parent.linearIssueId);
72
+ continue;
73
+ }
74
+ params.db.issueSessions.appendIssueSessionEventRespectingActiveLease(parent.projectId, parent.linearIssueId, {
75
+ projectId: parent.projectId,
76
+ linearIssueId: parent.linearIssueId,
77
+ eventType: params.eventType,
78
+ eventJson: JSON.stringify({
79
+ childIssueId: params.child.linearIssueId,
80
+ ...(params.child.issueKey ? { childIssueKey: params.child.issueKey } : {}),
81
+ ...(params.child.title ? { childTitle: params.child.title } : {}),
82
+ factoryState: params.child.factoryState,
83
+ ...(params.child.currentLinearState ? { currentLinearState: params.child.currentLinearState } : {}),
84
+ ...(params.child.prNumber !== undefined ? { prNumber: params.child.prNumber } : {}),
85
+ ...(params.child.prState ? { prState: params.child.prState } : {}),
86
+ ...(params.changeKind ? { changeKind: params.changeKind } : {}),
87
+ }),
88
+ dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.changeKind ?? params.child.prState ?? "no-pr"}`,
89
+ });
90
+ if (params.db.issueSessions.peekIssueSessionWake(parent.projectId, parent.linearIssueId)) {
91
+ params.enqueueIssue?.(parent.projectId, parent.linearIssueId);
92
+ }
93
+ parentIds.push(parent.linearIssueId);
94
+ }
95
+ return unique(parentIds);
96
+ }