Align `dws event consume` with an AI-subprocess contract an orchestrator
can drive deterministically, and expose a machine-readable input schema
for event commands via `dws schema`.
Subprocess contract:
- Fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>`;
block on it instead of sleeping.
- Final `[event] exited — received N event(s) in Xs (reason: ...)` line;
exit 0 on controlled exit, non-zero and no exited line on failure.
- stdin-EOF graceful shutdown, armed only for a pipe stdin on an
unbounded run; an interactive TTY and `< /dev/null` never trigger it.
- Ownership-based subscription cleanup: a run-created subscription is
unsubscribed on any clean exit while a --subscribe-id-reused one is
kept (--ephemeral still forces cleanup). Forward --profile to the
detached bus so non-default orgs resolve the right credentials, and
surface the child's real startup error over the ready pipe.
Schema:
- `dws schema "event consume"` (or event.consume) synthesizes a flat,
machine-readable schema from the command's cobra flags:
{description, path, source:"cobra",
parameters{<flag>:{type,required,description,default?}}} plus an
`arguments` array for positional inputs. Intermediate nodes list
subcommands. Inherited global flags and hidden internal flags are
excluded so the schema describes just that command.
- Reusable registry (cobraSchemaRoots); event is the first consumer and
more command trees can opt in without further wiring.
Docs: mono + dingtalk-event skills document the contract and the two
schema surfaces; design notes in docs/event-subprocess-contract.md.
4.8 KiB
Event consume — AI subprocess contract
Aligns dws event consume with the "AI subprocess contract" that
lark-cli event consume exposes, so any orchestrator (Claude Code's
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
exited.
Scope of this branch: the four contract items below. Reconnect resilience (keeping the stream alive across a transient upstream drop) is tracked separately and intentionally out of scope here.
Baseline (already present, no work)
--max-events N— stop after N events (exit 0).--duration D— wall-clock budget (exit 0). Kept as--duration, NOT aliased to--timeout: the global--timeoutis the HTTP request timeout (int seconds) and would collide (different type and meaning). Docs note the lark-cli name difference.- Bus idle-shutdown fires only with zero consumers, so a connected consumer is never idle-killed.
- SIGINT/SIGTERM already cancel the run context and return cleanly.
Improvements
1. Ready marker (standardized)
On connect, emit a fixed stderr line before any stdout event:
[event] ready event_key=<key> bus_pid=<pid>
Parents block on stderr until this line, then read stdout. Suppressed
under --quiet. Replaces the ad-hoc connected bus pid=... line (which
omits event_key).
Verification
- T1a: stderr contains a line matching
^\[event\] ready event_key=<key>. - T1b: that line appears before the first stdout event (ordering).
- T1c: with
--quiet, the line is absent.
2. stdin EOF = graceful exit
consume watches stdin; closing stdin is a shutdown signal (wired for AI
subprocess callers). To stay resident, feed a never-EOF stdin
(< <(tail -f /dev/null)) or run bounded (--max-events / --duration).
Verification
- T2a:
printf '' | dws event consume <key>exits ≤2s, code 0, final linereason: signal(stdin-eof classified as signal). - T2b:
dws event consume <key> < <(tail -f /dev/null)still alive after 5s, connection intact. - T2c (unit): a controllable stdin reader hitting EOF makes Run return nil via the cleanup path.
3. Exit reason contract + exit codes
On exit, final stderr line:
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
or runtime failure (permissions, network, params) = non-zero, with no
exited line and an Error: line instead.
Verification
- T3a:
--max-events 1+ 1 event → exit 0, reason=limit, N=1. - T3b:
--duration 2s, no events → exit 0, reason=timeout. - T3c: SIGTERM mid-run → exit 0, reason=
signal. - T3d: bad params / permission failure → exit≠0, no
exitedline, hasError:. - Unit tests assert (reason string, exit code) for each path.
4. Cleanup on exit (no kill -9)
Ownership-based, matching lark-cli:
- If this run created the subscription (no
--subscribe-id), a clean exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) unsubscribes it server-side and sends Bye. - If
--subscribe-idwas passed (reusing an existing subscription), the subscription is left intact — the caller owns its lifecycle. --ephemeralremains as an explicit "always unsubscribe" override.- Help/docs warn: avoid
kill -9(skips the unsubscribe → leaked server-side subscription: "subscription already exists" on restart, duplicate delivery). Prefer SIGTERM or closing stdin.
Verification
- T4a: start consume (self-created subscription), record subscribe_id;
SIGTERM; afterwards
dws event statusno longer lists that subscribe_id and the server-side subscription is gone. - T4b: start consume with
--subscribe-id <existing>; SIGTERM; the subscription is still present (reuse case preserved). - T4c (control):
kill -9leaves subscribe_id lingering (documented risk; we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
Out of scope (next branch)
Reconnect resilience — today personal source retries only
retryable errors (1–30s backoff); a non-retryable error tears the bus
down and takes consume with it (the likely cause of the observed silent
drop). Making more drops retryable, keeping the bus alive across a
reconnect, and emitting reason: source_lost only after exhausting the
budget — tracked on its own branch, since it needs error-classification
judgement and real flaky-network testing, and would otherwise couple clean
contract work with resilience work.
Test surface
- Unit: extend
internal/event/consume/*_test.gowith fake bus conn / stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch. - Integration/e2e:
--foreground+ mock source (or a short real run) for T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit codes.