patchrelay 0.78.1 → 0.80.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 +1 -1
- package/dist/awaiting-input-reason.js +6 -7
- package/dist/build-info.json +3 -3
- package/dist/git-worktree-status.js +1 -1
- package/dist/idle-reconciliation-helpers.js +2 -4
- package/dist/idle-reconciliation.js +3 -2
- package/dist/issue-execution-state.js +139 -0
- package/dist/issue-session-events.js +219 -53
- package/dist/issue-session-lease-service.js +69 -66
- package/dist/issue-session.js +0 -4
- package/dist/linear-agent-activity-recovery.js +2 -8
- package/dist/operator-retry-event.js +12 -6
- package/dist/paused-issue-state.js +5 -4
- package/dist/prompting/patchrelay.js +47 -80
- package/dist/queue-health-monitor.js +4 -3
- package/dist/run-context.js +382 -0
- package/dist/run-failure-policy.js +2 -1
- package/dist/run-finalizer.js +44 -28
- package/dist/run-launcher.js +3 -5
- package/dist/run-orchestrator.js +10 -14
- package/dist/run-wake-planner.js +40 -30
- package/dist/status-note.js +36 -18
- package/dist/tracked-issue-list-query.js +5 -1
- package/dist/utils.js +9 -0
- package/dist/waiting-reason.js +68 -68
- package/dist/webhooks/issue-update-plan.js +2 -1
- package/dist/workflow-wake-resolver.js +20 -10
- package/package.json +1 -1
|
@@ -1,34 +1,51 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { getThreadTurns } from "./codex-thread-utils.js";
|
|
3
2
|
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
4
|
-
const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
|
|
3
|
+
export const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* Expected heartbeat cadence for a live lease holder. Heartbeats are
|
|
6
|
+
* notification-driven (every Codex notification renews the lease) rather
|
|
7
|
+
* than timer-driven, so this is the staleness budget granted to a live
|
|
8
|
+
* holder, not a timer the service runs. A foreign holder whose inferred
|
|
9
|
+
* last heartbeat (`leasedUntil - ISSUE_SESSION_LEASE_MS`) is older than
|
|
10
|
+
* 2x this budget is presumed dead (crashed process) and its lease may be
|
|
11
|
+
* reclaimed for recovery without waiting for full TTL expiry.
|
|
12
|
+
*/
|
|
13
|
+
export const ISSUE_SESSION_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
14
|
+
const FOREIGN_LEASE_RECLAIM_STALENESS_MS = 2 * ISSUE_SESSION_HEARTBEAT_INTERVAL_MS;
|
|
15
|
+
/**
|
|
16
|
+
* Issue-session lease coordination over the `issue_sessions` lease columns.
|
|
17
|
+
* The DB row (`lease_id`, `worker_id`, `leased_until`) is the only truth —
|
|
18
|
+
* there is no in-memory mirror, so a restarted process loses no lease state
|
|
19
|
+
* (D4, core simplification plan). "Held by us" means the row carries this
|
|
20
|
+
* service's `workerId` with an unexpired `leased_until`.
|
|
21
|
+
*/
|
|
5
22
|
export class IssueSessionLeaseService {
|
|
6
23
|
db;
|
|
7
24
|
logger;
|
|
8
25
|
workerId;
|
|
9
|
-
readThreadWithRetry;
|
|
10
26
|
telemetry;
|
|
11
|
-
|
|
12
|
-
constructor(db, logger, workerId, readThreadWithRetry, telemetry = noopTelemetry) {
|
|
27
|
+
constructor(db, logger, workerId, telemetry = noopTelemetry) {
|
|
13
28
|
this.db = db;
|
|
14
29
|
this.logger = logger;
|
|
15
30
|
this.workerId = workerId;
|
|
16
|
-
this.readThreadWithRetry = readThreadWithRetry;
|
|
17
31
|
this.telemetry = telemetry;
|
|
18
32
|
}
|
|
19
33
|
hasLocalLease(projectId, linearIssueId) {
|
|
20
|
-
return this.
|
|
34
|
+
return this.getHeldLease(projectId, linearIssueId) !== undefined;
|
|
21
35
|
}
|
|
22
36
|
getHeldLease(projectId, linearIssueId) {
|
|
23
|
-
const
|
|
24
|
-
if (!leaseId)
|
|
37
|
+
const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
|
|
38
|
+
if (!session?.leaseId || session.workerId !== this.workerId || !isLeaseActive(session)) {
|
|
25
39
|
return undefined;
|
|
26
|
-
|
|
40
|
+
}
|
|
41
|
+
return { projectId, linearIssueId, leaseId: session.leaseId };
|
|
27
42
|
}
|
|
28
43
|
withHeldLease(projectId, linearIssueId, fn) {
|
|
29
44
|
const lease = this.getHeldLease(projectId, linearIssueId);
|
|
30
45
|
if (!lease)
|
|
31
46
|
return undefined;
|
|
47
|
+
// Re-validated inside the store's transaction so the check and the
|
|
48
|
+
// guarded writes are atomic.
|
|
32
49
|
return this.db.issueSessions.withIssueSessionLease(projectId, linearIssueId, lease.leaseId, () => fn(lease));
|
|
33
50
|
}
|
|
34
51
|
acquire(projectId, linearIssueId) {
|
|
@@ -46,7 +63,6 @@ export class IssueSessionLeaseService {
|
|
|
46
63
|
this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
|
|
47
64
|
return undefined;
|
|
48
65
|
}
|
|
49
|
-
this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
|
|
50
66
|
this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
|
|
51
67
|
return leaseId;
|
|
52
68
|
}
|
|
@@ -64,20 +80,17 @@ export class IssueSessionLeaseService {
|
|
|
64
80
|
this.emitLease("lease.acquire_failed", projectId, linearIssueId);
|
|
65
81
|
return undefined;
|
|
66
82
|
}
|
|
67
|
-
this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
|
|
68
83
|
this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
|
|
69
84
|
return leaseId;
|
|
70
85
|
}
|
|
71
86
|
claimForReconciliation(projectId, linearIssueId) {
|
|
72
|
-
const key = this.issueSessionLeaseKey(projectId, linearIssueId);
|
|
73
|
-
if (this.activeSessionLeases.has(key)) {
|
|
74
|
-
return "owned";
|
|
75
|
-
}
|
|
76
87
|
const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
|
|
77
88
|
if (!session)
|
|
78
89
|
return "skip";
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
if (isLeaseActive(session)) {
|
|
91
|
+
if (session.workerId === this.workerId) {
|
|
92
|
+
return "owned";
|
|
93
|
+
}
|
|
81
94
|
this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
|
|
82
95
|
return "skip";
|
|
83
96
|
}
|
|
@@ -86,11 +99,16 @@ export class IssueSessionLeaseService {
|
|
|
86
99
|
}
|
|
87
100
|
return this.acquire(projectId, linearIssueId) ? true : "skip";
|
|
88
101
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Post-crash recovery shortcut: take over a foreign (other-worker) lease on
|
|
104
|
+
* an issue whose active run needs recovery, without waiting for full TTL
|
|
105
|
+
* expiry. Safe purely on heartbeat staleness — post-phase-B invariants make
|
|
106
|
+
* the recovery path idempotent (settleRun is idempotent, slot-clearing has
|
|
107
|
+
* exactly one owner, recovery is detect → RunFailurePolicy → settleRun) —
|
|
108
|
+
* so no Codex thread probing is needed. A holder that has not heartbeat
|
|
109
|
+
* for 2x the heartbeat budget is presumed dead.
|
|
110
|
+
*/
|
|
111
|
+
reclaimForeignRecoveryLeaseIfSafe(run, issue) {
|
|
94
112
|
const session = this.db.issueSessions.getIssueSession(run.projectId, run.linearIssueId);
|
|
95
113
|
if (!session?.leaseId || !session.workerId || session.workerId === this.workerId) {
|
|
96
114
|
return false;
|
|
@@ -98,20 +116,12 @@ export class IssueSessionLeaseService {
|
|
|
98
116
|
if (issue.activeRunId !== run.id) {
|
|
99
117
|
return false;
|
|
100
118
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|| latestTurn?.status === "interrupted"
|
|
108
|
-
|| latestTurn?.status === "completed";
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
safeToReclaim = true;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (!safeToReclaim) {
|
|
119
|
+
const leasedUntilMs = session.leasedUntil ? Date.parse(session.leasedUntil) : Number.NaN;
|
|
120
|
+
// A lease row without a parseable leasedUntil cannot prove a live holder.
|
|
121
|
+
const heartbeatAgeMs = Number.isFinite(leasedUntilMs)
|
|
122
|
+
? Date.now() - (leasedUntilMs - ISSUE_SESSION_LEASE_MS)
|
|
123
|
+
: Number.POSITIVE_INFINITY;
|
|
124
|
+
if (heartbeatAgeMs < FOREIGN_LEASE_RECLAIM_STALENESS_MS) {
|
|
115
125
|
return false;
|
|
116
126
|
}
|
|
117
127
|
const leaseId = this.forceAcquire(run.projectId, run.linearIssueId);
|
|
@@ -123,6 +133,7 @@ export class IssueSessionLeaseService {
|
|
|
123
133
|
runId: run.id,
|
|
124
134
|
previousWorkerId: session.workerId,
|
|
125
135
|
previousLeaseId: session.leaseId,
|
|
136
|
+
heartbeatAgeMs: Math.round(heartbeatAgeMs),
|
|
126
137
|
reclaimedLeaseId: leaseId,
|
|
127
138
|
}, "Reclaimed foreign issue-session lease for active-run recovery");
|
|
128
139
|
emitTelemetry(this.telemetry, {
|
|
@@ -138,43 +149,29 @@ export class IssueSessionLeaseService {
|
|
|
138
149
|
return true;
|
|
139
150
|
}
|
|
140
151
|
heartbeat(projectId, linearIssueId) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
|
|
153
|
+
// Only this worker's lease may be renewed: extending a foreign holder's
|
|
154
|
+
// lease would let a launch proceed against a lease we do not hold.
|
|
155
|
+
if (!session?.leaseId || session.workerId !== this.workerId) {
|
|
144
156
|
return false;
|
|
145
|
-
|
|
157
|
+
}
|
|
158
|
+
return this.db.issueSessions.renewIssueSessionLease({
|
|
146
159
|
projectId,
|
|
147
160
|
linearIssueId,
|
|
148
|
-
leaseId,
|
|
161
|
+
leaseId: session.leaseId,
|
|
149
162
|
leasedUntil: new Date(Date.now() + ISSUE_SESSION_LEASE_MS).toISOString(),
|
|
150
163
|
});
|
|
151
|
-
if (renewed) {
|
|
152
|
-
this.activeSessionLeases.set(key, leaseId);
|
|
153
|
-
return true;
|
|
154
|
-
}
|
|
155
|
-
this.activeSessionLeases.delete(key);
|
|
156
|
-
return false;
|
|
157
164
|
}
|
|
158
165
|
release(projectId, linearIssueId) {
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
getValidatedLocalLeaseId(projectId, linearIssueId) {
|
|
166
|
-
const key = this.issueSessionLeaseKey(projectId, linearIssueId);
|
|
167
|
-
const leaseId = this.activeSessionLeases.get(key);
|
|
168
|
-
if (!leaseId)
|
|
169
|
-
return undefined;
|
|
170
|
-
if (this.db.issueSessions.hasActiveIssueSessionLease(projectId, linearIssueId, leaseId)) {
|
|
171
|
-
return leaseId;
|
|
166
|
+
const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
|
|
167
|
+
const ownLeaseId = session?.workerId === this.workerId ? session.leaseId : undefined;
|
|
168
|
+
if (!ownLeaseId && session?.leaseId && isLeaseActive(session)) {
|
|
169
|
+
// An active foreign lease is not ours to clear; expired leftovers are
|
|
170
|
+
// swept by releaseExpiredIssueSessionLeases.
|
|
171
|
+
return;
|
|
172
172
|
}
|
|
173
|
-
this.
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
issueSessionLeaseKey(projectId, linearIssueId) {
|
|
177
|
-
return `${projectId}:${linearIssueId}`;
|
|
173
|
+
this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, ownLeaseId);
|
|
174
|
+
this.emitLease("lease.released", projectId, linearIssueId, ownLeaseId);
|
|
178
175
|
}
|
|
179
176
|
emitLease(type, projectId, linearIssueId, leaseId) {
|
|
180
177
|
const issue = this.db.issues.getIssue(projectId, linearIssueId);
|
|
@@ -206,3 +203,9 @@ export class IssueSessionLeaseService {
|
|
|
206
203
|
});
|
|
207
204
|
}
|
|
208
205
|
}
|
|
206
|
+
function isLeaseActive(session, now = Date.now()) {
|
|
207
|
+
if (!session.leaseId || !session.leasedUntil)
|
|
208
|
+
return false;
|
|
209
|
+
const leasedUntilMs = Date.parse(session.leasedUntil);
|
|
210
|
+
return Number.isFinite(leasedUntilMs) && leasedUntilMs > now;
|
|
211
|
+
}
|
package/dist/issue-session.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
|
|
2
1
|
export function deriveIssueSessionState(params) {
|
|
3
2
|
if (params.factoryState === "done")
|
|
4
3
|
return "done";
|
|
@@ -10,9 +9,6 @@ export function deriveIssueSessionState(params) {
|
|
|
10
9
|
return "running";
|
|
11
10
|
return "idle";
|
|
12
11
|
}
|
|
13
|
-
export function deriveIssueSessionWaitingReason(params) {
|
|
14
|
-
return derivePatchRelayWaitingReason(params);
|
|
15
|
-
}
|
|
16
12
|
export function deriveIssueSessionWakeReason(params) {
|
|
17
13
|
if (params.delegatedToPatchRelay === false)
|
|
18
14
|
return undefined;
|
|
@@ -13,19 +13,13 @@ function hasRecoveredContext(context) {
|
|
|
13
13
|
function hasLocalHumanContext(context) {
|
|
14
14
|
if (hasRecoveredContext(context))
|
|
15
15
|
return true;
|
|
16
|
-
for (const
|
|
17
|
-
const value = context?.[key];
|
|
16
|
+
for (const value of [context?.promptContext, context?.promptBody, context?.operatorPrompt, context?.userComment]) {
|
|
18
17
|
if (typeof value === "string" && value.trim().length > 0)
|
|
19
18
|
return true;
|
|
20
19
|
}
|
|
21
20
|
if (!Array.isArray(context?.followUps))
|
|
22
21
|
return false;
|
|
23
|
-
return context.followUps.some((entry) =>
|
|
24
|
-
if (!entry || typeof entry !== "object")
|
|
25
|
-
return false;
|
|
26
|
-
const text = entry.text;
|
|
27
|
-
return typeof text === "string" && text.trim().length > 0;
|
|
28
|
-
});
|
|
22
|
+
return context.followUps.some((entry) => typeof entry.text === "string" && entry.text.trim().length > 0);
|
|
29
23
|
}
|
|
30
24
|
function activitySortKey(activity) {
|
|
31
25
|
const parsed = activity.updatedAt ? Date.parse(activity.updatedAt) : NaN;
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
2
|
-
|
|
2
|
+
import { tryParseRunContextValue } from "./run-context.js";
|
|
3
|
+
// Boundary over the stored failure/incident columns: malformed JSON or a
|
|
4
|
+
// schema-rejected legacy shape degrades to "no context" (pre-existing
|
|
5
|
+
// behavior of the old parseObjectJson for malformed JSON; this pure module
|
|
6
|
+
// has no logger to warn through).
|
|
7
|
+
function parseRunContextColumn(value) {
|
|
3
8
|
if (!value)
|
|
4
9
|
return undefined;
|
|
10
|
+
let parsed;
|
|
5
11
|
try {
|
|
6
|
-
|
|
7
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
12
|
+
parsed = JSON.parse(value);
|
|
8
13
|
}
|
|
9
14
|
catch {
|
|
10
15
|
return undefined;
|
|
11
16
|
}
|
|
17
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? tryParseRunContextValue(parsed) : undefined;
|
|
12
18
|
}
|
|
13
19
|
export function buildOperatorRetryEvent(issue, runType, source = "operator_retry") {
|
|
14
20
|
if (runType === "queue_repair") {
|
|
15
|
-
const queueIncident =
|
|
16
|
-
const failureContext =
|
|
21
|
+
const queueIncident = parseRunContextColumn(issue.lastQueueIncidentJson);
|
|
22
|
+
const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson);
|
|
17
23
|
return {
|
|
18
24
|
eventType: "merge_steward_incident",
|
|
19
25
|
eventJson: JSON.stringify({
|
|
@@ -32,7 +38,7 @@ export function buildOperatorRetryEvent(issue, runType, source = "operator_retry
|
|
|
32
38
|
};
|
|
33
39
|
}
|
|
34
40
|
if (runType === "ci_repair") {
|
|
35
|
-
const failureContext =
|
|
41
|
+
const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson);
|
|
36
42
|
return {
|
|
37
43
|
eventType: "settled_red_ci",
|
|
38
44
|
eventJson: JSON.stringify({
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { resolveAwaitingInputReason } from "./awaiting-input-reason.js";
|
|
2
|
+
import { deriveIssueExecutionState } from "./issue-execution-state.js";
|
|
2
3
|
export function isUndelegatedPausedIssue(issue) {
|
|
3
|
-
return
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
return deriveIssueExecutionState({
|
|
5
|
+
delegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
6
|
+
factoryState: issue.factoryState,
|
|
7
|
+
}).kind === "undelegated";
|
|
7
8
|
}
|
|
8
9
|
export function isUndelegatedPausedNoPrWork(issue) {
|
|
9
10
|
return isUndelegatedPausedIssue(issue)
|
|
@@ -121,14 +121,8 @@ function summarizeRelationEntries(entries, options) {
|
|
|
121
121
|
return lines;
|
|
122
122
|
}
|
|
123
123
|
function buildIssueTopology(context) {
|
|
124
|
-
const unresolvedBlockers =
|
|
125
|
-
|
|
126
|
-
: [];
|
|
127
|
-
const childIssues = Array.isArray(context?.childIssues)
|
|
128
|
-
? context.childIssues.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
129
|
-
: Array.isArray(context?.trackedDependents)
|
|
130
|
-
? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
131
|
-
: [];
|
|
124
|
+
const unresolvedBlockers = context?.unresolvedBlockers ?? [];
|
|
125
|
+
const childIssues = context?.childIssues ?? context?.trackedDependents ?? [];
|
|
132
126
|
if (unresolvedBlockers.length === 0 && childIssues.length === 0) {
|
|
133
127
|
return [];
|
|
134
128
|
}
|
|
@@ -165,14 +159,8 @@ function buildConstraints(issue, context) {
|
|
|
165
159
|
].join("\n");
|
|
166
160
|
}
|
|
167
161
|
function buildOrchestrationConstraints(context) {
|
|
168
|
-
const unresolvedBlockers =
|
|
169
|
-
|
|
170
|
-
: [];
|
|
171
|
-
const childIssues = Array.isArray(context?.childIssues)
|
|
172
|
-
? context.childIssues.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
173
|
-
: Array.isArray(context?.trackedDependents)
|
|
174
|
-
? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
175
|
-
: [];
|
|
162
|
+
const unresolvedBlockers = context?.unresolvedBlockers ?? [];
|
|
163
|
+
const childIssues = context?.childIssues ?? context?.trackedDependents ?? [];
|
|
176
164
|
return [
|
|
177
165
|
"## Constraints",
|
|
178
166
|
"",
|
|
@@ -201,13 +189,11 @@ function buildOrchestrationConstraints(context) {
|
|
|
201
189
|
].join("\n");
|
|
202
190
|
}
|
|
203
191
|
function buildHumanContextLines(context) {
|
|
204
|
-
const promptContext =
|
|
205
|
-
const latestPrompt =
|
|
206
|
-
const operatorPrompt =
|
|
207
|
-
const userComment =
|
|
208
|
-
const linearAgentActivityContext =
|
|
209
|
-
? context.linearAgentActivityContext.trim()
|
|
210
|
-
: "";
|
|
192
|
+
const promptContext = context?.promptContext?.trim() ?? "";
|
|
193
|
+
const latestPrompt = context?.promptBody?.trim() ?? "";
|
|
194
|
+
const operatorPrompt = context?.operatorPrompt?.trim() ?? "";
|
|
195
|
+
const userComment = context?.userComment?.trim() ?? "";
|
|
196
|
+
const linearAgentActivityContext = context?.linearAgentActivityContext?.trim() ?? "";
|
|
211
197
|
const lines = [];
|
|
212
198
|
if (promptContext) {
|
|
213
199
|
lines.push("Linear session context:", promptContext, "");
|
|
@@ -235,35 +221,28 @@ function resolveRequestedChangesMode(runType, context) {
|
|
|
235
221
|
: "address_review_feedback";
|
|
236
222
|
}
|
|
237
223
|
function readReviewFixComments(context) {
|
|
238
|
-
const raw = context?.reviewComments;
|
|
239
|
-
if (!Array.isArray(raw)) {
|
|
240
|
-
return [];
|
|
241
|
-
}
|
|
242
224
|
const comments = [];
|
|
243
|
-
for (const
|
|
244
|
-
|
|
245
|
-
continue;
|
|
246
|
-
const record = entry;
|
|
247
|
-
const body = typeof record.body === "string" ? record.body.trim() : "";
|
|
225
|
+
for (const record of context?.reviewComments ?? []) {
|
|
226
|
+
const body = record.body?.trim() ?? "";
|
|
248
227
|
if (!body)
|
|
249
228
|
continue;
|
|
250
229
|
comments.push({
|
|
251
230
|
body,
|
|
252
|
-
...(
|
|
253
|
-
...(
|
|
254
|
-
...(
|
|
255
|
-
...(
|
|
256
|
-
...(
|
|
257
|
-
...(
|
|
258
|
-
...(
|
|
231
|
+
...(record.path !== undefined ? { path: record.path } : {}),
|
|
232
|
+
...(record.line !== undefined ? { line: record.line } : {}),
|
|
233
|
+
...(record.side !== undefined ? { side: record.side } : {}),
|
|
234
|
+
...(record.startLine !== undefined ? { startLine: record.startLine } : {}),
|
|
235
|
+
...(record.startSide !== undefined ? { startSide: record.startSide } : {}),
|
|
236
|
+
...(record.url !== undefined ? { url: record.url } : {}),
|
|
237
|
+
...(record.authorLogin !== undefined ? { authorLogin: record.authorLogin } : {}),
|
|
259
238
|
});
|
|
260
239
|
}
|
|
261
240
|
return comments;
|
|
262
241
|
}
|
|
263
242
|
function buildStructuredReviewContext(context) {
|
|
264
|
-
const reviewId =
|
|
265
|
-
const reviewCommitId =
|
|
266
|
-
const reviewUrl =
|
|
243
|
+
const reviewId = context?.reviewId;
|
|
244
|
+
const reviewCommitId = context?.reviewCommitId;
|
|
245
|
+
const reviewUrl = context?.reviewUrl;
|
|
267
246
|
const reviewComments = readReviewFixComments(context);
|
|
268
247
|
const degraded = context?.reviewContextDegraded === true;
|
|
269
248
|
if (!degraded && !reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
|
|
@@ -271,9 +250,8 @@ function buildStructuredReviewContext(context) {
|
|
|
271
250
|
}
|
|
272
251
|
const lines = ["## Structured Review Context", ""];
|
|
273
252
|
if (degraded) {
|
|
274
|
-
const reason =
|
|
275
|
-
|
|
276
|
-
: "GitHub requested-changes context could not be refreshed before launch.";
|
|
253
|
+
const reason = context?.reviewContextDegradedReason?.trim()
|
|
254
|
+
|| "GitHub requested-changes context could not be refreshed before launch.";
|
|
277
255
|
lines.push("GitHub review context refresh: degraded", reason, "Do not assume cached review details are current. Re-read the PR review in GitHub before making review-fix changes.");
|
|
278
256
|
}
|
|
279
257
|
if (reviewId !== undefined)
|
|
@@ -311,17 +289,15 @@ function buildRequestedChangesContext(runType, context) {
|
|
|
311
289
|
lines.push("Branch upkeep is required on the existing PR branch.", "Goal: restore merge readiness on the current branch. Push a newer head only when the work actually changes the diff against the base; do not republish a patch-id-equivalent head.");
|
|
312
290
|
}
|
|
313
291
|
else {
|
|
314
|
-
const reviewer =
|
|
315
|
-
const reviewBody =
|
|
292
|
+
const reviewer = context?.reviewerName;
|
|
293
|
+
const reviewBody = context?.reviewBody?.trim() ?? "";
|
|
316
294
|
lines.push("Requested changes on the existing PR branch.", "Goal: restore review readiness on the current PR branch. Push a newer head only when the fix actually changes the diff; if the reviewer-pass produces only comments, test wording, or PR-body changes, edit the PR body via `gh pr edit` instead.", "Address the real concern behind the feedback and verify nearby invariants in the touched flow before you publish.", "For each review comment, identify the resource, epoch, or token it touches (e.g. session, capture, route, persistence handle, in-flight turn id), enumerate the other transitions that share that same resource, and verify each one before pushing — not just the exact path called out. If you find an adjacent transition that violates the same invariant, fix it in this iteration rather than waiting for the reviewer to surface it next round.", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "");
|
|
317
295
|
appendStructuredReviewContext(lines, context);
|
|
318
296
|
}
|
|
319
297
|
return lines.join("\n").trim();
|
|
320
298
|
}
|
|
321
299
|
function buildCiRepairContext(context) {
|
|
322
|
-
const snapshot = context?.ciSnapshot
|
|
323
|
-
? context.ciSnapshot
|
|
324
|
-
: undefined;
|
|
300
|
+
const snapshot = context?.ciSnapshot;
|
|
325
301
|
return [
|
|
326
302
|
"Settled CI failure on the existing PR branch.",
|
|
327
303
|
"Goal: restore CI readiness and push a branch that is likely to pass the next full CI run.",
|
|
@@ -346,31 +322,26 @@ function buildCiRepairContext(context) {
|
|
|
346
322
|
].filter(Boolean).join("\n");
|
|
347
323
|
}
|
|
348
324
|
function appendQueueRepairContext(lines, context) {
|
|
349
|
-
const
|
|
350
|
-
if (!
|
|
325
|
+
const record = context?.mergeQueueContext;
|
|
326
|
+
if (!record) {
|
|
351
327
|
return;
|
|
352
328
|
}
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
? record.conflictingFiles.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
356
|
-
: [];
|
|
357
|
-
const operatorHints = Array.isArray(record.operatorHints)
|
|
358
|
-
? record.operatorHints.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
359
|
-
: [];
|
|
329
|
+
const conflictingFiles = (record.conflictingFiles ?? []).filter((entry) => entry.trim().length > 0);
|
|
330
|
+
const operatorHints = (record.operatorHints ?? []).filter((entry) => entry.trim().length > 0);
|
|
360
331
|
lines.push("## Merge Queue Context", "");
|
|
361
|
-
if (
|
|
332
|
+
if (record.baseBranch !== undefined) {
|
|
362
333
|
lines.push(`Base branch: ${record.baseBranch}`);
|
|
363
334
|
}
|
|
364
|
-
if (
|
|
335
|
+
if (record.baseSha !== undefined) {
|
|
365
336
|
lines.push(`Base SHA at eviction: ${record.baseSha}`);
|
|
366
337
|
}
|
|
367
|
-
if (
|
|
338
|
+
if (record.mergeCommitSha !== undefined) {
|
|
368
339
|
lines.push(`Synthetic merge commit SHA: ${record.mergeCommitSha}`);
|
|
369
340
|
}
|
|
370
|
-
if (
|
|
341
|
+
if (record.checkRunUrl !== undefined) {
|
|
371
342
|
lines.push(`Steward check run: ${record.checkRunUrl}`);
|
|
372
343
|
}
|
|
373
|
-
if (
|
|
344
|
+
if (record.incidentSummary !== undefined) {
|
|
374
345
|
lines.push(`Steward summary: ${record.incidentSummary}`);
|
|
375
346
|
}
|
|
376
347
|
if (conflictingFiles.length > 0) {
|
|
@@ -391,11 +362,9 @@ function buildQueueRepairContext(context) {
|
|
|
391
362
|
}
|
|
392
363
|
function buildFollowUpContextLines(issue, runType, context) {
|
|
393
364
|
const prContext = derivePrDisplayContext(issue);
|
|
394
|
-
const wakeReason =
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
398
|
-
.map((entry) => `${String(entry.type ?? "follow_up")} from ${String(entry.author ?? "unknown")}: ${String(entry.text ?? "").trim()}`.trim())
|
|
365
|
+
const wakeReason = context?.wakeReason;
|
|
366
|
+
const followUpLines = (context?.followUps ?? [])
|
|
367
|
+
.map((entry) => `${entry.type ?? "follow_up"} from ${entry.author ?? "unknown"}: ${(entry.text ?? "").trim()}`.trim())
|
|
399
368
|
.filter((line) => !line.endsWith(":"));
|
|
400
369
|
const lines = [];
|
|
401
370
|
const turnReason = wakeReason === "direct_reply"
|
|
@@ -418,17 +387,15 @@ function buildFollowUpContextLines(issue, runType, context) {
|
|
|
418
387
|
? "A human follow-up comment arrived after the previous turn."
|
|
419
388
|
: `Continue the existing ${runType} run from the latest issue state.`;
|
|
420
389
|
lines.push(`Turn reason: ${turnReason}`);
|
|
421
|
-
if (wakeReason === "completion_check_continue" &&
|
|
390
|
+
if (wakeReason === "completion_check_continue" && context?.completionCheckSummary?.trim()) {
|
|
422
391
|
lines.push(`Completion check summary: ${context.completionCheckSummary.trim()}`);
|
|
423
392
|
}
|
|
424
393
|
if (context?.preserveDirtyWorktree === true) {
|
|
425
394
|
lines.push("", "Unpublished local work:", "PatchRelay detected that the previous repair turn ended with uncommitted changes in this worktree.", "Do not reset, clean, stash-drop, or otherwise discard the current worktree. Inspect the existing local diff, keep the intended in-scope repair, then commit and push a fresh PR head.");
|
|
426
|
-
if (
|
|
395
|
+
if (context.dirtyWorktreeSummary?.trim()) {
|
|
427
396
|
lines.push(`Dirty worktree summary: ${context.dirtyWorktreeSummary.trim()}`);
|
|
428
397
|
}
|
|
429
|
-
const changedPaths =
|
|
430
|
-
? context.dirtyWorktreeChangedPaths.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
431
|
-
: [];
|
|
398
|
+
const changedPaths = (context.dirtyWorktreeChangedPaths ?? []).filter((entry) => entry.trim().length > 0);
|
|
432
399
|
if (changedPaths.length > 0) {
|
|
433
400
|
lines.push("Changed paths:");
|
|
434
401
|
changedPaths.slice(0, 12).forEach((entry) => lines.push(`- ${entry}`));
|
|
@@ -443,16 +410,16 @@ function buildFollowUpContextLines(issue, runType, context) {
|
|
|
443
410
|
}
|
|
444
411
|
if (context?.replacementPrRequired === true) {
|
|
445
412
|
lines.push("", "Previous PR facts:");
|
|
446
|
-
if (
|
|
413
|
+
if (context.previousPrNumber !== undefined) {
|
|
447
414
|
lines.push(`Previous PR: #${context.previousPrNumber} (replacement PR needed)`);
|
|
448
415
|
}
|
|
449
|
-
if (
|
|
416
|
+
if (context.previousPrUrl !== undefined) {
|
|
450
417
|
lines.push(`Previous PR URL: ${context.previousPrUrl}`);
|
|
451
418
|
}
|
|
452
|
-
if (
|
|
419
|
+
if (context.previousPrState !== undefined) {
|
|
453
420
|
lines.push(`Previous PR state: ${context.previousPrState}`);
|
|
454
421
|
}
|
|
455
|
-
if (
|
|
422
|
+
if (context.previousPrHeadSha !== undefined) {
|
|
456
423
|
lines.push(`Previous PR head SHA: ${context.previousPrHeadSha}`);
|
|
457
424
|
}
|
|
458
425
|
lines.push("Create a fresh replacement PR for the new requested changes; do not mutate or republish the completed PR.");
|
|
@@ -476,7 +443,7 @@ function buildFollowUpContextLines(issue, runType, context) {
|
|
|
476
443
|
: "";
|
|
477
444
|
lines.push("", prHeading, `Fact freshness: ${context?.githubFactsFresh === true
|
|
478
445
|
? "refreshed immediately before this turn was created."
|
|
479
|
-
: "may now be stale; refresh before making irreversible decisions."}`, prLine, issue.prHeadSha ? `Current relevant head SHA: ${issue.prHeadSha}` : "", issue.prReviewState ? `Current review state: ${issue.prReviewState}` : "",
|
|
446
|
+
: "may now be stale; refresh before making irreversible decisions."}`, prLine, issue.prHeadSha ? `Current relevant head SHA: ${issue.prHeadSha}` : "", issue.prReviewState ? `Current review state: ${issue.prReviewState}` : "", context?.mergeStateStatus !== undefined ? `Merge state against ${context?.baseBranch ?? "main"}: ${context.mergeStateStatus}` : "");
|
|
480
447
|
}
|
|
481
448
|
return lines.filter(Boolean);
|
|
482
449
|
}
|
|
@@ -670,7 +637,7 @@ function shouldBuildFollowUpPrompt(runType, context) {
|
|
|
670
637
|
return true;
|
|
671
638
|
if (runType !== "implementation")
|
|
672
639
|
return true;
|
|
673
|
-
const wakeReason =
|
|
640
|
+
const wakeReason = context?.wakeReason;
|
|
674
641
|
return Boolean(wakeReason && wakeReason !== "delegated");
|
|
675
642
|
}
|
|
676
643
|
export function resolvePromptLayers(config, runType) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
2
2
|
import { buildRepairWakeDedupeKey } from "./reactive-wake-keys.js";
|
|
3
|
+
import { serializeRunContext } from "./run-context.js";
|
|
3
4
|
import { execCommand } from "./utils.js";
|
|
4
5
|
const WRITER = "queue-health-monitor";
|
|
5
6
|
const QUEUE_HEALTH_GRACE_MS = 120_000;
|
|
@@ -10,8 +11,8 @@ const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
|
|
|
10
11
|
const IN_REVIEW_STUCK_THRESHOLD_MS = 30 * 60 * 1000;
|
|
11
12
|
const IN_REVIEW_STUCK_FEED_COOLDOWN_MS = 30 * 60 * 1000;
|
|
12
13
|
function isDuplicateProbe(issue, context) {
|
|
13
|
-
const signature =
|
|
14
|
-
const headSha =
|
|
14
|
+
const signature = context?.failureSignature;
|
|
15
|
+
const headSha = context?.failureHeadSha;
|
|
15
16
|
if (!signature)
|
|
16
17
|
return false;
|
|
17
18
|
if (context?.requiresFreshHead === true)
|
|
@@ -176,7 +177,7 @@ export class QueueHealthMonitor {
|
|
|
176
177
|
const probed = probedCommit.outcome === "applied" ? probedCommit.issue : issue;
|
|
177
178
|
this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
178
179
|
eventType: "merge_steward_incident",
|
|
179
|
-
eventJson:
|
|
180
|
+
eventJson: serializeRunContext(pendingRunContext, "queue health repair context"),
|
|
180
181
|
dedupeKey: buildRepairWakeDedupeKey({
|
|
181
182
|
scope: "queue_health",
|
|
182
183
|
runType: "queue_repair",
|