ai-dispatch 10.17.0

Multi-AI CLI team orchestrator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
// Tests for what ends a rate-limit hold: a stated time, a person, or a short
// cooldown. Every refusal string below is verbatim captured CLI output — the
// fixtures under tests/fixtures/ are copies of live ~/.aid markers.

use super::*;
use crate::types::AgentKind;

fn isolated() -> tempfile::TempDir {
    let temp = tempfile::tempdir().expect("temp dir");
    std::fs::create_dir_all(temp.path().join(".aid")).expect("aid dir");
    temp
}

/// The refusal aid could not classify at all: a bare status code caught on
/// stderr, matching no signature. It writes a marker with no recovery time and
/// no manual hold, and that must expire on its own.
///
/// Treating "no stated recovery time" as "never recovers" blackholed a route
/// until someone ran `aid config clear-limit` — the same defect as an outage
/// going unrecorded, pointing the other way.
#[test]
fn a_transient_refusal_with_no_stated_time_expires_on_its_own() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    mark_rate_limited(&AgentKind::Claude, "HTTP 429 Too Many Requests");

    let info = get_rate_limit_info(&AgentKind::Claude).expect("marker written");
    assert_eq!(info.recovery_at, None, "no time was stated, so none is invented");
    assert!(!info.needs_human, "a bare 429 does not need a person");
    assert!(is_rate_limited(&AgentKind::Claude), "still inside the cooldown");

    // Age the marker past the cooldown window: the route is tried again.
    age_marker(&marker_path(&AgentKind::Claude), RATE_LIMIT_WINDOW_SECS + 60);
    assert!(
        !is_rate_limited(&AgentKind::Claude),
        "a transient refusal must not hold a route open indefinitely"
    );
}

/// The other class. A spent balance does not come back on a clock, so ageing
/// the marker changes nothing — only `aid config clear-limit` does.
#[test]
fn a_refusal_that_needs_a_person_is_not_released_by_time() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    mark_rate_limited(
        &AgentKind::Grok,
        "API error (status 402 Payment Required): Grok Build usage balance exhausted",
    );

    let info = get_rate_limit_info(&AgentKind::Grok).expect("marker written");
    assert_eq!(info.recovery_at, None, "no invented reset time");
    assert!(info.needs_human);

    age_marker(&marker_path(&AgentKind::Grok), RATE_LIMIT_WINDOW_SECS * 100);
    assert!(
        is_rate_limited(&AgentKind::Grok),
        "a spent balance must survive any amount of elapsed time"
    );

    assert!(clear_rate_limit(&AgentKind::Grok));
    assert!(!is_rate_limited(&AgentKind::Grok));
}

/// Every refusal we classify as needing a person, with the message that made us
/// classify it that way. Each holds with no recovery time rather than being
/// given one that later expires.
#[test]
fn every_human_ended_refusal_holds_without_an_invented_reset_time() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    for (agent, message) in [
        (
            AgentKind::Cursor,
            "ActionRequiredError: Increase limits for faster responses You're out of usage. \
             Switch to Auto, or ask your admin to increase your limit to continue.",
        ),
        (AgentKind::Copilot, "You have exceeded your monthly quota"),
        (
            AgentKind::Copilot,
            "You've reached your premium request limit for this billing cycle.",
        ),
        (
            AgentKind::Grok,
            "API error (status 402 Payment Required): Grok Build usage balance exhausted",
        ),
        (
            AgentKind::OpenCode,
            "Insufficient balance. Manage your billing here: https://opencode.ai/",
        ),
        (AgentKind::Droid, "402 payment required: reload your tokens"),
        (
            AgentKind::Gemini,
            "IneligibleTierError: This client is no longer supported for Gemini Code \
             Assist for individuals",
        ),
    ] {
        clear_rate_limit(&agent);
        mark_rate_limited(&agent, message);
        let info = get_rate_limit_info(&agent).expect("marker written");
        assert_eq!(
            info.recovery_at, None,
            "{agent:?} must not be given a reset time it never stated: {message}"
        );
        assert!(info.needs_human, "{agent:?} must be held for a person: {message}");

        age_marker(&marker_path(&agent), RATE_LIMIT_WINDOW_SECS * 100);
        assert!(is_rate_limited(&agent), "{agent:?} hold must survive elapsed time");
        clear_rate_limit(&agent);
    }
}

