220 lines
11 KiB
Markdown
220 lines
11 KiB
Markdown
# Event consume — AI subprocess contract
|
||
|
||
Defines the stable `dws event consume` subprocess contract so an
|
||
orchestrator can determine when the consumer is ready, stop it cleanly,
|
||
and machine-read why it exited.
|
||
|
||
Scope of this branch: the six **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).
|
||
- 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> subscribe_id=<id>
|
||
```
|
||
|
||
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 cleanup:
|
||
- 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).
|
||
|
||
### 5. Subscription-create retry orchestration and local guard
|
||
|
||
This policy covers all 16 public personal-event keys and every logical
|
||
subscription in a multi-event command. It applies only before the ready
|
||
marker; reconnecting an established Stream remains a separate mechanism.
|
||
|
||
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
|
||
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
|
||
process sends at most one subscription-create HTTP request for a logical
|
||
subscription and performs no in-process automatic retry. The CLI persists
|
||
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
|
||
not persist or enforce the Agent/host attempt count across invocations.
|
||
- ID resolution, `event consume`, and later `event status/stop` must use the
|
||
same `--profile`. A user or conversation ID resolved under another profile
|
||
must not be reused for the current subscription.
|
||
- A logical subscription is keyed by the current profile/identity, event key,
|
||
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
|
||
does not create a new logical operation or reset the Agent/host budget.
|
||
- For the Agent/host, `retryable=false` means
|
||
`max_additional_attempts=0`.
|
||
- For the Agent/host, `retryable=true` means
|
||
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
|
||
`next_retry_at` when present and must not retry early.
|
||
- For the Agent/host, an omitted retryable value
|
||
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
|
||
failure stops the operation.
|
||
- `in_flight` means the original logical request is still running.
|
||
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
|
||
it. These states must not recursively launch `event consume`, start a
|
||
parallel equivalent subscription, or bypass the guard with a new subId or
|
||
trace. The caller waits for the original request/guard or stops, while the
|
||
Agent/host keeps its own orchestration count.
|
||
- A multi-event command remains one original operation. A caller must not
|
||
split out a failed event, reorder events, or restart the command to bypass
|
||
a budget. Existing startup rollback cleans subscriptions created before a
|
||
later item fails.
|
||
|
||
#### Local guard state operations
|
||
|
||
- The default open-edition state file is
|
||
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
|
||
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
|
||
that edition's directory instead of `open`.
|
||
- The identity directory is mode `0700`; both
|
||
`personal_subscription_attempts.json` and
|
||
`personal_subscription_attempts.lock` are mode `0600`.
|
||
- A failure streak resets after 24h without another failure. A
|
||
`terminal_hold` lasts 1h. Prefer waiting until the reported
|
||
`next_retry_at`; do not clear the file as a normal retry mechanism.
|
||
- For emergency recovery, first ensure that no subscription-create process is
|
||
running for that identity. Delete only
|
||
`personal_subscription_attempts.json`, never the lock file. This clears
|
||
every protection record for that identity, not just one event.
|
||
|
||
**Verification**
|
||
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
|
||
contract and explicitly reject describing it as a CLI-persisted hard cap.
|
||
- T5b (CLI): one process issues at most one create request per logical
|
||
subscription; a changed subId/trace or process restart does not bypass the
|
||
persisted fingerprint guard.
|
||
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
|
||
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
|
||
and a partial startup still rolls back earlier subscriptions.
|
||
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
|
||
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
|
||
operational recovery instructions.
|
||
|
||
### 6. Host runtime-token handoff
|
||
|
||
When the root command carries an explicit host-supplied `--token`, personal
|
||
event control requests and the foreground Stream use that token with higher
|
||
priority than local OAuth. A detached bus receives it only through the
|
||
owner-only local IPC transport:
|
||
|
||
1. The child starts in runtime-token mode with non-sensitive identity and
|
||
ticket metadata only; neither its argv nor environment contains the token.
|
||
2. The consumer sends `Hello` with `credential_mode=runtime_token`.
|
||
3. The bus advertises the additive `runtime_token_v1` capability and its
|
||
in-memory credential generation in `HelloAck`.
|
||
4. Only after that capability is confirmed does the consumer send a bounded
|
||
`credential_update` frame. The bus applies it with generation CAS, replies
|
||
with `credential_update_ack`, and registers the consumer only on success.
|
||
|
||
The bus blocks ticket acquisition until the first runtime credential arrives.
|
||
A later invocation may rotate Token A to Token B on a compatible existing bus;
|
||
the current WebSocket remains connected and the next ticket request or natural
|
||
reconnect uses B. If a 401 rejects the current runtime token, only an already
|
||
installed newer generation is retried; the runtime path never refreshes or
|
||
falls back to a local OAuth profile and never suggests `dws auth login`.
|
||
|
||
Clients do not send a token to a bus that lacks the capability, do not stop
|
||
other consumers automatically, and fail before printing the ready marker. With
|
||
no explicit `--token`, the original OAuth, refresh, profile, and old-client to
|
||
new-bus protocol behavior remains unchanged.
|
||
|
||
**Verification**
|
||
- T6a: a stale local Token A and root Token B produce control and ticket
|
||
requests authenticated only with B.
|
||
- T6b: compatible bus reuse supports A-to-B rotation and generation conflicts;
|
||
401 retries only an already-installed newer runtime token.
|
||
- T6c: an old bus receives no credential and remains running; the new consumer
|
||
exits before its ready marker.
|
||
- T6d: a canary credential is absent from child argv/environment, dry-run,
|
||
stdout/stderr, `bus.meta`, `bus.log`, run state, and returned errors.
|
||
- T6e: no-token OAuth, refresh, multi-profile, marker/cache, and bus-reuse tests
|
||
continue to pass.
|
||
|
||
## 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.
|