patchrelay 0.75.2 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-input-service.js +40 -26
- package/dist/build-info.json +3 -3
- package/dist/cli/data.js +3 -1
- package/dist/db/issue-session-store.js +44 -9
- package/dist/db/issue-store.js +31 -2
- package/dist/db/migrations.js +3 -0
- package/dist/factory-state.js +23 -0
- package/dist/github-webhook-reactive-run.js +15 -11
- package/dist/github-webhook-stack-coordination.js +8 -4
- package/dist/github-webhook-state-projector.js +204 -139
- package/dist/github-webhook-terminal-handler.js +37 -27
- package/dist/idle-reconciliation.js +122 -66
- package/dist/implementation-outcome-policy.js +5 -1
- package/dist/interrupted-run-recovery.js +46 -33
- package/dist/issue-session-projection-invalidator.js +9 -0
- package/dist/linear-agent-session-client.js +16 -8
- package/dist/linear-issue-projection.js +15 -11
- package/dist/linear-status-comment-sync.js +8 -4
- package/dist/linear-workflow-state-sync.js +9 -5
- package/dist/merged-linear-completion-reconciler.js +39 -17
- package/dist/no-pr-completion-check.js +51 -29
- package/dist/orchestration-parent-wake.js +15 -8
- package/dist/queue-health-monitor.js +17 -8
- package/dist/reactive-run-policy.js +5 -1
- package/dist/run-finalizer.js +61 -29
- package/dist/run-launcher.js +42 -12
- package/dist/run-notification-handler.js +19 -7
- package/dist/run-orchestrator.js +121 -18
- package/dist/run-reconciler.js +121 -50
- package/dist/run-recovery-service.js +70 -33
- package/dist/run-wake-planner.js +39 -29
- package/dist/service-issue-actions.js +45 -28
- package/dist/service-startup-recovery.js +61 -35
- package/dist/telemetry.js +9 -0
- package/dist/terminal-wake-reconciler.js +20 -3
- package/dist/webhooks/agent-session-handler.js +22 -12
- package/dist/webhooks/dependency-readiness-handler.js +17 -10
- package/dist/webhooks/desired-stage-recorder.js +32 -13
- package/dist/webhooks/issue-removal-handler.js +24 -13
- package/package.json +1 -1
|
@@ -16,6 +16,7 @@ import { LinearIssueProjectionService } from "./linear-issue-projection.js";
|
|
|
16
16
|
import { TerminalWakeReconciler } from "./terminal-wake-reconciler.js";
|
|
17
17
|
const BLOCKED_DEPENDENCY_REFRESH_SUCCESS_BACKOFF_MS = 60_000;
|
|
18
18
|
const BLOCKED_DEPENDENCY_REFRESH_FAILURE_BACKOFF_MS = 5 * 60_000;
|
|
19
|
+
const WRITER = "idle-reconciliation";
|
|
19
20
|
export class IdleIssueReconciler {
|
|
20
21
|
db;
|
|
21
22
|
config;
|
|
@@ -174,12 +175,16 @@ export class IdleIssueReconciler {
|
|
|
174
175
|
return;
|
|
175
176
|
const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
|
|
176
177
|
if (isDeployTrackingEnabled(project)) {
|
|
177
|
-
this.advanceIdleIssue(issue, "deploying", { clearFailureProvenance: true })
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
if (this.advanceIdleIssue(issue, "deploying", { clearFailureProvenance: true }) !== "skipped") {
|
|
179
|
+
this.db.issueSessions.commitIssueState({
|
|
180
|
+
writer: WRITER,
|
|
181
|
+
update: {
|
|
182
|
+
projectId: issue.projectId,
|
|
183
|
+
linearIssueId: issue.linearIssueId,
|
|
184
|
+
deployStartedAt: new Date().toISOString(),
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
183
188
|
}
|
|
184
189
|
else {
|
|
185
190
|
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
@@ -242,33 +247,49 @@ export class IdleIssueReconciler {
|
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
finishDeploy(issue, state) {
|
|
245
|
-
this.advanceIdleIssue(issue, state, state === "done" ? { clearFailureProvenance: true } : undefined)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
+
if (this.advanceIdleIssue(issue, state, state === "done" ? { clearFailureProvenance: true } : undefined) === "skipped") {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.db.issueSessions.commitIssueState({
|
|
254
|
+
writer: WRITER,
|
|
255
|
+
update: {
|
|
256
|
+
projectId: issue.projectId,
|
|
257
|
+
linearIssueId: issue.linearIssueId,
|
|
258
|
+
deployStartedAt: null,
|
|
259
|
+
},
|
|
250
260
|
});
|
|
251
261
|
}
|
|
252
262
|
advanceIdleIssue(issue, newState, options) {
|
|
253
263
|
if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
|
|
254
|
-
return;
|
|
264
|
+
return "noop";
|
|
255
265
|
}
|
|
256
|
-
this.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
266
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
267
|
+
writer: WRITER,
|
|
268
|
+
expectedVersion: issue.version,
|
|
269
|
+
update: {
|
|
270
|
+
projectId: issue.projectId,
|
|
271
|
+
linearIssueId: issue.linearIssueId,
|
|
272
|
+
factoryState: newState,
|
|
273
|
+
...((options?.pendingRunType || newState === "awaiting_queue" || newState === "delegated" || newState === "done")
|
|
274
|
+
? {
|
|
275
|
+
pendingRunType: null,
|
|
276
|
+
pendingRunContextJson: null,
|
|
277
|
+
}
|
|
278
|
+
: {}),
|
|
279
|
+
...(options?.clearFailureProvenance
|
|
280
|
+
? { ...CLEARED_FAILURE_PROVENANCE }
|
|
281
|
+
: {}),
|
|
282
|
+
},
|
|
283
|
+
// A writer that landed mid-tick (almost always a webhook) is newer
|
|
284
|
+
// truth than this pass's read; skip and let the next tick re-derive.
|
|
285
|
+
onConflict: () => undefined,
|
|
270
286
|
});
|
|
271
|
-
|
|
287
|
+
if (commit.outcome !== "applied") {
|
|
288
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, outcome: commit.outcome }, "Reconciliation: skipped advancing idle issue after a concurrent write");
|
|
289
|
+
return "skipped";
|
|
290
|
+
}
|
|
291
|
+
this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
|
|
292
|
+
const updatedIssue = commit.issue;
|
|
272
293
|
if (this.syncIssue) {
|
|
273
294
|
void Promise.resolve(this.syncIssue(updatedIssue)).catch((error) => {
|
|
274
295
|
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to sync Linear workflow state after idle reconciliation");
|
|
@@ -289,6 +310,7 @@ export class IdleIssueReconciler {
|
|
|
289
310
|
// The dispatcher's recordEventAndDispatch in recordWakeEvent already
|
|
290
311
|
// handles the enqueue when no run is in flight, so no extra poke
|
|
291
312
|
// is needed here.
|
|
313
|
+
return "applied";
|
|
292
314
|
}
|
|
293
315
|
recordWakeEvent(issue, runType, context, dedupeScope = "idle_reconciliation") {
|
|
294
316
|
const eventType = reactiveWakeEventType(runType);
|
|
@@ -395,19 +417,27 @@ export class IdleIssueReconciler {
|
|
|
395
417
|
?? (inferred === "queue_eviction" && failureHeadSha && checkName
|
|
396
418
|
? ["queue_eviction", failureHeadSha, checkName].join("::")
|
|
397
419
|
: null);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
420
|
+
// Inference from a stale read must never overwrite provenance a
|
|
421
|
+
// concurrent webhook just recorded — skip on conflict and continue
|
|
422
|
+
// with the fresh row.
|
|
423
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
424
|
+
writer: WRITER,
|
|
425
|
+
expectedVersion: issue.version,
|
|
426
|
+
update: {
|
|
427
|
+
projectId: issue.projectId,
|
|
428
|
+
linearIssueId: issue.linearIssueId,
|
|
429
|
+
lastGitHubFailureSource: inferred,
|
|
430
|
+
...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
|
|
431
|
+
...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
|
|
432
|
+
...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
|
|
433
|
+
},
|
|
434
|
+
onConflict: () => undefined,
|
|
405
435
|
});
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
436
|
+
if (commit.outcome !== "applied") {
|
|
437
|
+
return (commit.outcome === "conflict_skipped" ? commit.issue : undefined) ?? issue;
|
|
438
|
+
}
|
|
409
439
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred, factoryState: issue.factoryState }, "Recovered missing failure provenance from GitHub state");
|
|
410
|
-
return
|
|
440
|
+
return commit.issue;
|
|
411
441
|
}
|
|
412
442
|
async reclassifyStaleBranchFailure(issue) {
|
|
413
443
|
const downstreamOwned = issue.factoryState === "awaiting_queue" || issue.prReviewState === "approved";
|
|
@@ -423,19 +453,24 @@ export class IdleIssueReconciler {
|
|
|
423
453
|
const checkName = issue.lastGitHubFailureCheckName ?? protocol.evictionCheckName;
|
|
424
454
|
const failureSignature = issue.lastGitHubFailureSignature
|
|
425
455
|
?? (failureHeadSha && checkName ? ["queue_eviction", failureHeadSha, checkName].join("::") : null);
|
|
426
|
-
this.db.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
456
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
457
|
+
writer: WRITER,
|
|
458
|
+
expectedVersion: issue.version,
|
|
459
|
+
update: {
|
|
460
|
+
projectId: issue.projectId,
|
|
461
|
+
linearIssueId: issue.linearIssueId,
|
|
462
|
+
lastGitHubFailureSource: "queue_eviction",
|
|
463
|
+
...(failureHeadSha ? { lastGitHubFailureHeadSha: failureHeadSha } : {}),
|
|
464
|
+
...(checkName ? { lastGitHubFailureCheckName: checkName } : {}),
|
|
465
|
+
...(failureSignature ? { lastGitHubFailureSignature: failureSignature } : {}),
|
|
466
|
+
},
|
|
467
|
+
onConflict: () => undefined,
|
|
433
468
|
});
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
469
|
+
if (commit.outcome !== "applied") {
|
|
470
|
+
return (commit.outcome === "conflict_skipped" ? commit.issue : undefined) ?? issue;
|
|
471
|
+
}
|
|
437
472
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reclassified stale branch failure as queue repair from GitHub state");
|
|
438
|
-
return
|
|
473
|
+
return commit.issue;
|
|
439
474
|
}
|
|
440
475
|
async inferFailureSourceFromGitHub(issue) {
|
|
441
476
|
const project = this.config.projects.find((candidate) => candidate.id === issue.projectId);
|
|
@@ -486,7 +521,8 @@ export class IdleIssueReconciler {
|
|
|
486
521
|
const project = this.config.projects.find((p) => p.id === issue.projectId);
|
|
487
522
|
if (!project?.github?.repoFullName || !issue.prNumber)
|
|
488
523
|
return;
|
|
489
|
-
const
|
|
524
|
+
const prNumber = issue.prNumber;
|
|
525
|
+
const snapshot = await fetchPullRequestSnapshot(project.github.repoFullName, prNumber);
|
|
490
526
|
if (!snapshot.ok) {
|
|
491
527
|
this.logger.debug({ issueKey: issue.issueKey, error: snapshot.error.message }, "Failed to query GitHub PR state during reconciliation");
|
|
492
528
|
if (issue.prReviewState === "approved") {
|
|
@@ -501,25 +537,42 @@ export class IdleIssueReconciler {
|
|
|
501
537
|
const previousHeadSha = issue.prHeadSha;
|
|
502
538
|
const gateCheckNames = getGateCheckNames(project);
|
|
503
539
|
const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
|
|
504
|
-
this.db.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
540
|
+
const factsCommit = this.db.issueSessions.commitIssueState({
|
|
541
|
+
writer: WRITER,
|
|
542
|
+
update: {
|
|
543
|
+
projectId: issue.projectId,
|
|
544
|
+
linearIssueId: issue.linearIssueId,
|
|
545
|
+
...buildPrStateUpdates(pr, gateCheckStatus, gateCheckNames[0] ?? "verify"),
|
|
546
|
+
},
|
|
508
547
|
});
|
|
548
|
+
// Continue the pass with the refreshed row so later version-checked
|
|
549
|
+
// writes don't see our own facts write as a conflict.
|
|
550
|
+
if (factsCommit.outcome === "applied") {
|
|
551
|
+
issue = factsCommit.issue;
|
|
552
|
+
}
|
|
509
553
|
if (pr.state === "MERGED") {
|
|
510
|
-
this.db.
|
|
554
|
+
this.db.issueSessions.commitIssueState({
|
|
555
|
+
writer: WRITER,
|
|
556
|
+
update: { projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" },
|
|
557
|
+
});
|
|
511
558
|
const merged = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? { ...issue, prState: "merged" };
|
|
512
559
|
await this.handleMergedIssue(merged);
|
|
513
560
|
return;
|
|
514
561
|
}
|
|
515
562
|
if (pr.state === "CLOSED") {
|
|
516
563
|
const closedPrDisposition = resolveClosedPrDisposition(issue);
|
|
517
|
-
this.db.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
564
|
+
const closedCommit = this.db.issueSessions.commitIssueState({
|
|
565
|
+
writer: WRITER,
|
|
566
|
+
update: {
|
|
567
|
+
projectId: issue.projectId,
|
|
568
|
+
linearIssueId: issue.linearIssueId,
|
|
569
|
+
prState: "closed",
|
|
570
|
+
...buildClosedPrCleanupFields(),
|
|
571
|
+
},
|
|
522
572
|
});
|
|
573
|
+
if (closedCommit.outcome === "applied") {
|
|
574
|
+
issue = closedCommit.issue;
|
|
575
|
+
}
|
|
523
576
|
if (closedPrDisposition === "done") {
|
|
524
577
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed for an already completed issue; preserving done state");
|
|
525
578
|
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
@@ -604,7 +657,7 @@ export class IdleIssueReconciler {
|
|
|
604
657
|
return;
|
|
605
658
|
}
|
|
606
659
|
const pendingRunContext = reactiveIntent.runType === "branch_upkeep"
|
|
607
|
-
? buildBranchUpkeepContext(
|
|
660
|
+
? buildBranchUpkeepContext(prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid)
|
|
608
661
|
: undefined;
|
|
609
662
|
this.logger.info({
|
|
610
663
|
issueKey: issue.issueKey,
|
|
@@ -648,7 +701,7 @@ export class IdleIssueReconciler {
|
|
|
648
701
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable, mergeStateStatus: pr.mergeStateStatus }, "Reconciliation: PR still needs branch upkeep after requested changes");
|
|
649
702
|
this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
|
|
650
703
|
pendingRunType: reactiveIntent.runType,
|
|
651
|
-
pendingRunContext: buildBranchUpkeepContext(
|
|
704
|
+
pendingRunContext: buildBranchUpkeepContext(prNumber, project.github?.baseBranch ?? "main", pr.mergeStateStatus, pr.headRefOid),
|
|
652
705
|
});
|
|
653
706
|
this.feed?.publish({
|
|
654
707
|
level: "warn",
|
|
@@ -683,10 +736,13 @@ export class IdleIssueReconciler {
|
|
|
683
736
|
return;
|
|
684
737
|
}
|
|
685
738
|
if (isReviewDecisionApproved(pr.reviewDecision)) {
|
|
686
|
-
this.db.
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
739
|
+
this.db.issueSessions.commitIssueState({
|
|
740
|
+
writer: WRITER,
|
|
741
|
+
update: {
|
|
742
|
+
projectId: issue.projectId,
|
|
743
|
+
linearIssueId: issue.linearIssueId,
|
|
744
|
+
prReviewState: "approved",
|
|
745
|
+
},
|
|
690
746
|
});
|
|
691
747
|
if (issue.factoryState !== "awaiting_queue" || hasFailureProvenance(issue)) {
|
|
692
748
|
const options = hasFailureProvenance(issue) ? { clearFailureProvenance: true } : undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execCommand } from "./utils.js";
|
|
2
|
+
const WRITER = "implementation-outcome-policy";
|
|
2
3
|
export class ImplementationOutcomePolicy {
|
|
3
4
|
config;
|
|
4
5
|
db;
|
|
@@ -92,7 +93,10 @@ export class ImplementationOutcomePolicy {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
|
|
95
|
-
const updated = this.withHeldLease(projectId, linearIssueId, (lease) =>
|
|
96
|
+
const updated = this.withHeldLease(projectId, linearIssueId, (lease) => {
|
|
97
|
+
const commit = this.db.issueSessions.commitIssueState({ writer: WRITER, lease, update: params });
|
|
98
|
+
return commit.outcome === "applied" ? commit.issue : undefined;
|
|
99
|
+
});
|
|
96
100
|
if (updated === undefined) {
|
|
97
101
|
this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write after losing issue-session lease");
|
|
98
102
|
}
|
|
@@ -2,6 +2,34 @@ import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
|
2
2
|
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
3
|
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
4
4
|
import { isRequestedChangesRunType } from "./reactive-pr-state.js";
|
|
5
|
+
const WRITER = "interrupted-run-recovery";
|
|
6
|
+
// Roll back the attempt counter consumed by the interrupted run and clear the
|
|
7
|
+
// attempted-failure provenance for repair runs, as a single issue update so
|
|
8
|
+
// the whole repair commits (and conflict-recomputes) atomically.
|
|
9
|
+
function buildInterruptedAttemptRepairUpdate(runType, issue) {
|
|
10
|
+
const counter = runType === "ci_repair" && issue.ciRepairAttempts > 0
|
|
11
|
+
? { ciRepairAttempts: issue.ciRepairAttempts - 1 }
|
|
12
|
+
: runType === "queue_repair" && issue.queueRepairAttempts > 0
|
|
13
|
+
? { queueRepairAttempts: issue.queueRepairAttempts - 1 }
|
|
14
|
+
: isRequestedChangesRunType(runType) && issue.reviewFixAttempts > 0
|
|
15
|
+
? { reviewFixAttempts: issue.reviewFixAttempts - 1 }
|
|
16
|
+
: undefined;
|
|
17
|
+
const provenance = runType === "ci_repair" || runType === "queue_repair"
|
|
18
|
+
? {
|
|
19
|
+
lastAttemptedFailureHeadSha: null,
|
|
20
|
+
lastAttemptedFailureSignature: null,
|
|
21
|
+
lastAttemptedFailureAt: null,
|
|
22
|
+
}
|
|
23
|
+
: undefined;
|
|
24
|
+
if (!counter && !provenance)
|
|
25
|
+
return undefined;
|
|
26
|
+
return {
|
|
27
|
+
projectId: issue.projectId,
|
|
28
|
+
linearIssueId: issue.linearIssueId,
|
|
29
|
+
...counter,
|
|
30
|
+
...provenance,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
5
33
|
function resolveRetryRunType(runType, context) {
|
|
6
34
|
if (runType === "branch_upkeep") {
|
|
7
35
|
return "branch_upkeep";
|
|
@@ -68,34 +96,16 @@ export class InterruptedRunRecovery {
|
|
|
68
96
|
async handle(run, issue) {
|
|
69
97
|
this.logger.warn({ issueKey: issue.issueKey, runType: run.runType, threadId: run.threadId }, "Run has interrupted turn - marking as failed");
|
|
70
98
|
const repairedCounters = this.withHeldLease(issue.projectId, issue.linearIssueId, (lease) => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
linearIssueId: issue.linearIssueId,
|
|
82
|
-
queueRepairAttempts: issue.queueRepairAttempts - 1,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
else if (isRequestedChangesRunType(run.runType) && issue.reviewFixAttempts > 0) {
|
|
86
|
-
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
87
|
-
projectId: issue.projectId,
|
|
88
|
-
linearIssueId: issue.linearIssueId,
|
|
89
|
-
reviewFixAttempts: issue.reviewFixAttempts - 1,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
if (run.runType === "ci_repair" || run.runType === "queue_repair") {
|
|
93
|
-
this.db.issueSessions.upsertIssueWithLease(lease, {
|
|
94
|
-
projectId: issue.projectId,
|
|
95
|
-
linearIssueId: issue.linearIssueId,
|
|
96
|
-
lastAttemptedFailureHeadSha: null,
|
|
97
|
-
lastAttemptedFailureSignature: null,
|
|
98
|
-
lastAttemptedFailureAt: null,
|
|
99
|
+
// The decrement is read-modify-write against an issue row read before
|
|
100
|
+
// the awaits that led here; on conflict, recompute from the fresh row.
|
|
101
|
+
const update = buildInterruptedAttemptRepairUpdate(run.runType, issue);
|
|
102
|
+
if (update) {
|
|
103
|
+
this.db.issueSessions.commitIssueState({
|
|
104
|
+
writer: WRITER,
|
|
105
|
+
lease,
|
|
106
|
+
expectedVersion: issue.version,
|
|
107
|
+
update,
|
|
108
|
+
onConflict: (current) => buildInterruptedAttemptRepairUpdate(run.runType, current),
|
|
99
109
|
});
|
|
100
110
|
}
|
|
101
111
|
return true;
|
|
@@ -192,11 +202,14 @@ export class InterruptedRunRecovery {
|
|
|
192
202
|
await this.restoreIdleWorktree(issue);
|
|
193
203
|
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
194
204
|
if (recoveredState === "changes_requested") {
|
|
195
|
-
this.db.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
this.db.issueSessions.commitIssueState({
|
|
206
|
+
writer: WRITER,
|
|
207
|
+
update: {
|
|
208
|
+
projectId: run.projectId,
|
|
209
|
+
linearIssueId: run.linearIssueId,
|
|
210
|
+
pendingRunType: retryRunType,
|
|
211
|
+
pendingRunContextJson: retryContext ? JSON.stringify(retryContext) : null,
|
|
212
|
+
},
|
|
200
213
|
});
|
|
201
214
|
this.feed?.publish({
|
|
202
215
|
level: "warn",
|
|
@@ -3,6 +3,9 @@ export class ImmediateIssueSessionProjectionInvalidator {
|
|
|
3
3
|
deps;
|
|
4
4
|
batchDepth = 0;
|
|
5
5
|
pendingProjections = new Map();
|
|
6
|
+
// Captured once: the guard must stay a single integer compare on the hot
|
|
7
|
+
// read path and be OFF in production.
|
|
8
|
+
strictMidBatchReads = process.env.NODE_ENV !== "production";
|
|
6
9
|
constructor(deps) {
|
|
7
10
|
this.deps = deps;
|
|
8
11
|
}
|
|
@@ -18,6 +21,12 @@ export class ImmediateIssueSessionProjectionInvalidator {
|
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
23
|
}
|
|
24
|
+
assertNotMidBatch(context) {
|
|
25
|
+
if (this.batchDepth > 0 && this.strictMidBatchReads) {
|
|
26
|
+
throw new Error(`Issue-session projection read mid-batch (${context}): the projection is stale until the `
|
|
27
|
+
+ "batch flushes. Read it before batchIssueSessionProjections() or after it returns.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
issueChanged(issue, options) {
|
|
22
31
|
const dependents = this.deps.listDependents(issue.projectId, issue.linearIssueId);
|
|
23
32
|
this.emitInvalidated("issue_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1 + dependents.length);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
2
2
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
3
3
|
import { computeLinearActivityKey } from "./linear-activity-key.js";
|
|
4
|
+
const WRITER = "linear-agent-session-client";
|
|
4
5
|
export class LinearAgentSessionClient {
|
|
5
6
|
config;
|
|
6
7
|
db;
|
|
@@ -22,11 +23,15 @@ export class LinearAgentSessionClient {
|
|
|
22
23
|
if (!recoveredAgentSessionId)
|
|
23
24
|
return issue;
|
|
24
25
|
this.logger.info({ issueKey: issue.issueKey, agentSessionId: recoveredAgentSessionId }, "Recovered missing Linear agent session id from webhook history");
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const commit = this.db.issueSessions.commitIssueState({
|
|
27
|
+
writer: WRITER,
|
|
28
|
+
update: {
|
|
29
|
+
projectId: issue.projectId,
|
|
30
|
+
linearIssueId: issue.linearIssueId,
|
|
31
|
+
agentSessionId: recoveredAgentSessionId,
|
|
32
|
+
},
|
|
29
33
|
});
|
|
34
|
+
return commit.outcome === "applied" ? commit.issue : issue;
|
|
30
35
|
}
|
|
31
36
|
async emitActivity(issue, content, options) {
|
|
32
37
|
const syncedIssue = this.ensureAgentSessionIssue(issue);
|
|
@@ -48,10 +53,13 @@ export class LinearAgentSessionClient {
|
|
|
48
53
|
...(ephemeral ? { ephemeral: true } : {}),
|
|
49
54
|
});
|
|
50
55
|
if (activityKey) {
|
|
51
|
-
this.db.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
this.db.issueSessions.commitIssueState({
|
|
57
|
+
writer: WRITER,
|
|
58
|
+
update: {
|
|
59
|
+
projectId: syncedIssue.projectId,
|
|
60
|
+
linearIssueId: syncedIssue.linearIssueId,
|
|
61
|
+
lastLinearActivityKey: activityKey,
|
|
62
|
+
},
|
|
55
63
|
});
|
|
56
64
|
}
|
|
57
65
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const WRITER = "linear-issue-projection";
|
|
1
2
|
export class LinearIssueProjectionService {
|
|
2
3
|
db;
|
|
3
4
|
linearProvider;
|
|
@@ -51,17 +52,20 @@ export function upsertLinearIssueProjection(db, projectId, liveIssue) {
|
|
|
51
52
|
childLinearIssueId: liveIssue.id,
|
|
52
53
|
parentLinearIssueId: liveIssue.parentId ?? null,
|
|
53
54
|
});
|
|
54
|
-
db.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
db.issueSessions.commitIssueState({
|
|
56
|
+
writer: WRITER,
|
|
57
|
+
update: {
|
|
58
|
+
projectId,
|
|
59
|
+
linearIssueId: liveIssue.id,
|
|
60
|
+
...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
|
|
61
|
+
...(liveIssue.title ? { title: liveIssue.title } : {}),
|
|
62
|
+
...(liveIssue.description ? { description: liveIssue.description } : {}),
|
|
63
|
+
...(liveIssue.url ? { url: liveIssue.url } : {}),
|
|
64
|
+
...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
|
|
65
|
+
...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
|
|
66
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
67
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
68
|
+
},
|
|
65
69
|
});
|
|
66
70
|
}
|
|
67
71
|
export function replaceIssueDependenciesFromLinearIssue(db, projectId, liveIssue) {
|
|
@@ -3,6 +3,7 @@ import { isClosedPrState } from "./pr-state.js";
|
|
|
3
3
|
import { derivePrDisplayContext } from "./pr-display-context.js";
|
|
4
4
|
import { deriveIssueStatusNote } from "./status-note.js";
|
|
5
5
|
import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
|
|
6
|
+
const WRITER = "linear-status-comment-sync";
|
|
6
7
|
export async function syncVisibleStatusComment(params) {
|
|
7
8
|
const { db, issue, linear, logger, trackedIssue, options } = params;
|
|
8
9
|
try {
|
|
@@ -13,10 +14,13 @@ export async function syncVisibleStatusComment(params) {
|
|
|
13
14
|
body,
|
|
14
15
|
});
|
|
15
16
|
if (result.id !== issue.statusCommentId) {
|
|
16
|
-
db.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
db.issueSessions.commitIssueState({
|
|
18
|
+
writer: WRITER,
|
|
19
|
+
update: {
|
|
20
|
+
projectId: issue.projectId,
|
|
21
|
+
linearIssueId: issue.linearIssueId,
|
|
22
|
+
statusCommentId: result.id,
|
|
23
|
+
},
|
|
20
24
|
});
|
|
21
25
|
}
|
|
22
26
|
}
|
|
@@ -2,6 +2,7 @@ import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState
|
|
|
2
2
|
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
3
3
|
import { isCompletedLinearState } from "./pr-state.js";
|
|
4
4
|
import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
|
|
5
|
+
const WRITER = "linear-workflow-state-sync";
|
|
5
6
|
export async function syncActiveWorkflowState(params) {
|
|
6
7
|
const { db, issue, linear, trackedIssue, options, project } = params;
|
|
7
8
|
const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
|
|
@@ -103,11 +104,14 @@ async function syncCompletedLinearState(params) {
|
|
|
103
104
|
refreshCachedLinearState(db, issue, updated.stateName, updated.stateType);
|
|
104
105
|
}
|
|
105
106
|
function refreshCachedLinearState(db, issue, stateName, stateType) {
|
|
106
|
-
db.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
db.issueSessions.commitIssueState({
|
|
108
|
+
writer: WRITER,
|
|
109
|
+
update: {
|
|
110
|
+
projectId: issue.projectId,
|
|
111
|
+
linearIssueId: issue.linearIssueId,
|
|
112
|
+
...(stateName ? { currentLinearState: stateName } : {}),
|
|
113
|
+
...(stateType ? { currentLinearStateType: stateType } : {}),
|
|
114
|
+
},
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
function shouldAutoAdvanceLinearState(issue) {
|