11 KiB
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--timeoutis 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 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 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-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).
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/1limits below are an Agent/host orchestration contract, not a CLI-enforced persisted total-attempt cap. Eachdws event consumeprocess sends at most one subscription-create HTTP request for a logical subscription and performs no in-process automatic retry. The CLI persists only thein_flight,cooldown, andterminal_holdguard states; it does not persist or enforce the Agent/host attempt count across invocations. - ID resolution,
event consume, and laterevent status/stopmust 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=falsemeansmax_additional_attempts=0. - For the Agent/host,
retryable=truemeansmax_additional_attempts=2. It must honorretry_after_secondsornext_retry_atwhen present and must not retry early. - For the Agent/host, an omitted retryable value
(
retryable=unknown) meansmax_additional_attempts=1; a second unknown failure stops the operation. in_flightmeans the original logical request is still running.cooldownandterminal_holdmean a guard is already delaying or blocking it. These states must not recursively launchevent 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 followsDWS_CONFIG_DIRwhen set, and another edition uses that edition's directory instead ofopen. - The identity directory is mode
0700; bothpersonal_subscription_attempts.jsonandpersonal_subscription_attempts.lockare mode0600. - A failure streak resets after 24h without another failure. A
terminal_holdlasts 1h. Prefer waiting until the reportednext_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/cooldowndoes 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/0600permissions, 24h reset, 1hterminal_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:
- The child starts in runtime-token mode with non-sensitive identity and ticket metadata only; neither its argv nor environment contains the token.
- The consumer sends
Hellowithcredential_mode=runtime_token. - The bus advertises the additive
runtime_token_v1capability and its in-memory credential generation inHelloAck. - Only after that capability is confirmed does the consumer send a bounded
credential_updateframe. The bus applies it with generation CAS, replies withcredential_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.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.