first-tree-staging 0.5.7-staging.366.1 → 0.5.7-staging.367.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as documentContextSchema, At as loadAgents, B as printResults, Bt as SdkError, Ct as agentConfigSchema, Dt as defaultHome, Et as defaultDataDir, F as checkBackgroundService, Ft as channelConfig, G as isServiceUnitDriftDetected, Gt as probeCapabilities, H as getClientServiceStatus, Ht as buildMessageDocumentSnapshots, I as checkClientConfig, It as resetConfig, J as startClientService, Jt as configureClientLoggerForService, Kt as setCliBinding, L as checkNodeVersion, Lt as ClientOrgMismatchError, M as createApiNameResolver, Mt as resetConfigMeta, N as migrateLocalAgentDirs, Nt as resolveConfigReadonly, Ot as getConfigValue, P as checkAgentConfigs, Pt as setConfigValue, Q as canonicalizeResourceRepoUrl, R as checkServerReachable, Rt as ClientUserMismatchError, Tt as defaultConfigDir, U as installClientService, Ut as cleanWorkspaces, V as reconcileAgentConfigs, Vt as SessionRegistry, W as isServiceSupported, Wt as discoverClaudeCodeSkills, Y as stopClientService, Z as ClientRuntime, _ as uploadClientCapabilities, _t as removeLocalAgent, a as declineUpdate, at as ensureFreshAccessToken, b as promptMissingFields, bt as print, c as PACKAGE_NAME, d as fetchServerCommandVersion, dt as saveCredentials, et as findAssembledBriefingFingerprint, f as installGlobalLatest, ft as cliFetch, g as uploadAgentSkills, gt as formatStaleReason, h as reconcileLocalRuntimeProviders, ht as findStaleAliases, i as createExecuteUpdate, jt as readConfigFile, kt as initConfig, l as detectInstallMode, lt as resolveServerUrl, m as listPinnedAgents, mt as COMMAND_VERSION, n as decodeJwtPayload, nt as rotateClientIdWithBackup, o as promptUpdate, ot as ensureFreshAdminToken, p as installGlobalSpec, pt as CLI_USER_AGENT, q as restartClientService, qt as applyClientLoggerConfig, r as deriveHubUrlFromToken, s as refreshServerUpdateTarget, st as loadCredentials, t as HubUrlDerivationError, tt as handleClientOrgMismatch, u as fetchLatestVersion, ut as saveAgentConfig, wt as clientConfigSchema, xt as setJsonMode, y as promptAddAgent, yt as isJsonMode, z as checkWebSocket, zt as FirstTreeHubSDK } from "../connect-token-
|
|
2
|
+
import { $ as documentContextSchema, At as loadAgents, B as printResults, Bt as SdkError, Ct as agentConfigSchema, Dt as defaultHome, Et as defaultDataDir, F as checkBackgroundService, Ft as channelConfig, G as isServiceUnitDriftDetected, Gt as probeCapabilities, H as getClientServiceStatus, Ht as buildMessageDocumentSnapshots, I as checkClientConfig, It as resetConfig, J as startClientService, Jt as configureClientLoggerForService, Kt as setCliBinding, L as checkNodeVersion, Lt as ClientOrgMismatchError, M as createApiNameResolver, Mt as resetConfigMeta, N as migrateLocalAgentDirs, Nt as resolveConfigReadonly, Ot as getConfigValue, P as checkAgentConfigs, Pt as setConfigValue, Q as canonicalizeResourceRepoUrl, R as checkServerReachable, Rt as ClientUserMismatchError, Tt as defaultConfigDir, U as installClientService, Ut as cleanWorkspaces, V as reconcileAgentConfigs, Vt as SessionRegistry, W as isServiceSupported, Wt as discoverClaudeCodeSkills, Y as stopClientService, Z as ClientRuntime, _ as uploadClientCapabilities, _t as removeLocalAgent, a as declineUpdate, at as ensureFreshAccessToken, b as promptMissingFields, bt as print, c as PACKAGE_NAME, d as fetchServerCommandVersion, dt as saveCredentials, et as findAssembledBriefingFingerprint, f as installGlobalLatest, ft as cliFetch, g as uploadAgentSkills, gt as formatStaleReason, h as reconcileLocalRuntimeProviders, ht as findStaleAliases, i as createExecuteUpdate, jt as readConfigFile, kt as initConfig, l as detectInstallMode, lt as resolveServerUrl, m as listPinnedAgents, mt as COMMAND_VERSION, n as decodeJwtPayload, nt as rotateClientIdWithBackup, o as promptUpdate, ot as ensureFreshAdminToken, p as installGlobalSpec, pt as CLI_USER_AGENT, q as restartClientService, qt as applyClientLoggerConfig, r as deriveHubUrlFromToken, s as refreshServerUpdateTarget, st as loadCredentials, t as HubUrlDerivationError, tt as handleClientOrgMismatch, u as fetchLatestVersion, ut as saveAgentConfig, wt as clientConfigSchema, xt as setJsonMode, y as promptAddAgent, yt as isJsonMode, z as checkWebSocket, zt as FirstTreeHubSDK } from "../connect-token-Cd0Z_mdb.mjs";
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync } from "node:fs";
|
|
5
5
|
import "node:crypto";
|
|
@@ -14401,20 +14401,22 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14401
14401
|
consumerDone = consumeOutput(sessionCtx);
|
|
14402
14402
|
}
|
|
14403
14403
|
/**
|
|
14404
|
-
* Single helper for "turn closed →
|
|
14404
|
+
* Single helper for "turn closed → finish the pending inbox message AND
|
|
14405
14405
|
* drop the replay stash". The two operations are paired everywhere a
|
|
14406
14406
|
* turn finishes (success / sniff-permanent / forward-error / no-result /
|
|
14407
14407
|
* non-success subtype / MAX_RETRIES / respawn-fail) — folding them into
|
|
14408
14408
|
* one call keeps the invariant "stash lives only as long as the turn
|
|
14409
14409
|
* still might need a replay" enforced in one place. Use the raw
|
|
14410
|
-
* `
|
|
14410
|
+
* `finishTurn(message, ...)` directly for per-message terminal
|
|
14411
14411
|
* failures (e.g. inject's `toSDKUserMessage` catch) where the semantics is
|
|
14412
14412
|
* "commit this single inbox message, NOT close the active SDK turn".
|
|
14413
14413
|
*/
|
|
14414
|
-
function ackTurnClose(sessionCtx) {
|
|
14414
|
+
async function ackTurnClose(sessionCtx, status) {
|
|
14415
14415
|
const message = pendingAckMessages.shift();
|
|
14416
|
-
if (message) sessionCtx.
|
|
14417
|
-
|
|
14416
|
+
if (message) await sessionCtx.finishTurn(message, {
|
|
14417
|
+
status,
|
|
14418
|
+
terminal: true
|
|
14419
|
+
});
|
|
14418
14420
|
markCurrentPendingMessageConsumed(sessionCtx);
|
|
14419
14421
|
stashedSdkMessage = null;
|
|
14420
14422
|
}
|
|
@@ -14440,7 +14442,11 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14440
14442
|
pushPendingAckMessage(message, sessionCtx);
|
|
14441
14443
|
} catch (err) {
|
|
14442
14444
|
sessionCtx.log(`toSDKUserMessage errored: ${err instanceof Error ? err.message : String(err)}`);
|
|
14443
|
-
sessionCtx.
|
|
14445
|
+
await sessionCtx.finishTurn(message, {
|
|
14446
|
+
status: "error",
|
|
14447
|
+
terminal: true,
|
|
14448
|
+
errorKind: "deterministic"
|
|
14449
|
+
});
|
|
14444
14450
|
}
|
|
14445
14451
|
}
|
|
14446
14452
|
function scheduleInjectedMessagesDrain(sessionCtx, sessionId) {
|
|
@@ -14568,9 +14574,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14568
14574
|
while (true) {
|
|
14569
14575
|
if (!currentQuery) return;
|
|
14570
14576
|
try {
|
|
14571
|
-
sessionCtx.setRuntimeState("working");
|
|
14572
14577
|
for await (const message of currentQuery) {
|
|
14573
|
-
sessionCtx.
|
|
14578
|
+
sessionCtx.recordProviderActivity();
|
|
14574
14579
|
toolCallProcessor.onMessage(message);
|
|
14575
14580
|
const authFailure = detectClaudeAuthFailure(message);
|
|
14576
14581
|
if (authFailure && !authHintEmitted) {
|
|
@@ -14614,7 +14619,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14614
14619
|
kind: "turn_end",
|
|
14615
14620
|
payload: { status: "error" }
|
|
14616
14621
|
});
|
|
14617
|
-
ackTurnClose(sessionCtx);
|
|
14622
|
+
await ackTurnClose(sessionCtx, "error");
|
|
14618
14623
|
} else {
|
|
14619
14624
|
retryCount = 0;
|
|
14620
14625
|
try {
|
|
@@ -14624,7 +14629,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14624
14629
|
kind: "turn_end",
|
|
14625
14630
|
payload: { status: "success" }
|
|
14626
14631
|
});
|
|
14627
|
-
ackTurnClose(sessionCtx);
|
|
14632
|
+
await ackTurnClose(sessionCtx, "success");
|
|
14628
14633
|
} catch (err) {
|
|
14629
14634
|
const reason = err instanceof Error ? err.message : String(err);
|
|
14630
14635
|
sessionCtx.log(`Failed to forward result: ${reason}`);
|
|
@@ -14641,7 +14646,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14641
14646
|
payload: { status: "error" }
|
|
14642
14647
|
});
|
|
14643
14648
|
retryCount = 0;
|
|
14644
|
-
ackTurnClose(sessionCtx);
|
|
14649
|
+
await ackTurnClose(sessionCtx, "error");
|
|
14645
14650
|
}
|
|
14646
14651
|
}
|
|
14647
14652
|
} else {
|
|
@@ -14650,7 +14655,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14650
14655
|
kind: "turn_end",
|
|
14651
14656
|
payload: { status: "success" }
|
|
14652
14657
|
});
|
|
14653
|
-
ackTurnClose(sessionCtx);
|
|
14658
|
+
await ackTurnClose(sessionCtx, "success");
|
|
14654
14659
|
}
|
|
14655
14660
|
else {
|
|
14656
14661
|
const errors = message.errors ? message.errors.join("; ") : message.subtype;
|
|
@@ -14667,13 +14672,11 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14667
14672
|
kind: "turn_end",
|
|
14668
14673
|
payload: { status: "error" }
|
|
14669
14674
|
});
|
|
14670
|
-
ackTurnClose(sessionCtx);
|
|
14675
|
+
await ackTurnClose(sessionCtx, "error");
|
|
14671
14676
|
}
|
|
14672
|
-
sessionCtx.setRuntimeState("idle");
|
|
14673
14677
|
if (message.subtype === "success") authHintEmitted = false;
|
|
14674
14678
|
}
|
|
14675
14679
|
}
|
|
14676
|
-
sessionCtx.setRuntimeState("idle");
|
|
14677
14680
|
return;
|
|
14678
14681
|
} catch (err) {
|
|
14679
14682
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -14704,8 +14707,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14704
14707
|
} catch (emitErr) {
|
|
14705
14708
|
sessionCtx.log(`Failed to emit retry-exhaustion error event: ${emitErr instanceof Error ? emitErr.message : String(emitErr)}`);
|
|
14706
14709
|
}
|
|
14707
|
-
sessionCtx
|
|
14708
|
-
ackTurnClose(sessionCtx);
|
|
14710
|
+
await ackTurnClose(sessionCtx, "error");
|
|
14709
14711
|
return;
|
|
14710
14712
|
}
|
|
14711
14713
|
toolCallProcessor.flush();
|
|
@@ -14731,8 +14733,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
14731
14733
|
} catch (emitErr) {
|
|
14732
14734
|
sessionCtx.log(`Failed to emit auto-resume error event: ${emitErr instanceof Error ? emitErr.message : String(emitErr)}`);
|
|
14733
14735
|
}
|
|
14734
|
-
sessionCtx
|
|
14735
|
-
ackTurnClose(sessionCtx);
|
|
14736
|
+
await ackTurnClose(sessionCtx, "error");
|
|
14736
14737
|
return;
|
|
14737
14738
|
}
|
|
14738
14739
|
}
|
|
@@ -15376,9 +15377,8 @@ var TranscriptTailer = class {
|
|
|
15376
15377
|
* is NOT a success. claude was interrupted before reaching idle, so we cannot
|
|
15377
15378
|
* confirm the work completed. Acking it would silently consume the user's
|
|
15378
15379
|
* message with no replay path — exactly the bug this function guards. So a
|
|
15379
|
-
* timeout reports `turn_end: error
|
|
15380
|
-
* server redelivers them on reconnect/restart for a genuine retry)
|
|
15381
|
-
* an `error` runtime state.
|
|
15380
|
+
* timeout reports `turn_end: error` and leaves the inbox entries un-acked (the
|
|
15381
|
+
* server redelivers them on reconnect/restart for a genuine retry).
|
|
15382
15382
|
*
|
|
15383
15383
|
* Ack policy otherwise mirrors the SDK handler's `ackTurnClose`: a clean close
|
|
15384
15384
|
* acks even on a plain forward failure (claude finished; re-running yields the
|
|
@@ -15395,8 +15395,7 @@ function resolveTurnDisposition(outcome) {
|
|
|
15395
15395
|
return {
|
|
15396
15396
|
status: turnFailed || forwardFailed || timedOut ? "error" : "success",
|
|
15397
15397
|
ack: consume,
|
|
15398
|
-
forward: consume
|
|
15399
|
-
runtimeState: turnFailed || timedOut ? "error" : "idle"
|
|
15398
|
+
forward: consume
|
|
15400
15399
|
};
|
|
15401
15400
|
}
|
|
15402
15401
|
const TURN_TIMEOUT_MS = 600 * 1e3;
|
|
@@ -15596,7 +15595,6 @@ const createClaudeCodeTuiHandler = (config) => {
|
|
|
15596
15595
|
async function runTurn(text, sessionCtx, messages) {
|
|
15597
15596
|
if (!tmuxSessionName || !transcriptTailer) throw new Error("runTurn called before session was prepared");
|
|
15598
15597
|
sessionCtx.markMessagesConsumed(messages);
|
|
15599
|
-
sessionCtx.setRuntimeState("working");
|
|
15600
15598
|
turnAborted = false;
|
|
15601
15599
|
const state = { finalTexts: [] };
|
|
15602
15600
|
let turnFailed = false;
|
|
@@ -15606,11 +15604,11 @@ const createClaudeCodeTuiHandler = (config) => {
|
|
|
15606
15604
|
try {
|
|
15607
15605
|
transcriptTailer.drainEntries();
|
|
15608
15606
|
await pasteText(tmuxSessionName, text);
|
|
15609
|
-
sessionCtx.
|
|
15607
|
+
sessionCtx.recordProviderActivity();
|
|
15610
15608
|
const startTs = Date.now();
|
|
15611
15609
|
while (Date.now() - startTs < TURN_TIMEOUT_MS) {
|
|
15612
15610
|
if (turnAborted) break;
|
|
15613
|
-
sessionCtx.
|
|
15611
|
+
sessionCtx.recordProviderActivity();
|
|
15614
15612
|
await drainAndConsume(sessionCtx, state);
|
|
15615
15613
|
const working = (await capturePane(tmuxSessionName)).includes(WORKING_MARKER);
|
|
15616
15614
|
if (working) everSawWorking = true;
|
|
@@ -15680,12 +15678,14 @@ const createClaudeCodeTuiHandler = (config) => {
|
|
|
15680
15678
|
kind: "turn_end",
|
|
15681
15679
|
payload: { status: disposition.status }
|
|
15682
15680
|
});
|
|
15683
|
-
if (disposition.ack) sessionCtx.
|
|
15681
|
+
if (disposition.ack) await sessionCtx.finishTurn(messages, {
|
|
15682
|
+
status: disposition.status,
|
|
15683
|
+
terminal: true
|
|
15684
|
+
});
|
|
15684
15685
|
else {
|
|
15685
15686
|
const reason = timedOut ? "turn_timeout" : turnAborted ? "turn_aborted" : "turn_retryable";
|
|
15686
|
-
sessionCtx.
|
|
15687
|
+
sessionCtx.retryTurn(messages, reason);
|
|
15687
15688
|
}
|
|
15688
|
-
sessionCtx.setRuntimeState(disposition.runtimeState);
|
|
15689
15689
|
resetProcessor();
|
|
15690
15690
|
}
|
|
15691
15691
|
/**
|
|
@@ -15710,7 +15710,11 @@ const createClaudeCodeTuiHandler = (config) => {
|
|
|
15710
15710
|
sessionCtx.log(`tui inject formatInboundContent failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
15711
15711
|
}
|
|
15712
15712
|
if (inputs.length === 0) {
|
|
15713
|
-
sessionCtx.
|
|
15713
|
+
await sessionCtx.finishTurn(drained, {
|
|
15714
|
+
status: "error",
|
|
15715
|
+
terminal: true,
|
|
15716
|
+
errorKind: "deterministic"
|
|
15717
|
+
});
|
|
15714
15718
|
return;
|
|
15715
15719
|
}
|
|
15716
15720
|
await runTurn(inputs.join("\n\n"), sessionCtx, drained);
|
|
@@ -16363,7 +16367,6 @@ const createCodexHandler = (config) => {
|
|
|
16363
16367
|
sessionCtx.markMessagesConsumed(messages);
|
|
16364
16368
|
const abort = new AbortController();
|
|
16365
16369
|
currentAbort = abort;
|
|
16366
|
-
sessionCtx.setRuntimeState("working");
|
|
16367
16370
|
let finalResponse = "";
|
|
16368
16371
|
let turnFailed = false;
|
|
16369
16372
|
let userVisibleEmitted = false;
|
|
@@ -16383,7 +16386,7 @@ const createCodexHandler = (config) => {
|
|
|
16383
16386
|
const streamed = await activeThread.runStreamed(input, { signal: attemptAbort.signal });
|
|
16384
16387
|
for await (const event of streamed.events) {
|
|
16385
16388
|
if (attemptAbort.signal.aborted) break;
|
|
16386
|
-
sessionCtx.
|
|
16389
|
+
sessionCtx.recordProviderActivity();
|
|
16387
16390
|
if (event.type === "thread.started") threadId = event.thread_id;
|
|
16388
16391
|
else if (event.type === "turn.started") {} else if (event.type === "item.completed") {
|
|
16389
16392
|
const text = processItem(event.item, sessionCtx);
|
|
@@ -16526,8 +16529,10 @@ const createCodexHandler = (config) => {
|
|
|
16526
16529
|
kind: "turn_end",
|
|
16527
16530
|
payload: { status: succeeded ? "success" : "error" }
|
|
16528
16531
|
});
|
|
16529
|
-
sessionCtx.
|
|
16530
|
-
|
|
16532
|
+
await sessionCtx.finishTurn(messages, {
|
|
16533
|
+
status: succeeded ? "success" : "error",
|
|
16534
|
+
terminal: true
|
|
16535
|
+
});
|
|
16531
16536
|
if (perTurnUsage) sessionCtx.log(`codex usage chatId=${sessionCtx.chatId} duration_ms=${Date.now() - turnStartedAt} input_tokens=${Math.max(0, perTurnUsage.input_tokens - perTurnUsage.cached_input_tokens)} cached_input_tokens=${perTurnUsage.cached_input_tokens} output_tokens=${perTurnUsage.output_tokens} reasoning_output_tokens=${perTurnUsage.reasoning_output_tokens} status=${succeeded ? "success" : "error"}`);
|
|
16532
16537
|
scheduleQueuedMessagesDrain();
|
|
16533
16538
|
}
|
|
@@ -16558,7 +16563,11 @@ const createCodexHandler = (config) => {
|
|
|
16558
16563
|
sessionCtx.log(`codex inject formatInboundContent failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
16559
16564
|
}
|
|
16560
16565
|
if (inputs.length === 0) {
|
|
16561
|
-
sessionCtx.
|
|
16566
|
+
await sessionCtx.finishTurn(drained, {
|
|
16567
|
+
status: "error",
|
|
16568
|
+
terminal: true,
|
|
16569
|
+
errorKind: "deterministic"
|
|
16570
|
+
});
|
|
16562
16571
|
return;
|
|
16563
16572
|
}
|
|
16564
16573
|
await runTurn(inputs.join("\n\n"), sessionCtx, drained);
|
|
@@ -16992,7 +17001,7 @@ var Deduplicator = class {
|
|
|
16992
17001
|
/**
|
|
16993
17002
|
* Drop every recorded id that starts with `prefix`. Used by the runtime
|
|
16994
17003
|
* when a chat is LRU-evicted: the in-flight entries for that chat won't
|
|
16995
|
-
* be acked (no handler will run `
|
|
17004
|
+
* be acked (no handler will run `finishTurn`), and the server will
|
|
16996
17005
|
* resend the same `(chatId, messageId)` pairs against a fresh session.
|
|
16997
17006
|
* Leaving the keys in the dedup set would cause the resend to be
|
|
16998
17007
|
* mis-classified as a duplicate — and (post-#1-fix) re-acked, which
|
|
@@ -17022,6 +17031,361 @@ var Deduplicator = class {
|
|
|
17022
17031
|
return this.seen.size;
|
|
17023
17032
|
}
|
|
17024
17033
|
};
|
|
17034
|
+
var InboxDeliveryCoordinator = class {
|
|
17035
|
+
config;
|
|
17036
|
+
deduplicator = new Deduplicator(1e3);
|
|
17037
|
+
ledgers = /* @__PURE__ */ new Map();
|
|
17038
|
+
recoveringChats = /* @__PURE__ */ new Map();
|
|
17039
|
+
constructor(config) {
|
|
17040
|
+
this.config = config;
|
|
17041
|
+
}
|
|
17042
|
+
receive(entry) {
|
|
17043
|
+
const chatId = entry.chatId ?? entry.message.chatId;
|
|
17044
|
+
const messageId = entry.message.id;
|
|
17045
|
+
const ledger = this.ledger(chatId);
|
|
17046
|
+
if (ledger.recoveryDebt !== "none") return { kind: "recovering" };
|
|
17047
|
+
const dedupKey = this.dedupKey(chatId, messageId);
|
|
17048
|
+
if (this.deduplicator.isDuplicate(dedupKey)) {
|
|
17049
|
+
const stillInFlight = ledger.entries.some((tracked) => tracked.entryId === entry.id);
|
|
17050
|
+
this.config.log.debug({
|
|
17051
|
+
chatId,
|
|
17052
|
+
messageId,
|
|
17053
|
+
entryId: entry.id,
|
|
17054
|
+
stillInFlight
|
|
17055
|
+
}, "duplicate message observed");
|
|
17056
|
+
if (stillInFlight) return { kind: "duplicate-in-flight" };
|
|
17057
|
+
this.config.log.debug({
|
|
17058
|
+
chatId,
|
|
17059
|
+
messageId,
|
|
17060
|
+
entryId: entry.id
|
|
17061
|
+
}, "duplicate key is not tied to an active entry; reprocessing redelivery");
|
|
17062
|
+
}
|
|
17063
|
+
ledger.entries.push({
|
|
17064
|
+
entryId: entry.id,
|
|
17065
|
+
messageId,
|
|
17066
|
+
dedupKey,
|
|
17067
|
+
phase: "tracked"
|
|
17068
|
+
});
|
|
17069
|
+
ledger.entries.sort((a, b) => a.entryId - b.entryId);
|
|
17070
|
+
this.emitWorkChanged(chatId);
|
|
17071
|
+
return {
|
|
17072
|
+
kind: "deliver",
|
|
17073
|
+
work: {
|
|
17074
|
+
chatId,
|
|
17075
|
+
entryId: entry.id,
|
|
17076
|
+
messageId
|
|
17077
|
+
}
|
|
17078
|
+
};
|
|
17079
|
+
}
|
|
17080
|
+
shouldRecoverBeforeDispatch(chatId, hasHealthyLiveHandler, hasLocalSessionRecord) {
|
|
17081
|
+
const ledger = this.ledgers.get(chatId);
|
|
17082
|
+
if (ledger?.recoveryActivationReady) {
|
|
17083
|
+
ledger.recoveryActivationReady = false;
|
|
17084
|
+
this.cleanupLedger(chatId);
|
|
17085
|
+
return false;
|
|
17086
|
+
}
|
|
17087
|
+
if (ledger?.recoveryDebt === "required" || ledger?.recoveryDebt === "running") return true;
|
|
17088
|
+
return Boolean(this.config.recoverChat) && hasLocalSessionRecord && !hasHealthyLiveHandler;
|
|
17089
|
+
}
|
|
17090
|
+
async recoverIfNeeded(chatId, reason) {
|
|
17091
|
+
await this.requestRecovery(chatId, reason);
|
|
17092
|
+
}
|
|
17093
|
+
async requestRecovery(chatId, reason) {
|
|
17094
|
+
const existing = this.recoveringChats.get(chatId);
|
|
17095
|
+
if (existing) {
|
|
17096
|
+
await existing;
|
|
17097
|
+
return;
|
|
17098
|
+
}
|
|
17099
|
+
const recoverChat = this.config.recoverChat;
|
|
17100
|
+
const ledger = this.ledger(chatId);
|
|
17101
|
+
if (!recoverChat) {
|
|
17102
|
+
ledger.recoveryDebt = "required";
|
|
17103
|
+
this.emitWorkChanged(chatId);
|
|
17104
|
+
this.config.log.error({
|
|
17105
|
+
chatId,
|
|
17106
|
+
reason
|
|
17107
|
+
}, "chat requires inbox recovery but no recoverChat callback is configured");
|
|
17108
|
+
return;
|
|
17109
|
+
}
|
|
17110
|
+
ledger.recoveryDebt = "running";
|
|
17111
|
+
this.emitWorkChanged(chatId);
|
|
17112
|
+
let recovery;
|
|
17113
|
+
recovery = recoverChat(chatId).then(() => {
|
|
17114
|
+
this.clearEntriesForRecoverySuccess(chatId);
|
|
17115
|
+
const current = this.ledger(chatId);
|
|
17116
|
+
current.recoveryDebt = "none";
|
|
17117
|
+
current.recoveryActivationReady = true;
|
|
17118
|
+
this.config.log.debug({
|
|
17119
|
+
chatId,
|
|
17120
|
+
reason
|
|
17121
|
+
}, "chat inbox recovery accepted before dispatch");
|
|
17122
|
+
}).catch((err) => {
|
|
17123
|
+
const current = this.ledger(chatId);
|
|
17124
|
+
current.recoveryDebt = "required";
|
|
17125
|
+
this.config.log.warn({
|
|
17126
|
+
chatId,
|
|
17127
|
+
reason,
|
|
17128
|
+
err
|
|
17129
|
+
}, "chat inbox recovery failed before dispatch");
|
|
17130
|
+
}).finally(() => {
|
|
17131
|
+
if (this.recoveringChats.get(chatId) === recovery) this.recoveringChats.delete(chatId);
|
|
17132
|
+
this.emitWorkChanged(chatId);
|
|
17133
|
+
});
|
|
17134
|
+
this.recoveringChats.set(chatId, recovery);
|
|
17135
|
+
await recovery;
|
|
17136
|
+
}
|
|
17137
|
+
async runAdmission(work, op) {
|
|
17138
|
+
const ledger = this.ledger(work.chatId);
|
|
17139
|
+
const next = (ledger.admissionQueue ?? Promise.resolve()).then(op, op);
|
|
17140
|
+
const queueMarker = next.then(() => {}, () => {});
|
|
17141
|
+
ledger.admissionQueue = queueMarker;
|
|
17142
|
+
this.emitWorkChanged(work.chatId);
|
|
17143
|
+
const cleanup = () => {
|
|
17144
|
+
if (ledger.admissionQueue === queueMarker) {
|
|
17145
|
+
ledger.admissionQueue = null;
|
|
17146
|
+
this.cleanupLedger(work.chatId);
|
|
17147
|
+
this.emitWorkChanged(work.chatId);
|
|
17148
|
+
}
|
|
17149
|
+
};
|
|
17150
|
+
next.then(cleanup, cleanup);
|
|
17151
|
+
return next;
|
|
17152
|
+
}
|
|
17153
|
+
markAccepted(work) {
|
|
17154
|
+
const tracked = this.findEntry(work.chatId, work.entryId);
|
|
17155
|
+
if (!tracked) return false;
|
|
17156
|
+
if (tracked.phase === "tracked") {
|
|
17157
|
+
tracked.phase = "accepted";
|
|
17158
|
+
this.emitWorkChanged(work.chatId);
|
|
17159
|
+
}
|
|
17160
|
+
return true;
|
|
17161
|
+
}
|
|
17162
|
+
hasEntry(work) {
|
|
17163
|
+
return this.findEntry(work.chatId, work.entryId) !== null;
|
|
17164
|
+
}
|
|
17165
|
+
markConsumed(chatId, messages) {
|
|
17166
|
+
const ledger = this.ledgers.get(chatId);
|
|
17167
|
+
if (!ledger || ledger.entries.length === 0) return;
|
|
17168
|
+
const consumedIds = this.messageEntryIds(chatId, messages);
|
|
17169
|
+
if (consumedIds.size === 0) return;
|
|
17170
|
+
let changed = false;
|
|
17171
|
+
for (const tracked of ledger.entries) {
|
|
17172
|
+
if (!consumedIds.has(tracked.entryId)) continue;
|
|
17173
|
+
if (tracked.phase === "tracked" || tracked.phase === "accepted") {
|
|
17174
|
+
tracked.phase = "consumed";
|
|
17175
|
+
changed = true;
|
|
17176
|
+
}
|
|
17177
|
+
}
|
|
17178
|
+
if (changed) this.emitWorkChanged(chatId);
|
|
17179
|
+
}
|
|
17180
|
+
async finishTurn(chatId, messages, _outcome) {
|
|
17181
|
+
this.markConsumed(chatId, messages);
|
|
17182
|
+
const throughEntryId = this.lastMessageEntryId(chatId, messages);
|
|
17183
|
+
if (throughEntryId === void 0) {
|
|
17184
|
+
this.config.log.warn({ chatId }, "turn completion ignored because no inboxEntryId was provided");
|
|
17185
|
+
return;
|
|
17186
|
+
}
|
|
17187
|
+
await this.ackThrough(chatId, throughEntryId, "finish_turn", { requireConsumedPrefix: true });
|
|
17188
|
+
}
|
|
17189
|
+
retryTurn(chatId, messages, reason) {
|
|
17190
|
+
const entryIds = this.messageEntryIds(chatId, messages);
|
|
17191
|
+
if (entryIds.size === 0) return;
|
|
17192
|
+
if (!this.ledgers.get(chatId)?.entries.some((entry) => entryIds.has(entry.entryId))) return;
|
|
17193
|
+
this.markRecoveryDebt(chatId, reason);
|
|
17194
|
+
}
|
|
17195
|
+
async prepareSuspend(chatId, reason) {
|
|
17196
|
+
const ledger = this.ledgers.get(chatId);
|
|
17197
|
+
if (!ledger || ledger.entries.length === 0) return;
|
|
17198
|
+
let consumedPrefixCount = 0;
|
|
17199
|
+
for (const tracked of ledger.entries) {
|
|
17200
|
+
if (tracked.phase !== "consumed" && tracked.phase !== "ackPending") break;
|
|
17201
|
+
consumedPrefixCount++;
|
|
17202
|
+
}
|
|
17203
|
+
if (consumedPrefixCount > 0) {
|
|
17204
|
+
const lastConsumed = ledger.entries[consumedPrefixCount - 1];
|
|
17205
|
+
if (lastConsumed) await this.ackThrough(chatId, lastConsumed.entryId, `${reason}:consumed_prefix`, { requireConsumedPrefix: true });
|
|
17206
|
+
}
|
|
17207
|
+
if ((this.ledgers.get(chatId)?.entries ?? []).length > 0) await this.markRecoveryDebt(chatId, reason);
|
|
17208
|
+
}
|
|
17209
|
+
prepareEvict(chatId, reason) {
|
|
17210
|
+
const ledger = this.ledgers.get(chatId);
|
|
17211
|
+
if (!ledger || ledger.entries.length === 0) return;
|
|
17212
|
+
this.markRecoveryDebt(chatId, reason);
|
|
17213
|
+
}
|
|
17214
|
+
async drainForTerminate(chatId) {
|
|
17215
|
+
const ledger = this.ledgers.get(chatId);
|
|
17216
|
+
if (!ledger || ledger.entries.length === 0) return;
|
|
17217
|
+
let acceptedPrefixCount = 0;
|
|
17218
|
+
for (const tracked of ledger.entries) {
|
|
17219
|
+
if (tracked.phase === "tracked") break;
|
|
17220
|
+
acceptedPrefixCount++;
|
|
17221
|
+
}
|
|
17222
|
+
if (acceptedPrefixCount > 0) {
|
|
17223
|
+
const lastAccepted = ledger.entries[acceptedPrefixCount - 1];
|
|
17224
|
+
if (lastAccepted) await this.ackThrough(chatId, lastAccepted.entryId, "terminate");
|
|
17225
|
+
}
|
|
17226
|
+
if ((this.ledgers.get(chatId)?.entries ?? []).length > 0) await this.markRecoveryDebt(chatId, "terminate_unaccepted_remainder");
|
|
17227
|
+
}
|
|
17228
|
+
hasUnsettledWork(chatId) {
|
|
17229
|
+
const ledger = this.ledgers.get(chatId);
|
|
17230
|
+
if (!ledger) return false;
|
|
17231
|
+
return ledger.entries.length > 0 || ledger.recoveryDebt !== "none" || ledger.admissionQueue !== null;
|
|
17232
|
+
}
|
|
17233
|
+
snapshot(chatId) {
|
|
17234
|
+
const ledger = this.ledgers.get(chatId);
|
|
17235
|
+
return {
|
|
17236
|
+
entries: (ledger?.entries ?? []).map((entry) => ({
|
|
17237
|
+
entryId: entry.entryId,
|
|
17238
|
+
messageId: entry.messageId,
|
|
17239
|
+
phase: entry.phase
|
|
17240
|
+
})),
|
|
17241
|
+
recoveryDebt: ledger?.recoveryDebt ?? "none",
|
|
17242
|
+
admissionPending: ledger?.admissionQueue !== null
|
|
17243
|
+
};
|
|
17244
|
+
}
|
|
17245
|
+
async ackThrough(chatId, throughEntryId, reason, opts = {}) {
|
|
17246
|
+
const ledger = this.ledgers.get(chatId);
|
|
17247
|
+
if (!ledger) return;
|
|
17248
|
+
const next = (ledger.ackQueue ?? Promise.resolve()).catch(() => void 0).then(() => this.ackThroughNow(chatId, throughEntryId, reason, opts));
|
|
17249
|
+
const queueMarker = next.then(() => {}, () => {});
|
|
17250
|
+
ledger.ackQueue = queueMarker;
|
|
17251
|
+
this.emitWorkChanged(chatId);
|
|
17252
|
+
try {
|
|
17253
|
+
await next;
|
|
17254
|
+
} finally {
|
|
17255
|
+
if (ledger.ackQueue === queueMarker) {
|
|
17256
|
+
ledger.ackQueue = null;
|
|
17257
|
+
this.cleanupLedger(chatId);
|
|
17258
|
+
this.emitWorkChanged(chatId);
|
|
17259
|
+
}
|
|
17260
|
+
}
|
|
17261
|
+
}
|
|
17262
|
+
async ackThroughNow(chatId, throughEntryId, reason, opts) {
|
|
17263
|
+
const ledger = this.ledgers.get(chatId);
|
|
17264
|
+
if (!ledger || ledger.entries.length === 0) return;
|
|
17265
|
+
if (ledger.recoveryDebt !== "none") {
|
|
17266
|
+
this.config.log.debug({
|
|
17267
|
+
chatId,
|
|
17268
|
+
throughEntryId,
|
|
17269
|
+
reason
|
|
17270
|
+
}, "ACK-through deferred because chat recovery is required");
|
|
17271
|
+
return;
|
|
17272
|
+
}
|
|
17273
|
+
const index = ledger.entries.findIndex((tracked) => tracked.entryId === throughEntryId);
|
|
17274
|
+
if (index < 0) {
|
|
17275
|
+
this.config.log.warn({
|
|
17276
|
+
chatId,
|
|
17277
|
+
throughEntryId,
|
|
17278
|
+
reason
|
|
17279
|
+
}, "attempt completion ignored for untracked inbox entry");
|
|
17280
|
+
return;
|
|
17281
|
+
}
|
|
17282
|
+
const ackPrefix = ledger.entries.slice(0, index + 1);
|
|
17283
|
+
if (opts.requireConsumedPrefix && ackPrefix.some((tracked) => tracked.phase === "tracked" || tracked.phase === "accepted")) {
|
|
17284
|
+
this.config.log.warn({
|
|
17285
|
+
chatId,
|
|
17286
|
+
throughEntryId,
|
|
17287
|
+
reason,
|
|
17288
|
+
prefix: ackPrefix.map((entry) => ({
|
|
17289
|
+
entryId: entry.entryId,
|
|
17290
|
+
phase: entry.phase
|
|
17291
|
+
}))
|
|
17292
|
+
}, "ACK-through blocked because delivery prefix has unconsumed entries");
|
|
17293
|
+
await this.markRecoveryDebt(chatId, `${reason}:unconsumed_prefix_gap`);
|
|
17294
|
+
return;
|
|
17295
|
+
}
|
|
17296
|
+
let changed = false;
|
|
17297
|
+
for (const tracked of ackPrefix) if (tracked.phase !== "ackPending") {
|
|
17298
|
+
tracked.phase = "ackPending";
|
|
17299
|
+
changed = true;
|
|
17300
|
+
}
|
|
17301
|
+
if (changed) this.emitWorkChanged(chatId);
|
|
17302
|
+
try {
|
|
17303
|
+
await this.config.ackEntry(throughEntryId);
|
|
17304
|
+
} catch (err) {
|
|
17305
|
+
this.config.log.warn({
|
|
17306
|
+
chatId,
|
|
17307
|
+
entryId: throughEntryId,
|
|
17308
|
+
reason,
|
|
17309
|
+
err
|
|
17310
|
+
}, "ACK-through failed; retaining ledger");
|
|
17311
|
+
const current = this.ledgers.get(chatId);
|
|
17312
|
+
if (current) {
|
|
17313
|
+
for (const tracked of current.entries) if (tracked.entryId <= throughEntryId && tracked.phase === "ackPending") tracked.phase = "consumed";
|
|
17314
|
+
}
|
|
17315
|
+
this.emitWorkChanged(chatId);
|
|
17316
|
+
this.markRecoveryDebt(chatId, `${reason}:ack_failed`);
|
|
17317
|
+
return;
|
|
17318
|
+
}
|
|
17319
|
+
const current = this.ledgers.get(chatId);
|
|
17320
|
+
if (!current) return;
|
|
17321
|
+
const committed = current.entries.filter((tracked) => tracked.entryId <= throughEntryId);
|
|
17322
|
+
current.entries = current.entries.filter((tracked) => tracked.entryId > throughEntryId);
|
|
17323
|
+
for (const tracked of committed) this.deduplicator.drop(tracked.dedupKey);
|
|
17324
|
+
if (current.entries.length === 0 && current.recoveryDebt === "required") current.recoveryDebt = "none";
|
|
17325
|
+
this.cleanupLedger(chatId);
|
|
17326
|
+
this.emitWorkChanged(chatId);
|
|
17327
|
+
}
|
|
17328
|
+
async markRecoveryDebt(chatId, reason) {
|
|
17329
|
+
const ledger = this.ledger(chatId);
|
|
17330
|
+
if (ledger.recoveryDebt !== "required") {
|
|
17331
|
+
ledger.recoveryDebt = "required";
|
|
17332
|
+
this.emitWorkChanged(chatId);
|
|
17333
|
+
}
|
|
17334
|
+
this.config.log.warn({
|
|
17335
|
+
chatId,
|
|
17336
|
+
reason,
|
|
17337
|
+
entryIds: ledger.entries.map((entry) => entry.entryId)
|
|
17338
|
+
}, "chat has unsettled inbox work; waiting for recovery redelivery");
|
|
17339
|
+
await this.requestRecovery(chatId, reason);
|
|
17340
|
+
}
|
|
17341
|
+
clearEntriesForRecoverySuccess(chatId) {
|
|
17342
|
+
const ledger = this.ledger(chatId);
|
|
17343
|
+
for (const tracked of ledger.entries) this.deduplicator.drop(tracked.dedupKey);
|
|
17344
|
+
ledger.entries = [];
|
|
17345
|
+
}
|
|
17346
|
+
findEntry(chatId, entryId) {
|
|
17347
|
+
return this.ledgers.get(chatId)?.entries.find((entry) => entry.entryId === entryId) ?? null;
|
|
17348
|
+
}
|
|
17349
|
+
messageEntryIds(chatId, messages) {
|
|
17350
|
+
const batch = Array.isArray(messages) ? messages : [messages];
|
|
17351
|
+
const entryIds = /* @__PURE__ */ new Set();
|
|
17352
|
+
for (const message of batch) if (message.chatId === chatId && message.inboxEntryId !== void 0) entryIds.add(message.inboxEntryId);
|
|
17353
|
+
return entryIds;
|
|
17354
|
+
}
|
|
17355
|
+
lastMessageEntryId(chatId, messages) {
|
|
17356
|
+
const batch = Array.isArray(messages) ? messages : [messages];
|
|
17357
|
+
let throughEntryId;
|
|
17358
|
+
for (const message of batch) {
|
|
17359
|
+
if (message.chatId !== chatId) continue;
|
|
17360
|
+
if (message.inboxEntryId !== void 0) throughEntryId = message.inboxEntryId;
|
|
17361
|
+
}
|
|
17362
|
+
return throughEntryId;
|
|
17363
|
+
}
|
|
17364
|
+
ledger(chatId) {
|
|
17365
|
+
const existing = this.ledgers.get(chatId);
|
|
17366
|
+
if (existing) return existing;
|
|
17367
|
+
const ledger = {
|
|
17368
|
+
entries: [],
|
|
17369
|
+
recoveryDebt: "none",
|
|
17370
|
+
recoveryActivationReady: false,
|
|
17371
|
+
admissionQueue: null,
|
|
17372
|
+
ackQueue: null
|
|
17373
|
+
};
|
|
17374
|
+
this.ledgers.set(chatId, ledger);
|
|
17375
|
+
return ledger;
|
|
17376
|
+
}
|
|
17377
|
+
cleanupLedger(chatId) {
|
|
17378
|
+
const ledger = this.ledgers.get(chatId);
|
|
17379
|
+
if (!ledger) return;
|
|
17380
|
+
if (ledger.entries.length === 0 && ledger.recoveryDebt === "none" && !ledger.recoveryActivationReady && ledger.admissionQueue === null && ledger.ackQueue === null) this.ledgers.delete(chatId);
|
|
17381
|
+
}
|
|
17382
|
+
emitWorkChanged(chatId) {
|
|
17383
|
+
this.config.onWorkChanged(chatId);
|
|
17384
|
+
}
|
|
17385
|
+
dedupKey(chatId, messageId) {
|
|
17386
|
+
return `${chatId}:${messageId}`;
|
|
17387
|
+
}
|
|
17388
|
+
};
|
|
17025
17389
|
/**
|
|
17026
17390
|
* Sanitize an `err.message` (or any free-text preview) before it leaves the
|
|
17027
17391
|
* `safe to embed in logs but NOT chat` boundary established by
|
|
@@ -17848,9 +18212,9 @@ var SessionManager = class {
|
|
|
17848
18212
|
sessions = /* @__PURE__ */ new Map();
|
|
17849
18213
|
evictedMappings = /* @__PURE__ */ new Map();
|
|
17850
18214
|
config;
|
|
18215
|
+
inboxDelivery;
|
|
17851
18216
|
/** Last lazy Context-Tree re-resolution attempt (epoch ms); see `TREE_RERESOLVE_INTERVAL_MS`. */
|
|
17852
18217
|
lastTreeResolveAttemptAt = 0;
|
|
17853
|
-
deduplicator = new Deduplicator(1e3);
|
|
17854
18218
|
/**
|
|
17855
18219
|
* Current trigger (messageId + senderId) per chat — the message that kicked
|
|
17856
18220
|
* off the current or most-recent turn. Read by `forwardResult` (via the
|
|
@@ -17860,38 +18224,6 @@ var SessionManager = class {
|
|
|
17860
18224
|
*/
|
|
17861
18225
|
currentTrigger = /* @__PURE__ */ new Map();
|
|
17862
18226
|
registry;
|
|
17863
|
-
/**
|
|
17864
|
-
* In-flight inbox entries per chat — delivered entries that this process
|
|
17865
|
-
* has handed toward a handler and has not yet committed. Completion is
|
|
17866
|
-
* identity based: handlers report the concrete SessionMessage or fused
|
|
17867
|
-
* batch they actually consumed, and the runtime sends one ack-through for
|
|
17868
|
-
* that batch's last inbox entry.
|
|
17869
|
-
*
|
|
17870
|
-
* Kept here (not on `SessionEntry`) because dispatch may push an entryId
|
|
17871
|
-
* before any session record exists for the chat (`startNewSession` runs
|
|
17872
|
-
* `routeMessage` first), and the queue must survive a session being
|
|
17873
|
-
* suspended / evicted between dispatch and markCompleted.
|
|
17874
|
-
*
|
|
17875
|
-
* Sized small in practice: usually 1, briefly up to N when several
|
|
17876
|
-
* messages land while a turn is mid-flight. Entries that were delivered
|
|
17877
|
-
* but only queued inside a handler remain tracked until the handler's
|
|
17878
|
-
* actual consuming turn calls `markMessagesCompleted`.
|
|
17879
|
-
* See
|
|
17880
|
-
* docs/inflight-message-recovery-design.md §4.
|
|
17881
|
-
*/
|
|
17882
|
-
inFlightEntries = /* @__PURE__ */ new Map();
|
|
17883
|
-
/**
|
|
17884
|
-
* Per-chat admission barrier. It serializes the pre-handler admission phase
|
|
17885
|
-
* only: config refresh, eager asset fetch, marking the entry accepted, and
|
|
17886
|
-
* invoking `routeMessage()`. It deliberately does NOT wait for the handler's
|
|
17887
|
-
* turn promise to settle, so A2 can still be appended/injected while A1's
|
|
17888
|
-
* attempt is running; it just cannot overtake A1 before A1 reaches handler
|
|
17889
|
-
* membership.
|
|
17890
|
-
*/
|
|
17891
|
-
admissionQueues = /* @__PURE__ */ new Map();
|
|
17892
|
-
recoveringChats = /* @__PURE__ */ new Map();
|
|
17893
|
-
recoveryActivationReady = /* @__PURE__ */ new Set();
|
|
17894
|
-
requiresInboxRecovery = /* @__PURE__ */ new Set();
|
|
17895
18227
|
pendingQueue = [];
|
|
17896
18228
|
lastReportedStates = /* @__PURE__ */ new Map();
|
|
17897
18229
|
sessionRuntimeStates = /* @__PURE__ */ new Map();
|
|
@@ -17901,6 +18233,12 @@ var SessionManager = class {
|
|
|
17901
18233
|
_activeCount = 0;
|
|
17902
18234
|
constructor(config) {
|
|
17903
18235
|
this.config = config;
|
|
18236
|
+
this.inboxDelivery = new InboxDeliveryCoordinator({
|
|
18237
|
+
ackEntry: config.ackEntry,
|
|
18238
|
+
recoverChat: config.recoverChat,
|
|
18239
|
+
onWorkChanged: (chatId) => this.projectSessionRuntime(chatId),
|
|
18240
|
+
log: config.log
|
|
18241
|
+
});
|
|
17904
18242
|
this.registry = config.registryPath ? new SessionRegistry(config.registryPath) : null;
|
|
17905
18243
|
this.idleTimer = setInterval(() => this.evictIdle(), 1e4);
|
|
17906
18244
|
if (config.onSessionRuntimeChange) {
|
|
@@ -17916,7 +18254,7 @@ var SessionManager = class {
|
|
|
17916
18254
|
}
|
|
17917
18255
|
/**
|
|
17918
18256
|
* Dispatch an inbox entry. ACK is deferred until the handler reports a
|
|
17919
|
-
* completed turn via `ctx.
|
|
18257
|
+
* completed turn via `ctx.finishTurn(...)`.
|
|
17920
18258
|
*
|
|
17921
18259
|
* Delayed ACK semantics (post inflight-message-recovery): the entry stays
|
|
17922
18260
|
* `delivered` server-side until forwardResult succeeds (or the handler
|
|
@@ -17933,40 +18271,16 @@ var SessionManager = class {
|
|
|
17933
18271
|
async dispatch(entry) {
|
|
17934
18272
|
const chatId = entry.chatId ?? entry.message.chatId;
|
|
17935
18273
|
const messageId = entry.message.id;
|
|
17936
|
-
if (this.shouldRecoverBeforeDispatch(chatId)) {
|
|
17937
|
-
await this.
|
|
18274
|
+
if (this.inboxDelivery.shouldRecoverBeforeDispatch(chatId, this.hasHealthyLiveHandler(chatId) || this.hasPendingTransientRetry(chatId), this.hasLocalSessionRecord(chatId))) {
|
|
18275
|
+
await this.inboxDelivery.recoverIfNeeded(chatId, `before_dispatch:${entry.id}:${messageId}`);
|
|
17938
18276
|
return;
|
|
17939
18277
|
}
|
|
17940
|
-
const
|
|
17941
|
-
if (
|
|
17942
|
-
|
|
17943
|
-
const stillInFlight = queue ? queue.some((tracked) => tracked.entryId === entry.id) : false;
|
|
17944
|
-
this.config.log.debug({
|
|
17945
|
-
chatId,
|
|
17946
|
-
messageId,
|
|
17947
|
-
entryId: entry.id,
|
|
17948
|
-
stillInFlight
|
|
17949
|
-
}, "duplicate message observed");
|
|
17950
|
-
if (stillInFlight) return;
|
|
17951
|
-
this.config.log.debug({
|
|
17952
|
-
chatId,
|
|
17953
|
-
messageId,
|
|
17954
|
-
entryId: entry.id
|
|
17955
|
-
}, "duplicate key is not tied to an active entry; reprocessing redelivery");
|
|
17956
|
-
}
|
|
17957
|
-
const queue = this.inFlightEntries.get(chatId);
|
|
17958
|
-
const tracked = {
|
|
17959
|
-
entryId: entry.id,
|
|
17960
|
-
messageId,
|
|
17961
|
-
dedupKey,
|
|
17962
|
-
accepted: false,
|
|
17963
|
-
consumed: false
|
|
17964
|
-
};
|
|
17965
|
-
if (queue) queue.push(tracked);
|
|
17966
|
-
else this.inFlightEntries.set(chatId, [tracked]);
|
|
18278
|
+
const decision = this.inboxDelivery.receive(entry);
|
|
18279
|
+
if (decision.kind !== "deliver") return;
|
|
18280
|
+
const { work } = decision;
|
|
17967
18281
|
let routePromise;
|
|
17968
|
-
await this.
|
|
17969
|
-
if (!this.
|
|
18282
|
+
await this.inboxDelivery.runAdmission(work, async () => {
|
|
18283
|
+
if (!this.inboxDelivery.hasEntry(work)) return;
|
|
17970
18284
|
if (this.config.agentConfigCache) try {
|
|
17971
18285
|
await this.config.agentConfigCache.refreshIfNewer(this.config.agentIdentity.agentId, entry.message.configVersion);
|
|
17972
18286
|
} catch (err) {
|
|
@@ -17977,13 +18291,13 @@ var SessionManager = class {
|
|
|
17977
18291
|
err
|
|
17978
18292
|
}, "config version mismatch — skipping refresh");
|
|
17979
18293
|
}
|
|
17980
|
-
if (!this.
|
|
18294
|
+
if (!this.inboxDelivery.hasEntry(work)) return;
|
|
17981
18295
|
const message = this.extractMessage(entry);
|
|
17982
18296
|
await this.ensureImagesLocal(message);
|
|
17983
|
-
if (!this.
|
|
18297
|
+
if (!this.inboxDelivery.markAccepted(work)) return;
|
|
17984
18298
|
if (!this.hasHealthyLiveHandler(chatId)) await this.ensureContextTreeBinding();
|
|
17985
18299
|
routePromise = this.routeMessage(chatId, message).catch((err) => {
|
|
17986
|
-
if (this.
|
|
18300
|
+
if (this.inboxDelivery.hasEntry(work)) this.inboxDelivery.retryTurn(chatId, message, "route_message_failed");
|
|
17987
18301
|
throw err;
|
|
17988
18302
|
});
|
|
17989
18303
|
});
|
|
@@ -18048,7 +18362,7 @@ var SessionManager = class {
|
|
|
18048
18362
|
this.lastReportedStates.delete(chatId);
|
|
18049
18363
|
this.currentTrigger.delete(chatId);
|
|
18050
18364
|
for (let i = this.pendingQueue.length - 1; i >= 0; i--) if (this.pendingQueue[i]?.chatId === chatId) this.pendingQueue.splice(i, 1);
|
|
18051
|
-
this.
|
|
18365
|
+
await this.inboxDelivery.drainForTerminate(chatId);
|
|
18052
18366
|
this.recomputeRuntimeState();
|
|
18053
18367
|
this.persistRegistry();
|
|
18054
18368
|
this.drainPendingQueue();
|
|
@@ -18139,92 +18453,33 @@ var SessionManager = class {
|
|
|
18139
18453
|
/**
|
|
18140
18454
|
* Compatibility hook for AgentSlot's bind listener. Bind-time recovery is
|
|
18141
18455
|
* now server-driven; chat-scoped same-socket recovery is requested lazily
|
|
18142
|
-
* by `dispatch` when no healthy live handler
|
|
18456
|
+
* by `dispatch` when a locally held chat has no healthy live handler.
|
|
18143
18457
|
*/
|
|
18144
18458
|
noteBindRecoveryComplete() {}
|
|
18145
|
-
async withAdmissionBarrier(chatId, op) {
|
|
18146
|
-
const next = (this.admissionQueues.get(chatId) ?? Promise.resolve()).then(op, op);
|
|
18147
|
-
this.admissionQueues.set(chatId, next);
|
|
18148
|
-
const cleanup = () => {
|
|
18149
|
-
if (this.admissionQueues.get(chatId) === next) this.admissionQueues.delete(chatId);
|
|
18150
|
-
};
|
|
18151
|
-
next.then(cleanup, cleanup);
|
|
18152
|
-
await next;
|
|
18153
|
-
}
|
|
18154
|
-
isTrackedEntry(chatId, entryId) {
|
|
18155
|
-
return this.inFlightEntries.get(chatId)?.some((tracked) => tracked.entryId === entryId) ?? false;
|
|
18156
|
-
}
|
|
18157
|
-
markTrackedEntryAccepted(chatId, entryId) {
|
|
18158
|
-
const tracked = this.inFlightEntries.get(chatId)?.find((entry) => entry.entryId === entryId);
|
|
18159
|
-
if (!tracked) return false;
|
|
18160
|
-
tracked.accepted = true;
|
|
18161
|
-
return true;
|
|
18162
|
-
}
|
|
18163
18459
|
hasHealthyLiveHandler(chatId) {
|
|
18164
18460
|
const entry = this.sessions.get(chatId);
|
|
18165
18461
|
return entry?.status === "active" && entry.suspending === null;
|
|
18166
18462
|
}
|
|
18167
|
-
|
|
18168
|
-
|
|
18169
|
-
|
|
18170
|
-
return false;
|
|
18171
|
-
}
|
|
18172
|
-
if (this.requiresInboxRecovery.has(chatId)) return true;
|
|
18173
|
-
return Boolean(this.config.recoverChat) && !this.hasHealthyLiveHandler(chatId);
|
|
18463
|
+
hasPendingTransientRetry(chatId) {
|
|
18464
|
+
const entry = this.sessions.get(chatId);
|
|
18465
|
+
return Boolean(entry && entry.retryAttempt > 0);
|
|
18174
18466
|
}
|
|
18175
|
-
|
|
18176
|
-
|
|
18177
|
-
if (existing) {
|
|
18178
|
-
await existing;
|
|
18179
|
-
return;
|
|
18180
|
-
}
|
|
18181
|
-
const recoverChat = this.config.recoverChat;
|
|
18182
|
-
if (!recoverChat) {
|
|
18183
|
-
this.config.log.error({
|
|
18184
|
-
chatId,
|
|
18185
|
-
entryId,
|
|
18186
|
-
messageId
|
|
18187
|
-
}, "chat requires inbox recovery but no recoverChat callback is configured; deferring dispatch");
|
|
18188
|
-
return;
|
|
18189
|
-
}
|
|
18190
|
-
let recovery;
|
|
18191
|
-
recovery = recoverChat(chatId).then(() => {
|
|
18192
|
-
this.requiresInboxRecovery.delete(chatId);
|
|
18193
|
-
this.recoveryActivationReady.add(chatId);
|
|
18194
|
-
this.config.log.debug({
|
|
18195
|
-
chatId,
|
|
18196
|
-
entryId,
|
|
18197
|
-
messageId
|
|
18198
|
-
}, "chat inbox recovery accepted before dispatch");
|
|
18199
|
-
}).catch((err) => {
|
|
18200
|
-
this.config.log.warn({
|
|
18201
|
-
chatId,
|
|
18202
|
-
entryId,
|
|
18203
|
-
messageId,
|
|
18204
|
-
err
|
|
18205
|
-
}, "chat inbox recovery failed before dispatch");
|
|
18206
|
-
}).finally(() => {
|
|
18207
|
-
if (this.recoveringChats.get(chatId) === recovery) this.recoveringChats.delete(chatId);
|
|
18208
|
-
});
|
|
18209
|
-
this.recoveringChats.set(chatId, recovery);
|
|
18210
|
-
await recovery;
|
|
18467
|
+
hasLocalSessionRecord(chatId) {
|
|
18468
|
+
return this.sessions.has(chatId) || this.evictedMappings.has(chatId);
|
|
18211
18469
|
}
|
|
18212
18470
|
async routeMessage(chatId, message) {
|
|
18213
|
-
if (message.id) this.currentTrigger.set(chatId, {
|
|
18214
|
-
messageId: message.id,
|
|
18215
|
-
senderId: message.senderId
|
|
18216
|
-
});
|
|
18217
18471
|
const existing = this.sessions.get(chatId);
|
|
18218
18472
|
if (existing && existing.retryAttempt > 0) {
|
|
18219
|
-
existing.
|
|
18473
|
+
existing.retryQueuedMessages.push(message);
|
|
18220
18474
|
this.triggerImmediateRetry(chatId);
|
|
18221
18475
|
return;
|
|
18222
18476
|
}
|
|
18223
18477
|
if (existing) switch (existing.status) {
|
|
18224
18478
|
case "active":
|
|
18479
|
+
this.setCurrentTrigger(chatId, message);
|
|
18225
18480
|
existing.handler.inject(message);
|
|
18226
18481
|
existing.lastActivity = Date.now();
|
|
18227
|
-
this.
|
|
18482
|
+
this.projectSessionRuntime(chatId);
|
|
18228
18483
|
this.config.log.debug({ chatId }, "message injected");
|
|
18229
18484
|
return;
|
|
18230
18485
|
case "suspended":
|
|
@@ -18285,13 +18540,16 @@ var SessionManager = class {
|
|
|
18285
18540
|
lastRetryReason: null,
|
|
18286
18541
|
lastRetryRawError: null,
|
|
18287
18542
|
startMessage: message,
|
|
18543
|
+
retryQueuedMessages: [],
|
|
18288
18544
|
retryFromEvicted: evicted ?? null
|
|
18289
18545
|
};
|
|
18290
18546
|
this.sessions.set(chatId, entry);
|
|
18291
18547
|
this._activeCount++;
|
|
18292
18548
|
if (evicted) this.evictedMappings.delete(chatId);
|
|
18293
18549
|
this.notifySessionState(chatId, "active");
|
|
18550
|
+
this.projectSessionRuntime(chatId);
|
|
18294
18551
|
try {
|
|
18552
|
+
this.setCurrentTrigger(chatId, message);
|
|
18295
18553
|
if (evicted) {
|
|
18296
18554
|
const sessionId = await handler.resume(message, evicted.claudeSessionId, ctx);
|
|
18297
18555
|
entry.claudeSessionId = sessionId;
|
|
@@ -18318,7 +18576,7 @@ var SessionManager = class {
|
|
|
18318
18576
|
phase,
|
|
18319
18577
|
classification
|
|
18320
18578
|
})) {
|
|
18321
|
-
this.
|
|
18579
|
+
await this.inboxDelivery.drainForTerminate(chatId);
|
|
18322
18580
|
this.sessions.delete(chatId);
|
|
18323
18581
|
this.sessionRuntimeStates.delete(chatId);
|
|
18324
18582
|
this.recomputeRuntimeState();
|
|
@@ -18342,7 +18600,9 @@ var SessionManager = class {
|
|
|
18342
18600
|
this._activeCount++;
|
|
18343
18601
|
entry.lastActivity = Date.now();
|
|
18344
18602
|
this.notifySessionState(entry.chatId, "active");
|
|
18603
|
+
this.projectSessionRuntime(entry.chatId);
|
|
18345
18604
|
try {
|
|
18605
|
+
if (message) this.setCurrentTrigger(entry.chatId, message);
|
|
18346
18606
|
entry.claudeSessionId = await entry.handler.resume(message ?? void 0, entry.claudeSessionId, ctx);
|
|
18347
18607
|
this.config.log.info({
|
|
18348
18608
|
chatId: entry.chatId,
|
|
@@ -18358,7 +18618,7 @@ var SessionManager = class {
|
|
|
18358
18618
|
phase: "resume",
|
|
18359
18619
|
classification
|
|
18360
18620
|
})) {
|
|
18361
|
-
this.
|
|
18621
|
+
await this.inboxDelivery.drainForTerminate(entry.chatId);
|
|
18362
18622
|
this.sessions.delete(entry.chatId);
|
|
18363
18623
|
this.sessionRuntimeStates.delete(entry.chatId);
|
|
18364
18624
|
this.recomputeRuntimeState();
|
|
@@ -18396,6 +18656,7 @@ var SessionManager = class {
|
|
|
18396
18656
|
this._activeCount--;
|
|
18397
18657
|
entry.status = "suspended";
|
|
18398
18658
|
this.sessionRuntimeStates.delete(chatId);
|
|
18659
|
+
this.projectSessionRuntime(chatId);
|
|
18399
18660
|
this.recomputeRuntimeState();
|
|
18400
18661
|
this.config.log.info({
|
|
18401
18662
|
chatId,
|
|
@@ -18437,7 +18698,9 @@ var SessionManager = class {
|
|
|
18437
18698
|
}, delayMs);
|
|
18438
18699
|
return true;
|
|
18439
18700
|
}
|
|
18701
|
+
entry.status = "errored";
|
|
18440
18702
|
this.notifySessionState(chatId, "errored");
|
|
18703
|
+
this.projectSessionRuntime(chatId);
|
|
18441
18704
|
try {
|
|
18442
18705
|
const preview = redactErrorPreview(errMsg, 800);
|
|
18443
18706
|
ctx.emitEvent({
|
|
@@ -18514,9 +18777,11 @@ var SessionManager = class {
|
|
|
18514
18777
|
entry.handler = newHandler;
|
|
18515
18778
|
const ctx = this.buildSessionContext(chatId);
|
|
18516
18779
|
this.notifySessionState(chatId, "active");
|
|
18780
|
+
this.projectSessionRuntime(chatId);
|
|
18517
18781
|
try {
|
|
18518
18782
|
const resumeMessage = entry.startMessage ?? null;
|
|
18519
18783
|
const previousSessionId = entry.claudeSessionId || entry.retryFromEvicted?.claudeSessionId || "";
|
|
18784
|
+
if (resumeMessage) this.setCurrentTrigger(chatId, resumeMessage);
|
|
18520
18785
|
if (previousSessionId) entry.claudeSessionId = await newHandler.resume(resumeMessage ?? void 0, previousSessionId, ctx);
|
|
18521
18786
|
else {
|
|
18522
18787
|
const message = resumeMessage ?? buildEmptySessionMessage(chatId);
|
|
@@ -18546,6 +18811,7 @@ var SessionManager = class {
|
|
|
18546
18811
|
emitErr
|
|
18547
18812
|
}, "resilience retry_succeeded emit failed");
|
|
18548
18813
|
}
|
|
18814
|
+
this.drainRetryQueuedMessages(entry);
|
|
18549
18815
|
this.persistRegistry();
|
|
18550
18816
|
} catch (err) {
|
|
18551
18817
|
const classification = classify(err, { source: "session" });
|
|
@@ -18556,7 +18822,7 @@ var SessionManager = class {
|
|
|
18556
18822
|
phase: previousAvailable(entry) ? "resume" : "start",
|
|
18557
18823
|
classification
|
|
18558
18824
|
})) {
|
|
18559
|
-
this.
|
|
18825
|
+
await this.inboxDelivery.drainForTerminate(chatId);
|
|
18560
18826
|
this.sessions.delete(chatId);
|
|
18561
18827
|
this.sessionRuntimeStates.delete(chatId);
|
|
18562
18828
|
this.recomputeRuntimeState();
|
|
@@ -18579,13 +18845,40 @@ var SessionManager = class {
|
|
|
18579
18845
|
}
|
|
18580
18846
|
this.runRetry(chatId);
|
|
18581
18847
|
}
|
|
18848
|
+
drainRetryQueuedMessages(entry) {
|
|
18849
|
+
if (entry.retryQueuedMessages.length === 0) return;
|
|
18850
|
+
const queued = entry.retryQueuedMessages.splice(0);
|
|
18851
|
+
for (const message of queued) {
|
|
18852
|
+
this.setCurrentTrigger(entry.chatId, message);
|
|
18853
|
+
try {
|
|
18854
|
+
entry.handler.inject(message);
|
|
18855
|
+
entry.lastActivity = Date.now();
|
|
18856
|
+
} catch (err) {
|
|
18857
|
+
this.config.log.warn({
|
|
18858
|
+
chatId: entry.chatId,
|
|
18859
|
+
messageId: message.id,
|
|
18860
|
+
err
|
|
18861
|
+
}, "retry queued inject failed");
|
|
18862
|
+
this.inboxDelivery.retryTurn(entry.chatId, message, "retry_queued_inject_failed");
|
|
18863
|
+
break;
|
|
18864
|
+
}
|
|
18865
|
+
}
|
|
18866
|
+
this.projectSessionRuntime(entry.chatId);
|
|
18867
|
+
}
|
|
18868
|
+
setCurrentTrigger(chatId, message) {
|
|
18869
|
+
if (!message.id) return;
|
|
18870
|
+
this.currentTrigger.set(chatId, {
|
|
18871
|
+
messageId: message.id,
|
|
18872
|
+
senderId: message.senderId
|
|
18873
|
+
});
|
|
18874
|
+
}
|
|
18582
18875
|
/**
|
|
18583
18876
|
* Try to acquire an active slot. If at concurrency limit:
|
|
18584
18877
|
* 1. Suspend the least-recently-active session to free a slot
|
|
18585
18878
|
* 2. If no candidates, queue the message
|
|
18586
18879
|
*
|
|
18587
18880
|
* Returns true if slot acquired, false if queued. The in-flight entryId
|
|
18588
|
-
* is tracked separately in `
|
|
18881
|
+
* is tracked separately in `InboxDeliveryCoordinator` (populated at dispatch),
|
|
18589
18882
|
* so the queue doesn't carry inbox metadata.
|
|
18590
18883
|
*/
|
|
18591
18884
|
acquireActiveSlot(chatId, message) {
|
|
@@ -18598,7 +18891,10 @@ var SessionManager = class {
|
|
|
18598
18891
|
}
|
|
18599
18892
|
if (oldestActive) {
|
|
18600
18893
|
this.config.log.info({ chatId: oldestActive.chatId }, "session preempted for concurrency");
|
|
18601
|
-
this.suspendSession(oldestActive
|
|
18894
|
+
this.suspendSession(oldestActive, {
|
|
18895
|
+
reason: "concurrency_preempted",
|
|
18896
|
+
ackConsumedPrefix: false
|
|
18897
|
+
});
|
|
18602
18898
|
return true;
|
|
18603
18899
|
}
|
|
18604
18900
|
this.config.log.info({ chatId }, "concurrency limit reached, queuing");
|
|
@@ -18608,14 +18904,21 @@ var SessionManager = class {
|
|
|
18608
18904
|
});
|
|
18609
18905
|
return false;
|
|
18610
18906
|
}
|
|
18611
|
-
suspendSession(entry
|
|
18612
|
-
|
|
18613
|
-
|
|
18907
|
+
suspendSession(entry, opts = {
|
|
18908
|
+
reason: "session_suspended",
|
|
18909
|
+
ackConsumedPrefix: true
|
|
18910
|
+
}) {
|
|
18911
|
+
const prepare = opts.ackConsumedPrefix ? this.inboxDelivery.prepareSuspend(entry.chatId, opts.reason) : Promise.resolve(this.inboxDelivery.prepareEvict(entry.chatId, opts.reason));
|
|
18614
18912
|
entry.status = "suspended";
|
|
18615
18913
|
this._activeCount--;
|
|
18616
18914
|
this.sessionRuntimeStates.delete(entry.chatId);
|
|
18617
18915
|
this.recomputeRuntimeState();
|
|
18618
|
-
entry.suspending = entry.handler.suspend().catch((err) => {
|
|
18916
|
+
entry.suspending = prepare.then(() => entry.handler.suspend()).catch((err) => {
|
|
18917
|
+
this.config.log.warn({
|
|
18918
|
+
chatId: entry.chatId,
|
|
18919
|
+
err
|
|
18920
|
+
}, "suspend preparation error");
|
|
18921
|
+
}).then(() => void 0).catch((err) => {
|
|
18619
18922
|
this.config.log.warn({
|
|
18620
18923
|
chatId: entry.chatId,
|
|
18621
18924
|
err
|
|
@@ -18671,7 +18974,7 @@ var SessionManager = class {
|
|
|
18671
18974
|
this.sessions.delete(candidate.key);
|
|
18672
18975
|
this.sessionRuntimeStates.delete(candidate.key);
|
|
18673
18976
|
this.currentTrigger.delete(candidate.key);
|
|
18674
|
-
this.
|
|
18977
|
+
this.inboxDelivery.prepareEvict(candidate.key, "session_evicted");
|
|
18675
18978
|
this.recomputeRuntimeState();
|
|
18676
18979
|
this.persistRegistry();
|
|
18677
18980
|
}
|
|
@@ -18682,28 +18985,12 @@ var SessionManager = class {
|
|
|
18682
18985
|
* Invariants this routine relies on:
|
|
18683
18986
|
* 1. `lastActivity` is monotonic per session and is bumped by every
|
|
18684
18987
|
* inbound activity — `dispatch` (new chat / resume), `inject`
|
|
18685
|
-
* (mid-turn message), and the handler's
|
|
18686
|
-
* 2.
|
|
18687
|
-
*
|
|
18688
|
-
*
|
|
18689
|
-
*
|
|
18690
|
-
*
|
|
18691
|
-
* for-await — see #536). Cleared back to `idle` on each
|
|
18692
|
-
* `result` message from the SDK.
|
|
18693
|
-
* - `blocked` — only reachable if a handler chooses to set it
|
|
18694
|
-
* explicitly. The runtime no longer auto-migrates `working` →
|
|
18695
|
-
* `blocked` on inactivity: with reasoning models a 2-minute
|
|
18696
|
-
* quiet stretch is normal deep-thinking, not a stuck process,
|
|
18697
|
-
* and the auto-migration was producing false-positive UI
|
|
18698
|
-
* warnings. State kept as a semantic slot for future use
|
|
18699
|
-
* (e.g. if/when the SDK transport exposes a real stuck signal).
|
|
18700
|
-
* - `idle` — no work in flight.
|
|
18701
|
-
* If you add a new way to wake the session up, you MUST also set
|
|
18702
|
-
* `runtimeState` to `working` from that path, or this guard will
|
|
18703
|
-
* treat the chat as idle and reap it.
|
|
18704
|
-
* 3. `working_grace_seconds` is an UPPER bound on how long a `working`
|
|
18705
|
-
* / `blocked` chat can hold a slot past `idle_timeout` — defends
|
|
18706
|
-
* against a stuck handler that never flips back to `idle`.
|
|
18988
|
+
* (mid-turn message), and the handler's provider-activity callback.
|
|
18989
|
+
* 2. The coordinator is the source of truth for unsettled delivery work.
|
|
18990
|
+
* If a chat has tracked / consumed / ACK-pending entries or recovery
|
|
18991
|
+
* debt, the reaper must not treat it as idle before the hard cap.
|
|
18992
|
+
* 3. `working_grace_seconds` is an UPPER bound on how long unsettled work
|
|
18993
|
+
* can hold a slot past `idle_timeout`.
|
|
18707
18994
|
*/
|
|
18708
18995
|
evictIdle() {
|
|
18709
18996
|
const timeoutMs = this.config.session.idle_timeout * 1e3;
|
|
@@ -18714,14 +19001,15 @@ var SessionManager = class {
|
|
|
18714
19001
|
const inactiveMs = now - session.lastActivity;
|
|
18715
19002
|
if (inactiveMs <= timeoutMs) continue;
|
|
18716
19003
|
const currentState = this.sessionRuntimeStates.get(session.chatId);
|
|
19004
|
+
const hasUnsettledWork = this.inboxDelivery.hasUnsettledWork(session.chatId);
|
|
18717
19005
|
const pastHardCap = inactiveMs >= timeoutMs + workingGraceMs;
|
|
18718
|
-
if (
|
|
19006
|
+
if (hasUnsettledWork && !pastHardCap) {
|
|
18719
19007
|
this.config.log.info({
|
|
18720
19008
|
chatId: session.chatId,
|
|
18721
19009
|
runtimeState: currentState,
|
|
18722
19010
|
inactiveSec: Math.round(inactiveMs / 1e3),
|
|
18723
19011
|
graceSec: this.config.session.working_grace_seconds
|
|
18724
|
-
}, "session idle threshold reached but
|
|
19012
|
+
}, "session idle threshold reached but inbox work is unsettled — skipping suspend");
|
|
18725
19013
|
continue;
|
|
18726
19014
|
}
|
|
18727
19015
|
this.config.log.info({
|
|
@@ -18748,104 +19036,6 @@ var SessionManager = class {
|
|
|
18748
19036
|
this.lastReportedStates.set(chatId, state);
|
|
18749
19037
|
this.config.onStateChange(chatId, state);
|
|
18750
19038
|
}
|
|
18751
|
-
ackThroughTrackedEntry(chatId, throughEntryId) {
|
|
18752
|
-
const queue = this.inFlightEntries.get(chatId);
|
|
18753
|
-
if (!queue || queue.length === 0) return;
|
|
18754
|
-
const index = queue.findIndex((tracked) => tracked.entryId === throughEntryId);
|
|
18755
|
-
if (index < 0) {
|
|
18756
|
-
this.config.log.warn({
|
|
18757
|
-
chatId,
|
|
18758
|
-
throughEntryId
|
|
18759
|
-
}, "attempt completion ignored for untracked inbox entry");
|
|
18760
|
-
return;
|
|
18761
|
-
}
|
|
18762
|
-
const committed = queue.splice(0, index + 1);
|
|
18763
|
-
for (const tracked of committed) this.deduplicator.drop(tracked.dedupKey);
|
|
18764
|
-
if (queue.length === 0) this.inFlightEntries.delete(chatId);
|
|
18765
|
-
this.config.ackEntry(throughEntryId).catch((err) => {
|
|
18766
|
-
this.config.log.warn({
|
|
18767
|
-
chatId,
|
|
18768
|
-
entryId: throughEntryId,
|
|
18769
|
-
err
|
|
18770
|
-
}, "ACK-through failed, continuing");
|
|
18771
|
-
});
|
|
18772
|
-
}
|
|
18773
|
-
ackOldestTrackedEntry(chatId) {
|
|
18774
|
-
const entryId = this.inFlightEntries.get(chatId)?.[0]?.entryId;
|
|
18775
|
-
if (entryId !== void 0) this.ackThroughTrackedEntry(chatId, entryId);
|
|
18776
|
-
}
|
|
18777
|
-
clearInFlightForRecovery(chatId, reason) {
|
|
18778
|
-
const queue = this.inFlightEntries.get(chatId);
|
|
18779
|
-
if (!queue || queue.length === 0) return;
|
|
18780
|
-
this.inFlightEntries.delete(chatId);
|
|
18781
|
-
this.deduplicator.dropByPrefix(`${chatId}:`);
|
|
18782
|
-
this.requiresInboxRecovery.add(chatId);
|
|
18783
|
-
this.config.log.warn({
|
|
18784
|
-
chatId,
|
|
18785
|
-
reason,
|
|
18786
|
-
entryIds: queue.map((entry) => entry.entryId)
|
|
18787
|
-
}, "cleared local in-flight inbox entries; waiting for recovery redelivery");
|
|
18788
|
-
}
|
|
18789
|
-
ackConsumedInFlightForSuspend(chatId) {
|
|
18790
|
-
const queue = this.inFlightEntries.get(chatId);
|
|
18791
|
-
if (!queue || queue.length === 0) return;
|
|
18792
|
-
let consumedPrefixCount = 0;
|
|
18793
|
-
for (const tracked of queue) {
|
|
18794
|
-
if (!tracked.consumed) break;
|
|
18795
|
-
consumedPrefixCount++;
|
|
18796
|
-
}
|
|
18797
|
-
if (consumedPrefixCount === 0) return;
|
|
18798
|
-
const lastConsumed = queue[consumedPrefixCount - 1];
|
|
18799
|
-
if (lastConsumed) this.ackThroughTrackedEntry(chatId, lastConsumed.entryId);
|
|
18800
|
-
}
|
|
18801
|
-
markMessagesConsumed(chatId, messages) {
|
|
18802
|
-
const queue = this.inFlightEntries.get(chatId);
|
|
18803
|
-
if (!queue || queue.length === 0) return;
|
|
18804
|
-
const batch = Array.isArray(messages) ? messages : [messages];
|
|
18805
|
-
const consumedIds = /* @__PURE__ */ new Set();
|
|
18806
|
-
for (const message of batch) if (message.chatId === chatId && message.inboxEntryId !== void 0) consumedIds.add(message.inboxEntryId);
|
|
18807
|
-
if (consumedIds.size === 0) return;
|
|
18808
|
-
for (const tracked of queue) if (consumedIds.has(tracked.entryId)) tracked.consumed = true;
|
|
18809
|
-
}
|
|
18810
|
-
markMessagesCompleted(chatId, messages) {
|
|
18811
|
-
const batch = Array.isArray(messages) ? messages : [messages];
|
|
18812
|
-
let throughEntryId;
|
|
18813
|
-
for (const message of batch) {
|
|
18814
|
-
if (message.chatId !== chatId) continue;
|
|
18815
|
-
if (message.inboxEntryId !== void 0) throughEntryId = message.inboxEntryId;
|
|
18816
|
-
}
|
|
18817
|
-
if (throughEntryId === void 0) {
|
|
18818
|
-
this.config.log.warn({ chatId }, "attempt completion ignored because no inboxEntryId was provided");
|
|
18819
|
-
return;
|
|
18820
|
-
}
|
|
18821
|
-
this.ackThroughTrackedEntry(chatId, throughEntryId);
|
|
18822
|
-
}
|
|
18823
|
-
markMessagesRetryable(chatId, messages, reason) {
|
|
18824
|
-
if (!(Array.isArray(messages) ? messages : [messages]).some((message) => message.chatId === chatId && message.inboxEntryId !== void 0 && (this.inFlightEntries.get(chatId) ?? []).some((entry) => entry.entryId === message.inboxEntryId))) return;
|
|
18825
|
-
this.clearInFlightForRecovery(chatId, reason);
|
|
18826
|
-
}
|
|
18827
|
-
/**
|
|
18828
|
-
* Drain every in-flight entry for this chat and ack them all. Used by
|
|
18829
|
-
* the runtime on `session:terminate` (operator intent — every queued
|
|
18830
|
-
* entry is doomed) and on permanent `handler.start` / `handler.resume`
|
|
18831
|
-
* failure (re-handling on redelivery would re-hit the same permanent
|
|
18832
|
-
* error, so acking avoids a loop). NOT to be called from handlers — the
|
|
18833
|
-
* per-turn pairing path is `markMessagesCompleted(messageOrBatch)`.
|
|
18834
|
-
*/
|
|
18835
|
-
drainAllInFlightEntries(chatId) {
|
|
18836
|
-
const queue = this.inFlightEntries.get(chatId);
|
|
18837
|
-
if (!queue || queue.length === 0) return;
|
|
18838
|
-
let acceptedPrefixCount = 0;
|
|
18839
|
-
for (const tracked of queue) {
|
|
18840
|
-
if (!tracked.accepted) break;
|
|
18841
|
-
acceptedPrefixCount++;
|
|
18842
|
-
}
|
|
18843
|
-
if (acceptedPrefixCount > 0) {
|
|
18844
|
-
const lastAccepted = queue[acceptedPrefixCount - 1];
|
|
18845
|
-
if (lastAccepted) this.ackThroughTrackedEntry(chatId, lastAccepted.entryId);
|
|
18846
|
-
}
|
|
18847
|
-
if (this.inFlightEntries.has(chatId)) this.clearInFlightForRecovery(chatId, "drain_unaccepted_remainder");
|
|
18848
|
-
}
|
|
18849
19039
|
buildSessionContext(chatId) {
|
|
18850
19040
|
const sessionLog = this.config.log.child({ chatId });
|
|
18851
19041
|
const log = (msg) => sessionLog.info(msg);
|
|
@@ -18886,28 +19076,22 @@ var SessionManager = class {
|
|
|
18886
19076
|
sdk: this.config.sdk,
|
|
18887
19077
|
log,
|
|
18888
19078
|
chatId,
|
|
18889
|
-
|
|
19079
|
+
recordProviderActivity: () => {
|
|
18890
19080
|
const entry = this.sessions.get(chatId);
|
|
18891
19081
|
if (entry && entry.status === "active") entry.lastActivity = Date.now();
|
|
18892
19082
|
},
|
|
18893
|
-
setRuntimeState: (state) => {
|
|
18894
|
-
this.setSessionRuntimeState(chatId, state);
|
|
18895
|
-
},
|
|
18896
19083
|
emitEvent: (event) => {
|
|
18897
19084
|
this.config.onSessionEvent?.(chatId, event);
|
|
18898
19085
|
},
|
|
18899
19086
|
forwardResult,
|
|
18900
|
-
markCompleted: () => {
|
|
18901
|
-
this.ackOldestTrackedEntry(chatId);
|
|
18902
|
-
},
|
|
18903
19087
|
markMessagesConsumed: (messages) => {
|
|
18904
|
-
this.
|
|
19088
|
+
this.inboxDelivery.markConsumed(chatId, messages);
|
|
18905
19089
|
},
|
|
18906
|
-
|
|
18907
|
-
this.
|
|
19090
|
+
finishTurn: (messages, outcome) => {
|
|
19091
|
+
return this.inboxDelivery.finishTurn(chatId, messages, outcome);
|
|
18908
19092
|
},
|
|
18909
|
-
|
|
18910
|
-
this.
|
|
19093
|
+
retryTurn: (messages, reason) => {
|
|
19094
|
+
this.inboxDelivery.retryTurn(chatId, messages, reason);
|
|
18911
19095
|
},
|
|
18912
19096
|
buildAgentEnv: (parentEnv) => buildAgentEnv(parentEnv, envCtx),
|
|
18913
19097
|
formatInboundContent: (message) => formatInboundContent(message, participants),
|
|
@@ -18925,14 +19109,24 @@ var SessionManager = class {
|
|
|
18925
19109
|
return { agentHome: sessionRoot };
|
|
18926
19110
|
}
|
|
18927
19111
|
}
|
|
18928
|
-
|
|
18929
|
-
setSessionRuntimeState(chatId, state) {
|
|
19112
|
+
projectSessionRuntime(chatId) {
|
|
18930
19113
|
const session = this.sessions.get(chatId);
|
|
18931
|
-
|
|
19114
|
+
const state = this.projectedRuntimeState(chatId, session ?? null);
|
|
19115
|
+
if (!state) {
|
|
19116
|
+
if (this.sessionRuntimeStates.delete(chatId)) this.recomputeRuntimeState();
|
|
19117
|
+
return;
|
|
19118
|
+
}
|
|
19119
|
+
if (this.sessionRuntimeStates.get(chatId) === state) return;
|
|
18932
19120
|
this.sessionRuntimeStates.set(chatId, state);
|
|
18933
19121
|
this.config.onSessionRuntimeChange?.(chatId, state);
|
|
18934
19122
|
this.recomputeRuntimeState();
|
|
18935
19123
|
}
|
|
19124
|
+
projectedRuntimeState(chatId, session) {
|
|
19125
|
+
if (!session) return null;
|
|
19126
|
+
if (session.status === "errored") return "error";
|
|
19127
|
+
if (session.status !== "active") return null;
|
|
19128
|
+
return this.inboxDelivery.hasUnsettledWork(chatId) ? "working" : "idle";
|
|
19129
|
+
}
|
|
18936
19130
|
/**
|
|
18937
19131
|
* Re-affirm working / blocked / error sessions so the server-side
|
|
18938
19132
|
* freshness stamp doesn't lapse mid-turn. Reports only — does NOT
|
|
@@ -18944,9 +19138,9 @@ var SessionManager = class {
|
|
|
18944
19138
|
reaffirmRuntimeStates() {
|
|
18945
19139
|
if (!this.config.onSessionRuntimeChange) return;
|
|
18946
19140
|
for (const [chatId, session] of this.sessions) {
|
|
18947
|
-
if (session.status !== "active") continue;
|
|
19141
|
+
if (session.status !== "active" && session.status !== "errored") continue;
|
|
18948
19142
|
const state = this.sessionRuntimeStates.get(chatId);
|
|
18949
|
-
if (state === "working" || state === "
|
|
19143
|
+
if (state === "working" || state === "error") this.config.onSessionRuntimeChange(chatId, state);
|
|
18950
19144
|
}
|
|
18951
19145
|
}
|
|
18952
19146
|
/**
|
|
@@ -18959,10 +19153,11 @@ var SessionManager = class {
|
|
|
18959
19153
|
getSessionRuntimeStates() {
|
|
18960
19154
|
const out = [];
|
|
18961
19155
|
for (const [chatId, session] of this.sessions) {
|
|
18962
|
-
|
|
19156
|
+
const runtimeState = this.projectedRuntimeState(chatId, session);
|
|
19157
|
+
if (!runtimeState) continue;
|
|
18963
19158
|
out.push({
|
|
18964
19159
|
chatId,
|
|
18965
|
-
runtimeState
|
|
19160
|
+
runtimeState
|
|
18966
19161
|
});
|
|
18967
19162
|
}
|
|
18968
19163
|
return out;
|
|
@@ -19288,7 +19483,7 @@ var AgentSlot = class {
|
|
|
19288
19483
|
* Ack happens INSIDE the SessionManager via the `ackEntry` callback we
|
|
19289
19484
|
* pinned at construction time — `clientConnection.sendInboxAck`. Post
|
|
19290
19485
|
* inflight-message-recovery the ack is deferred until the handler closes
|
|
19291
|
-
* the turn via `ctx.
|
|
19486
|
+
* the turn via `ctx.finishTurn(...)`. Sending an additional ack here
|
|
19292
19487
|
* would double-ack: a WS frame the server cannot match against any
|
|
19293
19488
|
* `delivered` row, which leaks the server's per-agent in-flight counter
|
|
19294
19489
|
* and stalls push after `inboxMaxInFlightPerAgent` messages.
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as readWorkspaceManifest, B as printResults, Bt as SdkError, C as onboardCreate, D as computeWorkspaceStatus, E as promoteToWorkspace, H as getClientServiceStatus, I as checkClientConfig, J as startClientService, K as resolveCliInvocation, L as checkNodeVersion, O as discoverWorkspaceRoot, P as checkAgentConfigs, R as checkServerReachable, S as onboardCheck, St as status, T as migrateWorkspaceToW1, U as installClientService, W as isServiceSupported, X as uninstallClientService, Y as stopClientService, Z as ClientRuntime, at as ensureFreshAccessToken, b as promptMissingFields, ct as resolveAccessToken, it as AuthRefreshRateLimitedError, j as writeWorkspaceManifest, k as pickImmediateWorkspaceSources, lt as resolveServerUrl, nt as rotateClientIdWithBackup, ot as ensureFreshAdminToken, q as restartClientService, r as deriveHubUrlFromToken, rt as AuthRefreshFailedError, t as HubUrlDerivationError, tt as handleClientOrgMismatch, v as isInteractive, vt as blank, w as detectMigrationState, x as formatCheckReport, y as promptAddAgent, z as checkWebSocket, zt as FirstTreeHubSDK } from "./connect-token-
|
|
1
|
+
import { A as readWorkspaceManifest, B as printResults, Bt as SdkError, C as onboardCreate, D as computeWorkspaceStatus, E as promoteToWorkspace, H as getClientServiceStatus, I as checkClientConfig, J as startClientService, K as resolveCliInvocation, L as checkNodeVersion, O as discoverWorkspaceRoot, P as checkAgentConfigs, R as checkServerReachable, S as onboardCheck, St as status, T as migrateWorkspaceToW1, U as installClientService, W as isServiceSupported, X as uninstallClientService, Y as stopClientService, Z as ClientRuntime, at as ensureFreshAccessToken, b as promptMissingFields, ct as resolveAccessToken, it as AuthRefreshRateLimitedError, j as writeWorkspaceManifest, k as pickImmediateWorkspaceSources, lt as resolveServerUrl, nt as rotateClientIdWithBackup, ot as ensureFreshAdminToken, q as restartClientService, r as deriveHubUrlFromToken, rt as AuthRefreshFailedError, t as HubUrlDerivationError, tt as handleClientOrgMismatch, v as isInteractive, vt as blank, w as detectMigrationState, x as formatCheckReport, y as promptAddAgent, z as checkWebSocket, zt as FirstTreeHubSDK } from "./connect-token-Cd0Z_mdb.mjs";
|
|
2
2
|
export { AuthRefreshFailedError, AuthRefreshRateLimitedError, ClientRuntime, FirstTreeHubSDK, FirstTreeHubSDK as FirstTreeSDK, HubUrlDerivationError, SdkError, blank, checkAgentConfigs, checkClientConfig, checkNodeVersion, checkServerReachable, checkWebSocket, computeWorkspaceStatus, deriveHubUrlFromToken, detectMigrationState, discoverWorkspaceRoot, ensureFreshAccessToken, ensureFreshAdminToken, formatCheckReport, getClientServiceStatus, handleClientOrgMismatch, installClientService, isInteractive, isServiceSupported, migrateWorkspaceToW1, onboardCheck, onboardCreate, pickImmediateWorkspaceSources, printResults, promoteToWorkspace, promptAddAgent, promptMissingFields, readWorkspaceManifest, resolveAccessToken, resolveCliInvocation, resolveServerUrl, restartClientService, rotateClientIdWithBackup, startClientService, status, stopClientService, uninstallClientService, writeWorkspaceManifest };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "first-tree-staging",
|
|
3
|
-
"version": "0.5.7-staging.
|
|
3
|
+
"version": "0.5.7-staging.367.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "First Tree — unified CLI for Context Tree onboarding, agent management, and team messaging. (Source-tree dev build; CI rewrites `name` and `bin` for prod / staging publishes — see docs/local-dev-isolation.md.)",
|
|
6
6
|
"keywords": [
|