Skip to content

feat(#24): cost-guard pre-check at outbound dispatch#57

Merged
suzuke merged 1 commit into
mainfrom
feat/24-usage-limit-notify
Apr 27, 2026
Merged

feat(#24): cost-guard pre-check at outbound dispatch#57
suzuke merged 1 commit into
mainfrom
feat/24-usage-limit-notify

Conversation

@suzuke

@suzuke suzuke commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Closes #24.

Summary

When a target instance hits its daily cost limit, the cost-guard's limit handler pauses it. Before this PR, the rest of the fleet kept dispatching send_to_instance / delegate_task to the paused target — the sender got no signal until the no-reply timed them out, and the target accumulated a backlog it could not work on.

This PR surfaces the limit to the sender immediately, at dispatch time.

Changes

  • OutboundContext gains costGuard: CostGuard | null. FleetManager already owns this field; the interface change just exposes it to handler code.
  • sendToInstance short-circuits with an error when ctx.costGuard?.isLimited(target) is true. Error names the target and its daily limit so the caller can decide whether to back off or escalate.
  • delegate_task inherits the gate via wrapAsSend (it routes through sendToInstance with request_kind: "task").
  • report_result (request_kind: "report") bypasses the gate — terminal status updates must reach the orchestrator even when the target is paused, otherwise the merge gate stalls and impl can't escalate.
  • Null-safe: fleet without cost guard → ctx.costGuard is null, optional chain skips the check.

LOC: src/outbound-handlers.ts +23 / -0; tests/outbound-cost-guard-precheck.test.ts +184.

Test plan (operator-mandated three states)

  • npx tsc --noEmit — clean
  • npx vitest run — 63 files / 524 tests pass (+1 file, +7 tests over baseline; no regressions)
  • tests/outbound-cost-guard-precheck.test.ts:
    • (a) limited → handler responds with error containing cost-guard, target name, and limit dollar amount; target IPC receives 0 messages (sendToInstance + delegateTask both verified)
    • (b) not limited → normal dispatch; target IPC receives 1 message; respond called with success object
    • (c) ctx.costGuard === null → graceful dispatch identical to (b), no error path triggered
    • report-kind bypass → message with request_kind: "report" dispatches normally even when target is limited

Risks considered

  • Existing cost-guard pause logic unchanged: this PR adds a pre-check at dispatch time. The existing costGuard.on("limit", …) pause flow (fleet-manager.ts:370) still fires when the target itself reports cost over the limit. Two complementary signals, neither replaces the other.
  • request_information (kind: "query"): also routes through sendToInstance and IS gated by this change. Defensible — an over-budget instance shouldn't be answering questions either, and the sender gets a clear signal. If review disagrees, narrowing to only "task" + direct sends is a 1-line change.
  • Bypass list: request_kind === "report" is the only bypass. update / query / task / unspecified all gate. Report bypass is justified because reports are terminal status (no follow-up cost).
  • Race: a target can transition from "not limited" to "limited" between gate check and dispatch, but that's pre-existing race surface (any pause signal arrives mid-dispatch). The pre-check shrinks the window, doesn't eliminate it. No new race introduced.
  • No fleet config migration: costGuard is already optional in fleet config, and ctx.costGuard is null when disabled.

Files

  • src/outbound-handlers.ts — interface field + gate at top of sendToInstance
  • tests/outbound-cost-guard-precheck.test.ts — new

🤖 Generated with Claude Code

Before this change, when a target instance hit its daily cost limit
the limit handler paused it but the rest of the fleet still dispatched
new messages to it via send_to_instance / delegate_task. The sender
saw no signal until the lack of reply timed them out, and the paused
target accumulated a backlog it could not act on.

Add a fleet-wide pre-check at the outbound dispatch handler:

- Expose `costGuard: CostGuard | null` on `OutboundContext`. FleetManager
  already owns the field; the interface declaration just makes it
  available to handler code.
- At the top of `sendToInstance`, when `costGuard?.isLimited(target)`
  is true, short-circuit with an error response that names the target
  and its daily limit (in dollars). Caller's MCP tool surfaces this as
  an immediate error rather than waiting for a silent timeout.
- `delegate_task` funnels through `sendToInstance` via wrapAsSend with
  request_kind="task", so it inherits the gate without duplication.
- `report_result` (request_kind="report") bypasses the gate. Terminal
  status updates must reach the orchestrator even when the target is
  paused, otherwise the merge gate stalls.
- Null-safe: when fleet config has no cost guard, `ctx.costGuard` is
  null and the optional chain skips the check entirely.

Tests cover the operator-mandated three states for both handlers, plus
the report-kind bypass:
- (a) limited → immediate warning, no dispatch
- (b) not limited → normal dispatch
- (c) ctx.costGuard null → graceful fallback, no error
- report_kind → bypasses gate even when limited

`npx tsc --noEmit` clean. `npx vitest run` 63 files / 524 tests pass
(+1 file, +7 tests over baseline; no regressions).

Closes #24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@suzuke suzuke merged commit 464c8f4 into main Apr 27, 2026
2 checks passed
@suzuke suzuke deleted the feat/24-usage-limit-notify branch April 27, 2026 03:14
suzuke added a commit that referenced this pull request Apr 27, 2026
…surface) (#58)

PR #57 (#24 fix) gated `sendToInstance` and (transitively via wrapAsSend)
`delegate_task` and `request_information`, but `broadcast` runs its own
per-target send loop without funnelling through `sendToInstance` — so a
limited instance could still be flooded via broadcast with task/query/
update kinds. ts-reviewer flagged this as Out-of-scope Observation 1 on
PR #57. This PR closes the gap.

Behaviour: per-target skip with structured warning, never short-circuits
the whole broadcast. Other targets still receive normally.

- broadcast loop: when `ctx.costGuard?.isLimited(target)` is true, push
  the target onto a new `costLimited` array with the same warning string
  format used by sendToInstance and continue the loop.
- broadcast result gains a `cost_limited: [{ target, warning }]` field
  alongside `sent_to` and `failed`. Callers see exactly which targets
  were skipped and why.
- BroadcastRequestKind already excludes `"report"` (broadcasts don't
  carry correlation_id), so no kind-bypass is needed here.
- Session-routing edge case (Out-of-scope Observation 2) explicitly
  deferred per ts-lead — gate keys by `targetName` exactly like the
  rest of the broadcast loop.

Tests cover the operator-mandated three states for broadcast:
- (a) mixed → limited targets warned + skipped, others delivered
- (b) all limited → every target warned, zero IPC sends
- (c) no targets limited → identical to pre-fix, cost_limited empty

`npx tsc --noEmit` clean. `npx vitest run` 63 files / 527 tests pass
(+3 over post-#57 baseline; no regressions).

Closes #24 issue intent (now covers all outbound-dispatch surfaces).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
suzuke added a commit that referenced this pull request Apr 27, 2026
Sprint 0 outcome: 0 open issues, 0 open PRs. Three PRs shipped
(#56 #55 fix; #57 #24 fix; #58 #24 follow-up broadcast surface),
four issues closed-with-migration to agend-terminal (#52 #53 #54 #8).

Adds:
- docs/ts-team-sprint-0-2026-04-27-wrap.md — sprint outcome, fix
  details, process notes, next-sprint pointer
- docs/ts-team-backlog.md — non-blocking gaps surfaced during
  deprecated-mode maintenance. B-001 = session-routing edge case
  in cost-guard gate (deferred from PR #57 review)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Notify sender when recipient instance has hit usage limit

1 participant