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.
@@ -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
  }
@@ -162,6 +164,45 @@ function buildScopeDiscipline(issue, context) {
162
164
  "- A review repair should fix the concrete concern on the current head, not silently expand the Linear issue into a broader rewrite.",
163
165
  ].join("\n");
164
166
  }
167
+ function buildOrchestrationScopeDiscipline(context) {
168
+ const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
169
+ ? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
170
+ : [];
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
+ : [];
176
+ return [
177
+ "## Scope Discipline",
178
+ "",
179
+ "This issue is orchestration work.",
180
+ "Treat it as the owner of convergence across related issues rather than as a normal code-owning implementation branch.",
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.",
184
+ "Do not create an overlapping umbrella PR unless this parent clearly owns unique direct cleanup work that child issues do not already cover.",
185
+ "If child work is still in motion, babysit the plan, record useful observations, and return to waiting.",
186
+ "If child work looks delivered, audit whether the original parent goal is actually satisfied.",
187
+ "Create blocking follow-up work only when it is necessary to satisfy the original parent goal.",
188
+ "Prefer non-blocking follow-up issues over keeping the umbrella open for optional polish or adjacent expansion.",
189
+ "",
190
+ "### Child Issue Summaries",
191
+ "",
192
+ ...(childIssues.length > 0
193
+ ? summarizeRelationEntries(childIssues, { emptyText: "No child issues are currently tracked." })
194
+ : ["No child issues are currently tracked."]),
195
+ "",
196
+ ...(unresolvedBlockers.length > 0
197
+ ? ["### Unresolved Blockers", "", ...summarizeRelationEntries(unresolvedBlockers), ""]
198
+ : []),
199
+ "### Convergence Rule",
200
+ "",
201
+ "- Close the umbrella when the original parent goal is satisfied.",
202
+ "- If you discover one missing required slice, you may create a justified blocking follow-up.",
203
+ "- Do not invent optional expansion without explicit human approval.",
204
+ ].join("\n");
205
+ }
165
206
  function buildHumanContext(context) {
166
207
  const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
167
208
  const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
@@ -357,18 +398,30 @@ function buildFollowUpPromptPrelude(issue, runType, context) {
357
398
  "",
358
399
  wakeReason === "direct_reply"
359
400
  ? "Why this turn exists: A human reply arrived for the outstanding question from the previous turn."
360
- : wakeReason === "completion_check_continue"
361
- ? "Why this turn exists: The previous turn ended without a PR, and PatchRelay's completion check decided the work should continue automatically."
362
- : wakeReason === "branch_upkeep"
363
- ? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
364
- : wakeReason === "followup_comment"
365
- ? "Why this turn exists: A human follow-up comment arrived after the previous turn."
366
- : `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
401
+ : wakeReason === "initial_delegate"
402
+ ? "Why this turn exists: This orchestration issue was just delegated and needs an initial plan."
403
+ : wakeReason === "child_delivered"
404
+ ? "Why this turn exists: A child issue was delivered and the umbrella needs to review the outcome."
405
+ : wakeReason === "child_changed"
406
+ ? "Why this turn exists: A child issue changed state and the umbrella may need to adjust."
407
+ : wakeReason === "child_regressed"
408
+ ? "Why this turn exists: A previously progressing child issue regressed and the umbrella needs to reassess."
409
+ : wakeReason === "human_instruction"
410
+ ? "Why this turn exists: A human added new guidance for this orchestration issue."
411
+ : wakeReason === "completion_check_continue"
412
+ ? "Why this turn exists: The previous turn ended without a PR, and PatchRelay's completion check decided the work should continue automatically."
413
+ : wakeReason === "branch_upkeep"
414
+ ? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
415
+ : wakeReason === "followup_comment"
416
+ ? "Why this turn exists: A human follow-up comment arrived after the previous turn."
417
+ : `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
367
418
  wakeReason === "direct_reply"
368
419
  ? "Required action now: Apply the latest human answer, continue from the current branch/session context, and publish the next concrete result."
369
- : wakeReason === "completion_check_continue"
370
- ? "Required action now: Continue from the current branch and thread context, finish the task, and publish the next concrete result."
371
- : "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
420
+ : wakeReason === "initial_delegate"
421
+ ? "Required action now: Inspect the umbrella goal, review the child set, and record the next orchestration step."
422
+ : wakeReason === "completion_check_continue"
423
+ ? "Required action now: Continue from the current branch and thread context, finish the task, and publish the next concrete result."
424
+ : "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
372
425
  "",
373
426
  ];
374
427
  if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
@@ -417,14 +470,32 @@ function buildWorkflowGuidance(repoPath, runType) {
417
470
  }
418
471
  return "";
419
472
  }
