@agentchatme/agent-core 0.0.1311 → 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-ER4AFPH7.js → chunk-AGDJ4A6R.js} +42 -2
- package/dist/chunk-AGDJ4A6R.js.map +1 -0
- package/dist/daemon-entry.d.ts +39 -4
- package/dist/daemon-entry.js +295 -65
- package/dist/daemon-entry.js.map +1 -1
- package/dist/index.d.ts +19 -6
- package/dist/index.js +172 -47
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-ER4AFPH7.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,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CODING_AGENTS_CLIENT_HEADERS,
|
|
3
3
|
acquireLeaderLock,
|
|
4
|
+
atomicWriteFile,
|
|
4
5
|
beat,
|
|
5
6
|
credentialsPath,
|
|
6
7
|
external_exports,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
idle,
|
|
9
10
|
log,
|
|
10
11
|
resolveIdentity
|
|
11
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-AGDJ4A6R.js";
|
|
12
13
|
|
|
13
14
|
// src/daemon/ws-client.ts
|
|
14
15
|
import { WebSocket } from "ws";
|
|
@@ -50,6 +51,7 @@ function contextOf(row) {
|
|
|
50
51
|
// src/daemon/ws-client.ts
|
|
51
52
|
var BASE_BACKOFF_MS = 1e3;
|
|
52
53
|
var MAX_BACKOFF_MS = 6e4;
|
|
54
|
+
var ACK_RETRY_MS = 1e3;
|
|
53
55
|
var LIVENESS_MS = 1e5;
|
|
54
56
|
var AgentWsClient = class extends EventEmitter {
|
|
55
57
|
constructor(url, apiKey) {
|
|
@@ -64,8 +66,12 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
64
66
|
attempt = 0;
|
|
65
67
|
reconnectTimer = null;
|
|
66
68
|
livenessTimer = null;
|
|
69
|
+
ackRetryTimer = null;
|
|
67
70
|
stopped = false;
|
|
68
71
|
ackMode = false;
|
|
72
|
+
inboundPaused = false;
|
|
73
|
+
pendingAcks = /* @__PURE__ */ new Set();
|
|
74
|
+
acksInFlight = /* @__PURE__ */ new Set();
|
|
69
75
|
/** True only while the socket is live and ready. The heartbeat writer keys
|
|
70
76
|
* off this, so a reconnecting/terminal daemon lets its heartbeat go stale
|
|
71
77
|
* and the next session detects that always-on is actually down. */
|
|
@@ -80,6 +86,7 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
80
86
|
this.stopped = true;
|
|
81
87
|
this.state = "closed";
|
|
82
88
|
this.clearTimers();
|
|
89
|
+
this.acksInFlight.clear();
|
|
83
90
|
if (this.ws) {
|
|
84
91
|
try {
|
|
85
92
|
this.ws.close(1e3, "daemon shutdown");
|
|
@@ -88,6 +95,29 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
88
95
|
this.ws = null;
|
|
89
96
|
}
|
|
90
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
|
+
}
|
|
91
121
|
getState() {
|
|
92
122
|
return this.state;
|
|
93
123
|
}
|
|
@@ -99,13 +129,41 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
99
129
|
* real-time push — which carries no delivery_id — be acked at all.
|
|
100
130
|
*/
|
|
101
131
|
ack(messageId) {
|
|
132
|
+
this.pendingAcks.add(messageId);
|
|
133
|
+
this.flushAcks();
|
|
134
|
+
}
|
|
135
|
+
flushAcks() {
|
|
102
136
|
if (this.state !== "ready" || !this.ws) return;
|
|
103
|
-
|
|
104
|
-
this.
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
}
|
|
107
157
|
}
|
|
108
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
|
+
}
|
|
109
167
|
open() {
|
|
110
168
|
if (this.stopped) return;
|
|
111
169
|
this.state = this.attempt === 0 ? "connecting" : "reconnecting";
|
|
@@ -126,8 +184,10 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
126
184
|
this.attempt = 0;
|
|
127
185
|
this.state = "ready";
|
|
128
186
|
this.armLiveness();
|
|
187
|
+
if (this.inboundPaused) ws.pause();
|
|
129
188
|
log.info("ws ready \u2014 draining + listening");
|
|
130
189
|
this.emit("ready");
|
|
190
|
+
this.flushAcks();
|
|
131
191
|
});
|
|
132
192
|
ws.on("message", (data) => {
|
|
133
193
|
this.armLiveness();
|
|
@@ -152,6 +212,7 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
152
212
|
});
|
|
153
213
|
ws.on("ping", () => this.armLiveness());
|
|
154
214
|
ws.on("unexpected-response", (_req, res) => {
|
|
215
|
+
if (this.stopped) return;
|
|
155
216
|
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
156
217
|
this.state = "terminal";
|
|
157
218
|
this.clearTimers();
|
|
@@ -167,18 +228,23 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
167
228
|
});
|
|
168
229
|
ws.on("close", (code) => {
|
|
169
230
|
if (this.state === "terminal" || this.stopped) return;
|
|
231
|
+
this.acksInFlight.clear();
|
|
170
232
|
log.warn(`ws closed (${code}) \u2014 scheduling reconnect`);
|
|
171
233
|
this.scheduleReconnect();
|
|
172
234
|
});
|
|
173
235
|
}
|
|
174
236
|
scheduleReconnect() {
|
|
175
237
|
if (this.stopped || this.state === "terminal") return;
|
|
238
|
+
if (this.reconnectTimer) return;
|
|
176
239
|
this.state = "reconnecting";
|
|
177
240
|
this.clearTimers();
|
|
178
241
|
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** this.attempt, MAX_BACKOFF_MS);
|
|
179
242
|
const jitter = backoff * (0.5 + Math.random() * 0.5);
|
|
180
243
|
this.attempt++;
|
|
181
|
-
this.reconnectTimer = setTimeout(() =>
|
|
244
|
+
this.reconnectTimer = setTimeout(() => {
|
|
245
|
+
this.reconnectTimer = null;
|
|
246
|
+
this.open();
|
|
247
|
+
}, jitter);
|
|
182
248
|
}
|
|
183
249
|
armLiveness() {
|
|
184
250
|
if (this.livenessTimer) clearTimeout(this.livenessTimer);
|
|
@@ -200,6 +266,10 @@ var AgentWsClient = class extends EventEmitter {
|
|
|
200
266
|
clearTimeout(this.livenessTimer);
|
|
201
267
|
this.livenessTimer = null;
|
|
202
268
|
}
|
|
269
|
+
if (this.ackRetryTimer) {
|
|
270
|
+
clearTimeout(this.ackRetryTimer);
|
|
271
|
+
this.ackRetryTimer = null;
|
|
272
|
+
}
|
|
203
273
|
}
|
|
204
274
|
};
|
|
205
275
|
|
|
@@ -266,8 +336,8 @@ function describeSender(ctx) {
|
|
|
266
336
|
}
|
|
267
337
|
|
|
268
338
|
// src/daemon/run.ts
|
|
269
|
-
import * as
|
|
270
|
-
import * as
|
|
339
|
+
import * as path3 from "path";
|
|
340
|
+
import * as fs2 from "fs";
|
|
271
341
|
|
|
272
342
|
// src/daemon/config.ts
|
|
273
343
|
import * as path from "path";
|
|
@@ -301,12 +371,49 @@ async function resolveDaemonConfig(opts) {
|
|
|
301
371
|
}
|
|
302
372
|
|
|
303
373
|
// src/daemon/loop.ts
|
|
304
|
-
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
|
+
}
|
|
305
382
|
var MAX_CONCURRENT_TURNS = 3;
|
|
306
|
-
var MAX_ATTEMPTS = 3;
|
|
307
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
|
+
);
|
|
308
396
|
var YIELD_MS = Number(process.env["AGENTCHATD_YIELD_MS"] ?? 1e4);
|
|
309
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
|
+
}
|
|
310
417
|
var Daemon = class {
|
|
311
418
|
constructor(cfg, adapter, ws, onTerminal) {
|
|
312
419
|
this.cfg = cfg;
|
|
@@ -315,7 +422,7 @@ var Daemon = class {
|
|
|
315
422
|
this.coord = new ReplyCoord({
|
|
316
423
|
apiKey: cfg.apiKey,
|
|
317
424
|
apiBase: cfg.apiBase,
|
|
318
|
-
holder: `daemon:${
|
|
425
|
+
holder: `daemon:${installationId(cfg.home)}`
|
|
319
426
|
});
|
|
320
427
|
this.ws = ws ?? new AgentWsClient(cfg.wsUrl, cfg.apiKey);
|
|
321
428
|
this.ws.on("inbound", (row) => this.onInbound(row));
|
|
@@ -323,7 +430,7 @@ var Daemon = class {
|
|
|
323
430
|
this.ws.on("terminal", (reason) => {
|
|
324
431
|
log.error(`daemon terminal: ${reason}`);
|
|
325
432
|
this.stop();
|
|
326
|
-
this.onTerminal?.(reason);
|
|
433
|
+
this.onTerminal?.({ kind: "socket-auth", reason });
|
|
327
434
|
});
|
|
328
435
|
}
|
|
329
436
|
cfg;
|
|
@@ -332,8 +439,9 @@ var Daemon = class {
|
|
|
332
439
|
ws;
|
|
333
440
|
coord;
|
|
334
441
|
seen = /* @__PURE__ */ new Map();
|
|
335
|
-
|
|
336
|
-
|
|
442
|
+
convQueues = /* @__PURE__ */ new Map();
|
|
443
|
+
convWorkers = /* @__PURE__ */ new Set();
|
|
444
|
+
pending = 0;
|
|
337
445
|
inFlight = 0;
|
|
338
446
|
waiters = [];
|
|
339
447
|
stopping = false;
|
|
@@ -357,23 +465,53 @@ var Daemon = class {
|
|
|
357
465
|
}
|
|
358
466
|
onInbound(row) {
|
|
359
467
|
if (senderOf(row) === this.cfg.handle) return;
|
|
360
|
-
|
|
361
|
-
this.seen.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
this.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
+
}
|
|
374
510
|
}
|
|
375
511
|
async handle(row) {
|
|
376
512
|
if (this.stopping) return;
|
|
513
|
+
const initial = this.seen.get(row.id);
|
|
514
|
+
if (!initial || initial.status !== "queued") return;
|
|
377
515
|
if (await this.coord.isSessionActive()) {
|
|
378
516
|
log.info(`msg ${row.id}: live session active \u2014 yielding for ${YIELD_MS}ms`);
|
|
379
517
|
await delay(YIELD_MS);
|
|
@@ -381,37 +519,90 @@ var Daemon = class {
|
|
|
381
519
|
}
|
|
382
520
|
if (!await this.coord.claim(row.id)) {
|
|
383
521
|
log.info(`msg ${row.id}: claimed by the live session \u2014 standing down`);
|
|
522
|
+
this.seen.delete(row.id);
|
|
523
|
+
this.markNoLongerPending();
|
|
384
524
|
return;
|
|
385
525
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|
|
403
561
|
if (result.ok) {
|
|
404
|
-
this.
|
|
405
|
-
|
|
406
|
-
log.error(`fatal turn error: ${result.detail} \u2014 not acking (will re-drain)`);
|
|
407
|
-
} else if (attempts >= MAX_ATTEMPTS) {
|
|
408
|
-
log.warn(`msg ${row.id} failed ${attempts}\xD7 (${result.detail}); acking to drop (poison guard)`);
|
|
409
|
-
this.ws.ack(row.id);
|
|
410
|
-
} else {
|
|
411
|
-
log.warn(`turn failed for ${row.id}: ${result.detail}; leaving unacked for re-drain`);
|
|
562
|
+
this.markHandled(row.id);
|
|
563
|
+
return;
|
|
412
564
|
}
|
|
413
|
-
|
|
414
|
-
|
|
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);
|
|
415
606
|
}
|
|
416
607
|
}
|
|
417
608
|
// ─── global concurrency semaphore ─────────────────────────────────────────
|
|
@@ -436,19 +627,44 @@ var Daemon = class {
|
|
|
436
627
|
var POLL_MS = 5e3;
|
|
437
628
|
var TICK_MS = 250;
|
|
438
629
|
var MAX_BACKOFF_MS2 = 5 * 6e4;
|
|
630
|
+
var MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
631
|
+
var KEEP_LOG_BYTES = 1024 * 1024;
|
|
439
632
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
440
633
|
function fingerprint(home) {
|
|
441
634
|
const id = resolveIdentity(home);
|
|
442
635
|
return id === null ? null : `${id.apiKey}:${id.handle ?? ""}`;
|
|
443
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
|
+
}
|
|
444
657
|
async function runDaemon(opts) {
|
|
445
|
-
const home =
|
|
446
|
-
const workdir = opts.workdir ??
|
|
658
|
+
const home = path3.resolve(opts.home);
|
|
659
|
+
const workdir = opts.workdir ?? path3.join(home, "daemon-workdir");
|
|
660
|
+
boundDaemonLog(home);
|
|
447
661
|
if (process.env["AGENTCHAT_LOG_LEVEL"] === void 0) process.env["AGENTCHAT_LOG_LEVEL"] = "info";
|
|
448
662
|
const lock = acquireLeaderLock(home);
|
|
449
663
|
if (lock === null) return 1;
|
|
450
664
|
let live = null;
|
|
451
665
|
let liveFingerprint = null;
|
|
666
|
+
let observedFingerprint = null;
|
|
667
|
+
let adapterFingerprint = null;
|
|
452
668
|
let refused = null;
|
|
453
669
|
let failures = 0;
|
|
454
670
|
let lastFailure = null;
|
|
@@ -474,8 +690,8 @@ async function runDaemon(opts) {
|
|
|
474
690
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
475
691
|
let nudged = false;
|
|
476
692
|
try {
|
|
477
|
-
|
|
478
|
-
const watcher =
|
|
693
|
+
fs2.mkdirSync(home, { recursive: true });
|
|
694
|
+
const watcher = fs2.watch(home, (_event, filename) => {
|
|
479
695
|
if (filename === null || String(filename).startsWith("credentials")) nudged = true;
|
|
480
696
|
});
|
|
481
697
|
watcher.on("error", (err) => {
|
|
@@ -493,19 +709,33 @@ async function runDaemon(opts) {
|
|
|
493
709
|
for (; ; ) {
|
|
494
710
|
if (shuttingDown) break;
|
|
495
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
|
+
}
|
|
496
720
|
if (fp === null) {
|
|
497
|
-
disconnect("signed out");
|
|
498
721
|
if (refused !== null) refused = null;
|
|
499
722
|
} else if (fp !== liveFingerprint) {
|
|
500
|
-
disconnect("identity changed");
|
|
501
|
-
failures = 0;
|
|
502
723
|
if (fp === refused) {
|
|
503
724
|
} else {
|
|
504
725
|
try {
|
|
505
726
|
const cfg = await resolveDaemonConfig({ home, workdir });
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
+
}
|
|
509
739
|
live = null;
|
|
510
740
|
liveFingerprint = null;
|
|
511
741
|
idle(home);
|