Skip to content

kernel+surface: body-level on — buffered event subscriptions with idle/deadline bounds (PR babysitting) #432

Description

@miyaontherelay

Problem

A flow that opens a pull request can't stay with it. Keeping a PR alive means waking on review comments, CI results, and pushes to the base branch, fixing what they reveal, and stopping when the PR is merged, closed, or ready. Software Garden does this with a long-lived babysitter process. A relayflow should do it with the resident verb on: "the handler wakes, executes to its next await, parks" (SURFACE.md §1).

There is never a signal meaning "no more feedback". A flow has to end on provider state and use time only as a backstop, and the spec doesn't give a way to express either inside a running body.

What's missing today

Verified against main at 85e7e372 (v2.0.14):

  • Nothing produces wait.event. kernel/DESIGN.md §1.4 defines it and relayflowd-core/src/state.rs folds it into Waiting, but no step or verb ever appends one. Only wait.human is produced, for manual recovery.
  • timeout_at_ms is never enforced. It exists on WaitEventPayload and WaitHumanPayload and nothing reads it, so a wait with a timeout waits forever.
  • Events without an open wait are dropped. engine/remote.rs emit_event only closes waits that are already open. It returns matched: 0 and journals nothing, so an event that arrives while the body is running a fix step is lost.
  • Authored flows have no on. packages/surface/src/context.ts exposes human, dispatch, and done, and the first two throw unsupported_verb (Support durable human approval gates in authored TypeScript flows #400).
  • Only exact-match waits. wait.event matches an exact event_key, while triggers already carry a recursive-subset pattern.

Proposal

Spec: docs/EVENT-AWAIT.md (PR to follow). In short:

const activity = f.on(github.pullRequest(repo, n).activity(), {
  settle: "2m", idle: "72h", deadline: "14d",
});
const wake = await activity.next(); // { kind: "events", events } | { kind: "idle" } | { kind: "deadline" }
  • Buffered subscriptions. A body-level on opens a subscription (subscription.opened / subscription.closed). Matching events are appended to a durable stream, deduplicated by provider delivery id, so nothing is lost while other steps run.
  • Stream-backed waits. next() lowers to an additive wait.event extension with stream, from_offset, settle_ms, idle_at_ms, and deadline_at_ms. Timeouts come back as timeout with result.timeout: "idle" | "deadline", so the reason enum doesn't change.
  • Required bounds. idle and deadline must both be set; flows check refuses an unbounded subscription with unbounded_subscription.
  • Wakes, not truth. The body re-reads provider state after every wake and before every wait.
  • Self-events filtered by default. Events caused by the run's own identity aren't delivered, so a babysitter's push doesn't wake itself.
  • Enforced timers. The scheduler enforces wait timers, including wait.human, which also unblocks Support durable human approval gates in authored TypeScript flows #400.
  • Routing stays in Cloud. Cloud's event router routes frames to open subscriptions; the kernel stays tenant-unaware (decision 15).

Acceptance

Crash-injection tests prove:

  • An event emitted while a step is running is delivered by the next next().
  • kill -9 between stream.appended and wait completion delivers the event exactly once after resume.
  • An idle instant that passes during an outage wakes immediately on resume, without re-running earlier steps.
  • idle resets on each wake; deadline never moves.
  • Events arriving within settle produce one wake that carries all of them.
  • A duplicate delivery id appends nothing.
  • An event caused by the run's own identity is not delivered unless includeSelf is set.
  • After close() or done(), matching frames are refused.
  • A body-level on without idle or deadline fails flows check with unbounded_subscription.
  • wait.human with timeout_at_ms completes with timeout.

Motivation

This came from comparing the Cloud one-click Relayflow software factory with Software Garden. The Relayflow opens a PR and parks as needs_human: nothing watches CI or review feedback afterwards, and nothing reports back. Watching the PR from inside the run needs this primitive.

Related: #400 (durable human approval gates).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions