switchroom 0.14.58 → 0.14.60
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 +1 -1
- package/dist/auth-broker/index.js +19 -9
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +29 -70
- package/dist/host-control/main.js +1 -1
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +216 -12
- package/telegram-plugin/gateway/gateway.ts +216 -2
- package/telegram-plugin/gateway/obligation-ledger.ts +216 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +167 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +44 -0
- package/telegram-plugin/tool-activity-summary.ts +14 -4
|
@@ -278,6 +278,11 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
278
278
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
279
279
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
280
280
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
281
|
+
import {
|
|
282
|
+
ObligationLedger,
|
|
283
|
+
buildObligationRepresentInbound,
|
|
284
|
+
obligationEscalationText,
|
|
285
|
+
} from './obligation-ledger.js'
|
|
281
286
|
import { createInboundSpool } from './inbound-spool.js'
|
|
282
287
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
283
288
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
@@ -1380,6 +1385,20 @@ const DELIVERY_CONFIRM_TIMEOUT_MS =
|
|
|
1380
1385
|
const DELIVERY_CONFIRM_SWEEP_MS = 5_000
|
|
1381
1386
|
const deliveryQueue = createDeliveryQueue<InboundMessage>()
|
|
1382
1387
|
|
|
1388
|
+
// ─── Deterministic delivery-obligation ledger (systems-analysis PR2) ──────────
|
|
1389
|
+
// An inbound is an OBLIGATION (keyed origin_turn_id) that is OPEN at receipt and
|
|
1390
|
+
// CLOSED only by an observable substantive reply resolving to that origin —
|
|
1391
|
+
// never the model's words. An open obligation that survives a turn boundary is
|
|
1392
|
+
// re-presented (bounded) until it closes, so a message the model read but never
|
|
1393
|
+
// answered (the marko 715 drop) cannot be silently lost. ADDITIVE + flagged: it
|
|
1394
|
+
// runs ALONGSIDE the existing acks/spool/buffer (PR3 retires the redundant
|
|
1395
|
+
// pieces). Default OFF — the canary turns it on (713/715 interleave UAT) before
|
|
1396
|
+
// any fleet activation. When off, every hook below is a no-op → zero change.
|
|
1397
|
+
const OBLIGATION_LEDGER_ENABLED = process.env.SWITCHROOM_OBLIGATION_LEDGER === '1'
|
|
1398
|
+
const OBLIGATION_REPRESENT_MAX = 2
|
|
1399
|
+
const OBLIGATION_SWEEP_MS = 5_000
|
|
1400
|
+
const obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX)
|
|
1401
|
+
|
|
1383
1402
|
// ─── Serialize-until-replied (multitopic reply-routing) ───────────────────
|
|
1384
1403
|
// Component 1 (deliver-before-drain gate). A buffered cross-topic inbound
|
|
1385
1404
|
// drains ONLY after the just-ended turn delivered its reply to its own
|
|
@@ -1431,6 +1450,25 @@ const QUEUED_STATUS_UX_ENABLED =
|
|
|
1431
1450
|
const FEED_REOPEN_AFTER_ACK_ENABLED =
|
|
1432
1451
|
process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== '0'
|
|
1433
1452
|
|
|
1453
|
+
// Activity-feed heartbeat (PR1). The feed is pull-only — it only re-renders on
|
|
1454
|
+
// a tool_label event, so a long single step that emits no new label leaves the
|
|
1455
|
+
// feed frozen on "→ doing X" for tens of seconds (the marko ~26s freeze). The
|
|
1456
|
+
// heartbeat re-renders the live feed every FEED_HEARTBEAT_TICK_MS with a
|
|
1457
|
+
// climbing " · Ns" elapsed on the in-progress line, but only once the current
|
|
1458
|
+
// step has run >= FEED_HEARTBEAT_MIN_STALE_MS (so a normally-advancing feed is
|
|
1459
|
+
// untouched). Kill switch: SWITCHROOM_FEED_HEARTBEAT=0. Default on.
|
|
1460
|
+
const FEED_HEARTBEAT_ENABLED = process.env.SWITCHROOM_FEED_HEARTBEAT !== '0'
|
|
1461
|
+
const FEED_HEARTBEAT_TICK_MS = 6_000
|
|
1462
|
+
const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
|
|
1463
|
+
|
|
1464
|
+
/** Compact mm/ss-ish elapsed for the live feed suffix: "18s", "1m05s". */
|
|
1465
|
+
function formatFeedElapsed(ms: number): string {
|
|
1466
|
+
const s = Math.floor(ms / 1000)
|
|
1467
|
+
if (s < 60) return `${s}s`
|
|
1468
|
+
const m = Math.floor(s / 60)
|
|
1469
|
+
return `${m}m${(s % 60).toString().padStart(2, '0')}s`
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1434
1472
|
/**
|
|
1435
1473
|
* Authoritative "is a turn in flight?" for every gate that previously
|
|
1436
1474
|
* read `claudeBusyKeys.size`. PR 3b cutover (extends PR 3a's bridgeUp
|
|
@@ -1653,6 +1691,12 @@ type CurrentTurn = {
|
|
|
1653
1691
|
activityInFlight: Promise<void> | null
|
|
1654
1692
|
activityPendingRender: string | null
|
|
1655
1693
|
activityLastSentRender: string | null
|
|
1694
|
+
// Wall-clock anchor for the newest in-progress feed step — set each time a
|
|
1695
|
+
// tool_label re-renders the feed. The heartbeat (`feedHeartbeatTick`) reads
|
|
1696
|
+
// it to show a climbing " · Ns" elapsed on the live line so a long single
|
|
1697
|
+
// step that emits no new label doesn't read as frozen (the feed is otherwise
|
|
1698
|
+
// pull-only). undefined until the first label of the turn renders.
|
|
1699
|
+
lastToolLabelAt?: number
|
|
1656
1700
|
// Accumulating friendly-action feed for this turn. Each non-surface
|
|
1657
1701
|
// tool_label appends a line via `appendActivityLabel`; the feed renders
|
|
1658
1702
|
// (via `renderActivityFeed`) as a capped chronological list into the
|
|
@@ -1727,6 +1771,69 @@ function findTurnByOriginId(originTurnId: string | null | undefined): CurrentTur
|
|
|
1727
1771
|
return recentTurnsById.get(originTurnId) ?? null
|
|
1728
1772
|
}
|
|
1729
1773
|
|
|
1774
|
+
/**
|
|
1775
|
+
* PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
|
|
1776
|
+
* (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
|
|
1777
|
+
* obligation discharged is the one for the SAME origin the answer routes to
|
|
1778
|
+
* (origin_turn_id the model echoed, else the live turn). So 713's reply closes
|
|
1779
|
+
* 713's obligation even after currentTurn flipped to 715, and 715 stays open
|
|
1780
|
+
* until ITS own substantive answer. An ack does NOT close (so ack-then-ghost is
|
|
1781
|
+
* re-presented, not re-dropped). turn.turnId === the obligation's origin id
|
|
1782
|
+
* (both deriveTurnId(chat,thread,messageId) of the same inbound). No-op unless
|
|
1783
|
+
* the flag is on. NOTE residual: a genuinely SHORT answer (<200 chars, not a
|
|
1784
|
+
* stream-done) reads as non-substantive and won't close → a bounded re-ask
|
|
1785
|
+
* (≤2) then one operator-visible nudge — the accepted double-ask tradeoff,
|
|
1786
|
+
* measured in the canary.
|
|
1787
|
+
*/
|
|
1788
|
+
function closeObligationOnSubstantiveReply(
|
|
1789
|
+
args: Record<string, unknown>,
|
|
1790
|
+
liveTurn: CurrentTurn | null | undefined,
|
|
1791
|
+
): void {
|
|
1792
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
1793
|
+
const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
1794
|
+
const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId)
|
|
1795
|
+
if (target != null) obligationLedger.close(target)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* PR2 obligation-ledger OPEN. Track a fresh user inbound as an unanswered
|
|
1800
|
+
* obligation the moment it is received — called BEFORE the buffer-until-idle /
|
|
1801
|
+
* deliver split so a mid-turn cross-topic inbound (the 715 case: buffered while
|
|
1802
|
+
* another turn runs) is tracked too. Drain paths re-deliver buffered inbounds
|
|
1803
|
+
* via sendToAgent (NOT through handleInbound), so handleInbound is the ONLY
|
|
1804
|
+
* point that sees every fresh inbound — opening here is mandatory for both
|
|
1805
|
+
* branches. Same gate as delivery-tracking (real user turns only; synthetic /
|
|
1806
|
+
* steering / `!` interrupt / empty excluded — they have no origin id / need no
|
|
1807
|
+
* answer; deriveTurnId null-guards them). Idempotent → opening at buffer-time
|
|
1808
|
+
* and any later delivery is safe. No-op unless the flag is on.
|
|
1809
|
+
*/
|
|
1810
|
+
function openObligationFromInbound(
|
|
1811
|
+
inboundMsg: InboundMessage,
|
|
1812
|
+
gate: { isSteering: boolean; isInterrupt: boolean; effectiveText: string },
|
|
1813
|
+
): void {
|
|
1814
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
1815
|
+
if (
|
|
1816
|
+
!shouldTrackDelivery({
|
|
1817
|
+
isSteering: gate.isSteering,
|
|
1818
|
+
isInterrupt: gate.isInterrupt,
|
|
1819
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
1820
|
+
effectiveText: gate.effectiveText,
|
|
1821
|
+
})
|
|
1822
|
+
) {
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId)
|
|
1826
|
+
if (oid == null) return
|
|
1827
|
+
obligationLedger.openIfAbsent({
|
|
1828
|
+
originTurnId: oid,
|
|
1829
|
+
chatId: inboundMsg.chatId,
|
|
1830
|
+
threadId: inboundMsg.threadId,
|
|
1831
|
+
messageId: inboundMsg.messageId,
|
|
1832
|
+
text: inboundMsg.text ?? '',
|
|
1833
|
+
openedAt: Date.now(),
|
|
1834
|
+
})
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1730
1837
|
/**
|
|
1731
1838
|
* Component 5 — post a "Queued — replying in another topic first" status
|
|
1732
1839
|
* into a cross-topic buffered message's OWN topic. Fire-and-forget through
|
|
@@ -2247,6 +2354,21 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
|
|
|
2247
2354
|
function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
2248
2355
|
if (currentTurn !== turn) return
|
|
2249
2356
|
currentTurn = null
|
|
2357
|
+
// PR2 obligation-ledger CLOSE-at-turn-end. Close the ended turn's obligation
|
|
2358
|
+
// when it delivered a final answer. finalAnswerDelivered is the right signal
|
|
2359
|
+
// HERE (not isSubstantiveFinalReply at reply-time): a SHORT genuine answer
|
|
2360
|
+
// ("4") is final-but-not-substantive, so the reply-time substantive-close
|
|
2361
|
+
// missed it → it looked unanswered → the idle sweep double-asked every short
|
|
2362
|
+
// turn (canary, v0.14.59). At turn_end the #2141 logic has already demoted a
|
|
2363
|
+
// bare interim ack to non-final, so finalAnswerDelivered===true means GENUINELY
|
|
2364
|
+
// answered. This runs before the next idle sweep, so a short answer closes
|
|
2365
|
+
// cleanly (no double-ask); an ack-then-ghost / no-reply turn ends with
|
|
2366
|
+
// finalAnswerDelivered===false → stays open → re-presented (the intended
|
|
2367
|
+
// catch). close() is a no-op for synthetic turns (turnId not in the ledger).
|
|
2368
|
+
// No-op when the flag is off.
|
|
2369
|
+
if (OBLIGATION_LEDGER_ENABLED && turn.finalAnswerDelivered) {
|
|
2370
|
+
obligationLedger.close(turn.turnId)
|
|
2371
|
+
}
|
|
2250
2372
|
// Component 2 — clear any prior no-reply drain timer for this turn; a
|
|
2251
2373
|
// fresh end re-evaluates below. (Idempotent — null when never armed.)
|
|
2252
2374
|
if (turn.noReplyDrainTimer != null) {
|
|
@@ -4700,6 +4822,51 @@ const inboundSpool = STATIC
|
|
|
4700
4822
|
},
|
|
4701
4823
|
})
|
|
4702
4824
|
const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
|
|
4825
|
+
|
|
4826
|
+
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
4827
|
+
// CLEAN idle: no turn in flight AND the inbound buffer is empty — so the
|
|
4828
|
+
// existing buffer-drain has already had its turn and anything still OPEN is
|
|
4829
|
+
// "delivered but never answered" (the 715 gap). Re-present by pushing a
|
|
4830
|
+
// synthetic must-answer inbound through the SAME buffer→drain path (idempotent
|
|
4831
|
+
// OPEN via meta.source; reuses tested delivery). Bounded: after maxRepresents,
|
|
4832
|
+
// escalate to ONE operator-visible "did I miss this?" and close — no loop.
|
|
4833
|
+
// No-op unless the flag is on; gated on the same idle predicate as the drains.
|
|
4834
|
+
function obligationSweep(): void {
|
|
4835
|
+
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
4836
|
+
if (!obligationLedger.hasOpen()) return
|
|
4837
|
+
if (turnInFlightForGate()) return // a turn is running — let it finish/answer
|
|
4838
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4839
|
+
if (pendingInboundBuffer.depth(agent) > 0) return // existing drain runs first; avoids double-present
|
|
4840
|
+
const decision = obligationLedger.decideAtIdle()
|
|
4841
|
+
const o = decision.obligation
|
|
4842
|
+
if (decision.action === 'none' || o == null) return
|
|
4843
|
+
if (decision.action === 'represent') {
|
|
4844
|
+
pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
|
|
4845
|
+
const attempt = obligationLedger.markRepresented(o.originTurnId)
|
|
4846
|
+
process.stderr.write(
|
|
4847
|
+
`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}\n`,
|
|
4848
|
+
)
|
|
4849
|
+
return
|
|
4850
|
+
}
|
|
4851
|
+
// escalate — close FIRST so the loop ends even if the send fails.
|
|
4852
|
+
obligationLedger.close(o.originTurnId)
|
|
4853
|
+
process.stderr.write(
|
|
4854
|
+
`telegram gateway: obligation escalated (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${o.originTurnId}\n`,
|
|
4855
|
+
)
|
|
4856
|
+
void robustApiCall(
|
|
4857
|
+
() =>
|
|
4858
|
+
bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
|
|
4859
|
+
...(o.threadId != null ? { message_thread_id: o.threadId } : {}),
|
|
4860
|
+
}),
|
|
4861
|
+
{ chat_id: o.chatId, ...(o.threadId != null ? { threadId: o.threadId } : {}), verb: 'obligation.escalate' },
|
|
4862
|
+
).catch((err) => {
|
|
4863
|
+
process.stderr.write(`telegram gateway: obligation escalation send failed: ${err}\n`)
|
|
4864
|
+
})
|
|
4865
|
+
}
|
|
4866
|
+
if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
|
|
4867
|
+
setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref()
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4703
4870
|
// Honest-restart-resume: inject the boot resume/report inbound built by the
|
|
4704
4871
|
// registry classifier above. When the spool exists we only PUT it (the
|
|
4705
4872
|
// boot-replay loop below pulls it into the in-memory buffer exactly once via
|
|
@@ -6336,6 +6503,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6336
6503
|
text: decision.mergedText,
|
|
6337
6504
|
disableNotification,
|
|
6338
6505
|
})
|
|
6506
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
6339
6507
|
}
|
|
6340
6508
|
outboundDedup.record(
|
|
6341
6509
|
chat_id,
|
|
@@ -6686,6 +6854,9 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
6686
6854
|
// the `turn_end` handler when it lands; this only fires the
|
|
6687
6855
|
// observable side effects that #1718 deferred unconditionally.
|
|
6688
6856
|
finalizeStatusReaction(chat_id, threadId, 'done')
|
|
6857
|
+
// PR2: close this origin's obligation on a SUBSTANTIVE final answer
|
|
6858
|
+
// (after finalize so the reaction guard test's anchor window is stable).
|
|
6859
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
6689
6860
|
}
|
|
6690
6861
|
// v0.13.30 follow-up — release the buffer gate on EVERY reply
|
|
6691
6862
|
// finalize, not just on `isFinalAnswerReply`. The narrow
|
|
@@ -7030,6 +7201,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
7030
7201
|
disableNotification: args.disable_notification === true,
|
|
7031
7202
|
done: args.done === true,
|
|
7032
7203
|
})
|
|
7204
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn)
|
|
7033
7205
|
// #1744 follow-up — stream_reply edge case. The first-emit gate at
|
|
7034
7206
|
// L5178 only clears silent-end state on the FIRST emit of a stream.
|
|
7035
7207
|
// If a stream's first emit was ack-shaped (disable_notification:true,
|
|
@@ -8417,12 +8589,12 @@ const FOREGROUND_SUBAGENT_ACCUM_MAX = 12
|
|
|
8417
8589
|
* order; the single-sub-agent common case nests precisely under its
|
|
8418
8590
|
* Delegating line.
|
|
8419
8591
|
*/
|
|
8420
|
-
function composeTurnActivity(turn: CurrentTurn, final = false): string | null {
|
|
8592
|
+
function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''): string | null {
|
|
8421
8593
|
const childLines: string[] = []
|
|
8422
8594
|
for (const narrative of turn.foregroundSubAgents.values()) {
|
|
8423
8595
|
childLines.push(...narrative)
|
|
8424
8596
|
}
|
|
8425
|
-
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final)
|
|
8597
|
+
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix)
|
|
8426
8598
|
}
|
|
8427
8599
|
|
|
8428
8600
|
/**
|
|
@@ -8495,6 +8667,35 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
|
|
|
8495
8667
|
}
|
|
8496
8668
|
}
|
|
8497
8669
|
|
|
8670
|
+
/**
|
|
8671
|
+
* Heartbeat tick (PR1): keep the live activity feed visibly advancing during a
|
|
8672
|
+
* long single step that emits no new tool_label. Re-renders the feed with a
|
|
8673
|
+
* climbing " · Ns" elapsed on the in-progress line through the SAME single-flight
|
|
8674
|
+
* drain path the tool_label handler uses (no separate transport, no race). Pure
|
|
8675
|
+
* no-op unless there is a live in-flight feed whose newest step has gone stale.
|
|
8676
|
+
* Skips once the final answer landed (the feed is handing off) and after the
|
|
8677
|
+
* turn ends (activityMessageId nulled by clearActivitySummary). Deterministic +
|
|
8678
|
+
* framework-owned — never depends on the model.
|
|
8679
|
+
*/
|
|
8680
|
+
function feedHeartbeatTick(): void {
|
|
8681
|
+
const turn = currentTurn
|
|
8682
|
+
if (turn == null) return
|
|
8683
|
+
if (turn.activityMessageId == null) return // no live feed yet / already cleared
|
|
8684
|
+
if (turn.finalAnswerDelivered) return // feed handed off to the answer
|
|
8685
|
+
if (turn.lastToolLabelAt == null) return // feed not driven by a labelled step
|
|
8686
|
+
const elapsed = Date.now() - turn.lastToolLabelAt
|
|
8687
|
+
if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS) return // step is fresh; feed advancing normally
|
|
8688
|
+
const rendered = composeTurnActivity(turn, false, ` · ${formatFeedElapsed(elapsed)}`)
|
|
8689
|
+
if (rendered == null) return
|
|
8690
|
+
turn.activityPendingRender = rendered
|
|
8691
|
+
if (turn.activityInFlight == null) {
|
|
8692
|
+
turn.activityInFlight = drainActivitySummary(turn)
|
|
8693
|
+
}
|
|
8694
|
+
}
|
|
8695
|
+
if (!STATIC && FEED_HEARTBEAT_ENABLED) {
|
|
8696
|
+
setInterval(feedHeartbeatTick, FEED_HEARTBEAT_TICK_MS).unref()
|
|
8697
|
+
}
|
|
8698
|
+
|
|
8498
8699
|
/**
|
|
8499
8700
|
* Reconcile the activity summary when the model's reply tool takes over as the
|
|
8500
8701
|
* authoritative surface. Awaits any in-flight render so we don't race a stale
|
|
@@ -8887,6 +9088,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
8887
9088
|
}
|
|
8888
9089
|
const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
|
|
8889
9090
|
if (rendered != null) {
|
|
9091
|
+
// A new tool label = a new live step → re-anchor the heartbeat clock so
|
|
9092
|
+
// the " · Ns" elapsed restarts from this step (and the feed itself just
|
|
9093
|
+
// advanced, so it isn't stale).
|
|
9094
|
+
turn.lastToolLabelAt = Date.now()
|
|
8890
9095
|
// Recompose so any active foreground sub-agent's nested block (Model A)
|
|
8891
9096
|
// is preserved when the parent appends its own step. composeTurnActivity
|
|
8892
9097
|
// == the flat render when no foreground sub-agent is active.
|
|
@@ -11437,6 +11642,15 @@ async function handleInbound(
|
|
|
11437
11642
|
return
|
|
11438
11643
|
}
|
|
11439
11644
|
|
|
11645
|
+
// PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
|
|
11646
|
+
// a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
|
|
11647
|
+
// buffered or delivered now. Idempotent + gated; no-op when the flag is off.
|
|
11648
|
+
openObligationFromInbound(inboundMsg, {
|
|
11649
|
+
isSteering,
|
|
11650
|
+
isInterrupt: interrupt.isInterrupt,
|
|
11651
|
+
effectiveText,
|
|
11652
|
+
})
|
|
11653
|
+
|
|
11440
11654
|
if (
|
|
11441
11655
|
decideInboundDelivery({
|
|
11442
11656
|
turnInFlight: turnInFlightAtReceipt,
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic delivery-obligation ledger (systems-analysis PR2).
|
|
3
|
+
*
|
|
4
|
+
* The framework today tracks whether an inbound was DELIVERED (read by claude),
|
|
5
|
+
* never whether it was ANSWERED — so a message claude reads but never replies to
|
|
6
|
+
* (the marko msg-715 verbal-deferral drop) is silently lost. This ledger adds
|
|
7
|
+
* the one missing invariant, and it is model-INDEPENDENT by construction:
|
|
8
|
+
*
|
|
9
|
+
* An inbound is an OBLIGATION keyed by its origin_turn_id. It is OPEN the
|
|
10
|
+
* moment the message is received, and CLOSED only by an observable framework
|
|
11
|
+
* event — a reply-tool call whose resolved target equals that origin_turn_id
|
|
12
|
+
* AND that carries a substantive answer (not a bare interim ack). The engine
|
|
13
|
+
* may go idle only when no obligation is OPEN; an OPEN obligation that
|
|
14
|
+
* survives a turn boundary is re-presented as a fresh must-answer turn until
|
|
15
|
+
* it closes, bounded so a mis-close degrades to ONE operator-visible nudge
|
|
16
|
+
* rather than an infinite re-ask loop.
|
|
17
|
+
*
|
|
18
|
+
* This file is PURE state + decisions — no Telegram, no claude, no timers. The
|
|
19
|
+
* gateway owns OPEN/CLOSE/re-present I/O and calls in here. Pure ⇒ unit-testable
|
|
20
|
+
* (see tests/obligation-ledger.test.ts), the seam the analysis demanded.
|
|
21
|
+
*
|
|
22
|
+
* The close event (substantive reply resolving to origin) is observed by the
|
|
23
|
+
* framework, never the model's narration/promise — that is the whole point: the
|
|
24
|
+
* 715 "I'll handle thread 3 as its own turn" does NOT close the obligation.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
28
|
+
|
|
29
|
+
export interface Obligation {
|
|
30
|
+
/** deriveTurnId(chat, thread, messageId) — the stable identity. */
|
|
31
|
+
readonly originTurnId: string
|
|
32
|
+
readonly chatId: string
|
|
33
|
+
readonly threadId?: number
|
|
34
|
+
readonly messageId: number
|
|
35
|
+
/** Original inbound text (may be truncated by the caller for re-presentation). */
|
|
36
|
+
readonly text: string
|
|
37
|
+
/** Wall-clock ms the obligation was first opened. */
|
|
38
|
+
readonly openedAt: number
|
|
39
|
+
/** How many times it has been re-presented (0 on first open). */
|
|
40
|
+
representCount: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What the gateway should do for the oldest open obligation at an idle boundary. */
|
|
44
|
+
export type LedgerAction = 'none' | 'represent' | 'escalate'
|
|
45
|
+
|
|
46
|
+
export interface LedgerDecision {
|
|
47
|
+
action: LedgerAction
|
|
48
|
+
obligation?: Obligation
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ObligationInput {
|
|
52
|
+
originTurnId: string
|
|
53
|
+
chatId: string
|
|
54
|
+
threadId?: number
|
|
55
|
+
messageId: number
|
|
56
|
+
text: string
|
|
57
|
+
openedAt: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ObligationLedger {
|
|
61
|
+
private readonly open = new Map<string, Obligation>()
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param maxRepresents max re-presentations before escalating to an
|
|
65
|
+
* operator-visible nudge instead of re-asking again. Default 2.
|
|
66
|
+
*/
|
|
67
|
+
constructor(private readonly maxRepresents = 2) {}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Open an obligation if not already tracked. Idempotent on originTurnId — a
|
|
71
|
+
* message that is buffered AND later enqueued opens once, keeping the first
|
|
72
|
+
* (earliest openedAt + accumulated representCount). Returns true if newly
|
|
73
|
+
* opened.
|
|
74
|
+
*/
|
|
75
|
+
openIfAbsent(input: ObligationInput): boolean {
|
|
76
|
+
if (this.open.has(input.originTurnId)) return false
|
|
77
|
+
this.open.set(input.originTurnId, { ...input, representCount: 0 })
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Close by origin id. Returns true if an obligation was open and is now closed. */
|
|
82
|
+
close(originTurnId: string | null | undefined): boolean {
|
|
83
|
+
if (originTurnId == null) return false
|
|
84
|
+
return this.open.delete(originTurnId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
isOpen(originTurnId: string): boolean {
|
|
88
|
+
return this.open.has(originTurnId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
hasOpen(): boolean {
|
|
92
|
+
return this.open.size > 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
size(): number {
|
|
96
|
+
return this.open.size
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Snapshot of open obligations, oldest first. For introspection/tests. */
|
|
100
|
+
list(): Obligation[] {
|
|
101
|
+
return [...this.open.values()].sort((a, b) => a.openedAt - b.openedAt)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The oldest open obligation, or undefined. */
|
|
105
|
+
private oldest(): Obligation | undefined {
|
|
106
|
+
let best: Obligation | undefined
|
|
107
|
+
for (const o of this.open.values()) {
|
|
108
|
+
if (best === undefined || o.openedAt < best.openedAt) best = o
|
|
109
|
+
}
|
|
110
|
+
return best
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decide what to do at an idle boundary (caller guarantees: no turn in flight
|
|
115
|
+
* AND the inbound buffer is empty — so the existing buffer-drain has already
|
|
116
|
+
* had its turn and anything still OPEN is "delivered but unanswered"). PURE —
|
|
117
|
+
* does not mutate. The caller performs the side effect then calls
|
|
118
|
+
* markRepresented / close accordingly.
|
|
119
|
+
*
|
|
120
|
+
* - 'none' → no open obligation; the agent may idle.
|
|
121
|
+
* - 'represent' → re-present `obligation` as a fresh must-answer turn.
|
|
122
|
+
* - 'escalate' → it has already been re-presented maxRepresents times; send
|
|
123
|
+
* ONE operator-visible "did I miss this?" and close it
|
|
124
|
+
* (caller calls close) rather than loop forever.
|
|
125
|
+
*/
|
|
126
|
+
decideAtIdle(): LedgerDecision {
|
|
127
|
+
const o = this.oldest()
|
|
128
|
+
if (o === undefined) return { action: 'none' }
|
|
129
|
+
if (o.representCount >= this.maxRepresents) return { action: 'escalate', obligation: o }
|
|
130
|
+
return { action: 'represent', obligation: o }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Decide which obligation a substantive reply discharges — DETERMINISTICALLY,
|
|
135
|
+
* holding for any model behavior:
|
|
136
|
+
* - `echoedTurnId` (the model echoed origin_turn_id back) → authoritative;
|
|
137
|
+
* close exactly that (a no-op via close() if it isn't actually open).
|
|
138
|
+
* - else, close the live turn's obligation ONLY when UNAMBIGUOUS — exactly
|
|
139
|
+
* one obligation open. With >1 open and no echo we cannot tell which one
|
|
140
|
+
* the reply answered; closing the live turn's would silently drop the other
|
|
141
|
+
* (713's un-echoed reply landing while currentTurn=715 must NOT close 715).
|
|
142
|
+
* So we close nothing → the real target stays open and is re-presented (a
|
|
143
|
+
* bounded double-ask), never wrong-closed. Returns the id to close, or null.
|
|
144
|
+
*/
|
|
145
|
+
resolveCloseTarget(
|
|
146
|
+
echoedTurnId: string | null | undefined,
|
|
147
|
+
liveTurnId: string | null | undefined,
|
|
148
|
+
): string | null {
|
|
149
|
+
if (echoedTurnId != null) return echoedTurnId
|
|
150
|
+
if (liveTurnId != null && this.open.size === 1 && this.open.has(liveTurnId)) return liveTurnId
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Record that an obligation was just re-presented (bumps representCount). */
|
|
155
|
+
markRepresented(originTurnId: string): number {
|
|
156
|
+
const o = this.open.get(originTurnId)
|
|
157
|
+
if (o === undefined) return 0
|
|
158
|
+
o.representCount += 1
|
|
159
|
+
return o.representCount
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Original message preview length for re-presentation (mirrors resume builder). */
|
|
164
|
+
const REPRESENT_PREVIEW_MAX = 200
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the synthetic inbound that RE-PRESENTS an open obligation as a fresh
|
|
168
|
+
* must-answer turn. Carries the obligation's original message_id (so the
|
|
169
|
+
* reply-quote and origin routing land in the right place) and origin_turn_id in
|
|
170
|
+
* meta (so the model's reply resolves back to THIS obligation → the close event
|
|
171
|
+
* matches). `source: obligation_represent` marks it synthetic, so it is NOT
|
|
172
|
+
* delivery-tracked and does NOT open a fresh obligation (the original stays
|
|
173
|
+
* open until a substantive reply closes it). Pure — the gateway injects it via
|
|
174
|
+
* the existing buffer→drain path. Context restoration (inject vs pointer) is a
|
|
175
|
+
* separate layer; here we point at get_recent_messages and quote the original.
|
|
176
|
+
*/
|
|
177
|
+
export function buildObligationRepresentInbound(o: Obligation, now: number): InboundMessage {
|
|
178
|
+
const preview =
|
|
179
|
+
o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + '…' : o.text
|
|
180
|
+
const topicClause = o.threadId != null ? ' in this topic' : ''
|
|
181
|
+
return {
|
|
182
|
+
type: 'inbound',
|
|
183
|
+
chatId: o.chatId,
|
|
184
|
+
...(o.threadId != null ? { threadId: o.threadId } : {}),
|
|
185
|
+
messageId: o.messageId,
|
|
186
|
+
user: 'switchroom',
|
|
187
|
+
userId: 0,
|
|
188
|
+
ts: now,
|
|
189
|
+
text:
|
|
190
|
+
`You have an earlier message${topicClause} that you started but never actually ` +
|
|
191
|
+
`answered (you may have set it aside mid-work): "${preview}". Answer it now via the ` +
|
|
192
|
+
`reply tool — deliver the real answer, don't just acknowledge it. If you've lost the ` +
|
|
193
|
+
`surrounding context, call get_recent_messages for this chat${topicClause} first. ` +
|
|
194
|
+
`That quoted text may be only the first ~200 characters of the original.`,
|
|
195
|
+
meta: {
|
|
196
|
+
source: 'obligation_represent',
|
|
197
|
+
origin_turn_id: o.originTurnId,
|
|
198
|
+
represent_count: String(o.representCount + 1),
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build the operator-visible escalation message text, used when an obligation
|
|
205
|
+
* has been re-presented maxRepresents times without closing — rather than loop
|
|
206
|
+
* forever (the new failure mode this trades silent-drop for), surface ONE
|
|
207
|
+
* honest "did I miss this?" and close it.
|
|
208
|
+
*/
|
|
209
|
+
export function obligationEscalationText(o: Obligation): string {
|
|
210
|
+
const preview =
|
|
211
|
+
o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + '…' : o.text
|
|
212
|
+
return (
|
|
213
|
+
`⚠️ I may have missed an earlier message and I'm not sure I answered it: ` +
|
|
214
|
+
`"${preview}". If you still need it, please re-send.`
|
|
215
|
+
)
|
|
216
|
+
}
|