@johpaz/hive-sdk 0.0.17 → 0.1.3
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 +83 -203
- package/bun.lock +833 -0
- package/bunfig.toml +7 -0
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +60 -0
- package/docs/HIVE-HARNESS.md +113 -0
- package/package.json +36 -2
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +13 -2
- package/packages/core/src/ace/Tracer.ts +1 -1
- package/packages/core/src/agent/AgentRunner.ts +12 -0
- package/packages/core/src/agent/ContextCompiler.ts +4 -4
- package/packages/core/src/agent/ConversationStore.ts +30 -20
- package/packages/core/src/agent/selectors/PlaybookSelector.ts +50 -76
- package/packages/core/src/agent/selectors/SkillSelector.ts +106 -262
- package/packages/core/src/agent/selectors/ToolSelector.ts +54 -89
- package/packages/core/src/api/createAgent.ts +10 -0
- package/packages/core/src/auth/auth.ts +36 -23
- package/packages/core/src/config/loader.ts +2 -2
- package/packages/core/src/harness/boot-id.ts +20 -0
- package/packages/core/src/harness/collections.ts +98 -0
- package/packages/core/src/harness/db-helpers.ts +87 -0
- package/packages/core/src/harness/durable-queue.ts +337 -0
- package/packages/core/src/harness/goal-verifier.ts +141 -0
- package/packages/core/src/harness/harness.test.ts +236 -0
- package/packages/core/src/harness/index.ts +34 -0
- package/packages/core/src/harness/job-store.ts +399 -0
- package/packages/core/src/harness/proof-packet.ts +69 -0
- package/packages/core/src/harness/reconcile.ts +149 -0
- package/packages/core/src/harness/run-epoch.ts +32 -0
- package/packages/core/src/harness/run-store.ts +334 -0
- package/packages/core/src/index.ts +19 -0
- package/packages/core/src/memory/Scratchpad.test.ts +23 -21
- package/packages/core/src/memory/Scratchpad.ts +41 -24
- package/packages/core/src/skills/bundled-data.generated.ts +50 -0
- package/packages/core/src/skills/skills.test.ts +21 -0
- package/packages/core/src/storage/HiveDBStorage.ts +64 -0
- package/packages/core/src/storage/SQLiteStorage.ts +7 -0
- package/packages/core/src/storage/hiveSeed.ts +308 -0
- package/packages/core/src/storage/hiveStorage.test.ts +38 -0
- package/packages/core/src/storage/index.ts +10 -0
- package/packages/core/src/storage/seed.ts +5 -1
- package/packages/core/src/storage/usage.ts +106 -167
- package/packages/core/src/tool-runtime/tool-runtime.test.ts +11 -3
- package/packages/core/src/tools/agents/get-available-models.ts +52 -56
- package/packages/core/src/tools/agents/index.ts +77 -60
- package/packages/core/src/tools/core/index.ts +106 -291
- package/packages/core/src/tools/index.ts +1 -0
- package/packages/core/src/tools/meeting/index.ts +83 -93
- package/packages/core/src/tools/web/api-request.test.ts +170 -0
- package/packages/core/src/tools/web/api-request.ts +239 -0
- package/packages/core/src/tools/web/browser-click.ts +2 -2
- package/packages/core/src/tools/web/browser-extract.ts +22 -6
- package/packages/core/src/tools/web/browser-navigate.ts +34 -18
- package/packages/core/src/tools/web/browser-screenshot.ts +40 -8
- package/packages/core/src/tools/web/browser-script.ts +2 -2
- package/packages/core/src/tools/web/browser-service.test.ts +83 -0
- package/packages/core/src/tools/web/browser-service.ts +290 -341
- package/packages/core/src/tools/web/browser-type.ts +2 -2
- package/packages/core/src/tools/web/browser-wait.ts +2 -2
- package/packages/core/src/tools/web/index.ts +3 -0
- package/packages/core/src/utils/toon.ts +4 -4
- package/CHANGELOG.md +0 -72
- package/docs/README.md +0 -161
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness module tests — job-store retry/idempotency, durable-queue
|
|
3
|
+
* dispatch + retry wiring, run-store checkpoint/acceptance/epoch
|
|
4
|
+
* round-trip, proof-packet persistence, goal-verifier (deterministic
|
|
5
|
+
* check-tool + acceptance-criteria paths only — no network/LLM calls).
|
|
6
|
+
*
|
|
7
|
+
* Uses an isolated HIVE_HOME so this never touches a real dev database.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
+
import { closeHiveDB } from "../storage/HiveDBStorage.ts";
|
|
16
|
+
import {
|
|
17
|
+
ensureHarnessIndexes,
|
|
18
|
+
createJob,
|
|
19
|
+
claimJob,
|
|
20
|
+
failJobOrRetry,
|
|
21
|
+
getJob,
|
|
22
|
+
computeBackoffDelay,
|
|
23
|
+
findByIdempotencyKey,
|
|
24
|
+
DurableLaneQueue,
|
|
25
|
+
registerExecutor,
|
|
26
|
+
createRun,
|
|
27
|
+
deserializeAcceptance,
|
|
28
|
+
deserializeEpoch,
|
|
29
|
+
buildRunEpoch,
|
|
30
|
+
buildProofPacket,
|
|
31
|
+
findProofPacketsByRun,
|
|
32
|
+
verifyGoal,
|
|
33
|
+
getBootId,
|
|
34
|
+
resetBootId,
|
|
35
|
+
col,
|
|
36
|
+
type JobRetryPolicy,
|
|
37
|
+
type JobDoc,
|
|
38
|
+
} from "./index.ts";
|
|
39
|
+
|
|
40
|
+
// Fresh HiveDB directory per test — hive-sdk's HiveDBStorage has no ":memory:"
|
|
41
|
+
// mode, so isolation means pointing HIVE_HOME at a new temp dir each time and
|
|
42
|
+
// letting the lazy singleton reopen there.
|
|
43
|
+
let currentDir: string;
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
closeHiveDB();
|
|
47
|
+
resetBootId();
|
|
48
|
+
currentDir = mkdtempSync(path.join(tmpdir(), "hive-sdk-harness-test-"));
|
|
49
|
+
process.env.HIVE_HOME = currentDir;
|
|
50
|
+
await ensureHarnessIndexes();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
closeHiveDB();
|
|
55
|
+
rmSync(currentDir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const FAST_POLICY: JobRetryPolicy = {
|
|
59
|
+
maxRetries: 2,
|
|
60
|
+
initialDelayMs: 10,
|
|
61
|
+
backoffMultiplier: 2,
|
|
62
|
+
maxDelayMs: 1000,
|
|
63
|
+
jitter: 0,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 3000): Promise<void> {
|
|
67
|
+
const start = Date.now();
|
|
68
|
+
while (Date.now() - start < timeoutMs) {
|
|
69
|
+
if (await predicate()) return;
|
|
70
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
71
|
+
}
|
|
72
|
+
throw new Error("waitFor timed out");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("harness/job-store: idempotency", () => {
|
|
76
|
+
test("repeated idempotency_key returns the same job", async () => {
|
|
77
|
+
const first = await createJob({ lane: "s1", type: "worker_task", payload: { n: 1 }, run_id: "r1", idempotency_key: "dedupe-1" });
|
|
78
|
+
const second = await createJob({ lane: "s1", type: "worker_task", payload: { n: 2 }, run_id: "r1", idempotency_key: "dedupe-1" });
|
|
79
|
+
expect(second.id).toBe(first.id);
|
|
80
|
+
expect(JSON.parse(second.payload_json).n).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("findByIdempotencyKey returns null for an unknown key", async () => {
|
|
84
|
+
expect(await findByIdempotencyKey("nope")).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("harness/job-store: retry/backoff", () => {
|
|
89
|
+
test("computeBackoffDelay grows exponentially and caps", () => {
|
|
90
|
+
const policy: JobRetryPolicy = { maxRetries: 10, initialDelayMs: 100, backoffMultiplier: 2, maxDelayMs: 500, jitter: 0 };
|
|
91
|
+
expect(computeBackoffDelay(0, policy)).toBe(100);
|
|
92
|
+
expect(computeBackoffDelay(1, policy)).toBe(200);
|
|
93
|
+
expect(computeBackoffDelay(3, policy)).toBe(500);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("failJobOrRetry schedules a retry, then fails terminally after maxRetries", async () => {
|
|
97
|
+
const job = await createJob({ lane: "s2", type: "worker_task", payload: {}, run_id: "r2" });
|
|
98
|
+
const c = await col<JobDoc>("harness_jobQueue");
|
|
99
|
+
// Force not_before into the past after each retry so claimJob doesn't block on the backoff delay.
|
|
100
|
+
const forcePastNotBefore = async () => {
|
|
101
|
+
const entry = await c.get(job.id);
|
|
102
|
+
await c.put(job.id, { ...entry!.doc, not_before: Date.now() - 1 }, { expectedVersion: entry!.version });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await claimJob(job.id);
|
|
106
|
+
const r1 = await failJobOrRetry(job.id, "boom", getBootId(), FAST_POLICY);
|
|
107
|
+
expect(r1!.status).toBe("pending");
|
|
108
|
+
expect(r1!.retry_count).toBe(1);
|
|
109
|
+
await forcePastNotBefore();
|
|
110
|
+
|
|
111
|
+
await claimJob(job.id);
|
|
112
|
+
const r2 = await failJobOrRetry(job.id, "boom again", getBootId(), FAST_POLICY);
|
|
113
|
+
expect(r2!.status).toBe("pending");
|
|
114
|
+
expect(r2!.retry_count).toBe(2);
|
|
115
|
+
await forcePastNotBefore();
|
|
116
|
+
|
|
117
|
+
await claimJob(job.id);
|
|
118
|
+
const r3 = await failJobOrRetry(job.id, "final", getBootId(), FAST_POLICY);
|
|
119
|
+
expect(r3!.status).toBe("failed");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("harness/durable-queue", () => {
|
|
124
|
+
let queue: DurableLaneQueue | null = null;
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
queue?.stop();
|
|
128
|
+
queue = null;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("retries a retryable logical failure then completes", async () => {
|
|
132
|
+
let calls = 0;
|
|
133
|
+
registerExecutor("flaky_task", async () => {
|
|
134
|
+
calls++;
|
|
135
|
+
if (calls < 2) return { ok: false, error: "flaky", retryable: true };
|
|
136
|
+
return { ok: true, result: "recovered" };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
queue = new DurableLaneQueue({ maxGlobalConcurrency: 2, jobRetryPolicy: FAST_POLICY });
|
|
140
|
+
const job = await queue.enqueue({ lane: "lane-flaky", type: "flaky_task", run_id: "r3", payload: {} });
|
|
141
|
+
|
|
142
|
+
await waitFor(async () => (await getJob(job.id))?.retry_count === 1);
|
|
143
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
144
|
+
queue.start();
|
|
145
|
+
|
|
146
|
+
await waitFor(async () => (await getJob(job.id))?.status === "completed");
|
|
147
|
+
expect(calls).toBe(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("nonRetryableTypes never auto-retries a logical failure", async () => {
|
|
151
|
+
let calls = 0;
|
|
152
|
+
registerExecutor("chat_turn", async () => {
|
|
153
|
+
calls++;
|
|
154
|
+
return { ok: false, error: "user-facing failure" };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
queue = new DurableLaneQueue({ maxGlobalConcurrency: 2, jobRetryPolicy: FAST_POLICY });
|
|
158
|
+
const job = await queue.enqueue({ lane: "lane-chat", type: "chat_turn", run_id: "r4", payload: {} });
|
|
159
|
+
|
|
160
|
+
await waitFor(async () => (await getJob(job.id))?.status === "failed");
|
|
161
|
+
expect(calls).toBe(1);
|
|
162
|
+
expect((await getJob(job.id))!.retry_count).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("harness/run-store: acceptance + epoch round-trip", () => {
|
|
167
|
+
test("createRun persists and deserializes acceptance criteria and epoch", async () => {
|
|
168
|
+
const epoch = buildRunEpoch({ provider: "anthropic", model: "claude-x", appVersion: "1.2.3", toolNames: ["search", "exec"] });
|
|
169
|
+
const run = await createRun({
|
|
170
|
+
thread_id: "t1",
|
|
171
|
+
agent_id: "a1",
|
|
172
|
+
user_id: "u1",
|
|
173
|
+
channel: null,
|
|
174
|
+
kind: "goal",
|
|
175
|
+
max_iterations: 20,
|
|
176
|
+
acceptance: [
|
|
177
|
+
{ id: "c1", description: "responds in Spanish" },
|
|
178
|
+
{ id: "c2", description: "calls the search tool", checkTool: "search" },
|
|
179
|
+
],
|
|
180
|
+
epoch,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const acceptance = deserializeAcceptance(run);
|
|
184
|
+
expect(acceptance).not.toBeNull();
|
|
185
|
+
expect(acceptance!.length).toBe(2);
|
|
186
|
+
expect(acceptance![1].checkTool).toBe("search");
|
|
187
|
+
expect(deserializeEpoch(run)).toEqual(epoch);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("harness/proof-packet", () => {
|
|
192
|
+
test("persists and retrieves a proof packet", async () => {
|
|
193
|
+
const packet = await buildProofPacket({
|
|
194
|
+
runId: "run-proof-1",
|
|
195
|
+
agentId: "a1",
|
|
196
|
+
intendedOutcome: "send the weekly report",
|
|
197
|
+
met: true,
|
|
198
|
+
checksRun: ["llm_verifier"],
|
|
199
|
+
evidence: ["report sent"],
|
|
200
|
+
});
|
|
201
|
+
expect(packet.met).toBe(true);
|
|
202
|
+
const found = await findProofPacketsByRun("run-proof-1");
|
|
203
|
+
expect(found.length).toBe(1);
|
|
204
|
+
expect(found[0].id).toBe(packet.id);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("harness/goal-verifier: deterministic check-tool path (no LLM)", () => {
|
|
209
|
+
test("a single boolean-returning check tool is interpreted directly", async () => {
|
|
210
|
+
const verdict = await verifyGoal({
|
|
211
|
+
goal: "workspace exists",
|
|
212
|
+
checkTool: "check_workspace",
|
|
213
|
+
messages: [],
|
|
214
|
+
providerCfg: { provider: "anthropic", model: "x", apiKey: "unused" },
|
|
215
|
+
runCheckTool: async () => true,
|
|
216
|
+
});
|
|
217
|
+
expect(verdict.met).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("acceptance criteria aggregate as a conjunction across check tools", async () => {
|
|
221
|
+
const verdict = await verifyGoal({
|
|
222
|
+
goal: "onboard client",
|
|
223
|
+
messages: [],
|
|
224
|
+
providerCfg: { provider: "anthropic", model: "x", apiKey: "unused" },
|
|
225
|
+
runCheckTool: async (tool) => (tool === "check_a" ? true : { met: false, reason: "email not sent" }),
|
|
226
|
+
acceptance: [
|
|
227
|
+
{ id: "c1", description: "workspace created", checkTool: "check_a" },
|
|
228
|
+
{ id: "c2", description: "welcome email sent", checkTool: "check_b" },
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
expect(verdict.met).toBe(false);
|
|
232
|
+
expect(verdict.acceptanceResults).toHaveLength(2);
|
|
233
|
+
expect(verdict.acceptanceResults![0].met).toBe(true);
|
|
234
|
+
expect(verdict.acceptanceResults![1].met).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hive Harness — durable task execution for the Hive Agent SDK.
|
|
3
|
+
*
|
|
4
|
+
* A generic (non-hive-app-specific) durable job queue + checkpointable run
|
|
5
|
+
* store, backed by HiveDB: retry/backoff on logical failure, lease-based
|
|
6
|
+
* crash recovery, idempotent job submission, goal verification, and proof
|
|
7
|
+
* packets. See docs/HIVE-HARNESS.md for the full write-up.
|
|
8
|
+
*
|
|
9
|
+
* This module does not wire itself into `AgentRunner` automatically — the
|
|
10
|
+
* host app decides what "durable" means for its own job types and registers
|
|
11
|
+
* executors accordingly via `registerExecutor()`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ensureJobStoreIndexes } from "./job-store";
|
|
15
|
+
import { ensureRunStoreIndexes } from "./run-store";
|
|
16
|
+
import { ensureProofPacketIndexes } from "./proof-packet";
|
|
17
|
+
|
|
18
|
+
export * from "./collections";
|
|
19
|
+
export * from "./job-store";
|
|
20
|
+
export * from "./run-store";
|
|
21
|
+
export * from "./durable-queue";
|
|
22
|
+
export * from "./goal-verifier";
|
|
23
|
+
export * from "./run-epoch";
|
|
24
|
+
export * from "./proof-packet";
|
|
25
|
+
export * from "./reconcile";
|
|
26
|
+
export { getBootId, resetBootId } from "./boot-id";
|
|
27
|
+
export { col, nextId, updateDoc, findByAny, toIndexable, fromIndexable, NO_PARENT } from "./db-helpers";
|
|
28
|
+
|
|
29
|
+
/** Create the equality indexes the harness collections need. Idempotent — safe to call on every boot. */
|
|
30
|
+
export async function ensureHarnessIndexes(): Promise<void> {
|
|
31
|
+
await ensureJobStoreIndexes();
|
|
32
|
+
await ensureRunStoreIndexes();
|
|
33
|
+
await ensureProofPacketIndexes();
|
|
34
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* job-store — durable persistence + lease/claim for the harness_jobQueue
|
|
3
|
+
* collection. Ported from `hive`'s gateway/job-store.ts.
|
|
4
|
+
*
|
|
5
|
+
* All claim transitions use OCC (expectedVersion). The "claim pending→running"
|
|
6
|
+
* path guarantees only one process wins the race for the same job.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { col, nextId, toIndexable } from "./db-helpers";
|
|
10
|
+
import type { JobDoc } from "./collections";
|
|
11
|
+
import { getBootId } from "./boot-id";
|
|
12
|
+
import { logger } from "../utils/logger";
|
|
13
|
+
|
|
14
|
+
const log = logger.child("harness:job-store");
|
|
15
|
+
|
|
16
|
+
const COLLECTION = "harness_jobQueue";
|
|
17
|
+
const DEFAULT_LEASE_DURATION_MS = 30 * 60 * 1000;
|
|
18
|
+
const MAX_RETRIES = 5;
|
|
19
|
+
|
|
20
|
+
let leaseDurationMs = DEFAULT_LEASE_DURATION_MS;
|
|
21
|
+
|
|
22
|
+
/** Override the job lease duration (default 30 minutes). Affects future claims/renewals. */
|
|
23
|
+
export function setJobLeaseDurationMs(ms: number): void {
|
|
24
|
+
leaseDurationMs = ms;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JobRetryPolicy {
|
|
28
|
+
maxRetries: number;
|
|
29
|
+
initialDelayMs: number;
|
|
30
|
+
backoffMultiplier: number;
|
|
31
|
+
maxDelayMs: number;
|
|
32
|
+
jitter: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_JOB_RETRY_POLICY: JobRetryPolicy = {
|
|
36
|
+
maxRetries: 3,
|
|
37
|
+
initialDelayMs: 1000,
|
|
38
|
+
backoffMultiplier: 2,
|
|
39
|
+
maxDelayMs: 5 * 60 * 1000,
|
|
40
|
+
jitter: 0.2,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Exponential backoff with full jitter, capped at policy.maxDelayMs. */
|
|
44
|
+
export function computeBackoffDelay(retryCount: number, policy: JobRetryPolicy): number {
|
|
45
|
+
const base = Math.min(policy.maxDelayMs, policy.initialDelayMs * Math.pow(policy.backoffMultiplier, retryCount));
|
|
46
|
+
const jitterAmount = base * policy.jitter * Math.random();
|
|
47
|
+
return Math.round(base + jitterAmount);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Small randomized delay between OCC-conflict retries to reduce thundering-herd contention. */
|
|
51
|
+
function occRetryDelay(attempt: number): Promise<void> {
|
|
52
|
+
const ms = 5 * (attempt + 1) + Math.random() * 10;
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function findByIdempotencyKey(key: string): Promise<JobDoc | null> {
|
|
57
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
58
|
+
const entries = await c.findBy("idempotency_key", key);
|
|
59
|
+
return entries.length > 0 ? entries[0].doc : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function createJob(input: {
|
|
63
|
+
lane: string;
|
|
64
|
+
type: string;
|
|
65
|
+
payload: unknown;
|
|
66
|
+
run_id: string;
|
|
67
|
+
priority?: number;
|
|
68
|
+
max_attempts?: number;
|
|
69
|
+
not_before?: number;
|
|
70
|
+
/** Client-supplied dedup key: a repeated key returns the existing job instead of creating a new one. */
|
|
71
|
+
idempotency_key?: string | null;
|
|
72
|
+
}): Promise<JobDoc> {
|
|
73
|
+
if (input.idempotency_key) {
|
|
74
|
+
const existing = await findByIdempotencyKey(input.idempotency_key);
|
|
75
|
+
if (existing) {
|
|
76
|
+
log.info(`[createJob] Idempotent hit for key=${input.idempotency_key} → job ${existing.id}`);
|
|
77
|
+
return existing;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const id = await nextId(COLLECTION);
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const doc: JobDoc = {
|
|
84
|
+
id,
|
|
85
|
+
lane: input.lane,
|
|
86
|
+
type: input.type,
|
|
87
|
+
status: "pending",
|
|
88
|
+
priority: input.priority ?? 0,
|
|
89
|
+
payload_json: JSON.stringify(input.payload),
|
|
90
|
+
run_id: input.run_id,
|
|
91
|
+
attempts: 0,
|
|
92
|
+
max_attempts: input.max_attempts ?? 2,
|
|
93
|
+
not_before: input.not_before ?? now,
|
|
94
|
+
boot_id: null,
|
|
95
|
+
lease_expires_at: null,
|
|
96
|
+
result_json: null,
|
|
97
|
+
error: null,
|
|
98
|
+
created_at: now,
|
|
99
|
+
started_at: null,
|
|
100
|
+
finished_at: null,
|
|
101
|
+
retry_count: 0,
|
|
102
|
+
last_error: null,
|
|
103
|
+
idempotency_key: toIndexable(input.idempotency_key ?? null),
|
|
104
|
+
};
|
|
105
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
106
|
+
await c.put(id, doc, { expectedVersion: 0 });
|
|
107
|
+
log.info(`[createJob] Job ${id} created (lane=${input.lane} type=${input.type})`);
|
|
108
|
+
return doc;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function claimJob(jobId: string, bootId: string = getBootId()): Promise<JobDoc | null> {
|
|
112
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
113
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
114
|
+
const entry = await c.get(jobId);
|
|
115
|
+
if (!entry) return null;
|
|
116
|
+
const doc = entry.doc;
|
|
117
|
+
if (doc.status !== "pending") return null;
|
|
118
|
+
if (doc.not_before > Date.now()) return null;
|
|
119
|
+
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
const updated: JobDoc = {
|
|
122
|
+
...doc,
|
|
123
|
+
status: "running",
|
|
124
|
+
attempts: doc.attempts + 1,
|
|
125
|
+
boot_id: bootId,
|
|
126
|
+
lease_expires_at: now + leaseDurationMs,
|
|
127
|
+
started_at: doc.started_at ?? now,
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
131
|
+
log.info(`[claimJob] Job ${jobId} claimed by boot ${bootId}`);
|
|
132
|
+
return updated;
|
|
133
|
+
} catch {
|
|
134
|
+
await occRetryDelay(attempt);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
log.warn(`[claimJob] Too much contention on job ${jobId}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function renewLease(jobId: string, bootId: string = getBootId()): Promise<boolean> {
|
|
142
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
143
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
144
|
+
const entry = await c.get(jobId);
|
|
145
|
+
if (!entry) return false;
|
|
146
|
+
const doc = entry.doc;
|
|
147
|
+
if (doc.status !== "running") return false;
|
|
148
|
+
if (doc.boot_id !== bootId) return false;
|
|
149
|
+
|
|
150
|
+
const updated: JobDoc = { ...doc, lease_expires_at: Date.now() + leaseDurationMs };
|
|
151
|
+
try {
|
|
152
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
await occRetryDelay(attempt);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
log.warn(`[renewLease] Too much contention on job ${jobId}`);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function completeJob(jobId: string, result: unknown, bootId: string = getBootId()): Promise<void> {
|
|
163
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
164
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
165
|
+
const entry = await c.get(jobId);
|
|
166
|
+
if (!entry) return;
|
|
167
|
+
const doc = entry.doc;
|
|
168
|
+
if (doc.status !== "running") return;
|
|
169
|
+
if (doc.boot_id !== bootId) return;
|
|
170
|
+
|
|
171
|
+
const updated: JobDoc = {
|
|
172
|
+
...doc,
|
|
173
|
+
status: "completed",
|
|
174
|
+
result_json: JSON.stringify(result),
|
|
175
|
+
error: null,
|
|
176
|
+
finished_at: Date.now(),
|
|
177
|
+
boot_id: null,
|
|
178
|
+
lease_expires_at: null,
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
182
|
+
log.info(`[completeJob] Job ${jobId} completed`);
|
|
183
|
+
return;
|
|
184
|
+
} catch {
|
|
185
|
+
await occRetryDelay(attempt);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
log.warn(`[completeJob] Too much contention on job ${jobId}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function failJob(jobId: string, error: string, bootId: string = getBootId()): Promise<void> {
|
|
192
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
193
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
194
|
+
const entry = await c.get(jobId);
|
|
195
|
+
if (!entry) return;
|
|
196
|
+
const doc = entry.doc;
|
|
197
|
+
if (doc.status !== "running") return;
|
|
198
|
+
if (doc.boot_id !== bootId) return;
|
|
199
|
+
|
|
200
|
+
const updated: JobDoc = {
|
|
201
|
+
...doc,
|
|
202
|
+
status: "failed",
|
|
203
|
+
error,
|
|
204
|
+
last_error: error,
|
|
205
|
+
finished_at: Date.now(),
|
|
206
|
+
boot_id: null,
|
|
207
|
+
lease_expires_at: null,
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
211
|
+
log.info(`[failJob] Job ${jobId} failed: ${error}`);
|
|
212
|
+
return;
|
|
213
|
+
} catch {
|
|
214
|
+
await occRetryDelay(attempt);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
log.warn(`[failJob] Too much contention on job ${jobId}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Fail a job that returned a LOGICAL failure ({ok:false}), retrying with
|
|
222
|
+
* exponential backoff + jitter up to `policy.maxRetries` before giving up.
|
|
223
|
+
* Distinct from `attempts`/`reclaimOrInterrupt`, which only handle crash /
|
|
224
|
+
* lease-expiry recovery.
|
|
225
|
+
*/
|
|
226
|
+
export async function failJobOrRetry(
|
|
227
|
+
jobId: string,
|
|
228
|
+
error: string,
|
|
229
|
+
bootId: string = getBootId(),
|
|
230
|
+
policy: JobRetryPolicy = DEFAULT_JOB_RETRY_POLICY
|
|
231
|
+
): Promise<JobDoc | null> {
|
|
232
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
233
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
234
|
+
const entry = await c.get(jobId);
|
|
235
|
+
if (!entry) return null;
|
|
236
|
+
const doc = entry.doc;
|
|
237
|
+
if (doc.status !== "running") return null;
|
|
238
|
+
if (doc.boot_id !== bootId) return null;
|
|
239
|
+
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const retryCount = doc.retry_count ?? 0;
|
|
242
|
+
|
|
243
|
+
if (retryCount >= policy.maxRetries) {
|
|
244
|
+
const updated: JobDoc = {
|
|
245
|
+
...doc,
|
|
246
|
+
status: "failed",
|
|
247
|
+
error,
|
|
248
|
+
last_error: error,
|
|
249
|
+
finished_at: now,
|
|
250
|
+
boot_id: null,
|
|
251
|
+
lease_expires_at: null,
|
|
252
|
+
};
|
|
253
|
+
try {
|
|
254
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
255
|
+
log.info(`[failJobOrRetry] Job ${jobId} failed terminally after ${retryCount} retr${retryCount === 1 ? "y" : "ies"}: ${error}`);
|
|
256
|
+
return updated;
|
|
257
|
+
} catch {
|
|
258
|
+
await occRetryDelay(attempt);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const delay = computeBackoffDelay(retryCount, policy);
|
|
264
|
+
const updated: JobDoc = {
|
|
265
|
+
...doc,
|
|
266
|
+
status: "pending",
|
|
267
|
+
retry_count: retryCount + 1,
|
|
268
|
+
last_error: error,
|
|
269
|
+
not_before: now + delay,
|
|
270
|
+
boot_id: null,
|
|
271
|
+
lease_expires_at: null,
|
|
272
|
+
};
|
|
273
|
+
try {
|
|
274
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
275
|
+
log.info(`[failJobOrRetry] Job ${jobId} scheduled for retry ${retryCount + 1}/${policy.maxRetries} in ${delay}ms: ${error}`);
|
|
276
|
+
return updated;
|
|
277
|
+
} catch {
|
|
278
|
+
await occRetryDelay(attempt);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
log.warn(`[failJobOrRetry] Too much contention on job ${jobId}`);
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Re-enqueue a job whose lease has expired back to pending, bumping its
|
|
287
|
+
* attempt count (already bumped at claim time). If attempts >= max_attempts,
|
|
288
|
+
* marks it as interrupted instead.
|
|
289
|
+
*
|
|
290
|
+
* `force` skips the lease-expiry check — used at boot, where every "running"
|
|
291
|
+
* row belongs to a dead process in a single-process HiveDB deployment.
|
|
292
|
+
*/
|
|
293
|
+
export async function reclaimOrInterrupt(jobId: string, opts?: { force?: boolean }): Promise<JobDoc | null> {
|
|
294
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
295
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
296
|
+
const entry = await c.get(jobId);
|
|
297
|
+
if (!entry) return null;
|
|
298
|
+
const doc = entry.doc;
|
|
299
|
+
if (doc.status !== "running") return null;
|
|
300
|
+
if (!opts?.force && (doc.lease_expires_at === null || doc.lease_expires_at > Date.now())) return null;
|
|
301
|
+
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
if (doc.attempts >= doc.max_attempts) {
|
|
304
|
+
const updated: JobDoc = {
|
|
305
|
+
...doc,
|
|
306
|
+
status: "interrupted",
|
|
307
|
+
error: `Max attempts (${doc.max_attempts}) reached after lease expiry`,
|
|
308
|
+
finished_at: now,
|
|
309
|
+
boot_id: null,
|
|
310
|
+
lease_expires_at: null,
|
|
311
|
+
};
|
|
312
|
+
try {
|
|
313
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
314
|
+
log.warn(`[reclaimOrInterrupt] Job ${jobId} interrupted (attempts exhausted)`);
|
|
315
|
+
return updated;
|
|
316
|
+
} catch {
|
|
317
|
+
await occRetryDelay(attempt);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const updated: JobDoc = { ...doc, status: "pending", boot_id: null, lease_expires_at: null };
|
|
323
|
+
try {
|
|
324
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
325
|
+
log.info(`[reclaimOrInterrupt] Job ${jobId} back to pending (attempt ${doc.attempts}/${doc.max_attempts})`);
|
|
326
|
+
return updated;
|
|
327
|
+
} catch {
|
|
328
|
+
await occRetryDelay(attempt);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
log.warn(`[reclaimOrInterrupt] Too much contention on job ${jobId}`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function cancelJob(jobId: string): Promise<boolean> {
|
|
336
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
337
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
338
|
+
const entry = await c.get(jobId);
|
|
339
|
+
if (!entry) return false;
|
|
340
|
+
const doc = entry.doc;
|
|
341
|
+
if (doc.status === "completed" || doc.status === "failed" || doc.status === "cancelled") return false;
|
|
342
|
+
|
|
343
|
+
const updated: JobDoc = { ...doc, status: "cancelled", finished_at: Date.now(), boot_id: null, lease_expires_at: null };
|
|
344
|
+
try {
|
|
345
|
+
await c.put(jobId, updated, { expectedVersion: entry.version });
|
|
346
|
+
log.info(`[cancelJob] Job ${jobId} cancelled`);
|
|
347
|
+
return true;
|
|
348
|
+
} catch {
|
|
349
|
+
await occRetryDelay(attempt);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Find the next pending job for a given lane, ordered by priority then creation order. */
|
|
356
|
+
export async function findPendingJobsByLane(lane: string, limit = 10): Promise<JobDoc[]> {
|
|
357
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
358
|
+
const entries = await c.findBy("lane", lane);
|
|
359
|
+
return entries
|
|
360
|
+
.filter((e) => e.doc.status === "pending" && e.doc.not_before <= Date.now())
|
|
361
|
+
.sort((a, b) => {
|
|
362
|
+
if (b.doc.priority !== a.doc.priority) return b.doc.priority - a.doc.priority;
|
|
363
|
+
return a.doc.id.localeCompare(b.doc.id);
|
|
364
|
+
})
|
|
365
|
+
.slice(0, limit)
|
|
366
|
+
.map((e) => e.doc);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function findExpiredLeases(): Promise<JobDoc[]> {
|
|
370
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
371
|
+
const entries = await c.findBy("status", "running");
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
return entries
|
|
374
|
+
.filter((e) => e.doc.lease_expires_at !== null && (e.doc.lease_expires_at as number) < now)
|
|
375
|
+
.map((e) => e.doc);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function findAllPendingJobs(): Promise<JobDoc[]> {
|
|
379
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
380
|
+
const entries = await c.findBy("status", "pending");
|
|
381
|
+
return entries.filter((e) => e.doc.not_before <= Date.now()).map((e) => e.doc);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function getJob(jobId: string): Promise<JobDoc | null> {
|
|
385
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
386
|
+
const entry = await c.get(jobId);
|
|
387
|
+
return entry ? entry.doc : null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function ensureJobStoreIndexes(): Promise<void> {
|
|
391
|
+
const c = await col<JobDoc>(COLLECTION);
|
|
392
|
+
await c.createIndex("status");
|
|
393
|
+
await c.createIndex("lane");
|
|
394
|
+
await c.createIndex("type");
|
|
395
|
+
await c.createIndex("run_id");
|
|
396
|
+
await c.createIndex("idempotency_key");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export { COLLECTION as JOB_QUEUE_COLLECTION };
|