Skip to content

Commit 68ebda3

Browse files
authored
feat: unify agent task execution budgets (#8199)
1 parent 4bb260f commit 68ebda3

20 files changed

Lines changed: 1016 additions & 27 deletions

File tree

docs/commands/agent-task.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ ordering and output bindings belong in the existing single-run `fanout submit` /
7171

7272
### Cook/Review
7373

74+
### Provider Execution Budgets
75+
76+
Each dispatched plan carries one versioned `execution_budget`: total provider
77+
executions, same-provider retries, and provider rotations. The total cap applies
78+
before either category cap, so retry and rotation cannot multiply executions.
79+
80+
```bash
81+
homeboy agent-task dispatch --prompt @task.md --max-provider-executions 1
82+
homeboy agent-task dispatch --prompt @task.md --max-provider-executions 2 --max-same-provider-retries 1
83+
homeboy agent-task dispatch --prompt @task.md --max-provider-executions 2 --max-provider-rotations 1
84+
```
85+
86+
`--attempts` remains a deprecated alias for the total. It cannot be combined
87+
with explicit budget fields and resolves both category limits to `N - 1`.
88+
Status previews the resolved budget without changing durable run data.
89+
7490
| Subcommand | Purpose |
7591
|---|---|
7692
| `cook` | Run one workspace task through the patch-artifact handoff workflow. |

src/commands/agent_task/fanout.rs

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ use super::args::{
2929
};
3030
use super::command_json_value;
3131

32+
const AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2: &str =
33+
"homeboy/agent-task-batch-cook-fanout-plan/v2";
34+
3235
pub(super) fn fanout(args: AgentTaskFanoutArgs) -> CmdResult<Value> {
3336
match args.command {
3437
AgentTaskFanoutCommand::CookBatch(cook_batch_args) => cook_batch(cook_batch_args),
@@ -329,7 +332,11 @@ fn load_batch_cook_fanout_plan(args: &AgentTaskFanoutInputArgs) -> Result<BatchC
329332
BatchCookFanoutPlan::from_value(value, args)
330333
}
331334

335+
// v1 has no flattened or extension-bearing fields, so strict decoding preserves
336+
// its concrete contract while rejecting misspelled budget fields. v2 makes the
337+
// budget-capable schema explicit for new producers.
332338
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339+
#[serde(deny_unknown_fields)]
333340
struct BatchCookFanoutPlan {
334341
#[serde(default = "batch_cook_fanout_plan_schema")]
335342
schema: String,
@@ -342,6 +349,16 @@ struct BatchCookFanoutPlan {
342349
impl BatchCookFanoutPlan {
343350
fn from_value(value: Value, args: &AgentTaskFanoutInputArgs) -> Result<Self> {
344351
reject_generic_fanout_inputs(&value)?;
352+
let attempts_explicit = value
353+
.get("cooks")
354+
.and_then(Value::as_array)
355+
.map(|cooks| {
356+
cooks
357+
.iter()
358+
.map(|cook| cook.get("attempts").is_some())
359+
.collect::<Vec<_>>()
360+
})
361+
.unwrap_or_default();
345362
let mut plan: BatchCookFanoutPlan = serde_json::from_value(value).map_err(|error| {
346363
Error::validation_invalid_argument(
347364
"input",
@@ -352,12 +369,17 @@ impl BatchCookFanoutPlan {
352369
]),
353370
)
354371
})?;
372+
for (cook, attempts_explicit) in plan.cooks.iter_mut().zip(attempts_explicit) {
373+
cook.attempts_explicit = attempts_explicit;
374+
}
355375
if let Some(fanout_id) = &args.fanout_id {
356376
plan.fanout_id = fanout_id.clone();
357377
}
358-
if plan.schema != AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA {
378+
if plan.schema != AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA
379+
&& plan.schema != AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2
380+
{
359381
return Err(invalid_fanout(
360-
"agent-task fanout requires homeboy/agent-task-batch-cook-fanout-plan/v1",
382+
"agent-task fanout requires homeboy/agent-task-batch-cook-fanout-plan/v1 or /v2",
361383
));
362384
}
363385
if plan.fanout_id.trim().is_empty() {
@@ -378,6 +400,7 @@ impl BatchCookFanoutPlan {
378400
}
379401

380402
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
403+
#[serde(deny_unknown_fields)]
381404
struct BatchCookSpec {
382405
cook_id: String,
383406
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -404,6 +427,14 @@ struct BatchCookSpec {
404427
secret_env: Vec<String>,
405428
#[serde(default = "one")]
406429
attempts: u32,
430+
#[serde(skip)]
431+
attempts_explicit: bool,
432+
#[serde(default, skip_serializing_if = "Option::is_none")]
433+
max_provider_executions: Option<u32>,
434+
#[serde(default, skip_serializing_if = "Option::is_none")]
435+
max_same_provider_retries: Option<u32>,
436+
#[serde(default, skip_serializing_if = "Option::is_none")]
437+
max_provider_rotations: Option<u32>,
407438
#[serde(default = "one_usize")]
408439
concurrency: usize,
409440
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -516,6 +547,10 @@ impl BatchCookSpec {
516547
provider_config: self.provider_config.clone(),
517548
client_context: Some(merged_client_context(plan, self)),
518549
attempts: self.attempts,
550+
attempts_explicit: self.attempts_explicit,
551+
max_provider_executions: self.max_provider_executions,
552+
max_same_provider_retries: self.max_same_provider_retries,
553+
max_provider_rotations: self.max_provider_rotations,
519554
queue_only: false,
520555
timeout_ms: None,
521556
resolved_provider_policy: None,
@@ -663,6 +698,10 @@ fn build_cook_batch_plan(args: &AgentTaskFanoutCookBatchArgs) -> Result<BatchCoo
663698
model: args.model.clone(),
664699
secret_env: args.secret_env.clone(),
665700
attempts: 1,
701+
attempts_explicit: false,
702+
max_provider_executions: None,
703+
max_same_provider_retries: None,
704+
max_provider_rotations: None,
666705
concurrency: 1,
667706
provider_config: args.provider_config.clone(),
668707
client_context: Some(
@@ -1075,6 +1114,47 @@ mod tests {
10751114
});
10761115
}
10771116

1117+
#[test]
1118+
fn v2_budget_fields_round_trip_and_reject_typos() {
1119+
let plan = BatchCookFanoutPlan::from_value(
1120+
json!({
1121+
"schema": AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2,
1122+
"fanout_id": "fanout/budget",
1123+
"cooks": [{
1124+
"cook_id": "budget",
1125+
"prompt": "fix budget",
1126+
"to_worktree": "homeboy@budget",
1127+
"verify": ["true"],
1128+
"max_provider_executions": 3
1129+
}]
1130+
}),
1131+
&args(),
1132+
)
1133+
.expect("v2 plan");
1134+
let invocation = plan.cooks[0]
1135+
.to_cook_invocation(&plan)
1136+
.expect("v2 invocation");
1137+
assert_eq!(invocation.dispatch.core.max_provider_executions, Some(3));
1138+
assert!(!invocation.dispatch.core.attempts_explicit);
1139+
1140+
let error = BatchCookFanoutPlan::from_value(
1141+
json!({
1142+
"schema": AGENT_TASK_BATCH_COOK_FANOUT_PLAN_SCHEMA_V2,
1143+
"fanout_id": "fanout/budget",
1144+
"cooks": [{
1145+
"cook_id": "budget",
1146+
"prompt": "fix budget",
1147+
"to_worktree": "homeboy@budget",
1148+
"verify": ["true"],
1149+
"max_provider_execution": 3
1150+
}]
1151+
}),
1152+
&args(),
1153+
)
1154+
.expect_err("v2 typo rejected");
1155+
assert!(error.message.contains("max_provider_execution"));
1156+
}
1157+
10781158
#[test]
10791159
fn generic_fanout_inputs_are_rejected_from_public_contract() {
10801160
let error = BatchCookFanoutPlan::from_value(

src/commands/agent_task/status.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ pub(super) fn status(args: StatusArgs) -> CmdResult<Value> {
4848
}
4949
Err(error) => return Err(error),
5050
};
51+
// A future durable budget is incompatible, not an absent optional preview.
52+
if let Err(error) = agent_task_lifecycle::load_plan(&args.run_id) {
53+
if error
54+
.message
55+
.contains("unsupported agent-task execution budget version")
56+
{
57+
return Err(error);
58+
}
59+
}
5160
let mut value = serde_json::to_value(&record).unwrap_or(Value::Null);
5261
enrich_with_diagnostic_summary(&mut value, &args.run_id)?;
5362
if args.full {
@@ -1051,6 +1060,7 @@ fn compact_status_summary(record: &Value, run_id: &str) -> Value {
10511060
"risk_flags": risk_flags,
10521061
"execution_location": execution_location(record),
10531062
"queue_visibility": queue_visibility(record),
1063+
"execution_budget": plan.as_ref().map(|plan| &plan.options.execution_budget),
10541064
"liveness": liveness_summary(record),
10551065
"full_command": format!("homeboy agent-task status {run_id} --full"),
10561066
});

src/commands/agent_task/tests/dispatch.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,58 @@ fn cook_dispatch_provider_id_alias_maps_to_selector() {
329329
assert_eq!(args.dispatch.model, None);
330330
}
331331

332+
#[test]
333+
fn execution_budget_flags_accept_explicit_values_and_reject_legacy_attempts_mix() {
334+
let cli = Cli::try_parse_from([
335+
"homeboy",
336+
"agent-task",
337+
"cook",
338+
"--to-worktree",
339+
"homeboy@execution-budget",
340+
"--verify",
341+
"true",
342+
"--backend",
343+
"sample-backend",
344+
"--prompt",
345+
"cook",
346+
"--max-provider-executions",
347+
"2",
348+
"--max-same-provider-retries",
349+
"1",
350+
"--max-provider-rotations",
351+
"0",
352+
])
353+
.expect("execution budget flags parse");
354+
let Commands::AgentTask(agent_task) = cli.command else {
355+
panic!("expected agent-task command");
356+
};
357+
let AgentTaskCommand::Cook(args) = agent_task.command else {
358+
panic!("expected cook command");
359+
};
360+
assert_eq!(args.dispatch.core.max_provider_executions, Some(2));
361+
assert_eq!(args.dispatch.core.max_same_provider_retries, Some(1));
362+
assert_eq!(args.dispatch.core.max_provider_rotations, Some(0));
363+
364+
assert!(Cli::try_parse_from([
365+
"homeboy",
366+
"agent-task",
367+
"cook",
368+
"--to-worktree",
369+
"homeboy@execution-budget",
370+
"--verify",
371+
"true",
372+
"--backend",
373+
"sample-backend",
374+
"--prompt",
375+
"cook",
376+
"--attempts",
377+
"2",
378+
"--max-provider-executions",
379+
"2",
380+
])
381+
.is_err());
382+
}
383+
332384
#[test]
333385
fn agent_task_timeout_ms_flags_parse_for_cook_run_and_run_plan() {
334386
let cook = Cli::try_parse_from([

src/commands/agent_task/tests/promotion_review.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ fn cook_returns_durable_id_when_promotion_provider_is_missing() {
138138
tasks_json: None,
139139
provider_config: None,
140140
client_context: None,
141-
attempts: 1,
141+
attempts: Some(1),
142+
max_provider_executions: None,
143+
max_same_provider_retries: None,
144+
max_provider_rotations: None,
142145
queue_only: false,
143146
timeout_ms: None,
144147
resolved_provider_policy: None,
@@ -332,7 +335,10 @@ fn cook_applies_executor_commit_from_source_repo_to_distinct_target_repo() {
332335
tasks_json: None,
333336
provider_config: None,
334337
client_context: None,
335-
attempts: 1,
338+
attempts: Some(1),
339+
max_provider_executions: None,
340+
max_same_provider_retries: None,
341+
max_provider_rotations: None,
336342
queue_only: false,
337343
timeout_ms: None,
338344
resolved_provider_policy: None,

src/commands/agent_task_dispatch.rs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,21 @@ pub struct DispatchCoreArgs {
2121
#[arg(long = "client-context", value_name = "JSON")]
2222
pub client_context: Option<String>,
2323

24-
/// Attempts per task, including the first attempt.
25-
#[arg(long, default_value_t = 1, value_name = "N")]
26-
pub attempts: u32,
24+
/// Deprecated total provider-execution budget. Use --max-provider-executions.
25+
#[arg(long, value_name = "N", conflicts_with_all = ["max_provider_executions", "max_same_provider_retries", "max_provider_rotations"])]
26+
pub attempts: Option<u32>,
27+
28+
/// Total provider executions per task, including the first attempt.
29+
#[arg(long, value_name = "N")]
30+
pub max_provider_executions: Option<u32>,
31+
32+
/// Same-provider retries allowed after the first execution.
33+
#[arg(long, value_name = "N")]
34+
pub max_same_provider_retries: Option<u32>,
35+
36+
/// Cross-provider rotations allowed after the first execution.
37+
#[arg(long, value_name = "N")]
38+
pub max_provider_rotations: Option<u32>,
2739

2840
/// Persist the run for a daemon/runner but do not execute immediately.
2941
#[arg(long)]
@@ -56,7 +68,11 @@ impl From<DispatchCoreArgs> for DispatchCoreInputs {
5668
tasks_json: args.tasks_json,
5769
provider_config: args.provider_config,
5870
client_context: args.client_context,
59-
attempts: args.attempts,
71+
attempts: args.attempts.unwrap_or(1),
72+
attempts_explicit: args.attempts.is_some(),
73+
max_provider_executions: args.max_provider_executions,
74+
max_same_provider_retries: args.max_same_provider_retries,
75+
max_provider_rotations: args.max_provider_rotations,
6076
queue_only: args.queue_only,
6177
timeout_ms: args.timeout_ms,
6278
resolved_provider_policy: args.resolved_provider_policy,
@@ -257,7 +273,10 @@ mod tests {
257273
tasks_json: None,
258274
provider_config: None,
259275
client_context: None,
260-
attempts: 1,
276+
attempts: Some(1),
277+
max_provider_executions: None,
278+
max_same_provider_retries: None,
279+
max_provider_rotations: None,
261280
queue_only: false,
262281
timeout_ms: None,
263282
resolved_provider_policy: None,
@@ -292,7 +311,10 @@ mod tests {
292311
tasks_json: None,
293312
provider_config: None,
294313
client_context: None,
295-
attempts: 1,
314+
attempts: Some(1),
315+
max_provider_executions: None,
316+
max_same_provider_retries: None,
317+
max_provider_rotations: None,
296318
queue_only: false,
297319
timeout_ms: None,
298320
resolved_provider_policy: None,
@@ -398,11 +420,14 @@ mod tests {
398420
tasks_json: overrides.core.tasks_json,
399421
provider_config: overrides.core.provider_config,
400422
client_context: overrides.core.client_context,
401-
attempts: if overrides.core.attempts == 0 {
423+
attempts: Some(if overrides.core.attempts == 0 {
402424
1
403425
} else {
404426
overrides.core.attempts
405-
},
427+
}),
428+
max_provider_executions: overrides.core.max_provider_executions,
429+
max_same_provider_retries: overrides.core.max_same_provider_retries,
430+
max_provider_rotations: overrides.core.max_provider_rotations,
406431
queue_only: overrides.core.queue_only,
407432
timeout_ms: overrides.core.timeout_ms,
408433
resolved_provider_policy: overrides.core.resolved_provider_policy,

src/core/agent_task_controller_service/request.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub fn controller_request_dispatch_command(
5050
provider_config: optional_string(dispatch, "provider_config"),
5151
client_context: optional_string(dispatch, "client_context"),
5252
attempts: optional_u32(dispatch, "attempts")?.unwrap_or(1),
53+
attempts_explicit: dispatch.get("attempts").is_some(),
54+
max_provider_executions: optional_u32(dispatch, "max_provider_executions")?,
55+
max_same_provider_retries: optional_u32(dispatch, "max_same_provider_retries")?,
56+
max_provider_rotations: optional_u32(dispatch, "max_provider_rotations")?,
5357
queue_only: optional_bool(dispatch, "queue_only").unwrap_or(false),
5458
timeout_ms: optional_u64(dispatch, "timeout_ms")?,
5559
resolved_provider_policy: None,

0 commit comments

Comments
 (0)