switchroom 0.19.23 → 0.19.24
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/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +35 -9
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +242 -82
- package/dist/host-control/main.js +36 -10
- package/dist/vault/approvals/kernel-server.js +35 -9
- package/dist/vault/broker/server.js +35 -9
- package/package.json +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +3 -4
- package/skills/dev-protocol/SKILL.md +22 -15
- package/skills/switchroom-release/SKILL.md +2 -1
- package/telegram-plugin/dist/gateway/gateway.js +171 -48
- package/telegram-plugin/gateway/gateway.ts +34 -35
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +53 -21
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
|
@@ -21042,17 +21042,19 @@ var init_schema = __esm(() => {
|
|
|
21042
21042
|
model: exports_external.string().min(1).optional().describe("Per-op model (upstream `HINDSIGHT_API_<OP>_LLM_MODEL`). Absent \u2192 " + "inherit the global `hindsight.llm.model`."),
|
|
21043
21043
|
provider: exports_external.string().min(1).optional().describe("Per-op provider (upstream `HINDSIGHT_API_<OP>_LLM_PROVIDER`). " + "Absent \u2192 inherit the global `hindsight.llm.provider`."),
|
|
21044
21044
|
base_url: exports_external.string().min(1).optional().describe("Per-op base URL (upstream `HINDSIGHT_API_<OP>_LLM_BASE_URL`). " + "Optional passthrough; absent \u2192 inherit the global."),
|
|
21045
|
-
api_key: exports_external.string().min(1).optional().describe("Per-op API key (upstream `HINDSIGHT_API_<OP>_LLM_API_KEY`). Literal " + "or `vault:` reference. Optional passthrough; absent \u2192 inherit global.")
|
|
21045
|
+
api_key: exports_external.string().min(1).optional().describe("Per-op API key (upstream `HINDSIGHT_API_<OP>_LLM_API_KEY`). Literal " + "or `vault:` reference. Optional passthrough; absent \u2192 inherit global."),
|
|
21046
|
+
context_window: exports_external.number().int().positive().optional().describe("Context window (tokens) of the backend serving THIS op. NOT an " + "upstream env var \u2014 switchroom derives the op's token budget " + "(consolidation batch size / max-completion caps / reflect " + "max-context cap) from it so a single call can never overflow the " + "window. Absent \u2192 inherit " + "`hindsight.llm.context_window`, else a per-provider default " + "(conservative for non-`claude-code` providers, which usually mean " + "a local llama.cpp/Ollama slot; a self-hosted `base_url` \u2014 loopback, " + "RFC1918, `.local`/`.internal` \u2014 forces the conservative default too, " + "regardless of the provider NAME, since the endpoint is where the " + "traffic actually terminates). All three lanes (`retain`, " + "`reflect`, `consolidation`) are budgeted independently.")
|
|
21046
21047
|
}).describe("Per-operation LLM override. Every field optional; an unset field (or " + "an omitted op block) inherits the global `hindsight.llm.*`, which is " + "already the engine's fallback \u2014 switchroom emits only the vars set.");
|
|
21047
21048
|
HindsightConfigSchema = exports_external.object({
|
|
21048
21049
|
llm: exports_external.object({
|
|
21049
21050
|
provider: exports_external.string().min(1).optional().describe("Hindsight LLM provider (upstream `HINDSIGHT_API_LLM_PROVIDER`). " + "Defaults to `claude-code` (subscription-honest, broker-fed OAuth). " + "Any litellm-routable provider the upstream image supports is valid. " + "Serves as the GLOBAL default for every op absent a per-op override."),
|
|
21050
21051
|
model: exports_external.string().min(1).optional().describe("Hindsight LLM model (upstream `HINDSIGHT_API_LLM_MODEL`). Defaults " + "to HINDSIGHT_DEFAULT_MODEL. Any model your LiteLLM proxy can route " + "is valid, e.g. `openrouter/z-ai/glm-5.2` when routing through the " + "fleet proxy. With provider=claude-code this value is ALSO exported " + "as `ANTHROPIC_MODEL` to the claude subprocess. Serves as the GLOBAL " + "default for every op absent a per-op override."),
|
|
21052
|
+
context_window: exports_external.number().int().positive().optional().describe("GLOBAL context window (tokens) of the backend serving hindsight's " + "LLM ops \u2014 the declared size switchroom derives every token budget " + "from. Set this to the real window of whatever you point " + "`hindsight.llm` at (e.g. 32768 for a llama.cpp slot launched with " + "`-c 65536 -np 2`, 131072 for a large-window OpenRouter model). " + "Absent \u2192 a per-provider default: 200000 for `claude-code`, a " + "conservative 32768 for everything else. Overflowing a local " + "backend's window does NOT error \u2014 llama.cpp context-shift silently " + "drops the system prompt and the model answers conversationally " + "with HTTP 200 \u2014 so this value is what makes the failure " + "detectable at setup time instead of never."),
|
|
21051
21053
|
retain: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `retain` LLM op (memory ingestion). Emits " + "`HINDSIGHT_API_RETAIN_LLM_*`. Absent \u2192 uses the global model/provider."),
|
|
21052
21054
|
reflect: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `reflect` LLM op (synthesis / mental-model " + "refresh). Emits `HINDSIGHT_API_REFLECT_LLM_*`. Absent \u2192 uses global."),
|
|
21053
21055
|
consolidation: HindsightPerOpLlmSchema.optional().describe("Per-op override for the `consolidation` LLM op (background memory " + "merge). Emits `HINDSIGHT_API_CONSOLIDATION_LLM_*`. Absent \u2192 global.")
|
|
21054
21056
|
}).optional().describe("LLM knob for the hindsight container. The flat `provider`/`model` set " + "the global default (backward-compatible); optional `retain`/`reflect`/" + "`consolidation` blocks override individual ops. All fields optional; " + "unset fields fall back to the hard-coded defaults."),
|
|
21055
|
-
env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "LLM_MAX_CONCURRENT, RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
|
|
21057
|
+
env: exports_external.record(exports_external.union([exports_external.string(), exports_external.number(), exports_external.boolean()])).optional().describe("Operator overrides for switchroom's capability-gated Hindsight " + "performance defaults. Only the keys switchroom actually manages are " + "honoured (`HINDSIGHT_PERF_ENV_KEYS` in " + "src/setup/hindsight-perf-defaults.ts: RERANKER_LOCAL_FP16, " + "RERANKER_LOCAL_BATCH_SIZE, LLM_MAX_CONCURRENT, " + "RETAIN/CONSOLIDATION_LLM_MAX_CONCURRENT, LLM_STRICT_SCHEMA, " + "LLM_MAX_RETRIES, CONSOLIDATION_LLM_PARALLELISM, " + "RECALL_MAX_CANDIDATES_PER_SOURCE, LINK_EXPANSION_PER_ENTITY_LIMIT, " + "LINK_EXPANSION_TIMEOUT, LLM_REASONING_EFFORT), the override-only keys " + "switchroom manages but ships NO default for " + "(`HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS`: " + "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY \u2014 a per-deployment " + "`bank-pattern:priority,...` map; unset means upstream's flat " + "created_at FIFO across banks), plus the " + "embedded-PostgreSQL (pg0) sizing keys switchroom manages in " + "src/setup/hindsight-pg-defaults.ts (`HINDSIGHT_PG_ENV_KEYS`: " + "SWITCHROOM_HINDSIGHT_PG_EFFECTIVE_CACHE_SIZE, " + "SWITCHROOM_HINDSIGHT_PG_SHARED_BUFFERS \u2014 a postgres size string such " + "as `4GB`, or the sentinel `off` to leave pg0's own default for that " + "one knob). A value set here " + "REPLACES switchroom's default and is emitted even when the gating " + "capability is absent, so an operator can always force a knob. Other " + "`HINDSIGHT_API_*` keys are deliberately IGNORED \u2014 a blanket " + "passthrough would collide with the vars startHindsight() derives " + "itself (HINDSIGHT_API_PORT, the retain token/deadline budget).")
|
|
21056
21058
|
});
|
|
21057
21059
|
MicrosoftWorkspaceConfigSchema = exports_external.object({
|
|
21058
21060
|
microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
|
|
@@ -63265,7 +63267,8 @@ function createSendGate(config) {
|
|
|
63265
63267
|
pending: null,
|
|
63266
63268
|
running: false,
|
|
63267
63269
|
suppressedUntilMs: 0,
|
|
63268
|
-
editWindowTs: []
|
|
63270
|
+
editWindowTs: [],
|
|
63271
|
+
criticalWake: null
|
|
63269
63272
|
};
|
|
63270
63273
|
perMessage.set(key, state);
|
|
63271
63274
|
}
|
|
@@ -63351,9 +63354,11 @@ function createSendGate(config) {
|
|
|
63351
63354
|
}
|
|
63352
63355
|
}
|
|
63353
63356
|
}
|
|
63354
|
-
async function admitLoop(buckets, deadline) {
|
|
63357
|
+
async function admitLoop(buckets, deadline, interrupt) {
|
|
63355
63358
|
let counted = false;
|
|
63356
63359
|
for (;; ) {
|
|
63360
|
+
if (interrupt?.fired)
|
|
63361
|
+
return false;
|
|
63357
63362
|
const now = clock.now();
|
|
63358
63363
|
let wait = 0;
|
|
63359
63364
|
for (const b of buckets)
|
|
@@ -63369,11 +63374,12 @@ function createSendGate(config) {
|
|
|
63369
63374
|
counters.queued++;
|
|
63370
63375
|
counted = true;
|
|
63371
63376
|
}
|
|
63372
|
-
|
|
63377
|
+
const nap = clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait);
|
|
63378
|
+
await (interrupt ? Promise.race([nap, interrupt.promise]) : nap);
|
|
63373
63379
|
}
|
|
63374
63380
|
}
|
|
63375
|
-
function admit(buckets) {
|
|
63376
|
-
return admitLoop(buckets);
|
|
63381
|
+
function admit(buckets, interrupt) {
|
|
63382
|
+
return admitLoop(buckets, undefined, interrupt);
|
|
63377
63383
|
}
|
|
63378
63384
|
let criticalTail = Promise.resolve();
|
|
63379
63385
|
function criticalSerialize(job) {
|
|
@@ -63391,6 +63397,24 @@ function createSendGate(config) {
|
|
|
63391
63397
|
}
|
|
63392
63398
|
})();
|
|
63393
63399
|
}
|
|
63400
|
+
function makeInterrupt() {
|
|
63401
|
+
let resolve6;
|
|
63402
|
+
const interrupt = {
|
|
63403
|
+
fired: false,
|
|
63404
|
+
promise: new Promise((r) => {
|
|
63405
|
+
resolve6 = r;
|
|
63406
|
+
})
|
|
63407
|
+
};
|
|
63408
|
+
return {
|
|
63409
|
+
interrupt,
|
|
63410
|
+
fire: () => {
|
|
63411
|
+
if (interrupt.fired)
|
|
63412
|
+
return;
|
|
63413
|
+
interrupt.fired = true;
|
|
63414
|
+
resolve6();
|
|
63415
|
+
}
|
|
63416
|
+
};
|
|
63417
|
+
}
|
|
63394
63418
|
async function admitPriority(buckets, priority) {
|
|
63395
63419
|
const now = clock.now();
|
|
63396
63420
|
if (priority === "cosmetic") {
|
|
@@ -63488,7 +63512,18 @@ function createSendGate(config) {
|
|
|
63488
63512
|
continue;
|
|
63489
63513
|
}
|
|
63490
63514
|
} else {
|
|
63491
|
-
|
|
63515
|
+
const { interrupt, fire } = makeInterrupt();
|
|
63516
|
+
state.criticalWake = fire;
|
|
63517
|
+
let admitted;
|
|
63518
|
+
try {
|
|
63519
|
+
admitted = await admit(bucketsFor(opts), interrupt);
|
|
63520
|
+
} finally {
|
|
63521
|
+
state.criticalWake = null;
|
|
63522
|
+
}
|
|
63523
|
+
if (!admitted) {
|
|
63524
|
+
supersede(state, p);
|
|
63525
|
+
continue;
|
|
63526
|
+
}
|
|
63492
63527
|
}
|
|
63493
63528
|
state.lastSentMs = clock.now();
|
|
63494
63529
|
if (perMessageEditMaxPerWindow > 0 && p.priorityClass === "cosmetic") {
|
|
@@ -63513,6 +63548,31 @@ function createSendGate(config) {
|
|
|
63513
63548
|
state.running = false;
|
|
63514
63549
|
}
|
|
63515
63550
|
}
|
|
63551
|
+
function supersede(state, p) {
|
|
63552
|
+
const next = state.pending;
|
|
63553
|
+
if (!next) {
|
|
63554
|
+
state.pending = p;
|
|
63555
|
+
return;
|
|
63556
|
+
}
|
|
63557
|
+
next.priorityClass = maxPriority(next.priorityClass, p.priorityClass);
|
|
63558
|
+
next.promise.then(p.resolve, p.reject);
|
|
63559
|
+
}
|
|
63560
|
+
function editUnderPressure(state, opts, now) {
|
|
63561
|
+
if (state.suppressedUntilMs > now)
|
|
63562
|
+
return true;
|
|
63563
|
+
for (const b of bucketsFor(opts)) {
|
|
63564
|
+
if (b.msUntilAvailable(now) > 0)
|
|
63565
|
+
return true;
|
|
63566
|
+
}
|
|
63567
|
+
return false;
|
|
63568
|
+
}
|
|
63569
|
+
function settleForCaller(state, pending, opts, priority, now) {
|
|
63570
|
+
if (priority !== "cosmetic" || !editUnderPressure(state, opts, now)) {
|
|
63571
|
+
return pending.promise;
|
|
63572
|
+
}
|
|
63573
|
+
pending.promise.catch(() => {});
|
|
63574
|
+
return Promise.resolve(undefined);
|
|
63575
|
+
}
|
|
63516
63576
|
function handleEdit(fn, opts) {
|
|
63517
63577
|
const messageId = opts.messageId;
|
|
63518
63578
|
const key = messageKey(opts.chat_id, messageId);
|
|
@@ -63521,16 +63581,6 @@ function createSendGate(config) {
|
|
|
63521
63581
|
const existing = perMessage.get(key);
|
|
63522
63582
|
maybeEvict(now, existing === undefined);
|
|
63523
63583
|
const state = existing ?? messageState(key);
|
|
63524
|
-
if ((opts.priorityClass ?? "useful") === "cosmetic") {
|
|
63525
|
-
let wait = 0;
|
|
63526
|
-
for (const b of bucketsFor(opts))
|
|
63527
|
-
wait = Math.max(wait, b.msUntilAvailable(now));
|
|
63528
|
-
const msgWait = state.suppressedUntilMs > now ? state.suppressedUntilMs - now : 0;
|
|
63529
|
-
if (wait > 0 || msgWait > 0) {
|
|
63530
|
-
counters.shed++;
|
|
63531
|
-
return Promise.resolve(SEND_GATE_SHED2);
|
|
63532
|
-
}
|
|
63533
|
-
}
|
|
63534
63584
|
if (state.pending) {
|
|
63535
63585
|
state.pending.priorityClass = maxPriority(state.pending.priorityClass, opts.priorityClass ?? "useful");
|
|
63536
63586
|
if (state.pending.hash !== hash) {
|
|
@@ -63538,7 +63588,9 @@ function createSendGate(config) {
|
|
|
63538
63588
|
state.pending.hash = hash;
|
|
63539
63589
|
state.pending.fn = fn;
|
|
63540
63590
|
}
|
|
63541
|
-
|
|
63591
|
+
if (state.pending.priorityClass === "critical")
|
|
63592
|
+
state.criticalWake?.();
|
|
63593
|
+
return settleForCaller(state, state.pending, opts, opts.priorityClass ?? "useful", now);
|
|
63542
63594
|
}
|
|
63543
63595
|
if (hash === state.lastHash) {
|
|
63544
63596
|
counters.dropped++;
|
|
@@ -63559,9 +63611,13 @@ function createSendGate(config) {
|
|
|
63559
63611
|
priorityClass: opts.priorityClass ?? "useful"
|
|
63560
63612
|
};
|
|
63561
63613
|
state.pending = pending;
|
|
63562
|
-
if (
|
|
63614
|
+
if (state.running) {
|
|
63615
|
+
if (pending.priorityClass === "critical")
|
|
63616
|
+
state.criticalWake?.();
|
|
63617
|
+
} else {
|
|
63563
63618
|
drive(state, opts);
|
|
63564
|
-
|
|
63619
|
+
}
|
|
63620
|
+
return settleForCaller(state, pending, opts, pending.priorityClass, now);
|
|
63565
63621
|
}
|
|
63566
63622
|
async function gate(fn, opts) {
|
|
63567
63623
|
if (!enabled2)
|
|
@@ -75869,6 +75925,30 @@ function makeKey3(chatId, threadId) {
|
|
|
75869
75925
|
}
|
|
75870
75926
|
|
|
75871
75927
|
// reply-owner-resolve.ts
|
|
75928
|
+
function latestEndedAccepted(candidates) {
|
|
75929
|
+
if (candidates.latestEndedTurnId == null)
|
|
75930
|
+
return false;
|
|
75931
|
+
const age = candidates.latestEndedAgeMs;
|
|
75932
|
+
const ttl = candidates.latestEndedTtlMs;
|
|
75933
|
+
if (age === null)
|
|
75934
|
+
return false;
|
|
75935
|
+
if (age === undefined || ttl == null)
|
|
75936
|
+
return true;
|
|
75937
|
+
return age <= ttl;
|
|
75938
|
+
}
|
|
75939
|
+
function decideContentGateBypass(input) {
|
|
75940
|
+
if (input.tier === "live")
|
|
75941
|
+
return true;
|
|
75942
|
+
if (input.tier === "none")
|
|
75943
|
+
return false;
|
|
75944
|
+
if (input.handbackCouldOwnReply)
|
|
75945
|
+
return false;
|
|
75946
|
+
if (input.candidates == null)
|
|
75947
|
+
return false;
|
|
75948
|
+
if (!latestEndedAccepted(input.candidates))
|
|
75949
|
+
return false;
|
|
75950
|
+
return input.resolvedTurnId != null && input.resolvedTurnId === input.candidates.latestEndedTurnId;
|
|
75951
|
+
}
|
|
75872
75952
|
function decideAnswerLatchSuppression(input) {
|
|
75873
75953
|
if (input.superseded)
|
|
75874
75954
|
return false;
|
|
@@ -76500,14 +76580,23 @@ async function sendReply(deps, req) {
|
|
|
76500
76580
|
let supersedeFlushIds = [];
|
|
76501
76581
|
{
|
|
76502
76582
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
76503
|
-
const {
|
|
76583
|
+
const {
|
|
76584
|
+
turn: ownerTurn,
|
|
76585
|
+
tier: ownerTier,
|
|
76586
|
+
candidates: ownerCandidates
|
|
76587
|
+
} = resolveReplyOwnerTurn(turn, chat_id, args);
|
|
76504
76588
|
const resolvedTurnId = ownerTurn?.turnId ?? null;
|
|
76505
76589
|
const ownerEndedAt = ownerTurn?.endedAt ?? null;
|
|
76506
76590
|
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
|
|
76507
76591
|
const handbackAt = getLastSubagentHandbackAt(chat_id);
|
|
76508
76592
|
const now = Date.now();
|
|
76509
76593
|
const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
|
|
76510
|
-
const replyIsOwnAnswer =
|
|
76594
|
+
const replyIsOwnAnswer = decideContentGateBypass({
|
|
76595
|
+
tier: ownerTier,
|
|
76596
|
+
resolvedTurnId,
|
|
76597
|
+
candidates: ownerCandidates,
|
|
76598
|
+
handbackCouldOwnReply
|
|
76599
|
+
});
|
|
76511
76600
|
const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
|
|
76512
76601
|
if (decision.supersede) {
|
|
76513
76602
|
process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
|
|
@@ -76532,7 +76621,7 @@ async function sendReply(deps, req) {
|
|
|
76532
76621
|
replyMatchesFlushedAnswer
|
|
76533
76622
|
});
|
|
76534
76623
|
if (decision.reason === "new-content") {
|
|
76535
|
-
process.stderr.write(`telegram gateway: reply: flush supersede declined \u2014 new content (#3429) ` + `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)}; sending fresh
|
|
76624
|
+
process.stderr.write(`telegram gateway: reply: flush supersede declined \u2014 new content (#3429) ` + `chatId=${chat_id} ownerTurnId=${JSON.stringify(resolvedTurnId)} ` + `tier=${ownerTier} latestEnded=${JSON.stringify(ownerCandidates.latestEndedTurnId)} ` + `handbackInWindow=${handbackCouldOwnReply}; sending fresh
|
|
76536
76625
|
`);
|
|
76537
76626
|
}
|
|
76538
76627
|
if (suppressByLatch) {
|
|
@@ -80062,12 +80151,14 @@ function isTurnFlushSafetyEnabled(env = process.env) {
|
|
|
80062
80151
|
}
|
|
80063
80152
|
|
|
80064
80153
|
// reply-owner-resolve.ts
|
|
80065
|
-
function
|
|
80154
|
+
function latestEndedAccepted2(candidates) {
|
|
80066
80155
|
if (candidates.latestEndedTurnId == null)
|
|
80067
80156
|
return false;
|
|
80068
80157
|
const age = candidates.latestEndedAgeMs;
|
|
80069
80158
|
const ttl = candidates.latestEndedTtlMs;
|
|
80070
|
-
if (age
|
|
80159
|
+
if (age === null)
|
|
80160
|
+
return false;
|
|
80161
|
+
if (age === undefined || ttl == null)
|
|
80071
80162
|
return true;
|
|
80072
80163
|
return age <= ttl;
|
|
80073
80164
|
}
|
|
@@ -80078,7 +80169,7 @@ function resolveReplyOwnerTier(candidates) {
|
|
|
80078
80169
|
return "origin";
|
|
80079
80170
|
if (candidates.quotedTurnId != null)
|
|
80080
80171
|
return "quoted";
|
|
80081
|
-
if (
|
|
80172
|
+
if (latestEndedAccepted2(candidates))
|
|
80082
80173
|
return "latest-ended";
|
|
80083
80174
|
return "none";
|
|
80084
80175
|
}
|
|
@@ -83038,6 +83129,10 @@ var HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT = 4;
|
|
|
83038
83129
|
var HINDSIGHT_DEFAULT_RETAIN_LLM_MAX_CONCURRENT = 1;
|
|
83039
83130
|
var HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_MAX_CONCURRENT = 1;
|
|
83040
83131
|
var HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16 = "true";
|
|
83132
|
+
var HINDSIGHT_DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 128;
|
|
83133
|
+
var HINDSIGHT_DEFAULT_LLM_STRICT_SCHEMA = "true";
|
|
83134
|
+
var HINDSIGHT_DEFAULT_LLM_MAX_RETRIES = 2;
|
|
83135
|
+
var HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_PARALLELISM = 2;
|
|
83041
83136
|
var HINDSIGHT_PERF_DEFAULTS_UNGATED = [
|
|
83042
83137
|
[
|
|
83043
83138
|
"HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE",
|
|
@@ -83051,10 +83146,18 @@ var HINDSIGHT_PERF_DEFAULTS_UNGATED = [
|
|
|
83051
83146
|
"HINDSIGHT_API_LINK_EXPANSION_TIMEOUT",
|
|
83052
83147
|
String(HINDSIGHT_DEFAULT_LINK_EXPANSION_TIMEOUT_S)
|
|
83053
83148
|
],
|
|
83054
|
-
["HINDSIGHT_API_LLM_REASONING_EFFORT", HINDSIGHT_DEFAULT_LLM_REASONING_EFFORT]
|
|
83149
|
+
["HINDSIGHT_API_LLM_REASONING_EFFORT", HINDSIGHT_DEFAULT_LLM_REASONING_EFFORT],
|
|
83150
|
+
[
|
|
83151
|
+
"HINDSIGHT_API_CONSOLIDATION_LLM_PARALLELISM",
|
|
83152
|
+
String(HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_PARALLELISM)
|
|
83153
|
+
]
|
|
83055
83154
|
];
|
|
83056
83155
|
var HINDSIGHT_PERF_DEFAULTS_GPU = [
|
|
83057
|
-
["HINDSIGHT_API_RERANKER_LOCAL_FP16", HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16]
|
|
83156
|
+
["HINDSIGHT_API_RERANKER_LOCAL_FP16", HINDSIGHT_DEFAULT_RERANKER_LOCAL_FP16],
|
|
83157
|
+
[
|
|
83158
|
+
"HINDSIGHT_API_RERANKER_LOCAL_BATCH_SIZE",
|
|
83159
|
+
String(HINDSIGHT_DEFAULT_RERANKER_LOCAL_BATCH_SIZE)
|
|
83160
|
+
]
|
|
83058
83161
|
];
|
|
83059
83162
|
var HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM = [
|
|
83060
83163
|
["HINDSIGHT_API_LLM_MAX_CONCURRENT", String(HINDSIGHT_DEFAULT_LLM_MAX_CONCURRENT)],
|
|
@@ -83065,13 +83168,21 @@ var HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM = [
|
|
|
83065
83168
|
[
|
|
83066
83169
|
"HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT",
|
|
83067
83170
|
String(HINDSIGHT_DEFAULT_CONSOLIDATION_LLM_MAX_CONCURRENT)
|
|
83068
|
-
]
|
|
83171
|
+
],
|
|
83172
|
+
["HINDSIGHT_API_LLM_STRICT_SCHEMA", HINDSIGHT_DEFAULT_LLM_STRICT_SCHEMA],
|
|
83173
|
+
["HINDSIGHT_API_LLM_MAX_RETRIES", String(HINDSIGHT_DEFAULT_LLM_MAX_RETRIES)]
|
|
83069
83174
|
];
|
|
83175
|
+
var HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS = new Set([
|
|
83176
|
+
"HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY"
|
|
83177
|
+
]);
|
|
83070
83178
|
var HINDSIGHT_PERF_ENV_KEYS = new Set([
|
|
83071
|
-
...
|
|
83072
|
-
|
|
83073
|
-
|
|
83074
|
-
|
|
83179
|
+
...[
|
|
83180
|
+
...HINDSIGHT_PERF_DEFAULTS_UNGATED,
|
|
83181
|
+
...HINDSIGHT_PERF_DEFAULTS_GPU,
|
|
83182
|
+
...HINDSIGHT_PERF_DEFAULTS_LOCAL_LLM
|
|
83183
|
+
].map(([k]) => k),
|
|
83184
|
+
...HINDSIGHT_PERF_OVERRIDE_ONLY_KEYS
|
|
83185
|
+
]);
|
|
83075
83186
|
|
|
83076
83187
|
// ../src/setup/hindsight-pg-defaults.ts
|
|
83077
83188
|
var HINDSIGHT_PG_MEM_LIMIT_MIB_FOR_DERIVATION = 8 * 1024;
|
|
@@ -83094,6 +83205,10 @@ var HINDSIGHT_PG_DEFAULTS = [
|
|
|
83094
83205
|
];
|
|
83095
83206
|
var HINDSIGHT_PG_ENV_KEYS = new Set(HINDSIGHT_PG_DEFAULTS.map(([k]) => k));
|
|
83096
83207
|
|
|
83208
|
+
// ../src/setup/hindsight-context-budget.ts
|
|
83209
|
+
var HINDSIGHT_UPSTREAM_RETAIN_CHUNK_SIZE = 3000;
|
|
83210
|
+
var HINDSIGHT_RETAIN_MAX_COMPLETION_FLOOR = HINDSIGHT_UPSTREAM_RETAIN_CHUNK_SIZE + 72;
|
|
83211
|
+
|
|
83097
83212
|
// ../src/setup/hindsight.ts
|
|
83098
83213
|
var HINDSIGHT_DEFAULT_API_PORT = 18888;
|
|
83099
83214
|
var HINDSIGHT_DEFAULT_MCP_URL = `http://127.0.0.1:${HINDSIGHT_DEFAULT_API_PORT}/mcp/`;
|
|
@@ -88540,6 +88655,19 @@ function resolveAnswerThreadId(input) {
|
|
|
88540
88655
|
return input.liveThreadId;
|
|
88541
88656
|
}
|
|
88542
88657
|
|
|
88658
|
+
// gateway/latest-turn-lookup.ts
|
|
88659
|
+
function latestTurnForChat(turns, chatId, opts) {
|
|
88660
|
+
let latest = null;
|
|
88661
|
+
for (const t of turns) {
|
|
88662
|
+
if (t.sessionChatId !== chatId)
|
|
88663
|
+
continue;
|
|
88664
|
+
if (opts.endedOnly && t.endedAt == null)
|
|
88665
|
+
continue;
|
|
88666
|
+
latest = t;
|
|
88667
|
+
}
|
|
88668
|
+
return latest;
|
|
88669
|
+
}
|
|
88670
|
+
|
|
88543
88671
|
// gateway/turns-jsonl-rotate.ts
|
|
88544
88672
|
var TURNS_JSONL_MAX_BYTES = 5 * 1024 * 1024;
|
|
88545
88673
|
var DEFAULT_AGENT_STATE_DIR = "/state/agent";
|
|
@@ -96421,10 +96549,10 @@ function startOutboxSweep(deps) {
|
|
|
96421
96549
|
}
|
|
96422
96550
|
|
|
96423
96551
|
// ../src/build-info.ts
|
|
96424
|
-
var VERSION2 = "0.19.
|
|
96425
|
-
var COMMIT_SHA = "
|
|
96426
|
-
var COMMIT_DATE = "2026-07-
|
|
96427
|
-
var LATEST_PR =
|
|
96552
|
+
var VERSION2 = "0.19.24";
|
|
96553
|
+
var COMMIT_SHA = "0fa8ffb8";
|
|
96554
|
+
var COMMIT_DATE = "2026-07-27T05:19:34Z";
|
|
96555
|
+
var LATEST_PR = 3744;
|
|
96428
96556
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
96429
96557
|
|
|
96430
96558
|
// gateway/boot-version.ts
|
|
@@ -99395,18 +99523,13 @@ function findTurnByOriginId(originTurnId) {
|
|
|
99395
99523
|
return recentTurnsById.get(originTurnId) ?? null;
|
|
99396
99524
|
}
|
|
99397
99525
|
var LATE_REPLY_TOPIC_RECOVERY_ENABLED = process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== "0";
|
|
99398
|
-
function
|
|
99399
|
-
|
|
99400
|
-
for (const t of recentTurnsById.values()) {
|
|
99401
|
-
if (t.sessionChatId === chatId)
|
|
99402
|
-
latest = t;
|
|
99403
|
-
}
|
|
99404
|
-
return latest;
|
|
99526
|
+
function findLatestTurnForChat(chatId, opts) {
|
|
99527
|
+
return latestTurnForChat(recentTurnsById.values(), chatId, opts);
|
|
99405
99528
|
}
|
|
99406
99529
|
function resolveReplyOwnerTurn(liveTurn, chatId, args) {
|
|
99407
99530
|
const origin = findTurnByOriginId(args.origin_turn_id);
|
|
99408
99531
|
const quoted = findTurnByQuotedMessageId(chatId, args.reply_to);
|
|
99409
|
-
const latestEnded =
|
|
99532
|
+
const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true });
|
|
99410
99533
|
const byId = new Map;
|
|
99411
99534
|
for (const t of [latestEnded, quoted, origin, liveTurn]) {
|
|
99412
99535
|
if (t != null)
|
|
@@ -99423,10 +99546,10 @@ function resolveReplyOwnerTurn(liveTurn, chatId, args) {
|
|
|
99423
99546
|
};
|
|
99424
99547
|
const tier = resolveReplyOwnerTier(candidates);
|
|
99425
99548
|
const winnerId = resolveReplyOwnerTurnId(candidates);
|
|
99426
|
-
return { turn: winnerId != null ? byId.get(winnerId) ?? null : null, tier };
|
|
99549
|
+
return { turn: winnerId != null ? byId.get(winnerId) ?? null : null, tier, candidates };
|
|
99427
99550
|
}
|
|
99428
99551
|
function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
|
|
99429
|
-
const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ?
|
|
99552
|
+
const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestTurnForChat(chatId, { endedOnly: false }) : null;
|
|
99430
99553
|
const threadId = resolveAnswerThreadId({
|
|
99431
99554
|
explicitThreadId,
|
|
99432
99555
|
originResolved: originTurn != null,
|
|
@@ -489,7 +489,7 @@ import {
|
|
|
489
489
|
import {
|
|
490
490
|
resolveReplyOwnerTurnId,
|
|
491
491
|
resolveReplyOwnerTier,
|
|
492
|
-
type ReplyOwnerTier,
|
|
492
|
+
type ReplyOwnerTier, type ReplyOwnerCandidates,
|
|
493
493
|
type AnswerDeliveredLatch,
|
|
494
494
|
} from '../reply-owner-resolve.js'
|
|
495
495
|
import { SubagentHandbackMarker } from './subagent-handback-marker.js'
|
|
@@ -717,6 +717,7 @@ import {
|
|
|
717
717
|
} from './emission-authority.js'
|
|
718
718
|
import { CurrentTurnMap } from './current-turn-map.js'
|
|
719
719
|
import { resolveAnswerThreadId } from './answer-thread-resolve.js'
|
|
720
|
+
import { latestTurnForChat } from './latest-turn-lookup.js'
|
|
720
721
|
import { decideObligationTurnEnd } from './obligation-turn-end.js'
|
|
721
722
|
import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
|
|
722
723
|
import {
|
|
@@ -3484,12 +3485,14 @@ export type CurrentTurn = {
|
|
|
3484
3485
|
flushedAnswerText: string | null
|
|
3485
3486
|
// 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
|
|
3486
3487
|
// ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
|
|
3487
|
-
// The `
|
|
3488
|
-
//
|
|
3489
|
-
//
|
|
3488
|
+
// The `latest-ended` supersede tier carries DESTRUCTIVE authority (it drives
|
|
3489
|
+
// message deletion), so `resolveReplyOwnerTurn` only honours a latest-ended
|
|
3490
|
+
// turn whose `endedAt` is non-null (#3725 — the registry is populated at turn
|
|
3491
|
+
// START, so the tail entry may still be RUNNING) AND within the supersede TTL —
|
|
3490
3492
|
// otherwise a late reply belonging to an OLDER turn could resolve its owner to
|
|
3491
|
-
// a NEWER turn
|
|
3492
|
-
//
|
|
3493
|
+
// a NEWER turn at the registry tail and delete that turn's legit answer. The
|
|
3494
|
+
// unbounded ROUTING use (`endedOnly: false`) is unaffected: it only picks a
|
|
3495
|
+
// topic to deliver into and deletes nothing.
|
|
3493
3496
|
endedAt: number | null
|
|
3494
3497
|
// #1675 (over-ping safety net): wall-clock ms of the first reply
|
|
3495
3498
|
// this turn that landed with `disable_notification: false` (a real
|
|
@@ -3999,21 +4002,18 @@ const LATE_REPLY_TOPIC_RECOVERY_ENABLED =
|
|
|
3999
4002
|
process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== '0'
|
|
4000
4003
|
|
|
4001
4004
|
/**
|
|
4002
|
-
* The most
|
|
4003
|
-
*
|
|
4004
|
-
*
|
|
4005
|
-
*
|
|
4006
|
-
*
|
|
4007
|
-
*
|
|
4008
|
-
*
|
|
4009
|
-
*
|
|
4005
|
+
* The most recent turn for a chat from the bounded recent-turn registry — the
|
|
4006
|
+
* deterministic fallback for a LATE answer reply when the model echoed no
|
|
4007
|
+
* `origin_turn_id` and `currentTurn` has already cleared. Returns null when the
|
|
4008
|
+
* chat has no matching turn (so the caller keeps the legacy result). NB: this is
|
|
4009
|
+
* the chat's own most-recent TURN, not the `chatThreadMap` last-seen-any-message
|
|
4010
|
+
* heuristic that caused the wrong-topic bug. `endedOnly` selects the consumer's
|
|
4011
|
+
* semantics — routing takes the tail entry even when that turn is still RUNNING;
|
|
4012
|
+
* the destructive `latest-ended` owner tier takes only a genuinely ENDED turn,
|
|
4013
|
+
* because the registry is populated at turn START (#3725; see the module).
|
|
4010
4014
|
*/
|
|
4011
|
-
function
|
|
4012
|
-
|
|
4013
|
-
for (const t of recentTurnsById.values()) {
|
|
4014
|
-
if (t.sessionChatId === chatId) latest = t
|
|
4015
|
-
}
|
|
4016
|
-
return latest
|
|
4015
|
+
function findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null {
|
|
4016
|
+
return latestTurnForChat(recentTurnsById.values(), chatId, opts)
|
|
4017
4017
|
}
|
|
4018
4018
|
|
|
4019
4019
|
/**
|
|
@@ -4029,7 +4029,7 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
|
|
|
4029
4029
|
* 1. the live `currentTurn` passed in (null once the flush nulled the atom);
|
|
4030
4030
|
* 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
|
|
4031
4031
|
* 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
|
|
4032
|
-
* 4. `
|
|
4032
|
+
* 4. `findLatestTurnForChat(chat_id, {endedOnly:true})` — last ENDED turn.
|
|
4033
4033
|
* Returns the CurrentTurn for the winning id (so callers can read its
|
|
4034
4034
|
* `answerDelivered` latch), or null when every lookup missed.
|
|
4035
4035
|
*/
|
|
@@ -4037,10 +4037,10 @@ function resolveReplyOwnerTurn(
|
|
|
4037
4037
|
liveTurn: CurrentTurn | null,
|
|
4038
4038
|
chatId: string,
|
|
4039
4039
|
args: Record<string, unknown>,
|
|
4040
|
-
): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
|
|
4040
|
+
): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
|
|
4041
4041
|
const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
4042
4042
|
const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
|
|
4043
|
-
const latestEnded =
|
|
4043
|
+
const latestEnded = findLatestTurnForChat(chatId, { endedOnly: true })
|
|
4044
4044
|
const byId = new Map<string, CurrentTurn>()
|
|
4045
4045
|
// Populate lowest-precedence first so a higher tier's turn wins the id slot
|
|
4046
4046
|
// when two lookups resolve the same turn (they carry the same turnId anyway).
|
|
@@ -4049,12 +4049,12 @@ function resolveReplyOwnerTurn(
|
|
|
4049
4049
|
}
|
|
4050
4050
|
// F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
|
|
4051
4051
|
// latest-ended turn can't inherit deletion authority over a newer turn's flush
|
|
4052
|
-
// record.
|
|
4053
|
-
//
|
|
4054
|
-
//
|
|
4052
|
+
// record. #3725: the lookup above is `endedOnly`, so `endedAt` is non-null here
|
|
4053
|
+
// and the age is ALWAYS a real number — a not-yet-ended turn is no longer a
|
|
4054
|
+
// candidate at all, and an explicit null age now fails CLOSED downstream.
|
|
4055
4055
|
const latestEndedAgeMs =
|
|
4056
4056
|
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
4057
|
-
const candidates = {
|
|
4057
|
+
const candidates: ReplyOwnerCandidates = {
|
|
4058
4058
|
liveTurnId: liveTurn?.turnId ?? null,
|
|
4059
4059
|
originTurnId: origin?.turnId ?? null,
|
|
4060
4060
|
quotedTurnId: quoted?.turnId ?? null,
|
|
@@ -4062,15 +4062,14 @@ function resolveReplyOwnerTurn(
|
|
|
4062
4062
|
latestEndedAgeMs,
|
|
4063
4063
|
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
4064
4064
|
}
|
|
4065
|
-
// #3429 — the
|
|
4066
|
-
//
|
|
4067
|
-
//
|
|
4068
|
-
//
|
|
4069
|
-
//
|
|
4070
|
-
// tier can never disagree.
|
|
4065
|
+
// #3429 — the winning tier AND the candidate set it came from travel with the
|
|
4066
|
+
// turn. Tier alone no longer decides the content-gate bypass: the
|
|
4067
|
+
// model-steerable `origin`/`quoted` tiers must be CORROBORATED against the
|
|
4068
|
+
// framework-derived `latestEndedTurnId` (`decideContentGateBypass`). All three
|
|
4069
|
+
// derive from these SAME candidates, so they can never disagree.
|
|
4071
4070
|
const tier = resolveReplyOwnerTier(candidates)
|
|
4072
4071
|
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
4073
|
-
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
|
|
4072
|
+
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier, candidates }
|
|
4074
4073
|
}
|
|
4075
4074
|
|
|
4076
4075
|
/**
|
|
@@ -4115,7 +4114,7 @@ function resolveAnswerThreadWithLog(
|
|
|
4115
4114
|
explicitThreadId == null &&
|
|
4116
4115
|
originTurn == null &&
|
|
4117
4116
|
liveTurn == null
|
|
4118
|
-
?
|
|
4117
|
+
? findLatestTurnForChat(chatId, { endedOnly: false })
|
|
4119
4118
|
: null
|
|
4120
4119
|
const threadId = resolveAnswerThreadId({
|
|
4121
4120
|
explicitThreadId,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recently-seen-turn registry scan, extracted from `gateway.ts` as a pure
|
|
3
|
+
* function so its ENDED-ness contract is unit-testable (#3725; gateway.ts is not
|
|
4
|
+
* importable in tests — the repo's `decideTurnFlush` / `resolveReplyOwnerTurnId`
|
|
5
|
+
* pattern).
|
|
6
|
+
*
|
|
7
|
+
* ## Why `endedOnly` exists (#3725)
|
|
8
|
+
*
|
|
9
|
+
* `recentTurnsById` is populated at turn **start** (`rememberRecentTurn` fires
|
|
10
|
+
* from the `enqueue` lifecycle event in `stream-render.ts`), and the atom is
|
|
11
|
+
* built with `endedAt: null`; `endedAt` is stamped later, in `turn-end.ts`. So
|
|
12
|
+
* the registry's tail entry for a chat is the most-recently-STARTED turn, which
|
|
13
|
+
* may still be RUNNING. The registry is also chat-wide and thread-agnostic, so
|
|
14
|
+
* on a forum a turn running in ANOTHER topic sits at the tail.
|
|
15
|
+
*
|
|
16
|
+
* That distinction is load-bearing because the two consumers want different
|
|
17
|
+
* things:
|
|
18
|
+
*
|
|
19
|
+
* - **Routing** (`resolveAnswerThreadWithLog`) wants the chat's most recent
|
|
20
|
+
* turn whether or not it has ended — it only picks a topic to deliver into,
|
|
21
|
+
* and a still-running turn's topic is a perfectly good (indeed better)
|
|
22
|
+
* answer than falling back to General. `endedOnly: false`.
|
|
23
|
+
* - **Owner resolution** (`resolveReplyOwnerTurn` → the `latest-ended`
|
|
24
|
+
* supersede tier) wants a genuinely ENDED turn: that tier carries
|
|
25
|
+
* DESTRUCTIVE authority (it drives message deletion) and is bounded by the
|
|
26
|
+
* supersede TTL measured from `endedAt`. A turn with `endedAt == null` has
|
|
27
|
+
* no age, so it could not be TTL-bounded at all — it was an unbounded
|
|
28
|
+
* anchor for the corroborated content-gate bypass (#3725). A still-running
|
|
29
|
+
* turn must be resolved by the `live` tier, never by this fallback.
|
|
30
|
+
* `endedOnly: true`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The registry-atom shape this scan needs (structural — `CurrentTurn` in the
|
|
34
|
+
* gateway satisfies it without importing the gateway's type). */
|
|
35
|
+
export interface LatestTurnLookupAtom {
|
|
36
|
+
/** The chat the turn belongs to. */
|
|
37
|
+
sessionChatId: string
|
|
38
|
+
/** Wall-clock ms the turn ENDED, or null while it is still running. */
|
|
39
|
+
endedAt: number | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The last turn for `chatId` in registry insertion order — i.e. the most recent
|
|
44
|
+
* one. With `endedOnly: true` the scan skips turns that have not ended yet, so
|
|
45
|
+
* the result is the most-recently-ended turn (which may NOT be the tail entry).
|
|
46
|
+
* Returns null when the chat has no matching turn.
|
|
47
|
+
*/
|
|
48
|
+
export function latestTurnForChat<T extends LatestTurnLookupAtom>(
|
|
49
|
+
turns: Iterable<T>,
|
|
50
|
+
chatId: string,
|
|
51
|
+
opts: { endedOnly: boolean },
|
|
52
|
+
): T | null {
|
|
53
|
+
let latest: T | null = null
|
|
54
|
+
for (const t of turns) {
|
|
55
|
+
if (t.sessionChatId !== chatId) continue
|
|
56
|
+
if (opts.endedOnly && t.endedAt == null) continue
|
|
57
|
+
latest = t
|
|
58
|
+
}
|
|
59
|
+
return latest
|
|
60
|
+
}
|