patchrelay 0.73.3 → 0.73.4
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 +11 -2
- package/dist/build-info.json +3 -3
- package/dist/git-worktree-status.js +69 -0
- package/dist/prompting/patchrelay.js +4 -0
- package/dist/run-finalizer.js +24 -0
- package/dist/status-note.js +18 -1
- package/dist/webhooks/agent-session-handler.js +15 -2
- package/dist/webhooks/desired-stage-recorder.js +22 -5
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildPromptDeliveryFailedActivity, buildPromptDeliveredThought, } from "./linear-session-reporting.js";
|
|
2
2
|
import { deriveIssueStatusNote } from "./status-note.js";
|
|
3
3
|
import { extractLatestAssistantSummary } from "./issue-session-events.js";
|
|
4
|
+
import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "./git-worktree-status.js";
|
|
4
5
|
export class AgentInputService {
|
|
5
6
|
db;
|
|
6
7
|
codex;
|
|
@@ -211,6 +212,9 @@ export class AgentInputService {
|
|
|
211
212
|
}) ?? issue;
|
|
212
213
|
}
|
|
213
214
|
async stopActiveRun(issue, run, body, source) {
|
|
215
|
+
const worktreeStatus = issue.worktreePath ? inspectGitWorktreeStatus(issue.worktreePath) : undefined;
|
|
216
|
+
const dirtyPayload = worktreeStatus ? dirtyWorktreeEventPayload(worktreeStatus) : undefined;
|
|
217
|
+
const dirtySummary = typeof dirtyPayload?.summary === "string" ? dirtyPayload.summary : undefined;
|
|
214
218
|
if (run.threadId && run.turnId) {
|
|
215
219
|
try {
|
|
216
220
|
await this.codex.steerTurn({
|
|
@@ -222,7 +226,12 @@ export class AgentInputService {
|
|
|
222
226
|
catch (error) {
|
|
223
227
|
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop request");
|
|
224
228
|
}
|
|
225
|
-
this.db.runs.finishRun(run.id, {
|
|
229
|
+
this.db.runs.finishRun(run.id, {
|
|
230
|
+
status: "released",
|
|
231
|
+
threadId: run.threadId,
|
|
232
|
+
turnId: run.turnId,
|
|
233
|
+
failureReason: dirtySummary ? `Operator stopped run; ${dirtySummary}` : "Operator stopped run",
|
|
234
|
+
});
|
|
226
235
|
}
|
|
227
236
|
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
228
237
|
projectId: issue.projectId,
|
|
@@ -232,7 +241,7 @@ export class AgentInputService {
|
|
|
232
241
|
});
|
|
233
242
|
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
234
243
|
eventType: "stop_requested",
|
|
235
|
-
eventJson: JSON.stringify({ body, source }),
|
|
244
|
+
eventJson: JSON.stringify({ body, source, ...dirtyPayload }),
|
|
236
245
|
});
|
|
237
246
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
238
247
|
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
package/dist/build-info.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const UNMERGED_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
|
3
|
+
function parsePorcelainPath(line) {
|
|
4
|
+
const raw = line.slice(3).trim();
|
|
5
|
+
const renameSeparator = " -> ";
|
|
6
|
+
const renamed = raw.includes(renameSeparator) ? raw.slice(raw.indexOf(renameSeparator) + renameSeparator.length) : raw;
|
|
7
|
+
return renamed.replace(/^"|"$/g, "");
|
|
8
|
+
}
|
|
9
|
+
function hasGitPath(worktreePath, pathName) {
|
|
10
|
+
const result = spawnSync("git", ["-C", worktreePath, "rev-parse", "--git-path", pathName], { encoding: "utf8" });
|
|
11
|
+
if (result.status !== 0)
|
|
12
|
+
return false;
|
|
13
|
+
const resolved = result.stdout.trim();
|
|
14
|
+
if (!resolved)
|
|
15
|
+
return false;
|
|
16
|
+
return spawnSync("test", ["-f", resolved]).status === 0;
|
|
17
|
+
}
|
|
18
|
+
export function inspectGitWorktreeStatus(worktreePath) {
|
|
19
|
+
const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain=v1", "-uall"], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
});
|
|
22
|
+
if (result.status !== 0) {
|
|
23
|
+
return {
|
|
24
|
+
dirty: true,
|
|
25
|
+
mergeInProgress: false,
|
|
26
|
+
unmergedPaths: [],
|
|
27
|
+
changedPaths: [],
|
|
28
|
+
summary: `Unable to inspect worktree: ${(result.stderr || result.stdout).trim() || "git status failed"}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const lines = result.stdout.split(/\r?\n/).filter(Boolean);
|
|
32
|
+
const changedPaths = lines.map(parsePorcelainPath);
|
|
33
|
+
const unmergedPaths = lines
|
|
34
|
+
.filter((line) => UNMERGED_CODES.has(line.slice(0, 2)) || line.slice(0, 2).includes("U"))
|
|
35
|
+
.map(parsePorcelainPath);
|
|
36
|
+
const mergeInProgress = hasGitPath(worktreePath, "MERGE_HEAD")
|
|
37
|
+
|| hasGitPath(worktreePath, "REBASE_HEAD")
|
|
38
|
+
|| hasGitPath(worktreePath, "CHERRY_PICK_HEAD")
|
|
39
|
+
|| hasGitPath(worktreePath, "REVERT_HEAD");
|
|
40
|
+
const dirty = lines.length > 0 || mergeInProgress;
|
|
41
|
+
const summary = dirty
|
|
42
|
+
? unmergedPaths.length > 0
|
|
43
|
+
? `Worktree has unresolved merge conflicts: ${unmergedPaths.join(", ")}`
|
|
44
|
+
: changedPaths.length > 0
|
|
45
|
+
? `Worktree has uncommitted changes: ${changedPaths.slice(0, 12).join(", ")}${changedPaths.length > 12 ? ", ..." : ""}`
|
|
46
|
+
: "Worktree has an unfinished git operation"
|
|
47
|
+
: undefined;
|
|
48
|
+
return {
|
|
49
|
+
dirty,
|
|
50
|
+
mergeInProgress,
|
|
51
|
+
unmergedPaths,
|
|
52
|
+
changedPaths,
|
|
53
|
+
...(summary ? { summary } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function isRepairRunType(runType) {
|
|
57
|
+
return runType === "review_fix" || runType === "branch_upkeep" || runType === "ci_repair" || runType === "queue_repair";
|
|
58
|
+
}
|
|
59
|
+
export function dirtyWorktreeEventPayload(status) {
|
|
60
|
+
if (!status.dirty)
|
|
61
|
+
return undefined;
|
|
62
|
+
return {
|
|
63
|
+
dirtyWorktree: true,
|
|
64
|
+
mergeInProgress: status.mergeInProgress,
|
|
65
|
+
unmergedPaths: status.unmergedPaths,
|
|
66
|
+
changedPaths: status.changedPaths,
|
|
67
|
+
summary: status.summary,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -563,6 +563,10 @@ function buildPublicationContract(runType, issueClass, context) {
|
|
|
563
563
|
"Restore and publish on the existing PR branch: commit and push the same branch.",
|
|
564
564
|
"Do not open a new PR.",
|
|
565
565
|
"A PR-less stop is not a successful outcome for a repair run unless a genuine external blocker prevents any correct push.",
|
|
566
|
+
"After pushing a new head, stop and report the pushed commit. Do not poll or watch GitHub for CI, review, mergeability, review-quill, merge-steward, approval, or merge completion.",
|
|
567
|
+
"Do not run blocking wait commands such as `gh pr checks --watch`, `gh pr view` polling loops, `review-quill pr status --wait`, `merge-steward pr status --wait`, or `gh pr merge` from the agent turn.",
|
|
568
|
+
"PatchRelay receives GitHub webhooks for check, review, and base-branch changes; those events will re-enter automation if more work is needed.",
|
|
569
|
+
"If the issue text asks you to watch CI, wait for approval, or merge after checks pass, treat that as PatchRelay service responsibility rather than agent-turn work.",
|
|
566
570
|
"",
|
|
567
571
|
...(requiresFreshQueueHead
|
|
568
572
|
? [
|
package/dist/run-finalizer.js
CHANGED
|
@@ -3,6 +3,7 @@ import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-ses
|
|
|
3
3
|
import { handleNoPrCompletionCheck } from "./no-pr-completion-check.js";
|
|
4
4
|
import { resolveCompletedRunState } from "./run-completion-policy.js";
|
|
5
5
|
import { computeChangeIdentityFromWorktree } from "./change-identity.js";
|
|
6
|
+
import { inspectGitWorktreeStatus, isRepairRunType } from "./git-worktree-status.js";
|
|
6
7
|
function parseEventJson(eventJson) {
|
|
7
8
|
if (!eventJson)
|
|
8
9
|
return undefined;
|
|
@@ -273,6 +274,16 @@ export class RunFinalizer {
|
|
|
273
274
|
// exists. Keeping the parameter would be redundant.
|
|
274
275
|
this.clearProgressAndRelease(params.run);
|
|
275
276
|
}
|
|
277
|
+
verifyRepairWorktreeClean(run, issue) {
|
|
278
|
+
if (!isRepairRunType(run.runType) || !issue.worktreePath)
|
|
279
|
+
return undefined;
|
|
280
|
+
const status = inspectGitWorktreeStatus(issue.worktreePath);
|
|
281
|
+
if (!status.dirty)
|
|
282
|
+
return undefined;
|
|
283
|
+
return status.summary
|
|
284
|
+
? `Repair run finished with a dirty worktree; ${status.summary}`
|
|
285
|
+
: "Repair run finished with a dirty worktree";
|
|
286
|
+
}
|
|
276
287
|
async finalizeCompletedRun(params) {
|
|
277
288
|
const { run, issue, thread, threadId } = params;
|
|
278
289
|
// Plan §4.4: a run flagged shouldNotPublish was deliberately
|
|
@@ -291,6 +302,19 @@ export class RunFinalizer {
|
|
|
291
302
|
const trackedIssue = this.db.issueToTrackedIssue(issue);
|
|
292
303
|
const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.runs.listThreadEvents(run.id)));
|
|
293
304
|
const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
305
|
+
const dirtyRepairWorktreeError = this.verifyRepairWorktreeClean(run, freshIssue);
|
|
306
|
+
if (dirtyRepairWorktreeError) {
|
|
307
|
+
this.failRunAndClear(run, dirtyRepairWorktreeError, "escalated");
|
|
308
|
+
this.syncFailureOutcome({
|
|
309
|
+
run,
|
|
310
|
+
fallbackIssue: freshIssue,
|
|
311
|
+
message: dirtyRepairWorktreeError,
|
|
312
|
+
level: "error",
|
|
313
|
+
status: "dirty_repair_worktree",
|
|
314
|
+
summary: dirtyRepairWorktreeError,
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
294
318
|
const verifiedRepairError = await this.completionPolicy.verifyReactiveRunAdvancedBranch(run, freshIssue);
|
|
295
319
|
if (verifiedRepairError) {
|
|
296
320
|
const holdState = params.resolveRecoverableRunState(freshIssue) ?? "failed";
|
package/dist/status-note.js
CHANGED
|
@@ -7,10 +7,18 @@ function clean(value) {
|
|
|
7
7
|
function eventStatusNote(event) {
|
|
8
8
|
if (!event)
|
|
9
9
|
return undefined;
|
|
10
|
+
const payload = event.eventJson ? parseEventJson(event.eventJson) : undefined;
|
|
11
|
+
const dirtySummary = typeof payload?.summary === "string" && payload.dirtyWorktree === true
|
|
12
|
+
? payload.summary
|
|
13
|
+
: undefined;
|
|
10
14
|
switch (event.eventType) {
|
|
11
15
|
case "stop_requested":
|
|
16
|
+
if (dirtySummary)
|
|
17
|
+
return `Operator stopped the run with dirty worktree: ${dirtySummary}. Use retry or delegate again to resume.`;
|
|
12
18
|
return "Operator stopped the run. Use retry or delegate again to resume.";
|
|
13
19
|
case "undelegated":
|
|
20
|
+
if (dirtySummary)
|
|
21
|
+
return `Issue was un-delegated from PatchRelay with dirty worktree: ${dirtySummary}`;
|
|
14
22
|
return "Issue was un-delegated from PatchRelay. Delegate it again to resume.";
|
|
15
23
|
case "issue_removed":
|
|
16
24
|
return "Issue was removed from Linear.";
|
|
@@ -22,6 +30,15 @@ function eventStatusNote(event) {
|
|
|
22
30
|
return undefined;
|
|
23
31
|
}
|
|
24
32
|
}
|
|
33
|
+
function parseEventJson(value) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(value);
|
|
36
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
25
42
|
export function deriveIssueStatusNote(params) {
|
|
26
43
|
const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
|
|
27
44
|
if (blockedByKeys.length > 0) {
|
|
@@ -61,7 +78,7 @@ export function deriveIssueStatusNote(params) {
|
|
|
61
78
|
note = failureSummary ?? sessionSummary ?? latestRunNote;
|
|
62
79
|
break;
|
|
63
80
|
default:
|
|
64
|
-
note = sessionSummary ?? latestRunNote ?? failureSummary;
|
|
81
|
+
note = latestEventNote ?? sessionSummary ?? latestRunNote ?? failureSummary;
|
|
65
82
|
break;
|
|
66
83
|
}
|
|
67
84
|
}
|
|
@@ -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 { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
|
|
4
|
+
import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
|
|
4
5
|
import { resolveProject, triggerEventAllowed } from "../project-resolution.js";
|
|
5
6
|
const PATCHRELAY_AGENT_ACTIVITY_TYPES = new Set([
|
|
6
7
|
"action",
|
|
@@ -154,6 +155,12 @@ export class AgentSessionHandler {
|
|
|
154
155
|
async handleStopSignal(params) {
|
|
155
156
|
const issueId = params.normalized.issue.id;
|
|
156
157
|
const sessionId = params.normalized.agentSession.id;
|
|
158
|
+
const storedIssue = this.db.issues.getIssue(params.project.id, issueId);
|
|
159
|
+
const worktreeStatus = storedIssue?.worktreePath
|
|
160
|
+
? inspectGitWorktreeStatus(storedIssue.worktreePath)
|
|
161
|
+
: undefined;
|
|
162
|
+
const dirtyPayload = worktreeStatus ? dirtyWorktreeEventPayload(worktreeStatus) : undefined;
|
|
163
|
+
const dirtySummary = typeof dirtyPayload?.summary === "string" ? dirtyPayload.summary : undefined;
|
|
157
164
|
if (params.activeRun?.threadId && params.activeRun.turnId) {
|
|
158
165
|
try {
|
|
159
166
|
await this.codex.steerTurn({
|
|
@@ -165,7 +172,12 @@ export class AgentSessionHandler {
|
|
|
165
172
|
catch (error) {
|
|
166
173
|
this.logger.warn({ issueKey: params.trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
|
|
167
174
|
}
|
|
168
|
-
this.db.runs.finishRun(params.activeRun.id, {
|
|
175
|
+
this.db.runs.finishRun(params.activeRun.id, {
|
|
176
|
+
status: "released",
|
|
177
|
+
threadId: params.activeRun.threadId,
|
|
178
|
+
turnId: params.activeRun.turnId,
|
|
179
|
+
failureReason: dirtySummary ? `Stop signal received; ${dirtySummary}` : "Stop signal received",
|
|
180
|
+
});
|
|
169
181
|
}
|
|
170
182
|
this.db.issueSessions.upsertIssueRespectingActiveLease(params.project.id, issueId, {
|
|
171
183
|
projectId: params.project.id,
|
|
@@ -178,6 +190,7 @@ export class AgentSessionHandler {
|
|
|
178
190
|
projectId: params.project.id,
|
|
179
191
|
linearIssueId: issueId,
|
|
180
192
|
eventType: "stop_requested",
|
|
193
|
+
...(dirtyPayload ? { eventJson: JSON.stringify(dirtyPayload) } : {}),
|
|
181
194
|
dedupeKey: `stop_requested:${issueId}`,
|
|
182
195
|
});
|
|
183
196
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, issueId);
|
|
@@ -188,7 +201,7 @@ export class AgentSessionHandler {
|
|
|
188
201
|
projectId: params.project.id,
|
|
189
202
|
issueKey: params.trackedIssue?.issueKey,
|
|
190
203
|
status: "stopped",
|
|
191
|
-
summary: "Stop signal received - work halted",
|
|
204
|
+
summary: dirtySummary ? `Stop signal received - work halted with dirty worktree: ${dirtySummary}` : "Stop signal received - work halted",
|
|
192
205
|
});
|
|
193
206
|
const updatedIssue = this.db.issues.getIssue(params.project.id, issueId);
|
|
194
207
|
await this.publishAgentActivity(params.linear, sessionId, buildStopConfirmationActivity());
|
|
@@ -6,6 +6,7 @@ import { syncIssueDependencies } from "./issue-dependency-sync.js";
|
|
|
6
6
|
import { resolveLinkedPrAdoption } from "./linked-pr-adoption.js";
|
|
7
7
|
import { buildOperatorRetryEvent } from "../operator-retry-event.js";
|
|
8
8
|
import { planIssueWebhookWorkflow } from "./issue-webhook-workflow-planner.js";
|
|
9
|
+
import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
|
|
9
10
|
export class DesiredStageRecorder {
|
|
10
11
|
db;
|
|
11
12
|
linearProvider;
|
|
@@ -74,6 +75,15 @@ export class DesiredStageRecorder {
|
|
|
74
75
|
incomingAgentSessionId,
|
|
75
76
|
childIssueCount,
|
|
76
77
|
});
|
|
78
|
+
const releaseWorktreeStatus = workflowPlan.effectiveRunRelease.release && activeRun && existingIssue?.worktreePath
|
|
79
|
+
? inspectGitWorktreeStatus(existingIssue.worktreePath)
|
|
80
|
+
: undefined;
|
|
81
|
+
const releaseReason = workflowPlan.effectiveRunRelease.reason
|
|
82
|
+
? releaseWorktreeStatus?.dirty && releaseWorktreeStatus.summary
|
|
83
|
+
? `${workflowPlan.effectiveRunRelease.reason}; ${releaseWorktreeStatus.summary}`
|
|
84
|
+
: workflowPlan.effectiveRunRelease.reason
|
|
85
|
+
: undefined;
|
|
86
|
+
const dirtyWorktreePayload = releaseWorktreeStatus ? dirtyWorktreeEventPayload(releaseWorktreeStatus) : undefined;
|
|
77
87
|
const commitIssueUpdate = () => {
|
|
78
88
|
const record = this.db.issues.upsertIssue({
|
|
79
89
|
projectId: params.project.id,
|
|
@@ -94,8 +104,8 @@ export class DesiredStageRecorder {
|
|
|
94
104
|
delegatedToPatchRelay: delegated,
|
|
95
105
|
...workflowPlan.resolvedIssueUpdate,
|
|
96
106
|
});
|
|
97
|
-
if (workflowPlan.effectiveRunRelease.release && activeRun &&
|
|
98
|
-
this.db.runs.finishRun(activeRun.id, { status: "released", failureReason:
|
|
107
|
+
if (workflowPlan.effectiveRunRelease.release && activeRun && releaseReason) {
|
|
108
|
+
this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: releaseReason });
|
|
99
109
|
}
|
|
100
110
|
return record;
|
|
101
111
|
};
|
|
@@ -119,6 +129,11 @@ export class DesiredStageRecorder {
|
|
|
119
129
|
projectId: params.project.id,
|
|
120
130
|
linearIssueId: normalizedIssue.id,
|
|
121
131
|
eventType: "undelegated",
|
|
132
|
+
...(dirtyWorktreePayload
|
|
133
|
+
? {
|
|
134
|
+
eventJson: JSON.stringify(dirtyWorktreePayload),
|
|
135
|
+
}
|
|
136
|
+
: {}),
|
|
122
137
|
dedupeKey: `undelegated:${normalizedIssue.id}`,
|
|
123
138
|
});
|
|
124
139
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, normalizedIssue.id);
|
|
@@ -130,9 +145,11 @@ export class DesiredStageRecorder {
|
|
|
130
145
|
projectId: params.project.id,
|
|
131
146
|
stage: issue.factoryState,
|
|
132
147
|
status: "un_delegated",
|
|
133
|
-
summary:
|
|
134
|
-
?
|
|
135
|
-
:
|
|
148
|
+
summary: releaseWorktreeStatus?.dirty && releaseWorktreeStatus.summary
|
|
149
|
+
? `Issue un-delegated from PatchRelay with dirty worktree: ${releaseWorktreeStatus.summary}`
|
|
150
|
+
: issue.factoryState === "awaiting_input"
|
|
151
|
+
? "Issue un-delegated from PatchRelay"
|
|
152
|
+
: `Issue un-delegated from PatchRelay; ${issue.factoryState} is now paused`,
|
|
136
153
|
});
|
|
137
154
|
}
|
|
138
155
|
else if (workflowPlan.blockerPausedImplementation) {
|