patchrelay 0.48.0 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -4,6 +4,7 @@ import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
4
4
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
5
5
  import { getThreadTurns } from "./codex-thread-utils.js";
6
6
  import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
7
+ import { resolveEffectiveActiveRun } from "./effective-active-run.js";
7
8
  export class RunReconciler {
8
9
  db;
9
10
  logger;
@@ -33,6 +34,19 @@ export class RunReconciler {
33
34
  const { run, issue, recoveryLease } = params;
34
35
  const acquiredRecoveryLease = recoveryLease === true;
35
36
  let effectiveIssue = issue;
37
+ const effectiveActiveRun = resolveEffectiveActiveRun({
38
+ activeRun: issue.activeRunId === run.id ? run : undefined,
39
+ latestRun: run,
40
+ });
41
+ if (effectiveActiveRun?.id === run.id && issue.activeRunId !== run.id) {
42
+ effectiveIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issues.upsertIssue({
43
+ projectId: run.projectId,
44
+ linearIssueId: run.linearIssueId,
45
+ activeRunId: run.id,
46
+ ...(run.threadId ? { threadId: run.threadId } : {}),
47
+ })) ?? effectiveIssue;
48
+ this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Reattached detached active run during reconciliation");
49
+ }
36
50
  if (!effectiveIssue.delegatedToPatchRelay) {
37
51
  const authority = await this.confirmDelegationAuthorityBeforeRelease(run, effectiveIssue);
38
52
  effectiveIssue = authority.issue;
@@ -2,6 +2,7 @@ import { parseGitHubFailureContext, summarizeGitHubFailureContext } from "./gith
2
2
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
3
3
  import { deriveIssueStatusNote } from "./status-note.js";
4
4
  import { isIssueSessionReadyForExecution } from "./issue-session.js";
5
+ import { hasDetachedActiveLatestRun } from "./effective-active-run.js";
5
6
  function shouldSuppressStatusNote(params) {
6
7
  if (!params.activeRunType && params.sessionState !== "running")
7
8
  return false;
@@ -82,6 +83,7 @@ export class TrackedIssueListQuery {
82
83
  s.project_id, s.linear_issue_id, s.issue_key, i.title,
83
84
  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
85
  i.pending_run_type,
86
+ i.orchestration_settle_until,
85
87
  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
88
  i.last_github_ci_snapshot_json,
87
89
  i.last_github_failure_source,
@@ -154,14 +156,28 @@ export class TrackedIssueListQuery {
154
156
  const hasPendingSessionEvents = Number(row.pending_session_event_count ?? 0) > 0;
155
157
  const hasPendingWake = hasPendingSessionEvents
156
158
  || this.db.issueSessions.peekIssueSessionWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
159
+ const detachedActiveRun = hasDetachedActiveLatestRun({
160
+ activeRunId: row.active_run_type !== null ? 1 : undefined,
161
+ latestRun: row.latest_run_status !== null
162
+ ? { id: 0, status: String(row.latest_run_status) }
163
+ : undefined,
164
+ });
165
+ const effectiveActiveRunType = row.active_run_type !== null
166
+ ? String(row.active_run_type)
167
+ : detachedActiveRun && row.latest_run_type !== null
168
+ ? String(row.latest_run_type)
169
+ : undefined;
157
170
  const readyForExecution = isIssueSessionReadyForExecution({
158
- ...(typeof row.session_state === "string" ? { sessionState: String(row.session_state) } : {}),
171
+ ...(typeof row.session_state === "string"
172
+ ? { sessionState: detachedActiveRun ? "running" : String(row.session_state) }
173
+ : {}),
159
174
  factoryState: String(row.factory_state ?? "delegated"),
160
175
  ...(row.delegated_to_patchrelay !== null ? { delegatedToPatchRelay: Number(row.delegated_to_patchrelay) !== 0 } : {}),
161
- ...(row.active_run_type !== null ? { activeRunId: 1 } : {}),
176
+ ...((row.active_run_type !== null || detachedActiveRun) ? { activeRunId: 1 } : {}),
162
177
  blockedByCount,
163
178
  hasPendingWake,
164
179
  hasLegacyPendingRun: row.pending_run_type !== null && row.pending_run_type !== undefined,
180
+ ...(row.orchestration_settle_until !== null ? { orchestrationSettleUntil: String(row.orchestration_settle_until) } : {}),
165
181
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
166
182
  ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
167
183
  ...(row.pr_review_state !== null ? { prReviewState: String(row.pr_review_state) } : {}),
@@ -175,12 +191,13 @@ export class TrackedIssueListQuery {
175
191
  const sessionSummary = typeof row.summary_text === "string" && row.summary_text.trim().length > 0
176
192
  ? row.summary_text
177
193
  : undefined;
178
- const waitingReason = sessionWaitingReason ?? derivePatchRelayWaitingReason({
194
+ const derivedWaitingReason = derivePatchRelayWaitingReason({
179
195
  ...(row.delegated_to_patchrelay !== null ? { delegatedToPatchRelay: Number(row.delegated_to_patchrelay) !== 0 } : {}),
180
- ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
196
+ ...((row.active_run_type !== null || detachedActiveRun) ? { activeRunId: 1 } : {}),
181
197
  blockedByKeys,
182
198
  factoryState: String(row.factory_state ?? "delegated"),
183
199
  ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
200
+ ...(row.orchestration_settle_until !== null ? { orchestrationSettleUntil: String(row.orchestration_settle_until) } : {}),
184
201
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
185
202
  ...(row.pr_state !== null ? { prState: String(row.pr_state) } : {}),
186
203
  ...(row.pr_head_sha !== null ? { prHeadSha: String(row.pr_head_sha) } : {}),
@@ -189,6 +206,7 @@ export class TrackedIssueListQuery {
189
206
  ...(row.last_blocking_review_head_sha !== null ? { lastBlockingReviewHeadSha: String(row.last_blocking_review_head_sha) } : {}),
190
207
  ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
191
208
  });
209
+ const waitingReason = detachedActiveRun ? derivedWaitingReason : sessionWaitingReason ?? derivedWaitingReason;
192
210
  const latestRun = row.latest_run_type !== null && row.latest_run_status !== null
193
211
  ? {
194
212
  id: 0,
@@ -219,29 +237,39 @@ export class TrackedIssueListQuery {
219
237
  waitingReason,
220
238
  }) ?? waitingReason;
221
239
  const statusNoteForReturn = shouldSuppressStatusNote({
222
- activeRunType: row.active_run_type,
223
- sessionState: row.session_state,
240
+ activeRunType: effectiveActiveRunType,
241
+ sessionState: detachedActiveRun ? "running" : row.session_state,
224
242
  statusNote: statusNoteCandidate,
225
243
  })
226
244
  ? undefined
227
245
  : statusNoteCandidate;
228
- const completionCheckActive = typeof row.active_completion_check_thread_id === "string"
229
- && row.active_completion_check_thread_id.length > 0
230
- && row.active_completion_check_outcome === null
231
- && row.active_run_type !== null;
246
+ const activeCompletionCheckThreadId = row.active_run_type !== null
247
+ ? row.active_completion_check_thread_id
248
+ : detachedActiveRun
249
+ ? row.latest_run_completion_check_thread_id
250
+ : null;
251
+ const activeCompletionCheckOutcome = row.active_run_type !== null
252
+ ? row.active_completion_check_outcome
253
+ : detachedActiveRun
254
+ ? row.latest_run_completion_check_outcome
255
+ : null;
256
+ const completionCheckActive = typeof activeCompletionCheckThreadId === "string"
257
+ && activeCompletionCheckThreadId.length > 0
258
+ && activeCompletionCheckOutcome === null
259
+ && effectiveActiveRunType !== undefined;
232
260
  return {
233
261
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
234
262
  ...(row.title !== null ? { title: String(row.title) } : {}),
235
263
  ...(statusNoteForReturn ? { statusNote: statusNoteForReturn } : {}),
236
264
  projectId: String(row.project_id),
237
265
  delegatedToPatchRelay: row.delegated_to_patchrelay === null ? true : Number(row.delegated_to_patchrelay) !== 0,
238
- ...(row.session_state !== null ? { sessionState: String(row.session_state) } : {}),
266
+ ...(row.session_state !== null ? { sessionState: detachedActiveRun ? "running" : String(row.session_state) } : {}),
239
267
  factoryState: String(row.factory_state ?? "delegated"),
240
268
  blockedByCount,
241
269
  blockedByKeys,
242
270
  readyForExecution,
243
271
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
244
- ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
272
+ ...(effectiveActiveRunType ? { activeRunType: effectiveActiveRunType } : {}),
245
273
  ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
246
274
  ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
247
275
  ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
@@ -2,6 +2,7 @@ import { parseGitHubFailureContext } from "./github-failure-context.js";
2
2
  import { isIssueSessionReadyForExecution } from "./issue-session.js";
3
3
  import { deriveIssueStatusNote } from "./status-note.js";
4
4
  import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
5
+ import { hasDetachedActiveLatestRun, resolveEffectiveActiveRun } from "./effective-active-run.js";
5
6
  export function isResolvedLinearState(stateType, stateName) {
6
7
  return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
7
8
  }
@@ -9,12 +10,21 @@ export function buildTrackedIssueRecord(params) {
9
10
  const unresolvedBlockedBy = params.blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
10
11
  const failureContext = parseGitHubFailureContext(params.issue.lastGitHubFailureContextJson);
11
12
  const blockedByKeys = unresolvedBlockedBy.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
13
+ const effectiveActiveRun = resolveEffectiveActiveRun({
14
+ activeRun: params.issue.activeRunId !== undefined && params.latestRun?.id === params.issue.activeRunId ? params.latestRun : undefined,
15
+ latestRun: params.latestRun,
16
+ });
17
+ const detachedActiveRun = hasDetachedActiveLatestRun({
18
+ activeRunId: params.issue.activeRunId,
19
+ latestRun: params.latestRun,
20
+ });
12
21
  const waitingReason = derivePatchRelayWaitingReason({
13
22
  delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
14
- ...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
23
+ ...(effectiveActiveRun ? { activeRunId: effectiveActiveRun.id } : {}),
15
24
  blockedByKeys,
16
25
  factoryState: params.issue.factoryState,
17
26
  pendingRunType: params.issue.pendingRunType,
27
+ orchestrationSettleUntil: params.issue.orchestrationSettleUntil,
18
28
  prNumber: params.issue.prNumber,
19
29
  prState: params.issue.prState,
20
30
  prHeadSha: params.issue.prHeadSha,
@@ -32,11 +42,9 @@ export function buildTrackedIssueRecord(params) {
32
42
  blockedByKeys,
33
43
  waitingReason,
34
44
  });
35
- const completionCheckActive = Boolean(params.issue.activeRunId !== undefined
36
- && params.latestRun?.id === params.issue.activeRunId
37
- && params.latestRun.status === "running"
38
- && params.latestRun.completionCheckThreadId
39
- && !params.latestRun.completionCheckOutcome);
45
+ const completionCheckActive = Boolean(effectiveActiveRun?.status === "running"
46
+ && effectiveActiveRun.completionCheckThreadId
47
+ && !effectiveActiveRun.completionCheckOutcome);
40
48
  return {
41
49
  id: params.issue.id,
42
50
  projectId: params.issue.projectId,
@@ -60,10 +68,11 @@ export function buildTrackedIssueRecord(params) {
60
68
  sessionState: params.session?.sessionState,
61
69
  factoryState: params.issue.factoryState,
62
70
  delegatedToPatchRelay: params.issue.delegatedToPatchRelay,
63
- activeRunId: params.issue.activeRunId,
71
+ ...(effectiveActiveRun ? { activeRunId: effectiveActiveRun.id } : {}),
64
72
  blockedByCount: unresolvedBlockedBy.length,
65
73
  hasPendingWake: params.hasPendingWake,
66
74
  hasLegacyPendingRun: params.issue.pendingRunType !== undefined,
75
+ orchestrationSettleUntil: params.issue.orchestrationSettleUntil,
67
76
  ...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
68
77
  ...(params.issue.prState ? { prState: params.issue.prState } : {}),
69
78
  ...(params.issue.prReviewState ? { prReviewState: params.issue.prReviewState } : {}),
@@ -77,8 +86,9 @@ export function buildTrackedIssueRecord(params) {
77
86
  ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
78
87
  ...(waitingReason ? { waitingReason } : {}),
79
88
  ...(completionCheckActive ? { completionCheckActive } : {}),
80
- ...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
89
+ ...(effectiveActiveRun ? { activeRunId: effectiveActiveRun.id } : {}),
81
90
  ...(params.issue.agentSessionId ? { activeAgentSessionId: params.issue.agentSessionId } : {}),
91
+ ...(detachedActiveRun && params.session?.sessionState ? { sessionState: "running" } : {}),
82
92
  updatedAt: params.issue.updatedAt,
83
93
  };
84
94
  }
@@ -1,5 +1,6 @@
1
1
  import { buildTrackedIssueRecord } from "./tracked-issue-projector.js";
2
2
  import { deriveIssueSessionState, isIssueSessionReadyForExecution } from "./issue-session.js";
3
+ import { resolveEffectiveActiveRun } from "./effective-active-run.js";
3
4
  export class TrackedIssueQuery {
4
5
  issues;
5
6
  issueSessions;
@@ -55,7 +56,10 @@ export class TrackedIssueQuery {
55
56
  if (!issue)
56
57
  return undefined;
57
58
  const tracked = this.issueToTrackedIssue(issue);
58
- const activeRun = issue.activeRunId ? this.runs.getRunById(issue.activeRunId) : undefined;
59
+ const activeRun = resolveEffectiveActiveRun({
60
+ activeRun: issue.activeRunId ? this.runs.getRunById(issue.activeRunId) : undefined,
61
+ latestRun: this.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId),
62
+ });
59
63
  return {
60
64
  issue: tracked,
61
65
  ...(activeRun ? { activeRun } : {}),
@@ -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) {