switchroom 0.18.11 → 0.18.13

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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -90,13 +90,30 @@ function defaultDeriveName(agentDir: string): string {
90
90
  return leaf;
91
91
  }
92
92
 
93
+ /**
94
+ * Two-tier owner-identity resolution shared by `ownedWorktreeCwds` and
95
+ * `refreshOwnedWorktreeHeartbeats`:
96
+ * Tier 1 — env fast path (`SWITCHROOM_AGENT_NAME`).
97
+ * Tier 2 — durable fallback derived from the agent's OWN directory basename.
98
+ * Returns "" when neither source yields a usable identity — callers MUST treat
99
+ * "" as fail-closed (never guess ownership).
100
+ */
101
+ export function resolveOwnerIdentity(
102
+ self: string | undefined,
103
+ agentDir: string | null | undefined,
104
+ deriveName?: (agentDir: string) => string,
105
+ ): string {
106
+ let resolved: string = self != null ? self : "";
107
+ if (resolved === "" && agentDir != null && agentDir !== "") {
108
+ const derive = deriveName ?? defaultDeriveName;
109
+ resolved = derive(agentDir) || "";
110
+ }
111
+ return resolved;
112
+ }
113
+
93
114
  export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
94
115
  // Tier 1: env fast path. Tier 2: durable agentDir-derived fallback.
95
- let resolved: string = opts.self != null ? opts.self : "";
96
- if (resolved === "" && opts.agentDir != null && opts.agentDir !== "") {
97
- const derive = opts.deriveName ?? defaultDeriveName;
98
- resolved = derive(opts.agentDir) || "";
99
- }
116
+ const resolved = resolveOwnerIdentity(opts.self, opts.agentDir, opts.deriveName);
100
117
 
101
118
  if (resolved === "") {
102
119
  // Both env and durable config unavailable. Keep the historical
@@ -133,3 +150,175 @@ export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
133
150
  return [];
134
151
  }
135
152
  }
