@hyperfixation/workflows 0.1.0
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/LICENSE +21 -0
- package/dist/actions.d.ts +60 -0
- package/dist/actions.js +175 -0
- package/dist/approval-notifier.d.ts +31 -0
- package/dist/approval-notifier.js +51 -0
- package/dist/approvals.d.ts +137 -0
- package/dist/approvals.js +350 -0
- package/dist/bump.d.ts +18 -0
- package/dist/bump.js +29 -0
- package/dist/client.d.ts +25 -0
- package/dist/client.js +43 -0
- package/dist/control-pool.d.ts +21 -0
- package/dist/control-pool.js +18 -0
- package/dist/define-flow.d.ts +40 -0
- package/dist/define-flow.js +83 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +16 -0
- package/dist/langfuse.d.ts +16 -0
- package/dist/langfuse.js +24 -0
- package/dist/queue-concurrency.d.ts +32 -0
- package/dist/queue-concurrency.js +36 -0
- package/dist/reconcile.d.ts +157 -0
- package/dist/reconcile.js +434 -0
- package/dist/run-context.d.ts +12 -0
- package/dist/run-context.js +25 -0
- package/dist/run-status.d.ts +18 -0
- package/dist/run-status.js +28 -0
- package/dist/runs.d.ts +18 -0
- package/dist/runs.js +18 -0
- package/dist/start-worker.d.ts +83 -0
- package/dist/start-worker.js +208 -0
- package/dist/step.d.ts +29 -0
- package/dist/step.js +54 -0
- package/dist/suspend.d.ts +17 -0
- package/dist/suspend.js +21 -0
- package/dist/telegram.d.ts +91 -0
- package/dist/telegram.js +161 -0
- package/dist/worker-lock.d.ts +37 -0
- package/dist/worker-lock.js +48 -0
- package/dist/worker-runtime.d.ts +18 -0
- package/dist/worker-runtime.js +22 -0
- package/package.json +57 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { appPaused, assertNotInWorkflow, controlPlaneTx, } from "@hyperfixation/db";
|
|
2
|
+
import { decide } from "./approvals.js";
|
|
3
|
+
import { bumpAndEnqueueOn, flowForRun } from "./bump.js";
|
|
4
|
+
import { PAUSED_CONCURRENCY, PAUSED_QUEUES, registeredConcurrency } from "./queue-concurrency.js";
|
|
5
|
+
import { concludeRun } from "./run-status.js";
|
|
6
|
+
/** How often the worker runs a pass after the one it runs at boot. */
|
|
7
|
+
export const RECONCILE_INTERVAL_MS = 60_000;
|
|
8
|
+
/**
|
|
9
|
+
* A `running` run whose `current_workflow_id` has no `dbos.workflow_status` row at all. Every
|
|
10
|
+
* path that writes the column enqueues in the same transaction, so this cannot happen — it is
|
|
11
|
+
* logged at error and counted rather than swallowed, and the attempt is enqueued anyway.
|
|
12
|
+
*/
|
|
13
|
+
export const RECONCILE_ANOMALY_MARKER = "hf-reconcile: a running run has no workflow row";
|
|
14
|
+
/** One line per run a pass could not finish; the pass carries on with the others. */
|
|
15
|
+
export const RECONCILE_FAILED_MARKER = "hf-reconcile: run refused";
|
|
16
|
+
/**
|
|
17
|
+
* One line per run a pass moved, carrying the `run_id`. The pass summary alone would make a
|
|
18
|
+
* redeploy that moved a whole backlog a single number, with no way back to which run went where.
|
|
19
|
+
*/
|
|
20
|
+
export const RECONCILE_ACTION_MARKER = "hf-reconcile: run moved";
|
|
21
|
+
/**
|
|
22
|
+
* One line per queue a pass re-derived from `hf_app_state.paused`, carrying both concurrencies.
|
|
23
|
+
* Its own marker rather than the action one: nothing about it is a run, and a stall this fixed
|
|
24
|
+
* is diagnosed by reading which direction it went.
|
|
25
|
+
*/
|
|
26
|
+
export const RECONCILE_QUEUE_MARKER = "hf-reconcile: queue concurrency corrected";
|
|
27
|
+
/** One line per pass, carrying the report. */
|
|
28
|
+
export const RECONCILE_PASS_MARKER = "hf-reconcile: pass";
|
|
29
|
+
/** DBOS statuses that mean the attempt has not run yet or is believed to be running. */
|
|
30
|
+
const LIVE_DBOS_STATUSES = ["PENDING", "ENQUEUED", "DELAYED"];
|
|
31
|
+
/**
|
|
32
|
+
* The drift scan. `reconcile()` reports it and never corrects it: correcting a counter is how
|
|
33
|
+
* round-2 finding 3 happened, and per-period drift is exact because a call is billed to the
|
|
34
|
+
* period stamped on its own row.
|
|
35
|
+
*/
|
|
36
|
+
export const DRIFT_STATEMENT = "SELECT b.period, b.spent_usd::text AS spent_usd, " +
|
|
37
|
+
"COALESCE((SELECT SUM(l.cost_usd) FROM hf_llm_call l " +
|
|
38
|
+
"WHERE l.period = b.period AND l.status = 'ok'), 0)::text AS ledger_usd " +
|
|
39
|
+
"FROM hf_budget_period b ORDER BY b.period";
|
|
40
|
+
/**
|
|
41
|
+
* Step (1)'s scan. A plain `SELECT`, joined against `dbos.workflow_status` rather than asking
|
|
42
|
+
* the SDK per run: one statement for the whole scan, and the application role reads that table
|
|
43
|
+
* under the grants E006 checks.
|
|
44
|
+
*/
|
|
45
|
+
export const RUNNING_RUNS_STATEMENT = "SELECT r.run_id, r.attempt, r.current_workflow_id, w.status AS dbos_status, " +
|
|
46
|
+
"w.application_version, w.error AS dbos_error " +
|
|
47
|
+
"FROM hf_run r LEFT JOIN dbos.workflow_status w ON w.workflow_uuid = r.current_workflow_id " +
|
|
48
|
+
"WHERE r.status = 'running' ORDER BY r.run_id";
|
|
49
|
+
/** Step (3)'s scan: runs parked by a pause the app has since come out of. */
|
|
50
|
+
export const PAUSED_RUNS_STATEMENT = "SELECT r.run_id FROM hf_run r WHERE r.status = 'paused' " +
|
|
51
|
+
"AND NOT COALESCE((SELECT paused FROM hf_app_state WHERE id = 1), false) ORDER BY r.run_id";
|
|
52
|
+
/**
|
|
53
|
+
* Step (4), ledger half. Round 3 widened the predicate twice over round 2: to `running` runs'
|
|
54
|
+
* non-current rows, and to every row on a `waiting`/`paused` run, whose current workflow has
|
|
55
|
+
* ended so nothing can still be in flight. Idempotent because the predicate is the status.
|
|
56
|
+
*
|
|
57
|
+
* Hygiene and audit, not budget correctness — none of these rows reserves anything by the time
|
|
58
|
+
* this runs, because the reservation only ever counts rows whose `workflow_id` is still their
|
|
59
|
+
* run's `current_workflow_id` on a `running` run.
|
|
60
|
+
*/
|
|
61
|
+
export const ABANDON_LLM_CALLS_STATEMENT = "UPDATE hf_llm_call l SET status = 'abandoned', finished_at = now() FROM hf_run r " +
|
|
62
|
+
"WHERE l.run_id = r.run_id AND l.status = 'started' " +
|
|
63
|
+
"AND (r.status IN ('done', 'failed', 'waiting', 'paused') " +
|
|
64
|
+
"OR l.workflow_id <> r.current_workflow_id)";
|
|
65
|
+
/**
|
|
66
|
+
* Step (4), actions half: the same predicate, into the status that already means this. The
|
|
67
|
+
* `RETURNING` is what makes "one task per uncertain row, once" fall out of the status predicate —
|
|
68
|
+
* a row this pass moved is never returned by the next one.
|
|
69
|
+
*/
|
|
70
|
+
export const UNCERTAIN_ACTIONS_STATEMENT = "UPDATE hf_action_log a SET status = 'uncertain', finished_at = now() FROM hf_run r " +
|
|
71
|
+
"WHERE a.run_id = r.run_id AND a.status = 'started' " +
|
|
72
|
+
"AND (r.status IN ('done', 'failed', 'waiting', 'paused') " +
|
|
73
|
+
"OR a.workflow_id <> r.current_workflow_id) " +
|
|
74
|
+
"RETURNING a.id::text AS id, a.run_id, a.key, a.channel, a.record_type, a.record_id";
|
|
75
|
+
/** The partial unique index is the backstop; the predicate above is the actual guarantee. */
|
|
76
|
+
const UNCERTAIN_TASK_STATEMENT = "INSERT INTO hf_task (record_type, record_id, title, origin, origin_ref) " +
|
|
77
|
+
"VALUES ($1, $2, $3, 'sweep', $4) " +
|
|
78
|
+
"ON CONFLICT (origin, origin_ref) WHERE origin_ref IS NOT NULL DO NOTHING RETURNING id";
|
|
79
|
+
const UNCERTAIN_TASK_LOOKUP_STATEMENT = "SELECT id FROM hf_task WHERE origin_ref = $1 AND origin IN ('flow', 'sweep') " +
|
|
80
|
+
"ORDER BY id LIMIT 1";
|
|
81
|
+
const UNCERTAIN_ACTIVITY_STATEMENT = "INSERT INTO hf_activity (record_type, record_id, kind, run_id, meta) " +
|
|
82
|
+
"VALUES ($1, $2, 'action.uncertain', $3, $4::jsonb)";
|
|
83
|
+
/**
|
|
84
|
+
* Step (5)'s scan. A plain `SELECT`: `decide()` locks what it decides, and an approval this
|
|
85
|
+
* scan read a moment before someone decided it is simply not pending any more by then.
|
|
86
|
+
*/
|
|
87
|
+
export const EXPIRED_APPROVALS_STATEMENT = "SELECT id, run_id FROM hf_approval WHERE status = 'pending' " +
|
|
88
|
+
"AND expires_at IS NOT NULL AND expires_at <= now() ORDER BY id";
|
|
89
|
+
/** Re-read under `FOR UPDATE` before the anomaly branch enqueues the id it already carries. */
|
|
90
|
+
const LOCK_RUN_FOR_ANOMALY_STATEMENT = "SELECT attempt, current_workflow_id, flow, input, status FROM hf_run " +
|
|
91
|
+
"WHERE run_id = $1 FOR UPDATE";
|
|
92
|
+
const WORKFLOW_ID_TAKEN_STATEMENT = "SELECT 1 FROM dbos.workflow_status WHERE workflow_uuid = $1";
|
|
93
|
+
/**
|
|
94
|
+
* The app-level half of a redeploy: DBOS's own recovery is version- and executor-scoped, so an
|
|
95
|
+
* old version's workflows are never touched by a new worker. This is what moves those runs on.
|
|
96
|
+
*
|
|
97
|
+
* A control-plane operation — control pool, `assertNotInWorkflow()`, every transaction through
|
|
98
|
+
* the tag-asserting helper — and it **never locks `hf_budget_period`**: its drift read is a
|
|
99
|
+
* plain `SELECT`, which is what keeps the lock order `hf_run → hf_budget_period → ledger` free
|
|
100
|
+
* of a cycle (round-3 finding 8).
|
|
101
|
+
*
|
|
102
|
+
* One run's refusal never ends the pass: the runs are independent, and a pass that stopped at
|
|
103
|
+
* the first one would leave the rest of a backlog stranded until the defect was fixed.
|
|
104
|
+
*
|
|
105
|
+
* Steps (1), (3), (4), (5) and (6) and the drift read; step (2) is deleted, not re-predicated —
|
|
106
|
+
* the enqueue is in the bump's own transaction, so there is no commit-to-enqueue window to
|
|
107
|
+
* backstop.
|
|
108
|
+
*/
|
|
109
|
+
export async function reconcile(pool, dbosClient, options) {
|
|
110
|
+
assertNotInWorkflow("reconcile");
|
|
111
|
+
const report = {
|
|
112
|
+
reattempted: [],
|
|
113
|
+
concluded: [],
|
|
114
|
+
anomalies: [],
|
|
115
|
+
abandonedLlmCalls: 0,
|
|
116
|
+
uncertainActions: 0,
|
|
117
|
+
expired: [],
|
|
118
|
+
queueConcurrency: [],
|
|
119
|
+
drift: [],
|
|
120
|
+
failures: [],
|
|
121
|
+
};
|
|
122
|
+
const running = await pool.query(RUNNING_RUNS_STATEMENT);
|
|
123
|
+
for (const row of running.rows) {
|
|
124
|
+
await reconcileRunningRun(pool, dbosClient, options, row, report);
|
|
125
|
+
}
|
|
126
|
+
const paused = await pool.query(PAUSED_RUNS_STATEMENT);
|
|
127
|
+
for (const row of paused.rows) {
|
|
128
|
+
try {
|
|
129
|
+
const bumped = await bumpAndEnqueue(pool, dbosClient, row.run_id, options);
|
|
130
|
+
recordReattempt(report, bumped, "resumed");
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
recordFailure(report, row.run_id, "resume", error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const hygiene = await controlPlaneTx(pool, { operation: "reconcile.hygiene", ...lockTimeoutOf(options) }, async (client) => {
|
|
137
|
+
const calls = await client.query(ABANDON_LLM_CALLS_STATEMENT);
|
|
138
|
+
const actions = await client.query(UNCERTAIN_ACTIONS_STATEMENT);
|
|
139
|
+
for (const row of actions.rows)
|
|
140
|
+
await openUncertainTask(client, row);
|
|
141
|
+
return { calls: calls.rowCount ?? 0, actions: actions.rowCount ?? 0 };
|
|
142
|
+
});
|
|
143
|
+
report.abandonedLlmCalls = hygiene.calls;
|
|
144
|
+
report.uncertainActions = hygiene.actions;
|
|
145
|
+
await reconcileQueueConcurrency(pool, dbosClient, report);
|
|
146
|
+
const drift = await pool.query(DRIFT_STATEMENT);
|
|
147
|
+
report.drift = drift.rows.map((row) => ({
|
|
148
|
+
period: row.period,
|
|
149
|
+
spentUsd: row.spent_usd,
|
|
150
|
+
ledgerUsd: row.ledger_usd,
|
|
151
|
+
driftUsd: (Number(row.spent_usd) - Number(row.ledger_usd)).toFixed(6),
|
|
152
|
+
}));
|
|
153
|
+
await expireApprovals(pool, dbosClient, options, report);
|
|
154
|
+
console.info(RECONCILE_PASS_MARKER, JSON.stringify(summaryOf(report)));
|
|
155
|
+
return report;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Step (4)'s other half: the row went `uncertain` with nobody told, which is what this fixes. The
|
|
159
|
+
* tier is the last one, after the `hf_action_log` update the same transaction just made.
|
|
160
|
+
*/
|
|
161
|
+
async function openUncertainTask(client, row) {
|
|
162
|
+
// The target columns follow the action row's, NULL included: a stand-in record type would fail
|
|
163
|
+
// E002 at the next boot, and `origin_ref` is what points the task back at the row.
|
|
164
|
+
const title = `Confirm ${row.channel} send ${row.key} for run ${row.run_id}`;
|
|
165
|
+
const inserted = await client.query(UNCERTAIN_TASK_STATEMENT, [
|
|
166
|
+
row.record_type,
|
|
167
|
+
row.record_id,
|
|
168
|
+
title,
|
|
169
|
+
row.id,
|
|
170
|
+
]);
|
|
171
|
+
const existing = inserted.rows[0] ??
|
|
172
|
+
(await client.query(UNCERTAIN_TASK_LOOKUP_STATEMENT, [row.id])).rows[0];
|
|
173
|
+
const taskId = existing === undefined ? null : Number(existing.id);
|
|
174
|
+
await client.query(UNCERTAIN_ACTIVITY_STATEMENT, [
|
|
175
|
+
row.record_type,
|
|
176
|
+
row.record_id,
|
|
177
|
+
row.run_id,
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
actionLogId: Number(row.id),
|
|
180
|
+
key: row.key,
|
|
181
|
+
channel: row.channel,
|
|
182
|
+
taskId,
|
|
183
|
+
}),
|
|
184
|
+
]);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Step (6). `pause`/`resume` write the flag and the queues in two statements and `startWorker()`
|
|
188
|
+
* reads the flag and writes the queues in two more, so a resume landing inside a booting
|
|
189
|
+
* worker's window leaves `paused = false` with `llm` and `actions` pinned at zero: runs enqueued
|
|
190
|
+
* behind a dequeue that claims nothing, and `/api/status` reporting `ok` because it grades health
|
|
191
|
+
* on anomalies and budget drift alone. Nothing else notices, so every pass re-derives the queues
|
|
192
|
+
* from the flag — including `startWorker()`'s own boot pass, which runs after that window.
|
|
193
|
+
*
|
|
194
|
+
* Only the two disagreements that race produces are corrected. A non-zero concurrency that is
|
|
195
|
+
* merely not the registered one is somebody's tuning, and a pass that overwrote it every minute
|
|
196
|
+
* would be a worse defect than the stall it fixes.
|
|
197
|
+
*
|
|
198
|
+
* The flag is read the way step (3) reads it — a plain `SELECT`, no lock, no transaction of its
|
|
199
|
+
* own — so this step takes nothing the lock order has an opinion about. One queue's refusal
|
|
200
|
+
* never ends the pass, for the reason every other step's does not.
|
|
201
|
+
*/
|
|
202
|
+
async function reconcileQueueConcurrency(pool, dbosClient, report) {
|
|
203
|
+
const paused = await appPaused(pool);
|
|
204
|
+
for (const name of PAUSED_QUEUES) {
|
|
205
|
+
try {
|
|
206
|
+
const queue = await dbosClient.retrieveQueue(name);
|
|
207
|
+
// No row at all: no worker has ever launched, so there is no live concurrency to disagree
|
|
208
|
+
// with the flag, and `startWorker()` applies it on the way up.
|
|
209
|
+
if (queue === null)
|
|
210
|
+
continue;
|
|
211
|
+
const was = (await queue.getGlobalConcurrency()) ?? null;
|
|
212
|
+
const stuck = !paused && was === PAUSED_CONCURRENCY;
|
|
213
|
+
// A NULL concurrency is "no limit", which is the loudest form of still dispatching.
|
|
214
|
+
const dispatching = paused && was !== PAUSED_CONCURRENCY;
|
|
215
|
+
if (!stuck && !dispatching)
|
|
216
|
+
continue;
|
|
217
|
+
const now = paused ? PAUSED_CONCURRENCY : registeredConcurrency(name);
|
|
218
|
+
await queue.setGlobalConcurrency(now);
|
|
219
|
+
const corrected = { name, paused, was, now };
|
|
220
|
+
console.info(RECONCILE_QUEUE_MARKER, JSON.stringify(corrected));
|
|
221
|
+
report.queueConcurrency.push(corrected);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
recordFailure(report, null, "queues", namingQueue(name, error));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Keeps the queue on a failure whose `runId` is null because no run owns it. */
|
|
229
|
+
function namingQueue(name, error) {
|
|
230
|
+
const thrown = error;
|
|
231
|
+
const named = new Error(`${name}: ${thrown?.message ?? String(error)}`);
|
|
232
|
+
named.name = thrown?.name ?? "Error";
|
|
233
|
+
return named;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Step (5). There is no separate sweep: an expiry is a decision like any other, so it goes
|
|
237
|
+
* through `decide()` and gets its bump, its resume workflow and its audit row from the same
|
|
238
|
+
* transaction as a human's. One approval per call — a batch would make one bad row strand the
|
|
239
|
+
* rest — and the `decisionKey` is the approval's own id, so a pass that died after the commit
|
|
240
|
+
* replays instead of deciding twice.
|
|
241
|
+
*/
|
|
242
|
+
async function expireApprovals(pool, dbosClient, options, report) {
|
|
243
|
+
const expiring = await pool.query(EXPIRED_APPROVALS_STATEMENT);
|
|
244
|
+
for (const row of expiring.rows) {
|
|
245
|
+
const approvalId = Number(row.id);
|
|
246
|
+
try {
|
|
247
|
+
const result = await decide(pool, dbosClient, {
|
|
248
|
+
ids: [approvalId],
|
|
249
|
+
decision: "expired",
|
|
250
|
+
via: "sweep",
|
|
251
|
+
decisionKey: sweepDecisionKey(approvalId),
|
|
252
|
+
...lockTimeoutOf(options),
|
|
253
|
+
});
|
|
254
|
+
for (const decided of result.decided) {
|
|
255
|
+
const expired = {
|
|
256
|
+
approvalId: decided.approvalId,
|
|
257
|
+
runId: decided.runId,
|
|
258
|
+
workflowId: decided.resumeWorkflowId,
|
|
259
|
+
};
|
|
260
|
+
console.info(RECONCILE_ACTION_MARKER, JSON.stringify({ action: "expire", ...expired }));
|
|
261
|
+
report.expired.push(expired);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
recordFailure(report, row.run_id, "expire", error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Stable across passes, so a re-decided row is a replay rather than a second decision. */
|
|
270
|
+
export function sweepDecisionKey(approvalId) {
|
|
271
|
+
return `sweep:${approvalId}`;
|
|
272
|
+
}
|
|
273
|
+
async function reconcileRunningRun(pool, dbosClient, options, row, report) {
|
|
274
|
+
if (row.dbos_status === null) {
|
|
275
|
+
await recordAnomaly(pool, dbosClient, options, row, report);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (row.dbos_status === "SUCCESS" || isTerminalFailure(row.dbos_status)) {
|
|
279
|
+
// The workflow ended but the run was never marked: the wrapper's own status write is the
|
|
280
|
+
// last thing it does, so this is the crash window between the two.
|
|
281
|
+
const status = row.dbos_status === "SUCCESS" ? "done" : "failed";
|
|
282
|
+
try {
|
|
283
|
+
await concludeRun(pool, row.run_id, row.current_workflow_id, status, status === "failed" ? (row.dbos_error ?? `the workflow ended ${row.dbos_status}`) : null);
|
|
284
|
+
const concluded = { runId: row.run_id, status, dbosStatus: row.dbos_status };
|
|
285
|
+
console.info(RECONCILE_ACTION_MARKER, JSON.stringify({ action: "conclude", ...concluded }));
|
|
286
|
+
report.concluded.push(concluded);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
recordFailure(report, row.run_id, "conclude", error);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// A cancelled current attempt is this pass's own crash window: step (1) cancels and then
|
|
294
|
+
// bumps, so a pass that died between the two leaves exactly this. Re-attempting is the same
|
|
295
|
+
// condition as a dead version — the attempt cannot run — and never marks the run terminal.
|
|
296
|
+
if (row.dbos_status === "CANCELLED") {
|
|
297
|
+
await reattempt(pool, dbosClient, options, row, report, "cancelled");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// An enqueue from a control-plane transaction carries no `application_version` (the SDK takes
|
|
301
|
+
// it from the client, and a client has none), so a NULL is an attempt any version may dequeue
|
|
302
|
+
// — including one this pass enqueued a moment ago. Only a version that is present and not
|
|
303
|
+
// ours is dead, or the reconciler would bump its own work forever.
|
|
304
|
+
const dead = LIVE_DBOS_STATUSES.includes(row.dbos_status) &&
|
|
305
|
+
row.application_version !== null &&
|
|
306
|
+
row.application_version !== options.applicationVersion;
|
|
307
|
+
if (!dead)
|
|
308
|
+
return;
|
|
309
|
+
try {
|
|
310
|
+
await dbosClient.cancelWorkflow(row.current_workflow_id);
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
recordFailure(report, row.run_id, "reattempt", error);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
await reattempt(pool, dbosClient, options, row, report, "dead-version");
|
|
317
|
+
}
|
|
318
|
+
async function reattempt(pool, dbosClient, options, row, report, reason) {
|
|
319
|
+
try {
|
|
320
|
+
const bumped = await bumpAndEnqueue(pool, dbosClient, row.run_id, options);
|
|
321
|
+
recordReattempt(report, bumped, reason);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
recordFailure(report, row.run_id, "reattempt", error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** One run's bump in its own control-plane transaction, around the shared bump path. */
|
|
328
|
+
async function bumpAndEnqueue(pool, dbosClient, runId, options) {
|
|
329
|
+
return controlPlaneTx(pool, { operation: "reconcile.bump", ...lockTimeoutOf(options) }, async (client) => bumpAndEnqueueOn(client, dbosClient, runId));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The invariant violation. The run is left on the attempt it already has — the id is fresh by
|
|
333
|
+
* construction, so what is missing is the enqueue, not the attempt — and the enqueue is redone
|
|
334
|
+
* under the run's own lock so a concurrent bump cannot be enqueued over.
|
|
335
|
+
*/
|
|
336
|
+
async function recordAnomaly(pool, dbosClient, options, row, report) {
|
|
337
|
+
console.error(RECONCILE_ANOMALY_MARKER, JSON.stringify({ runId: row.run_id, workflowId: row.current_workflow_id }));
|
|
338
|
+
try {
|
|
339
|
+
const enqueued = await controlPlaneTx(pool, { operation: "reconcile.anomaly", ...lockTimeoutOf(options) }, async (client) => enqueueCurrentAttempt(client, dbosClient, row));
|
|
340
|
+
report.anomalies.push({
|
|
341
|
+
runId: row.run_id,
|
|
342
|
+
workflowId: row.current_workflow_id,
|
|
343
|
+
enqueued,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
report.anomalies.push({
|
|
348
|
+
runId: row.run_id,
|
|
349
|
+
workflowId: row.current_workflow_id,
|
|
350
|
+
enqueued: false,
|
|
351
|
+
});
|
|
352
|
+
recordFailure(report, row.run_id, "anomaly", error);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function enqueueCurrentAttempt(client, dbosClient, row) {
|
|
356
|
+
const locked = await client.query(LOCK_RUN_FOR_ANOMALY_STATEMENT, [row.run_id]);
|
|
357
|
+
const run = locked.rows[0];
|
|
358
|
+
// The scan was a plain read; anything that moved the run since owns it now.
|
|
359
|
+
if (run === undefined ||
|
|
360
|
+
run.status !== "running" ||
|
|
361
|
+
run.current_workflow_id !== row.current_workflow_id) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
const taken = await client.query(WORKFLOW_ID_TAKEN_STATEMENT, [row.current_workflow_id]);
|
|
365
|
+
if (taken.rowCount !== 0)
|
|
366
|
+
return false;
|
|
367
|
+
const flow = flowForRun(row.run_id, run.flow);
|
|
368
|
+
await dbosClient.enqueueInTransaction(client, { queueName: flow.queue, workflowName: flow.name, workflowID: run.current_workflow_id }, { runId: row.run_id, attempt: run.attempt, input: run.input });
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
function isTerminalFailure(dbosStatus) {
|
|
372
|
+
return dbosStatus === "ERROR" || dbosStatus === "MAX_RECOVERY_ATTEMPTS_EXCEEDED";
|
|
373
|
+
}
|
|
374
|
+
function recordReattempt(report, bumped, reason) {
|
|
375
|
+
const reattempted = {
|
|
376
|
+
runId: bumped.runId,
|
|
377
|
+
attempt: bumped.attempt,
|
|
378
|
+
workflowId: bumped.workflowId,
|
|
379
|
+
reason,
|
|
380
|
+
};
|
|
381
|
+
console.info(RECONCILE_ACTION_MARKER, JSON.stringify({ action: "reattempt", ...reattempted }));
|
|
382
|
+
report.reattempted.push(reattempted);
|
|
383
|
+
}
|
|
384
|
+
function lockTimeoutOf(options) {
|
|
385
|
+
return options.lockTimeout === undefined ? {} : { lockTimeout: options.lockTimeout };
|
|
386
|
+
}
|
|
387
|
+
function recordFailure(report, runId, step, error) {
|
|
388
|
+
const thrown = error;
|
|
389
|
+
const message = `${thrown?.name ?? "Error"}: ${thrown?.message ?? String(error)}`;
|
|
390
|
+
console.error(RECONCILE_FAILED_MARKER, JSON.stringify({ runId, step, error: message }));
|
|
391
|
+
report.failures.push({ runId, step, error: message });
|
|
392
|
+
}
|
|
393
|
+
function summaryOf(report) {
|
|
394
|
+
return {
|
|
395
|
+
reattempted: report.reattempted.length,
|
|
396
|
+
concluded: report.concluded.length,
|
|
397
|
+
anomalies: report.anomalies.length,
|
|
398
|
+
abandonedLlmCalls: report.abandonedLlmCalls,
|
|
399
|
+
uncertainActions: report.uncertainActions,
|
|
400
|
+
expired: report.expired.length,
|
|
401
|
+
queueConcurrency: report.queueConcurrency.length,
|
|
402
|
+
failures: report.failures.length,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The every-minute schedule. A plain timer rather than a DBOS scheduled workflow: DBOS's
|
|
407
|
+
* scheduler runs its functions *as workflows*, and `reconcile()` is a control-plane operation
|
|
408
|
+
* that `assertNotInWorkflow()` refuses from inside one (round-3 finding 2).
|
|
409
|
+
*
|
|
410
|
+
* Passes never overlap — a pass that outruns the interval would have two reconcilers bumping
|
|
411
|
+
* the same backlog — and a failed pass is logged, never thrown: there is no caller left to
|
|
412
|
+
* throw to, and the next pass reconciles whatever this one did not.
|
|
413
|
+
*/
|
|
414
|
+
export function startReconciler(pool, dbosClient, options) {
|
|
415
|
+
let running = false;
|
|
416
|
+
const pass = async () => {
|
|
417
|
+
if (running)
|
|
418
|
+
return;
|
|
419
|
+
running = true;
|
|
420
|
+
try {
|
|
421
|
+
await reconcile(pool, dbosClient, options);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
console.error(RECONCILE_FAILED_MARKER, error);
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
running = false;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
// `unref` so the reconciler is never itself the reason a process stays up.
|
|
431
|
+
const timer = setInterval(() => void pass(), options.intervalMs ?? RECONCILE_INTERVAL_MS);
|
|
432
|
+
timer.unref();
|
|
433
|
+
return { stop: () => clearInterval(timer) };
|
|
434
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface RunContext {
|
|
2
|
+
runId: string;
|
|
3
|
+
attempt: number;
|
|
4
|
+
/** `DBOS.workflowID` of the attempt in flight; the fencing token every write matches. */
|
|
5
|
+
workflowId: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class OutsideRun extends Error {
|
|
8
|
+
readonly operation: string;
|
|
9
|
+
constructor(operation: string);
|
|
10
|
+
}
|
|
11
|
+
export declare function withRunContext<T>(context: RunContext, fn: () => Promise<T>): Promise<T>;
|
|
12
|
+
export declare function currentRun(operation: string): RunContext;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export class OutsideRun extends Error {
|
|
3
|
+
operation;
|
|
4
|
+
constructor(operation) {
|
|
5
|
+
super(`OutsideRun: ${operation} can only be called from inside a flow body`);
|
|
6
|
+
this.name = "OutsideRun";
|
|
7
|
+
this.operation = operation;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* How `step()` reaches the run it belongs to. `ctx.tx(runId, workflowId, …)` takes both
|
|
12
|
+
* explicitly — that seam is what lets `fence.test.ts` run with no DBOS launch — so something
|
|
13
|
+
* has to carry them from the workflow to the step, and DBOS's own arguments only reach the
|
|
14
|
+
* flow body. App code never passes them, so it cannot pass the wrong ones.
|
|
15
|
+
*/
|
|
16
|
+
const storage = new AsyncLocalStorage();
|
|
17
|
+
export function withRunContext(context, fn) {
|
|
18
|
+
return storage.run(context, fn);
|
|
19
|
+
}
|
|
20
|
+
export function currentRun(operation) {
|
|
21
|
+
const context = storage.getStore();
|
|
22
|
+
if (context === undefined)
|
|
23
|
+
throw new OutsideRun(operation);
|
|
24
|
+
return context;
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RunStatus } from "@hyperfixation/db";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
/** The wrapper's first statement; `version` stamps the SHA the attempt actually ran under. */
|
|
4
|
+
export declare const CLAIM_RUN_STATEMENT: string;
|
|
5
|
+
/** `finished_at` is only a conclusion: a `waiting`/`paused` run is still going. */
|
|
6
|
+
export declare const CONCLUDE_RUN_STATEMENT: string;
|
|
7
|
+
/** Logged whenever a fenced status write matched no row; the run has moved on without it. */
|
|
8
|
+
export declare const RUN_STATUS_REFUSED_MARKER = "hf-run: status write refused, the run moved on";
|
|
9
|
+
/**
|
|
10
|
+
* Both writes go straight at the control pool rather than through `controlPlaneTx`: they are
|
|
11
|
+
* issued from inside a workflow, which `assertNotInWorkflow()` refuses by design. A single
|
|
12
|
+
* `UPDATE` is its own transaction, so the helper's commit assert has nothing to add — what
|
|
13
|
+
* makes these safe is the `AND current_workflow_id = …` fence, which every one of them
|
|
14
|
+
* carries, so a superseded attempt's write matches no row instead of overwriting a run that
|
|
15
|
+
* a bump has already moved to its next attempt.
|
|
16
|
+
*/
|
|
17
|
+
export declare function claimRun(pool: Pool, runId: string, workflowId: string, version: string): Promise<boolean>;
|
|
18
|
+
export declare function concludeRun(pool: Pool, runId: string, workflowId: string, status: RunStatus, error: string | null): Promise<boolean>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The wrapper's first statement; `version` stamps the SHA the attempt actually ran under. */
|
|
2
|
+
export const CLAIM_RUN_STATEMENT = "UPDATE hf_run SET status = 'running', version = $2 " +
|
|
3
|
+
"WHERE run_id = $1 AND current_workflow_id = $3";
|
|
4
|
+
/** `finished_at` is only a conclusion: a `waiting`/`paused` run is still going. */
|
|
5
|
+
export const CONCLUDE_RUN_STATEMENT = "UPDATE hf_run SET status = $2, error = $3, " +
|
|
6
|
+
"finished_at = CASE WHEN $2 IN ('done', 'failed') THEN now() ELSE NULL END " +
|
|
7
|
+
"WHERE run_id = $1 AND current_workflow_id = $4";
|
|
8
|
+
/** Logged whenever a fenced status write matched no row; the run has moved on without it. */
|
|
9
|
+
export const RUN_STATUS_REFUSED_MARKER = "hf-run: status write refused, the run moved on";
|
|
10
|
+
/**
|
|
11
|
+
* Both writes go straight at the control pool rather than through `controlPlaneTx`: they are
|
|
12
|
+
* issued from inside a workflow, which `assertNotInWorkflow()` refuses by design. A single
|
|
13
|
+
* `UPDATE` is its own transaction, so the helper's commit assert has nothing to add — what
|
|
14
|
+
* makes these safe is the `AND current_workflow_id = …` fence, which every one of them
|
|
15
|
+
* carries, so a superseded attempt's write matches no row instead of overwriting a run that
|
|
16
|
+
* a bump has already moved to its next attempt.
|
|
17
|
+
*/
|
|
18
|
+
export async function claimRun(pool, runId, workflowId, version) {
|
|
19
|
+
const claimed = await pool.query(CLAIM_RUN_STATEMENT, [runId, version, workflowId]);
|
|
20
|
+
return claimed.rowCount === 1;
|
|
21
|
+
}
|
|
22
|
+
export async function concludeRun(pool, runId, workflowId, status, error) {
|
|
23
|
+
const written = await pool.query(CONCLUDE_RUN_STATEMENT, [runId, status, error, workflowId]);
|
|
24
|
+
if (written.rowCount === 1)
|
|
25
|
+
return true;
|
|
26
|
+
console.info(RUN_STATUS_REFUSED_MARKER, JSON.stringify({ runId, workflowId, status }));
|
|
27
|
+
return false;
|
|
28
|
+
}
|
package/dist/runs.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import type { Flow } from "./define-flow.js";
|
|
4
|
+
export declare const START_RUN_STATEMENT: string;
|
|
5
|
+
export interface RunsStartOptions {
|
|
6
|
+
/** A fresh id is generated when omitted. */
|
|
7
|
+
runId?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface StartedRun {
|
|
10
|
+
runId: string;
|
|
11
|
+
workflowId: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The only way a run begins: insert the `hf_run` row at attempt 1 and enqueue its first
|
|
15
|
+
* workflow in the same transaction, so the row and the workflow exist together or not at all.
|
|
16
|
+
* A control-plane operation — callable from the web (no run is in flight yet to forbid it).
|
|
17
|
+
*/
|
|
18
|
+
export declare function runsStart<I>(pool: Pool, dbosClient: DBOSClient, flow: Flow<I, unknown>, input: I, options?: RunsStartOptions): Promise<StartedRun>;
|
package/dist/runs.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { attemptWorkflowId, controlPlaneTx } from "@hyperfixation/db";
|
|
3
|
+
export const START_RUN_STATEMENT = "INSERT INTO hf_run (run_id, flow, input, status, attempt, current_workflow_id) " +
|
|
4
|
+
"VALUES ($1, $2, $3, 'running', 1, $4)";
|
|
5
|
+
/**
|
|
6
|
+
* The only way a run begins: insert the `hf_run` row at attempt 1 and enqueue its first
|
|
7
|
+
* workflow in the same transaction, so the row and the workflow exist together or not at all.
|
|
8
|
+
* A control-plane operation — callable from the web (no run is in flight yet to forbid it).
|
|
9
|
+
*/
|
|
10
|
+
export async function runsStart(pool, dbosClient, flow, input, options = {}) {
|
|
11
|
+
const runId = options.runId ?? randomUUID();
|
|
12
|
+
const workflowId = attemptWorkflowId(runId, 1);
|
|
13
|
+
return controlPlaneTx(pool, { operation: "runs.start" }, async (client) => {
|
|
14
|
+
await client.query(START_RUN_STATEMENT, [runId, flow.name, JSON.stringify(input), workflowId]);
|
|
15
|
+
await dbosClient.enqueueInTransaction(client, { queueName: flow.queue, workflowName: flow.name, workflowID: workflowId }, { runId, attempt: 1, input });
|
|
16
|
+
return { runId, workflowId };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DBOSClient, type WorkflowQueue } from "@dbos-inc/dbos-sdk";
|
|
2
|
+
import { type RecordTable, type StepPool } from "@hyperfixation/db";
|
|
3
|
+
import type { ApprovalNotifier } from "./approvals.js";
|
|
4
|
+
import { type ControlPool } from "./control-pool.js";
|
|
5
|
+
import { type Reconciler } from "./reconcile.js";
|
|
6
|
+
import { type WorkerLock } from "./worker-lock.js";
|
|
7
|
+
/** The value of `HF_PROCESS` in the one process shape allowed to launch DBOS. */
|
|
8
|
+
export declare const WORKER_PROCESS = "worker";
|
|
9
|
+
/** Enough of a commit sha to be a version; `hf dev` sets `dev-<timestamp>`. */
|
|
10
|
+
export declare const MIN_BUILD_SHA_LENGTH = 7;
|
|
11
|
+
export declare const SYSTEM_DATABASE_SCHEMA = "dbos";
|
|
12
|
+
export declare const SYSTEM_DATABASE_POOL_SIZE = 5;
|
|
13
|
+
/**
|
|
14
|
+
* The reconciler's own client. Deliberately not `getClient()`: that singleton is the web's, and
|
|
15
|
+
* calling it here would run the boot checks a second time and import this module back.
|
|
16
|
+
*/
|
|
17
|
+
export declare const RECONCILER_POOL_SIZE = 2;
|
|
18
|
+
/**
|
|
19
|
+
* The three queues, by name and concurrency. There is no flow registry yet; when there is,
|
|
20
|
+
* `defineFlow` names one of these and nothing else may.
|
|
21
|
+
*/
|
|
22
|
+
export declare const QUEUES: readonly [{
|
|
23
|
+
readonly name: "llm";
|
|
24
|
+
readonly globalConcurrency: 4;
|
|
25
|
+
}, {
|
|
26
|
+
readonly name: "actions";
|
|
27
|
+
readonly globalConcurrency: 2;
|
|
28
|
+
}, {
|
|
29
|
+
readonly name: "resolve";
|
|
30
|
+
readonly globalConcurrency: 1;
|
|
31
|
+
}];
|
|
32
|
+
export type QueueName = (typeof QUEUES)[number]["name"];
|
|
33
|
+
/**
|
|
34
|
+
* Logged on either side of the one `DBOS.launch()` call in the system. Redeploy case 6
|
|
35
|
+
* asserts the first never appears in a second worker's output.
|
|
36
|
+
*/
|
|
37
|
+
export declare const LAUNCHING_MARKER = "hf-worker: calling DBOS.launch";
|
|
38
|
+
export declare const LAUNCHED_MARKER = "hf-worker: DBOS launched";
|
|
39
|
+
/** One per SIGTERM the handler acts on; redeploy case 11 counts them. */
|
|
40
|
+
export declare const SHUTDOWN_MARKER = "hf-worker: SIGTERM, calling DBOS.shutdown";
|
|
41
|
+
export declare const SHUTDOWN_IGNORED_MARKER = "hf-worker: SIGTERM ignored, already draining";
|
|
42
|
+
export declare const SHUTDOWN_FAILED_MARKER = "hf-worker: DBOS.shutdown rejected";
|
|
43
|
+
/** How long the drain waits for workflows running here before it abandons them. */
|
|
44
|
+
export declare const DRAIN_TIMEOUT_MS = 60000;
|
|
45
|
+
/**
|
|
46
|
+
* The handler's own bound, past the drain. Compose's `stop_grace_period: 90s` SIGKILL is the
|
|
47
|
+
* line after this one, and the advisory lock is released by neither before the process dies.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SHUTDOWN_WATCHDOG_MS = 75000;
|
|
50
|
+
export declare class NotAWorkerProcess extends Error {
|
|
51
|
+
readonly hfProcess: string | undefined;
|
|
52
|
+
constructor(hfProcess: string | undefined);
|
|
53
|
+
}
|
|
54
|
+
export declare class MissingBuildSha extends Error {
|
|
55
|
+
constructor(buildSha: string | undefined);
|
|
56
|
+
}
|
|
57
|
+
export interface StartWorkerOptions {
|
|
58
|
+
appName: string;
|
|
59
|
+
/** The application role's connection string: both pools, the lock and DBOS all use it. */
|
|
60
|
+
databaseUrl: string;
|
|
61
|
+
/** Tables registered with `defineRecord`, for E001-E003. */
|
|
62
|
+
recordTables?: readonly RecordTable[];
|
|
63
|
+
/** The app's own migrations directory, for E005. */
|
|
64
|
+
appMigrationsDir?: string;
|
|
65
|
+
/** The gate's notifier for every `waitForApproval` that passes none itself. */
|
|
66
|
+
approvalNotifier?: ApprovalNotifier;
|
|
67
|
+
}
|
|
68
|
+
export interface Worker {
|
|
69
|
+
appName: string;
|
|
70
|
+
applicationVersion: string;
|
|
71
|
+
steps: StepPool;
|
|
72
|
+
control: ControlPool;
|
|
73
|
+
lock: WorkerLock;
|
|
74
|
+
queues: Record<QueueName, WorkflowQueue>;
|
|
75
|
+
/** What `reconcile()` enqueues through; the worker's only `DBOSClient`. */
|
|
76
|
+
client: DBOSClient;
|
|
77
|
+
reconciler: Reconciler;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The only place `DBOS.launch()` runs. Boot checks, then the two pools, then the advisory
|
|
81
|
+
* lock, then launch — a worker that cannot prove it is alone never reaches the launch.
|
|
82
|
+
*/
|
|
83
|
+
export declare function startWorker(options: StartWorkerOptions): Promise<Worker>;
|