Skip to content

Commit 11d046e

Browse files
author
Chris Huber
committed
fix(agent-task): skip ineligible queued work
AI assistance: OpenAI gpt-5.6-sol via OpenCode was used to implement, review, and verify this change. Chris Huber reviewed and is responsible for every line.
1 parent 7d4fedd commit 11d046e

17 files changed

Lines changed: 1161 additions & 106 deletions

File tree

crates/homeboy-agents/src/agent_task_lifecycle/lifecycle_ops.rs

Lines changed: 361 additions & 48 deletions
Large diffs are not rendered by default.

crates/homeboy-agents/src/agent_task_service/cook_recipe.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -953,14 +953,13 @@ pub fn claim_continuation() -> Result<Option<ClaimedCookContinuation>> {
953953
fs::create_dir_all(&root)
954954
.map_err(|error| Error::internal_io(error.to_string(), Some(root.display().to_string())))?;
955955
reclaim_dead_claims(&root)?;
956-
for entry in fs::read_dir(&root)
956+
let mut pending: Vec<_> = fs::read_dir(&root)
957957
.map_err(|error| Error::internal_io(error.to_string(), Some(root.display().to_string())))?
958-
{
959-
let path = entry
960-
.map_err(|error| {
961-
Error::internal_io(error.to_string(), Some(root.display().to_string()))
962-
})?
963-
.path();
958+
.collect::<std::result::Result<Vec<_>, _>>()
959+
.map_err(|error| Error::internal_io(error.to_string(), Some(root.display().to_string())))?;
960+
pending.sort_by_key(|entry| entry.file_name());
961+
for entry in pending {
962+
let path = entry.path();
964963
if path.extension().and_then(|value| value.to_str()) != Some("pending") {
965964
continue;
966965
}
@@ -981,12 +980,12 @@ pub fn claim_continuation() -> Result<Option<ClaimedCookContinuation>> {
981980
None,
982981
);
983982
fail_claimed_path(&claimed, &error.message)?;
984-
return Err(error);
983+
continue;
985984
}
986985
};
987986
if let Err(error) = validate_continuation(&continuation) {
988987
fail_claimed_path(&claimed, &error.message)?;
989-
return Err(error);
988+
continue;
990989
}
991990
return Ok(Some(ClaimedCookContinuation {
992991
continuation,

crates/homeboy-agents/src/agent_task_service/cook_tests.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,162 @@ fn terminal_review_form_continuation_rejects_generic_failed_and_cancelled_runs()
205205
});
206206
}
207207

208+
#[test]
209+
fn run_next_skips_persisted_test_detached_recipe_and_executes_eligible_work() {
210+
homeboy_core::test_support::with_isolated_home(|_| {
211+
let options = batch_cook_options(
212+
"run-next-ineligible-test-detached",
213+
Arc::new(AcceptedDetachedAttemptDispatcher),
214+
);
215+
persist_initial_recipe(&options).expect("persisted test-detached recipe");
216+
agent_task_lifecycle::submit_plan(&options.initial_plan, Some(&options.initial_run_id))
217+
.expect("declared Cook attempt submitted");
218+
agent_task_lifecycle::rewrite_record_for_test(&options.initial_run_id, |record| {
219+
record.submitted_at = "2000-01-01T00:00:00+00:00".to_string();
220+
})
221+
.expect("deterministic durable submission timestamp");
222+
agent_task_lifecycle::cancel_run(&options.initial_run_id, Some("fixture terminal"))
223+
.expect("declared Cook attempt terminal");
224+
super::super::enqueue_terminal_continuation(&options.cook_id, &options.initial_run_id)
225+
.expect("durable Cook continuation queued");
226+
227+
agent_task_lifecycle::submit_plan(
228+
&batch_cook_options(
229+
"run-next-eligible-work",
230+
Arc::new(AcceptedDetachedAttemptDispatcher),
231+
)
232+
.initial_plan,
233+
Some("run-next-eligible-work"),
234+
)
235+
.expect("eligible work queued");
236+
237+
let result =
238+
super::super::run_next_with_cook_dispatcher(ImmediateSuccessExecutor, |_| Ok(None))
239+
.expect("ineligible continuation does not block eligible work");
240+
241+
assert_eq!(
242+
result.value.expect("eligible aggregate").plan_id,
243+
"run-next-eligible-work"
244+
);
245+
assert_eq!(result.skipped.len(), 1);
246+
assert_eq!(result.skipped[0].run_id, options.initial_run_id);
247+
assert_eq!(
248+
result.skipped[0].submitted_at.as_deref(),
249+
Some("2000-01-01T00:00:00+00:00")
250+
);
251+
assert!(result.skipped[0]
252+
.age_seconds
253+
.is_some_and(|age_seconds| age_seconds > 800_000_000));
254+
assert_eq!(
255+
result.skipped[0].dispatcher_kind.as_deref(),
256+
Some("test-detached")
257+
);
258+
assert_eq!(
259+
result.skipped[0].category,
260+
"cook_continuation_preflight_failed"
261+
);
262+
assert_eq!(result.skipped[0].error_code, "validation.invalid_argument");
263+
assert!(result.skipped[0].remediation.contains("agent-task status"));
264+
});
265+
}
266+
267+
#[test]
268+
fn run_next_redacts_poisoned_recipe_dispatcher_kind() {
269+
homeboy_core::test_support::with_isolated_home(|_| {
270+
const POISONED_KIND: &str = "LEAK_RECIPE_DISPATCHER_SECRET";
271+
let options = batch_cook_options(
272+
"run-next-poisoned-recipe",
273+
Arc::new(AcceptedDetachedAttemptDispatcher),
274+
);
275+
persist_initial_recipe(&options).expect("persisted recipe");
276+
agent_task_lifecycle::submit_plan(&options.initial_plan, Some(&options.initial_run_id))
277+
.expect("declared Cook attempt submitted");
278+
agent_task_lifecycle::cancel_run(&options.initial_run_id, Some("fixture terminal"))
279+
.expect("declared Cook attempt terminal");
280+
super::super::enqueue_terminal_continuation(&options.cook_id, &options.initial_run_id)
281+
.expect("durable Cook continuation queued");
282+
283+
let recipe_path = homeboy_core::paths::homeboy_data()
284+
.expect("data path")
285+
.join("agent-task-cooks")
286+
.join(&options.cook_id)
287+
.join("recipe.json");
288+
let mut recipe: Value =
289+
serde_json::from_slice(&std::fs::read(&recipe_path).expect("persisted recipe bytes"))
290+
.expect("persisted recipe JSON");
291+
recipe["promotion_transport"]["attempt_dispatch"]["kind"] =
292+
serde_json::json!(POISONED_KIND);
293+
std::fs::write(
294+
&recipe_path,
295+
serde_json::to_vec(&recipe).expect("poisoned recipe JSON"),
296+
)
297+
.expect("poisoned recipe persisted");
298+
299+
agent_task_lifecycle::submit_plan(
300+
&batch_cook_options(
301+
"run-next-after-poisoned-recipe",
302+
Arc::new(AcceptedDetachedAttemptDispatcher),
303+
)
304+
.initial_plan,
305+
Some("run-next-after-poisoned-recipe"),
306+
)
307+
.expect("eligible work queued");
308+
309+
let result =
310+
super::super::run_next_with_cook_dispatcher(ImmediateSuccessExecutor, |_| Ok(None))
311+
.expect("poisoned continuation does not block eligible work");
312+
let status = agent_task_lifecycle::status(&options.initial_run_id)
313+
.expect("continuation record status");
314+
let logs =
315+
agent_task_lifecycle::logs(&options.initial_run_id).expect("continuation record logs");
316+
let rendered = serde_json::to_string(&serde_json::json!({
317+
"queue_skips": result.skipped,
318+
"status": status,
319+
"logs": logs,
320+
}))
321+
.expect("queue projections serialize");
322+
323+
assert_eq!(
324+
result.value.expect("eligible aggregate").plan_id,
325+
"run-next-after-poisoned-recipe"
326+
);
327+
assert!(!rendered.contains(POISONED_KIND));
328+
assert!(rendered.contains("cook_continuation_unsupported_dispatcher"));
329+
assert!(rendered.contains("agent-task status <run-id> --exact --full"));
330+
});
331+
}
332+
333+
#[test]
334+
fn malformed_continuation_does_not_head_of_line_block_run_next() {
335+
homeboy_core::test_support::with_isolated_home(|_| {
336+
let queue = homeboy_core::paths::homeboy_data()
337+
.expect("data path")
338+
.join("agent-task-cook-continuations");
339+
std::fs::create_dir_all(&queue).expect("continuation queue");
340+
std::fs::write(queue.join("000-malformed.pending"), "not JSON")
341+
.expect("malformed continuation persisted");
342+
agent_task_lifecycle::submit_plan(
343+
&batch_cook_options(
344+
"run-next-after-malformed-continuation",
345+
Arc::new(AcceptedDetachedAttemptDispatcher),
346+
)
347+
.initial_plan,
348+
Some("run-next-after-malformed-continuation"),
349+
)
350+
.expect("eligible work queued");
351+
352+
let result =
353+
super::super::run_next_with_cook_dispatcher(ImmediateSuccessExecutor, |_| Ok(None))
354+
.expect("malformed continuation is skipped");
355+
356+
assert_eq!(
357+
result.value.expect("eligible aggregate").plan_id,
358+
"run-next-after-malformed-continuation"
359+
);
360+
assert!(queue.join("000-malformed.failed").is_file());
361+
});
362+
}
363+
208364
#[test]
209365
fn durable_cook_inspection_reports_an_unsupported_run_schema() {
210366
homeboy_core::test_support::with_isolated_home(|_| {

0 commit comments

Comments
 (0)