switchroom 0.18.3 → 0.18.7
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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -17160,6 +17160,21 @@ function createToolLabelSidecar(opts) {
|
|
|
17160
17160
|
}
|
|
17161
17161
|
var init_tool_label_sidecar = () => {};
|
|
17162
17162
|
|
|
17163
|
+
// model-label.ts
|
|
17164
|
+
function isModelSentinel(model) {
|
|
17165
|
+
if (typeof model !== "string")
|
|
17166
|
+
return true;
|
|
17167
|
+
const m = model.trim();
|
|
17168
|
+
if (m.length === 0)
|
|
17169
|
+
return true;
|
|
17170
|
+
if (m.startsWith("<"))
|
|
17171
|
+
return true;
|
|
17172
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(m))
|
|
17173
|
+
return true;
|
|
17174
|
+
return false;
|
|
17175
|
+
}
|
|
17176
|
+
var init_model_label = () => {};
|
|
17177
|
+
|
|
17163
17178
|
// session-tail.ts
|
|
17164
17179
|
import {
|
|
17165
17180
|
closeSync,
|
|
@@ -17300,6 +17315,10 @@ function projectTranscriptLine(line) {
|
|
|
17300
17315
|
if (!Array.isArray(content))
|
|
17301
17316
|
return [];
|
|
17302
17317
|
const events = [];
|
|
17318
|
+
const mainModel = message?.model;
|
|
17319
|
+
if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
|
|
17320
|
+
events.push({ kind: "model", model: mainModel });
|
|
17321
|
+
}
|
|
17303
17322
|
const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({ kind: "text", text, blockIndex, lastInMessage }));
|
|
17304
17323
|
content.forEach((c, i) => {
|
|
17305
17324
|
const ct = c.type;
|
|
@@ -17402,6 +17421,10 @@ function projectSubagentLine(line, agentId, state) {
|
|
|
17402
17421
|
if (!Array.isArray(content))
|
|
17403
17422
|
return [];
|
|
17404
17423
|
const events = [];
|
|
17424
|
+
const subModel = message?.model;
|
|
17425
|
+
if (typeof subModel === "string" && !isModelSentinel(subModel)) {
|
|
17426
|
+
events.push({ kind: "sub_agent_model", agentId, model: subModel });
|
|
17427
|
+
}
|
|
17405
17428
|
const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({
|
|
17406
17429
|
kind: "sub_agent_text",
|
|
17407
17430
|
agentId,
|
|
@@ -17903,6 +17926,7 @@ var MAX_JSONL_LINE_BYTES, MAX_ERROR_TEXT_CHARS = 500;
|
|
|
17903
17926
|
var init_session_tail = __esm(() => {
|
|
17904
17927
|
init_operator_events();
|
|
17905
17928
|
init_tool_label_sidecar();
|
|
17929
|
+
init_model_label();
|
|
17906
17930
|
MAX_JSONL_LINE_BYTES = 2 * 1024 * 1024;
|
|
17907
17931
|
});
|
|
17908
17932
|
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable retry queue for the "🔁 Always allow" durable-persist flow
|
|
3
|
+
* (#1977, hardened in #2973).
|
|
4
|
+
*
|
|
5
|
+
* Problem (#2973): a `config_propose_edit` dispatch for an always-allow
|
|
6
|
+
* rule can fail for RETRYABLE reasons (a transient hostd error, a stale
|
|
7
|
+
* container-side config view producing `E_PATCH_APPLY_FAILED`, a rate
|
|
8
|
+
* limit with a structured `retry_after`) — today that failure is simply
|
|
9
|
+
* reported to the operator ("did NOT save") and the tap is lost; the rule
|
|
10
|
+
* only lands if the operator notices and re-taps after the next restart.
|
|
11
|
+
*
|
|
12
|
+
* Fix: on a retryable dispatch failure, enqueue `{agent, rule, ...}` here
|
|
13
|
+
* BEFORE giving up. The gateway drains this queue at boot (so a restart
|
|
14
|
+
* mid-persist doesn't lose the entry) and on a periodic timer. Each drain
|
|
15
|
+
* pass:
|
|
16
|
+
* 1. re-checks `isRulePersisted` against a FRESH config read first — if
|
|
17
|
+
* the rule already landed (e.g. the original attempt actually
|
|
18
|
+
* succeeded server-side but the ack was lost), dequeue as a no-op;
|
|
19
|
+
* 2. otherwise re-synthesizes the diff from that fresh read (fixes the
|
|
20
|
+
* stale-view failure class) and redispatches;
|
|
21
|
+
* 3. on success, dequeues; on failure, backs off exponentially (honoring
|
|
22
|
+
* a structured `retryAfterMs` when the failure supplies one) and
|
|
23
|
+
* re-tries later;
|
|
24
|
+
* 4. once `attempts` reaches {@link MAX_ATTEMPTS} or the entry has aged
|
|
25
|
+
* past {@link MAX_AGE_MS}, the entry is dropped and the caller is
|
|
26
|
+
* told to post a LOUD terminal-failure notice (a new message, not a
|
|
27
|
+
* card edit — card edits don't ping).
|
|
28
|
+
*
|
|
29
|
+
* HARD RULE (standing fleet rule, restated in #2973): nothing here may
|
|
30
|
+
* retry indefinitely. Bounds are enforced structurally, not just by
|
|
31
|
+
* convention — see {@link MAX_ATTEMPTS}, {@link MAX_AGE_MS}, and
|
|
32
|
+
* {@link MAX_QUEUE_SIZE} below, and the tests that pin them.
|
|
33
|
+
*
|
|
34
|
+
* File format mirrors `missed-approvals-store.ts`: a single bounded JSON
|
|
35
|
+
* array, written synchronously, mode 0o600.
|
|
36
|
+
*
|
|
37
|
+
* Failure semantics (hardened post-#2973 adversarial review): a failed
|
|
38
|
+
* READ degrades to an empty list — a corrupt/missing queue file is not
|
|
39
|
+
* fatal, it just means "nothing queued yet". A failed WRITE is a
|
|
40
|
+
* different story: silently swallowing it would mean `enqueue()` tells
|
|
41
|
+
* its caller "queued for retry" when nothing was actually persisted to
|
|
42
|
+
* disk, and a concurrent `recordAttempt()`/`remove()` would silently
|
|
43
|
+
* fail to advance the queue's on-disk state. So `write()` PROPAGATES
|
|
44
|
+
* (throws) on failure, and `enqueue()` / `recordAttempt()` / `remove()`
|
|
45
|
+
* let that throw reach their own caller rather than pretending the
|
|
46
|
+
* mutation landed. `drainAlwaysAllowPersistQueue` below catches around
|
|
47
|
+
* each entry so one entry's write failure can't take down the rest of
|
|
48
|
+
* the drain pass; callers in the gateway (the interactive "Always
|
|
49
|
+
* allow" tap path) are responsible for catching enqueue() failures and
|
|
50
|
+
* reflecting them in the operator-facing message rather than claiming
|
|
51
|
+
* success.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
55
|
+
import { join } from 'node:path'
|
|
56
|
+
|
|
57
|
+
/** Hard cap on retry attempts per entry — never retry indefinitely. */
|
|
58
|
+
export const MAX_ATTEMPTS = 5
|
|
59
|
+
|
|
60
|
+
/** Hard cap on how long an entry may live in the queue before being
|
|
61
|
+
* dropped as terminally failed, regardless of attempts made. */
|
|
62
|
+
export const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24h
|
|
63
|
+
|
|
64
|
+
/** Bound the on-disk file even under a pathological failure storm. */
|
|
65
|
+
export const MAX_QUEUE_SIZE = 50
|
|
66
|
+
|
|
67
|
+
/** Base backoff for attempt 1; doubles each subsequent attempt (capped). */
|
|
68
|
+
export const BASE_BACKOFF_MS = 60_000 // 1 minute
|
|
69
|
+
|
|
70
|
+
/** Ceiling on the computed backoff, independent of attempt count. */
|
|
71
|
+
export const MAX_BACKOFF_MS = 30 * 60_000 // 30 minutes
|
|
72
|
+
|
|
73
|
+
export interface AlwaysAllowPersistEntry {
|
|
74
|
+
/** Dedup key — one live queue entry per (agent, rule) pair. */
|
|
75
|
+
id: string
|
|
76
|
+
agentName: string
|
|
77
|
+
rule: string
|
|
78
|
+
/** Human-readable grant description, for the terminal-failure notice. */
|
|
79
|
+
grantPhrase: string
|
|
80
|
+
/** Origin chat/thread, so a terminal-failure notice can be targeted
|
|
81
|
+
* (falls back to the operator broadcast allowlist if absent). */
|
|
82
|
+
chatId?: string
|
|
83
|
+
threadId?: number | null
|
|
84
|
+
attempts: number
|
|
85
|
+
createdAt: number
|
|
86
|
+
nextAttemptAt: number
|
|
87
|
+
lastError?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface FileShape {
|
|
91
|
+
entries: AlwaysAllowPersistEntry[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface AlwaysAllowPersistQueue {
|
|
95
|
+
/** Enqueue (or refresh) a failed persist for retry. Idempotent on
|
|
96
|
+
* `(agentName, rule)` — an existing entry is updated in place rather
|
|
97
|
+
* than duplicated. Serialized (via an in-process lock) relative to
|
|
98
|
+
* every other read-modify-write on the queue file, and REJECTS if the
|
|
99
|
+
* underlying write fails — callers must not treat a settled promise
|
|
100
|
+
* as "persisted" without checking for rejection. */
|
|
101
|
+
enqueue(entry: {
|
|
102
|
+
agentName: string
|
|
103
|
+
rule: string
|
|
104
|
+
grantPhrase: string
|
|
105
|
+
chatId?: string
|
|
106
|
+
threadId?: number | null
|
|
107
|
+
error?: string
|
|
108
|
+
}): Promise<void>
|
|
109
|
+
/** All entries currently due for a retry attempt (nextAttemptAt <= now),
|
|
110
|
+
* oldest first. Does NOT filter by attempts/age — callers should apply
|
|
111
|
+
* {@link isExhausted} to decide terminal-failure vs. retry. */
|
|
112
|
+
listDue(now?: number): AlwaysAllowPersistEntry[]
|
|
113
|
+
/** Every entry in the queue, regardless of due-ness. */
|
|
114
|
+
listAll(): AlwaysAllowPersistEntry[]
|
|
115
|
+
/** Record the outcome of a retry attempt for `id`. Serialized relative
|
|
116
|
+
* to `enqueue`/`remove`/other `recordAttempt` calls on the same queue
|
|
117
|
+
* file — never races a concurrent mutation into a lost update.
|
|
118
|
+
* - `success: true` → dequeue.
|
|
119
|
+
* - `success: false` → increment attempts, compute the next backoff
|
|
120
|
+
* (honoring `retryAfterMs` when supplied), and re-persist — UNLESS
|
|
121
|
+
* the entry is now exhausted (see {@link isExhausted}), in which case
|
|
122
|
+
* it is dropped and the caller should have already emitted the
|
|
123
|
+
* terminal-failure notice.
|
|
124
|
+
* REJECTS if the underlying write fails — the caller must not assume
|
|
125
|
+
* the state change was durably persisted just because this returned.
|
|
126
|
+
*/
|
|
127
|
+
recordAttempt(
|
|
128
|
+
id: string,
|
|
129
|
+
outcome: { success: true } | { success: false; error: string; retryAfterMs?: number },
|
|
130
|
+
): Promise<void>
|
|
131
|
+
/** Remove an entry outright (e.g. after emitting its terminal notice).
|
|
132
|
+
* Serialized like the other mutators; REJECTS if the write fails. */
|
|
133
|
+
remove(id: string): Promise<void>
|
|
134
|
+
/** Delete the backing file entirely. */
|
|
135
|
+
clear(): void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Stable id for a (agent, rule) pair — one live retry entry per pair. */
|
|
139
|
+
export function makeEntryId(agentName: string, rule: string): string {
|
|
140
|
+
return `${agentName}::${rule}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True when `entry` has exhausted its retry budget — attempts maxed OR
|
|
144
|
+
* aged out — and should be dropped with a terminal-failure notice rather
|
|
145
|
+
* than retried again. Pure so callers/tests can reason about the bound
|
|
146
|
+
* without needing the store. */
|
|
147
|
+
export function isExhausted(entry: Pick<AlwaysAllowPersistEntry, 'attempts' | 'createdAt'>, now = Date.now()): boolean {
|
|
148
|
+
return entry.attempts >= MAX_ATTEMPTS || now - entry.createdAt >= MAX_AGE_MS
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Exponential backoff with a hard ceiling, honoring a structured
|
|
153
|
+
* `retry_after` hint when the failure supplied one (never retries SOONER
|
|
154
|
+
* than that hint, but still respects {@link MAX_BACKOFF_MS}).
|
|
155
|
+
*/
|
|
156
|
+
export function computeBackoffMs(attempts: number, retryAfterMs?: number): number {
|
|
157
|
+
const exp = Math.min(BASE_BACKOFF_MS * 2 ** Math.max(0, attempts - 1), MAX_BACKOFF_MS)
|
|
158
|
+
if (retryAfterMs != null && retryAfterMs > 0) {
|
|
159
|
+
return Math.min(Math.max(exp, retryAfterMs), MAX_BACKOFF_MS)
|
|
160
|
+
}
|
|
161
|
+
return exp
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createAlwaysAllowPersistQueue(
|
|
165
|
+
stateDir: string,
|
|
166
|
+
/** Injectable for tests to force a write failure (disk full / permissions /
|
|
167
|
+
* read-only fs) without real filesystem faults — we run as root in CI/
|
|
168
|
+
* containers, so chmod-based permission tricks don't reliably fail, and
|
|
169
|
+
* bun's test runner doesn't support mocking node:fs built-ins. */
|
|
170
|
+
writeFileSyncFn: typeof writeFileSync = writeFileSync,
|
|
171
|
+
): AlwaysAllowPersistQueue {
|
|
172
|
+
const filePath = join(stateDir, 'always-allow-persist-queue.json')
|
|
173
|
+
|
|
174
|
+
// ── In-process mutex ──────────────────────────────────────────────────
|
|
175
|
+
// enqueue / recordAttempt / remove are each a read-modify-write cycle
|
|
176
|
+
// against the SAME file. Node is single-threaded, but these operations
|
|
177
|
+
// are exposed as async (their callers `await` a hostd round trip, a
|
|
178
|
+
// drain pass, etc.), so two calls CAN interleave across a microtask/
|
|
179
|
+
// macrotask boundary — a naive "read, mutate, write" per call is a
|
|
180
|
+
// classic lost-update race (#2973 pt.3): call A reads, call B reads the
|
|
181
|
+
// same pre-A-write snapshot, B writes, A writes — B's update vanishes.
|
|
182
|
+
// Fix: every read-modify-write against `filePath` is queued through this
|
|
183
|
+
// single promise chain, so at most one is ever in flight at a time,
|
|
184
|
+
// regardless of how many callers invoke enqueue/recordAttempt/remove
|
|
185
|
+
// "concurrently".
|
|
186
|
+
let lock: Promise<unknown> = Promise.resolve()
|
|
187
|
+
function withLock<T>(fn: () => T): Promise<T> {
|
|
188
|
+
const result = lock.then(fn, fn) // run fn even if the previous link rejected
|
|
189
|
+
// Swallow the rejection on the CHAIN (not on `result`) so one failed
|
|
190
|
+
// mutation doesn't wedge the lock for everything queued after it.
|
|
191
|
+
lock = result.catch(() => {})
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function read(): FileShape {
|
|
196
|
+
try {
|
|
197
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
198
|
+
const parsed = JSON.parse(raw) as Partial<FileShape>
|
|
199
|
+
return { entries: Array.isArray(parsed?.entries) ? parsed.entries : [] }
|
|
200
|
+
} catch {
|
|
201
|
+
return { entries: [] }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Unlike `read()`, a write failure is NOT swallowed — it propagates so
|
|
206
|
+
* the caller (enqueue/recordAttempt/remove) finds out its mutation did
|
|
207
|
+
* not actually land on disk (disk full, permissions, etc.) instead of
|
|
208
|
+
* silently proceeding as if it had. */
|
|
209
|
+
function write(f: FileShape): void {
|
|
210
|
+
writeFileSyncFn(filePath, JSON.stringify(f), { encoding: 'utf-8', mode: 0o600 })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
enqueue(args) {
|
|
215
|
+
return withLock(() => {
|
|
216
|
+
const id = makeEntryId(args.agentName, args.rule)
|
|
217
|
+
const f = read()
|
|
218
|
+
const now = Date.now()
|
|
219
|
+
const idx = f.entries.findIndex(e => e.id === id)
|
|
220
|
+
if (idx >= 0) {
|
|
221
|
+
// Already queued (e.g. a second tap failed the same way) — keep
|
|
222
|
+
// its attempt history/createdAt (age bound is from FIRST failure),
|
|
223
|
+
// just refresh the description fields and last error.
|
|
224
|
+
f.entries[idx] = {
|
|
225
|
+
...f.entries[idx]!,
|
|
226
|
+
grantPhrase: args.grantPhrase,
|
|
227
|
+
chatId: args.chatId,
|
|
228
|
+
threadId: args.threadId,
|
|
229
|
+
lastError: args.error,
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
if (f.entries.length >= MAX_QUEUE_SIZE) {
|
|
233
|
+
// Bounded — drop the oldest to make room rather than growing
|
|
234
|
+
// without limit under a failure storm.
|
|
235
|
+
f.entries.sort((a, b) => a.createdAt - b.createdAt)
|
|
236
|
+
f.entries = f.entries.slice(f.entries.length - MAX_QUEUE_SIZE + 1)
|
|
237
|
+
}
|
|
238
|
+
f.entries.push({
|
|
239
|
+
id,
|
|
240
|
+
agentName: args.agentName,
|
|
241
|
+
rule: args.rule,
|
|
242
|
+
grantPhrase: args.grantPhrase,
|
|
243
|
+
chatId: args.chatId,
|
|
244
|
+
threadId: args.threadId,
|
|
245
|
+
attempts: 1,
|
|
246
|
+
createdAt: now,
|
|
247
|
+
nextAttemptAt: now + computeBackoffMs(1),
|
|
248
|
+
lastError: args.error,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
write(f)
|
|
252
|
+
})
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
listDue(now = Date.now()) {
|
|
256
|
+
return read()
|
|
257
|
+
.entries.filter(e => e.nextAttemptAt <= now)
|
|
258
|
+
.sort((a, b) => a.nextAttemptAt - b.nextAttemptAt)
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
listAll() {
|
|
262
|
+
return read().entries
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
recordAttempt(id, outcome) {
|
|
266
|
+
return withLock(() => {
|
|
267
|
+
const f = read()
|
|
268
|
+
const idx = f.entries.findIndex(e => e.id === id)
|
|
269
|
+
if (idx === -1) return
|
|
270
|
+
if (outcome.success) {
|
|
271
|
+
f.entries.splice(idx, 1)
|
|
272
|
+
write(f)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const entry = f.entries[idx]!
|
|
276
|
+
const attempts = entry.attempts + 1
|
|
277
|
+
const updated: AlwaysAllowPersistEntry = {
|
|
278
|
+
...entry,
|
|
279
|
+
attempts,
|
|
280
|
+
lastError: outcome.error,
|
|
281
|
+
nextAttemptAt: Date.now() + computeBackoffMs(attempts, outcome.retryAfterMs),
|
|
282
|
+
}
|
|
283
|
+
if (isExhausted(updated)) {
|
|
284
|
+
// Exhausted — drop it. The caller is responsible for having
|
|
285
|
+
// emitted (or being about to emit) the terminal-failure notice;
|
|
286
|
+
// this store only owns the bounded-retry bookkeeping.
|
|
287
|
+
f.entries.splice(idx, 1)
|
|
288
|
+
} else {
|
|
289
|
+
f.entries[idx] = updated
|
|
290
|
+
}
|
|
291
|
+
write(f)
|
|
292
|
+
})
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
remove(id) {
|
|
296
|
+
return withLock(() => {
|
|
297
|
+
const f = read()
|
|
298
|
+
const filtered = f.entries.filter(e => e.id !== id)
|
|
299
|
+
if (filtered.length !== f.entries.length) {
|
|
300
|
+
f.entries = filtered
|
|
301
|
+
write(f)
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
clear() {
|
|
307
|
+
try {
|
|
308
|
+
unlinkSync(filePath)
|
|
309
|
+
} catch {
|
|
310
|
+
// File may not exist — fine.
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Drain ──────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/** Injected dependencies for {@link drainAlwaysAllowPersistQueue} — kept
|
|
319
|
+
* abstract so the drain loop is unit-testable without gateway/network/hostd
|
|
320
|
+
* deps. The gateway wires the real implementations at call sites. */
|
|
321
|
+
export interface AlwaysAllowDrainDeps {
|
|
322
|
+
/** Read the CURRENT (fresh, not cached) config file text. Throwing here
|
|
323
|
+
* is treated as a retryable failure for the entry being processed. */
|
|
324
|
+
readConfigText(): string
|
|
325
|
+
/** Resolve the agent's current, fully-merged `tools.allow` list from the
|
|
326
|
+
* freshly-read config — used to no-op a retry whose rule already
|
|
327
|
+
* landed (e.g. the original dispatch actually succeeded). */
|
|
328
|
+
resolveAllowList(configText: string, agentName: string): string[]
|
|
329
|
+
isRulePersisted(allowList: string[], rule: string): boolean
|
|
330
|
+
/** Re-synthesize the diff from the fresh config text. Returns null if
|
|
331
|
+
* the agent block can no longer be located (treated as retryable —
|
|
332
|
+
* config may be mid-edit). */
|
|
333
|
+
synthesizeDiff(agentName: string, rule: string, configText: string): string | null
|
|
334
|
+
/** Dispatch the (re-synthesized) diff for durable persistence. */
|
|
335
|
+
dispatchConfigEdit(
|
|
336
|
+
entry: AlwaysAllowPersistEntry,
|
|
337
|
+
unifiedDiff: string,
|
|
338
|
+
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }>
|
|
339
|
+
/** Called once for an entry that has exhausted its retry budget — the
|
|
340
|
+
* "loud terminal failure" requirement (#2973 pt.3). Must not throw. */
|
|
341
|
+
notifyTerminalFailure(entry: AlwaysAllowPersistEntry, reason: string): void
|
|
342
|
+
/** Optional logger sink (defaults to a no-op) — kept separate from
|
|
343
|
+
* stderr writes so tests can assert on it without capturing stdio. */
|
|
344
|
+
log?(line: string): void
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Process every currently-due entry once. Safe to call at gateway boot
|
|
349
|
+
* (drains anything left over from a mid-persist restart) and on a
|
|
350
|
+
* periodic timer (~10 min in the gateway). Each entry is handled
|
|
351
|
+
* independently — one entry's dispatch throwing never blocks its
|
|
352
|
+
* siblings, and this function itself never throws.
|
|
353
|
+
*/
|
|
354
|
+
export async function drainAlwaysAllowPersistQueue(
|
|
355
|
+
queue: AlwaysAllowPersistQueue,
|
|
356
|
+
deps: AlwaysAllowDrainDeps,
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
const log = deps.log ?? (() => {})
|
|
359
|
+
const due = queue.listDue()
|
|
360
|
+
for (const entry of due) {
|
|
361
|
+
try {
|
|
362
|
+
let configText: string
|
|
363
|
+
try {
|
|
364
|
+
configText = deps.readConfigText()
|
|
365
|
+
} catch (err) {
|
|
366
|
+
recordFailure(entry, `config read failed: ${(err as Error).message}`)
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Re-check FIRST: if the rule already landed (original attempt
|
|
371
|
+
// actually succeeded, or a concurrent tap persisted it), this is a
|
|
372
|
+
// no-op dequeue — never re-append and risk a duplicate rule.
|
|
373
|
+
const allowList = deps.resolveAllowList(configText, entry.agentName)
|
|
374
|
+
if (deps.isRulePersisted(allowList, entry.rule)) {
|
|
375
|
+
log(
|
|
376
|
+
`always-allow-persist-queue: rule already persisted, no-op dequeue agent=${entry.agentName} rule=${entry.rule}\n`,
|
|
377
|
+
)
|
|
378
|
+
await recordAttemptSafely(entry, { success: true })
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const diff = deps.synthesizeDiff(entry.agentName, entry.rule, configText)
|
|
383
|
+
if (diff == null) {
|
|
384
|
+
await recordFailure(entry, 'could not re-synthesize diff from fresh config read')
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const result = await deps.dispatchConfigEdit(entry, diff)
|
|
389
|
+
if (result.ok) {
|
|
390
|
+
log(
|
|
391
|
+
`always-allow-persist-queue: retry succeeded agent=${entry.agentName} rule=${entry.rule} attempts=${entry.attempts}\n`,
|
|
392
|
+
)
|
|
393
|
+
await recordAttemptSafely(entry, { success: true })
|
|
394
|
+
} else {
|
|
395
|
+
await recordFailure(entry, result.error, result.retryAfterMs)
|
|
396
|
+
}
|
|
397
|
+
} catch (err) {
|
|
398
|
+
// Never let one entry's unexpected throw take down the drain pass.
|
|
399
|
+
await recordFailure(entry, `unexpected error: ${(err as Error).message}`)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** `queue.recordAttempt` now REJECTS on a write failure (disk full,
|
|
404
|
+
* permissions, …) rather than silently pretending the state change
|
|
405
|
+
* persisted. The drain loop must not let that reject take down the
|
|
406
|
+
* whole pass (or the other due entries) — log it and move on; the
|
|
407
|
+
* entry stays in whatever state the last successful write left it in,
|
|
408
|
+
* and will be picked up again on the next drain pass. */
|
|
409
|
+
async function recordAttemptSafely(
|
|
410
|
+
entry: AlwaysAllowPersistEntry,
|
|
411
|
+
outcome: { success: true } | { success: false; error: string; retryAfterMs?: number },
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
try {
|
|
414
|
+
await queue.recordAttempt(entry.id, outcome)
|
|
415
|
+
} catch (err) {
|
|
416
|
+
log(
|
|
417
|
+
`always-allow-persist-queue: recordAttempt write failed (state change NOT persisted) agent=${entry.agentName} rule=${entry.rule}: ${(err as Error).message}\n`,
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function recordFailure(entry: AlwaysAllowPersistEntry, error: string, retryAfterMs?: number): Promise<void> {
|
|
423
|
+
const willBeExhausted = isExhausted({ attempts: entry.attempts + 1, createdAt: entry.createdAt })
|
|
424
|
+
log(
|
|
425
|
+
`always-allow-persist-queue: retry failed agent=${entry.agentName} rule=${entry.rule} attempts=${entry.attempts + 1} exhausted=${willBeExhausted} error=${error}\n`,
|
|
426
|
+
)
|
|
427
|
+
if (willBeExhausted) {
|
|
428
|
+
try {
|
|
429
|
+
deps.notifyTerminalFailure(entry, error)
|
|
430
|
+
} catch (notifyErr) {
|
|
431
|
+
log(
|
|
432
|
+
`always-allow-persist-queue: notifyTerminalFailure threw agent=${entry.agentName} rule=${entry.rule}: ${(notifyErr as Error).message}\n`,
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
await recordAttemptSafely(entry, { success: false, error, retryAfterMs })
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builders for the synthetic inbounds the gateway injects when an
|
|
3
|
+
* agent-initiated approval card TTL-expires with no operator tap — one per
|
|
4
|
+
* family: `vault_request_access`, `vault_request_save`, `request_secret`,
|
|
5
|
+
* `mental_model_propose`.
|
|
6
|
+
*
|
|
7
|
+
* Before this, the only way these families woke the parked agent was an
|
|
8
|
+
* explicit tap. A card that expired unanswered left the agent (which ended its
|
|
9
|
+
* turn to wait) parked forever. These builders mirror the tap-outcome builders
|
|
10
|
+
* in `vault-grant-inbound-builders.ts`, but carry TIMEOUT wording:
|
|
11
|
+
*
|
|
12
|
+
* "This is a TIMEOUT, not a denial — you may re-request or degrade
|
|
13
|
+
* gracefully."
|
|
14
|
+
*
|
|
15
|
+
* matching the permission-card timeout philosophy (#2411 / #2862) so the model
|
|
16
|
+
* can tell an expiry from a real denial and NOT spam an identical re-request
|
|
17
|
+
* into an absent operator.
|
|
18
|
+
*
|
|
19
|
+
* The `meta.source` string is load-bearing (the bridge keys on it) and each is
|
|
20
|
+
* distinct from its tap-outcome sibling. Pinned by
|
|
21
|
+
* `approval-timeout-inbound-builders.test.ts`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
25
|
+
|
|
26
|
+
interface TimeoutInboundBase {
|
|
27
|
+
agent: string
|
|
28
|
+
/** Chat the card lived in — keeps the resumed turn on the same conversation. */
|
|
29
|
+
chatId: string
|
|
30
|
+
/** Forum topic, if the request came from one — so the resumed reply routes back. */
|
|
31
|
+
threadId?: number
|
|
32
|
+
stageId: string
|
|
33
|
+
/** TTL window in whole minutes, for the message copy. */
|
|
34
|
+
timeoutMinutes: number
|
|
35
|
+
nowMs?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envelope(opts: {
|
|
39
|
+
chatId: string
|
|
40
|
+
threadId?: number
|
|
41
|
+
ts: number
|
|
42
|
+
text: string
|
|
43
|
+
source: string
|
|
44
|
+
agent: string
|
|
45
|
+
stageId: string
|
|
46
|
+
extraMeta?: Record<string, string>
|
|
47
|
+
}): InboundMessage {
|
|
48
|
+
return {
|
|
49
|
+
type: 'inbound',
|
|
50
|
+
chatId: opts.chatId,
|
|
51
|
+
...(opts.threadId != null ? { threadId: opts.threadId } : {}),
|
|
52
|
+
messageId: opts.ts,
|
|
53
|
+
user: 'vault-broker',
|
|
54
|
+
userId: 0,
|
|
55
|
+
ts: opts.ts,
|
|
56
|
+
text: opts.text,
|
|
57
|
+
meta: {
|
|
58
|
+
source: opts.source,
|
|
59
|
+
agent: opts.agent,
|
|
60
|
+
...(opts.threadId != null ? { message_thread_id: String(opts.threadId) } : {}),
|
|
61
|
+
stage_id: opts.stageId,
|
|
62
|
+
...(opts.extraMeta ?? {}),
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `vault_request_access` card expired unanswered. */
|
|
68
|
+
export function buildVaultAccessTimeoutInbound(
|
|
69
|
+
opts: TimeoutInboundBase & { key: string; scope: 'read' | 'write' },
|
|
70
|
+
): InboundMessage {
|
|
71
|
+
const ts = opts.nowMs ?? Date.now()
|
|
72
|
+
return envelope({
|
|
73
|
+
chatId: opts.chatId,
|
|
74
|
+
threadId: opts.threadId,
|
|
75
|
+
ts,
|
|
76
|
+
source: 'vault_grant_timeout',
|
|
77
|
+
agent: opts.agent,
|
|
78
|
+
stageId: opts.stageId,
|
|
79
|
+
extraMeta: { key: opts.key, scope: opts.scope },
|
|
80
|
+
text:
|
|
81
|
+
`⌛ Your vault access request for \`${opts.key}\` (scope=${opts.scope}) ` +
|
|
82
|
+
`timed out after ${opts.timeoutMinutes} min — the operator did not tap. ` +
|
|
83
|
+
`This is a TIMEOUT, not a denial. Pick a fallback for the original task ` +
|
|
84
|
+
`(tell the user, try a different approach, or skip the feature), or ` +
|
|
85
|
+
`re-request later if it still matters. Do NOT re-request in a loop.`,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `vault_request_save` card expired unanswered. */
|
|
90
|
+
export function buildVaultSaveTimeoutInbound(
|
|
91
|
+
opts: TimeoutInboundBase & { key: string },
|
|
92
|
+
): InboundMessage {
|
|
93
|
+
const ts = opts.nowMs ?? Date.now()
|
|
94
|
+
return envelope({
|
|
95
|
+
chatId: opts.chatId,
|
|
96
|
+
threadId: opts.threadId,
|
|
97
|
+
ts,
|
|
98
|
+
source: 'vault_save_timeout',
|
|
99
|
+
agent: opts.agent,
|
|
100
|
+
stageId: opts.stageId,
|
|
101
|
+
extraMeta: { key: opts.key },
|
|
102
|
+
text:
|
|
103
|
+
`⌛ Your vault_request_save for \`${opts.key}\` timed out after ` +
|
|
104
|
+
`${opts.timeoutMinutes} min — the operator did not tap, so the secret was ` +
|
|
105
|
+
`NOT stored and \`vault:${opts.key}\` will not resolve. This is a TIMEOUT, ` +
|
|
106
|
+
`not a denial. Pick a fallback or re-request the save later. Do NOT loop.`,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `request_secret` card expired unanswered. */
|
|
111
|
+
export function buildSecretRequestTimeoutInbound(
|
|
112
|
+
opts: TimeoutInboundBase & { key: string },
|
|
113
|
+
): InboundMessage {
|
|
114
|
+
const ts = opts.nowMs ?? Date.now()
|
|
115
|
+
return envelope({
|
|
116
|
+
chatId: opts.chatId,
|
|
117
|
+
threadId: opts.threadId,
|
|
118
|
+
ts,
|
|
119
|
+
source: 'secret_request_timeout',
|
|
120
|
+
agent: opts.agent,
|
|
121
|
+
stageId: opts.stageId,
|
|
122
|
+
extraMeta: { key: opts.key },
|
|
123
|
+
text:
|
|
124
|
+
`⌛ Your request_secret for \`${opts.key}\` timed out after ` +
|
|
125
|
+
`${opts.timeoutMinutes} min — the operator did not provide it. This is a ` +
|
|
126
|
+
`TIMEOUT, not a denial. Proceed without it, ask the user how they want to ` +
|
|
127
|
+
`handle the task, or re-request later. Do NOT re-request in a loop.`,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `mental_model_propose` card expired unanswered. */
|
|
132
|
+
export function buildMentalModelProposeTimeoutInbound(
|
|
133
|
+
opts: TimeoutInboundBase & { name: string },
|
|
134
|
+
): InboundMessage {
|
|
135
|
+
const ts = opts.nowMs ?? Date.now()
|
|
136
|
+
return envelope({
|
|
137
|
+
chatId: opts.chatId,
|
|
138
|
+
threadId: opts.threadId,
|
|
139
|
+
ts,
|
|
140
|
+
source: 'mental_model_propose_timeout',
|
|
141
|
+
agent: opts.agent,
|
|
142
|
+
stageId: opts.stageId,
|
|
143
|
+
extraMeta: { name: opts.name },
|
|
144
|
+
text:
|
|
145
|
+
`⌛ Your mental_model_propose for \`${opts.name}\` timed out after ` +
|
|
146
|
+
`${opts.timeoutMinutes} min — the operator did not tap, so nothing was ` +
|
|
147
|
+
`declared. This is a TIMEOUT, not a denial. Re-propose later if it still ` +
|
|
148
|
+
`stands. Do NOT re-propose in a loop.`,
|
|
149
|
+
})
|
|
150
|
+
}
|