153
+
154
+ /**
155
+ * Default minimum interval (ms) between heartbeat writes for the same
156
+ * worktree record. The gateway invokes the refresh on every ~1s rescan tick;
157
+ * writing every tick would be needless churn. Refreshing at most every 2 min
158
+ * keeps every live claim's heartbeat FAR fresher than the reaper's 10-min
159
+ * `STALE_THRESHOLD_MS`, so a live claim never reads as stale, while a dead
160
+ * gateway (no ticks) lets the heartbeat age out and become reap-eligible.
161
+ */
162
+ export const DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS = 2 * 60_000;
163
+
164
+ /** Minimal record shape needed to refresh a worktree heartbeat. */
165
+ export interface WorktreeHeartbeatRecord {
166
+ id: string;
167
+ ownerAgent?: string;
168
+ /** ISO 8601 timestamp of the record's current heartbeat (for throttling). */
169
+ heartbeatAt?: string;
170
+ }
171
+
172
+ export interface RefreshOwnedHeartbeatsOptions {
173
+ /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
174
+ self: string | undefined;
175
+ /** Durable, non-env identity fallback: the agent's OWN directory. */
176
+ agentDir?: string | null;
177
+ /** Host-global registry read (`listRecords` from src/worktree/registry). */
178
+ listRecords: () => WorktreeHeartbeatRecord[];
179
+ /** Advance one record's heartbeat (`touchHeartbeat` from the registry). */
180
+ touchHeartbeat: (id: string) => void;
181
+ /**
182
+ * Skip a touch while the record's heartbeat is younger than this (ms).
183
+ * Defaults to `DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS`. Set to 0 to always
184
+ * touch (used by tests).
185
+ */
186
+ minRefreshIntervalMs?: number;
187
+ /** `Date.now` override for tests. */
188
+ now?: () => number;
189
+ /** Injectable name derivation (defaults to `path.basename`). */
190
+ deriveName?: (agentDir: string) => string;
191
+ /** Best-effort error sink. */
192
+ log?: (msg: string) => void;
193
+ }
194
+
195
+ /**
196
+ * Refresh the heartbeat of every worktree THIS agent owns.
197
+ *
198
+ * This is the production driver that keeps `touchHeartbeat` (previously dead —
199
+ * F1/H3) alive: the gateway calls it on the same rescan tick that re-derives
200
+ * the watched worktree cwds, so a claim held by a LIVE agent has its heartbeat
201
+ * advanced continuously and never trips the reaper's staleness gate. When the
202
+ * owning gateway dies, the ticks stop, the heartbeat ages past
203
+ * `STALE_THRESHOLD_MS`, and the (now fail-safe) reaper can reclaim the truly
204
+ * abandoned claim.
205
+ *
206
+ * Fail-CLOSED, mirroring `ownedWorktreeCwds`:
207
+ * - Unresolved identity ⇒ touch nothing (never guess ownership).
208
+ * - Ownerless records (`ownerAgent` undefined) are NEVER matched — we must
209
+ * not advance another agent's / an unattributable claim's heartbeat.
210
+ * - A registry read failure ⇒ touch nothing.
211
+ * - A per-record touch failure is swallowed (logged) — one bad record must
212
+ * not abort the rest, and this runs on the hot watch loop.
213
+ *
214
+ * @returns the number of heartbeats actually advanced this call.
215
+ */
216
+ export function refreshOwnedWorktreeHeartbeats(
217
+ opts: RefreshOwnedHeartbeatsOptions,
218
+ ): number {
219
+ const identity = resolveOwnerIdentity(opts.self, opts.agentDir, opts.deriveName);
220
+ if (identity === "") return 0; // fail-closed: never guess ownership
221
+
222
+ const nowMs = (opts.now ?? Date.now)();
223
+ const minInterval =
224
+ opts.minRefreshIntervalMs ?? DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS;
225
+
226
+ let records: WorktreeHeartbeatRecord[];
227
+ try {
228
+ records = opts.listRecords();
229
+ } catch {
230
+ return 0;
231
+ }
232
+
233
+ let touched = 0;
234
+ for (const r of records) {
235
+ if (r.ownerAgent !== identity) continue; // ownerless never matched
236
+ // Throttle: skip if the heartbeat is still comfortably fresh.
237
+ if (minInterval > 0 && r.heartbeatAt != null) {
238
+ const age = nowMs - new Date(r.heartbeatAt).getTime();
239
+ if (Number.isFinite(age) && age >= 0 && age < minInterval) continue;
240
+ }
241
+ try {
242
+ opts.touchHeartbeat(r.id);
243
+ touched++;
244
+ } catch (err) {
245
+ opts.log?.(
246
+ `worktree heartbeat refresh failed for ${r.id}: ${(err as Error).message}`,
247
+ );
248
+ }
249
+ }
250
+ return touched;
251
+ }
252
+
253
+ /** A registry record carrying everything both watch operations need. */
254
+ export type WorktreeWatchRecord = WorktreeOwnershipRecord & WorktreeHeartbeatRecord;
255
+
256
+ export interface WorktreeWatchProviderOptions {
257
+ /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
258
+ self: string | undefined;
259
+ /** Durable, non-env identity fallback: the agent's OWN directory. */
260
+ agentDir?: string | null;
261
+ /** Host-global registry read (`listRecords` from src/worktree/registry). */
262
+ listRecords: () => WorktreeWatchRecord[];
263
+ /** Advance one record's heartbeat (`touchHeartbeat` from the registry). */
264
+ touchHeartbeat: (id: string) => void;
265
+ /** Injectable realpath for the cwd derivation (defaults to fs.realpathSync). */
266
+ realpath?: (p: string) => string;
267
+ /** Injectable name derivation from `agentDir` (defaults to path.basename). */
268
+ deriveName?: (agentDir: string) => string;
269
+ /** Heartbeat throttle window (ms); see refreshOwnedWorktreeHeartbeats. */
270
+ minRefreshIntervalMs?: number;
271
+ /** `Date.now` override for tests. */
272
+ now?: () => number;
273
+ /** Best-effort log sink shared by both operations. */
274
+ log?: (msg: string) => void;
275
+ }
276
+
277
+ /**
278
+ * Build the `extraWatchCwdsProvider` closure the gateway installs on the
279
+ * subagent watcher.
280
+ *
281
+ * The provider is invoked on every ~1s rescan tick and does TWO things on that
282
+ * single tick:
283
+ * 1. advances the heartbeat of every worktree THIS agent owns
284
+ * (`refreshOwnedWorktreeHeartbeats` — the production driver that keeps
285
+ * `touchHeartbeat` alive; without it every claim reads "stale" 10 min
286
+ * after creation and the reaper's staleness guarantee collapses), AND
287
+ * 2. returns the set of owned worktree cwds for the watcher to also watch
288
+ * (`ownedWorktreeCwds`).
289
+ *
290
+ * It is extracted from the gateway (rather than inlined) SO THAT the wiring —
291
+ * specifically that the provider actually DRIVES heartbeats, not merely returns
292
+ * cwds — is under direct unit test. Deleting the heartbeat refresh here is
293
+ * caught by the provider's behaviour test (it would return cwds but stop
294
+ * advancing heartbeats), which the pre-extraction inline closure could not
295
+ * assert against.
296
+ *
297
+ * Both operations use the SAME two-tier identity (env fast path + agentDir
298
+ * fallback), so a single `agentDir` kill-switch on the caller governs both.
299
+ * Fully best-effort: neither operation throws out of the returned closure.
300
+ */
301
+ export function makeWorktreeWatchProvider(
302
+ opts: WorktreeWatchProviderOptions,
303
+ ): () => string[] {
304
+ return () => {
305
+ refreshOwnedWorktreeHeartbeats({
306
+ self: opts.self,
307
+ agentDir: opts.agentDir,
308
+ listRecords: opts.listRecords,
309
+ touchHeartbeat: opts.touchHeartbeat,
310
+ minRefreshIntervalMs: opts.minRefreshIntervalMs,
311
+ now: opts.now,
312
+ deriveName: opts.deriveName,
313
+ log: opts.log,
314
+ });
315
+ return ownedWorktreeCwds({
316
+ self: opts.self,
317
+ agentDir: opts.agentDir,
318
+ listRecords: opts.listRecords,
319
+ realpath: opts.realpath,
320
+ deriveName: opts.deriveName,
321
+ log: opts.log,
322
+ });
323
+ };
324
+ }
@@ -9,15 +9,22 @@ the queue no longer drains it but the operator can still inspect via
9
9
 
