patchrelay 0.48.0 → 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.
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.0",
4
+ "commit": "e4ca4c92eb96",
5
+ "builtAt": "2026-04-19T10:24:09.505Z"
6
6
  }
@@ -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
  }
@@ -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;
@@ -1,16 +1,74 @@
1
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
+ }
2
57
  export function wakeOrchestrationParentsForChildEvent(params) {
3
58
  const parentIds = [];
4
- for (const blocker of params.db.issues.listIssueDependencies(params.child.projectId, params.child.linearIssueId)) {
5
- const parent = params.db.issues.getIssue(params.child.projectId, blocker.blockerLinearIssueId);
59
+ for (const parentIssueId of resolveParentIssueIds(params.db, params.child)) {
60
+ const parent = params.db.issues.getIssue(params.child.projectId, parentIssueId);
6
61
  if (!parent || !parent.delegatedToPatchRelay) {
7
62
  continue;
8
63
  }
9
- const classification = classifyIssue({
10
- issue: parent,
11
- trackedDependentCount: params.db.issues.listDependents(parent.projectId, parent.linearIssueId).length,
12
- });
13
- if (classification.issueClass !== "orchestration") {
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);
14
72
  continue;
15
73
  }
16
74
  params.db.issueSessions.appendIssueSessionEventRespectingActiveLease(parent.projectId, parent.linearIssueId, {
@@ -25,13 +83,14 @@ export function wakeOrchestrationParentsForChildEvent(params) {
25
83
  ...(params.child.currentLinearState ? { currentLinearState: params.child.currentLinearState } : {}),
26
84
  ...(params.child.prNumber !== undefined ? { prNumber: params.child.prNumber } : {}),
27
85
  ...(params.child.prState ? { prState: params.child.prState } : {}),
86
+ ...(params.changeKind ? { changeKind: params.changeKind } : {}),
28
87
  }),
29
- dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.child.prState ?? "no-pr"}`,
88
+ dedupeKey: `${params.eventType}:${parent.linearIssueId}:${params.child.linearIssueId}:${params.child.factoryState}:${params.changeKind ?? params.child.prState ?? "no-pr"}`,
30
89
  });
31
90
  if (params.db.issueSessions.peekIssueSessionWake(parent.projectId, parent.linearIssueId)) {
32
91
  params.enqueueIssue?.(parent.projectId, parent.linearIssueId);
33
92
  }
34
93
  parentIds.push(parent.linearIssueId);
35
94
  }
36
- return parentIds;
95
+ return unique(parentIds);
37
96
  }
@@ -106,9 +106,11 @@ function buildCoordinationGuidance(context) {
106
106
  const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
107
107
  ? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
108
108
  : [];
109
- const trackedDependents = Array.isArray(context?.trackedDependents)
110
- ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
111
- : [];
109
+ const childIssues = Array.isArray(context?.childIssues)
110
+ ? context.childIssues.filter((entry) => Boolean(entry) && typeof entry === "object")
111
+ : Array.isArray(context?.trackedDependents)
112
+ ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
113
+ : [];
112
114
  const lines = [
113
115
  "### Coordination / Issue Topology",
114
116
  "",
@@ -117,7 +119,7 @@ function buildCoordinationGuidance(context) {
117
119
  "When child issues already own the concrete code slices, use this issue to coordinate, create or refine follow-up issues, or verify convergence. Only ship code here if this issue still has unique implementation scope that is not already owned elsewhere.",
118
120
  "Prefer one PR per concrete implementation issue over a broad parent branch that restates overlapping child work.",
119
121
  ];
120
- if (unresolvedBlockers.length === 0 && trackedDependents.length === 0) {
122
+ if (unresolvedBlockers.length === 0 && childIssues.length === 0) {
121
123
  return lines;
122
124
  }
123
125
  lines.push("", "Known relations from PatchRelay:");
@@ -125,12 +127,12 @@ function buildCoordinationGuidance(context) {
125
127
  lines.push("Unresolved blockers:");
126
128
  lines.push(...summarizeRelationEntries(unresolvedBlockers));
127
129
  }
128
- if (trackedDependents.length > 0) {
130
+ if (childIssues.length > 0) {
129
131
  if (unresolvedBlockers.length > 0) {
130
132
  lines.push("");
131
133
  }
132
- lines.push("Tracked dependent issues:");
133
- lines.push(...summarizeRelationEntries(trackedDependents));
134
+ lines.push("Canonical child issues:");
135
+ lines.push(...summarizeRelationEntries(childIssues));
134
136
  }
135
137
  return lines;
136
138
  }
@@ -166,15 +168,19 @@ function buildOrchestrationScopeDiscipline(context) {
166
168
  const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
167
169
  ? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
168
170
  : [];
169
- const trackedDependents = Array.isArray(context?.trackedDependents)
170
- ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
171
- : [];
171
+ const childIssues = Array.isArray(context?.childIssues)
172
+ ? context.childIssues.filter((entry) => Boolean(entry) && typeof entry === "object")
173
+ : Array.isArray(context?.trackedDependents)
174
+ ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
175
+ : [];
172
176
  return [
173
177
  "## Scope Discipline",
174
178
  "",
175
179
  "This issue is orchestration work.",
176
180
  "Treat it as the owner of convergence across related issues rather than as a normal code-owning implementation branch.",
177
181
  "Inspect why this wake happened before acting.",
182
+ "Adopt already-existing canonical child issues when they cover the intended split.",
183
+ "Do not recreate child issues that already exist under this parent unless a genuinely missing required slice remains.",
178
184
  "Do not create an overlapping umbrella PR unless this parent clearly owns unique direct cleanup work that child issues do not already cover.",
179
185
  "If child work is still in motion, babysit the plan, record useful observations, and return to waiting.",
180
186
  "If child work looks delivered, audit whether the original parent goal is actually satisfied.",
@@ -183,8 +189,8 @@ function buildOrchestrationScopeDiscipline(context) {
183
189
  "",
184
190
  "### Child Issue Summaries",
185
191
  "",
186
- ...(trackedDependents.length > 0
187
- ? summarizeRelationEntries(trackedDependents, { emptyText: "No child issues are currently tracked." })
192
+ ...(childIssues.length > 0
193
+ ? summarizeRelationEntries(childIssues, { emptyText: "No child issues are currently tracked." })
188
194
  : ["No child issues are currently tracked."]),
189
195
  "",
190
196
  ...(unresolvedBlockers.length > 0
@@ -150,10 +150,8 @@ export class RunOrchestrator {
150
150
  ...(entry.blockerCurrentLinearState ? { stateName: entry.blockerCurrentLinearState } : {}),
151
151
  ...(entry.blockerCurrentLinearStateType ? { stateType: entry.blockerCurrentLinearStateType } : {}),
152
152
  }));
153
- const trackedDependents = this.db.issues
154
- .listDependents(issue.projectId, issue.linearIssueId)
155
- .map((entry) => this.db.issues.getIssue(issue.projectId, entry.linearIssueId))
156
- .filter((entry) => Boolean(entry))
153
+ const childIssues = this.db.issues
154
+ .listChildIssues(issue.projectId, issue.linearIssueId)
157
155
  .map((entry) => ({
158
156
  linearIssueId: entry.linearIssueId,
159
157
  ...(entry.issueKey ? { issueKey: entry.issueKey } : {}),
@@ -163,17 +161,17 @@ export class RunOrchestrator {
163
161
  delegatedToPatchRelay: entry.delegatedToPatchRelay,
164
162
  hasOpenPr: entry.prNumber !== undefined && entry.prState !== "closed" && entry.prState !== "merged",
165
163
  }));
166
- if (unresolvedBlockers.length === 0 && trackedDependents.length === 0) {
164
+ if (unresolvedBlockers.length === 0 && childIssues.length === 0) {
167
165
  return {};
168
166
  }
169
167
  return {
170
168
  ...(unresolvedBlockers.length > 0 ? { unresolvedBlockers } : {}),
171
- ...(trackedDependents.length > 0 ? { trackedDependents } : {}),
169
+ ...(childIssues.length > 0 ? { childIssues } : {}),
172
170
  };
173
171
  }
174
172
  classifyTrackedIssue(issue) {
175
- const trackedDependentCount = this.db.issues.listDependents(issue.projectId, issue.linearIssueId).length;
176
- const classification = classifyIssue({ issue, trackedDependentCount });
173
+ const childIssueCount = this.db.issues.listChildIssues(issue.projectId, issue.linearIssueId).length;
174
+ const classification = classifyIssue({ issue, childIssueCount });
177
175
  if (issue.issueClass === classification.issueClass && issue.issueClassSource === classification.issueClassSource) {
178
176
  return issue;
179
177
  }
@@ -82,6 +82,7 @@ export class TrackedIssueListQuery {
82
82
  s.project_id, s.linear_issue_id, s.issue_key, i.title,
83
83
  i.current_linear_state, i.factory_state, i.delegated_to_patchrelay, s.session_state, s.waiting_reason, s.summary_text, s.display_updated_at,
84
84
  i.pending_run_type,
85
+ i.orchestration_settle_until,
85
86
  i.pr_number, i.pr_state, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
86
87
  i.last_github_ci_snapshot_json,
87
88
  i.last_github_failure_source,
@@ -162,6 +163,7 @@ export class TrackedIssueListQuery {
162
163
  blockedByCount,
163
164
  hasPendingWake,
164
165
  hasLegacyPendingRun: row.pending_run_type !== null && row.pending_run_type !== undefined,
166
+ ...(row.orchestration_settle_until !== null ? { orchestrationSettleUntil: String(row.orchestration_settle_until) } : {}),
165
167
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
166
168
  ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
167
169
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
@@ -181,6 +183,7 @@ export class TrackedIssueListQuery {
181
183
  blockedByKeys,
182
184
  factoryState: String(row.factory_state ?? "delegated"),
183
185
  ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
186
+ ...(row.orchestration_settle_until !== null ? { orchestrationSettleUntil: String(row.orchestration_settle_until) } : {}),
184
187
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
185
188
  ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
186
189
  ...(row.pr_head_sha !== null ? { prHeadSha: String(row.pr_head_sha) } : {}),
@@ -15,6 +15,7 @@ export function buildTrackedIssueRecord(params) {
15
15
  blockedByKeys,
16
16
  factoryState: params.issue.factoryState,
17
17
  pendingRunType: params.issue.pendingRunType,
18
+ orchestrationSettleUntil: params.issue.orchestrationSettleUntil,
18
19
  prNumber: params.issue.prNumber,
19
20
  prState: params.issue.prState,
20
21
  prHeadSha: params.issue.prHeadSha,
@@ -64,6 +65,7 @@ export function buildTrackedIssueRecord(params) {
64
65
  blockedByCount: unresolvedBlockedBy.length,
65
66
  hasPendingWake: params.hasPendingWake,
66
67
  hasLegacyPendingRun: params.issue.pendingRunType !== undefined,
68
+ orchestrationSettleUntil: params.issue.orchestrationSettleUntil,
67
69
  ...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
68
70
  ...(params.issue.prState ? { prState: params.issue.prState } : {}),
69
71
  ...(params.issue.prReviewState ? { prReviewState: params.issue.prReviewState } : {}),
@@ -11,6 +11,7 @@ export const PATCHRELAY_WAITING_REASONS = {
11
11
  sameHeadStillBlocked: "Requested changes still block the current head",
12
12
  waitingForMergeStewardRepair: "Waiting to repair a merge-steward incident",
13
13
  waitingForDownstreamAutomation: "PatchRelay work is done; waiting on downstream review/merge automation",
14
+ waitingForChildSettle: "Waiting briefly for child issues to settle before orchestration starts",
14
15
  workComplete: "PatchRelay work is complete",
15
16
  waitingForOperatorIntervention: "Waiting on operator intervention",
16
17
  waitingForExternalReview: "Waiting on external review",
@@ -33,6 +34,12 @@ export function derivePatchRelayWaitingReason(params) {
33
34
  if (params.activeRunId !== undefined) {
34
35
  return PATCHRELAY_WAITING_REASONS.activeWork;
35
36
  }
37
+ if (params.orchestrationSettleUntil) {
38
+ const settleAt = Date.parse(params.orchestrationSettleUntil);
39
+ if (Number.isFinite(settleAt) && settleAt > Date.now()) {
40
+ return PATCHRELAY_WAITING_REASONS.waitingForChildSettle;
41
+ }
42
+ }
36
43
  const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
37
44
  if (blockedByKeys.length > 0) {
38
45
  return `Blocked by ${blockedByKeys.join(", ")}`;
@@ -27,7 +27,7 @@ export class CommentWakeHandler {
27
27
  return;
28
28
  const issueClass = classifyIssue({
29
29
  issue,
30
- trackedDependentCount: this.db.issues.listDependents(project.id, normalized.issue.id).length,
30
+ childIssueCount: this.db.issues.listChildIssues(project.id, normalized.issue.id).length,
31
31
  }).issueClass;
32
32
  const trimmedBody = normalized.comment.body.trim();
33
33
  const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
@@ -104,6 +104,9 @@ export function hasCompleteIssueContext(issue) {
104
104
  export function mergeIssueMetadata(issue, liveIssue) {
105
105
  return {
106
106
  ...issue,
107
+ ...(issue.parentId ? {} : liveIssue.parentId ? { parentId: liveIssue.parentId } : {}),
108
+ ...(issue.parentIdentifier ? {} : liveIssue.parentIdentifier ? { parentIdentifier: liveIssue.parentIdentifier } : {}),
109
+ ...(issue.parentTitle ? {} : liveIssue.parentTitle ? { parentTitle: liveIssue.parentTitle } : {}),
107
110
  ...(issue.identifier ? {} : liveIssue.identifier ? { identifier: liveIssue.identifier } : {}),
108
111
  ...(issue.title ? {} : liveIssue.title ? { title: liveIssue.title } : {}),
109
112
  ...(issue.url ? {} : liveIssue.url ? { url: liveIssue.url } : {}),
@@ -1,7 +1,9 @@
1
+ import { classifyIssue } from "../issue-class.js";
2
+ import { computeOrchestrationSettleUntil, wakeOrchestrationParentsForChildEvent, } from "../orchestration-parent-wake.js";
1
3
  import { triggerEventAllowed } from "../project-resolution.js";
2
4
  import { resolveAwaitingInputReason } from "../awaiting-input-reason.js";
3
5
  import { appendDelegationObservedEvent } from "../delegation-audit.js";
4
- import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
6
+ import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isResolvedLinearState, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
5
7
  import { buildOperatorRetryEvent } from "../operator-retry-event.js";
6
8
  import { resolveLinkedPullRequest } from "../linear-linked-pr-reconciliation.js";
7
9
  import { readRemotePrState } from "../remote-pr-state.js";
@@ -70,6 +72,24 @@ export class DesiredStageRecorder {
70
72
  terminal,
71
73
  currentState: existingIssue?.factoryState,
72
74
  });
75
+ const childIssueCount = this.db.issues.listChildIssues(params.project.id, normalizedIssue.id).length;
76
+ const classification = classifyIssue({
77
+ issue: {
78
+ issueClass: existingIssue?.issueClass,
79
+ issueClassSource: existingIssue?.issueClassSource,
80
+ title: hydratedIssue.title ?? existingIssue?.title,
81
+ description: hydratedIssue.description ?? existingIssue?.description,
82
+ parentLinearIssueId: hydratedIssue.parentId ?? existingIssue?.parentLinearIssueId,
83
+ },
84
+ childIssueCount,
85
+ });
86
+ const shouldEnterOrchestrationSettle = Boolean(delegated
87
+ && desiredStage === "implementation"
88
+ && classification.issueClass === "orchestration"
89
+ && childIssueCount === 0
90
+ && !existingIssue?.threadId
91
+ && !activeRun
92
+ && !terminal);
73
93
  const runRelease = decideActiveRunRelease({
74
94
  hasActiveRun: Boolean(activeRun),
75
95
  terminal,
@@ -126,6 +146,10 @@ export class DesiredStageRecorder {
126
146
  projectId: params.project.id,
127
147
  linearIssueId: normalizedIssue.id,
128
148
  ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
149
+ ...(hydratedIssue.parentId !== undefined ? { parentLinearIssueId: hydratedIssue.parentId ?? null } : {}),
150
+ ...(hydratedIssue.parentIdentifier !== undefined ? { parentIssueKey: hydratedIssue.parentIdentifier ?? null } : {}),
151
+ issueClass: classification.issueClass,
152
+ issueClassSource: classification.issueClassSource,
129
153
  ...(hydratedIssue.title ? { title: hydratedIssue.title } : {}),
130
154
  ...(hydratedIssue.description ? { description: hydratedIssue.description } : {}),
131
155
  ...(hydratedIssue.url ? { url: hydratedIssue.url } : {}),
@@ -152,6 +176,7 @@ export class DesiredStageRecorder {
152
176
  ...(terminalRunRelease ? { factoryState: "done", pendingRunType: null, pendingRunContextJson: null } : {}),
153
177
  ...(blockerPausedImplementation ? { factoryState: "delegated" } : {}),
154
178
  ...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
179
+ ...(shouldEnterOrchestrationSettle ? { orchestrationSettleUntil: computeOrchestrationSettleUntil() } : {}),
155
180
  });
156
181
  if (effectiveRunRelease.release && activeRun && effectiveRunRelease.reason) {
157
182
  this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: effectiveRunRelease.reason });
@@ -166,6 +191,10 @@ export class DesiredStageRecorder {
166
191
  ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
167
192
  }))
168
193
  : this.db.transaction(commitIssueUpdate);
194
+ const previousParentIssueId = existingIssue?.parentLinearIssueId;
195
+ const currentParentIssueId = issue.parentLinearIssueId;
196
+ const wasResolved = isResolvedLinearState(existingIssue?.currentLinearStateType, existingIssue?.currentLinearState);
197
+ const isResolved = isResolvedLinearState(issue.currentLinearStateType, issue.currentLinearState);
169
198
  if (undelegation.factoryState) {
170
199
  if (activeRun?.threadId && activeRun.turnId) {
171
200
  await params.stopActiveRun(activeRun, "STOP: The issue was un-delegated from PatchRelay. Stop working immediately and exit.");
@@ -213,6 +242,17 @@ export class DesiredStageRecorder {
213
242
  ...buildOperatorRetryEvent(issue, startupResume.pendingRunType, startupResume.source),
214
243
  });
215
244
  }
245
+ else if (shouldEnterOrchestrationSettle) {
246
+ this.feed?.publish({
247
+ level: "info",
248
+ kind: "stage",
249
+ issueKey: issue.issueKey,
250
+ projectId: params.project.id,
251
+ stage: issue.factoryState,
252
+ status: "settling_children",
253
+ summary: "Waiting briefly for child issues to settle before orchestration starts",
254
+ });
255
+ }
216
256
  else if (!startupResume.factoryState
217
257
  && !startupResume.pendingRunType
218
258
  &&
@@ -232,6 +272,46 @@ export class DesiredStageRecorder {
232
272
  dedupeKey: `delegated:${normalizedIssue.id}`,
233
273
  });
234
274
  }
275
+ if (previousParentIssueId && previousParentIssueId !== currentParentIssueId) {
276
+ wakeOrchestrationParentsForChildEvent({
277
+ db: this.db,
278
+ child: {
279
+ projectId: issue.projectId,
280
+ linearIssueId: issue.linearIssueId,
281
+ parentLinearIssueId: previousParentIssueId,
282
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
283
+ ...(issue.title ? { title: issue.title } : {}),
284
+ factoryState: issue.factoryState,
285
+ ...(issue.currentLinearState ? { currentLinearState: issue.currentLinearState } : {}),
286
+ ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
287
+ ...(issue.prState ? { prState: issue.prState } : {}),
288
+ },
289
+ eventType: "child_changed",
290
+ changeKind: "detached",
291
+ });
292
+ }
293
+ if (currentParentIssueId) {
294
+ const changeKind = previousParentIssueId !== currentParentIssueId
295
+ ? "attached"
296
+ : issue.currentLinearState?.trim().toLowerCase() === "duplicate"
297
+ ? "duplicate"
298
+ : issue.currentLinearStateType === "canceled"
299
+ ? "canceled"
300
+ : "updated";
301
+ const eventType = previousParentIssueId !== currentParentIssueId
302
+ ? "child_changed"
303
+ : !wasResolved && isResolved
304
+ ? "child_delivered"
305
+ : wasResolved && !isResolved
306
+ ? "child_regressed"
307
+ : "child_changed";
308
+ wakeOrchestrationParentsForChildEvent({
309
+ db: this.db,
310
+ child: issue,
311
+ eventType,
312
+ changeKind,
313
+ });
314
+ }
235
315
  return {
236
316
  issue: this.db.issueToTrackedIssue(issue),
237
317
  wakeRunType: params.peekPendingSessionWakeRunType(params.project.id, normalizedIssue.id),
@@ -310,6 +390,11 @@ export class DesiredStageRecorder {
310
390
  })),
311
391
  });
312
392
  }
393
+ this.db.issues.replaceIssueParentLink({
394
+ projectId,
395
+ childLinearIssueId: source.id,
396
+ parentLinearIssueId: source.parentId ?? null,
397
+ });
313
398
  return { issue: source, hydration };
314
399
  }
315
400
  async resolveLinkedPrAdoption(params) {
package/dist/webhooks.js CHANGED
@@ -195,6 +195,7 @@ function extractIssueMetadata(payload) {
195
195
  return undefined;
196
196
  }
197
197
  const teamRecord = asRecord(issueRecord.team);
198
+ const parentRecord = asRecord(issueRecord.parent);
198
199
  const identifier = getString(issueRecord, "identifier");
199
200
  const title = getString(issueRecord, "title");
200
201
  const url = getString(issueRecord, "url") ?? payload.url;
@@ -208,12 +209,18 @@ function extractIssueMetadata(payload) {
208
209
  const delegateId = getString(issueRecord, "delegateId") ?? getString(delegateRecord ?? {}, "id");
209
210
  const delegateName = getString(delegateRecord ?? {}, "name");
210
211
  const description = getString(issueRecord, "description");
212
+ const parentId = getString(issueRecord, "parentId") ?? getString(parentRecord ?? {}, "id");
213
+ const parentIdentifier = getString(parentRecord ?? {}, "identifier");
214
+ const parentTitle = getString(parentRecord ?? {}, "title");
211
215
  const rawPriority = issueRecord.priority;
212
216
  const priority = typeof rawPriority === "number" ? rawPriority : undefined;
213
217
  const rawEstimate = issueRecord.estimate;
214
218
  const estimate = typeof rawEstimate === "number" ? rawEstimate : undefined;
215
219
  return {
216
220
  id,
221
+ ...(parentId ? { parentId } : {}),
222
+ ...(parentIdentifier ? { parentIdentifier } : {}),
223
+ ...(parentTitle ? { parentTitle } : {}),
217
224
  ...(identifier ? { identifier } : {}),
218
225
  ...(title ? { title } : {}),
219
226
  ...(description ? { description } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {