@dotsetlabs/dotclaw 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/config-examples/runtime.json +1 -0
- package/config-examples/tool-policy.json +11 -0
- package/container/Dockerfile +4 -0
- package/container/agent-runner/package.json +1 -1
- package/container/agent-runner/src/agent-config.ts +11 -2
- package/container/agent-runner/src/container-protocol.ts +10 -0
- package/container/agent-runner/src/daemon.ts +237 -32
- package/container/agent-runner/src/heartbeat-worker.ts +94 -0
- package/container/agent-runner/src/index.ts +181 -10
- package/container/agent-runner/src/ipc.ts +165 -4
- package/container/agent-runner/src/memory.ts +8 -2
- package/container/agent-runner/src/request-worker.ts +25 -0
- package/container/agent-runner/src/tools.ts +417 -27
- package/dist/agent-context.d.ts +1 -0
- package/dist/agent-context.d.ts.map +1 -1
- package/dist/agent-context.js +15 -7
- package/dist/agent-context.js.map +1 -1
- package/dist/agent-execution.d.ts +11 -0
- package/dist/agent-execution.d.ts.map +1 -1
- package/dist/agent-execution.js +4 -2
- package/dist/agent-execution.js.map +1 -1
- package/dist/background-jobs.d.ts +9 -1
- package/dist/background-jobs.d.ts.map +1 -1
- package/dist/background-jobs.js +54 -14
- package/dist/background-jobs.js.map +1 -1
- package/dist/cli.js +19 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/container-protocol.d.ts +10 -0
- package/dist/container-protocol.d.ts.map +1 -1
- package/dist/container-runner.d.ts +23 -8
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +213 -55
- package/dist/container-runner.js.map +1 -1
- package/dist/db.d.ts +15 -5
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +156 -20
- package/dist/db.js.map +1 -1
- package/dist/index.js +1098 -165
- package/dist/index.js.map +1 -1
- package/dist/maintenance.d.ts +1 -0
- package/dist/maintenance.d.ts.map +1 -1
- package/dist/maintenance.js +188 -19
- package/dist/maintenance.js.map +1 -1
- package/dist/memory-embeddings.d.ts.map +1 -1
- package/dist/memory-embeddings.js +29 -4
- package/dist/memory-embeddings.js.map +1 -1
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +6 -1
- package/dist/memory-recall.js.map +1 -1
- package/dist/memory-store.d.ts +1 -0
- package/dist/memory-store.d.ts.map +1 -1
- package/dist/memory-store.js +70 -9
- package/dist/memory-store.js.map +1 -1
- package/dist/metrics.js +1 -1
- package/dist/metrics.js.map +1 -1
- package/dist/path-mapping.d.ts +4 -0
- package/dist/path-mapping.d.ts.map +1 -0
- package/dist/path-mapping.js +50 -0
- package/dist/path-mapping.js.map +1 -0
- package/dist/personalization.d.ts +1 -0
- package/dist/personalization.d.ts.map +1 -1
- package/dist/personalization.js +14 -0
- package/dist/personalization.js.map +1 -1
- package/dist/progress.d.ts +2 -2
- package/dist/progress.d.ts.map +1 -1
- package/dist/progress.js +18 -14
- package/dist/progress.js.map +1 -1
- package/dist/runtime-config.d.ts +14 -0
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +17 -3
- package/dist/runtime-config.js.map +1 -1
- package/dist/task-scheduler.d.ts +6 -1
- package/dist/task-scheduler.d.ts.map +1 -1
- package/dist/task-scheduler.js +172 -47
- package/dist/task-scheduler.js.map +1 -1
- package/dist/timezone.d.ts +4 -0
- package/dist/timezone.d.ts.map +1 -0
- package/dist/timezone.js +111 -0
- package/dist/timezone.js.map +1 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Personal OpenRouter-based assistant for Telegram. Each request runs inside an is
|
|
|
6
6
|
|
|
7
7
|
- Telegram bot interface with per-group isolation
|
|
8
8
|
- Containerized agent runtime with strict mounts
|
|
9
|
+
- Rich Telegram I/O tools (file/photo/voice/audio/location/contact/poll/buttons/edit/delete)
|
|
10
|
+
- Incoming media ingestion to workspace (`/workspace/group/inbox`) for agent processing
|
|
9
11
|
- Long-term memory with embeddings and semantic search
|
|
10
12
|
- Scheduled tasks (cron and one-off)
|
|
11
13
|
- Background jobs for long-running work
|
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
"Bash",
|
|
14
14
|
"Python",
|
|
15
15
|
"mcp__dotclaw__send_message",
|
|
16
|
+
"mcp__dotclaw__send_file",
|
|
17
|
+
"mcp__dotclaw__send_photo",
|
|
18
|
+
"mcp__dotclaw__send_voice",
|
|
19
|
+
"mcp__dotclaw__send_audio",
|
|
20
|
+
"mcp__dotclaw__send_location",
|
|
21
|
+
"mcp__dotclaw__send_contact",
|
|
22
|
+
"mcp__dotclaw__send_poll",
|
|
23
|
+
"mcp__dotclaw__send_buttons",
|
|
24
|
+
"mcp__dotclaw__edit_message",
|
|
25
|
+
"mcp__dotclaw__delete_message",
|
|
26
|
+
"mcp__dotclaw__download_url",
|
|
16
27
|
"mcp__dotclaw__schedule_task",
|
|
17
28
|
"mcp__dotclaw__run_task",
|
|
18
29
|
"mcp__dotclaw__list_tasks",
|
package/container/Dockerfile
CHANGED
|
@@ -23,11 +23,15 @@ RUN apt-get update && apt-get install -y \
|
|
|
23
23
|
libxshmfence1 \
|
|
24
24
|
curl \
|
|
25
25
|
git \
|
|
26
|
+
sudo \
|
|
26
27
|
python3 \
|
|
27
28
|
python3-pip \
|
|
28
29
|
python3-venv \
|
|
29
30
|
&& rm -rf /var/lib/apt/lists/*
|
|
30
31
|
|
|
32
|
+
# Allow the node user to install system packages via sudo
|
|
33
|
+
RUN echo 'node ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/node && chmod 0440 /etc/sudoers.d/node
|
|
34
|
+
|
|
31
35
|
# Install common Python packages
|
|
32
36
|
RUN pip3 install --break-system-packages pandas numpy requests beautifulsoup4 matplotlib
|
|
33
37
|
|
|
@@ -3,6 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
export type AgentRuntimeConfig = {
|
|
4
4
|
defaultModel: string;
|
|
5
5
|
daemonPollMs: number;
|
|
6
|
+
daemonHeartbeatIntervalMs: number;
|
|
6
7
|
agent: {
|
|
7
8
|
assistantName: string;
|
|
8
9
|
openrouter: {
|
|
@@ -118,6 +119,7 @@ export type AgentRuntimeConfig = {
|
|
|
118
119
|
const CONFIG_PATH = '/workspace/config/runtime.json';
|
|
119
120
|
const DEFAULT_DEFAULT_MODEL = 'moonshotai/kimi-k2.5';
|
|
120
121
|
const DEFAULT_DAEMON_POLL_MS = 200;
|
|
122
|
+
const DEFAULT_DAEMON_HEARTBEAT_INTERVAL_MS = 1_000;
|
|
121
123
|
|
|
122
124
|
const DEFAULT_AGENT_CONFIG: AgentRuntimeConfig['agent'] = {
|
|
123
125
|
assistantName: 'Rain',
|
|
@@ -279,6 +281,7 @@ export function loadAgentConfig(): AgentRuntimeConfig {
|
|
|
279
281
|
|
|
280
282
|
let defaultModel = DEFAULT_DEFAULT_MODEL;
|
|
281
283
|
let daemonPollMs = DEFAULT_DAEMON_POLL_MS;
|
|
284
|
+
let daemonHeartbeatIntervalMs = DEFAULT_DAEMON_HEARTBEAT_INTERVAL_MS;
|
|
282
285
|
let agentOverrides: unknown = null;
|
|
283
286
|
|
|
284
287
|
if (isPlainObject(raw)) {
|
|
@@ -288,8 +291,13 @@ export function loadAgentConfig(): AgentRuntimeConfig {
|
|
|
288
291
|
defaultModel = host.defaultModel.trim();
|
|
289
292
|
}
|
|
290
293
|
const container = host.container;
|
|
291
|
-
if (isPlainObject(container)
|
|
292
|
-
|
|
294
|
+
if (isPlainObject(container)) {
|
|
295
|
+
if (typeof container.daemonPollMs === 'number') {
|
|
296
|
+
daemonPollMs = container.daemonPollMs;
|
|
297
|
+
}
|
|
298
|
+
if (typeof container.daemonHeartbeatIntervalMs === 'number') {
|
|
299
|
+
daemonHeartbeatIntervalMs = container.daemonHeartbeatIntervalMs;
|
|
300
|
+
}
|
|
293
301
|
}
|
|
294
302
|
}
|
|
295
303
|
if (isPlainObject(raw.agent)) {
|
|
@@ -300,6 +308,7 @@ export function loadAgentConfig(): AgentRuntimeConfig {
|
|
|
300
308
|
cachedConfig = {
|
|
301
309
|
defaultModel,
|
|
302
310
|
daemonPollMs,
|
|
311
|
+
daemonHeartbeatIntervalMs,
|
|
303
312
|
agent: mergeDefaults(DEFAULT_AGENT_CONFIG, agentOverrides)
|
|
304
313
|
};
|
|
305
314
|
return cachedConfig;
|
|
@@ -45,6 +45,16 @@ export interface ContainerInput {
|
|
|
45
45
|
disableResponseValidation?: boolean;
|
|
46
46
|
responseValidationMaxRetries?: number;
|
|
47
47
|
disableMemoryExtraction?: boolean;
|
|
48
|
+
attachments?: Array<{
|
|
49
|
+
type: 'photo' | 'document' | 'voice' | 'video' | 'audio';
|
|
50
|
+
path: string;
|
|
51
|
+
file_name?: string;
|
|
52
|
+
mime_type?: string;
|
|
53
|
+
file_size?: number;
|
|
54
|
+
duration?: number;
|
|
55
|
+
width?: number;
|
|
56
|
+
height?: number;
|
|
57
|
+
}>;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
export interface ContainerOutput {
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { Worker } from 'worker_threads';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
4
5
|
import { loadAgentConfig } from './agent-config.js';
|
|
5
|
-
import type { ContainerInput } from './container-protocol.js';
|
|
6
|
+
import type { ContainerInput, ContainerOutput } from './container-protocol.js';
|
|
6
7
|
|
|
7
8
|
const REQUESTS_DIR = '/workspace/ipc/agent_requests';
|
|
8
9
|
const RESPONSES_DIR = '/workspace/ipc/agent_responses';
|
|
9
10
|
const HEARTBEAT_FILE = '/workspace/ipc/heartbeat';
|
|
10
|
-
const
|
|
11
|
+
const STATUS_FILE = '/workspace/ipc/daemon_status.json';
|
|
12
|
+
|
|
13
|
+
const config = loadAgentConfig();
|
|
14
|
+
const POLL_MS = config.daemonPollMs;
|
|
15
|
+
const HEARTBEAT_INTERVAL_MS = config.daemonHeartbeatIntervalMs;
|
|
16
|
+
|
|
17
|
+
let shuttingDown = false;
|
|
18
|
+
let heartbeatWorker: Worker | null = null;
|
|
11
19
|
|
|
12
20
|
function log(message: string): void {
|
|
13
21
|
console.error(`[agent-daemon] ${message}`);
|
|
@@ -18,66 +26,263 @@ function ensureDirs(): void {
|
|
|
18
26
|
fs.mkdirSync(RESPONSES_DIR, { recursive: true });
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
// --- Worker thread management ---
|
|
30
|
+
|
|
31
|
+
function getWorkerPath(): string {
|
|
32
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
33
|
+
return path.join(path.dirname(thisFile), 'heartbeat-worker.js');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getRequestWorkerPath(): string {
|
|
37
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
38
|
+
return path.join(path.dirname(thisFile), 'request-worker.js');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let workerRestarts = 0;
|
|
42
|
+
let workerRestartWindowStart = Date.now();
|
|
43
|
+
let workerRestarting = false;
|
|
44
|
+
|
|
45
|
+
function maybeRestartWorker(exitCode: number): void {
|
|
46
|
+
if (shuttingDown || exitCode === 0 || workerRestarting) return;
|
|
47
|
+
workerRestarting = true;
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
if (now - workerRestartWindowStart > 60_000) {
|
|
50
|
+
workerRestarts = 0;
|
|
51
|
+
workerRestartWindowStart = now;
|
|
52
|
+
}
|
|
53
|
+
workerRestarts++;
|
|
54
|
+
if (workerRestarts > 5) {
|
|
55
|
+
log('Heartbeat worker crash loop detected, stopping restarts');
|
|
56
|
+
workerRestarting = false;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const delay = Math.min(1000 * Math.pow(2, workerRestarts - 1), 10_000);
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
workerRestarting = false;
|
|
62
|
+
heartbeatWorker = spawnHeartbeatWorker();
|
|
63
|
+
}, delay);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function spawnHeartbeatWorker(): Worker {
|
|
67
|
+
const workerPath = getWorkerPath();
|
|
68
|
+
const worker = new Worker(workerPath, {
|
|
69
|
+
workerData: {
|
|
70
|
+
heartbeatPath: HEARTBEAT_FILE,
|
|
71
|
+
statusPath: STATUS_FILE,
|
|
72
|
+
intervalMs: HEARTBEAT_INTERVAL_MS,
|
|
73
|
+
pid: process.pid,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
worker.on('error', (err) => {
|
|
78
|
+
log(`Heartbeat worker error: ${err.message}`);
|
|
79
|
+
maybeRestartWorker(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
worker.on('exit', (code) => {
|
|
83
|
+
if (code !== 0) {
|
|
84
|
+
log(`Heartbeat worker exited with code ${code}`);
|
|
85
|
+
}
|
|
86
|
+
maybeRestartWorker(code ?? 1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return worker;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function postWorkerMessage(msg: { type: string; requestId?: string }): void {
|
|
22
93
|
try {
|
|
23
|
-
|
|
94
|
+
heartbeatWorker?.postMessage(msg);
|
|
24
95
|
} catch {
|
|
25
|
-
//
|
|
96
|
+
// Worker may have died — will be restarted by exit handler
|
|
26
97
|
}
|
|
27
98
|
}
|
|
28
99
|
|
|
100
|
+
let currentRequestId: string | null = null;
|
|
101
|
+
|
|
102
|
+
async function runRequestWithCancellation(requestId: string, input: ContainerInput): Promise<{ output: ContainerOutput | null; canceled: boolean }> {
|
|
103
|
+
const cancelFile = path.join(REQUESTS_DIR, requestId + '.cancel');
|
|
104
|
+
const workerPath = getRequestWorkerPath();
|
|
105
|
+
const worker = new Worker(workerPath, { workerData: { input } });
|
|
106
|
+
|
|
107
|
+
return await new Promise((resolve, reject) => {
|
|
108
|
+
let settled = false;
|
|
109
|
+
|
|
110
|
+
const finish = (fn: () => void): void => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
settled = true;
|
|
113
|
+
clearInterval(cancelTimer);
|
|
114
|
+
fn();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handleCancel = (): void => {
|
|
118
|
+
if (!fs.existsSync(cancelFile)) return;
|
|
119
|
+
finish(() => {
|
|
120
|
+
try { fs.unlinkSync(cancelFile); } catch { /* already removed */ }
|
|
121
|
+
void worker.terminate().catch(() => undefined);
|
|
122
|
+
resolve({ output: null, canceled: true });
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cancelTimer = setInterval(handleCancel, Math.max(100, Math.floor(POLL_MS / 2)));
|
|
127
|
+
handleCancel();
|
|
128
|
+
|
|
129
|
+
worker.on('message', (message: unknown) => {
|
|
130
|
+
finish(() => {
|
|
131
|
+
const payload = (message && typeof message === 'object') ? (message as Record<string, unknown>) : null;
|
|
132
|
+
if (payload?.ok === true && payload.output && typeof payload.output === 'object') {
|
|
133
|
+
resolve({ output: payload.output as ContainerOutput, canceled: false });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const errMessage = typeof payload?.error === 'string' ? payload.error : 'Agent worker failed';
|
|
137
|
+
reject(new Error(errMessage));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
worker.on('error', (err) => {
|
|
142
|
+
finish(() => reject(err));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
worker.on('exit', (code) => {
|
|
146
|
+
finish(() => {
|
|
147
|
+
if (code === 0) {
|
|
148
|
+
reject(new Error('Agent worker exited without output'));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
reject(new Error(`Agent worker exited with code ${code}`));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Request processing ---
|
|
158
|
+
|
|
159
|
+
function isContainerInput(value: unknown): value is ContainerInput {
|
|
160
|
+
if (!value || typeof value !== 'object') return false;
|
|
161
|
+
const record = value as Record<string, unknown>;
|
|
162
|
+
return typeof record.prompt === 'string'
|
|
163
|
+
&& typeof record.groupFolder === 'string'
|
|
164
|
+
&& typeof record.chatJid === 'string'
|
|
165
|
+
&& typeof record.isMain === 'boolean';
|
|
166
|
+
}
|
|
167
|
+
|
|
29
168
|
async function processRequests(): Promise<void> {
|
|
30
|
-
const files = fs.readdirSync(REQUESTS_DIR).filter(file => file.endsWith('.json'));
|
|
169
|
+
const files = fs.readdirSync(REQUESTS_DIR).filter(file => file.endsWith('.json')).sort();
|
|
31
170
|
for (const file of files) {
|
|
171
|
+
if (shuttingDown) break;
|
|
172
|
+
|
|
32
173
|
const filePath = path.join(REQUESTS_DIR, file);
|
|
33
174
|
let requestId = file.replace('.json', '');
|
|
175
|
+
const cancelFile = path.join(REQUESTS_DIR, requestId + '.cancel');
|
|
176
|
+
if (fs.existsSync(cancelFile)) {
|
|
177
|
+
try { fs.unlinkSync(filePath); } catch { /* already removed */ }
|
|
178
|
+
try { fs.unlinkSync(cancelFile); } catch { /* already removed */ }
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
34
181
|
try {
|
|
35
|
-
|
|
182
|
+
let raw: string;
|
|
183
|
+
try {
|
|
184
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
185
|
+
} catch (readErr) {
|
|
186
|
+
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
187
|
+
throw readErr;
|
|
188
|
+
}
|
|
36
189
|
const payload = JSON.parse(raw) as { id?: string; input?: unknown };
|
|
37
190
|
requestId = payload.id || requestId;
|
|
38
191
|
const input = payload.input || payload;
|
|
39
192
|
if (!isContainerInput(input)) {
|
|
40
193
|
throw new Error('Invalid agent request payload');
|
|
41
194
|
}
|
|
42
|
-
|
|
195
|
+
|
|
196
|
+
currentRequestId = requestId;
|
|
197
|
+
postWorkerMessage({ type: 'processing', requestId });
|
|
198
|
+
|
|
199
|
+
const { output, canceled } = await runRequestWithCancellation(requestId, input);
|
|
200
|
+
if (canceled) {
|
|
201
|
+
try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!output) {
|
|
205
|
+
throw new Error('Agent worker returned no output');
|
|
206
|
+
}
|
|
207
|
+
if (fs.existsSync(cancelFile)) {
|
|
208
|
+
try { fs.unlinkSync(cancelFile); } catch { /* already removed */ }
|
|
209
|
+
try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
43
212
|
const responsePath = path.join(RESPONSES_DIR, `${requestId}.json`);
|
|
44
|
-
|
|
45
|
-
fs.
|
|
213
|
+
const tmpPath = responsePath + '.tmp';
|
|
214
|
+
fs.writeFileSync(tmpPath, JSON.stringify(output));
|
|
215
|
+
fs.renameSync(tmpPath, responsePath);
|
|
216
|
+
try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
|
|
46
217
|
} catch (err) {
|
|
47
218
|
log(`Failed processing request ${requestId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48
219
|
const responsePath = path.join(RESPONSES_DIR, `${requestId}.json`);
|
|
49
|
-
|
|
220
|
+
const tmpPath = responsePath + '.tmp';
|
|
221
|
+
fs.writeFileSync(tmpPath, JSON.stringify({
|
|
50
222
|
status: 'error',
|
|
51
223
|
result: null,
|
|
52
224
|
error: err instanceof Error ? err.message : String(err)
|
|
53
225
|
}));
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
226
|
+
fs.renameSync(tmpPath, responsePath);
|
|
227
|
+
try { fs.unlinkSync(filePath); } catch { /* request file already removed */ }
|
|
228
|
+
} finally {
|
|
229
|
+
currentRequestId = null;
|
|
230
|
+
postWorkerMessage({ type: 'idle' });
|
|
59
231
|
}
|
|
60
232
|
}
|
|
61
233
|
}
|
|
62
234
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
235
|
+
// --- Graceful shutdown ---
|
|
236
|
+
|
|
237
|
+
function shutdown(signal: string): void {
|
|
238
|
+
if (shuttingDown) return;
|
|
239
|
+
shuttingDown = true;
|
|
240
|
+
log(`Received ${signal}, shutting down gracefully...`);
|
|
241
|
+
postWorkerMessage({ type: 'shutdown' });
|
|
242
|
+
const deadline = Date.now() + 30_000;
|
|
243
|
+
const check = () => {
|
|
244
|
+
if (!currentRequestId || Date.now() > deadline) {
|
|
245
|
+
if (currentRequestId) {
|
|
246
|
+
// Write aborted response for in-flight request
|
|
247
|
+
const responsePath = path.join(RESPONSES_DIR, `${currentRequestId}.json`);
|
|
248
|
+
try {
|
|
249
|
+
const tmpPath = responsePath + '.tmp';
|
|
250
|
+
fs.writeFileSync(tmpPath, JSON.stringify({ status: 'error', result: null, error: 'Daemon shutting down' }));
|
|
251
|
+
fs.renameSync(tmpPath, responsePath);
|
|
252
|
+
} catch { /* best-effort abort response */ }
|
|
253
|
+
}
|
|
254
|
+
log('Daemon stopped.');
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
setTimeout(check, 500);
|
|
258
|
+
};
|
|
259
|
+
check();
|
|
70
260
|
}
|
|
71
261
|
|
|
262
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
263
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
264
|
+
|
|
265
|
+
// --- Error handlers (keep daemon alive through individual failures) ---
|
|
266
|
+
|
|
267
|
+
process.on('unhandledRejection', (reason) => {
|
|
268
|
+
log(`Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
269
|
+
// Don't exit — daemon should survive individual request failures
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
process.on('uncaughtException', (err) => {
|
|
273
|
+
log(`Uncaught exception (fatal): ${err.message}`);
|
|
274
|
+
try { postWorkerMessage({ type: 'shutdown' }); } catch { /* worker may be dead */ }
|
|
275
|
+
process.exit(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// --- Main loop ---
|
|
279
|
+
|
|
72
280
|
async function loop(): Promise<void> {
|
|
73
281
|
ensureDirs();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const heartbeatTimer = setInterval(writeHeartbeat, heartbeatIntervalMs);
|
|
77
|
-
while (true) {
|
|
78
|
-
// Write heartbeat at the start of each loop iteration
|
|
79
|
-
writeHeartbeat();
|
|
282
|
+
heartbeatWorker = spawnHeartbeatWorker();
|
|
283
|
+
log('Daemon started (worker thread heartbeat active)');
|
|
80
284
|
|
|
285
|
+
while (!shuttingDown) {
|
|
81
286
|
try {
|
|
82
287
|
await processRequests();
|
|
83
288
|
} catch (err) {
|
|
@@ -85,8 +290,8 @@ async function loop(): Promise<void> {
|
|
|
85
290
|
}
|
|
86
291
|
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
87
292
|
}
|
|
88
|
-
|
|
89
|
-
|
|
293
|
+
|
|
294
|
+
log('Daemon loop exited.');
|
|
90
295
|
}
|
|
91
296
|
|
|
92
297
|
loop().catch(err => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat Worker Thread
|
|
3
|
+
*
|
|
4
|
+
* Runs in a separate V8 isolate so heartbeat writes are never blocked
|
|
5
|
+
* by long-running agent tasks on the main thread. Writes two files:
|
|
6
|
+
*
|
|
7
|
+
* - /workspace/ipc/heartbeat — epoch ms (backward compatible)
|
|
8
|
+
* - /workspace/ipc/daemon_status.json — structured status with state info
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parentPort, workerData } from 'worker_threads';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
|
|
14
|
+
interface WorkerConfig {
|
|
15
|
+
heartbeatPath: string;
|
|
16
|
+
statusPath: string;
|
|
17
|
+
intervalMs: number;
|
|
18
|
+
pid: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type WorkerMessage =
|
|
22
|
+
| { type: 'processing'; requestId: string }
|
|
23
|
+
| { type: 'idle' }
|
|
24
|
+
| { type: 'shutdown' };
|
|
25
|
+
|
|
26
|
+
interface DaemonStatus {
|
|
27
|
+
state: 'idle' | 'processing';
|
|
28
|
+
ts: number;
|
|
29
|
+
request_id: string | null;
|
|
30
|
+
started_at: number | null;
|
|
31
|
+
pid: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = workerData as WorkerConfig;
|
|
35
|
+
let state: 'idle' | 'processing' = 'idle';
|
|
36
|
+
let requestId: string | null = null;
|
|
37
|
+
let startedAt: number | null = null;
|
|
38
|
+
function writeHeartbeat(): void {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
try {
|
|
41
|
+
const tmpHeartbeat = config.heartbeatPath + '.tmp';
|
|
42
|
+
fs.writeFileSync(tmpHeartbeat, now.toString());
|
|
43
|
+
fs.renameSync(tmpHeartbeat, config.heartbeatPath);
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore heartbeat write errors
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const status: DaemonStatus = {
|
|
49
|
+
state,
|
|
50
|
+
ts: now,
|
|
51
|
+
request_id: requestId,
|
|
52
|
+
started_at: startedAt,
|
|
53
|
+
pid: config.pid,
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
const tmpStatus = config.statusPath + '.tmp';
|
|
57
|
+
fs.writeFileSync(tmpStatus, JSON.stringify(status));
|
|
58
|
+
fs.renameSync(tmpStatus, config.statusPath);
|
|
59
|
+
} catch {
|
|
60
|
+
// Ignore status write errors
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Write immediately on start
|
|
65
|
+
writeHeartbeat();
|
|
66
|
+
|
|
67
|
+
const timer = setInterval(writeHeartbeat, config.intervalMs);
|
|
68
|
+
|
|
69
|
+
parentPort?.on('message', (msg: WorkerMessage) => {
|
|
70
|
+
switch (msg.type) {
|
|
71
|
+
case 'processing':
|
|
72
|
+
state = 'processing';
|
|
73
|
+
requestId = msg.requestId;
|
|
74
|
+
startedAt = Date.now();
|
|
75
|
+
writeHeartbeat(); // Immediate update
|
|
76
|
+
break;
|
|
77
|
+
case 'idle':
|
|
78
|
+
state = 'idle';
|
|
79
|
+
requestId = null;
|
|
80
|
+
startedAt = null;
|
|
81
|
+
writeHeartbeat(); // Immediate update
|
|
82
|
+
break;
|
|
83
|
+
case 'shutdown':
|
|
84
|
+
clearInterval(timer);
|
|
85
|
+
state = 'idle';
|
|
86
|
+
requestId = null;
|
|
87
|
+
startedAt = null;
|
|
88
|
+
writeHeartbeat(); // Final heartbeat
|
|
89
|
+
process.exit(0);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Worker stays alive via the setInterval timer
|