10
10
  Boundaries
11
11
  ----------
12
- * Per-entry HTTP timeout: ``HINDSIGHT_DRAIN_TIMEOUT`` (default 5s).
12
+ * Per-entry HTTP timeout: ``HINDSIGHT_DRAIN_TIMEOUT`` (default 5s), but
13
+ clamped per entry to the budget still remaining (see below) so a
14
+ single slow entry can never overshoot the wall-clock cap. The default
15
+ timeout (5s) intentionally exceeds the default budget (4s): the clamp,
16
+ not the raw timeout, is what bounds a slow entry.
17
+ * Total wall-clock cap: ``HINDSIGHT_DRAIN_BUDGET_S`` (default 4s) so
18
+ drain never blocks SessionStart longer than the upstream hook timeout
19
+ permits. This is the authoritative bound; the per-entry timeout is
20
+ clamped down to ``max(1, remaining budget)`` before each request, so
21
+ even one slow upstream entry overshoots the budget by at most the
22
+ clamp floor (~1s), not by ``HINDSIGHT_DRAIN_TIMEOUT - budget``.
13
23
  * Stall guard: if ``STALL_THRESHOLD`` (3) consecutive entries fail with
14
24
  the same error class, we stop draining for this session — that's a
15
25
  systemic outage, not a transient flake, and continuing would only
16
26
  burn the SessionStart timeout budget. The remaining entries stay
17
27
  queued for the next session.
18
- * Total wall-clock cap: ``HINDSIGHT_DRAIN_BUDGET_S`` (default 4s) so
19
- drain never blocks SessionStart longer than the upstream
20
- hook timeout permits.
21
28
 
22
29
  Standalone usage::
23
30
 
@@ -113,13 +120,22 @@ def drain(config: dict | None = None) -> dict:
113
120
  last_error_class: str | None = None
114
121
 
115
122
  for path, entry in entries:
116
- if time.monotonic() - started > budget:
123
+ elapsed = time.monotonic() - started
124
+ if elapsed > budget:
117
125
  summary["budget_exceeded"] = True
