@paperclipai/server 2026.910.0-canary.1 → 2026.910.0-canary.2

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.
Files changed (54) hide show
  1. package/dist/build-info.json +1 -1
  2. package/dist/modules/wake-queue/adapters/postgres.d.ts +9 -0
  3. package/dist/modules/wake-queue/adapters/postgres.d.ts.map +1 -0
  4. package/dist/modules/wake-queue/adapters/postgres.js +622 -0
  5. package/dist/modules/wake-queue/adapters/postgres.js.map +1 -0
  6. package/dist/modules/wake-queue/adapters/postgres.test.d.ts +2 -0
  7. package/dist/modules/wake-queue/adapters/postgres.test.d.ts.map +1 -0
  8. package/dist/modules/wake-queue/adapters/postgres.test.js +361 -0
  9. package/dist/modules/wake-queue/adapters/postgres.test.js.map +1 -0
  10. package/dist/modules/wake-queue/application/ports.d.ts +269 -0
  11. package/dist/modules/wake-queue/application/ports.d.ts.map +1 -0
  12. package/dist/modules/wake-queue/application/ports.js +2 -0
  13. package/dist/modules/wake-queue/application/ports.js.map +1 -0
  14. package/dist/modules/wake-queue/application/types.d.ts +91 -0
  15. package/dist/modules/wake-queue/application/types.d.ts.map +1 -0
  16. package/dist/modules/wake-queue/application/types.js +11 -0
  17. package/dist/modules/wake-queue/application/types.js.map +1 -0
  18. package/dist/modules/wake-queue/application/use-cases.d.ts +16 -0
  19. package/dist/modules/wake-queue/application/use-cases.d.ts.map +1 -0
  20. package/dist/modules/wake-queue/application/use-cases.js +413 -0
  21. package/dist/modules/wake-queue/application/use-cases.js.map +1 -0
  22. package/dist/modules/wake-queue/application/use-cases.test.d.ts +2 -0
  23. package/dist/modules/wake-queue/application/use-cases.test.d.ts.map +1 -0
  24. package/dist/modules/wake-queue/application/use-cases.test.js +281 -0
  25. package/dist/modules/wake-queue/application/use-cases.test.js.map +1 -0
  26. package/dist/modules/wake-queue/domain/context.d.ts +21 -0
  27. package/dist/modules/wake-queue/domain/context.d.ts.map +1 -0
  28. package/dist/modules/wake-queue/domain/context.js +135 -0
  29. package/dist/modules/wake-queue/domain/context.js.map +1 -0
  30. package/dist/modules/wake-queue/domain/context.test.d.ts +2 -0
  31. package/dist/modules/wake-queue/domain/context.test.d.ts.map +1 -0
  32. package/dist/modules/wake-queue/domain/context.test.js +113 -0
  33. package/dist/modules/wake-queue/domain/context.test.js.map +1 -0
  34. package/dist/modules/wake-queue/domain/policy.d.ts +110 -0
  35. package/dist/modules/wake-queue/domain/policy.d.ts.map +1 -0
  36. package/dist/modules/wake-queue/domain/policy.js +94 -0
  37. package/dist/modules/wake-queue/domain/policy.js.map +1 -0
  38. package/dist/modules/wake-queue/domain/policy.test.d.ts +2 -0
  39. package/dist/modules/wake-queue/domain/policy.test.d.ts.map +1 -0
  40. package/dist/modules/wake-queue/domain/policy.test.js +335 -0
  41. package/dist/modules/wake-queue/domain/policy.test.js.map +1 -0
  42. package/dist/modules/wake-queue/index.d.ts +31 -0
  43. package/dist/modules/wake-queue/index.d.ts.map +1 -0
  44. package/dist/modules/wake-queue/index.js +21 -0
  45. package/dist/modules/wake-queue/index.js.map +1 -0
  46. package/dist/services/heartbeat.d.ts.map +1 -1
  47. package/dist/services/heartbeat.js +142 -785
  48. package/dist/services/heartbeat.js.map +1 -1
  49. package/dist/services/issues.d.ts +139 -0
  50. package/dist/services/issues.d.ts.map +1 -1
  51. package/dist/services/issues.js +21 -5
  52. package/dist/services/issues.js.map +1 -1
  53. package/dist/services/productivity-review.d.ts +1 -1
  54. package/package.json +18 -18
@@ -1,3 +1,3 @@
1
1
  {
2
- "commit": "018ca5daa"
2
+ "commit": "6dd48cad4"
3
3
  }
