@c4t4/heyamigo 0.9.15 → 0.9.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot.js +3 -0
- package/dist/db/schema.js +38 -0
- package/dist/estimates/image-gen.js +36 -0
- package/dist/estimates/index.js +12 -0
- package/dist/estimates/registry.js +113 -0
- package/dist/estimates/types.js +6 -0
- package/dist/gateway/incoming.js +26 -6
- package/dist/queue/async-tasks.js +21 -20
- package/dist/queue/browser-queue.js +141 -0
- package/dist/queue/browser-worker.js +170 -0
- package/dist/queue/inbound.js +1 -0
- package/dist/queue/orchestrator.js +5 -0
- package/migrations/0006_phase4_browser_tasks.sql +20 -0
- package/migrations/0007_estimates_kind.sql +2 -0
- package/migrations/meta/0006_snapshot.json +909 -0
- package/migrations/meta/0007_snapshot.json +924 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Browser worker pool. N workers (config.browser.maxWorkers, default
|
|
2
|
+
// 3) drain the browser_tasks SQLite table. Each task runs as a fresh
|
|
3
|
+
// agent with its own tab on the shared Chrome — same model as before
|
|
4
|
+
// the durability change, just claimable from the DB now.
|
|
5
|
+
//
|
|
6
|
+
// Differences vs in-memory fastq:
|
|
7
|
+
// - Tasks survive process crashes (durable rows).
|
|
8
|
+
// - Orchestrator reclaims stuck claims via reclaimStuckBrowserTasks.
|
|
9
|
+
// - Retry / DLQ semantics live in the queue helpers.
|
|
10
|
+
import { hostname } from 'os';
|
|
11
|
+
import { eq } from 'drizzle-orm';
|
|
12
|
+
import { config } from '../config.js';
|
|
13
|
+
import { getDb } from '../db/index.js';
|
|
14
|
+
import { parseAddress } from '../db/address.js';
|
|
15
|
+
import { workers } from '../db/schema.js';
|
|
16
|
+
import { logger } from '../logger.js';
|
|
17
|
+
import { claimNextBrowserTask, markBrowserTaskDone, markBrowserTaskRetryOrDlq, } from './browser-queue.js';
|
|
18
|
+
import { initiate } from '../gateway/outgoing.js';
|
|
19
|
+
import { runBrowserTask } from './async-tasks.js';
|
|
20
|
+
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
21
|
+
const IDLE_POLL_INTERVAL_MS = 500;
|
|
22
|
+
const BUSY_POLL_INTERVAL_MS = 0;
|
|
23
|
+
const activeWorkers = [];
|
|
24
|
+
let stopping = false;
|
|
25
|
+
let heartbeatTimer = null;
|
|
26
|
+
function newWorkerId(slot) {
|
|
27
|
+
return `${hostname()}-${process.pid}-browser-${slot}`;
|
|
28
|
+
}
|
|
29
|
+
function registerWorker(id) {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
const now = Math.floor(Date.now() / 1000);
|
|
32
|
+
db.insert(workers)
|
|
33
|
+
.values({
|
|
34
|
+
id,
|
|
35
|
+
kind: 'browser',
|
|
36
|
+
status: 'idle',
|
|
37
|
+
currentJob: null,
|
|
38
|
+
lastSeen: now,
|
|
39
|
+
startedAt: now,
|
|
40
|
+
})
|
|
41
|
+
.onConflictDoUpdate({
|
|
42
|
+
target: workers.id,
|
|
43
|
+
set: { status: 'idle', currentJob: null, lastSeen: now, startedAt: now },
|
|
44
|
+
})
|
|
45
|
+
.run();
|
|
46
|
+
}
|
|
47
|
+
function setWorkerStatus(id, status, currentJob = null) {
|
|
48
|
+
const db = getDb();
|
|
49
|
+
db.update(workers)
|
|
50
|
+
.set({
|
|
51
|
+
status,
|
|
52
|
+
currentJob,
|
|
53
|
+
lastSeen: Math.floor(Date.now() / 1000),
|
|
54
|
+
})
|
|
55
|
+
.where(eq(workers.id, id))
|
|
56
|
+
.run();
|
|
57
|
+
}
|
|
58
|
+
function heartbeatAll() {
|
|
59
|
+
if (activeWorkers.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
const db = getDb();
|
|
62
|
+
const now = Math.floor(Date.now() / 1000);
|
|
63
|
+
for (const id of activeWorkers) {
|
|
64
|
+
db.update(workers)
|
|
65
|
+
.set({ lastSeen: now })
|
|
66
|
+
.where(eq(workers.id, id))
|
|
67
|
+
.run();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Convert a row into the AsyncTask shape that runBrowserTask expects.
|
|
71
|
+
// The id field is a synthetic string for log lines; the real row id
|
|
72
|
+
// is used for queue bookkeeping.
|
|
73
|
+
function rowToAsyncTask(row) {
|
|
74
|
+
let allowedTools = 'all';
|
|
75
|
+
if (row.allowedTools) {
|
|
76
|
+
try {
|
|
77
|
+
allowedTools = JSON.parse(row.allowedTools);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// bad JSON → fall back to 'all'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
id: `browser-${row.id}`,
|
|
85
|
+
jid: parseAddress(row.address).externalId,
|
|
86
|
+
senderNumber: row.senderNumber,
|
|
87
|
+
senderName: row.senderName ?? undefined,
|
|
88
|
+
description: row.description,
|
|
89
|
+
originatingMessage: row.originatingMessage,
|
|
90
|
+
allowedTools,
|
|
91
|
+
startedAt: row.claimedAt ?? row.createdAt,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function processOne(workerId, row) {
|
|
95
|
+
setWorkerStatus(workerId, 'busy', `browser_tasks:${row.id}`);
|
|
96
|
+
const task = rowToAsyncTask(row);
|
|
97
|
+
try {
|
|
98
|
+
await runBrowserTask(task);
|
|
99
|
+
const ok = markBrowserTaskDone(row.id, workerId);
|
|
100
|
+
if (!ok) {
|
|
101
|
+
logger.warn({ id: row.id, workerId }, 'browser task markDone failed (claim lost?). work already done.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
106
|
+
const result = markBrowserTaskRetryOrDlq(row.id, workerId, msg);
|
|
107
|
+
if (result.deadLettered) {
|
|
108
|
+
logger.error({ err, id: row.id, address: row.address }, 'browser task dead-lettered after max attempts');
|
|
109
|
+
// User-facing failure ack so the chat isn't left hanging.
|
|
110
|
+
try {
|
|
111
|
+
await initiate({
|
|
112
|
+
jid: parseAddress(row.address).externalId,
|
|
113
|
+
text: `Heads up: the browser task "${row.description.slice(0, 80)}" failed. Ask me again and I'll retry.`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
logger.error({ err: e, id: row.id }, 'failed to send DLQ-ack reply');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (result.retried) {
|
|
121
|
+
logger.warn({ err, id: row.id, address: row.address }, 'browser task transient fail, will retry');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
setWorkerStatus(workerId, 'idle');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function loop(workerId) {
|
|
129
|
+
while (!stopping) {
|
|
130
|
+
let processed = false;
|
|
131
|
+
try {
|
|
132
|
+
const row = claimNextBrowserTask(workerId);
|
|
133
|
+
if (row) {
|
|
134
|
+
await processOne(workerId, row);
|
|
135
|
+
processed = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
logger.error({ err, workerId }, 'browser worker loop error');
|
|
140
|
+
}
|
|
141
|
+
const delay = processed ? BUSY_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
|
|
142
|
+
if (delay > 0) {
|
|
143
|
+
await new Promise((res) => setTimeout(res, delay));
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
await new Promise((res) => setImmediate(res));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
setWorkerStatus(workerId, 'dead');
|
|
150
|
+
}
|
|
151
|
+
export function startBrowserWorkers() {
|
|
152
|
+
if (activeWorkers.length > 0) {
|
|
153
|
+
logger.warn('browser workers already started; ignoring');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const pool = Math.max(1, config.browser?.maxWorkers ?? 3);
|
|
157
|
+
for (let i = 0; i < pool; i++) {
|
|
158
|
+
const id = newWorkerId(i);
|
|
159
|
+
activeWorkers.push(id);
|
|
160
|
+
registerWorker(id);
|
|
161
|
+
void loop(id).catch((err) => logger.fatal({ err, workerId: id }, 'browser worker loop crashed'));
|
|
162
|
+
}
|
|
163
|
+
heartbeatTimer = setInterval(heartbeatAll, HEARTBEAT_INTERVAL_MS);
|
|
164
|
+
logger.info({ pool }, 'browser worker pool started');
|
|
165
|
+
}
|
|
166
|
+
export function stopBrowserWorkers() {
|
|
167
|
+
stopping = true;
|
|
168
|
+
if (heartbeatTimer)
|
|
169
|
+
clearInterval(heartbeatTimer);
|
|
170
|
+
}
|
package/dist/queue/inbound.js
CHANGED
|
@@ -37,6 +37,7 @@ export function enqueueInbound(input) {
|
|
|
37
37
|
mediaBytes: input.mediaBytes ?? null,
|
|
38
38
|
pushName: input.pushName ?? null,
|
|
39
39
|
triggerReason: input.triggerReason ?? null,
|
|
40
|
+
kind: input.kind ?? null,
|
|
40
41
|
payload: input.payload === undefined ? null : JSON.stringify(input.payload),
|
|
41
42
|
status: 'pending',
|
|
42
43
|
attempts: 0,
|
|
@@ -16,6 +16,7 @@ import { and, eq, lt, ne } from 'drizzle-orm';
|
|
|
16
16
|
import { getDb } from '../db/index.js';
|
|
17
17
|
import { workers } from '../db/schema.js';
|
|
18
18
|
import { logger } from '../logger.js';
|
|
19
|
+
import { reclaimStuckBrowserTasks } from './browser-queue.js';
|
|
19
20
|
import { reclaimStuckInbound } from './inbound.js';
|
|
20
21
|
import { reclaimStuckMemoryWrites } from './memory-writes.js';
|
|
21
22
|
import { reclaimStuckOutbound } from './outbound.js';
|
|
@@ -112,6 +113,10 @@ async function tick(id) {
|
|
|
112
113
|
if (reclaimedMemWr > 0) {
|
|
113
114
|
logger.info({ reclaimed: reclaimedMemWr }, 'reclaimed stuck memory_writes rows');
|
|
114
115
|
}
|
|
116
|
+
const reclaimedBrowser = reclaimStuckBrowserTasks();
|
|
117
|
+
if (reclaimedBrowser > 0) {
|
|
118
|
+
logger.info({ reclaimed: reclaimedBrowser }, 'reclaimed stuck browser_tasks rows');
|
|
119
|
+
}
|
|
115
120
|
// Fire any due crons. Order: dispatch each in turn; if dispatch
|
|
116
121
|
// throws (it shouldn't — dispatch swallows), the cron is NOT
|
|
117
122
|
// marked fired and we'll retry on the next tick.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
CREATE TABLE `browser_tasks` (
|
|
2
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
3
|
+
`address` text NOT NULL,
|
|
4
|
+
`actor_person_id` text,
|
|
5
|
+
`description` text NOT NULL,
|
|
6
|
+
`originating_message` text NOT NULL,
|
|
7
|
+
`sender_number` text NOT NULL,
|
|
8
|
+
`sender_name` text,
|
|
9
|
+
`allowed_tools` text,
|
|
10
|
+
`status` text NOT NULL,
|
|
11
|
+
`attempts` integer DEFAULT 0 NOT NULL,
|
|
12
|
+
`next_attempt_at` integer,
|
|
13
|
+
`last_error` text,
|
|
14
|
+
`claimed_by` text,
|
|
15
|
+
`claimed_at` integer,
|
|
16
|
+
`created_at` integer NOT NULL,
|
|
17
|
+
`updated_at` integer NOT NULL
|
|
18
|
+
);
|
|
19
|
+
--> statement-breakpoint
|
|
20
|
+
CREATE INDEX `btasks_by_status_next` ON `browser_tasks` (`status`,`next_attempt_at`);
|