118
126
  debug_log(config, "drain_pending: total budget exceeded, stopping")
119
127
  break
120
128
 
129
+ # Clamp the per-entry HTTP timeout to the budget still remaining
130
+ # (#1094 item 2). Without this, a single slow entry using the full
131
+ # HINDSIGHT_DRAIN_TIMEOUT (default 5s) overshoots the total budget
132
+ # (default 4s). Floor at 1s so we still give a near-exhausted
133
+ # budget one bounded shot rather than a 0s (instant-fail) request.
134
+ remaining = budget - elapsed
135
+ effective_timeout = max(1, min(timeout, int(remaining) if remaining >= 1 else 1))
136
+
121
137
  try:
122
- _retry_one(entry, timeout=timeout)
138
+ _retry_one(entry, timeout=effective_timeout)
123
139
  except Exception as e:
124
140
  err_class = type(e).__name__
125
141
  if err_class == last_error_class:
@@ -93,15 +93,22 @@ class HindsightClient:
93
93
  pass
94
94
  raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e
95
95
 
96
- def health_check(self, timeout: int = 5) -> bool:
96
+ def health_check(self, timeout: int = 5, retries: int = HEALTH_CHECK_RETRIES) -> bool:
97
97
  """Check if the Hindsight server is reachable.
98
98
 
99
- Mirrors Openclaw's checkExternalApiHealth: retries up to 3 times
100
- with 2s delay between attempts.
99
+ Mirrors Openclaw's checkExternalApiHealth: retries up to
100
+ ``retries`` times (default ``HEALTH_CHECK_RETRIES`` = 3) with
101
+ ``HEALTH_CHECK_DELAY`` (2s) between attempts.
102
+
103
+ Time-budgeted callers (e.g. the SessionStart drain gate,
104
+ #1094) should pass ``retries=1``: against a HUNG server the
105
+ default loop costs ~retries*timeout + (retries-1)*delay of wall
106
+ clock, which blows a 5s hook budget.
101
107
  """
102
108
  import time
103
109
 
104
- for attempt in range(1, HEALTH_CHECK_RETRIES + 1):
110
+ retries = max(1, retries)
111
+ for attempt in range(1, retries + 1):
105
112
  try:
106
113
  url = f"{self.api_url}/health"
107
114
  req = urllib.request.Request(url, headers=self._headers(), method="GET")
@@ -110,7 +117,7 @@ class HindsightClient:
110
117
  return True
111
118
  except Exception:
112
119
  pass
113
- if attempt < HEALTH_CHECK_RETRIES:
120
+ if attempt < retries:
114
121
  time.sleep(HEALTH_CHECK_DELAY)
115
122
  return False
116
123
 
@@ -183,6 +183,27 @@ def parse_active_directives_block(text: str) -> list:
183
183
  return contents
184
184
 
185
185
 
186
+ # Length-aware guard for terse rules (#2912). Forward coverage alone
187
+ # (|rule ∩ directive| / |rule|) is easy to satisfy when the rule has only a
188
+ # couple of significant tokens: any long directive that happens to contain
189
+ # those few words scores ~1.0 and silently swallows a genuinely-new short
190
+ # rule, skipping a legitimate re-prompt. For such short rules we additionally
191
+ # require REVERSE coverage — the matched directive's significant-token set must
192
+ # also be substantially covered by the rule's — which is a Jaccard-style
193
+ # bidirectional check. That fails precisely the "few tokens diluted inside a
194
+ # long unrelated directive" case while still passing a terse rule that restates
195
+ # a comparably terse directive.
196
+ #
197
+ # Constants chosen against the real tokenizer output:
198
+ # - < 4 significant tokens is "short" (1-3 tokens: the regime where a single
199
+ # incidental word swings forward coverage past 0.6).
200
+ # - reverse coverage >= 0.5 keeps near-equal-size restatements deduped
201
+ # (e.g. a 2-token rule vs a 4-token directive → 2/4 = 0.5) but rejects a
202
+ # 2-token rule diluted inside a 6+-token directive (2/6 ≈ 0.33 < 0.5).
203
+ _SHORT_RULE_TOKEN_LIMIT = 4
204
+ _SHORT_RULE_REVERSE_COVERAGE = 0.5
205
+
206
+
186
207
  def rule_already_captured(
187
208
  rule_text: str, directive_contents: list, threshold: float = 0.6
188
209
  ) -> bool:
