Skip to content

Commit 83f6138

Browse files
nficanocursoragent
andcommitted
fix: reject negative costs, allow exact-zero budget, emit metric on exhaustion (§9.6) (#62 #84 #85 #97)
CostBudget::consume() now throws InvalidArgumentException for negative cost metric values (no decrement) and only treats a counter as exhausted when it goes negative, so a metric that lands exactly on zero is within budget. JobContext::emitMetric() emits the consuming sample with budget_remaining='0' before re-throwing BudgetExhaustedException. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 53ed2e4 commit 83f6138

5 files changed

Lines changed: 55 additions & 7 deletions

File tree

src/Runtime/CostBudget.php

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,27 @@ public function consume(string $metricName, int|float $value, string $unit): ?st
5858
if (!str_starts_with($metricName, 'cost.') || $metricName === 'cost.budget.remaining') {
5959
return null;
6060
}
61-
if (!isset($this->remaining[$unit]) || $value < 0) {
62-
return null;
63-
}
6461
if (\is_float($value) && (!is_finite($value) || is_nan($value))) {
6562
throw new InvalidArgumentException('cost metric value must be finite', [
6663
'metric' => $metricName,
6764
'value' => $value,
6865
]);
6966
}
67+
// §9.6: "Negative values are rejected and produce no decrement."
68+
if ($value < 0) {
69+
throw new InvalidArgumentException('cost metric value must not be negative', [
70+
'metric' => $metricName,
71+
'value' => $value,
72+
]);
73+
}
74+
if (!isset($this->remaining[$unit])) {
75+
return null;
76+
}
7077
$this->remaining[$unit] -= self::numericToScaled($value);
71-
if ($this->remaining[$unit] <= 0) {
78+
// A metric that consumes exactly the remaining balance lands on zero
79+
// and is still within budget; only an overspend (negative remaining)
80+
// exhausts the counter.
81+
if ($this->remaining[$unit] < 0) {
7282
throw new BudgetExhaustedException($unit, $this->format($this->remaining[$unit]));
7383
}
7484
return $this->format($this->remaining[$unit]);

src/Runtime/JobContext.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Amp\Cancellation;
88
use Amp\DeferredCancellation;
99
use Arcp\Envelope\Priority;
10+
use Arcp\Errors\BudgetExhaustedException;
1011
use Arcp\Errors\DeadlineExceededException;
1112
use Arcp\Errors\PermissionDeniedException;
1213
use Arcp\Ids\JobId;
@@ -102,7 +103,18 @@ public function emitLog(string $level, string $message, array $attributes = []):
102103
public function emitMetric(string $name, int|float $value, string $unit, array $dims = []): void
103104
{
104105
$job = $this->runtime->jobs->tryGet($this->jobId);
105-
$remaining = $job?->budget?->consume($name, $value, $unit);
106+
try {
107+
$remaining = $job?->budget?->consume($name, $value, $unit);
108+
} catch (BudgetExhaustedException $e) {
109+
// Surface the consuming sample to observers before unwinding so
110+
// the metric that exhausted the budget is not lost.
111+
$dims['budget_remaining'] = '0';
112+
$this->runtime->emit($this->session, new MetricEvent($name, $value, $unit, $dims), [
113+
'job_id' => $this->jobId,
114+
'trace_id' => $this->traceId,
115+
]);
116+
throw $e;
117+
}
106118
if ($remaining !== null) {
107119
$dims['budget_remaining'] = $remaining;
108120
}

tests/Integration/JobLifecycleTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ public function testCostBudgetExhaustionFailsJob(): void
292292
public function invoke(array $arguments, JobContext $ctx, ?Cancellation $cancellation = null): mixed
293293
{
294294
$ctx->emitMetric('cost.search', 0.60, 'USD');
295-
$ctx->emitMetric('cost.search', 0.40, 'USD');
295+
$ctx->emitMetric('cost.search', 0.50, 'USD');
296296
return ['ok' => true];
297297
}
298298
});

tests/Unit/Runtime/CostBudgetTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,29 @@ public function testPatternAcceptsExactlySixDecimals(): void
7979
$budget = CostBudget::fromPatterns(['USD:0.000001']);
8080
self::assertSame(['USD' => '0.000001'], $budget->remaining());
8181
}
82+
83+
public function testConsumeRejectsNegativeValue(): void
84+
{
85+
$budget = CostBudget::fromPatterns(['USD:1.00']);
86+
try {
87+
$budget->consume('cost.refund', -1, 'USD');
88+
self::fail('expected InvalidArgumentException');
89+
} catch (InvalidArgumentException) {
90+
// §9.6: negative values are rejected and produce no decrement.
91+
}
92+
self::assertSame(['USD' => '1'], $budget->remaining());
93+
}
94+
95+
public function testConsumingExactRemainingReturnsZeroWithoutExhausting(): void
96+
{
97+
$budget = CostBudget::fromPatterns(['USD:1.00']);
98+
self::assertSame('0', $budget->consume('cost.inference', 1.00, 'USD'));
99+
}
100+
101+
public function testConsumingBeyondRemainingExhausts(): void
102+
{
103+
$budget = CostBudget::fromPatterns(['USD:1.00']);
104+
$this->expectException(\Arcp\Errors\BudgetExhaustedException::class);
105+
$budget->consume('cost.inference', 1.01, 'USD');
106+
}
82107
}

tests/Unit/Runtime/V11FeaturesTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ public function testCostBudgetThrowsWhenExhausted(): void
4242
{
4343
$budget = CostBudget::fromPatterns(['USD:0.10']);
4444
$this->expectException(BudgetExhaustedException::class);
45-
$budget->consume('cost.search', 0.10, 'USD');
45+
// Overspending the counter (not merely landing on zero) exhausts it.
46+
$budget->consume('cost.search', 0.11, 'USD');
4647
}
4748

4849
public function testCostBudgetSubsetUsesRemainingBudget(): void

0 commit comments

Comments
 (0)