@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.
- package/dist/build-info.json +1 -1
- package/dist/modules/wake-queue/adapters/postgres.d.ts +9 -0
- package/dist/modules/wake-queue/adapters/postgres.d.ts.map +1 -0
- package/dist/modules/wake-queue/adapters/postgres.js +622 -0
- package/dist/modules/wake-queue/adapters/postgres.js.map +1 -0
- package/dist/modules/wake-queue/adapters/postgres.test.d.ts +2 -0
- package/dist/modules/wake-queue/adapters/postgres.test.d.ts.map +1 -0
- package/dist/modules/wake-queue/adapters/postgres.test.js +361 -0
- package/dist/modules/wake-queue/adapters/postgres.test.js.map +1 -0
- package/dist/modules/wake-queue/application/ports.d.ts +269 -0
- package/dist/modules/wake-queue/application/ports.d.ts.map +1 -0
- package/dist/modules/wake-queue/application/ports.js +2 -0
- package/dist/modules/wake-queue/application/ports.js.map +1 -0
- package/dist/modules/wake-queue/application/types.d.ts +91 -0
- package/dist/modules/wake-queue/application/types.d.ts.map +1 -0
- package/dist/modules/wake-queue/application/types.js +11 -0
- package/dist/modules/wake-queue/application/types.js.map +1 -0
- package/dist/modules/wake-queue/application/use-cases.d.ts +16 -0
- package/dist/modules/wake-queue/application/use-cases.d.ts.map +1 -0
- package/dist/modules/wake-queue/application/use-cases.js +413 -0
- package/dist/modules/wake-queue/application/use-cases.js.map +1 -0
- package/dist/modules/wake-queue/application/use-cases.test.d.ts +2 -0
- package/dist/modules/wake-queue/application/use-cases.test.d.ts.map +1 -0
- package/dist/modules/wake-queue/application/use-cases.test.js +281 -0
- package/dist/modules/wake-queue/application/use-cases.test.js.map +1 -0
- package/dist/modules/wake-queue/domain/context.d.ts +21 -0
- package/dist/modules/wake-queue/domain/context.d.ts.map +1 -0
- package/dist/modules/wake-queue/domain/context.js +135 -0
- package/dist/modules/wake-queue/domain/context.js.map +1 -0
- package/dist/modules/wake-queue/domain/context.test.d.ts +2 -0
- package/dist/modules/wake-queue/domain/context.test.d.ts.map +1 -0
- package/dist/modules/wake-queue/domain/context.test.js +113 -0
- package/dist/modules/wake-queue/domain/context.test.js.map +1 -0
- package/dist/modules/wake-queue/domain/policy.d.ts +110 -0
- package/dist/modules/wake-queue/domain/policy.d.ts.map +1 -0
- package/dist/modules/wake-queue/domain/policy.js +94 -0
- package/dist/modules/wake-queue/domain/policy.js.map +1 -0
- package/dist/modules/wake-queue/domain/policy.test.d.ts +2 -0
- package/dist/modules/wake-queue/domain/policy.test.d.ts.map +1 -0
- package/dist/modules/wake-queue/domain/policy.test.js +335 -0
- package/dist/modules/wake-queue/domain/policy.test.js.map +1 -0
- package/dist/modules/wake-queue/index.d.ts +31 -0
- package/dist/modules/wake-queue/index.d.ts.map +1 -0
- package/dist/modules/wake-queue/index.js +21 -0
- package/dist/modules/wake-queue/index.js.map +1 -0
- package/dist/services/heartbeat.d.ts.map +1 -1
- package/dist/services/heartbeat.js +142 -785
- package/dist/services/heartbeat.js.map +1 -1
- package/dist/services/issues.d.ts +139 -0
- package/dist/services/issues.d.ts.map +1 -1
- package/dist/services/issues.js +21 -5
- package/dist/services/issues.js.map +1 -1
- package/dist/services/productivity-review.d.ts +1 -1
- package/package.json +18 -18
package/dist/build-info.json
CHANGED
|
@@ -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
|