mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
fix(fleet): name the bad field in spec errors; deflake sub-agent timing tests
0.10.1 addendum, fleet lane: F1 (errors part), F6, F7 (docs part). F1: fleet task specs were parsed through an untagged enum, so any malformed spec reported only "data did not match any variant of untagged enum FleetTaskSpecFile". The loader now detects the shape first (document / task array / single task) and then deserializes exactly that type, so the error names the format, shape, missing field and the offending entry, e.g. `JSON spec document at tasks[1] (id "review"): missing field `instructions``. Adds one test per shape, a scalar top-level test, and tests that parse docs/examples/fleet-dogfood.toml and the tutorial's tasks.json. `fleet run --check` stays 0.10.2 (D2). F6: the timeout tests raced a 150ms server reply against a 50ms step timeout, and the claim/finalize test hit the stub client's real provider URL with a 1s wait. The timeout servers now never answer within the test (the step timeout always wins), the claim test uses a loopback stub, and remaining waits are 30s hang guards rather than timing assertions. F7 (docs): FLEET.md workers shortcut is Tab / `w` (fleet_roster.rs:397), not `n`; spec shapes documented in FLEET.md and mirrored in docs/id and docs/zh_hans. crates/cli/src/lib.rs help text is left to the Honesty lane. Checks: - cargo test -p codewhale-tui --lib fleet::task_spec: 20 passed, 0 failed - the 3 fleet-6 tests with --exact: 20/20 consecutive runs passed, plus 40/40 runs with 8 concurrent processes - rustfmt on both touched Rust files Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
48df015dd1
commit
6dca8a8a61
5 files changed
+358
-36
No files matched your search
@@ -40,14 +40,169 @@ pub struct FleetTaskSpecDocument {
|
||||
pub usage_ceiling: Option<codewhale_protocol::fleet::FleetUsageCeiling>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
/// A parsed spec file in one of its three accepted shapes. The shape is
|
||||
/// chosen from the file's structure first ([`FleetTaskSpecShape::detect`]) and
|
||||
/// only then deserialized into the matching type, so a malformed spec reports
|
||||
/// the real field error instead of serde's opaque "did not match any variant
|
||||
/// of untagged enum".
|
||||
#[derive(Debug, Clone)]
|
||||
enum FleetTaskSpecFile {
|
||||
Document(FleetTaskSpecDocument),
|
||||
Tasks(Vec<FleetTaskSpec>),
|
||||
Single(Box<FleetTaskSpec>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FleetTaskSpecShape {
|
||||
/// `{ name?, labels?, workers?, tasks = [...] }`
|
||||
Document,
|
||||
/// A bare JSON array of task objects.
|
||||
Tasks,
|
||||
/// A single task object (`{ id, name, instructions, ... }`).
|
||||
Single,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FleetTaskSpecFormat {
|
||||
Json,
|
||||
Toml,
|
||||
}
|
||||
|
||||
impl FleetTaskSpecFormat {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Json => "JSON",
|
||||
Self::Toml => "TOML",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level keys that only a spec document carries.
|
||||
const DOCUMENT_KEYS: &[&str] = &["tasks", "workers", "worker_specs"];
|
||||
/// Top-level keys that mark a bare single-task file.
|
||||
const SINGLE_TASK_KEYS: &[&str] = &["id", "instructions"];
|
||||
|
||||
impl FleetTaskSpecShape {
|
||||
fn detect(value: &Value) -> Result<Self> {
|
||||
match value {
|
||||
Value::Array(_) => Ok(Self::Tasks),
|
||||
Value::Object(map) => {
|
||||
if DOCUMENT_KEYS.iter().any(|key| map.contains_key(*key)) {
|
||||
Ok(Self::Document)
|
||||
} else if SINGLE_TASK_KEYS.iter().any(|key| map.contains_key(*key)) {
|
||||
Ok(Self::Single)
|
||||
} else {
|
||||
// Name/labels-only (or empty) objects are documents; the
|
||||
// validator then reports the missing `tasks`.
|
||||
Ok(Self::Document)
|
||||
}
|
||||
}
|
||||
other => bail!(
|
||||
"a fleet task spec must be a document object with `tasks`, an array of task objects, or a single task object; found {}",
|
||||
json_kind(other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Document => "spec document",
|
||||
Self::Tasks => "task array",
|
||||
Self::Single => "single task",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "a boolean",
|
||||
Value::Number(_) => "a number",
|
||||
Value::String(_) => "a string",
|
||||
Value::Array(_) => "an array",
|
||||
Value::Object(_) => "an object",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_task_spec_file(raw: &str, format: FleetTaskSpecFormat) -> Result<FleetTaskSpecFile> {
|
||||
// Pass 1: syntax only, to learn the shape.
|
||||
let value = match format {
|
||||
FleetTaskSpecFormat::Json => serde_json::from_str::<Value>(raw)
|
||||
.map_err(|err| anyhow::anyhow!("invalid JSON: {err}"))?,
|
||||
FleetTaskSpecFormat::Toml => {
|
||||
let table = toml::from_str::<toml::Table>(raw)
|
||||
.map_err(|err| anyhow::anyhow!("invalid TOML: {err}"))?;
|
||||
serde_json::to_value(table).context("converting TOML fleet task spec")?
|
||||
}
|
||||
};
|
||||
let shape = FleetTaskSpecShape::detect(&value)?;
|
||||
|
||||
// Pass 2: typed deserialize of exactly that shape, from the raw text so
|
||||
// the error keeps its line/column.
|
||||
fn typed<T: serde::de::DeserializeOwned>(
|
||||
raw: &str,
|
||||
format: FleetTaskSpecFormat,
|
||||
) -> std::result::Result<T, String> {
|
||||
match format {
|
||||
FleetTaskSpecFormat::Json => serde_json::from_str::<T>(raw).map_err(|e| e.to_string()),
|
||||
FleetTaskSpecFormat::Toml => toml::from_str::<T>(raw).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
let parsed = match shape {
|
||||
FleetTaskSpecShape::Document => {
|
||||
typed::<FleetTaskSpecDocument>(raw, format).map(FleetTaskSpecFile::Document)
|
||||
}
|
||||
FleetTaskSpecShape::Tasks => {
|
||||
typed::<Vec<FleetTaskSpec>>(raw, format).map(FleetTaskSpecFile::Tasks)
|
||||
}
|
||||
FleetTaskSpecShape::Single => typed::<FleetTaskSpec>(raw, format)
|
||||
.map(|task| FleetTaskSpecFile::Single(Box::new(task))),
|
||||
};
|
||||
parsed.map_err(|err| {
|
||||
let location = locate_spec_error(shape, &value)
|
||||
.map(|loc| format!(" at {loc}"))
|
||||
.unwrap_or_default();
|
||||
anyhow::anyhow!(
|
||||
"{} {}{location}: {}",
|
||||
format.label(),
|
||||
shape.label(),
|
||||
err.trim()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Name the first task (or worker) entry that fails to deserialize on its
|
||||
/// own, e.g. `tasks[1] (id "review")`, so a long spec's error points at the
|
||||
/// entry and not only at a line number.
|
||||
fn locate_spec_error(shape: FleetTaskSpecShape, value: &Value) -> Option<String> {
|
||||
fn first_bad<T: serde::de::DeserializeOwned>(prefix: &str, items: &[Value]) -> Option<String> {
|
||||
items.iter().enumerate().find_map(|(index, item)| {
|
||||
serde_json::from_value::<T>(item.clone()).err().map(|_| {
|
||||
match item.get("id").and_then(Value::as_str) {
|
||||
Some(id) => format!("{prefix}[{index}] (id {id:?})"),
|
||||
None => format!("{prefix}[{index}]"),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
match shape {
|
||||
FleetTaskSpecShape::Document => {
|
||||
if let Some(tasks) = value.get("tasks").and_then(Value::as_array)
|
||||
&& let Some(loc) = first_bad::<FleetTaskSpec>("tasks", tasks)
|
||||
{
|
||||
return Some(loc);
|
||||
}
|
||||
let workers = value
|
||||
.get("workers")
|
||||
.or_else(|| value.get("worker_specs"))
|
||||
.and_then(Value::as_array)?;
|
||||
first_bad::<FleetWorkerSpec>("workers", workers)
|
||||
}
|
||||
FleetTaskSpecShape::Tasks => first_bad::<FleetTaskSpec>("", value.as_array()?),
|
||||
FleetTaskSpecShape::Single => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl FleetTaskSpecFile {
|
||||
fn into_document(self, fallback_name: String) -> FleetTaskSpecDocument {
|
||||
match self {
|
||||
@@ -138,12 +293,17 @@ pub fn load_task_spec_document(path: &Path) -> Result<FleetTaskSpecDocument> {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("fleet-run")
|
||||
.to_string();
|
||||
let parsed = match path.extension().and_then(|s| s.to_str()) {
|
||||
Some("toml") => toml::from_str::<FleetTaskSpecFile>(&raw)
|
||||
.with_context(|| format!("parsing TOML fleet task spec {}", path.display()))?,
|
||||
_ => serde_json::from_str::<FleetTaskSpecFile>(&raw)
|
||||
.with_context(|| format!("parsing JSON fleet task spec {}", path.display()))?,
|
||||
let format = match path.extension().and_then(|s| s.to_str()) {
|
||||
Some("toml") => FleetTaskSpecFormat::Toml,
|
||||
_ => FleetTaskSpecFormat::Json,
|
||||
};
|
||||
let parsed = parse_task_spec_file(&raw, format).with_context(|| {
|
||||
format!(
|
||||
"parsing {} fleet task spec {}",
|
||||
format.label(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let doc = parsed.into_document(fallback_name);
|
||||
validate_task_spec_document(&doc)?;
|
||||
Ok(doc)
|
||||
@@ -1272,4 +1432,121 @@ mod tests {
|
||||
assert_eq!(stale.result, FleetTaskResult::Fail);
|
||||
assert_eq!(winning.result, FleetTaskResult::Pass);
|
||||
}
|
||||
|
||||
fn load_error(file_name: &str, body: &str) -> String {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join(file_name);
|
||||
std::fs::write(&path, body).unwrap();
|
||||
let err = load_task_spec_document(&path).expect_err("spec should be rejected");
|
||||
format!("{err:#}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_task_spec_document_shape_error_names_missing_field_and_task() {
|
||||
let err = load_error(
|
||||
"doc.json",
|
||||
r#"{"name": "n", "tasks": [
|
||||
{"id": "ok", "name": "ok", "instructions": "do it"},
|
||||
{"id": "review", "name": "review"}
|
||||
]}"#,
|
||||
);
|
||||
assert!(!err.contains("untagged enum"), "{err}");
|
||||
assert!(err.contains("JSON spec document"), "{err}");
|
||||
assert!(err.contains("missing field `instructions`"), "{err}");
|
||||
assert!(err.contains(r#"tasks[1] (id "review")"#), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_task_spec_task_array_shape_error_names_missing_field() {
|
||||
let err = load_error("tasks.json", r#"[{"id": "a", "instructions": "do it"}]"#);
|
||||
assert!(!err.contains("untagged enum"), "{err}");
|
||||
assert!(err.contains("JSON task array"), "{err}");
|
||||
assert!(err.contains("missing field `name`"), "{err}");
|
||||
assert!(err.contains(r#"[0] (id "a")"#), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_task_spec_single_task_shape_error_names_missing_field() {
|
||||
let err = load_error("one.json", r#"{"id": "a", "name": "a"}"#);
|
||||
assert!(!err.contains("untagged enum"), "{err}");
|
||||
assert!(err.contains("JSON single task"), "{err}");
|
||||
assert!(err.contains("missing field `instructions`"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_task_spec_toml_document_error_names_missing_field() {
|
||||
let err = load_error(
|
||||
"doc.toml",
|
||||
"name = \"n\"\n\n[[tasks]]\nid = \"a\"\ninstructions = \"do it\"\n",
|
||||
);
|
||||
assert!(!err.contains("untagged enum"), "{err}");
|
||||
assert!(err.contains("TOML spec document"), "{err}");
|
||||
assert!(err.contains("missing field `name`"), "{err}");
|
||||
assert!(err.contains(r#"tasks[0] (id "a")"#), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_task_spec_rejects_scalar_top_level_with_shape_hint() {
|
||||
let err = load_error("scalar.json", "\"just a string\"");
|
||||
assert!(err.contains("found a string"), "{err}");
|
||||
assert!(err.contains("array of task objects"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_task_spec_single_and_array_shapes_load_with_fallback_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let single = tmp.path().join("solo.json");
|
||||
std::fs::write(
|
||||
&single,
|
||||
r#"{"id": "a", "name": "a", "instructions": "do it"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let doc = load_task_spec_document(&single).unwrap();
|
||||
assert_eq!(doc.name.as_deref(), Some("solo"));
|
||||
assert_eq!(doc.tasks.len(), 1);
|
||||
|
||||
let array = tmp.path().join("pair.json");
|
||||
std::fs::write(
|
||||
&array,
|
||||
r#"[{"id": "a", "name": "a", "instructions": "x"},
|
||||
{"id": "b", "name": "b", "instructions": "y"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let doc = load_task_spec_document(&array).unwrap();
|
||||
assert_eq!(doc.name.as_deref(), Some("pair"));
|
||||
assert_eq!(doc.tasks.len(), 2);
|
||||
}
|
||||
|
||||
fn repo_doc(relative: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join(relative)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_dogfood_example_spec_parses_and_validates() {
|
||||
let doc = load_task_spec_document(&repo_doc("docs/examples/fleet-dogfood.toml"))
|
||||
.expect("docs/examples/fleet-dogfood.toml must stay a valid fleet spec");
|
||||
assert_eq!(doc.name.as_deref(), Some("dogfood smoke"));
|
||||
let ids: Vec<_> = doc.tasks.iter().map(|task| task.id.as_str()).collect();
|
||||
assert_eq!(ids, ["cargo-check", "protocol-review"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_workflow_tutorial_json_spec_parses_and_validates() {
|
||||
let tutorial = std::fs::read_to_string(repo_doc("docs/FLEET_WORKFLOW_TUTORIAL.md"))
|
||||
.expect("read fleet tutorial");
|
||||
let start = tutorial
|
||||
.find("```json\n")
|
||||
.expect("tutorial should carry a JSON task spec")
|
||||
+ "```json\n".len();
|
||||
let end = start + tutorial[start..].find("```").expect("closed JSON fence");
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("tasks.json");
|
||||
std::fs::write(&path, &tutorial[start..end]).unwrap();
|
||||
let doc = load_task_spec_document(&path)
|
||||
.expect("the tutorial's tasks.json must stay a valid fleet spec");
|
||||
assert_eq!(doc.name.as_deref(), Some("docs readiness check"));
|
||||
assert_eq!(doc.tasks.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -2635,6 +2635,14 @@ async fn provider_success_without_usage_records_one_route_aware_gap_and_no_zero_
|
||||
assert_eq!(worker.usage_source_fingerprints, [fingerprint].into());
|
||||
}
|
||||
|
||||
/// A server-side delay no test outlives: the request is accepted and counted
|
||||
/// but never answered, so a step timeout can never lose a race to the reply.
|
||||
const NEVER_ANSWERS: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Upper bound for waits on real loopback I/O. Hitting it means the child hung;
|
||||
/// the assertions themselves never depend on how fast the runner is.
|
||||
const HANG_GUARD: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Like [`delayed_chat_client`] but delays *every* attempt, so the per-step
|
||||
/// API timeout fires on the first call and on every retry — the shape needed
|
||||
/// to drive the timeout-retry budget to exhaustion.
|
||||
@@ -9358,14 +9366,18 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin
|
||||
manager.register_worker(make_worker_spec(&agent_id, tmp.path().to_path_buf()));
|
||||
}
|
||||
|
||||
// Every attempt outlasts the 50ms step timeout, so the timeout-retry
|
||||
// budget (SUBAGENT_API_TIMEOUT_MAX_RETRIES) is driven to exhaustion
|
||||
// before the step interrupts. The backoff base is shrunk to 1ms so the
|
||||
// test does not wait out the production backoff sequence.
|
||||
let (client, calls) =
|
||||
always_delayed_chat_client(Duration::from_millis(150), "resumed answer").await;
|
||||
// Every attempt outlasts the step timeout, so the timeout-retry budget
|
||||
// (SUBAGENT_API_TIMEOUT_MAX_RETRIES) is driven to exhaustion before the
|
||||
// step interrupts. The backoff base is shrunk to 1ms so the test does not
|
||||
// wait out the production backoff sequence.
|
||||
//
|
||||
// Determinism (fleet-6): the server never answers within the test, so the
|
||||
// step timeout always wins; a 150ms reply used to race a 50ms timeout on a
|
||||
// loaded runner. The 250ms step timeout is the window each attempt has to
|
||||
// reach the loopback server and be counted.
|
||||
let (client, calls) = always_delayed_chat_client(NEVER_ANSWERS, "resumed answer").await;
|
||||
let mut runtime = stub_runtime()
|
||||
.with_step_api_timeout(Duration::from_millis(50))
|
||||
.with_step_api_timeout(Duration::from_millis(250))
|
||||
.with_api_timeout_retry_base_backoff(Duration::from_millis(1));
|
||||
runtime.client = client;
|
||||
runtime.manager = Arc::clone(&manager);
|
||||
@@ -9392,7 +9404,7 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin
|
||||
};
|
||||
let task_handle = tokio::spawn(run_subagent_task(task));
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::time::timeout(HANG_GUARD, async {
|
||||
loop {
|
||||
if calls.load(Ordering::SeqCst) >= 1 {
|
||||
break;
|
||||
@@ -9403,7 +9415,7 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin
|
||||
.await
|
||||
.expect("first timed-out API attempt should reach the test server");
|
||||
|
||||
let interrupted_envelope = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let interrupted_envelope = tokio::time::timeout(HANG_GUARD, async {
|
||||
loop {
|
||||
for env in mailbox_rx.drain() {
|
||||
if let MailboxMessage::Interrupted {
|
||||
@@ -9426,7 +9438,7 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin
|
||||
interrupted_envelope.1
|
||||
);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), task_handle)
|
||||
tokio::time::timeout(HANG_GUARD, task_handle)
|
||||
.await
|
||||
.expect("sub-agent task must not park waiting for checkpoint input")
|
||||
.expect("sub-agent task should finish");
|
||||
@@ -9533,13 +9545,14 @@ async fn subagent_retries_api_timeout_before_succeeding() {
|
||||
manager.register_worker(make_worker_spec(&agent_id, tmp.path().to_path_buf()));
|
||||
}
|
||||
|
||||
// Only the first attempt outlasts the 50ms step timeout; the retry
|
||||
// answers immediately, so a single timed-out attempt must be retried
|
||||
// exactly once and then complete.
|
||||
let (client, calls, _bodies) =
|
||||
delayed_chat_client(Duration::from_millis(150), "recovered answer").await;
|
||||
// Only the first attempt outlasts the step timeout; the retry answers
|
||||
// immediately, so a single timed-out attempt must be retried exactly once
|
||||
// and then complete. The first reply never arrives within the test, so it
|
||||
// cannot race the timeout (fleet-6); 500ms is the window the immediate
|
||||
// retry has to answer on a loaded runner.
|
||||
let (client, calls, _bodies) = delayed_chat_client(NEVER_ANSWERS, "recovered answer").await;
|
||||
let mut runtime = stub_runtime()
|
||||
.with_step_api_timeout(Duration::from_millis(50))
|
||||
.with_step_api_timeout(Duration::from_millis(500))
|
||||
.with_api_timeout_retry_base_backoff(Duration::from_millis(1));
|
||||
runtime.client = client;
|
||||
runtime.manager = Arc::clone(&manager);
|
||||
@@ -9562,13 +9575,10 @@ async fn subagent_retries_api_timeout_before_succeeding() {
|
||||
_foreground_child_registration: None,
|
||||
};
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::spawn(run_subagent_task(task)),
|
||||
)
|
||||
.await
|
||||
.expect("sub-agent task should finish")
|
||||
.expect("sub-agent join should succeed");
|
||||
tokio::time::timeout(HANG_GUARD, tokio::spawn(run_subagent_task(task)))
|
||||
.await
|
||||
.expect("sub-agent task should finish")
|
||||
.expect("sub-agent join should succeed");
|
||||
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
@@ -15546,6 +15556,11 @@ async fn run_subagent_task_claims_before_delivery_and_then_finalizes() {
|
||||
|
||||
let (completion_tx, mut completion_rx) = mpsc::channel::<SubAgentCompletion>(16);
|
||||
let mut runtime = runtime_with_depth(1, Some(completion_tx));
|
||||
// Answer the child's single model call from a loopback stub instead of the
|
||||
// stub client's real provider URL: the old network round-trip (DNS, TLS,
|
||||
// a 401) is what made the post-release wait flaky (fleet-6).
|
||||
let (client, _calls, _bodies) = delayed_chat_client(Duration::ZERO, "done").await;
|
||||
runtime.client = client;
|
||||
runtime.manager = Arc::clone(&manager);
|
||||
agent.terminal_delivery = Some(SubAgentTerminalDeliveryContext::from_runtime(&runtime));
|
||||
manager.write().await.agents.insert(agent_id.clone(), agent);
|
||||
@@ -15580,14 +15595,16 @@ async fn run_subagent_task_claims_before_delivery_and_then_finalizes() {
|
||||
);
|
||||
drop(manager_lock);
|
||||
|
||||
let completion = tokio::time::timeout(Duration::from_secs(1), completion_rx.recv())
|
||||
// Hang guard only: the completion is ordered after the claim, not timed.
|
||||
let completion = tokio::time::timeout(Duration::from_secs(30), completion_rx.recv())
|
||||
.await
|
||||
.expect("completion should follow the successful terminal claim");
|
||||
let completion = completion.expect("completion channel should remain open");
|
||||
assert_eq!(completion.agent_id, agent_id);
|
||||
|
||||
task_handle
|
||||
tokio::time::timeout(Duration::from_secs(30), task_handle)
|
||||
.await
|
||||
.expect("run_subagent_task should not hang after lock release")
|
||||
.expect("run_subagent_task should complete after lock release");
|
||||
|
||||
let snapshot = manager
|
||||
|
||||
+18
-2
@@ -158,8 +158,8 @@ neither creates the ledger as a side effect of reading it.
|
||||
The current interactive session's sub-agents are a **different set**, and now
|
||||
have their own name:
|
||||
|
||||
- `/fleet workers` (or `/subagents`, or `n`) shows sub-agents attached to the
|
||||
current TUI session. It does not read the persistent ledger.
|
||||
- `/fleet workers` (or `/subagents`, or Tab / `w` from the `/fleet` roster)
|
||||
shows sub-agents attached to the current TUI session. It does not read the persistent ledger.
|
||||
- `/fleet list|status|interrupt|resume` and `codewhale fleet
|
||||
list|status|interrupt|resume` act on the durable ledger.
|
||||
- `codewhale fleet restart <worker-id>` is CLI-only: it re-leases the task and
|
||||
@@ -499,6 +499,22 @@ next recursive ring rather than trying to show the whole tree at once.
|
||||
Workers are optional. If omitted, Codewhale creates local worker slots up to
|
||||
`--max-workers`.
|
||||
|
||||
A spec file takes one of three shapes, chosen by its structure before any
|
||||
field is read:
|
||||
|
||||
- a **document** — an object with `tasks` (and optionally `name`, `labels`,
|
||||
`workers`, `usage_ceiling`); every TOML spec is this shape;
|
||||
- a **task array** — a bare JSON array of task objects;
|
||||
- a **single task** — one task object with `id` / `instructions` at the top
|
||||
level.
|
||||
|
||||
Array and single-task files take their run name from the file name. Because
|
||||
the shape is picked first, a malformed spec reports the real problem, for
|
||||
example ``JSON spec document at tasks[1] (id "review"): missing field
|
||||
`instructions` at line 7 column 5``. The checked-in
|
||||
[`docs/examples/fleet-dogfood.toml`](examples/fleet-dogfood.toml) and the
|
||||
tutorial's `tasks.json` are parsed by the test suite, so they stay valid.
|
||||
|
||||
Task specs are typed in Rust and keep verification data separate from worker
|
||||
transcripts. Only the `worker` member/role reference participates in fleet
|
||||
identity selection. The remaining execution fields are delegated-coordination
|
||||
|
||||
+13
-1
@@ -37,4 +37,16 @@ Status fleet disimpan di dalam ruang kerja di bawah `.codewhale/fleet.jsonl`. Lo
|
||||
### Perbedaan Status fleet dan Worker Sesi
|
||||
|
||||
- Perintah TUI `/fleet status` dan perintah shell `codewhale fleet status` membaca ledger fleet persisten yang sama di `.codewhale/fleet.jsonl`.
|
||||
- Gunakan `/subagents` atau `/fleet workers` untuk menampilkan sub-agen yang hanya terhubung ke sesi TUI saat ini.
|
||||
- Gunakan `/subagents`, `/fleet workers`, atau Tab / `w` dari roster `/fleet` untuk menampilkan sub-agen yang hanya terhubung ke sesi TUI saat ini.
|
||||
|
||||
---
|
||||
|
||||
## Bentuk Spesifikasi Tugas
|
||||
|
||||
`codewhale fleet run` menerima JSON atau TOML dalam salah satu dari tiga bentuk, yang dipilih dari strukturnya sebelum field apa pun dibaca:
|
||||
|
||||
- **dokumen** — objek dengan `tasks` (serta opsional `name`, `labels`, `workers`, `usage_ceiling`); setiap spesifikasi TOML berbentuk ini;
|
||||
- **array tugas** — array JSON berisi objek tugas;
|
||||
- **tugas tunggal** — satu objek tugas dengan `id` / `instructions` di tingkat teratas.
|
||||
|
||||
Karena bentuk dipilih lebih dulu, spesifikasi yang rusak melaporkan masalah sebenarnya, misalnya ``JSON spec document at tasks[1] (id "review"): missing field `instructions` ``. Contoh [`docs/examples/fleet-dogfood.toml`](../examples/fleet-dogfood.toml) diuji oleh test suite sehingga tetap valid.
|
||||
@@ -31,7 +31,7 @@ Fleet 状态存储在工作区下的 `.codewhale/fleet.jsonl`。worker 日志与
|
||||
|
||||
当前交互会话的子代理是**另一组**对象,现在它们有自己的名字:
|
||||
|
||||
- `/fleet workers`(或 `/subagents`,或 `n`)显示附着在当前 TUI 会话上的子代理。它不读取持久 ledger。
|
||||
- `/fleet workers`(或 `/subagents`,或在 `/fleet` roster 中按 Tab / `w`)显示附着在当前 TUI 会话上的子代理。它不读取持久 ledger。
|
||||
- `/fleet list|status|interrupt|resume` 与 `codewhale fleet list|status|interrupt|resume` 作用于持久 ledger。
|
||||
- `codewhale fleet restart <worker-id>` 仅限 CLI:它重新获取任务的 lease,然后驱动 manager 循环直至完成。`/fleet restart` 不会默默做一个更小的动作——它会报告 `surface_not_supported` 并指名 CLI 命令。
|
||||
|
||||
|
||||
Reference in new issue
Block a user