agent-working-memory 0.17.0 → 0.18.1
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 +321 -319
- package/dist/adapters/common.d.ts +34 -0
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +528 -446
- package/dist/adapters/common.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +19 -0
- package/dist/api/routes.js.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +31 -29
- package/dist/cli/migrate.js.map +1 -1
- package/dist/core/asked-value.d.ts +37 -0
- package/dist/core/asked-value.d.ts.map +1 -0
- package/dist/core/asked-value.js +77 -0
- package/dist/core/asked-value.js.map +1 -0
- package/dist/core/conflicts.d.ts +89 -0
- package/dist/core/conflicts.d.ts.map +1 -0
- package/dist/core/conflicts.js +267 -0
- package/dist/core/conflicts.js.map +1 -0
- package/dist/core/embeddings.d.ts +14 -2
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +32 -10
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/format-recall.d.ts +21 -1
- package/dist/core/format-recall.d.ts.map +1 -1
- package/dist/core/format-recall.js +60 -5
- package/dist/core/format-recall.js.map +1 -1
- package/dist/core/ml-worker.d.ts +24 -1
- package/dist/core/ml-worker.d.ts.map +1 -1
- package/dist/core/ml-worker.js +165 -4
- package/dist/core/ml-worker.js.map +1 -1
- package/dist/core/model-daemon-client.d.ts +77 -0
- package/dist/core/model-daemon-client.d.ts.map +1 -0
- package/dist/core/model-daemon-client.js +383 -0
- package/dist/core/model-daemon-client.js.map +1 -0
- package/dist/core/model-daemon-server.d.ts +71 -0
- package/dist/core/model-daemon-server.d.ts.map +1 -0
- package/dist/core/model-daemon-server.js +363 -0
- package/dist/core/model-daemon-server.js.map +1 -0
- package/dist/core/model-daemon.d.ts +137 -0
- package/dist/core/model-daemon.d.ts.map +1 -0
- package/dist/core/model-daemon.js +231 -0
- package/dist/core/model-daemon.js.map +1 -0
- package/dist/core/query-expander.d.ts +5 -0
- package/dist/core/query-expander.d.ts.map +1 -1
- package/dist/core/query-expander.js +3 -2
- package/dist/core/query-expander.js.map +1 -1
- package/dist/core/recall-config.d.ts +1 -1
- package/dist/core/recall-config.d.ts.map +1 -1
- package/dist/core/recall-config.js +5 -0
- package/dist/core/recall-config.js.map +1 -1
- package/dist/core/rerank-window.d.ts +7 -2
- package/dist/core/rerank-window.d.ts.map +1 -1
- package/dist/core/rerank-window.js +9 -4
- package/dist/core/rerank-window.js.map +1 -1
- package/dist/core/rerank2.d.ts +52 -2
- package/dist/core/rerank2.d.ts.map +1 -1
- package/dist/core/rerank2.js +60 -3
- package/dist/core/rerank2.js.map +1 -1
- package/dist/core/reranker.d.ts +19 -0
- package/dist/core/reranker.d.ts.map +1 -1
- package/dist/core/reranker.js +76 -12
- package/dist/core/reranker.js.map +1 -1
- package/dist/core/trust.d.ts +32 -0
- package/dist/core/trust.d.ts.map +1 -0
- package/dist/core/trust.js +66 -0
- package/dist/core/trust.js.map +1 -0
- package/dist/core/validity.d.ts +21 -0
- package/dist/core/validity.d.ts.map +1 -0
- package/dist/core/validity.js +41 -0
- package/dist/core/validity.js.map +1 -0
- package/dist/core/whoami.d.ts +3 -0
- package/dist/core/whoami.d.ts.map +1 -1
- package/dist/core/whoami.js +14 -0
- package/dist/core/whoami.js.map +1 -1
- package/dist/core/write-pipeline.d.ts +48 -0
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +80 -3
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +27 -4
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +53 -4
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/hooks/prime-query.d.ts +57 -0
- package/dist/hooks/prime-query.d.ts.map +1 -0
- package/dist/hooks/prime-query.js +388 -0
- package/dist/hooks/prime-query.js.map +1 -0
- package/dist/hooks/prime.d.ts +8 -0
- package/dist/hooks/prime.d.ts.map +1 -1
- package/dist/hooks/prime.js +21 -2
- package/dist/hooks/prime.js.map +1 -1
- package/dist/hooks/sidecar.d.ts +7 -0
- package/dist/hooks/sidecar.d.ts.map +1 -1
- package/dist/hooks/sidecar.js +10 -2
- package/dist/hooks/sidecar.js.map +1 -1
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +148 -103
- package/dist/mcp.js.map +1 -1
- package/dist/model-daemon.d.ts +8 -0
- package/dist/model-daemon.d.ts.map +1 -0
- package/dist/model-daemon.js +45 -0
- package/dist/model-daemon.js.map +1 -0
- package/dist/storage/factory.d.ts.map +1 -1
- package/dist/storage/factory.js +5 -0
- package/dist/storage/factory.js.map +1 -1
- package/dist/storage/pglite-schema.d.ts.map +1 -1
- package/dist/storage/pglite-schema.js +176 -170
- package/dist/storage/pglite-schema.js.map +1 -1
- package/dist/storage/pglite.d.ts +5 -0
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +165 -153
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts +5 -0
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +165 -153
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts +22 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +400 -363
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +33 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/README.md +1 -1
- package/plugin/bin/awm-mcp-launcher.cjs +2 -2
- package/plugin/skills/awm-memory/SKILL.md +11 -2
- package/src/adapters/common.ts +881 -802
- package/src/api/routes.ts +20 -0
- package/src/cli/migrate.ts +309 -307
- package/src/core/asked-value.ts +74 -0
- package/src/core/conflicts.ts +280 -0
- package/src/core/embeddings.ts +35 -10
- package/src/core/format-recall.ts +83 -25
- package/src/core/ml-worker.ts +458 -281
- package/src/core/model-daemon-client.ts +374 -0
- package/src/core/model-daemon-server.ts +344 -0
- package/src/core/model-daemon.ts +243 -0
- package/src/core/query-expander.ts +127 -126
- package/src/core/recall-config.ts +5 -0
- package/src/core/rerank-window.ts +164 -159
- package/src/core/rerank2.ts +143 -82
- package/src/core/reranker.ts +78 -11
- package/src/core/trust.ts +65 -0
- package/src/core/validity.ts +47 -0
- package/src/core/whoami.ts +136 -120
- package/src/core/write-pipeline.ts +119 -3
- package/src/engine/activation.ts +27 -4
- package/src/engine/consolidation.ts +950 -899
- package/src/hooks/prime-query.ts +375 -0
- package/src/hooks/prime.ts +25 -2
- package/src/hooks/sidecar.ts +13 -3
- package/src/index.ts +255 -248
- package/src/mcp.ts +1521 -1475
- package/src/model-daemon.ts +48 -0
- package/src/storage/factory.ts +25 -19
- package/src/storage/pglite-schema.ts +199 -193
- package/src/storage/pglite.ts +1513 -1498
- package/src/storage/postgres.ts +1613 -1598
- package/src/storage/sqlite.ts +2056 -2019
- package/src/types/engram.ts +30 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Client side of the shared model daemon (see model-daemon.ts).
|
|
5
|
+
*
|
|
6
|
+
* Connecting: try the daemon's address. If nothing answers, take the lockfile (O_EXCL,
|
|
7
|
+
* holding this pid) and spawn the daemon detached, through a launcher so that it is not
|
|
8
|
+
* this process's child (see LAUNCHER); every other process starting at the
|
|
9
|
+
* same moment finds the lock taken and just keeps trying to connect. A lock whose pid is
|
|
10
|
+
* dead, or that is older than the start timeout, is stale and is taken over, so a
|
|
11
|
+
* spawner that crashed mid-start blocks nobody. A daemon this client spawned that exits
|
|
12
|
+
* with an error ends the wait at once instead of at the start timeout.
|
|
13
|
+
*
|
|
14
|
+
* Timeouts: a request waits in the daemon's queue for up to AWM_MODEL_DAEMON_QUEUE_MS
|
|
15
|
+
* (10 min) without failing, because a busy daemon is not a broken one; when it gives up
|
|
16
|
+
* it tells the daemon to drop the job. While the daemon reports a step `started` (and
|
|
17
|
+
* until it reports the job `requeued` between chunks), the step must finish within
|
|
18
|
+
* AWM_MODEL_DAEMON_TIMEOUT_MS (2 min); a daemon that does not is stuck, and the
|
|
19
|
+
* connection is dropped.
|
|
20
|
+
*
|
|
21
|
+
* The client never falls back to in-process inference itself; ml-worker.ts decides that.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { connect, type Socket } from 'node:net';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
import { closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, mkdirSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import {
|
|
31
|
+
PROTOCOL_VERSION, HELLO_MAX_BYTES, MAX_FRAME_BYTES, addressProblem, encodeFrame, envInt, FrameReader,
|
|
32
|
+
modelDaemonPaths, proof, proofMatches, runDirProblem, EXIT_FINGERPRINT_DRIFT, REQUEST_TIMEOUT_MS, WARM_TIMEOUT_MS,
|
|
33
|
+
type DaemonPaths, type Lane, type ModelOp, type ResponseFrame,
|
|
34
|
+
} from './model-daemon.js';
|
|
35
|
+
|
|
36
|
+
/** The daemon ran the request and it failed there. In-process would fail the same way. */
|
|
37
|
+
export class DaemonRemoteError extends Error {}
|
|
38
|
+
|
|
39
|
+
/** The daemon could not be reached or stopped answering. */
|
|
40
|
+
export class DaemonUnavailableError extends Error {
|
|
41
|
+
constructor(message: string, readonly reason: string) { super(message); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DaemonEntry { command: string; args: string[] }
|
|
45
|
+
|
|
46
|
+
export interface ClientOptions {
|
|
47
|
+
fingerprint: string;
|
|
48
|
+
runDir?: string;
|
|
49
|
+
/** How to start the daemon. Defaults to this package's model-daemon entry. */
|
|
50
|
+
entry?: DaemonEntry;
|
|
51
|
+
/** Extra env for a spawned daemon: the client's captured inference config. */
|
|
52
|
+
spawnEnv?: Record<string, string>;
|
|
53
|
+
startTimeoutMs?: number;
|
|
54
|
+
requestTimeoutMs?: number;
|
|
55
|
+
queueTimeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
59
|
+
const POLL_MS = 150;
|
|
60
|
+
const HELLO_TIMEOUT_MS = 5_000;
|
|
61
|
+
|
|
62
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
63
|
+
|
|
64
|
+
function pidAlive(pid: number): boolean {
|
|
65
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
66
|
+
try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === 'EPERM'; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Starts the daemon and exits once it listens, passing on its exit code if it stops
|
|
71
|
+
* before that. The daemon's parent is then a process that no longer exists, so a tree
|
|
72
|
+
* kill of the AWM process that started it does not reach it: when Claude Code closes a
|
|
73
|
+
* session whose MCP server does not exit on stdin close, it kills the server's whole
|
|
74
|
+
* process tree, and a daemon spawned directly died with it (measured with Claude Code
|
|
75
|
+
* 2.1.302 on Windows). Inline, so it needs no loader in dev.
|
|
76
|
+
*/
|
|
77
|
+
const LAUNCHER = [
|
|
78
|
+
"const c = require('node:child_process').spawn(process.argv[1], process.argv.slice(2),",
|
|
79
|
+
" { detached: true, windowsHide: true, stdio: ['ignore', 'inherit', 'inherit', 'ipc'] });",
|
|
80
|
+
"c.on('message', () => process.exit(0));",
|
|
81
|
+
"c.on('exit', code => process.exit(code ?? 1));",
|
|
82
|
+
"c.on('error', () => process.exit(1));",
|
|
83
|
+
].join('\n');
|
|
84
|
+
|
|
85
|
+
export function defaultDaemonEntry(): DaemonEntry {
|
|
86
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
87
|
+
const js = join(here, '..', 'model-daemon.js');
|
|
88
|
+
if (existsSync(js)) return { command: process.execPath, args: [js] };
|
|
89
|
+
// Running from src/ under tsx (dev, tests): the daemon needs the same loader.
|
|
90
|
+
const ts = join(here, '..', 'model-daemon.ts');
|
|
91
|
+
return { command: process.execPath, args: ['--import', import.meta.resolve('tsx'), ts] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Pending {
|
|
95
|
+
op: ModelOp;
|
|
96
|
+
resolve: (v: any) => void;
|
|
97
|
+
reject: (e: Error) => void;
|
|
98
|
+
timer: ReturnType<typeof setTimeout>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class ModelDaemonClient {
|
|
102
|
+
readonly fingerprint: string;
|
|
103
|
+
readonly paths: DaemonPaths;
|
|
104
|
+
private readonly entry: DaemonEntry;
|
|
105
|
+
private readonly spawnEnv: Record<string, string>;
|
|
106
|
+
private readonly startTimeoutMs: number;
|
|
107
|
+
private readonly requestTimeoutMs: number;
|
|
108
|
+
private readonly queueTimeoutMs: number;
|
|
109
|
+
private socket: Socket | null = null;
|
|
110
|
+
private connecting: Promise<Socket> | null = null;
|
|
111
|
+
private pending = new Map<number, Pending>();
|
|
112
|
+
private nextId = 1;
|
|
113
|
+
daemonPid: number | null = null;
|
|
114
|
+
spawned = 0;
|
|
115
|
+
|
|
116
|
+
constructor(opts: ClientOptions) {
|
|
117
|
+
this.fingerprint = opts.fingerprint;
|
|
118
|
+
this.paths = modelDaemonPaths(this.fingerprint, opts.runDir);
|
|
119
|
+
this.entry = opts.entry ?? defaultDaemonEntry();
|
|
120
|
+
this.spawnEnv = opts.spawnEnv ?? {};
|
|
121
|
+
this.startTimeoutMs = opts.startTimeoutMs ?? envInt('AWM_MODEL_DAEMON_START_MS', 20_000);
|
|
122
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? envInt('AWM_MODEL_DAEMON_TIMEOUT_MS', REQUEST_TIMEOUT_MS);
|
|
123
|
+
this.queueTimeoutMs = opts.queueTimeoutMs ?? envInt('AWM_MODEL_DAEMON_QUEUE_MS', 10 * 60_000);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get connected(): boolean { return !!this.socket && !this.socket.destroyed; }
|
|
127
|
+
|
|
128
|
+
async request<T>(op: ModelOp, args?: unknown, lane: Lane = 'interactive'): Promise<T> {
|
|
129
|
+
const socket = await this.ensureConnected();
|
|
130
|
+
const id = this.nextId++;
|
|
131
|
+
return new Promise<T>((resolve, reject) => {
|
|
132
|
+
const p: Pending = { op, resolve, reject, timer: this.queueTimer(id, op) };
|
|
133
|
+
this.pending.set(id, p);
|
|
134
|
+
socket.write(encodeFrame({ id, op, lane, args }));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
close(): void {
|
|
139
|
+
this.socket?.destroy();
|
|
140
|
+
this.socket = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Waiting in the queue: fail this request only, the daemon is busy, not gone. */
|
|
144
|
+
private queueTimer(id: number, op: ModelOp): ReturnType<typeof setTimeout> {
|
|
145
|
+
return setTimeout(() => {
|
|
146
|
+
const p = this.pending.get(id);
|
|
147
|
+
if (!p) return;
|
|
148
|
+
this.pending.delete(id);
|
|
149
|
+
p.reject(new DaemonUnavailableError(`model daemon: ${op} still queued after ${this.queueTimeoutMs} ms`, 'queue-timeout'));
|
|
150
|
+
if (this.socket && !this.socket.destroyed) this.socket.write(encodeFrame({ id: this.nextId++, op: 'cancel', args: { id } }));
|
|
151
|
+
}, this.queueTimeoutMs);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Running: no answer and no progress in time means the daemon is stuck. */
|
|
155
|
+
private runTimer(id: number, op: ModelOp): ReturnType<typeof setTimeout> {
|
|
156
|
+
const ms = op === 'warm' ? Math.max(this.requestTimeoutMs, WARM_TIMEOUT_MS) : this.requestTimeoutMs;
|
|
157
|
+
return setTimeout(() => {
|
|
158
|
+
const p = this.pending.get(id);
|
|
159
|
+
if (!p) return;
|
|
160
|
+
this.pending.delete(id);
|
|
161
|
+
p.reject(new DaemonUnavailableError(`model daemon: ${op} step ran over ${ms} ms`, 'timeout'));
|
|
162
|
+
this.socket?.destroy();
|
|
163
|
+
}, ms);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private ensureConnected(): Promise<Socket> {
|
|
167
|
+
if (this.socket && !this.socket.destroyed) return Promise.resolve(this.socket);
|
|
168
|
+
this.connecting ??= this.connectOrSpawn().finally(() => { this.connecting = null; });
|
|
169
|
+
return this.connecting;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async connectOrSpawn(): Promise<Socket> {
|
|
173
|
+
const problem = addressProblem(this.paths);
|
|
174
|
+
if (problem) throw new DaemonUnavailableError(problem, 'socket-path-too-long');
|
|
175
|
+
if (existsSync(this.paths.runDir)) {
|
|
176
|
+
const unsafe = runDirProblem(this.paths.runDir);
|
|
177
|
+
if (unsafe) throw new DaemonUnavailableError(unsafe, 'run-dir-unsafe');
|
|
178
|
+
}
|
|
179
|
+
const deadline = Date.now() + this.startTimeoutMs;
|
|
180
|
+
let holdsLock = false;
|
|
181
|
+
let exited: number | null = null;
|
|
182
|
+
let last = 'unreachable';
|
|
183
|
+
try {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const r = await this.tryConnect();
|
|
186
|
+
if (r.socket) return r.socket;
|
|
187
|
+
last = r.reason;
|
|
188
|
+
// A daemon of a different protocol or config, or one that cannot prove it holds
|
|
189
|
+
// the token, will not change by waiting.
|
|
190
|
+
if (r.fatal) throw new DaemonUnavailableError(`model daemon refused: ${r.reason}`, r.reason);
|
|
191
|
+
if (exited === EXIT_FINGERPRINT_DRIFT) {
|
|
192
|
+
// This process computed its fingerprint from code that has since changed on disk
|
|
193
|
+
// (a rebuild, an update): a daemon started now is a different daemon.
|
|
194
|
+
throw new DaemonUnavailableError('model daemon code changed on disk since this process started; restart the session to use the daemon', 'code-changed');
|
|
195
|
+
}
|
|
196
|
+
if (exited !== null && exited !== 0) {
|
|
197
|
+
throw new DaemonUnavailableError(`model daemon exited with code ${exited} during start; see ${this.paths.log}`, 'daemon-exited');
|
|
198
|
+
}
|
|
199
|
+
if (!holdsLock && !['bad token', 'starting'].includes(r.reason) && this.takeLock()) {
|
|
200
|
+
holdsLock = true;
|
|
201
|
+
// Exit code 0 is a daemon that lost the start race to another: keep polling.
|
|
202
|
+
this.spawnDaemon(code => { exited = code; });
|
|
203
|
+
}
|
|
204
|
+
if (Date.now() >= deadline) break;
|
|
205
|
+
await sleep(POLL_MS);
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
if (holdsLock) this.releaseLock();
|
|
209
|
+
}
|
|
210
|
+
throw new DaemonUnavailableError(`model daemon did not start within ${this.startTimeoutMs} ms (${last}); see ${this.paths.log}`, 'start-timeout');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private tryConnect(): Promise<{ socket?: Socket; reason: string; fatal?: boolean }> {
|
|
214
|
+
return new Promise(resolve => {
|
|
215
|
+
let settled = false;
|
|
216
|
+
const done = (v: { socket?: Socket; reason: string; fatal?: boolean }) => {
|
|
217
|
+
if (settled) return;
|
|
218
|
+
settled = true;
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
if (!v.socket) socket.destroy();
|
|
221
|
+
resolve(v);
|
|
222
|
+
};
|
|
223
|
+
const socket = connect(this.paths.address);
|
|
224
|
+
socket.setEncoding('utf8');
|
|
225
|
+
const timer = setTimeout(() => done({ reason: 'hello timeout' }), HELLO_TIMEOUT_MS);
|
|
226
|
+
socket.once('error', err => done({ reason: (err as NodeJS.ErrnoException).code ?? err.message }));
|
|
227
|
+
socket.once('close', () => done({ reason: 'closed during hello' }));
|
|
228
|
+
socket.once('connect', () => {
|
|
229
|
+
// The token is read when the challenge arrives, not now: a daemon that has
|
|
230
|
+
// answered has written its token, while one that only just started listening may
|
|
231
|
+
// not have replaced a dead daemon's file yet.
|
|
232
|
+
const readToken = () => { try { return readFileSync(this.paths.token, 'utf8').trim(); } catch { return ''; } };
|
|
233
|
+
let token = '';
|
|
234
|
+
const nonce = randomBytes(16).toString('hex');
|
|
235
|
+
let stage: 'challenge' | 'result' | 'ready' = 'challenge';
|
|
236
|
+
let daemonPid: number | null = null;
|
|
237
|
+
const reader = new FrameReader(frame => {
|
|
238
|
+
if (stage === 'ready') return this.onResponse(frame as ResponseFrame);
|
|
239
|
+
const f = frame as { t?: string; ok?: boolean; error?: string; nonce?: string; proof?: string; pid?: number };
|
|
240
|
+
if (f?.t === 'hello' && !f.ok) {
|
|
241
|
+
const reason = f.error ?? 'refused';
|
|
242
|
+
// 'starting': the daemon has not written its token yet. 'closing': it is
|
|
243
|
+
// exiting on its lease. 'bad token': our proof used a stale file. All three
|
|
244
|
+
// clear up by trying again.
|
|
245
|
+
return done({ reason, fatal: !['bad token', 'closing', 'starting'].includes(reason) });
|
|
246
|
+
}
|
|
247
|
+
if (stage === 'challenge') {
|
|
248
|
+
if (f?.t !== 'challenge') return done({ reason: 'unexpected handshake frame', fatal: true });
|
|
249
|
+
const verifies = (t: string) => !!t && proofMatches(proof(t, 'daemon', nonce, String(f.nonce), this.fingerprint), f.proof);
|
|
250
|
+
token = readToken();
|
|
251
|
+
if (!verifies(token)) {
|
|
252
|
+
token = readToken(); // a rename may have landed between the two reads
|
|
253
|
+
if (!verifies(token)) {
|
|
254
|
+
// Whatever answered on our address does not hold our token: send it nothing.
|
|
255
|
+
return done({ reason: 'daemon failed authentication', fatal: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
daemonPid = f.pid ?? null;
|
|
259
|
+
stage = 'result';
|
|
260
|
+
socket.write(encodeFrame({ t: 'auth', proof: proof(token, 'client', nonce, String(f.nonce), this.fingerprint) }));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (f?.t === 'hello' && f.ok) {
|
|
264
|
+
stage = 'ready';
|
|
265
|
+
reader.limit = MAX_FRAME_BYTES;
|
|
266
|
+
this.daemonPid = daemonPid;
|
|
267
|
+
this.attach(socket);
|
|
268
|
+
return done({ socket, reason: 'ok' });
|
|
269
|
+
}
|
|
270
|
+
return done({ reason: 'unexpected handshake frame', fatal: true });
|
|
271
|
+
}, HELLO_MAX_BYTES);
|
|
272
|
+
socket.on('data', (chunk: string) => { if (!reader.push(chunk)) socket.destroy(); });
|
|
273
|
+
socket.write(encodeFrame({ t: 'hello', protocol: PROTOCOL_VERSION, fingerprint: this.fingerprint, nonce }));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private attach(socket: Socket): void {
|
|
279
|
+
this.socket = socket;
|
|
280
|
+
socket.removeAllListeners('close');
|
|
281
|
+
socket.removeAllListeners('error');
|
|
282
|
+
socket.on('error', () => { /* close follows */ });
|
|
283
|
+
socket.on('close', () => {
|
|
284
|
+
if (this.socket === socket) this.socket = null;
|
|
285
|
+
for (const [, p] of this.pending) {
|
|
286
|
+
clearTimeout(p.timer);
|
|
287
|
+
p.reject(new DaemonUnavailableError('model daemon disconnected', 'disconnected'));
|
|
288
|
+
}
|
|
289
|
+
this.pending.clear();
|
|
290
|
+
});
|
|
291
|
+
// The connection must not keep an otherwise finished process alive.
|
|
292
|
+
socket.unref();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private onResponse(frame: ResponseFrame): void {
|
|
296
|
+
const p = this.pending.get(frame?.id);
|
|
297
|
+
if (!p) return;
|
|
298
|
+
if (frame.started) {
|
|
299
|
+
clearTimeout(p.timer);
|
|
300
|
+
p.timer = this.runTimer(frame.id, p.op);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (frame.requeued) {
|
|
304
|
+
clearTimeout(p.timer);
|
|
305
|
+
p.timer = this.queueTimer(frame.id, p.op);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.pending.delete(frame.id);
|
|
309
|
+
clearTimeout(p.timer);
|
|
310
|
+
if (frame.ok) p.resolve(frame.result);
|
|
311
|
+
else p.reject(new DaemonRemoteError(frame.error ?? 'model daemon error'));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private takeLock(): boolean {
|
|
315
|
+
mkdirSync(this.paths.runDir, { recursive: true, mode: 0o700 });
|
|
316
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
317
|
+
try {
|
|
318
|
+
const fd = openSync(this.paths.lock, 'wx', 0o600);
|
|
319
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
320
|
+
closeSync(fd);
|
|
321
|
+
return true;
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
|
324
|
+
let holder: { pid?: number; at?: number } = {};
|
|
325
|
+
try { holder = JSON.parse(readFileSync(this.paths.lock, 'utf8')); } catch { /* half-written: judge by age */ }
|
|
326
|
+
const age = Date.now() - (holder.at ?? statMtime(this.paths.lock));
|
|
327
|
+
if (pidAlive(holder.pid ?? -1) && age < this.startTimeoutMs) return false;
|
|
328
|
+
// Two processes can both judge it stale and both start a daemon; the second to
|
|
329
|
+
// listen gets EADDRINUSE and exits 0, so the race costs one process start.
|
|
330
|
+
try { unlinkSync(this.paths.lock); } catch { /* another process took it over first */ }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private releaseLock(): void {
|
|
337
|
+
try {
|
|
338
|
+
const holder = JSON.parse(readFileSync(this.paths.lock, 'utf8'));
|
|
339
|
+
if (holder.pid === process.pid) unlinkSync(this.paths.lock);
|
|
340
|
+
} catch { /* gone already */ }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private spawnDaemon(onExit: (code: number) => void): void {
|
|
344
|
+
let fd: number | 'ignore' = 'ignore';
|
|
345
|
+
try {
|
|
346
|
+
if (existsSync(this.paths.log) && statSync(this.paths.log).size > LOG_MAX_BYTES) {
|
|
347
|
+
renameSync(this.paths.log, `${this.paths.log}.1`);
|
|
348
|
+
}
|
|
349
|
+
fd = openSync(this.paths.log, 'a', 0o600);
|
|
350
|
+
} catch { /* no log, still start */ }
|
|
351
|
+
try {
|
|
352
|
+
// The exit code is the launcher's: 0 once the daemon listens (or lost the race),
|
|
353
|
+
// otherwise the daemon's own.
|
|
354
|
+
const child = spawn(process.execPath, ['-e', LAUNCHER, '--', this.entry.command, ...this.entry.args], {
|
|
355
|
+
// Not the session's project directory: on Windows a process's cwd cannot be
|
|
356
|
+
// deleted or renamed, and the daemon outlives the session that started it.
|
|
357
|
+
cwd: this.paths.runDir,
|
|
358
|
+
detached: true, windowsHide: true,
|
|
359
|
+
env: { ...process.env, ...this.spawnEnv, AWM_MODEL_DAEMON_FINGERPRINT: this.fingerprint },
|
|
360
|
+
stdio: ['ignore', fd, fd],
|
|
361
|
+
});
|
|
362
|
+
child.on('error', err => { console.error(`[awm] model daemon spawn failed: ${err.message}`); onExit(-1); });
|
|
363
|
+
child.on('exit', code => onExit(code ?? -1));
|
|
364
|
+
child.unref();
|
|
365
|
+
this.spawned++;
|
|
366
|
+
} finally {
|
|
367
|
+
if (typeof fd === 'number') closeSync(fd);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function statMtime(path: string): number {
|
|
373
|
+
try { return statSync(path).mtimeMs; } catch { return 0; }
|
|
374
|
+
}
|