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.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- 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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
Returns the marker path, or ``None`` if
|
|
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.
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|