@c4t4/heyamigo 0.9.1 → 0.9.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/dist/index.js +19 -8
- package/dist/memory/digest-flag.js +28 -0
- package/dist/queue/async-tasks.js +27 -2
- package/dist/queue/control.js +40 -0
- package/dist/queue/orchestrator.js +158 -0
- package/dist/queue/worker.js +15 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { attachIncoming } from './gateway/incoming.js';
|
|
|
5
5
|
import { handleReply } from './gateway/outgoing.js';
|
|
6
6
|
import { logger } from './logger.js';
|
|
7
7
|
import { startScheduler } from './memory/scheduler.js';
|
|
8
|
+
import { requestShutdown, startOrchestrator, stopOrchestrator, } from './queue/orchestrator.js';
|
|
8
9
|
import { replayPending } from './queue/queue.js';
|
|
9
10
|
import { startSenderWorker, stopSenderWorker } from './queue/sender-worker.js';
|
|
10
11
|
import { startSocket } from './wa/socket.js';
|
|
@@ -14,6 +15,16 @@ async function main() {
|
|
|
14
15
|
initDb();
|
|
15
16
|
// Derived view: populate persons + identities from access.json.
|
|
16
17
|
syncIdentitiesFromAccess();
|
|
18
|
+
// Orchestrator handles cross-cutting bookkeeping: control table
|
|
19
|
+
// signals, stuck-claim reclaim, dead-worker detection, cron polling
|
|
20
|
+
// (Phase 2.2+). Starts before workers so it can see them register.
|
|
21
|
+
startOrchestrator({
|
|
22
|
+
onShutdownDrained: () => {
|
|
23
|
+
stopSenderWorker();
|
|
24
|
+
stopOrchestrator();
|
|
25
|
+
closeDb();
|
|
26
|
+
},
|
|
27
|
+
});
|
|
17
28
|
// Sender worker drains outbound queue → channel adapters. Started
|
|
18
29
|
// before the socket so it's ready when handleReply enqueues rows.
|
|
19
30
|
startSenderWorker();
|
|
@@ -30,17 +41,17 @@ async function main() {
|
|
|
30
41
|
await handleReply(job, result, {});
|
|
31
42
|
}).catch((err) => logger.error({ err }, 'replay failed'));
|
|
32
43
|
}
|
|
44
|
+
// Graceful shutdown: signal handler writes a 'shutdown' row to the
|
|
45
|
+
// control table; orchestrator picks it up, drains in-flight work,
|
|
46
|
+
// then runs onShutdownDrained (stops workers, closes DB) and exits.
|
|
47
|
+
// A 30s timer inside the orchestrator force-exits if drain hangs.
|
|
33
48
|
process.on('SIGINT', () => {
|
|
34
|
-
logger.info('SIGINT received,
|
|
35
|
-
|
|
36
|
-
closeDb();
|
|
37
|
-
process.exit(0);
|
|
49
|
+
logger.info('SIGINT received, requesting graceful shutdown');
|
|
50
|
+
requestShutdown('SIGINT');
|
|
38
51
|
});
|
|
39
52
|
process.on('SIGTERM', () => {
|
|
40
|
-
logger.info('SIGTERM received,
|
|
41
|
-
|
|
42
|
-
closeDb();
|
|
43
|
-
process.exit(0);
|
|
53
|
+
logger.info('SIGTERM received, requesting graceful shutdown');
|
|
54
|
+
requestShutdown('SIGTERM');
|
|
44
55
|
});
|
|
45
56
|
main().catch((err) => {
|
|
46
57
|
logger.error({ err }, 'fatal error during boot');
|
|
@@ -14,6 +14,7 @@ const KINDS = [
|
|
|
14
14
|
'JOURNAL-NEW',
|
|
15
15
|
'ASYNC',
|
|
16
16
|
'ASYNC-BROWSER',
|
|
17
|
+
'SEND-TEXT',
|
|
17
18
|
];
|
|
18
19
|
// Walk backwards from the end of the string, tracking bracket depth, to find
|
|
19
20
|
// the `[` that matches the final `]`. Returns the tag kind, its payload, and
|
|
@@ -74,6 +75,7 @@ export function extractFlags(reply) {
|
|
|
74
75
|
const journalCreates = [];
|
|
75
76
|
const asyncTasks = [];
|
|
76
77
|
const asyncBrowserTasks = [];
|
|
78
|
+
const sendTexts = [];
|
|
77
79
|
while (true) {
|
|
78
80
|
const peeled = peelTrailingTag(current);
|
|
79
81
|
if (!peeled)
|
|
@@ -105,6 +107,11 @@ export function extractFlags(reply) {
|
|
|
105
107
|
asyncBrowserTasks.unshift({ description: payload });
|
|
106
108
|
}
|
|
107
109
|
}
|
|
110
|
+
else if (kind === 'SEND-TEXT') {
|
|
111
|
+
const parsed = parseSendTextPayload(payload);
|
|
112
|
+
if (parsed)
|
|
113
|
+
sendTexts.unshift(parsed);
|
|
114
|
+
}
|
|
108
115
|
}
|
|
109
116
|
return {
|
|
110
117
|
clean: current,
|
|
@@ -113,6 +120,7 @@ export function extractFlags(reply) {
|
|
|
113
120
|
journalCreates,
|
|
114
121
|
asyncTasks,
|
|
115
122
|
asyncBrowserTasks,
|
|
123
|
+
sendTexts,
|
|
116
124
|
};
|
|
117
125
|
}
|
|
118
126
|
// Legacy helper kept so existing callers still compile.
|
|
@@ -121,6 +129,26 @@ export function extractDigestFlag(reply) {
|
|
|
121
129
|
return { clean: r.clean, flag: r.digest };
|
|
122
130
|
}
|
|
123
131
|
const JOURNAL_SEP_RE = /\s*(?:[—\-–]|:)\s*/;
|
|
132
|
+
// Parse `address=<addr> body="..."` style key=value payload.
|
|
133
|
+
// Body is delimited by double quotes; everything else by whitespace.
|
|
134
|
+
// Returns null if address or body is missing.
|
|
135
|
+
function parseSendTextPayload(payload) {
|
|
136
|
+
// Grab body="..." first (longest match so quoted body can contain spaces)
|
|
137
|
+
const bodyMatch = payload.match(/\bbody\s*=\s*"((?:[^"\\]|\\.)*)"/);
|
|
138
|
+
if (!bodyMatch)
|
|
139
|
+
return null;
|
|
140
|
+
const body = bodyMatch[1]
|
|
141
|
+
.replace(/\\"/g, '"')
|
|
142
|
+
.replace(/\\\\/g, '\\');
|
|
143
|
+
if (!body.trim())
|
|
144
|
+
return null;
|
|
145
|
+
// Strip the body=... portion so address parsing doesn't trip on it
|
|
146
|
+
const withoutBody = payload.replace(bodyMatch[0], '').trim();
|
|
147
|
+
const addrMatch = withoutBody.match(/\baddress\s*=\s*([^\s]+)/);
|
|
148
|
+
if (!addrMatch)
|
|
149
|
+
return null;
|
|
150
|
+
return { address: addrMatch[1], body };
|
|
151
|
+
}
|
|
124
152
|
function parseJournalPayload(payload) {
|
|
125
153
|
// Split on first em-dash, en-dash, hyphen, or colon between slug and note.
|
|
126
154
|
const match = payload.match(/^([a-zA-Z0-9][a-zA-Z0-9-]*)(.*)$/);
|
|
@@ -124,7 +124,20 @@ async function executeAsyncTask(task) {
|
|
|
124
124
|
// as markers; clean pre-marker text is only sent to chat when short (a
|
|
125
125
|
// failure explanation or tight ack) or when no markers fired at all.
|
|
126
126
|
const { extractFlags } = await import('../memory/digest-flag.js');
|
|
127
|
-
const { clean, digest, journals, journalCreates } = extractFlags(output);
|
|
127
|
+
const { clean, digest, journals, journalCreates, sendTexts } = extractFlags(output);
|
|
128
|
+
// SEND-TEXT: async task wants to text a different chat too.
|
|
129
|
+
if (sendTexts.length > 0) {
|
|
130
|
+
const { enqueueOutbound } = await import('./outbound.js');
|
|
131
|
+
for (let i = 0; i < sendTexts.length; i++) {
|
|
132
|
+
const t = sendTexts[i];
|
|
133
|
+
enqueueOutbound({
|
|
134
|
+
address: t.address,
|
|
135
|
+
kind: 'text',
|
|
136
|
+
text: t.body,
|
|
137
|
+
idempotencyKey: `async-sendtext-${task.id}-${i}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
128
141
|
// Journal creates run first so an entry flagged in the same output against
|
|
129
142
|
// a new slug lands correctly.
|
|
130
143
|
const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
|
|
@@ -402,7 +415,19 @@ async function runBrowserTask(task) {
|
|
|
402
415
|
}
|
|
403
416
|
// Route markers the same way the general async lane does.
|
|
404
417
|
const { extractFlags } = await import('../memory/digest-flag.js');
|
|
405
|
-
const { clean, digest, journals, journalCreates } = extractFlags(reply);
|
|
418
|
+
const { clean, digest, journals, journalCreates, sendTexts } = extractFlags(reply);
|
|
419
|
+
if (sendTexts.length > 0) {
|
|
420
|
+
const { enqueueOutbound } = await import('./outbound.js');
|
|
421
|
+
for (let i = 0; i < sendTexts.length; i++) {
|
|
422
|
+
const t = sendTexts[i];
|
|
423
|
+
enqueueOutbound({
|
|
424
|
+
address: t.address,
|
|
425
|
+
kind: 'text',
|
|
426
|
+
text: t.body,
|
|
427
|
+
idempotencyKey: `browser-sendtext-${task.id}-${i}`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
406
431
|
const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
|
|
407
432
|
for (const op of journalCreates) {
|
|
408
433
|
if (!isValidSlug(op.slug))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Helpers for the control table — the bot's runtime signalling
|
|
2
|
+
// channel. SIGTERM, /shutdown chat command, or external trigger all
|
|
3
|
+
// insert a control row; the orchestrator picks it up on its next tick
|
|
4
|
+
// and acts.
|
|
5
|
+
//
|
|
6
|
+
// Single-row-per-key: PK on `key` gives natural upsert semantics.
|
|
7
|
+
import { eq } from 'drizzle-orm';
|
|
8
|
+
import { getDb } from '../db/index.js';
|
|
9
|
+
import { control } from '../db/schema.js';
|
|
10
|
+
export function requestControl(key, value = null, requestedBy = null) {
|
|
11
|
+
const db = getDb();
|
|
12
|
+
const now = Math.floor(Date.now() / 1000);
|
|
13
|
+
db.insert(control)
|
|
14
|
+
.values({ key, value, requestedBy, requestedAt: now })
|
|
15
|
+
.onConflictDoUpdate({
|
|
16
|
+
target: control.key,
|
|
17
|
+
set: { value, requestedBy, requestedAt: now },
|
|
18
|
+
})
|
|
19
|
+
.run();
|
|
20
|
+
}
|
|
21
|
+
export function readControl(key) {
|
|
22
|
+
const db = getDb();
|
|
23
|
+
const row = db.select().from(control).where(eq(control.key, key)).get();
|
|
24
|
+
if (!row)
|
|
25
|
+
return null;
|
|
26
|
+
return {
|
|
27
|
+
value: row.value,
|
|
28
|
+
requestedBy: row.requestedBy,
|
|
29
|
+
requestedAt: row.requestedAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function clearControl(key) {
|
|
33
|
+
const db = getDb();
|
|
34
|
+
const result = db
|
|
35
|
+
.delete(control)
|
|
36
|
+
.where(eq(control.key, key))
|
|
37
|
+
.returning({ key: control.key })
|
|
38
|
+
.all();
|
|
39
|
+
return result.length > 0;
|
|
40
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Bot-wide orchestrator. One process-wide instance. Polls every
|
|
2
|
+
// ~500ms and does the cross-cutting work no single worker should
|
|
3
|
+
// own:
|
|
4
|
+
// - Read control table → apply shutdown/pause/reload signals.
|
|
5
|
+
// - Reclaim stuck claims on outbound (and later: async, browser,
|
|
6
|
+
// memory_writes).
|
|
7
|
+
// - Mark dead workers (last_seen past threshold).
|
|
8
|
+
// - Poll the cron table → enqueue due jobs (Phase 2.2; not yet).
|
|
9
|
+
// - Log queue depths to a metrics buffer (Phase 7; not yet).
|
|
10
|
+
//
|
|
11
|
+
// Distinct from the sender worker: sender pulls from outbound and
|
|
12
|
+
// sends. Orchestrator pulls signals and metadata; it dispatches but
|
|
13
|
+
// doesn't do per-row work itself.
|
|
14
|
+
import { hostname } from 'os';
|
|
15
|
+
import { and, eq, lt, ne } from 'drizzle-orm';
|
|
16
|
+
import { getDb } from '../db/index.js';
|
|
17
|
+
import { workers } from '../db/schema.js';
|
|
18
|
+
import { logger } from '../logger.js';
|
|
19
|
+
import { reclaimStuckOutbound } from './outbound.js';
|
|
20
|
+
import { clearControl, readControl, requestControl } from './control.js';
|
|
21
|
+
const TICK_INTERVAL_MS = 500;
|
|
22
|
+
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
23
|
+
const WORKER_DEAD_AFTER_SECONDS = 30;
|
|
24
|
+
const SHUTDOWN_GRACE_MS = 30_000; // total drain window before force-exit
|
|
25
|
+
let workerId = null;
|
|
26
|
+
let stopping = false;
|
|
27
|
+
let draining = false;
|
|
28
|
+
let tickTimer = null;
|
|
29
|
+
let heartbeatTimer = null;
|
|
30
|
+
let exitHook = null;
|
|
31
|
+
function newOrchestratorId() {
|
|
32
|
+
return `${hostname()}-${process.pid}-orchestrator-0`;
|
|
33
|
+
}
|
|
34
|
+
function registerSelf(id) {
|
|
35
|
+
const db = getDb();
|
|
36
|
+
const now = Math.floor(Date.now() / 1000);
|
|
37
|
+
db.insert(workers)
|
|
38
|
+
.values({
|
|
39
|
+
id,
|
|
40
|
+
kind: 'orchestrator',
|
|
41
|
+
status: 'idle',
|
|
42
|
+
currentJob: null,
|
|
43
|
+
lastSeen: now,
|
|
44
|
+
startedAt: now,
|
|
45
|
+
})
|
|
46
|
+
.onConflictDoUpdate({
|
|
47
|
+
target: workers.id,
|
|
48
|
+
set: { status: 'idle', currentJob: null, lastSeen: now, startedAt: now },
|
|
49
|
+
})
|
|
50
|
+
.run();
|
|
51
|
+
}
|
|
52
|
+
function heartbeat(id) {
|
|
53
|
+
const db = getDb();
|
|
54
|
+
db.update(workers)
|
|
55
|
+
.set({ lastSeen: Math.floor(Date.now() / 1000) })
|
|
56
|
+
.where(eq(workers.id, id))
|
|
57
|
+
.run();
|
|
58
|
+
}
|
|
59
|
+
// Mark workers as dead when their last_seen has aged past the
|
|
60
|
+
// threshold. Used as a liveness signal in observability queries and
|
|
61
|
+
// (eventually) to reclaim their claimed jobs across all queues.
|
|
62
|
+
function markDeadWorkers() {
|
|
63
|
+
const db = getDb();
|
|
64
|
+
const cutoff = Math.floor(Date.now() / 1000) - WORKER_DEAD_AFTER_SECONDS;
|
|
65
|
+
const result = db
|
|
66
|
+
.update(workers)
|
|
67
|
+
.set({ status: 'dead' })
|
|
68
|
+
.where(and(lt(workers.lastSeen, cutoff), ne(workers.status, 'dead')))
|
|
69
|
+
.returning({ id: workers.id, kind: workers.kind })
|
|
70
|
+
.all();
|
|
71
|
+
for (const w of result) {
|
|
72
|
+
logger.warn({ id: w.id, kind: w.kind }, 'worker marked dead (no heartbeat)');
|
|
73
|
+
}
|
|
74
|
+
return result.length;
|
|
75
|
+
}
|
|
76
|
+
function busyWorkerCount() {
|
|
77
|
+
const db = getDb();
|
|
78
|
+
const row = db
|
|
79
|
+
.select({ id: workers.id })
|
|
80
|
+
.from(workers)
|
|
81
|
+
.where(eq(workers.status, 'busy'))
|
|
82
|
+
.all();
|
|
83
|
+
return row.length;
|
|
84
|
+
}
|
|
85
|
+
async function tick(id) {
|
|
86
|
+
try {
|
|
87
|
+
const ctl = readControl('shutdown');
|
|
88
|
+
if (ctl && !draining) {
|
|
89
|
+
logger.info({ requestedBy: ctl.requestedBy }, 'shutdown requested via control table');
|
|
90
|
+
draining = true;
|
|
91
|
+
// Mark ourselves draining so observability shows it.
|
|
92
|
+
const db = getDb();
|
|
93
|
+
db.update(workers)
|
|
94
|
+
.set({ status: 'draining' })
|
|
95
|
+
.where(eq(workers.id, id))
|
|
96
|
+
.run();
|
|
97
|
+
}
|
|
98
|
+
// Cross-queue housekeeping. More queues land in later phases.
|
|
99
|
+
const reclaimed = reclaimStuckOutbound();
|
|
100
|
+
if (reclaimed > 0) {
|
|
101
|
+
logger.info({ reclaimed }, 'reclaimed stuck outbound rows');
|
|
102
|
+
}
|
|
103
|
+
markDeadWorkers();
|
|
104
|
+
if (draining) {
|
|
105
|
+
const busy = busyWorkerCount();
|
|
106
|
+
if (busy === 0) {
|
|
107
|
+
logger.info('all workers idle, exiting cleanly');
|
|
108
|
+
clearControl('shutdown');
|
|
109
|
+
if (exitHook) {
|
|
110
|
+
await exitHook();
|
|
111
|
+
}
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logger.error({ err }, 'orchestrator tick error');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function startOrchestrator(opts = {}) {
|
|
121
|
+
if (workerId) {
|
|
122
|
+
logger.warn('orchestrator already started; ignoring');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
workerId = newOrchestratorId();
|
|
126
|
+
exitHook = opts.onShutdownDrained ?? null;
|
|
127
|
+
registerSelf(workerId);
|
|
128
|
+
heartbeatTimer = setInterval(() => workerId && heartbeat(workerId), HEARTBEAT_INTERVAL_MS);
|
|
129
|
+
const id = workerId;
|
|
130
|
+
tickTimer = setInterval(() => {
|
|
131
|
+
void tick(id);
|
|
132
|
+
}, TICK_INTERVAL_MS);
|
|
133
|
+
logger.info({ workerId }, 'orchestrator started');
|
|
134
|
+
}
|
|
135
|
+
export function stopOrchestrator() {
|
|
136
|
+
stopping = true;
|
|
137
|
+
if (tickTimer)
|
|
138
|
+
clearInterval(tickTimer);
|
|
139
|
+
if (heartbeatTimer)
|
|
140
|
+
clearInterval(heartbeatTimer);
|
|
141
|
+
}
|
|
142
|
+
// Public entry point for "begin graceful shutdown." Inserts the
|
|
143
|
+
// control row + sets a force-exit timer so we don't hang forever if
|
|
144
|
+
// some worker refuses to drain.
|
|
145
|
+
export function requestShutdown(by) {
|
|
146
|
+
if (draining)
|
|
147
|
+
return;
|
|
148
|
+
requestControl('shutdown', 'requested', by);
|
|
149
|
+
setTimeout(() => {
|
|
150
|
+
if (!stopping) {
|
|
151
|
+
logger.warn({ graceMs: SHUTDOWN_GRACE_MS }, 'graceful shutdown timed out, forcing exit');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}, SHUTDOWN_GRACE_MS).unref();
|
|
155
|
+
}
|
|
156
|
+
export function isDraining() {
|
|
157
|
+
return draining;
|
|
158
|
+
}
|
package/dist/queue/worker.js
CHANGED
|
@@ -7,6 +7,7 @@ import { extractFlags } from '../memory/digest-flag.js';
|
|
|
7
7
|
import { appendEntry, createJournal, getJournal, isValidSlug, } from '../memory/journals.js';
|
|
8
8
|
import { scheduleDigest } from '../memory/scheduler.js';
|
|
9
9
|
import { enqueueAsyncTask, enqueueBrowserTask } from './async-tasks.js';
|
|
10
|
+
import { enqueueOutbound } from './outbound.js';
|
|
10
11
|
function isStaleSessionError(err) {
|
|
11
12
|
return (err instanceof Error &&
|
|
12
13
|
err.message.includes('No conversation found'));
|
|
@@ -39,7 +40,7 @@ async function callClaude(job) {
|
|
|
39
40
|
if (job.senderNumber) {
|
|
40
41
|
addDailyTokens(job.senderNumber, usage.inputTokens + usage.outputTokens);
|
|
41
42
|
}
|
|
42
|
-
const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, } = extractFlags(reply);
|
|
43
|
+
const { clean, digest, journals, journalCreates, asyncTasks, asyncBrowserTasks, sendTexts, } = extractFlags(reply);
|
|
43
44
|
if (digest) {
|
|
44
45
|
logger.info({ jid: job.jid, number: job.senderNumber, reason: digest }, 'DIGEST flag raised, scheduling');
|
|
45
46
|
scheduleDigest({
|
|
@@ -105,6 +106,19 @@ async function callClaude(job) {
|
|
|
105
106
|
allowedTools: job.allowedTools ?? 'all',
|
|
106
107
|
});
|
|
107
108
|
}
|
|
109
|
+
// SEND-TEXT: cross-chat text send. Agent specified the destination
|
|
110
|
+
// address explicitly. Just drops a row in outbound; sender worker
|
|
111
|
+
// dispatches by channel.
|
|
112
|
+
for (let i = 0; i < sendTexts.length; i++) {
|
|
113
|
+
const t = sendTexts[i];
|
|
114
|
+
enqueueOutbound({
|
|
115
|
+
address: t.address,
|
|
116
|
+
kind: 'text',
|
|
117
|
+
text: t.body,
|
|
118
|
+
idempotencyKey: `sendtext-${job.jid}-${Date.now()}-${i}`,
|
|
119
|
+
});
|
|
120
|
+
logger.info({ from: job.jid, to: t.address, chars: t.body.length }, 'SEND-TEXT enqueued');
|
|
121
|
+
}
|
|
108
122
|
return {
|
|
109
123
|
reply: clean,
|
|
110
124
|
stats: {
|