Skip to content

Enforce spawn/resume-only agent delivery: remove waitForInjected/sendInput once the last 3 call sites migrate (lint + test guards, babysitter coalesced-resume) #105

Description

@khaliqgant

Goal

Make live injection into a running agent structurally impossible, so all agent-directed delivery is spawn-or-resume only. Escalation/dispatch fragility (#94/#96/#98) all traced to injecting into a live TUI; #104 removes most of it, but nothing stops it being reintroduced (e.g. the babysitter wake in #100 still injects). A convention won't hold — this needs enforcement.

The invariant

An agent receives input only at spawn (the spawn task) or via resume (re-spawn with context). Live injection into a running session is prohibited.

The surface

Two optional methods on the FleetClient port carry all injection:

  • waitForInjected?(input, opts)src/ports/fleet.ts:66
  • sendInput?(name, data)src/ports/fleet.ts:67

resume(input) (fleet.ts:53) is the durable alternative. Call sites in factory.ts: 12 on main, 3 after #104. Small, reachable target.

Enforcement ladder (weakest → airtight)

  1. Test invariant (now, cheap). Count waitForInjected/sendInput calls in FakeFleetClient; assert the babysitter-wake path (and durable dispatch) produce zero. feat(dispatch): opt-in spawn-task delivery to avoid the codex injection race (#98) #101/Make human escalation durable through GitHub issues #104 already do a version for dispatch (expect(fleet.messages).toEqual([])). Catches regressions before the primitive is gone.
  2. CI lint guard (now). no-restricted-syntax (or a CI grep) that fails on any new .waitForInjected( / .sendInput( outside an explicit legacy-compat allowlist. Reintroducing injection becomes a deliberate, reviewer-visible allowlist edit — not an accident.
  3. Single delivery seam (structural). Route all agent-directed delivery through one method (#deliverToAgent(record, spec, content)) whose only implementations are spawn-task and resume. Delivery code never touches the fleet injection methods directly.
  4. Delete the primitive (airtight). Once the last 3 call sites migrate, remove waitForInjected/sendInput from the port and all impls. The type system then enforces it — you cannot call a method that doesn't exist. Only this is airtight; 1–3 are the bridge to it.

The design tension to resolve first (babysitter cadence)

The babysitter is why injection was tempting: it's long-lived and reacts to many small PR events (review comments, CI). Naïve "no injection" → resume-per-event → a cold-start on every wake = expensive, and someone will "temporarily" reach for injection again. So enforcement must pair with coalesced resume: batch events, re-spawn with the batch. #100's durable subscription (claim/ack) already provides the coalescing primitive — deliver the batch via resume, not sendInput. Solving the cadence is a prerequisite for step 4 being acceptable.

Sequence

  1. Land Make human escalation durable through GitHub issues #104 (removes 9 of 12 injection calls; durable issue-comment escalation).
  2. Add the test invariant (1) + lint guard (2) now, allowlisting the remaining 3 call sites.
  3. Migrate the last 3 to resume — babysitter wake via coalesced resume (pairs with feat: consume durable Relayfile babysitter subscriptions #100).
  4. Delete waitForInjected/sendInput from the port + impls; drop the allowlist.
  5. Document the invariant (ADR / CONTRIBUTING) so the guard and reviews have a rationale to cite.

Acceptance

  • No .waitForInjected( / .sendInput( call sites remain in src/ (grep-clean).
  • The methods are absent from FleetClient (src/ports/fleet.ts) and all impls — build fails if re-added.
  • Babysitter wakes are delivered via coalesced resume with no per-event cold-start regression.
  • A test asserts durable dispatch + babysitter wake perform zero live injection.

Refs: #99 (escalation redesign), #104 (removes 9/12 — the enabling step), #100 (durable babysitter subscription — its wake delivery is one of the last 3 to migrate; provides the coalescing primitive), #98/#96 (the failures live injection caused).

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