@agentchatme/agent-core 0.0.131 → 0.0.1312
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 +5 -0
- package/dist/{chunk-XLRPNQ7G.js → chunk-AGDJ4A6R.js} +58 -1
- package/dist/chunk-AGDJ4A6R.js.map +1 -0
- package/dist/daemon-entry.d.ts +39 -4
- package/dist/daemon-entry.js +305 -65
- package/dist/daemon-entry.js.map +1 -1
- package/dist/index.d.ts +28 -4
- package/dist/index.js +203 -54
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-XLRPNQ7G.js.map +0 -1
package/dist/daemon-entry.d.ts
CHANGED
|
@@ -47,8 +47,12 @@ declare class AgentWsClient extends EventEmitter {
|
|
|
47
47
|
private attempt;
|
|
48
48
|
private reconnectTimer;
|
|
49
49
|
private livenessTimer;
|
|
50
|
+
private ackRetryTimer;
|
|
50
51
|
private stopped;
|
|
51
52
|
private ackMode;
|
|
53
|
+
private inboundPaused;
|
|
54
|
+
private readonly pendingAcks;
|
|
55
|
+
private readonly acksInFlight;
|
|
52
56
|
constructor(url: string, apiKey: string);
|
|
53
57
|
/** True only while the socket is live and ready. The heartbeat writer keys
|
|
54
58
|
* off this, so a reconnecting/terminal daemon lets its heartbeat go stale
|
|
@@ -56,6 +60,14 @@ declare class AgentWsClient extends EventEmitter {
|
|
|
56
60
|
get connected(): boolean;
|
|
57
61
|
start(): void;
|
|
58
62
|
stop(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Apply TCP backpressure while the model-turn queue is saturated. `ws`
|
|
65
|
+
* delegates this to the underlying socket; no delivered frame is discarded.
|
|
66
|
+
* A server heartbeat may close a very long pause, which is safe because all
|
|
67
|
+
* unacked messages re-drain after reconnect.
|
|
68
|
+
*/
|
|
69
|
+
pauseInbound(): void;
|
|
70
|
+
resumeInbound(): void;
|
|
59
71
|
getState(): State;
|
|
60
72
|
/**
|
|
61
73
|
* Confirm a message as handled: `{"type":"ack","message_id":"msg_..."}`.
|
|
@@ -65,6 +77,8 @@ declare class AgentWsClient extends EventEmitter {
|
|
|
65
77
|
* real-time push — which carries no delivery_id — be acked at all.
|
|
66
78
|
*/
|
|
67
79
|
ack(messageId: string): void;
|
|
80
|
+
private flushAcks;
|
|
81
|
+
private scheduleAckRetry;
|
|
68
82
|
private open;
|
|
69
83
|
private scheduleReconnect;
|
|
70
84
|
private armLiveness;
|
|
@@ -94,6 +108,8 @@ declare class ReplyCoord {
|
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
interface TurnContext {
|
|
111
|
+
/** Trusted server message id that caused this autonomous turn. */
|
|
112
|
+
messageId?: string | undefined;
|
|
97
113
|
/** The AgentChat conversation the message belongs to. */
|
|
98
114
|
conversationId: string;
|
|
99
115
|
/** @handle of the sender. */
|
|
@@ -127,6 +143,13 @@ interface TurnResult {
|
|
|
127
143
|
}
|
|
128
144
|
interface RuntimeAdapter {
|
|
129
145
|
readonly name: string;
|
|
146
|
+
/**
|
|
147
|
+
* Reset conversation continuity when the authenticated AgentChat identity
|
|
148
|
+
* changes. `identityNamespace` contains no credential material; callers use
|
|
149
|
+
* the authenticated API base + handle. Adapters that persist host sessions
|
|
150
|
+
* must include it in their session key, not merely clear an in-memory map.
|
|
151
|
+
*/
|
|
152
|
+
reset?(identityNamespace: string): void;
|
|
130
153
|
/** Verify the runtime is usable (binary present, logged in). */
|
|
131
154
|
preflight(): Promise<{
|
|
132
155
|
ok: boolean;
|
|
@@ -186,6 +209,10 @@ interface ResolveDaemonOpts {
|
|
|
186
209
|
*/
|
|
187
210
|
declare function resolveDaemonConfig(opts: ResolveDaemonOpts): Promise<DaemonConfig>;
|
|
188
211
|
|
|
212
|
+
interface DaemonFailure {
|
|
213
|
+
kind: 'socket-auth' | 'runtime';
|
|
214
|
+
reason: string;
|
|
215
|
+
}
|
|
189
216
|
declare class Daemon {
|
|
190
217
|
private readonly cfg;
|
|
191
218
|
private readonly adapter;
|
|
@@ -195,7 +222,9 @@ declare class Daemon {
|
|
|
195
222
|
private readonly ws;
|
|
196
223
|
private readonly coord;
|
|
197
224
|
private readonly seen;
|
|
198
|
-
private readonly
|
|
225
|
+
private readonly convQueues;
|
|
226
|
+
private readonly convWorkers;
|
|
227
|
+
private pending;
|
|
199
228
|
private inFlight;
|
|
200
229
|
private readonly waiters;
|
|
201
230
|
private stopping;
|
|
@@ -203,13 +232,19 @@ declare class Daemon {
|
|
|
203
232
|
constructor(cfg: DaemonConfig, adapter: RuntimeAdapter, ws?: AgentWsClient, // injectable for tests; defaults to a real socket
|
|
204
233
|
/** Called when the socket gives up for good (auth refused). The supervisor
|
|
205
234
|
* above decides what happens next — this class does not end the process. */
|
|
206
|
-
onTerminal?: ((
|
|
235
|
+
onTerminal?: ((failure: DaemonFailure) => void) | undefined);
|
|
207
236
|
start(): Promise<void>;
|
|
208
237
|
stop(): void;
|
|
209
238
|
private onInbound;
|
|
210
|
-
/**
|
|
211
|
-
private
|
|
239
|
+
/** Queue one already-tracked row and ensure exactly one worker for its conversation. */
|
|
240
|
+
private enqueueExisting;
|
|
241
|
+
/** Process each message independently, in arrival order within a conversation. */
|
|
242
|
+
private drainConversation;
|
|
212
243
|
private handle;
|
|
244
|
+
private markHandled;
|
|
245
|
+
private markNoLongerPending;
|
|
246
|
+
/** Bound reconnect-dedup memory without ever evicting unfinished work. */
|
|
247
|
+
private pruneSeen;
|
|
213
248
|
private acquireSlot;
|
|
214
249
|
private releaseSlot;
|
|
215
250
|
}
|
package/dist/daemon-entry.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CODING_AGENTS_CLIENT_HEADERS,
|
|
2
3
|
acquireLeaderLock,
|
|
4
|
+
atomicWriteFile,
|
|
3
5
|
beat,
|
|
4
6
|
credentialsPath,
|
|
5
7
|
external_exports,
|
|
@@ -7,7 +9,7 @@ import {
|
|
|
7
9
|
idle,
|
|
8
10
|
log,
|
|
9
11
|
resolveIdentity
|
|
10
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-AGDJ4A6R.js";
|
|
11
13
|
|
|
12
14
|
// src/daemon/ws-client.ts
|
|
13
15
|
import { WebSocket } from "ws";
|
|
@@ -49,6 +51,7 @@ function contextOf(row) {
|
|
|
49
51
|
// src/daemon/ws-client.ts
|
|
50
52
|
var BASE_BACKOFF_MS = 1e3;
|
|
51
53
|
var MAX_BACKOFF_MS = 6e4;
|
|
54
|
+
var ACK_RETRY_MS = 1e3;
|
|
52
55
|
var LIVENESS_MS = 1e5;
|
|
53
56
|
var AgentWsClient = class extends EventEmitter {
|
|
54
57
|
constructor(url, apiKey) {
|
|
@@ -63,8 +66,12 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
63
66
|
attempt = 0;
|
|
64
67
|
reconnectTimer = null;
|
|
65
68
|
livenessTimer = null;
|
|
69
|
+
ackRetryTimer = null;
|
|
66
70
|
stopped = false;
|
|
67
71
|
ackMode = false;
|
|
72
|
+
inboundPaused = false;
|
|
73
|
+
pendingAcks = /* @__PURE__ */ new Set();
|
|
74
|
+
acksInFlight = /* @__PURE__ */ new Set();
|
|
68
75
|
/** True only while the socket is live and ready. The heartbeat writer keys
|
|
69
76
|
* off this, so a reconnecting/terminal daemon lets its heartbeat go stale
|
|
70
77
|
* and the next session detects that always-on is actually down. */
|
|
@@ -79,6 +86,7 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
79
86
|
this.stopped = true;
|
|
80
87
|
this.state = "closed";
|
|
81
88
|
this.clearTimers();
|
|
89
|
+
this.acksInFlight.clear();
|
|
82
90
|
if (this.ws) {
|
|
83
91
|
try {
|
|
84
92
|
this.ws.close(1e3, "daemon shutdown");
|
|
@@ -87,6 +95,29 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
87
95
|
this.ws = null;
|
|
88
96
|
}
|
|
89
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Apply TCP backpressure while the model-turn queue is saturated. `ws`
|
|
100
|
+
* delegates this to the underlying socket; no delivered frame is discarded.
|
|
101
|
+
* A server heartbeat may close a very long pause, which is safe because all
|
|
102
|
+
* unacked messages re-drain after reconnect.
|
|
103
|
+
*/
|
|
104
|
+
pauseInbound() {
|
|
105
|
+
if (this.inboundPaused) return;
|
|
106
|
+
this.inboundPaused = true;
|
|
107
|
+
try {
|
|
108
|
+
this.ws?.pause();
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
resumeInbound() {
|
|
113
|
+
if (!this.inboundPaused) return;
|
|
114
|
+
this.inboundPaused = false;
|
|
115
|
+
try {
|
|
116
|
+
this.ws?.resume();
|
|
117
|
+
if (this.state === "ready") this.armLiveness();
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
}
|
|
90
121
|
getState() {
|
|
91
122
|
return this.state;
|
|
92
123
|
}
|
|
@@ -98,19 +129,48 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
98
129
|
* real-time push — which carries no delivery_id — be acked at all.
|
|
99
130
|
*/
|
|
100
131
|
ack(messageId) {
|
|
132
|
+
this.pendingAcks.add(messageId);
|
|
133
|
+
this.flushAcks();
|
|
134
|
+
}
|
|
135
|
+
flushAcks() {
|
|
101
136
|
if (this.state !== "ready" || !this.ws) return;
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
for (const messageId of this.pendingAcks) {
|
|
138
|
+
if (this.acksInFlight.has(messageId)) continue;
|
|
139
|
+
this.acksInFlight.add(messageId);
|
|
140
|
+
try {
|
|
141
|
+
this.ws.send(
|
|
142
|
+
JSON.stringify({ type: "ack", message_id: messageId }),
|
|
143
|
+
(err) => {
|
|
144
|
+
this.acksInFlight.delete(messageId);
|
|
145
|
+
if (!err) this.pendingAcks.delete(messageId);
|
|
146
|
+
else {
|
|
147
|
+
log.debug(`ack send failed for ${messageId} (will retry): ${String(err)}`);
|
|
148
|
+
this.scheduleAckRetry();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
this.acksInFlight.delete(messageId);
|
|
154
|
+
log.debug(`ack send failed for ${messageId} (will retry): ${String(err)}`);
|
|
155
|
+
this.scheduleAckRetry();
|
|
156
|
+
}
|
|
106
157
|
}
|
|
107
158
|
}
|
|
159
|
+
scheduleAckRetry() {
|
|
160
|
+
if (this.stopped || this.ackRetryTimer) return;
|
|
161
|
+
this.ackRetryTimer = setTimeout(() => {
|
|
162
|
+
this.ackRetryTimer = null;
|
|
163
|
+
this.flushAcks();
|
|
164
|
+
}, ACK_RETRY_MS);
|
|
165
|
+
this.ackRetryTimer.unref();
|
|
166
|
+
}
|
|
108
167
|
open() {
|
|
109
168
|
if (this.stopped) return;
|
|
110
169
|
this.state = this.attempt === 0 ? "connecting" : "reconnecting";
|
|
111
170
|
log.info(`ws ${this.state} (attempt ${this.attempt + 1}) \u2192 ${this.url}`);
|
|
112
171
|
const ws = new WebSocket(this.url, {
|
|
113
172
|
headers: {
|
|
173
|
+
...CODING_AGENTS_CLIENT_HEADERS,
|
|
114
174
|
authorization: `Bearer ${this.apiKey}`,
|
|
115
175
|
// Opt into the delivery-ack protocol: the server then leaves each
|
|
116
176
|
// delivery 'stored' until we ack it (by message id) instead of
|
|
@@ -124,8 +184,10 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
124
184
|
this.attempt = 0;
|
|
125
185
|
this.state = "ready";
|
|
126
186
|
this.armLiveness();
|
|
187
|
+
if (this.inboundPaused) ws.pause();
|
|
127
188
|
log.info("ws ready \u2014 draining + listening");
|
|
128
189
|
this.emit("ready");
|
|
190
|
+
this.flushAcks();
|
|
129
191
|
});
|
|
130
192
|
ws.on("message", (data) => {
|
|
131
193
|
this.armLiveness();
|
|
@@ -150,6 +212,7 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
150
212
|
});
|
|
151
213
|
ws.on("ping", () => this.armLiveness());
|
|
152
214
|
ws.on("unexpected-response", (_req, res) => {
|
|
215
|
+
if (this.stopped) return;
|
|
153
216
|
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
154
217
|
this.state = "terminal";
|
|
155
218
|
this.clearTimers();
|
|
@@ -165,18 +228,23 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
165
228
|
});
|
|
166
229
|
ws.on("close", (code) => {
|
|
167
230
|
if (this.state === "terminal" || this.stopped) return;
|
|
231
|
+
this.acksInFlight.clear();
|
|
168
232
|
log.warn(`ws closed (${code}) \u2014 scheduling reconnect`);
|
|
169
233
|
this.scheduleReconnect();
|
|
170
234
|
});
|
|
171
235
|
}
|
|
172
236
|
scheduleReconnect() {
|
|
173
237
|
if (this.stopped || this.state === "terminal") return;
|
|
238
|
+
if (this.reconnectTimer) return;
|
|
174
239
|
this.state = "reconnecting";
|
|
175
240
|
this.clearTimers();
|
|
176
241
|
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** this.attempt, MAX_BACKOFF_MS);
|
|
177
242
|
const jitter = backoff * (0.5 + Math.random() * 0.5);
|
|
178
243
|
this.attempt++;
|
|
179
|
-
this.reconnectTimer = setTimeout(() =>
|
|
244
|
+
this.reconnectTimer = setTimeout(() => {
|
|
245
|
+
this.reconnectTimer = null;
|
|
246
|
+
this.open();
|
|
247
|
+
}, jitter);
|
|
180
248
|
}
|
|
181
249
|
armLiveness() {
|
|
182
250
|
if (this.livenessTimer) clearTimeout(this.livenessTimer);
|
|
@@ -198,6 +266,10 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
198
266
|
clearTimeout(this.livenessTimer);
|
|
199
267
|
this.livenessTimer = null;
|
|
200
268
|
}
|
|
269
|
+
if (this.ackRetryTimer) {
|
|
270
|
+
clearTimeout(this.ackRetryTimer);
|
|
271
|
+
this.ackRetryTimer = null;
|
|
272
|
+
}
|
|
201
273
|
}
|
|
202
274
|
};
|
|
203
275
|
|
|
@@ -212,6 +284,7 @@ var ReplyCoord = class {
|
|
|
212
284
|
const res = await fetch(url, {
|
|
213
285
|
method,
|
|
214
286
|
headers: {
|
|
287
|
+
...CODING_AGENTS_CLIENT_HEADERS,
|
|
215
288
|
authorization: `Bearer ${this.cfg.apiKey}`,
|
|
216
289
|
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
217
290
|
},
|
|
@@ -263,8 +336,8 @@ function describeSender(ctx) {
|
|
|
263
336
|
}
|
|
264
337
|
|
|
265
338
|
// src/daemon/run.ts
|
|
266
|
-
import * as
|
|
267
|
-
import * as
|
|
339
|
+
import * as path3 from "path";
|
|
340
|
+
import * as fs2 from "fs";
|
|
268
341
|
|
|
269
342
|
// src/daemon/config.ts
|
|
270
343
|
import * as path from "path";
|
|
@@ -298,12 +371,49 @@ async function resolveDaemonConfig(opts) {
|
|
|
298
371
|
}
|
|
299
372
|
|
|
300
373
|
// src/daemon/loop.ts
|
|
301
|
-
import * as
|
|
374
|
+
import * as crypto from "crypto";
|
|
375
|
+
import * as fs from "fs";
|
|
376
|
+
import * as path2 from "path";
|
|
377
|
+
var MAX_TIMER_MS = 2147483647;
|
|
378
|
+
function positiveBoundedEnv(name, fallback) {
|
|
379
|
+
const parsed = Number(process.env[name]);
|
|
380
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_TIMER_MS) : fallback;
|
|
381
|
+
}
|
|
302
382
|
var MAX_CONCURRENT_TURNS = 3;
|
|
303
|
-
var MAX_ATTEMPTS = 3;
|
|
304
383
|
var HEARTBEAT_MS = 3e4;
|
|
384
|
+
var SEEN_TTL_MS = 24 * 60 * 6e4;
|
|
385
|
+
var MAX_COMPLETED_SEEN = 1e4;
|
|
386
|
+
var PAUSE_AT_PENDING = Math.max(
|
|
387
|
+
1,
|
|
388
|
+
Math.floor(positiveBoundedEnv("AGENTCHATD_MAX_PENDING", 2e3))
|
|
389
|
+
);
|
|
390
|
+
var RESUME_AT_PENDING = Math.max(1, Math.floor(PAUSE_AT_PENDING / 2));
|
|
391
|
+
var RETRY_BASE_MS = positiveBoundedEnv("AGENTCHATD_RETRY_MS", 1e3);
|
|
392
|
+
var RETRY_MAX_MS = Math.max(
|
|
393
|
+
RETRY_BASE_MS,
|
|
394
|
+
positiveBoundedEnv("AGENTCHATD_RETRY_MAX_MS", 5 * 6e4)
|
|
395
|
+
);
|
|
305
396
|
var YIELD_MS = Number(process.env["AGENTCHATD_YIELD_MS"] ?? 1e4);
|
|
306
397
|
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
398
|
+
function retryDelay(attempt) {
|
|
399
|
+
return Math.min(RETRY_BASE_MS * 2 ** Math.min(20, Math.max(0, attempt - 1)), RETRY_MAX_MS);
|
|
400
|
+
}
|
|
401
|
+
function installationId(home) {
|
|
402
|
+
const file = path2.join(home, "daemon.installation-id");
|
|
403
|
+
try {
|
|
404
|
+
const existing = fs.readFileSync(file, "utf-8").trim();
|
|
405
|
+
if (/^[0-9a-f-]{36}$/i.test(existing)) return existing;
|
|
406
|
+
} catch {
|
|
407
|
+
}
|
|
408
|
+
const id = crypto.randomUUID();
|
|
409
|
+
try {
|
|
410
|
+
atomicWriteFile(file, `${id}
|
|
411
|
+
`, 384);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
log.warn(`could not persist daemon installation id: ${String(err)}`);
|
|
414
|
+
}
|
|
415
|
+
return id;
|
|
416
|
+
}
|
|
307
417
|
var Daemon = class {
|
|
308
418
|
constructor(cfg, adapter, ws, onTerminal) {
|
|
309
419
|
this.cfg = cfg;
|
|
@@ -312,7 +422,7 @@ var Daemon = class {
|
|
|
312
422
|
this.coord = new ReplyCoord({
|
|
313
423
|
apiKey: cfg.apiKey,
|
|
314
424
|
apiBase: cfg.apiBase,
|
|
315
|
-
holder: `daemon:${
|
|
425
|
+
holder: `daemon:${installationId(cfg.home)}`
|
|
316
426
|
});
|
|
317
427
|
this.ws = ws ?? new AgentWsClient(cfg.wsUrl, cfg.apiKey);
|
|
318
428
|
this.ws.on("inbound", (row) => this.onInbound(row));
|
|
@@ -320,7 +430,7 @@ var Daemon = class {
|
|
|
320
430
|
this.ws.on("terminal", (reason) => {
|
|
321
431
|
log.error(`daemon terminal: ${reason}`);
|
|
322
432
|
this.stop();
|
|
323
|
-
this.onTerminal?.(reason);
|
|
433
|
+
this.onTerminal?.({ kind: "socket-auth", reason });
|
|
324
434
|
});
|
|
325
435
|
}
|
|
326
436
|
cfg;
|
|
@@ -329,8 +439,9 @@ var Daemon = class {
|
|
|
329
439
|
ws;
|
|
330
440
|
coord;
|
|
331
441
|
seen = /* @__PURE__ */ new Map();
|
|
332
|
-
|
|
333
|
-
|
|
442
|
+
convQueues = /* @__PURE__ */ new Map();
|
|
443
|
+
convWorkers = /* @__PURE__ */ new Set();
|
|
444
|
+
pending = 0;
|
|
334
445
|
inFlight = 0;
|
|
335
446
|
waiters = [];
|
|
336
447
|
stopping = false;
|
|
@@ -354,23 +465,53 @@ var Daemon = class {
|
|
|
354
465
|
}
|
|
355
466
|
onInbound(row) {
|
|
356
467
|
if (senderOf(row) === this.cfg.handle) return;
|
|
357
|
-
|
|
358
|
-
this.seen.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
this.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
468
|
+
this.pruneSeen();
|
|
469
|
+
const prior = this.seen.get(row.id);
|
|
470
|
+
if (prior) {
|
|
471
|
+
prior.updatedAt = Date.now();
|
|
472
|
+
if (prior.status === "handled") this.ws.ack(row.id);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
this.seen.set(row.id, { row, status: "queued", attempts: 0, updatedAt: Date.now() });
|
|
476
|
+
this.pending += 1;
|
|
477
|
+
if (this.pending >= PAUSE_AT_PENDING) this.ws.pauseInbound();
|
|
478
|
+
this.enqueueExisting(row);
|
|
479
|
+
}
|
|
480
|
+
/** Queue one already-tracked row and ensure exactly one worker for its conversation. */
|
|
481
|
+
enqueueExisting(row) {
|
|
482
|
+
const queue = this.convQueues.get(row.conversation_id) ?? [];
|
|
483
|
+
queue.push(row);
|
|
484
|
+
this.convQueues.set(row.conversation_id, queue);
|
|
485
|
+
if (this.convWorkers.has(row.conversation_id)) return;
|
|
486
|
+
this.convWorkers.add(row.conversation_id);
|
|
487
|
+
void this.drainConversation(row.conversation_id);
|
|
488
|
+
}
|
|
489
|
+
/** Process each message independently, in arrival order within a conversation. */
|
|
490
|
+
async drainConversation(conversationId) {
|
|
491
|
+
try {
|
|
492
|
+
while (!this.stopping) {
|
|
493
|
+
const queue = this.convQueues.get(conversationId);
|
|
494
|
+
if (!queue || queue.length === 0) break;
|
|
495
|
+
const row = queue.shift();
|
|
496
|
+
if (!row) break;
|
|
497
|
+
await this.handle(row);
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
log.warn(`unhandled in conv ${conversationId}: ${String(err)}`);
|
|
501
|
+
} finally {
|
|
502
|
+
this.convWorkers.delete(conversationId);
|
|
503
|
+
const queue = this.convQueues.get(conversationId);
|
|
504
|
+
if (!queue || queue.length === 0) this.convQueues.delete(conversationId);
|
|
505
|
+
else if (!this.stopping) {
|
|
506
|
+
this.convWorkers.add(conversationId);
|
|
507
|
+
void this.drainConversation(conversationId);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
371
510
|
}
|
|
372
511
|
async handle(row) {
|
|
373
512
|
if (this.stopping) return;
|
|
513
|
+
const initial = this.seen.get(row.id);
|
|
514
|
+
if (!initial || initial.status !== "queued") return;
|
|
374
515
|
if (await this.coord.isSessionActive()) {
|
|
375
516
|
log.info(`msg ${row.id}: live session active \u2014 yielding for ${YIELD_MS}ms`);
|
|
376
517
|
await delay(YIELD_MS);
|
|
@@ -378,37 +519,90 @@ var Daemon = class {
|
|
|
378
519
|
}
|
|
379
520
|
if (!await this.coord.claim(row.id)) {
|
|
380
521
|
log.info(`msg ${row.id}: claimed by the live session \u2014 standing down`);
|
|
522
|
+
this.seen.delete(row.id);
|
|
523
|
+
this.markNoLongerPending();
|
|
381
524
|
return;
|
|
382
525
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
526
|
+
while (!this.stopping) {
|
|
527
|
+
const state = this.seen.get(row.id);
|
|
528
|
+
if (!state || state.status === "handled") return;
|
|
529
|
+
state.status = "running";
|
|
530
|
+
state.attempts += 1;
|
|
531
|
+
state.updatedAt = Date.now();
|
|
532
|
+
const attempt = state.attempts;
|
|
533
|
+
await this.acquireSlot();
|
|
534
|
+
if (this.stopping) {
|
|
535
|
+
this.releaseSlot();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
let result;
|
|
539
|
+
try {
|
|
540
|
+
log.info(
|
|
541
|
+
`turn for msg ${row.id} in ${row.conversation_id} from @${senderOf(row)} (attempt ${attempt})`
|
|
542
|
+
);
|
|
543
|
+
const ctx = contextOf(row);
|
|
544
|
+
result = await this.adapter.runTurn({
|
|
545
|
+
messageId: row.id,
|
|
546
|
+
conversationId: row.conversation_id,
|
|
547
|
+
sender: senderOf(row),
|
|
548
|
+
text: typeof row.content?.["text"] === "string" ? row.content["text"] : "",
|
|
549
|
+
createdAt: typeof row.created_at === "string" ? row.created_at : void 0,
|
|
550
|
+
type: typeof row.type === "string" ? row.type : void 0,
|
|
551
|
+
senderDisplayName: ctx.senderDisplayName,
|
|
552
|
+
senderKind: ctx.senderKind,
|
|
553
|
+
groupName: ctx.groupName,
|
|
554
|
+
mentioned: ctx.mentions.includes(this.cfg.handle.toLowerCase())
|
|
555
|
+
});
|
|
556
|
+
} catch (err) {
|
|
557
|
+
result = { ok: false, detail: `adapter threw: ${String(err)}` };
|
|
558
|
+
} finally {
|
|
559
|
+
this.releaseSlot();
|
|
560
|
+
}
|
|
400
561
|
if (result.ok) {
|
|
401
|
-
this.
|
|
402
|
-
|
|
403
|
-
log.error(`fatal turn error: ${result.detail} \u2014 not acking (will re-drain)`);
|
|
404
|
-
} else if (attempts >= MAX_ATTEMPTS) {
|
|
405
|
-
log.warn(`msg ${row.id} failed ${attempts}\xD7 (${result.detail}); acking to drop (poison guard)`);
|
|
406
|
-
this.ws.ack(row.id);
|
|
407
|
-
} else {
|
|
408
|
-
log.warn(`turn failed for ${row.id}: ${result.detail}; leaving unacked for re-drain`);
|
|
562
|
+
this.markHandled(row.id);
|
|
563
|
+
return;
|
|
409
564
|
}
|
|
410
|
-
|
|
411
|
-
|
|
565
|
+
if (result.fatal) {
|
|
566
|
+
log.error(`fatal turn error: ${result.detail} \u2014 stopping runtime so preflight can recover`);
|
|
567
|
+
this.stop();
|
|
568
|
+
this.onTerminal?.({ kind: "runtime", reason: result.detail ?? "runtime failed" });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const retryMs = retryDelay(attempt);
|
|
572
|
+
state.status = "retry-wait";
|
|
573
|
+
state.updatedAt = Date.now();
|
|
574
|
+
log.warn(
|
|
575
|
+
`turn failed for msg ${row.id}: ${result.detail}; retrying in ${retryMs}ms without acknowledging it`
|
|
576
|
+
);
|
|
577
|
+
await delay(retryMs);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
markHandled(messageId) {
|
|
581
|
+
const state = this.seen.get(messageId);
|
|
582
|
+
if (!state || state.status === "handled") return;
|
|
583
|
+
state.status = "handled";
|
|
584
|
+
state.updatedAt = Date.now();
|
|
585
|
+
this.markNoLongerPending();
|
|
586
|
+
this.ws.ack(messageId);
|
|
587
|
+
}
|
|
588
|
+
markNoLongerPending() {
|
|
589
|
+
this.pending = Math.max(0, this.pending - 1);
|
|
590
|
+
if (this.pending <= RESUME_AT_PENDING) this.ws.resumeInbound();
|
|
591
|
+
}
|
|
592
|
+
/** Bound reconnect-dedup memory without ever evicting unfinished work. */
|
|
593
|
+
pruneSeen() {
|
|
594
|
+
const cutoff = Date.now() - SEEN_TTL_MS;
|
|
595
|
+
const completed = [];
|
|
596
|
+
for (const entry of this.seen.entries()) {
|
|
597
|
+
const [id, state] = entry;
|
|
598
|
+
if (state.status !== "handled") continue;
|
|
599
|
+
if (state.updatedAt < cutoff) this.seen.delete(id);
|
|
600
|
+
else completed.push(entry);
|
|
601
|
+
}
|
|
602
|
+
if (completed.length <= MAX_COMPLETED_SEEN) return;
|
|
603
|
+
completed.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
|
604
|
+
for (const [id] of completed.slice(0, completed.length - MAX_COMPLETED_SEEN)) {
|
|
605
|
+
this.seen.delete(id);
|
|
412
606
|
}
|
|
413
607
|
}
|
|
414
608
|
// ─── global concurrency semaphore ─────────────────────────────────────────
|
|
@@ -433,19 +627,44 @@ var Daemon = class {
|
|
|
433
627
|
var POLL_MS = 5e3;
|
|
434
628
|
var TICK_MS = 250;
|
|
435
629
|
var MAX_BACKOFF_MS2 = 5 * 6e4;
|
|
630
|
+
var MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
631
|
+
var KEEP_LOG_BYTES = 1024 * 1024;
|
|
436
632
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
437
633
|
function fingerprint(home) {
|
|
438
634
|
const id = resolveIdentity(home);
|
|
439
635
|
return id === null ? null : `${id.apiKey}:${id.handle ?? ""}`;
|
|
440
636
|
}
|
|
637
|
+
function boundDaemonLog(home) {
|
|
638
|
+
const file = path3.join(home, "daemon.log");
|
|
639
|
+
try {
|
|
640
|
+
const size = fs2.statSync(file).size;
|
|
641
|
+
if (size <= MAX_LOG_BYTES) return;
|
|
642
|
+
const fd = fs2.openSync(file, "r");
|
|
643
|
+
try {
|
|
644
|
+
const keep = Buffer.alloc(Math.min(KEEP_LOG_BYTES, size));
|
|
645
|
+
fs2.readSync(fd, keep, 0, keep.length, size - keep.length);
|
|
646
|
+
fs2.writeFileSync(
|
|
647
|
+
file,
|
|
648
|
+
`[agentchat:info] older daemon log output truncated at ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
649
|
+
${keep.toString("utf-8")}`
|
|
650
|
+
);
|
|
651
|
+
} finally {
|
|
652
|
+
fs2.closeSync(fd);
|
|
653
|
+
}
|
|
654
|
+
} catch {
|
|
655
|
+
}
|
|
656
|
+
}
|
|
441
657
|
async function runDaemon(opts) {
|
|
442
|
-
const home =
|
|
443
|
-
const workdir = opts.workdir ??
|
|
658
|
+
const home = path3.resolve(opts.home);
|
|
659
|
+
const workdir = opts.workdir ?? path3.join(home, "daemon-workdir");
|
|
660
|
+
boundDaemonLog(home);
|
|
444
661
|
if (process.env["AGENTCHAT_LOG_LEVEL"] === void 0) process.env["AGENTCHAT_LOG_LEVEL"] = "info";
|
|
445
662
|
const lock = acquireLeaderLock(home);
|
|
446
663
|
if (lock === null) return 1;
|
|
447
664
|
let live = null;
|
|
448
665
|
let liveFingerprint = null;
|
|
666
|
+
let observedFingerprint = null;
|
|
667
|
+
let adapterFingerprint = null;
|
|
449
668
|
let refused = null;
|
|
450
669
|
let failures = 0;
|
|
451
670
|
let lastFailure = null;
|
|
@@ -471,10 +690,17 @@ async function runDaemon(opts) {
|
|
|
471
690
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
472
691
|
let nudged = false;
|
|
473
692
|
try {
|
|
474
|
-
|
|
475
|
-
const watcher =
|
|
693
|
+
fs2.mkdirSync(home, { recursive: true });
|
|
694
|
+
const watcher = fs2.watch(home, (_event, filename) => {
|
|
476
695
|
if (filename === null || String(filename).startsWith("credentials")) nudged = true;
|
|
477
696
|
});
|
|
697
|
+
watcher.on("error", (err) => {
|
|
698
|
+
log.warn(`credential watcher unavailable; polling instead: ${String(err)}`);
|
|
699
|
+
try {
|
|
700
|
+
watcher.close();
|
|
701
|
+
} catch {
|
|
702
|
+
}
|
|
703
|
+
});
|
|
478
704
|
watcher.unref();
|
|
479
705
|
} catch {
|
|
480
706
|
}
|
|
@@ -483,19 +709,33 @@ async function runDaemon(opts) {
|
|
|
483
709
|
for (; ; ) {
|
|
484
710
|
if (shuttingDown) break;
|
|
485
711
|
const fp = fingerprint(home);
|
|
712
|
+
const identityChanged = fp !== observedFingerprint;
|
|
713
|
+
if (identityChanged) {
|
|
714
|
+
disconnect(fp === null ? "signed out" : "identity changed");
|
|
715
|
+
observedFingerprint = fp;
|
|
716
|
+
failures = 0;
|
|
717
|
+
lastFailure = null;
|
|
718
|
+
if (fp !== refused) refused = null;
|
|
719
|
+
}
|
|
486
720
|
if (fp === null) {
|
|
487
|
-
disconnect("signed out");
|
|
488
721
|
if (refused !== null) refused = null;
|
|
489
722
|
} else if (fp !== liveFingerprint) {
|
|
490
|
-
disconnect("identity changed");
|
|
491
|
-
failures = 0;
|
|
492
723
|
if (fp === refused) {
|
|
493
724
|
} else {
|
|
494
725
|
try {
|
|
495
726
|
const cfg = await resolveDaemonConfig({ home, workdir });
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
727
|
+
if (adapterFingerprint !== fp) {
|
|
728
|
+
opts.adapter.reset?.(`${cfg.apiBase}:${cfg.handle}`);
|
|
729
|
+
adapterFingerprint = fp;
|
|
730
|
+
}
|
|
731
|
+
const candidate = new Daemon(cfg, opts.adapter, void 0, (failure) => {
|
|
732
|
+
if (failure.kind === "socket-auth") {
|
|
733
|
+
log.warn(`credential refused (${failure.reason}) \u2014 idling until it changes`);
|
|
734
|
+
refused = fp;
|
|
735
|
+
} else {
|
|
736
|
+
log.warn(`runtime became unhealthy (${failure.reason}) \u2014 re-running preflight`);
|
|
737
|
+
failures += 1;
|
|
738
|
+
}
|
|
499
739
|
live = null;
|
|
500
740
|
liveFingerprint = null;
|
|
501
741
|
idle(home);
|