@johpaz/hive-sdk 0.0.18 → 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/bun.lock +291 -1
- 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 +53 -89
- package/packages/core/src/auth/auth.ts +36 -23
- 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 +6 -0
- package/packages/core/src/memory/Scratchpad.test.ts +23 -21
- package/packages/core/src/memory/Scratchpad.ts +41 -24
- 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/meeting/index.ts +83 -93
- package/packages/core/src/utils/toon.ts +4 -4
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof packets — compressed evidence artifact for a completed run
|
|
3
|
+
* (harness-engineering "proof" practice): what was intended, what was
|
|
4
|
+
* checked, what evidence backs the verdict, and known limits. Written once
|
|
5
|
+
* per run so a reviewer doesn't have to replay the whole run to trust its
|
|
6
|
+
* outcome.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { col, nextId } from "./db-helpers";
|
|
10
|
+
import type { ProofPacketDoc } from "./collections";
|
|
11
|
+
import type { AcceptanceResult } from "./goal-verifier";
|
|
12
|
+
import type { RunEpoch } from "./run-epoch";
|
|
13
|
+
import { logger } from "../utils/logger";
|
|
14
|
+
|
|
15
|
+
const log = logger.child("harness:proof-packet");
|
|
16
|
+
|
|
17
|
+
const COLLECTION = "harness_proofPackets";
|
|
18
|
+
|
|
19
|
+
export interface BuildProofPacketInput {
|
|
20
|
+
runId: string;
|
|
21
|
+
agentId: string;
|
|
22
|
+
intendedOutcome: string;
|
|
23
|
+
met: boolean;
|
|
24
|
+
/** Per-criterion verdicts when acceptance criteria were set; falls back to a single-entry summary otherwise. */
|
|
25
|
+
acceptanceResults?: AcceptanceResult[];
|
|
26
|
+
checksRun: string[];
|
|
27
|
+
evidence: string[];
|
|
28
|
+
knownLimits?: string | null;
|
|
29
|
+
epoch?: RunEpoch | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function buildProofPacket(input: BuildProofPacketInput): Promise<ProofPacketDoc> {
|
|
33
|
+
const id = await nextId(COLLECTION);
|
|
34
|
+
const acceptanceResults: AcceptanceResult[] =
|
|
35
|
+
input.acceptanceResults ?? [{ id: "goal", description: input.intendedOutcome, met: input.met, evidence: input.evidence.join("; ") || "n/a" }];
|
|
36
|
+
|
|
37
|
+
const doc: ProofPacketDoc = {
|
|
38
|
+
id,
|
|
39
|
+
run_id: input.runId,
|
|
40
|
+
agent_id: input.agentId,
|
|
41
|
+
intended_outcome: input.intendedOutcome,
|
|
42
|
+
acceptance_results_json: JSON.stringify(acceptanceResults),
|
|
43
|
+
checks_run_json: JSON.stringify(input.checksRun),
|
|
44
|
+
evidence_json: JSON.stringify(input.evidence),
|
|
45
|
+
known_limits: input.knownLimits ?? null,
|
|
46
|
+
epoch_json: input.epoch ? JSON.stringify(input.epoch) : null,
|
|
47
|
+
met: input.met,
|
|
48
|
+
created_at: Date.now(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const c = await col<ProofPacketDoc>(COLLECTION);
|
|
52
|
+
await c.put(id, doc, { expectedVersion: 0 });
|
|
53
|
+
log.info(`[buildProofPacket] Packet ${id} written for run ${input.runId} (met=${input.met})`);
|
|
54
|
+
return doc;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function findProofPacketsByRun(runId: string): Promise<ProofPacketDoc[]> {
|
|
58
|
+
const c = await col<ProofPacketDoc>(COLLECTION);
|
|
59
|
+
const entries = await c.findBy("run_id", runId);
|
|
60
|
+
return entries.map((e) => e.doc);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function ensureProofPacketIndexes(): Promise<void> {
|
|
64
|
+
const c = await col<ProofPacketDoc>(COLLECTION);
|
|
65
|
+
await c.createIndex("run_id");
|
|
66
|
+
await c.createIndex("agent_id");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { COLLECTION as PROOF_PACKETS_COLLECTION };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcileOnBoot — repair harness rows left in inconsistent states by a
|
|
3
|
+
* previous crash/kill, plus a retention cap. Ported from `hive`'s
|
|
4
|
+
* storage/reconcile.ts, trimmed to only the generic harness collections
|
|
5
|
+
* (agentRuns/jobQueue) — `hive`'s version also repairs app-specific rows
|
|
6
|
+
* (taskRuns, meetingSessions, project tasks) that don't exist in the SDK.
|
|
7
|
+
*
|
|
8
|
+
* Call early in a host app's boot sequence, before `initDurableQueue`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { col } from "./db-helpers";
|
|
12
|
+
import type { HarnessRunDoc } from "./collections";
|
|
13
|
+
import type { JobDoc } from "./collections";
|
|
14
|
+
import { logger } from "../utils/logger";
|
|
15
|
+
import { reclaimOrInterrupt, JOB_QUEUE_COLLECTION } from "./job-store";
|
|
16
|
+
import { interruptRun, AGENT_RUNS_COLLECTION } from "./run-store";
|
|
17
|
+
|
|
18
|
+
const log = logger.child("harness:reconcile");
|
|
19
|
+
|
|
20
|
+
export interface ReconcileResult {
|
|
21
|
+
bootId: string;
|
|
22
|
+
runsInterrupted: number;
|
|
23
|
+
runsReEnqueueable: number;
|
|
24
|
+
jobsReclaimed: number;
|
|
25
|
+
jobsInterrupted: number;
|
|
26
|
+
runsPruned: number;
|
|
27
|
+
jobsPruned: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReconcileOptions {
|
|
31
|
+
/** Run kinds treated as "interactive" (interrupted outright rather than flagged for re-enqueue). Default: ["chat"]. */
|
|
32
|
+
interactiveKinds?: string[];
|
|
33
|
+
/** Retention cap per thread/run. Default: 500. */
|
|
34
|
+
retentionLimit?: number;
|
|
35
|
+
/** Called when an interactive run is interrupted, so the host can notify the user. */
|
|
36
|
+
onInteractiveInterrupted?: (run: HarnessRunDoc) => Promise<void> | void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function reconcileOnBoot(bootId: string, opts: ReconcileOptions = {}): Promise<ReconcileResult> {
|
|
40
|
+
const interactiveKinds = new Set(opts.interactiveKinds ?? ["chat"]);
|
|
41
|
+
const retentionLimit = opts.retentionLimit ?? 500;
|
|
42
|
+
|
|
43
|
+
log.info(`[reconcileOnBoot] Starting with boot_id=${bootId}`);
|
|
44
|
+
const result: ReconcileResult = {
|
|
45
|
+
bootId,
|
|
46
|
+
runsInterrupted: 0,
|
|
47
|
+
runsReEnqueueable: 0,
|
|
48
|
+
jobsReclaimed: 0,
|
|
49
|
+
jobsInterrupted: 0,
|
|
50
|
+
runsPruned: 0,
|
|
51
|
+
jobsPruned: 0,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// 1. harness_agentRuns: any "running" row is orphaned at boot — HiveDB is
|
|
55
|
+
// single-process and this process just started, so no run can actually be
|
|
56
|
+
// executing.
|
|
57
|
+
try {
|
|
58
|
+
const runsCol = await col<HarnessRunDoc>(AGENT_RUNS_COLLECTION);
|
|
59
|
+
const runningRuns = await runsCol.findBy("status", "running");
|
|
60
|
+
for (const entry of runningRuns) {
|
|
61
|
+
const run = entry.doc;
|
|
62
|
+
if (interactiveKinds.has(run.kind)) {
|
|
63
|
+
await interruptRun(run.id, "Process restarted while an interactive run was in flight");
|
|
64
|
+
result.runsInterrupted++;
|
|
65
|
+
try {
|
|
66
|
+
await opts.onInteractiveInterrupted?.(run);
|
|
67
|
+
} catch {
|
|
68
|
+
// non-critical
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
await interruptRun(run.id, "Process restarted — job will be re-enqueued if durable");
|
|
72
|
+
result.runsReEnqueueable++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (result.runsInterrupted > 0 || result.runsReEnqueueable > 0) {
|
|
76
|
+
log.info(`[reconcileOnBoot] ${result.runsInterrupted} interactive runs interrupted, ${result.runsReEnqueueable} durable runs flagged for re-enqueue`);
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log.warn(`[reconcileOnBoot] Failed to repair agentRuns: ${(err as Error).message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. harness_jobQueue: any "running" row is orphaned at boot (same
|
|
83
|
+
// single-process argument as above) → reclaim (pending) or interrupt,
|
|
84
|
+
// ignoring the lease.
|
|
85
|
+
try {
|
|
86
|
+
const jobsCol = await col<JobDoc>(JOB_QUEUE_COLLECTION);
|
|
87
|
+
const runningJobs = await jobsCol.findBy("status", "running");
|
|
88
|
+
for (const entry of runningJobs) {
|
|
89
|
+
const doc = await reclaimOrInterrupt(entry.doc.id, { force: true });
|
|
90
|
+
if (doc?.status === "pending") result.jobsReclaimed++;
|
|
91
|
+
else if (doc?.status === "interrupted") result.jobsInterrupted++;
|
|
92
|
+
}
|
|
93
|
+
if (result.jobsReclaimed > 0 || result.jobsInterrupted > 0) {
|
|
94
|
+
log.info(`[reconcileOnBoot] ${result.jobsReclaimed} jobs reclaimed to pending, ${result.jobsInterrupted} jobs interrupted (attempts exhausted)`);
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
log.warn(`[reconcileOnBoot] Failed to repair jobQueue: ${(err as Error).message}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Retention cap: keep only the most recent N agentRuns/jobs per thread/run.
|
|
101
|
+
try {
|
|
102
|
+
const runsCol = await col<HarnessRunDoc>(AGENT_RUNS_COLLECTION);
|
|
103
|
+
const allRuns = await runsCol.scan({});
|
|
104
|
+
const runsByThread = new Map<string, typeof allRuns>();
|
|
105
|
+
for (const entry of allRuns) {
|
|
106
|
+
const key = entry.doc.thread_id ?? entry.doc.agent_id ?? "_default";
|
|
107
|
+
const list = runsByThread.get(key) ?? [];
|
|
108
|
+
list.push(entry);
|
|
109
|
+
runsByThread.set(key, list);
|
|
110
|
+
}
|
|
111
|
+
for (const [, runs] of runsByThread) {
|
|
112
|
+
if (runs.length <= retentionLimit) continue;
|
|
113
|
+
const sorted = runs.sort((a, b) => (b.doc.created_at ?? 0) - (a.doc.created_at ?? 0));
|
|
114
|
+
for (const entry of sorted.slice(retentionLimit)) {
|
|
115
|
+
await runsCol.delete(entry.id);
|
|
116
|
+
result.runsPruned++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (result.runsPruned > 0) {
|
|
120
|
+
log.info(`[reconcileOnBoot] Retention: pruned ${result.runsPruned} old agentRuns (cap=${retentionLimit}/thread)`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const jobsCol = await col<JobDoc>(JOB_QUEUE_COLLECTION);
|
|
124
|
+
const allJobs = await jobsCol.scan({});
|
|
125
|
+
const jobsByRun = new Map<string, typeof allJobs>();
|
|
126
|
+
for (const entry of allJobs) {
|
|
127
|
+
const key = entry.doc.run_id ?? "_default";
|
|
128
|
+
const list = jobsByRun.get(key) ?? [];
|
|
129
|
+
list.push(entry);
|
|
130
|
+
jobsByRun.set(key, list);
|
|
131
|
+
}
|
|
132
|
+
for (const [, jobs] of jobsByRun) {
|
|
133
|
+
if (jobs.length <= retentionLimit) continue;
|
|
134
|
+
const sorted = jobs.sort((a, b) => (b.doc.created_at ?? 0) - (a.doc.created_at ?? 0));
|
|
135
|
+
for (const entry of sorted.slice(retentionLimit)) {
|
|
136
|
+
await jobsCol.delete(entry.id);
|
|
137
|
+
result.jobsPruned++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (result.jobsPruned > 0) {
|
|
141
|
+
log.info(`[reconcileOnBoot] Retention: pruned ${result.jobsPruned} old jobs (cap=${retentionLimit}/run)`);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
log.warn(`[reconcileOnBoot] Failed to enforce retention cap: ${(err as Error).message}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
log.info(`[reconcileOnBoot] Done`, result);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-worker epoch (harness-engineering concept): the exact
|
|
3
|
+
* provider/model/app-version/tool-catalog combination a run executed under.
|
|
4
|
+
* A model or tool-catalog change is a requalification signal — proof
|
|
5
|
+
* packets from different epochs shouldn't be compared as if the "worker"
|
|
6
|
+
* were unchanged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RunEpoch {
|
|
10
|
+
provider: string;
|
|
11
|
+
model: string;
|
|
12
|
+
/** Caller-supplied version identifier (e.g. the host app's package version) — the harness doesn't assume its own version is what matters. */
|
|
13
|
+
app_version: string;
|
|
14
|
+
tool_catalog_hash: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Stable non-cryptographic hash (djb2) over sorted tool names — a cheap fingerprint of the active tool catalog. */
|
|
18
|
+
function hashToolNames(names: string[]): string {
|
|
19
|
+
const sorted = [...names].sort().join(",");
|
|
20
|
+
let hash = 5381;
|
|
21
|
+
for (let i = 0; i < sorted.length; i++) hash = ((hash * 33) ^ sorted.charCodeAt(i)) >>> 0;
|
|
22
|
+
return hash.toString(16);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildRunEpoch(opts: { provider: string; model: string; appVersion: string; toolNames: string[] }): RunEpoch {
|
|
26
|
+
return {
|
|
27
|
+
provider: opts.provider,
|
|
28
|
+
model: opts.model,
|
|
29
|
+
app_version: opts.appVersion,
|
|
30
|
+
tool_catalog_hash: hashToolNames(opts.toolNames),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-store — persistent checkpoint + lease for harness runs. Ported from
|
|
3
|
+
* `hive`'s agent/run-store.ts.
|
|
4
|
+
*
|
|
5
|
+
* A HarnessRunDoc tracks the lifecycle of a single durable agent invocation
|
|
6
|
+
* (chat turn, worker task, goal run). Its checkpoint (state_json) allows
|
|
7
|
+
* resuming after a crash: messages, iteration count, token totals and
|
|
8
|
+
* pending tool calls are persisted after every round-trip — when the host
|
|
9
|
+
* app chooses to call `checkpoint()` from its own agent loop (this module
|
|
10
|
+
* does not wire itself into `AgentRunner` automatically).
|
|
11
|
+
*
|
|
12
|
+
* All write operations use OCC (expectedVersion). Only the owning loop
|
|
13
|
+
* should write to a run; single-writer pattern keeps contention minimal.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { col, updateDoc, nextId } from "./db-helpers";
|
|
17
|
+
import type { HarnessRunDoc } from "./collections";
|
|
18
|
+
import { getBootId } from "./boot-id";
|
|
19
|
+
import { logger } from "../utils/logger";
|
|
20
|
+
import type { RunEpoch } from "./run-epoch";
|
|
21
|
+
|
|
22
|
+
const log = logger.child("harness:run-store");
|
|
23
|
+
|
|
24
|
+
const COLLECTION = "harness_agentRuns";
|
|
25
|
+
const MAX_STATE_BYTES = 1_500_000;
|
|
26
|
+
|
|
27
|
+
let leaseRenewIntervalMs = 30_000;
|
|
28
|
+
let leaseDurationMs = 2 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** Override the run lease duration / renewal interval (defaults: 2min / 30s). */
|
|
31
|
+
export function setRunLeaseConfig(opts: { leaseDurationMs?: number; leaseRenewIntervalMs?: number }): void {
|
|
32
|
+
if (opts.leaseDurationMs !== undefined) leaseDurationMs = opts.leaseDurationMs;
|
|
33
|
+
if (opts.leaseRenewIntervalMs !== undefined) leaseRenewIntervalMs = opts.leaseRenewIntervalMs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunCheckpointState {
|
|
37
|
+
version: 1
|
|
38
|
+
messages: unknown[]
|
|
39
|
+
iterations: number
|
|
40
|
+
totalInputTokens: number
|
|
41
|
+
totalOutputTokens: number
|
|
42
|
+
lastToolSignature?: string
|
|
43
|
+
consecutiveRepeat?: number
|
|
44
|
+
idleIterations?: number
|
|
45
|
+
injectedToolNames?: string[]
|
|
46
|
+
systemPromptSkillSections?: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whole-job acceptance criterion (harness-engineering "proof" concept). */
|
|
50
|
+
export interface AcceptanceCriterion {
|
|
51
|
+
id: string
|
|
52
|
+
description: string
|
|
53
|
+
/** Deterministic tool to check this specific criterion; falls back to LLM judgment when absent. */
|
|
54
|
+
checkTool?: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CreateRunInput {
|
|
58
|
+
thread_id: string
|
|
59
|
+
agent_id: string
|
|
60
|
+
user_id: string
|
|
61
|
+
channel: string | null
|
|
62
|
+
kind: string
|
|
63
|
+
max_iterations: number
|
|
64
|
+
max_turns?: number | null
|
|
65
|
+
max_tokens?: number | null
|
|
66
|
+
goal?: string | null
|
|
67
|
+
goal_check_tool?: string | null
|
|
68
|
+
resume_policy?: HarnessRunDoc["resume_policy"]
|
|
69
|
+
acceptance?: AcceptanceCriterion[]
|
|
70
|
+
epoch?: RunEpoch
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createRun(input: CreateRunInput): Promise<HarnessRunDoc> {
|
|
74
|
+
const id = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const bootId = getBootId();
|
|
77
|
+
const doc: HarnessRunDoc = {
|
|
78
|
+
id,
|
|
79
|
+
thread_id: input.thread_id,
|
|
80
|
+
agent_id: input.agent_id,
|
|
81
|
+
user_id: input.user_id,
|
|
82
|
+
channel: input.channel,
|
|
83
|
+
kind: input.kind,
|
|
84
|
+
status: "running",
|
|
85
|
+
iterations_used: 0,
|
|
86
|
+
max_iterations: input.max_iterations,
|
|
87
|
+
turns_used: 0,
|
|
88
|
+
max_turns: input.max_turns ?? null,
|
|
89
|
+
tokens_used: 0,
|
|
90
|
+
max_tokens: input.max_tokens ?? null,
|
|
91
|
+
goal: input.goal ?? null,
|
|
92
|
+
goal_check_tool: input.goal_check_tool ?? null,
|
|
93
|
+
goal_attempts: 0,
|
|
94
|
+
state_json: "",
|
|
95
|
+
state_bytes: 0,
|
|
96
|
+
pending_tool_calls_json: null,
|
|
97
|
+
checkpointed_at: now,
|
|
98
|
+
boot_id: bootId,
|
|
99
|
+
lease_expires_at: now + leaseDurationMs,
|
|
100
|
+
resume_policy: input.resume_policy ?? "resume",
|
|
101
|
+
acceptance_json: input.acceptance ? JSON.stringify(input.acceptance) : null,
|
|
102
|
+
epoch_json: input.epoch ? JSON.stringify(input.epoch) : null,
|
|
103
|
+
error: null,
|
|
104
|
+
created_at: now,
|
|
105
|
+
updated_at: now,
|
|
106
|
+
finished_at: null,
|
|
107
|
+
};
|
|
108
|
+
const c = await col<HarnessRunDoc>(COLLECTION);
|
|
109
|
+
await c.put(id, doc, { expectedVersion: 0 });
|
|
110
|
+
log.info(`[createRun] Run ${id} created (agent=${input.agent_id} kind=${input.kind})`);
|
|
111
|
+
return doc;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Save a checkpoint to the run: serialize messages, trim state if too big. */
|
|
115
|
+
export async function checkpoint(
|
|
116
|
+
runId: string,
|
|
117
|
+
state: RunCheckpointState,
|
|
118
|
+
pendingToolCalls?: unknown[] | null
|
|
119
|
+
): Promise<HarnessRunDoc> {
|
|
120
|
+
const serialized = JSON.stringify(state);
|
|
121
|
+
let stateJson = serialized;
|
|
122
|
+
let stateBytes = new TextEncoder().encode(serialized).length;
|
|
123
|
+
|
|
124
|
+
if (stateBytes > MAX_STATE_BYTES) {
|
|
125
|
+
stateJson = truncateState(state);
|
|
126
|
+
stateBytes = new TextEncoder().encode(stateJson).length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const patch: Partial<HarnessRunDoc> = {
|
|
130
|
+
state_json: stateJson,
|
|
131
|
+
state_bytes: stateBytes,
|
|
132
|
+
pending_tool_calls_json: pendingToolCalls ? JSON.stringify(pendingToolCalls) : null,
|
|
133
|
+
checkpointed_at: Date.now(),
|
|
134
|
+
iterations_used: state.iterations,
|
|
135
|
+
tokens_used: state.totalInputTokens + state.totalOutputTokens,
|
|
136
|
+
lease_expires_at: Date.now() + leaseDurationMs,
|
|
137
|
+
boot_id: getBootId(),
|
|
138
|
+
updated_at: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return updateDoc<HarnessRunDoc>(COLLECTION, runId, patch);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function bumpTurn(runId: string, tokensDelta: number): Promise<HarnessRunDoc> {
|
|
145
|
+
const existing = await getRun(runId);
|
|
146
|
+
if (!existing) throw new Error(`Run ${runId} not found`);
|
|
147
|
+
return updateDoc<HarnessRunDoc>(COLLECTION, runId, {
|
|
148
|
+
turns_used: existing.turns_used + 1,
|
|
149
|
+
tokens_used: existing.tokens_used + tokensDelta,
|
|
150
|
+
lease_expires_at: Date.now() + leaseDurationMs,
|
|
151
|
+
updated_at: Date.now(),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function completeRun(runId: string, finalContent?: string): Promise<void> {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
void finalContent;
|
|
158
|
+
await updateDoc<HarnessRunDoc>(COLLECTION, runId, {
|
|
159
|
+
status: "completed",
|
|
160
|
+
state_json: "",
|
|
161
|
+
state_bytes: 0,
|
|
162
|
+
pending_tool_calls_json: null,
|
|
163
|
+
lease_expires_at: now,
|
|
164
|
+
finished_at: now,
|
|
165
|
+
updated_at: now,
|
|
166
|
+
} as Partial<HarnessRunDoc>);
|
|
167
|
+
log.info(`[completeRun] Run ${runId} completed`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function failRun(runId: string, error: string): Promise<void> {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
await updateDoc<HarnessRunDoc>(COLLECTION, runId, {
|
|
173
|
+
status: "failed",
|
|
174
|
+
error,
|
|
175
|
+
lease_expires_at: now,
|
|
176
|
+
finished_at: now,
|
|
177
|
+
updated_at: now,
|
|
178
|
+
state_json: "",
|
|
179
|
+
state_bytes: 0,
|
|
180
|
+
pending_tool_calls_json: null,
|
|
181
|
+
});
|
|
182
|
+
log.warn(`[failRun] Run ${runId} failed: ${error}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function interruptRun(runId: string, reason: string): Promise<void> {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
await updateDoc<HarnessRunDoc>(COLLECTION, runId, {
|
|
188
|
+
status: "interrupted",
|
|
189
|
+
error: reason,
|
|
190
|
+
lease_expires_at: now,
|
|
191
|
+
finished_at: now,
|
|
192
|
+
updated_at: now,
|
|
193
|
+
});
|
|
194
|
+
log.warn(`[interruptRun] Run ${runId} interrupted: ${reason}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Take ownership of an existing run before (re-)executing it. After a crash,
|
|
199
|
+
* reconcile leaves the row "interrupted" with the dead process's boot_id;
|
|
200
|
+
* both must be reset before resuming.
|
|
201
|
+
*/
|
|
202
|
+
export async function reclaimRun(runId: string): Promise<void> {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
await updateDoc<HarnessRunDoc>(COLLECTION, runId, {
|
|
205
|
+
status: "running",
|
|
206
|
+
boot_id: getBootId(),
|
|
207
|
+
lease_expires_at: now + leaseDurationMs,
|
|
208
|
+
error: null,
|
|
209
|
+
finished_at: null,
|
|
210
|
+
updated_at: now,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function getRun(runId: string): Promise<HarnessRunDoc | null> {
|
|
215
|
+
const c = await col<HarnessRunDoc>(COLLECTION);
|
|
216
|
+
const entry = await c.get(runId);
|
|
217
|
+
return entry ? entry.doc : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function findRunsByStatus(status: HarnessRunDoc["status"]): Promise<HarnessRunDoc[]> {
|
|
221
|
+
const c = await col<HarnessRunDoc>(COLLECTION);
|
|
222
|
+
const entries = await c.findBy("status", status);
|
|
223
|
+
return entries.map((e) => e.doc);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function findRunsByThread(threadId: string): Promise<HarnessRunDoc[]> {
|
|
227
|
+
const c = await col<HarnessRunDoc>(COLLECTION);
|
|
228
|
+
const entries = await c.findBy("thread_id", threadId);
|
|
229
|
+
return entries.map((e) => e.doc);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function findExpiredRuns(): Promise<HarnessRunDoc[]> {
|
|
233
|
+
const running = await findRunsByStatus("running");
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
return running.filter((r) => r.lease_expires_at < now);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function deserializeAcceptance(run: HarnessRunDoc): AcceptanceCriterion[] | null {
|
|
239
|
+
if (!run.acceptance_json) return null;
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(run.acceptance_json) as AcceptanceCriterion[];
|
|
242
|
+
} catch {
|
|
243
|
+
log.warn(`[deserializeAcceptance] Failed to parse acceptance_json for run ${run.id}`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function deserializeEpoch(run: HarnessRunDoc): RunEpoch | null {
|
|
249
|
+
if (!run.epoch_json) return null;
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(run.epoch_json) as RunEpoch;
|
|
252
|
+
} catch {
|
|
253
|
+
log.warn(`[deserializeEpoch] Failed to parse epoch_json for run ${run.id}`);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Deserialize a checkpoint, or null if the run has no checkpoint. */
|
|
259
|
+
export function deserializeCheckpoint(run: HarnessRunDoc): RunCheckpointState | null {
|
|
260
|
+
if (!run.state_json) return null;
|
|
261
|
+
try {
|
|
262
|
+
const raw = JSON.parse(run.state_json);
|
|
263
|
+
if (raw.version !== 1) return null;
|
|
264
|
+
return raw as RunCheckpointState;
|
|
265
|
+
} catch {
|
|
266
|
+
log.warn(`[deserializeCheckpoint] Failed to parse state_json for run ${run.id}`);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function truncateState(state: RunCheckpointState): string {
|
|
272
|
+
const messages = [...state.messages] as Array<Record<string, unknown>>;
|
|
273
|
+
const keepLastN = 8;
|
|
274
|
+
const cutoff = messages.length - keepLastN;
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < cutoff; i++) {
|
|
277
|
+
const msg = messages[i];
|
|
278
|
+
if (msg.role === "tool" && typeof msg.content === "string" && msg.content.length > 200) {
|
|
279
|
+
messages[i] = { ...msg, content: `[Truncated: ${(msg.content as string).substring(0, 200)}...]` };
|
|
280
|
+
}
|
|
281
|
+
if (msg.role === "assistant" && typeof msg.content === "string" && (msg.content as string).length > 500) {
|
|
282
|
+
messages[i] = { ...msg, content: (msg.content as string).substring(0, 500) + "[...]" };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return JSON.stringify({ ...state, messages });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Lease renewal timer ────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
const leaseTimers: Map<string, ReturnType<typeof setInterval>> = new Map();
|
|
292
|
+
|
|
293
|
+
export function startLeaseRenewal(runId: string): void {
|
|
294
|
+
if (leaseTimers.has(runId)) return;
|
|
295
|
+
const timer = setInterval(async () => {
|
|
296
|
+
try {
|
|
297
|
+
const run = await getRun(runId);
|
|
298
|
+
if (!run || run.status !== "running") {
|
|
299
|
+
stopLeaseRenewal(runId);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
await updateDoc<HarnessRunDoc>(COLLECTION, runId, {
|
|
303
|
+
lease_expires_at: Date.now() + leaseDurationMs,
|
|
304
|
+
updated_at: Date.now(),
|
|
305
|
+
} as Partial<HarnessRunDoc>);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
log.warn(`[startLeaseRenewal] Failed to renew lease for ${runId}: ${(err as Error).message}`);
|
|
308
|
+
}
|
|
309
|
+
}, leaseRenewIntervalMs);
|
|
310
|
+
leaseTimers.set(runId, timer);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function stopLeaseRenewal(runId: string): void {
|
|
314
|
+
const timer = leaseTimers.get(runId);
|
|
315
|
+
if (timer) {
|
|
316
|
+
clearInterval(timer);
|
|
317
|
+
leaseTimers.delete(runId);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function stopAllLeaseRenewals(): void {
|
|
322
|
+
for (const [, timer] of leaseTimers) clearInterval(timer);
|
|
323
|
+
leaseTimers.clear();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function ensureRunStoreIndexes(): Promise<void> {
|
|
327
|
+
const c = await col<HarnessRunDoc>(COLLECTION);
|
|
328
|
+
await c.createIndex("status");
|
|
329
|
+
await c.createIndex("thread_id");
|
|
330
|
+
await c.createIndex("agent_id");
|
|
331
|
+
await c.createIndex("kind");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export { COLLECTION as AGENT_RUNS_COLLECTION };
|
|
@@ -92,3 +92,9 @@ export type { WorkerConfig, WorkerInstance, WorkerChunk, WorkerPoolConfig, PoolT
|
|
|
92
92
|
// ─── Utils ───────────────────────────────────────────────────────────────────
|
|
93
93
|
export { logger } from "./utils/index.ts";
|
|
94
94
|
export { retry } from "./utils/retry.ts";
|
|
95
|
+
|
|
96
|
+
// ─── Harness (durable task execution) ───────────────────────────────────────
|
|
97
|
+
// Namespaced to avoid flooding the top-level barrel with ~50 harness exports —
|
|
98
|
+
// see docs/HIVE-HARNESS.md. Also available as a flat import from
|
|
99
|
+
// "@johpaz/hive-sdk/harness".
|
|
100
|
+
export * as harness from "./harness/index.ts";
|
|
@@ -1,47 +1,49 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
2
|
import { Scratchpad } from "./Scratchpad.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { initializeDatabase, dbService } from "../storage/SQLiteStorage.ts";
|
|
4
|
+
import { getHiveDB, closeHiveDB } from "../storage/HiveDBStorage.ts";
|
|
4
5
|
|
|
5
6
|
describe("Scratchpad", () => {
|
|
6
7
|
let pad: Scratchpad;
|
|
7
8
|
|
|
8
9
|
beforeAll(async () => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
pad = new Scratchpad(
|
|
10
|
+
initializeDatabase();
|
|
11
|
+
await getHiveDB();
|
|
12
|
+
pad = new Scratchpad();
|
|
12
13
|
});
|
|
13
14
|
|
|
14
15
|
afterAll(() => {
|
|
16
|
+
closeHiveDB();
|
|
15
17
|
dbService.close();
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
const THREAD = "test-thread";
|
|
19
21
|
|
|
20
|
-
it("writes and reads a note", () => {
|
|
21
|
-
pad.write(THREAD, "test-1", "hello world");
|
|
22
|
-
const value = pad.read(THREAD, "test-1");
|
|
22
|
+
it("writes and reads a note", async () => {
|
|
23
|
+
await pad.write(THREAD, "test-1", "hello world");
|
|
24
|
+
const value = await pad.read(THREAD, "test-1");
|
|
23
25
|
expect(value).toBe("hello world");
|
|
24
26
|
});
|
|
25
27
|
|
|
26
|
-
it("lists notes as key-value map", () => {
|
|
27
|
-
pad.write(THREAD, "list-a", "aaa");
|
|
28
|
-
pad.write(THREAD, "list-b", "bbb");
|
|
29
|
-
const notes = pad.list(THREAD);
|
|
28
|
+
it("lists notes as key-value map", async () => {
|
|
29
|
+
await pad.write(THREAD, "list-a", "aaa");
|
|
30
|
+
await pad.write(THREAD, "list-b", "bbb");
|
|
31
|
+
const notes = await pad.list(THREAD);
|
|
30
32
|
expect(notes["list-a"]).toBe("aaa");
|
|
31
33
|
expect(notes["list-b"]).toBe("bbb");
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
it("deletes a note", () => {
|
|
35
|
-
pad.write(THREAD, "to-delete", "delete me");
|
|
36
|
-
pad.delete(THREAD, "to-delete");
|
|
37
|
-
const value = pad.read(THREAD, "to-delete");
|
|
38
|
-
expect(value).
|
|
36
|
+
it("deletes a note", async () => {
|
|
37
|
+
await pad.write(THREAD, "to-delete", "delete me");
|
|
38
|
+
await pad.delete(THREAD, "to-delete");
|
|
39
|
+
const value = await pad.read(THREAD, "to-delete");
|
|
40
|
+
expect(value).toBeUndefined();
|
|
39
41
|
});
|
|
40
42
|
|
|
41
|
-
it("clear removes all notes for a thread", () => {
|
|
42
|
-
pad.write(THREAD, "clear-a", "a");
|
|
43
|
-
pad.write(THREAD, "clear-b", "b");
|
|
44
|
-
pad.clear(THREAD);
|
|
45
|
-
expect(Object.keys(pad.list(THREAD)).length).toBe(0);
|
|
43
|
+
it("clear removes all notes for a thread", async () => {
|
|
44
|
+
await pad.write(THREAD, "clear-a", "a");
|
|
45
|
+
await pad.write(THREAD, "clear-b", "b");
|
|
46
|
+
await pad.clear(THREAD);
|
|
47
|
+
expect(Object.keys(await pad.list(THREAD)).length).toBe(0);
|
|
46
48
|
});
|
|
47
49
|
});
|