patchrelay 0.79.0 → 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/awaiting-input-reason.js +6 -7
- package/dist/build-info.json +3 -3
- package/dist/issue-execution-state.js +139 -0
- package/dist/issue-session-lease-service.js +69 -66
- package/dist/issue-session.js +0 -4
- package/dist/paused-issue-state.js +5 -4
- package/dist/run-orchestrator.js +4 -6
- package/dist/tracked-issue-list-query.js +5 -1
- package/dist/waiting-reason.js +68 -68
- package/package.json +1 -1
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import { deriveIssueExecutionState } from "./issue-execution-state.js";
|
|
1
2
|
export function resolveAwaitingInputReason(params) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
return "paused_local_work";
|
|
3
|
+
const state = deriveIssueExecutionState({
|
|
4
|
+
factoryState: params.issue.factoryState,
|
|
5
|
+
latestRunCompletionCheckOutcome: params.latestRun?.completionCheckOutcome,
|
|
6
|
+
});
|
|
7
|
+
return state.kind === "waiting_input" ? state.reason : undefined;
|
|
9
8
|
}
|
package/dist/build-info.json
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { hasOpenPr } from "./pr-state.js";
|
|
2
|
+
/** Run statuses that may legally occupy an issue's active-run slot. */
|
|
3
|
+
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
|
|
4
|
+
export function deriveIssueExecutionState(params) {
|
|
5
|
+
const factoryState = params.factoryState;
|
|
6
|
+
// Undelegation pauses automation for any non-finished issue and outranks
|
|
7
|
+
// every other answer (including an active run, which keeps executing but
|
|
8
|
+
// is reported as paused-with-downstream-continuation where relevant).
|
|
9
|
+
if (params.delegatedToPatchRelay === false
|
|
10
|
+
&& factoryState !== "done"
|
|
11
|
+
&& factoryState !== "failed"
|
|
12
|
+
&& factoryState !== "escalated") {
|
|
13
|
+
const downstreamMayContinue = factoryState === "awaiting_queue"
|
|
14
|
+
|| (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved");
|
|
15
|
+
return { kind: "undelegated", downstreamMayContinue };
|
|
16
|
+
}
|
|
17
|
+
// Active run facts win next — the issue is moving (or claims to be).
|
|
18
|
+
if (params.activeRunType || params.activeRunId !== undefined) {
|
|
19
|
+
const run = {
|
|
20
|
+
...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
|
|
21
|
+
...(params.activeRunType ? { runType: params.activeRunType } : {}),
|
|
22
|
+
phase: resolveRunPhase(params),
|
|
23
|
+
};
|
|
24
|
+
// `done` + active run is a legitimate finalizing window (the post-run
|
|
25
|
+
// finalizer advances factoryState before clearing the slot), and
|
|
26
|
+
// `awaiting_input` + active run is a resumed reply turn. `failed` /
|
|
27
|
+
// `escalated` should never hold a slot: settleRun clears it before the
|
|
28
|
+
// terminal transition lands.
|
|
29
|
+
if (factoryState === "failed" || factoryState === "escalated") {
|
|
30
|
+
return {
|
|
31
|
+
kind: "inconsistent",
|
|
32
|
+
description: `terminal factoryState "${factoryState}" still holds an active run slot`,
|
|
33
|
+
run,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (params.activeRunStatus !== undefined && !ACTIVE_RUN_STATUSES.has(params.activeRunStatus)) {
|
|
37
|
+
return {
|
|
38
|
+
kind: "inconsistent",
|
|
39
|
+
description: `active run slot points at a ${params.activeRunStatus} run`,
|
|
40
|
+
run,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { kind: "running", run };
|
|
44
|
+
}
|
|
45
|
+
if (params.orchestrationSettleUntil) {
|
|
46
|
+
const settleAt = Date.parse(params.orchestrationSettleUntil);
|
|
47
|
+
if (Number.isFinite(settleAt) && settleAt > (params.now ?? Date.now())) {
|
|
48
|
+
return { kind: "settling", settleUntil: params.orchestrationSettleUntil };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
|
|
52
|
+
if (blockedByKeys.length > 0) {
|
|
53
|
+
return { kind: "blocked", blockedByKeys };
|
|
54
|
+
}
|
|
55
|
+
switch (factoryState) {
|
|
56
|
+
case "awaiting_input":
|
|
57
|
+
return {
|
|
58
|
+
kind: "waiting_input",
|
|
59
|
+
reason: params.latestRunCompletionCheckOutcome === "needs_input"
|
|
60
|
+
? "completion_check_question"
|
|
61
|
+
: "paused_local_work",
|
|
62
|
+
};
|
|
63
|
+
case "changes_requested":
|
|
64
|
+
return { kind: "awaiting_followup", followup: "review_fix" };
|
|
65
|
+
case "repairing_ci":
|
|
66
|
+
return { kind: "awaiting_followup", followup: "ci_repair", checkName: params.latestFailureCheckName };
|
|
67
|
+
case "repairing_queue":
|
|
68
|
+
return { kind: "awaiting_followup", followup: "queue_repair" };
|
|
69
|
+
case "awaiting_queue":
|
|
70
|
+
return { kind: "idle_awaiting_external", waitingOn: "merge_queue" };
|
|
71
|
+
case "done":
|
|
72
|
+
return { kind: "terminal", outcome: "done" };
|
|
73
|
+
case "failed":
|
|
74
|
+
return { kind: "terminal", outcome: "failed" };
|
|
75
|
+
case "escalated":
|
|
76
|
+
return { kind: "terminal", outcome: "escalated" };
|
|
77
|
+
default:
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
// delegated / implementing / pr_open / deploying: the wait, if any, is
|
|
81
|
+
// derived from live PR truth.
|
|
82
|
+
if (params.prCheckStatus === "failed" || params.prCheckStatus === "failure") {
|
|
83
|
+
return { kind: "idle_awaiting_external", waitingOn: "ci_failure", checkName: params.latestFailureCheckName };
|
|
84
|
+
}
|
|
85
|
+
if (params.prReviewState === "changes_requested") {
|
|
86
|
+
if (params.prCheckStatus === "passed" || params.prCheckStatus === "success") {
|
|
87
|
+
if (params.prHeadSha
|
|
88
|
+
&& params.lastBlockingReviewHeadSha
|
|
89
|
+
&& params.prHeadSha !== params.lastBlockingReviewHeadSha) {
|
|
90
|
+
return { kind: "idle_awaiting_external", waitingOn: "review_of_new_head" };
|
|
91
|
+
}
|
|
92
|
+
return { kind: "idle_awaiting_external", waitingOn: "blocking_review_same_head" };
|
|
93
|
+
}
|
|
94
|
+
return { kind: "idle_awaiting_external", waitingOn: "review_feedback" };
|
|
95
|
+
}
|
|
96
|
+
if (params.prReviewState === "approved") {
|
|
97
|
+
return { kind: "idle_awaiting_external", waitingOn: "downstream_automation" };
|
|
98
|
+
}
|
|
99
|
+
if (hasOpenPr(params.prNumber, params.prState)) {
|
|
100
|
+
return { kind: "idle_awaiting_external", waitingOn: "external_review" };
|
|
101
|
+
}
|
|
102
|
+
if (params.pendingRunType) {
|
|
103
|
+
return { kind: "ready", pendingRunType: params.pendingRunType };
|
|
104
|
+
}
|
|
105
|
+
return { kind: "idle" };
|
|
106
|
+
}
|
|
107
|
+
function resolveRunPhase(params) {
|
|
108
|
+
if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
|
|
109
|
+
return "finalizing_published_pr";
|
|
110
|
+
}
|
|
111
|
+
if (params.factoryState === "done") {
|
|
112
|
+
return "finalizing_merged_change";
|
|
113
|
+
}
|
|
114
|
+
return "working";
|
|
115
|
+
}
|
|
116
|
+
/** Build the deriver input from full records (issue row + resolved runs). */
|
|
117
|
+
export function issueExecutionStateInputFromRecords(issue, extras) {
|
|
118
|
+
return {
|
|
119
|
+
delegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
120
|
+
factoryState: issue.factoryState,
|
|
121
|
+
activeRunId: issue.activeRunId,
|
|
122
|
+
activeRunType: extras?.activeRun?.runType,
|
|
123
|
+
activeRunStatus: extras?.activeRun?.status,
|
|
124
|
+
latestRunCompletionCheckOutcome: extras?.latestRun?.completionCheckOutcome,
|
|
125
|
+
pendingRunType: issue.pendingRunType,
|
|
126
|
+
blockedByKeys: extras?.blockedByKeys,
|
|
127
|
+
orchestrationSettleUntil: issue.orchestrationSettleUntil,
|
|
128
|
+
prNumber: issue.prNumber,
|
|
129
|
+
prState: issue.prState,
|
|
130
|
+
prHeadSha: issue.prHeadSha,
|
|
131
|
+
prReviewState: issue.prReviewState,
|
|
132
|
+
prCheckStatus: issue.prCheckStatus,
|
|
133
|
+
lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
|
|
134
|
+
latestFailureCheckName: issue.lastGitHubFailureCheckName,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export function deriveIssueExecutionStateFromRecords(issue, extras) {
|
|
138
|
+
return deriveIssueExecutionState(issueExecutionStateInputFromRecords(issue, extras));
|
|
139
|
+
}
|
|
@@ -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;
|
|
@@ -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)
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -82,7 +82,6 @@ export class RunOrchestrator {
|
|
|
82
82
|
failRunAndClear: (run, message, nextState) => this.failRunAndClear(run, message, nextState),
|
|
83
83
|
restoreIdleWorktree: (issue) => this.restoreIdleWorktree(issue),
|
|
84
84
|
};
|
|
85
|
-
activeSessionLeases;
|
|
86
85
|
botIdentity;
|
|
87
86
|
wakeDispatcher;
|
|
88
87
|
logger;
|
|
@@ -124,8 +123,7 @@ export class RunOrchestrator {
|
|
|
124
123
|
this.worktreeManager = new WorktreeManager(config);
|
|
125
124
|
this.codexRuntimeConfig = config.runner.codex;
|
|
126
125
|
this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
|
|
127
|
-
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId,
|
|
128
|
-
this.activeSessionLeases = this.leaseService.activeSessionLeases;
|
|
126
|
+
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, telemetry);
|
|
129
127
|
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
|
|
130
128
|
this.completionCheck = new CompletionCheckService(codex, logger);
|
|
131
129
|
this.issueTriage = new IssueTriageService(codex, logger);
|
|
@@ -648,7 +646,7 @@ export class RunOrchestrator {
|
|
|
648
646
|
if (!issue)
|
|
649
647
|
return;
|
|
650
648
|
let recoveryLease = this.claimLeaseForReconciliation(run.projectId, run.linearIssueId);
|
|
651
|
-
if (recoveryLease === "skip" &&
|
|
649
|
+
if (recoveryLease === "skip" && this.reclaimForeignRecoveryLeaseIfSafe(run, issue)) {
|
|
652
650
|
recoveryLease = true;
|
|
653
651
|
}
|
|
654
652
|
if (recoveryLease === "skip")
|
|
@@ -725,8 +723,8 @@ export class RunOrchestrator {
|
|
|
725
723
|
claimLeaseForReconciliation(projectId, linearIssueId) {
|
|
726
724
|
return this.leaseService.claimForReconciliation(projectId, linearIssueId);
|
|
727
725
|
}
|
|
728
|
-
|
|
729
|
-
return
|
|
726
|
+
reclaimForeignRecoveryLeaseIfSafe(run, issue) {
|
|
727
|
+
return this.leaseService.reclaimForeignRecoveryLeaseIfSafe(run, issue);
|
|
730
728
|
}
|
|
731
729
|
heartbeatIssueSessionLease(projectId, linearIssueId) {
|
|
732
730
|
return this.leaseService.heartbeat(projectId, linearIssueId);
|
|
@@ -137,7 +137,11 @@ export class TrackedIssueListQuery {
|
|
|
137
137
|
...(row.last_blocking_review_head_sha !== null ? { lastBlockingReviewHeadSha: String(row.last_blocking_review_head_sha) } : {}),
|
|
138
138
|
...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
|
|
139
139
|
});
|
|
140
|
-
|
|
140
|
+
// The derivation (issue-execution-state.ts via waiting-reason.ts) is the
|
|
141
|
+
// single source; the stored session projection is only a fallback for
|
|
142
|
+
// rows whose live facts derive no reason. A detached active run means
|
|
143
|
+
// the projection is stale, so it is not consulted at all.
|
|
144
|
+
const waitingReason = derivedWaitingReason ?? (detachedActiveRun ? undefined : sessionWaitingReason);
|
|
141
145
|
const latestRun = row.latest_run_type !== null && row.latest_run_status !== null
|
|
142
146
|
? {
|
|
143
147
|
id: 0,
|
package/dist/waiting-reason.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deriveIssueExecutionState, } from "./issue-execution-state.js";
|
|
2
2
|
export const PATCHRELAY_WAITING_REASONS = {
|
|
3
3
|
activeWork: "PatchRelay is actively working",
|
|
4
4
|
automationPaused: "PatchRelay automation is paused because the issue is undelegated",
|
|
@@ -16,82 +16,82 @@ export const PATCHRELAY_WAITING_REASONS = {
|
|
|
16
16
|
waitingForOperatorIntervention: "Waiting on operator intervention",
|
|
17
17
|
waitingForExternalReview: "Waiting on external review",
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* `waitingReason` is a pure function of {@link IssueExecutionState} — the
|
|
21
|
+
* union (issue-execution-state.ts) is the single derivation of "why is this
|
|
22
|
+
* issue not moving"; this module only renders it for operators.
|
|
23
|
+
*/
|
|
19
24
|
export function derivePatchRelayWaitingReason(params) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (params.activeRunId !== undefined) {
|
|
35
|
-
return PATCHRELAY_WAITING_REASONS.activeWork;
|
|
36
|
-
}
|
|
37
|
-
if (params.orchestrationSettleUntil) {
|
|
38
|
-
const settleAt = Date.parse(params.orchestrationSettleUntil);
|
|
39
|
-
if (Number.isFinite(settleAt) && settleAt > Date.now()) {
|
|
25
|
+
return waitingReasonForExecutionState(deriveIssueExecutionState(params));
|
|
26
|
+
}
|
|
27
|
+
export function waitingReasonForExecutionState(state) {
|
|
28
|
+
switch (state.kind) {
|
|
29
|
+
case "undelegated":
|
|
30
|
+
return state.downstreamMayContinue
|
|
31
|
+
? PATCHRELAY_WAITING_REASONS.automationPausedDownstream
|
|
32
|
+
: PATCHRELAY_WAITING_REASONS.automationPaused;
|
|
33
|
+
case "running":
|
|
34
|
+
// An inconsistent row still describes what is observably happening (a run
|
|
35
|
+
// occupies the slot); reconcilers act on the union kind, not this string.
|
|
36
|
+
case "inconsistent":
|
|
37
|
+
return describeRun(state.run);
|
|
38
|
+
case "settling":
|
|
40
39
|
return PATCHRELAY_WAITING_REASONS.waitingForChildSettle;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (blockedByKeys.length > 0) {
|
|
45
|
-
return `Blocked by ${blockedByKeys.join(", ")}`;
|
|
46
|
-
}
|
|
47
|
-
const checkName = params.latestFailureCheckName ?? "CI";
|
|
48
|
-
switch (params.factoryState) {
|
|
49
|
-
case "awaiting_input":
|
|
40
|
+
case "blocked":
|
|
41
|
+
return `Blocked by ${state.blockedByKeys.join(", ")}`;
|
|
42
|
+
case "waiting_input":
|
|
50
43
|
return PATCHRELAY_WAITING_REASONS.waitingForOperatorInput;
|
|
51
|
-
case "
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return PATCHRELAY_WAITING_REASONS.workComplete;
|
|
61
|
-
case "failed":
|
|
62
|
-
case "escalated":
|
|
63
|
-
return PATCHRELAY_WAITING_REASONS.waitingForOperatorIntervention;
|
|
64
|
-
default:
|
|
44
|
+
case "awaiting_followup":
|
|
45
|
+
switch (state.followup) {
|
|
46
|
+
case "review_fix":
|
|
47
|
+
return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
|
|
48
|
+
case "ci_repair":
|
|
49
|
+
return `Waiting to repair ${state.checkName ?? "CI"}`;
|
|
50
|
+
case "queue_repair":
|
|
51
|
+
return PATCHRELAY_WAITING_REASONS.waitingForMergeStewardRepair;
|
|
52
|
+
}
|
|
65
53
|
break;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
54
|
+
case "terminal":
|
|
55
|
+
return state.outcome === "done"
|
|
56
|
+
? PATCHRELAY_WAITING_REASONS.workComplete
|
|
57
|
+
: PATCHRELAY_WAITING_REASONS.waitingForOperatorIntervention;
|
|
58
|
+
case "idle_awaiting_external":
|
|
59
|
+
switch (state.waitingOn) {
|
|
60
|
+
case "merge_queue":
|
|
61
|
+
case "downstream_automation":
|
|
62
|
+
return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
|
|
63
|
+
case "ci_failure":
|
|
64
|
+
return `${state.checkName ?? "CI"} failed`;
|
|
65
|
+
case "review_of_new_head":
|
|
66
|
+
return PATCHRELAY_WAITING_REASONS.waitingForReviewOnNewHead;
|
|
67
|
+
case "blocking_review_same_head":
|
|
68
|
+
return PATCHRELAY_WAITING_REASONS.sameHeadStillBlocked;
|
|
69
|
+
case "review_feedback":
|
|
70
|
+
return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
|
|
71
|
+
case "external_review":
|
|
72
|
+
return PATCHRELAY_WAITING_REASONS.waitingForExternalReview;
|
|
76
73
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
|
|
74
|
+
break;
|
|
75
|
+
case "ready":
|
|
76
|
+
return `Ready to run ${humanize(state.pendingRunType)}`;
|
|
77
|
+
case "idle":
|
|
78
|
+
return undefined;
|
|
83
79
|
}
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
function describeRun(run) {
|
|
83
|
+
if (!run.runType) {
|
|
84
|
+
return PATCHRELAY_WAITING_REASONS.activeWork;
|
|
86
85
|
}
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
switch (run.phase) {
|
|
87
|
+
case "finalizing_published_pr":
|
|
88
|
+
return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
|
|
89
|
+
case "finalizing_merged_change":
|
|
90
|
+
return PATCHRELAY_WAITING_REASONS.finalizingMergedChange;
|
|
91
|
+
case "working":
|
|
92
|
+
return `PatchRelay is running ${humanize(run.runType)}`;
|
|
89
93
|
}
|
|
90
|
-
return undefined;
|
|
91
94
|
}
|
|
92
95
|
function humanize(value) {
|
|
93
96
|
return value.replaceAll("_", " ");
|
|
94
97
|
}
|
|
95
|
-
function hasLiveOpenPr(prNumber, prState) {
|
|
96
|
-
return prNumber !== undefined && (prState === undefined || prState === "open");
|
|
97
|
-
}
|