feat(hooks): offline payload spool + drain — buffer unsent events, replay on recovery#4232
Conversation
…ane downtime DNO-498 (DCPD 4), part 1 of 2 — capture. When an ingest exchange ends unsent (unreachable, 5xx, 429/408), the built envelope is persisted under $XDG_STATE_HOME/gram/hooks/spool/, one JSON file per entry so concurrent hook processes never contend (the Windows file lock is a no-op). Entries carry the send's idempotency key — now minted in deliver and threaded through client.send so a replay dedupes against any partially delivered original — plus the deployment identity a drain needs to route and re-resolve auth. Caps at write time: 2000 entries / 64 MiB drop-oldest, 14-day expiry, drops logged. Nothing reads the spool yet; part 2 adds the drain subcommand and opportunistic replay. Placement rationale: device-agent ADR-0011. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
There was a problem hiding this comment.
cubic analysis
All reported issues were addressed across 5 files
Linked issue analysis
Linked issue: DNO-498: DCPD 4: Buffer session/tool-call payloads offline and replay on recovery
| Status | Acceptance criteria | Notes |
|---|---|---|
| ✅ | Durable local on-disk queue for unsent hook payloads (path under $XDG_STATE_HOME/gram/hooks/spool) | The PR creates a spool directory, persists one JSON file per unsent entry, and ensures concurrency-safe filenames. |
| ✅ | Queue caps and retention (count cap, bytes cap, age expiry) with drop-oldest behavior and logging when entries are dropped | Caps are enforced at write time (entry and byte caps), oldest entries are dropped first, 14-day expiry is implemented, and debug logs emitted on drops; trim behavior is covered by tests. |
| ✅ | Preserve original event timestamps and maintain chronological ordering in the spool | The envelope carries the occurred_at set at build time and entries are written with timestamp-prefixed filenames whose lexicographic order is chronological; tests verify filename ordering and that spooled Envelope retains event fields. |
| ✅ | Server-side replay safety via idempotency/dedupe (idempotency key minted and threaded through send and persisted with spooled entries) | An idempotency key is minted in deliver, passed into client.send for all attempts, and stored in the spool entry so future replays will use the original key and dedupe server-side; tests assert the wire header uses the caller's key. |
| ✅ | Decision on buffer placement (hooks-owned spool with device-agent supplying recovery trigger) | The PR implements the spool inside hooks and documents the placement rationale; it explicitly defers replay/drain to part 2 and references the device-agent ADR for the recovery trigger. |
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…al idempotency keys DNO-498 (DCPD 4), part 2 of 2. Top-level `speakeasy-hooks drain` replays spooled entries oldest-first, each under its originally minted Idempotency-Key and an X-Gram-Replayed: 1 marker (DNO-499). Per-entry outcomes: accepted → delete, definitive 4xx or corrupt → drop, unreachable → abort and keep the backlog. Auth re-resolves per deployment from the entry's recorded identity (env key / disk cache / the config file's org key), memoized. Hooks also self-heal: the first successful send after an outage spawns a detached drain, debounced; the device agent's recovery trigger execs the same subcommand for idle machines (ADR-0011). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 9 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…ma edge cases Review follow-ups (DNO-498): auth rejection never deletes backlog (machine state, not event state — mirrors the live path's org-key fallback, then skips the deployment); unparseable entries are skipped, not dropped (a newer binary's event type fails this binary's strict SDK enum); a single oversize entry sheds its Raw debug echo instead of evicting the whole backlog (8 MiB per-entry cap); env keys are pinned to their env-named deployment so a drain never posts one deployment's key to another's server; stale .tmp orphans are swept; Raw round-trips as raw bytes (float64 decoding mutated >2^53 integers); the detached drain gets its own session/process group like agenthooks' async worker; exit 1 only on abort (retryable) rather than any leftovers; outcome classification unified as ingestResult.accepted/unsent; deployment-match rule shared with the auth cache; state-dir resolution shared with codex-tools; test hermeticity guard replaced with an exact-value check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ain file handling Cubic follow-ups: a GRAM_HOOKS_PROJECT_SLUG inherited from the drain's spawning session no longer reroutes another project's entries (stored identity is the routing truth); spoolNanos accepts only the exact writer-produced filename shape so digits-prefixed foreign files are never trimmed or drained; failed unlinks no longer count as replayed/dropped (entry stays, idempotency covers the re-send); the soft-caps-under- concurrency posture is documented as deliberate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 11 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
…sweep race Cubic follow-ups: the drain's byte-level restoration now covers every any-typed envelope field (tool call input/output/error, not just raw) so replays never round >2^53 integers through float64; and a writer whose temp file was swept mid-suspend retries the commit under a fresh name — the data is still in hand, so the sweep can no longer cost an event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves runner.go against #4204 (org-level fail open/closed): main's Relay.send wrapper (org-settings mirroring) now threads the idempotency key through to client.send, composing both changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…anaged prefs) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part of DNO-498 (DCPD 4) — part 1 of 2: capture. Today a payload whose send fails against a down control plane dies with the hook process; this PR spools it for replay so the event still gets scanned/flagged/monitored retroactively. Placement rationale (hooks own the buffer; the device agent supplies the recovery trigger) is in device-agent ADR-0012; plan and decisions are on the Linear ticket.
What
relay/spool.go— when an exchange ends unsent (transport failure, 5xx, or 429/408 — the server didn't store the event; a definitive 4xx never spools), the builtIngestRequestBodyis persisted under$XDG_STATE_HOME/gram/hooks/spool/. One JSON file per entry, named<zero-padded-nanos>-<pid>-<seq>.jsonso lexicographic order is chronological order and concurrent hook processes never contend on a write — load-bearing because the existing file lock is a no-op on Windows (codextools_lock_windows.go).occurred_atwas set at build time, so replay preserves original timestamps), the send's idempotency key, and the deployment identity (server_url/org_id/project_slug/config_path) a drain needs to route and re-resolve auth per entry.deliverand passed throughclient.send(both the first attempt and the org-key auth retry reuse it). This is what makes every redelivery path safe: SDK-internal replays, the org retry, and a future drain of a spooled copy all dedupe server-side against any partially delivered original.Nothing reads the spool yet — this ships safely alone (bounded, aging disk usage; zero behavior change on the wire). Part 2 adds the top-level
speakeasy-hooks drainsubcommand (replay oldest-first with stored keys,X-Gram-Replayed: 1marker) and the opportunistic detached drain after a successful send; the device agent's recovery trigger (merged, dormant) will exec it on agent-managed machines.Tests
client.senduses the caller's idempotency key verbatim.unsentpredicate tables.go test -race ./...green across hooks packages;CGO_ENABLED=0builds for darwin/linux/windows.🤖 Generated with Claude Code
Part 2 (second commit): drain
speakeasy-hooks drain— top-level subcommand (dispatched inmain.goalongsideversion/login/install; the external agenthooks lib is untouched). Takes no arguments: entries carry their own deployment identity. Replays oldest-first (global chronological order ⇒ per-session order), each request under its originally minted Idempotency-Key plusX-Gram-Replayed: 1so the server can distinguish downtime backlog from live traffic (DNO-499 contract).device-agentPR chore(deps): bump k8s.io/apimachinery from 0.33.3 to 0.33.4 in /server #72, merged) execs the same subcommand the moment its detector sees recovery — covering idle machines.Drain tests cover: ordering + stored-key + replay-marker wire assertions, abort-and-keep on a dead server, 4xx drop, expiry-without-send, newer-schema skip, corrupt-entry drop, credential-less skip (exit 1), config org-key fallback (asserted on the wire), spawn-on-recovery with debounce, no spawn on failed sends or empty spools, and exit codes. The
invoketest helper is now hermetic w.r.t.XDG_STATE_HOMEso the suite can never touch (or drain) a developer's real spool.