@@ -193,15 +214,29 @@ def rule_already_captured(
193
214
  best-matching directive. A high coverage ratio means the restated rule adds
194
215
  (almost) no new significant words over one already stored — i.e. a
195
216
  duplicate. Deterministic; no model/API call.
217
+
218
+ For terse rules (fewer than ``_SHORT_RULE_TOKEN_LIMIT`` significant tokens)
219
+ forward coverage is not sufficient — see the module comment above — so a
220
+ reverse-coverage guard is also required before declaring a match.
196
221
  """
197
222
  rule_tokens = _dedup_tokens(rule_text)
198
223
  if not rule_tokens:
199
224
  return False
225
+ is_short_rule = len(rule_tokens) < _SHORT_RULE_TOKEN_LIMIT
200
226
  for content in directive_contents:
201
227
  d_tokens = _dedup_tokens(content)
202
228
  if not d_tokens:
203
229
  continue
204
- covered = len(rule_tokens & d_tokens) / len(rule_tokens)
205
- if covered >= threshold:
206
- return True
230
+ intersection = len(rule_tokens & d_tokens)
231
+ covered = intersection / len(rule_tokens)
232
+ if covered < threshold:
233
+ continue
234
+ if is_short_rule:
235
+ # Bidirectional guard: the directive must not be much larger than
236
+ # the rule, or those few shared tokens are incidental overlap
237
+ # rather than a true restatement.
238
+ reverse_covered = intersection / len(d_tokens)
239
+ if reverse_covered < _SHORT_RULE_REVERSE_COVERAGE:
240
+ continue
241
+ return True
207
242
  return False
@@ -197,22 +197,49 @@ def update_attempt(path: str, entry: dict, error: BaseException) -> bool:
197
197
 
198
198
  def mark_dead(path: str, entry: dict) -> Optional[str]:
199
199
  """Convert an entry that exceeded ``MAX_ATTEMPTS`` into a permanent
200
- failure marker. Renames ``<path>`` to ``<path>.dead`` so the queue
201
- no longer drains it but operators can still inspect.
202
-
203
- Returns the marker path, or ``None`` if the rename failed.
200
+ failure marker at ``<path>.dead`` so the queue no longer drains it
201
+ but operators can still inspect.
202
+
203
+ Returns the marker path, or ``None`` if it failed.
204
+
205
+ Crash-window invariant (#1094 item 3): **a live ``<path>.json`` entry
206
+ must never carry a ``dead_at`` stamp.** The old two-step form violated
207
+ this — it wrote the dead_at-stamped payload back to the *live* path
208
+ (rename tmp -> path) and only then renamed path -> path.dead, so a
209
+ crash between the two renames left a live entry with ``dead_at`` set
210
+ that the drainer would re-enter and re-bump. Here we instead:
211
+
212
+ 1. write the dead_at-stamped payload to ``<path>.tmp``
213
+ 2. ``os.replace(tmp, dead_path)`` — the .dead marker appears in one
214
+ atomic step (never drained: the drainer only lists ``*.json``)
215
+ 3. ``os.unlink(path)`` — drop the original live entry
216
+
217
+ At every crash point the invariant holds: the ``dead_at`` stamp only
218
+ ever lands on ``<path>.dead``. A crash after step 2 leaves both the
219
+ (stale, no-dead_at) live entry and the .dead marker; the next drain
220
+ re-marks it dead (os.replace overwrites the marker idempotently),
221
+ never observing a live entry with dead_at.
204
222
  """
205
223
  entry["dead_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
206
224
  dead_path = path + ".dead"
225
+ tmp = path + ".tmp"
207
226
  try:
208
- # Best-effort: write the final state first so the marker shows
209
- # the death timestamp + last error.
210
- tmp = path + ".tmp"
211
227
  with open(tmp, "w", encoding="utf-8") as f:
212
228
  json.dump(entry, f, ensure_ascii=False)
213
229
  os.chmod(tmp, 0o600)
214
- os.rename(tmp, path)
215
- os.rename(path, dead_path)
230
+ os.replace(tmp, dead_path)
231
+ # Marker is durable now; removing the original never resurrects a
232
+ # dead_at-stamped live entry. Best-effort — a leftover live entry
233
+ # is self-healing (re-marked dead on the next pass).
234
+ try:
235
+ os.unlink(path)
236
+ except OSError:
237
+ pass
216
238
  return dead_path
217
239
  except OSError:
240
+ # Clean up a possibly-orphaned tmp so it doesn't linger.
241
+ try:
242
+ os.unlink(tmp)
243
+ except OSError:
244
+ pass
218
245
  return None
@@ -40,7 +40,16 @@ from lib.pending import MAX_ENTRIES, count as pending_count, enqueue as pending_
40
40
  # Exit codes:
41
41
  # 0 — success (or retain skipped for benign reasons)
42
42
  # 1 — retain failed AND was queued to pending-retains (recoverable)
43
- # 2 — retain failed AND the queue rejected it (chronic backlog)
43
+ # 2 — retain failed but nothing was queued — either the queue rejected
44
+ # it (chronic backlog) or there was no payload to queue (the
45
+ # failure happened before one was built). Not recoverable via the
46
+ # pending-retains drain, so it's semantically "dropped", not
47
+ # "queued".
48
+ # Only the sign of the exit code is load-bearing downstream: Claude
49
+ # Code's hook runner routes any non-zero SessionEnd exit to the issue
50
+ # sink (bin/run-hook.sh / #424); nothing distinguishes 1 from 2
51
+ # programmatically, so this is a correctness/clarity fix, not a
52
+ # contract change.
44
53
  EXIT_OK = 0
45
54
  EXIT_QUEUED = 1
46
55
  EXIT_DROPPED = 2
@@ -98,8 +107,10 @@ def main() -> int:
98
107
  exit_code = EXIT_QUEUED
99
108
  else:
100
109
  # No payload to queue — the failure happened before we
101
- # finished building one (e.g. URL resolution).
102
- exit_code = EXIT_QUEUED
110
+ # finished building one (e.g. URL resolution). Nothing
111
+ # landed in pending-retains, so this is a drop, not a
112
+ # queue (#1094 item 5): EXIT_DROPPED, not EXIT_QUEUED.
113
+ exit_code = EXIT_DROPPED
103
114
 
104
115
  # Stop daemon if we started it. Always runs, even on retain failure,
105
116
  # so we don't leak a daemon process.
@@ -53,6 +53,27 @@ def main():
53
53
  prestart_daemon_background(config, debug_fn=_dbg)
54
54
  return
55
55
 
56
+ # Mode 1 (external hindsightApiUrl) reachability gate (#1094 item 1).
57
+ # get_api_url() returns the external URL WITHOUT probing it, so an
58
+ # external server that's down would otherwise slip past the guard
59
+ # above and let the drain run against nothing — bumping attempt
60
+ # counters on healthy entries until the stall guard trips. Modes 2/3
61
+ # already gate via _check_health inside get_api_url, so only Mode 1
62
+ # needs an explicit probe here. On failure, skip the drain and return
63
+ # (no local daemon to pre-start in Mode 1 — the external server is the
64
+ # operator's responsibility); queued entries stay for the next session.
65
+ # Single bounded attempt (retries=1, timeout=2): the default 3-retry
66
+ # health_check against a HUNG (timing-out) server would cost ~10s
67
+ # (3*2s timeouts + 2*2s sleeps) — worse than the attempt-counter
68
+ # bumps this gate prevents, and over the 5s SessionStart hook budget.
69
+ # Worst-case probe cost here is ~2s.
70
+ if config.get("hindsightApiUrl") and not client.health_check(timeout=2, retries=1):
71
+ debug_log(
72
+ config,
73
+ f"External Hindsight at {api_url} unreachable, skipping drain",
74
+ )
75
+ return
76
+
56
77
  # Drain any retains that session_end.py queued on failure (#1071).
57
78
  # Bounded by HINDSIGHT_DRAIN_BUDGET_S so a slow upstream can't pin
58
79
  # the SessionStart hook. The drain is best-effort — failures stay
@@ -255,6 +255,44 @@ class TestDirectiveDedup(unittest.TestCase):
255
255
  def test_empty_rule_is_not_captured(self):
256
256
  self.assertFalse(rule_already_captured("", ["Always use British spelling."]))
257
257
 
258
+ # --- #2912: length-aware guard for terse rules -----------------------
259
+
260
+ def test_terse_new_rule_not_swallowed_by_long_directive(self):
261
+ # A genuinely-new terse rule whose few significant tokens all appear
262
+ # inside a much longer, unrelated directive must NOT be deduped — the
263
+ # verifier should still re-prompt. Rule tokens {tabs, indentation} are
264
+ # both present in the long directive, so forward coverage is 1.0, but
265
+ # they are incidental overlap inside a broader rule.
266
+ contents = parse_active_directives_block(
267
+ self._block(
268
+ "Always use spaces not tabs for indentation and trim "
269
+ "trailing whitespace on every saved source file."
270
+ )
271
+ )
272
+ self.assertFalse(rule_already_captured("Use tabs for indentation.", contents))
273
+
274
+ def test_terse_rule_restating_equally_terse_directive_is_captured(self):
275
+ # Two comparably terse rules about the same thing → real duplicate.
276
+ contents = parse_active_directives_block(self._block("Use British spelling."))
277
+ self.assertTrue(rule_already_captured("Always use British spelling.", contents))
278
+
279
+ def test_long_rule_boundary_unchanged_at_0_6(self):
280
+ # Long rules (>= _SHORT_RULE_TOKEN_LIMIT significant tokens) keep the
281
+ # plain forward-coverage 0.6 behavior — the guard does not engage.
282
+ directive = "capital python numeric boolean spelling timezone"
283
+ # Rule shares 3 of its 5 significant tokens (0.6 exactly) → captured.
284
+ self.assertTrue(
285
+ rule_already_captured(
286
+ "capital python numeric quarterly monthly", [directive]
287
+ )
288
+ )
289
+ # Rule shares 2 of 5 (0.4 < 0.6) → not captured.
290
+ self.assertFalse(
291
+ rule_already_captured(
292
+ "capital python quarterly monthly weekly", [directive]
293
+ )
294
+ )
295
+
258
296
 
259
297
  if __name__ == "__main__":
260
298
  unittest.main()
@@ -162,6 +162,74 @@ class DrainPendingTest(unittest.TestCase):
162
162
  remaining = [n for n in os.listdir(self._pending) if n.endswith(".json")]
163
163
  self.assertEqual(len(remaining), 10)
164
164
 
165
+ def test_per_entry_timeout_clamped_to_remaining_budget(self):
166
+ # #1094 item 2: default HINDSIGHT_DRAIN_TIMEOUT (5) exceeds the
167
+ # budget (4 here), so the effective per-entry timeout must be
168
+ # clamped DOWN to the remaining budget — never the raw 5s.
169
+ self._env.stop()
170
+ self._env = patch.dict(
171
+ os.environ,
172
+ {
173
+ "HINDSIGHT_PENDING_DIR": self._pending,
174
+ "HINDSIGHT_DRAIN_TIMEOUT": "5",
175
+ "HINDSIGHT_DRAIN_BUDGET_S": "4",
176
+ },
177
+ clear=False,
178
+ )
179
+ self._env.start()
180
+
181
+ _seed_entry(self._pending)
182
+ import drain_pending
183
+
184
+ seen = {"timeout": None}
185
+
186
+ def capture(*a, **kw):
187
+ seen["timeout"] = kw.get("timeout")
188
+ raise urllib.error.URLError("down")
189
+
190
+ with patch("urllib.request.urlopen", side_effect=capture):
191
+ drain_pending.drain({})
192
+
193
+ self.assertIsNotNone(seen["timeout"])
194
+ # Clamped to the ~4s budget remaining, not the raw 5s timeout.
195
+ self.assertLessEqual(seen["timeout"], 4)
196
+ self.assertGreaterEqual(seen["timeout"], 1)
197
+
198
+ def test_slow_entry_cannot_overshoot_budget_beyond_clamp_floor(self):
199
+ # A single slow entry must not blow the wall-clock budget by
200
+ # HINDSIGHT_DRAIN_TIMEOUT - budget. With the clamp, overshoot is
201
+ # bounded by the 1s clamp floor (+ scheduling epsilon).
202
+ self._env.stop()
203
+ self._env = patch.dict(
204
+ os.environ,
205
+ {
206
+ "HINDSIGHT_PENDING_DIR": self._pending,
207
+ "HINDSIGHT_DRAIN_TIMEOUT": "5",
208
+ "HINDSIGHT_DRAIN_BUDGET_S": "2",
209
+ },
210
+ clear=False,
211
+ )
212
+ self._env.start()
213
+
214
+ _seed_entry(self._pending)
215
+ import drain_pending
216
+
217
+ def slow_then_fail(*a, **kw):
218
+ # Sleep exactly as long as the timeout the drainer granted —
219
+ # emulates a request that runs to its (clamped) timeout.
220
+ time.sleep(kw.get("timeout", 5))
221
+ raise urllib.error.URLError("timed out")
222
+
223
+ started = time.monotonic()
224
+ with patch("urllib.request.urlopen", side_effect=slow_then_fail):
225
+ drain_pending.drain({})
226
+ elapsed = time.monotonic() - started
227
+
228
+ # Budget is 2s; clamped timeout is min(5, ~2)=2, so ~2s of sleep.
229
+ # Without the clamp the entry would sleep the full 5s. Assert we
230
+ # stay near the budget, well under the raw 5s timeout.
231
+ self.assertLess(elapsed, 4.0)
232
+
165
233
  def test_drain_mixed_success_failure(self):
166
234
  # Three entries: server returns alternating ok/fail/ok.
167
235
  _seed_entry(self._pending, document_id="doc-a")
@@ -144,6 +144,50 @@ class PendingQueueTest(unittest.TestCase):
144
144
  # iter_entries no longer surfaces .dead files
145
145
  self.assertEqual(pending_mod.iter_entries(), [])
146
146
 
147
+ def test_mark_dead_never_leaves_live_entry_with_dead_at(self):
148
+ # Crash-window invariant (#1094 item 3): the dead_at stamp must
149
+ # only ever land on <path>.dead, never on the live <path>.json.
150
+ # The old two-step form wrote dead_at to the live path first;
151
+ # simulate a crash *between* the two visible transitions by
152
+ # stubbing the SECOND os.replace/os.rename so it raises, then
153
+ # assert no live .json entry carries dead_at.
154
+ path = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
155
+ _, entry = pending_mod.iter_entries()[0]
156
+
157
+ real_replace = os.replace
158
+ calls = {"n": 0}
159
+
160
+ def replace_fail_after_first(src, dst):
161
+ # First replace = tmp -> dead_path (the marker). Let it run.
162
+ # Any later mutation would be the pre-fix live-path write —
163
+ # there is none in the new single-transition form, but if a
164
+ # regression reintroduces it, blow up here.
165
+ calls["n"] += 1
166
+ if calls["n"] == 1:
167
+ return real_replace(src, dst)
168
+ raise OSError("simulated crash mid-mark_dead")
169
+
170
+ with patch("os.replace", side_effect=replace_fail_after_first):
171
+ pending_mod.mark_dead(path, entry)
172
+
173
+ # No live .json entry may carry dead_at.
174
+ for _p, e in pending_mod.iter_entries():
175
+ self.assertNotIn(
176
+ "dead_at", e, "a live queue entry must never carry dead_at"
177
+ )
178
+ # The .dead marker exists and carries dead_at.
179
+ dead = path + ".dead"
180
+ self.assertTrue(os.path.isfile(dead))
181
+ with open(dead) as f:
182
+ self.assertIn("dead_at", json.load(f))
183
+
184
+ def test_mark_dead_no_tmp_left_behind(self):
185
+ path = pending_mod.enqueue(self._sample_payload(), RuntimeError("boom"))
186
+ _, entry = pending_mod.iter_entries()[0]
187
+ pending_mod.mark_dead(path, entry)
188
+ leftovers = [n for n in os.listdir(self._dir) if n.endswith(".tmp")]
189
+ self.assertEqual(leftovers, [])
190
+
147
191
  def test_count_safe_when_dir_missing(self):
148
192
  self.assertEqual(pending_mod.count(), 0)
149
193