switchroom 0.15.10 → 0.15.11
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 +2 -80
- package/dist/cli/switchroom.js +4 -82
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +10 -6
- package/telegram-plugin/dist/gateway/gateway.js +70 -21
- package/telegram-plugin/gateway/gateway.ts +92 -24
- package/telegram-plugin/gateway/obligation-ledger.ts +56 -15
- package/telegram-plugin/history.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +83 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +213 -5
- package/telegram-plugin/tests/obligation-store.test.ts +17 -0
|
@@ -12313,88 +12313,10 @@ function resolveEscalationRouting(input, opts) {
|
|
|
12313
12313
|
return resolveCronRouting({ ...input, kind: "prompt" }, opts);
|
|
12314
12314
|
}
|
|
12315
12315
|
|
|
12316
|
-
// src/scheduler/cron-cadence.ts
|
|
12317
|
-
function csvSmallestGap(field) {
|
|
12318
|
-
if (!field.includes(","))
|
|
12319
|
-
return null;
|
|
12320
|
-
const parts = field.split(",").map((s) => Number(s)).filter((n) => Number.isInteger(n) && n >= 0);
|
|
12321
|
-
if (parts.length < 2)
|
|
12322
|
-
return null;
|
|
12323
|
-
const sorted = [...parts].sort((a, b) => a - b);
|
|
12324
|
-
let smallest = Infinity;
|
|
12325
|
-
for (let i = 1;i < sorted.length; i++) {
|
|
12326
|
-
const gap = sorted[i] - sorted[i - 1];
|
|
12327
|
-
if (gap > 0 && gap < smallest)
|
|
12328
|
-
smallest = gap;
|
|
12329
|
-
}
|
|
12330
|
-
return Number.isFinite(smallest) ? smallest : null;
|
|
12331
|
-
}
|
|
12332
|
-
function estimateCronGapMin(expr) {
|
|
12333
|
-
const fields = expr.trim().split(/\s+/);
|
|
12334
|
-
if (fields.length < 5)
|
|
12335
|
-
return Infinity;
|
|
12336
|
-
const [min, hour] = fields;
|
|
12337
|
-
if (min === "*")
|
|
12338
|
-
return 1;
|
|
12339
|
-
const minStep = min.match(/^\*\/(\d+)$/);
|
|
12340
|
-
if (minStep) {
|
|
12341
|
-
const n = Number(minStep[1]);
|
|
12342
|
-
return n > 0 ? n : Infinity;
|
|
12343
|
-
}
|
|
12344
|
-
const minCsv = csvSmallestGap(min);
|
|
12345
|
-
if (minCsv !== null)
|
|
12346
|
-
return minCsv;
|
|
12347
|
-
if (!/^\d+$/.test(min))
|
|
12348
|
-
return Infinity;
|
|
12349
|
-
if (hour === "*")
|
|
12350
|
-
return 60;
|
|
12351
|
-
const hourStep = hour.match(/^\*\/(\d+)$/);
|
|
12352
|
-
if (hourStep) {
|
|
12353
|
-
const n = Number(hourStep[1]);
|
|
12354
|
-
return n > 0 ? n * 60 : Infinity;
|
|
12355
|
-
}
|
|
12356
|
-
const hourCsv = csvSmallestGap(hour);
|
|
12357
|
-
if (hourCsv !== null)
|
|
12358
|
-
return hourCsv * 60;
|
|
12359
|
-
if (/^\d+$/.test(hour))
|
|
12360
|
-
return 1440;
|
|
12361
|
-
return Infinity;
|
|
12362
|
-
}
|
|
12363
|
-
|
|
12364
12316
|
// src/scheduler/tier-selector.ts
|
|
12365
12317
|
var DEFAULT_FREQUENT_GAP_MIN = 60;
|
|
12366
|
-
function
|
|
12367
|
-
|
|
12368
|
-
return { tier: "poll", source: "explicit", reason: "declared kind: poll (model-free check)" };
|
|
12369
|
-
}
|
|
12370
|
-
if (input.context === "fresh") {
|
|
12371
|
-
return { tier: "cheap", source: "explicit", reason: "declared context: fresh (cheap cron session)" };
|
|
12372
|
-
}
|
|
12373
|
-
if (input.context === "agent") {
|
|
12374
|
-
return { tier: "main", source: "explicit", reason: "declared context: agent (full live session)" };
|
|
12375
|
-
}
|
|
12376
|
-
if (input.model !== undefined) {
|
|
12377
|
-
return isKnownCheapModel(input.model) ? { tier: "cheap", source: "explicit", reason: `cheap model '${input.model}' → cheap cron session` } : { tier: "main", source: "explicit", reason: `model '${input.model}' is not a known-cheap id → full live session` };
|
|
12378
|
-
}
|
|
12379
|
-
if (input.smallestGapMin <= frequentGapMin) {
|
|
12380
|
-
return {
|
|
12381
|
-
tier: "cheap",
|
|
12382
|
-
source: "cadence-default",
|
|
12383
|
-
reason: `fires every ~${input.smallestGapMin}min (≤ ${frequentGapMin}min) — defaulting to a cheap ` + `session; set context: agent (or an Opus/custom model) if this needs the agent's full context`
|
|
12384
|
-
};
|
|
12385
|
-
}
|
|
12386
|
-
return {
|
|
12387
|
-
tier: "main",
|
|
12388
|
-
source: "cadence-default",
|
|
12389
|
-
reason: `fires every ~${input.smallestGapMin}min (> ${frequentGapMin}min) — defaulting to the agent's ` + `full session; set model: sonnet (or context: fresh) to run it cheaply`
|
|
12390
|
-
};
|
|
12391
|
-
}
|
|
12392
|
-
function applyDefaultTier(entry, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
|
|
12393
|
-
if (entry.kind === "poll" || entry.context !== undefined || entry.model !== undefined) {
|
|
12394
|
-
return entry;
|
|
12395
|
-
}
|
|
12396
|
-
const rec = recommendCronTier({ smallestGapMin: estimateCronGapMin(entry.cron) }, frequentGapMin);
|
|
12397
|
-
return rec.tier === "cheap" ? { ...entry, context: "fresh" } : entry;
|
|
12318
|
+
function applyDefaultTier(entry, _frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
|
|
12319
|
+
return entry;
|
|
12398
12320
|
}
|
|
12399
12321
|
|
|
12400
12322
|
// src/agent-scheduler/cheap-cron-wiring.ts
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -15435,87 +15435,9 @@ var init_cron_routing = __esm(() => {
|
|
|
15435
15435
|
OPUS_MODEL_RE = /opus/i;
|
|
15436
15436
|
});
|
|
15437
15437
|
|
|
15438
|
-
// src/scheduler/cron-cadence.ts
|
|
15439
|
-
function csvSmallestGap(field) {
|
|
15440
|
-
if (!field.includes(","))
|
|
15441
|
-
return null;
|
|
15442
|
-
const parts = field.split(",").map((s) => Number(s)).filter((n) => Number.isInteger(n) && n >= 0);
|
|
15443
|
-
if (parts.length < 2)
|
|
15444
|
-
return null;
|
|
15445
|
-
const sorted = [...parts].sort((a, b) => a - b);
|
|
15446
|
-
let smallest = Infinity;
|
|
15447
|
-
for (let i = 1;i < sorted.length; i++) {
|
|
15448
|
-
const gap = sorted[i] - sorted[i - 1];
|
|
15449
|
-
if (gap > 0 && gap < smallest)
|
|
15450
|
-
smallest = gap;
|
|
15451
|
-
}
|
|
15452
|
-
return Number.isFinite(smallest) ? smallest : null;
|
|
15453
|
-
}
|
|
15454
|
-
function estimateCronGapMin(expr) {
|
|
15455
|
-
const fields = expr.trim().split(/\s+/);
|
|
15456
|
-
if (fields.length < 5)
|
|
15457
|
-
return Infinity;
|
|
15458
|
-
const [min, hour] = fields;
|
|
15459
|
-
if (min === "*")
|
|
15460
|
-
return 1;
|
|
15461
|
-
const minStep = min.match(/^\*\/(\d+)$/);
|
|
15462
|
-
if (minStep) {
|
|
15463
|
-
const n = Number(minStep[1]);
|
|
15464
|
-
return n > 0 ? n : Infinity;
|
|
15465
|
-
}
|
|
15466
|
-
const minCsv = csvSmallestGap(min);
|
|
15467
|
-
if (minCsv !== null)
|
|
15468
|
-
return minCsv;
|
|
15469
|
-
if (!/^\d+$/.test(min))
|
|
15470
|
-
return Infinity;
|
|
15471
|
-
if (hour === "*")
|
|
15472
|
-
return 60;
|
|
15473
|
-
const hourStep = hour.match(/^\*\/(\d+)$/);
|
|
15474
|
-
if (hourStep) {
|
|
15475
|
-
const n = Number(hourStep[1]);
|
|
15476
|
-
return n > 0 ? n * 60 : Infinity;
|
|
15477
|
-
}
|
|
15478
|
-
const hourCsv = csvSmallestGap(hour);
|
|
15479
|
-
if (hourCsv !== null)
|
|
15480
|
-
return hourCsv * 60;
|
|
15481
|
-
if (/^\d+$/.test(hour))
|
|
15482
|
-
return 1440;
|
|
15483
|
-
return Infinity;
|
|
15484
|
-
}
|
|
15485
|
-
|
|
15486
15438
|
// src/scheduler/tier-selector.ts
|
|
15487
|
-
function
|
|
15488
|
-
|
|
15489
|
-
return { tier: "poll", source: "explicit", reason: "declared kind: poll (model-free check)" };
|
|
15490
|
-
}
|
|
15491
|
-
if (input.context === "fresh") {
|
|
15492
|
-
return { tier: "cheap", source: "explicit", reason: "declared context: fresh (cheap cron session)" };
|
|
15493
|
-
}
|
|
15494
|
-
if (input.context === "agent") {
|
|
15495
|
-
return { tier: "main", source: "explicit", reason: "declared context: agent (full live session)" };
|
|
15496
|
-
}
|
|
15497
|
-
if (input.model !== undefined) {
|
|
15498
|
-
return isKnownCheapModel(input.model) ? { tier: "cheap", source: "explicit", reason: `cheap model '${input.model}' \u2192 cheap cron session` } : { tier: "main", source: "explicit", reason: `model '${input.model}' is not a known-cheap id \u2192 full live session` };
|
|
15499
|
-
}
|
|
15500
|
-
if (input.smallestGapMin <= frequentGapMin) {
|
|
15501
|
-
return {
|
|
15502
|
-
tier: "cheap",
|
|
15503
|
-
source: "cadence-default",
|
|
15504
|
-
reason: `fires every ~${input.smallestGapMin}min (\u2264 ${frequentGapMin}min) \u2014 defaulting to a cheap ` + `session; set context: agent (or an Opus/custom model) if this needs the agent's full context`
|
|
15505
|
-
};
|
|
15506
|
-
}
|
|
15507
|
-
return {
|
|
15508
|
-
tier: "main",
|
|
15509
|
-
source: "cadence-default",
|
|
15510
|
-
reason: `fires every ~${input.smallestGapMin}min (> ${frequentGapMin}min) \u2014 defaulting to the agent's ` + `full session; set model: sonnet (or context: fresh) to run it cheaply`
|
|
15511
|
-
};
|
|
15512
|
-
}
|
|
15513
|
-
function applyDefaultTier(entry, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
|
|
15514
|
-
if (entry.kind === "poll" || entry.context !== undefined || entry.model !== undefined) {
|
|
15515
|
-
return entry;
|
|
15516
|
-
}
|
|
15517
|
-
const rec = recommendCronTier({ smallestGapMin: estimateCronGapMin(entry.cron) }, frequentGapMin);
|
|
15518
|
-
return rec.tier === "cheap" ? { ...entry, context: "fresh" } : entry;
|
|
15439
|
+
function applyDefaultTier(entry, _frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
|
|
15440
|
+
return entry;
|
|
15519
15441
|
}
|
|
15520
15442
|
var DEFAULT_FREQUENT_GAP_MIN = 60;
|
|
15521
15443
|
var init_tier_selector = __esm(() => {
|
|
@@ -50325,8 +50247,8 @@ var {
|
|
|
50325
50247
|
} = import__.default;
|
|
50326
50248
|
|
|
50327
50249
|
// src/build-info.ts
|
|
50328
|
-
var VERSION = "0.15.
|
|
50329
|
-
var COMMIT_SHA = "
|
|
50250
|
+
var VERSION = "0.15.11";
|
|
50251
|
+
var COMMIT_SHA = "43331954";
|
|
50330
50252
|
|
|
50331
50253
|
// src/cli/agent.ts
|
|
50332
50254
|
init_source();
|
package/package.json
CHANGED
|
@@ -17,15 +17,19 @@
|
|
|
17
17
|
set -u
|
|
18
18
|
|
|
19
19
|
# Runtime kill-switch. The fork is baked into start.sh whenever {{name}} has a
|
|
20
|
-
#
|
|
21
|
-
# runs when
|
|
22
|
-
#
|
|
20
|
+
# cron entry the value-gate routes to a cheap session, but the session only
|
|
21
|
+
# actually runs when cheap-cron is enabled at runtime. Cheap-cron is ON by
|
|
22
|
+
# DEFAULT (matches isCheapCronEnabled in src/scheduler/cron-routing.ts — only
|
|
23
|
+
# SWITCHROOM_CHEAP_CRON=0/false/off disables it); the old "off unless =1" gate
|
|
24
|
+
# here meant the cron session quarantined itself even with cheap-by-default on,
|
|
25
|
+
# so every Tier-1 fire fell back to the main session and saved nothing. Exit 78
|
|
26
|
+
# (EX_CONFIG) on the explicit kill-switch so the supervisor cleanly idles.
|
|
23
27
|
case "${SWITCHROOM_CHEAP_CRON:-}" in
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
echo "cron-session: SWITCHROOM_CHEAP_CRON off — not starting (exit 78, no respawn)" >&2
|
|
28
|
+
0 | false | off | OFF | False | FALSE)
|
|
29
|
+
echo "cron-session: SWITCHROOM_CHEAP_CRON disabled (=0) — not starting (exit 78, no respawn)" >&2
|
|
27
30
|
exit 78
|
|
28
31
|
;;
|
|
32
|
+
*) : ;;
|
|
29
33
|
esac
|
|
30
34
|
|
|
31
35
|
CRON_NAME="{{name}}-cron"
|
|
@@ -28268,6 +28268,7 @@ __export(exports_history, {
|
|
|
28268
28268
|
pruneMessagesOlderThanDays: () => pruneMessagesOlderThanDays,
|
|
28269
28269
|
lookupMessageRoleAndText: () => lookupMessageRoleAndText,
|
|
28270
28270
|
initHistory: () => initHistory,
|
|
28271
|
+
hasOutboundDeliveredSince: () => hasOutboundDeliveredSince,
|
|
28271
28272
|
getRecentOutboundCount: () => getRecentOutboundCount,
|
|
28272
28273
|
getLatestInboundMessageId: () => getLatestInboundMessageId,
|
|
28273
28274
|
deleteFromHistory: () => deleteFromHistory,
|
|
@@ -28462,6 +28463,26 @@ function getRecentOutboundCount(chatId, withinSeconds) {
|
|
|
28462
28463
|
const row = requireDb().prepare("SELECT COUNT(*) as cnt FROM messages WHERE chat_id = ? AND role = ? AND ts >= ?").get(chatId, "assistant", cutoff);
|
|
28463
28464
|
return row?.cnt ?? 0;
|
|
28464
28465
|
}
|
|
28466
|
+
function hasOutboundDeliveredSince(chatId, sinceMs, threadId) {
|
|
28467
|
+
try {
|
|
28468
|
+
const cutoffSec = Math.floor(sinceMs / 1000);
|
|
28469
|
+
const params = [chatId, cutoffSec];
|
|
28470
|
+
let sql = "SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= 200";
|
|
28471
|
+
if (threadId !== undefined) {
|
|
28472
|
+
if (threadId === null) {
|
|
28473
|
+
sql += " AND thread_id IS NULL";
|
|
28474
|
+
} else {
|
|
28475
|
+
sql += " AND thread_id = ?";
|
|
28476
|
+
params.push(threadId);
|
|
28477
|
+
}
|
|
28478
|
+
}
|
|
28479
|
+
sql += " LIMIT 1";
|
|
28480
|
+
const row = requireDb().prepare(sql).get(...params);
|
|
28481
|
+
return row != null;
|
|
28482
|
+
} catch {
|
|
28483
|
+
return false;
|
|
28484
|
+
}
|
|
28485
|
+
}
|
|
28465
28486
|
function query(opts) {
|
|
28466
28487
|
const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT));
|
|
28467
28488
|
const params = [opts.chat_id];
|
|
@@ -48305,21 +48326,23 @@ class ObligationLedger {
|
|
|
48305
48326
|
return best;
|
|
48306
48327
|
}
|
|
48307
48328
|
decideAtIdle(opts) {
|
|
48308
|
-
const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true);
|
|
48309
|
-
const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0) : this.oldest();
|
|
48329
|
+
const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true || (opts.representGraceMs ?? 0) > 0);
|
|
48330
|
+
const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0, opts.representGraceMs ?? 0) : this.oldest();
|
|
48310
48331
|
if (o === undefined)
|
|
48311
48332
|
return { action: "none" };
|
|
48312
48333
|
if (o.representCount >= this.maxRepresents)
|
|
48313
48334
|
return { action: "escalate", obligation: o };
|
|
48314
48335
|
return { action: "represent", obligation: o };
|
|
48315
48336
|
}
|
|
48316
|
-
oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs) {
|
|
48337
|
+
oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs, representGraceMs) {
|
|
48317
48338
|
let best;
|
|
48318
48339
|
for (const o of this.open.values()) {
|
|
48319
48340
|
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs)
|
|
48320
48341
|
continue;
|
|
48321
48342
|
if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
|
|
48322
48343
|
continue;
|
|
48344
|
+
if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
|
|
48345
|
+
continue;
|
|
48323
48346
|
if (best === undefined || o.openedAt < best.openedAt)
|
|
48324
48347
|
best = o;
|
|
48325
48348
|
}
|
|
@@ -48332,18 +48355,21 @@ class ObligationLedger {
|
|
|
48332
48355
|
o.lastTurnEndedAt = ts;
|
|
48333
48356
|
this.persist();
|
|
48334
48357
|
}
|
|
48335
|
-
resolveCloseTarget(echoedTurnId, liveTurnId) {
|
|
48358
|
+
resolveCloseTarget(echoedTurnId, liveTurnId, routedOriginId) {
|
|
48336
48359
|
if (echoedTurnId != null)
|
|
48337
48360
|
return echoedTurnId;
|
|
48338
|
-
if (
|
|
48361
|
+
if (routedOriginId != null)
|
|
48362
|
+
return routedOriginId;
|
|
48363
|
+
if (liveTurnId != null && this.open.has(liveTurnId))
|
|
48339
48364
|
return liveTurnId;
|
|
48340
48365
|
return null;
|
|
48341
48366
|
}
|
|
48342
|
-
markRepresented(originTurnId) {
|
|
48367
|
+
markRepresented(originTurnId, now = Date.now()) {
|
|
48343
48368
|
const o = this.open.get(originTurnId);
|
|
48344
48369
|
if (o === undefined)
|
|
48345
48370
|
return 0;
|
|
48346
48371
|
o.representCount += 1;
|
|
48372
|
+
o.lastRepresentedAt = now;
|
|
48347
48373
|
this.persist();
|
|
48348
48374
|
return o.representCount;
|
|
48349
48375
|
}
|
|
@@ -53767,10 +53793,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
53767
53793
|
}
|
|
53768
53794
|
|
|
53769
53795
|
// ../src/build-info.ts
|
|
53770
|
-
var VERSION = "0.15.
|
|
53771
|
-
var COMMIT_SHA = "
|
|
53772
|
-
var COMMIT_DATE = "2026-06-
|
|
53773
|
-
var LATEST_PR =
|
|
53796
|
+
var VERSION = "0.15.11";
|
|
53797
|
+
var COMMIT_SHA = "43331954";
|
|
53798
|
+
var COMMIT_DATE = "2026-06-13T03:24:01Z";
|
|
53799
|
+
var LATEST_PR = 2308;
|
|
53774
53800
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
53775
53801
|
|
|
53776
53802
|
// gateway/boot-version.ts
|
|
@@ -55003,6 +55029,13 @@ var OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
|
|
|
55003
55029
|
const n = Number(raw);
|
|
55004
55030
|
return Number.isFinite(n) && n >= 0 ? n : 1200000;
|
|
55005
55031
|
})();
|
|
55032
|
+
var OBLIGATION_REPRESENT_GRACE_MS = (() => {
|
|
55033
|
+
const raw = process.env.SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS;
|
|
55034
|
+
if (raw == null || raw === "")
|
|
55035
|
+
return 120000;
|
|
55036
|
+
const n = Number(raw);
|
|
55037
|
+
return Number.isFinite(n) && n >= 0 ? n : 120000;
|
|
55038
|
+
})();
|
|
55006
55039
|
var TURN_ACTIVE_MARKER_FRESH_MS = 90000;
|
|
55007
55040
|
var AUTOCLASSIFY_MIDTURN_SHADOW = process.env.SWITCHROOM_AUTOCLASSIFY_MIDTURN_SHADOW !== "0";
|
|
55008
55041
|
var lastAgentOutputAt = new Map;
|
|
@@ -55158,11 +55191,12 @@ function hasDifferentThreadedRecentTurn(chatId, liveThreadId) {
|
|
|
55158
55191
|
}
|
|
55159
55192
|
return false;
|
|
55160
55193
|
}
|
|
55161
|
-
function closeObligationOnSubstantiveReply(args, liveTurn) {
|
|
55194
|
+
function closeObligationOnSubstantiveReply(args, liveTurn, routedOriginTurn) {
|
|
55162
55195
|
if (!OBLIGATION_LEDGER_ENABLED)
|
|
55163
55196
|
return;
|
|
55164
55197
|
const echoed = findTurnByOriginId(args.origin_turn_id);
|
|
55165
|
-
const
|
|
55198
|
+
const routedOriginId = routedOriginTurn != null && echoed == null ? routedOriginTurn.turnId : null;
|
|
55199
|
+
const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId);
|
|
55166
55200
|
if (target != null)
|
|
55167
55201
|
obligationLedger.close(target);
|
|
55168
55202
|
}
|
|
@@ -56538,11 +56572,12 @@ function obligationSweep() {
|
|
|
56538
56572
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
56539
56573
|
const now = Date.now();
|
|
56540
56574
|
const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now);
|
|
56541
|
-
const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive ? {
|
|
56575
|
+
const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0 ? {
|
|
56542
56576
|
now,
|
|
56543
56577
|
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
56544
56578
|
backgroundWorkActive,
|
|
56545
|
-
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS
|
|
56579
|
+
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
56580
|
+
representGraceMs: OBLIGATION_REPRESENT_GRACE_MS
|
|
56546
56581
|
} : undefined);
|
|
56547
56582
|
const o = decision.obligation;
|
|
56548
56583
|
if (decision.action === "none" || o == null) {
|
|
@@ -56562,6 +56597,12 @@ function obligationSweep() {
|
|
|
56562
56597
|
`);
|
|
56563
56598
|
return;
|
|
56564
56599
|
}
|
|
56600
|
+
if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
|
|
56601
|
+
process.stderr.write(`telegram gateway: obligation closed silently \u2014 outbound delivered since open origin=${o.originTurnId}
|
|
56602
|
+
`);
|
|
56603
|
+
obligationLedger.close(o.originTurnId);
|
|
56604
|
+
return;
|
|
56605
|
+
}
|
|
56565
56606
|
driveEscalation({
|
|
56566
56607
|
escId: o.originTurnId,
|
|
56567
56608
|
inFlight: obligationEscalateInFlight,
|
|
@@ -57444,12 +57485,14 @@ ${url}`;
|
|
|
57444
57485
|
effectiveText = text;
|
|
57445
57486
|
}
|
|
57446
57487
|
assertAllowedChat(chat_id);
|
|
57488
|
+
let replyRoutedOriginTurn = null;
|
|
57447
57489
|
let threadId;
|
|
57448
57490
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
57449
57491
|
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
57450
57492
|
const echoedTurn = findTurnByOriginId(args.origin_turn_id);
|
|
57451
57493
|
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null;
|
|
57452
57494
|
const originTurn = echoedTurn ?? quotedTurn;
|
|
57495
|
+
replyRoutedOriginTurn = originTurn ?? null;
|
|
57453
57496
|
threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "reply");
|
|
57454
57497
|
} else {
|
|
57455
57498
|
threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
|
|
@@ -57583,7 +57626,7 @@ ${url}`;
|
|
|
57583
57626
|
disableNotification
|
|
57584
57627
|
});
|
|
57585
57628
|
if (turn2.finalAnswerSubstantive)
|
|
57586
|
-
closeObligationOnSubstantiveReply(args, turn2);
|
|
57629
|
+
closeObligationOnSubstantiveReply(args, turn2, replyRoutedOriginTurn);
|
|
57587
57630
|
}
|
|
57588
57631
|
outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
|
|
57589
57632
|
silentAnchorEditDone = true;
|
|
@@ -57786,7 +57829,7 @@ ${url}`;
|
|
|
57786
57829
|
turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification });
|
|
57787
57830
|
finalizeStatusReaction(chat_id, threadId, "done");
|
|
57788
57831
|
if (turn.finalAnswerSubstantive)
|
|
57789
|
-
closeObligationOnSubstantiveReply(args, turn);
|
|
57832
|
+
closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn);
|
|
57790
57833
|
}
|
|
57791
57834
|
releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
|
|
57792
57835
|
if (turn?.finalAnswerDelivered === true) {
|
|
@@ -57806,13 +57849,19 @@ async function executeStreamReply(args) {
|
|
|
57806
57849
|
throw new Error("stream_reply: chat_id is required");
|
|
57807
57850
|
if (args.text == null || args.text === "")
|
|
57808
57851
|
throw new Error("stream_reply: text is required and cannot be empty");
|
|
57852
|
+
let streamRoutedOriginTurn = null;
|
|
57853
|
+
let streamOriginVia = null;
|
|
57854
|
+
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
57855
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id);
|
|
57856
|
+
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
|
|
57857
|
+
const originTurn = echoedTurn ?? quotedTurn;
|
|
57858
|
+
streamRoutedOriginTurn = originTurn ?? null;
|
|
57859
|
+
streamOriginVia = originTurn == null ? null : echoedTurn != null ? "echo" : "quoted";
|
|
57860
|
+
}
|
|
57809
57861
|
if (args.message_thread_id == null) {
|
|
57810
57862
|
let injected;
|
|
57811
57863
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
57812
|
-
|
|
57813
|
-
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
|
|
57814
|
-
const originTurn = echoedTurn ?? quotedTurn;
|
|
57815
|
-
injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "stream_reply");
|
|
57864
|
+
injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, streamRoutedOriginTurn, streamOriginVia, turn, "stream_reply");
|
|
57816
57865
|
} else {
|
|
57817
57866
|
injected = turn?.sessionThreadId;
|
|
57818
57867
|
}
|
|
@@ -57955,7 +58004,7 @@ async function executeStreamReply(args) {
|
|
|
57955
58004
|
done: args.done === true
|
|
57956
58005
|
});
|
|
57957
58006
|
if (turn.finalAnswerSubstantive)
|
|
57958
|
-
closeObligationOnSubstantiveReply(args, turn);
|
|
58007
|
+
closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn);
|
|
57959
58008
|
const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
57960
58009
|
clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
|
|
57961
58010
|
}
|
|
@@ -139,6 +139,7 @@ import {
|
|
|
139
139
|
recordReaction, lookupMessageRoleAndText,
|
|
140
140
|
checkpointWal as checkpointHistoryWal,
|
|
141
141
|
pruneMessagesOlderThanDays,
|
|
142
|
+
hasOutboundDeliveredSince,
|
|
142
143
|
} from '../history.js'
|
|
143
144
|
import {
|
|
144
145
|
runRegistryReaper,
|
|
@@ -1506,6 +1507,20 @@ const OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
|
|
|
1506
1507
|
const n = Number(raw)
|
|
1507
1508
|
return Number.isFinite(n) && n >= 0 ? n : 20 * 60_000
|
|
1508
1509
|
})()
|
|
1510
|
+
// Per-represent grace window. After a re-present fires, the obligation is
|
|
1511
|
+
// ineligible for the next represent/escalate until at least this many ms have
|
|
1512
|
+
// elapsed since markRepresented. Without this the 5s sweep can fire again
|
|
1513
|
+
// before the re-presented turn even reaches the agent, burning the represent
|
|
1514
|
+
// budget and producing back-to-back re-presents or a premature escalation.
|
|
1515
|
+
// Default 120s — generous enough for a turn to start + deliver an answer;
|
|
1516
|
+
// small enough not to delay genuine unanswered re-presents.
|
|
1517
|
+
// Kill switch: SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS=0 → no per-represent grace.
|
|
1518
|
+
const OBLIGATION_REPRESENT_GRACE_MS = (() => {
|
|
1519
|
+
const raw = process.env.SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS
|
|
1520
|
+
if (raw == null || raw === '') return 120_000
|
|
1521
|
+
const n = Number(raw)
|
|
1522
|
+
return Number.isFinite(n) && n >= 0 ? n : 120_000
|
|
1523
|
+
})()
|
|
1509
1524
|
// Marker-freshness window for the orphaned-foreground signal. The turn-active
|
|
1510
1525
|
// marker is touched on every foreground tool_use and on foreground sub-agent
|
|
1511
1526
|
// JSONL growth, so an mtime younger than this means a sub-agent is touching it
|
|
@@ -2175,23 +2190,38 @@ function hasDifferentThreadedRecentTurn(
|
|
|
2175
2190
|
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
2176
2191
|
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
2177
2192
|
* obligation discharged is the one for the SAME origin the answer routes to
|
|
2178
|
-
* (origin_turn_id the model echoed, else the
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
*
|
|
2185
|
-
*
|
|
2186
|
-
*
|
|
2193
|
+
* (origin_turn_id the model echoed, else the routed origin the gateway resolved,
|
|
2194
|
+
* else the live turn). So 713's reply closes 713's obligation even after
|
|
2195
|
+
* currentTurn flipped to 715, and 715 stays open until ITS own substantive
|
|
2196
|
+
* answer. Answers to re-presented obligations (via=quoted, no model echo) close
|
|
2197
|
+
* via the gateway-resolved routedOriginTurn. An ack does NOT close (so
|
|
2198
|
+
* ack-then-ghost is re-presented, not re-dropped). The live-turn fallback fires
|
|
2199
|
+
* only for the live turn's OWN obligation (it was the turn delivering this
|
|
2200
|
+
* reply), preserving the 713/715 invariant. No-op unless the flag is on.
|
|
2201
|
+
*
|
|
2202
|
+
* @param routedOriginTurn — the origin the reply router already resolved
|
|
2203
|
+
* (echoedTurn ?? quotedTurn); pass whenever the TURN_ORIGIN_ROUTING path ran.
|
|
2204
|
+
* Skipped when null/undefined (pre-routing paths, or DM with no quote).
|
|
2187
2205
|
*/
|
|
2188
2206
|
function closeObligationOnSubstantiveReply(
|
|
2189
2207
|
args: Record<string, unknown>,
|
|
2190
2208
|
liveTurn: CurrentTurn | null | undefined,
|
|
2209
|
+
routedOriginTurn?: CurrentTurn | null,
|
|
2191
2210
|
): void {
|
|
2192
2211
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
2193
2212
|
const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
2194
|
-
|
|
2213
|
+
// routedOriginTurn is the gateway-resolved origin (echoedTurn ?? quotedTurn).
|
|
2214
|
+
// Only pass it as routedOriginId when it DIFFERS from the echoed turn (if
|
|
2215
|
+
// echoed is present, resolveCloseTarget's first branch already handles it),
|
|
2216
|
+
// and only when it is NOT the live turn (live-turn is the fallback, not the
|
|
2217
|
+
// routed origin — passing live turn here would bypass the live-turn fallback
|
|
2218
|
+
// logic and still close correctly, but naming matters for the 713/715 case:
|
|
2219
|
+
// the routed origin on a via=quoted reply IS the origin, not "live fallback").
|
|
2220
|
+
const routedOriginId =
|
|
2221
|
+
routedOriginTurn != null && echoed == null
|
|
2222
|
+
? routedOriginTurn.turnId
|
|
2223
|
+
: null
|
|
2224
|
+
const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
|
|
2195
2225
|
if (target != null) obligationLedger.close(target)
|
|
2196
2226
|
}
|
|
2197
2227
|
|
|
@@ -5414,13 +5444,16 @@ function obligationSweep(): void {
|
|
|
5414
5444
|
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
|
|
5415
5445
|
// Grace window: skip an obligation whose handling turn ended < grace ago — its
|
|
5416
5446
|
// trailing slow/worker answer may still be landing (over-escalation fix).
|
|
5447
|
+
// Per-represent grace: skip an obligation re-presented < grace ago — prevents
|
|
5448
|
+
// the 5s sweep from immediately firing again before the re-present even lands.
|
|
5417
5449
|
const decision = obligationLedger.decideAtIdle(
|
|
5418
|
-
OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive
|
|
5450
|
+
OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0
|
|
5419
5451
|
? {
|
|
5420
5452
|
now,
|
|
5421
5453
|
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
5422
5454
|
backgroundWorkActive,
|
|
5423
5455
|
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
5456
|
+
representGraceMs: OBLIGATION_REPRESENT_GRACE_MS,
|
|
5424
5457
|
}
|
|
5425
5458
|
: undefined,
|
|
5426
5459
|
)
|
|
@@ -5449,8 +5482,22 @@ function obligationSweep(): void {
|
|
|
5449
5482
|
)
|
|
5450
5483
|
return
|
|
5451
5484
|
}
|
|
5452
|
-
// escalate — re-present ladder exhausted.
|
|
5453
|
-
//
|
|
5485
|
+
// escalate — re-present ladder exhausted. Before sending the user-visible
|
|
5486
|
+
// apology, check whether the agent has ALREADY delivered an outbound reply
|
|
5487
|
+
// to this chat since the obligation was opened. If yes, the obligation is
|
|
5488
|
+
// stale (the agent did answer, just without closing the obligation via the
|
|
5489
|
+
// normal close path) — close silently instead of alarming the user with a
|
|
5490
|
+
// false "I may have missed this". This is Fix 4: escalate only on knowledge,
|
|
5491
|
+
// not doubt. Fall back to false (safe: never suppresses) if history unavailable.
|
|
5492
|
+
if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
|
|
5493
|
+
process.stderr.write(
|
|
5494
|
+
`telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
|
|
5495
|
+
)
|
|
5496
|
+
obligationLedger.close(o.originTurnId)
|
|
5497
|
+
return
|
|
5498
|
+
}
|
|
5499
|
+
// Proceed with escalation: send ONE operator-visible nudge and close the
|
|
5500
|
+
// obligation ONLY AFTER it actually lands. This inverts the old
|
|
5454
5501
|
// close-before-send (which silently dropped the terminal whenever the send
|
|
5455
5502
|
// failed): the close is now itself an observable terminal. A transient send
|
|
5456
5503
|
// failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
|
|
@@ -6970,6 +7017,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6970
7017
|
// heuristic is what mis-routed a late reply to whichever topic most
|
|
6971
7018
|
// recently received a message. DM: every tier is undefined → unchanged.
|
|
6972
7019
|
// Kill switch off → exact legacy resolveThreadId precedence.
|
|
7020
|
+
// Hoist the resolved origin turn so the obligation-close path (below) can
|
|
7021
|
+
// pass it into resolveCloseTarget as routedOriginId, closing re-presented
|
|
7022
|
+
// obligations even when the model omitted origin_turn_id (Fix 1/2).
|
|
7023
|
+
let replyRoutedOriginTurn: CurrentTurn | null = null
|
|
6973
7024
|
let threadId: number | undefined
|
|
6974
7025
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
6975
7026
|
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
@@ -6979,6 +7030,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6979
7030
|
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
6980
7031
|
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
|
|
6981
7032
|
const originTurn = echoedTurn ?? quotedTurn
|
|
7033
|
+
replyRoutedOriginTurn = originTurn ?? null
|
|
6982
7034
|
threadId = resolveAnswerThreadWithLog(
|
|
6983
7035
|
chat_id,
|
|
6984
7036
|
Number.isFinite(explicit as number) ? (explicit as number) : undefined,
|
|
@@ -7220,7 +7272,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
7220
7272
|
text: decision.mergedText,
|
|
7221
7273
|
disableNotification,
|
|
7222
7274
|
})
|
|
7223
|
-
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
7275
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
|
|
7224
7276
|
}
|
|
7225
7277
|
outboundDedup.record(
|
|
7226
7278
|
chat_id,
|
|
@@ -7573,7 +7625,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
7573
7625
|
finalizeStatusReaction(chat_id, threadId, 'done')
|
|
7574
7626
|
// PR2: close this origin's obligation on a SUBSTANTIVE final answer
|
|
7575
7627
|
// (after finalize so the reaction guard test's anchor window is stable).
|
|
7576
|
-
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
7628
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
|
|
7577
7629
|
}
|
|
7578
7630
|
// v0.13.30 follow-up — release the buffer gate on EVERY reply
|
|
7579
7631
|
// finalize, not just on `isFinalAnswerReply`. The narrow
|
|
@@ -7633,20 +7685,36 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7633
7685
|
// topic and a late stream-reply can't be stolen by a successor turn. DM:
|
|
7634
7686
|
// every tier undefined → unchanged. Kill switch off → legacy live-turn
|
|
7635
7687
|
// injection only.
|
|
7688
|
+
// Origin resolution is hoisted UNCONDITIONALLY (outside the
|
|
7689
|
+
// message_thread_id==null guard below) so the obligation-close path has
|
|
7690
|
+
// the correct routedOriginTurn even when the model explicitly passes
|
|
7691
|
+
// message_thread_id (forum-topic streams). Without this hoist, Fix 1
|
|
7692
|
+
// is a no-op for forum-topic streams — the origin is never resolved and
|
|
7693
|
+
// closeObligationOnSubstantiveReply falls through to the live-turn
|
|
7694
|
+
// fallback. Matches executeReply's unconditional resolution. Thread
|
|
7695
|
+
// injection still stays scoped to the message_thread_id==null branch —
|
|
7696
|
+
// only the obligation-close input changes.
|
|
7697
|
+
let streamRoutedOriginTurn: CurrentTurn | null = null
|
|
7698
|
+
// Track whether the origin was found via echo (for the routing log below).
|
|
7699
|
+
let streamOriginVia: 'echo' | 'quoted' | null = null
|
|
7700
|
+
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
7701
|
+
// Origin precedence: model echo first, then the framework-owned quoted
|
|
7702
|
+
// message_id as a deterministic fallback (mirrors executeReply).
|
|
7703
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
7704
|
+
const quotedTurn =
|
|
7705
|
+
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
7706
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
7707
|
+
streamRoutedOriginTurn = originTurn ?? null
|
|
7708
|
+
streamOriginVia = originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted'
|
|
7709
|
+
}
|
|
7636
7710
|
if (args.message_thread_id == null) {
|
|
7637
7711
|
let injected: number | undefined
|
|
7638
7712
|
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
7639
|
-
// Origin precedence: model echo first, then the framework-owned quoted
|
|
7640
|
-
// message_id as a deterministic fallback (mirrors executeReply).
|
|
7641
|
-
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
7642
|
-
const quotedTurn =
|
|
7643
|
-
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
7644
|
-
const originTurn = echoedTurn ?? quotedTurn
|
|
7645
7713
|
injected = resolveAnswerThreadWithLog(
|
|
7646
7714
|
String(args.chat_id),
|
|
7647
7715
|
undefined,
|
|
7648
|
-
|
|
7649
|
-
|
|
7716
|
+
streamRoutedOriginTurn,
|
|
7717
|
+
streamOriginVia,
|
|
7650
7718
|
turn,
|
|
7651
7719
|
'stream_reply',
|
|
7652
7720
|
)
|
|
@@ -7925,7 +7993,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7925
7993
|
disableNotification: args.disable_notification === true,
|
|
7926
7994
|
done: args.done === true,
|
|
7927
7995
|
})
|
|
7928
|
-
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
7996
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn)
|
|
7929
7997
|
// #1744 follow-up — stream_reply edge case. The first-emit gate at
|
|
7930
7998
|
// L5178 only clears silent-end state on the FIRST emit of a stream.
|
|
7931
7999
|
// If a stream's first emit was ack-shaped (disable_notification:true,
|
|
@@ -55,6 +55,12 @@ export interface Obligation {
|
|
|
55
55
|
* that re-stamps this once, and representCount is capped, so the ladder still
|
|
56
56
|
* terminates. Durable (part of the snapshot) so the grace survives restart. */
|
|
57
57
|
lastTurnEndedAt?: number
|
|
58
|
+
/** Wall-clock ms this obligation was most recently re-presented. Drives the
|
|
59
|
+
* per-represent grace: a freshly re-presented obligation is skipped until
|
|
60
|
+
* at least `representGraceMs` has elapsed, preventing immediate second
|
|
61
|
+
* re-present/escalate when the sweep fires < 5s later. Durable (part of the
|
|
62
|
+
* snapshot) so the grace window survives a restart. */
|
|
63
|
+
lastRepresentedAt?: number
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/** What the gateway should do for the oldest open obligation at an idle boundary. */
|
|
@@ -202,20 +208,30 @@ export class ObligationLedger {
|
|
|
202
208
|
* pathologically-stuck/leaked worker cannot suppress the escalation forever —
|
|
203
209
|
* once openedAt+backgroundGraceMs passes, the obligation is acted on regardless
|
|
204
210
|
* of work state, and the FSM still terminates.
|
|
211
|
+
*
|
|
212
|
+
* PER-REPRESENT GRACE (opts.representGraceMs > 0): an obligation that was just
|
|
213
|
+
* re-presented is ineligible until at least `representGraceMs` ms have elapsed
|
|
214
|
+
* since `lastRepresentedAt`. Without this, the 5s sweep can fire again before
|
|
215
|
+
* the re-presented turn even reaches the agent, burning the represent budget
|
|
216
|
+
* immediately and producing back-to-back escalations on the same message.
|
|
205
217
|
*/
|
|
206
218
|
decideAtIdle(opts?: {
|
|
207
219
|
now: number
|
|
208
220
|
graceMs: number
|
|
209
221
|
backgroundWorkActive?: boolean
|
|
210
222
|
backgroundGraceMs?: number
|
|
223
|
+
representGraceMs?: number
|
|
211
224
|
}): LedgerDecision {
|
|
212
|
-
const useEligible =
|
|
225
|
+
const useEligible =
|
|
226
|
+
opts != null &&
|
|
227
|
+
(opts.graceMs > 0 || opts.backgroundWorkActive === true || (opts.representGraceMs ?? 0) > 0)
|
|
213
228
|
const o = useEligible
|
|
214
229
|
? this.oldestEligible(
|
|
215
230
|
opts!.now,
|
|
216
231
|
opts!.graceMs,
|
|
217
232
|
opts!.backgroundWorkActive === true,
|
|
218
233
|
opts!.backgroundGraceMs ?? 0,
|
|
234
|
+
opts!.representGraceMs ?? 0,
|
|
219
235
|
)
|
|
220
236
|
: this.oldest()
|
|
221
237
|
if (o === undefined) return { action: 'none' }
|
|
@@ -224,24 +240,30 @@ export class ObligationLedger {
|
|
|
224
240
|
}
|
|
225
241
|
|
|
226
242
|
/** The oldest open obligation that is currently ELIGIBLE to act on — i.e. NOT
|
|
227
|
-
* within
|
|
243
|
+
* within any grace window:
|
|
228
244
|
* - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
|
|
229
245
|
* obligation with no lastTurnEndedAt can't have a trailing answer, so it is
|
|
230
|
-
* always eligible on this axis);
|
|
246
|
+
* always eligible on this axis);
|
|
231
247
|
* - background-work grace: when `backgroundWorkActive`, it was opened <
|
|
232
248
|
* `backgroundGraceMs` ago (genuine in-flight autonomous work — bounded by
|
|
233
|
-
* the ceiling so a stale/leaked worker can't suppress escalation forever)
|
|
249
|
+
* the ceiling so a stale/leaked worker can't suppress escalation forever);
|
|
250
|
+
* - per-represent grace: it was re-presented < `representGraceMs` ago (prevents
|
|
251
|
+
* a 5s sweep tick from immediately firing again on the same obligation before
|
|
252
|
+
* the re-presented turn even reaches the agent). */
|
|
234
253
|
private oldestEligible(
|
|
235
254
|
now: number,
|
|
236
255
|
graceMs: number,
|
|
237
256
|
backgroundWorkActive: boolean,
|
|
238
257
|
backgroundGraceMs: number,
|
|
258
|
+
representGraceMs: number,
|
|
239
259
|
): Obligation | undefined {
|
|
240
260
|
let best: Obligation | undefined
|
|
241
261
|
for (const o of this.open.values()) {
|
|
242
262
|
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
|
|
243
263
|
if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
|
|
244
264
|
continue // in-flight autonomous work, bounded by the ceiling
|
|
265
|
+
if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
|
|
266
|
+
continue // per-represent grace: sweep fired before re-presented turn landed
|
|
245
267
|
if (best === undefined || o.openedAt < best.openedAt) best = o
|
|
246
268
|
}
|
|
247
269
|
return best
|
|
@@ -259,29 +281,48 @@ export class ObligationLedger {
|
|
|
259
281
|
/**
|
|
260
282
|
* Decide which obligation a substantive reply discharges — DETERMINISTICALLY,
|
|
261
283
|
* holding for any model behavior:
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
284
|
+
*
|
|
285
|
+
* 1. `echoedTurnId` (model echoed origin_turn_id back) → authoritative; close
|
|
286
|
+
* exactly that (a no-op via close() if it isn't actually open).
|
|
287
|
+
* 2. `routedOriginId` (gateway-resolved origin from quote/via=quoted or
|
|
288
|
+
* via=live routing) → treat as the definitive target when present; this
|
|
289
|
+
* makes answers to re-presented messages close their obligation even when
|
|
290
|
+
* no model echo was provided and even with >1 open obligation (the routed
|
|
291
|
+
* origin IS the answer's origin — this is deterministic, not a guess).
|
|
292
|
+
* The 713/715 invariant still holds: the gateway only passes a routedOriginId
|
|
293
|
+
* that it has positively resolved as the reply's origin (quote resolution,
|
|
294
|
+
* via=quoted); it never passes the LIVE turn id here when a different
|
|
295
|
+
* obligation is the resolved origin.
|
|
296
|
+
* 3. else, close the live turn's own obligation when that turn itself is open —
|
|
297
|
+
* this is unambiguous (the reply happened IN that turn, so the turn's own
|
|
298
|
+
* obligation IS the right target). The 713/715 wrong-close protection is
|
|
299
|
+
* preserved by ordering: routed/echoed origin (steps 1/2) wins first;
|
|
300
|
+
* live-turn fallback (step 3) only fires when no routed origin resolved, AND
|
|
301
|
+
* only for the live turn's OWN obligation (not another open obligation). A
|
|
302
|
+
* reply answering message A landing while currentTurn=B must STILL not close B
|
|
303
|
+
* — only steps 1/2 can close A in that case. With multiple open obligations
|
|
304
|
+
* and no routed origin, the LIVE turn's own obligation is the safe default
|
|
305
|
+
* (relaxed from size==1 which wrongly blocked it when a second message arrived
|
|
306
|
+
* meanwhile). Returns the id to close, or null.
|
|
270
307
|
*/
|
|
271
308
|
resolveCloseTarget(
|
|
272
309
|
echoedTurnId: string | null | undefined,
|
|
273
310
|
liveTurnId: string | null | undefined,
|
|
311
|
+
routedOriginId?: string | null,
|
|
274
312
|
): string | null {
|
|
275
313
|
if (echoedTurnId != null) return echoedTurnId
|
|
276
|
-
if (
|
|
314
|
+
if (routedOriginId != null) return routedOriginId
|
|
315
|
+
if (liveTurnId != null && this.open.has(liveTurnId)) return liveTurnId
|
|
277
316
|
return null
|
|
278
317
|
}
|
|
279
318
|
|
|
280
|
-
/** Record that an obligation was just re-presented (bumps representCount
|
|
281
|
-
|
|
319
|
+
/** Record that an obligation was just re-presented (bumps representCount, stamps
|
|
320
|
+
* lastRepresentedAt for the per-represent grace window). */
|
|
321
|
+
markRepresented(originTurnId: string, now = Date.now()): number {
|
|
282
322
|
const o = this.open.get(originTurnId)
|
|
283
323
|
if (o === undefined) return 0
|
|
284
324
|
o.representCount += 1
|
|
325
|
+
o.lastRepresentedAt = now
|
|
285
326
|
this.persist()
|
|
286
327
|
return o.representCount
|
|
287
328
|
}
|
|
@@ -546,6 +546,63 @@ export function getRecentOutboundCount(
|
|
|
546
546
|
return row?.cnt ?? 0
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Returns true if at least one SUBSTANTIVE outbound (bot → user, role='assistant')
|
|
551
|
+
* message was delivered to `chatId` (and optionally `threadId`) AFTER `sinceMs`
|
|
552
|
+
* (wall-clock epoch milliseconds). Used by the obligation sweep to suppress a false
|
|
553
|
+
* "I may have missed this" escalation when the agent visibly answered: if a
|
|
554
|
+
* substantive outbound landed since the obligation was opened, the obligation is
|
|
555
|
+
* stale — close it silently rather than alarming the user.
|
|
556
|
+
*
|
|
557
|
+
* SUBSTANTIVE: we never suppress escalation on a bare ack ("on it", "give me a
|
|
558
|
+
* sec") — an agent that acks then ghosts must still escalate. The history schema
|
|
559
|
+
* does not store a done/substantive flag, so we approximate: a row counts only
|
|
560
|
+
* when LENGTH(text) >= 200 (the FINAL_ANSWER_MIN_CHARS constant from
|
|
561
|
+
* final-answer-detect.ts). This is false-negative-safe: a genuine substantive
|
|
562
|
+
* answer that happens to be < 200 chars will still fire an escalation, which is
|
|
563
|
+
* the conservative (safe) outcome. A schema column would be more precise but is
|
|
564
|
+
* disproportionate for this predicate; the reviewer accepted this approach.
|
|
565
|
+
*
|
|
566
|
+
* `threadId` semantics:
|
|
567
|
+
* - undefined → any message in the chat regardless of thread (DMs + supergroups)
|
|
568
|
+
* - explicit number → only that thread (precise for supergroups with topics)
|
|
569
|
+
* - explicit null → only chat-root (non-thread) messages
|
|
570
|
+
*
|
|
571
|
+
* Falls back to false (safe: never suppresses escalation) if history is not yet
|
|
572
|
+
* initialised or the query fails.
|
|
573
|
+
*/
|
|
574
|
+
export function hasOutboundDeliveredSince(
|
|
575
|
+
chatId: string,
|
|
576
|
+
sinceMs: number,
|
|
577
|
+
threadId?: number | null,
|
|
578
|
+
): boolean {
|
|
579
|
+
try {
|
|
580
|
+
const cutoffSec = Math.floor(sinceMs / 1000)
|
|
581
|
+
const params: unknown[] = [chatId, cutoffSec]
|
|
582
|
+
// LENGTH(text) >= 200 scopes to substantive replies only — never suppress
|
|
583
|
+
// escalation on a mere ack. Mirrors FINAL_ANSWER_MIN_CHARS (200) from
|
|
584
|
+
// final-answer-detect.ts; the `done` flag is not stored in the history
|
|
585
|
+
// schema, so length is the closest available proxy.
|
|
586
|
+
let sql =
|
|
587
|
+
"SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= 200"
|
|
588
|
+
if (threadId !== undefined) {
|
|
589
|
+
if (threadId === null) {
|
|
590
|
+
sql += ' AND thread_id IS NULL'
|
|
591
|
+
} else {
|
|
592
|
+
sql += ' AND thread_id = ?'
|
|
593
|
+
params.push(threadId)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
sql += ' LIMIT 1'
|
|
597
|
+
const row = requireDb()
|
|
598
|
+
.prepare(sql)
|
|
599
|
+
.get(...(params as [unknown, ...unknown[]])) as Record<string, unknown> | undefined
|
|
600
|
+
return row != null
|
|
601
|
+
} catch {
|
|
602
|
+
return false
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
549
606
|
export function query(opts: QueryOptions): RecordedMessage[] {
|
|
550
607
|
const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
|
|
551
608
|
const params: unknown[] = [opts.chat_id]
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
query,
|
|
11
11
|
getRecentOutboundCount,
|
|
12
12
|
getLatestInboundMessageId,
|
|
13
|
+
hasOutboundDeliveredSince,
|
|
13
14
|
_resetForTests,
|
|
14
15
|
} from '../history.js'
|
|
15
16
|
|
|
@@ -363,6 +364,88 @@ describe('getRecentOutboundCount (backstop dedup helper)', () => {
|
|
|
363
364
|
})
|
|
364
365
|
})
|
|
365
366
|
|
|
367
|
+
// A substantive reply: 200+ chars (the FINAL_ANSWER_MIN_CHARS threshold).
|
|
368
|
+
const SUBSTANTIVE = 'A'.repeat(200)
|
|
369
|
+
// A non-substantive ack: short (<200 chars).
|
|
370
|
+
const ACK = 'On it.'
|
|
371
|
+
|
|
372
|
+
describe('hasOutboundDeliveredSince', () => {
|
|
373
|
+
beforeEach(() => initHistory(stateDir, 30))
|
|
374
|
+
|
|
375
|
+
it('returns true when a substantive outbound exists after openedAt', () => {
|
|
376
|
+
const openedAt = 1_000_000 * 1000 // ms
|
|
377
|
+
recordOutbound({
|
|
378
|
+
chat_id: '-100',
|
|
379
|
+
thread_id: null,
|
|
380
|
+
message_ids: [10],
|
|
381
|
+
texts: [SUBSTANTIVE],
|
|
382
|
+
ts: 1_000_001, // sec — 1s after openedAt
|
|
383
|
+
})
|
|
384
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(true)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('returns false when the only outbound is BEFORE openedAt', () => {
|
|
388
|
+
const openedAt = 1_000_002 * 1000 // ms — after the message
|
|
389
|
+
recordOutbound({
|
|
390
|
+
chat_id: '-100',
|
|
391
|
+
thread_id: null,
|
|
392
|
+
message_ids: [10],
|
|
393
|
+
texts: [SUBSTANTIVE],
|
|
394
|
+
ts: 1_000_001, // sec — before openedAt
|
|
395
|
+
})
|
|
396
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('returns false for a non-substantive ack after openedAt (blocker regression)', () => {
|
|
400
|
+
// An agent that sends a short ack ("on it") then ghosts must NOT have
|
|
401
|
+
// its escalation suppressed. The predicate must never match a bare ack.
|
|
402
|
+
const openedAt = 1_000_000 * 1000
|
|
403
|
+
recordOutbound({
|
|
404
|
+
chat_id: '-100',
|
|
405
|
+
thread_id: null,
|
|
406
|
+
message_ids: [10],
|
|
407
|
+
texts: [ACK], // < 200 chars — non-substantive
|
|
408
|
+
ts: 1_000_001,
|
|
409
|
+
})
|
|
410
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('thread_id=undefined matches any thread (DM semantics)', () => {
|
|
414
|
+
const openedAt = 1_000_000 * 1000
|
|
415
|
+
recordOutbound({
|
|
416
|
+
chat_id: '-100',
|
|
417
|
+
thread_id: 5,
|
|
418
|
+
message_ids: [10],
|
|
419
|
+
texts: [SUBSTANTIVE],
|
|
420
|
+
ts: 1_000_001,
|
|
421
|
+
})
|
|
422
|
+
// No thread filter → should find it
|
|
423
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined)).toBe(true)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('thread_id=number scopes to that thread only', () => {
|
|
427
|
+
const openedAt = 1_000_000 * 1000
|
|
428
|
+
recordOutbound({ chat_id: '-100', thread_id: 5, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
|
|
429
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 5)).toBe(true)
|
|
430
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 6)).toBe(false)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('thread_id=null matches only chat-root (non-thread) messages', () => {
|
|
434
|
+
const openedAt = 1_000_000 * 1000
|
|
435
|
+
recordOutbound({ chat_id: '-100', thread_id: null, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
|
|
436
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true)
|
|
437
|
+
// A thread-scoped message should NOT match the root filter
|
|
438
|
+
recordOutbound({ chat_id: '-100', thread_id: 3, message_ids: [11], texts: [SUBSTANTIVE], ts: 1_000_002 })
|
|
439
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true) // root still there
|
|
440
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 3)).toBe(true) // thread 3 also there
|
|
441
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 9)).toBe(false) // thread 9 not there
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('returns false when no history is present for the chat', () => {
|
|
445
|
+
expect(hasOutboundDeliveredSince('-999', 0)).toBe(false)
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
366
449
|
describe('secret redaction at persistence (both directions)', () => {
|
|
367
450
|
beforeEach(() => initHistory(stateDir, 30))
|
|
368
451
|
|
|
@@ -105,14 +105,26 @@ describe("ObligationLedger", () => {
|
|
|
105
105
|
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
it("no echo + MULTIPLE open → close
|
|
108
|
+
it("no echo + MULTIPLE open + live turn IS open → close the live turn's OWN obligation (Fix 2)", () => {
|
|
109
109
|
const L = new ObligationLedger();
|
|
110
110
|
L.openIfAbsent(input("c:635#713", 1000));
|
|
111
111
|
L.openIfAbsent(input("c:3#715", 1100));
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
expect(L.
|
|
112
|
+
// A substantive reply delivered during currentTurn=715 (no model echo, no
|
|
113
|
+
// routed origin) closes 715's own obligation. 713 stays open and is
|
|
114
|
+
// re-presented. The 713/715 invariant holds: this does NOT close 713.
|
|
115
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
|
|
116
|
+
expect(L.isOpen("c:635#713")).toBe(true); // 713 stays open
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("no echo + MULTIPLE open + live turn NOT one of them → close nothing (713/715 invariant preserved)", () => {
|
|
120
|
+
const L = new ObligationLedger();
|
|
121
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
122
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
123
|
+
// currentTurn=999 is NOT an open obligation. We can't identify the right
|
|
124
|
+
// target → refuse to close anything (a re-present is safer than a wrong close).
|
|
125
|
+
expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
|
|
126
|
+
expect(L.isOpen("c:635#713")).toBe(true);
|
|
127
|
+
expect(L.isOpen("c:3#715")).toBe(true);
|
|
116
128
|
});
|
|
117
129
|
|
|
118
130
|
it("no echo + live turn not an open obligation → null", () => {
|
|
@@ -120,6 +132,35 @@ describe("ObligationLedger", () => {
|
|
|
120
132
|
L.openIfAbsent(input("c:3#715", 1100));
|
|
121
133
|
expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
|
|
122
134
|
});
|
|
135
|
+
|
|
136
|
+
it("routedOriginId (Fix 1) closes the routed target even with multiple open + no model echo", () => {
|
|
137
|
+
// Simulate the clerk incident: two obligations open, agent answers #14057
|
|
138
|
+
// via a quoted reply (via=quoted), no model echo. The gateway resolves
|
|
139
|
+
// routedOriginId=#14057 from the quote. That obligation closes; #14059 stays.
|
|
140
|
+
const L = new ObligationLedger();
|
|
141
|
+
L.openIfAbsent(input("c:0#14057", 1000));
|
|
142
|
+
L.openIfAbsent(input("c:0#14059", 1100));
|
|
143
|
+
expect(L.resolveCloseTarget(undefined, "c:0#14059", "c:0#14057")).toBe("c:0#14057");
|
|
144
|
+
// Close it to confirm only #14057 closes
|
|
145
|
+
L.close("c:0#14057");
|
|
146
|
+
expect(L.isOpen("c:0#14057")).toBe(false);
|
|
147
|
+
expect(L.isOpen("c:0#14059")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("echoedTurnId wins over routedOriginId when both present (echoed is authoritative)", () => {
|
|
151
|
+
const L = new ObligationLedger();
|
|
152
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
153
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
154
|
+
// Model explicitly echoed 713 AND router resolved 715 as routed origin.
|
|
155
|
+
// The echo is authoritative — close 713.
|
|
156
|
+
expect(L.resolveCloseTarget("c:635#713", "c:3#715", "c:3#715")).toBe("c:635#713");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("routedOriginId=null falls through to live-turn fallback", () => {
|
|
160
|
+
const L = new ObligationLedger();
|
|
161
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
162
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715", null)).toBe("c:3#715");
|
|
163
|
+
});
|
|
123
164
|
});
|
|
124
165
|
});
|
|
125
166
|
|
|
@@ -411,3 +452,170 @@ describe("ObligationLedger — background-work grace (extended-autonomous fix, g
|
|
|
411
452
|
).toBe("represent");
|
|
412
453
|
});
|
|
413
454
|
});
|
|
455
|
+
|
|
456
|
+
describe("ObligationLedger — per-represent grace (Fix 3: clerk 2026-06-13 incident)", () => {
|
|
457
|
+
function input(id: string, openedAt: number) {
|
|
458
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const REPR_GRACE = 120_000; // 2 min, mirroring the default
|
|
462
|
+
|
|
463
|
+
it("a freshly re-presented obligation is ineligible until representGraceMs elapses", () => {
|
|
464
|
+
const L = new ObligationLedger(2);
|
|
465
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
466
|
+
// Re-present fires at t=5000; markRepresented stamps lastRepresentedAt.
|
|
467
|
+
L.markRepresented("c:3#1", 5000);
|
|
468
|
+
// 10s later — still within 120s represent grace → ineligible → none.
|
|
469
|
+
expect(
|
|
470
|
+
L.decideAtIdle({ now: 15000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
471
|
+
).toBe("none");
|
|
472
|
+
// 120s + 1ms after re-present → grace expired → act.
|
|
473
|
+
expect(
|
|
474
|
+
L.decideAtIdle({ now: 5000 + REPR_GRACE + 1, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
475
|
+
).toBe("represent");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("markRepresented stamps lastRepresentedAt and persists", () => {
|
|
479
|
+
const snapshots: Obligation[][] = [];
|
|
480
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
481
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
482
|
+
L.markRepresented("c:3#1", 9000);
|
|
483
|
+
const snap = L.list()[0];
|
|
484
|
+
expect(snap.lastRepresentedAt).toBe(9000);
|
|
485
|
+
expect(snap.representCount).toBe(1);
|
|
486
|
+
// Persisted: onChange fired for open + markRepresented = 2 snapshots
|
|
487
|
+
expect(snapshots.length).toBe(2);
|
|
488
|
+
expect(snapshots[1][0].lastRepresentedAt).toBe(9000);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("representGraceMs=0 (kill switch) → no per-represent grace, acts immediately", () => {
|
|
492
|
+
const L = new ObligationLedger(2);
|
|
493
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
494
|
+
L.markRepresented("c:3#1", 5000);
|
|
495
|
+
// Kill switch: representGraceMs=0 → the freshly-represented obligation is still eligible.
|
|
496
|
+
expect(
|
|
497
|
+
L.decideAtIdle({ now: 5001, graceMs: 45000, representGraceMs: 0 }).action,
|
|
498
|
+
).toBe("represent");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("an obligation that was NEVER re-presented has no per-represent grace (no lastRepresentedAt)", () => {
|
|
502
|
+
const L = new ObligationLedger(2);
|
|
503
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
504
|
+
// No markRepresented call → no lastRepresentedAt → always eligible on this axis.
|
|
505
|
+
expect(
|
|
506
|
+
L.decideAtIdle({ now: 2000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
507
|
+
).toBe("represent");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("per-represent grace survives hydration (durable snapshot)", () => {
|
|
511
|
+
const L = new ObligationLedger(2);
|
|
512
|
+
const now = 10_000;
|
|
513
|
+
L.hydrate([
|
|
514
|
+
{
|
|
515
|
+
originTurnId: "c:3#1",
|
|
516
|
+
chatId: "-100123",
|
|
517
|
+
threadId: 3,
|
|
518
|
+
messageId: 1,
|
|
519
|
+
text: "x",
|
|
520
|
+
openedAt: 1000,
|
|
521
|
+
representCount: 1,
|
|
522
|
+
lastRepresentedAt: now - 5000, // represented 5s ago
|
|
523
|
+
},
|
|
524
|
+
]);
|
|
525
|
+
// 5s after re-present, grace=120s → still within grace → none.
|
|
526
|
+
expect(
|
|
527
|
+
L.decideAtIdle({ now, graceMs: 0, representGraceMs: REPR_GRACE }).action,
|
|
528
|
+
).toBe("none");
|
|
529
|
+
// 120s + 1ms after re-present → grace expired → escalate (count=1 < max=2 → represent).
|
|
530
|
+
expect(
|
|
531
|
+
L.decideAtIdle({ now: now - 5000 + REPR_GRACE + 1, graceMs: 0, representGraceMs: REPR_GRACE }).action,
|
|
532
|
+
).toBe("represent");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("per-represent grace composes with trailing-answer grace: both must clear", () => {
|
|
536
|
+
const L = new ObligationLedger(2);
|
|
537
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
538
|
+
L.noteTurnEnded("c:3#1", 5000);
|
|
539
|
+
L.markRepresented("c:3#1", 5000);
|
|
540
|
+
// Both graces active; trailing: turn ended 5s ago (grace=45s → in grace);
|
|
541
|
+
// per-represent: re-presented 5s ago (grace=120s → in grace) → none.
|
|
542
|
+
expect(
|
|
543
|
+
L.decideAtIdle({ now: 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
544
|
+
).toBe("none");
|
|
545
|
+
// Trailing grace cleared (50s after turn-end), per-represent NOT yet (only 45s after re-present).
|
|
546
|
+
expect(
|
|
547
|
+
L.decideAtIdle({ now: 5000 + 50000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
548
|
+
).toBe("none");
|
|
549
|
+
// Both cleared (125s after re-present at t=5000 → t=130000).
|
|
550
|
+
expect(
|
|
551
|
+
L.decideAtIdle({ now: 5000 + REPR_GRACE + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
552
|
+
).toBe("represent");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("the clerk incident: two messages arrive, re-present fires at T; the sweep ticks again at T+5s → grace prevents premature escalation", () => {
|
|
556
|
+
// This is the exact sequence from clerk 2026-06-13: obligation re-presented
|
|
557
|
+
// at T, sweep fires at T+5s before the re-present turn has even landed, would
|
|
558
|
+
// immediately fire AGAIN (burning representCount to 2 → escalate in 5 more
|
|
559
|
+
// seconds). With per-represent grace, the T+5s tick is a no-op.
|
|
560
|
+
const L = new ObligationLedger(2);
|
|
561
|
+
const T = 1_000_000;
|
|
562
|
+
L.openIfAbsent(input("c:0#14057", T));
|
|
563
|
+
// First represent at T
|
|
564
|
+
L.markRepresented("c:0#14057", T);
|
|
565
|
+
expect(L.list()[0].representCount).toBe(1);
|
|
566
|
+
// Sweep at T+5s: MUST be "none" (within per-represent grace)
|
|
567
|
+
expect(
|
|
568
|
+
L.decideAtIdle({ now: T + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
569
|
+
).toBe("none");
|
|
570
|
+
// Sweep at T+10s: still within grace
|
|
571
|
+
expect(
|
|
572
|
+
L.decideAtIdle({ now: T + 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
573
|
+
).toBe("none");
|
|
574
|
+
// Sweep after grace: eligible for second represent (not escalate — count=1 < max=2)
|
|
575
|
+
expect(
|
|
576
|
+
L.decideAtIdle({ now: T + REPR_GRACE + 1000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
577
|
+
).toBe("represent");
|
|
578
|
+
expect(L.list()[0].representCount).toBe(1); // still 1 — decideAtIdle is pure
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe("ObligationLedger — escalation suppression predicate (Fix 4)", () => {
|
|
583
|
+
// Fix 4 is implemented in the gateway (obligationSweep checks
|
|
584
|
+
// hasOutboundDeliveredSince before calling driveEscalation). We test the
|
|
585
|
+
// predicate seam via the pattern the sweep uses: if an outbound was delivered
|
|
586
|
+
// since openedAt, the obligation should be closed silently, not escalated.
|
|
587
|
+
// This suite tests the ledger behaviour that enables that path: after a
|
|
588
|
+
// silent close the ledger is empty and the next sweep sees 'none'.
|
|
589
|
+
function input(id: string, openedAt: number) {
|
|
590
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
it("a silently-closed obligation (gateway closed on outbound-delivered check) leaves ledger empty", () => {
|
|
594
|
+
const L = new ObligationLedger(2);
|
|
595
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
596
|
+
// Gateway's Fix 4 path: outbound delivered since openedAt → close silently
|
|
597
|
+
expect(L.close("c:3#1")).toBe(true);
|
|
598
|
+
expect(L.hasOpen()).toBe(false);
|
|
599
|
+
expect(L.decideAtIdle().action).toBe("none");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("an escalation that reaches maxRepresents WITHOUT any known outbound still proceeds (escalate action)", () => {
|
|
603
|
+
const L = new ObligationLedger(2);
|
|
604
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
605
|
+
L.markRepresented("c:3#1");
|
|
606
|
+
L.markRepresented("c:3#1");
|
|
607
|
+
// hasOutboundDeliveredSince returns false (no outbound recorded) → escalate
|
|
608
|
+
const d = L.decideAtIdle({ now: 9_999_999, graceMs: 0, representGraceMs: 0 });
|
|
609
|
+
expect(d.action).toBe("escalate");
|
|
610
|
+
expect(d.obligation?.originTurnId).toBe("c:3#1");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("silent close fires onChange (persists the empty set)", () => {
|
|
614
|
+
const snapshots: Obligation[][] = [];
|
|
615
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
616
|
+
L.openIfAbsent(input("c:3#1", 1000)); // snapshot[0]
|
|
617
|
+
L.close("c:3#1"); // snapshot[1] = []
|
|
618
|
+
expect(snapshots.length).toBe(2);
|
|
619
|
+
expect(snapshots[1]).toEqual([]);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
@@ -101,6 +101,23 @@ describe("obligation-store", () => {
|
|
|
101
101
|
expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:5#900"]);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it("round-trips lastRepresentedAt through parse → isObligationRow filter → hydrate", () => {
|
|
105
|
+
// Regression: isObligationRow only checks required fields; optional fields
|
|
106
|
+
// (lastRepresentedAt, lastTurnEndedAt, escalateAttempts) must survive the
|
|
107
|
+
// filter without being stripped. A missing check would silently drop the field
|
|
108
|
+
// and break the per-represent grace window across restarts.
|
|
109
|
+
const { fs } = memFs();
|
|
110
|
+
const snap: Obligation[] = [
|
|
111
|
+
ob("c:3#715", { representCount: 1, lastRepresentedAt: 1_700_000_000_000, lastTurnEndedAt: 1_700_000_001_000 }),
|
|
112
|
+
];
|
|
113
|
+
persistObligations(PATH, fs, snap);
|
|
114
|
+
const loaded = loadObligations(PATH, fs);
|
|
115
|
+
expect(loaded).toHaveLength(1);
|
|
116
|
+
expect(loaded[0]!.lastRepresentedAt).toBe(1_700_000_000_000);
|
|
117
|
+
expect(loaded[0]!.lastTurnEndedAt).toBe(1_700_000_001_000);
|
|
118
|
+
expect(loaded[0]!.representCount).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
104
121
|
it("never throws on a write failure — degrades to in-memory (logs)", () => {
|
|
105
122
|
const logs: string[] = [];
|
|
106
123
|
const fs: ObligationStoreFsSeam = {
|