Skip to content

Obsidian Bridge

HTTP bridge to Obsidian — eval_js, latency handling, timeout retry rules

Details

HTTP bridge on 27125 exposing bridge.eval_js; intermittent ~4s latency spikes; on failure the framework's RetryStrategy retries via @bridge_retry (max 3 attempts, jittered backoff with base≈60s, capped at max(wait, 30s)) — or obsidian_retry does the same explicitly — then admit failure. NEVER bypass to direct vault writes or REST.

Typed exception hierarchy (post-CP1–CP9)

The bridge layer raises subclasses of work_buddy.obsidian.errors.ObsidianError on failure. The gateway classifies via isinstance (not substring matching). Each instance carries an error_kind string that survives serialization (op records, result dicts, notifications).

ObsidianError                          error_kind = 'obsidian_unknown'
├── ObsidianUnreachable                error_kind = 'obsidian_unreachable'
│   ├── ObsidianNotRunning             error_kind = 'obsidian_not_running'
│   ├── ObsidianPluginMissing          error_kind = 'obsidian_plugin_missing'
│   ├── ObsidianPluginDisabled         error_kind = 'obsidian_plugin_disabled'
│   └── ObsidianStartupRace            error_kind = 'obsidian_startup_race'
├── ObsidianTimeout                    error_kind = 'obsidian_timeout'
│   └── ObsidianPostWriteUncertain     error_kind = 'obsidian_post_write_uncertain'
│       carries (path, content_hint, write_mode)
└── ObsidianHTTPError                  carries (status, body)
    ├── ObsidianEditorConflict         error_kind = 'obsidian_editor_conflict'
    ├── ObsidianRefused                error_kind = 'obsidian_refused' (PERMANENT)
    └── ObsidianServerError            error_kind = 'obsidian_server_error'

Every kind classifies as transient EXCEPT obsidian_refused (4xx other than 409 — structural refusal, no retry will help).

Four-state taxonomy (legacy strings still propagated)

The pre-typed-exception four-state classification still flows through get_last_bridge_state() and the dashboard sparkline (work_buddy/dashboard/api.py::get_bridge_status). The bridge module sets _last_failure_kind to 'timeout' | 'unreachable' | 'http_error' | '' on every failure path, derived from the typed exception class. The dashboard frontend (scripts/tabs/settings.py, the loadActivity function) maps these strings to bar classes (bar-ok, bar-slow, bar-fail, bar-unreachable). This contract is preserved.

Every bridge-dependent capability's failure response also carries: - _bridge_state: one of ok, timeout, obsidian_not_running, plugin_not_installed, plugin_disabled, http_error, unknown - _bridge_state_detail: human-readable explanation - _bridge_terminal: true when the state is one that retrying will never fix (obsidian_not_running / plugin_not_installed / plugin_disabled). @bridge_retry short-circuits on these via _BridgeHealthGate's terminal-classification path. The typed-exception path uses the analogous _TERMINAL_OBSIDIAN_ERROR_KINDS set in work_buddy.obsidian.retry.

Post-write-uncertain recovery (CP5)

A client-side timeout AFTER a PUT body has been sent is ambiguous: the plugin may have committed the write before the response failed to arrive. write_file_raw raises ObsidianPostWriteUncertain(path, content_hint, write_mode). The gateway's exception handler catches it and dispatches to work_buddy.obsidian.post_write_verify.verify_post_write, which reads the file from FILESYSTEM (not bridge) and decides:

  • verified → success-with-warning (no retry; closes the latent double-write hazard)
  • absent → fall through to normal failure path (enqueue retry)
  • indeterminate → same as absent

write_mode controls verifier semantics: "replace" matches a sha256 hint; "insert" / "append" checks the hint as a substring; "absent" (delete-style operations) inverts — verified iff the hint is NOT in the file.

For capabilities that produce multiple external effects (declared via Capability.effects), the recovery path uses verify_post_write_effects which walks every declared effect and can return partial (some landed, some not). See architecture/retry-queue for the recovery semantics and work_buddy.obsidian.effects.EffectSpec for the schema.

Wired in three places: tools/gateway.py wb_run dispatch, tools/gateway.py retry_workflow_step, sidecar/retry_sweep.py::_replay. All write paths benefit.

Race-safe line mutations

For master-task-list and other concurrently-edited files, prefer the atomic helpers over bridge.read_file + bridge.write_file:

  • bridge.atomic_replace_line_by_task_id(file_path, task_id, expected_old_line, new_line) — atomically rewrites the line containing 🆆 {task_id} via Obsidian's app.vault.process(). Returns {found, conflict, replaced, line_number, old_line, new_line}. Sets conflict=True (without writing) when the in-vault line content differs from expected_old_line — caller decides whether to retry, escalate, or accept.
  • bridge.atomic_delete_line_by_task_id(file_path, task_id) — same shape with removed instead of replaced.
  • bridge.write_file(path, content, *, write_mode="replace", content_hint=None) — the consent-gated wrapper accepts write_mode and content_hint. For files that change concurrently, prefer write_mode="insert" with a unique substring witness; full-file sha256 is fragile against unrelated concurrent writes.

Diagnostic helpers

Classification is cheap: get_last_bridge_state() reads module-level counters set by _request_with_status, consults is_obsidian_running() (process check) and get_work_buddy_plugin_state() (filesystem check on .obsidian/plugins/obsidian-work-buddy/manifest.json + community-plugins.json). On Windows, closed TCP ports often surface as socket timeouts rather than ECONNREFUSED; _probe_port_open() disambiguates via a direct TCP probe so timeouts on closed ports reclassify as unreachable.

Entry points: work_buddy.obsidian.errors (typed hierarchy), work_buddy.obsidian.bridge.get_last_bridge_state, work_buddy.obsidian.bridge._request_with_status, work_buddy.obsidian.bridge.write_file_raw, work_buddy.obsidian.bridge.atomic_replace_line_by_task_id, work_buddy.obsidian.bridge.atomic_delete_line_by_task_id, work_buddy.obsidian.post_write_verify.verify_post_write, work_buddy.obsidian.post_write_verify.verify_post_write_effects, work_buddy.obsidian.effects.EffectSpec, work_buddy.obsidian.retry.bridge_failure (auto-enriches), work_buddy.obsidian.retry.bridge_retry (decorator — a thin shim that runs RetryStrategy → _BridgeHealthGate → call via guarded_call_sync; see architecture/resilience), work_buddy.obsidian.retry.obsidian_retry (capability), work_buddy.health.requirement_checks.get_work_buddy_plugin_state.

What was removed in CP9

  • The legacy EditorConflict exception class and the EditorConflict = ObsidianEditorConflict alias. Use ObsidianEditorConflict directly.
  • The Obsidian-specific entries in work_buddy/errors.py::_TRANSIENT_PATTERNS (bridge, editor_dirty, urlopen error, winerror 10061). Obsidian failures take the typed-exception fast-path; the residual list serves only non-Obsidian transient failures.