patchrelay 0.75.2 → 0.76.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/agent-input-service.js +40 -26
- package/dist/build-info.json +3 -3
- package/dist/cli/data.js +3 -1
- package/dist/db/issue-session-store.js +44 -9
- package/dist/db/issue-store.js +31 -2
- package/dist/db/migrations.js +3 -0
- package/dist/factory-state.js +23 -0
- package/dist/github-webhook-reactive-run.js +15 -11
- package/dist/github-webhook-stack-coordination.js +8 -4
- package/dist/github-webhook-state-projector.js +204 -139
- package/dist/github-webhook-terminal-handler.js +37 -27
- package/dist/idle-reconciliation.js +122 -66
- package/dist/implementation-outcome-policy.js +5 -1
- package/dist/interrupted-run-recovery.js +46 -33
- package/dist/issue-session-projection-invalidator.js +9 -0
- package/dist/linear-agent-session-client.js +16 -8
- package/dist/linear-issue-projection.js +15 -11
- package/dist/linear-status-comment-sync.js +8 -4
- package/dist/linear-workflow-state-sync.js +9 -5
- package/dist/merged-linear-completion-reconciler.js +39 -17
- package/dist/no-pr-completion-check.js +51 -29
- package/dist/orchestration-parent-wake.js +15 -8
- package/dist/queue-health-monitor.js +17 -8
- package/dist/reactive-run-policy.js +5 -1
- package/dist/run-finalizer.js +61 -29
- package/dist/run-launcher.js +42 -12
- package/dist/run-notification-handler.js +19 -7
- package/dist/run-orchestrator.js +121 -18
- package/dist/run-reconciler.js +121 -50
- package/dist/run-recovery-service.js +70 -33
- package/dist/run-wake-planner.js +39 -29
- package/dist/service-issue-actions.js +45 -28
- package/dist/service-startup-recovery.js +61 -35
- package/dist/telemetry.js +9 -0
- package/dist/terminal-wake-reconciler.js +20 -3
- package/dist/webhooks/agent-session-handler.js +22 -12
- package/dist/webhooks/dependency-readiness-handler.js +17 -10
- package/dist/webhooks/desired-stage-recorder.js +32 -13
- package/dist/webhooks/issue-removal-handler.js +24 -13
- package/package.json +1 -1
package/dist/run-wake-planner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
|
|
2
2
|
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
3
|
+
const WRITER = "run-wake-planner";
|
|
3
4
|
export class RunWakePlanner {
|
|
4
5
|
db;
|
|
5
6
|
constructor(db) {
|
|
@@ -62,15 +63,19 @@ export class RunWakePlanner {
|
|
|
62
63
|
? JSON.parse(issue.pendingRunContextJson)
|
|
63
64
|
: undefined;
|
|
64
65
|
this.appendWakeEventWithLease(lease, issue, issue.pendingRunType, context, "legacy_pending");
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
67
|
+
writer: WRITER,
|
|
68
|
+
lease,
|
|
69
|
+
update: {
|
|
70
|
+
projectId: issue.projectId,
|
|
71
|
+
linearIssueId: issue.linearIssueId,
|
|
72
|
+
pendingRunType: null,
|
|
73
|
+
pendingRunContextJson: null,
|
|
74
|
+
},
|
|
70
75
|
});
|
|
71
|
-
if (
|
|
76
|
+
if (commit.outcome !== "applied")
|
|
72
77
|
return issue;
|
|
73
|
-
return
|
|
78
|
+
return commit.issue;
|
|
74
79
|
}
|
|
75
80
|
budgetExceeded(issue, project, runType, isRequestedChangesRunType) {
|
|
76
81
|
const ciRepairBudget = getCiRepairBudget(project);
|
|
@@ -88,27 +93,32 @@ export class RunWakePlanner {
|
|
|
88
93
|
return undefined;
|
|
89
94
|
}
|
|
90
95
|
incrementAttemptCounters(issue, lease, runType, isRequestedChangesRunType) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
projectId: issue.projectId,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
96
|
+
// The increments are read-modify-write against the issue row (which may
|
|
97
|
+
// be stale by the time the launch path gets here); on conflict, recompute
|
|
98
|
+
// from the fresh row instead of writing a counter derived from the stale
|
|
99
|
+
// read.
|
|
100
|
+
const buildIncrement = (record) => {
|
|
101
|
+
if (runType === "ci_repair") {
|
|
102
|
+
return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: record.ciRepairAttempts + 1 };
|
|
103
|
+
}
|
|
104
|
+
if (runType === "queue_repair") {
|
|
105
|
+
return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: record.queueRepairAttempts + 1 };
|
|
106
|
+
}
|
|
107
|
+
if (isRequestedChangesRunType(runType)) {
|
|
108
|
+
return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: record.reviewFixAttempts + 1 };
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
};
|
|
112
|
+
const update = buildIncrement(issue);
|
|
113
|
+
if (!update)
|
|
114
|
+
return true;
|
|
115
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
116
|
+
writer: WRITER,
|
|
117
|
+
lease,
|
|
118
|
+
expectedVersion: issue.version,
|
|
119
|
+
update,
|
|
120
|
+
onConflict: (current) => buildIncrement(current),
|
|
121
|
+
});
|
|
122
|
+
return commit.outcome === "applied";
|
|
113
123
|
}
|
|
114
124
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildOperatorRetryEvent } from "./operator-retry-event.js";
|
|
2
2
|
import { buildManualRetryAttemptReset, resolveRetryTarget } from "./manual-issue-actions.js";
|
|
3
|
+
const WRITER = "service-issue-actions";
|
|
3
4
|
export class ServiceIssueActions {
|
|
4
5
|
config;
|
|
5
6
|
db;
|
|
@@ -76,10 +77,13 @@ export class ServiceIssueActions {
|
|
|
76
77
|
dedupeKey: `operator_stop:${issue.linearIssueId}`,
|
|
77
78
|
});
|
|
78
79
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
79
|
-
this.db.issueSessions.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
this.db.issueSessions.commitIssueState({
|
|
81
|
+
writer: WRITER,
|
|
82
|
+
update: {
|
|
83
|
+
projectId: issue.projectId,
|
|
84
|
+
linearIssueId: issue.linearIssueId,
|
|
85
|
+
factoryState: "awaiting_input",
|
|
86
|
+
},
|
|
83
87
|
});
|
|
84
88
|
this.feed.publish({
|
|
85
89
|
level: "warn",
|
|
@@ -111,19 +115,25 @@ export class ServiceIssueActions {
|
|
|
111
115
|
lastGitHubFailureSource: issue.lastGitHubFailureSource,
|
|
112
116
|
});
|
|
113
117
|
if (retryTarget.runType === "none") {
|
|
114
|
-
this.db.issueSessions.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
this.db.issueSessions.commitIssueState({
|
|
119
|
+
writer: WRITER,
|
|
120
|
+
update: {
|
|
121
|
+
projectId: issue.projectId,
|
|
122
|
+
linearIssueId: issue.linearIssueId,
|
|
123
|
+
factoryState: "done",
|
|
124
|
+
},
|
|
118
125
|
});
|
|
119
126
|
return { issueKey, runType: "none" };
|
|
120
127
|
}
|
|
121
128
|
this.appendOperatorRetryEvent(issue, retryTarget.runType);
|
|
122
|
-
this.db.issueSessions.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
this.db.issueSessions.commitIssueState({
|
|
130
|
+
writer: WRITER,
|
|
131
|
+
update: {
|
|
132
|
+
projectId: issue.projectId,
|
|
133
|
+
linearIssueId: issue.linearIssueId,
|
|
134
|
+
factoryState: retryTarget.factoryState,
|
|
135
|
+
...buildManualRetryAttemptReset(retryTarget.runType),
|
|
136
|
+
},
|
|
127
137
|
});
|
|
128
138
|
this.feed.publish({
|
|
129
139
|
level: "info",
|
|
@@ -168,22 +178,29 @@ export class ServiceIssueActions {
|
|
|
168
178
|
dedupeKey: `operator_closed:${issue.linearIssueId}:${terminalState}:${issue.activeRunId ?? "no-run"}`,
|
|
169
179
|
});
|
|
170
180
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
// Operator close is authoritative: the issue terminal write and the run
|
|
182
|
+
// release ride in one transaction, with the run gated on the issue commit.
|
|
183
|
+
this.db.transaction(() => {
|
|
184
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
185
|
+
writer: WRITER,
|
|
186
|
+
update: {
|
|
187
|
+
projectId: issue.projectId,
|
|
188
|
+
linearIssueId: issue.linearIssueId,
|
|
189
|
+
delegatedToPatchRelay: false,
|
|
190
|
+
factoryState: terminalState,
|
|
191
|
+
activeRunId: null,
|
|
192
|
+
pendingRunType: null,
|
|
193
|
+
pendingRunContextJson: null,
|
|
194
|
+
},
|
|
177
195
|
});
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
pendingRunContextJson: null,
|
|
196
|
+
if (run && commit.outcome === "applied") {
|
|
197
|
+
this.db.runs.finishRun(run.id, {
|
|
198
|
+
status: "released",
|
|
199
|
+
failureReason: options?.reason
|
|
200
|
+
? `Operator closed issue as ${terminalState}: ${options.reason}`
|
|
201
|
+
: `Operator closed issue as ${terminalState}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
187
204
|
});
|
|
188
205
|
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
189
206
|
this.feed.publish({
|
|
@@ -3,6 +3,7 @@ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
|
3
3
|
import { isResumablePausedLocalWork } from "./paused-issue-state.js";
|
|
4
4
|
import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
|
|
5
5
|
import { upsertLinearIssueProjection } from "./linear-issue-projection.js";
|
|
6
|
+
const WRITER = "service-startup-recovery";
|
|
6
7
|
export class ServiceStartupRecovery {
|
|
7
8
|
config;
|
|
8
9
|
db;
|
|
@@ -30,13 +31,17 @@ export class ServiceStartupRecovery {
|
|
|
30
31
|
? issue
|
|
31
32
|
: (() => {
|
|
32
33
|
const recoveredAgentSessionId = this.db.webhookEvents.findLatestAgentSessionIdForIssue(issue.linearIssueId);
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
if (!recoveredAgentSessionId)
|
|
35
|
+
return issue;
|
|
36
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
37
|
+
writer: WRITER,
|
|
38
|
+
update: {
|
|
35
39
|
projectId: issue.projectId,
|
|
36
40
|
linearIssueId: issue.linearIssueId,
|
|
37
41
|
agentSessionId: recoveredAgentSessionId,
|
|
38
|
-
}
|
|
39
|
-
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
return commit.outcome === "applied" ? commit.issue : issue;
|
|
40
45
|
})();
|
|
41
46
|
if (!syncedIssue.agentSessionId) {
|
|
42
47
|
continue;
|
|
@@ -54,7 +59,7 @@ export class ServiceStartupRecovery {
|
|
|
54
59
|
}
|
|
55
60
|
async recoverDelegatedIssueStateFromLinear() {
|
|
56
61
|
await this.discoverDelegatedIssuesFromLinear();
|
|
57
|
-
for (
|
|
62
|
+
for (let issue of this.db.issues.listIssues()) {
|
|
58
63
|
if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
|
|
59
64
|
continue;
|
|
60
65
|
}
|
|
@@ -71,6 +76,9 @@ export class ServiceStartupRecovery {
|
|
|
71
76
|
continue;
|
|
72
77
|
}
|
|
73
78
|
upsertLinearIssueProjection(this.db, issue.projectId, liveIssue);
|
|
79
|
+
// The projection write bumped the issue version; continue with the
|
|
80
|
+
// fresh row so the recovery commit below doesn't self-conflict.
|
|
81
|
+
issue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
74
82
|
const delegated = liveIssue.delegateId === installation.actorId;
|
|
75
83
|
if (issue.delegatedToPatchRelay !== delegated) {
|
|
76
84
|
appendDelegationObservedEvent(this.db, {
|
|
@@ -116,24 +124,36 @@ export class ServiceStartupRecovery {
|
|
|
116
124
|
const shouldRecoverReactivePrWork = delegated
|
|
117
125
|
&& issue.prNumber !== undefined
|
|
118
126
|
&& reactiveIntent !== undefined;
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
? {
|
|
133
|
-
:
|
|
134
|
-
|
|
135
|
-
:
|
|
127
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
128
|
+
writer: WRITER,
|
|
129
|
+
expectedVersion: issue.version,
|
|
130
|
+
update: {
|
|
131
|
+
projectId: issue.projectId,
|
|
132
|
+
linearIssueId: issue.linearIssueId,
|
|
133
|
+
delegatedToPatchRelay: delegated,
|
|
134
|
+
...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
|
|
135
|
+
...(liveIssue.title ? { title: liveIssue.title } : {}),
|
|
136
|
+
...(liveIssue.description ? { description: liveIssue.description } : {}),
|
|
137
|
+
...(liveIssue.url ? { url: liveIssue.url } : {}),
|
|
138
|
+
...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
|
|
139
|
+
...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
|
|
140
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
141
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
142
|
+
...(shouldRecoverPausedLocalWork
|
|
143
|
+
? { factoryState: "delegated" }
|
|
144
|
+
: shouldRecoverReactivePrWork
|
|
145
|
+
? { factoryState: reactiveIntent.compatibilityFactoryState }
|
|
146
|
+
: {}),
|
|
147
|
+
},
|
|
148
|
+
// The recovery decision was derived from the row read at loop start
|
|
149
|
+
// plus stale PR facts; a concurrent writer (webhook, another recovery
|
|
150
|
+
// pass) invalidates it. Skip — reconciliation re-derives shortly.
|
|
151
|
+
onConflict: () => undefined,
|
|
136
152
|
});
|
|
153
|
+
if (commit.outcome !== "applied") {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const updated = commit.issue;
|
|
137
157
|
if (!shouldRecoverPausedLocalWork && !shouldRecoverReactivePrWork) {
|
|
138
158
|
continue;
|
|
139
159
|
}
|
|
@@ -215,20 +235,26 @@ export class ServiceStartupRecovery {
|
|
|
215
235
|
upsertDiscoveredDelegatedIssue(project, liveIssue) {
|
|
216
236
|
upsertLinearIssueProjection(this.db, project.id, liveIssue);
|
|
217
237
|
const existing = this.db.issues.getIssue(project.id, liveIssue.id);
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
238
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
239
|
+
writer: WRITER,
|
|
240
|
+
update: {
|
|
241
|
+
projectId: project.id,
|
|
242
|
+
linearIssueId: liveIssue.id,
|
|
243
|
+
delegatedToPatchRelay: true,
|
|
244
|
+
factoryState: existing?.factoryState ?? "delegated",
|
|
245
|
+
...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
|
|
246
|
+
...(liveIssue.title ? { title: liveIssue.title } : {}),
|
|
247
|
+
...(liveIssue.description ? { description: liveIssue.description } : {}),
|
|
248
|
+
...(liveIssue.url ? { url: liveIssue.url } : {}),
|
|
249
|
+
...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
|
|
250
|
+
...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
|
|
251
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
252
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
253
|
+
},
|
|
231
254
|
});
|
|
255
|
+
if (commit.outcome !== "applied")
|
|
256
|
+
return;
|
|
257
|
+
const updated = commit.issue;
|
|
232
258
|
const hasPendingWake = this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id) !== undefined;
|
|
233
259
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(project.id, liveIssue.id);
|
|
234
260
|
if (!hasPendingWake && unresolvedBlockers === 0) {
|
package/dist/telemetry.js
CHANGED
|
@@ -78,6 +78,15 @@ export class OperatorFeedTelemetrySink {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
return undefined;
|
|
81
|
+
case "state.write_conflict":
|
|
82
|
+
return {
|
|
83
|
+
level: "warn",
|
|
84
|
+
kind: "workflow",
|
|
85
|
+
...(event.issueKey ? { issueKey: event.issueKey } : {}),
|
|
86
|
+
...(event.projectId ? { projectId: event.projectId } : {}),
|
|
87
|
+
status: "state_write_conflict",
|
|
88
|
+
summary: `Issue-state write conflict (${event.writer}): expected v${event.expectedVersion ?? "none"}, found v${event.actualVersion ?? "none"} — ${event.resolution.replaceAll("_", " ")}`,
|
|
89
|
+
};
|
|
81
90
|
case "health.invariant":
|
|
82
91
|
return {
|
|
83
92
|
level: event.status === "observed" ? "warn" : "info",
|
|
@@ -15,14 +15,31 @@ export class TerminalWakeReconciler {
|
|
|
15
15
|
&& issue.pendingRunType === undefined) {
|
|
16
16
|
continue;
|
|
17
17
|
}
|
|
18
|
-
this.db.issueSessions.
|
|
19
|
-
|
|
18
|
+
const pendingEvents = this.db.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId, { pendingOnly: true });
|
|
19
|
+
const clearUpdate = {
|
|
20
20
|
projectId: issue.projectId,
|
|
21
21
|
linearIssueId: issue.linearIssueId,
|
|
22
22
|
pendingRunType: null,
|
|
23
23
|
pendingRunContextJson: null,
|
|
24
|
+
};
|
|
25
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
26
|
+
writer: "terminal-wake-reconciler",
|
|
27
|
+
expectedVersion: issue.version,
|
|
28
|
+
update: clearUpdate,
|
|
29
|
+
// Only clear if the issue is still terminal on the fresh row.
|
|
30
|
+
onConflict: (current) => (TERMINAL_STATES.has(current.factoryState) ? clearUpdate : undefined),
|
|
24
31
|
});
|
|
25
|
-
|
|
32
|
+
if (commit.outcome !== "applied")
|
|
33
|
+
continue;
|
|
34
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
35
|
+
// Audit trail: record what was dropped so "why didn't this retry?"
|
|
36
|
+
// is answerable later.
|
|
37
|
+
this.logger.info({
|
|
38
|
+
issueKey: issue.issueKey,
|
|
39
|
+
factoryState: issue.factoryState,
|
|
40
|
+
droppedPendingRunType: issue.pendingRunType,
|
|
41
|
+
droppedEventTypes: pendingEvents.map((event) => event.eventType),
|
|
42
|
+
}, "Reconciliation: cleared stale terminal wake");
|
|
26
43
|
}
|
|
27
44
|
}
|
|
28
45
|
}
|
|
@@ -3,6 +3,7 @@ import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js"
|
|
|
3
3
|
import { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
|
|
4
4
|
import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
|
|
5
5
|
import { resolveProject, triggerEventAllowed } from "../project-resolution.js";
|
|
6
|
+
const WRITER = "agent-session-handler";
|
|
6
7
|
const PATCHRELAY_AGENT_ACTIVITY_TYPES = new Set([
|
|
7
8
|
"action",
|
|
8
9
|
"elicitation",
|
|
@@ -172,19 +173,28 @@ export class AgentSessionHandler {
|
|
|
172
173
|
catch (error) {
|
|
173
174
|
this.logger.warn({ issueKey: params.trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
|
|
174
175
|
}
|
|
175
|
-
this.db.runs.finishRun(params.activeRun.id, {
|
|
176
|
-
status: "released",
|
|
177
|
-
threadId: params.activeRun.threadId,
|
|
178
|
-
turnId: params.activeRun.turnId,
|
|
179
|
-
failureReason: dirtySummary ? `Stop signal received; ${dirtySummary}` : "Stop signal received",
|
|
180
|
-
});
|
|
181
176
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
177
|
+
// The stop signal is a user fact: the issue slot clear and the run
|
|
178
|
+
// release ride in one transaction, with the run gated on the issue commit.
|
|
179
|
+
this.db.transaction(() => {
|
|
180
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
181
|
+
writer: WRITER,
|
|
182
|
+
update: {
|
|
183
|
+
projectId: params.project.id,
|
|
184
|
+
linearIssueId: issueId,
|
|
185
|
+
activeRunId: null,
|
|
186
|
+
factoryState: "awaiting_input",
|
|
187
|
+
agentSessionId: sessionId,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
if (commit.outcome === "applied" && params.activeRun?.threadId && params.activeRun.turnId) {
|
|
191
|
+
this.db.runs.finishRun(params.activeRun.id, {
|
|
192
|
+
status: "released",
|
|
193
|
+
threadId: params.activeRun.threadId,
|
|
194
|
+
turnId: params.activeRun.turnId,
|
|
195
|
+
failureReason: dirtySummary ? `Stop signal received; ${dirtySummary}` : "Stop signal received",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
188
198
|
});
|
|
189
199
|
this.db.issueSessions.appendIssueSessionEvent({
|
|
190
200
|
projectId: params.project.id,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { emitTelemetry, noopTelemetry } from "../telemetry.js";
|
|
2
|
+
const WRITER = "dependency-readiness-handler";
|
|
2
3
|
export class DependencyReadinessHandler {
|
|
3
4
|
db;
|
|
4
5
|
wakeDispatcher;
|
|
@@ -41,11 +42,14 @@ export class DependencyReadinessHandler {
|
|
|
41
42
|
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
|
|
42
43
|
&& issue.activeRunId === undefined
|
|
43
44
|
&& !this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
|
|
44
|
-
this.db.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
this.db.issueSessions.commitIssueState({
|
|
46
|
+
writer: WRITER,
|
|
47
|
+
update: {
|
|
48
|
+
projectId,
|
|
49
|
+
linearIssueId: dependent.linearIssueId,
|
|
50
|
+
pendingRunType: null,
|
|
51
|
+
pendingRunContextJson: null,
|
|
52
|
+
},
|
|
49
53
|
});
|
|
50
54
|
}
|
|
51
55
|
continue;
|
|
@@ -72,11 +76,14 @@ export class DependencyReadinessHandler {
|
|
|
72
76
|
continue;
|
|
73
77
|
}
|
|
74
78
|
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
|
|
75
|
-
this.db.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
this.db.issueSessions.commitIssueState({
|
|
80
|
+
writer: WRITER,
|
|
81
|
+
update: {
|
|
82
|
+
projectId,
|
|
83
|
+
linearIssueId: dependent.linearIssueId,
|
|
84
|
+
pendingRunType: null,
|
|
85
|
+
pendingRunContextJson: null,
|
|
86
|
+
},
|
|
80
87
|
});
|
|
81
88
|
}
|
|
82
89
|
const dispatchedRunType = this.wakeDispatcher.recordEventAndDispatch(projectId, dependent.linearIssueId, {
|
|
@@ -7,6 +7,7 @@ import { resolveLinkedPrAdoption } from "./linked-pr-adoption.js";
|
|
|
7
7
|
import { buildOperatorRetryEvent } from "../operator-retry-event.js";
|
|
8
8
|
import { planIssueWebhookWorkflow } from "./issue-webhook-workflow-planner.js";
|
|
9
9
|
import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
|
|
10
|
+
const WRITER = "desired-stage-recorder";
|
|
10
11
|
export class DesiredStageRecorder {
|
|
11
12
|
db;
|
|
12
13
|
linearProvider;
|
|
@@ -84,8 +85,14 @@ export class DesiredStageRecorder {
|
|
|
84
85
|
: workflowPlan.effectiveRunRelease.reason
|
|
85
86
|
: undefined;
|
|
86
87
|
const dirtyWorktreePayload = releaseWorktreeStatus ? dirtyWorktreeEventPayload(releaseWorktreeStatus) : undefined;
|
|
87
|
-
const
|
|
88
|
-
|
|
88
|
+
const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.project.id, normalizedIssue.id);
|
|
89
|
+
// Webhook intake projection: the fields are facts carried by the webhook
|
|
90
|
+
// payload and the hydrated Linear issue, applied unconditionally (the
|
|
91
|
+
// active lease still gates the write, matching the previous semantics).
|
|
92
|
+
const issueCommit = this.db.issueSessions.commitIssueState({
|
|
93
|
+
writer: WRITER,
|
|
94
|
+
...(activeLease ? { lease: activeLease } : {}),
|
|
95
|
+
update: {
|
|
89
96
|
projectId: params.project.id,
|
|
90
97
|
linearIssueId: normalizedIssue.id,
|
|
91
98
|
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
@@ -103,20 +110,32 @@ export class DesiredStageRecorder {
|
|
|
103
110
|
...linkedPrAdoption?.issueUpdates,
|
|
104
111
|
delegatedToPatchRelay: delegated,
|
|
105
112
|
...workflowPlan.resolvedIssueUpdate,
|
|
106
|
-
}
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
let issue;
|
|
116
|
+
if (issueCommit.outcome === "applied") {
|
|
117
|
+
issue = issueCommit.issue;
|
|
107
118
|
if (workflowPlan.effectiveRunRelease.release && activeRun && releaseReason) {
|
|
108
119
|
this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: releaseReason });
|
|
109
120
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
}
|
|
122
|
+
else if (existingIssue) {
|
|
123
|
+
issue = existingIssue;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const fallbackCommit = this.db.issueSessions.commitIssueState({
|
|
127
|
+
writer: WRITER,
|
|
128
|
+
update: {
|
|
129
|
+
projectId: params.project.id,
|
|
130
|
+
linearIssueId: normalizedIssue.id,
|
|
131
|
+
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
if (fallbackCommit.outcome !== "applied") {
|
|
135
|
+
return { issue: undefined, wakeRunType: undefined, delegated };
|
|
136
|
+
}
|
|
137
|
+
issue = fallbackCommit.issue;
|
|
138
|
+
}
|
|
120
139
|
const previousParentIssueId = existingIssue?.parentLinearIssueId;
|
|
121
140
|
const currentParentIssueId = issue.parentLinearIssueId;
|
|
122
141
|
const wasResolved = isResolvedLinearState(existingIssue?.currentLinearStateType, existingIssue?.currentLinearState);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TERMINAL_STATES } from "../factory-state.js";
|
|
2
|
+
const WRITER = "issue-removal-handler";
|
|
2
3
|
export class IssueRemovalHandler {
|
|
3
4
|
db;
|
|
4
5
|
feed;
|
|
@@ -13,27 +14,42 @@ export class IssueRemovalHandler {
|
|
|
13
14
|
const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.projectId, params.issue.id);
|
|
14
15
|
const commitRemoval = () => {
|
|
15
16
|
if (removedIssue?.activeRunId) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
return this.db.issues.upsertIssue({
|
|
17
|
+
const removedRunId = removedIssue.activeRunId;
|
|
18
|
+
const run = this.db.runs.getRunById(removedRunId);
|
|
19
|
+
const update = {
|
|
21
20
|
projectId: params.projectId,
|
|
22
21
|
linearIssueId: params.issue.id,
|
|
23
22
|
activeRunId: null,
|
|
24
23
|
pendingRunType: null,
|
|
25
24
|
factoryState: "failed",
|
|
25
|
+
};
|
|
26
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
27
|
+
writer: WRITER,
|
|
28
|
+
expectedVersion: removedIssue.version,
|
|
29
|
+
...(activeLease ? { lease: activeLease } : {}),
|
|
30
|
+
update,
|
|
31
|
+
onConflict: (current) => (current.activeRunId === removedRunId ? update : undefined),
|
|
26
32
|
});
|
|
33
|
+
if (run && commit.outcome === "applied") {
|
|
34
|
+
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
27
37
|
}
|
|
28
38
|
if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
|
|
29
|
-
|
|
39
|
+
const update = {
|
|
30
40
|
projectId: params.projectId,
|
|
31
41
|
linearIssueId: params.issue.id,
|
|
32
42
|
pendingRunType: null,
|
|
33
43
|
factoryState: "failed",
|
|
44
|
+
};
|
|
45
|
+
this.db.issueSessions.commitIssueState({
|
|
46
|
+
writer: WRITER,
|
|
47
|
+
expectedVersion: removedIssue.version,
|
|
48
|
+
...(activeLease ? { lease: activeLease } : {}),
|
|
49
|
+
update,
|
|
50
|
+
onConflict: (current) => (TERMINAL_STATES.has(current.factoryState) ? undefined : update),
|
|
34
51
|
});
|
|
35
52
|
}
|
|
36
|
-
return removedIssue;
|
|
37
53
|
};
|
|
38
54
|
if (removedIssue?.activeRunId) {
|
|
39
55
|
const run = this.db.runs.getRunById(removedIssue.activeRunId);
|
|
@@ -41,12 +57,7 @@ export class IssueRemovalHandler {
|
|
|
41
57
|
await params.stopActiveRun(run, "STOP: The Linear issue was removed. Stop working immediately and exit.");
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
|
-
|
|
45
|
-
this.db.issueSessions.withIssueSessionLease(params.projectId, params.issue.id, activeLease.leaseId, commitRemoval);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
commitRemoval();
|
|
49
|
-
}
|
|
60
|
+
commitRemoval();
|
|
50
61
|
this.db.issueSessions.appendIssueSessionEvent({
|
|
51
62
|
projectId: params.projectId,
|
|
52
63
|
linearIssueId: params.issue.id,
|