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.
116 lines
4.8 KiB
Markdown
116 lines
4.8 KiB
Markdown
# 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 `--timeout` is 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
|
||
line `reason: 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 `exited` line, has `Error:`.
|
||
- 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-id` was passed (reusing an existing subscription), the
|
||
subscription is **left intact** — the caller owns its lifecycle.
|
||
- `--ephemeral` remains 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 status` no 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 -9` leaves 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.go` with 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.
|