@@ -0,0 +1,9 @@
1
+ import type { Db } from "@paperclipai/db";
2
+ import type { IssueLockWriter, WakeQueueReader } from "../application/ports.js";
3
+ export type WakeQueuePostgresAdapterDeps = {
4
+ resolveResponsibleUserId: WakeQueueReader["resolveResponsibleUserId"];
5
+ getRoutineEnv: WakeQueueReader["getRoutineEnv"];
6
+ resolveSessionBeforeForWakeup: WakeQueueReader["resolveSessionBeforeForWakeup"];
7
+ };
8
+ export declare function createPostgresWakeQueueAdapter(db: Db, deps: WakeQueuePostgresAdapterDeps): IssueLockWriter;
9
+ //# sourceMappingURL=postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../../../src/modules/wake-queue/adapters/postgres.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,iBAAiB,CAAC;AAgC1C,OAAO,KAAK,EAGV,eAAe,EAKf,eAAe,EAEhB,MAAM,yBAAyB,CAAC;AA0HjC,MAAM,MAAM,4BAA4B,GAAG;IACzC,wBAAwB,EAAE,eAAe,CAAC,0BAA0B,CAAC,CAAC;IACtE,aAAa,EAAE,eAAe,CAAC,eAAe,CAAC,CAAC;IAChD,6BAA6B,EAAE,eAAe,CAAC,+BAA+B,CAAC,CAAC;CACjF,CAAC;AAohBF,wBAAgB,8BAA8B,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,4BAA4B,GAAG,eAAe,CAmH1G"}
@@ -0,0 +1,622 @@
1
+ import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
2
+ import { agentWakeupRequests, agents, heartbeatRuns, issueComments, issueRecoveryActions, issueRelations, issues, nativeRunFinalizations, } from "@paperclipai/db";
3
+ import { legacyExecutionNeedsReconciliation } from "../../../services/legacy-execution-recovery.js";
4
+ import { evaluateAgentInvokability } from "../../../services/agent-invokability.js";
5
+ import { issueTreeControlService, isVerifiedIssueTreeControlInteractionWake } from "../../../services/issue-tree-control.js";
6
+ import { isAutomaticRecoverySuppressedByPauseHold } from "../../../services/recovery/pause-hold-guard.js";
7
+ import { issueService } from "../../../services/issues.js";
8
+ import { issueRecoveryActionService } from "../../../services/issue-recovery-actions.js";
9
+ import { readContinuationAttempt } from "../../../services/recovery/run-liveness-continuations.js";
10
+ import { withRecoveryContext } from "../../../services/recovery/status-only-context.js";
11
+ import { parseIssueExecutionState } from "../../../services/issue-execution-policy.js";
12
+ import { buildConfigurationIncompleteRecoveryNoticeSeed, buildExecutionReviewParticipantRecoveryNoticeSeed, buildImmediateExecutionPathRecoveryNoticeSeed, buildWorkspaceValidationRecoveryNoticeSeed, } from "../../../services/recovery/stranded-notice.js";
13
+ import { queuedCommentIdsFromWakePayload, withQueuedCommentIdsInWakePayload, } from "../../../services/issue-queued-comment-queue.js";
14
+ import { extractWakeCommentIds } from "../../run-dispatch/index.js";
15
+ import { hasInteractionContinuationWakeContext } from "../domain/context.js";
16
+ import { WakeQueueApplicationError } from "../application/types.js";
17
+ const DEFERRED_WAKE_STATUS = "deferred_issue_execution";
18
+ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
19
+ const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
20
+ const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
21
+ const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
22
+ const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete";
23
+ const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participant_recovery";
24
+ const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = "execution_review_participant_recovery";
25
+ const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery";
26
+ const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"];
27
+ function parseObject(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value)
29
+ ? value
30
+ : {};
31
+ }
32
+ function readNonEmptyString(value) {
33
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
34
+ }
35
+ function normalizeAgentNameKey(value) {
36
+ if (typeof value !== "string")
37
+ return null;
38
+ const normalized = value.trim().toLowerCase();
39
+ return normalized.length > 0 ? normalized : null;
40
+ }
41
+ function isWorkspaceValidationFailedRun(run) {
42
+ return run.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
43
+ }
44
+ function isConfigurationIncompleteFailedRun(run) {
45
+ return run.errorCode === CONFIGURATION_INCOMPLETE_FAILURE_CODE || run.errorCode === "model_not_found";
46
+ }
47
+ function toRunSnapshot(row) {
48
+ const configurationIncompletePayload = parseObject(parseObject(row.resultJson).configurationIncomplete);
49
+ return {
50
+ id: row.id,
51
+ companyId: row.companyId,
52
+ agentId: row.agentId,
53
+ status: row.status,
54
+ runtimeMode: row.runtimeMode,
55
+ errorCode: row.errorCode,
56
+ responsibleUserId: row.responsibleUserId,
57
+ contextSnapshot: parseObject(row.contextSnapshot),
58
+ configurationIncompletePayload: Object.keys(configurationIncompletePayload).length > 0 ? configurationIncompletePayload : null,
59
+ };
60
+ }
61
+ function toIssueSnapshot(row) {
62
+ return {
63
+ id: row.id,
64
+ companyId: row.companyId,
65
+ identifier: row.identifier ?? "",
66
+ status: row.status,
67
+ assigneeAgentId: row.assigneeAgentId,
68
+ assigneeUserId: row.assigneeUserId,
69
+ hiddenAt: row.hiddenAt,
70
+ originKind: row.originKind,
71
+ monitorNextCheckAt: row.monitorNextCheckAt,
72
+ executionState: row.executionState ?? null,
73
+ responsibleUserId: row.responsibleUserId,
74
+ parentId: row.parentId,
75
+ originId: row.originId,
76
+ originRunId: row.originRunId,
77
+ };
78
+ }
79
+ function toRunSummary(row) {
80
+ return {
81
+ id: row.id,
82
+ companyId: row.companyId,
83
+ agentId: row.agentId,
84
+ invocationSource: row.invocationSource,
85
+ triggerDetail: row.triggerDetail,
86
+ wakeupRequestId: row.wakeupRequestId,
87
+ };
88
+ }
89
+ function toDeferredWakeCandidate(row) {
90
+ const payload = parseObject(row.payload);
91
+ const queuedCommentIds = queuedCommentIdsFromWakePayload(payload);
92
+ const deferredContextSeed = parseObject(payload[DEFERRED_WAKE_CONTEXT_KEY]);
93
+ const deferredCommentIds = extractWakeCommentIds(deferredContextSeed);
94
+ const wakeReason = readNonEmptyString(deferredContextSeed.wakeReason);
95
+ const queuedReason = wakeReason ?? readNonEmptyString(row.reason);
96
+ const queuedWakeIsCommentOnly = !queuedReason ||
97
+ queuedReason === "issue_commented" ||
98
+ queuedReason === "issue_reopened_via_comment" ||
99
+ queuedReason === "issue_comment_mentioned";
100
+ const preservesIndependentContinuation = hasInteractionContinuationWakeContext(deferredContextSeed) ||
101
+ deferredContextSeed.resumeIntent === true ||
102
+ !queuedWakeIsCommentOnly;
103
+ return {
104
+ id: row.id,
105
+ companyId: row.companyId,
106
+ agentId: row.agentId,
107
+ reason: row.reason,
108
+ source: row.source,
109
+ triggerDetail: row.triggerDetail,
110
+ requestedByActorType: row.requestedByActorType,
111
+ requestedByActorId: row.requestedByActorId,
112
+ payload,
113
+ queuedCommentIds,
114
+ preservesIndependentContinuation,
115
+ deferredContextSeed,
116
+ deferredCommentIds,
117
+ wakeReason,
118
+ };
119
+ }
120
+ function buildReader(tx, deps) {
121
+ return {
122
+ async findInvokableAgent({ companyId, agentId }) {
123
+ const agent = await tx
124
+ .select()
125
+ .from(agents)
126
+ .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
127
+ .then((rows) => rows[0] ?? null);
128
+ if (!agent)
129
+ return null;
130
+ const companyAgents = await tx
131
+ .select({ id: agents.id, companyId: agents.companyId, name: agents.name, reportsTo: agents.reportsTo, status: agents.status })
132
+ .from(agents)
133
+ .where(eq(agents.companyId, companyId));
134
+ const invokability = evaluateAgentInvokability(agent, companyAgents);
135
+ return { id: agent.id, companyId: agent.companyId, name: agent.name, invokable: invokability.invokable };
136
+ },
137
+ resolveResponsibleUserId: deps.resolveResponsibleUserId,
138
+ getRoutineEnv: deps.getRoutineEnv,
139
+ resolveSessionBeforeForWakeup: deps.resolveSessionBeforeForWakeup,
140
+ };
141
+ }
142
+ function buildWriter(tx, deps) {
143
+ const treeControlSvc = issueTreeControlService(tx);
144
+ const issuesSvc = issueService(tx);
145
+ return {
146
+ async claimNextDeferredWake({ companyId, issueId }) {
147
+ const row = await tx
148
+ .select()
149
+ .from(agentWakeupRequests)
150
+ .where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS), sql `${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`))
151
+ .orderBy(asc(agentWakeupRequests.requestedAt))
152
+ .limit(1)
153
+ .then((rows) => rows[0] ?? null);
154
+ return row ? toDeferredWakeCandidate(row) : null;
155
+ },
156
+ async getQueuedCommentLiveness({ companyId, issueId, wakeAgentId, finishingRunId, finishingRunAgentId, queuedCommentIds }) {
157
+ const rows = await tx
158
+ .select({ id: issueComments.id, deletedAt: issueComments.deletedAt, createdByRunId: issueComments.createdByRunId })
159
+ .from(issueComments)
160
+ .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, queuedCommentIds)));
161
+ const targetsFinishingRunAgent = wakeAgentId === finishingRunAgentId;
162
+ const liveNonSelfCommentIds = queuedCommentIds.filter((commentId) => {
163
+ const row = rows.find((candidate) => candidate.id === commentId);
164
+ return Boolean(row && !row.deletedAt && (!targetsFinishingRunAgent || row.createdByRunId !== finishingRunId));
165
+ });
166
+ const containedSelfAuthoredComment = rows.some((row) => targetsFinishingRunAgent && !row.deletedAt && row.createdByRunId === finishingRunId);
167
+ return { liveNonSelfCommentIds, containedSelfAuthoredComment };
168
+ },
169
+ async cancelDeferredWake({ companyId, wakeId, reason, now }) {
170
+ const rows = await tx
171
+ .update(agentWakeupRequests)
172
+ .set({ status: "cancelled", finishedAt: now, error: reason, updatedAt: now })
173
+ .where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS)))
174
+ .returning({ id: agentWakeupRequests.id });
175
+ return rows.length > 0;
176
+ },
177
+ async normalizeDeferredWakeCommentIds({ companyId, wakeId, payload, liveCommentIds, now }) {
178
+ const rows = await tx
179
+ .update(agentWakeupRequests)
180
+ .set({ payload: withQueuedCommentIdsInWakePayload(payload, liveCommentIds), updatedAt: now })
181
+ .where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS)))
182
+ .returning();
183
+ const row = rows[0];
184
+ return row ? toDeferredWakeCandidate(row) : null;
185
+ },
186
+ async failDeferredWake({ companyId, wakeId, now }) {
187
+ const rows = await tx
188
+ .update(agentWakeupRequests)
189
+ .set({
190
+ status: "failed",
191
+ finishedAt: now,
192
+ error: "Deferred wake could not be promoted: agent is not invokable",
193
+ updatedAt: now,
194
+ })
195
+ .where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS)))
196
+ .returning({ id: agentWakeupRequests.id });
197
+ return rows.length > 0;
198
+ },
199
+ async getPauseHoldFacts({ companyId, issueId, wakeAgentId, deferredContextSeed, requestedByActorType, requestedByActorId }) {
200
+ const activePauseHold = await treeControlSvc.getActivePauseHoldGate(companyId, issueId);
201
+ if (!activePauseHold) {
202
+ return {
203
+ activePauseHold: false,
204
+ treeHoldInteractionWake: false,
205
+ holdId: null,
206
+ rootIssueId: null,
207
+ mode: null,
208
+ reason: null,
209
+ releasePolicy: null,
210
+ };
211
+ }
212
+ const treeHoldInteractionWake = await isVerifiedIssueTreeControlInteractionWake(tx, {
213
+ companyId,
214
+ issueId,
215
+ agentId: wakeAgentId,
216
+ contextSnapshot: deferredContextSeed,
217
+ requestedByActorType,
218
+ requestedByActorId,
219
+ });
220
+ return {
221
+ activePauseHold: true,
222
+ treeHoldInteractionWake,
223
+ holdId: activePauseHold.holdId,
224
+ rootIssueId: activePauseHold.rootIssueId,
225
+ mode: activePauseHold.mode,
226
+ reason: activePauseHold.reason,
227
+ releasePolicy: activePauseHold.releasePolicy,
228
+ };
229
+ },
230
+ async getCommentSelfAuthorship({ companyId, issueId, finishingRunId, commentIds }) {
231
+ const rows = await tx
232
+ .select({ createdByRunId: issueComments.createdByRunId })
233
+ .from(issueComments)
234
+ .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, commentIds)));
235
+ return { allSelfAuthored: rows.length > 0 && rows.every((row) => row.createdByRunId === finishingRunId) };
236
+ },
237
+ async reopenIssue({ companyId, issueId }) {
238
+ const updated = await issuesSvc.updateForCompany(issueId, companyId, { status: "todo", executionState: null }, tx);
239
+ return updated ? toIssueSnapshot(updated) : null;
240
+ },
241
+ async claimDeferredWakeForPromotion({ companyId, wakeId, now }) {
242
+ const claimed = await tx
243
+ .update(agentWakeupRequests)
244
+ .set({
245
+ status: "queued",
246
+ reason: "issue_execution_promoted",
247
+ claimedAt: null,
248
+ finishedAt: null,
249
+ error: null,
250
+ updatedAt: now,
251
+ })
252
+ .where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, DEFERRED_WAKE_STATUS)))
253
+ .returning({ id: agentWakeupRequests.id });
254
+ return claimed.length > 0;
255
+ },
256
+ async finalizePromotedWake(input) {
257
+ const newRun = await tx
258
+ .insert(heartbeatRuns)
259
+ .values({
260
+ companyId: input.deferredAgent.companyId,
261
+ agentId: input.deferredAgent.id,
262
+ invocationSource: input.source,
263
+ triggerDetail: input.triggerDetail,
264
+ status: "queued",
265
+ wakeupRequestId: input.wakeId,
266
+ contextSnapshot: input.contextSnapshot,
267
+ responsibleUserId: input.responsibleUserId,
268
+ sessionIdBefore: input.sessionBefore,
269
+ continuationAttempt: readContinuationAttempt(input.contextSnapshot.livenessContinuationAttempt),
270
+ })
271
+ .returning()
272
+ .then((rows) => rows[0]);
273
+ // `claimDeferredWakeForPromotion` already moved this row off
274
+ // `deferred_issue_execution` inside this same transaction, so no
275
+ // concurrent claimer can still match that guard; this extra `runId is
276
+ // null` guard only protects against writing the link twice.
277
+ await tx
278
+ .update(agentWakeupRequests)
279
+ .set({ runId: newRun.id, updatedAt: input.now })
280
+ .where(and(eq(agentWakeupRequests.id, input.wakeId), eq(agentWakeupRequests.companyId, input.companyId), isNull(agentWakeupRequests.runId)));
281
+ // Promoted mention wakes are issue-scoped, not issue ownership
282
+ // transfers. The lock-clearing step earlier in this transaction
283
+ // already set `executionRunId` to null for this issue, so the `is
284
+ // null` guard only protects against taking the lock twice.
285
+ await tx
286
+ .update(issues)
287
+ .set({
288
+ executionRunId: newRun.id,
289
+ executionAgentNameKey: normalizeAgentNameKey(input.deferredAgent.name),
290
+ executionLockedAt: input.now,
291
+ updatedAt: input.now,
292
+ })
293
+ .where(and(eq(issues.id, input.issue.id), eq(issues.companyId, input.companyId), eq(issues.assigneeAgentId, input.deferredAgent.id), isNull(issues.executionRunId)));
294
+ return toRunSummary(newRun);
295
+ },
296
+ async hasExistingExecutionPath({ companyId, issueId, excludeRunId, agentId }) {
297
+ const row = await tx
298
+ .select({ id: heartbeatRuns.id })
299
+ .from(heartbeatRuns)
300
+ .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), sql `${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, sql `${heartbeatRuns.id} <> ${excludeRunId}`, agentId ? eq(heartbeatRuns.agentId, agentId) : sql `true`))
301
+ .limit(1)
302
+ .then((rows) => rows[0] ?? null);
303
+ return row !== null;
304
+ },
305
+ async hasExplicitBlockerPath({ companyId, issueId }) {
306
+ const row = await tx
307
+ .select({ issueId: issueRelations.issueId })
308
+ .from(issueRelations)
309
+ .innerJoin(issues, eq(issueRelations.issueId, issues.id))
310
+ .where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.relatedIssueId, issueId), eq(issueRelations.type, "blocks"), eq(issues.companyId, companyId), notInArray(issues.status, ["done", "cancelled"]), isNull(issues.hiddenAt)))
311
+ .limit(1)
312
+ .then((rows) => rows[0] ?? null);
313
+ return row !== null;
314
+ },
315
+ async isAutomaticRecoverySuppressedByPauseHold({ companyId, issueId }) {
316
+ return isAutomaticRecoverySuppressedByPauseHold(tx, companyId, issueId, treeControlSvc);
317
+ },
318
+ async buildBlockedRecoveryNotice({ noticeKind, issueStatus, finishingRun }) {
319
+ if (noticeKind === "workspace_validation") {
320
+ return { notice: buildWorkspaceValidationRecoveryNoticeSeed(), recoveryCause: WORKSPACE_VALIDATION_RECOVERY_CAUSE };
321
+ }
322
+ if (noticeKind === "configuration_incomplete") {
323
+ return {
324
+ notice: buildConfigurationIncompleteRecoveryNoticeSeed(finishingRun.configurationIncompletePayload),
325
+ recoveryCause: CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE,
326
+ };
327
+ }
328
+ if (noticeKind === "execution_review_participant") {
329
+ return {
330
+ notice: buildExecutionReviewParticipantRecoveryNoticeSeed(),
331
+ recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE,
332
+ };
333
+ }
334
+ return { notice: buildImmediateExecutionPathRecoveryNoticeSeed({ status: issueStatus }), recoveryCause: null };
335
+ },
336
+ async queueReviewParticipantRecoveryRun({ companyId, issue, finishingRun, recoveryAgent, sessionBefore, now }) {
337
+ const executionState = parseIssueExecutionState(issue.executionState);
338
+ const wakeupRequest = await tx
339
+ .insert(agentWakeupRequests)
340
+ .values({
341
+ companyId,
342
+ agentId: recoveryAgent.id,
343
+ source: "automation",
344
+ triggerDetail: "system",
345
+ reason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
346
+ payload: withRecoveryContext({
347
+ issueId: issue.id,
348
+ retryOfRunId: finishingRun.id,
349
+ retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
350
+ currentStageId: executionState?.currentStageId ?? null,
351
+ currentStageType: executionState?.currentStageType ?? null,
352
+ }, "normal_model"),
353
+ status: "queued",
354
+ requestedByActorType: "system",
355
+ requestedByActorId: null,
356
+ updatedAt: now,
357
+ })
358
+ .returning()
359
+ .then((rows) => rows[0]);
360
+ const queuedRun = await tx
361
+ .insert(heartbeatRuns)
362
+ .values({
363
+ companyId,
364
+ agentId: recoveryAgent.id,
365
+ invocationSource: "automation",
366
+ triggerDetail: "system",
367
+ status: "queued",
368
+ wakeupRequestId: wakeupRequest.id,
369
+ contextSnapshot: withRecoveryContext({
370
+ issueId: issue.id,
371
+ taskId: issue.id,
372
+ wakeReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
373
+ retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
374
+ source: "issue.execution_review_recovery",
375
+ retryOfRunId: finishingRun.id,
376
+ currentStageId: executionState?.currentStageId ?? null,
377
+ currentStageType: executionState?.currentStageType ?? null,
378
+ reviewRecoveryInstruction: "The previous reviewer run ended while this execution-review stage was still pending. Submit the review decision now, or mark the issue blocked with the exact unblock action.",
379
+ }, "normal_model"),
380
+ sessionIdBefore: sessionBefore,
381
+ retryOfRunId: finishingRun.id,
382
+ updatedAt: now,
383
+ })
384
+ .returning()
385
+ .then((rows) => rows[0]);
386
+ await tx
387
+ .update(agentWakeupRequests)
388
+ .set({ runId: queuedRun.id, updatedAt: now })
389
+ .where(and(eq(agentWakeupRequests.id, wakeupRequest.id), eq(agentWakeupRequests.companyId, companyId)));
390
+ await tx
391
+ .update(issues)
392
+ .set({
393
+ executionRunId: queuedRun.id,
394
+ executionAgentNameKey: normalizeAgentNameKey(recoveryAgent.name),
395
+ executionLockedAt: now,
396
+ updatedAt: now,
397
+ })
398
+ .where(and(eq(issues.id, issue.id), eq(issues.companyId, companyId)));
399
+ return toRunSummary(queuedRun);
400
+ },
401
+ async queueImmediateRecoveryRun({ companyId, issue, finishingRun, recoveryAgent, sessionBefore, now }) {
402
+ const retryReason = issue.status === "todo" ? "assignment_recovery" : "issue_continuation_needed";
403
+ const recoveryReason = issue.status === "todo" ? "issue_assignment_recovery" : "issue_continuation_needed";
404
+ const recoverySource = issue.status === "todo" ? "issue.assignment_recovery" : "issue.continuation_recovery";
405
+ const recoveryContextSnapshot = withRecoveryContext({
406
+ issueId: issue.id,
407
+ taskId: issue.id,
408
+ wakeReason: recoveryReason,
409
+ retryReason,
410
+ source: recoverySource,
411
+ retryOfRunId: finishingRun.id,
412
+ }, "normal_model");
413
+ const routineEnvContext = await deps.getRoutineEnv({ companyId, issue });
414
+ const responsibleUserId = await deps.resolveResponsibleUserId({
415
+ companyId,
416
+ contextSnapshot: recoveryContextSnapshot,
417
+ issue,
418
+ routineEnvContext,
419
+ requestedByActorType: "system",
420
+ requestedByActorId: null,
421
+ source: "automation",
422
+ triggerDetail: "system",
423
+ existingRunResponsibleUserId: finishingRun.responsibleUserId,
424
+ });
425
+ if (!responsibleUserId) {
426
+ throw new WakeQueueApplicationError("responsible_user_unresolved", "Unable to resolve responsible user for recovery heartbeat run", {
427
+ runId: finishingRun.id,
428
+ agentId: recoveryAgent.id,
429
+ companyId,
430
+ issueId: issue.id,
431
+ wakeReason: recoveryReason,
432
+ });
433
+ }
434
+ const wakeupRequest = await tx
435
+ .insert(agentWakeupRequests)
436
+ .values({
437
+ companyId,
438
+ agentId: recoveryAgent.id,
439
+ source: "automation",
440
+ triggerDetail: "system",
441
+ reason: recoveryReason,
442
+ payload: withRecoveryContext({ issueId: issue.id, retryOfRunId: finishingRun.id }, "normal_model"),
443
+ status: "queued",
444
+ requestedByActorType: "system",
445
+ requestedByActorId: null,
446
+ updatedAt: now,
447
+ })
448
+ .returning()
449
+ .then((rows) => rows[0]);
450
+ const queuedRun = await tx
451
+ .insert(heartbeatRuns)
452
+ .values({
453
+ companyId,
454
+ agentId: recoveryAgent.id,
455
+ invocationSource: "automation",
456
+ triggerDetail: "system",
457
+ status: "queued",
458
+ wakeupRequestId: wakeupRequest.id,
459
+ contextSnapshot: recoveryContextSnapshot,
460
+ responsibleUserId,
461
+ sessionIdBefore: sessionBefore,
462
+ retryOfRunId: finishingRun.id,
463
+ updatedAt: now,
464
+ })
465
+ .returning()
466
+ .then((rows) => rows[0]);
467
+ await tx
468
+ .update(agentWakeupRequests)
469
+ .set({ runId: queuedRun.id, updatedAt: now })
470
+ .where(and(eq(agentWakeupRequests.id, wakeupRequest.id), eq(agentWakeupRequests.companyId, companyId)));
471
+ await tx
472
+ .update(issues)
473
+ .set({
474
+ executionRunId: queuedRun.id,
475
+ executionAgentNameKey: normalizeAgentNameKey(recoveryAgent.name),
476
+ executionLockedAt: now,
477
+ updatedAt: now,
478
+ })
479
+ .where(and(eq(issues.id, issue.id), eq(issues.companyId, companyId)));
480
+ return toRunSummary(queuedRun);
481
+ },
482
+ };
483
+ }
484
+ async function recordNativeTerminalRecoveryIfNeeded(tx, run, issue, now) {
485
+ const applies = run.runtimeMode === "native" &&
486
+ ["failed", "timed_out", "interrupted", "cancelled"].includes(run.status) &&
487
+ issue.assigneeAgentId === run.agentId &&
488
+ !["done", "cancelled"].includes(issue.status);
489
+ if (!applies)
490
+ return false;
491
+ const existing = await tx
492
+ .select({ id: issueRecoveryActions.id })
493
+ .from(issueRecoveryActions)
494
+ .where(and(eq(issueRecoveryActions.companyId, issue.companyId), eq(issueRecoveryActions.sourceIssueId, issue.id), or(inArray(issueRecoveryActions.status, ["active", "escalated"]), sql `${issueRecoveryActions.evidence}->'automaticRecovery'->>'runId' = ${run.id}`)))
495
+ .limit(1);
496
+ if (!existing.length) {
497
+ await tx
498
+ .update(nativeRunFinalizations)
499
+ .set({
500
+ phase: "terminal_failure",
501
+ leaseOwner: null,
502
+ leaseExpiresAt: null,
503
+ nextAttemptAt: null,
504
+ recoveryState: "blocked",
505
+ failureCode: "native_continuation_requires_reconciliation",
506
+ updatedAt: now,
507
+ })
508
+ .where(and(eq(nativeRunFinalizations.companyId, issue.companyId), eq(nativeRunFinalizations.runId, run.id), isNull(nativeRunFinalizations.resultId)));
509
+ await issueRecoveryActionService(tx).upsertSourceScoped({
510
+ companyId: issue.companyId,
511
+ sourceIssueId: issue.id,
512
+ kind: "active_run_watchdog",
513
+ ownerType: "board",
514
+ returnOwnerAgentId: run.agentId,
515
+ cause: "native_continuation_requires_reconciliation",
516
+ fingerprint: `native-continuation:${run.id}`,
517
+ evidence: { runId: run.id, originalFailureCode: run.errorCode },
518
+ nextAction: "Inspect the original failure and reconcile the previous execution before continuing. Automatic recovery cannot start another incident.",
519
+ maxAttempts: 3,
520
+ wakePolicy: null,
521
+ supersedeOnIdentityChange: true,
522
+ });
523
+ }
524
+ return true;
525
+ }
526
+ export function createPostgresWakeQueueAdapter(db, deps) {
527
+ return {
528
+ async withIssueExecutionLock(input, fn) {
529
+ return db.transaction(async (rawTx) => {
530
+ const tx = rawTx;
531
+ const run = await tx
532
+ .select()
533
+ .from(heartbeatRuns)
534
+ .where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)))
535
+ .then((rows) => rows[0] ?? null);
536
+ if (!run) {
537
+ throw new Error(`wake-queue: run ${input.runId} was not found while releasing issue execution`);
538
+ }
539
+ const runSnapshot = toRunSnapshot(run);
540
+ const contextIssueId = readNonEmptyString(parseObject(run.contextSnapshot).issueId);
541
+ // Lock the context issue (if any) and every issue that still references this
542
+ // run, in id order, so two concurrent finalizations can never deadlock on
543
+ // each other's row-lock acquisition order.
544
+ await tx.execute(contextIssueId
545
+ ? sql `
546
+ select id from issues
547
+ where company_id = ${input.companyId}
548
+ and (
549
+ id = ${contextIssueId}
550
+ or execution_run_id = ${run.id}
551
+ or checkout_run_id = ${run.id}
552
+ )
553
+ order by id
554
+ for update
555
+ `
556
+ : sql `
557
+ select id from issues
558
+ where company_id = ${input.companyId}
559
+ and (execution_run_id = ${run.id} or checkout_run_id = ${run.id})
560
+ order by id
561
+ for update
562
+ `);
563
+ const candidateIssues = await tx
564
+ .select()
565
+ .from(issues)
566
+ .where(and(eq(issues.companyId, input.companyId), contextIssueId
567
+ ? or(eq(issues.id, contextIssueId), eq(issues.executionRunId, run.id), eq(issues.checkoutRunId, run.id))
568
+ : or(eq(issues.executionRunId, run.id), eq(issues.checkoutRunId, run.id))))
569
+ .orderBy(asc(issues.id));
570
+ // Two separate updates: a retry can move `executionRunId` to a new run
571
+ // while `checkoutRunId` still points at this one finishing.
572
+ await tx
573
+ .update(issues)
574
+ .set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: input.now })
575
+ .where(and(eq(issues.companyId, input.companyId), eq(issues.executionRunId, run.id)));
576
+ await tx
577
+ .update(issues)
578
+ .set({ checkoutRunId: null, updatedAt: input.now })
579
+ .where(and(eq(issues.companyId, input.companyId), eq(issues.checkoutRunId, run.id)));
580
+ const issueRow = (contextIssueId ? candidateIssues.find((candidate) => candidate.id === contextIssueId) : candidateIssues[0]) ?? null;
581
+ if (!issueRow || (issueRow.executionRunId && issueRow.executionRunId !== run.id)) {
582
+ return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
583
+ }
584
+ if ((isWorkspaceValidationFailedRun(run) || isConfigurationIncompleteFailedRun(run)) &&
585
+ (issueRow.status === "todo" || issueRow.status === "in_progress") &&
586
+ !issueRow.assigneeUserId &&
587
+ issueRow.assigneeAgentId === run.agentId) {
588
+ const configurationIncomplete = isConfigurationIncompleteFailedRun(run);
589
+ const notice = configurationIncomplete
590
+ ? buildConfigurationIncompleteRecoveryNoticeSeed(runSnapshot.configurationIncompletePayload)
591
+ : buildWorkspaceValidationRecoveryNoticeSeed();
592
+ return {
593
+ outcome: {
594
+ kind: "blocked",
595
+ issue: toIssueSnapshot(issueRow),
596
+ previousStatus: issueRow.status,
597
+ notice,
598
+ recoveryCause: configurationIncomplete ? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE : WORKSPACE_VALIDATION_RECOVERY_CAUSE,
599
+ },
600
+ postCommitEffects: [],
601
+ run: runSnapshot,
602
+ };
603
+ }
604
+ if (legacyExecutionNeedsReconciliation(run)) {
605
+ return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
606
+ }
607
+ // An operator stop never promotes old queued work by itself. The next
608
+ // explicit wake adopts those messages atomically when it queues a run.
609
+ if (run.status === "cancelled" && parseObject(run.resultJson?.executionCancellation).state === "acknowledged") {
610
+ return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
611
+ }
612
+ if (await recordNativeTerminalRecoveryIfNeeded(tx, run, issueRow, input.now)) {
613
+ return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
614
+ }
615
+ const locked = { primaryIssue: toIssueSnapshot(issueRow), run: runSnapshot };
616
+ const result = await fn(locked, { reader: buildReader(tx, deps), writer: buildWriter(tx, deps) });
617
+ return { ...result, run: runSnapshot };
618
+ });
619
+ },
620
+ };
621
+ }
622
+ //# sourceMappingURL=postgres.js.map