@nodetool-ai/execution 0.8.1
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/README.md +129 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/app-debug/app-spec.d.ts +94 -0
- package/dist/app-debug/app-spec.d.ts.map +1 -0
- package/dist/app-debug/app-spec.js +710 -0
- package/dist/app-debug/app-spec.js.map +1 -0
- package/dist/app-debug/application-target.d.ts +53 -0
- package/dist/app-debug/application-target.d.ts.map +1 -0
- package/dist/app-debug/application-target.js +126 -0
- package/dist/app-debug/application-target.js.map +1 -0
- package/dist/app-debug/bundle-target.d.ts +21 -0
- package/dist/app-debug/bundle-target.d.ts.map +1 -0
- package/dist/app-debug/bundle-target.js +51 -0
- package/dist/app-debug/bundle-target.js.map +1 -0
- package/dist/app-debug/graph-shape.d.ts +14 -0
- package/dist/app-debug/graph-shape.d.ts.map +1 -0
- package/dist/app-debug/graph-shape.js +23 -0
- package/dist/app-debug/graph-shape.js.map +1 -0
- package/dist/app-debug/index.d.ts +26 -0
- package/dist/app-debug/index.d.ts.map +1 -0
- package/dist/app-debug/index.js +19 -0
- package/dist/app-debug/index.js.map +1 -0
- package/dist/app-debug/markdown.d.ts +6 -0
- package/dist/app-debug/markdown.d.ts.map +1 -0
- package/dist/app-debug/markdown.js +133 -0
- package/dist/app-debug/markdown.js.map +1 -0
- package/dist/app-debug/runtime.d.ts +267 -0
- package/dist/app-debug/runtime.d.ts.map +1 -0
- package/dist/app-debug/runtime.js +633 -0
- package/dist/app-debug/runtime.js.map +1 -0
- package/dist/app-debug/script-operation.d.ts +58 -0
- package/dist/app-debug/script-operation.d.ts.map +1 -0
- package/dist/app-debug/script-operation.js +44 -0
- package/dist/app-debug/script-operation.js.map +1 -0
- package/dist/app-debug/simulate.d.ts +85 -0
- package/dist/app-debug/simulate.d.ts.map +1 -0
- package/dist/app-debug/simulate.js +991 -0
- package/dist/app-debug/simulate.js.map +1 -0
- package/dist/app-debug/summarize.d.ts +64 -0
- package/dist/app-debug/summarize.d.ts.map +1 -0
- package/dist/app-debug/summarize.js +65 -0
- package/dist/app-debug/summarize.js.map +1 -0
- package/dist/app-debug/types.d.ts +343 -0
- package/dist/app-debug/types.d.ts.map +1 -0
- package/dist/app-debug/types.js +2 -0
- package/dist/app-debug/types.js.map +1 -0
- package/dist/cost-ledger.d.ts +147 -0
- package/dist/cost-ledger.d.ts.map +1 -0
- package/dist/cost-ledger.js +288 -0
- package/dist/cost-ledger.js.map +1 -0
- package/dist/debug/collector.d.ts +28 -0
- package/dist/debug/collector.d.ts.map +1 -0
- package/dist/debug/collector.js +284 -0
- package/dist/debug/collector.js.map +1 -0
- package/dist/debug/index.d.ts +15 -0
- package/dist/debug/index.d.ts.map +1 -0
- package/dist/debug/index.js +16 -0
- package/dist/debug/index.js.map +1 -0
- package/dist/debug/types.d.ts +179 -0
- package/dist/debug/types.d.ts.map +1 -0
- package/dist/debug/types.js +11 -0
- package/dist/debug/types.js.map +1 -0
- package/dist/debug/verdict.d.ts +27 -0
- package/dist/debug/verdict.d.ts.map +1 -0
- package/dist/debug/verdict.js +51 -0
- package/dist/debug/verdict.js.map +1 -0
- package/dist/executor-resolver.d.ts +13 -0
- package/dist/executor-resolver.d.ts.map +1 -0
- package/dist/executor-resolver.js +13 -0
- package/dist/executor-resolver.js.map +1 -0
- package/dist/fal-output-decoder.d.ts +36 -0
- package/dist/fal-output-decoder.d.ts.map +1 -0
- package/dist/fal-output-decoder.js +129 -0
- package/dist/fal-output-decoder.js.map +1 -0
- package/dist/generation-lifecycle.d.ts +54 -0
- package/dist/generation-lifecycle.d.ts.map +1 -0
- package/dist/generation-lifecycle.js +431 -0
- package/dist/generation-lifecycle.js.map +1 -0
- package/dist/generation-recovery-worker.d.ts +100 -0
- package/dist/generation-recovery-worker.d.ts.map +1 -0
- package/dist/generation-recovery-worker.js +995 -0
- package/dist/generation-recovery-worker.js.map +1 -0
- package/dist/generation-tracker.d.ts +83 -0
- package/dist/generation-tracker.d.ts.map +1 -0
- package/dist/generation-tracker.js +436 -0
- package/dist/generation-tracker.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/js-script-debug/index.d.ts +13 -0
- package/dist/js-script-debug/index.d.ts.map +1 -0
- package/dist/js-script-debug/index.js +12 -0
- package/dist/js-script-debug/index.js.map +1 -0
- package/dist/js-script-debug/markdown.d.ts +6 -0
- package/dist/js-script-debug/markdown.d.ts.map +1 -0
- package/dist/js-script-debug/markdown.js +62 -0
- package/dist/js-script-debug/markdown.js.map +1 -0
- package/dist/js-script-debug/outputs.d.ts +20 -0
- package/dist/js-script-debug/outputs.d.ts.map +1 -0
- package/dist/js-script-debug/outputs.js +30 -0
- package/dist/js-script-debug/outputs.js.map +1 -0
- package/dist/js-script-debug/report.d.ts +15 -0
- package/dist/js-script-debug/report.d.ts.map +1 -0
- package/dist/js-script-debug/report.js +90 -0
- package/dist/js-script-debug/report.js.map +1 -0
- package/dist/js-script-debug/types.d.ts +63 -0
- package/dist/js-script-debug/types.d.ts.map +1 -0
- package/dist/js-script-debug/types.js +10 -0
- package/dist/js-script-debug/types.js.map +1 -0
- package/dist/js-script-debug/validate.d.ts +38 -0
- package/dist/js-script-debug/validate.d.ts.map +1 -0
- package/dist/js-script-debug/validate.js +137 -0
- package/dist/js-script-debug/validate.js.map +1 -0
- package/dist/message-stream.d.ts +33 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +58 -0
- package/dist/message-stream.js.map +1 -0
- package/dist/normalize-graph.d.ts +13 -0
- package/dist/normalize-graph.d.ts.map +1 -0
- package/dist/normalize-graph.js +63 -0
- package/dist/normalize-graph.js.map +1 -0
- package/dist/output-names.d.ts +13 -0
- package/dist/output-names.d.ts.map +1 -0
- package/dist/output-names.js +13 -0
- package/dist/output-names.js.map +1 -0
- package/dist/predicates.d.ts +6 -0
- package/dist/predicates.d.ts.map +1 -0
- package/dist/predicates.js +6 -0
- package/dist/predicates.js.map +1 -0
- package/dist/preflight.d.ts +87 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +139 -0
- package/dist/preflight.js.map +1 -0
- package/dist/provider-secret-key.d.ts +3 -0
- package/dist/provider-secret-key.d.ts.map +1 -0
- package/dist/provider-secret-key.js +7 -0
- package/dist/provider-secret-key.js.map +1 -0
- package/dist/service/app-debug-service.d.ts +78 -0
- package/dist/service/app-debug-service.d.ts.map +1 -0
- package/dist/service/app-debug-service.js +201 -0
- package/dist/service/app-debug-service.js.map +1 -0
- package/dist/service/app-run-server.d.ts +28 -0
- package/dist/service/app-run-server.d.ts.map +1 -0
- package/dist/service/app-run-server.js +104 -0
- package/dist/service/app-run-server.js.map +1 -0
- package/dist/service/debug-sessions.d.ts +158 -0
- package/dist/service/debug-sessions.d.ts.map +1 -0
- package/dist/service/debug-sessions.js +358 -0
- package/dist/service/debug-sessions.js.map +1 -0
- package/dist/service/index.d.ts +22 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +17 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/job-progress.d.ts +40 -0
- package/dist/service/job-progress.d.ts.map +1 -0
- package/dist/service/job-progress.js +53 -0
- package/dist/service/job-progress.js.map +1 -0
- package/dist/service/workflow-run.d.ts +279 -0
- package/dist/service/workflow-run.d.ts.map +1 -0
- package/dist/service/workflow-run.js +598 -0
- package/dist/service/workflow-run.js.map +1 -0
- package/dist/service/workflow-workspace.d.ts +89 -0
- package/dist/service/workflow-workspace.d.ts.map +1 -0
- package/dist/service/workflow-workspace.js +160 -0
- package/dist/service/workflow-workspace.js.map +1 -0
- package/dist/session.d.ts +62 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +353 -0
- package/dist/session.js.map +1 -0
- package/dist/sketch-debug/index.d.ts +11 -0
- package/dist/sketch-debug/index.d.ts.map +1 -0
- package/dist/sketch-debug/index.js +10 -0
- package/dist/sketch-debug/index.js.map +1 -0
- package/dist/sketch-debug/markdown.d.ts +6 -0
- package/dist/sketch-debug/markdown.d.ts.map +1 -0
- package/dist/sketch-debug/markdown.js +93 -0
- package/dist/sketch-debug/markdown.js.map +1 -0
- package/dist/sketch-debug/report.d.ts +15 -0
- package/dist/sketch-debug/report.d.ts.map +1 -0
- package/dist/sketch-debug/report.js +98 -0
- package/dist/sketch-debug/report.js.map +1 -0
- package/dist/sketch-debug/types.d.ts +62 -0
- package/dist/sketch-debug/types.d.ts.map +1 -0
- package/dist/sketch-debug/types.js +10 -0
- package/dist/sketch-debug/types.js.map +1 -0
- package/dist/sketch-debug/validate.d.ts +14 -0
- package/dist/sketch-debug/validate.d.ts.map +1 -0
- package/dist/sketch-debug/validate.js +478 -0
- package/dist/sketch-debug/validate.js.map +1 -0
- package/dist/supervisor.d.ts +45 -0
- package/dist/supervisor.d.ts.map +1 -0
- package/dist/supervisor.js +97 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/timeline-debug/index.d.ts +11 -0
- package/dist/timeline-debug/index.d.ts.map +1 -0
- package/dist/timeline-debug/index.js +10 -0
- package/dist/timeline-debug/index.js.map +1 -0
- package/dist/timeline-debug/legibility.d.ts +23 -0
- package/dist/timeline-debug/legibility.d.ts.map +1 -0
- package/dist/timeline-debug/legibility.js +240 -0
- package/dist/timeline-debug/legibility.js.map +1 -0
- package/dist/timeline-debug/markdown.d.ts +6 -0
- package/dist/timeline-debug/markdown.d.ts.map +1 -0
- package/dist/timeline-debug/markdown.js +103 -0
- package/dist/timeline-debug/markdown.js.map +1 -0
- package/dist/timeline-debug/motion.d.ts +20 -0
- package/dist/timeline-debug/motion.d.ts.map +1 -0
- package/dist/timeline-debug/motion.js +362 -0
- package/dist/timeline-debug/motion.js.map +1 -0
- package/dist/timeline-debug/report.d.ts +16 -0
- package/dist/timeline-debug/report.d.ts.map +1 -0
- package/dist/timeline-debug/report.js +100 -0
- package/dist/timeline-debug/report.js.map +1 -0
- package/dist/timeline-debug/showcase.d.ts +7 -0
- package/dist/timeline-debug/showcase.d.ts.map +1 -0
- package/dist/timeline-debug/showcase.js +347 -0
- package/dist/timeline-debug/showcase.js.map +1 -0
- package/dist/timeline-debug/types.d.ts +64 -0
- package/dist/timeline-debug/types.d.ts.map +1 -0
- package/dist/timeline-debug/types.js +10 -0
- package/dist/timeline-debug/types.js.map +1 -0
- package/dist/timeline-debug/validate.d.ts +24 -0
- package/dist/timeline-debug/validate.d.ts.map +1 -0
- package/dist/timeline-debug/validate.js +1432 -0
- package/dist/timeline-debug/validate.js.map +1 -0
- package/dist/types.d.ts +211 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +103 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { GenerationAttempt, GenerationAttachment, GenerationOutput, GenerationWebhookDelivery, Prediction } from "@nodetool-ai/models";
|
|
3
|
+
import { createLogger } from "@nodetool-ai/config";
|
|
4
|
+
import { decodeFalOutputs, deterministicFalStorageKey } from "./fal-output-decoder.js";
|
|
5
|
+
const log = createLogger("nodetool.execution.generation-recovery");
|
|
6
|
+
function record(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
function parsedRecord(value) {
|
|
12
|
+
if (!value)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return record(JSON.parse(value));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function asString(value) {
|
|
22
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
23
|
+
}
|
|
24
|
+
function statusError(value) {
|
|
25
|
+
const error = value.error ?? value.error_type ?? value.payload_error;
|
|
26
|
+
if (error == null || error === "")
|
|
27
|
+
return null;
|
|
28
|
+
return typeof error === "string" ? error : JSON.stringify(error);
|
|
29
|
+
}
|
|
30
|
+
const TERMINAL_ATTACHMENT_STATUSES = new Set([
|
|
31
|
+
"attached",
|
|
32
|
+
"superseded",
|
|
33
|
+
"target_deleted"
|
|
34
|
+
]);
|
|
35
|
+
function attachmentIntents(generation) {
|
|
36
|
+
return Array.isArray(generation.metadata?.attachments)
|
|
37
|
+
? generation.metadata.attachments
|
|
38
|
+
.map(record)
|
|
39
|
+
.filter((value) => value !== null)
|
|
40
|
+
: [];
|
|
41
|
+
}
|
|
42
|
+
function isTerminalProjection(generation) {
|
|
43
|
+
return ((generation.status === "completed" &&
|
|
44
|
+
generation.provider_status === "succeeded" &&
|
|
45
|
+
generation.output_status === "ready") ||
|
|
46
|
+
generation.status === "failed" ||
|
|
47
|
+
generation.status === "cancelled");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Bounded, lease-fenced finalization. Webhook rows are consumed before queue
|
|
51
|
+
* polling, so a callback that arrived during a process restart wins without
|
|
52
|
+
* spending another provider read. Every write is idempotent and guarded by
|
|
53
|
+
* the generation and attempt lease versions.
|
|
54
|
+
*/
|
|
55
|
+
export class DurableGenerationRecoveryWorker {
|
|
56
|
+
provider;
|
|
57
|
+
workerId;
|
|
58
|
+
batchSize;
|
|
59
|
+
leaseMs;
|
|
60
|
+
now;
|
|
61
|
+
finalizeOutput;
|
|
62
|
+
submitAccepted;
|
|
63
|
+
attachOutput;
|
|
64
|
+
pass = null;
|
|
65
|
+
constructor(options) {
|
|
66
|
+
this.provider = options.provider;
|
|
67
|
+
this.workerId = options.workerId ?? `generation-recovery:${randomUUID()}`;
|
|
68
|
+
this.batchSize = Math.max(1, Math.min(options.batchSize ?? 50, 500));
|
|
69
|
+
this.leaseMs = Math.max(5_000, Math.min(options.leaseMs ?? 60_000, 15 * 60_000));
|
|
70
|
+
this.now = options.now ?? (() => new Date());
|
|
71
|
+
this.finalizeOutput = options.finalizeOutput;
|
|
72
|
+
this.submitAccepted = options.submitAccepted;
|
|
73
|
+
this.attachOutput = options.attachOutput;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One pass at a time per instance. A pass polls the provider item by item,
|
|
77
|
+
* so it can outlast the interval the host runs it on; a second overlapping
|
|
78
|
+
* pass would reclaim leases the first still holds and redo its work.
|
|
79
|
+
*/
|
|
80
|
+
async runOnce() {
|
|
81
|
+
if (this.pass)
|
|
82
|
+
return this.pass;
|
|
83
|
+
this.pass = this.runPass().finally(() => {
|
|
84
|
+
this.pass = null;
|
|
85
|
+
});
|
|
86
|
+
return this.pass;
|
|
87
|
+
}
|
|
88
|
+
/** A lease window measured at the moment of the claim it fences. A batch
|
|
89
|
+
* can take longer than one lease, so a window minted for the whole batch
|
|
90
|
+
* hands its last items an expiry that has already passed. */
|
|
91
|
+
leaseWindow() {
|
|
92
|
+
const now = this.now();
|
|
93
|
+
return {
|
|
94
|
+
nowIso: now.toISOString(),
|
|
95
|
+
expiresAt: new Date(now.getTime() + this.leaseMs).toISOString()
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async runPass() {
|
|
99
|
+
const result = {
|
|
100
|
+
deliveries: 0,
|
|
101
|
+
polledAttempts: 0,
|
|
102
|
+
completed: 0,
|
|
103
|
+
failed: 0,
|
|
104
|
+
skipped: 0
|
|
105
|
+
};
|
|
106
|
+
const deliveries = await GenerationWebhookDelivery.pending(this.now().toISOString(), this.batchSize);
|
|
107
|
+
for (const delivery of deliveries) {
|
|
108
|
+
const { nowIso, expiresAt } = this.leaseWindow();
|
|
109
|
+
const claimed = await GenerationWebhookDelivery.claim(delivery.id, this.workerId, nowIso, expiresAt);
|
|
110
|
+
if (!claimed) {
|
|
111
|
+
result.skipped++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
result.deliveries++;
|
|
115
|
+
let outcome;
|
|
116
|
+
try {
|
|
117
|
+
outcome = await this.finalizeDelivery(claimed, nowIso, expiresAt);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// Storage/finalizer failures are retryable infrastructure failures,
|
|
121
|
+
// not provider failures. Leave the leased delivery for its expiry so
|
|
122
|
+
// another bounded pass can retry it.
|
|
123
|
+
log.warn("Could not finalize FAL webhook delivery", {
|
|
124
|
+
deliveryId: claimed.id,
|
|
125
|
+
error: error instanceof Error ? error.message : String(error)
|
|
126
|
+
});
|
|
127
|
+
outcome = "skipped";
|
|
128
|
+
}
|
|
129
|
+
if (outcome === "completed")
|
|
130
|
+
result.completed++;
|
|
131
|
+
else if (outcome === "failed")
|
|
132
|
+
result.failed++;
|
|
133
|
+
else
|
|
134
|
+
result.skipped++;
|
|
135
|
+
}
|
|
136
|
+
const remaining = Math.max(0, this.batchSize - result.deliveries);
|
|
137
|
+
if (remaining > 0) {
|
|
138
|
+
const attempts = await GenerationAttempt.recoverable(this.now().toISOString(), remaining);
|
|
139
|
+
for (const attempt of attempts) {
|
|
140
|
+
const { nowIso, expiresAt } = this.leaseWindow();
|
|
141
|
+
let outcome;
|
|
142
|
+
try {
|
|
143
|
+
outcome = await this.pollAttempt(attempt, nowIso, expiresAt);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
log.warn("Could not finalize FAL recovery attempt", {
|
|
147
|
+
attemptId: attempt.id,
|
|
148
|
+
error: error instanceof Error ? error.message : String(error)
|
|
149
|
+
});
|
|
150
|
+
outcome = "skipped";
|
|
151
|
+
}
|
|
152
|
+
result.polledAttempts++;
|
|
153
|
+
if (outcome === "completed")
|
|
154
|
+
result.completed++;
|
|
155
|
+
else if (outcome === "failed")
|
|
156
|
+
result.failed++;
|
|
157
|
+
else
|
|
158
|
+
result.skipped++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
async finalizeDelivery(delivery, nowIso, expiresAt) {
|
|
164
|
+
let attempt = delivery.attempt_id
|
|
165
|
+
? await GenerationAttempt.get(delivery.attempt_id)
|
|
166
|
+
: await GenerationAttempt.findByProviderRequest(delivery.provider, delivery.provider_account_ref, delivery.provider_request_id);
|
|
167
|
+
if (!attempt) {
|
|
168
|
+
await GenerationWebhookDelivery.markProcessed(delivery.id, this.workerId, delivery.lease_version, "generation attempt no longer exists");
|
|
169
|
+
return "skipped";
|
|
170
|
+
}
|
|
171
|
+
const existingGeneration = await Prediction.find(attempt.generation_id);
|
|
172
|
+
const deliveryStatus = asString(record(delivery.observation)?.status);
|
|
173
|
+
const contradictoryTerminal = existingGeneration
|
|
174
|
+
? (existingGeneration.status === "completed" &&
|
|
175
|
+
deliveryStatus === "ERROR") ||
|
|
176
|
+
((existingGeneration.status === "failed" ||
|
|
177
|
+
existingGeneration.status === "cancelled") &&
|
|
178
|
+
deliveryStatus === "OK")
|
|
179
|
+
: false;
|
|
180
|
+
if (contradictoryTerminal) {
|
|
181
|
+
if (!attempt.provider_request_id)
|
|
182
|
+
return "skipped";
|
|
183
|
+
let recheck;
|
|
184
|
+
try {
|
|
185
|
+
const queue = await this.provider.queueFor(attempt);
|
|
186
|
+
const binding = await queue.bind({
|
|
187
|
+
provider: "fal_ai",
|
|
188
|
+
endpoint: attempt.endpoint ?? "",
|
|
189
|
+
providerRequestId: attempt.provider_request_id,
|
|
190
|
+
requestId: attempt.provider_request_id
|
|
191
|
+
});
|
|
192
|
+
const observation = await queue.wait(binding, {
|
|
193
|
+
signal: AbortSignal.timeout(5_000),
|
|
194
|
+
pollIntervalMs: 500
|
|
195
|
+
});
|
|
196
|
+
recheck = `provider recheck reported ${observation.state}`;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const state = error && typeof error === "object" && "state" in error
|
|
200
|
+
? asString(error.state)
|
|
201
|
+
: null;
|
|
202
|
+
if (!state)
|
|
203
|
+
return "skipped";
|
|
204
|
+
recheck = `provider recheck reported ${state}`;
|
|
205
|
+
}
|
|
206
|
+
await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, `callback status ${deliveryStatus} contradicts terminal generation ${existingGeneration?.status}; ${recheck}`);
|
|
207
|
+
return "skipped";
|
|
208
|
+
}
|
|
209
|
+
if (existingGeneration && isTerminalProjection(existingGeneration)) {
|
|
210
|
+
if (attempt.provider_request_id &&
|
|
211
|
+
attempt.provider_request_id !== delivery.provider_request_id) {
|
|
212
|
+
await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, "callback request ID conflicts with the bound attempt");
|
|
213
|
+
return "skipped";
|
|
214
|
+
}
|
|
215
|
+
const claimed = await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
|
|
216
|
+
if (!claimed)
|
|
217
|
+
return "skipped";
|
|
218
|
+
const outputs = typeof GenerationOutput.forAttempt === "function"
|
|
219
|
+
? await GenerationOutput.forAttempt(claimed.id)
|
|
220
|
+
: [];
|
|
221
|
+
const attachments = await this.retryPendingAttachments(claimed, existingGeneration, outputs);
|
|
222
|
+
if (attachments === "skipped")
|
|
223
|
+
return "skipped";
|
|
224
|
+
await this.settleAttachmentProjection(existingGeneration, attachments);
|
|
225
|
+
const nextCheckAt = attachments === "pending"
|
|
226
|
+
? new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
227
|
+
: null;
|
|
228
|
+
if (!(await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
|
|
229
|
+
submission_status: "submitted",
|
|
230
|
+
provider_status: existingGeneration.status === "completed"
|
|
231
|
+
? "succeeded"
|
|
232
|
+
: existingGeneration.status === "cancelled"
|
|
233
|
+
? "cancelled"
|
|
234
|
+
: "failed",
|
|
235
|
+
last_error: existingGeneration.error ?? null,
|
|
236
|
+
next_check_at: nextCheckAt
|
|
237
|
+
})))
|
|
238
|
+
return "skipped";
|
|
239
|
+
await GenerationWebhookDelivery.markProcessed(delivery.id, this.workerId, delivery.lease_version, null);
|
|
240
|
+
return "skipped";
|
|
241
|
+
}
|
|
242
|
+
const claimed = await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
|
|
243
|
+
if (!claimed)
|
|
244
|
+
return "skipped";
|
|
245
|
+
attempt = claimed;
|
|
246
|
+
let observation = delivery.observation;
|
|
247
|
+
if (!attempt.provider_request_id) {
|
|
248
|
+
try {
|
|
249
|
+
const generation = await Prediction.find(attempt.generation_id);
|
|
250
|
+
if (!generation)
|
|
251
|
+
return "skipped";
|
|
252
|
+
const queue = await this.provider.queueFor(attempt);
|
|
253
|
+
const binding = await queue.bind({
|
|
254
|
+
provider: "fal_ai",
|
|
255
|
+
endpoint: attempt.endpoint ?? "",
|
|
256
|
+
providerRequestId: delivery.provider_request_id,
|
|
257
|
+
requestId: delivery.provider_request_id
|
|
258
|
+
});
|
|
259
|
+
const verified = await queue.wait(binding, {
|
|
260
|
+
signal: AbortSignal.timeout(5_000),
|
|
261
|
+
pollIntervalMs: 500
|
|
262
|
+
});
|
|
263
|
+
const adopted = await GenerationAttempt.bindProviderRequest(attempt.id, this.workerId, attempt.lease_version, delivery.provider_request_id);
|
|
264
|
+
if (!adopted) {
|
|
265
|
+
const current = await GenerationAttempt.get(attempt.id);
|
|
266
|
+
if (current?.provider_request_id !== delivery.provider_request_id) {
|
|
267
|
+
await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, "callback request ID could not be authenticated to the attempt");
|
|
268
|
+
return "skipped";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
observation = verified;
|
|
272
|
+
attempt =
|
|
273
|
+
(await GenerationAttempt.get(attempt.id)) ??
|
|
274
|
+
attempt;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return "skipped";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (attempt.provider_request_id !== delivery.provider_request_id) {
|
|
281
|
+
await GenerationWebhookDelivery.markConflictProcessed(delivery.id, this.workerId, delivery.lease_version, "callback request ID conflicts with the bound attempt");
|
|
282
|
+
return "skipped";
|
|
283
|
+
}
|
|
284
|
+
const callback = record(observation);
|
|
285
|
+
const callbackStatus = asString(callback?.status);
|
|
286
|
+
// fal OK callbacks can omit the result or report payload_error. They are
|
|
287
|
+
// success evidence, not provider failures. Re-read the authoritative
|
|
288
|
+
// result through the attempt's authenticated queue adapter.
|
|
289
|
+
if (callbackStatus === "OK" &&
|
|
290
|
+
(callback?.payload_error != null ||
|
|
291
|
+
(!record(callback?.payload) &&
|
|
292
|
+
!record(callback?.result) &&
|
|
293
|
+
!record(callback?.data)))) {
|
|
294
|
+
try {
|
|
295
|
+
const providerRequestId = attempt.provider_request_id;
|
|
296
|
+
if (!providerRequestId)
|
|
297
|
+
return "skipped";
|
|
298
|
+
const queue = await this.provider.queueFor(attempt);
|
|
299
|
+
const binding = await queue.bind({
|
|
300
|
+
provider: "fal_ai",
|
|
301
|
+
endpoint: attempt.endpoint ?? "",
|
|
302
|
+
providerRequestId,
|
|
303
|
+
requestId: providerRequestId
|
|
304
|
+
});
|
|
305
|
+
observation = await queue.wait(binding, {
|
|
306
|
+
signal: AbortSignal.timeout(5_000),
|
|
307
|
+
pollIntervalMs: 500
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return "skipped";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const outcome = await this.finalizeAttempt(attempt, observation, nowIso, expiresAt, true);
|
|
315
|
+
if (outcome === "skipped")
|
|
316
|
+
return "skipped";
|
|
317
|
+
await GenerationWebhookDelivery.markProcessed(delivery.id, this.workerId, delivery.lease_version, outcome === "failed" ? "provider returned an error" : null);
|
|
318
|
+
return outcome;
|
|
319
|
+
}
|
|
320
|
+
async pollAttempt(attempt, nowIso, expiresAt) {
|
|
321
|
+
const claimed = await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
|
|
322
|
+
if (!claimed)
|
|
323
|
+
return "skipped";
|
|
324
|
+
const generation = await Prediction.find(claimed.generation_id);
|
|
325
|
+
if (!generation)
|
|
326
|
+
return "skipped";
|
|
327
|
+
if (isTerminalProjection(generation)) {
|
|
328
|
+
const storedOutputs = typeof GenerationOutput.forAttempt === "function"
|
|
329
|
+
? await GenerationOutput.forAttempt(claimed.id)
|
|
330
|
+
: [];
|
|
331
|
+
const attachments = await this.retryPendingAttachments(claimed, generation, storedOutputs);
|
|
332
|
+
if (attachments === "skipped")
|
|
333
|
+
return "skipped";
|
|
334
|
+
await this.settleAttachmentProjection(generation, attachments);
|
|
335
|
+
await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
|
|
336
|
+
submission_status: "submitted",
|
|
337
|
+
provider_status: generation.status === "completed"
|
|
338
|
+
? "succeeded"
|
|
339
|
+
: generation.status === "cancelled"
|
|
340
|
+
? "cancelled"
|
|
341
|
+
: "failed",
|
|
342
|
+
last_error: generation.error ?? null,
|
|
343
|
+
next_check_at: attachments === "pending"
|
|
344
|
+
? new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
345
|
+
: null
|
|
346
|
+
});
|
|
347
|
+
return "skipped";
|
|
348
|
+
}
|
|
349
|
+
const storedRetry = await this.retryStoredOutputs(claimed, generation, nowIso, expiresAt);
|
|
350
|
+
if (storedRetry !== null)
|
|
351
|
+
return storedRetry;
|
|
352
|
+
if (!claimed.provider_request_id) {
|
|
353
|
+
if (claimed.submission_status === "accepted" && this.submitAccepted) {
|
|
354
|
+
const submitting = await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, { submission_status: "submitting", next_check_at: null });
|
|
355
|
+
if (!submitting)
|
|
356
|
+
return "skipped";
|
|
357
|
+
try {
|
|
358
|
+
const submission = await this.submitAccepted(generation, claimed);
|
|
359
|
+
if (!submission.providerRequestId)
|
|
360
|
+
throw new Error("submission did not return a request ID");
|
|
361
|
+
const bound = await GenerationAttempt.bindProviderRequest(claimed.id, this.workerId, claimed.lease_version, submission.providerRequestId);
|
|
362
|
+
if (!bound)
|
|
363
|
+
return "skipped";
|
|
364
|
+
if (!(await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
|
|
365
|
+
provider_request_id: submission.providerRequestId,
|
|
366
|
+
gateway_request_id: submission.gatewayRequestId ?? null,
|
|
367
|
+
submission_status: "submitted",
|
|
368
|
+
provider_status: "queued",
|
|
369
|
+
next_check_at: null
|
|
370
|
+
})))
|
|
371
|
+
return "skipped";
|
|
372
|
+
return "skipped";
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
376
|
+
await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
|
|
377
|
+
submission_status: "submission_unknown",
|
|
378
|
+
provider_status: "unknown",
|
|
379
|
+
last_error: message,
|
|
380
|
+
next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
381
|
+
});
|
|
382
|
+
return "skipped";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
|
|
386
|
+
submission_status: "submission_unknown",
|
|
387
|
+
provider_status: "unknown",
|
|
388
|
+
next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
389
|
+
});
|
|
390
|
+
return "skipped";
|
|
391
|
+
}
|
|
392
|
+
if (generation.cancel_requested_at || claimed.cancel_requested_at) {
|
|
393
|
+
try {
|
|
394
|
+
const queue = await this.provider.queueFor(claimed);
|
|
395
|
+
if (claimed.endpoint) {
|
|
396
|
+
const binding = await queue.bind({
|
|
397
|
+
provider: "fal_ai",
|
|
398
|
+
endpoint: claimed.endpoint,
|
|
399
|
+
providerRequestId: claimed.provider_request_id,
|
|
400
|
+
requestId: claimed.provider_request_id
|
|
401
|
+
});
|
|
402
|
+
await queue.cancel(binding);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
log.warn("Could not cancel FAL request during recovery", {
|
|
407
|
+
generationId: claimed.generation_id,
|
|
408
|
+
error: error instanceof Error ? error.message : String(error)
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const queue = await this.provider.queueFor(claimed);
|
|
414
|
+
const binding = await queue.bind({
|
|
415
|
+
provider: "fal_ai",
|
|
416
|
+
endpoint: claimed.endpoint ?? "",
|
|
417
|
+
providerRequestId: claimed.provider_request_id,
|
|
418
|
+
requestId: claimed.provider_request_id
|
|
419
|
+
});
|
|
420
|
+
const observation = await queue.wait(binding, {
|
|
421
|
+
signal: AbortSignal.timeout(5_000),
|
|
422
|
+
pollIntervalMs: 500
|
|
423
|
+
});
|
|
424
|
+
return this.finalizeAttempt(claimed, observation, nowIso, expiresAt, true);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
428
|
+
await GenerationAttempt.transition(claimed.id, this.workerId, claimed.lease_version, {
|
|
429
|
+
// A bound request remains submitted when a status/result read times
|
|
430
|
+
// out. Only an ambiguous submit is submission_unknown.
|
|
431
|
+
submission_status: "submitted",
|
|
432
|
+
provider_status: "unknown",
|
|
433
|
+
last_error: message,
|
|
434
|
+
next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
435
|
+
});
|
|
436
|
+
return "skipped";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async finalizeAttempt(attempt, rawObservation, nowIso, expiresAt, attemptAlreadyClaimed = false) {
|
|
440
|
+
const generation = await Prediction.find(attempt.generation_id);
|
|
441
|
+
if (!generation)
|
|
442
|
+
return "skipped";
|
|
443
|
+
const observation = record(rawObservation) ?? {};
|
|
444
|
+
const status = asString(observation.status);
|
|
445
|
+
const error = status === "ERROR" ? statusError(observation) : statusError(observation);
|
|
446
|
+
const payload = observation.payload ?? observation.result ?? observation.data;
|
|
447
|
+
const attemptLease = attemptAlreadyClaimed
|
|
448
|
+
? attempt
|
|
449
|
+
: await GenerationAttempt.claimLease(attempt.id, this.workerId, nowIso, expiresAt);
|
|
450
|
+
if (!attemptLease)
|
|
451
|
+
return "skipped";
|
|
452
|
+
const generationLease = await Prediction.claimGenerationLease(generation.id, this.workerId, nowIso, expiresAt);
|
|
453
|
+
if (!generationLease)
|
|
454
|
+
return "skipped";
|
|
455
|
+
const heartbeat = this.startLeaseRenewal(attemptLease, generationLease);
|
|
456
|
+
try {
|
|
457
|
+
return await this.finalizeAttemptWithLeases(attempt, generation, observation, status, error, payload, nowIso, attemptLease, generationLease, heartbeat);
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
heartbeat.stop();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Retry durable output saves from the committed raw result before asking
|
|
464
|
+
* fal for a queue result. This is important after result retention expires
|
|
465
|
+
* or after a crash between object upload and the output-row CAS. */
|
|
466
|
+
async retryStoredOutputs(attempt, generation, nowIso, expiresAt) {
|
|
467
|
+
// Older hosts may not expose the optional output query yet. In that case
|
|
468
|
+
// fall through to the authenticated queue lookup.
|
|
469
|
+
if (typeof GenerationOutput.forAttempt !== "function")
|
|
470
|
+
return null;
|
|
471
|
+
const outputs = await GenerationOutput.forAttempt(attempt.id);
|
|
472
|
+
if (outputs.length === 0)
|
|
473
|
+
return null;
|
|
474
|
+
const retryable = outputs.filter((output) => output.status !== "ready" &&
|
|
475
|
+
output.raw_result !== null &&
|
|
476
|
+
this.finalizeOutput !== undefined);
|
|
477
|
+
if (retryable.length === 0) {
|
|
478
|
+
const attachments = await this.retryPendingAttachments(attempt, generation, outputs);
|
|
479
|
+
if (attachments === "skipped")
|
|
480
|
+
return "skipped";
|
|
481
|
+
if (attachments === "pending") {
|
|
482
|
+
await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, {
|
|
483
|
+
next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
484
|
+
});
|
|
485
|
+
return "skipped";
|
|
486
|
+
}
|
|
487
|
+
return this.repairFromStoredOutputs(attempt, generation, outputs, attachments, nowIso, expiresAt);
|
|
488
|
+
}
|
|
489
|
+
const generationLease = await Prediction.claimGenerationLease(generation.id, this.workerId, nowIso, expiresAt);
|
|
490
|
+
if (!generationLease)
|
|
491
|
+
return "skipped";
|
|
492
|
+
const heartbeat = this.startLeaseRenewal(attempt, generationLease);
|
|
493
|
+
try {
|
|
494
|
+
const states = new Map();
|
|
495
|
+
for (const output of outputs) {
|
|
496
|
+
if (output.status === "ready") {
|
|
497
|
+
states.set(output.id, {
|
|
498
|
+
status: "ready",
|
|
499
|
+
storage_key: output.storage_key,
|
|
500
|
+
asset_id: output.asset_id,
|
|
501
|
+
provider_ref: output.provider_ref,
|
|
502
|
+
raw_result: output.raw_result
|
|
503
|
+
});
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (!output.raw_result || !this.finalizeOutput) {
|
|
507
|
+
states.set(output.id, { status: output.status });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (heartbeat.lost())
|
|
511
|
+
return "skipped";
|
|
512
|
+
const saving = await GenerationOutput.transition(output.id, {
|
|
513
|
+
status: "saving",
|
|
514
|
+
provider_ref: output.provider_ref,
|
|
515
|
+
raw_result: output.raw_result
|
|
516
|
+
}, {
|
|
517
|
+
attemptId: attempt.id,
|
|
518
|
+
workerId: this.workerId,
|
|
519
|
+
leaseVersion: attempt.lease_version
|
|
520
|
+
});
|
|
521
|
+
if (!saving)
|
|
522
|
+
return "skipped";
|
|
523
|
+
let savedState;
|
|
524
|
+
try {
|
|
525
|
+
savedState = await this.finalizeOutput({
|
|
526
|
+
generation,
|
|
527
|
+
attempt,
|
|
528
|
+
payload: output.raw_result,
|
|
529
|
+
descriptor: {
|
|
530
|
+
outputKey: output.output_key,
|
|
531
|
+
outputIndex: output.output_index,
|
|
532
|
+
outputType: output.output_type === "structured" ? "structured" : "media",
|
|
533
|
+
providerRef: output.provider_ref,
|
|
534
|
+
rawResult: output.raw_result,
|
|
535
|
+
storageKey: deterministicFalStorageKey(generation.id, attempt.id, output.output_key, output.output_index),
|
|
536
|
+
existingStorageKey: output.storage_key,
|
|
537
|
+
existingAssetId: output.asset_id
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
543
|
+
const retrying = await GenerationOutput.transition(output.id, {
|
|
544
|
+
status: "retrying",
|
|
545
|
+
error: message,
|
|
546
|
+
provider_ref: output.provider_ref,
|
|
547
|
+
raw_result: output.raw_result
|
|
548
|
+
}, {
|
|
549
|
+
attemptId: attempt.id,
|
|
550
|
+
workerId: this.workerId,
|
|
551
|
+
leaseVersion: attempt.lease_version
|
|
552
|
+
});
|
|
553
|
+
if (!retrying)
|
|
554
|
+
return "skipped";
|
|
555
|
+
states.set(output.id, { status: "retrying", error: message });
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const saved = await GenerationOutput.transition(output.id, savedState, {
|
|
559
|
+
attemptId: attempt.id,
|
|
560
|
+
workerId: this.workerId,
|
|
561
|
+
leaseVersion: attempt.lease_version
|
|
562
|
+
});
|
|
563
|
+
if (!saved || heartbeat.lost())
|
|
564
|
+
return "skipped";
|
|
565
|
+
states.set(output.id, savedState);
|
|
566
|
+
}
|
|
567
|
+
const allReady = outputs.length > 0 &&
|
|
568
|
+
outputs.every((output) => states.get(output.id)?.status === "ready");
|
|
569
|
+
const hasRetrying = [...states.values()].some((state) => state.status === "retrying");
|
|
570
|
+
const refreshedOutputs = await GenerationOutput.forAttempt(attempt.id);
|
|
571
|
+
const attachments = await this.retryPendingAttachments(attempt, generation, refreshedOutputs);
|
|
572
|
+
if (attachments === "skipped")
|
|
573
|
+
return "skipped";
|
|
574
|
+
const nextCheckAt = hasRetrying || attachments === "pending"
|
|
575
|
+
? new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
576
|
+
: null;
|
|
577
|
+
const stagedNextCheckAt = nextCheckAt ??
|
|
578
|
+
new Date(this.now().getTime() + this.leaseMs).toISOString();
|
|
579
|
+
if (heartbeat.lost())
|
|
580
|
+
return "skipped";
|
|
581
|
+
if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, {
|
|
582
|
+
provider_status: "succeeded",
|
|
583
|
+
submission_status: "submitted",
|
|
584
|
+
next_check_at: stagedNextCheckAt
|
|
585
|
+
})))
|
|
586
|
+
return "skipped";
|
|
587
|
+
if (heartbeat.lost())
|
|
588
|
+
return "skipped";
|
|
589
|
+
if (!(await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
|
|
590
|
+
status: allReady ? "completed" : "recovering",
|
|
591
|
+
provider_status: "succeeded",
|
|
592
|
+
output_status: allReady ? "ready" : "retrying",
|
|
593
|
+
attachment_status: attachments === "complete" ? "attached" : "pending",
|
|
594
|
+
asset_ids: [...states.values()]
|
|
595
|
+
.map((state) => state.asset_id)
|
|
596
|
+
.filter((id) => typeof id === "string"),
|
|
597
|
+
next_check_at: nextCheckAt,
|
|
598
|
+
completed_at: allReady ? this.now().toISOString() : null
|
|
599
|
+
})))
|
|
600
|
+
return "skipped";
|
|
601
|
+
if (!nextCheckAt) {
|
|
602
|
+
if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, { next_check_at: null }))) {
|
|
603
|
+
return "skipped";
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return allReady && attachments !== "pending" ? "completed" : "skipped";
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
heartbeat.stop();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Commit the terminal generation from output rows that are already saved.
|
|
614
|
+
* The attempt's recorded provider result is the manifest of a complete
|
|
615
|
+
* result: every output identity it names must have a ready row before the
|
|
616
|
+
* generation is called completed, so a crash halfway through recording a
|
|
617
|
+
* multi-output result still falls back to the provider. Returning null means
|
|
618
|
+
* the state could not be repaired locally.
|
|
619
|
+
*/
|
|
620
|
+
async repairFromStoredOutputs(attempt, generation, outputs, attachments, nowIso, expiresAt) {
|
|
621
|
+
const manifest = parsedRecord(attempt.raw_result_ref);
|
|
622
|
+
if (!manifest)
|
|
623
|
+
return null;
|
|
624
|
+
const ready = new Map(outputs
|
|
625
|
+
.filter((output) => output.status === "ready")
|
|
626
|
+
.map((output) => [
|
|
627
|
+
`${output.output_key}:${output.output_index}`,
|
|
628
|
+
output
|
|
629
|
+
]));
|
|
630
|
+
const expected = decodeFalOutputs(manifest);
|
|
631
|
+
if (expected.length === 0 ||
|
|
632
|
+
expected.some((descriptor) => !ready.has(`${descriptor.outputKey}:${descriptor.outputIndex}`))) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
const generationLease = await Prediction.claimGenerationLease(generation.id, this.workerId, nowIso, expiresAt);
|
|
636
|
+
if (!generationLease)
|
|
637
|
+
return "skipped";
|
|
638
|
+
if (!(await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
|
|
639
|
+
status: "completed",
|
|
640
|
+
submission_status: "submitted",
|
|
641
|
+
provider_status: "succeeded",
|
|
642
|
+
provider_request_id: attempt.provider_request_id,
|
|
643
|
+
output_status: "ready",
|
|
644
|
+
attachment_status: attachments === "complete" ? "attached" : "pending",
|
|
645
|
+
error: null,
|
|
646
|
+
asset_ids: [...ready.values()]
|
|
647
|
+
.map((output) => output.asset_id)
|
|
648
|
+
.filter((id) => typeof id === "string"),
|
|
649
|
+
next_check_at: null,
|
|
650
|
+
completed_at: this.now().toISOString()
|
|
651
|
+
})))
|
|
652
|
+
return "skipped";
|
|
653
|
+
if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attempt.lease_version, {
|
|
654
|
+
submission_status: "submitted",
|
|
655
|
+
provider_status: "succeeded",
|
|
656
|
+
last_error: null,
|
|
657
|
+
next_check_at: null
|
|
658
|
+
})))
|
|
659
|
+
return "skipped";
|
|
660
|
+
return "completed";
|
|
661
|
+
}
|
|
662
|
+
startLeaseRenewal(attempt, generation) {
|
|
663
|
+
let leaseLost = false;
|
|
664
|
+
let stopped = false;
|
|
665
|
+
let renewing = false;
|
|
666
|
+
const renew = async () => {
|
|
667
|
+
if (stopped || renewing || leaseLost)
|
|
668
|
+
return;
|
|
669
|
+
renewing = true;
|
|
670
|
+
try {
|
|
671
|
+
const expiresAt = new Date(this.now().getTime() + this.leaseMs).toISOString();
|
|
672
|
+
const [attemptRenewed, generationRenewed] = await Promise.all([
|
|
673
|
+
typeof GenerationAttempt.renewLease === "function"
|
|
674
|
+
? GenerationAttempt.renewLease(attempt.id, this.workerId, attempt.lease_version, expiresAt)
|
|
675
|
+
: true,
|
|
676
|
+
typeof Prediction.renewGenerationLease === "function"
|
|
677
|
+
? Prediction.renewGenerationLease(generation.id, this.workerId, generation.lease_version, expiresAt)
|
|
678
|
+
: true
|
|
679
|
+
]);
|
|
680
|
+
if (!attemptRenewed || !generationRenewed)
|
|
681
|
+
leaseLost = true;
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
leaseLost = true;
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
renewing = false;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
const timer = setInterval(() => void renew(), Math.max(1_000, Math.floor(this.leaseMs / 3)));
|
|
691
|
+
return {
|
|
692
|
+
lost: () => leaseLost,
|
|
693
|
+
stop: () => {
|
|
694
|
+
stopped = true;
|
|
695
|
+
clearInterval(timer);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
async finalizeAttemptWithLeases(attempt, generation, observation, status, error, payload, nowIso, attemptLease, generationLease, heartbeat) {
|
|
700
|
+
if (heartbeat.lost())
|
|
701
|
+
return "skipped";
|
|
702
|
+
const queueState = asString(observation.state);
|
|
703
|
+
const terminalFailure = status === "ERROR" ||
|
|
704
|
+
queueState === "failed" ||
|
|
705
|
+
queueState === "cancelled";
|
|
706
|
+
const terminalError = error ??
|
|
707
|
+
(queueState === "failed"
|
|
708
|
+
? "provider queue reported failure"
|
|
709
|
+
: queueState === "cancelled"
|
|
710
|
+
? "provider request was cancelled"
|
|
711
|
+
: status === "ERROR"
|
|
712
|
+
? "provider returned an error"
|
|
713
|
+
: null);
|
|
714
|
+
if (terminalFailure && terminalError) {
|
|
715
|
+
if (!(await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
|
|
716
|
+
status: queueState === "cancelled" ? "cancelled" : "failed",
|
|
717
|
+
provider_status: queueState === "cancelled" ? "cancelled" : "failed",
|
|
718
|
+
output_status: "unavailable",
|
|
719
|
+
error: terminalError,
|
|
720
|
+
completed_at: nowIso
|
|
721
|
+
})))
|
|
722
|
+
return "skipped";
|
|
723
|
+
if (heartbeat.lost())
|
|
724
|
+
return "skipped";
|
|
725
|
+
if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, {
|
|
726
|
+
submission_status: "submitted",
|
|
727
|
+
provider_status: queueState === "cancelled" ? "cancelled" : "failed",
|
|
728
|
+
last_error: terminalError,
|
|
729
|
+
next_check_at: null
|
|
730
|
+
})))
|
|
731
|
+
return "skipped";
|
|
732
|
+
return queueState === "cancelled" ? "skipped" : "failed";
|
|
733
|
+
}
|
|
734
|
+
if (queueState === "queued" || queueState === "running") {
|
|
735
|
+
return (await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, {
|
|
736
|
+
submission_status: "submitted",
|
|
737
|
+
provider_status: queueState,
|
|
738
|
+
next_check_at: new Date(this.now().getTime() + Math.min(this.leaseMs, 60_000)).toISOString()
|
|
739
|
+
}))
|
|
740
|
+
? "skipped"
|
|
741
|
+
: "skipped";
|
|
742
|
+
}
|
|
743
|
+
if (error)
|
|
744
|
+
return "skipped";
|
|
745
|
+
const output = record(payload);
|
|
746
|
+
if (!output) {
|
|
747
|
+
log.warn("FAL generation completed without an object payload", {
|
|
748
|
+
generationId: generation.id
|
|
749
|
+
});
|
|
750
|
+
if (!(await GenerationAttempt.transition(attemptLease.id, this.workerId, attemptLease.lease_version, {
|
|
751
|
+
provider_status: "succeeded",
|
|
752
|
+
next_check_at: new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
753
|
+
})))
|
|
754
|
+
return "skipped";
|
|
755
|
+
return "skipped";
|
|
756
|
+
}
|
|
757
|
+
const descriptors = decodeFalOutputs(output);
|
|
758
|
+
// Record the complete provider result before the first output row is
|
|
759
|
+
// written. A later pass reads it back as the manifest of what this result
|
|
760
|
+
// contains, so a crash before the generation projection can be repaired
|
|
761
|
+
// from the saved rows instead of another paid provider read.
|
|
762
|
+
if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, { raw_result_ref: JSON.stringify(output) })))
|
|
763
|
+
return "skipped";
|
|
764
|
+
const outputStates = [];
|
|
765
|
+
const persistedOutputs = [];
|
|
766
|
+
for (const descriptor of descriptors) {
|
|
767
|
+
const persisted = await GenerationOutput.upsertOutput({
|
|
768
|
+
generation_id: generation.id,
|
|
769
|
+
attempt_id: attempt.id,
|
|
770
|
+
output_key: descriptor.outputKey,
|
|
771
|
+
output_index: descriptor.outputIndex,
|
|
772
|
+
output_type: descriptor.outputType,
|
|
773
|
+
provider_ref: descriptor.providerRef,
|
|
774
|
+
raw_result: descriptor.rawResult
|
|
775
|
+
});
|
|
776
|
+
// A restart after the storage commit must reuse the existing output and
|
|
777
|
+
// asset instead of downloading or creating another asset.
|
|
778
|
+
if (persisted.status === "ready") {
|
|
779
|
+
persistedOutputs.push(persisted);
|
|
780
|
+
outputStates.push({
|
|
781
|
+
status: "ready",
|
|
782
|
+
provider_ref: persisted.provider_ref,
|
|
783
|
+
raw_result: persisted.raw_result,
|
|
784
|
+
storage_key: persisted.storage_key,
|
|
785
|
+
asset_id: persisted.asset_id
|
|
786
|
+
});
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const saving = await GenerationOutput.transition(persisted.id, {
|
|
790
|
+
status: "saving",
|
|
791
|
+
provider_ref: descriptor.providerRef,
|
|
792
|
+
raw_result: descriptor.rawResult
|
|
793
|
+
}, {
|
|
794
|
+
attemptId: attemptLease.id,
|
|
795
|
+
workerId: this.workerId,
|
|
796
|
+
leaseVersion: attemptLease.lease_version
|
|
797
|
+
});
|
|
798
|
+
if (!saving || heartbeat.lost())
|
|
799
|
+
return "skipped";
|
|
800
|
+
let outputState;
|
|
801
|
+
try {
|
|
802
|
+
outputState = this.finalizeOutput
|
|
803
|
+
? await this.finalizeOutput({
|
|
804
|
+
generation,
|
|
805
|
+
attempt,
|
|
806
|
+
payload: output,
|
|
807
|
+
descriptor: {
|
|
808
|
+
...descriptor,
|
|
809
|
+
storageKey: deterministicFalStorageKey(generation.id, attempt.id, descriptor.outputKey, descriptor.outputIndex),
|
|
810
|
+
// A finalizer can use these to make a storage retry reuse a
|
|
811
|
+
// deterministic object rather than uploading a second copy.
|
|
812
|
+
existingStorageKey: persisted.storage_key,
|
|
813
|
+
existingAssetId: persisted.asset_id
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
: { status: "saving", raw_result: descriptor.rawResult };
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
820
|
+
const retrying = await GenerationOutput.transition(persisted.id, {
|
|
821
|
+
status: "retrying",
|
|
822
|
+
error: message,
|
|
823
|
+
provider_ref: descriptor.providerRef,
|
|
824
|
+
raw_result: descriptor.rawResult
|
|
825
|
+
}, {
|
|
826
|
+
attemptId: attemptLease.id,
|
|
827
|
+
workerId: this.workerId,
|
|
828
|
+
leaseVersion: attemptLease.lease_version
|
|
829
|
+
});
|
|
830
|
+
if (!retrying)
|
|
831
|
+
return "skipped";
|
|
832
|
+
outputStates.push({ status: "retrying", error: message });
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (this.finalizeOutput || outputState.status !== "saving") {
|
|
836
|
+
const saved = await GenerationOutput.transition(persisted.id, outputState, {
|
|
837
|
+
attemptId: attemptLease.id,
|
|
838
|
+
workerId: this.workerId,
|
|
839
|
+
leaseVersion: attemptLease.lease_version
|
|
840
|
+
});
|
|
841
|
+
if (!saved || heartbeat.lost())
|
|
842
|
+
return "skipped";
|
|
843
|
+
if (saved.status === "ready") {
|
|
844
|
+
persistedOutputs.push(saved);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
outputStates.push(outputState);
|
|
848
|
+
}
|
|
849
|
+
const attachments = await this.retryPendingAttachments(attemptLease, generation, persistedOutputs);
|
|
850
|
+
if (attachments === "skipped")
|
|
851
|
+
return "skipped";
|
|
852
|
+
const hasRetryingOutput = outputStates.some((state) => state.status === "retrying");
|
|
853
|
+
const hasSavingOutput = outputStates.some((state) => state.status === "saving");
|
|
854
|
+
const hasUnavailableOutput = outputStates.some((state) => state.status === "unavailable");
|
|
855
|
+
const allReady = outputStates.every((state) => state.status === "ready");
|
|
856
|
+
const nextCheckAt = hasRetryingOutput || attachments === "pending"
|
|
857
|
+
? new Date(this.now().getTime() + this.leaseMs).toISOString()
|
|
858
|
+
: null;
|
|
859
|
+
// Keep the attempt scheduled until the generation projection commits. If
|
|
860
|
+
// a crash or lease loss happens between these writes, the next pass can
|
|
861
|
+
// repair the terminal generation instead of losing the attempt forever.
|
|
862
|
+
const stagedNextCheckAt = nextCheckAt ??
|
|
863
|
+
new Date(this.now().getTime() + this.leaseMs).toISOString();
|
|
864
|
+
if (heartbeat.lost())
|
|
865
|
+
return "skipped";
|
|
866
|
+
if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, {
|
|
867
|
+
submission_status: "submitted",
|
|
868
|
+
provider_status: "succeeded",
|
|
869
|
+
last_error: null,
|
|
870
|
+
next_check_at: stagedNextCheckAt
|
|
871
|
+
})))
|
|
872
|
+
return "skipped";
|
|
873
|
+
if (heartbeat.lost())
|
|
874
|
+
return "skipped";
|
|
875
|
+
const projected = await Prediction.transitionDurable(generation.id, this.workerId, generationLease.lease_version, {
|
|
876
|
+
status: allReady ? "completed" : "recovering",
|
|
877
|
+
provider_status: "succeeded",
|
|
878
|
+
provider_request_id: attempt.provider_request_id,
|
|
879
|
+
output_status: allReady
|
|
880
|
+
? "ready"
|
|
881
|
+
: hasUnavailableOutput
|
|
882
|
+
? "unavailable"
|
|
883
|
+
: hasRetryingOutput
|
|
884
|
+
? "retrying"
|
|
885
|
+
: hasSavingOutput
|
|
886
|
+
? "saving"
|
|
887
|
+
: "saving",
|
|
888
|
+
// Intent creation is not a destination mutation. Keep it pending
|
|
889
|
+
// until a destination-specific adapter commits the attachment.
|
|
890
|
+
attachment_status: "pending",
|
|
891
|
+
error: null,
|
|
892
|
+
asset_ids: outputStates
|
|
893
|
+
.map((state) => state.asset_id)
|
|
894
|
+
.filter((id) => typeof id === "string"),
|
|
895
|
+
next_check_at: nextCheckAt,
|
|
896
|
+
completed_at: allReady ? nowIso : null
|
|
897
|
+
});
|
|
898
|
+
if (!projected) {
|
|
899
|
+
const current = await Prediction.find(generation.id);
|
|
900
|
+
const alreadyProjected = current
|
|
901
|
+
? hasRetryingOutput
|
|
902
|
+
? current.provider_status === "succeeded" &&
|
|
903
|
+
current.output_status === "retrying"
|
|
904
|
+
: current.provider_status === "succeeded" &&
|
|
905
|
+
current.output_status === "ready"
|
|
906
|
+
: false;
|
|
907
|
+
if (!alreadyProjected)
|
|
908
|
+
return "skipped";
|
|
909
|
+
}
|
|
910
|
+
if (!nextCheckAt) {
|
|
911
|
+
if (!(await GenerationAttempt.transition(attempt.id, this.workerId, attemptLease.lease_version, { next_check_at: null })))
|
|
912
|
+
return "skipped";
|
|
913
|
+
}
|
|
914
|
+
return allReady ? "completed" : hasUnavailableOutput ? "failed" : "skipped";
|
|
915
|
+
}
|
|
916
|
+
/** Record on the generation that its destination work is done. A generation
|
|
917
|
+
* whose media is saved and ready is terminal and holds no lease, so the
|
|
918
|
+
* projection cannot ride on a fenced transition. */
|
|
919
|
+
async settleAttachmentProjection(generation, attachments) {
|
|
920
|
+
if (attachments !== "complete")
|
|
921
|
+
return;
|
|
922
|
+
if (generation.attachment_status === "attached")
|
|
923
|
+
return;
|
|
924
|
+
await Prediction.settleAttachments(generation.id, "attached");
|
|
925
|
+
}
|
|
926
|
+
/** Apply each destination intent independently. A ready output can be
|
|
927
|
+
* retried after its generation is terminal, so attachment work must not be
|
|
928
|
+
* coupled to provider polling or storage retries. */
|
|
929
|
+
async retryPendingAttachments(attempt, generation, outputs) {
|
|
930
|
+
const intents = attachmentIntents(generation);
|
|
931
|
+
if (intents.length === 0 || !this.attachOutput)
|
|
932
|
+
return null;
|
|
933
|
+
let pendingCount = 0;
|
|
934
|
+
for (const intent of intents) {
|
|
935
|
+
const targetType = asString(intent.target_type);
|
|
936
|
+
const targetId = asString(intent.target_id);
|
|
937
|
+
if (!targetType || !targetId)
|
|
938
|
+
continue;
|
|
939
|
+
const outputKey = asString(intent.output_key);
|
|
940
|
+
const outputIndex = typeof intent.output_index === "number" ? intent.output_index : 0;
|
|
941
|
+
// An intent without an output key means "the media this generation
|
|
942
|
+
// produced". A structured row carries no asset, so attaching it could
|
|
943
|
+
// never succeed and would reschedule the attempt forever.
|
|
944
|
+
const output = outputKey
|
|
945
|
+
? outputs.find((candidate) => candidate.output_key === outputKey &&
|
|
946
|
+
candidate.output_index === outputIndex)
|
|
947
|
+
: (outputs.find((candidate) => candidate.output_type === "media" && Boolean(candidate.asset_id)) ??
|
|
948
|
+
outputs.find((candidate) => candidate.output_type === "media") ??
|
|
949
|
+
outputs[0]);
|
|
950
|
+
if (!output || output.status !== "ready") {
|
|
951
|
+
pendingCount++;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const attachment = await GenerationAttachment.upsertAttachment({
|
|
955
|
+
generation_id: generation.id,
|
|
956
|
+
output_id: output.id,
|
|
957
|
+
target_type: targetType,
|
|
958
|
+
target_id: targetId,
|
|
959
|
+
selected: intent.selected === true
|
|
960
|
+
});
|
|
961
|
+
if (TERMINAL_ATTACHMENT_STATUSES.has(attachment.status))
|
|
962
|
+
continue;
|
|
963
|
+
pendingCount++;
|
|
964
|
+
let change;
|
|
965
|
+
try {
|
|
966
|
+
change = await this.attachOutput({
|
|
967
|
+
generation,
|
|
968
|
+
attempt,
|
|
969
|
+
output,
|
|
970
|
+
attachment
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
change = {
|
|
975
|
+
status: "retrying",
|
|
976
|
+
error: error instanceof Error ? error.message : String(error)
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (!change || change.status === "pending")
|
|
980
|
+
continue;
|
|
981
|
+
const transitioned = await GenerationAttachment.transition(attachment.id, change, {
|
|
982
|
+
attemptId: attempt.id,
|
|
983
|
+
workerId: this.workerId,
|
|
984
|
+
leaseVersion: attempt.lease_version
|
|
985
|
+
});
|
|
986
|
+
if (!transitioned)
|
|
987
|
+
return "skipped";
|
|
988
|
+
if (TERMINAL_ATTACHMENT_STATUSES.has(transitioned.status)) {
|
|
989
|
+
pendingCount--;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return pendingCount > 0 ? "pending" : "complete";
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
//# sourceMappingURL=generation-recovery-worker.js.map
|