patchrelay 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/db/issue-store.js +8 -2
- package/dist/db/migrations.js +5 -0
- package/dist/delegation-audit.js +39 -0
- package/dist/delegation-linked-pr.js +104 -0
- package/dist/issue-overview-query.js +2 -1
- package/dist/issue-session-events.js +11 -3
- package/dist/issue-session.js +2 -0
- package/dist/linear-client.js +17 -0
- package/dist/linear-linked-pr-reconciliation.js +44 -0
- package/dist/merged-linear-completion-reconciler.js +84 -2
- package/dist/remote-pr-state.js +1 -1
- 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/decision-helpers.js +8 -0
- package/dist/webhooks/desired-stage-recorder.js +149 -35
- package/package.json +1 -1
|
@@ -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/remote-pr-state.js
CHANGED
|
@@ -3,7 +3,7 @@ export async function readRemotePrState(repoFullName, prNumber) {
|
|
|
3
3
|
const { stdout, exitCode } = await execCommand("gh", [
|
|
4
4
|
"pr", "view", String(prNumber),
|
|
5
5
|
"--repo", repoFullName,
|
|
6
|
-
"--json", "headRefOid,state,reviewDecision,mergeStateStatus",
|
|
6
|
+
"--json", "url,headRefName,headRefOid,isDraft,isCrossRepository,state,author,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup",
|
|
7
7
|
], { timeoutMs: 10_000 });
|
|
8
8
|
if (exitCode !== 0)
|
|
9
9
|
return undefined;
|
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",
|
|
@@ -38,10 +38,17 @@ export function resolveReDelegationResume(p) {
|
|
|
38
38
|
if (p.prState === "merged") {
|
|
39
39
|
return { factoryState: "done", pendingRunType: null };
|
|
40
40
|
}
|
|
41
|
+
if (p.prNumber !== undefined && (p.prState === undefined || p.prState === "open") && p.prIsDraft) {
|
|
42
|
+
return {
|
|
43
|
+
factoryState: "delegated",
|
|
44
|
+
pendingRunType: (p.unresolvedBlockers ?? 0) === 0 ? "implementation" : null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
41
47
|
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
|
42
48
|
delegatedToPatchRelay: true,
|
|
43
49
|
prNumber: p.prNumber,
|
|
44
50
|
prState: p.prState,
|
|
51
|
+
prIsDraft: p.prIsDraft,
|
|
45
52
|
prReviewState: p.prReviewState,
|
|
46
53
|
prCheckStatus: p.prCheckStatus,
|
|
47
54
|
latestFailureSource: p.latestFailureSource,
|
|
@@ -100,6 +107,7 @@ export function mergeIssueMetadata(issue, liveIssue) {
|
|
|
100
107
|
...(issue.identifier ? {} : liveIssue.identifier ? { identifier: liveIssue.identifier } : {}),
|
|
101
108
|
...(issue.title ? {} : liveIssue.title ? { title: liveIssue.title } : {}),
|
|
102
109
|
...(issue.url ? {} : liveIssue.url ? { url: liveIssue.url } : {}),
|
|
110
|
+
...(issue.attachments && issue.attachments.length > 0 ? {} : liveIssue.attachments ? { attachments: liveIssue.attachments } : {}),
|
|
103
111
|
...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
|
|
104
112
|
...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
|
|
105
113
|
...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
|