feat(#24): cost-guard pre-check at outbound dispatch#57
Merged
Conversation
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>
3 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #24.
Summary
When a target instance hits its daily cost limit, the cost-guard's
limithandler pauses it. Before this PR, the rest of the fleet kept dispatchingsend_to_instance/delegate_taskto 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
OutboundContextgainscostGuard: CostGuard | null.FleetManageralready owns this field; the interface change just exposes it to handler code.sendToInstanceshort-circuits with an error whenctx.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_taskinherits the gate viawrapAsSend(it routes throughsendToInstancewithrequest_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.ctx.costGuardis 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— cleannpx vitest run— 63 files / 524 tests pass (+1 file, +7 tests over baseline; no regressions)tests/outbound-cost-guard-precheck.test.ts:cost-guard, target name, and limit dollar amount; target IPC receives 0 messages (sendToInstance + delegateTask both verified)request_kind: "report"dispatches normally even when target is limitedRisks considered
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 throughsendToInstanceand 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.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).costGuardis already optional in fleet config, andctx.costGuardis null when disabled.Files
src/outbound-handlers.ts— interface field + gate at top ofsendToInstancetests/outbound-cost-guard-precheck.test.ts— new🤖 Generated with Claude Code