420
- function buildPublicationContract(runType) {
473
+ function buildOrchestrationWorkflowGuidance() {
474
+ return [
475
+ "## Workflow Guidance",
476
+ "",
477
+ "Use the wake reason and current child issue summaries to decide what kind of orchestration work is needed now.",
478
+ "Typical orchestration phases are: initial setup, waiting on child progress, reviewing delivered child work, final audit, creating a justified follow-up, or closing the umbrella.",
479
+ "Keep outputs concise and observable in Linear.",
480
+ ].join("\n");
481
+ }
482
+ function buildPublicationContract(runType, issueClass) {
483
+ if (issueClass === "orchestration") {
484
+ return [
485
+ "## Publication Requirements",
486
+ "",
487
+ "Before finishing, publish the orchestration outcome rather than leaving it implicit.",
488
+ "By default, orchestration work should finish without opening an overlapping umbrella PR.",
489
+ "Valid orchestration outcomes include: recording an observation, updating the rollout plan, creating follow-up issues, opening a small cleanup PR that the parent clearly owns, or closing the umbrella.",
490
+ "If you create new blocking follow-up work, justify it against the original parent goal rather than optional polish.",
491
+ ].join("\n");
492
+ }
421
493
  if (runType === "implementation") {
422
494
  return [
423
495
  "## Publication Requirements",
424
496
  "",
425
497
  "Before finishing, publish the result instead of leaving it only in the worktree.",
426
498
  "If the task is genuinely complete without a PR, say so clearly in your normal summary instead of inventing one.",
427
- "If the issue is acting as coordination-only work and the real implementation belongs in child issues, finish without opening an overlapping umbrella PR.",
428
499
  "If the worktree already contains relevant changes for this issue, verify them and publish them.",
429
500
  "If you changed files for this issue, commit them, push the issue branch, and open or update the PR before stopping.",
430
501
  "Do not stop with only local commits or uncommitted changes.",
@@ -456,6 +527,7 @@ function buildPublicationContract(runType) {
456
527
  ].join("\n");
457
528
  }
458
529
  function buildSections(issue, runType, repoPath, context, followUp = false) {
530
+ const issueClass = issue.issueClass;
459
531
  const sections = [
460
532
  { id: "header", content: buildPromptHeader(issue) },
461
533
  ];
@@ -463,7 +535,10 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
463
535
  if (followUp && reactiveContext) {
464
536
  sections.push({ id: "follow-up-turn", content: reactiveContext });
465
537
  }
466
- sections.push({ id: "task-objective", content: buildTaskObjective(issue) }, { id: "scope-discipline", content: buildScopeDiscipline(issue, context) });
538
+ sections.push({ id: "task-objective", content: buildTaskObjective(issue) }, {
539
+ id: "scope-discipline",
540
+ content: issueClass === "orchestration" ? buildOrchestrationScopeDiscipline(context) : buildScopeDiscipline(issue, context),
541
+ });
467
542
  const humanContext = buildHumanContext(context);
468
543
  if (humanContext) {
469
544
  sections.push({ id: "human-context", content: humanContext });
@@ -471,11 +546,13 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
471
546
  if (!followUp && reactiveContext) {
472
547
  sections.push({ id: "reactive-context", content: reactiveContext });
473
548
  }
474
- const workflow = buildWorkflowGuidance(repoPath, runType);
549
+ const workflow = issueClass === "orchestration"
550
+ ? buildOrchestrationWorkflowGuidance()
551
+ : buildWorkflowGuidance(repoPath, runType);
475
552
  if (workflow) {
476
553
  sections.push({ id: "workflow-guidance", content: workflow });
477
554
  }
478
- sections.push({ id: "publication-contract", content: buildPublicationContract(runType) });
555
+ sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issueClass) });
479
556
  return sections;
480
557
  }
481
558
  function filterAllowedReplacements(promptLayer) {
@@ -16,6 +16,7 @@ import { RunReconciler } from "./run-reconciler.js";
16
16
  import { RunRecoveryService } from "./run-recovery-service.js";
17
17
  import { RunWakePlanner } from "./run-wake-planner.js";
18
18
  import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
19
+ import { classifyIssue } from "./issue-class.js";
19
20
  import { loadConfig } from "./config.js";
20
21
  function lowerCaseFirst(value) {
21
22
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
@@ -138,7 +139,7 @@ export class RunOrchestrator {
138
139
  materializeLegacyPendingWake(issue, lease) {
139
140
  return this.runWakePlanner.materializeLegacyPendingWake(issue, lease);
140
141
  }
141
- buildImplementationCoordinationContext(issue) {
142
+ buildRelatedIssueContext(issue) {
142
143
  const unresolvedBlockers = this.db.issues
143
144
  .listIssueDependencies(issue.projectId, issue.linearIssueId)
144
145
  .filter((entry) => !isResolvedDependencyState(entry.blockerCurrentLinearStateType))
@@ -149,10 +150,8 @@ export class RunOrchestrator {
149
150
  ...(entry.blockerCurrentLinearState ? { stateName: entry.blockerCurrentLinearState } : {}),
150
151
  ...(entry.blockerCurrentLinearStateType ? { stateType: entry.blockerCurrentLinearStateType } : {}),
151
152
  }));
152
- const trackedDependents = this.db.issues
153
- .listDependents(issue.projectId, issue.linearIssueId)
154
- .map((entry) => this.db.issues.getIssue(issue.projectId, entry.linearIssueId))
155
- .filter((entry) => Boolean(entry))
153
+ const childIssues = this.db.issues
154
+ .listChildIssues(issue.projectId, issue.linearIssueId)
156
155
  .map((entry) => ({
157
156
  linearIssueId: entry.linearIssueId,
158
157
  ...(entry.issueKey ? { issueKey: entry.issueKey } : {}),
@@ -162,14 +161,27 @@ export class RunOrchestrator {
162
161
  delegatedToPatchRelay: entry.delegatedToPatchRelay,
163
162
  hasOpenPr: entry.prNumber !== undefined && entry.prState !== "closed" && entry.prState !== "merged",
164
163
  }));
165
- if (unresolvedBlockers.length === 0 && trackedDependents.length === 0) {
164
+ if (unresolvedBlockers.length === 0 && childIssues.length === 0) {
166
165
  return {};
167
166
  }
168
167
  return {
169
168
  ...(unresolvedBlockers.length > 0 ? { unresolvedBlockers } : {}),
170
- ...(trackedDependents.length > 0 ? { trackedDependents } : {}),
169
+ ...(childIssues.length > 0 ? { childIssues } : {}),
171
170
  };
172
171
  }
172
+ classifyTrackedIssue(issue) {
173
+ const childIssueCount = this.db.issues.listChildIssues(issue.projectId, issue.linearIssueId).length;
174
+ const classification = classifyIssue({ issue, childIssueCount });
175
+ if (issue.issueClass === classification.issueClass && issue.issueClassSource === classification.issueClassSource) {
176
+ return issue;
177
+ }
178
+ return this.db.issues.upsertIssue({
179
+ projectId: issue.projectId,
180
+ linearIssueId: issue.linearIssueId,
181
+ issueClass: classification.issueClass,
182
+ issueClassSource: classification.issueClassSource,
183
+ });
184
+ }
173
185
  // ─── Run ────────────────────────────────────────────────────────
174
186
  async run(item) {
175
187
  await this.refreshCodexRuntimeConfig();
@@ -179,7 +191,8 @@ export class RunOrchestrator {
179
191
  if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
180
192
  return;
181
193
  }
182
- const issue = this.db.issues.getIssue(item.projectId, item.issueId);
194
+ const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
195
+ const issue = initialIssue ? this.classifyTrackedIssue(initialIssue) : undefined;
183
196
  if (!issue || issue.activeRunId !== undefined)
184
197
  return;
185
198
  const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
@@ -216,7 +229,7 @@ export class RunOrchestrator {
216
229
  ? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
217
230
  : context;
218
231
  const coordinationContext = runType === "implementation"
219
- ? this.buildImplementationCoordinationContext(issue)
232
+ ? this.buildRelatedIssueContext(issue)
220
233
  : undefined;
221
234
  const effectiveContext = coordinationContext
222
235
  ? { ...coordinationContext, ...(baseContext ?? {}) }
@@ -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,
@@ -42,6 +43,7 @@ export function buildTrackedIssueRecord(params) {
42
43
  projectId: params.issue.projectId,
43
44
  linearIssueId: params.issue.linearIssueId,
44
45
  delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
46
+ ...(params.issue.issueClass ? { issueClass: params.issue.issueClass } : {}),
45
47
  ...(params.issue.issueKey ? { issueKey: params.issue.issueKey } : {}),
46
48
  ...(params.issue.title ? { title: params.issue.title } : {}),
47
49
  ...(params.issue.url ? { issueUrl: params.issue.url } : {}),
@@ -63,6 +65,7 @@ export function buildTrackedIssueRecord(params) {
63
65
  blockedByCount: unresolvedBlockedBy.length,
64
66
  hasPendingWake: params.hasPendingWake,
65
67
  hasLegacyPendingRun: params.issue.pendingRunType !== undefined,
68
+ orchestrationSettleUntil: params.issue.orchestrationSettleUntil,
66
69
  ...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
67
70
  ...(params.issue.prState ? { prState: params.issue.prState } : {}),
68
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(", ")}`;
@@ -1,5 +1,6 @@
1
1
  import { triggerEventAllowed } from "../project-resolution.js";
2
2
  import { hasExplicitPatchRelayWakeIntent, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
3
+ import { classifyIssue } from "../issue-class.js";
3
4
  const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
4
5
  export class CommentWakeHandler {
5
6
  db;
@@ -24,6 +25,10 @@ export class CommentWakeHandler {
24
25
  const issue = this.db.issues.getIssue(project.id, normalized.issue.id);
25
26
  if (!issue)
26
27
  return;
28
+ const issueClass = classifyIssue({
29
+ issue,
30
+ childIssueCount: this.db.issues.listChildIssues(project.id, normalized.issue.id).length,
31
+ }).issueClass;
27
32
  const trimmedBody = normalized.comment.body.trim();
28
33
  const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
29
34
  const selfAuthored = isPatchRelayManagedCommentAuthor(installation, normalized.actor, normalized.comment.userName);
@@ -55,7 +60,7 @@ export class CommentWakeHandler {
55
60
  if (!issue.activeRunId) {
56
61
  if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
57
62
  const directReply = params.isDirectReplyToOutstandingQuestion(issue);
58
- const wakeIntent = directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
63
+ const wakeIntent = issueClass === "orchestration" || directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
59
64
  if (!wakeIntent) {
60
65
  this.feed?.publish({
61
66
  level: "info",
@@ -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.47.2",
3
+ "version": "0.49.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {