58 KiB
Inngest Functions
Canonical notes for packages/system-bus/src/inngest/functions/.
Contract
- Durable workflows only (
step.run,step.sendEvent, etc.). - Retries are mandatory (
retries: 0is forbidden unless explicitly justified in ADR). - Every critical branch emits OTEL evidence.
- Health checks should route remediation via
system/self.healing.requestedand carry playbook context. - Never use
Bun.spawnSyncforjoelclawCLI calls inside handlers that also depend on worker/HTTP probes; use async subprocesses with explicit timeouts to avoid worker event-loop deadlocks. system/agent-dispatchnow supportstool: "pi"for real repo work. Pi dispatch must run from the requestedcwdand enable tools when file work is requested; otherwise Restate PRD story runs inspect the wrong checkout or fail on file-access prompts.- Host-only internal agent bridge requests must be idempotent by
requestId./internal/agent-dispatchshould return the existingrunning|completed|failed|cancelledsnapshot instead of emitting duplicatesystem/agent.requestedevents, andsystem/agent-dispatchshould write arunninginbox snapshot before long agent execution starts so operators do not see a useless forever-pendingstate. system/agent-dispatchimplements requestId-level deduplication: if a terminal result (completed|failed|cancelled) already exists, the function returns that result without spawning a new execution. This prevents duplicate work on retries or repeated dispatches.- Terminal snapshots (
completed|failed|cancelled) always includestdout/stderroutput in thelogsfield (truncated to 10KB each) for post-mortem debugging. These are surfaced via the inbox result and OTEL events. - Cancellation is honored via
cancelOnconfig: sendingsystem/agent.cancelledwith matchingrequestIdkills the active subprocess and writes acancelledinbox snapshot. TheonFailurehandler ensures terminal state is written even if the subprocess is killed mid-execution. system/agent-dispatchsupportsexecutionMode: "host" | "sandbox"(default:"host"). Host mode uses the existing shared-checkout path. Sandbox mode now has two backends:sandboxBackend: "local" | "k8s"(default local), plus a local runtime mode hintsandboxMode: "minimal" | "full"when the local backend is selected. The local backend remains the proved live path on the host worker: it materializes a clean checkout atbaseSha, runs the requested agent inside that sandbox, exports patch/touched-file artifacts, and keeps the operator checkout clean. Gate A (non-coding vertical slice) is proven viapackages/agent-execution/__tests__/gate-a-smoke.test.ts, and Gate B (minimal coding sandbox) is proven viapackages/agent-execution/__tests__/gate-b-smoke.test.ts; the live dispatch path consumes those same repo-materialization and artifact-export primitives. Phase 4 now adds an opt-in full local mode that maps the requested sandboxcwdinto the cloned checkout, discovers compose files relative to that workdir, reserves the sandbox-specificCOMPOSE_PROJECT_NAME, and brings the compose project up before agent execution starts. Dogfood proved one more guardrail is necessary: sandboxed stage runs must not start nested workflow-rig runs from inside the sandbox, so the task contract now forbidsjoelclaw workload run/scripts/verify-workload-full-mode.tsrecursion and the CLI blocks nested workload admission unless explicitly overridden for debugging. Current earned truth: guarded full-mode workflow-rig stage-2 dogfood now completes terminally (WR_20260310_013158), records the requiredfull-mode-ok|full|...proof line inside the returned summary, and tears the compose runtime back down cleanly. The live host-worker path now also has a non-LLM canary tool (tool: "canary") for deterministic verification only: it runs fixed scenario commands such assleep-timeoutandorphan-stderrthrough the same subprocess capture and terminal inbox/registry writeback path without involving Codex, Claude, or Pi. The canonical live timeout proof isbun scripts/verify-agent-dispatch-timeout.ts, the canonical on-demand operator surface isjoelclaw status --agent-dispatch-canary, the default status envelope now surfaceslatestAgentDispatchCanary, and the existing scheduled health pipeline can optionally include the same timeout proof whenHEALTH_AGENT_DISPATCH_CANARY_SCHEDULE=signalsis set in the live host-worker environment. Default remainsoff. The k8s backend has also landed as an opt-in control plane:@joelclaw/agent-executionowns Job spec generation, Job launch/status/log parsing, log-marker result extraction, and thejob-runner.tsruntime contract;system-busnow accepts callback results at/internal/agent-resultand preservesInboxResult.sandboxBackendplus optional Job metadata. For deterministic isolated k8s runs,system/agent.requestedshould now carryworkflowId,storyId,baseSha,repoUrl, andbranch.piremains a local-backend story executor for now; the k8s runner is meant for runner-installed CLIs until host-routed pi-in-pod execution is designed.packages/system-bus/src/lib/inference.tsmust not rely on pipe EOF when capturingpioutput for tool-enabled background work. Tool subprocesses can inherit stdout/stderr, leavinginfer()hung after the realpichild exits and freezingsystem/agent-dispatchin a falserunningstate. Capture to temp files (or another exit-driven sink) and read them afterproc.exitedinstead.packages/system-bus/src/inngest/functions/agent-dispatch.tsuses the same exit-driven temp-file capture rule for codex/claude/bash subprocesses and sandbox infra commands. Waiting onResponse(proc.stdout).text()/ pipe EOF here is a bug class: descendants can inherit the descriptors, block terminal inbox writeback, and strand sandbox runs in fakerunningstate even after the real work already failed or finished.- Explicit
infer({ timeout })budgets are overall request budgets, not per-fallback-attempt budgets. Story 6 proved that clamping each attempt to 10 minutes produced three back-to-back SIGTERM kills (exit 143) and a misleading 30-minute failure chain.inference.tsnow preserves up to a 1-hour request budget and spends the remaining deadline across fallback attempts instead of restarting a fresh timeout per attempt. - Timed-out
piattempts must surface aspi timed out after <ms>instead of the uselesspi exited 143: empty outputmessage. If a subprocess timer fired, the timeout must be explicit in the thrown error and OTEL failure event. - Long-form content-review inference (
content/review.submitted) must set an explicit timeout budget on everyinfer()call instead of inheriting the shared 120s default. Current contract: rewrite/retry/verify all use a 300s budget so long posts do not die inagent-editafter the bookkeeping steps already succeeded. - Post content-review cache invalidation must revalidate the markdown twin as well as the human page. Current contract for
postcontent: tagspost:<slug>,article:<slug>,articles; paths/,/<slug>,/<slug>.md,/<slug>/md,/feed.xml,/sitemap.md. - Video ingest canonical events are
pipeline/video.requested→pipeline/video.downloaded+pipeline/transcript.requested→pipeline/transcript.processed+content/summarize.requested. The host worker also accepts legacy public aliasespipeline/video.download,pipeline/transcript.process, andcontent/summarizebecause older CLI/schema/skills documented those names; never let documented operator events silently produce zero function runs. - VIP delivery functions (
vip/email-received,vip/email-brief) now send the operator brief directly to Telegram viapackages/system-bus/src/lib/telegram.ts.vip/email-receivedno longer pushes a duplicate gateway event for operator delivery, and the generic Front notifier skips VIP senders so one VIP email produces exactly one operator-facing notification. Strip relay-only instructions before direct delivery and returntelegramDelivered/telegramErrorin function output so failures are visible in run traces. vip/email-receivedanalysis/brief inference must stay inside bounded prompt and timeout budgets: compact the prompt to head+tail thread context plus clipped link/memory/repo/access-gap excerpts, include prompt-size metadata on theinfer()calls, and usemaxAttempts: 1when a caller-specific fallback plan is better than spending an overall request budget on a toothless router fallback chain.vip/email-threads.backfillis the one-shot Front history hydrator for Typesenseemail_threads: ensure the collection first, resolve VIP sender emails fromgetVipSenders(), walk/contacts/alt:email:{email}/conversationswith_pagination.next, fetch each conversation's messages, and upsert canonical thread docs so narrative VIP briefs can refer to prior email arcs.- For inference calls that must return machine-readable output, set
json: trueplusrequireJson: true(andrequireTextOutput: truewhere needed) so null/empty outputs are treated as failures, not successes. - ADR pitch automation now has a closed funnel:
system-heartbeatemitsadr/pitch.requestedonce per local morning window (8:00–10:00 America/Los_Angeles) behind Redis keyadr:pitch:last-firedwith a 20h TTL;adr-daily-pitchemits OTELpitch.sent;telegram-callbackemits OTELpitch.responded;adr-pitch-executehandlesadr/pitch.approvedby reading the ADR file from~/Vault/docs/decisions, runningcodex exec --full-auto -C <repo> "<prompt>"(prompt is positional;-pis Codex profile, not prompt), verifyingbunx tsc --noEmit, storing rollback metadata atadr:pitch:rollback:<adr_number>, and sending Telegram success/failure notifications viapushGatewayEvent. - Worker code must not import
packages/cli/src/*via relative paths. Keep recovery-runbook helpers local topackages/system-bus(or move them to a leaf package) and avoid introducing@joelclaw/system-bus↔@joelclaw/sdkdependency cycles that break Turbo/Vercel builds. memory/friction-fixautomation is disabled by default (FRICTION_FIX_AUTOMATION_ENABLEDmust be exactlytrue). It previously checked out branches in the canonical host repo and left the worker onfriction-fix/*; do not re-enable until the implementation uses an isolated worktree/sandbox and cannot mutate the serving checkout.- Host worker
serve.tsmust set a high BunidleTimeoutfor/api/inngest. Self-hosted Inngest registration PUTs can exceed Bun's 10s default under cron/backlog pressure; when that happens the CLI seescurl: (52) Empty reply from server, the worker logs a Bun request timeout, and the runtime keeps stale triggers. conversation-thread-enrichvault lookup is keyword-only unless a real query vector exists. Do not use Typesense empty auto-embedding syntax likeembedding:([], k:N)againstvault_notes; the current schema has no compatible embedded field and fails runs withVector query could not find any embedded fields. Its summarizer must fail open to a source-grounded summary from the thread messages when model JSON is empty or malformed; enrichment should not fail just because a non-critical summary model returned junk.memory/batch-reviewmust not let LLM timeout/retry churn leave the function RUNNING/FAILED for minutes. The LLM step is a best-effort review pass with a short timeout and single fallback attempt; if output is empty or unparseable, returnstatus: "deferred", keep proposals queued, and emitbatch-review.deferredinstead of failing the run.- Agent-loop PRDs are preflight-normalized at runtime (
normalizePrdOrThrow): acceptsacceptance_criteriaplus aliases (acceptance,acceptanceCriteria), defaults missingpasses/priority, and fails fast with explicit errors when story shape is invalid.
Docs Pipeline v2 (ADR-0234)
docs-reindex-v2is the staged reindex path for PDFs. It persists durable artifacts underJOELCLAW_DOCS_ARTIFACTS_DIR(default/tmp/docs-artifacts) as{docId}.md,{docId}.meta.json, and{docId}.chunks.jsonl.- Keep the four durable steps stable:
convert-pdf→classify-summarize→chunk→index-typesense. Re-runs should reuse artifacts wheneverskipExistingArtifactsis true. - Reuse
docs-ingest.tshelpers for PDF extraction, taxonomy classification, chunk-record building, and Typesense schema setup instead of re-implementing classification logic in multiple places. docs_chunks_v2is the new retrieval collection. It uses Typesense auto-embedding withts/nomic-embed-text-v1.5; leavedocs_chunksintact until cutover is complete.- Batch orchestration now flows through
docs-reindex-batch, which resolves PDF targets (provided paths, collection records, or/Volumes/three-body/booksscan) and dispatchesdocs/reindex-v2.requestedin batches of 10.
Key reliability flows
Restate dual-run Phase-1 (ADR-0207)
Restate is introduced as a new-workload durable runtime while Inngest remains primary for existing workflows.
Implemented Phase-1 surfaces:
- k8s runtime manifest:
k8s/restate.yaml- StatefulSet
restatewith pinned imagerestatedev/restate:1.6.2 - ClusterIP service exposing ingress/admin/metrics ports (
8080/9070/9071) - startup/readiness/liveness probes configured
- StatefulSet
- Restate service package:
packages/restate/- demonstrates step chain (
ctx.run), fan-out/fan-in (ctx.serviceClient), approval signal workflow (ctx.promise), and MinIO artifact persistence
- demonstrates step chain (
- deployment registration script:
scripts/restate/register-deployment.sh- registers service endpoint via
restate deployments register - lists deployments post-registration
- registers service endpoint via
- smoke test script:
scripts/restate/test-workflow.sh- runs end-to-end Restate workflow invocation against k8s runtime
- verifies MinIO object write/read round-trip from workflow output
- defaults to
joelclaw/minio; auto-falls back toaistor/aistor-s3-apiwhen MinIO is unavailable
- CLI visibility:
joelclaw restatejoelclaw restate statuschecks runtime/statefulset/service/admin probejoelclaw restate deploymentsshells to Restate CLI listingjoelclaw restate smokeruns end-to-end workflow+MinIO smoke test
Operational boundary for Phase 1:
- Selected ADR-0216 tier-1 cron ownership has now moved off Inngest and onto Dkron → Restate:
check/system-health-signals-scheduleskill-gardentypesense/full-syncmemory/digest-dailysubscription/check-feeds
- Those Inngest functions keep manual/on-demand event triggers where useful, but they no longer own the recurring cron schedule.
- The tier-1 Restate jobs run host-side direct task runners via
scripts/restate/run-tier1-task.tsso soak results reflect actual work execution. - The same host-runner pattern now powers
pi-mono-sync: a Restate DAG that calls--task pi-mono-artifacts-syncto materialize repo docs/issues/PRs/comments/commits/releases into Typesense collectionpi_mono_artifacts, plusmaintainer_profileandsync_statedocuments. - Discovery-family pilot cutover has started behind two reversible flags:
QUEUE_PILOTS=discoverymakesjoelclaw discoverenqueuediscovery/notedinto the shared queue instead of sending directly to Inngest.discovery/notednow accepts routing metadata:site(joelclaw|wizardshit|shared) andvisibility(public|private|archived|migration-only). The current default when omitted issite=joelclaw,visibility=public.runSubscriptionCheckSingleDirect()now does the same for feed-published discovery items on the direct Restate/Dkron path, and this has been proved live withpublishMode: queueon a real subscription check.QUEUE_PILOTS=discovery-capturedmakes thediscovery-capturefunction enqueue its follow-updiscovery/capturedevent into the shared queue instead of usingstep.sendEventdirectly.discovery-capturenow resolves routing metadata from the written note, computes afinalLink, and returns/forwardssite,visibility, andfinalLinkin its completion payload.discovery-capturegives the note-generation model a 5-minute budget with no fallback chain. If generation still times out or errors, it writes a source-grounded degraded fallback note (captureStatus: degraded,needs-review) with a source fingerprint in the filename/slug, then continues todiscovery/capturedwithcaptureStatus: degradedso/coolcaptures do not disappear behind a model stall.- the legacy Inngest
subscription/check-singlepath still usesstep.sendEventfor now, so pilot cutover remains incremental and reversible.
- Subscription-request pilot cutover has now started behind
QUEUE_PILOTS=subscriptions:- unscoped
joelclaw subscribe checkenqueuessubscription/check-feeds.requestedinto the shared queue instead of posting directly to Inngest. - the queue registry now stores the concrete Inngest event name (
subscription/check-feeds.requested) as the drainer target; using function ids likesubscription/check-feedsis wrong because the drainer posts events, not function identifiers.
- unscoped
- GitHub webhook ingress is now the third pilot family behind
QUEUE_PILOTS=github:POST /webhooks/githubkeeps direct emission as the default path.- when the flag is enabled, normalized
workflow_run.completedwebhook events are persisted into the shared queue first and the Restate drainer forwards the concretegithub/workflow_run.completedevent onward. github/package.publishedremains on the legacy direct-to-Inngest path for now.
content/updatedis the remaining launchd-backed pilot family behindQUEUE_PILOTS=content:- canonical watcher source now lives in
infra/launchd/com.joel.content-sync-watcher.plistandscripts/content-sync-watcher.sh. - the watcher reads
~/.config/system-bus.envon each trigger and choosesjoelclaw queue emit content/updatedwhen thecontentpilot is enabled, otherwise it falls back to legacyjoelclaw send content/updated. - this keeps the queue cutover reversible without leaving an untracked plist in
~/Library/LaunchAgentsas the only source of truth.
- canonical watcher source now lives in
- Story 5 operator surface now starts from
joelclaw queue statsinstead of ad-hoc Redis/OTEL spelunking:- the command queries
queue.dispatch.started|completed|failedOTEL from the Restate drainer and summarizes sample coverage, success/failure counts, queue wait-time percentiles, dispatch durations, promotion count, top event families, and recent failures. metadata.waitTimeMsfromqueue.dispatch.startedis the canonical Phase-1 queue-to-dispatch latency signal.joelclaw queue stats --since <iso|ms>can pin the lower bound to a known-clean moment (for example the supervised Restatequeue.drainer.startedafter rollout) so Story 5 soak evidence does not mix fresh traffic with a dirty pre-fix window.- this is the first human sanity-pass tool for the "Joel can inspect queue state from CLI only" Story 5 acceptance gate.
- the command queries
- Phase 2 Story 1 now defines the bounded queue-triage contract in
packages/system-bus/src/lib/queue-triage.ts:- Haiku is the canonical triage model (
MODEL.HAIKU) for this queue-admission layer. - the model may only shape
priority,dedupKey, androuteCheck(confirm|mismatch). It may not invent handler targets or override the registry route. - canonical fallback reasons are
disabled|timeout|model_error|invalid_json|schema_error|unsafe_override. - canonical OTEL vocabulary is
queue.triage.started|completed|failed|fallbackwitheventId,correlationId,family,mode, and latency metadata. - the queue envelope can now carry optional
trace.correlationId|causationIdplus optionaltriagemetadata without making the deterministic queue core depend on model output for correctness.
- Haiku is the canonical triage model (
- Phase 2 Story 2 now routes queue admission through one canonical server-side surface in
packages/system-bus/src/lib/queue.ts:- worker-local ingress paths (
discovery-capture, direct subscription discovery publish, GitHub webhook queueing) callenqueueRegisteredQueueEvent()directly. - edge clients (
joelclaw queue emit,joelclaw workload run,joelclaw discover, queue-modejoelclaw subscribe check) post raw event intent toPOST /internal/queue/enqueueso they stay thin and stop writing Redis directly.
- worker-local ingress paths (
joelclaw workload runis now the canonical workload-to-runtime bridge: it normalizes a saved workload artifact into queue familyworkload/requested, and the static queue registry routes that family to the real runtime eventsystem/agent.requested.- base triage remains opt-in via
QUEUE_TRIAGE_MODE=shadow|enforceplusQUEUE_TRIAGE_FAMILIES=discovery,content,subscriptions,github(or exact event names). QUEUE_TRIAGE_ENFORCE_FAMILIES=discovery,githubis the narrow Story 4 override that upgrades onlydiscovery/notedandgithub/workflow_run.completedinto enforce while leaving other enabled families in shadow.- handler routing stays registry-derived even in enforce mode; triage only shapes bounded admission fields.
- base triage remains opt-in via
- Phase 2 Story 3 extends
joelclaw queue statsinto the triage operator surface:- the command now reads both Restate drainer OTEL and
queue.triage.*OTEL in one window. - the triage block summarizes attempts, fallback counts by reason, disagreement counts, applied-vs-suggested deltas, route mismatches, latency percentiles, per-family rollups, and recent mismatch/fallback samples.
- if Story 3 cannot explain queue-admission behavior from this one command, the operator surface is still unfinished.
- the command now reads both Restate drainer OTEL and
- Phase 3 Story 1 now defines the bounded Sonnet observation contract in
packages/system-bus/src/lib/queue-observe.ts:- the canonical queue snapshot builder produces
QueueObservationSnapshotwith totals, per-family rollups, triage summary, drainer summary, gateway reporting state, and current active deterministic pauses so resume suggestions are grounded in real control state. - Sonnet is the canonical observation model (
MODEL.SONNET) for this layer and still goes through the sharedinfer()path. - the observer may only return the bounded action enum (
noop|pause_family|resume_family|reprioritize_family|batch_family|shed_family|escalate), and families must already exist in the supplied snapshot queue families or active pause state. - canonical fallback reasons are
disabled|timeout|model_error|invalid_json|schema_error|unsafe_action. - canonical OTEL vocabulary is
queue.observe.started|completed|failed|fallbackplusqueue.control.applied|expired|rejected. - overly long Sonnet summaries are now trimmed instead of causing bogus schema fallbacks during live canaries.
- Story 1 stops short of the deterministic pause/resume control plane:
finalActionsare safety-filtered, but no automatic queue mutation exists yet.
- the canonical queue snapshot builder produces
- Phase 3 Story 2 adds the dry-run operator surface on the installed CLI:
joelclaw queue observebuilds the live snapshot from current queue state, recent drainer OTEL, recent triage OTEL, and gateway sleep/muted-channel state before calling the bounded Sonnet observer.- when the backlog is entirely explained by fresh active manual pauses and there are no recent drainer/triage failures, the shared observer contract now short-circuits to a deterministic
noopinstead of burning the full Sonnet timeout on an obvious operator hold state. - when queued work is entirely held behind a settled active observer pause and there are still no fresh drainer/triage failures, the shared observer now normalizes that self-imposed hold back to
downstreamState=healthyand short-circuits to a deterministicresume_familyinstead of mistaking zero dispatch throughput for ongoing downstream failure. - the command returns the current
snapshot, the current dry-rundecision,historyfromqueue.observe.*OTEL, and the current deterministiccontrolblock. - Story 2 keeps all automatic queue control mutations absent;
appliedCountremains zero and the CLI must say so plainly.
- Phase 3 Story 3 adds the deterministic queue-control plane before any automatic Sonnet mutation:
@joelclaw/queuenow owns Redis-backed family pause state plus deterministicpauseQueueFamily,resumeQueueFamily,expireQueueFamilyPauses, andlistActiveQueueFamilyPauseshelpers.packages/restate/src/queue-drainer.tsreaps expired pauses, emitsqueue.control.expired, and filters paused families out of dispatch candidates without dropping queued work.- the installed CLI now exposes
joelclaw queue pause,joelclaw queue resume, andjoelclaw queue control status. - queue operator commands resolve Redis from the canonical CLI config (
~/.config/system-bus.env→REDIS_URL) before ambient shell env so manual controls target the same localhost queue as the worker and drainer.
- Phase 3 Story 4 now has a live cluster-worker runtime path in
packages/system-bus/src/inngest/functions/queue-observer.ts:- the durable cron controller stays on
queue/observer(TZ=America/Los_Angeles */1 * * * *), while manualqueue/observer.requestedprobes now run through a separatequeue/observer-requestedfunction so operator requests do not queue behind the cron pass. memory/observe-session,queue/observer,queue/observer-requested, andcheck/memory-revieware owned by the cluster worker so callbacks stay inside Kubernetes viahttp://system-bus-worker:3111; they must not depend on host-only filesystem, GUI, or local CLI state.memory/observe-sessionmust tolerate missing host-only model tooling by using deterministic extraction/embedding fallbacks while still writing canonical Typesense records. The cluster deployment must overrideTYPESENSE_URL=http://typesense:8108; inherited hostlocalhost:8108config is wrong inside the pod.- Host-owned functions must advertise an SDK callback URL that the Inngest pod can actually reach. On Panda the host worker uses
INNGEST_SERVE_HOST=http://100.93.201.72:3111;host.lima.internal/host.docker.internalare not valid unless a live pod-to-host probe proves the alias reaches macOS. - runtime flags are
QUEUE_OBSERVER_MODE=off|dry-run|enforce,QUEUE_OBSERVER_FAMILIES=discovery,content,subscriptions,github,QUEUE_OBSERVER_AUTO_FAMILIES=content, andQUEUE_OBSERVER_INTERVAL_SECONDS(currently clamped to 60s minimum on the durable cron path). - both paths build the same bounded snapshot and call Sonnet through
infer(), but only the cron controller may auto-applypause_family,resume_family, andescalate; manual probes are read-only even if the configured mode isenforce. - the shared observer short-circuits deterministic noops for both truly empty queues and empty queues that only still have active pauses hanging around, so the cron path does not waste a full model call on obvious nothing-to-do snapshots.
- idle empty snapshots with no recent drainer failures or triage trouble now report
downstreamState=healthyinstead of inheriting a noisy degraded label from stale throughput/latency history. - settled observer-held backlogs now get the same treatment: if all queued work is already behind an active observer pause, that pause is at least one cadence old, and no fresh drainer/triage failures exist, the shared observer deterministically emits
resume_familyinstead of treating its own hold as proof that downstream is still down. - manual probes use singleton-skip semantics so repeated operator requests do not pile up stale queued runs.
- the prompt contract is now hardened for live canaries:
content/updatedexplicitly preferspause_family/resume_familyoverbatch_family, and legacy model output that still sendsescalate.reasonis normalized to the required{ severity, message }shape instead of forcing a full schema fallback. - operator reports flow through
gateway/send.message, while real queue mutations still emitqueue.control.applied|rejected. - current live truth: the first supervised enforce canary anchored at
since=1772981290859proved the cron observer can auto-apply a realpause_familyoncontent/updated(snapshotId=cca656f7-a9ce-4ca2-9f6d-0ed332f56a4d). The follow-up supervised canary anchored atsince=1772985057594then proved the patched host worker can auto-apply the matchingresume_family: it paused on snapshot1cb24e7b-f0cd-4e0c-ae5d-27cb4934b49a, resumed on snapshot151aa03a-fced-41f0-9a54-2f3d1a70856d/ run01KK72HD0EMT3T34K8QP3SMEW9, and drained the heldcontent/updateditem back to queue depth0. The worker was then rolled back toQUEUE_OBSERVER_MODE=dry-runas the conservative steady state.
- the durable cron controller stays on
- Do not migrate tier-2 cron candidates until the Dkron/Restate tier-1 soak shows clean execution and observable failure behavior.
Slack work-state pass
- function:
slack-work-state-pass - file:
packages/system-bus/src/inngest/functions/work-state-pass.ts - triggers: hourly cron (
17 * * * *) plusslack/work-state.pass.requestedfor supervised runs. - truth: one bounded live
conversations.historyread per allowlisted channel;channel_messagesis never the candidate boundary. Joel-owned root reactions classifyuntagged,started, andshipped. - runtime config lives in
~/.config/system-bus.env:WORK_STATE_PASS_ENABLED=0|1WORK_STATE_PASS_CHANNELS=C0211NSK3TP:cc-matt-p,C09LKT871PE:brain-joelWORK_STATE_PASS_UNTAGGED_AFTER_HOURS=4WORK_STATE_PASS_STARTED_STALE_AFTER_DAYS=7WORK_STATE_PASS_HISTORY_LIMIT=200WORK_STATE_PASS_WAKE_MODE=notify|off- optional path overrides:
WORK_STATE_PASS_OBSERVATIONS_DIR,WORK_STATE_PASS_STATE_PATH,WORK_STATE_PASS_LAST_RUN_PATH - supervised proof only:
WORK_STATE_PASS_SEEDED_PROOF_ENABLED=0|1; leave it0outside a seeded canary.
- findings write a sensitive
.svxobservation page without Slack message bodies. The notified-state JSON only suppresses repeat wakes; Slack reactions remain canonical. Wake retries carry a stablejoelclaw notify --event-id; the gateway Redis adapter atomically deduplicates that UUID for seven days. Expired reservations reconcile against that Redis delivery receipt before a changed batch is assembled. - funnel:
bun scripts/work-state-pass-funnel.ts; per-stage evidence:joelclaw otel search "slack.work_state.pass" --component work-state-pass --hours 24 --limit 100.
System health
- function:
system/check-system-health - file:
packages/system-bus/src/inngest/functions/check-system-health.ts - inputs:
- cron heartbeat checks
system/health.check.requested
- checks include core services (
Inngest,Worker,Redis, etc.) and mount probes. - current truth guards:
- Agent Secrets health is based on
secrets status, notsecrets health; the latter can time out under load and falsely report the daemon as down even while leases and audit traffic still work. - Webhooks health probes the actual
/webhooksroute through the shared endpoint resolver (localhost -> Colima VM IP -> service DNS) instead of assuming the host worker localhost path is the only truthful surface.
- Agent Secrets health is based on
Self-healing router
- function:
system/self-healing.router - file:
packages/system-bus/src/inngest/functions/self-healing-router.ts - input event:
system/self.healing.requested - responsibility: apply retry/defer/escalate policy and emit target remediation events.
Self-healing investigator
- function:
system/self-healing.investigator - file:
packages/system-bus/src/inngest/functions/self-healing-investigator.ts - current guardrails:
- the initial
list-failed-runsGraphQL scan now uses a longer timeout budget plus one bounded retry on abort-like failures; the investigator was tripping on the same slow-detail surface asjoelclaw runand falsely timing out before it could inspect evidence. - downstream per-run inspection still shells through
joelclaw run, so CLI detail timeout fixes directly improve investigator stability too.
- the initial
Inngest runtime remediation (new)
- function:
system/self-healing.inngest-runtime - file:
packages/system-bus/src/inngest/functions/self-healing-inngest-runtime.ts - triggers:
- cron:
TZ=America/Los_Angeles */10 * * * * system/inngest.runtime.health.requestedsystem/self.healing.requested(domain-filtered)
- cron:
- behavior:
- probe runtime health (
joelclaw inngest status) - if degraded and not dry run, run
joelclaw inngest restart-worker --register --wait-ms 1500 - re-probe and emit before/after OTEL evidence
- probe runtime health (
Host worker rollout reality
The running host worker is launched by com.joel.system-bus-worker through worker-supervisor, and the current launchd/plist + supervisor config points at the main monorepo checkout:
- supervisor working dir:
~/Code/joelhooks/joelclaw/infra/worker-supervisor - worker dir:
~/Code/joelhooks/joelclaw/packages/system-bus - worker entry:
bun run src/serve.ts
After changing host-role functions in the monorepo:
- restart launchd:
launchctl kickstart -k system/com.joel.system-bus-workerif permitted, or kill the Bun child and let the system LaunchDaemon'sworker-supervisorrestart it - re-register functions:
curl -X PUT http://127.0.0.1:3111/api/inngest - verify with
joelclaw inngest statusor a targeted synthetic event
Reboot recovery gotchas that bit for real:
localhost:3111belongs to the host worker, not the Talos container. Ifdocker inspect joelclaw-controlplane-1still shows a stale3111/tcpport binding whilek8s/system-bus-worker.yamlisClusterIP, remove that Docker binding or the host worker cannot bind and Inngest runs fail withUnable to reach SDK URL.com.joel.system-bus-workeris a system LaunchDaemon on Panda. Talon must detect it throughlaunchctl print system/com.joel.system-bus-worker; checking onlylaunchctl list <label>in the user bootstrap domain creates a false negative and starts a second supervisor that fights over port 3111.- If the reboot lands in a headless/non-Aqua session and the system worker is truly unavailable, start
worker-supervisormanually with the launchd env (HOME,PATH,VAULT_PATH,WORKER_ROLE=host,INNGEST_DEV=1for local/self-hosted Inngest) until launchd is back. The SDK must run in dev mode against local self-hosted Inngest callbacks, otherwise it expects signed cloud callbacks and functions fail withNo x-inngest-signature provided.
Do not rely on stale instructions about a separate ~/Code/system-bus-worker/ checkout unless the launchd plist/supervisor config has been deliberately changed back to that topology.
Host worker startup preflight + supervisor OTEL
- component:
infra/worker-supervisor/src/main.rs - applies to host worker launch path (
com.joel.system-bus-worker) - behavior:
- before spawning
bun run src/serve.ts, supervisor runs a host-import preflight:bun --eval "await import('./src/inngest/functions/index.host.ts');"
- if preflight fails (syntax/import regression), supervisor does not spawn worker, logs clipped Bun error, and retries with exponential backoff
- supervisor now emits explicit OTEL events through
joelclaw otel emit:worker.supervisor.preflight.failedworker.supervisor.worker_exitworker.supervisor.health_check.restart
- before spawning
Operational impact: startup regressions are surfaced immediately with structured telemetry instead of looking like generic flapping.
Stale RUNNING run forensics (SDK outage fallout)
Observed failure mode during worker/runtime blips:
- Inngest server logs show
Unable to reach SDK URL/EOF writing request to SDK. - Historical runs can remain listed as
RUNNINGeven when there is no live cancellable execution. cancelRuncan returnnot foundfor those stale IDs.Unable to reach SDK URLis not always a raw reachability outage. A real example fromcheck/o11y-triageon 2026-03-08: nestedstep.sendEvent(...)calls insidestep.run(...)producedNESTING_STEPSwarnings, left a run stuck inRUNNINGafter early steps completed, and blocked newer runs behind the concurrency partition. If worker stderr showsNESTING_STEPS, fix the function shape before assuming the server/runtime is broken.
Operational contract:
- Trust run detail over list
- use
joelclaw run <run-id>to inspect trace/errors first.
- use
- Use backup-first DB surgery only when needed
- runtime DB:
kubectl -n joelclaw exec inngest-0 -- sqlite3 /data/main.db - backup first:
.backup /data/main.db.pre-sweep-<ts>.sqlite
- runtime DB:
- Terminalize stale runs with full state updates
- ensure terminal history row exists (
FunctionCancelled), - ensure
function_finishesrow exists, - then set
trace_runs.status=500for stale candidates.
- ensure terminal history row exists (
- Re-verify
- run detail should resolve terminal status,
joelclaw runs --status RUNNINGshould reflect only active runs.
Preferred operator path: joelclaw inngest sweep-stale-runs (preview by default; --apply performs backup + transactional terminalization).
Do not mutate main.db without a point-in-time backup.
Conversation thread intelligence (ADR-0237)
- files:
packages/system-bus/src/inngest/functions/channel-message-classify.tspackages/system-bus/src/inngest/functions/conversation-thread-aggregate.tspackages/system-bus/src/inngest/functions/conversation-thread-enrich.tspackages/system-bus/src/inngest/functions/conversation-thread-stale-sweep.ts
- collections:
channel_messagesnow stores MiniLM embeddings plus workload taxonomy fields (primary_concept_id,concept_ids,taxonomy_version,concept_source)conversation_threadsstores lightweight thread aggregates and enrichment output (summary,related_projects,related_contacts,vault_gap,needs_joel)
- schema maintenance guard:
ensureChannelMessagesCollection()must never include Typesense fieldidin PATCH payloads.idis implicit/immutable there, and trying to re-patch it floodschannel-message-ingestwithField \id` cannot be altered.` failures plus pointless o11y escalations.
- event flow:
channel/message.received→channel-message-ingestchannel/message.classify.requested→channel-message-classifyconversation/thread.updated→conversation-thread-aggregateconversation/thread.enrichment.requested→conversation-thread-enrich- hourly cron →
conversation-thread-stale-sweep
- Slack historical backfill:
- functions:
slack-backfill-batchandslack-channel-backfill - file:
packages/system-bus/src/inngest/functions/slack-backfill.ts - triggers:
channel/slack.backfill.batch.requestedandchannel/slack.backfill.requested - runtime: host worker, because it leases
slack_user_tokenthrough the localsecretsCLI; do not move this to the cluster worker unless token leasing is replaced with an explicit cluster-safe adapter - target collection: legacy flat
slack_messages; current realtime channel intelligence still writeschannel_messages/conversation_threads - fetches top-level channel history, expands
reply_count > 0parents throughconversations.replies, and also indexes boundedsearch.messagesmatches for the requested channel/date window because Slack search returns active replies even when the thread parent is older than the backfill window - use for catch-up windows when realtime Slack events miss ambient channel traffic or when a daily summary needs channel-wide context
- functions:
- current scope:
- thread aggregation/enrichment is enabled for
slackandemail - email uses the Front conversation id as both
channelIdandthreadId channel-message-classifynow acceptsemailmessages and emits workload concept metadata- classifier JSON parsing must tolerate markdown-fenced or prose-wrapped model output before failing; strict raw-JSON-only parsing was too brittle and produced false
classification response was not valid JSONfailures even when the model returned usable structured content - if classifier output is still malformed after parsing attempts, the function must degrade to heuristic fallback classification (
conceptSource: "fallback") and emitchannel.message.classify_fallbackinstead of failing the workflow channel-message-classifymust upsert a fullchannel_messagesdocument (includingtimestampand the base message fields), not just classification deltas; Typesense rejects partial upserts when the collection'sdefault_sorting_fieldis missing
- thread aggregation/enrichment is enabled for
- debounce contract:
- enrich immediately for new threads
- re-enrich after
5new messages - re-enrich after a
30mgap when new messages arrive - mark threads
staleafter48hidle without re-running enrichment
Conversation annotation pipeline (ADR-0225)
- function:
conversation/annotate - file:
packages/system-bus/src/inngest/functions/conversation-annotate.ts - trigger event:
conversation/annotate.requested - host-only runtime reason: shells to
joelclaw email read, usesinfer()for structured JSON summarization, pushes gateway notifications, and persists markdown notes under~/Vault/Resources/conversations/ - contract:
- fetches the Front conversation via CLI and emits
conversation.annotate.triggered|fetchedOTEL - skips unchanged threads via Redis key
conversation:annotate:{conversationId}:last_countwith 4h TTL and emitsconversation.annotate.dedup_skipwhen unchanged - summarizes into strict JSON (participants, decisions, action items, links, urgency, Joel input) and emits
conversation.annotate.summarized - pushes summary + Joel-action notifications through the gateway and persists a Vault markdown note, with OTEL at every step and
conversation.annotate.failedon failure
- fetches the Front conversation via CLI and emits
Task triage classification contract
- function:
tasks/triage - file:
packages/system-bus/src/inngest/functions/task-triage.ts - behavior:
- scopes review to the human-facing task surface only (
Joel's Tasks+Questions for Joel) and excludes machine backlog by default - resolves Todoist project IDs to names before filtering so triage can work against real API payloads
- enforces strict JSON classification schema (
id,category,reason) for every visible task ID - retries once with a repair prompt when output is invalid
- returns
status: degraded(not success) when classification remains invalid/null - sets cooldown only when a gateway notification is actually pushed
- emits telemetry with
classificationValid,triageItemsCount,actionableCount,outputFailureReason, and excluded-task counts
- scopes review to the human-facing task surface only (
O11y triage singleton contract
- function:
check/o11y-triage - file:
packages/system-bus/src/inngest/functions/o11y-triage.ts - behavior:
- uses
singleton: { key: '"global"', mode: "skip" }in addition to concurrency so duplicate cron/manual scans do not pile up stale queued runs behind one long active triage pass - manual triggers may therefore be skipped while another triage run is already active; that is intentional because this function scans current state rather than processing an irreplaceable payload
- tier-3 o11y classifications are log-only by default (
O11Y_TIER3_OPERATOR_ACTIONS=log): emittriage.operator_action_suppressedandtriage.escalatedOTEL with runbook metadata, but do not create Todoist tasks, dispatch codex, or send Telegram alerts - temporary supervised operator paging can be re-enabled with
O11Y_TIER3_OPERATOR_ACTIONS=notify, but this is opt-in because ADR-0189 treats meta-system chatter as non-operator-facing unless explicitly requested - if operators see queued build-up again, treat it as a regression in singleton or runtime truth rather than expected behavior
- uses
Email triage + nag contract
- functions:
check/email-triage(packages/system-bus/src/inngest/functions/check-email.ts)email-nag(packages/system-bus/src/inngest/functions/email-nag.ts)
- behavior:
- triage is nag-first for human senders (
reply-neededdefault for real-person emails) interestingis a first-class escalation action (never silently archived)- escalation notifications include direct Front deep links (
https://app.frontapp.com/open/<conversationId>) email-nagruns on cron0 17,22 * * *(9am/2pm PST), leasesfront_api_token, and only nags for inbound-last conversations waiting>4h- Front list/search responses may return
_pagination.next: null; the Front adapter must tolerate that shape and treatunread: trueas Front's supportedis:unrepliedquery instead of the invalidis:unreadfilter - nag digests are sorted oldest-first and delivered through
pushGatewayEvent
- triage is nag-first for human senders (
VIP email brief contract
- function:
vip/email-brief - file:
packages/system-bus/src/inngest/functions/vip-morning-brief.ts - triggers:
- cron:
30 13 * * 1-5(13:30 UTC weekdays; ~6:30am PT during DST) - cron:
0 17 * * 1-5(17:00 UTC weekdays; ~10:00am PT during DST) - cron:
0 22 * * 1-5(22:00 UTC weekdays; ~3:00pm PT during DST) - cron:
0 2 * * 2-6(02:00 UTC Tue-Sat; ~7:00pm PT on the prior weekday during DST)
- cron:
- behavior:
- ensures the
email_threadsTypesense collection exists, queries non-archived VIP thread cache entries sorted bylast_message_at:desc, and degrades tonoopif Typesense is unavailable or the cache is empty - classifies up to 20 cached VIP threads into priority buckets within a 30-day attention window:
dangling(Joel owes a reply),new activity(recently active but not dangling), andstale(open with 7+ days of inactivity). Older threads are ignored until fresh inbound activity updateslast_message_at. - returns
{ status: "noop", reason: "no-signal" }without notifying Telegram when all three buckets are empty - generates a Telegram-ready VIP email brief through
infer()with a deterministic fallback formatter if model generation fails - sends the final brief directly to Telegram only; it does not enqueue a
vip.email.briefgateway event, because that creates duplicate Signal Digest spam for a brief already formatted for Joel
- ensures the
VIP email contextual analysis contract
- function:
vip/email-received - file:
packages/system-bus/src/inngest/functions/vip-email-received.ts - behavior:
- keeps newsletter detection + auto-archive unchanged for low-signal VIP senders
- fetches the full Front thread via pagination (up to 50 messages) with sender + timestamp + full text, then caches the thread in Typesense
email_threads - follows up to 5 interesting links from the latest email via
defuddleand persists extracted content intofollowed_links_json - compacts prompt-only context before
infer()runs: head/tail thread sampling, clipped message/link/memory/repo/access-gap excerpts, and prompt-budget omission markers keep VIP analysis inside realisticpilatency bounds without changing cached source data - uses quality-first default budgets (90s overall, 20s brief window, 45s Opus window) while still honoring env-var overrides
- pins each
infer()call to a single router attempt because the function already has deterministic fallbacks (buildFallbackOperatorBrief, empty-analysis degradation) and should not burn the full budget on hidden router retries - sends a direct Telegram operator brief as concise narrative prose with calibrated urgency (
🔴🟠🟡🟢✅), explicit reply-state language, and a direct Front deep link; the relay now suppressesvip.email.receivedso the direct send stays single-delivery, and timing data stays in function output for observability only - preserves Todoist task extraction and memory
echo-fizzledispatch after analysis
VIP email thread backfill contract
- function:
vip/email-threads.backfill - file:
packages/system-bus/src/inngest/functions/vip-email-backfill.ts - trigger:
vip/email-threads.backfill - behavior:
- ensures Typesense
email_threadsexists before any Front fetches - resolves configured VIP senders to concrete sender emails (defaults now include Alex Hillman's
alex@indyhall.orgalias so the backfill can find the canonical Front contact) - paginates Front contact conversations via
GET /contacts/alt:email:{email}/conversations - fetches each conversation's messages via
GET /conversations/{id}/messages, normalizes sender/text/timestamps, and derives Joel reply state - upserts each thread into
email_threadswith the shared cache-document builder so live VIP email ingestion and historical backfill use the same schema
- ensures Typesense
Channel intelligence triage to Todoist
- function:
channel-intelligence-todoist - file:
packages/system-bus/src/inngest/functions/channels/channel-intelligence-todoist.ts - trigger:
channel/intelligence.triage.requested - behavior:
- scans Front unreplied inbox via
joelclaw email inbox -q "is:open is:unreplied" -n 50 - leases
slack_user_tokenand searches VIP channels + Joel mentions over configurable lookback window - extracts concrete verb-first action items using
infer()with strict JSON output - creates Todoist tasks via
todoist-cli addwith priority mapp2→3,p3→2,p4→1 - returns summary payload with
tasksCreated, per-source scan counts, and task outcomes
- scans Front unreplied inbox via
Channel intelligence garden
- function:
channel-intelligence-garden - file:
packages/system-bus/src/inngest/functions/channels/channel-intelligence-garden.ts - triggers:
- cron:
0 */6 * * * channel/intelligence.garden.requested
- cron:
- behavior:
- pulls active Todoist tasks (
todoist-cli list --json) plus inbox snapshot (todoist-cli inbox --json) - scopes to channel-intelligence tasks (email/slack source markers) and computes duplicate/consolidation plans
- checks email-linked tasks against Front (
joelclaw email read --id <conversationId>) and auto-completes archived/replied threads - evaluates stale low-priority tasks (>3 days, no progress) via
infer()for keep/escalate/complete/delete decisions - applies actions (complete/delete/priority update/description consolidation) and returns
{ reviewed, completed, deleted, escalated, duplicatesRemoved }
- pulls active Todoist tasks (
Memory proposal triage review-task contract
- function:
memory/proposal-triage - file:
packages/system-bus/src/inngest/functions/memory/proposal-triage.ts - behavior:
- proposal triage result (
auto-reject|auto-merge|needs-review|llm-pending) is authoritative; Todoist task creation is a downstream side effect - Todoist create failures (including
HTTP 403 Forbiddenauth issues) must not fail the function run - for
needs-review, the worker attempts project targets in order:MEMORY_REVIEW_TODOIST_PROJECT(defaultAgent Work)MEMORY_REVIEW_TODOIST_FALLBACK_PROJECTonly if it resolves to another machine-facing project; human-facing projects are rejected
Joel's TasksandQuestions for Joelare blocked as machine-review fallback targets under ADR-0238- task-create outcomes are persisted on the proposal hash (
reviewTaskStatus,reviewTaskId,reviewTaskProjectId,reviewTaskError,reviewTaskLastAttemptAt) - emits explicit task-create telemetry:
proposal-triage.review-task.created(includesprojectId,projectFallbackUsed,attempts)proposal-triage.review-task.failed(includes attempted project list + any blocked human-facing projects)
- proposal triage result (
Content sync ADR frontmatter resilience
- library:
packages/system-bus/src/lib/convex-content-sync.ts(upsertAdr) - behavior:
- malformed ADR frontmatter no longer blocks ADR publication into Convex
- on frontmatter parse failure, sync falls back to body-only parsing (
data = {}+ frontmatter block stripped from content) - warning is logged with file path + parse error for operator visibility
- impact: transient YAML/frontmatter edits no longer leave new ADRs missing from
/adrs; they degrade to default metadata (status: proposed, empty rubric) until frontmatter is fixed
Runs-based memory capture (ADR-0243)
- function:
memory/run.captured - file:
packages/system-bus/src/inngest/functions/memory/run-captured.ts - trigger:
memory/run.captured - derived indexes:
runs_dev- SQLite
sessions.dbfor full transcript search (run_chunks_devretired 2026-07-20)
- source of truth:
~/.joelclaw/runs-dev/<user>/<yyyy-mm>/<run-id>.jsonlplus.metadata.json - live ingest endpoint while Panda is Central:
POST https://panda.tail7af24.ts.net/api/runs, served by the host system-bus worker onlocalhost:3111through Tailscale Funnel root proxy. This mirrors the web app ADR-0243 route because no durablelocalhost:3000Central web listener currently exists on Panda. - auth: satellite hooks send
Authorization: Bearer <~/.joelclaw/auth.json token>; the worker hashes the token and resolves identity from persistent local SQLite~/.joelclaw/capture-auth.db. A five-minute background sync mirrors the four Machine records from Typesensemachines_dev; known-token requests do not call Typesense. - recovery rule: if raw run blobs exist but Typesense is stale, restart Inngest only after logging the restart, force worker registration with
PUT /api/inngest, then backfill missing blobs with:
TYPESENSE_API_KEY=$(secrets lease typesense_api_key) \
bun scripts/backfill-run-typesense.ts \
--since <iso-or-ms> \
--machine dark-wizard \
--runtime pi \
--limit 0 \
--sleep-ms 250
- validation gates:
joelclaw runs --count 5 --hours 1 --compactreturns quickly and shows recentmemory/run.capturedcompletionsruns_devlatestagent_runtime:=pidoc advances past the stale timestamp- SQLite
sessions.dbcontains the samerun_idand its FTS chunks - OTEL emission uses the canonical single-object
emitOtelEvent({ level, source, component, action, success, metadata })contract; old two-argument calls are type failures and should not be reintroduced
Knowledge turn-write contract (ADR-0202)
- function:
knowledge-turn-write - file:
packages/system-bus/src/inngest/functions/knowledge-turn-write.ts - trigger:
knowledge/turn.write.requested - behavior:
- validates turn payload shape and skip-reason enum
- enforces summary-or-skip policy (
summaryrequired unless explicit skip reason) - writes
turn_notedocuments intosystem_knowledge - emits OTEL lifecycle events:
knowledge.turn_write.startedknowledge.turn_write.completedknowledge.turn_write.skippedknowledge.turn_write.failed
Watchdog extension:
knowledge-watchdognow checks drift betweenknowledge.turn_write.eligibleand accounted outcomes (completed + skipped + failed) and alerts on mismatch.
Backup hardening
Typesense backup + snapshot retention
- function:
system/backup.typesense - file:
packages/system-bus/src/inngest/functions/nas-backup.ts - snapshot creation supports primary→fallback root selection.
- the source adapter supports native Flagg Typesense (
local, default) and the retired pod path (k8s). - native snapshots stay on local scratch storage until the backup transport copies them to NAS.
- backup transport now uses a three-tier write policy:
- local NAS mount (
/Volumes/three-body) - direct remote copy over SSH/SCP to NAS
- local deferred queue spool (when both NAS paths are unavailable)
- local NAS mount (
- after successful sync/defer:
- delete the just-created snapshot directory at its source
- prune old snapshot directories by retention count
Environment variables:
TYPESENSE_SNAPSHOT_SOURCE(localdefault; usek8sonly when the Typesense API and snapshot files live intypesense-0)TYPESENSE_SNAPSHOT_ROOT(default:/tmp/typesense-native-snapshotsforlocal;/data/snapshotsfork8s)TYPESENSE_SNAPSHOT_FALLBACK_ROOT(defaults to the source-specific primary root)TYPESENSE_SNAPSHOT_RETENTION_COUNT(default:2, min1)NAS_BACKUP_PREFER_REMOTE(1bypasses the local NAS mount and starts with the SSH/SCP transport)NAS_BACKUP_QUEUE_ROOT(default:/tmp/joelclaw/nas-queue)
Kubernetes note:
k8s/typesense.yamlkeepstypesense-0NAS-independent at runtime (no hard NFS mount). Snapshot durability is handled by the backup transport flow above, not by pod startup dependencies.
Webhook subscription dispatch (ADR-0185)
GitHub workflow completion fan-out
- function:
webhook-subscription-dispatch-github-workflow-run-completed - file:
packages/system-bus/src/inngest/functions/webhook-subscription-dispatch.ts - trigger:
github/workflow_run.completed - responsibilities:
- match Redis-backed session subscriptions (
joelclaw:webhook:*) - prune expired/invalid subscriptions
- best-effort fetch workflow artifacts from GitHub Actions API
- dedupe delivery per subscription
- publish match payloads to subscription NDJSON channels
- push
webhook.subscription.matchedto gateway withoriginSessionfor immediate follow-up turns
- match Redis-backed session subscriptions (
Swarm DAG spike (ADR-0060 follow-up)
-
function:
swarm-orchestrator -
file:
packages/system-bus/src/inngest/functions/swarm-orchestrator.ts -
trigger:
swarm/started -
behavior:
- parse + validate swarm YAML (
packages/system-bus/src/swarm/schema.ts) - build dependency graph + detect cycles + compute execution waves (
packages/system-bus/src/swarm/dag.ts) - execute each wave in parallel via
step.invoke()ofswarm-agent-exec - emit
swarm/completedwithcompleted|failedstatus
- parse + validate swarm YAML (
-
function:
swarm-agent-exec -
file:
packages/system-bus/src/inngest/functions/swarm-agent-exec.ts -
invocation:
step.invoke()from orchestrator (spike wiring) -
behavior:
- receive agent config + workspace + wave
- spawn agent tool (codex/claude/pi) with codex using
codex exec --full-auto -m <model>pattern - return structured success/failure summary
Verification
bunx tsc --noEmit
bun test packages/system-bus/src/inngest/functions/check-system-health.test.ts
bun test packages/system-bus/src/lib/webhook-subscriptions.test.ts
bun test packages/gateway/src/knowledge-turn.test.ts
joelclaw inngest status
joelclaw otel search "webhook.subscription.dispatch" --hours 1
joelclaw otel search "knowledge.turn_write" --hours 1
Deploy
./k8s/publish-system-bus-worker.sh
joelclaw inngest restart-worker --register
joelclaw inngest status
Related ADRs
docs/decisions/0010-system-loop-gateway.md~/Vault/docs/decisions/0088-nas-backed-storage-tiering.md~/Vault/docs/decisions/0187-nas-degradation-local-temp-queue-fallback-contract.md~/Vault/docs/decisions/0194-inngest-runtime-sqlite-forensics-and-stale-run-sweep.md