/// The clock-ended class keeps its bounded cooldown. Holding these for a person
/// would strand a route that comes back by itself.
#[test]
fn clock_ended_refusals_still_get_a_recovery_time() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    for (agent, message) in [
        (AgentKind::Qwen, "Quota exhausted: Your token-plan 5-hour quota has been exhausted."),
        (AgentKind::Oz, "Error: Quota limit reached."),
        (AgentKind::Cursor, "quota exceeded for this workspace"),
        (
            AgentKind::Antigravity,
            "Individual quota reached. Please upgrade your subscription to increase \
             your limits. Resets in 59m21s.",
        ),
        (
            AgentKind::Droid,
            "402 You've reached your weekly standard usage limit (resets in 1 day).",
        ),
    ] {
        clear_rate_limit(&agent);
        mark_rate_limited(&agent, message);
        let info = get_rate_limit_info(&agent).expect("marker written");
        assert!(
            info.recovery_at.is_some(),
            "{agent:?} recovers on a clock and must say when: {message}"
        );
        assert!(!info.needs_human, "{agent:?} must not wait for a person: {message}");
        clear_rate_limit(&agent);
    }
}

/// A stated reset time outranks the signature's class default: a refusal that
/// does name its reset date is held to that date, not to a person.
#[test]
fn a_stated_reset_time_wins_over_the_class_default() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    mark_rate_limited(
        &AgentKind::Droid,
        "402 payment required: reload your tokens (resets in 2 hours)",
    );
    let info = get_rate_limit_info(&AgentKind::Droid).expect("marker written");
    assert!(info.recovery_at.is_some(), "the stated time must be recorded");
    assert!(!info.needs_human, "a stated time is not a human hold");
}

/// A recovery phrase we cannot parse is not a permanent hold either. Writing
/// "tomorrow morning" into the field and then failing to read it back must fall
/// to the bounded cooldown, not to "out forever".
#[test]
fn an_unparseable_recovery_phrase_falls_back_to_the_cooldown() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let path = marker_path(&AgentKind::Qwen);
    std::fs::write(&path, "recovery_at: tomorrow morning\nmessage: out of quota\n")
        .expect("write marker");

    assert!(is_rate_limited(&AgentKind::Qwen), "fresh marker still holds");
    age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
    assert!(
        !is_rate_limited(&AgentKind::Qwen),
        "an unreadable time must expire, not become permanent"
    );
}

/// The live markers copied from ~/.aid on 2026-08-07. Each states its reset time
/// in its own provider's phrasing, and every one of those must still read as out —
/// this fix must not shorten a hold that exists today.
///
/// The stated year is pushed forward before the marker is written. As captured,
/// opencode's reset time had passed within a day and droid's within hours, so the
/// test decayed into failing on the calendar rather than on the code. The phrasing
/// each provider uses is the part under test — `parse_recovery_datetime` has to read
/// `Aug 11th, 2026 2:23 PM` and `Aug 08, 2026 12:39 AM` alike — so only the year moves.
///
/// The marker is then aged past the transient cooldown. Without that, a `recovery_at`
/// this parser failed to read would fall through to `StoredHold::Transient`, and a
/// freshly written file is inside its window, so the assertion would pass without the
/// stated time doing any work at all.
#[test]
fn live_markers_with_a_future_reset_time_still_read_as_out() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    for (agent, fixture) in [
        (AgentKind::Codex, "rate-limit-codex"),
        (AgentKind::Qwen, "rate-limit-qwen"),
        (AgentKind::Droid, "rate-limit-droid"),
        (AgentKind::OpenCode, "rate-limit-opencode"),
    ] {
        let content = with_future_recovery_year(&read_fixture(fixture));
        let path = marker_path(&agent);
        std::fs::write(&path, &content).expect("write marker");
        age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
        assert!(
            is_rate_limited(&agent),
            "{fixture} states a future reset time and must still hold"
        );
    }
}

