5
0
Fork
You've already forked hark
0

Wire hark dialect publish/subscribe/query/list through the daemon (closes #4) #5

Merged
hugooconnor merged 6 commits from spec-009/dialect-cli-wiring into main 2026年05月11日 15:46:35 +02:00

Closes #4.

Wires all five dialect verbs through the daemon: hark dialect publish | subscribe | unsubscribe | query | list. Each lands a real local-API endpoint, validates user input where appropriate, and (for the three that need a response) uses a single-slot per-agent meta-reply correlation primitive to route the router's reply back to the requester.

What ships

hark dialect publish [--define '(define ...)'] [--json]
hark dialect subscribe <pattern> # pattern defaults to "*"
hark dialect unsubscribe
hark dialect query <name> [--json]
hark dialect list

Three commits, one per slice, on top of main (e0e171f):

  1. be2f951 — subscribe + unsubscribe (fire-and-forget; reuse existing send_outbound)
  2. 63173c1 — publish (introduces send_meta_and_await + try_route_meta_reply; daemon-side R1-R5 validation)
  3. 949c0a6 — query + list (reuse the correlation primitive)

Correlation primitive (load-bearing new piece)

AgentEntry.pending_meta_reply: Option<oneshot::Sender<String>> — single-slot per agent. Concurrent meta sends serialise; a second caller returns meta_send_busy rather than queuing.

AgentStore::send_meta_and_await(handle, frame, timeout):

  • atomically claims the slot
  • sends via existing send_outbound
  • awaits the oneshot with timeout (10s default)
  • clears slot on timeout / error so the next attempt proceeds

AgentStore::try_route_meta_reply(handle, message):

  • called from the receive loop before enqueue_inbound
  • returns None if routed (frame is consumed), Some(message) otherwise so the caller falls through to normal recv

Classifier extension

New InboundClass::MetaReply for bare (reply ...) frames. Normal asks arrive wrapped in (lang <dialect> (ask ...)) and stay Ordinary. The receive loop tries to route both DialectPush and MetaReply to a pending meta-await; failing that, they fall through unchanged.

Defense in depth

publish runs cbcl_parser::run_pipeline on (meta <define>) before sending — mirrors the dialect cache's install-time guard on the receive path. An R3 violation (override tell etc.) surfaces as cbcl_validation_failed (exit 8) without ever reaching the router. Verified end-to-end.

Receive-loop reorder

Cache install for DialectPush now runs before the meta-reply routing check, so both subscriber pushes and query teach-backs install into the local cache via the same code path. InboundOutcome::Installed(name) still fires after install regardless of which path consumed the frame, so auto re-hello continues to work as before.

Endpoints

POST /v1/agents/<handle>/meta/subscribe { "pattern": "..." } → 200 { ok }
POST /v1/agents/<handle>/meta/unsubscribe → 200 { ok }
POST /v1/agents/<handle>/meta/publish { "define": "..." } → 200 { ok, digest, name }
POST /v1/agents/<handle>/meta/query { "name": "..." } → 200 { ok, digest, name, define }
 404 { error.code: "dialect_unknown_to_router" }
POST /v1/agents/<handle>/meta/list → 200 { ok, names: [...] }

API versioning

Bumps LOCAL_API_VERSION 2 → 3. The new endpoints are the schema change.

New error codes (CLI mapping)

Code Where CLI exit
invalid_subscribe_pattern local 2 (usage)
meta_send_busy local 7 (agent unavailable)
meta_reply_timeout local 10 (timeout)
dialect_unknown_to_router router miss 2 (usage)
meta_reply_malformed / meta_reply_missing_digest / meta_reply_missing_name local 12 (internal)

End-to-end verification

Run against a live cbcl-router (slice-3 PR #5 branch) — all transcripts in commit messages. Highlights:

$ hark dialect publish --define '(define alpha-v1 (cbcl) @e2e)'
7b105efd2d9137f88cc969ebc40cebb520e09bd33d6905490424aa831f14c6d1 alpha-v1
$ hark dialect publish --define '(define alpha-v1 (cbcl) @e2e)' # idempotent
7b105efd2d9137f88cc969ebc40cebb520e09bd33d6905490424aa831f14c6d1 alpha-v1
$ hark dialect publish --define '(define bad (cbcl) @e2e (extend tell () x))'
cbcl_validation_failed: define failed R1–R5: R3 violation: core performative(s) overridden: tell
 (exit 8)
$ hark dialect list
beta-v1
alpha-v1
$ hark dialect query alpha-v1
7b105efd2d9137f88cc969ebc40cebb520e09bd33d6905490424aa831f14c6d1 alpha-v1
$ hark dialect query nope-not-here
dialect_unknown_to_router: router-does-not-speak (exit 2)
$ hark dialect subscribe 'arena-*'
$ # producer teaches arena-v1 → hark recv picks up the pushed frame
$ hark dialect unsubscribe
$ # producer teaches arena-v2 → hark recv times out (subscription dropped)

Test plan

  • cargo build --release — clean
  • cargo test --release — 122/122 pass
  • End-to-end against live router for each verb (transcripts in commit messages and PR description)
  • Manual smoke: concurrent meta sends from two threads / shells (should one see meta_send_busy)
  • CI green

Non-goals (per issue #4)

  • Persistent subscription state across daemon restarts
  • A hark dialect describe <name> UX — pending the cbcl-router dialect / :description design conversation
  • Replacing recv's handling of stray meta replies — currently if a (reply ...) arrives with no pending meta-await, it falls through to recv unchanged

🤖 Generated with Claude Code

Closes #4. Wires all five dialect verbs through the daemon: `hark dialect publish | subscribe | unsubscribe | query | list`. Each lands a real local-API endpoint, validates user input where appropriate, and (for the three that need a response) uses a single-slot per-agent meta-reply correlation primitive to route the router's reply back to the requester. ## What ships ```text hark dialect publish [--define '(define ...)'] [--json] hark dialect subscribe <pattern> # pattern defaults to "*" hark dialect unsubscribe hark dialect query <name> [--json] hark dialect list ``` Three commits, one per slice, on top of `main` (`e0e171f`): 1. **`be2f951`** — subscribe + unsubscribe (fire-and-forget; reuse existing `send_outbound`) 2. **`63173c1`** — publish (introduces `send_meta_and_await` + `try_route_meta_reply`; daemon-side R1-R5 validation) 3. **`949c0a6`** — query + list (reuse the correlation primitive) ## Correlation primitive (load-bearing new piece) `AgentEntry.pending_meta_reply: Option<oneshot::Sender<String>>` — single-slot per agent. Concurrent meta sends serialise; a second caller returns `meta_send_busy` rather than queuing. `AgentStore::send_meta_and_await(handle, frame, timeout)`: - atomically claims the slot - sends via existing `send_outbound` - awaits the oneshot with timeout (10s default) - clears slot on timeout / error so the next attempt proceeds `AgentStore::try_route_meta_reply(handle, message)`: - called from the receive loop before `enqueue_inbound` - returns `None` if routed (frame is consumed), `Some(message)` otherwise so the caller falls through to normal recv ## Classifier extension New `InboundClass::MetaReply` for bare `(reply ...)` frames. Normal asks arrive wrapped in `(lang <dialect> (ask ...))` and stay `Ordinary`. The receive loop tries to route both `DialectPush` and `MetaReply` to a pending meta-await; failing that, they fall through unchanged. ## Defense in depth `publish` runs `cbcl_parser::run_pipeline` on `(meta <define>)` before sending — mirrors the dialect cache's install-time guard on the receive path. An R3 violation (override `tell` etc.) surfaces as `cbcl_validation_failed` (exit 8) without ever reaching the router. Verified end-to-end. ## Receive-loop reorder Cache install for `DialectPush` now runs **before** the meta-reply routing check, so both subscriber pushes *and* query teach-backs install into the local cache via the same code path. `InboundOutcome::Installed(name)` still fires after install regardless of which path consumed the frame, so auto re-hello continues to work as before. ## Endpoints ``` POST /v1/agents/<handle>/meta/subscribe { "pattern": "..." } → 200 { ok } POST /v1/agents/<handle>/meta/unsubscribe → 200 { ok } POST /v1/agents/<handle>/meta/publish { "define": "..." } → 200 { ok, digest, name } POST /v1/agents/<handle>/meta/query { "name": "..." } → 200 { ok, digest, name, define } 404 { error.code: "dialect_unknown_to_router" } POST /v1/agents/<handle>/meta/list → 200 { ok, names: [...] } ``` ## API versioning Bumps `LOCAL_API_VERSION 2 → 3`. The new endpoints are the schema change. ## New error codes (CLI mapping) | Code | Where | CLI exit | |---|---|---| | `invalid_subscribe_pattern` | local | 2 (usage) | | `meta_send_busy` | local | 7 (agent unavailable) | | `meta_reply_timeout` | local | 10 (timeout) | | `dialect_unknown_to_router` | router miss | 2 (usage) | | `meta_reply_malformed` / `meta_reply_missing_digest` / `meta_reply_missing_name` | local | 12 (internal) | ## End-to-end verification Run against a live cbcl-router (slice-3 PR #5 branch) — all transcripts in commit messages. Highlights: ```text $ hark dialect publish --define '(define alpha-v1 (cbcl) @e2e)' 7b105efd2d9137f88cc969ebc40cebb520e09bd33d6905490424aa831f14c6d1 alpha-v1 $ hark dialect publish --define '(define alpha-v1 (cbcl) @e2e)' # idempotent 7b105efd2d9137f88cc969ebc40cebb520e09bd33d6905490424aa831f14c6d1 alpha-v1 $ hark dialect publish --define '(define bad (cbcl) @e2e (extend tell () x))' cbcl_validation_failed: define failed R1–R5: R3 violation: core performative(s) overridden: tell (exit 8) $ hark dialect list beta-v1 alpha-v1 $ hark dialect query alpha-v1 7b105efd2d9137f88cc969ebc40cebb520e09bd33d6905490424aa831f14c6d1 alpha-v1 $ hark dialect query nope-not-here dialect_unknown_to_router: router-does-not-speak (exit 2) $ hark dialect subscribe 'arena-*' $ # producer teaches arena-v1 → hark recv picks up the pushed frame $ hark dialect unsubscribe $ # producer teaches arena-v2 → hark recv times out (subscription dropped) ``` ## Test plan - [x] `cargo build --release` — clean - [x] `cargo test --release` — 122/122 pass - [x] End-to-end against live router for each verb (transcripts in commit messages and PR description) - [ ] Manual smoke: concurrent meta sends from two threads / shells (should one see `meta_send_busy`) - [ ] CI green ## Non-goals (per issue #4) - Persistent subscription state across daemon restarts - A `hark dialect describe <name>` UX — pending the cbcl-router dialect / `:description` design conversation - Replacing `recv`'s handling of stray meta replies — currently if a `(reply ...)` arrives with no pending meta-await, it falls through to `recv` unchanged 🤖 Generated with [Claude Code](https://claude.ai/code)
First slice of issue #4 — the fire-and-forget meta verbs that don't
need router-reply correlation.
CLI: re-adds `hark dialect <subcommand>` with `subscribe <pattern>`
and `unsubscribe` variants. Pattern defaults to `*`. Handlers call
new `LocalApiClient` methods instead of just printing a frame.
Daemon: new endpoints
 POST /v1/agents/<handle>/meta/subscribe { "pattern": "..." }
 POST /v1/agents/<handle>/meta/unsubscribe
Both build the canonical `(meta (subscribe (speak? <p>)))` or
`(meta (unsubscribe))` frame and submit via the existing
`send_outbound` path. Subscribe pattern is validated locally
(non-empty, no whitespace/parens/quotes) — new
`invalid_subscribe_pattern` error code → exit 2.
Router builders: adds `build_meta_unsubscribe_frame` and
`build_meta_query_list_frame` alongside the existing teach/query/
subscribe helpers, plus unit tests.
Wire compatibility: bumps LOCAL_API_VERSION 2 → 3. New endpoints are
the schema change.
End-to-end verified against a live cbcl-router: subscribe → producer
teach to a matching name → push lands in `hark recv`; unsubscribe →
subsequent matching teach does NOT arrive (recv_timeout).
cargo test: 122 passed (9 suites).
publish, query, list — the verbs that need router-reply correlation
and dialect-cache integration — are deferred to follow-up commits on
this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second slice of issue #4. Sends `(meta (teach @router <define>))` to
the router, awaits the reply, parses `digest` + `name`, returns them
to the CLI.
Correlation primitive (the load-bearing new piece):
- `AgentEntry.pending_meta_reply: Option<oneshot::Sender<String>>`
 single-slot per agent.
- `AgentStore::send_meta_and_await(handle, frame, timeout)`
 atomically claims the slot, sends via `send_outbound`, awaits the
 oneshot. Concurrent callers see `MetaSendBusy`. Timeouts clear the
 slot so the next attempt can proceed.
- `AgentStore::try_route_meta_reply(handle, message)` — called from
 the receive loop before `enqueue_inbound`; routes the frame to the
 pending sender if filled, otherwise returns the message so the
 caller falls through to the normal recv queue.
- Receive loop: classifies inbound frames; `DialectPush` and a new
 `MetaReply` class try the pending slot first. Unmatched DialectPush
 still installs into the cache + enqueues as before; unmatched
 MetaReply passes through to recv unchanged.
Classifier:
- New `InboundClass::MetaReply` for bare `(reply ...)` frames at the
 top level. Normal asks come wrapped in `(lang <dialect> (ask ...))`
 and stay as `Ordinary`.
Validation:
- The daemon runs `cbcl_parser::run_pipeline` on `(meta <define>)`
 before sending — mirroring the dialect cache's install-time guard
 on the receive path. An R1–R5 violation surfaces as
 `cbcl_validation_failed` (exit 8) without ever leaving the daemon.
Endpoint:
 POST /v1/agents/<handle>/meta/publish
 body: { "define": "(define <name> ...)" }
 200: { "ok": true, "digest": "...", "name": "..." }
CLI:
 hark dialect publish [--define '(define ...)'] [--json]
 Reads the define from --define or stdin; prints `<digest> <name>`
 on success, or JSON if --json is given.
New error codes wired in CLI mapping:
- `meta_send_busy` → exit 7 (agent handle unavailable)
- `meta_reply_timeout` → exit 10 (timeout)
- `meta_reply_malformed` / `meta_reply_missing_{digest,name}` → exit 12 (internal)
End-to-end verified against a live cbcl-router:
- Valid define: `<digest> name` printed, idempotent under
 content addressing (re-publish returns identical digest).
- R3 violation: caught locally, never reaches the router; CLI
 exits 8 with the cbcl-rs diagnostic.
cargo test: 122 passed (9 suites).
Query + list still deferred — they need cache-install integration
for the teach-back response and a list-reply parser. Same correlation
primitive carries them; coming in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third slice of issue #4. Both reuse the meta-reply correlation primitive
(`send_meta_and_await` + `try_route_meta_reply`) shipped with the
publish slice.
query <name>:
- Daemon sends `(meta (query (speak? <name>)))` and awaits the reply.
- Two reply shapes possible — teach-back `(meta (teach @<self> (define
 <name> ...)))` on hit, or `(reply ...)` with `:reason "router-does-not-
 speak"` on miss. The handler discriminates by leading `(meta` vs
 anything else.
- On hit: classify_inbound's existing DialectPush path extracts name +
 define_form; daemon computes the SHA-256 digest and returns
 {digest, name, define}. The receive loop installs into the local
 cache as a side effect (same code path as subscriber pushes — the
 pieces line up because R3 forces dialect-perf names disjoint from
 cores and the teach-back shape is structurally identical).
- On miss: 404 with `dialect_unknown_to_router` carrying the router's
 reason. CLI exit 2 (usage).
list:
- Daemon sends `(meta (query (list)))` and awaits the reply.
- Parses `:names "a b c"` from `(reply ...)` via the same param parser
 as publish. Returns names as a Vec.
- CLI prints one per line.
Receive loop change:
- Cache install for DialectPush now runs BEFORE the meta-reply routing
 check, so query teach-backs and subscriber pushes BOTH install. If
 the frame is then routed to a pending meta-await, we still emit
 `InboundOutcome::Installed(name)` so the auto re-hello path fires
 whenever a new dialect lands in the cache regardless of which path
 produced it.
CLI:
 hark dialect query <name> [--json]
 Prints `<digest> <name>` (or JSON with `define`) on hit.
 hark dialect list
 Prints each known dialect name on its own line.
Endpoints:
 POST /v1/agents/<handle>/meta/query { "name": "..." }
 → 200 { digest, name, define }
 → 404 { error.code: "dialect_unknown_to_router" }
 POST /v1/agents/<handle>/meta/list
 → 200 { names: ["a", "b", ...] }
End-to-end verified against a live cbcl-router:
- list returns every published name
- query <known> returns the digest + name + define (and installs the
 define into the local cache as a side effect)
- query <unknown> exits 2 with `dialect_unknown_to_router`
- query --json emits structured output
cargo test: 122 passed (9 suites). Closes issue #4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates README, specs/cli.md, and specs/local-api.md for the five new
dialect verbs that close issue #4.
README:
- New Workflow snippet showing list / query / publish / subscribe /
 unsubscribe with one-line examples and the R1–R5 + 404 semantics
 callouts.
- New `### dialect <verb>` sections under Commands.
- Adds the new local-API error codes to the existing list:
 invalid_subscribe_pattern, meta_send_busy, meta_reply_timeout,
 dialect_unknown_to_router, meta_reply_malformed,
 meta_reply_missing_{digest,name}.
specs/local-api.md:
- Documents POST /v1/agents/<handle>/meta/{subscribe,unsubscribe,
 publish,query,list} with request/response shapes, error codes, and
 the meta-reply correlation contract (single-slot per agent,
 10-second default timeout, meta_send_busy on concurrent calls).
- Extends the stable error-code list with the new codes.
specs/cli.md:
- New `### dialect <verb>` sections with stdout shape, exit codes, and
 the JSON-output variants where applicable.
- Notes content-addressed idempotence for publish and the local cache
 install side effect for query.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reject non-define publish bodies before they reach the router; the
 parsed form's head must be `define`, not any valid meta op.
- Correlate meta replies with the in-flight request via a new
 MetaReplyExpectation slot, so an unsolicited subscription push can't
 satisfy a pending publish/list/query waiter.
- Surface teach-back install failures as teach_back_rejected on query
 instead of reporting a digest for a define that wasn't installed.
- Drop pending meta waiters when the handle goes unhealthy so callers
 fail fast on a closed connection instead of waiting META_REPLY_TIMEOUT.
- Enforce the documented subscribe wildcard grammar locally (`*`,
 trailing prefix*, or exact name); reject foo*bar, **, and *foo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the pending-meta-waiter cleanup into `AgentEntry::mark_unhealthy`
so every unhealthy path drops the slot. The previous fix only covered
`AgentStore::mark_unhealthy`, leaving `enqueue_inbound`'s
queue-overflow case (which calls the entry method directly) blocking
the waiter until META_REPLY_TIMEOUT. Reachable now that unrelated
subscription pushes fall through to the inbound queue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hugooconnor deleted branch spec-009/dialect-cli-wiring 2026年05月11日 15:46:36 +02:00
Sign in to join this conversation.
No reviewers
Labels
Clear labels
No items
No labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
anuna/hark!5
Reference in a new issue
anuna/hark
No description provided.
Delete branch "spec-009/dialect-cli-wiring"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?