patchrelay 0.40.0 → 0.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/commands/issues.js +15 -1
- package/dist/cli/data.js +45 -0
- package/dist/cli/formatters/text.js +20 -0
- package/dist/cli/help.js +2 -0
- package/dist/cli/watch/state-visualization.js +1 -1
- package/dist/db/issue-session-store.js +3 -8
- package/dist/delegation-audit.js +39 -0
- package/dist/issue-overview-query.js +2 -1
- package/dist/issue-session-events.js +11 -3
- package/dist/merged-linear-completion-reconciler.js +84 -2
- package/dist/run-reconciler.js +131 -22
- package/dist/service-runtime.js +1 -2
- package/dist/service-startup-recovery.js +19 -0
- package/dist/service.js +4 -1
- package/dist/waiting-reason.js +1 -1
- package/dist/webhooks/desired-stage-recorder.js +56 -3
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
2
2
|
import { getRunTypeFlag } from "../args.js";
|
|
3
3
|
import { CliUsageError } from "../errors.js";
|
|
4
4
|
import { formatJson } from "../formatters/json.js";
|
|
5
|
-
import { formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatTranscriptSource, formatWorktree } from "../formatters/text.js";
|
|
5
|
+
import { formatAudit, formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatTranscriptSource, formatWorktree } from "../formatters/text.js";
|
|
6
6
|
import { buildOpenCommand } from "../interactive.js";
|
|
7
7
|
import { writeOutput } from "../output.js";
|
|
8
8
|
export async function handleIssueCommand(params) {
|
|
@@ -36,6 +36,8 @@ export async function handleIssueCommand(params) {
|
|
|
36
36
|
return await handleOpenCommand(nested);
|
|
37
37
|
case "sessions":
|
|
38
38
|
return await handleSessionsCommand(nested);
|
|
39
|
+
case "audit":
|
|
40
|
+
return await handleAuditCommand(nested);
|
|
39
41
|
case "transcript-source":
|
|
40
42
|
return await handleTranscriptSourceCommand(nested);
|
|
41
43
|
case "retry":
|
|
@@ -156,6 +158,18 @@ export async function handleSessionsCommand(params) {
|
|
|
156
158
|
: formatSessionHistory(result, (threadId) => buildOpenCommand(params.config, result.worktreePath ?? "", threadId)));
|
|
157
159
|
return 0;
|
|
158
160
|
}
|
|
161
|
+
export async function handleAuditCommand(params) {
|
|
162
|
+
const issueKey = params.commandArgs[0];
|
|
163
|
+
if (!issueKey) {
|
|
164
|
+
throw new Error("audit requires <issueKey>.");
|
|
165
|
+
}
|
|
166
|
+
const result = params.data.audit(issueKey);
|
|
167
|
+
if (!result) {
|
|
168
|
+
throw new Error(`Issue not found: ${issueKey}`);
|
|
169
|
+
}
|
|
170
|
+
writeOutput(params.stdout, params.json ? formatJson(result) : formatAudit(result));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
159
173
|
export async function handleRetryCommand(params) {
|
|
160
174
|
const issueKey = params.commandArgs[0];
|
|
161
175
|
if (!issueKey) {
|
package/dist/cli/data.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getThreadTurns } from "../codex-thread-utils.js";
|
|
|
7
7
|
import { PatchRelayDatabase } from "../db.js";
|
|
8
8
|
import { buildManualRetryAttemptReset, resolveRetryTarget } from "../manual-issue-actions.js";
|
|
9
9
|
import { WorktreeManager } from "../worktree-manager.js";
|
|
10
|
+
import { parseDelegationObservedPayload, parseRunReleasedAuthorityPayload } from "../delegation-audit.js";
|
|
10
11
|
import { CliOperatorApiClient } from "./operator-client.js";
|
|
11
12
|
function safeJsonParse(value) {
|
|
12
13
|
if (!value)
|
|
@@ -273,6 +274,50 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
273
274
|
...(run ? { releasedRunId: run.id } : {}),
|
|
274
275
|
};
|
|
275
276
|
}
|
|
277
|
+
audit(issueKey) {
|
|
278
|
+
const issue = this.db.getTrackedIssueByKey(issueKey);
|
|
279
|
+
if (!issue)
|
|
280
|
+
return undefined;
|
|
281
|
+
const events = this.db.issueSessions
|
|
282
|
+
.listIssueSessionEvents(issue.projectId, issue.linearIssueId)
|
|
283
|
+
.flatMap((event) => {
|
|
284
|
+
const delegationObserved = parseDelegationObservedPayload(event);
|
|
285
|
+
if (delegationObserved) {
|
|
286
|
+
return [{
|
|
287
|
+
createdAt: event.createdAt,
|
|
288
|
+
eventType: event.eventType,
|
|
289
|
+
summary: [
|
|
290
|
+
delegationObserved.source,
|
|
291
|
+
`observed=${delegationObserved.observedDelegatedToPatchRelay ? "delegated" : "undelegated"}`,
|
|
292
|
+
`applied=${delegationObserved.appliedDelegatedToPatchRelay ? "delegated" : "undelegated"}`,
|
|
293
|
+
`hydration=${delegationObserved.hydration}`,
|
|
294
|
+
delegationObserved.reason ? `reason=${delegationObserved.reason}` : undefined,
|
|
295
|
+
].filter(Boolean).join(" "),
|
|
296
|
+
details: delegationObserved,
|
|
297
|
+
}];
|
|
298
|
+
}
|
|
299
|
+
const authorityRelease = parseRunReleasedAuthorityPayload(event);
|
|
300
|
+
if (authorityRelease) {
|
|
301
|
+
return [{
|
|
302
|
+
createdAt: event.createdAt,
|
|
303
|
+
eventType: event.eventType,
|
|
304
|
+
summary: `released run #${authorityRelease.runId} (${authorityRelease.runType}) via ${authorityRelease.source}: ${authorityRelease.reason}`,
|
|
305
|
+
details: authorityRelease,
|
|
306
|
+
}];
|
|
307
|
+
}
|
|
308
|
+
if (event.eventType === "delegated" || event.eventType === "undelegated") {
|
|
309
|
+
return [{
|
|
310
|
+
createdAt: event.createdAt,
|
|
311
|
+
eventType: event.eventType,
|
|
312
|
+
summary: event.eventType === "delegated"
|
|
313
|
+
? "PatchRelay accepted delegation"
|
|
314
|
+
: "PatchRelay recorded undelegation",
|
|
315
|
+
}];
|
|
316
|
+
}
|
|
317
|
+
return [];
|
|
318
|
+
});
|
|
319
|
+
return { issue, events };
|
|
320
|
+
}
|
|
276
321
|
transcriptSource(issueKey, runId) {
|
|
277
322
|
const issue = this.db.getTrackedIssueByKey(issueKey);
|
|
278
323
|
if (!issue)
|
|
@@ -84,6 +84,26 @@ export function formatRetry(result) {
|
|
|
84
84
|
.filter(Boolean)
|
|
85
85
|
.join("\n")}\n`;
|
|
86
86
|
}
|
|
87
|
+
export function formatAudit(result) {
|
|
88
|
+
const lines = [
|
|
89
|
+
`${result.issue.issueKey ?? result.issue.linearIssueId}${result.issue.currentLinearState ? ` ${result.issue.currentLinearState}` : ""}`,
|
|
90
|
+
];
|
|
91
|
+
if (result.events.length === 0) {
|
|
92
|
+
lines.push("No delegation audit events recorded.");
|
|
93
|
+
return `${lines.join("\n")}\n`;
|
|
94
|
+
}
|
|
95
|
+
for (const event of result.events) {
|
|
96
|
+
lines.push("");
|
|
97
|
+
lines.push([event.createdAt, event.eventType].join(" "));
|
|
98
|
+
lines.push(event.summary);
|
|
99
|
+
if (event.details && Object.keys(event.details).length > 0) {
|
|
100
|
+
lines.push(Object.entries(event.details)
|
|
101
|
+
.map(([key, value]) => `${key}=${value === undefined ? "-" : typeof value === "string" ? value : JSON.stringify(value)}`)
|
|
102
|
+
.join(" "));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return `${lines.join("\n")}\n`;
|
|
106
|
+
}
|
|
87
107
|
export function formatClose(result) {
|
|
88
108
|
return `${[
|
|
89
109
|
value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
|
package/dist/cli/help.js
CHANGED
|
@@ -34,6 +34,7 @@ export function rootHelpText() {
|
|
|
34
34
|
" issue list [--active] [--failed] [--repo <id>] [--json]",
|
|
35
35
|
" List tracked issues",
|
|
36
36
|
" issue show <issueKey> [--json] Show the latest known issue state",
|
|
37
|
+
" issue audit <issueKey> [--json] Show delegation/release audit events for one issue",
|
|
37
38
|
" issue watch <issueKey> [--json] Follow the active run until it settles",
|
|
38
39
|
" issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
|
|
39
40
|
" issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
|
|
@@ -146,6 +147,7 @@ export function issueHelpText() {
|
|
|
146
147
|
"",
|
|
147
148
|
"Commands:",
|
|
148
149
|
" show <issueKey> Show the latest known issue state",
|
|
150
|
+
" audit <issueKey> Show delegation/release audit events",
|
|
149
151
|
" list List tracked issues",
|
|
150
152
|
" watch <issueKey> Follow issue activity until it settles",
|
|
151
153
|
" path <issueKey> Print the issue worktree path",
|
|
@@ -127,7 +127,7 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
|
|
|
127
127
|
case "awaiting_queue":
|
|
128
128
|
observations.push({
|
|
129
129
|
tone: "info",
|
|
130
|
-
text: "PatchRelay has finished active work
|
|
130
|
+
text: "PatchRelay has finished active work. Delivery now depends on downstream review and merge automation.",
|
|
131
131
|
});
|
|
132
132
|
break;
|
|
133
133
|
case "repairing_queue":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deriveSessionWakePlan } from "../issue-session-events.js";
|
|
1
|
+
import { deriveSessionWakePlan, isActionableIssueSessionEventType } from "../issue-session-events.js";
|
|
2
2
|
import { isoNow } from "./shared.js";
|
|
3
3
|
export class IssueSessionStore {
|
|
4
4
|
connection;
|
|
@@ -90,13 +90,8 @@ export class IssueSessionStore {
|
|
|
90
90
|
`).run(isoNow(), projectId, linearIssueId);
|
|
91
91
|
}
|
|
92
92
|
hasPendingIssueSessionEvents(projectId, linearIssueId) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
FROM issue_session_events
|
|
96
|
-
WHERE project_id = ? AND linear_issue_id = ? AND processed_at IS NULL
|
|
97
|
-
LIMIT 1
|
|
98
|
-
`).get(projectId, linearIssueId);
|
|
99
|
-
return row !== undefined;
|
|
93
|
+
return this.listIssueSessionEvents(projectId, linearIssueId, { pendingOnly: true })
|
|
94
|
+
.some((event) => isActionableIssueSessionEventType(event.eventType));
|
|
100
95
|
}
|
|
101
96
|
peekIssueSessionWake(projectId, linearIssueId) {
|
|
102
97
|
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function appendDelegationObservedEvent(db, params) {
|
|
2
|
+
db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.projectId, params.linearIssueId, {
|
|
3
|
+
projectId: params.projectId,
|
|
4
|
+
linearIssueId: params.linearIssueId,
|
|
5
|
+
eventType: "delegation_observed",
|
|
6
|
+
eventJson: JSON.stringify(params.payload),
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export function appendRunReleasedAuthorityEvent(db, params) {
|
|
10
|
+
db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.projectId, params.linearIssueId, {
|
|
11
|
+
projectId: params.projectId,
|
|
12
|
+
linearIssueId: params.linearIssueId,
|
|
13
|
+
eventType: "run_released_authority",
|
|
14
|
+
eventJson: JSON.stringify(params.payload),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function parseDelegationObservedPayload(event) {
|
|
18
|
+
if (event.eventType !== "delegation_observed" || !event.eventJson) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return parseObject(event.eventJson);
|
|
22
|
+
}
|
|
23
|
+
export function parseRunReleasedAuthorityPayload(event) {
|
|
24
|
+
if (event.eventType !== "run_released_authority" || !event.eventJson) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return parseObject(event.eventJson);
|
|
28
|
+
}
|
|
29
|
+
function parseObject(raw) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
33
|
+
? parsed
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -88,7 +88,7 @@ export class IssueOverviewQuery {
|
|
|
88
88
|
const runCount = runs.length;
|
|
89
89
|
const liveThread = await this.readLiveThread(activeRun);
|
|
90
90
|
const failureContext = parseGitHubFailureContext(issueRecord?.lastGitHubFailureContextJson);
|
|
91
|
-
const
|
|
91
|
+
const derivedWaitingReason = derivePatchRelayWaitingReason({
|
|
92
92
|
delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
|
|
93
93
|
...(activeRun ? { activeRunType: activeRun.runType } : {}),
|
|
94
94
|
blockedByKeys,
|
|
@@ -102,6 +102,7 @@ export class IssueOverviewQuery {
|
|
|
102
102
|
lastBlockingReviewHeadSha: issueRecord?.lastBlockingReviewHeadSha,
|
|
103
103
|
latestFailureCheckName: issueRecord?.lastGitHubFailureCheckName,
|
|
104
104
|
});
|
|
105
|
+
const waitingReason = derivedWaitingReason ?? session.waitingReason;
|
|
105
106
|
const issue = {
|
|
106
107
|
id: issueRecord?.id ?? session.id,
|
|
107
108
|
projectId: session.projectId,
|
|
@@ -7,14 +7,19 @@ const TERMINAL_SESSION_EVENTS = new Set([
|
|
|
7
7
|
"pr_closed",
|
|
8
8
|
"pr_merged",
|
|
9
9
|
]);
|
|
10
|
+
const NON_ACTIONABLE_SESSION_EVENTS = new Set([
|
|
11
|
+
"delegation_observed",
|
|
12
|
+
"run_released_authority",
|
|
13
|
+
]);
|
|
10
14
|
const RUN_TYPES = new Set(["implementation", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
|
|
11
15
|
function parseRunType(value) {
|
|
12
16
|
return typeof value === "string" && RUN_TYPES.has(value) ? value : undefined;
|
|
13
17
|
}
|
|
14
18
|
export function deriveSessionWakePlan(issue, events) {
|
|
15
|
-
|
|
19
|
+
const actionableEvents = events.filter((event) => !NON_ACTIONABLE_SESSION_EVENTS.has(event.eventType));
|
|
20
|
+
if (actionableEvents.length === 0)
|
|
16
21
|
return undefined;
|
|
17
|
-
if (
|
|
22
|
+
if (actionableEvents.some((event) => TERMINAL_SESSION_EVENTS.has(event.eventType))) {
|
|
18
23
|
return undefined;
|
|
19
24
|
}
|
|
20
25
|
const context = {};
|
|
@@ -22,7 +27,7 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
22
27
|
let wakeReason;
|
|
23
28
|
let runType;
|
|
24
29
|
let resumeThread = false;
|
|
25
|
-
for (const event of
|
|
30
|
+
for (const event of actionableEvents) {
|
|
26
31
|
const payload = parseEventJson(event.eventJson);
|
|
27
32
|
switch (event.eventType) {
|
|
28
33
|
case "merge_steward_incident":
|
|
@@ -128,6 +133,9 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
128
133
|
}
|
|
129
134
|
return { runType, wakeReason, resumeThread, context };
|
|
130
135
|
}
|
|
136
|
+
export function isActionableIssueSessionEventType(eventType) {
|
|
137
|
+
return !NON_ACTIONABLE_SESSION_EVENTS.has(eventType);
|
|
138
|
+
}
|
|
131
139
|
export function extractLatestAssistantSummary(run) {
|
|
132
140
|
if (!run)
|
|
133
141
|
return undefined;
|
|
@@ -2,24 +2,47 @@ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
|
2
2
|
import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
3
3
|
import { isCompletedLinearState } from "./pr-state.js";
|
|
4
4
|
import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
|
|
5
|
+
const COMPLETION_RECONCILE_WINDOW_MS = 60 * 60 * 1000;
|
|
6
|
+
const COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS = 60 * 60 * 1000;
|
|
7
|
+
const COMPLETION_RECONCILE_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
|
|
8
|
+
const COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS = 30 * 60 * 1000;
|
|
9
|
+
const COMPLETION_RECONCILE_MAX_ISSUES_PER_PASS = 10;
|
|
5
10
|
export class MergedLinearCompletionReconciler {
|
|
6
11
|
db;
|
|
7
12
|
linearProvider;
|
|
8
13
|
logger;
|
|
14
|
+
retryAfterByIssueKey = new Map();
|
|
15
|
+
globalRetryAfter;
|
|
9
16
|
constructor(db, linearProvider, logger) {
|
|
10
17
|
this.db = db;
|
|
11
18
|
this.linearProvider = linearProvider;
|
|
12
19
|
this.logger = logger;
|
|
13
20
|
}
|
|
14
21
|
async reconcile() {
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
if (this.globalRetryAfter !== undefined) {
|
|
24
|
+
if (this.globalRetryAfter > now) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.globalRetryAfter = undefined;
|
|
28
|
+
}
|
|
29
|
+
const candidates = this.db.issues.listIssues()
|
|
30
|
+
.filter((issue) => this.isRecentCompletionCandidate(issue, now))
|
|
31
|
+
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
32
|
+
this.pruneRetryBackoff(candidates, now);
|
|
33
|
+
let attemptedIssues = 0;
|
|
34
|
+
for (const issue of candidates) {
|
|
35
|
+
if (attemptedIssues >= COMPLETION_RECONCILE_MAX_ISSUES_PER_PASS) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (!this.shouldAttemptIssue(issue, now)) {
|
|
17
39
|
continue;
|
|
18
40
|
}
|
|
19
41
|
const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
|
|
20
42
|
if (!linear) {
|
|
21
43
|
continue;
|
|
22
44
|
}
|
|
45
|
+
attemptedIssues += 1;
|
|
23
46
|
try {
|
|
24
47
|
const liveIssue = await linear.getIssue(issue.linearIssueId);
|
|
25
48
|
this.db.issues.replaceIssueDependencies({
|
|
@@ -37,6 +60,7 @@ export class MergedLinearCompletionReconciler {
|
|
|
37
60
|
const trustedNoPrDone = hasTrustedNoPrCompletion(issue, latestRun);
|
|
38
61
|
if (issue.prState === "merged" || trustedNoPrDone) {
|
|
39
62
|
await this.reconcileCompletedLinearState(issue, liveIssue, linear);
|
|
63
|
+
this.settleIssue(issue, now);
|
|
40
64
|
continue;
|
|
41
65
|
}
|
|
42
66
|
if (issue.factoryState === "done" && !isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
|
|
@@ -45,9 +69,15 @@ export class MergedLinearCompletionReconciler {
|
|
|
45
69
|
else {
|
|
46
70
|
this.refreshCachedLinearState(issue, liveIssue);
|
|
47
71
|
}
|
|
72
|
+
this.settleIssue(issue, now);
|
|
48
73
|
}
|
|
49
74
|
catch (error) {
|
|
75
|
+
this.deferIssue(issue, error, now);
|
|
50
76
|
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged or stale completed issue state");
|
|
77
|
+
if (isRateLimitedError(error)) {
|
|
78
|
+
this.globalRetryAfter = now + COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
51
81
|
}
|
|
52
82
|
}
|
|
53
83
|
}
|
|
@@ -92,6 +122,9 @@ export class MergedLinearCompletionReconciler {
|
|
|
92
122
|
}, "Reopened stale local done state from live Linear workflow");
|
|
93
123
|
}
|
|
94
124
|
refreshCachedLinearState(issue, liveIssue) {
|
|
125
|
+
if (issue.currentLinearState === liveIssue.stateName && issue.currentLinearStateType === liveIssue.stateType) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
95
128
|
this.db.issues.upsertIssue({
|
|
96
129
|
projectId: issue.projectId,
|
|
97
130
|
linearIssueId: issue.linearIssueId,
|
|
@@ -99,6 +132,55 @@ export class MergedLinearCompletionReconciler {
|
|
|
99
132
|
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
100
133
|
});
|
|
101
134
|
}
|
|
135
|
+
isRecentCompletionCandidate(issue, now) {
|
|
136
|
+
if (issue.factoryState !== "done" && issue.prState !== "merged") {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const updatedAt = Date.parse(issue.updatedAt);
|
|
140
|
+
return Number.isFinite(updatedAt) && now - updatedAt <= COMPLETION_RECONCILE_WINDOW_MS;
|
|
141
|
+
}
|
|
142
|
+
shouldAttemptIssue(issue, now) {
|
|
143
|
+
const retry = this.retryAfterByIssueKey.get(this.issueKey(issue));
|
|
144
|
+
if (!retry) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (retry.updatedAt !== issue.updatedAt) {
|
|
148
|
+
this.retryAfterByIssueKey.delete(this.issueKey(issue));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return retry.retryAfter <= now;
|
|
152
|
+
}
|
|
153
|
+
settleIssue(issue, now) {
|
|
154
|
+
this.retryAfterByIssueKey.set(this.issueKey(issue), {
|
|
155
|
+
retryAfter: now + COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS,
|
|
156
|
+
updatedAt: issue.updatedAt,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
deferIssue(issue, error, now) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
const backoffMs = /ratelimit|rate limit/i.test(message)
|
|
162
|
+
? COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS
|
|
163
|
+
: COMPLETION_RECONCILE_FAILURE_BACKOFF_MS;
|
|
164
|
+
this.retryAfterByIssueKey.set(this.issueKey(issue), {
|
|
165
|
+
retryAfter: now + backoffMs,
|
|
166
|
+
updatedAt: issue.updatedAt,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
pruneRetryBackoff(candidates, now) {
|
|
170
|
+
const candidateKeys = new Set(candidates.map((issue) => this.issueKey(issue)));
|
|
171
|
+
for (const [key, retry] of this.retryAfterByIssueKey.entries()) {
|
|
172
|
+
if (!candidateKeys.has(key) || retry.retryAfter <= now) {
|
|
173
|
+
this.retryAfterByIssueKey.delete(key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
issueKey(issue) {
|
|
178
|
+
return `${issue.projectId}::${issue.linearIssueId}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isRateLimitedError(error) {
|
|
182
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
183
|
+
return /ratelimit|rate limit/i.test(message);
|
|
102
184
|
}
|
|
103
185
|
function resolveOpenWorkflowState(issue) {
|
|
104
186
|
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
package/dist/run-reconciler.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendDelegationObservedEvent, appendRunReleasedAuthorityEvent } from "./delegation-audit.js";
|
|
1
2
|
import { TERMINAL_STATES } from "./factory-state.js";
|
|
2
3
|
import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
|
|
3
4
|
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
@@ -31,39 +32,40 @@ export class RunReconciler {
|
|
|
31
32
|
async reconcile(params) {
|
|
32
33
|
const { run, issue, recoveryLease } = params;
|
|
33
34
|
const acquiredRecoveryLease = recoveryLease === true;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
let effectiveIssue = issue;
|
|
36
|
+
if (!effectiveIssue.delegatedToPatchRelay) {
|
|
37
|
+
const authority = await this.confirmDelegationAuthorityBeforeRelease(run, effectiveIssue);
|
|
38
|
+
effectiveIssue = authority.issue;
|
|
39
|
+
if (authority.released) {
|
|
40
|
+
const pausedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
41
|
+
void this.linearSync.syncSession(pausedIssue, { activeRunType: run.runType });
|
|
42
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
43
45
|
}
|
|
44
|
-
if (TERMINAL_STATES.has(
|
|
46
|
+
if (TERMINAL_STATES.has(effectiveIssue.factoryState)) {
|
|
45
47
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
46
48
|
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
|
|
47
49
|
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
48
50
|
});
|
|
49
|
-
this.logger.info({ issueKey:
|
|
50
|
-
const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
51
|
+
this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, factoryState: effectiveIssue.factoryState }, "Reconciliation: released run on terminal issue");
|
|
52
|
+
const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
51
53
|
void this.linearSync.syncSession(releasedIssue, { activeRunType: run.runType });
|
|
52
54
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
53
55
|
return;
|
|
54
56
|
}
|
|
55
57
|
if (!run.threadId) {
|
|
56
58
|
if (recoveryLease === "owned") {
|
|
57
|
-
this.logger.debug({ issueKey:
|
|
59
|
+
this.logger.debug({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
|
|
58
60
|
return;
|
|
59
61
|
}
|
|
60
|
-
this.logger.warn({ issueKey:
|
|
62
|
+
this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
|
|
61
63
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
62
64
|
this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
|
|
63
65
|
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
64
66
|
});
|
|
65
|
-
this.recoverOrEscalate(
|
|
66
|
-
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
67
|
+
this.recoverOrEscalate(effectiveIssue, run.runType, "zombie");
|
|
68
|
+
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
67
69
|
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex turn never started before PatchRelay restarted."));
|
|
68
70
|
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
69
71
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
@@ -74,13 +76,13 @@ export class RunReconciler {
|
|
|
74
76
|
thread = await this.readThreadWithRetry(run.threadId);
|
|
75
77
|
}
|
|
76
78
|
catch {
|
|
77
|
-
this.logger.warn({ issueKey:
|
|
79
|
+
this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
|
|
78
80
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
79
81
|
this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
|
|
80
82
|
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
81
83
|
});
|
|
82
|
-
this.recoverOrEscalate(
|
|
83
|
-
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
84
|
+
this.recoverOrEscalate(effectiveIssue, run.runType, "stale_thread");
|
|
85
|
+
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
84
86
|
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "PatchRelay lost the active Codex thread after restart and needs to recover."));
|
|
85
87
|
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
86
88
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
@@ -111,7 +113,7 @@ export class RunReconciler {
|
|
|
111
113
|
status: "reconciled",
|
|
112
114
|
summary: `Linear state ${stopState.stateName} -> done`,
|
|
113
115
|
});
|
|
114
|
-
const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
116
|
+
const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
115
117
|
void this.linearSync.syncSession(doneIssue, { activeRunType: run.runType });
|
|
116
118
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
117
119
|
return;
|
|
@@ -120,14 +122,14 @@ export class RunReconciler {
|
|
|
120
122
|
}
|
|
121
123
|
const latestTurn = getThreadTurns(thread).at(-1);
|
|
122
124
|
if (latestTurn?.status === "interrupted") {
|
|
123
|
-
await this.interruptedRunRecovery.handle(run,
|
|
125
|
+
await this.interruptedRunRecovery.handle(run, effectiveIssue);
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
126
128
|
if (latestTurn?.status === "completed") {
|
|
127
129
|
await this.runFinalizer.finalizeCompletedRun({
|
|
128
130
|
source: "reconciliation",
|
|
129
131
|
run,
|
|
130
|
-
issue,
|
|
132
|
+
issue: effectiveIssue,
|
|
131
133
|
thread,
|
|
132
134
|
threadId: run.threadId,
|
|
133
135
|
...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
|
|
@@ -139,4 +141,111 @@ export class RunReconciler {
|
|
|
139
141
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
140
142
|
}
|
|
141
143
|
}
|
|
144
|
+
async confirmDelegationAuthorityBeforeRelease(run, issue) {
|
|
145
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(run.projectId);
|
|
146
|
+
const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
|
|
147
|
+
if (!installation?.actorId || !linear) {
|
|
148
|
+
appendDelegationObservedEvent(this.db, {
|
|
149
|
+
projectId: run.projectId,
|
|
150
|
+
linearIssueId: run.linearIssueId,
|
|
151
|
+
payload: {
|
|
152
|
+
source: "run_reconciler",
|
|
153
|
+
...(installation?.actorId ? { actorId: installation.actorId } : {}),
|
|
154
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
155
|
+
observedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
156
|
+
appliedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
157
|
+
hydration: "live_linear_failed",
|
|
158
|
+
activeRunId: run.id,
|
|
159
|
+
decision: "none",
|
|
160
|
+
reason: "live_linear_unavailable_before_undelegation_release",
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return { issue, released: false };
|
|
164
|
+
}
|
|
165
|
+
const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
|
|
166
|
+
if (!linearIssue) {
|
|
167
|
+
appendDelegationObservedEvent(this.db, {
|
|
168
|
+
projectId: run.projectId,
|
|
169
|
+
linearIssueId: run.linearIssueId,
|
|
170
|
+
payload: {
|
|
171
|
+
source: "run_reconciler",
|
|
172
|
+
actorId: installation.actorId,
|
|
173
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
174
|
+
observedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
175
|
+
appliedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
176
|
+
hydration: "live_linear_failed",
|
|
177
|
+
activeRunId: run.id,
|
|
178
|
+
decision: "none",
|
|
179
|
+
reason: "live_linear_refresh_failed_before_undelegation_release",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
return { issue, released: false };
|
|
183
|
+
}
|
|
184
|
+
const delegated = linearIssue.delegateId === installation.actorId;
|
|
185
|
+
appendDelegationObservedEvent(this.db, {
|
|
186
|
+
projectId: run.projectId,
|
|
187
|
+
linearIssueId: run.linearIssueId,
|
|
188
|
+
payload: {
|
|
189
|
+
source: "run_reconciler",
|
|
190
|
+
actorId: installation.actorId,
|
|
191
|
+
...(linearIssue.delegateId ? { observedDelegateId: linearIssue.delegateId } : {}),
|
|
192
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
193
|
+
observedDelegatedToPatchRelay: delegated,
|
|
194
|
+
appliedDelegatedToPatchRelay: delegated,
|
|
195
|
+
hydration: "live_linear",
|
|
196
|
+
activeRunId: run.id,
|
|
197
|
+
decision: delegated ? "resume_issue" : "release_run",
|
|
198
|
+
reason: delegated
|
|
199
|
+
? "live_linear_confirmed_issue_is_still_delegated"
|
|
200
|
+
: "live_linear_confirmed_issue_is_no_longer_delegated",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
if (delegated) {
|
|
204
|
+
const repairedIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issues.upsertIssue({
|
|
205
|
+
projectId: run.projectId,
|
|
206
|
+
linearIssueId: run.linearIssueId,
|
|
207
|
+
delegatedToPatchRelay: true,
|
|
208
|
+
...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
|
|
209
|
+
...(linearIssue.title ? { title: linearIssue.title } : {}),
|
|
210
|
+
...(linearIssue.description ? { description: linearIssue.description } : {}),
|
|
211
|
+
...(linearIssue.url ? { url: linearIssue.url } : {}),
|
|
212
|
+
...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
|
|
213
|
+
...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
|
|
214
|
+
...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
|
|
215
|
+
...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
|
|
216
|
+
})) ?? issue;
|
|
217
|
+
return { issue: repairedIssue, released: false };
|
|
218
|
+
}
|
|
219
|
+
appendRunReleasedAuthorityEvent(this.db, {
|
|
220
|
+
projectId: run.projectId,
|
|
221
|
+
linearIssueId: run.linearIssueId,
|
|
222
|
+
payload: {
|
|
223
|
+
runId: run.id,
|
|
224
|
+
runType: run.runType,
|
|
225
|
+
localDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
226
|
+
liveDelegatedToPatchRelay: delegated,
|
|
227
|
+
source: "run_reconciler",
|
|
228
|
+
reason: "Issue was un-delegated during active run",
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
232
|
+
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
|
|
233
|
+
this.db.issues.upsertIssue({
|
|
234
|
+
projectId: run.projectId,
|
|
235
|
+
linearIssueId: run.linearIssueId,
|
|
236
|
+
activeRunId: null,
|
|
237
|
+
factoryState: issue.factoryState,
|
|
238
|
+
delegatedToPatchRelay: false,
|
|
239
|
+
...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
|
|
240
|
+
...(linearIssue.title ? { title: linearIssue.title } : {}),
|
|
241
|
+
...(linearIssue.description ? { description: linearIssue.description } : {}),
|
|
242
|
+
...(linearIssue.url ? { url: linearIssue.url } : {}),
|
|
243
|
+
...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
|
|
244
|
+
...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
|
|
245
|
+
...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
|
|
246
|
+
...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
return { issue, released: true };
|
|
250
|
+
}
|
|
142
251
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -30,13 +30,12 @@ export class ServiceRuntime {
|
|
|
30
30
|
async start() {
|
|
31
31
|
try {
|
|
32
32
|
await this.codex.start();
|
|
33
|
-
await this.runReconciler.reconcileActiveRuns();
|
|
34
33
|
for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
|
|
35
34
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
36
35
|
}
|
|
37
36
|
this.ready = true;
|
|
38
37
|
this.startupError = undefined;
|
|
39
|
-
this.
|
|
38
|
+
void this.runBackgroundReconcile();
|
|
40
39
|
}
|
|
41
40
|
catch (error) {
|
|
42
41
|
this.ready = false;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendDelegationObservedEvent } from "./delegation-audit.js";
|
|
1
2
|
import { isResumablePausedLocalWork } from "./paused-issue-state.js";
|
|
2
3
|
export class ServiceStartupRecovery {
|
|
3
4
|
db;
|
|
@@ -65,6 +66,24 @@ export class ServiceStartupRecovery {
|
|
|
65
66
|
})),
|
|
66
67
|
});
|
|
67
68
|
const delegated = liveIssue.delegateId === installation.actorId;
|
|
69
|
+
if (issue.delegatedToPatchRelay !== delegated) {
|
|
70
|
+
appendDelegationObservedEvent(this.db, {
|
|
71
|
+
projectId: issue.projectId,
|
|
72
|
+
linearIssueId: issue.linearIssueId,
|
|
73
|
+
payload: {
|
|
74
|
+
source: "startup_recovery",
|
|
75
|
+
actorId: installation.actorId,
|
|
76
|
+
...(liveIssue.delegateId ? { observedDelegateId: liveIssue.delegateId } : {}),
|
|
77
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
78
|
+
observedDelegatedToPatchRelay: delegated,
|
|
79
|
+
appliedDelegatedToPatchRelay: delegated,
|
|
80
|
+
hydration: "live_linear",
|
|
81
|
+
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
82
|
+
decision: delegated ? "resume_issue" : "none",
|
|
83
|
+
reason: "startup_recovery_refreshed_linear_delegation",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
68
87
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
69
88
|
const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
70
89
|
const shouldRecoverPausedLocalWork = delegated
|
package/dist/service.js
CHANGED
|
@@ -106,7 +106,10 @@ export class PatchRelayService {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
await this.runtime.start();
|
|
109
|
-
|
|
109
|
+
void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
|
|
110
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
111
|
+
this.logger.warn({ error: msg }, "Background delegated issue recovery failed");
|
|
112
|
+
});
|
|
110
113
|
void this.startupRecovery.syncKnownAgentSessions().catch((error) => {
|
|
111
114
|
const msg = error instanceof Error ? error.message : String(error);
|
|
112
115
|
this.logger.warn({ error: msg }, "Background agent session sync failed");
|
package/dist/waiting-reason.js
CHANGED
|
@@ -10,7 +10,7 @@ export const PATCHRELAY_WAITING_REASONS = {
|
|
|
10
10
|
waitingForReviewOnNewHead: "Waiting on review of a newer pushed head",
|
|
11
11
|
sameHeadStillBlocked: "Requested changes still block the current head",
|
|
12
12
|
waitingForMergeStewardRepair: "Waiting to repair a merge-steward incident",
|
|
13
|
-
waitingForDownstreamAutomation: "
|
|
13
|
+
waitingForDownstreamAutomation: "PatchRelay work is done; waiting on downstream review/merge automation",
|
|
14
14
|
workComplete: "PatchRelay work is complete",
|
|
15
15
|
waitingForOperatorIntervention: "Waiting on operator intervention",
|
|
16
16
|
waitingForExternalReview: "Waiting on external review",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { triggerEventAllowed } from "../project-resolution.js";
|
|
2
2
|
import { resolveAwaitingInputReason } from "../awaiting-input-reason.js";
|
|
3
|
+
import { appendDelegationObservedEvent } from "../delegation-audit.js";
|
|
3
4
|
import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
|
|
4
5
|
import { buildOperatorRetryEvent } from "../operator-retry-event.js";
|
|
5
6
|
export class DesiredStageRecorder {
|
|
@@ -25,8 +26,20 @@ export class DesiredStageRecorder {
|
|
|
25
26
|
if (!existingIssue && !this.isDelegatedToPatchRelay(params.project, normalizedIssue) && !incomingAgentSessionId) {
|
|
26
27
|
return { issue: undefined, wakeRunType: undefined, delegated: false };
|
|
27
28
|
}
|
|
28
|
-
const
|
|
29
|
-
const
|
|
29
|
+
const syncResult = await this.syncIssueDependencies(params.project.id, normalizedIssue);
|
|
30
|
+
const hydratedIssue = syncResult.issue;
|
|
31
|
+
const delegation = this.resolveDelegationTruth({
|
|
32
|
+
project: params.project,
|
|
33
|
+
normalizedIssue,
|
|
34
|
+
hydratedIssue,
|
|
35
|
+
existingIssue,
|
|
36
|
+
triggerEvent: params.normalized.triggerEvent,
|
|
37
|
+
webhookId: params.normalized.webhookId,
|
|
38
|
+
actorId: params.normalized.actor?.id,
|
|
39
|
+
hydration: syncResult.hydration,
|
|
40
|
+
activeRunId: activeRun?.id,
|
|
41
|
+
});
|
|
42
|
+
const delegated = delegation.delegated;
|
|
30
43
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
|
|
31
44
|
const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
|
|
32
45
|
const openPrExists = existingIssue?.prNumber !== undefined
|
|
@@ -200,16 +213,56 @@ export class DesiredStageRecorder {
|
|
|
200
213
|
return false;
|
|
201
214
|
return issue.delegateId === installation.actorId;
|
|
202
215
|
}
|
|
216
|
+
resolveDelegationTruth(params) {
|
|
217
|
+
const previousDelegated = params.existingIssue?.delegatedToPatchRelay;
|
|
218
|
+
const observedDelegated = this.isDelegatedToPatchRelay(params.project, params.hydratedIssue);
|
|
219
|
+
const explicitDelegateSignal = params.triggerEvent === "delegateChanged";
|
|
220
|
+
const hasObservedDelegate = params.hydratedIssue.delegateId !== undefined;
|
|
221
|
+
let delegated = observedDelegated;
|
|
222
|
+
let reason = hasObservedDelegate
|
|
223
|
+
? "delegate_id_present"
|
|
224
|
+
: `missing_delegate_identity_after_${params.hydration}`;
|
|
225
|
+
if (!hasObservedDelegate && !explicitDelegateSignal && previousDelegated !== undefined) {
|
|
226
|
+
delegated = previousDelegated;
|
|
227
|
+
reason = `preserved_previous_delegation_after_${params.hydration}`;
|
|
228
|
+
}
|
|
229
|
+
if (previousDelegated !== delegated
|
|
230
|
+
|| params.hydration === "live_linear_failed"
|
|
231
|
+
|| (!hasObservedDelegate && previousDelegated !== undefined)) {
|
|
232
|
+
appendDelegationObservedEvent(this.db, {
|
|
233
|
+
projectId: params.project.id,
|
|
234
|
+
linearIssueId: params.normalizedIssue.id,
|
|
235
|
+
payload: {
|
|
236
|
+
source: "linear_webhook",
|
|
237
|
+
webhookId: params.webhookId,
|
|
238
|
+
triggerEvent: params.triggerEvent,
|
|
239
|
+
...(params.actorId ? { actorId: params.actorId } : {}),
|
|
240
|
+
...(params.hydratedIssue.delegateId ? { observedDelegateId: params.hydratedIssue.delegateId } : {}),
|
|
241
|
+
...(previousDelegated !== undefined ? { previousDelegatedToPatchRelay: previousDelegated } : {}),
|
|
242
|
+
observedDelegatedToPatchRelay: observedDelegated,
|
|
243
|
+
appliedDelegatedToPatchRelay: delegated,
|
|
244
|
+
hydration: params.hydration,
|
|
245
|
+
...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
|
|
246
|
+
decision: "none",
|
|
247
|
+
reason,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return { delegated };
|
|
252
|
+
}
|
|
203
253
|
async syncIssueDependencies(projectId, issue) {
|
|
204
254
|
let source = issue;
|
|
255
|
+
let hydration = "webhook_only";
|
|
205
256
|
if (!source.relationsKnown) {
|
|
206
257
|
const linear = await this.linearProvider.forProject(projectId);
|
|
207
258
|
if (linear) {
|
|
208
259
|
try {
|
|
209
260
|
source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
|
|
261
|
+
hydration = "live_linear";
|
|
210
262
|
}
|
|
211
263
|
catch {
|
|
212
264
|
// Preserve existing dependency rows when webhook relation data is incomplete.
|
|
265
|
+
hydration = "live_linear_failed";
|
|
213
266
|
}
|
|
214
267
|
}
|
|
215
268
|
}
|
|
@@ -226,6 +279,6 @@ export class DesiredStageRecorder {
|
|
|
226
279
|
})),
|
|
227
280
|
});
|
|
228
281
|
}
|
|
229
|
-
return source;
|
|
282
|
+
return { issue: source, hydration };
|
|
230
283
|
}
|
|
231
284
|
}
|