/// Move the year on the `recovery_at:` line only, leaving each provider's message
/// body — and its date phrasing — exactly as it was captured.
fn with_future_recovery_year(content: &str) -> String {
    let mut out: String = content
        .lines()
        .map(|line| match line.strip_prefix("recovery_at: ") {
            Some(stated) => format!("recovery_at: {}\n", stated.replace("2026", "2126")),
            None => format!("{line}\n"),
        })
        .collect();
    if !content.ends_with('\n') {
        out.pop();
    }
    out
}

/// oz's live marker says "try again at Aug 07, 2026 02:05 AM" — fourteen hours
/// in the past when it was captured. `aid config agents` printed it as the
/// current status because the renderer never asked whether the hold was over.
#[test]
fn a_marker_whose_stated_time_has_passed_reads_as_recovered() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    std::fs::write(marker_path(&AgentKind::Oz), read_fixture("rate-limit-oz"))
        .expect("write marker");
    assert!(
        !is_rate_limited(&AgentKind::Oz),
        "a reset time in the past means the route is available again"
    );
}

/// A marker written before the hold classes existed has no `hold:` line. It must
/// not be read as permanent — that is the blackhole this fix removes.
#[test]
fn a_marker_without_a_hold_field_is_not_permanent() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let path = marker_path(&AgentKind::Copilot);
    std::fs::write(&path, "recovery_at: \nmessage: some old refusal\n").expect("write marker");
    age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);
    assert!(!is_rate_limited(&AgentKind::Copilot));
}

/// Group markers are held by the same three classes as agent markers, so a
/// spent cursor premium pool survives while `auto` is untouched.
#[test]
fn a_group_marker_holds_for_a_person_too() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let cursor = AgentKind::Cursor;
    mark_group_rate_limited(
        &cursor,
        "premium",
        "ActionRequiredError: Increase limits for faster responses You're out of usage. \
         Switch to Auto, or ask your admin to increase your limit to continue.",
    );

    age_marker(&group_marker_path(&cursor, "premium"), RATE_LIMIT_WINDOW_SECS * 100);
    assert!(is_group_rate_limited(&cursor, "premium"));
    assert!(!is_group_rate_limited(&cursor, "auto"), "auto keeps serving");
    assert!(!is_rate_limited(&cursor), "the agent itself is not written off");

    assert!(clear_all_rate_limits_for_agent(&cursor));
    assert!(!is_group_rate_limited(&cursor, "premium"));
}

/// The live call site, not just the grouping helper. `classify_line` in the
/// cursor adapter has no model in hand, so it marked the agent — which made
/// `is_rate_limited(Cursor)` true and took `auto` out with the pool that ran
/// out. The message names the tier; the marking must follow it.
#[test]
fn a_cursor_premium_refusal_with_no_model_in_hand_holds_only_the_premium_pool() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let cursor = AgentKind::Cursor;
    mark_rate_limited_for_message(
        &cursor,
        "ActionRequiredError: Increase limits for faster responses You're out of usage. \
         Switch to Auto, or ask your admin to increase your limit to continue.",
    );

    assert!(is_group_rate_limited(&cursor, "premium"), "the spent pool is held");
    assert!(!is_group_rate_limited(&cursor, "auto"), "auto keeps serving");
    assert!(!is_rate_limited(&cursor), "the agent as a whole is not written off");
    assert!(
        dispatch_blocking_hold(&cursor).is_none(),
        "aid run must still dispatch cursor — on auto"
    );
}

