patchrelay 0.36.16 → 0.36.18
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/build-info.json +3 -3
- package/dist/cli/data.js +15 -1
- package/dist/cli/formatters/text.js +5 -0
- package/dist/cli/watch/IssueRow.js +5 -0
- package/dist/cli/watch/detail-rows.js +5 -0
- package/dist/codex-app-server.js +23 -8
- package/dist/completion-check-types.js +1 -0
- package/dist/completion-check.js +143 -0
- package/dist/db/migrations.js +16 -0
- package/dist/db/run-store.js +32 -0
- package/dist/db.js +8 -0
- package/dist/implementation-outcome-policy.js +2 -16
- package/dist/issue-query-service.js +5 -44
- package/dist/issue-session-events.js +17 -0
- package/dist/linear-session-reporting.js +22 -0
- package/dist/linear-session-sync.js +15 -0
- package/dist/merged-linear-completion-reconciler.js +48 -0
- package/dist/prompting/patchrelay.js +16 -77
- package/dist/public-agent-session-status-query.js +52 -0
- package/dist/run-completion-policy.js +1 -5
- package/dist/run-finalizer.js +222 -10
- package/dist/run-orchestrator.js +8 -40
- package/dist/service.js +19 -0
- package/dist/status-note.js +31 -15
- package/dist/tracked-issue-projector.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
2
|
+
export class MergedLinearCompletionReconciler {
|
|
3
|
+
db;
|
|
4
|
+
linearProvider;
|
|
5
|
+
logger;
|
|
6
|
+
constructor(db, linearProvider, logger) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
this.linearProvider = linearProvider;
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
async reconcile() {
|
|
12
|
+
for (const issue of this.db.issues.listIssues()) {
|
|
13
|
+
if (issue.prState !== "merged")
|
|
14
|
+
continue;
|
|
15
|
+
if (issue.currentLinearStateType?.trim().toLowerCase() === "completed")
|
|
16
|
+
continue;
|
|
17
|
+
const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
|
|
18
|
+
if (!linear)
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
const liveIssue = await linear.getIssue(issue.linearIssueId);
|
|
22
|
+
const targetState = resolvePreferredCompletedLinearState(liveIssue);
|
|
23
|
+
if (!targetState)
|
|
24
|
+
continue;
|
|
25
|
+
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
26
|
+
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
27
|
+
this.db.issues.upsertIssue({
|
|
28
|
+
projectId: issue.projectId,
|
|
29
|
+
linearIssueId: issue.linearIssueId,
|
|
30
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
31
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
36
|
+
this.db.issues.upsertIssue({
|
|
37
|
+
projectId: issue.projectId,
|
|
38
|
+
linearIssueId: issue.linearIssueId,
|
|
39
|
+
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
40
|
+
...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged issue to a completed Linear state");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -29,62 +29,6 @@ function readWorkflowFile(repoPath, runType) {
|
|
|
29
29
|
return undefined;
|
|
30
30
|
return readFileSync(filePath, "utf8").trim();
|
|
31
31
|
}
|
|
32
|
-
function collectImplementationInstructionText(issue, context, promptText) {
|
|
33
|
-
const parts = [];
|
|
34
|
-
if (issue.title)
|
|
35
|
-
parts.push(issue.title);
|
|
36
|
-
if (issue.description)
|
|
37
|
-
parts.push(issue.description);
|
|
38
|
-
if (promptText)
|
|
39
|
-
parts.push(promptText);
|
|
40
|
-
const stringFields = ["promptContext", "promptBody", "operatorPrompt", "userComment"];
|
|
41
|
-
for (const field of stringFields) {
|
|
42
|
-
const value = context?.[field];
|
|
43
|
-
if (typeof value === "string" && value.trim()) {
|
|
44
|
-
parts.push(value);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
if (Array.isArray(context?.followUps)) {
|
|
48
|
-
for (const entry of context.followUps) {
|
|
49
|
-
if (!entry || typeof entry !== "object")
|
|
50
|
-
continue;
|
|
51
|
-
const text = entry.text;
|
|
52
|
-
if (typeof text === "string" && text.trim()) {
|
|
53
|
-
parts.push(text);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return parts.join("\n").toLowerCase();
|
|
58
|
-
}
|
|
59
|
-
export function resolveImplementationDeliveryMode(issue, context, promptText) {
|
|
60
|
-
const instructionText = collectImplementationInstructionText(issue, context, promptText);
|
|
61
|
-
if (!instructionText)
|
|
62
|
-
return "publish_pr";
|
|
63
|
-
const hasExplicitNoPr = [
|
|
64
|
-
/\bdo not open (?:a |any )?pr\b/,
|
|
65
|
-
/\bdo not open (?:a |any )?pull request\b/,
|
|
66
|
-
/\bno pr is opened\b/,
|
|
67
|
-
/\bpatchrelay should not open a pr\b/,
|
|
68
|
-
/\bwithout opening a pr\b/,
|
|
69
|
-
].some((pattern) => pattern.test(instructionText));
|
|
70
|
-
const forbidsRepoChanges = [
|
|
71
|
-
/\bdo not make repository changes\b/,
|
|
72
|
-
/\bdo not make repo changes\b/,
|
|
73
|
-
/\bno repository changes\b/,
|
|
74
|
-
/\bno repo changes\b/,
|
|
75
|
-
/\bdo not modify repo files\b/,
|
|
76
|
-
].some((pattern) => pattern.test(instructionText));
|
|
77
|
-
const planningOnly = [
|
|
78
|
-
/\bplanning\/specification issue only\b/,
|
|
79
|
-
/\bplanning[- ]only\b/,
|
|
80
|
-
/\bspecification[- ]only\b/,
|
|
81
|
-
/\bplanning issue only\b/,
|
|
82
|
-
].some((pattern) => pattern.test(instructionText));
|
|
83
|
-
if (hasExplicitNoPr || (planningOnly && forbidsRepoChanges)) {
|
|
84
|
-
return "linear_only";
|
|
85
|
-
}
|
|
86
|
-
return "publish_pr";
|
|
87
|
-
}
|
|
88
32
|
function buildPromptHeader(issue) {
|
|
89
33
|
return [
|
|
90
34
|
`Issue: ${issue.issueKey ?? issue.linearIssueId}`,
|
|
@@ -338,16 +282,23 @@ function buildFollowUpPromptPrelude(issue, runType, context) {
|
|
|
338
282
|
"",
|
|
339
283
|
wakeReason === "direct_reply"
|
|
340
284
|
? "Why this turn exists: A human reply arrived for the outstanding question from the previous turn."
|
|
341
|
-
: wakeReason === "
|
|
342
|
-
? "Why this turn exists:
|
|
343
|
-
: wakeReason === "
|
|
344
|
-
? "Why this turn exists:
|
|
345
|
-
:
|
|
285
|
+
: wakeReason === "completion_check_continue"
|
|
286
|
+
? "Why this turn exists: The previous turn ended without a PR, and PatchRelay's completion check decided the work should continue automatically."
|
|
287
|
+
: wakeReason === "branch_upkeep"
|
|
288
|
+
? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
|
|
289
|
+
: wakeReason === "followup_comment"
|
|
290
|
+
? "Why this turn exists: A human follow-up comment arrived after the previous turn."
|
|
291
|
+
: `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
|
|
346
292
|
wakeReason === "direct_reply"
|
|
347
293
|
? "Required action now: Apply the latest human answer, continue from the current branch/session context, and publish the next concrete result."
|
|
348
|
-
:
|
|
294
|
+
: wakeReason === "completion_check_continue"
|
|
295
|
+
? "Required action now: Continue from the current branch and thread context, finish the task, and publish the next concrete result."
|
|
296
|
+
: "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
|
|
349
297
|
"",
|
|
350
298
|
];
|
|
299
|
+
if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
|
|
300
|
+
lines.push(`Completion check summary: ${context.completionCheckSummary.trim()}`, "");
|
|
301
|
+
}
|
|
351
302
|
if (followUpLines.length > 0) {
|
|
352
303
|
lines.push("Recent updates:");
|
|
353
304
|
followUpLines.forEach((line) => lines.push(`- ${line}`));
|
|
@@ -391,25 +342,13 @@ function buildWorkflowGuidance(repoPath, runType) {
|
|
|
391
342
|
}
|
|
392
343
|
return "";
|
|
393
344
|
}
|
|
394
|
-
function buildPublicationContract(runType
|
|
395
|
-
const deliveryMode = runType === "implementation" && issue
|
|
396
|
-
? resolveImplementationDeliveryMode(issue, context)
|
|
397
|
-
: "publish_pr";
|
|
398
|
-
if (runType === "implementation" && deliveryMode === "linear_only") {
|
|
399
|
-
return [
|
|
400
|
-
"## Delivery Requirements",
|
|
401
|
-
"",
|
|
402
|
-
"This issue is planning/specification only.",
|
|
403
|
-
"Do not modify repo files or open a PR for this issue.",
|
|
404
|
-
"Deliver the result through Linear artifacts such as follow-up issues, documents, and a concise summary.",
|
|
405
|
-
"Leave the worktree clean before stopping.",
|
|
406
|
-
].join("\n");
|
|
407
|
-
}
|
|
345
|
+
function buildPublicationContract(runType) {
|
|
408
346
|
if (runType === "implementation") {
|
|
409
347
|
return [
|
|
410
348
|
"## Publication Requirements",
|
|
411
349
|
"",
|
|
412
350
|
"Before finishing, publish the result instead of leaving it only in the worktree.",
|
|
351
|
+
"If the task is genuinely complete without a PR, say so clearly in your normal summary instead of inventing one.",
|
|
413
352
|
"If the worktree already contains relevant changes for this issue, verify them and publish them.",
|
|
414
353
|
"If you changed files for this issue, commit them, push the issue branch, and open or update the PR before stopping.",
|
|
415
354
|
"Do not stop with only local commits or uncommitted changes.",
|
|
@@ -444,7 +383,7 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
|
|
|
444
383
|
if (workflow) {
|
|
445
384
|
sections.push({ id: "workflow-guidance", content: workflow });
|
|
446
385
|
}
|
|
447
|
-
sections.push({ id: "publication-contract", content: buildPublicationContract(runType
|
|
386
|
+
sections.push({ id: "publication-contract", content: buildPublicationContract(runType) });
|
|
448
387
|
return sections;
|
|
449
388
|
}
|
|
450
389
|
function filterAllowedReplacements(promptLayer) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
|
|
2
|
+
import { parseStageReport } from "./issue-overview-query.js";
|
|
3
|
+
export class PublicAgentSessionStatusQuery {
|
|
4
|
+
db;
|
|
5
|
+
overviewQuery;
|
|
6
|
+
constructor(db, overviewQuery) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
this.overviewQuery = overviewQuery;
|
|
9
|
+
}
|
|
10
|
+
async getStatus(issueKey) {
|
|
11
|
+
const overview = await this.overviewQuery.getIssueOverview(issueKey);
|
|
12
|
+
if (!overview)
|
|
13
|
+
return undefined;
|
|
14
|
+
const issueRecord = this.db.issues.getIssueByKey(issueKey);
|
|
15
|
+
const latestRunReport = parseStageReport(overview.latestRun?.reportJson, overview.latestRun?.status ?? "unknown");
|
|
16
|
+
const runs = (overview.runs ?? this.overviewQuery.buildRuns(overview.issue.projectId, overview.issue.linearIssueId)).map((run) => ({
|
|
17
|
+
run: {
|
|
18
|
+
id: run.id,
|
|
19
|
+
runType: run.runType,
|
|
20
|
+
status: run.status,
|
|
21
|
+
startedAt: run.startedAt,
|
|
22
|
+
...(run.endedAt ? { endedAt: run.endedAt } : {}),
|
|
23
|
+
},
|
|
24
|
+
...(run.report ? { report: run.report } : {}),
|
|
25
|
+
}));
|
|
26
|
+
return {
|
|
27
|
+
issue: {
|
|
28
|
+
issueKey: overview.issue.issueKey,
|
|
29
|
+
title: overview.issue.title,
|
|
30
|
+
issueUrl: overview.issue.issueUrl,
|
|
31
|
+
currentLinearState: overview.issue.currentLinearState,
|
|
32
|
+
...(overview.session?.sessionState ? { sessionState: overview.session.sessionState } : {}),
|
|
33
|
+
factoryState: overview.issue.factoryState,
|
|
34
|
+
...(overview.session?.prNumber !== undefined ? { prNumber: overview.session.prNumber } : {}),
|
|
35
|
+
...(issueRecord?.prUrl ? { prUrl: issueRecord.prUrl } : {}),
|
|
36
|
+
...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
|
|
37
|
+
...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
|
|
38
|
+
...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
|
|
39
|
+
...(issueRecord ? { ciRepairAttempts: issueRecord.ciRepairAttempts, queueRepairAttempts: issueRecord.queueRepairAttempts } : {}),
|
|
40
|
+
...(overview.issue.waitingReason ? { waitingReason: overview.issue.waitingReason } : {}),
|
|
41
|
+
...(overview.issue.statusNote ? { statusNote: overview.issue.statusNote } : {}),
|
|
42
|
+
...(overview.session?.lastWakeReason ? { lastWakeReason: overview.session.lastWakeReason } : {}),
|
|
43
|
+
},
|
|
44
|
+
...(overview.activeRun ? { activeRun: overview.activeRun } : {}),
|
|
45
|
+
...(overview.latestRun ? { latestRun: overview.latestRun } : {}),
|
|
46
|
+
...(overview.liveThread ? { liveThread: summarizeCurrentThread(overview.liveThread) } : {}),
|
|
47
|
+
...(latestRunReport ? { latestReportSummary: extractStageSummary(latestRunReport) } : {}),
|
|
48
|
+
runs,
|
|
49
|
+
generatedAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
2
|
-
import { resolveImplementationDeliveryMode, } from "./prompting/patchrelay.js";
|
|
3
2
|
import { ImplementationOutcomePolicy } from "./implementation-outcome-policy.js";
|
|
4
3
|
import { ReactiveRunPolicy } from "./reactive-run-policy.js";
|
|
5
4
|
function resolvePostRunState(issue) {
|
|
@@ -12,10 +11,7 @@ function resolvePostRunState(issue) {
|
|
|
12
11
|
}
|
|
13
12
|
return undefined;
|
|
14
13
|
}
|
|
15
|
-
export function resolveCompletedRunState(issue,
|
|
16
|
-
if (run.runType === "implementation" && resolveImplementationDeliveryMode(issue, undefined, run.promptText) === "linear_only") {
|
|
17
|
-
return "done";
|
|
18
|
-
}
|
|
14
|
+
export function resolveCompletedRunState(issue, _run) {
|
|
19
15
|
return resolvePostRunState(issue);
|
|
20
16
|
}
|
|
21
17
|
export class RunCompletionPolicy {
|
package/dist/run-finalizer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildStageReport, countEventMethods } from "./run-reporting.js";
|
|
2
|
-
import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
2
|
+
import { buildCompletionCheckActivity, buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
3
|
import { resolveCompletedRunState } from "./run-completion-policy.js";
|
|
4
4
|
export class RunFinalizer {
|
|
5
5
|
db;
|
|
@@ -11,8 +11,9 @@ export class RunFinalizer {
|
|
|
11
11
|
appendWakeEventWithLease;
|
|
12
12
|
failRunAndClear;
|
|
13
13
|
completionPolicy;
|
|
14
|
+
completionCheck;
|
|
14
15
|
feed;
|
|
15
|
-
constructor(db, logger, linearSync, enqueueIssue, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, feed) {
|
|
16
|
+
constructor(db, logger, linearSync, enqueueIssue, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, completionCheck, feed) {
|
|
16
17
|
this.db = db;
|
|
17
18
|
this.logger = logger;
|
|
18
19
|
this.linearSync = linearSync;
|
|
@@ -22,8 +23,22 @@ export class RunFinalizer {
|
|
|
22
23
|
this.appendWakeEventWithLease = appendWakeEventWithLease;
|
|
23
24
|
this.failRunAndClear = failRunAndClear;
|
|
24
25
|
this.completionPolicy = completionPolicy;
|
|
26
|
+
this.completionCheck = completionCheck;
|
|
25
27
|
this.feed = feed;
|
|
26
28
|
}
|
|
29
|
+
buildCompletedRunUpdate(params) {
|
|
30
|
+
return {
|
|
31
|
+
status: "completed",
|
|
32
|
+
threadId: params.threadId,
|
|
33
|
+
...(params.completedTurnId ? { turnId: params.completedTurnId } : {}),
|
|
34
|
+
summaryJson: JSON.stringify({ latestAssistantMessage: params.report.assistantMessages.at(-1) ?? null }),
|
|
35
|
+
reportJson: JSON.stringify(params.report),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
clearProgressAndRelease(run) {
|
|
39
|
+
this.linearSync.clearProgress(run.id);
|
|
40
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
41
|
+
}
|
|
27
42
|
async finalizeCompletedRun(params) {
|
|
28
43
|
const { run, issue, thread, threadId } = params;
|
|
29
44
|
const trackedIssue = this.db.issueToTrackedIssue(issue);
|
|
@@ -70,7 +85,206 @@ export class RunFinalizer {
|
|
|
70
85
|
}
|
|
71
86
|
const publishedOutcomeError = await this.completionPolicy.verifyPublishedRunOutcome(run, freshIssue);
|
|
72
87
|
if (publishedOutcomeError) {
|
|
73
|
-
this.
|
|
88
|
+
this.feed?.publish({
|
|
89
|
+
level: "info",
|
|
90
|
+
kind: "turn",
|
|
91
|
+
issueKey: freshIssue.issueKey,
|
|
92
|
+
projectId: run.projectId,
|
|
93
|
+
stage: run.runType,
|
|
94
|
+
status: "completion_check_started",
|
|
95
|
+
summary: "No PR found; checking next step",
|
|
96
|
+
detail: publishedOutcomeError,
|
|
97
|
+
});
|
|
98
|
+
void this.linearSync.emitActivity(freshIssue, buildCompletionCheckActivity("started"), { ephemeral: true });
|
|
99
|
+
let completionCheck;
|
|
100
|
+
try {
|
|
101
|
+
completionCheck = await this.completionCheck.run({
|
|
102
|
+
issue: freshIssue,
|
|
103
|
+
run,
|
|
104
|
+
noPrSummary: publishedOutcomeError,
|
|
105
|
+
onStarted: ({ threadId: completionCheckThreadId, turnId: completionCheckTurnId }) => {
|
|
106
|
+
this.db.runs.markCompletionCheckStarted(run.id, {
|
|
107
|
+
threadId: completionCheckThreadId,
|
|
108
|
+
turnId: completionCheckTurnId,
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
this.failRunAndClear(run, `No PR observed and the completion check failed: ${message}`, "failed");
|
|
116
|
+
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
117
|
+
this.feed?.publish({
|
|
118
|
+
level: "error",
|
|
119
|
+
kind: "turn",
|
|
120
|
+
issueKey: freshIssue.issueKey,
|
|
121
|
+
projectId: run.projectId,
|
|
122
|
+
stage: run.runType,
|
|
123
|
+
status: "completion_check_failed",
|
|
124
|
+
summary: "No PR found; completion check failed",
|
|
125
|
+
detail: message,
|
|
126
|
+
});
|
|
127
|
+
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, `No PR observed and the completion check failed: ${message}`));
|
|
128
|
+
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
129
|
+
this.clearProgressAndRelease(run);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const completedRunUpdate = this.buildCompletedRunUpdate({
|
|
133
|
+
threadId,
|
|
134
|
+
...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
|
|
135
|
+
report,
|
|
136
|
+
});
|
|
137
|
+
if (completionCheck.outcome === "continue") {
|
|
138
|
+
const continued = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
139
|
+
this.db.runs.finishRun(run.id, completedRunUpdate);
|
|
140
|
+
this.db.runs.saveCompletionCheck(run.id, completionCheck);
|
|
141
|
+
this.db.issues.upsertIssue({
|
|
142
|
+
projectId: run.projectId,
|
|
143
|
+
linearIssueId: run.linearIssueId,
|
|
144
|
+
activeRunId: null,
|
|
145
|
+
factoryState: "delegated",
|
|
146
|
+
pendingRunType: null,
|
|
147
|
+
pendingRunContextJson: null,
|
|
148
|
+
});
|
|
149
|
+
return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
|
|
150
|
+
projectId: run.projectId,
|
|
151
|
+
linearIssueId: run.linearIssueId,
|
|
152
|
+
eventType: "completion_check_continue",
|
|
153
|
+
eventJson: JSON.stringify({
|
|
154
|
+
runType: run.runType,
|
|
155
|
+
summary: completionCheck.summary,
|
|
156
|
+
}),
|
|
157
|
+
dedupeKey: `completion_check_continue:${run.id}`,
|
|
158
|
+
}));
|
|
159
|
+
});
|
|
160
|
+
if (!continued) {
|
|
161
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check continue writes after losing issue-session lease");
|
|
162
|
+
this.clearProgressAndRelease(run);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const continuedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
166
|
+
this.feed?.publish({
|
|
167
|
+
level: "info",
|
|
168
|
+
kind: "turn",
|
|
169
|
+
issueKey: freshIssue.issueKey,
|
|
170
|
+
projectId: run.projectId,
|
|
171
|
+
stage: run.runType,
|
|
172
|
+
status: "completion_check_continue",
|
|
173
|
+
summary: "No PR found; continuing automatically",
|
|
174
|
+
detail: completionCheck.summary,
|
|
175
|
+
});
|
|
176
|
+
void this.linearSync.emitActivity(continuedIssue, buildCompletionCheckActivity("continue"), { ephemeral: true });
|
|
177
|
+
void this.linearSync.syncSession(continuedIssue);
|
|
178
|
+
this.linearSync.clearProgress(run.id);
|
|
179
|
+
this.enqueueIssue(run.projectId, run.linearIssueId);
|
|
180
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (completionCheck.outcome === "needs_input") {
|
|
184
|
+
const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
185
|
+
this.db.runs.finishRun(run.id, completedRunUpdate);
|
|
186
|
+
this.db.runs.saveCompletionCheck(run.id, completionCheck);
|
|
187
|
+
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
188
|
+
this.db.issues.upsertIssue({
|
|
189
|
+
projectId: run.projectId,
|
|
190
|
+
linearIssueId: run.linearIssueId,
|
|
191
|
+
activeRunId: null,
|
|
192
|
+
factoryState: "awaiting_input",
|
|
193
|
+
pendingRunType: null,
|
|
194
|
+
pendingRunContextJson: null,
|
|
195
|
+
});
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
if (!completed) {
|
|
199
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check needs-input writes after losing issue-session lease");
|
|
200
|
+
this.clearProgressAndRelease(run);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const awaitingIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
204
|
+
this.feed?.publish({
|
|
205
|
+
level: "warn",
|
|
206
|
+
kind: "turn",
|
|
207
|
+
issueKey: freshIssue.issueKey,
|
|
208
|
+
projectId: run.projectId,
|
|
209
|
+
stage: run.runType,
|
|
210
|
+
status: "completion_check_needs_input",
|
|
211
|
+
summary: "No PR found; waiting for answer",
|
|
212
|
+
detail: completionCheck.question ?? completionCheck.summary,
|
|
213
|
+
});
|
|
214
|
+
void this.linearSync.emitActivity(awaitingIssue, buildCompletionCheckActivity("needs_input", completionCheck));
|
|
215
|
+
void this.linearSync.syncSession(awaitingIssue);
|
|
216
|
+
this.clearProgressAndRelease(run);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (completionCheck.outcome === "done") {
|
|
220
|
+
const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
|
|
221
|
+
this.db.runs.finishRun(run.id, completedRunUpdate);
|
|
222
|
+
this.db.runs.saveCompletionCheck(run.id, completionCheck);
|
|
223
|
+
this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
|
|
224
|
+
this.db.issues.upsertIssue({
|
|
225
|
+
projectId: run.projectId,
|
|
226
|
+
linearIssueId: run.linearIssueId,
|
|
227
|
+
activeRunId: null,
|
|
228
|
+
factoryState: "done",
|
|
229
|
+
pendingRunType: null,
|
|
230
|
+
pendingRunContextJson: null,
|
|
231
|
+
lastGitHubFailureSource: null,
|
|
232
|
+
lastGitHubFailureHeadSha: null,
|
|
233
|
+
lastGitHubFailureSignature: null,
|
|
234
|
+
lastGitHubFailureCheckName: null,
|
|
235
|
+
lastGitHubFailureCheckUrl: null,
|
|
236
|
+
lastGitHubFailureContextJson: null,
|
|
237
|
+
lastGitHubFailureAt: null,
|
|
238
|
+
lastQueueIncidentJson: null,
|
|
239
|
+
lastAttemptedFailureHeadSha: null,
|
|
240
|
+
lastAttemptedFailureSignature: null,
|
|
241
|
+
});
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
if (!completed) {
|
|
245
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check done writes after losing issue-session lease");
|
|
246
|
+
this.clearProgressAndRelease(run);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
250
|
+
this.feed?.publish({
|
|
251
|
+
level: "info",
|
|
252
|
+
kind: "turn",
|
|
253
|
+
issueKey: freshIssue.issueKey,
|
|
254
|
+
projectId: run.projectId,
|
|
255
|
+
stage: run.runType,
|
|
256
|
+
status: "completion_check_done",
|
|
257
|
+
summary: "No PR found; confirmed done",
|
|
258
|
+
detail: completionCheck.summary,
|
|
259
|
+
});
|
|
260
|
+
void this.linearSync.emitActivity(doneIssue, buildCompletionCheckActivity("done", completionCheck));
|
|
261
|
+
void this.linearSync.syncSession(doneIssue);
|
|
262
|
+
this.clearProgressAndRelease(run);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
|
|
266
|
+
const failed = this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
267
|
+
this.db.runs.finishRun(run.id, {
|
|
268
|
+
...completedRunUpdate,
|
|
269
|
+
status: "failed",
|
|
270
|
+
failureReason,
|
|
271
|
+
});
|
|
272
|
+
this.db.runs.saveCompletionCheck(run.id, completionCheck);
|
|
273
|
+
this.db.issues.upsertIssue({
|
|
274
|
+
projectId: run.projectId,
|
|
275
|
+
linearIssueId: run.linearIssueId,
|
|
276
|
+
activeRunId: null,
|
|
277
|
+
factoryState: "failed",
|
|
278
|
+
pendingRunType: null,
|
|
279
|
+
pendingRunContextJson: null,
|
|
280
|
+
});
|
|
281
|
+
return true;
|
|
282
|
+
});
|
|
283
|
+
if (!failed) {
|
|
284
|
+
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check failed writes after losing issue-session lease");
|
|
285
|
+
this.clearProgressAndRelease(run);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
74
288
|
const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
|
|
75
289
|
this.feed?.publish({
|
|
76
290
|
level: "warn",
|
|
@@ -78,15 +292,13 @@ export class RunFinalizer {
|
|
|
78
292
|
issueKey: freshIssue.issueKey,
|
|
79
293
|
projectId: run.projectId,
|
|
80
294
|
stage: run.runType,
|
|
81
|
-
status: "
|
|
82
|
-
summary:
|
|
295
|
+
status: "completion_check_failed",
|
|
296
|
+
summary: "No PR found; completion check failed",
|
|
297
|
+
detail: completionCheck.summary,
|
|
83
298
|
});
|
|
84
|
-
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType,
|
|
299
|
+
void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, failureReason));
|
|
85
300
|
void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
|
|
86
|
-
this.
|
|
87
|
-
if (params.source === "notification") {
|
|
88
|
-
this.releaseLease(run.projectId, run.linearIssueId);
|
|
89
|
-
}
|
|
301
|
+
this.clearProgressAndRelease(run);
|
|
90
302
|
return;
|
|
91
303
|
}
|
|
92
304
|
const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { summarizeCurrentThread } from "./run-reporting.js";
|
|
2
2
|
import { buildRunStartedActivity, } from "./linear-session-reporting.js";
|
|
3
|
+
import { CompletionCheckService } from "./completion-check.js";
|
|
3
4
|
import { WorktreeManager } from "./worktree-manager.js";
|
|
4
|
-
import {
|
|
5
|
+
import { MergedLinearCompletionReconciler } from "./merged-linear-completion-reconciler.js";
|
|
5
6
|
import { QueueHealthMonitor } from "./queue-health-monitor.js";
|
|
6
7
|
import { IdleIssueReconciler, resolveBranchOwnerForStateTransition } from "./idle-reconciliation.js";
|
|
7
8
|
import { LinearSessionSync } from "./linear-session-sync.js";
|
|
@@ -49,8 +50,10 @@ export class RunOrchestrator {
|
|
|
49
50
|
runWakePlanner;
|
|
50
51
|
interruptedRunRecovery;
|
|
51
52
|
runCompletionPolicy;
|
|
53
|
+
completionCheck;
|
|
52
54
|
runNotificationHandler;
|
|
53
55
|
runReconciler;
|
|
56
|
+
mergedLinearCompletionReconciler;
|
|
54
57
|
activeSessionLeases;
|
|
55
58
|
botIdentity;
|
|
56
59
|
constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
|
|
@@ -66,7 +69,8 @@ export class RunOrchestrator {
|
|
|
66
69
|
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries));
|
|
67
70
|
this.activeSessionLeases = this.leaseService.activeSessionLeases;
|
|
68
71
|
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn));
|
|
69
|
-
this.
|
|
72
|
+
this.completionCheck = new CompletionCheckService(codex, logger);
|
|
73
|
+
this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (run, message, nextState) => this.failRunAndClear(run, message, nextState), this.runCompletionPolicy, this.completionCheck, feed);
|
|
70
74
|
this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
|
|
71
75
|
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), feed);
|
|
72
76
|
this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (projectId, issueId) => this.enqueueIssue(projectId, issueId), (newState, pendingRunType) => this.resolveBranchOwnerForStateTransition(newState, pendingRunType), feed);
|
|
@@ -76,6 +80,7 @@ export class RunOrchestrator {
|
|
|
76
80
|
this.idleReconciler = new IdleIssueReconciler(db, config, {
|
|
77
81
|
enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
|
|
78
82
|
}, logger, feed);
|
|
83
|
+
this.mergedLinearCompletionReconciler = new MergedLinearCompletionReconciler(db, linearProvider, logger);
|
|
79
84
|
this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
|
|
80
85
|
advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
|
|
81
86
|
enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
|
|
@@ -251,44 +256,7 @@ export class RunOrchestrator {
|
|
|
251
256
|
// Advance issues stuck in pr_open whose stored PR metadata already
|
|
252
257
|
// shows they should transition (e.g. approved PR, missed webhook).
|
|
253
258
|
await this.idleReconciler.reconcile();
|
|
254
|
-
await this.
|
|
255
|
-
}
|
|
256
|
-
async reconcileMergedLinearCompletion() {
|
|
257
|
-
for (const issue of this.db.issues.listIssues()) {
|
|
258
|
-
if (issue.prState !== "merged")
|
|
259
|
-
continue;
|
|
260
|
-
if (issue.currentLinearStateType?.trim().toLowerCase() === "completed")
|
|
261
|
-
continue;
|
|
262
|
-
const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
|
|
263
|
-
if (!linear)
|
|
264
|
-
continue;
|
|
265
|
-
try {
|
|
266
|
-
const liveIssue = await linear.getIssue(issue.linearIssueId);
|
|
267
|
-
const targetState = resolvePreferredCompletedLinearState(liveIssue);
|
|
268
|
-
if (!targetState)
|
|
269
|
-
continue;
|
|
270
|
-
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
271
|
-
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
272
|
-
this.db.issues.upsertIssue({
|
|
273
|
-
projectId: issue.projectId,
|
|
274
|
-
linearIssueId: issue.linearIssueId,
|
|
275
|
-
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
276
|
-
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
277
|
-
});
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
281
|
-
this.db.issues.upsertIssue({
|
|
282
|
-
projectId: issue.projectId,
|
|
283
|
-
linearIssueId: issue.linearIssueId,
|
|
284
|
-
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
285
|
-
...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
catch (error) {
|
|
289
|
-
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged issue to a completed Linear state");
|
|
290
|
-
}
|
|
291
|
-
}
|
|
259
|
+
await this.mergedLinearCompletionReconciler.reconcile();
|
|
292
260
|
}
|
|
293
261
|
// advanceIdleIssue is now on IdleIssueReconciler — delegate for internal callers
|
|
294
262
|
advanceIdleIssue(issue, newState, options) {
|
package/dist/service.js
CHANGED
|
@@ -371,10 +371,18 @@ export class PatchRelayService {
|
|
|
371
371
|
i.last_github_failure_check_name,
|
|
372
372
|
i.last_github_failure_context_json,
|
|
373
373
|
active_run.run_type AS active_run_type,
|
|
374
|
+
active_run.completion_check_thread_id AS active_completion_check_thread_id,
|
|
375
|
+
active_run.completion_check_outcome AS active_completion_check_outcome,
|
|
374
376
|
latest_run.run_type AS latest_run_type,
|
|
375
377
|
latest_run.status AS latest_run_status,
|
|
376
378
|
latest_run.summary_json AS latest_run_summary_json,
|
|
377
379
|
latest_run.report_json AS latest_run_report_json,
|
|
380
|
+
latest_run.completion_check_thread_id AS latest_run_completion_check_thread_id,
|
|
381
|
+
latest_run.completion_check_outcome AS latest_run_completion_check_outcome,
|
|
382
|
+
latest_run.completion_check_summary AS latest_run_completion_check_summary,
|
|
383
|
+
latest_run.completion_check_question AS latest_run_completion_check_question,
|
|
384
|
+
latest_run.completion_check_why AS latest_run_completion_check_why,
|
|
385
|
+
latest_run.completion_check_recommended_reply AS latest_run_completion_check_recommended_reply,
|
|
378
386
|
(
|
|
379
387
|
SELECT COUNT(*)
|
|
380
388
|
FROM issue_session_events e
|
|
@@ -470,6 +478,12 @@ export class PatchRelayService {
|
|
|
470
478
|
status: String(row.latest_run_status),
|
|
471
479
|
...(typeof row.latest_run_summary_json === "string" ? { summaryJson: row.latest_run_summary_json } : {}),
|
|
472
480
|
...(typeof row.latest_run_report_json === "string" ? { reportJson: row.latest_run_report_json } : {}),
|
|
481
|
+
...(typeof row.latest_run_completion_check_thread_id === "string" ? { completionCheckThreadId: row.latest_run_completion_check_thread_id } : {}),
|
|
482
|
+
...(typeof row.latest_run_completion_check_outcome === "string" ? { completionCheckOutcome: row.latest_run_completion_check_outcome } : {}),
|
|
483
|
+
...(typeof row.latest_run_completion_check_summary === "string" ? { completionCheckSummary: row.latest_run_completion_check_summary } : {}),
|
|
484
|
+
...(typeof row.latest_run_completion_check_question === "string" ? { completionCheckQuestion: row.latest_run_completion_check_question } : {}),
|
|
485
|
+
...(typeof row.latest_run_completion_check_why === "string" ? { completionCheckWhy: row.latest_run_completion_check_why } : {}),
|
|
486
|
+
...(typeof row.latest_run_completion_check_recommended_reply === "string" ? { completionCheckRecommendedReply: row.latest_run_completion_check_recommended_reply } : {}),
|
|
473
487
|
startedAt: String(row.updated_at),
|
|
474
488
|
}
|
|
475
489
|
: undefined;
|
|
@@ -490,6 +504,10 @@ export class PatchRelayService {
|
|
|
490
504
|
})
|
|
491
505
|
? undefined
|
|
492
506
|
: statusNoteCandidate;
|
|
507
|
+
const completionCheckActive = typeof row.active_completion_check_thread_id === "string"
|
|
508
|
+
&& row.active_completion_check_thread_id.length > 0
|
|
509
|
+
&& row.active_completion_check_outcome === null
|
|
510
|
+
&& row.active_run_type !== null;
|
|
493
511
|
return {
|
|
494
512
|
...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
|
|
495
513
|
...(row.title !== null ? { title: String(row.title) } : {}),
|
|
@@ -515,6 +533,7 @@ export class PatchRelayService {
|
|
|
515
533
|
...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
|
|
516
534
|
...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
|
|
517
535
|
...(waitingReason ? { waitingReason } : {}),
|
|
536
|
+
...(completionCheckActive ? { completionCheckActive } : {}),
|
|
518
537
|
updatedAt: String(row.updated_at),
|
|
519
538
|
};
|
|
520
539
|
});
|