@sellable/mcp 0.1.512 → 0.1.514
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/api.js +15 -12
- package/dist/refill-journal.d.ts +26 -0
- package/dist/refill-journal.js +70 -2
- package/dist/refill-local-state.d.ts +21 -0
- package/dist/refill-local-state.js +177 -1
- package/dist/refill-run-client.d.ts +62 -0
- package/dist/refill-run-client.js +101 -0
- package/dist/refill-run-loop.d.ts +100 -0
- package/dist/refill-run-loop.js +1238 -0
- package/dist/server.js +9 -7
- package/dist/tools/campaign-processing.js +3 -3
- package/dist/tools/evergreen-refill-plan.d.ts +29 -3
- package/dist/tools/evergreen-refill-plan.js +93 -16
- package/dist/tools/prompts.d.ts +4 -3
- package/dist/tools/prompts.js +5 -1
- package/dist/tools/refill-executors.d.ts +178 -0
- package/dist/tools/refill-executors.js +788 -0
- package/dist/tools/refill-sends-v2.d.ts +115 -0
- package/dist/tools/refill-sends-v2.js +162 -0
- package/dist/tools/refill-sends.js +2 -657
- package/dist/tools/registry.d.ts +184 -16
- package/dist/tools/registry.js +6 -6
- package/package.json +1 -1
- package/skills/refill-sends-evergreen/SKILL.md +6 -71
- package/skills/refill-sends-evergreen-workflow/SKILL.md +9 -96
- package/skills/refill-sends-v2/SKILL.md +156 -0
- package/skills/refill-sends-v2-workflow/SKILL.md +138 -0
- package/skills/refill-sends-v2-workflow/core/flow.v1.json +372 -0
|
@@ -0,0 +1,1238 @@
|
|
|
1
|
+
import { SellableApiError } from "./api.js";
|
|
2
|
+
import { buildRunStateFromLocalHints } from "./tools/evergreen-refill-plan.js";
|
|
3
|
+
import { prepareRowSelectorValue as defaultPrepareRowSelectorValue, refillPrepareRequestHash as defaultRefillPrepareRequestHash, } from "./tools/refill-executors.js";
|
|
4
|
+
const DEFAULT_BUDGETS = {
|
|
5
|
+
maxGateCyclesPerInvocation: 8,
|
|
6
|
+
pollIntervalMs: 15_000,
|
|
7
|
+
maxPollsPerWait: 20,
|
|
8
|
+
maxSchedulerReadbacks: 3,
|
|
9
|
+
waitWallClockPerInvocationMs: 45_000,
|
|
10
|
+
schedulerWaitRunBudgetMs: 300_000,
|
|
11
|
+
};
|
|
12
|
+
// Mirrors RefillDoneReason in src/lib/workflow-tables/refill-target-plan.ts.
|
|
13
|
+
const REFILL_DONE_REASONS = new Set([
|
|
14
|
+
"complete",
|
|
15
|
+
"capped_by_scheduler",
|
|
16
|
+
"loaded_awaiting_scheduler",
|
|
17
|
+
"lanes_exhausted",
|
|
18
|
+
"blocked",
|
|
19
|
+
"not_an_evergreen_workspace",
|
|
20
|
+
"no_refillable_campaigns",
|
|
21
|
+
]);
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function stringValue(value) {
|
|
26
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
27
|
+
}
|
|
28
|
+
function numberValue(value) {
|
|
29
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
30
|
+
}
|
|
31
|
+
function booleanValue(value) {
|
|
32
|
+
return typeof value === "boolean" ? value : null;
|
|
33
|
+
}
|
|
34
|
+
function recordValue(value) {
|
|
35
|
+
return isRecord(value) ? value : null;
|
|
36
|
+
}
|
|
37
|
+
function arrayValue(value) {
|
|
38
|
+
return Array.isArray(value) ? value : [];
|
|
39
|
+
}
|
|
40
|
+
function hasBlocker(value, blocker) {
|
|
41
|
+
return isRecord(value) && value.blocker === blocker;
|
|
42
|
+
}
|
|
43
|
+
function isLeaseLost(value) {
|
|
44
|
+
return hasBlocker(value, "lease_lost");
|
|
45
|
+
}
|
|
46
|
+
function isActiveRun(value) {
|
|
47
|
+
return hasBlocker(value, "active_run_in_progress");
|
|
48
|
+
}
|
|
49
|
+
function actionToolInput(action) {
|
|
50
|
+
return recordValue(action.toolInput) ?? {};
|
|
51
|
+
}
|
|
52
|
+
function actionIds(action) {
|
|
53
|
+
return recordValue(action.ids) ?? {};
|
|
54
|
+
}
|
|
55
|
+
function actionCampaignId(action) {
|
|
56
|
+
const ids = actionIds(action);
|
|
57
|
+
const toolInput = actionToolInput(action);
|
|
58
|
+
return (stringValue(toolInput.campaignId) ??
|
|
59
|
+
stringValue(toolInput.campaignOfferId) ??
|
|
60
|
+
stringValue(ids.campaignId) ??
|
|
61
|
+
stringValue(action.campaignId));
|
|
62
|
+
}
|
|
63
|
+
function actionSenderId(action) {
|
|
64
|
+
const ids = actionIds(action);
|
|
65
|
+
const toolInput = actionToolInput(action);
|
|
66
|
+
return (stringValue(toolInput.senderId) ??
|
|
67
|
+
stringValue(ids.senderId) ??
|
|
68
|
+
stringValue(action.senderId));
|
|
69
|
+
}
|
|
70
|
+
function fingerprintFromPlan(plan) {
|
|
71
|
+
const packet = recordValue(plan.packet) ?? {};
|
|
72
|
+
return {
|
|
73
|
+
planRevision: stringValue(plan.planRevision),
|
|
74
|
+
stateRevision: stringValue(plan.stateRevision) ?? stringValue(packet.stateRevision),
|
|
75
|
+
targetShapeRevision: stringValue(packet.targetShapeRevision) ??
|
|
76
|
+
stringValue(packet.targetRevision),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function fingerprintChanged(expected, actual) {
|
|
80
|
+
if (!expected || !actual)
|
|
81
|
+
return [];
|
|
82
|
+
const changed = [];
|
|
83
|
+
if (expected.planRevision !== actual.planRevision)
|
|
84
|
+
changed.push("planRevision");
|
|
85
|
+
if (expected.stateRevision !== actual.stateRevision)
|
|
86
|
+
changed.push("stateRevision");
|
|
87
|
+
if (expected.targetShapeRevision !== actual.targetShapeRevision) {
|
|
88
|
+
changed.push("targetShapeRevision");
|
|
89
|
+
}
|
|
90
|
+
return changed;
|
|
91
|
+
}
|
|
92
|
+
function firstAction(plan) {
|
|
93
|
+
const globalActionQueue = arrayValue(plan.globalActionQueue);
|
|
94
|
+
return recordValue(globalActionQueue[0]);
|
|
95
|
+
}
|
|
96
|
+
function doneReasonFromPlan(plan) {
|
|
97
|
+
const action = firstAction(plan);
|
|
98
|
+
return (stringValue(action?.doneReason) ??
|
|
99
|
+
stringValue(recordValue(plan.packet)?.doneReason) ??
|
|
100
|
+
stringValue(plan.doneReason));
|
|
101
|
+
}
|
|
102
|
+
function selectedLaneAuthority(plan, action) {
|
|
103
|
+
const senderId = actionSenderId(action);
|
|
104
|
+
const evergreen = recordValue(plan.evergreen) ?? {};
|
|
105
|
+
const laneOrder = arrayValue(evergreen.laneOrder).filter(isRecord);
|
|
106
|
+
const campaignIdsByLaneKey = new Map();
|
|
107
|
+
for (const lane of laneOrder) {
|
|
108
|
+
const laneKey = stringValue(lane.laneKey);
|
|
109
|
+
const campaignId = stringValue(lane.campaignId);
|
|
110
|
+
if (laneKey && campaignId)
|
|
111
|
+
campaignIdsByLaneKey.set(laneKey, campaignId);
|
|
112
|
+
}
|
|
113
|
+
const selections = arrayValue(evergreen.senderLaneSelections).filter(isRecord);
|
|
114
|
+
const selection = selections.find((entry) => stringValue(entry.senderId) === senderId) ??
|
|
115
|
+
selections[0] ??
|
|
116
|
+
{};
|
|
117
|
+
const selectedLane = recordValue(selection.selectedLane) ?? {};
|
|
118
|
+
const laneChain = arrayValue(selection.laneChain)
|
|
119
|
+
.map((entry) => stringValue(entry))
|
|
120
|
+
.filter((entry) => Boolean(entry));
|
|
121
|
+
const laneChainCampaignIds = laneChain
|
|
122
|
+
.map((laneKeyOrCampaignId) => campaignIdsByLaneKey.get(laneKeyOrCampaignId) ?? laneKeyOrCampaignId)
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
return {
|
|
125
|
+
laneChainCampaignIds,
|
|
126
|
+
pinnedCampaignId: stringValue(selectedLane.campaignId) ?? actionCampaignId(action),
|
|
127
|
+
planRevision: stringValue(plan.planRevision),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function mergeRunState(current, updates) {
|
|
131
|
+
return {
|
|
132
|
+
...current,
|
|
133
|
+
...updates,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function normalizeDoneReason(value) {
|
|
137
|
+
const raw = stringValue(value);
|
|
138
|
+
if (!raw)
|
|
139
|
+
return "blocked";
|
|
140
|
+
if (raw === "awaiting_scheduler_after_ready_buffer") {
|
|
141
|
+
return "loaded_awaiting_scheduler";
|
|
142
|
+
}
|
|
143
|
+
return REFILL_DONE_REASONS.has(raw) ? raw : "blocked";
|
|
144
|
+
}
|
|
145
|
+
function runStateProgress(ctx) {
|
|
146
|
+
return recordValue(ctx.runState.progress) ?? {};
|
|
147
|
+
}
|
|
148
|
+
function mergeProgress(ctx, updates) {
|
|
149
|
+
return {
|
|
150
|
+
...runStateProgress(ctx),
|
|
151
|
+
...updates,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function filenameFromPath(filePath) {
|
|
155
|
+
if (!filePath)
|
|
156
|
+
return null;
|
|
157
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
158
|
+
return normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
159
|
+
}
|
|
160
|
+
function safeJson(value) {
|
|
161
|
+
try {
|
|
162
|
+
return JSON.stringify(value);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return String(value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function summarizeAction(action) {
|
|
169
|
+
if (!action)
|
|
170
|
+
return "no action";
|
|
171
|
+
return [
|
|
172
|
+
stringValue(action.evergreenRung) ?? stringValue(action.type) ?? "action",
|
|
173
|
+
stringValue(action.toolName),
|
|
174
|
+
actionCampaignId(action),
|
|
175
|
+
]
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
.join(" ");
|
|
178
|
+
}
|
|
179
|
+
function resultPayload(value) {
|
|
180
|
+
const record = recordValue(value) ?? {};
|
|
181
|
+
const result = recordValue(record.result);
|
|
182
|
+
return result ?? record;
|
|
183
|
+
}
|
|
184
|
+
function activeJobIdFromOutcome(value) {
|
|
185
|
+
const payload = resultPayload(value);
|
|
186
|
+
return stringValue(payload.activeJobId) ?? stringValue(payload.jobId);
|
|
187
|
+
}
|
|
188
|
+
function isTerminalJobStatus(value) {
|
|
189
|
+
const status = stringValue(recordValue(value)?.status);
|
|
190
|
+
const jobStatus = stringValue(recordValue(value)?.jobStatus) ?? status;
|
|
191
|
+
return Boolean(jobStatus && jobStatus !== "pending" && jobStatus !== "running");
|
|
192
|
+
}
|
|
193
|
+
function preparedCountFromStatus(value) {
|
|
194
|
+
const status = recordValue(value) ?? {};
|
|
195
|
+
const progress = recordValue(status.progress) ?? {};
|
|
196
|
+
return (numberValue(progress.preparedCapacityRows) ??
|
|
197
|
+
numberValue(progress.preparedMessages) ??
|
|
198
|
+
numberValue(status.preparedMessages) ??
|
|
199
|
+
0);
|
|
200
|
+
}
|
|
201
|
+
function checkedRowsFromStatus(value) {
|
|
202
|
+
const status = recordValue(value) ?? {};
|
|
203
|
+
const progress = recordValue(status.progress) ?? {};
|
|
204
|
+
return (numberValue(progress.checkedRows) ?? numberValue(status.checkedRows) ?? 0);
|
|
205
|
+
}
|
|
206
|
+
function expectedPrepareRequestHash(deps, action) {
|
|
207
|
+
const campaignId = actionCampaignId(action);
|
|
208
|
+
if (!campaignId)
|
|
209
|
+
return null;
|
|
210
|
+
const toolInput = actionToolInput(action);
|
|
211
|
+
const approvalMode = toolInput.approvalMode === "approve" ? "approve" : "mark_ready";
|
|
212
|
+
const prepareRowSelector = deps.executors.prepareRowSelectorValue ?? defaultPrepareRowSelectorValue;
|
|
213
|
+
const requestHash = deps.executors.refillPrepareRequestHash ?? defaultRefillPrepareRequestHash;
|
|
214
|
+
return requestHash({
|
|
215
|
+
action,
|
|
216
|
+
campaignId,
|
|
217
|
+
tableId: stringValue(toolInput.tableId) ?? undefined,
|
|
218
|
+
approvalMode,
|
|
219
|
+
rowSelector: prepareRowSelector(toolInput.rowSelector),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function lastAction(ctx) {
|
|
223
|
+
return (recordValue(ctx.runState.lastExecutedAction) ??
|
|
224
|
+
ctx.headAction ??
|
|
225
|
+
recordValue(ctx.runState.headAction));
|
|
226
|
+
}
|
|
227
|
+
function lastEventId(ctx) {
|
|
228
|
+
return stringValue(ctx.runState.lastEventId) ?? `${ctx.runId}:${ctx.gateSeq}`;
|
|
229
|
+
}
|
|
230
|
+
function coverageSnapshot(plan) {
|
|
231
|
+
const packet = recordValue(plan.packet) ?? {};
|
|
232
|
+
const target = recordValue(plan.target) ?? {};
|
|
233
|
+
const coverage = recordValue(packet.coverage) ??
|
|
234
|
+
recordValue(target.coverage) ??
|
|
235
|
+
recordValue(plan.coverage) ??
|
|
236
|
+
{};
|
|
237
|
+
return {
|
|
238
|
+
sent: numberValue(coverage.sent) ?? numberValue(plan.sent) ?? 0,
|
|
239
|
+
scheduled: numberValue(coverage.scheduled) ?? numberValue(plan.scheduled) ?? 0,
|
|
240
|
+
ready: numberValue(coverage.ready) ?? numberValue(plan.ready) ?? 0,
|
|
241
|
+
goal: numberValue(coverage.goal) ?? numberValue(target.goal) ?? 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function actionTableId(action) {
|
|
245
|
+
if (!action)
|
|
246
|
+
return null;
|
|
247
|
+
const toolInput = actionToolInput(action);
|
|
248
|
+
const ids = actionIds(action);
|
|
249
|
+
return stringValue(toolInput.tableId) ?? stringValue(ids.tableId);
|
|
250
|
+
}
|
|
251
|
+
function noProgressSignature(plan, action) {
|
|
252
|
+
const coverage = coverageSnapshot(plan);
|
|
253
|
+
return {
|
|
254
|
+
headRung: stringValue(action.evergreenRung) ?? stringValue(action.type),
|
|
255
|
+
actionFingerprint: [
|
|
256
|
+
stringValue(action.type),
|
|
257
|
+
actionCampaignId(action),
|
|
258
|
+
actionTableId(action),
|
|
259
|
+
actionSenderId(action),
|
|
260
|
+
]
|
|
261
|
+
.filter(Boolean)
|
|
262
|
+
.join(":"),
|
|
263
|
+
counters: {
|
|
264
|
+
sent: coverage.sent,
|
|
265
|
+
scheduled: coverage.scheduled,
|
|
266
|
+
ready: coverage.ready,
|
|
267
|
+
goal: coverage.goal,
|
|
268
|
+
neverRun: numberValue(recordValue(plan.supplyCensus)?.neverRun) ??
|
|
269
|
+
numberValue(recordValue(recordValue(plan.packet)?.supplyCensus)?.neverRun) ??
|
|
270
|
+
0,
|
|
271
|
+
laneCursor: numberValue(recordValue(plan.selectedLane)?.laneCursor) ??
|
|
272
|
+
numberValue(recordValue(plan.evergreen)?.laneCursor) ??
|
|
273
|
+
0,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function nextNoProgressState(ctx, plan, action) {
|
|
278
|
+
const next = noProgressSignature(plan, action);
|
|
279
|
+
const prior = recordValue(runStateProgress(ctx).noProgress) ?? {};
|
|
280
|
+
const priorSignature = recordValue(prior.signature);
|
|
281
|
+
const same = priorSignature && safeJson(priorSignature) === safeJson(next);
|
|
282
|
+
const repeatCount = same ? (numberValue(prior.repeatCount) ?? 1) + 1 : 1;
|
|
283
|
+
return {
|
|
284
|
+
signature: next,
|
|
285
|
+
repeatCount,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function sourceHandoffText(action) {
|
|
289
|
+
const campaignId = actionCampaignId(action) ?? "campaign";
|
|
290
|
+
const toolInput = actionToolInput(action);
|
|
291
|
+
const sourceLeadListId = stringValue(toolInput.sourceLeadListId);
|
|
292
|
+
return [
|
|
293
|
+
`$sellable:create-campaign ${campaignId}`,
|
|
294
|
+
"Scope: add leads via this campaign's existing source only.",
|
|
295
|
+
sourceLeadListId
|
|
296
|
+
? `Import mode: add into sourceLeadListId=${sourceLeadListId}.`
|
|
297
|
+
: null,
|
|
298
|
+
"No brief, rubric, message, sequence, sender, provider-family, or campaign changes without a separate approval packet.",
|
|
299
|
+
]
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.join(" ");
|
|
302
|
+
}
|
|
303
|
+
function getPlanLine(plan) {
|
|
304
|
+
const plans = arrayValue(plan.plans).filter(isRecord);
|
|
305
|
+
const firstPlan = plans[0] ?? {};
|
|
306
|
+
const itinerary = recordValue(firstPlan.itinerary) ?? {};
|
|
307
|
+
const chosen = recordValue(itinerary.chosen) ?? {};
|
|
308
|
+
const projected = arrayValue(itinerary.projected).filter(isRecord);
|
|
309
|
+
return {
|
|
310
|
+
chosenSummary: stringValue(chosen.summary) ?? summarizeAction(firstAction(plan)),
|
|
311
|
+
projectedSummaries: projected
|
|
312
|
+
.map((entry) => stringValue(entry.summary))
|
|
313
|
+
.filter((entry) => Boolean(entry)),
|
|
314
|
+
fallbackSummary: stringValue(recordValue(itinerary.fallback)?.trigger) ?? "",
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function packetCoherenceFailure(plan) {
|
|
318
|
+
const fingerprint = fingerprintFromPlan(plan);
|
|
319
|
+
if (!fingerprint.planRevision || !fingerprint.targetShapeRevision) {
|
|
320
|
+
return "missing_fingerprint";
|
|
321
|
+
}
|
|
322
|
+
const action = firstAction(plan);
|
|
323
|
+
const doneReason = doneReasonFromPlan(plan);
|
|
324
|
+
if (doneReason && action && action.type !== "done") {
|
|
325
|
+
return "terminal_with_action_queue";
|
|
326
|
+
}
|
|
327
|
+
if (!action)
|
|
328
|
+
return null;
|
|
329
|
+
const toolInput = actionToolInput(action);
|
|
330
|
+
const type = stringValue(action.type);
|
|
331
|
+
if (type === "prepare_messages" &&
|
|
332
|
+
(!actionCampaignId(action) || !stringValue(toolInput.tableId))) {
|
|
333
|
+
return "malformed_prepare_tool_input";
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
async function safeJournalAppend(ctx, deps, block) {
|
|
338
|
+
if (!ctx.journalPath)
|
|
339
|
+
return;
|
|
340
|
+
try {
|
|
341
|
+
deps.journal.appendJournalEvent(ctx.journalPath, block);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// Best effort; journal failure must not stop execution.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function safeLocalStateWrite(input, deps, update) {
|
|
348
|
+
if (input.dryRun || !deps.localState?.writeRefillWorkspaceState)
|
|
349
|
+
return;
|
|
350
|
+
try {
|
|
351
|
+
await deps.localState.writeRefillWorkspaceState(input.workspaceId, update);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Best effort; local calibration failure must not kill a leased run.
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async function sleepForWait(input, deps, ctx, budgets) {
|
|
358
|
+
if (ctx.sleptMs + budgets.pollIntervalMs >
|
|
359
|
+
budgets.waitWallClockPerInvocationMs) {
|
|
360
|
+
return heartbeatAndContinue(input, deps, ctx);
|
|
361
|
+
}
|
|
362
|
+
const heartbeat = await deps.runClient.heartbeatRefillRun({
|
|
363
|
+
workspaceId: input.workspaceId,
|
|
364
|
+
runId: ctx.runId,
|
|
365
|
+
fence: ctx.fence,
|
|
366
|
+
});
|
|
367
|
+
if (isLeaseLost(heartbeat))
|
|
368
|
+
return leaseLostResult(ctx);
|
|
369
|
+
ctx.sleptMs += budgets.pollIntervalMs;
|
|
370
|
+
await (deps.sleep?.(budgets.pollIntervalMs) ??
|
|
371
|
+
new Promise((resolve) => {
|
|
372
|
+
setTimeout(resolve, budgets.pollIntervalMs);
|
|
373
|
+
}));
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
async function createJournal(input, deps, runId) {
|
|
377
|
+
try {
|
|
378
|
+
return deps.journal.createRunJournal({
|
|
379
|
+
workspaceId: input.workspaceId,
|
|
380
|
+
runId,
|
|
381
|
+
dryRun: false,
|
|
382
|
+
now: deps.now?.() ?? new Date(),
|
|
383
|
+
}).filePath;
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function startOrResume(input, deps) {
|
|
390
|
+
const initialRunState = buildRunStateFromLocalHints(input.workspaceId);
|
|
391
|
+
const started = await deps.runClient.startRefillRunRemote({
|
|
392
|
+
workspaceId: input.workspaceId,
|
|
393
|
+
config: {
|
|
394
|
+
intent: input.intent ?? "auto",
|
|
395
|
+
senderIds: input.senderIds,
|
|
396
|
+
approvalMode: input.approvalMode ?? "approve",
|
|
397
|
+
},
|
|
398
|
+
runState: initialRunState,
|
|
399
|
+
resume: input.resume,
|
|
400
|
+
});
|
|
401
|
+
if (isLeaseLost(started)) {
|
|
402
|
+
return {
|
|
403
|
+
status: "blocked",
|
|
404
|
+
blocker: "lease_lost",
|
|
405
|
+
guidance: "Refill run lease was lost. Re-invoke after the lease TTL expires or resume with a fresh holder handle.",
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (isActiveRun(started)) {
|
|
409
|
+
return {
|
|
410
|
+
status: "blocked",
|
|
411
|
+
blocker: "active_run_in_progress",
|
|
412
|
+
holderRunId: started.runId,
|
|
413
|
+
gate: started.gate ?? undefined,
|
|
414
|
+
guidance: "A refill run is already active for this workspace. Use the original handle or wait for the lease TTL window before retrying takeover.",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const result = started;
|
|
418
|
+
const runId = stringValue(result.runId);
|
|
419
|
+
const fence = numberValue(result.fence);
|
|
420
|
+
if (!runId || fence == null) {
|
|
421
|
+
return {
|
|
422
|
+
status: "blocked",
|
|
423
|
+
blocker: "bad_start_response",
|
|
424
|
+
guidance: "The refill run route returned an unusable start response.",
|
|
425
|
+
report: { response: started },
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const gate = (stringValue(result.gate) ?? "g0_bootstrap");
|
|
429
|
+
const gateSeq = numberValue(result.gateSeq) ?? 0;
|
|
430
|
+
const runState = recordValue(result.runState) ??
|
|
431
|
+
recordValue(initialRunState) ??
|
|
432
|
+
{
|
|
433
|
+
version: 1,
|
|
434
|
+
senderCursors: [],
|
|
435
|
+
laneCooldowns: [],
|
|
436
|
+
passRates: [],
|
|
437
|
+
};
|
|
438
|
+
const journalPath = await createJournal(input, deps, runId);
|
|
439
|
+
const ctx = {
|
|
440
|
+
workspaceId: input.workspaceId,
|
|
441
|
+
runId,
|
|
442
|
+
fence,
|
|
443
|
+
gate,
|
|
444
|
+
gateSeq,
|
|
445
|
+
runState,
|
|
446
|
+
journalPath,
|
|
447
|
+
plan: null,
|
|
448
|
+
headAction: null,
|
|
449
|
+
lastFingerprint: null,
|
|
450
|
+
recoveryPoint: recordValue(result.recoveryPoint),
|
|
451
|
+
gateCycles: 0,
|
|
452
|
+
sleptMs: 0,
|
|
453
|
+
blockedContinuations: [],
|
|
454
|
+
lanesStarted: [],
|
|
455
|
+
lanesTouched: [],
|
|
456
|
+
};
|
|
457
|
+
const takeover = recordValue(result.takeover);
|
|
458
|
+
if (takeover && takeover.crashed === true) {
|
|
459
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderCrashedAbandonedSection({
|
|
460
|
+
runId,
|
|
461
|
+
priorGate: stringValue(takeover.priorGate) ?? gate,
|
|
462
|
+
decidedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
463
|
+
}));
|
|
464
|
+
}
|
|
465
|
+
return ctx;
|
|
466
|
+
}
|
|
467
|
+
async function gateBootstrap(input, deps, ctx) {
|
|
468
|
+
const plan = await deps.readPlan({
|
|
469
|
+
workspaceId: input.workspaceId,
|
|
470
|
+
intent: input.intent,
|
|
471
|
+
senderIds: input.senderIds,
|
|
472
|
+
runState: ctx.runState,
|
|
473
|
+
journal: false,
|
|
474
|
+
});
|
|
475
|
+
const paidCredit = arrayValue(recordValue(plan.bootstrap)?.paidCredit).filter(isRecord);
|
|
476
|
+
const refreshedSenderIds = new Set(arrayValue(recordValue(ctx.runState.creditTrust)?.senderIds)
|
|
477
|
+
.map((entry) => stringValue(entry))
|
|
478
|
+
.filter((entry) => Boolean(entry)));
|
|
479
|
+
const receipts = [];
|
|
480
|
+
for (const entry of paidCredit) {
|
|
481
|
+
const senderId = stringValue(entry.senderId);
|
|
482
|
+
if (!senderId ||
|
|
483
|
+
!entry.plannedJitRefresh ||
|
|
484
|
+
refreshedSenderIds.has(senderId)) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const receipt = await deps.executors.refreshPaidInmailCreditsWithRetry(senderId, input.workspaceId);
|
|
488
|
+
receipts.push({ senderId, receipt });
|
|
489
|
+
refreshedSenderIds.add(senderId);
|
|
490
|
+
}
|
|
491
|
+
const nextRunState = mergeRunState(ctx.runState, {
|
|
492
|
+
creditTrust: {
|
|
493
|
+
refreshedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
494
|
+
senderIds: [...refreshedSenderIds],
|
|
495
|
+
receipts,
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderBootstrapSection({
|
|
499
|
+
summary: "Refill v2 execution bootstrap",
|
|
500
|
+
senderSummary: safeJson(recordValue(plan.bootstrap)?.senders ?? []),
|
|
501
|
+
laneSummary: safeJson(recordValue(plan.bootstrap)?.laneOrder ?? []),
|
|
502
|
+
targetSummary: safeJson(recordValue(plan.bootstrap)?.target ?? []),
|
|
503
|
+
creditSummary: safeJson(recordValue(plan.bootstrap)?.paidCredit ?? []),
|
|
504
|
+
}));
|
|
505
|
+
const advanced = await deps.runClient.advanceRefillRunGateRemote({
|
|
506
|
+
workspaceId: input.workspaceId,
|
|
507
|
+
runId: ctx.runId,
|
|
508
|
+
fence: ctx.fence,
|
|
509
|
+
expectedSeq: ctx.gateSeq,
|
|
510
|
+
nextGate: "g1_plan",
|
|
511
|
+
runState: nextRunState,
|
|
512
|
+
});
|
|
513
|
+
if (isLeaseLost(advanced))
|
|
514
|
+
return leaseLostResult(ctx);
|
|
515
|
+
ctx.gate = "g1_plan";
|
|
516
|
+
ctx.gateSeq += 1;
|
|
517
|
+
ctx.runState = nextRunState;
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
async function completeTerminal(input, deps, ctx, doneReasonInput, reportInput = {}) {
|
|
521
|
+
const doneReason = normalizeDoneReason(doneReasonInput);
|
|
522
|
+
const coverage = ctx.plan
|
|
523
|
+
? coverageSnapshot(ctx.plan)
|
|
524
|
+
: { sent: 0, scheduled: 0, ready: 0, goal: 0 };
|
|
525
|
+
const progress = {
|
|
526
|
+
sendsLoaded: numberValue(reportInput.sendsLoaded) ??
|
|
527
|
+
(coverage.scheduled ?? 0) + (coverage.sent ?? 0),
|
|
528
|
+
lanesTouched: ctx.lanesTouched,
|
|
529
|
+
lanesStarted: ctx.lanesStarted,
|
|
530
|
+
gateCycles: ctx.gateCycles,
|
|
531
|
+
blockedLanes: ctx.blockedContinuations,
|
|
532
|
+
...(recordValue(reportInput.progress) ?? {}),
|
|
533
|
+
};
|
|
534
|
+
const report = {
|
|
535
|
+
doneReason,
|
|
536
|
+
coverage,
|
|
537
|
+
lanesTouched: ctx.lanesTouched,
|
|
538
|
+
lanesStarted: ctx.lanesStarted,
|
|
539
|
+
blockedContinuations: ctx.blockedContinuations,
|
|
540
|
+
journalPath: ctx.journalPath,
|
|
541
|
+
...reportInput,
|
|
542
|
+
};
|
|
543
|
+
const terminalRunState = mergeRunState(ctx.runState, {
|
|
544
|
+
terminal: {
|
|
545
|
+
doneReason,
|
|
546
|
+
completedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
const advanced = await deps.runClient.advanceRefillRunGateRemote({
|
|
550
|
+
workspaceId: input.workspaceId,
|
|
551
|
+
runId: ctx.runId,
|
|
552
|
+
fence: ctx.fence,
|
|
553
|
+
expectedSeq: ctx.gateSeq,
|
|
554
|
+
nextGate: "g4_terminal",
|
|
555
|
+
doneReason,
|
|
556
|
+
progress,
|
|
557
|
+
runState: terminalRunState,
|
|
558
|
+
});
|
|
559
|
+
if (isLeaseLost(advanced))
|
|
560
|
+
return leaseLostResult(ctx);
|
|
561
|
+
ctx.gate = "g4_terminal";
|
|
562
|
+
ctx.gateSeq += 1;
|
|
563
|
+
ctx.runState = terminalRunState;
|
|
564
|
+
const completed = await deps.runClient.completeRefillRun({
|
|
565
|
+
workspaceId: input.workspaceId,
|
|
566
|
+
runId: ctx.runId,
|
|
567
|
+
fence: ctx.fence,
|
|
568
|
+
doneReason,
|
|
569
|
+
progress,
|
|
570
|
+
});
|
|
571
|
+
if (isLeaseLost(completed))
|
|
572
|
+
return leaseLostResult(ctx);
|
|
573
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderTerminalSection({
|
|
574
|
+
summary: `Run ended with ${doneReason}.`,
|
|
575
|
+
finalNumbers: doneReason === "loaded_awaiting_scheduler"
|
|
576
|
+
? `remainingReady=${String(reportInput.remainingReady ?? "unknown")}; expectedPickupAt=${String(reportInput.expectedPickupAt ?? "unknown")}; resume={runId:${ctx.runId},fence:${ctx.fence},gate:${ctx.gate}}`
|
|
577
|
+
: undefined,
|
|
578
|
+
executionNumbers: {
|
|
579
|
+
doneReason,
|
|
580
|
+
sendsLoaded: progress.sendsLoaded,
|
|
581
|
+
lanesTouched: ctx.lanesTouched,
|
|
582
|
+
lanesStarted: ctx.lanesStarted,
|
|
583
|
+
gateCycles: ctx.gateCycles,
|
|
584
|
+
},
|
|
585
|
+
}));
|
|
586
|
+
const fileName = filenameFromPath(ctx.journalPath);
|
|
587
|
+
if (fileName && deps.journal.appendIndexLine) {
|
|
588
|
+
deps.journal.appendIndexLine({
|
|
589
|
+
runId: ctx.runId,
|
|
590
|
+
fileName,
|
|
591
|
+
workspaceId: input.workspaceId,
|
|
592
|
+
dryRun: false,
|
|
593
|
+
summary: doneReason,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
await safeLocalStateWrite(input, deps, {
|
|
597
|
+
runSummary: {
|
|
598
|
+
runId: ctx.runId,
|
|
599
|
+
endedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
600
|
+
terminalState: doneReason,
|
|
601
|
+
sendsLoaded: progress.sendsLoaded,
|
|
602
|
+
lanesTouched: ctx.lanesTouched,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
return {
|
|
606
|
+
status: "terminal",
|
|
607
|
+
doneReason,
|
|
608
|
+
report,
|
|
609
|
+
runId: ctx.runId,
|
|
610
|
+
journalPath: ctx.journalPath,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
async function gatePlan(input, deps, ctx) {
|
|
614
|
+
const plan = await deps.readPlan({
|
|
615
|
+
workspaceId: input.workspaceId,
|
|
616
|
+
intent: input.intent,
|
|
617
|
+
senderIds: input.senderIds,
|
|
618
|
+
runState: ctx.runState,
|
|
619
|
+
journal: false,
|
|
620
|
+
});
|
|
621
|
+
if (plan.blocker === "workspace_access") {
|
|
622
|
+
return {
|
|
623
|
+
status: "blocked",
|
|
624
|
+
blocker: "workspace_access",
|
|
625
|
+
runId: ctx.runId,
|
|
626
|
+
fence: ctx.fence,
|
|
627
|
+
gate: ctx.gate,
|
|
628
|
+
guidance: stringValue(plan.guidance) ?? "Workspace access blocked.",
|
|
629
|
+
journalPath: ctx.journalPath,
|
|
630
|
+
report: plan,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const coherenceFailure = packetCoherenceFailure(plan);
|
|
634
|
+
if (coherenceFailure) {
|
|
635
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderPlanSection({
|
|
636
|
+
chosenSummary: `packet_incoherent: ${coherenceFailure}`,
|
|
637
|
+
itinerarySummaries: [],
|
|
638
|
+
fallbackSummary: "replan once before blocking",
|
|
639
|
+
}));
|
|
640
|
+
return {
|
|
641
|
+
status: "blocked",
|
|
642
|
+
blocker: "packet_incoherent",
|
|
643
|
+
runId: ctx.runId,
|
|
644
|
+
fence: ctx.fence,
|
|
645
|
+
gate: ctx.gate,
|
|
646
|
+
guidance: `Planner returned an incoherent packet: ${coherenceFailure}.`,
|
|
647
|
+
report: { coherenceFailure, plan },
|
|
648
|
+
journalPath: ctx.journalPath,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
const action = firstAction(plan);
|
|
652
|
+
const doneReason = doneReasonFromPlan(plan);
|
|
653
|
+
ctx.plan = plan;
|
|
654
|
+
if (!action) {
|
|
655
|
+
if (doneReason) {
|
|
656
|
+
return completeTerminal(input, deps, ctx, doneReason, { plan });
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
status: "blocked",
|
|
660
|
+
blocker: "empty_queue_no_reason",
|
|
661
|
+
runId: ctx.runId,
|
|
662
|
+
fence: ctx.fence,
|
|
663
|
+
gate: ctx.gate,
|
|
664
|
+
guidance: "Planner returned an empty queue without a doneReason.",
|
|
665
|
+
report: { doneReason, plan },
|
|
666
|
+
journalPath: ctx.journalPath,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (doneReason && stringValue(action.type) === "done") {
|
|
670
|
+
return completeTerminal(input, deps, ctx, doneReason, { plan });
|
|
671
|
+
}
|
|
672
|
+
const line = getPlanLine(plan);
|
|
673
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderPlanSection({
|
|
674
|
+
chosenSummary: line.chosenSummary,
|
|
675
|
+
itinerarySummaries: line.projectedSummaries,
|
|
676
|
+
fallbackSummary: line.fallbackSummary,
|
|
677
|
+
}));
|
|
678
|
+
const fingerprint = fingerprintFromPlan(plan);
|
|
679
|
+
const noProgress = nextNoProgressState(ctx, plan, action);
|
|
680
|
+
if (noProgress.repeatCount >= 3) {
|
|
681
|
+
return completeTerminal(input, deps, ctx, "blocked", {
|
|
682
|
+
blocker: "no_forward_progress",
|
|
683
|
+
stuckRung: stringValue(action.evergreenRung) ?? stringValue(action.type),
|
|
684
|
+
repeatedHeadAction: action,
|
|
685
|
+
progress: { noProgress },
|
|
686
|
+
guidance: "The same refill rung repeated three times with no coverage, frontier, or lane movement.",
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const nextRunState = mergeRunState(ctx.runState, {
|
|
690
|
+
lastPlan: fingerprint,
|
|
691
|
+
headAction: action,
|
|
692
|
+
progress: mergeProgress(ctx, { noProgress }),
|
|
693
|
+
});
|
|
694
|
+
const advanced = await deps.runClient.advanceRefillRunGateRemote({
|
|
695
|
+
workspaceId: input.workspaceId,
|
|
696
|
+
runId: ctx.runId,
|
|
697
|
+
fence: ctx.fence,
|
|
698
|
+
expectedSeq: ctx.gateSeq,
|
|
699
|
+
nextGate: "g2_execute",
|
|
700
|
+
runState: nextRunState,
|
|
701
|
+
planRevision: fingerprint.planRevision,
|
|
702
|
+
stateRevision: fingerprint.stateRevision,
|
|
703
|
+
targetShapeRevision: fingerprint.targetShapeRevision,
|
|
704
|
+
});
|
|
705
|
+
if (isLeaseLost(advanced))
|
|
706
|
+
return leaseLostResult(ctx);
|
|
707
|
+
ctx.gate = "g2_execute";
|
|
708
|
+
ctx.gateSeq += 1;
|
|
709
|
+
ctx.runState = nextRunState;
|
|
710
|
+
ctx.headAction = action;
|
|
711
|
+
ctx.lastFingerprint = fingerprint;
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
async function verifyRecoveryPoint(input, deps, ctx) {
|
|
715
|
+
if (!ctx.recoveryPoint)
|
|
716
|
+
return null;
|
|
717
|
+
const planned = recordValue(ctx.recoveryPoint.planned) ?? {};
|
|
718
|
+
const expected = {
|
|
719
|
+
planRevision: stringValue(planned.planRevision),
|
|
720
|
+
stateRevision: stringValue(planned.stateRevision),
|
|
721
|
+
targetShapeRevision: stringValue(planned.targetShapeRevision),
|
|
722
|
+
};
|
|
723
|
+
const fresh = await deps.readPlan({
|
|
724
|
+
workspaceId: input.workspaceId,
|
|
725
|
+
intent: input.intent,
|
|
726
|
+
senderIds: input.senderIds,
|
|
727
|
+
runState: ctx.runState,
|
|
728
|
+
journal: false,
|
|
729
|
+
});
|
|
730
|
+
const changed = fingerprintChanged(expected, fingerprintFromPlan(fresh));
|
|
731
|
+
if (changed.length > 0) {
|
|
732
|
+
await deps.runClient.recordRunOutcome({
|
|
733
|
+
workspaceId: input.workspaceId,
|
|
734
|
+
runId: ctx.runId,
|
|
735
|
+
fence: ctx.fence,
|
|
736
|
+
eventId: stringValue(ctx.recoveryPoint.eventId) ?? `${ctx.runId}:recovery`,
|
|
737
|
+
did: { reverified: true },
|
|
738
|
+
outcome: { status: "stale_refused", changed },
|
|
739
|
+
});
|
|
740
|
+
ctx.recoveryPoint = null;
|
|
741
|
+
ctx.gate = "g1_plan";
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
ctx.recoveryPoint = null;
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
async function gateExecute(input, deps, ctx) {
|
|
748
|
+
const recoveryResult = await verifyRecoveryPoint(input, deps, ctx);
|
|
749
|
+
if (recoveryResult)
|
|
750
|
+
return recoveryResult;
|
|
751
|
+
if (ctx.gate !== "g2_execute")
|
|
752
|
+
return null;
|
|
753
|
+
const action = ctx.headAction;
|
|
754
|
+
const plan = ctx.plan;
|
|
755
|
+
if (!action || !plan) {
|
|
756
|
+
ctx.gate = "g1_plan";
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
if (action.yoloEligible === false) {
|
|
760
|
+
const continuation = {
|
|
761
|
+
action,
|
|
762
|
+
handoff: action.type === "continue_sales_nav_source" ||
|
|
763
|
+
action.type === "continue_prospeo_source"
|
|
764
|
+
? sourceHandoffText(action)
|
|
765
|
+
: undefined,
|
|
766
|
+
};
|
|
767
|
+
ctx.blockedContinuations.push(continuation);
|
|
768
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderExecutionEventSection({
|
|
769
|
+
gate: ctx.gate,
|
|
770
|
+
rung: stringValue(action.evergreenRung),
|
|
771
|
+
planned: {
|
|
772
|
+
toolName: stringValue(action.toolName),
|
|
773
|
+
inputSummary: safeJson(continuation),
|
|
774
|
+
planRevision: ctx.lastFingerprint?.planRevision,
|
|
775
|
+
},
|
|
776
|
+
outcome: "blocked continuation packet recorded",
|
|
777
|
+
}));
|
|
778
|
+
return {
|
|
779
|
+
status: "blocked",
|
|
780
|
+
blocker: "non_yolo_action",
|
|
781
|
+
runId: ctx.runId,
|
|
782
|
+
fence: ctx.fence,
|
|
783
|
+
gate: ctx.gate,
|
|
784
|
+
guidance: "The head refill action requires explicit continuation.",
|
|
785
|
+
report: continuation,
|
|
786
|
+
journalPath: ctx.journalPath,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const planned = {
|
|
790
|
+
toolName: stringValue(action.toolName),
|
|
791
|
+
toolInput: actionToolInput(action),
|
|
792
|
+
planRevision: ctx.lastFingerprint?.planRevision,
|
|
793
|
+
stateRevision: ctx.lastFingerprint?.stateRevision,
|
|
794
|
+
targetShapeRevision: ctx.lastFingerprint?.targetShapeRevision,
|
|
795
|
+
dispatchKey: stringValue(action.type),
|
|
796
|
+
evergreenRung: stringValue(action.evergreenRung),
|
|
797
|
+
sideEffectClass: stringValue(action.sideEffectClass),
|
|
798
|
+
};
|
|
799
|
+
const plannedResult = await deps.runClient.recordRunPlanned({
|
|
800
|
+
workspaceId: input.workspaceId,
|
|
801
|
+
runId: ctx.runId,
|
|
802
|
+
fence: ctx.fence,
|
|
803
|
+
gate: ctx.gate,
|
|
804
|
+
gateSeq: ctx.gateSeq,
|
|
805
|
+
planned,
|
|
806
|
+
});
|
|
807
|
+
if (isLeaseLost(plannedResult))
|
|
808
|
+
return leaseLostResult(ctx);
|
|
809
|
+
const eventId = stringValue(recordValue(plannedResult)?.eventId) ??
|
|
810
|
+
`${ctx.runId}:${ctx.gateSeq}`;
|
|
811
|
+
let did;
|
|
812
|
+
let outcome;
|
|
813
|
+
if (action.type === "start_paused_campaign") {
|
|
814
|
+
did = await deps.executors.executeStartCampaignPrimitive({
|
|
815
|
+
action,
|
|
816
|
+
workspaceId: input.workspaceId,
|
|
817
|
+
authority: selectedLaneAuthority(plan, action),
|
|
818
|
+
});
|
|
819
|
+
outcome = did;
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
did = await deps.executors.executeOneYoloPrimitive({
|
|
823
|
+
...action,
|
|
824
|
+
toolInput: {
|
|
825
|
+
...actionToolInput(action),
|
|
826
|
+
approvalMode: input.approvalMode ?? actionToolInput(action).approvalMode,
|
|
827
|
+
},
|
|
828
|
+
}, input.workspaceId);
|
|
829
|
+
outcome = did;
|
|
830
|
+
}
|
|
831
|
+
await deps.runClient.recordRunOutcome({
|
|
832
|
+
workspaceId: input.workspaceId,
|
|
833
|
+
runId: ctx.runId,
|
|
834
|
+
fence: ctx.fence,
|
|
835
|
+
eventId,
|
|
836
|
+
did,
|
|
837
|
+
outcome,
|
|
838
|
+
});
|
|
839
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderExecutionEventSection({
|
|
840
|
+
gate: ctx.gate,
|
|
841
|
+
rung: stringValue(action.evergreenRung),
|
|
842
|
+
planned: {
|
|
843
|
+
toolName: stringValue(action.toolName),
|
|
844
|
+
inputSummary: safeJson(planned.toolInput),
|
|
845
|
+
planRevision: ctx.lastFingerprint?.planRevision,
|
|
846
|
+
},
|
|
847
|
+
did: safeJson(did),
|
|
848
|
+
outcome: safeJson(outcome),
|
|
849
|
+
}));
|
|
850
|
+
const nextRunState = mergeRunState(ctx.runState, {
|
|
851
|
+
lastEventId: eventId,
|
|
852
|
+
lastExecutedAction: action,
|
|
853
|
+
lastOutcome: outcome,
|
|
854
|
+
});
|
|
855
|
+
const advanced = await deps.runClient.advanceRefillRunGateRemote({
|
|
856
|
+
workspaceId: input.workspaceId,
|
|
857
|
+
runId: ctx.runId,
|
|
858
|
+
fence: ctx.fence,
|
|
859
|
+
expectedSeq: ctx.gateSeq,
|
|
860
|
+
nextGate: "g3_verify",
|
|
861
|
+
runState: nextRunState,
|
|
862
|
+
});
|
|
863
|
+
if (isLeaseLost(advanced))
|
|
864
|
+
return leaseLostResult(ctx);
|
|
865
|
+
ctx.gate = "g3_verify";
|
|
866
|
+
ctx.gateSeq += 1;
|
|
867
|
+
ctx.runState = nextRunState;
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
async function advanceBackToPlan(input, deps, ctx, runStateUpdates = {}) {
|
|
871
|
+
const nextRunState = mergeRunState(ctx.runState, runStateUpdates);
|
|
872
|
+
const advanced = await deps.runClient.advanceRefillRunGateRemote({
|
|
873
|
+
workspaceId: input.workspaceId,
|
|
874
|
+
runId: ctx.runId,
|
|
875
|
+
fence: ctx.fence,
|
|
876
|
+
expectedSeq: ctx.gateSeq,
|
|
877
|
+
nextGate: "g1_plan",
|
|
878
|
+
runState: nextRunState,
|
|
879
|
+
});
|
|
880
|
+
if (isLeaseLost(advanced))
|
|
881
|
+
return leaseLostResult(ctx);
|
|
882
|
+
ctx.gate = "g1_plan";
|
|
883
|
+
ctx.gateSeq += 1;
|
|
884
|
+
ctx.runState = nextRunState;
|
|
885
|
+
ctx.headAction = null;
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
async function verifyPrepareAction(input, deps, ctx, action, budgets) {
|
|
889
|
+
const getStatus = deps.executors.getPrepareCampaignMessagesStatus;
|
|
890
|
+
const jobId = activeJobIdFromOutcome(ctx.runState.lastOutcome);
|
|
891
|
+
if (!getStatus || !jobId) {
|
|
892
|
+
return {
|
|
893
|
+
status: "in_progress",
|
|
894
|
+
runId: ctx.runId,
|
|
895
|
+
fence: ctx.fence,
|
|
896
|
+
gate: ctx.gate,
|
|
897
|
+
guidance: "Preparation dispatch has no status hook or job id yet; re-invoke with the same handle after the job appears in the run record.",
|
|
898
|
+
journalPath: ctx.journalPath,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
const expectedHash = expectedPrepareRequestHash(deps, action);
|
|
902
|
+
const tableId = stringValue(actionToolInput(action).tableId) ?? undefined;
|
|
903
|
+
const campaignId = actionCampaignId(action) ?? undefined;
|
|
904
|
+
let latestStatus = null;
|
|
905
|
+
for (let poll = 0; poll < budgets.maxPollsPerWait; poll += 1) {
|
|
906
|
+
latestStatus = await getStatus({
|
|
907
|
+
workspaceId: input.workspaceId,
|
|
908
|
+
jobId,
|
|
909
|
+
campaignId,
|
|
910
|
+
tableId,
|
|
911
|
+
});
|
|
912
|
+
const statusRecord = recordValue(latestStatus) ?? {};
|
|
913
|
+
const requestSource = stringValue(statusRecord.requestSource);
|
|
914
|
+
const requestHash = stringValue(statusRecord.requestHash);
|
|
915
|
+
const ownJob = requestSource === "refill_sends" &&
|
|
916
|
+
(!expectedHash || requestHash === expectedHash);
|
|
917
|
+
if (!ownJob && !isTerminalJobStatus(latestStatus)) {
|
|
918
|
+
const waitResult = await sleepForWait(input, deps, ctx, budgets);
|
|
919
|
+
if (waitResult)
|
|
920
|
+
return waitResult;
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (!ownJob && isTerminalJobStatus(latestStatus)) {
|
|
924
|
+
return advanceBackToPlan(input, deps, ctx, {
|
|
925
|
+
progress: mergeProgress(ctx, {
|
|
926
|
+
foreignPrepClearedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
927
|
+
}),
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
if (isTerminalJobStatus(latestStatus)) {
|
|
931
|
+
const preparedCount = preparedCountFromStatus(latestStatus);
|
|
932
|
+
const checkedRows = checkedRowsFromStatus(latestStatus);
|
|
933
|
+
await deps.runClient.recordRunOutcome({
|
|
934
|
+
workspaceId: input.workspaceId,
|
|
935
|
+
runId: ctx.runId,
|
|
936
|
+
fence: ctx.fence,
|
|
937
|
+
eventId: lastEventId(ctx),
|
|
938
|
+
outcome: {
|
|
939
|
+
status: "verified",
|
|
940
|
+
stopReason: stringValue(statusRecord.stopReason),
|
|
941
|
+
preparedCount,
|
|
942
|
+
checkedRows,
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
await safeJournalAppend(ctx, deps, deps.journal.renderExecutionEventSection({
|
|
946
|
+
gate: ctx.gate,
|
|
947
|
+
rung: stringValue(action.evergreenRung),
|
|
948
|
+
planned: {
|
|
949
|
+
toolName: "get_campaign_message_preparation_status",
|
|
950
|
+
inputSummary: `jobId=${jobId}`,
|
|
951
|
+
planRevision: ctx.lastFingerprint?.planRevision,
|
|
952
|
+
},
|
|
953
|
+
did: safeJson(latestStatus),
|
|
954
|
+
outcome: `conversion-verdict: prepared ${preparedCount}/${checkedRows}`,
|
|
955
|
+
}));
|
|
956
|
+
await safeLocalStateWrite(input, deps, {
|
|
957
|
+
passRates: [
|
|
958
|
+
{
|
|
959
|
+
campaignId: actionCampaignId(action) ?? "unknown",
|
|
960
|
+
enrichedCount: checkedRows,
|
|
961
|
+
passedCount: preparedCount,
|
|
962
|
+
observedAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
963
|
+
},
|
|
964
|
+
],
|
|
965
|
+
});
|
|
966
|
+
return advanceBackToPlan(input, deps, ctx, {
|
|
967
|
+
progress: mergeProgress(ctx, {
|
|
968
|
+
lastPrepStopReason: stringValue(statusRecord.stopReason),
|
|
969
|
+
lastPreparedCount: preparedCount,
|
|
970
|
+
lastCheckedRows: checkedRows,
|
|
971
|
+
}),
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
const waitResult = await sleepForWait(input, deps, ctx, budgets);
|
|
975
|
+
if (waitResult)
|
|
976
|
+
return waitResult;
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
status: "blocked",
|
|
980
|
+
blocker: "foreign_prep_active",
|
|
981
|
+
runId: ctx.runId,
|
|
982
|
+
fence: ctx.fence,
|
|
983
|
+
gate: ctx.gate,
|
|
984
|
+
guidance: "A preparation job is still active and did not verify within the bounded poll budget.",
|
|
985
|
+
report: { jobId, latestStatus },
|
|
986
|
+
journalPath: ctx.journalPath,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
async function verifyReadOnlyWait(input, deps, ctx, budgets, waitKind) {
|
|
990
|
+
for (let poll = 0; poll < budgets.maxPollsPerWait; poll += 1) {
|
|
991
|
+
const fresh = await deps.readPlan({
|
|
992
|
+
workspaceId: input.workspaceId,
|
|
993
|
+
intent: input.intent,
|
|
994
|
+
senderIds: input.senderIds,
|
|
995
|
+
runState: ctx.runState,
|
|
996
|
+
journal: false,
|
|
997
|
+
});
|
|
998
|
+
ctx.plan = fresh;
|
|
999
|
+
const doneReason = doneReasonFromPlan(fresh);
|
|
1000
|
+
if (doneReason) {
|
|
1001
|
+
return completeTerminal(input, deps, ctx, doneReason, {
|
|
1002
|
+
plan: fresh,
|
|
1003
|
+
waitKind,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
const action = firstAction(fresh);
|
|
1007
|
+
if (stringValue(action?.type) !== waitKind) {
|
|
1008
|
+
return advanceBackToPlan(input, deps, ctx, {
|
|
1009
|
+
progress: mergeProgress(ctx, {
|
|
1010
|
+
[`${waitKind}ClearedAt`]: (deps.now?.() ?? new Date()).toISOString(),
|
|
1011
|
+
}),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
const waitResult = await sleepForWait(input, deps, ctx, budgets);
|
|
1015
|
+
if (waitResult)
|
|
1016
|
+
return waitResult;
|
|
1017
|
+
}
|
|
1018
|
+
return heartbeatAndContinue(input, deps, ctx);
|
|
1019
|
+
}
|
|
1020
|
+
async function verifySchedulerWait(input, deps, ctx, action, budgets) {
|
|
1021
|
+
const progress = runStateProgress(ctx);
|
|
1022
|
+
const enteredAt = stringValue(progress.schedulerWaitEnteredAt) ??
|
|
1023
|
+
(deps.now?.() ?? new Date()).toISOString();
|
|
1024
|
+
const jitFired = booleanValue(progress.schedulerJitFired) ?? false;
|
|
1025
|
+
if (!jitFired) {
|
|
1026
|
+
const senderId = actionSenderId(action);
|
|
1027
|
+
if (senderId) {
|
|
1028
|
+
await deps.executors.refreshPaidInmailCreditsWithRetry(senderId, input.workspaceId);
|
|
1029
|
+
}
|
|
1030
|
+
if (deps.requestSchedulerRun) {
|
|
1031
|
+
await deps.requestSchedulerRun(input.workspaceId);
|
|
1032
|
+
}
|
|
1033
|
+
ctx.runState = mergeRunState(ctx.runState, {
|
|
1034
|
+
progress: mergeProgress(ctx, {
|
|
1035
|
+
schedulerWaitEnteredAt: enteredAt,
|
|
1036
|
+
schedulerJitFired: true,
|
|
1037
|
+
}),
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
for (let poll = 0; poll < budgets.maxSchedulerReadbacks; poll += 1) {
|
|
1041
|
+
const fresh = await deps.readPlan({
|
|
1042
|
+
workspaceId: input.workspaceId,
|
|
1043
|
+
intent: input.intent,
|
|
1044
|
+
senderIds: input.senderIds,
|
|
1045
|
+
runState: ctx.runState,
|
|
1046
|
+
journal: false,
|
|
1047
|
+
});
|
|
1048
|
+
ctx.plan = fresh;
|
|
1049
|
+
const doneReason = normalizeDoneReason(doneReasonFromPlan(fresh));
|
|
1050
|
+
if (doneReason === "complete" || doneReason === "capped_by_scheduler") {
|
|
1051
|
+
return completeTerminal(input, deps, ctx, doneReason, { plan: fresh });
|
|
1052
|
+
}
|
|
1053
|
+
if (doneReason === "loaded_awaiting_scheduler") {
|
|
1054
|
+
return completeTerminal(input, deps, ctx, doneReason, {
|
|
1055
|
+
plan: fresh,
|
|
1056
|
+
remainingReady: numberValue(fresh.remainingReady) ?? "unknown",
|
|
1057
|
+
expectedPickupAt: stringValue(fresh.expectedPickupAt) ??
|
|
1058
|
+
stringValue(recordValue(fresh.packet)?.expectedPickupAt) ??
|
|
1059
|
+
"next scheduler sweep",
|
|
1060
|
+
resume: { runId: ctx.runId, fence: ctx.fence, gate: ctx.gate },
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
const waitResult = await sleepForWait(input, deps, ctx, budgets);
|
|
1064
|
+
if (waitResult?.status === "in_progress") {
|
|
1065
|
+
return {
|
|
1066
|
+
...waitResult,
|
|
1067
|
+
guidance: `scheduler sweep in progress since ${enteredAt}; re-invoke with this handle`,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
if (waitResult)
|
|
1071
|
+
return waitResult;
|
|
1072
|
+
}
|
|
1073
|
+
return heartbeatAndContinue(input, deps, ctx);
|
|
1074
|
+
}
|
|
1075
|
+
async function gateVerify(input, deps, ctx, budgets) {
|
|
1076
|
+
if (ctx.gate !== "g3_verify")
|
|
1077
|
+
return null;
|
|
1078
|
+
const action = lastAction(ctx);
|
|
1079
|
+
if (!action) {
|
|
1080
|
+
return advanceBackToPlan(input, deps, ctx);
|
|
1081
|
+
}
|
|
1082
|
+
const actionType = stringValue(action.type);
|
|
1083
|
+
if (actionType === "prepare_messages") {
|
|
1084
|
+
return verifyPrepareAction(input, deps, ctx, action, budgets);
|
|
1085
|
+
}
|
|
1086
|
+
if (actionType === "start_paused_campaign") {
|
|
1087
|
+
return {
|
|
1088
|
+
status: "in_progress",
|
|
1089
|
+
runId: ctx.runId,
|
|
1090
|
+
fence: ctx.fence,
|
|
1091
|
+
gate: ctx.gate,
|
|
1092
|
+
guidance: "Campaign start was dispatched and recorded. Re-invoke with this handle so the next plan read starts from the new campaign state.",
|
|
1093
|
+
journalPath: ctx.journalPath,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
if (actionType === "wait_for_scheduler") {
|
|
1097
|
+
return verifySchedulerWait(input, deps, ctx, action, budgets);
|
|
1098
|
+
}
|
|
1099
|
+
if (actionType === "wait_for_active_work" ||
|
|
1100
|
+
actionType === "wait_for_source_import") {
|
|
1101
|
+
return verifyReadOnlyWait(input, deps, ctx, budgets, actionType);
|
|
1102
|
+
}
|
|
1103
|
+
const fresh = await deps.readPlan({
|
|
1104
|
+
workspaceId: input.workspaceId,
|
|
1105
|
+
intent: input.intent,
|
|
1106
|
+
senderIds: input.senderIds,
|
|
1107
|
+
runState: ctx.runState,
|
|
1108
|
+
journal: false,
|
|
1109
|
+
});
|
|
1110
|
+
ctx.plan = fresh;
|
|
1111
|
+
const doneReason = doneReasonFromPlan(fresh);
|
|
1112
|
+
if (doneReason) {
|
|
1113
|
+
return completeTerminal(input, deps, ctx, doneReason, { plan: fresh });
|
|
1114
|
+
}
|
|
1115
|
+
return advanceBackToPlan(input, deps, ctx);
|
|
1116
|
+
}
|
|
1117
|
+
function leaseLostResult(ctx) {
|
|
1118
|
+
return {
|
|
1119
|
+
status: "blocked",
|
|
1120
|
+
blocker: "lease_lost",
|
|
1121
|
+
runId: ctx.runId,
|
|
1122
|
+
fence: ctx.fence,
|
|
1123
|
+
gate: ctx.gate,
|
|
1124
|
+
guidance: "Refill run lease was lost. Re-invoke with a fresh handle or wait for lease takeover.",
|
|
1125
|
+
journalPath: ctx.journalPath,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function classifyCaughtError(error, ctx) {
|
|
1129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1130
|
+
if (error instanceof SellableApiError &&
|
|
1131
|
+
error.status >= 400 &&
|
|
1132
|
+
error.status < 500) {
|
|
1133
|
+
return {
|
|
1134
|
+
status: "blocked",
|
|
1135
|
+
blocker: "deterministic_api_error",
|
|
1136
|
+
runId: ctx?.runId,
|
|
1137
|
+
fence: ctx?.fence,
|
|
1138
|
+
gate: ctx?.gate,
|
|
1139
|
+
guidance: message,
|
|
1140
|
+
report: { status: error.status, body: error.body },
|
|
1141
|
+
journalPath: ctx?.journalPath,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
if (ctx) {
|
|
1145
|
+
return {
|
|
1146
|
+
status: "in_progress",
|
|
1147
|
+
runId: ctx.runId,
|
|
1148
|
+
fence: ctx.fence,
|
|
1149
|
+
gate: ctx.gate,
|
|
1150
|
+
guidance: `Transient refill loop error recorded; re-invoke with this handle. ${message}`,
|
|
1151
|
+
journalPath: ctx.journalPath,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
return {
|
|
1155
|
+
status: "blocked",
|
|
1156
|
+
blocker: "loop_error",
|
|
1157
|
+
guidance: message,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
async function heartbeatAndContinue(input, deps, ctx) {
|
|
1161
|
+
const heartbeat = await deps.runClient.heartbeatRefillRun({
|
|
1162
|
+
workspaceId: input.workspaceId,
|
|
1163
|
+
runId: ctx.runId,
|
|
1164
|
+
fence: ctx.fence,
|
|
1165
|
+
});
|
|
1166
|
+
if (isLeaseLost(heartbeat))
|
|
1167
|
+
return leaseLostResult(ctx);
|
|
1168
|
+
return {
|
|
1169
|
+
status: "in_progress",
|
|
1170
|
+
runId: ctx.runId,
|
|
1171
|
+
fence: ctx.fence,
|
|
1172
|
+
gate: ctx.gate,
|
|
1173
|
+
guidance: "Per-invocation gate budget reached. Re-invoke refill_sends_v2 with this runId/fence to continue.",
|
|
1174
|
+
journalPath: ctx.journalPath,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
export async function runRefillV2Loop(input, deps) {
|
|
1178
|
+
const budgets = { ...DEFAULT_BUDGETS, ...(deps.budgets ?? {}) };
|
|
1179
|
+
let ctx = null;
|
|
1180
|
+
try {
|
|
1181
|
+
if (input.dryRun) {
|
|
1182
|
+
const plan = await deps.readPlan({
|
|
1183
|
+
workspaceId: input.workspaceId,
|
|
1184
|
+
intent: input.intent,
|
|
1185
|
+
senderIds: input.senderIds,
|
|
1186
|
+
journal: true,
|
|
1187
|
+
});
|
|
1188
|
+
return { status: "dry_run", plan };
|
|
1189
|
+
}
|
|
1190
|
+
const started = await startOrResume(input, deps);
|
|
1191
|
+
if ("status" in started)
|
|
1192
|
+
return started;
|
|
1193
|
+
ctx = started;
|
|
1194
|
+
for (let cycle = 0; cycle < budgets.maxGateCyclesPerInvocation; cycle += 1) {
|
|
1195
|
+
ctx.gateCycles += 1;
|
|
1196
|
+
if (ctx.gate === "g0_bootstrap") {
|
|
1197
|
+
const result = await gateBootstrap(input, deps, ctx);
|
|
1198
|
+
if (result)
|
|
1199
|
+
return result;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (ctx.gate === "g1_plan") {
|
|
1203
|
+
const result = await gatePlan(input, deps, ctx);
|
|
1204
|
+
if (result)
|
|
1205
|
+
return result;
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
if (ctx.gate === "g2_execute") {
|
|
1209
|
+
const result = await gateExecute(input, deps, ctx);
|
|
1210
|
+
if (result)
|
|
1211
|
+
return result;
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
if (ctx.gate === "g3_verify") {
|
|
1215
|
+
const result = await gateVerify(input, deps, ctx, budgets);
|
|
1216
|
+
if (result)
|
|
1217
|
+
return result;
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (ctx.gate === "g4_terminal") {
|
|
1221
|
+
return {
|
|
1222
|
+
status: "terminal",
|
|
1223
|
+
doneReason: normalizeDoneReason(recordValue(ctx.runState.terminal)?.doneReason),
|
|
1224
|
+
report: {
|
|
1225
|
+
doneReason: normalizeDoneReason(recordValue(ctx.runState.terminal)?.doneReason),
|
|
1226
|
+
journalPath: ctx.journalPath,
|
|
1227
|
+
},
|
|
1228
|
+
runId: ctx.runId,
|
|
1229
|
+
journalPath: ctx.journalPath,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return heartbeatAndContinue(input, deps, ctx);
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
return classifyCaughtError(error, ctx);
|
|
1237
|
+
}
|
|
1238
|
+
}
|