/// End to end on the channel it actually arrives on. cursor's premium refusal is
/// on stderr and nowhere else — no error envelope in the stream carries it — so
/// this is the coverage that decides whether `Channel::CliStderr` earns its
/// place in the enumeration. If it ever stops passing, the answer is not to
/// widen the stream channel but to say so.
#[test]
fn cursors_premium_refusal_is_read_off_the_stderr_channel() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let stderr = "ActionRequiredError: Increase limits for faster responses You're out of usage. \
                  Switch to Auto, or ask your admin to increase your limit to continue.";
    let refusal = refusal_on_channel(
        stderr,
        AgentKind::Cursor,
        crate::quota_channel::Channel::CliStderr,
    )
    .expect("cursor's premium refusal must be readable on stderr");
    mark_rate_limited_for_message(&AgentKind::Cursor, &refusal);

    assert!(is_group_rate_limited(&AgentKind::Cursor, "premium"));
    assert!(!is_group_rate_limited(&AgentKind::Cursor, "auto"));
    assert!(!is_rate_limited(&AgentKind::Cursor));
}

/// The complement: a cursor refusal that names no tier is still an agent-level
/// fact and must not be quietly narrowed to one group.
#[test]
fn a_cursor_refusal_naming_no_tier_still_marks_the_agent() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let cursor = AgentKind::Cursor;
    mark_rate_limited_for_message(&cursor, "Quota exceeded for this workspace");

    assert!(is_rate_limited(&cursor));
    assert!(!is_group_rate_limited(&cursor, "premium"));
}

/// `aid run` used to divert only when a recovery time was present. A spent
/// balance states none, so dispatch walked into an account that cannot serve.
#[test]
fn a_human_ended_hold_blocks_dispatch_and_names_the_way_out() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    mark_rate_limited(
        &AgentKind::Grok,
        "API error (status 402 Payment Required): Grok Build usage balance exhausted",
    );
    let hold = dispatch_blocking_hold(&AgentKind::Grok).expect("a spent balance must block");
    assert_eq!(hold, "until cleared with `aid config clear-limit grok`");

    assert!(clear_rate_limit(&AgentKind::Grok));
    assert!(dispatch_blocking_hold(&AgentKind::Grok).is_none());
}

/// The other direction of the same gate: a stated time still blocks while it is
/// in the future, and stops blocking once it has passed. oz's live marker is
/// fourteen hours stale and kept diverting runs off a route that was back.
#[test]
fn a_stated_time_blocks_dispatch_only_until_it_passes() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    std::fs::write(marker_path(&AgentKind::Codex), read_fixture("rate-limit-codex"))
        .expect("write marker");
    assert_eq!(
        dispatch_blocking_hold(&AgentKind::Codex),
        Some("until Aug 11th, 2026 2:23 PM".to_string()),
        "the provider's own phrasing of the time is quoted back"
    );

    std::fs::write(marker_path(&AgentKind::Oz), read_fixture("rate-limit-oz")).expect("write marker");
    assert!(
        dispatch_blocking_hold(&AgentKind::Oz).is_none(),
        "a reset time in the past must not divert a run"
    );
}

/// A bounded cooldown is not a dispatch gate. Moving the caller off the agent
/// they asked for costs more than the few minutes left on a transient 429, and
/// gating on it was never the previous behaviour either.
#[test]
fn a_transient_cooldown_does_not_divert_dispatch() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    mark_rate_limited(&AgentKind::Claude, "HTTP 429 Too Many Requests");
    assert!(is_rate_limited(&AgentKind::Claude), "still cooling down");
    assert!(dispatch_blocking_hold(&AgentKind::Claude).is_none());
}

