patchrelay 0.37.0 → 0.38.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/README.md +47 -9
- package/dist/awaiting-input-reason.js +9 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/cluster-health.js +64 -5
- package/dist/cli/data.js +1 -0
- package/dist/cli/formatters/text.js +5 -1
- package/dist/cli/help.js +1 -1
- package/dist/cli/output.js +2 -0
- package/dist/cli/watch/IssueRow.js +4 -3
- package/dist/cli/watch/StatusBar.js +2 -1
- package/dist/cli/watch/detail-rows.js +4 -3
- package/dist/cli/watch/pr-status.js +2 -1
- package/dist/cli/watch/state-visualization.js +5 -1
- package/dist/db/issue-session-store.js +0 -14
- package/dist/db/issue-store.js +8 -16
- package/dist/db/migrations.js +6 -13
- package/dist/db.js +1 -3
- package/dist/factory-state.js +1 -1
- package/dist/github-webhook-handler.js +95 -54
- package/dist/github-webhooks.js +4 -0
- package/dist/idle-reconciliation.js +38 -22
- package/dist/implementation-outcome-policy.js +3 -1
- package/dist/issue-overview-query.js +8 -0
- package/dist/issue-session-projector.js +1 -0
- package/dist/issue-session.js +8 -0
- package/dist/linear-session-reporting.js +43 -5
- package/dist/linear-session-sync.js +9 -1
- package/dist/linear-status-comment-sync.js +47 -2
- package/dist/linear-workflow-state-sync.js +2 -2
- package/dist/operator-retry-event.js +15 -12
- package/dist/paused-issue-state.js +24 -0
- package/dist/pr-state.js +49 -0
- package/dist/run-launcher.js +0 -1
- package/dist/run-orchestrator.js +2 -5
- package/dist/run-reconciler.js +10 -0
- package/dist/run-recovery-service.js +1 -10
- package/dist/service-issue-actions.js +10 -4
- package/dist/service-startup-recovery.js +9 -6
- package/dist/service.js +0 -1
- package/dist/tracked-issue-list-query.js +6 -2
- package/dist/tracked-issue-projector.js +8 -0
- package/dist/waiting-reason.js +13 -2
- package/dist/webhooks/agent-session-handler.js +9 -1
- package/dist/webhooks/comment-wake-handler.js +12 -0
- package/dist/webhooks/decision-helpers.js +44 -3
- package/dist/webhooks/dependency-readiness-handler.js +1 -0
- package/dist/webhooks/desired-stage-recorder.js +40 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# PatchRelay
|
|
2
2
|
|
|
3
|
-
PatchRelay is a self-hosted harness for delegated Linear work and upkeep of
|
|
3
|
+
PatchRelay is a self-hosted harness for delegated Linear work and upkeep of linked pull requests on your own machine.
|
|
4
4
|
|
|
5
|
-
It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge-steward incidents on
|
|
5
|
+
It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge-steward incidents on linked delegated PRs. Separate downstream services own review automation and merge execution.
|
|
6
6
|
|
|
7
7
|
PatchRelay is the system around the model:
|
|
8
8
|
|
|
@@ -38,8 +38,9 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
|
|
|
38
38
|
- creates and reuses one durable worktree and branch per issue lifecycle
|
|
39
39
|
- starts Codex threads for implementation runs
|
|
40
40
|
- triggers reactive runs for CI failures, review feedback, and Merge Steward evictions
|
|
41
|
-
- opens and updates
|
|
41
|
+
- opens and updates PRs for delegated implementation work
|
|
42
42
|
- marks its own PRs ready when implementation is complete
|
|
43
|
+
- can later repair a linked PR that was opened externally once the issue is delegated
|
|
43
44
|
- persists enough state to correlate the Linear issue, local workspace, run, and Codex thread
|
|
44
45
|
- reports progress back to Linear and forwards follow-up agent input into active runs
|
|
45
46
|
- exposes CLI and optional read-only inspection surfaces so operators can understand what happened
|
|
@@ -87,19 +88,48 @@ You will also need:
|
|
|
87
88
|
5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures and review fix on changes requested.
|
|
88
89
|
6. PatchRelay opens draft PRs while implementation is in progress and marks its own PR ready when implementation is complete.
|
|
89
90
|
7. Downstream automation reacts to GitHub truth: `reviewbot` reviews ready PRs with green CI, and Merge Steward admits ready PRs with green CI and approval into the merge queue.
|
|
90
|
-
8. If requested changes, red CI, or a merge-steward incident lands on a
|
|
91
|
+
8. If requested changes, red CI, or a merge-steward incident lands on a linked delegated PR, PatchRelay resumes work on that same PR branch.
|
|
91
92
|
9. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
|
|
92
93
|
|
|
94
|
+
### Undelegation And Re-delegation
|
|
95
|
+
|
|
96
|
+
Undelegation pauses PatchRelay authority. It does not erase PR truth.
|
|
97
|
+
|
|
98
|
+
- If there is no PR yet, the issue keeps its literal local-work state such as `delegated` or `implementing`, but PatchRelay becomes paused.
|
|
99
|
+
- If a PR already exists, the issue keeps its PR-backed state and PatchRelay becomes observer-only.
|
|
100
|
+
- Worktrees, branches, and PRs remain in place.
|
|
101
|
+
- PatchRelay still reflects GitHub review, CI, queue, merge, and close events while undelegated.
|
|
102
|
+
- PatchRelay does not enqueue implementation, review-fix, CI-repair, or queue-repair work again until the issue is delegated back.
|
|
103
|
+
- If someone opens a new PR for the issue while it is undelegated, PatchRelay can link that PR when the title, body, or branch name contains one unambiguous tracked issue key for the project.
|
|
104
|
+
|
|
105
|
+
Downstream services stay PR-centric:
|
|
106
|
+
|
|
107
|
+
- `review-quill` may still review a qualifying PR
|
|
108
|
+
- `merge-steward` may still queue or merge a qualifying PR
|
|
109
|
+
|
|
110
|
+
When the issue is delegated back to PatchRelay, it should resume from current truth:
|
|
111
|
+
|
|
112
|
+
- no PR: queue implementation
|
|
113
|
+
- PR with requested changes: queue review fix or branch upkeep
|
|
114
|
+
- PR with failing CI: queue CI repair
|
|
115
|
+
- PR with queue eviction/conflict: queue queue repair
|
|
116
|
+
- healthy open PR: keep waiting on review
|
|
117
|
+
- approved PR: keep waiting downstream
|
|
118
|
+
|
|
93
119
|
## Ownership Model
|
|
94
120
|
|
|
95
|
-
PatchRelay
|
|
121
|
+
PatchRelay keeps ownership simple:
|
|
122
|
+
|
|
123
|
+
- workflow truth: the current factory state plus GitHub PR/review/CI facts
|
|
124
|
+
- runtime authority: whether PatchRelay may actively write or repair code right now
|
|
125
|
+
|
|
126
|
+
PatchRelay persists one explicit authority bit:
|
|
96
127
|
|
|
97
|
-
-
|
|
98
|
-
- PR ownership: who is responsible for keeping an existing PR healthy until it merges or closes
|
|
128
|
+
- `delegatedToPatchRelay`: whether PatchRelay may actively implement or repair code for the issue right now
|
|
99
129
|
|
|
100
|
-
|
|
130
|
+
Once a PR is linked to an issue, delegation decides whether PatchRelay may repair it. The PR may have been opened by PatchRelay, a human, or another external system.
|
|
101
131
|
|
|
102
|
-
That
|
|
132
|
+
That authority does not change just because:
|
|
103
133
|
|
|
104
134
|
- the issue is undelegated
|
|
105
135
|
- the PR becomes ready for review
|
|
@@ -138,6 +168,14 @@ The long-term runtime model is a small durable `IssueSession`:
|
|
|
138
168
|
|
|
139
169
|
Waiting on review or queue should be represented as a waiting reason, not as a large internal control-plane state machine.
|
|
140
170
|
|
|
171
|
+
`awaiting_input` is reserved for real human-needed situations:
|
|
172
|
+
|
|
173
|
+
- a completion check asked a question
|
|
174
|
+
- an operator explicitly stopped the run and wants a next decision
|
|
175
|
+
- a reply is required before PatchRelay can continue
|
|
176
|
+
|
|
177
|
+
Undelegated local work should stay in its literal workflow state and show a paused waiting reason instead.
|
|
178
|
+
|
|
141
179
|
## Restart And Reconciliation
|
|
142
180
|
|
|
143
181
|
PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function resolveAwaitingInputReason(params) {
|
|
2
|
+
if (params.issue.factoryState !== "awaiting_input") {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
if (params.latestRun?.completionCheckOutcome === "needs_input") {
|
|
6
|
+
return "completion_check_question";
|
|
7
|
+
}
|
|
8
|
+
return "paused_local_work";
|
|
9
|
+
}
|
package/dist/build-info.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deriveGateCheckStatusFromRollup } from "../github-rollup.js";
|
|
2
2
|
import { ACTIVE_RUN_STATES } from "../factory-state.js";
|
|
3
|
+
import { hasOpenPr, resolveClosedPrDisposition } from "../pr-state.js";
|
|
3
4
|
const RECONCILIATION_GRACE_MS = 120_000;
|
|
4
5
|
const DOWNSTREAM_STALE_MS = 900_000;
|
|
5
6
|
export async function collectClusterHealth(config, db, runCommand) {
|
|
@@ -98,7 +99,7 @@ export async function collectClusterHealth(config, db, runCommand) {
|
|
|
98
99
|
}
|
|
99
100
|
checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
|
|
100
101
|
for (const snapshot of snapshots) {
|
|
101
|
-
if (!snapshot.issue.prNumber) {
|
|
102
|
+
if (!hasOpenPr(snapshot.issue.prNumber, snapshot.issue.prState)) {
|
|
102
103
|
continue;
|
|
103
104
|
}
|
|
104
105
|
const githubHealth = await evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe);
|
|
@@ -277,8 +278,31 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
|
|
|
277
278
|
const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
|
|
278
279
|
const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
|
|
279
280
|
const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
|
|
281
|
+
if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
282
|
+
return {
|
|
283
|
+
finding: {
|
|
284
|
+
status: "fail",
|
|
285
|
+
scope: "github:reconcile",
|
|
286
|
+
message: "PR is already merged but the issue has not advanced to done",
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (pr.state === "CLOSED") {
|
|
291
|
+
const closedPrDisposition = resolveClosedPrDisposition(issue);
|
|
292
|
+
if (closedPrDisposition === "redelegate" && issue.factoryState !== "delegated" && ageMs >= RECONCILIATION_GRACE_MS) {
|
|
293
|
+
return {
|
|
294
|
+
finding: {
|
|
295
|
+
status: "fail",
|
|
296
|
+
scope: "github:reconcile",
|
|
297
|
+
message: "PR is closed but unfinished work has not been re-delegated",
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
280
303
|
const ciEntry = buildCiEntry({
|
|
281
304
|
issue,
|
|
305
|
+
delegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
282
306
|
gateCheckStatus,
|
|
283
307
|
reviewDecision,
|
|
284
308
|
reviewRequested,
|
|
@@ -307,7 +331,11 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
|
|
|
307
331
|
},
|
|
308
332
|
};
|
|
309
333
|
}
|
|
310
|
-
if (
|
|
334
|
+
if (issue.delegatedToPatchRelay
|
|
335
|
+
&& gateCheckStatus === "failure"
|
|
336
|
+
&& issue.factoryState !== "repairing_ci"
|
|
337
|
+
&& issue.activeRunId === undefined
|
|
338
|
+
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
311
339
|
return {
|
|
312
340
|
ciEntry,
|
|
313
341
|
finding: {
|
|
@@ -330,6 +358,7 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
|
|
|
330
358
|
if (gateCheckStatus === "success"
|
|
331
359
|
&& reviewDecision === "CHANGES_REQUESTED"
|
|
332
360
|
&& mergeConflictDetected
|
|
361
|
+
&& issue.delegatedToPatchRelay
|
|
333
362
|
&& issue.factoryState !== "changes_requested"
|
|
334
363
|
&& issue.activeRunId === undefined
|
|
335
364
|
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
@@ -346,6 +375,7 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
|
|
|
346
375
|
&& reviewDecision === "CHANGES_REQUESTED"
|
|
347
376
|
&& latestBlockingReviewHeadSha === pr.headRefOid
|
|
348
377
|
&& !reviewQuillAttempt
|
|
378
|
+
&& issue.delegatedToPatchRelay
|
|
349
379
|
&& issue.factoryState !== "changes_requested"
|
|
350
380
|
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
351
381
|
return {
|
|
@@ -367,7 +397,11 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
|
|
|
367
397
|
},
|
|
368
398
|
};
|
|
369
399
|
}
|
|
370
|
-
if (issue.
|
|
400
|
+
if (issue.delegatedToPatchRelay
|
|
401
|
+
&& issue.factoryState === "awaiting_queue"
|
|
402
|
+
&& mergeConflictDetected
|
|
403
|
+
&& issue.activeRunId === undefined
|
|
404
|
+
&& ageMs >= RECONCILIATION_GRACE_MS) {
|
|
371
405
|
return {
|
|
372
406
|
ciEntry,
|
|
373
407
|
finding: {
|
|
@@ -390,8 +424,9 @@ async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQui
|
|
|
390
424
|
return { ciEntry };
|
|
391
425
|
}
|
|
392
426
|
function buildCiEntry(params) {
|
|
393
|
-
const { issue, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
|
|
427
|
+
const { issue, delegatedToPatchRelay, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
|
|
394
428
|
const owner = deriveCiOwner({
|
|
429
|
+
delegatedToPatchRelay,
|
|
395
430
|
gateCheckStatus,
|
|
396
431
|
factoryState: issue.factoryState,
|
|
397
432
|
reviewDecision,
|
|
@@ -411,6 +446,7 @@ function buildCiEntry(params) {
|
|
|
411
446
|
factoryState: issue.factoryState,
|
|
412
447
|
...(reviewDecision ? { reviewDecision } : {}),
|
|
413
448
|
message: describeCiOwnership({
|
|
449
|
+
delegatedToPatchRelay,
|
|
414
450
|
gateCheckStatus,
|
|
415
451
|
owner,
|
|
416
452
|
reviewDecision,
|
|
@@ -427,20 +463,29 @@ function deriveCiOwner(params) {
|
|
|
427
463
|
&& params.latestBlockingReviewHeadSha
|
|
428
464
|
&& params.currentHeadSha !== params.latestBlockingReviewHeadSha);
|
|
429
465
|
if (params.gateCheckStatus === "failure") {
|
|
466
|
+
if (!params.delegatedToPatchRelay)
|
|
467
|
+
return "paused";
|
|
430
468
|
return params.factoryState === "repairing_ci" ? "patchrelay" : "unknown";
|
|
431
469
|
}
|
|
432
470
|
if (params.gateCheckStatus === "pending") {
|
|
433
471
|
return "external";
|
|
434
472
|
}
|
|
435
473
|
if (params.factoryState === "awaiting_queue" || params.reviewDecision === "APPROVED") {
|
|
474
|
+
if (params.mergeConflictDetected && !params.delegatedToPatchRelay) {
|
|
475
|
+
return "paused";
|
|
476
|
+
}
|
|
436
477
|
return params.mergeConflictDetected && params.factoryState !== "repairing_queue"
|
|
437
478
|
? "unknown"
|
|
438
479
|
: "downstream";
|
|
439
480
|
}
|
|
440
481
|
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
441
482
|
if (params.mergeConflictDetected) {
|
|
483
|
+
if (!params.delegatedToPatchRelay)
|
|
484
|
+
return "paused";
|
|
442
485
|
return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
|
|
443
486
|
}
|
|
487
|
+
if (!params.delegatedToPatchRelay)
|
|
488
|
+
return "paused";
|
|
444
489
|
if (params.factoryState === "changes_requested")
|
|
445
490
|
return "patchrelay";
|
|
446
491
|
if (params.reviewQuillAttempt)
|
|
@@ -499,6 +544,20 @@ function describeCiOwnership(params) {
|
|
|
499
544
|
? "Waiting on external CI checks to settle"
|
|
500
545
|
: "Waiting on external GitHub automation";
|
|
501
546
|
}
|
|
547
|
+
if (params.owner === "paused") {
|
|
548
|
+
if (params.gateCheckStatus === "failure") {
|
|
549
|
+
return "PatchRelay is paused; delegate the issue again to repair failing CI";
|
|
550
|
+
}
|
|
551
|
+
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
552
|
+
return params.mergeConflictDetected
|
|
553
|
+
? "PatchRelay is paused; delegate the issue again to repair the blocked PR branch"
|
|
554
|
+
: "PatchRelay is paused; delegate the issue again to address requested changes";
|
|
555
|
+
}
|
|
556
|
+
if (params.mergeConflictDetected) {
|
|
557
|
+
return "PatchRelay is paused; delegate the issue again to repair this merge conflict";
|
|
558
|
+
}
|
|
559
|
+
return "PatchRelay is paused; no automatic repair will start until the issue is delegated again";
|
|
560
|
+
}
|
|
502
561
|
if (params.reviewDecision === "CHANGES_REQUESTED") {
|
|
503
562
|
if (params.mergeConflictDetected) {
|
|
504
563
|
return headAdvancedPastBlockingReview
|
|
@@ -521,7 +580,7 @@ function needsReviewAutomation(issue) {
|
|
|
521
580
|
if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
|
|
522
581
|
return false;
|
|
523
582
|
}
|
|
524
|
-
return issue.prNumber
|
|
583
|
+
return hasOpenPr(issue.prNumber, issue.prState);
|
|
525
584
|
}
|
|
526
585
|
async function collectReviewQuillAttemptOwners(snapshots, config, runCommand) {
|
|
527
586
|
const owners = new Map();
|
package/dist/cli/data.js
CHANGED
|
@@ -119,6 +119,7 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
119
119
|
...(latestReport ? { latestReport } : {}),
|
|
120
120
|
...(latestSummary ? { latestSummary } : {}),
|
|
121
121
|
...(dbIssue.prNumber ? { prNumber: dbIssue.prNumber } : {}),
|
|
122
|
+
...(dbIssue.prState ? { prState: dbIssue.prState } : {}),
|
|
122
123
|
...(dbIssue.prReviewState ? { prReviewState: dbIssue.prReviewState } : {}),
|
|
123
124
|
...((dbIssue.sessionState) ? { sessionState: dbIssue.sessionState } : {}),
|
|
124
125
|
...((dbIssue.waitingReason) ? { waitingReason: dbIssue.waitingReason } : {}),
|
|
@@ -20,7 +20,11 @@ export function formatInspect(result) {
|
|
|
20
20
|
value("Debug stage", result.issue?.factoryState),
|
|
21
21
|
result.activeRun ? value("Active run", `${result.activeRun.runType} (${result.activeRun.status})`) : undefined,
|
|
22
22
|
result.latestRun && !result.activeRun ? value("Latest run", `${result.latestRun.runType} (${result.latestRun.status})`) : undefined,
|
|
23
|
-
result.prNumber
|
|
23
|
+
result.prNumber
|
|
24
|
+
? value("PR", `#${result.prNumber}${result.prState || result.prReviewState
|
|
25
|
+
? ` [${[result.prState, result.prReviewState].filter(Boolean).join(", ")}]`
|
|
26
|
+
: ""}`)
|
|
27
|
+
: undefined,
|
|
24
28
|
result.completionCheckOutcome ? value("Completion check", result.completionCheckOutcome) : undefined,
|
|
25
29
|
result.completionCheckSummary ? value("Completion summary", truncateLine(result.completionCheckSummary)) : undefined,
|
|
26
30
|
result.completionCheckQuestion ? value("Question", truncateLine(result.completionCheckQuestion)) : undefined,
|
package/dist/cli/help.js
CHANGED
|
@@ -144,7 +144,7 @@ export function issueHelpText() {
|
|
|
144
144
|
"Commands:",
|
|
145
145
|
" show <issueKey> Show the latest known issue state",
|
|
146
146
|
" list List tracked issues",
|
|
147
|
-
" watch <issueKey> Follow
|
|
147
|
+
" watch <issueKey> Follow issue activity until it settles",
|
|
148
148
|
" path <issueKey> Print the issue worktree path",
|
|
149
149
|
" open <issueKey> Open Codex in the issue worktree",
|
|
150
150
|
" sessions <issueKey> Show recorded Codex app-server sessions",
|
package/dist/cli/output.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
3
4
|
import { summarizeIssueStatusNote } from "./issue-status-note.js";
|
|
4
5
|
import { relativeTime, truncate } from "./format-utils.js";
|
|
5
6
|
import { hasDisplayPrBlocker, isApprovedReviewState, isAwaitingReviewState, isChangesRequestedReviewState, isRereviewNeeded, prChecksFact, } from "./pr-status.js";
|
|
@@ -19,7 +20,7 @@ function effectiveState(issue) {
|
|
|
19
20
|
return "blocked";
|
|
20
21
|
if (issue.sessionState === "waiting_input")
|
|
21
22
|
return "awaiting_input";
|
|
22
|
-
if (issue.prNumber
|
|
23
|
+
if (hasOpenPr(issue.prNumber, issue.prState))
|
|
23
24
|
return issue.factoryState;
|
|
24
25
|
if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
|
|
25
26
|
return "ready";
|
|
@@ -94,7 +95,7 @@ function buildFacts(issue, selected) {
|
|
|
94
95
|
else if (isChangesRequestedReviewState(issue.prReviewState)) {
|
|
95
96
|
facts.push({ text: "changes requested", color: "yellow" });
|
|
96
97
|
}
|
|
97
|
-
else if (issue.prNumber
|
|
98
|
+
else if (hasOpenPr(issue.prNumber, issue.prState)
|
|
98
99
|
&& (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && !TERMINAL_STATES.has(effectiveState(issue))))) {
|
|
99
100
|
facts.push({ text: "awaiting review", color: "yellow" });
|
|
100
101
|
}
|
|
@@ -146,7 +147,7 @@ function blockerText(issue) {
|
|
|
146
147
|
return "Awaiting re-review after requested changes";
|
|
147
148
|
if (isChangesRequestedReviewState(issue.prReviewState))
|
|
148
149
|
return "Review changes requested";
|
|
149
|
-
if (issue.prNumber
|
|
150
|
+
if (hasOpenPr(issue.prNumber, issue.prState) && isAwaitingReviewState(issue.prReviewState))
|
|
150
151
|
return "Awaiting review";
|
|
151
152
|
return null;
|
|
152
153
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
3
4
|
import { computeAggregates } from "./watch-state.js";
|
|
4
5
|
import { FreshnessBadge } from "./FreshnessBadge.js";
|
|
5
6
|
const FILTER_LABELS = {
|
|
@@ -11,7 +12,7 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
|
|
|
11
12
|
const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
|
|
12
13
|
const aggregateSource = filter === "all" ? allIssues : issues;
|
|
13
14
|
const agg = computeAggregates(aggregateSource);
|
|
14
|
-
const withPr = aggregateSource.filter((i) => i.prNumber
|
|
15
|
+
const withPr = aggregateSource.filter((i) => hasOpenPr(i.prNumber, i.prState)).length;
|
|
15
16
|
const waitingInput = aggregateSource.filter((i) => i.sessionState === "waiting_input" || i.factoryState === "awaiting_input").length;
|
|
16
17
|
const intervention = aggregateSource.filter((i) => i.sessionState === "failed" || i.factoryState === "failed" || i.factoryState === "escalated").length;
|
|
17
18
|
const running = aggregateSource.filter((i) => i.sessionState === "running").length;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
1
2
|
import { buildStateHistory } from "./history-builder.js";
|
|
2
3
|
import { buildTimelineRows } from "./timeline-presentation.js";
|
|
3
4
|
import { planStepColor, planStepSymbol } from "./plan-helpers.js";
|
|
@@ -396,7 +397,7 @@ function buildFactSegments(issue, issueContext) {
|
|
|
396
397
|
facts.push([{ text: "re-review needed", color: "yellow" }]);
|
|
397
398
|
else if (isChangesRequestedReviewState(issue.prReviewState))
|
|
398
399
|
facts.push([{ text: "changes requested", color: "yellow" }]);
|
|
399
|
-
else if (issue.prNumber
|
|
400
|
+
else if (hasOpenPr(issue.prNumber, issue.prState)
|
|
400
401
|
&& (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && issue.factoryState === "pr_open")))
|
|
401
402
|
facts.push([{ text: "awaiting review", color: "yellow" }]);
|
|
402
403
|
if (issue.factoryState === "awaiting_queue")
|
|
@@ -503,7 +504,7 @@ function effectiveState(issue) {
|
|
|
503
504
|
return "blocked";
|
|
504
505
|
if (issue.sessionState === "waiting_input")
|
|
505
506
|
return "awaiting_input";
|
|
506
|
-
if (issue.prNumber
|
|
507
|
+
if (hasOpenPr(issue.prNumber, issue.prState))
|
|
507
508
|
return issue.factoryState;
|
|
508
509
|
if (issue.readyForExecution && !issue.activeRunType && !hasDisplayPrBlocker(issue))
|
|
509
510
|
return "ready";
|
|
@@ -546,7 +547,7 @@ function blockerText(issue, issueContext) {
|
|
|
546
547
|
return "Awaiting re-review after requested changes";
|
|
547
548
|
if (isChangesRequestedReviewState(issue.prReviewState))
|
|
548
549
|
return "Review changes requested";
|
|
549
|
-
if (issue.prNumber
|
|
550
|
+
if (hasOpenPr(issue.prNumber, issue.prState) && (isAwaitingReviewState(issue.prReviewState) || (!issue.prReviewState && effectiveState(issue) !== "done"))) {
|
|
550
551
|
return "Awaiting review";
|
|
551
552
|
}
|
|
552
553
|
return null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
1
2
|
function isPassingCheckStatus(status) {
|
|
2
3
|
return status === "passed" || status === "success";
|
|
3
4
|
}
|
|
@@ -72,7 +73,7 @@ export function prChecksFact(issue) {
|
|
|
72
73
|
return undefined;
|
|
73
74
|
}
|
|
74
75
|
export function hasDisplayPrBlocker(issue) {
|
|
75
|
-
if (issue.prNumber
|
|
76
|
+
if (!hasOpenPr(issue.prNumber, issue.prState) || issue.activeRunType) {
|
|
76
77
|
return false;
|
|
77
78
|
}
|
|
78
79
|
if (issue.factoryState === "pr_open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "repairing_queue") {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasOpenPr } from "../../pr-state.js";
|
|
1
2
|
const STATE_LABELS = {
|
|
2
3
|
delegated: "delegated",
|
|
3
4
|
implementing: "implementing",
|
|
@@ -163,9 +164,12 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
|
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
166
|
if (issue.prNumber !== undefined) {
|
|
167
|
+
const prLabel = hasOpenPr(issue.prNumber, issue.prState)
|
|
168
|
+
? `Tracked PR: #${issue.prNumber}`
|
|
169
|
+
: `Tracked PR: #${issue.prNumber}${issue.prState ? ` (${issue.prState})` : ""}`;
|
|
166
170
|
observations.push({
|
|
167
171
|
tone: "info",
|
|
168
|
-
text:
|
|
172
|
+
text: `${prLabel}${issue.prReviewState ? ` (${issue.prReviewState})` : ""}`,
|
|
169
173
|
});
|
|
170
174
|
}
|
|
171
175
|
return observations.slice(0, 3);
|
|
@@ -263,20 +263,6 @@ export class IssueSessionStore {
|
|
|
263
263
|
WHERE project_id = ? AND linear_issue_id = ?
|
|
264
264
|
`).run(lastWakeReason ?? null, isoNow(), projectId, linearIssueId);
|
|
265
265
|
}
|
|
266
|
-
setBranchOwnerWithLease(lease, owner) {
|
|
267
|
-
return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
|
|
268
|
-
this.issues.setBranchOwner(lease.projectId, lease.linearIssueId, owner);
|
|
269
|
-
return true;
|
|
270
|
-
}) ?? false;
|
|
271
|
-
}
|
|
272
|
-
setBranchOwnerRespectingActiveLease(projectId, linearIssueId, owner) {
|
|
273
|
-
const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
|
|
274
|
-
if (!lease) {
|
|
275
|
-
this.issues.setBranchOwner(projectId, linearIssueId, owner);
|
|
276
|
-
return true;
|
|
277
|
-
}
|
|
278
|
-
return this.setBranchOwnerWithLease(lease, owner);
|
|
279
|
-
}
|
|
280
266
|
releaseIssueSessionLeaseRespectingActiveLease(projectId, linearIssueId) {
|
|
281
267
|
const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
|
|
282
268
|
this.releaseIssueSessionLease(projectId, linearIssueId, lease?.leaseId);
|
package/dist/db/issue-store.js
CHANGED
|
@@ -16,6 +16,10 @@ export class IssueStore {
|
|
|
16
16
|
projectId: params.projectId,
|
|
17
17
|
linearIssueId: params.linearIssueId,
|
|
18
18
|
};
|
|
19
|
+
if (params.delegatedToPatchRelay !== undefined) {
|
|
20
|
+
sets.push("delegated_to_patchrelay = @delegatedToPatchRelay");
|
|
21
|
+
values.delegatedToPatchRelay = params.delegatedToPatchRelay ? 1 : 0;
|
|
22
|
+
}
|
|
19
23
|
if (params.issueKey !== undefined) {
|
|
20
24
|
sets.push("issue_key = COALESCE(@issueKey, issue_key)");
|
|
21
25
|
values.issueKey = params.issueKey;
|
|
@@ -205,7 +209,7 @@ export class IssueStore {
|
|
|
205
209
|
else {
|
|
206
210
|
this.connection.prepare(`
|
|
207
211
|
INSERT INTO issues (
|
|
208
|
-
project_id, linear_issue_id, issue_key, title, description, url,
|
|
212
|
+
project_id, linear_issue_id, delegated_to_patchrelay, issue_key, title, description, url,
|
|
209
213
|
priority, estimate,
|
|
210
214
|
current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
|
|
211
215
|
branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
|
|
@@ -218,7 +222,7 @@ export class IssueStore {
|
|
|
218
222
|
ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
|
|
219
223
|
updated_at
|
|
220
224
|
) VALUES (
|
|
221
|
-
@projectId, @linearIssueId, @issueKey, @title, @description, @url,
|
|
225
|
+
@projectId, @linearIssueId, @delegatedToPatchRelay, @issueKey, @title, @description, @url,
|
|
222
226
|
@priority, @estimate,
|
|
223
227
|
@currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
|
|
224
228
|
@branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
|
|
@@ -234,6 +238,7 @@ export class IssueStore {
|
|
|
234
238
|
`).run({
|
|
235
239
|
projectId: params.projectId,
|
|
236
240
|
linearIssueId: params.linearIssueId,
|
|
241
|
+
delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
|
|
237
242
|
issueKey: params.issueKey ?? null,
|
|
238
243
|
title: params.title ?? null,
|
|
239
244
|
description: params.description ?? null,
|
|
@@ -357,14 +362,6 @@ export class IssueStore {
|
|
|
357
362
|
.all();
|
|
358
363
|
return rows.map(mapIssueRow);
|
|
359
364
|
}
|
|
360
|
-
setBranchOwner(projectId, linearIssueId, owner) {
|
|
361
|
-
const now = isoNow();
|
|
362
|
-
this.connection.prepare(`
|
|
363
|
-
UPDATE issues
|
|
364
|
-
SET branch_owner = ?, branch_ownership_changed_at = ?, updated_at = ?
|
|
365
|
-
WHERE project_id = ? AND linear_issue_id = ?
|
|
366
|
-
`).run(owner, now, now, projectId, linearIssueId);
|
|
367
|
-
}
|
|
368
365
|
replaceIssueDependencies(params) {
|
|
369
366
|
const now = isoNow();
|
|
370
367
|
this.connection
|
|
@@ -466,6 +463,7 @@ export function mapIssueRow(row) {
|
|
|
466
463
|
id: Number(row.id),
|
|
467
464
|
projectId: String(row.project_id),
|
|
468
465
|
linearIssueId: String(row.linear_issue_id),
|
|
466
|
+
delegatedToPatchRelay: Number(row.delegated_to_patchrelay ?? 1) !== 0,
|
|
469
467
|
...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
|
|
470
468
|
...(row.title !== null ? { title: String(row.title) } : {}),
|
|
471
469
|
...(row.description !== null && row.description !== undefined ? { description: String(row.description) } : {}),
|
|
@@ -480,12 +478,6 @@ export function mapIssueRow(row) {
|
|
|
480
478
|
...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
|
|
481
479
|
...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
|
|
482
480
|
...(row.branch_name !== null ? { branchName: String(row.branch_name) } : {}),
|
|
483
|
-
...(row.branch_owner !== null && row.branch_owner !== undefined && String(row.branch_owner) === "patchrelay"
|
|
484
|
-
? { branchOwner: "patchrelay" }
|
|
485
|
-
: { branchOwner: "patchrelay" }),
|
|
486
|
-
...(row.branch_ownership_changed_at !== null && row.branch_ownership_changed_at !== undefined
|
|
487
|
-
? { branchOwnershipChangedAt: String(row.branch_ownership_changed_at) }
|
|
488
|
-
: {}),
|
|
489
481
|
...(row.worktree_path !== null ? { worktreePath: String(row.worktree_path) } : {}),
|
|
490
482
|
...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
|
|
491
483
|
...(row.active_run_id !== null ? { activeRunId: Number(row.active_run_id) } : {}),
|
package/dist/db/migrations.js
CHANGED
|
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
3
3
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
4
|
project_id TEXT NOT NULL,
|
|
5
5
|
linear_issue_id TEXT NOT NULL,
|
|
6
|
+
delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
|
|
6
7
|
issue_key TEXT,
|
|
7
8
|
title TEXT,
|
|
8
9
|
url TEXT,
|
|
@@ -12,8 +13,6 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
12
13
|
pending_run_type TEXT,
|
|
13
14
|
pending_run_context_json TEXT,
|
|
14
15
|
branch_name TEXT,
|
|
15
|
-
branch_owner TEXT NOT NULL DEFAULT 'patchrelay',
|
|
16
|
-
branch_ownership_changed_at TEXT,
|
|
17
16
|
worktree_path TEXT,
|
|
18
17
|
thread_id TEXT,
|
|
19
18
|
active_run_id INTEGER,
|
|
@@ -239,12 +238,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
239
238
|
connection.exec(schema);
|
|
240
239
|
// Clean up stale dedupe-only webhook records (no payload, never processable)
|
|
241
240
|
connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
|
|
241
|
+
addColumnIfMissing(connection, "issues", "delegated_to_patchrelay", "INTEGER NOT NULL DEFAULT 1");
|
|
242
242
|
// Add pending_merge_prep column for merge queue stewardship
|
|
243
243
|
addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
|
|
244
|
-
// Explicit PR branch ownership hand-off between PatchRelay and MergeSteward
|
|
245
|
-
addColumnIfMissing(connection, "issues", "branch_owner", "TEXT NOT NULL DEFAULT 'patchrelay'");
|
|
246
|
-
addColumnIfMissing(connection, "issues", "branch_ownership_changed_at", "TEXT");
|
|
247
|
-
connection.prepare("UPDATE issues SET branch_owner = 'patchrelay' WHERE branch_owner IS NULL OR branch_owner != 'patchrelay'").run();
|
|
248
244
|
// Add merge_prep_attempts for retry budget / escalation
|
|
249
245
|
addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
250
246
|
// Add review_fix_attempts counter
|
|
@@ -304,7 +300,7 @@ function addColumnIfMissing(connection, table, column, definition) {
|
|
|
304
300
|
function removeRetiredIssueColumnsIfPresent(connection) {
|
|
305
301
|
const cols = connection.prepare("PRAGMA table_info(issues)").all();
|
|
306
302
|
const columnNames = new Set(cols.map((column) => String(column.name)));
|
|
307
|
-
const retired = ["queue_label_applied", "pending_merge_prep", "merge_prep_attempts"];
|
|
303
|
+
const retired = ["queue_label_applied", "pending_merge_prep", "merge_prep_attempts", "branch_owner", "branch_ownership_changed_at"];
|
|
308
304
|
if (!retired.some((name) => columnNames.has(name))) {
|
|
309
305
|
return;
|
|
310
306
|
}
|
|
@@ -315,6 +311,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
315
311
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
316
312
|
project_id TEXT NOT NULL,
|
|
317
313
|
linear_issue_id TEXT NOT NULL,
|
|
314
|
+
delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
|
|
318
315
|
issue_key TEXT,
|
|
319
316
|
title TEXT,
|
|
320
317
|
description TEXT,
|
|
@@ -327,8 +324,6 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
327
324
|
pending_run_type TEXT,
|
|
328
325
|
pending_run_context_json TEXT,
|
|
329
326
|
branch_name TEXT,
|
|
330
|
-
branch_owner TEXT NOT NULL DEFAULT 'patchrelay',
|
|
331
|
-
branch_ownership_changed_at TEXT,
|
|
332
327
|
worktree_path TEXT,
|
|
333
328
|
thread_id TEXT,
|
|
334
329
|
active_run_id INTEGER,
|
|
@@ -371,6 +366,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
371
366
|
id,
|
|
372
367
|
project_id,
|
|
373
368
|
linear_issue_id,
|
|
369
|
+
delegated_to_patchrelay,
|
|
374
370
|
issue_key,
|
|
375
371
|
title,
|
|
376
372
|
description,
|
|
@@ -383,8 +379,6 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
383
379
|
pending_run_type,
|
|
384
380
|
pending_run_context_json,
|
|
385
381
|
branch_name,
|
|
386
|
-
branch_owner,
|
|
387
|
-
branch_ownership_changed_at,
|
|
388
382
|
worktree_path,
|
|
389
383
|
thread_id,
|
|
390
384
|
active_run_id,
|
|
@@ -425,6 +419,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
425
419
|
id,
|
|
426
420
|
project_id,
|
|
427
421
|
linear_issue_id,
|
|
422
|
+
COALESCE(delegated_to_patchrelay, 1),
|
|
428
423
|
issue_key,
|
|
429
424
|
title,
|
|
430
425
|
description,
|
|
@@ -437,8 +432,6 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
437
432
|
pending_run_type,
|
|
438
433
|
pending_run_context_json,
|
|
439
434
|
branch_name,
|
|
440
|
-
COALESCE(branch_owner, 'patchrelay'),
|
|
441
|
-
branch_ownership_changed_at,
|
|
442
435
|
worktree_path,
|
|
443
436
|
thread_id,
|
|
444
437
|
active_run_id,
|
package/dist/db.js
CHANGED
|
@@ -34,6 +34,7 @@ function hasUnattemptedFailureSignature(issue, fallbackHeadSha) {
|
|
|
34
34
|
}
|
|
35
35
|
function deriveImplicitReactiveWake(issue) {
|
|
36
36
|
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
|
37
|
+
delegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
37
38
|
activeRunId: issue.activeRunId,
|
|
38
39
|
prNumber: issue.prNumber,
|
|
39
40
|
prState: issue.prState,
|
|
@@ -144,9 +145,6 @@ export class PatchRelayDatabase {
|
|
|
144
145
|
getIssueByPrNumber(prNumber) {
|
|
145
146
|
return this.issues.getIssueByPrNumber(prNumber);
|
|
146
147
|
}
|
|
147
|
-
setBranchOwner(projectId, linearIssueId, owner) {
|
|
148
|
-
this.issues.setBranchOwner(projectId, linearIssueId, owner);
|
|
149
|
-
}
|
|
150
148
|
replaceIssueDependencies(params) {
|
|
151
149
|
this.issues.replaceIssueDependencies(params);
|
|
152
150
|
}
|
package/dist/factory-state.js
CHANGED
|
@@ -30,7 +30,7 @@ const TRANSITION_RULES = [
|
|
|
30
30
|
// pr_closed during an active run is suppressed — Codex may reopen.
|
|
31
31
|
// Without a guard match, the event produces no transition (undefined).
|
|
32
32
|
{ event: "pr_closed",
|
|
33
|
-
guard: (
|
|
33
|
+
guard: (s, ctx) => ctx.activeRunId === undefined && !TERMINAL_STATES.has(s),
|
|
34
34
|
to: "failed" },
|
|
35
35
|
// ── PR lifecycle ───────────────────────────────────────────────
|
|
36
36
|
{ event: "pr_opened",
|