@agentconnect.md/daemon 1.0.0-rc.307 → 1.0.0-rc.309
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/load-agents-CROyMyq9.js.map +1 -1
- package/dist/{login-BOGVQVpU.js → login-CcV7neWZ.js} +2 -2
- package/dist/{login-BOGVQVpU.js.map → login-CcV7neWZ.js.map} +1 -1
- package/dist/run-foreground-B-FPE0tJ.js +2 -0
- package/dist/{run-foreground-DrYgPjW_.js → run-foreground-DoM1PkHn.js} +1058 -180
- package/dist/{run-foreground-DrYgPjW_.js.map → run-foreground-DoM1PkHn.js.map} +1 -1
- package/package.json +1 -1
- package/dist/run-foreground-DeXQELR6.js +0 -2
|
@@ -6366,6 +6366,12 @@ var LocalStore = class {
|
|
|
6366
6366
|
effortOverride TEXT, permissionModeOverride TEXT, fastModeOverride INTEGER,
|
|
6367
6367
|
outputModeOverride TEXT
|
|
6368
6368
|
);
|
|
6369
|
+
-- A !stop can arrive while a cold session is still materializing, before the
|
|
6370
|
+
-- sessions row exists. Keep the mute independently keyed so that stop survives a
|
|
6371
|
+
-- daemon restart and is applied when the session row is eventually created.
|
|
6372
|
+
CREATE TABLE IF NOT EXISTS session_mutes (
|
|
6373
|
+
key TEXT PRIMARY KEY
|
|
6374
|
+
);
|
|
6369
6375
|
-- Platform id → human display name (Slack channel/user names, daemon-resolved
|
|
6370
6376
|
-- and cached here so session read-back can label ids without a Slack call).
|
|
6371
6377
|
CREATE TABLE IF NOT EXISTS display_names (
|
|
@@ -6411,9 +6417,22 @@ var LocalStore = class {
|
|
|
6411
6417
|
integrationId TEXT,
|
|
6412
6418
|
callMeta TEXT,
|
|
6413
6419
|
isQueueCmd INTEGER,
|
|
6420
|
+
loopGuardCounted INTEGER NOT NULL DEFAULT 0,
|
|
6414
6421
|
enqueuedAt TEXT NOT NULL
|
|
6415
6422
|
);
|
|
6416
6423
|
CREATE INDEX IF NOT EXISTS inbox_fifo ON inbox (sessionKey, enqueuedAt);
|
|
6424
|
+
-- Conversation-wide spam/feedback-loop circuit. Unlike the in-memory dedup
|
|
6425
|
+
-- caches, this latch survives a daemon restart, so durable inbox replay cannot
|
|
6426
|
+
-- re-ignite a conversation that was already stopped by loop protection.
|
|
6427
|
+
CREATE TABLE IF NOT EXISTS loop_guard (
|
|
6428
|
+
scopeKey TEXT PRIMARY KEY,
|
|
6429
|
+
windowStartedAt INTEGER NOT NULL,
|
|
6430
|
+
totalCount INTEGER NOT NULL,
|
|
6431
|
+
automaticWindowStartedAt INTEGER NOT NULL,
|
|
6432
|
+
automaticCount INTEGER NOT NULL,
|
|
6433
|
+
trippedAt INTEGER,
|
|
6434
|
+
reason TEXT
|
|
6435
|
+
);
|
|
6417
6436
|
-- §3.4/§6.8 main-agent orchestration: a main agent fans out N subtasks to
|
|
6418
6437
|
-- worker agents, then waits asynchronously and summarizes. The record is
|
|
6419
6438
|
-- persisted BEFORE any delivery (record-first — a fast worker's reply must
|
|
@@ -6458,8 +6477,26 @@ var LocalStore = class {
|
|
|
6458
6477
|
ON orchestration_subtask (orchestrationId, idx);
|
|
6459
6478
|
`);
|
|
6460
6479
|
this.migrateSessionUsage();
|
|
6480
|
+
this.migrateSessionMutes();
|
|
6481
|
+
this.migrateInboxLoopGuardCounted();
|
|
6461
6482
|
this.migrateTranscriptToolBody();
|
|
6462
6483
|
}
|
|
6484
|
+
/** Backfill the durable mute tombstones from the legacy sessions.muted column.
|
|
6485
|
+
* Both writes in setSessionMuted stay transactional, so this idempotent sync cannot
|
|
6486
|
+
* resurrect a mute that was explicitly cleared. */
|
|
6487
|
+
migrateSessionMutes() {
|
|
6488
|
+
this.db.exec(`
|
|
6489
|
+
CREATE TABLE IF NOT EXISTS session_mutes (key TEXT PRIMARY KEY);
|
|
6490
|
+
INSERT OR IGNORE INTO session_mutes (key) SELECT key FROM sessions WHERE muted = 1;
|
|
6491
|
+
UPDATE sessions SET muted = 1 WHERE key IN (SELECT key FROM session_mutes);
|
|
6492
|
+
`);
|
|
6493
|
+
}
|
|
6494
|
+
/** Rows written before loop protection had no per-delivery accounting marker. They
|
|
6495
|
+
* start at 0 so the first owner that can replay them charges them exactly once. */
|
|
6496
|
+
migrateInboxLoopGuardCounted() {
|
|
6497
|
+
if (!this.db.prepare("PRAGMA table_info(inbox)").all().some((c) => c.name === "loopGuardCounted")) this.db.exec("ALTER TABLE inbox ADD COLUMN loopGuardCounted INTEGER NOT NULL DEFAULT 0");
|
|
6498
|
+
else this.db.exec("UPDATE inbox SET loopGuardCounted = 0 WHERE loopGuardCounted IS NULL");
|
|
6499
|
+
}
|
|
6463
6500
|
/** Add the `transcript.tool_call_id` + `transcript.body` columns (and the partial
|
|
6464
6501
|
* unique index) to a pre-existing DB. CREATE TABLE IF NOT EXISTS above adds them for
|
|
6465
6502
|
* fresh DBs but never alters an existing table, so upgrade in place. No-op once the
|
|
@@ -6551,11 +6588,19 @@ var LocalStore = class {
|
|
|
6551
6588
|
};
|
|
6552
6589
|
}
|
|
6553
6590
|
upsertSession(rec) {
|
|
6554
|
-
this.db.prepare(`INSERT INTO sessions
|
|
6555
|
-
|
|
6591
|
+
this.db.prepare(`INSERT INTO sessions
|
|
6592
|
+
(key, agentId, platform, channel, thread, acpSessionId, state, lastDeliveredTs, updatedAt, muted, triggeredBy)
|
|
6593
|
+
VALUES
|
|
6594
|
+
(@key, @agentId, @platform, @channel, @thread, @acpSessionId, @state, @lastDeliveredTs, @updatedAt,
|
|
6595
|
+
CASE WHEN EXISTS (SELECT 1 FROM session_mutes WHERE key = @key) THEN 1 ELSE NULL END,
|
|
6596
|
+
@triggeredBy)
|
|
6556
6597
|
ON CONFLICT(key) DO UPDATE SET
|
|
6557
6598
|
acpSessionId=excluded.acpSessionId, state=excluded.state,
|
|
6558
6599
|
lastDeliveredTs=excluded.lastDeliveredTs, updatedAt=excluded.updatedAt,
|
|
6600
|
+
muted=CASE
|
|
6601
|
+
WHEN EXISTS (SELECT 1 FROM session_mutes WHERE key = excluded.key) THEN 1
|
|
6602
|
+
ELSE sessions.muted
|
|
6603
|
+
END,
|
|
6559
6604
|
triggeredBy=COALESCE(sessions.triggeredBy, excluded.triggeredBy)`).run({
|
|
6560
6605
|
key: rec.key,
|
|
6561
6606
|
agentId: rec.agentId,
|
|
@@ -6575,11 +6620,20 @@ var LocalStore = class {
|
|
|
6575
6620
|
setSessionState(key, state, updatedAt) {
|
|
6576
6621
|
this.db.prepare("UPDATE sessions SET state = ?, updatedAt = ? WHERE key = ?").run(state, updatedAt, key);
|
|
6577
6622
|
}
|
|
6578
|
-
/** `!stop` thread mute:
|
|
6579
|
-
*
|
|
6580
|
-
* clears
|
|
6623
|
+
/** `!stop` thread mute: the tombstone is written even before a session row exists,
|
|
6624
|
+
* then mirrored into sessions.muted for existing readers. An explicit @mention
|
|
6625
|
+
* clears both atomically. */
|
|
6581
6626
|
setSessionMuted(key, muted) {
|
|
6582
|
-
this.db.
|
|
6627
|
+
this.db.exec("BEGIN");
|
|
6628
|
+
try {
|
|
6629
|
+
if (muted) this.db.prepare("INSERT OR IGNORE INTO session_mutes (key) VALUES (?)").run(key);
|
|
6630
|
+
else this.db.prepare("DELETE FROM session_mutes WHERE key = ?").run(key);
|
|
6631
|
+
this.db.prepare("UPDATE sessions SET muted = ? WHERE key = ?").run(muted ? 1 : 0, key);
|
|
6632
|
+
this.db.exec("COMMIT");
|
|
6633
|
+
} catch (err) {
|
|
6634
|
+
this.db.exec("ROLLBACK");
|
|
6635
|
+
throw err;
|
|
6636
|
+
}
|
|
6583
6637
|
}
|
|
6584
6638
|
/** Human-facing session title from the runtime's `session_info_update` (latest
|
|
6585
6639
|
* wins; null clears per ACP semantics). No-op on an unknown key. */
|
|
@@ -6587,7 +6641,11 @@ var LocalStore = class {
|
|
|
6587
6641
|
this.db.prepare("UPDATE sessions SET title = ? WHERE key = ?").run(title, key);
|
|
6588
6642
|
}
|
|
6589
6643
|
isSessionMuted(key) {
|
|
6590
|
-
return this.db.prepare(
|
|
6644
|
+
return this.db.prepare(`SELECT CASE
|
|
6645
|
+
WHEN EXISTS (SELECT 1 FROM session_mutes WHERE key = ?)
|
|
6646
|
+
OR EXISTS (SELECT 1 FROM sessions WHERE key = ? AND muted = 1)
|
|
6647
|
+
THEN 1 ELSE 0
|
|
6648
|
+
END AS muted`).get(key, key).muted === 1;
|
|
6591
6649
|
}
|
|
6592
6650
|
/** The session-scoped model override (set via the console's in-session model switch),
|
|
6593
6651
|
* or undefined if the session runs on the agent's default. Sticky across turns and
|
|
@@ -6811,11 +6869,15 @@ var LocalStore = class {
|
|
|
6811
6869
|
return this.db.prepare("SELECT lastRunAt FROM cron_runs WHERE key = ?").get(key)?.lastRunAt;
|
|
6812
6870
|
}
|
|
6813
6871
|
/** §6.9 #353 durable inbox: persist an admitted message BEFORE its admission ACK. Keyed
|
|
6814
|
-
* by the stable id (deliveryId/msgId)
|
|
6815
|
-
*
|
|
6872
|
+
* by the stable id (deliveryId/msgId); a re-append preserves the original payload/FIFO
|
|
6873
|
+
* position and may only advance the durable loop-accounting marker from 0 to 1. */
|
|
6816
6874
|
appendInbox(row) {
|
|
6817
|
-
this.db.prepare(`INSERT
|
|
6818
|
-
|
|
6875
|
+
this.db.prepare(`INSERT INTO inbox
|
|
6876
|
+
(id, sessionKey, agentId, msg, integrationId, callMeta, isQueueCmd, loopGuardCounted, enqueuedAt)
|
|
6877
|
+
VALUES
|
|
6878
|
+
(@id, @sessionKey, @agentId, @msg, @integrationId, @callMeta, @isQueueCmd, @loopGuardCounted, @enqueuedAt)
|
|
6879
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
6880
|
+
loopGuardCounted=MAX(inbox.loopGuardCounted, excluded.loopGuardCounted)`).run({
|
|
6819
6881
|
id: row.id,
|
|
6820
6882
|
sessionKey: row.sessionKey,
|
|
6821
6883
|
agentId: row.agentId,
|
|
@@ -6823,6 +6885,7 @@ var LocalStore = class {
|
|
|
6823
6885
|
integrationId: row.integrationId ?? null,
|
|
6824
6886
|
callMeta: row.callMeta ?? null,
|
|
6825
6887
|
isQueueCmd: row.isQueueCmd ?? null,
|
|
6888
|
+
loopGuardCounted: row.loopGuardCounted ?? 0,
|
|
6826
6889
|
enqueuedAt: row.enqueuedAt
|
|
6827
6890
|
});
|
|
6828
6891
|
}
|
|
@@ -6836,6 +6899,129 @@ var LocalStore = class {
|
|
|
6836
6899
|
listInboxBySessionKeyFifo() {
|
|
6837
6900
|
return this.db.prepare("SELECT * FROM inbox ORDER BY sessionKey ASC, enqueuedAt ASC").all();
|
|
6838
6901
|
}
|
|
6902
|
+
hasInbox(id) {
|
|
6903
|
+
return this.db.prepare("SELECT 1 FROM inbox WHERE id = ?").get(id) !== void 0;
|
|
6904
|
+
}
|
|
6905
|
+
/** Remove every durable turn owned by an agent. Agent pause is an operator stop,
|
|
6906
|
+
* not a drain/retry signal: work purged here must never resurrect after unpause +
|
|
6907
|
+
* restart. Returns the removed ids so the daemon can clear its live-id cache. */
|
|
6908
|
+
removeInboxByAgentId(agentId) {
|
|
6909
|
+
const rows = this.db.prepare("SELECT id FROM inbox WHERE agentId = ?").all(agentId);
|
|
6910
|
+
this.db.prepare("DELETE FROM inbox WHERE agentId = ?").run(agentId);
|
|
6911
|
+
return rows.map((row) => row.id);
|
|
6912
|
+
}
|
|
6913
|
+
/** Record one turn admission against a conversation-wide fixed window. A trusted
|
|
6914
|
+
* human boundary resets only the consecutive automatic counter; the total-rate
|
|
6915
|
+
* backstop deliberately keeps counting so a platform bug that misclassifies its
|
|
6916
|
+
* own events as human still eventually opens the circuit. */
|
|
6917
|
+
recordLoopGuardTurn(scopeKey, now, automatic, limits) {
|
|
6918
|
+
this.db.prepare(`DELETE FROM loop_guard
|
|
6919
|
+
WHERE trippedAt IS NULL AND windowStartedAt <= @cutoff AND automaticWindowStartedAt <= @cutoff`).run({ cutoff: now - limits.windowMs });
|
|
6920
|
+
const current = this.getLoopGuard(scopeKey);
|
|
6921
|
+
if (current?.trippedAt !== null && current?.trippedAt !== void 0) return {
|
|
6922
|
+
allowed: false,
|
|
6923
|
+
trippedNow: false,
|
|
6924
|
+
totalCount: current.totalCount,
|
|
6925
|
+
automaticCount: current.automaticCount,
|
|
6926
|
+
...current.reason ? { reason: current.reason } : {}
|
|
6927
|
+
};
|
|
6928
|
+
const totalWindowExpired = !current || now - current.windowStartedAt >= limits.windowMs;
|
|
6929
|
+
const automaticWindowExpired = !current || now - current.automaticWindowStartedAt >= limits.windowMs;
|
|
6930
|
+
const windowStartedAt = totalWindowExpired ? now : current.windowStartedAt;
|
|
6931
|
+
const totalCount = totalWindowExpired ? 1 : current.totalCount + 1;
|
|
6932
|
+
const automaticWindowStartedAt = automaticWindowExpired || !automatic ? now : current.automaticWindowStartedAt;
|
|
6933
|
+
const automaticCount = automatic ? automaticWindowExpired ? 1 : current.automaticCount + 1 : 0;
|
|
6934
|
+
const reason = automaticCount > limits.maxAutomatic ? "automatic_turn_burst" : totalCount > limits.maxTotal ? "turn_rate_burst" : void 0;
|
|
6935
|
+
const trippedAt = reason ? now : null;
|
|
6936
|
+
this.db.prepare(`INSERT INTO loop_guard
|
|
6937
|
+
(scopeKey, windowStartedAt, totalCount, automaticWindowStartedAt, automaticCount, trippedAt, reason)
|
|
6938
|
+
VALUES (@scopeKey, @windowStartedAt, @totalCount, @automaticWindowStartedAt, @automaticCount, @trippedAt, @reason)
|
|
6939
|
+
ON CONFLICT(scopeKey) DO UPDATE SET
|
|
6940
|
+
windowStartedAt=excluded.windowStartedAt,
|
|
6941
|
+
totalCount=excluded.totalCount,
|
|
6942
|
+
automaticWindowStartedAt=excluded.automaticWindowStartedAt,
|
|
6943
|
+
automaticCount=excluded.automaticCount,
|
|
6944
|
+
trippedAt=excluded.trippedAt,
|
|
6945
|
+
reason=excluded.reason`).run({
|
|
6946
|
+
scopeKey,
|
|
6947
|
+
windowStartedAt,
|
|
6948
|
+
totalCount,
|
|
6949
|
+
automaticWindowStartedAt,
|
|
6950
|
+
automaticCount,
|
|
6951
|
+
trippedAt,
|
|
6952
|
+
reason: reason ?? null
|
|
6953
|
+
});
|
|
6954
|
+
return {
|
|
6955
|
+
allowed: reason === void 0,
|
|
6956
|
+
trippedNow: reason !== void 0,
|
|
6957
|
+
totalCount,
|
|
6958
|
+
automaticCount,
|
|
6959
|
+
...reason ? { reason } : {}
|
|
6960
|
+
};
|
|
6961
|
+
}
|
|
6962
|
+
/** Charge a migrated inbox delivery and advance its marker in one SQLite transaction.
|
|
6963
|
+
* A crash can therefore neither lose the charge nor charge the same retained row again
|
|
6964
|
+
* after ownership moves. A tripping delivery is intentionally left at marker 0: the
|
|
6965
|
+
* newly-open durable circuit makes its whole scope terminal and replay purges it. */
|
|
6966
|
+
recordLoopGuardTurnForInbox(inboxId, scopeKey, now, automatic, limits) {
|
|
6967
|
+
this.db.exec("BEGIN");
|
|
6968
|
+
try {
|
|
6969
|
+
const verdict = this.recordLoopGuardTurn(scopeKey, now, automatic, limits);
|
|
6970
|
+
if (verdict.allowed) {
|
|
6971
|
+
if (this.db.prepare("UPDATE inbox SET loopGuardCounted = 1 WHERE id = ?").run(inboxId).changes !== 1) throw new Error(`inbox delivery disappeared while charging loop guard: ${inboxId}`);
|
|
6972
|
+
}
|
|
6973
|
+
this.db.exec("COMMIT");
|
|
6974
|
+
return verdict;
|
|
6975
|
+
} catch (err) {
|
|
6976
|
+
this.db.exec("ROLLBACK");
|
|
6977
|
+
throw err;
|
|
6978
|
+
}
|
|
6979
|
+
}
|
|
6980
|
+
/** Open a loop circuit immediately for a structurally-invalid platform event. */
|
|
6981
|
+
tripLoopGuard(scopeKey, now, reason) {
|
|
6982
|
+
const current = this.getLoopGuard(scopeKey);
|
|
6983
|
+
if (current?.trippedAt !== null && current?.trippedAt !== void 0) return {
|
|
6984
|
+
allowed: false,
|
|
6985
|
+
trippedNow: false,
|
|
6986
|
+
totalCount: current.totalCount,
|
|
6987
|
+
automaticCount: current.automaticCount,
|
|
6988
|
+
...current.reason ? { reason: current.reason } : {}
|
|
6989
|
+
};
|
|
6990
|
+
const row = current ?? {
|
|
6991
|
+
scopeKey,
|
|
6992
|
+
windowStartedAt: now,
|
|
6993
|
+
totalCount: 0,
|
|
6994
|
+
automaticWindowStartedAt: now,
|
|
6995
|
+
automaticCount: 0
|
|
6996
|
+
};
|
|
6997
|
+
this.db.prepare(`INSERT INTO loop_guard
|
|
6998
|
+
(scopeKey, windowStartedAt, totalCount, automaticWindowStartedAt, automaticCount, trippedAt, reason)
|
|
6999
|
+
VALUES (@scopeKey, @windowStartedAt, @totalCount, @automaticWindowStartedAt, @automaticCount, @trippedAt, @reason)
|
|
7000
|
+
ON CONFLICT(scopeKey) DO UPDATE SET trippedAt=excluded.trippedAt, reason=excluded.reason`).run({
|
|
7001
|
+
...row,
|
|
7002
|
+
trippedAt: now,
|
|
7003
|
+
reason
|
|
7004
|
+
});
|
|
7005
|
+
return {
|
|
7006
|
+
allowed: false,
|
|
7007
|
+
trippedNow: true,
|
|
7008
|
+
totalCount: row.totalCount,
|
|
7009
|
+
automaticCount: row.automaticCount,
|
|
7010
|
+
reason
|
|
7011
|
+
};
|
|
7012
|
+
}
|
|
7013
|
+
getLoopGuard(scopeKey) {
|
|
7014
|
+
return this.db.prepare("SELECT * FROM loop_guard WHERE scopeKey = ?").get(scopeKey);
|
|
7015
|
+
}
|
|
7016
|
+
isLoopGuardOpen(scopeKey) {
|
|
7017
|
+
const row = this.getLoopGuard(scopeKey);
|
|
7018
|
+
return row?.trippedAt !== null && row?.trippedAt !== void 0;
|
|
7019
|
+
}
|
|
7020
|
+
/** Explicit operator/user reset. Purged inbox rows stay purged; reset only lets a
|
|
7021
|
+
* future fresh message start a new window. Returns whether a guard existed. */
|
|
7022
|
+
resetLoopGuard(scopeKey) {
|
|
7023
|
+
return this.db.prepare("DELETE FROM loop_guard WHERE scopeKey = ?").run(scopeKey).changes > 0;
|
|
7024
|
+
}
|
|
6839
7025
|
/**
|
|
6840
7026
|
* RECORD-FIRST (§3.4): persist the orchestration header + all subtask rows (status
|
|
6841
7027
|
* 'pending') in ONE transaction, BEFORE any delivery. A fast worker's reply that
|
|
@@ -7004,6 +7190,30 @@ function attachmentMention(attachments) {
|
|
|
7004
7190
|
}
|
|
7005
7191
|
//#endregion
|
|
7006
7192
|
//#region src/session/session-manager.ts
|
|
7193
|
+
function interrupted(signal) {
|
|
7194
|
+
return signal.reason instanceof Error ? signal.reason : new Error(typeof signal.reason === "string" ? signal.reason : "session initialization interrupted");
|
|
7195
|
+
}
|
|
7196
|
+
/** Await one initialization seam with a turn-scoped abort fence. The underlying I/O may
|
|
7197
|
+
* finish later, but its continuation cannot resume SessionManager.handle after abort. */
|
|
7198
|
+
function abortable(start, signal) {
|
|
7199
|
+
if (!signal) return Promise.resolve().then(start);
|
|
7200
|
+
if (signal.aborted) return Promise.reject(interrupted(signal));
|
|
7201
|
+
return new Promise((resolve, reject) => {
|
|
7202
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
7203
|
+
const onAbort = () => {
|
|
7204
|
+
cleanup();
|
|
7205
|
+
reject(interrupted(signal));
|
|
7206
|
+
};
|
|
7207
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
7208
|
+
Promise.resolve().then(start).then((result) => {
|
|
7209
|
+
cleanup();
|
|
7210
|
+
resolve(result);
|
|
7211
|
+
}, (err) => {
|
|
7212
|
+
cleanup();
|
|
7213
|
+
reject(err);
|
|
7214
|
+
});
|
|
7215
|
+
});
|
|
7216
|
+
}
|
|
7007
7217
|
var SessionManager = class {
|
|
7008
7218
|
deps;
|
|
7009
7219
|
constructor(deps) {
|
|
@@ -7015,7 +7225,7 @@ var SessionManager = class {
|
|
|
7015
7225
|
const dormant = this.deps.store.closedSessionAgents(channel, thread);
|
|
7016
7226
|
return dormant.length === 1 ? dormant[0] : null;
|
|
7017
7227
|
}
|
|
7018
|
-
async handle(agentId, msg) {
|
|
7228
|
+
async handle(agentId, msg, signal) {
|
|
7019
7229
|
const agent = this.deps.agentById(agentId);
|
|
7020
7230
|
if (!agent) throw new Error(`unknown agent ${agentId}`);
|
|
7021
7231
|
this.deps.memory.ensure({ agentId }, agent.name);
|
|
@@ -7032,9 +7242,9 @@ var SessionManager = class {
|
|
|
7032
7242
|
text: mention ? `${msg.text}\n${mention}`.trim() : msg.text
|
|
7033
7243
|
});
|
|
7034
7244
|
let rec = this.deps.store.getSession(key);
|
|
7035
|
-
const host = await this.deps.hostFor(agentId);
|
|
7245
|
+
const host = await abortable(() => this.deps.hostFor(agentId), signal);
|
|
7036
7246
|
const effortOverride = this.deps.store.getEffortOverride(key);
|
|
7037
|
-
const memoryIndex = (await this.deps.memory.injectAtSessionStart({ agentId })).trim();
|
|
7247
|
+
const memoryIndex = (await abortable(() => this.deps.memory.injectAtSessionStart({ agentId }), signal)).trim();
|
|
7038
7248
|
const memoryAppend = memoryIndex ? `# Your memory\nThis is your memory INDEX from earlier sessions. Read a linked topic file with the \`readMemory\` tool when relevant; revise memory with \`writeMemory\`.\n\n${memoryIndex}` : "";
|
|
7039
7249
|
const channelName = this.deps.store.getDisplayNames([msg.channel]).get(msg.channel);
|
|
7040
7250
|
const agentMeta = [
|
|
@@ -7051,14 +7261,14 @@ var SessionManager = class {
|
|
|
7051
7261
|
const metaContext = usesMeta ? sessionContext || void 0 : void 0;
|
|
7052
7262
|
let created = false;
|
|
7053
7263
|
if (!rec || !rec.acpSessionId) {
|
|
7054
|
-
const cwd = await prepareWorkspace(agent);
|
|
7264
|
+
const cwd = await abortable(() => prepareWorkspace(agent), signal);
|
|
7055
7265
|
const mcpServers = this.deps.mcpServersFor?.({
|
|
7056
7266
|
agent,
|
|
7057
7267
|
platform: msg.platform,
|
|
7058
7268
|
channel: msg.channel,
|
|
7059
7269
|
thread
|
|
7060
7270
|
}) ?? [];
|
|
7061
|
-
const acpSessionId = await host.newSession(cwd, mcpServers, effortOverride, metaContext);
|
|
7271
|
+
const acpSessionId = await abortable(() => host.newSession(cwd, mcpServers, effortOverride, metaContext), signal);
|
|
7062
7272
|
created = true;
|
|
7063
7273
|
rec = {
|
|
7064
7274
|
key,
|
|
@@ -7074,7 +7284,8 @@ var SessionManager = class {
|
|
|
7074
7284
|
};
|
|
7075
7285
|
this.deps.store.upsertSession(rec);
|
|
7076
7286
|
} else if (host.hasSession?.(rec.acpSessionId) === false) {
|
|
7077
|
-
const
|
|
7287
|
+
const persistedSessionId = rec.acpSessionId;
|
|
7288
|
+
const cwd = await abortable(() => prepareWorkspace(agent), signal);
|
|
7078
7289
|
const mcpServers = this.deps.mcpServersFor?.({
|
|
7079
7290
|
agent,
|
|
7080
7291
|
platform: msg.platform,
|
|
@@ -7085,12 +7296,14 @@ var SessionManager = class {
|
|
|
7085
7296
|
if (host.loadSupported?.()) {
|
|
7086
7297
|
this.deps.store.setSessionState(key, "resuming", Date.now());
|
|
7087
7298
|
try {
|
|
7088
|
-
await host.loadSession(
|
|
7299
|
+
await abortable(() => host.loadSession(persistedSessionId, cwd, mcpServers, effortOverride, usesMeta ? agentMeta : void 0), signal);
|
|
7089
7300
|
resumed = true;
|
|
7090
|
-
} catch {
|
|
7301
|
+
} catch {
|
|
7302
|
+
if (signal?.aborted) throw interrupted(signal);
|
|
7303
|
+
}
|
|
7091
7304
|
}
|
|
7092
7305
|
if (!resumed) {
|
|
7093
|
-
const acpSessionId = await host.newSession(cwd, mcpServers, effortOverride, metaContext);
|
|
7306
|
+
const acpSessionId = await abortable(() => host.newSession(cwd, mcpServers, effortOverride, metaContext), signal);
|
|
7094
7307
|
created = true;
|
|
7095
7308
|
rec = {
|
|
7096
7309
|
...rec,
|
|
@@ -7104,7 +7317,7 @@ var SessionManager = class {
|
|
|
7104
7317
|
}
|
|
7105
7318
|
if (rec.lastDeliveredTs === null && thread !== ts && msg.platform !== "webchat" && this.deps.fetchThreadHistory) {
|
|
7106
7319
|
if (!this.deps.store.transcriptSince(msg.channel, thread, null).some((e) => e.ts === thread)) {
|
|
7107
|
-
const history = await this.deps.fetchThreadHistory(agentId, msg.channel, thread);
|
|
7320
|
+
const history = await abortable(() => this.deps.fetchThreadHistory(agentId, msg.channel, thread), signal);
|
|
7108
7321
|
for (const h of history) this.deps.store.appendTranscript({
|
|
7109
7322
|
channel: msg.channel,
|
|
7110
7323
|
thread,
|
|
@@ -7133,11 +7346,11 @@ var SessionManager = class {
|
|
|
7133
7346
|
text: msg.text
|
|
7134
7347
|
});
|
|
7135
7348
|
if (msg.attachments?.length) {
|
|
7136
|
-
const attBlocks = await buildAttachmentBlocks(msg.attachments, {
|
|
7349
|
+
const attBlocks = await abortable(() => buildAttachmentBlocks(msg.attachments, {
|
|
7137
7350
|
download: (att) => this.deps.downloadAttachment?.(agentId, att) ?? Promise.resolve(null),
|
|
7138
7351
|
supports: (kind) => host.promptSupports?.(kind) ?? false,
|
|
7139
7352
|
...this.deps.attachmentMaxBytes !== void 0 ? { maxBytes: this.deps.attachmentMaxBytes } : {}
|
|
7140
|
-
});
|
|
7353
|
+
}), signal);
|
|
7141
7354
|
blocks.push(...attBlocks);
|
|
7142
7355
|
}
|
|
7143
7356
|
if (created && !usesMeta && sessionContext) blocks.unshift({
|
|
@@ -7293,6 +7506,7 @@ function guessMimeFromUrl(url) {
|
|
|
7293
7506
|
* missing connection — the caller turns that into an MCP `isError` result.
|
|
7294
7507
|
*/
|
|
7295
7508
|
async function executeTool(ctx, name, args, deps) {
|
|
7509
|
+
if (deps.canRun && !deps.canRun(ctx)) throw new Error("this agent turn has been stopped");
|
|
7296
7510
|
if (name === "listMemory" || name === "readMemory" || name === "writeMemory") {
|
|
7297
7511
|
const scope = { agentId: ctx.agentId };
|
|
7298
7512
|
try {
|
|
@@ -11167,6 +11381,7 @@ function routeRules(msg, rules, threadOwner, explicitAgentId) {
|
|
|
11167
11381
|
const COMMAND_PREFIXES = ["!", "/"];
|
|
11168
11382
|
const STOP_WORDS = /* @__PURE__ */ new Set(["stop"]);
|
|
11169
11383
|
const CANCEL_WORDS = /* @__PURE__ */ new Set(["cancel"]);
|
|
11384
|
+
const RESUME_WORDS = /* @__PURE__ */ new Set(["resume"]);
|
|
11170
11385
|
const QUEUE_WORDS = /* @__PURE__ */ new Set(["queue"]);
|
|
11171
11386
|
const STATUS_WORDS = /* @__PURE__ */ new Set(["status"]);
|
|
11172
11387
|
const FAST_WORDS = /* @__PURE__ */ new Set(["fast"]);
|
|
@@ -11195,6 +11410,7 @@ function parseCommand(raw) {
|
|
|
11195
11410
|
const arg = (m[2] ?? "").trim();
|
|
11196
11411
|
if (STOP_WORDS.has(word)) return { kind: "stop" };
|
|
11197
11412
|
if (CANCEL_WORDS.has(word)) return { kind: "cancel" };
|
|
11413
|
+
if (RESUME_WORDS.has(word)) return { kind: "resume" };
|
|
11198
11414
|
if (QUEUE_WORDS.has(word)) return {
|
|
11199
11415
|
kind: "queue",
|
|
11200
11416
|
text: arg
|
|
@@ -68467,6 +68683,19 @@ function consolidateShared(agents) {
|
|
|
68467
68683
|
}
|
|
68468
68684
|
return groups;
|
|
68469
68685
|
}
|
|
68686
|
+
/** Only user-authored chat shapes enter routing. Slack emits edits, deletes,
|
|
68687
|
+
* assistant-thread metadata/title updates, and system records through the same generic
|
|
68688
|
+
* `message` listener. In particular, `message_changed` keeps the actual sender and
|
|
68689
|
+
* text in a nested `message` object; normalizing that outer wrapper would turn it
|
|
68690
|
+
* into an anonymous empty DM and could feed our own update back into the agent.
|
|
68691
|
+
* `me_message` and thread broadcasts still carry genuine user text and must remain
|
|
68692
|
+
* routable. Keep this allowlist aligned with the relay's shared Slack ingest. */
|
|
68693
|
+
function isRoutableMessageSubtype(subtype) {
|
|
68694
|
+
return subtype === void 0 || subtype === "file_share" || subtype === "me_message" || subtype === "thread_broadcast" || subtype === "reply_broadcast";
|
|
68695
|
+
}
|
|
68696
|
+
function isRoutableMessageEvent(ev) {
|
|
68697
|
+
return Boolean(ev.user) && !ev.bot_id && !ev.hidden && ev.message === void 0 && isRoutableMessageSubtype(ev.subtype);
|
|
68698
|
+
}
|
|
68470
68699
|
/** Cap on members enriched per `listChannelMembers` call (bounds users.info fan-out). */
|
|
68471
68700
|
const MEMBER_ENRICH_CAP = 50;
|
|
68472
68701
|
/**
|
|
@@ -68558,8 +68787,8 @@ var SlackConnection = class {
|
|
|
68558
68787
|
};
|
|
68559
68788
|
this.app.message(async ({ message }) => {
|
|
68560
68789
|
const ev = message;
|
|
68561
|
-
if (ev.type !== "message" || !ev.channel) {
|
|
68562
|
-
log?.debug(`slack: inbound event ignored (type=${ev.type}, channel=${ev.channel ?? "none"})`);
|
|
68790
|
+
if (ev.type !== "message" || !ev.channel || !isRoutableMessageEvent(ev)) {
|
|
68791
|
+
log?.debug(`slack: inbound event ignored (type=${ev.type}, subtype=${ev.subtype ?? "none"}, channel=${ev.channel ?? "none"})`);
|
|
68563
68792
|
return;
|
|
68564
68793
|
}
|
|
68565
68794
|
deliver(ev, "message");
|
|
@@ -118741,7 +118970,7 @@ function normalizeTelegramMessage(msg, ctx) {
|
|
|
118741
118970
|
* like Slack Socket Mode. One connection per bot token.
|
|
118742
118971
|
*
|
|
118743
118972
|
* Control surface: unlike Slack (interactive status bar + modal), Telegram exposes
|
|
118744
|
-
* session state and controls via slash commands — `/status`, `/stop`, `/cancel`,
|
|
118973
|
+
* session state and controls via slash commands — `/status`, `/stop`, `/cancel`, `/resume`,
|
|
118745
118974
|
* `/fast`, `/queue`, parsed by the daemon like any inbound text. start() registers
|
|
118746
118975
|
* them with BotFather (setMyCommands) so they autocomplete in the Telegram UI.
|
|
118747
118976
|
*/
|
|
@@ -118761,6 +118990,10 @@ const BOT_COMMANDS = [
|
|
|
118761
118990
|
command: "cancel",
|
|
118762
118991
|
description: "Cancel the current turn (keep the session live)"
|
|
118763
118992
|
},
|
|
118993
|
+
{
|
|
118994
|
+
command: "resume",
|
|
118995
|
+
description: "Reset loop protection and unmute this conversation"
|
|
118996
|
+
},
|
|
118764
118997
|
{
|
|
118765
118998
|
command: "fast",
|
|
118766
118999
|
description: "Toggle fast mode — /fast on | off"
|
|
@@ -195408,6 +195641,10 @@ const DISCORD_APP_COMMANDS = [
|
|
|
195408
195641
|
name: "cancel",
|
|
195409
195642
|
description: "Cancel the in-flight turn (session stays live)"
|
|
195410
195643
|
},
|
|
195644
|
+
{
|
|
195645
|
+
name: "resume",
|
|
195646
|
+
description: "Reset loop protection and unmute this conversation"
|
|
195647
|
+
},
|
|
195411
195648
|
{
|
|
195412
195649
|
name: "queue",
|
|
195413
195650
|
description: "Queue a message to run once the agent is idle",
|
|
@@ -214877,6 +215114,9 @@ function formatErr(err) {
|
|
|
214877
215114
|
return e?.stack ?? String(err);
|
|
214878
215115
|
}
|
|
214879
215116
|
const MAX_QUEUED_PER_SESSION = 10;
|
|
215117
|
+
const LOOP_GUARD_WINDOW_MS = 6e4;
|
|
215118
|
+
const MAX_AUTOMATIC_TURNS_PER_WINDOW = 8;
|
|
215119
|
+
const MAX_TOTAL_TURNS_PER_WINDOW = 60;
|
|
214880
215120
|
/** Thrown to a `dispatch()` caller when the per-session admission queue is at its depth
|
|
214881
215121
|
* cap (§4.4 backpressure): the message is fast-failed, not buffered. Carries a stable
|
|
214882
215122
|
* `reason` so an agent-call source (P1/P2) can surface a typed `queue_full`. */
|
|
@@ -214937,6 +215177,38 @@ const DEFAULT_WEB_APP_URL = "https://app.agentconnect.md";
|
|
|
214937
215177
|
/** Cap on agent→agent hop depth (design §2.4/§4.5) — reject a `messageAgent` that
|
|
214938
215178
|
* would push the outgoing hopCount past this, so an A↔B wake loop can't run away. */
|
|
214939
215179
|
const MAX_AGENT_CALL_HOPS = 8;
|
|
215180
|
+
/** One durable loop-guard scope shared by every agent in a conversation. DMs are
|
|
215181
|
+
* keyed at channel level because malformed platform wrappers may lose thread
|
|
215182
|
+
* coordinates; threaded channel conversations retain their canonical thread. */
|
|
215183
|
+
function loopGuardScopeFromCoords(platform, channel, thread, isDm) {
|
|
215184
|
+
return `${platform}:${channel}:${isDm ? "dm" : thread}`;
|
|
215185
|
+
}
|
|
215186
|
+
function slackTopLevelLoopGuardScope(channel) {
|
|
215187
|
+
return `slack:${channel}:top-level`;
|
|
215188
|
+
}
|
|
215189
|
+
function loopGuardScope(msg) {
|
|
215190
|
+
if (msg.platform === "slack" && !msg.isDm) {
|
|
215191
|
+
const prefix = `slack:${msg.channel}:`;
|
|
215192
|
+
const eventTs = msg.msgId.startsWith(prefix) ? msg.msgId.slice(prefix.length) : void 0;
|
|
215193
|
+
if (eventTs !== void 0 && msg.thread === eventTs) return slackTopLevelLoopGuardScope(msg.channel);
|
|
215194
|
+
}
|
|
215195
|
+
return loopGuardScopeFromCoords(msg.platform, msg.channel, msg.thread ?? msg.msgId, msg.isDm);
|
|
215196
|
+
}
|
|
215197
|
+
function isTrustedHumanTurn(msg) {
|
|
215198
|
+
return msg.source === "user" && !msg.sender.isBot && msg.sender.id !== "unknown";
|
|
215199
|
+
}
|
|
215200
|
+
/** The coarse rate circuit protects platform chat ingress. Agent calls have an exact
|
|
215201
|
+
* trusted hop cap; cron/hooks are operator automation; webchat has a separate sync ACK
|
|
215202
|
+
* contract and no in-band !resume surface. */
|
|
215203
|
+
function usesLoopGuard(msg) {
|
|
215204
|
+
return msg.source === "user" && msg.platform !== "webchat";
|
|
215205
|
+
}
|
|
215206
|
+
/** The exact poison shape produced when a Slack message_changed/assistant metadata
|
|
215207
|
+
* wrapper was normalized as if it were a real message. No supported platform has a
|
|
215208
|
+
* legitimate anonymous, empty, attachment-less user turn, so fail closed and latch. */
|
|
215209
|
+
function isMalformedPlatformTurn(msg) {
|
|
215210
|
+
return msg.platform === "slack" && msg.source === "user" && !msg.sender.isBot && msg.sender.id === "unknown" && msg.text.trim() === "" && (msg.attachments?.length ?? 0) === 0;
|
|
215211
|
+
}
|
|
214940
215212
|
/** Build the wire SessionKey (protocol §5) for a pending turn — what `drain/done`
|
|
214941
215213
|
* reports as released so the CP may reassign it. Uses the real `thread` (absent for
|
|
214942
215214
|
* a channel-root message), NOT `statusThread` (which falls back to msgId): the CP
|
|
@@ -214990,6 +215262,8 @@ var Daemon = class Daemon {
|
|
|
214990
215262
|
tgConnByIntegration = /* @__PURE__ */ new Map();
|
|
214991
215263
|
dcConnByIntegration = /* @__PURE__ */ new Map();
|
|
214992
215264
|
hostStarts = /* @__PURE__ */ new Map();
|
|
215265
|
+
hostStartGeneration = /* @__PURE__ */ new Map();
|
|
215266
|
+
hostStartAborts = /* @__PURE__ */ new Map();
|
|
214993
215267
|
watcher;
|
|
214994
215268
|
debounceTimer;
|
|
214995
215269
|
agentsDir = "";
|
|
@@ -215020,16 +215294,21 @@ var Daemon = class Daemon {
|
|
|
215020
215294
|
requestExit;
|
|
215021
215295
|
draining = false;
|
|
215022
215296
|
drainingAgents = /* @__PURE__ */ new Set();
|
|
215297
|
+
safetyDrainingAgents = /* @__PURE__ */ new Set();
|
|
215298
|
+
safetyDrainWaits = /* @__PURE__ */ new Map();
|
|
215299
|
+
safetyDrainRuns = /* @__PURE__ */ new Map();
|
|
215023
215300
|
moveStagedAgents = /* @__PURE__ */ new Set();
|
|
215024
215301
|
moveStageMetadata = /* @__PURE__ */ new Map();
|
|
215025
215302
|
activatingAgents = /* @__PURE__ */ new Set();
|
|
215026
215303
|
agentMoveTails = /* @__PURE__ */ new Map();
|
|
215027
215304
|
agentMoveInFlight = /* @__PURE__ */ new Map();
|
|
215028
215305
|
activeDispatchesByAgent = /* @__PURE__ */ new Map();
|
|
215306
|
+
activeDispatchDoneByKey = /* @__PURE__ */ new Map();
|
|
215029
215307
|
activeReplyConnectionUses = /* @__PURE__ */ new Map();
|
|
215030
215308
|
hostStopping = /* @__PURE__ */ new Map();
|
|
215031
215309
|
idleSweepTimer;
|
|
215032
215310
|
cancelTimers = /* @__PURE__ */ new Map();
|
|
215311
|
+
coldCancelTimers = /* @__PURE__ */ new Map();
|
|
215033
215312
|
slackRetryTimers = /* @__PURE__ */ new Map();
|
|
215034
215313
|
slackRetryRuns = /* @__PURE__ */ new Map();
|
|
215035
215314
|
constructor(opts = {}) {
|
|
@@ -215144,6 +215423,11 @@ var Daemon = class Daemon {
|
|
|
215144
215423
|
socketPath: mcpSocketPath(root),
|
|
215145
215424
|
log: this.log,
|
|
215146
215425
|
now: () => Date.now(),
|
|
215426
|
+
canRun: (ctx) => {
|
|
215427
|
+
const key = sessionKey(ctx.platform, ctx.channel, ctx.thread, ctx.agentId);
|
|
215428
|
+
const active = this.activeGateEntries.get(key);
|
|
215429
|
+
return !this.paused(ctx.agentId) && active !== void 0 && !active.cancelledReason;
|
|
215430
|
+
},
|
|
215147
215431
|
gatewayFor: (integrationId) => this.connByIntegration.get(integrationId) ?? this.tgConnByIntegration.get(integrationId) ?? this.dcConnByIntegration.get(integrationId),
|
|
215148
215432
|
channelAgents: (req) => {
|
|
215149
215433
|
const client = this.cpClient;
|
|
@@ -215333,33 +215617,34 @@ var Daemon = class Daemon {
|
|
|
215333
215617
|
}).join(", ")}]`);
|
|
215334
215618
|
else this.log.debug(`reconcile: no changes (${desired.length} desired agent(s))`);
|
|
215335
215619
|
for (const id of toStop) {
|
|
215336
|
-
const
|
|
215337
|
-
|
|
215338
|
-
|
|
215339
|
-
this.hosts.delete(id);
|
|
215340
|
-
}
|
|
215341
|
-
this.hostStarts.delete(id);
|
|
215620
|
+
const wasDraining = this.drainingAgents.has(id);
|
|
215621
|
+
this.drainingAgents.add(id);
|
|
215622
|
+
this.interruptAgentTurns(id, "stop");
|
|
215342
215623
|
this.agents.delete(id);
|
|
215343
215624
|
this.scheduler.unregister(id);
|
|
215625
|
+
await this.stopHost(id);
|
|
215626
|
+
if (!wasDraining && !this.moveStagedAgents.has(id)) this.drainingAgents.delete(id);
|
|
215344
215627
|
}
|
|
215345
215628
|
let connectionsDirty = toStart.length > 0 || toStop.length > 0;
|
|
215346
215629
|
for (const change of toChange) {
|
|
215347
215630
|
const a = change.agent;
|
|
215631
|
+
const wasPaused = this.agents.get(a.id)?.pause === true;
|
|
215348
215632
|
this.agents.set(a.id, a);
|
|
215633
|
+
if (!wasPaused && a.pause) this.interruptAgentTurns(a.id, "pause");
|
|
215349
215634
|
this.scheduler.sync(a.id, a.crons);
|
|
215350
215635
|
if (change.hostRespawn || change.workspace) {
|
|
215351
|
-
const
|
|
215352
|
-
|
|
215353
|
-
|
|
215354
|
-
|
|
215355
|
-
|
|
215356
|
-
this.hostStarts.delete(a.id);
|
|
215636
|
+
const wasDraining = this.drainingAgents.has(a.id);
|
|
215637
|
+
this.drainingAgents.add(a.id);
|
|
215638
|
+
this.interruptAgentTurns(a.id, "stop");
|
|
215639
|
+
await this.stopHost(a.id);
|
|
215640
|
+
if (!wasDraining) this.drainingAgents.delete(a.id);
|
|
215357
215641
|
}
|
|
215358
215642
|
if (change.workspace) this.prefetchClone(a);
|
|
215359
215643
|
if (change.integrations) connectionsDirty = true;
|
|
215360
215644
|
}
|
|
215361
215645
|
for (const a of toStart) {
|
|
215362
215646
|
this.agents.set(a.id, a);
|
|
215647
|
+
if (a.pause) this.purgeAgentInbox(a.id);
|
|
215363
215648
|
this.prefetchClone(a);
|
|
215364
215649
|
this.scheduler.sync(a.id, a.crons);
|
|
215365
215650
|
}
|
|
@@ -215912,6 +216197,10 @@ var Daemon = class Daemon {
|
|
|
215912
216197
|
this.canonicalizeTelegramThread(msg);
|
|
215913
216198
|
const command = parseCommand(msg.text);
|
|
215914
216199
|
if (command) {
|
|
216200
|
+
if (command.kind === "resume" && !isTrustedHumanTurn(msg)) {
|
|
216201
|
+
this.log.warn(`loop guard: ignored unauthenticated resume for ${loopGuardScope(msg)}`);
|
|
216202
|
+
return;
|
|
216203
|
+
}
|
|
215915
216204
|
this.handleCommand(command, msg);
|
|
215916
216205
|
return;
|
|
215917
216206
|
}
|
|
@@ -215926,13 +216215,13 @@ var Daemon = class Daemon {
|
|
|
215926
216215
|
return;
|
|
215927
216216
|
}
|
|
215928
216217
|
const muteKey = sessionKey(msg.platform, msg.channel, msg.thread ?? msg.msgId, result.agentId);
|
|
215929
|
-
if (this.
|
|
216218
|
+
if (this.isSessionMuted(muteKey)) {
|
|
215930
216219
|
if (result.via !== "mention") {
|
|
215931
216220
|
this.recordUnrouted(msg);
|
|
215932
216221
|
this.log.debug(`routing: dropping ${msg.msgId} for agent "${result.agentId}" (muted by !stop; awaiting @mention)`);
|
|
215933
216222
|
return;
|
|
215934
216223
|
}
|
|
215935
|
-
this.
|
|
216224
|
+
this.setSessionMuted(muteKey, false);
|
|
215936
216225
|
this.log.info(`routing: agent "${result.agentId}" un-muted in ch=${msg.channel} (explicit @mention)`);
|
|
215937
216226
|
}
|
|
215938
216227
|
this.log.info(`routing: ch=${msg.channel} → agent "${result.agentId}" (integration ${result.integrationId})`);
|
|
@@ -215983,7 +216272,7 @@ var Daemon = class Daemon {
|
|
|
215983
216272
|
reason: "no_agent"
|
|
215984
216273
|
};
|
|
215985
216274
|
}
|
|
215986
|
-
if (this.
|
|
216275
|
+
if (this.paused(result.agentId)) {
|
|
215987
216276
|
this.log.info(`webchat: agent "${result.agentId}" is paused — rejecting turn`);
|
|
215988
216277
|
return {
|
|
215989
216278
|
accepted: false,
|
|
@@ -215991,6 +216280,14 @@ var Daemon = class Daemon {
|
|
|
215991
216280
|
reason: "paused"
|
|
215992
216281
|
};
|
|
215993
216282
|
}
|
|
216283
|
+
if (this.safetyDrainingAgents.has(result.agentId)) {
|
|
216284
|
+
this.log.info(`webchat: agent "${result.agentId}" is stopping an interrupted turn — rejecting turn`);
|
|
216285
|
+
return {
|
|
216286
|
+
accepted: false,
|
|
216287
|
+
turnId,
|
|
216288
|
+
reason: "busy"
|
|
216289
|
+
};
|
|
216290
|
+
}
|
|
215994
216291
|
if (this.draining || this.drainingAgents.has(result.agentId)) {
|
|
215995
216292
|
this.log.info(`webchat: agent "${result.agentId}" is draining — rejecting turn`);
|
|
215996
216293
|
return {
|
|
@@ -216014,6 +216311,15 @@ var Daemon = class Daemon {
|
|
|
216014
216311
|
isDm: true,
|
|
216015
216312
|
trigger: "dm"
|
|
216016
216313
|
};
|
|
216314
|
+
const key = this.webchatSessionKey(chatId, result.agentId);
|
|
216315
|
+
if (this.inflight.has(key) && (this.serialQueue.get(key)?.length ?? 0) >= MAX_QUEUED_PER_SESSION) {
|
|
216316
|
+
this.log.warn(`webchat: queue full for session ${key} — rejecting turn`);
|
|
216317
|
+
return {
|
|
216318
|
+
accepted: false,
|
|
216319
|
+
turnId,
|
|
216320
|
+
reason: "busy"
|
|
216321
|
+
};
|
|
216322
|
+
}
|
|
216017
216323
|
this.dispatch(result.agentId, msg, void 0, {
|
|
216018
216324
|
conversationId: chatId,
|
|
216019
216325
|
turnId,
|
|
@@ -216134,6 +216440,10 @@ var Daemon = class Daemon {
|
|
|
216134
216440
|
this.interruptTurn(p.agentId, p.sessionKey, "cancel", acpSessionId);
|
|
216135
216441
|
return;
|
|
216136
216442
|
}
|
|
216443
|
+
for (const [key, entry] of this.activeGateEntries) if (entry.webchat?.conversationId === conversationId) {
|
|
216444
|
+
this.interruptTurn(entry.agentId, key, "cancel");
|
|
216445
|
+
return;
|
|
216446
|
+
}
|
|
216137
216447
|
for (const [key, entries] of this.serialQueue) {
|
|
216138
216448
|
const hit = entries.find((e) => e.webchat?.conversationId === conversationId);
|
|
216139
216449
|
if (hit) {
|
|
@@ -216187,6 +216497,31 @@ var Daemon = class Daemon {
|
|
|
216187
216497
|
};
|
|
216188
216498
|
}
|
|
216189
216499
|
const normalized = msg.payload;
|
|
216500
|
+
const command = parseCommand(normalized.text);
|
|
216501
|
+
if (command) {
|
|
216502
|
+
if (command.kind === "resume" && !isTrustedHumanTurn(normalized)) {
|
|
216503
|
+
this.log.warn(`loop guard: ignored unauthenticated relay resume for ${loopGuardScope(normalized)}`);
|
|
216504
|
+
return {
|
|
216505
|
+
msgId: msg.msgId,
|
|
216506
|
+
accepted: false,
|
|
216507
|
+
reason: "unauthorized"
|
|
216508
|
+
};
|
|
216509
|
+
}
|
|
216510
|
+
const target = this.resolveExplicitCommandTarget(msg.agentId, msg.integrationId, normalized);
|
|
216511
|
+
if (!target) {
|
|
216512
|
+
this.log.warn(`relay: unauthorized command from ${normalized.sender.id} for agent ${msg.agentId}`);
|
|
216513
|
+
return {
|
|
216514
|
+
msgId: msg.msgId,
|
|
216515
|
+
accepted: false,
|
|
216516
|
+
reason: "unauthorized"
|
|
216517
|
+
};
|
|
216518
|
+
}
|
|
216519
|
+
this.handleCommand(command, normalized, target);
|
|
216520
|
+
return {
|
|
216521
|
+
msgId: msg.msgId,
|
|
216522
|
+
accepted: true
|
|
216523
|
+
};
|
|
216524
|
+
}
|
|
216190
216525
|
this.dispatch(msg.agentId, normalized, msg.integrationId).catch((err) => this.log.error(`relay im dispatch failed for agent "${msg.agentId}": ${formatErr(err)}`));
|
|
216191
216526
|
return {
|
|
216192
216527
|
msgId: msg.msgId,
|
|
@@ -216294,6 +216629,16 @@ var Daemon = class Daemon {
|
|
|
216294
216629
|
this.agentCallDeliveries.set(deliveryId, result);
|
|
216295
216630
|
return result;
|
|
216296
216631
|
};
|
|
216632
|
+
const callerKey = sessionKey(platform, req.callerChannel, req.callerThread, req.callerAgentId);
|
|
216633
|
+
const inbound = this.activeTurnCallMeta.get(callerKey);
|
|
216634
|
+
const sourceHopCount = inbound?.hopCount ?? 0;
|
|
216635
|
+
if (inbound !== void 0 && sourceHopCount >= MAX_AGENT_CALL_HOPS) return record({
|
|
216636
|
+
delivered: false,
|
|
216637
|
+
targetSession,
|
|
216638
|
+
reason: "hop_limit"
|
|
216639
|
+
});
|
|
216640
|
+
const isReply = inbound !== void 0 && req.toAgentId === inbound.callFrom;
|
|
216641
|
+
const correlationId = req.correlationId !== void 0 ? req.correlationId : isReply ? inbound.correlationId : void 0;
|
|
216297
216642
|
const target = this.agents.get(req.toAgentId);
|
|
216298
216643
|
if (!target) {
|
|
216299
216644
|
const coordPlatform = platform === "hook" ? "slack" : platform;
|
|
@@ -216302,7 +216647,9 @@ var Daemon = class Daemon {
|
|
|
216302
216647
|
channel,
|
|
216303
216648
|
thread,
|
|
216304
216649
|
deliveryId,
|
|
216305
|
-
targetSession
|
|
216650
|
+
targetSession,
|
|
216651
|
+
sourceHopCount,
|
|
216652
|
+
correlationId
|
|
216306
216653
|
}));
|
|
216307
216654
|
}
|
|
216308
216655
|
if (target.callPolicy === "selected" && !target.allowedCallerAgentIds.includes(req.callerAgentId)) {
|
|
@@ -216313,16 +216660,7 @@ var Daemon = class Daemon {
|
|
|
216313
216660
|
reason: "not_allowed"
|
|
216314
216661
|
});
|
|
216315
216662
|
}
|
|
216316
|
-
const callerKey = sessionKey(platform, req.callerChannel, req.callerThread, req.callerAgentId);
|
|
216317
|
-
const inbound = this.activeTurnCallMeta.get(callerKey);
|
|
216318
216663
|
const hopCount = inbound ? inbound.hopCount + 1 : 0;
|
|
216319
|
-
if (hopCount > MAX_AGENT_CALL_HOPS) return record({
|
|
216320
|
-
delivered: false,
|
|
216321
|
-
targetSession,
|
|
216322
|
-
reason: "hop_limit"
|
|
216323
|
-
});
|
|
216324
|
-
const isReply = inbound !== void 0 && req.toAgentId === inbound.callFrom;
|
|
216325
|
-
const correlationId = req.correlationId !== void 0 ? req.correlationId : isReply ? inbound.correlationId : void 0;
|
|
216326
216664
|
const callMeta = {
|
|
216327
216665
|
callFrom: req.callerAgentId,
|
|
216328
216666
|
...correlationId !== void 0 ? { correlationId } : {},
|
|
@@ -216359,9 +216697,8 @@ var Daemon = class Daemon {
|
|
|
216359
216697
|
* §2.3/§6.2/§6.4, P2) and map the relay's typed admission verdict to a
|
|
216360
216698
|
* {@link MessageAgentResult}. The `claimedFromAgentId` is our trusted caller — the
|
|
216361
216699
|
* relay re-validates it against our authenticated daemonId (a forged claim is
|
|
216362
|
-
* rejected there). `
|
|
216363
|
-
*
|
|
216364
|
-
* the CP — only the relay.
|
|
216700
|
+
* rejected there). `sourceHopCount` is the trusted source turn's depth; the relay
|
|
216701
|
+
* increments it (+1, cap 8). The body never touches the CP — only the relay.
|
|
216365
216702
|
*/
|
|
216366
216703
|
async routeAgentMsgCrossDaemon(req, ctx) {
|
|
216367
216704
|
if (!this.relays) return {
|
|
@@ -216379,8 +216716,8 @@ var Daemon = class Daemon {
|
|
|
216379
216716
|
channel: ctx.channel,
|
|
216380
216717
|
...ctx.thread !== void 0 ? { thread: ctx.thread } : {}
|
|
216381
216718
|
},
|
|
216382
|
-
...
|
|
216383
|
-
hopCount:
|
|
216719
|
+
...ctx.correlationId !== void 0 ? { correlationId: ctx.correlationId } : {},
|
|
216720
|
+
hopCount: ctx.sourceHopCount,
|
|
216384
216721
|
deliveryId: ctx.deliveryId
|
|
216385
216722
|
});
|
|
216386
216723
|
if (ack.delivered) return {
|
|
@@ -216651,7 +216988,7 @@ var Daemon = class Daemon {
|
|
|
216651
216988
|
reason: "no_agent"
|
|
216652
216989
|
};
|
|
216653
216990
|
}
|
|
216654
|
-
if (this.
|
|
216991
|
+
if (this.paused(msg.agentId)) {
|
|
216655
216992
|
this.log.info(`hook: agent "${msg.agentId}" is paused — rejecting fire ${msg.msgId}`);
|
|
216656
216993
|
return {
|
|
216657
216994
|
msgId: msg.msgId,
|
|
@@ -216659,6 +216996,14 @@ var Daemon = class Daemon {
|
|
|
216659
216996
|
reason: "paused"
|
|
216660
216997
|
};
|
|
216661
216998
|
}
|
|
216999
|
+
if (this.safetyDrainingAgents.has(msg.agentId)) {
|
|
217000
|
+
this.log.info(`hook: agent "${msg.agentId}" is stopping an interrupted turn — rejecting fire ${msg.msgId}`);
|
|
217001
|
+
return {
|
|
217002
|
+
msgId: msg.msgId,
|
|
217003
|
+
accepted: false,
|
|
217004
|
+
reason: "busy"
|
|
217005
|
+
};
|
|
217006
|
+
}
|
|
216662
217007
|
if (this.draining || this.drainingAgents.has(msg.agentId)) {
|
|
216663
217008
|
this.log.info(`hook: agent "${msg.agentId}" is draining — rejecting fire ${msg.msgId}`);
|
|
216664
217009
|
return {
|
|
@@ -216877,10 +217222,20 @@ var Daemon = class Daemon {
|
|
|
216877
217222
|
}
|
|
216878
217223
|
inflight = /* @__PURE__ */ new Set();
|
|
216879
217224
|
serialQueue = /* @__PURE__ */ new Map();
|
|
217225
|
+
/** Current head for every owned serial gate, including the cold pre-Pending phase.
|
|
217226
|
+
* Lets pause/loop trip latch cancellation onto work that cannot yet be found through
|
|
217227
|
+
* `pending` and would otherwise revive after a quick reset. */
|
|
217228
|
+
activeGateEntries = /* @__PURE__ */ new Map();
|
|
216880
217229
|
/** §6.9 #353: stable ids of messages currently backed by a durable inbox row (admitted,
|
|
216881
217230
|
* not yet terminal). Guards startup replay from re-admitting a row whose entry is already
|
|
216882
217231
|
* live in the gate (idempotency — a duplicate/in-flight id is not double-processed). */
|
|
216883
217232
|
liveInboxIds = /* @__PURE__ */ new Set();
|
|
217233
|
+
isSessionMuted(key) {
|
|
217234
|
+
return this.store.isSessionMuted(key);
|
|
217235
|
+
}
|
|
217236
|
+
setSessionMuted(key, muted) {
|
|
217237
|
+
this.store.setSessionMuted(key, muted);
|
|
217238
|
+
}
|
|
216884
217239
|
/**
|
|
216885
217240
|
* Resolve a command's target from the channel's latest session when the routing ladder
|
|
216886
217241
|
* couldn't (no mention entity / thread / dm rule matched — e.g. a group `/status@bot`).
|
|
@@ -216902,25 +217257,71 @@ var Daemon = class Daemon {
|
|
|
216902
217257
|
via: "thread"
|
|
216903
217258
|
};
|
|
216904
217259
|
}
|
|
217260
|
+
/** Validate the relay-arbitrated command target against the local agent spec. Shared
|
|
217261
|
+
* bot IMs bypass routeRules' arbitration, but must retain its bot rejection and
|
|
217262
|
+
* per-integration allowedUserIds authorization before executing a control command. */
|
|
217263
|
+
resolveExplicitCommandTarget(agentId, integrationId, msg) {
|
|
217264
|
+
if (!this.commandSenderAllowed(agentId, integrationId, msg)) return null;
|
|
217265
|
+
return {
|
|
217266
|
+
agentId,
|
|
217267
|
+
integrationId,
|
|
217268
|
+
via: "thread"
|
|
217269
|
+
};
|
|
217270
|
+
}
|
|
217271
|
+
/** Recovery path for a channel-wide Slack top-level loop latch. The triggering
|
|
217272
|
+
* message itself was rejected, so its warning thread may have no thread owner or
|
|
217273
|
+
* latest session to route a bare `!resume` through. Select only an integration
|
|
217274
|
+
* that independently authorizes this human, preferring an explicitly mentioned bot. */
|
|
217275
|
+
resolveTopLevelResumeTarget(msg) {
|
|
217276
|
+
if (msg.platform !== "slack" || msg.isDm || !this.store.isLoopGuardOpen(slackTopLevelLoopGuardScope(msg.channel))) return null;
|
|
217277
|
+
const candidates = [];
|
|
217278
|
+
for (const [agentId, agent] of this.agents) for (const integration of agent.integrations) {
|
|
217279
|
+
if (integration.platform !== "slack" || !this.commandSenderAllowed(agentId, integration.id, msg)) continue;
|
|
217280
|
+
const mentioned = integration.slack.botUserId !== void 0 && msg.mentionedBots.includes(integration.slack.botUserId);
|
|
217281
|
+
candidates.push({
|
|
217282
|
+
agentId,
|
|
217283
|
+
integrationId: integration.id,
|
|
217284
|
+
via: mentioned ? "mention" : "auto",
|
|
217285
|
+
mentioned
|
|
217286
|
+
});
|
|
217287
|
+
}
|
|
217288
|
+
candidates.sort((a, b) => Number(b.mentioned) - Number(a.mentioned) || a.agentId.localeCompare(b.agentId) || a.integrationId.localeCompare(b.integrationId));
|
|
217289
|
+
return candidates[0] ?? null;
|
|
217290
|
+
}
|
|
217291
|
+
/** Final command authorization at the concrete integration. CP routing rules do not
|
|
217292
|
+
* carry allowedUserIds themselves, so routing success alone is not an auth verdict. */
|
|
217293
|
+
commandSenderAllowed(agentId, integrationId, msg) {
|
|
217294
|
+
if (msg.sender.isBot) return false;
|
|
217295
|
+
const integration = this.agents.get(agentId)?.integrations.find((candidate) => candidate.id === integrationId && candidate.platform === msg.platform);
|
|
217296
|
+
if (!integration) return false;
|
|
217297
|
+
const allowed = integration.platform === "telegram" ? integration.telegram.allowedUserIds : integration.platform === "discord" ? integration.discord.allowedUserIds : integration.slack.allowedUserIds;
|
|
217298
|
+
return allowed.length === 0 || allowed.includes(msg.sender.id);
|
|
217299
|
+
}
|
|
216905
217300
|
/**
|
|
216906
217301
|
* Handle an in-conversation control command. Resolves the target agent via the
|
|
216907
217302
|
* same routing ladder as a normal message (so thread-affinity + per-integration
|
|
216908
217303
|
* `allowedUserIds` authz apply), then acts on that agent's session in this
|
|
216909
217304
|
* (channel, thread).
|
|
216910
217305
|
*/
|
|
216911
|
-
handleCommand(command, msg) {
|
|
216912
|
-
let target = routeRules(msg, this.mergedRules(), (c, t) => this.sessions.threadOwner(c, t));
|
|
217306
|
+
handleCommand(command, msg, explicitTarget) {
|
|
217307
|
+
let target = explicitTarget ?? routeRules(msg, this.mergedRules(), (c, t) => this.sessions.threadOwner(c, t));
|
|
216913
217308
|
if (!target) target = this.resolveCommandTargetFromLatest(msg);
|
|
217309
|
+
if (!target && command.kind === "resume") target = this.resolveTopLevelResumeTarget(msg);
|
|
216914
217310
|
if (!target) {
|
|
216915
217311
|
this.log.debug(`command: '${command.kind}' in ch=${msg.channel} — no agent resolved, ignoring`);
|
|
216916
217312
|
return;
|
|
216917
217313
|
}
|
|
217314
|
+
if (!this.commandSenderAllowed(target.agentId, target.integrationId, msg)) {
|
|
217315
|
+
this.log.warn(`command: '${command.kind}' rejected for unauthorized sender ${msg.sender.id}`);
|
|
217316
|
+
return;
|
|
217317
|
+
}
|
|
216918
217318
|
const conn = this.replyConnFor(target.agentId, target.integrationId);
|
|
216919
217319
|
const replyThread = msg.thread ?? msg.msgId;
|
|
216920
217320
|
let thread = replyThread;
|
|
216921
217321
|
let key = sessionKey(msg.platform, msg.channel, thread, target.agentId);
|
|
216922
217322
|
let rec = this.store.getSession(key);
|
|
216923
|
-
|
|
217323
|
+
const directGateActive = this.inflight.has(key) || this.activeGateEntries.has(key) || (this.serialQueue.get(key)?.length ?? 0) > 0;
|
|
217324
|
+
if (!rec && !directGateActive) {
|
|
216924
217325
|
const latest = this.store.latestSession(target.agentId, msg.channel);
|
|
216925
217326
|
if (latest) {
|
|
216926
217327
|
rec = latest;
|
|
@@ -216936,8 +217337,28 @@ var Daemon = class Daemon {
|
|
|
216936
217337
|
if (msg.platform === "telegram") conn.postMessage(msg.channel, text, replyThread, tgReplyTo);
|
|
216937
217338
|
else conn.postMessage(msg.channel, text, replyThread);
|
|
216938
217339
|
};
|
|
217340
|
+
if (command.kind === "resume") {
|
|
217341
|
+
const directScope = thread === replyThread ? loopGuardScope(msg) : loopGuardScopeFromCoords(msg.platform, msg.channel, thread, msg.isDm);
|
|
217342
|
+
const topLevelScope = msg.platform === "slack" && !msg.isDm ? slackTopLevelLoopGuardScope(msg.channel) : void 0;
|
|
217343
|
+
const scope = topLevelScope && this.store.isLoopGuardOpen(topLevelScope) ? topLevelScope : directScope;
|
|
217344
|
+
if ([...this.activeGateEntries.values()].some((entry) => entry.cancelledReason === "loop protection" && loopGuardScope(entry.msg) === scope) || [...this.pending.values()].some((pending) => {
|
|
217345
|
+
return pending.outputSuppressed === "loop protection" && pending.loopGuardScope === scope;
|
|
217346
|
+
})) {
|
|
217347
|
+
reply("Loop protection is still stopping the previous turn. Try `!resume` again in a moment.");
|
|
217348
|
+
return;
|
|
217349
|
+
}
|
|
217350
|
+
const wasOpen = this.store.isLoopGuardOpen(scope);
|
|
217351
|
+
this.store.resetLoopGuard(scope);
|
|
217352
|
+
const wasMuted = this.isSessionMuted(key);
|
|
217353
|
+
if (wasMuted) this.setSessionMuted(key, false);
|
|
217354
|
+
if (wasOpen || wasMuted) {
|
|
217355
|
+
this.log.info(`loop guard: explicitly reset ${scope} by ${msg.sender.id}`);
|
|
217356
|
+
reply("▶️ Resumed. Loop protection is reset; send a new message to continue.");
|
|
217357
|
+
} else reply("Loop protection is not active in this conversation.");
|
|
217358
|
+
return;
|
|
217359
|
+
}
|
|
216939
217360
|
if (command.kind === "stop") {
|
|
216940
|
-
if (rec) this.
|
|
217361
|
+
if (rec || inflight) this.setSessionMuted(key, true);
|
|
216941
217362
|
const muteNote = "Muted in this thread — @mention me to resume.";
|
|
216942
217363
|
if (!inflight) {
|
|
216943
217364
|
reply(rec ? `🔇 Nothing is running. ${muteNote}` : "Nothing is running to stop.");
|
|
@@ -217245,6 +217666,7 @@ var Daemon = class Daemon {
|
|
|
217245
217666
|
integrationId: entry.integrationId ?? null,
|
|
217246
217667
|
callMeta: entry.callMeta ? JSON.stringify(entry.callMeta) : null,
|
|
217247
217668
|
isQueueCmd: entry.isQueueCmd ? 1 : null,
|
|
217669
|
+
loopGuardCounted: 1,
|
|
217248
217670
|
enqueuedAt: monotonicTs()
|
|
217249
217671
|
});
|
|
217250
217672
|
entry.inboxId = id;
|
|
@@ -217266,6 +217688,94 @@ var Daemon = class Daemon {
|
|
|
217266
217688
|
this.log.warn(`durable inbox: remove failed for ${entry.inboxId}: ${err.message}`);
|
|
217267
217689
|
}
|
|
217268
217690
|
}
|
|
217691
|
+
/** Pause is an explicit operator stop, so admitted work for that agent becomes
|
|
217692
|
+
* terminal instead of surviving as restart replay. */
|
|
217693
|
+
purgeAgentInbox(agentId) {
|
|
217694
|
+
try {
|
|
217695
|
+
const ids = this.store.removeInboxByAgentId(agentId);
|
|
217696
|
+
for (const id of ids) this.liveInboxIds.delete(id);
|
|
217697
|
+
if (ids.length > 0) this.log.warn(`durable inbox: purged ${ids.length} row(s) for paused agent "${agentId}"`);
|
|
217698
|
+
return ids.length;
|
|
217699
|
+
} catch (err) {
|
|
217700
|
+
this.log.error(`durable inbox: failed to purge paused agent "${agentId}": ${err.message}`);
|
|
217701
|
+
return 0;
|
|
217702
|
+
}
|
|
217703
|
+
}
|
|
217704
|
+
/** Inbox rows intentionally keep the complete normalized message instead of a
|
|
217705
|
+
* duplicated conversation column. A loop trip is rare, so scanning this small,
|
|
217706
|
+
* already-bounded backlog keeps the persisted schema compatible while purging every
|
|
217707
|
+
* agent/session in the affected conversation. */
|
|
217708
|
+
purgeLoopScopeInbox(scope) {
|
|
217709
|
+
let removed = 0;
|
|
217710
|
+
try {
|
|
217711
|
+
for (const row of this.store.listInboxBySessionKeyFifo()) {
|
|
217712
|
+
let msg;
|
|
217713
|
+
try {
|
|
217714
|
+
msg = JSON.parse(row.msg);
|
|
217715
|
+
} catch {
|
|
217716
|
+
continue;
|
|
217717
|
+
}
|
|
217718
|
+
if (loopGuardScope(msg) !== scope) continue;
|
|
217719
|
+
this.store.removeInbox(row.id);
|
|
217720
|
+
this.liveInboxIds.delete(row.id);
|
|
217721
|
+
removed++;
|
|
217722
|
+
}
|
|
217723
|
+
} catch (err) {
|
|
217724
|
+
this.log.error(`loop guard: failed to purge inbox for ${scope}: ${err.message}`);
|
|
217725
|
+
}
|
|
217726
|
+
return removed;
|
|
217727
|
+
}
|
|
217728
|
+
isLoopGuardOpen(msg) {
|
|
217729
|
+
return usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopGuardScope(msg));
|
|
217730
|
+
}
|
|
217731
|
+
/** First-open side effects for a durable conversation circuit: purge restart work,
|
|
217732
|
+
* drop every matching serial queue, cancel live ACP turns across ALL agents sharing
|
|
217733
|
+
* the conversation, and emit exactly one operator-facing warning. */
|
|
217734
|
+
onLoopGuardTripped(scope, reason, trigger) {
|
|
217735
|
+
const purged = this.purgeLoopScopeInbox(scope);
|
|
217736
|
+
const targets = /* @__PURE__ */ new Map();
|
|
217737
|
+
for (const [key, entry] of this.activeGateEntries) {
|
|
217738
|
+
if (loopGuardScope(entry.msg) !== scope) continue;
|
|
217739
|
+
entry.cancelledReason = "loop protection";
|
|
217740
|
+
targets.set(key, { agentId: entry.agentId });
|
|
217741
|
+
}
|
|
217742
|
+
for (const [acpSessionId, pending] of this.pending) if (pending.loopGuardScope === scope) targets.set(pending.sessionKey, {
|
|
217743
|
+
agentId: pending.agentId,
|
|
217744
|
+
acpSessionId
|
|
217745
|
+
});
|
|
217746
|
+
for (const [key, queued] of this.serialQueue) {
|
|
217747
|
+
const entry = queued.find((candidate) => loopGuardScope(candidate.msg) === scope);
|
|
217748
|
+
if (entry && !targets.has(key)) targets.set(key, { agentId: entry.agentId });
|
|
217749
|
+
}
|
|
217750
|
+
for (const [key, target] of targets) this.interruptTurn(target.agentId, key, "loop protection", target.acpSessionId, { dropQueued: true });
|
|
217751
|
+
this.log.warn(`loop guard: OPEN ${scope} reason=${reason}; interrupted=${targets.size}, purgedInbox=${purged}; explicit !resume required`);
|
|
217752
|
+
if (trigger.msg.headless) return;
|
|
217753
|
+
const conn = this.replyConnFor(trigger.agentId, trigger.integrationId);
|
|
217754
|
+
if (!conn) return;
|
|
217755
|
+
const warningThread = reason === "malformed_platform_event" && trigger.msg.isDm ? void 0 : trigger.msg.thread;
|
|
217756
|
+
conn.postMessage(trigger.msg.channel, "⚠️ Loop protection stopped this conversation and discarded its queued backlog. Send `!resume` when it is safe to continue.", warningThread).catch((err) => this.log.warn(`loop guard: warning post failed for ${scope}: ${err.message}`));
|
|
217757
|
+
}
|
|
217758
|
+
/** Count a genuine admission and run first-open side effects if its fixed-window
|
|
217759
|
+
* budget is exhausted. Returns false when this incoming turn must not enter the
|
|
217760
|
+
* serial gate. */
|
|
217761
|
+
admitLoopGuardTurn(agentId, msg, integrationId, inboxReplayId) {
|
|
217762
|
+
if (!usesLoopGuard(msg)) return true;
|
|
217763
|
+
const scope = loopGuardScope(msg);
|
|
217764
|
+
const limits = {
|
|
217765
|
+
windowMs: LOOP_GUARD_WINDOW_MS,
|
|
217766
|
+
maxTotal: MAX_TOTAL_TURNS_PER_WINDOW,
|
|
217767
|
+
maxAutomatic: MAX_AUTOMATIC_TURNS_PER_WINDOW
|
|
217768
|
+
};
|
|
217769
|
+
const verdict = inboxReplayId ? this.store.recordLoopGuardTurnForInbox(inboxReplayId, scope, this.clock.now(), !isTrustedHumanTurn(msg), limits) : this.store.recordLoopGuardTurn(scope, this.clock.now(), !isTrustedHumanTurn(msg), limits);
|
|
217770
|
+
if (verdict.allowed) return true;
|
|
217771
|
+
if (verdict.trippedNow) this.onLoopGuardTripped(scope, verdict.reason ?? "turn_burst", {
|
|
217772
|
+
agentId,
|
|
217773
|
+
msg,
|
|
217774
|
+
integrationId
|
|
217775
|
+
});
|
|
217776
|
+
else this.purgeLoopScopeInbox(scope);
|
|
217777
|
+
return false;
|
|
217778
|
+
}
|
|
217269
217779
|
/**
|
|
217270
217780
|
* Terminate a QUEUED entry's webchat sink when it is gate-dropped/rejected without ever
|
|
217271
217781
|
* entering dispatchOne (drain/pause gate-drop, fail-stop of the queued rest, `!cancel`/`!stop`
|
|
@@ -217275,7 +217785,8 @@ var Daemon = class Daemon {
|
|
|
217275
217785
|
* entries. Pass an `error` for reject/cancel/shutdown; omit it for a clean gate-drop.
|
|
217276
217786
|
*/
|
|
217277
217787
|
terminateQueuedSink(entry, error) {
|
|
217278
|
-
if (!entry.webchat) return;
|
|
217788
|
+
if (!entry.webchat || entry.webchat.doneSent) return;
|
|
217789
|
+
entry.webchat.doneSent = true;
|
|
217279
217790
|
entry.webchat.sink.done({
|
|
217280
217791
|
conversationId: entry.webchat.conversationId,
|
|
217281
217792
|
turnId: entry.webchat.turnId,
|
|
@@ -217289,15 +217800,54 @@ var Daemon = class Daemon {
|
|
|
217289
217800
|
resolve(null);
|
|
217290
217801
|
return;
|
|
217291
217802
|
}
|
|
217292
|
-
if (this.
|
|
217803
|
+
if (this.paused(agentId)) {
|
|
217293
217804
|
this.log.info(`dispatch: skipped for agent "${agentId}" (paused)`);
|
|
217294
217805
|
resolve(null);
|
|
217295
217806
|
return;
|
|
217296
217807
|
}
|
|
217808
|
+
if (this.safetyDrainingAgents.has(agentId)) {
|
|
217809
|
+
if (opts?.fromInboxReplay) this.waitForSafetyDrain(agentId).then(() => {
|
|
217810
|
+
if (this.draining || !this.agents.has(agentId)) {
|
|
217811
|
+
resolve(null);
|
|
217812
|
+
return;
|
|
217813
|
+
}
|
|
217814
|
+
if (opts.inboxReplayId !== void 0 && !this.store.hasInbox(opts.inboxReplayId)) {
|
|
217815
|
+
resolve(null);
|
|
217816
|
+
return;
|
|
217817
|
+
}
|
|
217818
|
+
this.dispatch(agentId, msg, integrationId, webchat, callMeta, opts, githubReply).then(resolve, reject);
|
|
217819
|
+
}).catch(reject);
|
|
217820
|
+
else {
|
|
217821
|
+
this.log.info(`dispatch: skipped for agent "${agentId}" (stopping interrupted work)`);
|
|
217822
|
+
resolve(null);
|
|
217823
|
+
}
|
|
217824
|
+
return;
|
|
217825
|
+
}
|
|
217297
217826
|
const key = sessionKey(msg.platform, msg.channel, msg.thread ?? msg.msgId, agentId);
|
|
217827
|
+
const loopScope = loopGuardScope(msg);
|
|
217828
|
+
if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
|
|
217829
|
+
if (opts?.fromInboxReplay) this.purgeLoopScopeInbox(loopScope);
|
|
217830
|
+
this.log.warn(`dispatch: skipped ${msg.msgId}; loop guard is open for ${loopScope}`);
|
|
217831
|
+
resolve(null);
|
|
217832
|
+
return;
|
|
217833
|
+
}
|
|
217834
|
+
if (isMalformedPlatformTurn(msg)) {
|
|
217835
|
+
if (msg.isDm) {
|
|
217836
|
+
const verdict = this.store.tripLoopGuard(loopScope, this.clock.now(), "malformed_platform_event");
|
|
217837
|
+
if (verdict.trippedNow) this.onLoopGuardTripped(loopScope, verdict.reason ?? "malformed_platform_event", {
|
|
217838
|
+
agentId,
|
|
217839
|
+
msg,
|
|
217840
|
+
integrationId
|
|
217841
|
+
});
|
|
217842
|
+
else this.purgeLoopScopeInbox(loopScope);
|
|
217843
|
+
} else this.log.warn(`dispatch: dropped malformed Slack platform event ${msg.msgId}`);
|
|
217844
|
+
resolve(null);
|
|
217845
|
+
return;
|
|
217846
|
+
}
|
|
217298
217847
|
const entry = {
|
|
217299
217848
|
agentId,
|
|
217300
217849
|
msg,
|
|
217850
|
+
initAbort: new AbortController(),
|
|
217301
217851
|
...integrationId !== void 0 ? { integrationId } : {},
|
|
217302
217852
|
...webchat ? { webchat } : {},
|
|
217303
217853
|
...callMeta ? { callMeta } : {},
|
|
@@ -217313,12 +217863,20 @@ var Daemon = class Daemon {
|
|
|
217313
217863
|
reject(new QueueFullError(key));
|
|
217314
217864
|
return;
|
|
217315
217865
|
}
|
|
217866
|
+
if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
|
|
217867
|
+
resolve(null);
|
|
217868
|
+
return;
|
|
217869
|
+
}
|
|
217316
217870
|
q.push(entry);
|
|
217317
217871
|
this.serialQueue.set(key, q);
|
|
217318
217872
|
this.persistInbox(entry, key);
|
|
217319
217873
|
this.log.debug(`dispatch: queued behind in-flight turn for session ${key} (depth ${q.length})`);
|
|
217320
217874
|
return;
|
|
217321
217875
|
}
|
|
217876
|
+
if (!opts?.replay && !this.admitLoopGuardTurn(agentId, msg, integrationId, opts?.inboxReplayId)) {
|
|
217877
|
+
resolve(null);
|
|
217878
|
+
return;
|
|
217879
|
+
}
|
|
217322
217880
|
this.persistInbox(entry, key);
|
|
217323
217881
|
this.inflight.add(key);
|
|
217324
217882
|
this.runLoop(key, entry);
|
|
@@ -217336,18 +217894,63 @@ var Daemon = class Daemon {
|
|
|
217336
217894
|
paused(agentId) {
|
|
217337
217895
|
return this.agents.get(agentId)?.pause === true;
|
|
217338
217896
|
}
|
|
217339
|
-
|
|
217897
|
+
/** Await a transient interrupt drain without broadening its target set. The wait set is
|
|
217898
|
+
* additive, so loop until the runner removes the admission latch; a second interrupt
|
|
217899
|
+
* that joins while we wait is therefore included automatically. */
|
|
217900
|
+
async waitForSafetyDrain(agentId) {
|
|
217901
|
+
while (this.safetyDrainingAgents.has(agentId)) await Promise.all([...this.safetyDrainWaits.get(agentId) ?? []]);
|
|
217902
|
+
}
|
|
217903
|
+
/** Gate new work until the selected active dispatches have fully unwound. Waits are
|
|
217904
|
+
* additive: overlapping interrupts on different keys of one agent join the same drain
|
|
217905
|
+
* instead of letting the first completion reopen admission too early. */
|
|
217906
|
+
beginSafetyDrain(agentId, reason, keys) {
|
|
217907
|
+
const selected = keys === void 0 ? [...this.activeDispatchesByAgent.get(agentId) ?? []] : [...keys].map((key) => this.activeDispatchDoneByKey.get(key)).filter((done) => done !== void 0);
|
|
217908
|
+
if (selected.length === 0) return;
|
|
217909
|
+
const waits = this.safetyDrainWaits.get(agentId) ?? /* @__PURE__ */ new Set();
|
|
217910
|
+
for (const done of selected) waits.add(done);
|
|
217911
|
+
this.safetyDrainWaits.set(agentId, waits);
|
|
217912
|
+
this.safetyDrainingAgents.add(agentId);
|
|
217913
|
+
if (this.safetyDrainRuns.has(agentId)) return;
|
|
217914
|
+
const token = Symbol(agentId);
|
|
217915
|
+
this.safetyDrainRuns.set(agentId, token);
|
|
217916
|
+
(async () => {
|
|
217917
|
+
try {
|
|
217918
|
+
while (true) {
|
|
217919
|
+
const pending = [...this.safetyDrainWaits.get(agentId) ?? []];
|
|
217920
|
+
if (pending.length === 0) break;
|
|
217921
|
+
await Promise.all(pending);
|
|
217922
|
+
const current = this.safetyDrainWaits.get(agentId);
|
|
217923
|
+
for (const done of pending) current?.delete(done);
|
|
217924
|
+
}
|
|
217925
|
+
} finally {
|
|
217926
|
+
if (this.safetyDrainRuns.get(agentId) !== token) return;
|
|
217927
|
+
this.safetyDrainWaits.delete(agentId);
|
|
217928
|
+
this.safetyDrainRuns.delete(agentId);
|
|
217929
|
+
this.safetyDrainingAgents.delete(agentId);
|
|
217930
|
+
this.log.info(`${reason}: interrupted turns fully stopped for agent "${agentId}"`);
|
|
217931
|
+
}
|
|
217932
|
+
})();
|
|
217933
|
+
}
|
|
217934
|
+
dispatchGateReason(entry) {
|
|
217935
|
+
if (entry.cancelledReason) return entry.cancelledReason;
|
|
217936
|
+
if (this.paused(entry.agentId)) return "pause";
|
|
217937
|
+
if (this.isLoopGuardOpen(entry.msg)) return "loop protection";
|
|
217938
|
+
}
|
|
217939
|
+
beginActiveDispatch(agentId, key) {
|
|
217340
217940
|
let resolveDone;
|
|
217341
217941
|
const done = new Promise((resolve) => resolveDone = resolve);
|
|
217342
217942
|
const active = this.activeDispatchesByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
|
|
217343
217943
|
active.add(done);
|
|
217344
217944
|
this.activeDispatchesByAgent.set(agentId, active);
|
|
217945
|
+
this.activeDispatchDoneByKey.set(key, done);
|
|
217345
217946
|
let released = false;
|
|
217346
217947
|
return () => {
|
|
217347
217948
|
if (released) return;
|
|
217348
217949
|
released = true;
|
|
217349
217950
|
active.delete(done);
|
|
217350
217951
|
if (active.size === 0) this.activeDispatchesByAgent.delete(agentId);
|
|
217952
|
+
this.clearColdCancelBackstop(key, done);
|
|
217953
|
+
if (this.activeDispatchDoneByKey.get(key) === done) this.activeDispatchDoneByKey.delete(key);
|
|
217351
217954
|
resolveDone();
|
|
217352
217955
|
};
|
|
217353
217956
|
}
|
|
@@ -217377,53 +217980,64 @@ var Daemon = class Daemon {
|
|
|
217377
217980
|
}
|
|
217378
217981
|
async runLoop(key, firstEntry) {
|
|
217379
217982
|
let entry = firstEntry;
|
|
217380
|
-
|
|
217381
|
-
|
|
217382
|
-
this.
|
|
217383
|
-
if (
|
|
217384
|
-
|
|
217385
|
-
|
|
217386
|
-
|
|
217387
|
-
|
|
217388
|
-
this.
|
|
217389
|
-
|
|
217390
|
-
|
|
217391
|
-
|
|
217392
|
-
|
|
217393
|
-
|
|
217394
|
-
|
|
217395
|
-
|
|
217396
|
-
|
|
217397
|
-
let sessionId;
|
|
217983
|
+
try {
|
|
217984
|
+
while (entry) {
|
|
217985
|
+
this.activeGateEntries.set(key, entry);
|
|
217986
|
+
if (entry !== firstEntry && (this.draining || this.drainingAgents.has(entry.agentId) || this.paused(entry.agentId))) {
|
|
217987
|
+
this.terminateQueuedSink(entry);
|
|
217988
|
+
if (!this.draining) this.removeInbox(entry);
|
|
217989
|
+
entry.resolve(null);
|
|
217990
|
+
const rest = this.serialQueue.get(key) ?? [];
|
|
217991
|
+
this.serialQueue.delete(key);
|
|
217992
|
+
for (const e of rest) {
|
|
217993
|
+
this.terminateQueuedSink(e);
|
|
217994
|
+
if (!this.draining) this.removeInbox(e);
|
|
217995
|
+
e.resolve(null);
|
|
217996
|
+
}
|
|
217997
|
+
this.inflight.delete(key);
|
|
217998
|
+
return;
|
|
217999
|
+
}
|
|
217398
218000
|
try {
|
|
217399
|
-
|
|
217400
|
-
|
|
217401
|
-
|
|
218001
|
+
const releaseDispatch = this.beginActiveDispatch(entry.agentId, key);
|
|
218002
|
+
let sessionId;
|
|
218003
|
+
try {
|
|
218004
|
+
sessionId = await this.dispatchOne(entry, key);
|
|
218005
|
+
} finally {
|
|
218006
|
+
releaseDispatch();
|
|
218007
|
+
}
|
|
218008
|
+
if (!(this.draining && entry.cancelledReason === "shutdown")) this.removeInbox(entry);
|
|
218009
|
+
entry.resolve(sessionId);
|
|
218010
|
+
} catch (err) {
|
|
218011
|
+
if (!this.draining) this.removeInbox(entry);
|
|
218012
|
+
entry.reject(err);
|
|
218013
|
+
const rest = this.serialQueue.get(key) ?? [];
|
|
218014
|
+
this.serialQueue.delete(key);
|
|
218015
|
+
for (const e of rest) {
|
|
218016
|
+
const failStop = new FailStopError(key);
|
|
218017
|
+
this.terminateQueuedSink(e, failStop.message);
|
|
218018
|
+
if (!this.draining) this.removeInbox(e);
|
|
218019
|
+
e.reject(failStop);
|
|
218020
|
+
}
|
|
218021
|
+
this.inflight.delete(key);
|
|
218022
|
+
return;
|
|
217402
218023
|
}
|
|
217403
|
-
this.
|
|
217404
|
-
entry
|
|
217405
|
-
|
|
217406
|
-
if (!this.draining) this.removeInbox(entry);
|
|
217407
|
-
entry.reject(err);
|
|
217408
|
-
const rest = this.serialQueue.get(key) ?? [];
|
|
217409
|
-
this.serialQueue.delete(key);
|
|
217410
|
-
for (const e of rest) {
|
|
217411
|
-
const failStop = new FailStopError(key);
|
|
217412
|
-
this.terminateQueuedSink(e, failStop.message);
|
|
217413
|
-
if (!this.draining) this.removeInbox(e);
|
|
217414
|
-
e.reject(failStop);
|
|
217415
|
-
}
|
|
217416
|
-
this.inflight.delete(key);
|
|
217417
|
-
return;
|
|
218024
|
+
const q = this.serialQueue.get(key);
|
|
218025
|
+
entry = q?.shift();
|
|
218026
|
+
if (q && q.length === 0) this.serialQueue.delete(key);
|
|
217418
218027
|
}
|
|
217419
|
-
|
|
217420
|
-
|
|
217421
|
-
|
|
218028
|
+
this.inflight.delete(key);
|
|
218029
|
+
} finally {
|
|
218030
|
+
this.activeGateEntries.delete(key);
|
|
217422
218031
|
}
|
|
217423
|
-
this.inflight.delete(key);
|
|
217424
218032
|
}
|
|
217425
218033
|
async dispatchOne(entry, key) {
|
|
217426
218034
|
const { agentId, msg, integrationId, webchat, callMeta, githubReply } = entry;
|
|
218035
|
+
const initialGate = this.dispatchGateReason(entry);
|
|
218036
|
+
if (initialGate) {
|
|
218037
|
+
this.terminateQueuedSink(entry);
|
|
218038
|
+
this.log.info(`dispatch: skipped admitted turn ${msg.msgId} (${initialGate})`);
|
|
218039
|
+
return null;
|
|
218040
|
+
}
|
|
217427
218041
|
if (callMeta?.correlationId !== void 0) this.recordWorkerReport(key, callMeta, msg.text);
|
|
217428
218042
|
const agent = this.agents.get(agentId);
|
|
217429
218043
|
const rec = new TranscriptRecorder();
|
|
@@ -217436,11 +218050,15 @@ var Daemon = class Daemon {
|
|
|
217436
218050
|
const releaseReplyConn = this.holdReplyConnection(replyConn);
|
|
217437
218051
|
let handled;
|
|
217438
218052
|
try {
|
|
217439
|
-
handled = await this.sessions.handle(agentId, msg);
|
|
218053
|
+
handled = await this.sessions.handle(agentId, msg, entry.initAbort.signal);
|
|
217440
218054
|
} catch (err) {
|
|
218055
|
+
const interrupted = this.dispatchGateReason(entry);
|
|
217441
218056
|
try {
|
|
217442
218057
|
this.store.setSessionState(key, "idle", this.clock.now());
|
|
217443
|
-
|
|
218058
|
+
if (interrupted) {
|
|
218059
|
+
this.terminateQueuedSink(entry, interrupted);
|
|
218060
|
+
this.showActivity(replyConn, msg.channel, statusThread, "");
|
|
218061
|
+
} else this.surfaceTurnFailure(err, {
|
|
217444
218062
|
agentId,
|
|
217445
218063
|
webchat,
|
|
217446
218064
|
replyConn,
|
|
@@ -217451,9 +218069,27 @@ var Daemon = class Daemon {
|
|
|
217451
218069
|
} finally {
|
|
217452
218070
|
releaseReplyConn();
|
|
217453
218071
|
}
|
|
218072
|
+
if (interrupted) return null;
|
|
217454
218073
|
throw err;
|
|
217455
218074
|
}
|
|
217456
218075
|
const { sessionId, blocks, created } = handled;
|
|
218076
|
+
const initializedGate = this.dispatchGateReason(entry);
|
|
218077
|
+
if (initializedGate) {
|
|
218078
|
+
const interruptedSession = this.store.getSession(key);
|
|
218079
|
+
if (created && interruptedSession?.acpSessionId === sessionId) this.store.upsertSession({
|
|
218080
|
+
...interruptedSession,
|
|
218081
|
+
acpSessionId: null,
|
|
218082
|
+
state: "idle",
|
|
218083
|
+
lastDeliveredTs: null,
|
|
218084
|
+
updatedAt: this.clock.now()
|
|
218085
|
+
});
|
|
218086
|
+
else this.store.setSessionState(key, "idle", this.clock.now());
|
|
218087
|
+
this.showActivity(replyConn, msg.channel, statusThread, "");
|
|
218088
|
+
this.terminateQueuedSink(entry);
|
|
218089
|
+
releaseReplyConn();
|
|
218090
|
+
this.log.info(`dispatch: skipped initialized turn ${msg.msgId} (${initializedGate})`);
|
|
218091
|
+
return null;
|
|
218092
|
+
}
|
|
217457
218093
|
if (created) this.emitSessionMetadataSnapshot({
|
|
217458
218094
|
sessionId,
|
|
217459
218095
|
agentId,
|
|
@@ -217464,12 +218100,17 @@ var Daemon = class Daemon {
|
|
|
217464
218100
|
});
|
|
217465
218101
|
let resolveDone;
|
|
217466
218102
|
const done = new Promise((r) => resolveDone = r);
|
|
218103
|
+
const pendingWebchat = webchat ? Object.assign(webchat, {
|
|
218104
|
+
index: 0,
|
|
218105
|
+
replyText: ""
|
|
218106
|
+
}) : void 0;
|
|
217467
218107
|
const p = {
|
|
217468
218108
|
conv,
|
|
217469
218109
|
rec,
|
|
217470
218110
|
agentId,
|
|
217471
218111
|
platform: msg.platform,
|
|
217472
218112
|
isDm: msg.isDm,
|
|
218113
|
+
loopGuardScope: loopGuardScope(msg),
|
|
217473
218114
|
sessionKey: key,
|
|
217474
218115
|
acpSessionId: sessionId,
|
|
217475
218116
|
channel: msg.channel,
|
|
@@ -217481,11 +218122,7 @@ var Daemon = class Daemon {
|
|
|
217481
218122
|
done,
|
|
217482
218123
|
resolveDone,
|
|
217483
218124
|
...callMeta ? { callMeta } : {},
|
|
217484
|
-
...
|
|
217485
|
-
...webchat,
|
|
217486
|
-
index: 0,
|
|
217487
|
-
replyText: ""
|
|
217488
|
-
} } : {},
|
|
218125
|
+
...pendingWebchat ? { webchat: pendingWebchat } : {},
|
|
217489
218126
|
...githubReply ? { github: {
|
|
217490
218127
|
...this.makeGithubReply(agentId, githubReply, sessionId),
|
|
217491
218128
|
deferredFinalTranscript: false
|
|
@@ -217504,9 +218141,21 @@ var Daemon = class Daemon {
|
|
|
217504
218141
|
if (effortOverride) await host.setSessionEffort(sessionId, effortOverride).catch((err) => this.log.debug(`effort override "${effortOverride}" not applied: ${err.message}`));
|
|
217505
218142
|
const fastOverride = this.store.getFastModeOverride(key);
|
|
217506
218143
|
if (fastOverride !== void 0) await host.setSessionFastMode(sessionId, fastOverride).catch((err) => this.log.debug(`fast-mode override ${fastOverride} not applied: ${err.message}`));
|
|
218144
|
+
const readyGate = this.dispatchGateReason(entry);
|
|
218145
|
+
if (readyGate) {
|
|
218146
|
+
this.showActivity(replyConn, msg.channel, statusThread, "");
|
|
218147
|
+
this.terminateQueuedSink(entry);
|
|
218148
|
+
if (readyGate === "loop protection") finalPhase = "problem";
|
|
218149
|
+
this.log.info(`dispatch: skipped ready turn ${msg.msgId} (${readyGate})`);
|
|
218150
|
+
return null;
|
|
218151
|
+
}
|
|
217507
218152
|
if (!wasRunning) this.showActivity(replyConn, msg.channel, statusThread, "is thinking…");
|
|
217508
218153
|
this.emitStatusBar(p);
|
|
217509
218154
|
const { stopReason, usage } = await host.prompt(sessionId, blocks);
|
|
218155
|
+
if (p.outputSuppressed) {
|
|
218156
|
+
if (p.outputSuppressed === "loop protection") finalPhase = "problem";
|
|
218157
|
+
return null;
|
|
218158
|
+
}
|
|
217510
218159
|
if (usage) this.store.setTokenUsage(key, {
|
|
217511
218160
|
totalTokens: usage.totalTokens,
|
|
217512
218161
|
inputTokens: usage.inputTokens,
|
|
@@ -217539,12 +218188,15 @@ var Daemon = class Daemon {
|
|
|
217539
218188
|
kind: "text",
|
|
217540
218189
|
text: p.webchat.replyText
|
|
217541
218190
|
});
|
|
217542
|
-
p.webchat.
|
|
217543
|
-
|
|
217544
|
-
|
|
217545
|
-
|
|
217546
|
-
|
|
217547
|
-
|
|
218191
|
+
if (!p.webchat.doneSent) {
|
|
218192
|
+
p.webchat.doneSent = true;
|
|
218193
|
+
p.webchat.sink.done({
|
|
218194
|
+
conversationId: p.webchat.conversationId,
|
|
218195
|
+
turnId: p.webchat.turnId,
|
|
218196
|
+
...stopReason ? { stopReason } : {},
|
|
218197
|
+
...usage?.totalTokens !== void 0 ? { usage: { used: usage.totalTokens } } : {}
|
|
218198
|
+
});
|
|
218199
|
+
}
|
|
217548
218200
|
} else {
|
|
217549
218201
|
const link = this.sessionLink(sessionId);
|
|
217550
218202
|
const finals = conv instanceof TelegramConverger || conv instanceof DiscordConverger ? conv.onFinal(link) : conv.onFinal(link, this.webAppBase(), this.sharedFooterFor(agentId, integrationId));
|
|
@@ -217554,6 +218206,10 @@ var Daemon = class Daemon {
|
|
|
217554
218206
|
await p.applyChain;
|
|
217555
218207
|
} catch (err) {
|
|
217556
218208
|
this.clearIdle(p);
|
|
218209
|
+
if (p.outputSuppressed) {
|
|
218210
|
+
if (p.outputSuppressed === "loop protection") finalPhase = "problem";
|
|
218211
|
+
return null;
|
|
218212
|
+
}
|
|
217557
218213
|
finalPhase = "problem";
|
|
217558
218214
|
if (p.webchat) {
|
|
217559
218215
|
this.surfaceTurnFailure(err, {
|
|
@@ -217588,13 +218244,14 @@ var Daemon = class Daemon {
|
|
|
217588
218244
|
}
|
|
217589
218245
|
throw err;
|
|
217590
218246
|
} finally {
|
|
218247
|
+
this.clearCancelBackstop(sessionId);
|
|
218248
|
+
if (p.outputSuppressed) await p.applyChain.catch(() => {});
|
|
217591
218249
|
releaseReplyConn();
|
|
217592
218250
|
this.clearIdle(p);
|
|
217593
218251
|
this.releasePermissions(sessionId);
|
|
217594
218252
|
this.releaseElicits(sessionId);
|
|
217595
218253
|
if (callMeta) this.activeTurnCallMeta.delete(key);
|
|
217596
|
-
|
|
217597
|
-
const githubFinal = p.github && finalPhase === "end" ? p.github.collector.finalText(true) : void 0;
|
|
218254
|
+
const githubFinal = p.github && !p.outputSuppressed && finalPhase === "end" ? p.github.collector.finalText(true) : void 0;
|
|
217598
218255
|
if (p.github?.deferredFinalTranscript && githubFinal?.trim()) this.store.appendTranscript({
|
|
217599
218256
|
channel: p.channel,
|
|
217600
218257
|
thread: p.statusThread,
|
|
@@ -217603,7 +218260,7 @@ var Daemon = class Daemon {
|
|
|
217603
218260
|
kind: "text",
|
|
217604
218261
|
text: githubFinal
|
|
217605
218262
|
});
|
|
217606
|
-
if (p.github) await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
|
|
218263
|
+
if (p.github && !p.outputSuppressed) await p.github.poster.publish(githubFinal).catch((err) => this.log.warn(`github poster: final publish failed (${formatErr(err)})`));
|
|
217607
218264
|
this.pending.delete(sessionId);
|
|
217608
218265
|
this.store.setSessionState(key, "idle", this.clock.now());
|
|
217609
218266
|
this.emitSessionMetadataSnapshot({
|
|
@@ -217618,44 +218275,91 @@ var Daemon = class Daemon {
|
|
|
217618
218275
|
}
|
|
217619
218276
|
return sessionId;
|
|
217620
218277
|
}
|
|
217621
|
-
/** Interrupt a session by its LOGICAL sessionKey (shared by `!stop`, `!cancel`,
|
|
217622
|
-
* webchat cancel):
|
|
218278
|
+
/** Interrupt a session by its LOGICAL sessionKey (shared by pause, `!stop`, `!cancel`,
|
|
218279
|
+
* and webchat cancel): settle+drop anything queued behind it in the serial gate, then — if
|
|
217623
218280
|
* a turn is actually in flight (ACP session live) — release any unanswered cards, mark
|
|
217624
218281
|
* it `cancelling`, send ACP `session/cancel`, and arm the force backstop. Works for a
|
|
217625
218282
|
* queued-but-not-yet-started or cold session too (§6.9 #390): draining the queue is
|
|
217626
218283
|
* enough, no ACP id required. Muting is the CALLER's concern — `!stop` mutes,
|
|
217627
218284
|
* `!cancel`/webchat-cancel do not. */
|
|
217628
|
-
interruptTurn(agentId, key, reason, acpSessionId) {
|
|
218285
|
+
interruptTurn(agentId, key, reason, acpSessionId, opts = {}) {
|
|
218286
|
+
this.beginSafetyDrain(agentId, reason, [key]);
|
|
218287
|
+
const activeEntry = this.activeGateEntries.get(key);
|
|
218288
|
+
if (activeEntry) {
|
|
218289
|
+
activeEntry.cancelledReason ??= reason;
|
|
218290
|
+
this.removeInbox(activeEntry);
|
|
218291
|
+
}
|
|
217629
218292
|
const queued = this.serialQueue.get(key);
|
|
217630
218293
|
if (queued && queued.length > 0) {
|
|
217631
218294
|
this.serialQueue.delete(key);
|
|
217632
218295
|
for (const e of queued) {
|
|
217633
|
-
this.terminateQueuedSink(e, reason);
|
|
218296
|
+
this.terminateQueuedSink(e, opts.dropQueued ? void 0 : reason);
|
|
217634
218297
|
this.removeInbox(e);
|
|
217635
|
-
e.
|
|
218298
|
+
if (opts.dropQueued) e.resolve(null);
|
|
218299
|
+
else e.reject(new FailStopError(key));
|
|
218300
|
+
}
|
|
218301
|
+
}
|
|
218302
|
+
const livePair = acpSessionId ? [acpSessionId, this.pending.get(acpSessionId)] : [...this.pending.entries()].find(([, pending]) => pending.sessionKey === key);
|
|
218303
|
+
const liveSessionId = livePair?.[0];
|
|
218304
|
+
const live = livePair?.[1];
|
|
218305
|
+
if (live) {
|
|
218306
|
+
live.outputSuppressed ??= reason;
|
|
218307
|
+
this.clearIdle(live);
|
|
218308
|
+
this.showActivity(live.conn, live.channel, live.statusThread, "");
|
|
218309
|
+
if (live.webchat && !live.webchat.doneSent) {
|
|
218310
|
+
live.webchat.doneSent = true;
|
|
218311
|
+
live.webchat.sink.done({
|
|
218312
|
+
conversationId: live.webchat.conversationId,
|
|
218313
|
+
turnId: live.webchat.turnId,
|
|
218314
|
+
error: reason
|
|
218315
|
+
});
|
|
217636
218316
|
}
|
|
218317
|
+
} else if (activeEntry) {
|
|
218318
|
+
this.terminateQueuedSink(activeEntry, reason);
|
|
218319
|
+
this.showActivity(this.replyConnFor(activeEntry.agentId, activeEntry.integrationId), activeEntry.msg.channel, activeEntry.msg.thread ?? activeEntry.msg.msgId, "");
|
|
217637
218320
|
}
|
|
217638
|
-
this.log.info(`command: ${reason} → agent "${agentId}" session ${key}${
|
|
217639
|
-
if (!
|
|
217640
|
-
|
|
217641
|
-
|
|
218321
|
+
this.log.info(`command: ${reason} → agent "${agentId}" session ${key}${liveSessionId ? ` (${liveSessionId})` : ""}`);
|
|
218322
|
+
if (!liveSessionId || !live) {
|
|
218323
|
+
const active = this.activeDispatchDoneByKey.get(key);
|
|
218324
|
+
if (activeEntry && active) this.armColdCancelBackstop(agentId, key, reason, active);
|
|
218325
|
+
return;
|
|
218326
|
+
}
|
|
218327
|
+
this.releasePermissions(liveSessionId);
|
|
218328
|
+
this.releaseElicits(liveSessionId);
|
|
217642
218329
|
this.store.setSessionState(key, "cancelling", this.clock.now());
|
|
217643
|
-
this.hosts.get(agentId)?.cancel(
|
|
217644
|
-
this.armCancelBackstop(agentId,
|
|
217645
|
-
}
|
|
217646
|
-
/**
|
|
218330
|
+
this.hosts.get(agentId)?.cancel(liveSessionId).catch((err) => this.log.error(`command ${reason}: cancel failed: ${err.message}`));
|
|
218331
|
+
this.armCancelBackstop(agentId, liveSessionId, key, reason);
|
|
218332
|
+
}
|
|
218333
|
+
/** Interrupt every logical session owned by an agent for a lifecycle gate (pause,
|
|
218334
|
+
* removal, or host respawn). Active ACP turns are cancelled; queued turns are
|
|
218335
|
+
* cleanly gate-dropped. A cold head is latched so it cannot resume after teardown. */
|
|
218336
|
+
interruptAgentTurns(agentId, reason) {
|
|
218337
|
+
this.beginSafetyDrain(agentId, reason);
|
|
218338
|
+
this.purgeAgentInbox(agentId);
|
|
218339
|
+
const targets = /* @__PURE__ */ new Map();
|
|
218340
|
+
for (const [key, entry] of this.activeGateEntries) {
|
|
218341
|
+
if (entry.agentId !== agentId) continue;
|
|
218342
|
+
entry.cancelledReason = reason;
|
|
218343
|
+
targets.set(key, void 0);
|
|
218344
|
+
}
|
|
218345
|
+
for (const [acpSessionId, p] of this.pending) if (p.agentId === agentId) targets.set(p.sessionKey, acpSessionId);
|
|
218346
|
+
for (const [key, queued] of this.serialQueue) if (queued.some((entry) => entry.agentId === agentId) && !targets.has(key)) targets.set(key, this.store.getSession(key)?.acpSessionId ?? void 0);
|
|
218347
|
+
if (targets.size > 0) this.log.info(`${reason}: interrupting ${targets.size} active session(s) for agent "${agentId}"`);
|
|
218348
|
+
for (const [key, acpSessionId] of targets) this.interruptTurn(agentId, key, reason, acpSessionId, { dropQueued: true });
|
|
218349
|
+
}
|
|
218350
|
+
/** After an interrupt sends session/cancel, give the agent `cancelBackstopMs` to yield.
|
|
217647
218351
|
* If the turn is still in flight when the timer fires, the agent ignored the
|
|
217648
218352
|
* cancel — force-stop its host (the only hard kill available) so the session
|
|
217649
218353
|
* can't be stuck in `cancelling` forever. dispatch's finally clears this timer
|
|
217650
218354
|
* the moment the turn yields on its own. */
|
|
217651
|
-
armCancelBackstop(agentId, acpSessionId, key) {
|
|
218355
|
+
armCancelBackstop(agentId, acpSessionId, key, reason) {
|
|
217652
218356
|
this.clearCancelBackstop(acpSessionId);
|
|
217653
218357
|
const ms = this.cfg.limits.cancelBackstopMs;
|
|
217654
218358
|
this.cancelTimers.set(acpSessionId, this.clock.setTimeout(() => {
|
|
217655
218359
|
this.cancelTimers.delete(acpSessionId);
|
|
217656
218360
|
if (!this.pending.has(acpSessionId)) return;
|
|
217657
|
-
this.log.warn(
|
|
217658
|
-
this.stopHost(agentId, 0);
|
|
218361
|
+
this.log.warn(`${reason}: agent "${agentId}" ignored session/cancel for ${ms}ms — force-stopping host (session ${acpSessionId})`);
|
|
218362
|
+
this.stopHost(agentId, 0).catch((err) => this.log.error(`${reason}: force-stop failed for agent "${agentId}": ${formatErr(err)}`));
|
|
217659
218363
|
this.store.setSessionState(key, "idle", this.clock.now());
|
|
217660
218364
|
}, ms));
|
|
217661
218365
|
}
|
|
@@ -217666,6 +218370,37 @@ var Daemon = class Daemon {
|
|
|
217666
218370
|
this.cancelTimers.delete(acpSessionId);
|
|
217667
218371
|
}
|
|
217668
218372
|
}
|
|
218373
|
+
armColdCancelBackstop(agentId, key, reason, active) {
|
|
218374
|
+
this.clearColdCancelBackstop(key);
|
|
218375
|
+
const ms = this.cfg.limits.cancelBackstopMs;
|
|
218376
|
+
const timer = this.clock.setTimeout(() => {
|
|
218377
|
+
const current = this.coldCancelTimers.get(key);
|
|
218378
|
+
if (!current || current.active !== active) return;
|
|
218379
|
+
this.coldCancelTimers.delete(key);
|
|
218380
|
+
if (this.activeDispatchDoneByKey.get(key) !== active) return;
|
|
218381
|
+
const entry = this.activeGateEntries.get(key);
|
|
218382
|
+
if (!entry?.cancelledReason) return;
|
|
218383
|
+
this.log.warn(`${reason}: agent "${agentId}" did not finish cold initialization within ${ms}ms — force-stopping host (${key})`);
|
|
218384
|
+
const stopped = this.stopHost(agentId, 0).catch((err) => {
|
|
218385
|
+
this.log.error(`${reason}: cold force-stop failed for agent "${agentId}": ${formatErr(err)}`);
|
|
218386
|
+
return new Promise(() => {});
|
|
218387
|
+
});
|
|
218388
|
+
const waits = this.safetyDrainWaits.get(agentId) ?? /* @__PURE__ */ new Set();
|
|
218389
|
+
waits.add(stopped);
|
|
218390
|
+
this.safetyDrainWaits.set(agentId, waits);
|
|
218391
|
+
entry.initAbort.abort(new Error(reason));
|
|
218392
|
+
}, ms);
|
|
218393
|
+
this.coldCancelTimers.set(key, {
|
|
218394
|
+
timer,
|
|
218395
|
+
active
|
|
218396
|
+
});
|
|
218397
|
+
}
|
|
218398
|
+
clearColdCancelBackstop(key, active) {
|
|
218399
|
+
const current = this.coldCancelTimers.get(key);
|
|
218400
|
+
if (!current || active !== void 0 && current.active !== active) return;
|
|
218401
|
+
this.clock.clearTimeout(current.timer);
|
|
218402
|
+
this.coldCancelTimers.delete(key);
|
|
218403
|
+
}
|
|
217669
218404
|
/**
|
|
217670
218405
|
* Apply one converger action against the session's Slack connection:
|
|
217671
218406
|
* - set-status → assistant.threads.setStatus (best-effort; '' clears)
|
|
@@ -218057,6 +218792,7 @@ var Daemon = class Daemon {
|
|
|
218057
218792
|
* demand via `/status` (see handleCommand) — so it's skipped here. Headless no-ops in
|
|
218058
218793
|
* applyAction (no connection), which is fine. */
|
|
218059
218794
|
emitStatusBar(p) {
|
|
218795
|
+
if (p.outputSuppressed) return;
|
|
218060
218796
|
const info = this.buildStatusInfo(p);
|
|
218061
218797
|
const key = JSON.stringify(info);
|
|
218062
218798
|
if (key === p.lastStatusBar) return;
|
|
@@ -218093,7 +218829,11 @@ var Daemon = class Daemon {
|
|
|
218093
218829
|
* remembered message ts (two concurrent `progress` actions both posting). Routes to
|
|
218094
218830
|
* the platform's applier by the Pending's platform tag. */
|
|
218095
218831
|
enqueueApply(p, action) {
|
|
218096
|
-
|
|
218832
|
+
if (p.outputSuppressed) return;
|
|
218833
|
+
p.applyChain = p.applyChain.then(() => {
|
|
218834
|
+
if (p.outputSuppressed) return;
|
|
218835
|
+
return (p.platform === "telegram" ? this.applyTelegramAction(p, action) : p.platform === "discord" ? this.applyDiscordAction(p, action) : this.applySlackAction(p, action)).catch((err) => this.log.error(`apply failed: ${formatErr(err)}`));
|
|
218836
|
+
});
|
|
218097
218837
|
}
|
|
218098
218838
|
/** (Re)arm the ~2s idle-flush timer when body text is buffered (§9.1 text-buffer):
|
|
218099
218839
|
* a long pure-text stream posts in steps instead of all at turn end. */
|
|
@@ -218134,22 +218874,34 @@ var Daemon = class Daemon {
|
|
|
218134
218874
|
async onAcpPermission(_agentId, sessionId, params) {
|
|
218135
218875
|
const p = this.pending.get(sessionId);
|
|
218136
218876
|
if (!p) return void 0;
|
|
218877
|
+
if (p.outputSuppressed) return { outcome: { outcome: "cancelled" } };
|
|
218137
218878
|
const conn = p.conn;
|
|
218138
218879
|
if (p.platform !== "slack" || !(conn instanceof SlackConnection)) return void 0;
|
|
218139
218880
|
if (!params.options?.length) return void 0;
|
|
218140
218881
|
const requestId = `perm-${++this.permSeq}`;
|
|
218141
218882
|
const fallback = `Permission requested: ${params.toolCall?.title ?? "a tool call"}`;
|
|
218142
|
-
|
|
218143
|
-
|
|
218144
|
-
|
|
218145
|
-
|
|
218146
|
-
|
|
218147
|
-
|
|
218148
|
-
|
|
218149
|
-
|
|
218150
|
-
resolve
|
|
218151
|
-
});
|
|
218883
|
+
let resolveResult;
|
|
218884
|
+
const result = new Promise((resolve) => resolveResult = resolve);
|
|
218885
|
+
this.pendingPerms.set(requestId, {
|
|
218886
|
+
sessionId,
|
|
218887
|
+
params,
|
|
218888
|
+
conn,
|
|
218889
|
+
channel: p.channel,
|
|
218890
|
+
resolve: resolveResult
|
|
218152
218891
|
});
|
|
218892
|
+
const ts = await conn.postBlocks(p.channel, buildPermissionCard(requestId, params), fallback, p.statusThread).catch(() => void 0);
|
|
218893
|
+
const live = this.pendingPerms.get(requestId);
|
|
218894
|
+
if (!live) {
|
|
218895
|
+
if (ts) conn.updateBlocks(p.channel, ts, buildPermissionResolvedCard(params, "Cancelled", void 0), "Permission cancelled").catch(() => {});
|
|
218896
|
+
return await result;
|
|
218897
|
+
}
|
|
218898
|
+
if (!ts) {
|
|
218899
|
+
this.pendingPerms.delete(requestId);
|
|
218900
|
+
live.resolve({ outcome: { outcome: "cancelled" } });
|
|
218901
|
+
return await result;
|
|
218902
|
+
}
|
|
218903
|
+
live.ts = ts;
|
|
218904
|
+
return await result;
|
|
218153
218905
|
}
|
|
218154
218906
|
/** A tapped permission-card button (SlackDeps.onPermissionChoice): resolve the pending
|
|
218155
218907
|
* ACP request with the chosen option and edit the card in place to its resolved state
|
|
@@ -218189,6 +218941,7 @@ var Daemon = class Daemon {
|
|
|
218189
218941
|
async onAcpElicit(_agentId, sessionId, params) {
|
|
218190
218942
|
const p = this.pending.get(sessionId);
|
|
218191
218943
|
if (!p) return void 0;
|
|
218944
|
+
if (p.outputSuppressed) return { action: "cancel" };
|
|
218192
218945
|
const conn = p.conn;
|
|
218193
218946
|
if (p.platform !== "slack" || !(conn instanceof SlackConnection)) return void 0;
|
|
218194
218947
|
const target = elicitTarget(params);
|
|
@@ -218197,19 +218950,30 @@ var Daemon = class Daemon {
|
|
|
218197
218950
|
const blocks = buildElicitationCard(requestId, params);
|
|
218198
218951
|
if (!blocks) return void 0;
|
|
218199
218952
|
const fallback = params.message ?? "The agent needs your input";
|
|
218200
|
-
|
|
218201
|
-
|
|
218202
|
-
|
|
218203
|
-
|
|
218204
|
-
|
|
218205
|
-
|
|
218206
|
-
|
|
218207
|
-
|
|
218208
|
-
|
|
218209
|
-
|
|
218210
|
-
resolve
|
|
218211
|
-
});
|
|
218953
|
+
let resolveResult;
|
|
218954
|
+
const result = new Promise((resolve) => resolveResult = resolve);
|
|
218955
|
+
this.pendingElicits.set(requestId, {
|
|
218956
|
+
sessionId,
|
|
218957
|
+
params,
|
|
218958
|
+
propName: target.propName,
|
|
218959
|
+
kind: target.kind,
|
|
218960
|
+
conn,
|
|
218961
|
+
channel: p.channel,
|
|
218962
|
+
resolve: resolveResult
|
|
218212
218963
|
});
|
|
218964
|
+
const ts = await conn.postBlocks(p.channel, blocks, fallback, p.statusThread).catch(() => void 0);
|
|
218965
|
+
const live = this.pendingElicits.get(requestId);
|
|
218966
|
+
if (!live) {
|
|
218967
|
+
if (ts) conn.updateBlocks(p.channel, ts, buildElicitationResolvedCard(params, ":hourglass: Cancelled"), "Cancelled").catch(() => {});
|
|
218968
|
+
return await result;
|
|
218969
|
+
}
|
|
218970
|
+
if (!ts) {
|
|
218971
|
+
this.pendingElicits.delete(requestId);
|
|
218972
|
+
live.resolve({ action: "cancel" });
|
|
218973
|
+
return await result;
|
|
218974
|
+
}
|
|
218975
|
+
live.ts = ts;
|
|
218976
|
+
return await result;
|
|
218213
218977
|
}
|
|
218214
218978
|
/** A tapped elicitation-card button (SlackDeps.onElicitChoice): resolve the pending ACP
|
|
218215
218979
|
* request — `accept` with the chosen value (under the field name), or `decline` for the
|
|
@@ -218247,6 +219011,7 @@ var Daemon = class Daemon {
|
|
|
218247
219011
|
onAcpUpdate(_agentId, sessionId, update) {
|
|
218248
219012
|
const p = this.pending.get(sessionId);
|
|
218249
219013
|
if (!p) return;
|
|
219014
|
+
if (p.outputSuppressed) return;
|
|
218250
219015
|
if (update?.sessionUpdate === "usage_update") {
|
|
218251
219016
|
this.store.setUsageSnapshot(p.sessionKey, {
|
|
218252
219017
|
contextUsed: update.used,
|
|
@@ -218374,15 +219139,37 @@ var Daemon = class Daemon {
|
|
|
218374
219139
|
text: ev.text
|
|
218375
219140
|
});
|
|
218376
219141
|
}
|
|
218377
|
-
|
|
219142
|
+
invalidateHostStart(agentId) {
|
|
219143
|
+
this.hostStartGeneration.set(agentId, (this.hostStartGeneration.get(agentId) ?? 0) + 1);
|
|
219144
|
+
this.hostStarts.delete(agentId);
|
|
219145
|
+
this.hostStartAborts.get(agentId)?.abort(/* @__PURE__ */ new Error(`host start superseded for ${agentId}`));
|
|
219146
|
+
this.hostStartAborts.delete(agentId);
|
|
219147
|
+
}
|
|
219148
|
+
async ensureHostAsync(agentId, opts = {}) {
|
|
219149
|
+
const assertStartAllowed = () => {
|
|
219150
|
+
if (this.draining) throw new Error(`host start blocked while daemon is draining (${agentId})`);
|
|
219151
|
+
if (this.safetyDrainingAgents.has(agentId) && !this.hostStarts.has(agentId)) throw new Error(`host start blocked while interrupted turns are stopping (${agentId})`);
|
|
219152
|
+
if (!opts.allowAgentDrain && this.drainingAgents.has(agentId)) throw new Error(`host start blocked while agent is draining (${agentId})`);
|
|
219153
|
+
};
|
|
219154
|
+
assertStartAllowed();
|
|
218378
219155
|
const stopping = this.hostStopping.get(agentId);
|
|
218379
|
-
if (stopping)
|
|
219156
|
+
if (stopping) {
|
|
219157
|
+
await stopping;
|
|
219158
|
+
assertStartAllowed();
|
|
219159
|
+
}
|
|
218380
219160
|
let p = this.hostStarts.get(agentId);
|
|
218381
219161
|
if (!p) {
|
|
218382
|
-
|
|
219162
|
+
const generation = (this.hostStartGeneration.get(agentId) ?? 0) + 1;
|
|
219163
|
+
this.hostStartGeneration.set(agentId, generation);
|
|
219164
|
+
const startAbort = new AbortController();
|
|
219165
|
+
this.hostStartAborts.set(agentId, startAbort);
|
|
219166
|
+
p = this.startHostWithRetry(agentId, generation, startAbort.signal);
|
|
218383
219167
|
this.hostStarts.set(agentId, p);
|
|
218384
|
-
p.
|
|
219168
|
+
p.then(() => {
|
|
219169
|
+
if (this.hostStartAborts.get(agentId) === startAbort) this.hostStartAborts.delete(agentId);
|
|
219170
|
+
}, () => {
|
|
218385
219171
|
if (this.hostStarts.get(agentId) === p) this.hostStarts.delete(agentId);
|
|
219172
|
+
if (this.hostStartAborts.get(agentId) === startAbort) this.hostStartAborts.delete(agentId);
|
|
218386
219173
|
});
|
|
218387
219174
|
}
|
|
218388
219175
|
return await p;
|
|
@@ -218392,7 +219179,7 @@ var Daemon = class Daemon {
|
|
|
218392
219179
|
* builds a FRESH host; a failed start()'s half-spawned child is reaped before the
|
|
218393
219180
|
* next try. On success the started host is left memoized in `this.hosts`; when every
|
|
218394
219181
|
* attempt fails the last error is thrown (dispatch surfaces it to the session). */
|
|
218395
|
-
async startHostWithRetry(agentId) {
|
|
219182
|
+
async startHostWithRetry(agentId, generation, signal) {
|
|
218396
219183
|
const attempts = Math.max(1, this.cfg.limits.agentStartAttempts);
|
|
218397
219184
|
let ghToken;
|
|
218398
219185
|
const agentSpec = this.agents.get(agentId);
|
|
@@ -218401,30 +219188,46 @@ var Daemon = class Daemon {
|
|
|
218401
219188
|
} catch (err) {
|
|
218402
219189
|
this.log.warn(`gitcred: GH_TOKEN unavailable for agent "${agentId}" — spawning without it (${formatErr(err)})`);
|
|
218403
219190
|
}
|
|
219191
|
+
if (this.hostStartGeneration.get(agentId) !== generation) throw new Error(`host start superseded for ${agentId}`);
|
|
218404
219192
|
let lastErr;
|
|
218405
219193
|
for (let i = 1; i <= attempts; i++) {
|
|
219194
|
+
if (this.hostStartGeneration.get(agentId) !== generation) throw new Error(`host start superseded for ${agentId}`);
|
|
218406
219195
|
const host = this.ensureHost(agentId, this.cfg, ghToken);
|
|
218407
219196
|
try {
|
|
218408
219197
|
await host.start();
|
|
219198
|
+
if (this.hostStartGeneration.get(agentId) !== generation) throw new Error(`host start superseded for ${agentId}`);
|
|
218409
219199
|
return host;
|
|
218410
219200
|
} catch (err) {
|
|
218411
219201
|
lastErr = err;
|
|
218412
|
-
this.hosts.delete(agentId);
|
|
219202
|
+
if (this.hosts.get(agentId) === host) this.hosts.delete(agentId);
|
|
218413
219203
|
await host.stop().catch(() => {});
|
|
219204
|
+
if (this.hostStartGeneration.get(agentId) !== generation) throw err;
|
|
218414
219205
|
if (i < attempts) {
|
|
218415
219206
|
const backoff = this.cfg.limits.agentStartBackoffMs;
|
|
218416
219207
|
this.log.warn(`acp: agent "${agentId}" start attempt ${i}/${attempts} failed (${err.message}) — retrying in ${backoff}ms`);
|
|
218417
|
-
await this.sleep(backoff);
|
|
219208
|
+
await this.sleep(backoff, signal);
|
|
218418
219209
|
} else this.log.error(`acp: agent "${agentId}" failed to start after ${attempts} attempt(s): ${formatErr(err)}`);
|
|
218419
219210
|
}
|
|
218420
219211
|
}
|
|
218421
219212
|
throw lastErr;
|
|
218422
219213
|
}
|
|
218423
219214
|
/** Clock-driven delay (so a FakeClock stays deterministic in tests). */
|
|
218424
|
-
sleep(ms) {
|
|
219215
|
+
sleep(ms, signal) {
|
|
218425
219216
|
const delay = Math.min(Math.max(0, ms), 2147483647);
|
|
218426
219217
|
return new Promise((resolve) => {
|
|
218427
|
-
|
|
219218
|
+
if (signal?.aborted) {
|
|
219219
|
+
resolve();
|
|
219220
|
+
return;
|
|
219221
|
+
}
|
|
219222
|
+
const timer = this.clock.setTimeout(() => {
|
|
219223
|
+
signal?.removeEventListener("abort", onAbort);
|
|
219224
|
+
resolve();
|
|
219225
|
+
}, delay);
|
|
219226
|
+
const onAbort = () => {
|
|
219227
|
+
this.clock.clearTimeout(timer);
|
|
219228
|
+
resolve();
|
|
219229
|
+
};
|
|
219230
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
218428
219231
|
});
|
|
218429
219232
|
}
|
|
218430
219233
|
replyConnFor(agentId, integrationId) {
|
|
@@ -218444,12 +219247,17 @@ var Daemon = class Daemon {
|
|
|
218444
219247
|
* is registered in `hostStopping` so a concurrent ensureHostAsync waits for it
|
|
218445
219248
|
* instead of spawning a second live child. */
|
|
218446
219249
|
async stopHost(agentId, deadlineMs) {
|
|
219250
|
+
this.invalidateHostStart(agentId);
|
|
219251
|
+
const alreadyStopping = this.hostStopping.get(agentId);
|
|
219252
|
+
if (alreadyStopping) {
|
|
219253
|
+
await alreadyStopping;
|
|
219254
|
+
this.invalidateHostStart(agentId);
|
|
219255
|
+
}
|
|
218447
219256
|
const host = this.hosts.get(agentId);
|
|
218448
219257
|
this.hosts.delete(agentId);
|
|
218449
|
-
this.hostStarts.delete(agentId);
|
|
218450
219258
|
this.hostStartedAt.delete(agentId);
|
|
218451
219259
|
if (!host) return;
|
|
218452
|
-
const stop = host.stop(deadlineMs).finally(() => {
|
|
219260
|
+
const stop = Promise.resolve(host.stop(deadlineMs)).finally(() => {
|
|
218453
219261
|
if (this.hostStopping.get(agentId) === stop) this.hostStopping.delete(agentId);
|
|
218454
219262
|
});
|
|
218455
219263
|
this.hostStopping.set(agentId, stop);
|
|
@@ -218632,13 +219440,44 @@ var Daemon = class Daemon {
|
|
|
218632
219440
|
* to `shutdownDrainMs`, cancelling stragglers. Safe to call repeatedly. */
|
|
218633
219441
|
async drainForShutdown() {
|
|
218634
219442
|
this.draining = true;
|
|
218635
|
-
const
|
|
218636
|
-
|
|
219443
|
+
const active = [...new Set([...this.activeDispatchesByAgent.values()].flatMap((runs) => [...runs]))];
|
|
219444
|
+
const pendingKeys = new Set([...this.pending.values()].map((p) => p.sessionKey));
|
|
219445
|
+
const coldAgents = /* @__PURE__ */ new Set();
|
|
219446
|
+
for (const [key, entry] of this.activeGateEntries) {
|
|
219447
|
+
if (pendingKeys.has(key)) continue;
|
|
219448
|
+
entry.cancelledReason ??= "shutdown";
|
|
219449
|
+
entry.initAbort.abort(/* @__PURE__ */ new Error("daemon shutting down"));
|
|
219450
|
+
coldAgents.add(entry.agentId);
|
|
219451
|
+
}
|
|
219452
|
+
const stopFailClosed = (agentId) => this.stopHost(agentId, 0).catch((err) => {
|
|
219453
|
+
this.log.error(`shutdown: force-stop failed for agent "${agentId}": ${formatErr(err)}`);
|
|
219454
|
+
return new Promise(() => {});
|
|
219455
|
+
});
|
|
219456
|
+
const coldStops = [...coldAgents].map(stopFailClosed);
|
|
219457
|
+
const work = Promise.all([...active, ...coldStops]);
|
|
219458
|
+
if (active.length > 0 || coldStops.length > 0) {
|
|
218637
219459
|
const deadlineMs = this.cfg.limits.shutdownDrainMs;
|
|
218638
|
-
this.log.info(`shutdown: draining ${
|
|
218639
|
-
if (await this.raceDeadline(
|
|
218640
|
-
this.log.warn(`shutdown: deadline hit with ${this.pending.size} turn(s) still in flight — cancelling`);
|
|
218641
|
-
|
|
219460
|
+
this.log.info(`shutdown: draining ${active.length} active dispatch(es) (deadline ${Math.round(deadlineMs / 1e3)}s)`);
|
|
219461
|
+
if (await this.raceDeadline(work, deadlineMs) === "timeout") {
|
|
219462
|
+
this.log.warn(`shutdown: deadline hit with ${this.pending.size} ACP turn(s) still in flight — cancelling`);
|
|
219463
|
+
const forceAgents = /* @__PURE__ */ new Set();
|
|
219464
|
+
for (const [, entry] of this.activeGateEntries) {
|
|
219465
|
+
entry.cancelledReason ??= "shutdown";
|
|
219466
|
+
entry.initAbort.abort(/* @__PURE__ */ new Error("daemon shutdown deadline"));
|
|
219467
|
+
forceAgents.add(entry.agentId);
|
|
219468
|
+
}
|
|
219469
|
+
for (const [sid, p] of this.pending) {
|
|
219470
|
+
p.outputSuppressed ??= "shutdown";
|
|
219471
|
+
this.releasePermissions(sid);
|
|
219472
|
+
this.releaseElicits(sid);
|
|
219473
|
+
this.hosts.get(p.agentId)?.cancel(sid).catch(() => {});
|
|
219474
|
+
}
|
|
219475
|
+
const forceStops = [...forceAgents].filter((id) => !coldAgents.has(id)).map(stopFailClosed);
|
|
219476
|
+
await Promise.all([
|
|
219477
|
+
...active,
|
|
219478
|
+
...coldStops,
|
|
219479
|
+
...forceStops
|
|
219480
|
+
]);
|
|
218642
219481
|
}
|
|
218643
219482
|
}
|
|
218644
219483
|
this.settleQueuedForShutdown();
|
|
@@ -218675,12 +219514,21 @@ var Daemon = class Daemon {
|
|
|
218675
219514
|
}
|
|
218676
219515
|
if (rows.length === 0) return;
|
|
218677
219516
|
let replayed = 0;
|
|
219517
|
+
const purgedPausedAgents = /* @__PURE__ */ new Set();
|
|
219518
|
+
const purgedLoopScopes = /* @__PURE__ */ new Set();
|
|
218678
219519
|
for (const row of rows) {
|
|
218679
219520
|
if (this.liveInboxIds.has(row.id)) continue;
|
|
218680
219521
|
if (!this.agents.has(row.agentId)) {
|
|
218681
219522
|
this.log.warn(`durable inbox: skipping replay of ${row.id} — unknown agent "${row.agentId}"`);
|
|
218682
219523
|
continue;
|
|
218683
219524
|
}
|
|
219525
|
+
if (this.paused(row.agentId)) {
|
|
219526
|
+
if (!purgedPausedAgents.has(row.agentId)) {
|
|
219527
|
+
purgedPausedAgents.add(row.agentId);
|
|
219528
|
+
this.purgeAgentInbox(row.agentId);
|
|
219529
|
+
}
|
|
219530
|
+
continue;
|
|
219531
|
+
}
|
|
218684
219532
|
let msg;
|
|
218685
219533
|
let callMeta;
|
|
218686
219534
|
try {
|
|
@@ -218690,7 +219538,24 @@ var Daemon = class Daemon {
|
|
|
218690
219538
|
this.log.warn(`durable inbox: skipping corrupt row ${row.id}: ${err.message}`);
|
|
218691
219539
|
continue;
|
|
218692
219540
|
}
|
|
218693
|
-
|
|
219541
|
+
if (isMalformedPlatformTurn(msg) && !msg.isDm) {
|
|
219542
|
+
this.store.removeInbox(row.id);
|
|
219543
|
+
continue;
|
|
219544
|
+
}
|
|
219545
|
+
const loopScope = loopGuardScope(msg);
|
|
219546
|
+
if (usesLoopGuard(msg) && this.store.isLoopGuardOpen(loopScope)) {
|
|
219547
|
+
if (!purgedLoopScopes.has(loopScope)) {
|
|
219548
|
+
purgedLoopScopes.add(loopScope);
|
|
219549
|
+
this.purgeLoopScopeInbox(loopScope);
|
|
219550
|
+
}
|
|
219551
|
+
continue;
|
|
219552
|
+
}
|
|
219553
|
+
this.dispatch(row.agentId, msg, row.integrationId ?? void 0, void 0, callMeta, {
|
|
219554
|
+
...row.isQueueCmd ? { isQueueCmd: true } : {},
|
|
219555
|
+
fromInboxReplay: true,
|
|
219556
|
+
inboxReplayId: row.id,
|
|
219557
|
+
replay: row.loopGuardCounted === 1
|
|
219558
|
+
}).catch(() => {});
|
|
218694
219559
|
replayed++;
|
|
218695
219560
|
}
|
|
218696
219561
|
if (replayed) this.log.info(`durable inbox: replayed ${replayed} admitted message(s) through the serial gate`);
|
|
@@ -218860,7 +219725,7 @@ var Daemon = class Daemon {
|
|
|
218860
219725
|
};
|
|
218861
219726
|
}
|
|
218862
219727
|
try {
|
|
218863
|
-
await this.ensureHostAsync(agentId);
|
|
219728
|
+
await this.ensureHostAsync(agentId, { allowAgentDrain: true });
|
|
218864
219729
|
} catch (err) {
|
|
218865
219730
|
this.moveStagedAgents.add(agentId);
|
|
218866
219731
|
await this.stopHost(agentId).catch(() => {});
|
|
@@ -218952,6 +219817,10 @@ var Daemon = class Daemon {
|
|
|
218952
219817
|
* in dispatch. Resolves with dispatch's sessionId (null = gate-dropped).
|
|
218953
219818
|
*/
|
|
218954
219819
|
async fireTrigger(agentId, msg, target, anchorText, label, githubReply) {
|
|
219820
|
+
if (this.draining || this.drainingAgents.has(agentId) || this.paused(agentId) || this.safetyDrainingAgents.has(agentId)) {
|
|
219821
|
+
this.log.info(`${label}: skipped for agent "${agentId}" (paused or draining)`);
|
|
219822
|
+
return null;
|
|
219823
|
+
}
|
|
218955
219824
|
if (target?.channel) {
|
|
218956
219825
|
const conn = this.replyConnFor(agentId, target.integrationId);
|
|
218957
219826
|
if (!conn) this.log.warn(`${label}: agent "${agentId}" has no live platform connection — running without output`);
|
|
@@ -219216,6 +220085,8 @@ var Daemon = class Daemon {
|
|
|
219216
220085
|
}
|
|
219217
220086
|
for (const t of this.cancelTimers.values()) this.clock.clearTimeout(t);
|
|
219218
220087
|
this.cancelTimers.clear();
|
|
220088
|
+
for (const { timer } of this.coldCancelTimers.values()) this.clock.clearTimeout(timer);
|
|
220089
|
+
this.coldCancelTimers.clear();
|
|
219219
220090
|
for (const t of this.slackRetryTimers.values()) this.clock.clearTimeout(t);
|
|
219220
220091
|
this.slackRetryTimers.clear();
|
|
219221
220092
|
for (const t of this.orchestrationDeadlines.values()) this.clock.clearTimeout(t);
|
|
@@ -219232,7 +220103,14 @@ var Daemon = class Daemon {
|
|
|
219232
220103
|
for (const c of this.sharedSlackConns.values()) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
|
|
219233
220104
|
for (const c of this.telegramConns) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
|
|
219234
220105
|
for (const c of this.discordConns) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
|
|
219235
|
-
|
|
220106
|
+
const hostStarts = [...this.hostStarts.values()];
|
|
220107
|
+
const hostIds = /* @__PURE__ */ new Set([
|
|
220108
|
+
...this.hosts.keys(),
|
|
220109
|
+
...this.hostStarts.keys(),
|
|
220110
|
+
...this.hostStopping.keys()
|
|
220111
|
+
]);
|
|
220112
|
+
for (const agentId of hostIds) await this.stopHost(agentId).catch((e) => errors.push(e));
|
|
220113
|
+
await Promise.allSettled(hostStarts);
|
|
219236
220114
|
await Promise.resolve(this.mcp?.stop()).catch((e) => errors.push(e));
|
|
219237
220115
|
this.gitCredServer?.stop();
|
|
219238
220116
|
this.store?.close();
|
|
@@ -219272,4 +220150,4 @@ async function runForeground(opts, deps = {}) {
|
|
|
219272
220150
|
//#endregion
|
|
219273
220151
|
export { ClientTransport as i, CP_SUBPROTOCOL as n, CP_WS_PATH as r, runForeground as t };
|
|
219274
220152
|
|
|
219275
|
-
//# sourceMappingURL=run-foreground-
|
|
220153
|
+
//# sourceMappingURL=run-foreground-DoM1PkHn.js.map
|