/// Markers already on disk when this version lands carry no `hold:` line. The
/// two human-ended ones state no reset time either, so the cooldown would
/// release them within five minutes and hand work back to a spent allowance.
/// Their stored refusal text is the same evidence write-time classification
/// uses, so it is re-read rather than the file rewritten.
#[test]
fn a_legacy_marker_is_reclassified_from_the_refusal_it_stored() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    for (agent, fixture) in [
        // "recovery_at: " empty, message a mid-token JSON fragment that still
        // carries "You have exceeded your monthly quota".
        (AgentKind::Copilot, "rate-limit-copilot"),
        // "recovery_at: " empty, refusal on a later line of a multi-line message.
        (AgentKind::Grok, "rate-limit-grok"),
    ] {
        let path = marker_path(&agent);
        std::fs::write(&path, read_fixture(fixture)).expect("write marker");
        age_marker(&path, RATE_LIMIT_WINDOW_SECS * 100);

        assert!(
            is_rate_limited(&agent),
            "{fixture} states a refusal only a person ends and must not expire on a timer"
        );
        assert!(
            get_rate_limit_info(&agent).expect("marker present").needs_human,
            "{fixture} must report which kind of hold it is under"
        );
        assert!(dispatch_blocking_hold(&agent).is_some(), "{fixture} must divert dispatch");
    }
}

/// The same rule must not turn every legacy marker permanent. A stored message
/// matching no signature is still transient and still expires — that is the
/// blackhole this whole change exists to avoid.
#[test]
fn a_legacy_marker_with_an_unrecognised_refusal_still_expires() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let path = marker_path(&AgentKind::Claude);
    std::fs::write(&path, "recovery_at: \nmessage: 429 Too Many Requests\n")
        .expect("write marker");
    age_marker(&path, RATE_LIMIT_WINDOW_SECS + 60);

    assert!(!is_rate_limited(&AgentKind::Claude));
    assert!(!get_rate_limit_info(&AgentKind::Claude).expect("marker present").needs_human);
}

/// A marker records what *one* provider said, so a needle another provider owns
/// is not evidence about this one. `~/.aid/rate-limit-claude` was written on
/// 2026-08-07 from an agent's own message quoting this crate's signature table,
/// and the stored text contained `"insufficient balance"` — opencode's refusal,
/// which held claude open until someone cleared it.
///
/// The scoping is what does the work here, not the wording: the identical text
/// in opencode's own marker is still a human-ended hold, as the second half
/// asserts. The write side can no longer produce such a marker at all
/// (`quota_channel`); this covers the ones already on disk.
#[test]
fn a_stored_refusal_only_speaks_for_the_agent_whose_marker_it_is() {
    let temp = isolated();
    let _guard = crate::paths::AidHomeGuard::set(temp.path());

    let stored = "recovery_at: \nmessage: QuotaSignature { needle: \"insufficient balance\", \
                  recovery: QuotaRecovery::NeedsHuman }\n";

    let claude = marker_path(&AgentKind::Claude);
    std::fs::write(&claude, stored).expect("write marker");
    age_marker(&claude, RATE_LIMIT_WINDOW_SECS + 60);
    assert!(
        !is_rate_limited(&AgentKind::Claude),
        "another provider's needle must not hold claude open until someone clears it"
    );

    let opencode = marker_path(&AgentKind::OpenCode);
    std::fs::write(&opencode, stored).expect("write marker");
    age_marker(&opencode, RATE_LIMIT_WINDOW_SECS + 60);
    assert!(
        is_rate_limited(&AgentKind::OpenCode),
        "opencode's own refusal must still hold, or this became a denylist"
    );
}

fn read_fixture(name: &str) -> String {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name);
    std::fs::read_to_string(&path).unwrap_or_else(|err| panic!("read {path:?}: {err}"))
}

/// Backdate a marker's mtime so the bounded cooldown can be exercised without
/// sleeping. The transient class measures from the write time.
fn age_marker(path: &std::path::Path, seconds: u64) {
    let when = std::time::SystemTime::now() - std::time::Duration::from_secs(seconds);
    let file = std::fs::File::options()
        .write(true)
        .open(path)
        .unwrap_or_else(|err| panic!("open {path:?}: {err}"));
    file.set_modified(when)
        .unwrap_or_else(|err| panic!("set mtime on {path:?}: {err}"));
}