From 853b8c33b6cfe9f053f17d1882b83f7c2d6b4b41 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sat, 22 Aug 2026 03:34:20 +0800 Subject: [PATCH 1/7] feat(agent-org): enforce task FSM and owner authority Make SQLite the final authority for Task creation, mutation, and lifecycle transitions. Add the canonical five-state model, typed actors, durable provenance and results, recovery behavior, and bounded task history reads. Separate coordinator graph administration from Owner lifecycle operations, validate persisted Turn bindings inside each transaction, and expose Current Work and History without eagerly loading full results or annotations. Verification: - cargo test -p agent_core: 3154 passed, 0 failed, 2 ignored - cargo test -p agent_core section_tests -- --nocapture: 12 passed after the final mechanical fix - cargo clippy -p agent_core --all-targets -- -D warnings: passed - pnpm run typecheck: passed - pnpm run lint: 0 errors; 5 pre-existing warnings outside this diff - cargo check -p e2e-test and focused Vitest/WDIO PR4 suites: passed - BuildFast fake-provider batches and one live-provider pending -> in_progress -> completed smoke: passed - pnpm run check:circular: blocked by two baseline ?raw imports unchanged from PR3 Pre-commit hook ran. Total eslint: 5, total circular: 0 --- config/webpack.config.js | 8 +- scripts/dev/webpack-config-light.test.cjs | 41 + src-tauri/Cargo.toml | 2 +- src-tauri/crates/agent-core/Cargo.toml | 3 + .../coordination/agent_inbox/store_drain.rs | 84 + .../coordination/agent_inbox/store_read.rs | 6 +- .../core/coordination/agent_inbox/tests.rs | 101 +- .../coordination/agent_org_payload_limits.rs | 11 +- .../agent_org_plan_approvals/mod.rs | 4 + .../agent_org_plan_approvals/persistence.rs | 52 +- .../agent_org_plan_approvals/store.rs | 4 +- .../agent_org_plan_approvals/tests.rs | 165 +- .../agent_org_plan_approvals/transitions.rs | 44 +- .../agent_org_plan_approvals/validation.rs | 1 + .../core/coordination/agent_org_runs/mod.rs | 1 + .../coordination/agent_org_runs/quiescence.rs | 22 +- .../coordination/agent_org_runs/rollout.rs | 14 + .../agent_org_runs/store/completion.rs | 8 +- .../agent_org_runs/store/delete.rs | 2 +- .../agent_org_runs/store/lifecycle.rs | 24 +- .../core/coordination/agent_org_runs/tests.rs | 50 +- .../coordination/agent_org_tasks/actor.rs | 282 ++ .../coordination/agent_org_tasks/graph.rs | 27 +- .../coordination/agent_org_tasks/helpers.rs | 117 +- .../core/coordination/agent_org_tasks/mod.rs | 654 ++- .../coordination/agent_org_tasks/pr4_tests.rs | 890 +++++ .../agent_org_tasks/store/annotations.rs | 230 ++ .../agent_org_tasks/store/create.rs | 168 +- .../agent_org_tasks/store/dependencies.rs | 155 +- .../coordination/agent_org_tasks/store/fsm.rs | 746 ++++ .../coordination/agent_org_tasks/store/mod.rs | 26 +- .../agent_org_tasks/store/read.rs | 804 ++-- .../agent_org_tasks/store/requeue.rs | 488 +-- .../agent_org_tasks/store/update.rs | 132 +- .../agent_org_tasks/store/validation.rs | 207 +- .../coordination/agent_org_tasks/tests.rs | 207 +- .../coordination/agent_org_turn_contexts.rs | 421 ++ .../agent_org_turn_contexts/tests.rs | 158 +- .../core/coordination/agent_org_watchdog.rs | 2 + .../coordination/agent_org_watchdog/budget.rs | 99 + .../agent_org_watchdog/inspect/facts.rs | 81 +- .../agent_org_watchdog/inspect/run.rs | 10 +- .../agent_org_watchdog/recover.rs | 2 +- .../src/core/coordination/schema.rs | 30 +- .../coordination/work_item_run_dispatcher.rs | 1 + .../agent-core/src/core/providers/e2e_fake.rs | 591 ++- .../agent-core/src/core/session/goal_loop.rs | 1 + .../src/core/session/launch/launch_org.rs | 2 + .../prompt/section_builders/agent_org.rs | 35 +- .../src/core/session/prompt/section_tests.rs | 27 +- .../turn/processor/inbox_drain/drain.rs | 86 +- .../session/turn/processor/inbox_drain/mod.rs | 1 + .../turn/processor/inbox_drain/render.rs | 2 +- .../turn/processor/inbox_drain/tests.rs | 19 +- .../session/turn/processor/member_idle.rs | 22 +- .../session/turn/processor/orchestrator.rs | 17 +- .../builtin_tools/table/agent_org_tasks.rs | 6 +- .../impls/coding/render_inline_canvas.rs | 3 +- .../orchestration/agent_org/send_message.rs | 13 +- .../agent_org/send_message/persistence.rs | 2 +- .../orchestration/agent_org/task_create.rs | 140 +- .../agent_org/task_graph_create.rs | 30 +- .../orchestration/agent_org/task_list_get.rs | 5 +- .../orchestration/agent_org/task_tests.rs | 3490 ++--------------- .../orchestration/agent_org/task_update.rs | 1238 +++--- .../impls/orchestration/agent_org/tasks.rs | 94 +- .../core/tools/impls/plan_mode/create_plan.rs | 1 + .../src/core/tools/tests/ui_metadata_tests.rs | 6 +- .../agent-core/src/orchestrator_notify/mod.rs | 126 +- .../commands/session/debug/org_runtime.rs | 8 + .../src/state/commands/session/interaction.rs | 1 + .../commands/session/message/entry_points.rs | 3 + .../commands/session/message/org_wake.rs | 248 +- .../state/commands/session/message/send.rs | 47 +- .../state/commands/session/message/tests.rs | 307 +- .../src/state/commands/session/mod.rs | 1 + .../session/org_tasks/intervention.rs | 1 + .../state/commands/session/org_tasks/mod.rs | 2 + .../commands/session/org_tasks/run_view.rs | 38 +- .../commands/session/org_tasks/task_pages.rs | 129 + .../state/commands/session/org_tasks/tests.rs | 23 + .../commands/session/persistence_tests.rs | 14 +- .../agent-core/src/tests/lifecycle_tests.rs | 2 +- .../extractors/misc_extractor.rs | 7 +- src-tauri/src/api/agent/test/agent_org.rs | 272 +- src-tauri/src/api/agent/test/workspace.rs | 49 +- src-tauri/src/commands/handler_list.inc | 4 + src/api/tauri/agent/orgTasks.test.ts | 27 + src/api/tauri/agent/orgTasks.ts | 133 + src/app/root/AppBootstrap.tsx | 3 +- src/app/root/E2EBootstrap.tsx | 13 +- src/app/root/e2e/helpers/agentOrgs.ts | 13 + src/app/root/e2e/types.ts | 1 + .../components/AgentOrgOverviewPanel.tsx | 299 +- .../InputArea/components/AgentOrgTaskList.tsx | 363 +- .../components/AgentOrgTaskPanel.test.ts | 549 +++ .../useAgentOrgIntervention.test.ts | 2 + .../useAgentOrgPlanApprovalDetail.test.ts | 1 + .../components/useAgentOrgRunView.test.ts | 2 + src/i18n/locales/de/sessions.json | 19 +- src/i18n/locales/en/sessions.json | 19 +- src/i18n/locales/es/sessions.json | 19 +- src/i18n/locales/fr/sessions.json | 19 +- src/i18n/locales/ja/sessions.json | 19 +- src/i18n/locales/ko/sessions.json | 19 +- src/i18n/locales/pl/sessions.json | 19 +- src/i18n/locales/pt/sessions.json | 19 +- src/i18n/locales/ru/sessions.json | 19 +- src/i18n/locales/tr/sessions.json | 19 +- src/i18n/locales/vi/sessions.json | 19 +- src/i18n/locales/zh-Hant/sessions.json | 19 +- src/i18n/locales/zh/sessions.json | 19 +- .../Chat/Communication/TodoKanban.tsx | 17 +- .../__tests__/TodoKanban.test.ts | 19 + .../core/agent-org-group-chat-ui.spec.mjs | 358 ++ tests/e2e/support/core/agentOrgUiDriver.mjs | 145 +- .../session/agentQueuedControlScenarios.mjs | 94 +- .../session/agentQueuedFollowupDriver.mjs | 92 +- tests/e2e/wdio.conf.mjs | 15 +- 119 files changed, 9586 insertions(+), 6504 deletions(-) create mode 100644 src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs create mode 100644 src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs create mode 100644 src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs create mode 100644 src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs create mode 100644 src-tauri/crates/agent-core/src/state/commands/session/org_tasks/task_pages.rs create mode 100644 src/api/tauri/agent/orgTasks.test.ts create mode 100644 src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts diff --git a/config/webpack.config.js b/config/webpack.config.js index 3a28e0816c..9103cbd283 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -72,7 +72,7 @@ module.exports = (env, argv) => { }, }; - const isE2E = process.env.ORGII_E2E === "1"; + const isE2E = process.env.ORGII_E2E === "1" || process.env.WEBDRIVER === "1"; const devServerPort = Number.parseInt( process.env.WEBPACK_DEV_SERVER_PORT ?? process.env.PORT ?? "1998", 10 @@ -647,6 +647,10 @@ module.exports = (env, argv) => { // Inline-compared in src/index.tsx so webpack constant-folds the // `webpackMode: "eager"` App import away on platforms that don't need it. "process.env.ORGII_DEV_EAGER_APP": JSON.stringify(String(eagerDevApp)), + // WebDriver builds are explicit test artifacts, even when their + // embedded frontend uses production optimization. Ordinary release + // builds receive "0", so E2E helpers remain tree-shaken away. + "process.env.ORGII_E2E": JSON.stringify(isE2E ? "1" : "0"), // Local Rust IDE-server port, baked into the bundle so a second app // instance (dual-instance collab testing) talks to its own backend. // Must match the ORGII_IDE_SERVER_PORT the Rust side is launched with. @@ -657,7 +661,7 @@ module.exports = (env, argv) => { process.env.ORGII_DEEP_LINK_SCHEME ?? "orgii" ), "process.env.ORGII_AGENT_ORG_REDESIGN": JSON.stringify( - process.env.ORGII_AGENT_ORG_REDESIGN ?? "0" + isE2E ? "1" : (process.env.ORGII_AGENT_ORG_REDESIGN ?? "0") ), "process.env.E2E_BASE_URL": JSON.stringify( process.env.E2E_BASE_URL ?? diff --git a/scripts/dev/webpack-config-light.test.cjs b/scripts/dev/webpack-config-light.test.cjs index 0bdd0b4394..a2c54816a7 100644 --- a/scripts/dev/webpack-config-light.test.cjs +++ b/scripts/dev/webpack-config-light.test.cjs @@ -27,6 +27,13 @@ function withEnv(overrides, fn) { } } +function getDefinedValue(config, key) { + const definePlugin = config.plugins.find( + (plugin) => plugin.constructor?.name === "DefinePlugin" + ); + return definePlugin?.definitions?.[key]; +} + test("light dev disables webpack dev-server browser client", () => { const config = withEnv( { @@ -76,3 +83,37 @@ test("production keeps default HTML script injection", () => { assert.equal(htmlPlugin?.userOptions?.inject, "body"); assert.equal(htmlPlugin?.userOptions?.retryMainScriptLoad, false); }); + +test("WebDriver production bundles enable the E2E-only Agent Org gate", () => { + const config = withEnv( + { + ORGII_E2E: null, + ORGII_AGENT_ORG_REDESIGN: null, + WEBDRIVER: "1", + }, + () => createWebpackConfig({}, { mode: "production" }) + ); + + assert.equal(getDefinedValue(config, "process.env.ORGII_E2E"), '"1"'); + assert.equal( + getDefinedValue(config, "process.env.ORGII_AGENT_ORG_REDESIGN"), + '"1"' + ); +}); + +test("ordinary production bundles keep the Agent Org rollout disabled", () => { + const config = withEnv( + { + ORGII_E2E: null, + ORGII_AGENT_ORG_REDESIGN: null, + WEBDRIVER: null, + }, + () => createWebpackConfig({}, { mode: "production" }) + ); + + assert.equal(getDefinedValue(config, "process.env.ORGII_E2E"), '"0"'); + assert.equal( + getDefinedValue(config, "process.env.ORGII_AGENT_ORG_REDESIGN"), + '"0"' + ); +}); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index df9a789ebe..74b9e9ad4b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -513,7 +513,7 @@ libc = "0.2" [features] default = [] # E2E WebDriver automation plugin (debug/test only). Enable with --features webdriver. -webdriver = ["dep:tauri-plugin-webdriver-automation"] +webdriver = ["dep:tauri-plugin-webdriver-automation", "agent_core/webdriver"] [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } diff --git a/src-tauri/crates/agent-core/Cargo.toml b/src-tauri/crates/agent-core/Cargo.toml index 9b898bf0a5..09f6292021 100644 --- a/src-tauri/crates/agent-core/Cargo.toml +++ b/src-tauri/crates/agent-core/Cargo.toml @@ -18,6 +18,9 @@ path = "src/lib.rs" [features] default = [] wingman-bar-native = [] +# Explicit test artifact capability forwarded by the desktop `webdriver` +# feature. Ordinary release builds do not enable this. +webdriver = [] # --------------------------------------------------------------------------- # Workspace dependencies # --------------------------------------------------------------------------- diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs index 1f0f1e80bc..74e3df1523 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs @@ -16,6 +16,90 @@ use super::record::{row_to_record, AgentInboxBatch}; use super::{AgentInboxStore, MAX_INBOX_DRAIN_PAYLOAD_BYTES, MAX_INBOX_DRAIN_ROWS}; impl AgentInboxStore { + /// Load the single formal Inbox input bound to one persisted + /// `TaskExecution` context. + /// + /// A generic member drain is intentionally too broad for PR4: it could + /// acknowledge another Task's assignment or a user-directed message in + /// the same provider Turn. Pending Tasks consume their oldest matching + /// `TaskAssigned`; an in-progress Plan Task consumes its oldest matching + /// changes-requested approval response. The Task row and approval mapping + /// remain authoritative, while the returned source row stays unread until + /// the normal deferred guard commits after a successful Turn. + pub fn list_unread_task_input_for_member( + recipient_member_id: &str, + org_run_id: &str, + task_id: &str, + ) -> Result { + let conn = get_connection().map_err(|err| err.to_string())?; + let mut stmt = conn + .prepare( + "SELECT inbox.id, + inbox.recipient_agent_id, + inbox.recipient_member_id, + inbox.sender_agent_id, + inbox.sender_member_id, + inbox.org_run_id, + inbox.payload_kind, + inbox.payload_json, + inbox.request_id, + inbox.created_at, + inbox.read_at + FROM agent_org_runtime_inbox inbox + JOIN agent_org_runtime_tasks task + ON task.org_run_id=inbox.org_run_id + AND task.id=?3 + AND task.owner=?1 + WHERE inbox.recipient_member_id=?1 + AND inbox.org_run_id=?2 + AND inbox.read_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM agent_org_runtime_inbox_delivery_resolutions resolution + WHERE resolution.inbox_id=inbox.id + ) + AND ( + (task.status='pending' + AND inbox.payload_kind='task_assigned' + AND json_valid(inbox.payload_json) + AND json_type(inbox.payload_json,'$.task_id')='text' + AND json_extract(inbox.payload_json,'$.task_id')=?3) + OR + (task.status='in_progress' + AND task.execution_mode='plan' + AND inbox.payload_kind='plan_approval_response' + AND json_valid(inbox.payload_json) + AND json_type(inbox.payload_json,'$.request_id')='text' + AND EXISTS ( + SELECT 1 + FROM agent_org_runtime_plan_approvals approval + WHERE approval.org_run_id=?2 + AND approval.source_task_id=?3 + AND approval.source_member_id=?1 + AND approval.status='changes_requested' + AND approval.request_id=json_extract( + inbox.payload_json,'$.request_id' + ) + )) + ) + ORDER BY inbox.id ASC + LIMIT 2", + ) + .map_err(|err| err.to_string())?; + let rows = stmt + .query_map( + params![recipient_member_id, org_run_id, task_id], + row_to_record, + ) + .map_err(|err| err.to_string())? + .collect::, _>>() + .map_err(|err| err.to_string())?; + let has_more = rows.len() > 1; + Ok(AgentInboxBatch { + rows: rows.into_iter().take(1).collect(), + has_more, + }) + } + /// `EXISTS`-style unread probe. Periodic scanners (watchdog) only /// need the boolean; loading and decoding full rows for it is /// wasted work. diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs index 455301f0e6..9faeb60629 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_read.rs @@ -952,14 +952,16 @@ impl AgentInboxStore { .query_map( params![ org_run_id, - (crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_TASKS + 1) as i64, + (crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS + 1) + as i64, ], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), ) .map_err(|err| err.to_string())? .collect::, _>>() .map_err(|err| err.to_string())?; - if open_tasks.len() > crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_TASKS { + if open_tasks.len() > crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS + { return Err( "Agent Org task board exceeds the supported assignment snapshot limit".to_string(), ); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs index 5bb03a348a..d8f6db4625 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/tests.rs @@ -402,12 +402,24 @@ fn open_assignment_snapshot_uses_current_tasks_and_expression_index() { let run_id = format!("run-{}", uuid::Uuid::new_v4()); let now = chrono::Utc::now().to_rfc3339(); for (task_id, status) in [("open-task", "pending"), ("done-task", "completed")] { + let output_json = (status == "completed").then(|| { + serde_json::json!({ + "summary": "done", + "content": null, + "artifactIds": [], + "producedByMemberId": "member-worker", + "producedAt": &now, + }) + .to_string() + }); conn.execute( "INSERT INTO agent_org_runtime_tasks (id, org_run_id, subject, description, status, owner, - blocks_json, blocked_by_json, created_at, updated_at) - VALUES (?1, ?2, ?1, '', ?3, 'member-worker', '[]', '[]', ?4, ?4)", - params![task_id, &run_id, status, &now], + execution_mode, blocked_by_json, output_json, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) + VALUES (?1, ?2, ?1, '', ?3, 'member-worker', 'build', '[]', ?4, + 'coordinator', 'test-turn', ?5, ?5)", + params![task_id, &run_id, status, output_json, &now], ) .expect("seed task"); AgentInboxStore::insert(InsertInboxParams { @@ -460,6 +472,83 @@ fn open_assignment_snapshot_uses_current_tasks_and_expression_index() { ); } +#[test] +fn task_execution_drain_claims_exactly_one_bound_assignment() { + let _sandbox = sandbox_with_inbox_schema(); + let conn = get_connection().expect("test database"); + crate::coordination::agent_org_tasks::init_schema(&conn).expect("task schema"); + crate::coordination::agent_org_plan_approvals::init_schema(&conn) + .expect("plan approval schema"); + let run_id = format!("run-{}", uuid::Uuid::new_v4()); + let now = chrono::Utc::now().to_rfc3339(); + for task_id in ["task-one", "task-two"] { + conn.execute( + "INSERT INTO agent_org_runtime_tasks + (id, org_run_id, subject, description, status, owner, + execution_mode, blocked_by_json, created_by_participant_id, + source_turn_intent_id, created_at, updated_at) + VALUES (?1,?2,?1,'','pending','member-worker','build','[]', + 'coordinator','turn-create',?3,?3)", + params![task_id, &run_id, &now], + ) + .expect("seed pending task"); + } + let first = AgentInboxStore::insert(InsertInboxParams { + recipient_agent_id: "worker".into(), + recipient_member_id: Some("member-worker".into()), + sender_agent_id: "coordinator".into(), + sender_member_id: Some("coordinator".into()), + org_run_id: Some(run_id.clone()), + message: AgentMessage::TaskAssigned { + task_id: "task-one".into(), + subject: "Task one".into(), + description: String::new(), + assigned_by: "Coordinator".into(), + dependency_outputs: Vec::new(), + execution_mode: crate::coordination::agent_org_tasks::TaskExecutionMode::Build, + }, + }) + .expect("first assignment"); + let second = AgentInboxStore::insert(InsertInboxParams { + recipient_agent_id: "worker".into(), + recipient_member_id: Some("member-worker".into()), + sender_agent_id: "coordinator".into(), + sender_member_id: Some("coordinator".into()), + org_run_id: Some(run_id.clone()), + message: AgentMessage::TaskAssigned { + task_id: "task-two".into(), + subject: "Task two".into(), + description: String::new(), + assigned_by: "Coordinator".into(), + dependency_outputs: Vec::new(), + execution_mode: crate::coordination::agent_org_tasks::TaskExecutionMode::Build, + }, + }) + .expect("second assignment"); + + let task_one = + AgentInboxStore::list_unread_task_input_for_member("member-worker", &run_id, "task-one") + .expect("bound task-one input"); + assert_eq!(task_one.rows.len(), 1); + assert_eq!(task_one.rows[0].id, first.id); + assert!(!task_one.has_more); + + let task_two = + AgentInboxStore::list_unread_task_input_for_member("member-worker", &run_id, "task-two") + .expect("bound task-two input"); + assert_eq!(task_two.rows.len(), 1); + assert_eq!(task_two.rows[0].id, second.id); + assert!(!task_two.has_more); + + assert_eq!( + AgentInboxStore::list_unread_for_member("member-worker", &run_id) + .expect("unread rows remain deferred") + .len(), + 2, + "reading one TaskExecution input must not acknowledge either assignment" + ); +} + #[test] fn assignment_snapshot_requires_current_owner_and_valid_typed_payload() { let _sandbox = sandbox_with_inbox_schema(); @@ -471,8 +560,10 @@ fn assignment_snapshot_requires_current_owner_and_valid_typed_payload() { conn.execute( "INSERT INTO agent_org_runtime_tasks (id, org_run_id, subject, description, status, owner, - blocks_json, blocked_by_json, created_at, updated_at) - VALUES (?1, ?2, ?1, '', 'pending', 'member-b', '[]', '[]', ?3, ?3)", + execution_mode, blocked_by_json, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) + VALUES (?1, ?2, ?1, '', 'pending', 'member-b', 'build', '[]', + 'coordinator', 'test-turn', ?3, ?3)", params![task_id, &run_id, &now], ) .expect("seed reassigned task"); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_payload_limits.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_payload_limits.rs index 29f04de5fc..ade3e73a2f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_payload_limits.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_payload_limits.rs @@ -17,6 +17,7 @@ pub const TASK_SUMMARY_DEPENDENCY_PREVIEW_MAX_COUNT: usize = 8; pub const TASK_SUMMARY_ELIGIBILITY_PREVIEW_MAX_COUNT: usize = 16; pub const TASK_SUMMARY_ARTIFACT_PREVIEW_MAX_COUNT: usize = 16; pub const TASK_SUMMARY_PAGE_MAX_BYTES: usize = 512 * 1024; +pub const TASK_ANNOTATION_PAGE_MAX_BYTES: usize = 512 * 1024; pub const TASK_OPEN_ID_PREVIEW_MAX_BYTES: usize = 16 * 1024; pub const TASK_ACTIVE_FORM_MAX_CHARS: usize = 1_000; pub const TASK_ACTIVE_FORM_MAX_BYTES: usize = TASK_ACTIVE_FORM_MAX_CHARS * 4; @@ -40,12 +41,12 @@ pub const TASK_ELIGIBILITY_TOTAL_MAX_BYTES: usize = TASK_ELIGIBILITY_TOTAL_MAX_C pub const TASK_DEPENDENCY_JSON_MAX_BYTES: usize = 256 * 1024; pub const RFC3339_TIMESTAMP_MAX_CHARS: usize = 64; pub const RFC3339_TIMESTAMP_MAX_BYTES: usize = RFC3339_TIMESTAMP_MAX_CHARS * 4; -/// Maximum number of durable task rows that one Agent Org run may retain. -/// This is a run-level storage boundary, not a recommendation for how many -/// tasks a coordinator should create in one model tool call. -pub const TASK_RUN_MAX_TASKS: usize = 200; +/// Maximum number of open (`pending`/`in_progress`) Tasks one long-lived Team +/// may retain at once. Terminal history is intentionally not counted: it is +/// durable and cursor-paged rather than eventually preventing new work. +pub const TASK_RUN_MAX_OPEN_TASKS: usize = 200; /// LLM-facing request limit for one atomic `task_graph_create` call. Keeping -/// this separate from [`TASK_RUN_MAX_TASKS`] avoids teaching coordinators to +/// this separate from [`TASK_RUN_MAX_OPEN_TASKS`] avoids teaching coordinators to /// create 200-node graphs merely because the database can retain that many. pub const TASK_GRAPH_CREATE_MAX_TASKS: usize = 32; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs index f48e937114..9e4658bc33 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/mod.rs @@ -82,6 +82,7 @@ pub struct AgentOrgPlanApproval { pub source_task_id: String, pub source_member_id: String, pub source_session_id: String, + pub source_turn_intent_id: String, pub root_session_id: String, pub policy: PlanApprovalPolicy, pub status: AgentOrgPlanApprovalStatus, @@ -109,6 +110,7 @@ pub struct AgentOrgPlanApprovalSummary { pub source_task_id: String, pub source_member_id: String, pub source_session_id: String, + pub source_turn_intent_id: String, pub root_session_id: String, pub policy: PlanApprovalPolicy, pub status: AgentOrgPlanApprovalStatus, @@ -124,6 +126,7 @@ pub struct CreateAgentOrgPlanApprovalParams { pub source_task_id: String, pub source_member_id: String, pub source_session_id: String, + pub source_turn_intent_id: String, pub root_session_id: String, pub policy: PlanApprovalPolicy, pub plan_title: String, @@ -162,6 +165,7 @@ pub(crate) fn create_schema(conn: &Connection) -> rusqlite::Result<()> { source_task_id TEXT NOT NULL, source_member_id TEXT NOT NULL, source_session_id TEXT NOT NULL, + source_turn_intent_id TEXT NOT NULL, root_session_id TEXT NOT NULL, policy TEXT NOT NULL, status TEXT NOT NULL, diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/persistence.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/persistence.rs index 5a66a6f316..cdadfbd571 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/persistence.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/persistence.rs @@ -11,10 +11,11 @@ pub(super) fn insert_record( conn.execute( "INSERT INTO agent_org_runtime_plan_approvals ( approval_id, plan_revision_id, request_id, org_run_id, - source_task_id, source_member_id, source_session_id, root_session_id, + source_task_id, source_member_id, source_session_id, source_turn_intent_id, + root_session_id, policy, status, plan_title, plan_path, plan_content, decision_by, feedback, created_at, resolved_at - ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)", + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18)", params![ &approval.approval_id, &approval.plan_revision_id, @@ -23,6 +24,7 @@ pub(super) fn insert_record( &approval.source_task_id, &approval.source_member_id, &approval.source_session_id, + &approval.source_turn_intent_id, &approval.root_session_id, approval.policy.as_wire(), approval.status.as_wire(), @@ -46,7 +48,7 @@ pub(super) fn query_record( ) -> Result, String> { let sql = format!( "SELECT approval_id, plan_revision_id, request_id, org_run_id, - source_task_id, source_member_id, source_session_id, + source_task_id, source_member_id, source_session_id, source_turn_intent_id, root_session_id, policy, status, plan_title, plan_path, plan_content, decision_by, feedback, created_at, resolved_at FROM agent_org_runtime_plan_approvals {where_clause} LIMIT 1" @@ -57,11 +59,11 @@ pub(super) fn query_record( } pub(super) fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let policy_raw: String = row.get(8)?; - let status_raw: String = row.get(9)?; - let policy = parse_policy(8, &policy_raw)?; + let policy_raw: String = row.get(9)?; + let status_raw: String = row.get(10)?; + let policy = parse_policy(9, &policy_raw)?; let status = AgentOrgPlanApprovalStatus::from_wire(&status_raw).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, err.into()) + rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, err.into()) })?; Ok(AgentOrgPlanApproval { approval_id: row.get(0)?, @@ -71,27 +73,28 @@ pub(super) fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result, ) -> rusqlite::Result { - let policy_raw: String = row.get(8)?; - let status_raw: String = row.get(9)?; - let plan_content_bytes_raw: i64 = row.get(11)?; + let policy_raw: String = row.get(9)?; + let status_raw: String = row.get(10)?; + let plan_content_bytes_raw: i64 = row.get(12)?; let plan_content_bytes = u64::try_from(plan_content_bytes_raw) - .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(11, plan_content_bytes_raw))?; + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(12, plan_content_bytes_raw))?; Ok(AgentOrgPlanApprovalSummary { approval_id: row.get(0)?, plan_revision_id: row.get(1)?, @@ -100,14 +103,15 @@ pub(super) fn row_to_summary( source_task_id: row.get(4)?, source_member_id: row.get(5)?, source_session_id: row.get(6)?, - root_session_id: row.get(7)?, - policy: parse_policy(8, &policy_raw)?, + source_turn_intent_id: row.get(7)?, + root_session_id: row.get(8)?, + policy: parse_policy(9, &policy_raw)?, status: AgentOrgPlanApprovalStatus::from_wire(&status_raw).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, err.into()) + rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, err.into()) })?, - plan_title: row.get(10)?, + plan_title: row.get(11)?, plan_content_bytes, - created_at: row.get(12)?, + created_at: row.get(13)?, }) } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs index 6a5d8f2c62..1e5fb07486 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/store.rs @@ -224,7 +224,7 @@ impl AgentOrgPlanApprovalStore { let mut stmt = conn .prepare( "SELECT approval_id, plan_revision_id, request_id, org_run_id, - source_task_id, source_member_id, source_session_id, + source_task_id, source_member_id, source_session_id, source_turn_intent_id, root_session_id, policy, status, plan_title, plan_path, plan_content, decision_by, feedback, created_at, resolved_at FROM agent_org_runtime_plan_approvals @@ -277,7 +277,7 @@ impl AgentOrgPlanApprovalStore { let mut stmt = conn .prepare( "SELECT approval_id, plan_revision_id, request_id, org_run_id, - source_task_id, source_member_id, source_session_id, + source_task_id, source_member_id, source_session_id, source_turn_intent_id, root_session_id, policy, status, plan_title, length(CAST(plan_content AS BLOB)), created_at FROM agent_org_runtime_plan_approvals diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs index 5aca36d721..3aa6ac0bfb 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/tests.rs @@ -4,13 +4,14 @@ use database::db::get_connection; use rusqlite::params; use super::*; -use crate::coordination::agent_inbox::{AgentInboxStore, AgentMessage}; +use crate::coordination::agent_inbox::AgentInboxStore; use crate::coordination::agent_org_runs::{ AgentOrgContextMember, AgentOrgRunContext, AgentOrgRunEntryMode, AgentOrgRunStatus, AgentOrgRunStore, CreateAgentOrgRunParams, COORDINATOR_MEMBER_ID, }; use crate::coordination::agent_org_tasks::{ - AgentOrgTaskStore, CreateTaskParams, TaskStatus, TASK_METADATA_EXECUTION_MODE, + AgentOrgTaskStore, CreateTaskParams, TaskGraphWriterAdmin, TaskStatus, TaskTerminalReason, + TASK_METADATA_EXECUTION_MODE, }; use crate::definitions::orgs::{FlatOrgMember, OrgDefinition}; @@ -18,7 +19,23 @@ fn setup(policy: PlanApprovalPolicy) -> (test_helpers::test_env::SandboxGuard, A let sandbox = test_helpers::test_env::sandbox(); let conn = get_connection().expect("test db"); crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); + conn.execute_batch( + "CREATE TABLE session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) + );", + ) + .expect("base Turn schema"); crate::coordination::agent_org_runs::init_schema(&conn).expect("run schema"); + crate::coordination::agent_org_turn_contexts::create_schema(&conn) + .expect("Agent Org Turn context schema"); crate::coordination::agent_org_tasks::init_schema(&conn).expect("task schema"); crate::coordination::agent_inbox::init_schema(&conn).expect("inbox schema"); init_schema(&conn).expect("approval schema"); @@ -106,6 +123,24 @@ fn setup(policy: PlanApprovalPolicy) -> (test_helpers::test_env::SandboxGuard, A capability_index: Default::default(), root_session_id: Some("root-plan-approval".into()), }; + let conn = get_connection().expect("Coordinator context database"); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO session_turn_intents( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES ('root-plan-approval','coordinator-turn',?1,'agent_org','running',?2,?2)", + params![&context.run_id, &now], + ) + .expect("persist Coordinator Turn intent"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + source_kind,source_id,activation_generation,created_at + ) VALUES ('root-plan-approval','coordinator-turn',?1,'coordinator','coordinator', + 'root_turn','coordinator-turn',1,?2)", + params![&context.run_id, &now], + ) + .expect("persist Coordinator Turn context"); (sandbox, context) } @@ -123,6 +158,27 @@ fn create_plan_task(context: &AgentOrgRunContext) { metadata: Some(serde_json::json!({ TASK_METADATA_EXECUTION_MODE: "plan" })), }) .expect("create plan task"); + let conn = get_connection().expect("plan Task context database"); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO session_turn_intents( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES ('planner-session','planner-turn',?1,'agent_org','running',?2,?2)", + params![&context.run_id, &now], + ) + .expect("persist planning Turn intent"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + task_id,owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,activation_generation,created_at + ) VALUES ( + 'planner-session','planner-turn',?1,'planner','task_execution', + 'plan-task','planner','planner',1,'task','plan-task',1,?2 + )", + params![&context.run_id, &now], + ) + .expect("persist planning TaskExecution context"); } fn approval_params(context: &AgentOrgRunContext) -> CreateAgentOrgPlanApprovalParams { @@ -132,6 +188,7 @@ fn approval_params(context: &AgentOrgRunContext) -> CreateAgentOrgPlanApprovalPa source_task_id: "plan-task".into(), source_member_id: "planner".into(), source_session_id: "planner-session".into(), + source_turn_intent_id: "planner-turn".into(), root_session_id: "root-plan-approval".into(), policy: context.plan_approval_policy, plan_title: "Implementation plan".into(), @@ -209,65 +266,6 @@ fn approval_completes_source_task_and_dispatches_unblocked_work() { .iter() .any(|row| row.payload_kind == "task_assigned")); } - -#[test] -fn approval_dispatches_task_from_legacy_blocks_only_edge() { - let (_sandbox, context) = setup(PlanApprovalPolicy::Coordinator); - create_plan_task(&context); - AgentOrgTaskStore::create(CreateTaskParams { - id: "legacy-build-task".into(), - org_run_id: context.run_id.clone(), - subject: "Build the approved legacy plan".into(), - description: String::new(), - active_form: None, - owner: Some("builder".into()), - status: TaskStatus::Pending, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: Some(serde_json::json!({ TASK_METADATA_EXECUTION_MODE: "build" })), - }) - .expect("create legacy dependent task"); - - let conn = get_connection().expect("test sqlite connection"); - conn.execute( - "UPDATE agent_org_runtime_tasks SET blocks_json='[\"legacy-build-task\"]' - WHERE org_run_id=?1 AND id='plan-task'", - params![&context.run_id], - ) - .expect("seed legacy upstream blocks edge"); - conn.execute( - "UPDATE agent_org_runtime_tasks SET blocked_by_json='[]' - WHERE org_run_id=?1 AND id='legacy-build-task'", - params![&context.run_id], - ) - .expect("preserve legacy blocks-only representation"); - conn.execute("DELETE FROM agent_org_runtime_inbox", []) - .expect("remove create-time assignment noise"); - - let pending = create_pending_approval(&context); - let approved = AgentOrgPlanApprovalStore::approve( - &pending.approval_id, - &pending.plan_revision_id, - AgentOrgPlanDecisionBy::Coordinator, - None, - ) - .expect("approve legacy graph"); - - assert!(approved.wake_member_ids.contains(&"builder".to_string())); - let assignments = AgentInboxStore::list_unread_for_member("builder", &context.run_id) - .expect("list builder inbox") - .into_iter() - .filter(|row| { - matches!( - row.decode_payload(), - Ok(AgentMessage::TaskAssigned { ref task_id, .. }) - if task_id == "legacy-build-task" - ) - }) - .count(); - assert_eq!(assignments, 1); -} - #[test] fn approval_policy_rejects_the_wrong_decision_actor() { let (_sandbox, context) = setup(PlanApprovalPolicy::User); @@ -609,6 +607,51 @@ fn stale_revision_cannot_complete_a_plan_twice() { assert!(error.contains("stale_revision")); } +#[test] +fn approval_rejects_atomically_when_source_task_was_cancelled() { + let (_sandbox, context) = setup(PlanApprovalPolicy::Coordinator); + create_plan_task(&context); + let pending = create_pending_approval(&context); + let task = AgentOrgTaskStore::get(&context.run_id, "plan-task") + .unwrap() + .unwrap(); + AgentOrgTaskStore::cancel_with_transactional_effects( + TaskGraphWriterAdmin::new("root-plan-approval", "coordinator-turn").unwrap(), + &context.run_id, + "plan-task", + &task.updated_at, + TaskTerminalReason { + code: "scope.changed".to_string(), + message: "replace the planning goal".to_string(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect("cancel source Task"); + + let error = AgentOrgPlanApprovalStore::approve( + &pending.approval_id, + &pending.plan_revision_id, + AgentOrgPlanDecisionBy::Coordinator, + None, + ) + .expect_err("approval cannot complete a cancelled Task"); + assert!(error.contains("plan_task_not_in_progress"), "{error}"); + assert_eq!( + AgentOrgPlanApprovalStore::get(&pending.approval_id) + .unwrap() + .unwrap() + .status, + AgentOrgPlanApprovalStatus::Pending + ); + assert_eq!( + AgentOrgTaskStore::get(&context.run_id, "plan-task") + .unwrap() + .unwrap() + .status, + TaskStatus::Cancelled + ); +} + #[test] fn changes_requested_and_feedback_delivery_commit_together() { let (_sandbox, context) = setup(PlanApprovalPolicy::Coordinator); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs index 9bc27a6ba4..69a5e8963e 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/transitions.rs @@ -10,7 +10,7 @@ use crate::coordination::agent_org_payload_limits::{ }; use crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID; use crate::coordination::agent_org_tasks::{ - AgentOrgTaskStore, TaskExecutionMode, TaskOutput, TaskStatus, TASK_METADATA_EXECUTION_MODE, + AgentOrgTaskStore, TaskExecutionMode, TaskOutputInput, TaskOwnerExecution, TaskStatus, }; use crate::definitions::orgs::AgentOrgLaunchSnapshot; @@ -43,17 +43,25 @@ pub(super) fn create_pending_in_tx( run_status.as_deref().unwrap_or("missing") )); } + let owner_actor = TaskOwnerExecution::new( + params.source_session_id.clone(), + params.source_turn_intent_id.clone(), + )?; + let owner_audit = owner_actor.validate(tx, ¶ms.org_run_id, ¶ms.source_task_id)?; + if owner_audit.participant_id != params.source_member_id { + return Err("plan_task_owner_context_mismatch".to_string()); + } - let task: Option<(Option, String, Option)> = tx + let task: Option<(Option, String, String)> = tx .query_row( - "SELECT owner, status, metadata_json FROM agent_org_runtime_tasks + "SELECT owner, status, execution_mode FROM agent_org_runtime_tasks WHERE org_run_id=?1 AND id=?2", params![¶ms.org_run_id, ¶ms.source_task_id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional() .map_err(|err| err.to_string())?; - let Some((owner, status, metadata_json)) = task else { + let Some((owner, status, execution_mode_raw)) = task else { return Err(format!("plan_task_not_found: {}", params.source_task_id)); }; if owner.as_deref() != Some(params.source_member_id.as_str()) { @@ -68,17 +76,7 @@ pub(super) fn create_pending_in_tx( params.source_task_id )); } - let execution_mode = metadata_json - .as_deref() - .and_then(|raw| serde_json::from_str::(raw).ok()) - .and_then(|metadata| { - metadata - .get(TASK_METADATA_EXECUTION_MODE) - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) - .and_then(|value| TaskExecutionMode::from_wire(&value).ok()) - .unwrap_or(TaskExecutionMode::Build); + let execution_mode = TaskExecutionMode::from_wire(&execution_mode_raw)?; if execution_mode != TaskExecutionMode::Plan { return Err(format!( "plan_task_execution_mode_mismatch: task {} is not a plan task", @@ -109,6 +107,7 @@ pub(super) fn create_pending_in_tx( source_task_id: params.source_task_id, source_member_id: params.source_member_id, source_session_id: params.source_session_id, + source_turn_intent_id: params.source_turn_intent_id, root_session_id: params.root_session_id, policy: params.policy, status: AgentOrgPlanApprovalStatus::Pending, @@ -146,21 +145,23 @@ pub(super) fn approve_pending_in_tx( plan_char_count, approval.plan_path )); } - let output = TaskOutput { + let output = TaskOutputInput { summary: crate::utils::safe_truncate_chars_to_string( &format!("Approved plan: {}", approval.plan_title), 500, ), content: Some(inline_plan_content), artifact_ids: vec![approval.plan_path.clone()], - produced_by_member_id: approval.source_member_id.clone(), - produced_at: chrono::Utc::now().to_rfc3339(), }; + let owner_actor = TaskOwnerExecution::new( + approval.source_session_id.clone(), + approval.source_turn_intent_id.clone(), + )?; let task_outcome = AgentOrgTaskStore::complete_planning_task_in_tx( tx, + owner_actor, &approval.org_run_id, &approval.source_task_id, - &approval.source_member_id, output, )?; let resolved_at = chrono::Utc::now().to_rfc3339(); @@ -252,10 +253,7 @@ fn enqueue_post_approval_messages_in_tx( } } - let remaining_open_task_count = tasks - .iter() - .filter(|task| !task.status.is_resolved()) - .count(); + let remaining_open_task_count = tasks.iter().filter(|task| task.status.is_open()).count(); AgentInboxStore::insert_in_tx( tx, InsertInboxParams { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/validation.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/validation.rs index 2611bc3db3..5462db2276 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/validation.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_plan_approvals/validation.rs @@ -22,6 +22,7 @@ pub(super) fn validate_create_params( || params.source_task_id.trim().is_empty() || params.source_member_id.trim().is_empty() || params.source_session_id.trim().is_empty() + || params.source_turn_intent_id.trim().is_empty() || params.root_session_id.trim().is_empty() { return Err("plan approval identifiers must not be empty".to_string()); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs index e1105fb6ef..2546189282 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/mod.rs @@ -28,6 +28,7 @@ pub use quiescence::{ AgentOrgQuiescenceSessionFact, }; pub use rollout::{ + enable_for_webdriver_test as enable_agent_org_for_webdriver_test, is_enabled as agent_org_redesign_enabled, require_enabled as require_agent_org_redesign, }; pub use store::AgentOrgRunStore; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/quiescence.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/quiescence.rs index 1ddc0b0631..14030b41e7 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/quiescence.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/quiescence.rs @@ -395,19 +395,19 @@ pub(super) fn load_and_assess( let corrupt_task_predicate = crate::coordination::agent_org_tasks::corrupt_task_row_predicate_sql(); - let persisted_task_count: i64 = conn + let persisted_open_task_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM agent_org_runtime_tasks WHERE org_run_id=?1", + "SELECT COUNT(*) FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND status IN ('pending','in_progress')", params![run_id], |row| row.get(0), ) .map_err(|err| err.to_string())?; - let corruption_projection = if persisted_task_count - > crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_TASKS as i64 + let corruption_projection = if persisted_open_task_count + > crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS as i64 { - // A historical board above the supported cap is one run-level - // corruption. Do not parse every legacy JSON row merely to enumerate - // extra violations. + // An over-cap open board is one run-level corruption. Terminal + // history remains valid and is intentionally excluded from the cap. "1".to_string() } else { format!("COALESCE(SUM(CASE WHEN {corrupt_task_predicate} THEN 1 ELSE 0 END), 0)") @@ -417,16 +417,16 @@ pub(super) fn load_and_assess( let metadata_max = crate::coordination::agent_org_payload_limits::TASK_METADATA_MAX_BYTES; let task_counts_sql = format!( "SELECT COUNT(*), - COALESCE(SUM(CASE WHEN status <> 'completed' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status IN ('pending','in_progress') THEN 1 ELSE 0 END), 0), {corruption_projection}, COALESCE(SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status='in_progress' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END), 0) FROM ( SELECT id, subject, description, active_form, owner, status, - created_at, updated_at, - CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_max} - THEN blocks_json ELSE '!' END AS blocks_json, + execution_mode, output_json, failure_reason_json, cancel_reason_json, + created_by_participant_id, source_turn_intent_id, + originating_message_id, replaces_task_id, created_at, updated_at, CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_max} THEN blocked_by_json ELSE '!' END AS blocked_by_json, CASE WHEN metadata_json IS NULL diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/rollout.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/rollout.rs index 9520f6a0ee..5e65a3df92 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/rollout.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/rollout.rs @@ -16,6 +16,20 @@ pub fn is_enabled() -> bool { configured_enabled(configured.as_deref(), cfg!(test)) } +/// Enables the redesign only after the packaged WebDriver harness explicitly +/// asks for it. Ordinary binaries cannot activate this in-process override, +/// even if a production frontend somehow tries to invoke the debug command. +pub fn enable_for_webdriver_test() -> Result<(), String> { + if !cfg!(feature = "webdriver") { + return Err( + "agent_org_webdriver_override_unavailable: binary lacks the webdriver feature" + .to_string(), + ); + } + std::env::set_var(ROLLOUT_ENV, ENABLED_VALUE); + require_enabled() +} + pub fn require_enabled() -> Result<(), String> { is_enabled().then_some(()).ok_or_else(|| { "agent_org_redesign_disabled: the long-lived Agent Team lifecycle is not enabled" diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs index 3a08eccd5b..0d9cbd6d1d 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/completion.rs @@ -1,6 +1,6 @@ use rusqlite::{params, OptionalExtension}; -use crate::coordination::agent_org_tasks::{AgentOrgTaskStore, Task, TaskStatus}; +use crate::coordination::agent_org_tasks::{AgentOrgTaskStore, Task}; use database::db::{get_connection, with_sessions_writer}; use super::super::progress::{ @@ -101,14 +101,12 @@ impl AgentOrgRunStore { let mut stmt = tx .prepare( "SELECT id FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND status<>?2 + WHERE org_run_id=?1 AND status IN ('pending','in_progress') ORDER BY created_at ASC, id ASC", ) .map_err(|err| err.to_string())?; let rows = stmt - .query_map(params![run_id, TaskStatus::Completed.as_wire()], |row| { - row.get::<_, String>(0) - }) + .query_map(params![run_id], |row| row.get::<_, String>(0)) .map_err(|err| err.to_string())?; rows.collect::, _>>() .map_err(|err| err.to_string())? diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/delete.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/delete.rs index 1e890f1451..5f4b843a86 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/delete.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/delete.rs @@ -79,13 +79,13 @@ impl AgentOrgRunStore { for table in [ "agent_org_runtime_plan_approvals", "agent_org_runtime_recovery_attempts", + "agent_org_runtime_task_annotations", "agent_org_runtime_task_events", "agent_org_runtime_tasks", "agent_org_runtime_inbox_delivery_resolutions", "agent_org_runtime_inbox", "agent_org_runtime_member_interventions", "agent_org_runtime_run_progress", - "agent_org_runtime_task_schema_migrations", ] { conn.execute( &format!("DELETE FROM {table} WHERE org_run_id=?1"), diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs index 25a1a2dafa..70d22ad1ed 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs @@ -77,10 +77,10 @@ impl AgentOrgRunStore { } /// Apply the normal failed-member task disposition after crash recovery - /// has converted stranded Running sessions to Abandoned. Tasks with an - /// eligible peer return to the pool; sole-member work stays owned and - /// pending for an explicit retry. The Team remains Running and is then - /// reconciled from its Quiescence facts. + /// has converted stranded Running sessions to Abandoned. Every still + /// in-progress Task returns to ownerless pending with its candidate roster + /// preserved. No peer or Coordinator is auto-promoted. The Team remains + /// Running and is then reconciled from Quiescence facts. pub fn requeue_abandoned_member_tasks_on_startup() -> Result { let mut changed = 0usize; for run in Self::list_running_runs(100)? { @@ -91,8 +91,22 @@ impl AgentOrgRunStore { let Some(member_id) = worker.member_id.as_deref() else { continue; }; + let reservation = + crate::coordination::agent_org_watchdog::reserve_task_failure_recovery( + &run.id, member_id, + )?; + let operation = if reservation.exhausted { + crate::coordination::agent_org_tasks::SystemTaskOperation::RecoveryFail + } else { + crate::coordination::agent_org_tasks::SystemTaskOperation::RecoveryRequeue + }; + let actor = crate::coordination::agent_org_tasks::SystemArchiveOrRecovery::new( + reservation.token, + reservation.generation, + operation, + )?; changed += - AgentOrgTaskStore::requeue_in_progress_for_owner(&run.id, member_id)?.len(); + AgentOrgTaskStore::recover_owner_failure(actor, &run.id, member_id)?.len(); } } Ok(changed) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs index 86bca916bd..af8a3870e0 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/tests.rs @@ -5,6 +5,20 @@ use crate::core::session::SessionStatus; use crate::definitions::orgs::{AgentOrgsStore, FlatOrgMember, OrgDefinition, PlanApprovalPolicy}; use rusqlite::params; +fn completed_task_metadata(owner_member_id: &str) -> Option { + Some(serde_json::json!({ + crate::coordination::agent_org_tasks::TASK_METADATA_ELIGIBLE_MEMBER_IDS: + [owner_member_id], + crate::coordination::agent_org_tasks::TASK_METADATA_OUTPUT: { + "summary": "completed fixture", + "content": null, + "artifactIds": [], + "producedByMemberId": owner_member_id, + "producedAt": chrono::Utc::now().to_rfc3339(), + }, + })) +} + #[test] fn enum_values_round_trip() { assert_eq!( @@ -513,11 +527,11 @@ fn delete_by_id_cascades_all_run_owned_state_and_plan_artifact() { "INSERT INTO agent_org_runtime_plan_approvals ( approval_id, plan_revision_id, request_id, org_run_id, source_task_id, source_member_id, source_session_id, - root_session_id, policy, status, plan_title, plan_path, + source_turn_intent_id, root_session_id, policy, status, plan_title, plan_path, plan_content, created_at ) VALUES ('delete-approval','delete-revision','delete-request',?1, 'delete-task','member-w1','worker-delete-cascade', - 'root-delete-cascade','coordinator','pending','Delete plan',?2, + 'delete-intent','root-delete-cascade','coordinator','pending','Delete plan',?2, '# disposable plan',?3)", params![&run.id, plan_path.to_string_lossy().as_ref(), &now], ) @@ -526,11 +540,11 @@ fn delete_by_id_cascades_all_run_owned_state_and_plan_artifact() { "INSERT INTO agent_org_runtime_plan_approvals ( approval_id, plan_revision_id, request_id, org_run_id, source_task_id, source_member_id, source_session_id, - root_session_id, policy, status, plan_title, plan_path, + source_turn_intent_id, root_session_id, policy, status, plan_title, plan_path, plan_content, created_at ) VALUES ('external-approval','external-revision','external-request',?1, 'delete-task','member-w1','worker-delete-cascade', - 'root-delete-cascade','coordinator','superseded','Historical notes',?2, + 'delete-intent','root-delete-cascade','coordinator','superseded','Historical notes',?2, '# historical corrupt path',?3)", params![&run.id, external_notes.to_string_lossy().as_ref(), &now], ) @@ -544,9 +558,11 @@ fn delete_by_id_cascades_all_run_owned_state_and_plan_artifact() { ) .unwrap(); conn.execute( - "INSERT INTO agent_org_runtime_task_schema_migrations - (name, org_run_id, applied_at) - VALUES ('delete-test', ?1, ?2)", + "INSERT INTO agent_org_runtime_task_annotations + (id, org_run_id, task_id, kind, body, actor_kind, + actor_participant_id, created_at) + VALUES ('delete-note', ?1, 'delete-task', 'audit_note', 'delete', + 'system', 'system:test', ?2)", params![&run.id, &now], ) .unwrap(); @@ -564,11 +580,11 @@ fn delete_by_id_cascades_all_run_owned_state_and_plan_artifact() { "agent_org_runtime_run_progress", "agent_org_runtime_tasks", "agent_org_runtime_task_events", + "agent_org_runtime_task_annotations", "agent_org_runtime_inbox", "agent_org_runtime_member_interventions", "agent_org_runtime_plan_approvals", "agent_org_runtime_recovery_attempts", - "agent_org_runtime_task_schema_migrations", ] { let count: i64 = conn .query_row( @@ -1152,10 +1168,7 @@ fn cross_transport_duplicate_member_uses_fresh_rust_session_and_does_not_block_q status: TaskStatus::Completed, blocks: Vec::new(), blocked_by: Vec::new(), - metadata: Some(serde_json::json!({ - crate::coordination::agent_org_tasks::TASK_METADATA_ELIGIBLE_MEMBER_IDS: - ["member-w1"], - })), + metadata: completed_task_metadata("member-w1"), }) .expect("create completed task"); mark_coordinator_observed_current_work(&run.id); @@ -1284,10 +1297,7 @@ fn quiescence_transitions_run_to_idle_when_all_tasks_completed() { status: TaskStatus::Completed, blocks: Vec::new(), blocked_by: Vec::new(), - metadata: Some(serde_json::json!({ - crate::coordination::agent_org_tasks::TASK_METADATA_ELIGIBLE_MEMBER_IDS: - ["member-w1"], - })), + metadata: completed_task_metadata("member-w1"), }) .expect("create completed task"); mark_coordinator_observed_current_work(&run.id); @@ -1417,7 +1427,7 @@ fn quiescence_idles_run_only_after_inbox_is_drained() { status: TaskStatus::Completed, blocks: Vec::new(), blocked_by: Vec::new(), - metadata: None, + metadata: completed_task_metadata("member-w1"), }) .unwrap(); mark_coordinator_observed_current_work(&run.id); @@ -1481,7 +1491,7 @@ fn resolved_undeliverable_inbox_stays_unread_but_no_longer_blocks_quiescence() { status: TaskStatus::Completed, blocks: Vec::new(), blocked_by: Vec::new(), - metadata: None, + metadata: completed_task_metadata("member-w1"), }) .expect("create completed task"); mark_coordinator_observed_current_work(&run.id); @@ -1614,7 +1624,7 @@ fn archived_sessions_with_open_work_do_not_auto_archive_the_run() { status, blocks: Vec::new(), blocked_by: Vec::new(), - metadata: None, + metadata: completed_task_metadata("member-w1"), }) .expect("create completed task"); } @@ -1723,7 +1733,7 @@ fn idle_cas_and_task_create_have_one_serializable_outcome() { status: TaskStatus::Completed, blocks: Vec::new(), blocked_by: Vec::new(), - metadata: None, + metadata: completed_task_metadata("member-w1"), }) .unwrap(); mark_coordinator_observed_current_work(&run.id); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs new file mode 100644 index 0000000000..e9952e07bf --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs @@ -0,0 +1,282 @@ +//! Typed authority for every durable Agent Org Task mutation. +//! +//! These values carry only the claimed identity. Store transactions always +//! resolve the persisted companion context again before accepting a write. + +use rusqlite::{params, OptionalExtension}; + +use crate::coordination::agent_org_runs::{AgentOrgRunStatus, COORDINATOR_MEMBER_ID}; +use crate::coordination::agent_org_turn_contexts::{ + require_context_with_connection, AgentOrgTurnKind, +}; + +#[derive(Debug, Clone)] +pub struct TaskGraphWriterAdmin { + session_id: String, + turn_intent_id: String, +} + +impl TaskGraphWriterAdmin { + pub fn new( + session_id: impl Into, + turn_intent_id: impl Into, + ) -> Result { + let actor = Self { + session_id: session_id.into(), + turn_intent_id: turn_intent_id.into(), + }; + if actor.session_id.trim().is_empty() || actor.turn_intent_id.trim().is_empty() { + return Err("task_graph_writer_context_required".to_string()); + } + Ok(actor) + } + + pub(crate) fn validate( + &self, + conn: &rusqlite::Connection, + org_run_id: &str, + ) -> Result { + let context = + require_context_with_connection(conn, &self.session_id, &self.turn_intent_id)?; + validate_run_and_generation(conn, org_run_id, context.activation_generation)?; + if context.org_run_id != org_run_id + || context.turn_kind != AgentOrgTurnKind::Coordinator + || context.participant_id != COORDINATOR_MEMBER_ID + || context.task_id.is_some() + || context.owner_member_id.is_some() + { + return Err("task_graph_writer_context_mismatch".to_string()); + } + let root_session_id: Option = conn + .query_row( + "SELECT root_session_id FROM agent_org_runtime_runs WHERE id=?1", + [org_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .flatten(); + if root_session_id.as_deref() != Some(self.session_id.as_str()) { + return Err("task_graph_writer_not_canonical_root".to_string()); + } + Ok(TaskActorAudit { + kind: TaskActorKind::GraphWriter, + participant_id: context.participant_id, + turn_intent_id: Some(context.turn_intent_id), + }) + } +} + +#[derive(Debug, Clone)] +pub struct TaskOwnerExecution { + session_id: String, + turn_intent_id: String, +} + +impl TaskOwnerExecution { + pub fn new( + session_id: impl Into, + turn_intent_id: impl Into, + ) -> Result { + let actor = Self { + session_id: session_id.into(), + turn_intent_id: turn_intent_id.into(), + }; + if actor.session_id.trim().is_empty() || actor.turn_intent_id.trim().is_empty() { + return Err("task_owner_context_required".to_string()); + } + Ok(actor) + } + + pub(crate) fn validate( + &self, + conn: &rusqlite::Connection, + org_run_id: &str, + task_id: &str, + ) -> Result { + let context = + require_context_with_connection(conn, &self.session_id, &self.turn_intent_id)?; + validate_run_and_generation(conn, org_run_id, context.activation_generation)?; + if context.org_run_id != org_run_id + || context.turn_kind != AgentOrgTurnKind::TaskExecution + || context.task_id.as_deref() != Some(task_id) + || context.owner_member_id.as_deref() != Some(context.participant_id.as_str()) + || context.participant_id == COORDINATOR_MEMBER_ID + { + return Err("task_owner_context_mismatch".to_string()); + } + Ok(TaskActorAudit { + kind: TaskActorKind::OwnerExecution, + participant_id: context.participant_id, + turn_intent_id: Some(context.turn_intent_id), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SystemTaskOperation { + RecoveryRequeue, + RecoveryFail, + ShutdownRelease, +} + +impl SystemTaskOperation { + pub(crate) const fn as_wire(self) -> &'static str { + match self { + Self::RecoveryRequeue => "recovery_requeue", + Self::RecoveryFail => "recovery_fail", + Self::ShutdownRelease => "shutdown_release", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SystemArchiveOrRecovery { + receipt_id: String, + generation: i64, + operation: SystemTaskOperation, +} + +impl SystemArchiveOrRecovery { + pub(crate) fn new( + receipt_id: impl Into, + generation: i64, + operation: SystemTaskOperation, + ) -> Result { + let receipt_id = receipt_id.into(); + if receipt_id.trim().is_empty() || generation < 1 { + return Err("system_task_actor_invalid".to_string()); + } + Ok(Self { + receipt_id, + generation, + operation, + }) + } + + pub(crate) fn validate( + &self, + conn: &rusqlite::Connection, + org_run_id: &str, + owner_member_id: &str, + ) -> Result { + validate_run_and_generation(conn, org_run_id, Some(self.generation))?; + let action_kind = match self.operation { + SystemTaskOperation::RecoveryRequeue | SystemTaskOperation::RecoveryFail => { + "task_failure_recovery" + } + SystemTaskOperation::ShutdownRelease => "task_shutdown_release", + }; + let receipt: Option<(String, i64)> = conn + .query_row( + "SELECT action_kind, attempts + FROM agent_org_runtime_recovery_attempts + WHERE org_run_id=?1 AND reservation_token=?2 + AND action_kind=?3 AND target_key=?4", + params![org_run_id, &self.receipt_id, action_kind, owner_member_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((persisted_action_kind, attempts)) = receipt else { + return Err("system_task_recovery_receipt_invalid".to_string()); + }; + let operation_matches_receipt = match self.operation { + SystemTaskOperation::RecoveryRequeue => { + persisted_action_kind == "task_failure_recovery" + && !crate::coordination::agent_org_watchdog::task_failure_recovery_attempts_exhausted(attempts) + } + SystemTaskOperation::RecoveryFail => { + persisted_action_kind == "task_failure_recovery" + && crate::coordination::agent_org_watchdog::task_failure_recovery_attempts_exhausted(attempts) + } + SystemTaskOperation::ShutdownRelease => { + persisted_action_kind == "task_shutdown_release" + } + }; + if !operation_matches_receipt { + return Err("system_task_recovery_operation_mismatch".to_string()); + } + Ok(TaskActorAudit { + kind: TaskActorKind::System, + participant_id: format!("system:{}", self.operation.as_wire()), + turn_intent_id: None, + }) + } + + pub(crate) fn receipt_id(&self) -> &str { + &self.receipt_id + } + + pub(crate) const fn action_kind(&self) -> &'static str { + match self.operation { + SystemTaskOperation::RecoveryRequeue | SystemTaskOperation::RecoveryFail => { + "task_failure_recovery" + } + SystemTaskOperation::ShutdownRelease => "task_shutdown_release", + } + } + + pub(crate) const fn operation(&self) -> SystemTaskOperation { + self.operation + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TaskActorKind { + GraphWriter, + OwnerExecution, + System, +} + +impl TaskActorKind { + pub(crate) const fn as_wire(self) -> &'static str { + match self { + Self::GraphWriter => "graph_writer", + Self::OwnerExecution => "owner_execution", + Self::System => "system", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct TaskActorAudit { + pub(crate) kind: TaskActorKind, + pub(crate) participant_id: String, + pub(crate) turn_intent_id: Option, +} + +fn validate_run_and_generation( + conn: &rusqlite::Connection, + org_run_id: &str, + expected_generation: Option, +) -> Result<(), String> { + let row: Option<(String, i64, Option)> = conn + .query_row( + "SELECT status, activation_generation, org_snapshot_json + FROM agent_org_runtime_runs WHERE id=?1", + [org_run_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((status_raw, generation, snapshot_json)) = row else { + return Err(format!("agent_org_run_not_found: {org_run_id}")); + }; + let status = AgentOrgRunStatus::parse(&status_raw) + .ok_or_else(|| format!("unknown Agent Org run status: {status_raw}"))?; + if status != AgentOrgRunStatus::Running { + return Err(format!( + "agent_org_run_not_mutable: run {org_run_id} is {status}" + )); + } + if expected_generation != Some(generation) { + return Err("task_actor_generation_mismatch".to_string()); + } + let snapshot_json = snapshot_json.ok_or_else(|| "task_actor_snapshot_missing".to_string())?; + let snapshot: crate::definitions::orgs::AgentOrgLaunchSnapshot = + serde_json::from_str(&snapshot_json) + .map_err(|error| format!("task_actor_snapshot_invalid: {error}"))?; + crate::definitions::orgs::validate_launch_snapshot(&snapshot) + .map_err(|error| format!("task_actor_snapshot_invalid: {error}")) +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/graph.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/graph.rs index ec077b1746..f6756e9332 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/graph.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/graph.rs @@ -4,11 +4,10 @@ use super::{Task, TASK_DEPENDENCY_CYCLE_ERROR}; /// One canonical dependency projection for an already-loaded task board. /// -/// `blocked_by` is the authoritative direction. Historical rows may contain -/// only the reciprocal `blocks` field, so construction folds those legacy -/// edges into `blocked_by` once and derives `blocks` from the result. All -/// readiness consumers should share this index instead of re-scanning the -/// full board with subtly different predicates. +/// `blocked_by` is the only authoritative direction. `blocks` is always +/// derived from those canonical edges. All readiness consumers should share +/// this index instead of re-scanning the board with subtly different +/// predicates. #[derive(Debug, Clone)] pub struct TaskGraphIndex { blocked_by: HashMap>, @@ -25,28 +24,14 @@ impl TaskGraphIndex { .collect::>(); let resolved_ids = tasks .iter() - .filter(|task| task.status.is_resolved()) + .filter(|task| task.status.satisfies_dependency()) .map(|task| task.id.clone()) .collect::>(); - let mut blocked_by = tasks + let blocked_by = tasks .iter() .map(|task| (task.id.clone(), dedupe_ids(&task.blocked_by))) .collect::>(); - // Compatibility for historical rows that persisted only the reverse - // edge. Unknown downstream ids remain a graph validation concern; a - // projection cannot attach them to a task row that does not exist. - for task in tasks { - for downstream_id in &task.blocks { - if known_ids.contains(downstream_id) { - push_unique( - blocked_by.entry(downstream_id.clone()).or_default(), - task.id.clone(), - ); - } - } - } - let mut blocks = known_ids .iter() .map(|task_id| (task_id.clone(), Vec::new())) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs index bb650ab021..88e735c11c 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/helpers.rs @@ -2,7 +2,7 @@ use rusqlite::{params, Connection, Result as SqliteResult, Transaction}; #[cfg(test)] use super::TaskHistoryEvent; -use super::{Task, TaskStatus}; +use super::{Task, TaskExecutionMode, TaskOutput, TaskStatus, TaskTerminalReason}; pub(super) fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339() @@ -48,6 +48,32 @@ pub(super) fn decode_metadata(raw: Option) -> Result( + label: &str, + value: Option<&T>, +) -> Result, String> { + value + .map(|value| serde_json::to_string(value).map_err(|err| format!("encode {label}: {err}"))) + .transpose() +} + +fn decode_optional_json( + raw: Option, + column_index: usize, + label: &str, +) -> SqliteResult> { + raw.map(|raw| { + serde_json::from_str(&raw).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + column_index, + rusqlite::types::Type::Text, + format!("decode {label}: {err}").into(), + ) + }) + }) + .transpose() +} + #[cfg(test)] pub(super) fn status_from_optional_wire( value: Option, @@ -73,17 +99,27 @@ pub(super) const SELECT_COLUMNS: &str = "id, active_form, owner, status, - blocks_json, + execution_mode, blocked_by_json, metadata_json, + output_json, + failure_reason_json, + cancel_reason_json, + created_by_participant_id, + source_turn_intent_id, + originating_message_id, + replaces_task_id, created_at, updated_at"; pub(super) fn row_to_task(row: &rusqlite::Row<'_>) -> SqliteResult { - let blocks_json: String = row.get(7)?; let blocked_by_json: String = row.get(8)?; let metadata_raw: Option = row.get(9)?; + let output_raw: Option = row.get(10)?; + let failure_reason_raw: Option = row.get(11)?; + let cancel_reason_raw: Option = row.get(12)?; let status_raw: String = row.get(6)?; + let execution_mode_raw: String = row.get(7)?; let task = Task { id: row.get(0)?, @@ -95,17 +131,33 @@ pub(super) fn row_to_task(row: &rusqlite::Row<'_>) -> SqliteResult { status: TaskStatus::from_wire(&status_raw).map_err(|err| { rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, err.into()) })?, - blocks: decode_json_array(&blocks_json).map_err(|err| { + execution_mode: TaskExecutionMode::from_wire(&execution_mode_raw).map_err(|err| { rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, err.into()) })?, + blocks: Vec::new(), blocked_by: decode_json_array(&blocked_by_json).map_err(|err| { rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, err.into()) })?, metadata: decode_metadata(metadata_raw).map_err(|err| { rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, err.into()) })?, - created_at: row.get(10)?, - updated_at: row.get(11)?, + output: decode_optional_json::(output_raw, 10, "task output")?, + failure_reason: decode_optional_json::( + failure_reason_raw, + 11, + "task failure reason", + )?, + cancel_reason: decode_optional_json::( + cancel_reason_raw, + 12, + "task cancel reason", + )?, + created_by_participant_id: row.get(13)?, + source_turn_intent_id: row.get(14)?, + originating_message_id: row.get(15)?, + replaces_task_id: row.get(16)?, + created_at: row.get(17)?, + updated_at: row.get(18)?, }; Ok(task) } @@ -124,10 +176,13 @@ pub(super) fn row_to_task_history_event(row: &rusqlite::Row<'_>) -> SqliteResult previous_status: status_from_optional_wire(previous_status_raw, 6)?, next_status: status_from_optional_wire(next_status_raw, 7)?, actor_member_id: row.get(8)?, - created_at: row.get(9)?, + actor_kind: row.get(9)?, + source_turn_intent_id: row.get(10)?, + created_at: row.get(11)?, }) } +#[cfg(test)] pub(super) fn insert_task_history_event( tx: &Transaction<'_>, org_run_id: &str, @@ -137,11 +192,20 @@ pub(super) fn insert_task_history_event( next: &Task, actor_member_id: Option<&str>, ) -> Result<(), String> { + let actor_kind = + if actor_member_id == Some(crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID) { + "graph_writer" + } else if actor_member_id.is_some() { + "owner_execution" + } else { + "system" + }; tx.execute( "INSERT INTO agent_org_runtime_task_events ( id, org_run_id, task_id, event_type, previous_owner, next_owner, - previous_status, next_status, actor_member_id, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + previous_status, next_status, actor_member_id, actor_kind, + source_turn_intent_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, ?11)", params![ uuid::Uuid::new_v4().to_string(), org_run_id, @@ -152,6 +216,41 @@ pub(super) fn insert_task_history_event( previous.map(|task| task.status.as_wire()), next.status.as_wire(), actor_member_id, + actor_kind, + &next.updated_at, + ], + ) + .map_err(|err| err.to_string())?; + Ok(()) +} + +pub(super) fn insert_task_history_event_as( + tx: &Transaction<'_>, + org_run_id: &str, + task_id: &str, + event_type: &str, + previous: Option<&Task>, + next: &Task, + actor: &super::actor::TaskActorAudit, +) -> Result<(), String> { + tx.execute( + "INSERT INTO agent_org_runtime_task_events ( + id, org_run_id, task_id, event_type, previous_owner, next_owner, + previous_status, next_status, actor_member_id, actor_kind, + source_turn_intent_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + uuid::Uuid::new_v4().to_string(), + org_run_id, + task_id, + event_type, + previous.and_then(|task| task.owner.as_deref()), + next.owner.as_deref(), + previous.map(|task| task.status.as_wire()), + next.status.as_wire(), + &actor.participant_id, + actor.kind.as_wire(), + actor.turn_intent_id.as_deref(), &next.updated_at, ], ) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs index 81f453ed9d..93678b2d9e 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs @@ -16,13 +16,18 @@ use crate::coordination::agent_org_runs::{ recovery_dispatch_recipient_is_available, AgentOrgRunStore, }; +mod actor; pub(super) mod graph; pub(super) mod helpers; mod store; +pub(crate) use actor::{SystemArchiveOrRecovery, SystemTaskOperation}; +pub use actor::{TaskGraphWriterAdmin, TaskOwnerExecution}; pub(crate) use graph::validate_dependency_graph; pub use graph::TaskGraphIndex; pub use store::AgentOrgTaskStore; +#[cfg(test)] +mod pr4_tests; #[cfg(test)] mod tests; @@ -30,14 +35,15 @@ pub const TASK_DEPENDENCY_CYCLE_ERROR: &str = "task_dependency_cycle"; pub const TASK_DEPENDENCY_LIMIT_ERROR: &str = "task_dependency_limit"; pub const TASK_RUN_TASK_LIMIT_ERROR: &str = "task_run_task_limit"; pub const TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR: &str = "task_graph_unlisted_open_tasks"; -pub const TASK_COMPLETED_IMMUTABLE_ERROR: &str = "task_completed_immutable"; +pub const TASK_TERMINAL_IMMUTABLE_ERROR: &str = "task_terminal_immutable"; +pub const TASK_COMPLETED_IMMUTABLE_ERROR: &str = TASK_TERMINAL_IMMUTABLE_ERROR; pub const TASK_MUTATION_CONFLICT_ERROR: &str = "task_mutation_conflict"; pub const TASK_DELETE_HAS_DEPENDENTS_ERROR: &str = "task_delete_has_dependents"; pub const TASK_DELETE_IS_DELIVERY_REPLACEMENT_ERROR: &str = "task_delete_is_delivery_replacement"; pub const TASK_METADATA_ELIGIBLE_MEMBER_IDS: &str = "eligible_member_ids"; pub const TASK_METADATA_REQUIRED_ROLE: &str = "required_role"; -pub const TASK_METADATA_OUTPUT: &str = "output"; -pub const TASK_METADATA_EXECUTION_MODE: &str = "execution_mode"; +pub(crate) const TASK_METADATA_OUTPUT: &str = "output"; +pub(crate) const TASK_METADATA_EXECUTION_MODE: &str = "execution_mode"; /// SQL predicate shared by Quiescence and watchdog repair discovery. /// @@ -51,275 +57,96 @@ pub(crate) fn corrupt_task_row_predicate_sql() -> String { format!( r#"( - status NOT IN ('pending','in_progress','completed') - OR (status='in_progress' AND owner IS NULL) - OR trim(id)='' - OR id<>trim(id) - OR length(id)>{id_chars} - OR length(CAST(id AS BLOB))>{id_bytes} + status NOT IN ('pending','in_progress','completed','failed','cancelled') + OR execution_mode NOT IN ('build','plan') + OR (owner IS NOT NULL AND (trim(owner)='' OR owner<>trim(owner) OR owner='coordinator')) + OR (status IN ('in_progress','completed','failed') AND owner IS NULL) + OR trim(id)='' OR id<>trim(id) + OR length(id)>{id_chars} OR length(CAST(id AS BLOB))>{id_bytes} OR trim(subject)='' - OR length(subject)>{subject_chars} - OR length(CAST(subject AS BLOB))>{subject_bytes} - OR length(description)>{description_chars} - OR length(CAST(description AS BLOB))>{description_bytes} + OR length(subject)>{subject_chars} OR length(CAST(subject AS BLOB))>{subject_bytes} + OR length(description)>{description_chars} OR length(CAST(description AS BLOB))>{description_bytes} OR (active_form IS NOT NULL AND ( - length(active_form)>{active_chars} - OR length(CAST(active_form AS BLOB))>{active_bytes} + length(active_form)>{active_chars} OR length(CAST(active_form AS BLOB))>{active_bytes} )) - OR (owner IS NOT NULL AND ( - trim(owner)='' OR owner<>trim(owner) OR length(owner)>{id_chars} - OR length(CAST(owner AS BLOB))>{id_bytes} + OR trim(created_by_participant_id)='' + OR trim(source_turn_intent_id)='' + OR datetime(created_at) IS NULL OR datetime(updated_at) IS NULL + OR length(CAST(blocked_by_json AS BLOB))>{dependency_json_bytes} + OR json_valid(blocked_by_json)=0 OR json_type(blocked_by_json)<>'array' + OR EXISTS (SELECT 1 FROM json_each(blocked_by_json) + WHERE type<>'text' OR trim(value)='' OR value<>trim(value)) + OR (metadata_json IS NOT NULL AND ( + length(CAST(metadata_json AS BLOB))>{metadata_bytes} + OR json_valid(metadata_json)=0 OR json_type(metadata_json)<>'object' + OR json_type(metadata_json,'$.output') IS NOT NULL + OR json_type(metadata_json,'$.execution_mode') IS NOT NULL + OR (json_type(metadata_json,'$.eligible_member_ids') IS NOT NULL + AND json_type(metadata_json,'$.eligible_member_ids')<>'array') + OR EXISTS ( + SELECT 1 FROM json_each(metadata_json,'$.eligible_member_ids') + WHERE type<>'text' OR trim(value)='' OR value<>trim(value) + OR length(value)>{id_chars} OR length(CAST(value AS BLOB))>{id_bytes} + ) + OR (json_type(metadata_json,'$.required_role') IS NOT NULL + AND (json_type(metadata_json,'$.required_role')<>'text' + OR trim(json_extract(metadata_json,'$.required_role'))='')) )) - OR length(created_at)>{timestamp_chars} - OR length(CAST(created_at AS BLOB))>{timestamp_bytes} - OR datetime(created_at) IS NULL - OR length(updated_at)>{timestamp_chars} - OR length(CAST(updated_at AS BLOB))>{timestamp_bytes} - OR datetime(updated_at) IS NULL - OR CASE WHEN length(CAST(blocks_json AS BLOB))>{dependency_json_bytes} - THEN 1 ELSE json_valid(blocks_json)=0 END - OR CASE WHEN length(CAST(blocked_by_json AS BLOB))>{dependency_json_bytes} - THEN 1 ELSE json_valid(blocked_by_json)=0 END - OR (metadata_json IS NOT NULL AND CASE - WHEN length(CAST(metadata_json AS BLOB))>{metadata_bytes} - THEN 1 ELSE json_valid(metadata_json)=0 END) - OR json_type(CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocks_json)=1 THEN blocks_json ELSE '[]' END)<>'array' - OR json_type(CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocked_by_json)=1 THEN blocked_by_json ELSE '[]' END)<>'array' - OR (metadata_json IS NOT NULL AND json_type( - CASE WHEN length(CAST(metadata_json AS BLOB))<={metadata_bytes} AND json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END - )<>'object') - OR EXISTS ( - SELECT 1 FROM json_each( - CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocks_json)=1 THEN blocks_json ELSE '[]' END - ) WHERE type<>'text' OR trim(value)='' - OR value<>trim(value) - OR length(value)>{id_chars} - OR length(CAST(value AS BLOB))>{id_bytes} - ) - OR EXISTS ( - SELECT 1 FROM json_each( - CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocked_by_json)=1 THEN blocked_by_json ELSE '[]' END - ) WHERE type<>'text' OR trim(value)='' - OR value<>trim(value) - OR length(value)>{id_chars} - OR length(CAST(value AS BLOB))>{id_bytes} - ) - OR (SELECT COUNT(*) FROM json_each( - CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocks_json)=1 THEN blocks_json ELSE '[]' END - ))>{dependency_count} - OR (SELECT COUNT(*) FROM json_each( - CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocked_by_json)=1 THEN blocked_by_json ELSE '[]' END - ))>{dependency_count} - OR (SELECT COALESCE(SUM(length(value)),0) FROM json_each( - CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocks_json)=1 THEN blocks_json ELSE '[]' END - ) WHERE type='text')>{dependency_chars} - OR (SELECT COALESCE(SUM(length(value)),0) FROM json_each( - CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocked_by_json)=1 THEN blocked_by_json ELSE '[]' END - ) WHERE type='text')>{dependency_chars} - OR (SELECT COALESCE(SUM(length(CAST(value AS BLOB))),0) FROM json_each( - CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocks_json)=1 THEN blocks_json ELSE '[]' END - ) WHERE type='text')>{dependency_bytes} - OR (SELECT COALESCE(SUM(length(CAST(value AS BLOB))),0) FROM json_each( - CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_bytes} AND json_valid(blocked_by_json)=1 THEN blocked_by_json ELSE '[]' END - ) WHERE type='text')>{dependency_bytes} - OR (json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.eligible_member_ids' - ) IS NOT NULL - AND json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.eligible_member_ids' - )<>'array') - OR EXISTS ( - SELECT 1 FROM json_each( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.eligible_member_ids' - ) WHERE type<>'text' OR trim(value)='' - OR value<>trim(value) - OR length(value)>{id_chars} - OR length(CAST(value AS BLOB))>{id_bytes} + OR (output_json IS NOT NULL AND json_valid(output_json)=0) + OR (failure_reason_json IS NOT NULL AND json_valid(failure_reason_json)=0) + OR (cancel_reason_json IS NOT NULL AND json_valid(cancel_reason_json)=0) + OR (output_json IS NOT NULL AND ( + json_type(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.summary') IS NOT 'text' + OR trim(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.summary'))='' + OR length(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.summary'))>{output_summary_chars} + OR length(CAST(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.summary') AS BLOB))>{output_summary_bytes} + OR (json_type(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.content') IS NOT NULL + AND json_type(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.content') NOT IN ('text','null')) + OR (json_type(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.content')='text' + AND (length(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.content'))>{output_content_chars} + OR length(CAST(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.content') AS BLOB))>{output_content_bytes})) + OR json_type(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.artifactIds') IS NOT 'array' + OR json_array_length(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.artifactIds')>{artifact_count} + OR EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.artifactIds') + WHERE type<>'text' OR trim(value)='' OR value<>trim(value) + OR length(value)>{artifact_chars} OR length(CAST(value AS BLOB))>{artifact_bytes}) + OR (SELECT COALESCE(SUM(length(value)),0) FROM json_each(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.artifactIds'))>{artifact_total_chars} + OR (SELECT COALESCE(SUM(length(CAST(value AS BLOB))),0) FROM json_each(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.artifactIds'))>{artifact_total_bytes} + OR json_type(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedByMemberId') IS NOT 'text' + OR trim(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedByMemberId'))='' + OR json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedByMemberId')<>owner + OR length(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedByMemberId'))>{id_chars} + OR length(CAST(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedByMemberId') AS BLOB))>{id_bytes} + OR json_type(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedAt') IS NOT 'text' + OR instr(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedAt'),'T')=0 + OR datetime(json_extract(CASE WHEN json_valid(output_json)=1 THEN output_json ELSE '{{}}' END,'$.producedAt')) IS NULL + )) + OR (failure_reason_json IS NOT NULL AND ( + json_type(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.code') IS NOT 'text' + OR json_type(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.message') IS NOT 'text' + OR trim(json_extract(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.code'))='' + OR trim(json_extract(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.message'))='' + OR length(json_extract(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.code'))>128 + OR length(CAST(json_extract(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.code') AS BLOB))>512 + OR length(json_extract(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.message'))>2000 + OR length(CAST(json_extract(CASE WHEN json_valid(failure_reason_json)=1 THEN failure_reason_json ELSE '{{}}' END,'$.message') AS BLOB))>8000 + )) + OR (cancel_reason_json IS NOT NULL AND ( + json_type(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.code') IS NOT 'text' + OR json_type(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.message') IS NOT 'text' + OR trim(json_extract(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.code'))='' + OR trim(json_extract(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.message'))='' + OR length(json_extract(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.code'))>128 + OR length(CAST(json_extract(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.code') AS BLOB))>512 + OR length(json_extract(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.message'))>2000 + OR length(CAST(json_extract(CASE WHEN json_valid(cancel_reason_json)=1 THEN cancel_reason_json ELSE '{{}}' END,'$.message') AS BLOB))>8000 + )) + OR NOT ( + (status IN ('pending','in_progress') AND output_json IS NULL AND failure_reason_json IS NULL AND cancel_reason_json IS NULL) + OR (status='completed' AND output_json IS NOT NULL AND failure_reason_json IS NULL AND cancel_reason_json IS NULL) + OR (status='failed' AND output_json IS NULL AND failure_reason_json IS NOT NULL AND cancel_reason_json IS NULL) + OR (status='cancelled' AND output_json IS NULL AND failure_reason_json IS NULL AND cancel_reason_json IS NOT NULL) ) - OR (SELECT COUNT(*) FROM json_each( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.eligible_member_ids' - ))>{eligibility_count} - OR (SELECT COALESCE(SUM(length(value)),0) FROM json_each( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.eligible_member_ids' - ) WHERE type='text')>{eligibility_chars} - OR (SELECT COALESCE(SUM(length(CAST(value AS BLOB))),0) FROM json_each( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.eligible_member_ids' - ) WHERE type='text')>{eligibility_bytes} - OR (json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.required_role' - ) IS NOT NULL - AND (json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.required_role' - )<>'text' - OR trim(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.required_role' - ))='' - OR length(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.required_role' - ))>{role_chars} - OR length(CAST(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.required_role' - ) AS BLOB))>{role_bytes})) - OR (json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.execution_mode' - ) IS NOT NULL - AND (json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.execution_mode' - )<>'text' - OR json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.execution_mode' - ) NOT IN ('build','plan'))) - OR (json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output' - ) IS NOT NULL - AND ( - json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output' - )<>'object' - OR status<>'completed' - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.summary' - ) IS NULL - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.summary' - )<>'text' - OR trim(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.summary' - ))='' - OR length(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.summary' - ))>{output_summary_chars} - OR length(CAST(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.summary' - ) AS BLOB))>{output_summary_bytes} - OR (json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.content' - ) IS NOT NULL - AND json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.content' - ) NOT IN ('null','text')) - OR length(COALESCE(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.content' - ),''))>{output_content_chars} - OR length(CAST(COALESCE(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.content' - ),'') AS BLOB))>{output_content_bytes} - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - ) IS NULL - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - )<>'array' - OR (SELECT COUNT(*) FROM json_each( - CASE WHEN json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - )='array' THEN json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - ) ELSE '[]' END - ))>{artifact_count} - OR EXISTS (SELECT 1 FROM json_each( - CASE WHEN json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - )='array' THEN json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - ) ELSE '[]' END - ) WHERE type<>'text' OR trim(value)='' - OR length(value)>{artifact_chars} - OR length(CAST(value AS BLOB))>{artifact_bytes}) - OR (SELECT COALESCE(SUM(length(value)),0) FROM json_each( - CASE WHEN json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - )='array' THEN json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - ) ELSE '[]' END - ) WHERE type='text')>{artifact_total_chars} - OR (SELECT COALESCE(SUM(length(CAST(value AS BLOB))),0) FROM json_each( - CASE WHEN json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - )='array' THEN json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.artifactIds' - ) ELSE '[]' END - ) WHERE type='text')>{artifact_total_bytes} - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedByMemberId' - ) IS NULL - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedByMemberId' - )<>'text' - OR trim(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedByMemberId' - ))='' - OR length(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedByMemberId' - ))>{id_chars} - OR length(CAST(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedByMemberId' - ) AS BLOB))>{id_bytes} - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedAt' - ) IS NULL - OR json_type( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedAt' - )<>'text' - OR datetime(json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedAt' - )) IS NULL - OR (json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedAt' - ) NOT GLOB '*T*Z' - AND json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedAt' - ) NOT GLOB '*T*+??:??' - AND json_extract( - CASE WHEN json_valid(metadata_json)=1 THEN metadata_json ELSE '{{}}' END, - '$.output.producedAt' - ) NOT GLOB '*T*-??:??') - )) )"#, id_chars = limits::TASK_IDENTIFIER_MAX_CHARS, id_bytes = limits::TASK_IDENTIFIER_MAX_BYTES, @@ -331,14 +158,6 @@ pub(crate) fn corrupt_task_row_predicate_sql() -> String { active_bytes = limits::TASK_ACTIVE_FORM_MAX_BYTES, metadata_bytes = limits::TASK_METADATA_MAX_BYTES, dependency_json_bytes = limits::TASK_DEPENDENCY_JSON_MAX_BYTES, - dependency_count = limits::TASK_DEPENDENCY_MAX_COUNT, - dependency_chars = limits::TASK_DEPENDENCY_TOTAL_MAX_CHARS, - dependency_bytes = limits::TASK_DEPENDENCY_TOTAL_MAX_BYTES, - eligibility_count = limits::TASK_ELIGIBILITY_MAX_COUNT, - eligibility_chars = limits::TASK_ELIGIBILITY_TOTAL_MAX_CHARS, - eligibility_bytes = limits::TASK_ELIGIBILITY_TOTAL_MAX_BYTES, - role_chars = limits::TASK_REQUIRED_ROLE_MAX_CHARS, - role_bytes = limits::TASK_REQUIRED_ROLE_MAX_BYTES, output_summary_chars = limits::TASK_OUTPUT_SUMMARY_MAX_CHARS, output_summary_bytes = limits::TASK_OUTPUT_SUMMARY_MAX_BYTES, output_content_chars = limits::TASK_OUTPUT_CONTENT_MAX_CHARS, @@ -348,38 +167,21 @@ pub(crate) fn corrupt_task_row_predicate_sql() -> String { artifact_bytes = limits::TASK_ARTIFACT_ID_MAX_BYTES, artifact_total_chars = limits::TASK_ARTIFACT_IDS_TOTAL_MAX_CHARS, artifact_total_bytes = limits::TASK_ARTIFACT_IDS_TOTAL_MAX_BYTES, - timestamp_chars = limits::RFC3339_TIMESTAMP_MAX_CHARS, - timestamp_bytes = limits::RFC3339_TIMESTAMP_MAX_BYTES, ) } -/// Transaction-time scheduling decision for a single task created through -/// the Agent Org tool boundary. -/// -/// The tool may perform an earlier read to return richer guidance, but that -/// snapshot is advisory only. The store must apply this policy again while it -/// holds the shared writer lock and an IMMEDIATE SQLite transaction, using -/// the task graph that is current at the moment of commit. +/// Transaction-time scheduling decision for a single Task created through +/// the graph-writer boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TaskCreateSchedulingPolicy { - /// The coordinator explicitly confirmed that this task is independent of - /// every currently-open task omitted from its dependency closure. pub allow_parallel_with_unlisted_open_tasks: bool, } -/// Return every task reached by following `blocked_by` links upstream from -/// `task_ids`, including the starting ids. Scheduling guards share this -/// helper so single-task creation, atomic graph creation, and the store's -/// transaction-time recheck agree on what an existing dependency covers. +/// Return every Task reached by following canonical `blocked_by` edges. pub fn task_dependency_closure(task_ids: &[String], tasks: &[Task]) -> HashSet { TaskGraphIndex::new(tasks).dependency_closure(task_ids) } -/// Execution mode requested by the task assignment itself. -/// -/// This is deliberately task-scoped: a planning task must start its very -/// first provider turn in Plan mode, rather than relying on a separate inbox -/// control message that may only be drained after the mode was selected. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaskExecutionMode { @@ -388,7 +190,7 @@ pub enum TaskExecutionMode { } impl TaskExecutionMode { - pub fn as_wire(self) -> &'static str { + pub const fn as_wire(self) -> &'static str { match self { Self::Build => "build", Self::Plan => "plan", @@ -396,7 +198,7 @@ impl TaskExecutionMode { } pub fn from_wire(value: &str) -> Result { - match value.trim() { + match value { "build" => Ok(Self::Build), "plan" => Ok(Self::Plan), other => Err(format!( @@ -406,14 +208,8 @@ impl TaskExecutionMode { } } -/// Historical tasks predate the typed field and retain Build semantics. pub fn task_execution_mode(task: &Task) -> TaskExecutionMode { - task.metadata - .as_ref() - .and_then(|metadata| metadata.get(TASK_METADATA_EXECUTION_MODE)) - .and_then(serde_json::Value::as_str) - .and_then(|value| TaskExecutionMode::from_wire(value).ok()) - .unwrap_or(TaskExecutionMode::Build) + task.execution_mode } /// Durable, task-scoped result used for cross-session handoff. @@ -431,11 +227,26 @@ pub struct TaskOutput { } pub fn task_output(task: &Task) -> Option { - task.metadata - .as_ref() - .and_then(|metadata| metadata.get(TASK_METADATA_OUTPUT)) - .cloned() - .and_then(|value| serde_json::from_value(value).ok()) + task.output.clone() +} + +/// Owner-supplied completion payload. The Store, not the caller, records +/// who produced it and when. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskOutputInput { + pub summary: String, + pub content: Option, + #[serde(default)] + pub artifact_ids: Vec, +} + +/// Bounded machine-readable reason for a failed or cancelled task. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskTerminalReason { + pub code: String, + pub message: String, } /// A quiet Running owner keeps its task; this age only controls when the @@ -483,9 +294,9 @@ pub fn ready_unassigned_tasks(tasks: &[Task]) -> Vec<&Task> { pub(super) const TASK_EVENT_CREATED: &str = "created"; pub(super) const TASK_EVENT_UPDATED: &str = "updated"; +#[cfg(test)] pub(super) const TASK_EVENT_DELETED: &str = "deleted"; pub(super) const TASK_EVENT_RELEASED: &str = "released"; -pub(super) const TASK_EVENT_ESCALATED_TO_COORDINATOR: &str = "escalated_to_coordinator"; /// Task status. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -494,6 +305,8 @@ pub enum TaskStatus { Pending, InProgress, Completed, + Failed, + Cancelled, } impl TaskStatus { @@ -502,6 +315,8 @@ impl TaskStatus { TaskStatus::Pending => "pending", TaskStatus::InProgress => "in_progress", TaskStatus::Completed => "completed", + TaskStatus::Failed => "failed", + TaskStatus::Cancelled => "cancelled", } } @@ -510,13 +325,22 @@ impl TaskStatus { "pending" => Ok(TaskStatus::Pending), "in_progress" => Ok(TaskStatus::InProgress), "completed" => Ok(TaskStatus::Completed), + "failed" => Ok(TaskStatus::Failed), + "cancelled" => Ok(TaskStatus::Cancelled), other => Err(format!("invalid TaskStatus wire value: {other}")), } } - /// `completed` is treated as resolved for dependency and Quiescence checks. - pub fn is_resolved(&self) -> bool { - matches!(self, TaskStatus::Completed) + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } + + pub fn is_open(&self) -> bool { + matches!(self, Self::Pending | Self::InProgress) + } + + pub fn satisfies_dependency(&self) -> bool { + matches!(self, Self::Completed) } } @@ -531,9 +355,17 @@ pub struct Task { pub active_form: Option, pub owner: Option, pub status: TaskStatus, + pub execution_mode: TaskExecutionMode, pub blocks: Vec, pub blocked_by: Vec, pub metadata: Option, + pub output: Option, + pub failure_reason: Option, + pub cancel_reason: Option, + pub created_by_participant_id: String, + pub source_turn_intent_id: String, + pub originating_message_id: Option, + pub replaces_task_id: Option, pub created_at: String, pub updated_at: String, } @@ -556,11 +388,15 @@ pub struct TaskSummary { pub blocks_truncated: bool, pub blocked_by: Vec, pub blocked_by_truncated: bool, + pub dependencies_satisfied: bool, pub eligible_member_ids: Vec, pub eligible_member_ids_truncated: bool, pub required_role: Option, pub execution_mode: TaskExecutionMode, pub output: Option, + pub failure_reason: Option, + pub cancel_reason: Option, + pub replaces_task_id: Option, pub created_at: String, pub updated_at: String, } @@ -576,12 +412,44 @@ pub struct TaskOutputSummary { pub has_content: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] pub struct TaskSummaryPage { pub tasks: Vec, pub filtered_total: usize, pub has_more: bool, pub next_cursor: Option, + pub previous_cursor: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskPageBucket { + Current, + History, +} + +impl TaskPageBucket { + pub fn accepts(self, status: TaskStatus) -> bool { + match self { + Self::Current => status.is_open(), + Self::History => status.is_terminal(), + } + } + + pub const fn as_wire(self) -> &'static str { + match self { + Self::Current => "current", + Self::History => "history", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskPageDirection { + Forward, + Backward, } #[derive(Debug, Clone)] @@ -606,13 +474,65 @@ pub struct TaskHistoryEvent { pub previous_status: Option, pub next_status: Option, pub actor_member_id: Option, + pub actor_kind: String, + pub source_turn_intent_id: Option, pub created_at: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskAnnotationKind { + Progress, + Evidence, + AuditNote, +} + +impl TaskAnnotationKind { + pub fn as_wire(self) -> &'static str { + match self { + Self::Progress => "progress", + Self::Evidence => "evidence", + Self::AuditNote => "audit_note", + } + } + + pub fn from_wire(value: &str) -> Result { + match value { + "progress" => Ok(Self::Progress), + "evidence" => Ok(Self::Evidence), + "audit_note" => Ok(Self::AuditNote), + other => Err(format!("invalid TaskAnnotationKind wire value: {other}")), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskAnnotation { + pub id: String, + pub org_run_id: String, + pub task_id: String, + pub kind: TaskAnnotationKind, + pub body: String, + pub actor_kind: String, + pub actor_participant_id: String, + pub source_turn_intent_id: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskAnnotationPage { + pub annotations: Vec, + pub has_more: bool, + pub next_cursor: Option, +} + /// Inputs for creating a task. `id` is caller-supplied so the LLM tool /// layer can deterministically generate UUIDs. If you want the store to /// mint one, call `new_task_id()` first. #[derive(Debug, Clone)] +#[cfg(test)] pub struct CreateTaskParams { pub id: String, pub org_run_id: String, @@ -626,11 +546,40 @@ pub struct CreateTaskParams { pub metadata: Option, } -/// Patch applied by `update`. Every field is `Option`; only `Some(_)` -/// fields are written. `None` keeps the existing value. To clear a -/// nullable column (e.g. unassign owner), use the explicit clear-flag -/// pattern via `UpdateTaskPatch::clear_owner` etc. +/// Canonical graph-writer request. Creation is always `pending`; provenance +/// is injected from the persisted Coordinator turn context inside the Store. +#[derive(Debug, Clone)] +pub struct CreatePendingTaskParams { + pub id: String, + pub org_run_id: String, + pub subject: String, + pub description: String, + pub active_form: Option, + pub owner: Option, + pub execution_mode: TaskExecutionMode, + pub blocked_by: Vec, + pub metadata: Option, + pub originating_message_id: Option, + pub replaces_task_id: Option, +} + +/// Sparse graph-admin patch. It intentionally contains no lifecycle result +/// fields, so a graph writer cannot accidentally impersonate an Owner. #[derive(Debug, Clone, Default)] +pub struct PendingTaskGraphPatch { + pub subject: Option, + pub description: Option, + pub active_form: Option>, + pub owner: Option>, + pub execution_mode: Option, + pub blocked_by: Option>, + pub metadata: Option>, +} + +/// Test-only compatibility patch for pre-PR4 Store fixtures. Production +/// code cannot compile this type and must use the typed actor requests. +#[derive(Debug, Clone, Default)] +#[cfg(test)] pub struct UpdateTaskPatch { pub subject: Option, pub description: Option, @@ -653,8 +602,7 @@ pub fn new_task_id() -> String { /// recovery diagnostics. /// - `(org_run_id, owner)` -- per-member listings and failure requeue. pub fn init_schema(conn: &Connection) -> SqliteResult<()> { - create_schema(conn)?; - store::normalize_legacy_dependency_rows(conn) + create_schema(conn) } pub(crate) fn create_schema(conn: &Connection) -> SqliteResult<()> { @@ -666,18 +614,47 @@ pub(crate) fn create_schema(conn: &Connection) -> SqliteResult<()> { description TEXT NOT NULL DEFAULT '', active_form TEXT, owner TEXT, - status TEXT NOT NULL, - blocks_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL CHECK(status IN ('pending','in_progress','completed','failed','cancelled')), + execution_mode TEXT NOT NULL CHECK(execution_mode IN ('build','plan')), blocked_by_json TEXT NOT NULL DEFAULT '[]', metadata_json TEXT, + output_json TEXT, + failure_reason_json TEXT, + cancel_reason_json TEXT, + created_by_participant_id TEXT NOT NULL CHECK(trim(created_by_participant_id) <> ''), + source_turn_intent_id TEXT NOT NULL CHECK(trim(source_turn_intent_id) <> ''), + originating_message_id TEXT, + replaces_task_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY (org_run_id, id) + PRIMARY KEY (org_run_id, id), + FOREIGN KEY (org_run_id, replaces_task_id) + REFERENCES agent_org_runtime_tasks(org_run_id, id), + CHECK(owner IS NULL OR (trim(owner) <> '' AND owner <> 'coordinator')), + CHECK(replaces_task_id IS NULL OR replaces_task_id <> id), + CHECK(json_valid(blocked_by_json)=1 AND json_type(blocked_by_json)='array'), + CHECK(metadata_json IS NULL OR ( + json_valid(metadata_json)=1 AND json_type(metadata_json)='object' + AND json_type(metadata_json,'$.output') IS NULL + AND json_type(metadata_json,'$.execution_mode') IS NULL + )), + CHECK(output_json IS NULL OR (json_valid(output_json)=1 AND json_type(output_json)='object')), + CHECK(failure_reason_json IS NULL OR (json_valid(failure_reason_json)=1 AND json_type(failure_reason_json)='object')), + CHECK(cancel_reason_json IS NULL OR (json_valid(cancel_reason_json)=1 AND json_type(cancel_reason_json)='object')), + CHECK(status NOT IN ('in_progress','completed','failed') OR owner IS NOT NULL), + CHECK( + (status IN ('pending','in_progress') AND output_json IS NULL AND failure_reason_json IS NULL AND cancel_reason_json IS NULL) + OR (status='completed' AND output_json IS NOT NULL AND failure_reason_json IS NULL AND cancel_reason_json IS NULL) + OR (status='failed' AND output_json IS NULL AND failure_reason_json IS NOT NULL AND cancel_reason_json IS NULL) + OR (status='cancelled' AND output_json IS NULL AND failure_reason_json IS NULL AND cancel_reason_json IS NOT NULL) + ) ); - CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_tasks_status - ON agent_org_runtime_tasks(org_run_id, status, owner); + CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_tasks_page + ON agent_org_runtime_tasks(org_run_id, status, created_at, id); CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_tasks_owner - ON agent_org_runtime_tasks(org_run_id, owner); + ON agent_org_runtime_tasks(org_run_id, owner, status); + CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_tasks_replacement + ON agent_org_runtime_tasks(org_run_id, replaces_task_id); CREATE TABLE IF NOT EXISTS agent_org_runtime_task_events ( id TEXT PRIMARY KEY, org_run_id TEXT NOT NULL, @@ -688,25 +665,32 @@ pub(crate) fn create_schema(conn: &Connection) -> SqliteResult<()> { previous_status TEXT, next_status TEXT, actor_member_id TEXT, + actor_kind TEXT NOT NULL CHECK(actor_kind IN ('graph_writer','owner_execution','system')), + source_turn_intent_id TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_task_events_run ON agent_org_runtime_task_events(org_run_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_task_events_task ON agent_org_runtime_task_events(org_run_id, task_id, created_at, id); - CREATE TABLE IF NOT EXISTS agent_org_runtime_task_schema_migrations ( - name TEXT NOT NULL, + CREATE TABLE IF NOT EXISTS agent_org_runtime_task_annotations ( + id TEXT PRIMARY KEY, org_run_id TEXT NOT NULL, - applied_at TEXT NOT NULL, - PRIMARY KEY (name, org_run_id) - );", + task_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('progress','evidence','audit_note')), + body TEXT NOT NULL CHECK(trim(body) <> ''), + actor_kind TEXT NOT NULL CHECK(actor_kind IN ('graph_writer','owner_execution','system')), + actor_participant_id TEXT NOT NULL CHECK(trim(actor_participant_id) <> ''), + source_turn_intent_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (org_run_id, task_id) + REFERENCES agent_org_runtime_tasks(org_run_id, id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_agent_org_runtime_task_annotations_page + ON agent_org_runtime_task_annotations(org_run_id, task_id, created_at, id);", ) } -pub(crate) fn normalize_runtime_data(conn: &Connection) -> SqliteResult<()> { - store::normalize_legacy_dependency_rows(conn) -} - /// Inbox helper: enqueue a `TaskAssigned` payload into the task owner's /// inbox for the same `org_run_id` as the task itself. /// diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs new file mode 100644 index 0000000000..67441eb76a --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs @@ -0,0 +1,890 @@ +use database::db::get_connection; +use rusqlite::{params, OptionalExtension}; + +use super::*; + +const RUN_ID: &str = "pr4-run"; +const ROOT_SESSION: &str = "pr4-root-session"; +const COORDINATOR_TURN: &str = "pr4-coordinator-turn"; +const MEMBER_A: &str = "member-a"; +const MEMBER_B: &str = "member-b"; +const MEMBER_A_SESSION: &str = "pr4-member-a-session"; +const MEMBER_B_SESSION: &str = "pr4-member-b-session"; + +struct Fixture { + _sandbox: test_helpers::test_env::SandboxGuard, +} + +fn fixture() -> Fixture { + let sandbox = test_helpers::test_env::sandbox(); + let conn = get_connection().expect("PR4 test database"); + conn.execute_batch( + "CREATE TABLE session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id,turn_intent_id) + );", + ) + .expect("base Turn table"); + crate::coordination::agent_org_runs::init_schema(&conn).expect("run schema"); + crate::coordination::agent_org_turn_contexts::create_schema(&conn) + .expect("Turn context schema"); + crate::coordination::agent_org_watchdog::init_schema(&conn).expect("recovery schema"); + init_schema(&conn).expect("Task schema"); + let now = chrono::Utc::now().to_rfc3339(); + let snapshot = serde_json::json!({ + "schemaVersion": 1, + "orgId": "pr4-org", + "orgName": "PR4 Team", + "coordinatorRole": "Lead", + "coordinatorAgentId": "agent-coordinator", + "planApprovalPolicy": "coordinator", + "members": [ + {"memberId": MEMBER_A, "name": "A", "role": "Builder", "agentId": "agent-a"}, + {"memberId": MEMBER_B, "name": "B", "role": "Reviewer", "agentId": "agent-b"} + ], + "additionalTaskGraphWriterMemberIds": [], + "memberCommunicationLinks": [], + }) + .to_string(); + conn.execute( + "INSERT INTO agent_org_runtime_runs( + id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, + entry_mode,status,activation_generation,created_at,updated_at + ) VALUES (?1,'pr4-org','agent-coordinator',?2,?3, + 'standalone_session','running',1,?4,?4)", + params![RUN_ID, ROOT_SESSION, snapshot, now], + ) + .expect("running Team"); + insert_coordinator_context(&conn, COORDINATOR_TURN, 1); + Fixture { _sandbox: sandbox } +} + +fn insert_base_turn(conn: &rusqlite::Connection, session_id: &str, turn_id: &str) { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO session_turn_intents( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES (?1,?2,?3,'agent_org','running',?4,?4)", + params![session_id, turn_id, RUN_ID, now], + ) + .expect("base Turn"); +} + +fn insert_coordinator_context(conn: &rusqlite::Connection, turn_id: &str, generation: i64) { + insert_base_turn(conn, ROOT_SESSION, turn_id); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + source_kind,source_id,activation_generation,created_at + ) VALUES (?1,?2,?3,'coordinator','coordinator','root_turn',?2,?4,?5)", + params![ROOT_SESSION, turn_id, RUN_ID, generation, now], + ) + .expect("Coordinator context"); +} + +fn insert_owner_context( + conn: &rusqlite::Connection, + member_id: &str, + session_id: &str, + turn_id: &str, + task_id: &str, + generation: i64, +) { + insert_base_turn(conn, session_id, turn_id); + let sequence: i64 = conn + .query_row( + "SELECT COALESCE(MAX(member_dispatch_sequence),0)+1 + FROM agent_org_runtime_turn_contexts + WHERE org_run_id=?1 AND dispatch_member_id=?2", + params![RUN_ID, member_id], + |row| row.get(0), + ) + .expect("next sequence"); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + task_id,owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,activation_generation,created_at + ) VALUES (?1,?2,?3,?4,'task_execution',?5,?4,?4,?6, + 'task',?5,?7,?8)", + params![session_id, turn_id, RUN_ID, member_id, task_id, sequence, generation, now], + ) + .expect("Owner TaskExecution context"); +} + +fn graph_actor() -> TaskGraphWriterAdmin { + TaskGraphWriterAdmin::new(ROOT_SESSION, COORDINATOR_TURN).expect("graph actor") +} + +fn owner_actor(session_id: &str, turn_id: &str) -> TaskOwnerExecution { + TaskOwnerExecution::new(session_id, turn_id).expect("Owner actor") +} + +fn pending(id: &str, owner: Option<&str>, blocked_by: Vec<&str>) -> CreatePendingTaskParams { + let eligible = owner + .map(|owner| vec![owner.to_string()]) + .unwrap_or_else(|| vec![MEMBER_A.to_string(), MEMBER_B.to_string()]); + CreatePendingTaskParams { + id: id.to_string(), + org_run_id: RUN_ID.to_string(), + subject: format!("Task {id}"), + description: format!("Description for {id}"), + active_form: None, + owner: owner.map(str::to_string), + execution_mode: TaskExecutionMode::Build, + blocked_by: blocked_by.into_iter().map(str::to_string).collect(), + metadata: Some(serde_json::json!({ + TASK_METADATA_ELIGIBLE_MEMBER_IDS: eligible, + })), + originating_message_id: Some(format!("message-{id}")), + replaces_task_id: None, + } +} + +fn create(params: CreatePendingTaskParams) -> Task { + AgentOrgTaskStore::create_pending_with_transactional_effects( + graph_actor(), + params, + TaskCreateSchedulingPolicy { + allow_parallel_with_unlisted_open_tasks: true, + }, + |_tx, _task, _tasks| Ok(()), + ) + .expect("create pending Task") + .0 +} + +fn start(task_id: &str, session_id: &str, turn_id: &str) -> TaskMutationOutcome { + AgentOrgTaskStore::owner_start_with_transactional_effects( + owner_actor(session_id, turn_id), + RUN_ID, + task_id, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect("start Task") + .0 +} + +#[test] +fn canonical_schema_and_five_state_semantics_are_frozen() { + let _fixture = fixture(); + let conn = get_connection().unwrap(); + let columns = conn + .prepare("PRAGMA table_info(agent_org_runtime_tasks)") + .unwrap() + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>>() + .unwrap(); + assert!(columns.contains(&"output_json".to_string())); + assert!(columns.contains(&"failure_reason_json".to_string())); + assert!(columns.contains(&"cancel_reason_json".to_string())); + assert!(columns.contains(&"execution_mode".to_string())); + assert!(!columns.contains(&"blocks_json".to_string())); + assert!(!columns.contains(&"status_migration".to_string())); + let migration_table: Option = conn + .query_row( + "SELECT name FROM sqlite_master WHERE name='agent_org_runtime_task_schema_migrations'", + [], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(migration_table.is_none()); + + assert!(TaskStatus::Pending.is_open()); + assert!(TaskStatus::InProgress.is_open()); + assert!(TaskStatus::Completed.is_terminal()); + assert!(TaskStatus::Failed.is_terminal()); + assert!(TaskStatus::Cancelled.is_terminal()); + assert!(TaskStatus::Completed.satisfies_dependency()); + assert!(!TaskStatus::Failed.satisfies_dependency()); + assert!(!TaskStatus::Cancelled.satisfies_dependency()); +} + +#[test] +fn graph_create_forces_pending_and_injects_provenance() { + let _fixture = fixture(); + let task = create(pending("created", Some(MEMBER_A), vec![])); + assert_eq!(task.status, TaskStatus::Pending); + assert_eq!(task.created_by_participant_id, "coordinator"); + assert_eq!(task.source_turn_intent_id, COORDINATOR_TURN); + assert_eq!( + task.originating_message_id.as_deref(), + Some("message-created") + ); + + let error = AgentOrgTaskStore::create_pending_with_transactional_effects( + graph_actor(), + pending("bad-owner", Some("coordinator"), vec![]), + TaskCreateSchedulingPolicy { + allow_parallel_with_unlisted_open_tasks: true, + }, + |_tx, _task, _tasks| Ok(()), + ) + .expect_err("Coordinator cannot own formal Task"); + assert!(error.contains("coordinator"), "{error}"); + assert!(AgentOrgTaskStore::get(RUN_ID, "bad-owner") + .unwrap() + .is_none()); +} + +#[test] +fn owner_fsm_stamps_output_and_freezes_terminal_task() { + let _fixture = fixture(); + create(pending("owned", Some(MEMBER_A), vec![])); + let conn = get_connection().unwrap(); + insert_owner_context(&conn, MEMBER_A, MEMBER_A_SESSION, "turn-owned", "owned", 1); + start("owned", MEMBER_A_SESSION, "turn-owned"); + + let wrong = TaskOwnerExecution::new(MEMBER_B_SESSION, "turn-wrong").unwrap(); + insert_owner_context(&conn, MEMBER_B, MEMBER_B_SESSION, "turn-wrong", "owned", 1); + let error = AgentOrgTaskStore::owner_complete_with_transactional_effects( + wrong, + RUN_ID, + "owned", + TaskOutputInput { + summary: "forged".to_string(), + content: None, + artifact_ids: Vec::new(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect_err("non-Owner output must be rejected"); + assert!(error.contains("owner"), "{error}"); + + let completed = AgentOrgTaskStore::owner_complete_with_transactional_effects( + owner_actor(MEMBER_A_SESSION, "turn-owned"), + RUN_ID, + "owned", + TaskOutputInput { + summary: "done".to_string(), + content: Some("full result".to_string()), + artifact_ids: vec!["artifact-1".to_string()], + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .unwrap() + .0 + .current; + assert_eq!(completed.status, TaskStatus::Completed); + let output = completed.output.unwrap(); + assert_eq!(output.produced_by_member_id, MEMBER_A); + assert!(chrono::DateTime::parse_from_rfc3339(&output.produced_at).is_ok()); + + let late_progress = AgentOrgTaskStore::append_owner_annotation( + owner_actor(MEMBER_A_SESSION, "turn-owned"), + RUN_ID, + "owned", + TaskAnnotationKind::Progress, + "too late".to_string(), + ) + .expect_err("Owner progress freezes with the terminal Task"); + assert!(late_progress.contains("in-progress"), "{late_progress}"); + AgentOrgTaskStore::append_audit_annotation( + graph_actor(), + RUN_ID, + "owned", + "terminal audit remains append-only".to_string(), + ) + .expect("graph admin may append a terminal audit note"); + + let error = AgentOrgTaskStore::cancel_with_transactional_effects( + graph_actor(), + RUN_ID, + "owned", + &completed.updated_at, + TaskTerminalReason { + code: "scope.changed".to_string(), + message: "cannot rewrite terminal work".to_string(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect_err("terminal Task must be frozen"); + assert!(error.contains(TASK_TERMINAL_IMMUTABLE_ERROR), "{error}"); +} + +#[test] +fn only_completed_dependencies_unlock_owner_start() { + let _fixture = fixture(); + create(pending("failed-blocker", Some(MEMBER_A), vec![])); + create(pending( + "blocked-on-failure", + Some(MEMBER_B), + vec!["failed-blocker"], + )); + let conn = get_connection().unwrap(); + insert_owner_context( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "turn-failed-blocker", + "failed-blocker", + 1, + ); + insert_owner_context( + &conn, + MEMBER_B, + MEMBER_B_SESSION, + "turn-blocked", + "blocked-on-failure", + 1, + ); + start("failed-blocker", MEMBER_A_SESSION, "turn-failed-blocker"); + AgentOrgTaskStore::owner_fail_with_transactional_effects( + owner_actor(MEMBER_A_SESSION, "turn-failed-blocker"), + RUN_ID, + "failed-blocker", + TaskTerminalReason { + code: "execution.failed".to_string(), + message: "failed normally".to_string(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .unwrap(); + let error = AgentOrgTaskStore::owner_start_with_transactional_effects( + owner_actor(MEMBER_B_SESSION, "turn-blocked"), + RUN_ID, + "blocked-on-failure", + |_tx, _outcome, _tasks| Ok(()), + ) + .expect_err("failed dependency cannot unlock downstream"); + assert_eq!(error, "task_dependencies_not_completed"); +} + +#[test] +fn cancel_and_replace_is_atomic_and_rejects_late_owner_callback() { + let _fixture = fixture(); + create(pending("old", Some(MEMBER_A), vec![])); + let conn = get_connection().unwrap(); + insert_owner_context(&conn, MEMBER_A, MEMBER_A_SESSION, "turn-old", "old", 1); + start("old", MEMBER_A_SESSION, "turn-old"); + let old = AgentOrgTaskStore::get(RUN_ID, "old").unwrap().unwrap(); + let (_outcome, replacement, ()) = + AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( + graph_actor(), + RUN_ID, + "old", + &old.updated_at, + TaskTerminalReason { + code: "scope.changed".to_string(), + message: "replace the goal".to_string(), + }, + pending("replacement", Some(MEMBER_B), vec![]), + |_tx, _outcome, _replacement, _tasks| Ok(()), + ) + .unwrap(); + assert_eq!(replacement.status, TaskStatus::Pending); + assert_eq!(replacement.replaces_task_id.as_deref(), Some("old")); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "old") + .unwrap() + .unwrap() + .status, + TaskStatus::Cancelled + ); + let late = AgentOrgTaskStore::owner_complete_with_transactional_effects( + owner_actor(MEMBER_A_SESSION, "turn-old"), + RUN_ID, + "old", + TaskOutputInput { + summary: "late".to_string(), + content: None, + artifact_ids: Vec::new(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect_err("cancelled Task rejects late callback"); + assert!(late.contains("requires_in_progress"), "{late}"); + + let current = create(pending("fault-old", Some(MEMBER_A), vec![])); + let error = AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( + graph_actor(), + RUN_ID, + "fault-old", + ¤t.updated_at, + TaskTerminalReason { + code: "scope.changed".to_string(), + message: "fault injection".to_string(), + }, + pending("fault-replacement", Some(MEMBER_B), vec![]), + |_tx, _outcome, _replacement, _tasks| { + Err::<(), String>("injected outbox failure".to_string()) + }, + ) + .expect_err("fault must roll back both rows"); + assert_eq!(error, "injected outbox failure"); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "fault-old") + .unwrap() + .unwrap() + .status, + TaskStatus::Pending + ); + assert!(AgentOrgTaskStore::get(RUN_ID, "fault-replacement") + .unwrap() + .is_none()); +} + +#[test] +fn stale_generation_and_wrong_turn_binding_fail_without_partial_write() { + let _fixture = fixture(); + create(pending("generation", Some(MEMBER_A), vec![])); + let conn = get_connection().unwrap(); + insert_owner_context( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "turn-generation", + "generation", + 1, + ); + conn.execute( + "UPDATE agent_org_runtime_runs SET activation_generation=2 WHERE id=?1", + [RUN_ID], + ) + .unwrap(); + let error = AgentOrgTaskStore::owner_start_with_transactional_effects( + owner_actor(MEMBER_A_SESSION, "turn-generation"), + RUN_ID, + "generation", + |_tx, _outcome, _tasks| Ok(()), + ) + .expect_err("old generation must be rejected"); + assert_eq!(error, "task_actor_generation_mismatch"); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "generation") + .unwrap() + .unwrap() + .status, + TaskStatus::Pending + ); +} + +#[test] +fn current_history_detail_and_annotations_are_demand_driven() { + let _fixture = fixture(); + create(pending("current", Some(MEMBER_B), vec![])); + create(pending("history", Some(MEMBER_A), vec![])); + let conn = get_connection().unwrap(); + insert_owner_context( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "turn-history", + "history", + 1, + ); + start("history", MEMBER_A_SESSION, "turn-history"); + AgentOrgTaskStore::append_owner_annotation( + owner_actor(MEMBER_A_SESSION, "turn-history"), + RUN_ID, + "history", + TaskAnnotationKind::Progress, + "halfway".to_string(), + ) + .unwrap(); + for index in 0..24 { + AgentOrgTaskStore::append_owner_annotation( + owner_actor(MEMBER_A_SESSION, "turn-history"), + RUN_ID, + "history", + TaskAnnotationKind::Evidence, + format!("evidence-{index:02}-{}", "测".repeat(7_900)), + ) + .unwrap(); + } + AgentOrgTaskStore::owner_complete_with_transactional_effects( + owner_actor(MEMBER_A_SESSION, "turn-history"), + RUN_ID, + "history", + TaskOutputInput { + summary: "summary".to_string(), + content: Some("large detail remains on demand".to_string()), + artifact_ids: Vec::new(), + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .unwrap(); + create(pending("downstream", Some(MEMBER_B), vec!["history"])); + + let current = AgentOrgTaskStore::list_task_page( + RUN_ID, + TaskPageBucket::Current, + None, + None, + TaskPageDirection::Forward, + 50, + ) + .unwrap(); + assert_eq!(current.tasks.len(), 2); + assert!(current.tasks.iter().all(|task| task.output.is_none())); + assert!( + current + .tasks + .iter() + .find(|task| task.id == "downstream") + .is_some_and(|task| task.dependencies_satisfied), + "a completed dependency outside the Current page must still make the task ready" + ); + + let history = AgentOrgTaskStore::list_task_page( + RUN_ID, + TaskPageBucket::History, + Some(TaskStatus::Completed), + None, + TaskPageDirection::Forward, + 50, + ) + .unwrap(); + assert_eq!(history.tasks.len(), 1); + assert_eq!(history.tasks[0].output.as_ref().unwrap().summary, "summary"); + assert!(history.tasks[0].output.as_ref().unwrap().has_content); + let detail = AgentOrgTaskStore::get(RUN_ID, "history").unwrap().unwrap(); + assert_eq!(detail.blocks, vec!["downstream"]); + assert_eq!( + detail.output.unwrap().content.as_deref(), + Some("large detail remains on demand") + ); + let annotations = AgentOrgTaskStore::list_annotation_page(RUN_ID, "history", None, 50).unwrap(); + assert!(annotations.has_more); + assert_eq!(annotations.annotations[0].body, "halfway"); + assert!( + serde_json::to_vec(&annotations.annotations).unwrap().len() + <= crate::coordination::agent_org_payload_limits::TASK_ANNOTATION_PAGE_MAX_BYTES + ); + let next = AgentOrgTaskStore::list_annotation_page( + RUN_ID, + "history", + annotations.next_cursor.as_deref(), + 50, + ) + .unwrap(); + assert!(!next.annotations.is_empty()); + assert_ne!(next.annotations[0].id, annotations.annotations[0].id); +} + +#[test] +fn ten_thousand_task_history_uses_bounded_keyset_pages() { + let _fixture = fixture(); + let mut conn = get_connection().unwrap(); + let tx = conn.transaction().unwrap(); + { + let mut insert = tx + .prepare( + "INSERT INTO agent_org_runtime_tasks( + id,org_run_id,subject,description,owner,status,execution_mode, + blocked_by_json,metadata_json,output_json, + created_by_participant_id,source_turn_intent_id,created_at,updated_at + ) VALUES (?1,?2,?3,'history detail',?4,'completed','build', + '[]','{}',?5,'coordinator',?6,?7,?7)", + ) + .unwrap(); + for index in 0..10_000 { + let id = format!("history-{index:05}"); + let created_at = format!( + "2026-01-01T00:{:02}:{:02}.{:03}Z", + (index / 60) % 60, + index % 60, + index % 1_000 + ); + let output = serde_json::json!({ + "summary": format!("result {index}"), + "content": "detail remains behind task_detail", + "artifactIds": [], + "producedByMemberId": MEMBER_A, + "producedAt": "2026-01-01T00:00:00Z", + }) + .to_string(); + insert + .execute(params![ + id, + RUN_ID, + format!("History {index}"), + MEMBER_A, + output, + COORDINATOR_TURN, + created_at, + ]) + .unwrap(); + } + } + tx.commit().unwrap(); + + let first = AgentOrgTaskStore::list_task_page( + RUN_ID, + TaskPageBucket::History, + Some(TaskStatus::Completed), + None, + TaskPageDirection::Forward, + 50, + ) + .unwrap(); + assert_eq!(first.tasks.len(), 50); + assert!(first.has_more); + assert!(first.tasks.iter().all(|task| { + task.output + .as_ref() + .is_some_and(|output| output.has_content) + && task.blocks.is_empty() + && task.blocks_truncated + })); + assert!( + serde_json::to_vec(&first.tasks).unwrap().len() + <= crate::coordination::agent_org_payload_limits::TASK_SUMMARY_PAGE_MAX_BYTES + ); + + let second = AgentOrgTaskStore::list_task_page( + RUN_ID, + TaskPageBucket::History, + Some(TaskStatus::Completed), + first.next_cursor.as_deref(), + TaskPageDirection::Forward, + 50, + ) + .unwrap(); + assert_eq!(second.tasks.len(), 50); + assert_ne!(first.tasks[0].id, second.tasks[0].id); + let back = AgentOrgTaskStore::list_task_page( + RUN_ID, + TaskPageBucket::History, + Some(TaskStatus::Completed), + second.previous_cursor.as_deref(), + TaskPageDirection::Backward, + 50, + ) + .unwrap(); + assert_eq!( + back.tasks.iter().map(|task| &task.id).collect::>(), + first.tasks.iter().map(|task| &task.id).collect::>() + ); + let mismatch = AgentOrgTaskStore::list_task_page( + RUN_ID, + TaskPageBucket::History, + Some(TaskStatus::Failed), + first.next_cursor.as_deref(), + TaskPageDirection::Forward, + 50, + ) + .expect_err("a cursor cannot be reused with another status filter"); + assert_eq!(mismatch, "task_page_cursor_filter_mismatch"); + + let artifact_ids = (0..16) + .map(|index| format!("artifact-{index:02}-{}", "x".repeat(980))) + .collect::>(); + for index in 0..100 { + let output = serde_json::json!({ + "summary": format!("large result {index}"), + "content": "detail remains behind task_detail", + "artifactIds": artifact_ids, + "producedByMemberId": MEMBER_A, + "producedAt": "2026-01-01T00:00:00Z", + }) + .to_string(); + conn.execute( + "UPDATE agent_org_runtime_tasks SET output_json=?1 WHERE org_run_id=?2 AND id=?3", + params![output, RUN_ID, format!("history-{index:05}")], + ) + .unwrap(); + } + let byte_bounded = AgentOrgTaskStore::list_task_page( + RUN_ID, + TaskPageBucket::History, + Some(TaskStatus::Completed), + None, + TaskPageDirection::Forward, + 200, + ) + .unwrap(); + assert!(byte_bounded.tasks.len() < 200); + assert!(byte_bounded.has_more); + assert!( + serde_json::to_vec(&byte_bounded.tasks).unwrap().len() + <= crate::coordination::agent_org_payload_limits::TASK_SUMMARY_PAGE_MAX_BYTES + ); + + let plan = conn + .prepare( + "EXPLAIN QUERY PLAN + SELECT id FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND status='completed' + AND (created_at>?2 OR (created_at=?2 AND id>?3)) + ORDER BY created_at,id LIMIT 51", + ) + .unwrap() + .query_map(params![RUN_ID, "", ""], |row| row.get::<_, String>(3)) + .unwrap() + .collect::>>() + .unwrap() + .join("\n"); + assert!( + plan.contains("idx_agent_org_runtime_tasks_page"), + "query plan must use the keyset page index:\n{plan}" + ); + assert!(!plan.contains("SCAN agent_org_runtime_tasks"), "{plan}"); + + let after_history = create(pending("after-long-history", Some(MEMBER_B), vec![])); + assert_eq!(after_history.status, TaskStatus::Pending); + let operational = AgentOrgTaskStore::list_operational(RUN_ID).unwrap(); + assert_eq!( + operational + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["after-long-history"], + "operational polling must not materialize unrelated terminal history" + ); +} + +#[test] +fn recovery_receipt_requeues_then_exhaustion_fails_without_coordinator_owner() { + let _fixture = fixture(); + create(pending("recover", Some(MEMBER_A), vec![])); + let conn = get_connection().unwrap(); + insert_owner_context( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "turn-recover", + "recover", + 1, + ); + start("recover", MEMBER_A_SESSION, "turn-recover"); + let reservation = + crate::coordination::agent_org_watchdog::reserve_task_failure_recovery(RUN_ID, MEMBER_A) + .unwrap(); + let wrong_actor = SystemArchiveOrRecovery::new( + reservation.token.clone(), + reservation.generation, + SystemTaskOperation::RecoveryFail, + ) + .unwrap(); + let mismatch = AgentOrgTaskStore::recover_owner_failure(wrong_actor, RUN_ID, MEMBER_A) + .expect_err("a non-exhausted durable receipt cannot fail the Task"); + assert_eq!(mismatch, "system_task_recovery_operation_mismatch"); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "recover") + .unwrap() + .unwrap() + .status, + TaskStatus::InProgress + ); + let actor = SystemArchiveOrRecovery::new( + reservation.token, + reservation.generation, + SystemTaskOperation::RecoveryRequeue, + ) + .unwrap(); + let recovered = AgentOrgTaskStore::recover_owner_failure(actor, RUN_ID, MEMBER_A).unwrap(); + assert_eq!(recovered.len(), 1); + assert_eq!(recovered[0].status, TaskStatus::Pending); + assert_eq!(recovered[0].owner, None); + assert!(eligible_member_ids(&recovered[0]).contains(&MEMBER_A.to_string())); + + let recovered_pending = recovered[0].clone(); + AgentOrgTaskStore::patch_pending_with_transactional_effects( + graph_actor(), + RUN_ID, + "recover", + &recovered_pending.updated_at, + PendingTaskGraphPatch { + owner: Some(Some(MEMBER_A.to_string())), + ..Default::default() + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .unwrap(); + insert_owner_context( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "turn-recover-again", + "recover", + 1, + ); + start("recover", MEMBER_A_SESSION, "turn-recover-again"); + conn.execute( + "UPDATE agent_org_runtime_recovery_attempts SET attempts=3 + WHERE org_run_id=?1 AND action_kind='task_failure_recovery' AND target_key=?2", + params![RUN_ID, MEMBER_A], + ) + .unwrap(); + let reservation = + crate::coordination::agent_org_watchdog::reserve_task_failure_recovery(RUN_ID, MEMBER_A) + .unwrap(); + assert!(reservation.exhausted); + let wrong_actor = SystemArchiveOrRecovery::new( + reservation.token.clone(), + reservation.generation, + SystemTaskOperation::RecoveryRequeue, + ) + .unwrap(); + let mismatch = AgentOrgTaskStore::recover_owner_failure(wrong_actor, RUN_ID, MEMBER_A) + .expect_err("an exhausted durable receipt cannot requeue the Task"); + assert_eq!(mismatch, "system_task_recovery_operation_mismatch"); + let actor = SystemArchiveOrRecovery::new( + reservation.token, + reservation.generation, + SystemTaskOperation::RecoveryFail, + ) + .unwrap(); + let failed = AgentOrgTaskStore::recover_owner_failure(actor, RUN_ID, MEMBER_A).unwrap(); + assert_eq!(failed[0].status, TaskStatus::Failed); + assert_eq!( + failed[0].failure_reason.as_ref().unwrap().code, + "system.recovery_budget_exhausted" + ); + assert_ne!(failed[0].owner.as_deref(), Some("coordinator")); + + create(pending("corrupt-recovery", Some(MEMBER_B), vec![])); + insert_owner_context( + &conn, + MEMBER_B, + MEMBER_B_SESSION, + "turn-corrupt-recovery", + "corrupt-recovery", + 1, + ); + start( + "corrupt-recovery", + MEMBER_B_SESSION, + "turn-corrupt-recovery", + ); + conn.execute( + "UPDATE agent_org_runtime_tasks + SET metadata_json='{\"eligible_member_ids\":\"member-b\"}' + WHERE org_run_id=?1 AND id='corrupt-recovery'", + [RUN_ID], + ) + .unwrap(); + let reservation = + crate::coordination::agent_org_watchdog::reserve_task_failure_recovery(RUN_ID, MEMBER_B) + .unwrap(); + let actor = SystemArchiveOrRecovery::new( + reservation.token, + reservation.generation, + SystemTaskOperation::RecoveryRequeue, + ) + .unwrap(); + let error = AgentOrgTaskStore::recover_owner_failure(actor, RUN_ID, MEMBER_B) + .expect_err("system recovery must not silently repair corrupt eligibility metadata"); + assert_eq!(error, "eligible_member_ids must be an array"); + let persisted_status: String = conn + .query_row( + "SELECT status FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id='corrupt-recovery'", + [RUN_ID], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(persisted_status, TaskStatus::InProgress.as_wire()); +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs new file mode 100644 index 0000000000..3e5535a999 --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/annotations.rs @@ -0,0 +1,230 @@ +//! Demand-driven Task annotation writes and bounded keyset reads. + +use database::db::{get_connection, with_sessions_writer}; +use rusqlite::{params, OptionalExtension}; + +use super::super::actor::TaskActorAudit; +use super::super::helpers::{now_rfc3339, row_to_task, SELECT_COLUMNS}; +use super::super::{ + Task, TaskAnnotation, TaskAnnotationKind, TaskAnnotationPage, TaskGraphWriterAdmin, + TaskOwnerExecution, TaskStatus, +}; +use super::AgentOrgTaskStore; +use crate::coordination::agent_org_payload_limits::TASK_ANNOTATION_PAGE_MAX_BYTES; + +const TASK_ANNOTATION_BODY_MAX_CHARS: usize = 8_000; +const TASK_ANNOTATION_BODY_MAX_BYTES: usize = 32_000; + +impl AgentOrgTaskStore { + pub fn append_owner_annotation( + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + kind: TaskAnnotationKind, + body: String, + ) -> Result { + if !matches!( + kind, + TaskAnnotationKind::Progress | TaskAnnotationKind::Evidence + ) { + return Err("Owner may append only progress or evidence".to_string()); + } + append_annotation(org_run_id, task_id, kind, body, |tx, task| { + let audit = actor.validate(tx, org_run_id, task_id)?; + if task.status != TaskStatus::InProgress + || task.owner.as_deref() != Some(audit.participant_id.as_str()) + { + return Err("Owner annotations require the Owner's in-progress task".to_string()); + } + Ok(audit) + }) + } + + pub fn append_audit_annotation( + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, + body: String, + ) -> Result { + append_annotation( + org_run_id, + task_id, + TaskAnnotationKind::AuditNote, + body, + |tx, task| { + let audit = actor.validate(tx, org_run_id)?; + if !task.status.is_terminal() { + return Err("audit_note is available only after a task is terminal".to_string()); + } + Ok(audit) + }, + ) + } + + pub fn list_annotation_page( + org_run_id: &str, + task_id: &str, + after_annotation_id: Option<&str>, + limit: usize, + ) -> Result { + let conn = get_connection().map_err(|error| error.to_string())?; + let bounded_limit = limit.clamp(1, 200); + let cursor = after_annotation_id + .map(|id| { + conn.query_row( + "SELECT created_at, id FROM agent_org_runtime_task_annotations + WHERE org_run_id=?1 AND task_id=?2 AND id=?3", + params![org_run_id, task_id, id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| "annotation cursor not found for task".to_string()) + }) + .transpose()?; + let (cursor_created_at, cursor_id) = cursor + .as_ref() + .map(|(created_at, id)| (Some(created_at.as_str()), Some(id.as_str()))) + .unwrap_or((None, None)); + let mut stmt = conn + .prepare( + "SELECT id, org_run_id, task_id, kind, body, actor_kind, + actor_participant_id, source_turn_intent_id, created_at + FROM agent_org_runtime_task_annotations + WHERE org_run_id=?1 AND task_id=?2 + AND (?3 IS NULL OR created_at>?3 OR (created_at=?3 AND id>?4)) + ORDER BY created_at ASC, id ASC LIMIT ?5", + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map( + params![ + org_run_id, + task_id, + cursor_created_at, + cursor_id, + (bounded_limit + 1) as i64 + ], + |row| { + let kind_raw: String = row.get(3)?; + Ok(TaskAnnotation { + id: row.get(0)?, + org_run_id: row.get(1)?, + task_id: row.get(2)?, + kind: TaskAnnotationKind::from_wire(&kind_raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Text, + error.into(), + ) + })?, + body: row.get(4)?, + actor_kind: row.get(5)?, + actor_participant_id: row.get(6)?, + source_turn_intent_id: row.get(7)?, + created_at: row.get(8)?, + }) + }, + ) + .map_err(|error| error.to_string())?; + let mut annotations = Vec::new(); + let mut serialized_bytes = 2usize; + let mut has_more = false; + for row in rows { + let annotation = row.map_err(|error| error.to_string())?; + if annotations.len() == bounded_limit { + has_more = true; + break; + } + let bytes = serde_json::to_vec(&annotation) + .map_err(|error| error.to_string())? + .len(); + let separator = usize::from(!annotations.is_empty()); + if serialized_bytes + .saturating_add(separator) + .saturating_add(bytes) + > TASK_ANNOTATION_PAGE_MAX_BYTES + { + has_more = true; + break; + } + serialized_bytes = serialized_bytes + .saturating_add(separator) + .saturating_add(bytes); + annotations.push(annotation); + } + let next_cursor = has_more + .then(|| annotations.last().map(|annotation| annotation.id.clone())) + .flatten(); + Ok(TaskAnnotationPage { + annotations, + has_more, + next_cursor, + }) + } +} + +fn append_annotation( + org_run_id: &str, + task_id: &str, + kind: TaskAnnotationKind, + body: String, + validate_actor: impl FnOnce(&rusqlite::Connection, &Task) -> Result, +) -> Result { + crate::coordination::agent_org_payload_limits::validate_required_text( + "task annotation body", + &body, + TASK_ANNOTATION_BODY_MAX_CHARS, + TASK_ANNOTATION_BODY_MAX_BYTES, + )?; + let annotation = with_sessions_writer(|| -> Result { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let sql = format!( + "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id=?2" + ); + let task = tx + .query_row(&sql, params![org_run_id, task_id], row_to_task) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("task_not_found: {task_id} in run {org_run_id}"))?; + let audit = validate_actor(&tx, &task)?; + let annotation = TaskAnnotation { + id: uuid::Uuid::new_v4().to_string(), + org_run_id: org_run_id.to_string(), + task_id: task_id.to_string(), + kind, + body, + actor_kind: audit.kind.as_wire().to_string(), + actor_participant_id: audit.participant_id, + source_turn_intent_id: audit.turn_intent_id, + created_at: now_rfc3339(), + }; + tx.execute( + "INSERT INTO agent_org_runtime_task_annotations( + id, org_run_id, task_id, kind, body, actor_kind, + actor_participant_id, source_turn_intent_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + &annotation.id, + &annotation.org_run_id, + &annotation.task_id, + annotation.kind.as_wire(), + &annotation.body, + &annotation.actor_kind, + &annotation.actor_participant_id, + annotation.source_turn_intent_id.as_deref(), + &annotation.created_at, + ], + ) + .map_err(|error| error.to_string())?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, org_run_id)?; + tx.commit().map_err(|error| error.to_string())?; + Ok(annotation) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(org_run_id); + Ok(annotation) +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/create.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/create.rs index 02a14f83ea..64746cd146 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/create.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/create.rs @@ -12,14 +12,17 @@ use crate::coordination::agent_org_payload_limits::{ }; use super::super::helpers::{ - encode_json_array, encode_metadata, insert_task_history_event, list_tasks_with_conn, - now_rfc3339, + encode_json_array, encode_metadata, encode_optional_json, insert_task_history_event, + list_tasks_with_conn, now_rfc3339, }; use super::super::{ - task_dependency_closure, CreateTaskParams, Task, TaskCreateSchedulingPolicy, TaskStatus, - TASK_EVENT_CREATED, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, + task_dependency_closure, CreateTaskParams, Task, TaskCreateSchedulingPolicy, TaskExecutionMode, + TaskOutput, TaskStatus, TASK_EVENT_CREATED, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, + TASK_METADATA_EXECUTION_MODE, TASK_METADATA_OUTPUT, +}; +use super::dependencies::{ + canonicalize_dependencies, persist_canonical_blocked_by_for_test_fixture, }; -use super::dependencies::{canonicalize_dependencies, persist_dependency_projection}; use super::validation::{ ensure_run_allows_task_mutation, ensure_task_run_capacity, reject_writable_blocks, validate_task_persistence_invariants, validate_task_text_fields, @@ -85,7 +88,17 @@ impl AgentOrgTaskStore { } reject_writable_blocks(¶ms.blocks)?; - let metadata_json = encode_metadata(params.metadata.as_ref())?; + let execution_mode = legacy_execution_mode(params.metadata.as_ref())?; + let output = legacy_output(params.metadata.as_ref())?; + let mut canonical_metadata = params.metadata.clone(); + if let Some(object) = canonical_metadata + .as_mut() + .and_then(serde_json::Value::as_object_mut) + { + object.remove(TASK_METADATA_EXECUTION_MODE); + object.remove(TASK_METADATA_OUTPUT); + } + let metadata_json = encode_metadata(canonical_metadata.as_ref())?; let now = now_rfc3339(); let (task, effect) = with_sessions_writer(|| -> Result<(Task, T), String> { @@ -103,7 +116,11 @@ impl AgentOrgTaskStore { )?; let mut candidate_tasks = list_tasks_with_conn(&tx, ¶ms.org_run_id)?; let existing_task_count = candidate_tasks.len(); - ensure_task_run_capacity(existing_task_count, 1)?; + let existing_open_task_count = candidate_tasks + .iter() + .filter(|task| task.status.is_open()) + .count(); + ensure_task_run_capacity(existing_open_task_count, 1)?; candidate_tasks.push(Task { id: params.id.clone(), org_run_id: params.org_run_id.clone(), @@ -112,9 +129,18 @@ impl AgentOrgTaskStore { active_form: params.active_form.clone(), owner: params.owner.clone(), status: params.status, + execution_mode, blocks: Vec::new(), blocked_by: params.blocked_by.clone(), - metadata: params.metadata.clone(), + metadata: canonical_metadata, + output, + failure_reason: None, + cancel_reason: None, + created_by_participant_id: + crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID.to_string(), + source_turn_intent_id: format!("legacy-create:{}", params.id), + originating_message_id: None, + replaces_task_id: None, created_at: now.clone(), updated_at: now.clone(), }); @@ -123,7 +149,7 @@ impl AgentOrgTaskStore { .last() .cloned() .expect("candidate graph contains the task being created"); - if !task.status.is_resolved() + if task.status.is_open() && scheduling_policy .is_some_and(|policy| !policy.allow_parallel_with_unlisted_open_tasks) { @@ -131,7 +157,7 @@ impl AgentOrgTaskStore { task_dependency_closure(&task.blocked_by, &candidate_tasks); let omitted_open_task_ids = candidate_tasks[..existing_task_count] .iter() - .filter(|existing| !existing.status.is_resolved()) + .filter(|existing| existing.status.is_open()) .filter(|existing| !covered_dependency_ids.contains(&existing.id)) .map(|existing| existing.id.clone()) .collect::>(); @@ -142,15 +168,18 @@ impl AgentOrgTaskStore { )); } } - let blocks_json = encode_json_array(&task.blocks)?; let blocked_by_json = encode_json_array(&task.blocked_by)?; + let output_json = encode_optional_json("task output", task.output.as_ref())?; tx.execute( "INSERT INTO agent_org_runtime_tasks ( id, org_run_id, subject, description, active_form, owner, - status, blocks_json, blocked_by_json, metadata_json, - created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)", + status, execution_mode, blocked_by_json, metadata_json, + output_json, failure_reason_json, cancel_reason_json, + created_by_participant_id, source_turn_intent_id, + originating_message_id, replaces_task_id, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, + NULL, NULL, ?12, ?13, NULL, NULL, ?14, ?14)", params![ &task.id, &task.org_run_id, @@ -159,9 +188,12 @@ impl AgentOrgTaskStore { task.active_form.as_deref(), task.owner.as_deref(), task.status.as_wire(), - &blocks_json, + task.execution_mode.as_wire(), &blocked_by_json, metadata_json.as_deref(), + output_json.as_deref(), + &task.created_by_participant_id, + &task.source_turn_intent_id, &now, ], ) @@ -176,7 +208,7 @@ impl AgentOrgTaskStore { &task, task.owner.as_deref(), )?; - persist_dependency_projection(&tx, &candidate_tasks)?; + persist_canonical_blocked_by_for_test_fixture(&tx, &candidate_tasks)?; crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &task.org_run_id)?; let effect = effects(&tx, &task, &candidate_tasks)?; tx.commit().map_err(|err| err.to_string())?; @@ -212,7 +244,13 @@ impl AgentOrgTaskStore { if params_list.is_empty() { return Err("task graph must contain at least one task".to_string()); } - ensure_task_run_capacity(0, params_list.len())?; + ensure_task_run_capacity( + 0, + params_list + .iter() + .filter(|params| params.status.is_open()) + .count(), + )?; let org_run_id = params_list[0].org_run_id.clone(); if org_run_id.trim().is_empty() { return Err("org_run_id must be non-empty".to_string()); @@ -242,7 +280,17 @@ impl AgentOrgTaskStore { ensure_run_allows_task_mutation(&tx, &org_run_id)?; let existing_tasks = list_tasks_with_conn(&tx, &org_run_id)?; - ensure_task_run_capacity(existing_tasks.len(), params_list.len())?; + let incoming_open_task_count = params_list + .iter() + .filter(|params| params.status.is_open()) + .count(); + ensure_task_run_capacity( + existing_tasks + .iter() + .filter(|task| task.status.is_open()) + .count(), + incoming_open_task_count, + )?; if !allow_parallel_with_existing_open_tasks { let existing_ids = existing_tasks .iter() @@ -258,7 +306,7 @@ impl AgentOrgTaskStore { task_dependency_closure(&referenced_existing_ids, &existing_tasks); let omitted_open_task_ids = existing_tasks .iter() - .filter(|task| !task.status.is_resolved()) + .filter(|task| task.status.is_open()) .filter(|task| !covered_existing_ids.contains(&task.id)) .map(|task| task.id.clone()) .collect::>(); @@ -301,21 +349,32 @@ impl AgentOrgTaskStore { let now = now_rfc3339(); let new_tasks = params_list .iter() - .map(|params| Task { - id: params.id.clone(), - org_run_id: params.org_run_id.clone(), - subject: params.subject.clone(), - description: params.description.clone(), - active_form: params.active_form.clone(), - owner: params.owner.clone(), - status: params.status, - blocks: params.blocks.clone(), - blocked_by: params.blocked_by.clone(), - metadata: params.metadata.clone(), - created_at: now.clone(), - updated_at: now.clone(), + .map(|params| -> Result { + Ok(Task { + id: params.id.clone(), + org_run_id: params.org_run_id.clone(), + subject: params.subject.clone(), + description: params.description.clone(), + active_form: params.active_form.clone(), + owner: params.owner.clone(), + status: params.status, + execution_mode: legacy_execution_mode(params.metadata.as_ref())?, + blocks: params.blocks.clone(), + blocked_by: params.blocked_by.clone(), + metadata: params.metadata.clone(), + output: legacy_output(params.metadata.as_ref())?, + failure_reason: None, + cancel_reason: None, + created_by_participant_id: + crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID.to_string(), + source_turn_intent_id: format!("legacy-create:{}", params.id), + originating_message_id: None, + replaces_task_id: None, + created_at: now.clone(), + updated_at: now.clone(), + }) }) - .collect::>(); + .collect::, _>>()?; let existing_task_count = existing_tasks.len(); let mut candidate_graph = existing_tasks; candidate_graph.extend(new_tasks); @@ -323,15 +382,18 @@ impl AgentOrgTaskStore { let new_tasks = candidate_graph.split_off(existing_task_count); for task in &new_tasks { - let blocks_json = encode_json_array(&task.blocks)?; let blocked_by_json = encode_json_array(&task.blocked_by)?; let metadata_json = encode_metadata(task.metadata.as_ref())?; + let output_json = encode_optional_json("task output", task.output.as_ref())?; tx.execute( "INSERT INTO agent_org_runtime_tasks ( id, org_run_id, subject, description, active_form, owner, - status, blocks_json, blocked_by_json, metadata_json, - created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)", + status, execution_mode, blocked_by_json, metadata_json, + output_json, failure_reason_json, cancel_reason_json, + created_by_participant_id, source_turn_intent_id, + originating_message_id, replaces_task_id, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, + NULL, NULL, ?12, ?13, NULL, NULL, ?14, ?14)", params![ &task.id, &task.org_run_id, @@ -340,9 +402,12 @@ impl AgentOrgTaskStore { task.active_form.as_deref(), task.owner.as_deref(), task.status.as_wire(), - &blocks_json, + task.execution_mode.as_wire(), &blocked_by_json, metadata_json.as_deref(), + output_json.as_deref(), + &task.created_by_participant_id, + &task.source_turn_intent_id, &now, ], ) @@ -357,7 +422,7 @@ impl AgentOrgTaskStore { task.owner.as_deref(), )?; } - persist_dependency_projection(&tx, &candidate_graph)?; + persist_canonical_blocked_by_for_test_fixture(&tx, &candidate_graph)?; crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &org_run_id)?; let effect = effects(&tx, &new_tasks, &candidate_graph)?; tx.commit().map_err(|err| err.to_string())?; @@ -367,3 +432,28 @@ impl AgentOrgTaskStore { Ok((tasks, effect)) } } + +fn legacy_execution_mode( + metadata: Option<&serde_json::Value>, +) -> Result { + metadata + .and_then(|value| value.get(TASK_METADATA_EXECUTION_MODE)) + .map(|value| { + value + .as_str() + .ok_or_else(|| "execution_mode must be build or plan".to_string()) + .and_then(TaskExecutionMode::from_wire) + }) + .transpose() + .map(|mode| mode.unwrap_or(TaskExecutionMode::Build)) +} + +fn legacy_output(metadata: Option<&serde_json::Value>) -> Result, String> { + metadata + .and_then(|value| value.get(TASK_METADATA_OUTPUT)) + .cloned() + .map(|value| { + serde_json::from_value(value).map_err(|error| format!("invalid task output: {error}")) + }) + .transpose() +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/dependencies.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/dependencies.rs index 0ddb5087c2..912c65880a 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/dependencies.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/dependencies.rs @@ -1,24 +1,22 @@ -//! Dependency-graph canonicalization and its persisted `blocks`/`blocked_by` -//! projection. `blocked_by` is authoritative; `blocks` is a derived read -//! projection recomputed here and written back only when it drifts. +//! Dependency-graph canonicalization. `blocked_by` is the only persisted edge; +//! `blocks` is derived in memory for readers. use rusqlite::params; -use tracing::warn; use super::super::graph::validate_dependency_graph; -use super::super::helpers::{encode_json_array, list_tasks_with_conn, now_rfc3339}; +#[cfg(test)] +use super::super::helpers::encode_json_array; use super::super::{Task, TaskGraphIndex}; use crate::coordination::agent_org_payload_limits::{ - validate_task_dependency_ids, TASK_RUN_MAX_TASKS, + validate_task_dependency_ids, TASK_RUN_MAX_OPEN_TASKS, }; pub(super) fn canonicalize_dependencies( tasks: &mut [Task], org_run_id: &str, ) -> Result<(), String> { - // `list_tasks_with_conn` has already folded historical reverse-only - // `blocks` edges into `blocked_by`. From this point forward blocked_by is - // authoritative and blocks is a derived read projection. + // `blocked_by` is the sole persisted edge. `blocks` is rebuilt only as an + // in-memory read projection before validating the candidate graph. for task in tasks.iter_mut() { task.blocks.clear(); } @@ -31,131 +29,40 @@ pub(super) fn canonicalize_dependencies( validate_dependency_graph(tasks, org_run_id) } -pub(super) fn persist_dependency_projection( +#[cfg(test)] +pub(super) fn persist_canonical_blocked_by_for_test_fixture( conn: &rusqlite::Connection, tasks: &[Task], ) -> Result<(), String> { let mut stmt = conn .prepare( "UPDATE agent_org_runtime_tasks - SET blocks_json=?1, blocked_by_json=?2 - WHERE org_run_id=?3 AND id=?4 - AND (blocks_json<>?1 OR blocked_by_json<>?2)", + SET blocked_by_json=?1 + WHERE org_run_id=?2 AND id=?3 AND blocked_by_json<>?1", ) .map_err(|err| err.to_string())?; for task in tasks { - let blocks_json = encode_json_array(&task.blocks)?; let blocked_by_json = encode_json_array(&task.blocked_by)?; - stmt.execute(params![ - &blocks_json, - &blocked_by_json, - &task.org_run_id, - &task.id, - ]) - .map_err(|err| err.to_string())?; - } - Ok(()) -} - -/// One-time migration for the historical dual-write dependency fields. -/// Legacy `blocks`-only edges are folded into canonical `blocked_by`, then -/// both stored columns are rewritten as a consistent forward/reverse pair. -pub(super) fn normalize_legacy_dependency_rows( - conn: &rusqlite::Connection, -) -> rusqlite::Result<()> { - const MIGRATION_NAME: &str = "canonical_blocked_by_v1"; - let mut after_run_id: Option = None; - loop { - let run_ids = { - let mut stmt = conn.prepare( - "SELECT task.org_run_id - FROM agent_org_runtime_tasks task - WHERE NOT EXISTS ( - SELECT 1 FROM agent_org_runtime_task_schema_migrations migration - WHERE migration.name=?1 - AND migration.org_run_id=task.org_run_id - ) - AND (?2 IS NULL OR task.org_run_id>?2) - GROUP BY task.org_run_id - ORDER BY task.org_run_id - LIMIT 256", - )?; - let rows = stmt.query_map(params![MIGRATION_NAME, after_run_id.as_deref()], |row| { - row.get::<_, String>(0) - })?; - rows.collect::, _>>()? - }; - if run_ids.is_empty() { - break; - } - after_run_id = run_ids.last().cloned(); - - for run_id in run_ids { - conn.execute_batch("BEGIN IMMEDIATE")?; - let normalized = (|| -> Result<(), String> { - let already_applied: bool = conn - .query_row( - "SELECT EXISTS( - SELECT 1 FROM agent_org_runtime_task_schema_migrations - WHERE name=?1 AND org_run_id=?2 - )", - params![MIGRATION_NAME, &run_id], - |row| row.get(0), - ) - .map_err(|err| err.to_string())?; - if already_applied { - return Ok(()); - } - - if !run_is_safe_for_dependency_normalization(conn, &run_id)? { - return Err( - "task board exceeds current resource/integrity limits; repair is required before dependency normalization" - .to_string(), - ); - } - - let mut tasks = list_tasks_with_conn(conn, &run_id)?; - canonicalize_dependencies(&mut tasks, &run_id)?; - persist_dependency_projection(conn, &tasks)?; - conn.execute( - "INSERT INTO agent_org_runtime_task_schema_migrations( - name, org_run_id, applied_at - ) VALUES (?1, ?2, ?3)", - params![MIGRATION_NAME, &run_id, now_rfc3339()], - ) - .map_err(|err| err.to_string())?; - Ok(()) - })(); - - match normalized { - Ok(()) => conn.execute_batch("COMMIT")?, - Err(error) => { - let _ = conn.execute_batch("ROLLBACK"); - warn!( - org_run_id = %run_id, - error = %error, - "deferring corrupt Agent Org task board dependency normalization" - ); - } - } - } + stmt.execute(params![&blocked_by_json, &task.org_run_id, &task.id,]) + .map_err(|err| err.to_string())?; } Ok(()) } -/// Preflight historical rows without deserializing their JSON. -pub(super) fn run_is_safe_for_dependency_normalization( +/// Preflight canonical rows without deserializing their JSON. +pub(super) fn run_is_safe_for_operational_projection( conn: &rusqlite::Connection, run_id: &str, ) -> Result { let count: i64 = conn .query_row( - "SELECT COUNT(*) FROM agent_org_runtime_tasks WHERE org_run_id=?1", + "SELECT COUNT(*) FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND status IN ('pending','in_progress')", params![run_id], |row| row.get(0), ) .map_err(|err| err.to_string())?; - if count > TASK_RUN_MAX_TASKS as i64 { + if count > TASK_RUN_MAX_OPEN_TASKS as i64 { return Ok(false); } let predicate = super::super::corrupt_task_row_predicate_sql(); @@ -163,18 +70,34 @@ pub(super) fn run_is_safe_for_dependency_normalization( crate::coordination::agent_org_payload_limits::TASK_DEPENDENCY_JSON_MAX_BYTES; let metadata_max = crate::coordination::agent_org_payload_limits::TASK_METADATA_MAX_BYTES; let sql = format!( - "SELECT COALESCE(SUM(CASE WHEN {predicate} THEN 1 ELSE 0 END),0) + "WITH operational_ids(id) AS ( + SELECT id + FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND status IN ('pending','in_progress') + UNION + SELECT CAST(edge.value AS TEXT) + FROM agent_org_runtime_tasks open_task, + json_each( + CASE WHEN json_valid(open_task.blocked_by_json) + THEN open_task.blocked_by_json ELSE '[]' END + ) edge + WHERE open_task.org_run_id=?1 + AND open_task.status IN ('pending','in_progress') + AND edge.type='text' + ) + SELECT COALESCE(SUM(CASE WHEN {predicate} THEN 1 ELSE 0 END),0) FROM ( SELECT id, subject, description, active_form, owner, status, - created_at, updated_at, - CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_max} - THEN blocks_json ELSE '!' END AS blocks_json, + execution_mode, output_json, failure_reason_json, cancel_reason_json, + created_by_participant_id, source_turn_intent_id, + originating_message_id, replaces_task_id, created_at, updated_at, CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_max} THEN blocked_by_json ELSE '!' END AS blocked_by_json, CASE WHEN metadata_json IS NULL OR length(CAST(metadata_json AS BLOB))<={metadata_max} THEN metadata_json ELSE '!' END AS metadata_json - FROM agent_org_runtime_tasks WHERE org_run_id=?1 + FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id IN (SELECT id FROM operational_ids) ) AS bounded_tasks" ); let corrupt: i64 = conn diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs new file mode 100644 index 0000000000..2853351f5c --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs @@ -0,0 +1,746 @@ +//! Canonical PR4 Task state machine. Every method validates its typed actor +//! against persisted Turn context after beginning the IMMEDIATE transaction. + +use database::db::{get_connection, with_sessions_writer}; +use rusqlite::{params, OptionalExtension}; + +use crate::coordination::agent_org_payload_limits::{ + validate_task_dependency_ids, validate_task_identifier, +}; + +use super::super::actor::TaskActorAudit; +use super::super::helpers::{ + encode_json_array, encode_metadata, encode_optional_json, insert_task_history_event_as, + list_tasks_with_conn, now_rfc3339, row_to_task, SELECT_COLUMNS, +}; +use super::super::{ + task_dependency_closure, CreatePendingTaskParams, PendingTaskGraphPatch, Task, + TaskCreateSchedulingPolicy, TaskGraphWriterAdmin, TaskMutationOutcome, TaskOutput, + TaskOutputInput, TaskOwnerExecution, TaskStatus, TaskTerminalReason, TASK_EVENT_CREATED, + TASK_EVENT_UPDATED, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, TASK_MUTATION_CONFLICT_ERROR, + TASK_TERMINAL_IMMUTABLE_ERROR, +}; +use super::dependencies::canonicalize_dependencies; +use super::validation::{ + ensure_task_run_capacity, validate_task_model_invariants, validate_task_text_fields, +}; +use super::AgentOrgTaskStore; + +impl AgentOrgTaskStore { + pub fn create_pending_with_transactional_effects( + actor: TaskGraphWriterAdmin, + params: CreatePendingTaskParams, + scheduling_policy: TaskCreateSchedulingPolicy, + effects: impl FnOnce(&rusqlite::Connection, &Task, &[Task]) -> Result, + ) -> Result<(Task, T), String> { + validate_create_params(¶ms)?; + let run_id = params.org_run_id.clone(); + let (task, effect) = with_sessions_writer(|| -> Result<(Task, T), String> { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let audit = actor.validate(&tx, &run_id)?; + let mut tasks = list_tasks_with_conn(&tx, &run_id)?; + ensure_task_run_capacity(tasks.iter().filter(|task| task.status.is_open()).count(), 1)?; + let now = now_rfc3339(); + let task = pending_task_from_params(params, &audit, &now)?; + validate_task_model_invariants(&tx, &task)?; + validate_replacement_reference(&tx, &task)?; + tasks.push(task); + canonicalize_dependencies(&mut tasks, &run_id)?; + let task = tasks + .last() + .cloned() + .expect("candidate graph includes newly-created task"); + enforce_scheduling_policy(&task, &tasks, scheduling_policy)?; + insert_task_row(&tx, &task)?; + insert_task_history_event_as( + &tx, + &run_id, + &task.id, + TASK_EVENT_CREATED, + None, + &task, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; + let effect = effects(&tx, &task, &tasks)?; + tx.commit().map_err(|error| error.to_string())?; + Ok((task, effect)) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + Ok((task, effect)) + } + + pub fn create_pending_batch_with_transactional_effects( + actor: TaskGraphWriterAdmin, + params_list: Vec, + allow_parallel_with_existing_open_tasks: bool, + effects: impl FnOnce(&rusqlite::Connection, &[Task], &[Task]) -> Result, + ) -> Result<(Vec, T), String> { + if params_list.is_empty() { + return Err("task graph must contain at least one task".to_string()); + } + for params in ¶ms_list { + validate_create_params(params)?; + } + let run_id = params_list[0].org_run_id.clone(); + if params_list.iter().any(|params| params.org_run_id != run_id) { + return Err("every task in a graph must belong to the same org run".to_string()); + } + let (created, effect) = with_sessions_writer(|| -> Result<(Vec, T), String> { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let audit = actor.validate(&tx, &run_id)?; + let mut tasks = list_tasks_with_conn(&tx, &run_id)?; + let existing_count = tasks.len(); + ensure_task_run_capacity( + tasks.iter().filter(|task| task.status.is_open()).count(), + params_list.len(), + )?; + let now = now_rfc3339(); + let mut ids = tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(); + for params in ¶ms_list { + if !ids.insert(params.id.as_str()) { + return Err(format!("duplicate task id: {}", params.id)); + } + } + for params in params_list { + let task = pending_task_from_params(params, &audit, &now)?; + validate_task_model_invariants(&tx, &task)?; + validate_replacement_reference(&tx, &task)?; + tasks.push(task); + } + canonicalize_dependencies(&mut tasks, &run_id)?; + if !allow_parallel_with_existing_open_tasks { + let referenced = tasks[existing_count..] + .iter() + .flat_map(|task| task.blocked_by.iter()) + .cloned() + .collect::>(); + let covered = task_dependency_closure(&referenced, &tasks[..existing_count]); + let omitted = tasks[..existing_count] + .iter() + .filter(|task| task.status.is_open() && !covered.contains(&task.id)) + .map(|task| task.id.clone()) + .collect::>(); + if !omitted.is_empty() { + return Err(format!( + "{TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR}:{}", + omitted.join(",") + )); + } + } + let created = tasks[existing_count..].to_vec(); + for task in &created { + insert_task_row(&tx, task)?; + insert_task_history_event_as( + &tx, + &run_id, + &task.id, + TASK_EVENT_CREATED, + None, + task, + &audit, + )?; + } + crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; + let effect = effects(&tx, &created, &tasks)?; + tx.commit().map_err(|error| error.to_string())?; + Ok((created, effect)) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + Ok((created, effect)) + } + + pub fn patch_pending_with_transactional_effects( + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, + expected_updated_at: &str, + patch: PendingTaskGraphPatch, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + if let Some(blocked_by) = patch.blocked_by.as_ref() { + validate_task_dependency_ids("blocked_by", blocked_by)?; + } + let run_id = org_run_id.to_string(); + let (outcome, effect) = with_sessions_writer(|| -> Result<_, String> { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let audit = actor.validate(&tx, &run_id)?; + let mut tasks = list_tasks_with_conn(&tx, &run_id)?; + let index = tasks + .iter() + .position(|task| task.id == task_id) + .ok_or_else(|| format!("task_not_found: {task_id} in run {run_id}"))?; + let previous = tasks[index].clone(); + if previous.updated_at != expected_updated_at { + return Err(format!( + "{TASK_MUTATION_CONFLICT_ERROR}: task {task_id} changed after authorization" + )); + } + if previous.status != TaskStatus::Pending { + return Err(format!( + "task_graph_edit_requires_pending: task {task_id} is {}", + previous.status.as_wire() + )); + } + let task = &mut tasks[index]; + if let Some(subject) = patch.subject { + task.subject = subject; + } + if let Some(description) = patch.description { + task.description = description; + } + if let Some(active_form) = patch.active_form { + task.active_form = active_form; + } + if let Some(owner) = patch.owner { + task.owner = owner; + } + if let Some(execution_mode) = patch.execution_mode { + task.execution_mode = execution_mode; + } + if let Some(blocked_by) = patch.blocked_by { + task.blocked_by = blocked_by; + } + if let Some(metadata) = patch.metadata { + task.metadata = metadata; + } + task.updated_at = now_rfc3339(); + validate_task_model_invariants(&tx, task)?; + canonicalize_dependencies(&mut tasks, &run_id)?; + let current = tasks[index].clone(); + update_task_row(&tx, ¤t)?; + insert_task_history_event_as( + &tx, + &run_id, + task_id, + TASK_EVENT_UPDATED, + Some(&previous), + ¤t, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; + let outcome = mutation_outcome(previous, current, &tasks); + let effect = effects(&tx, &outcome, &tasks)?; + tx.commit().map_err(|error| error.to_string())?; + Ok((outcome, effect)) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + Ok((outcome, effect)) + } + + pub fn cancel_with_transactional_effects( + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, + expected_updated_at: &str, + reason: TaskTerminalReason, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + if reason.code.starts_with("system.") { + return Err( + "system.* cancel reason codes are reserved for system recovery".to_string(), + ); + } + mutate_lifecycle( + org_run_id, + task_id, + Some(expected_updated_at), + |tx, _previous| actor.validate(tx, org_run_id), + move |task, _audit| { + if task.status.is_terminal() { + return Err(format!( + "{TASK_TERMINAL_IMMUTABLE_ERROR}: task {} is {}", + task.id, + task.status.as_wire() + )); + } + task.status = TaskStatus::Cancelled; + task.cancel_reason = Some(reason); + Ok(()) + }, + effects, + ) + } + + pub fn cancel_and_replace_with_transactional_effects( + actor: TaskGraphWriterAdmin, + org_run_id: &str, + task_id: &str, + expected_updated_at: &str, + reason: TaskTerminalReason, + mut replacement: CreatePendingTaskParams, + effects: impl FnOnce( + &rusqlite::Connection, + &TaskMutationOutcome, + &Task, + &[Task], + ) -> Result, + ) -> Result<(TaskMutationOutcome, Task, T), String> { + if reason.code.starts_with("system.") { + return Err( + "system.* cancel reason codes are reserved for system recovery".to_string(), + ); + } + validate_create_params(&replacement)?; + if replacement.org_run_id != org_run_id { + return Err("replacement must belong to the same org run".to_string()); + } + replacement.replaces_task_id = Some(task_id.to_string()); + let run_id = org_run_id.to_string(); + let old_task_id = task_id.to_string(); + let (outcome, replacement_task, effect) = with_sessions_writer(|| -> Result<_, String> { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let audit = actor.validate(&tx, &run_id)?; + let mut tasks = list_tasks_with_conn(&tx, &run_id)?; + let old_index = tasks + .iter() + .position(|task| task.id == old_task_id) + .ok_or_else(|| format!("task_not_found: {old_task_id} in run {run_id}"))?; + let previous = tasks[old_index].clone(); + if previous.updated_at != expected_updated_at { + return Err(format!( + "{TASK_MUTATION_CONFLICT_ERROR}: task {old_task_id} changed after authorization" + )); + } + if previous.status.is_terminal() { + return Err(format!( + "{TASK_TERMINAL_IMMUTABLE_ERROR}: task {old_task_id} is {}", + previous.status.as_wire() + )); + } + ensure_task_run_capacity( + tasks + .iter() + .filter(|task| task.status.is_open()) + .count() + .saturating_sub(1), + 1, + )?; + let now = now_rfc3339(); + tasks[old_index].status = TaskStatus::Cancelled; + tasks[old_index].cancel_reason = Some(reason); + tasks[old_index].updated_at = now.clone(); + validate_task_model_invariants(&tx, &tasks[old_index])?; + let cancelled = tasks[old_index].clone(); + update_task_row(&tx, &cancelled)?; + + let replacement_task = pending_task_from_params(replacement, &audit, &now)?; + validate_task_model_invariants(&tx, &replacement_task)?; + tasks.push(replacement_task); + canonicalize_dependencies(&mut tasks, &run_id)?; + let replacement_task = tasks + .last() + .cloned() + .expect("replacement remains in candidate graph"); + insert_task_row(&tx, &replacement_task)?; + insert_task_history_event_as( + &tx, + &run_id, + &old_task_id, + TASK_EVENT_UPDATED, + Some(&previous), + &cancelled, + &audit, + )?; + insert_task_history_event_as( + &tx, + &run_id, + &replacement_task.id, + TASK_EVENT_CREATED, + None, + &replacement_task, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; + let outcome = mutation_outcome(previous, cancelled, &tasks); + let effect = effects(&tx, &outcome, &replacement_task, &tasks)?; + tx.commit().map_err(|error| error.to_string())?; + Ok((outcome, replacement_task, effect)) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + Ok((outcome, replacement_task, effect)) + } + + pub fn owner_start_with_transactional_effects( + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + mutate_lifecycle( + org_run_id, + task_id, + None, + |tx, _previous| actor.validate(tx, org_run_id, task_id), + |task, audit| { + if task.status != TaskStatus::Pending { + return Err(format!( + "task_owner_start_requires_pending: task {} is {}", + task.id, + task.status.as_wire() + )); + } + if task.owner.as_deref() != Some(audit.participant_id.as_str()) { + return Err("task_owner_mismatch".to_string()); + } + task.status = TaskStatus::InProgress; + Ok(()) + }, + effects, + ) + } + + pub fn owner_complete_with_transactional_effects( + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + output: TaskOutputInput, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + mutate_lifecycle( + org_run_id, + task_id, + None, + |tx, _previous| actor.validate(tx, org_run_id, task_id), + move |task, audit| { + require_in_progress_owner(task, audit)?; + task.status = TaskStatus::Completed; + task.output = Some(TaskOutput { + summary: output.summary, + content: output.content, + artifact_ids: output.artifact_ids, + produced_by_member_id: audit.participant_id.clone(), + produced_at: now_rfc3339(), + }); + Ok(()) + }, + effects, + ) + } + + pub fn owner_fail_with_transactional_effects( + actor: TaskOwnerExecution, + org_run_id: &str, + task_id: &str, + reason: TaskTerminalReason, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, + ) -> Result<(TaskMutationOutcome, T), String> { + if reason.code.starts_with("system.") { + return Err( + "system.* failure reason codes are reserved for system recovery".to_string(), + ); + } + mutate_lifecycle( + org_run_id, + task_id, + None, + |tx, _previous| actor.validate(tx, org_run_id, task_id), + move |task, audit| { + require_in_progress_owner(task, audit)?; + task.status = TaskStatus::Failed; + task.failure_reason = Some(reason); + Ok(()) + }, + effects, + ) + } +} + +fn validate_create_params(params: &CreatePendingTaskParams) -> Result<(), String> { + validate_task_identifier("task id", ¶ms.id)?; + validate_task_dependency_ids("blocked_by", ¶ms.blocked_by)?; + validate_task_text_fields( + ¶ms.subject, + ¶ms.description, + params.active_form.as_deref(), + )?; + if params.org_run_id.trim().is_empty() { + return Err("org_run_id must be non-empty".to_string()); + } + Ok(()) +} + +fn pending_task_from_params( + params: CreatePendingTaskParams, + audit: &TaskActorAudit, + now: &str, +) -> Result { + let source_turn_intent_id = audit + .turn_intent_id + .clone() + .ok_or_else(|| "graph writer source turn is required".to_string())?; + Ok(Task { + id: params.id, + org_run_id: params.org_run_id, + subject: params.subject, + description: params.description, + active_form: params.active_form, + owner: params.owner, + status: TaskStatus::Pending, + execution_mode: params.execution_mode, + blocks: Vec::new(), + blocked_by: params.blocked_by, + metadata: params.metadata, + output: None, + failure_reason: None, + cancel_reason: None, + created_by_participant_id: audit.participant_id.clone(), + source_turn_intent_id, + originating_message_id: params.originating_message_id, + replaces_task_id: params.replaces_task_id, + created_at: now.to_string(), + updated_at: now.to_string(), + }) +} + +fn validate_replacement_reference(tx: &rusqlite::Connection, task: &Task) -> Result<(), String> { + let Some(replaced_id) = task.replaces_task_id.as_deref() else { + return Ok(()); + }; + let replaced_status: Option = tx + .query_row( + "SELECT status FROM agent_org_runtime_tasks WHERE org_run_id=?1 AND id=?2", + params![&task.org_run_id, replaced_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + if replaced_status.as_deref() != Some(TaskStatus::Cancelled.as_wire()) { + return Err("replacement must reference a cancelled task in the same run".to_string()); + } + Ok(()) +} + +fn enforce_scheduling_policy( + task: &Task, + tasks: &[Task], + policy: TaskCreateSchedulingPolicy, +) -> Result<(), String> { + if policy.allow_parallel_with_unlisted_open_tasks { + return Ok(()); + } + let covered = task_dependency_closure(&task.blocked_by, tasks); + let omitted = tasks + .iter() + .filter(|candidate| candidate.id != task.id && candidate.status.is_open()) + .filter(|candidate| !covered.contains(&candidate.id)) + .map(|candidate| candidate.id.clone()) + .collect::>(); + if omitted.is_empty() { + Ok(()) + } else { + Err(format!( + "{TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR}:{}", + omitted.join(",") + )) + } +} + +fn insert_task_row(tx: &rusqlite::Connection, task: &Task) -> Result<(), String> { + let blocked_by_json = encode_json_array(&task.blocked_by)?; + let metadata_json = encode_metadata(task.metadata.as_ref())?; + let output_json = encode_optional_json("task output", task.output.as_ref())?; + let failure_reason_json = + encode_optional_json("task failure reason", task.failure_reason.as_ref())?; + let cancel_reason_json = + encode_optional_json("task cancel reason", task.cancel_reason.as_ref())?; + tx.execute( + "INSERT INTO agent_org_runtime_tasks ( + id, org_run_id, subject, description, active_form, owner, status, + execution_mode, blocked_by_json, metadata_json, output_json, + failure_reason_json, cancel_reason_json, created_by_participant_id, + source_turn_intent_id, originating_message_id, replaces_task_id, + created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, + ?13, ?14, ?15, ?16, ?17, ?18, ?19)", + params![ + &task.id, + &task.org_run_id, + &task.subject, + &task.description, + task.active_form.as_deref(), + task.owner.as_deref(), + task.status.as_wire(), + task.execution_mode.as_wire(), + &blocked_by_json, + metadata_json.as_deref(), + output_json.as_deref(), + failure_reason_json.as_deref(), + cancel_reason_json.as_deref(), + &task.created_by_participant_id, + &task.source_turn_intent_id, + task.originating_message_id.as_deref(), + task.replaces_task_id.as_deref(), + &task.created_at, + &task.updated_at, + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn update_task_row(tx: &rusqlite::Connection, task: &Task) -> Result<(), String> { + let blocked_by_json = encode_json_array(&task.blocked_by)?; + let metadata_json = encode_metadata(task.metadata.as_ref())?; + let output_json = encode_optional_json("task output", task.output.as_ref())?; + let failure_reason_json = + encode_optional_json("task failure reason", task.failure_reason.as_ref())?; + let cancel_reason_json = + encode_optional_json("task cancel reason", task.cancel_reason.as_ref())?; + let changed = tx + .execute( + "UPDATE agent_org_runtime_tasks SET + subject=?1, description=?2, active_form=?3, owner=?4, status=?5, + execution_mode=?6, blocked_by_json=?7, metadata_json=?8, + output_json=?9, failure_reason_json=?10, cancel_reason_json=?11, + updated_at=?12 + WHERE org_run_id=?13 AND id=?14", + params![ + &task.subject, + &task.description, + task.active_form.as_deref(), + task.owner.as_deref(), + task.status.as_wire(), + task.execution_mode.as_wire(), + &blocked_by_json, + metadata_json.as_deref(), + output_json.as_deref(), + failure_reason_json.as_deref(), + cancel_reason_json.as_deref(), + &task.updated_at, + &task.org_run_id, + &task.id, + ], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err(format!("task_not_found: {}", task.id)); + } + Ok(()) +} + +fn mutate_lifecycle( + org_run_id: &str, + task_id: &str, + expected_updated_at: Option<&str>, + validate_actor: impl FnOnce(&rusqlite::Connection, &Task) -> Result, + mutation: impl FnOnce(&mut Task, &TaskActorAudit) -> Result<(), String>, + effects: impl FnOnce(&rusqlite::Connection, &TaskMutationOutcome, &[Task]) -> Result, +) -> Result<(TaskMutationOutcome, T), String> { + let run_id = org_run_id.to_string(); + let task_id = task_id.to_string(); + let (outcome, effect) = with_sessions_writer(|| -> Result<_, String> { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let sql = format!( + "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id=?2" + ); + let previous: Task = tx + .query_row(&sql, params![&run_id, &task_id], row_to_task) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("task_not_found: {task_id} in run {run_id}"))?; + if expected_updated_at.is_some_and(|expected| expected != previous.updated_at) { + return Err(format!( + "{TASK_MUTATION_CONFLICT_ERROR}: task {task_id} changed after authorization" + )); + } + let audit = validate_actor(&tx, &previous)?; + let mut tasks = list_tasks_with_conn(&tx, &run_id)?; + let index = tasks + .iter() + .position(|task| task.id == task_id) + .expect("task selected above remains in same transaction"); + if previous.status == TaskStatus::Pending { + let graph = super::super::TaskGraphIndex::new(&tasks); + let starting = matches!( + audit.kind, + super::super::actor::TaskActorKind::OwnerExecution + ); + if starting && !graph.is_ready(&previous) { + return Err("task_dependencies_not_completed".to_string()); + } + } + let task = &mut tasks[index]; + mutation(task, &audit)?; + task.updated_at = now_rfc3339(); + validate_task_model_invariants(&tx, task)?; + let current = task.clone(); + update_task_row(&tx, ¤t)?; + insert_task_history_event_as( + &tx, + &run_id, + &task_id, + TASK_EVENT_UPDATED, + Some(&previous), + ¤t, + &audit, + )?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, &run_id)?; + let outcome = mutation_outcome(previous, current, &tasks); + let effect = effects(&tx, &outcome, &tasks)?; + tx.commit().map_err(|error| error.to_string())?; + Ok((outcome, effect)) + })?; + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(&run_id); + Ok((outcome, effect)) +} + +fn require_in_progress_owner(task: &Task, audit: &TaskActorAudit) -> Result<(), String> { + if task.status != TaskStatus::InProgress { + return Err(format!( + "task_owner_terminal_requires_in_progress: task {} is {}", + task.id, + task.status.as_wire() + )); + } + if task.owner.as_deref() != Some(audit.participant_id.as_str()) { + return Err("task_owner_mismatch".to_string()); + } + Ok(()) +} + +fn mutation_outcome(previous: Task, current: Task, tasks: &[Task]) -> TaskMutationOutcome { + let current_graph = super::super::TaskGraphIndex::new(tasks); + let mut previous_tasks = tasks.to_vec(); + if let Some(task) = previous_tasks + .iter_mut() + .find(|task| task.id == previous.id) + { + *task = previous.clone(); + } + let previous_graph = super::super::TaskGraphIndex::new(&previous_tasks); + let was_ready = previous.owner.is_some() + && previous.status == TaskStatus::Pending + && previous_graph.is_ready(&previous); + let is_ready = current.owner.is_some() + && current.status == TaskStatus::Pending + && current_graph.is_ready(¤t); + TaskMutationOutcome { + owner_changed: previous.owner != current.owner, + status_changed: previous.status != current.status, + became_completed: previous.status != TaskStatus::Completed + && current.status == TaskStatus::Completed, + became_ready: !was_ready && is_ready, + previous, + current, + } +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/mod.rs index 87228aee73..ee09d53142 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/mod.rs @@ -5,40 +5,42 @@ //! //! - [`validation`] — pre-write guards (run mutability, text limits, persistence //! invariants). -//! - [`dependencies`] — dependency-graph canonicalization and the persisted -//! `blocks`/`blocked_by` projection. -//! - [`create`] — single and batched task creation. +//! - [`dependencies`] — canonical `blocked_by` graph validation and derived +//! reverse-edge projection. +//! - [`fsm`] — production actor-gated creation and lifecycle transitions. +//! - [`create`] — test-only compatibility fixtures for older Store tests. //! - [`read`] — full-row reads, the operational projection, summary pages, and //! previews. -//! - [`update`] — partial updates and plan-completion. -//! - [`delete`] — deletion with dependent-guard. +//! - [`update`] — production plan-completion plus test-only compatibility +//! updates. +//! - [`delete`] — test-only physical deletion fixtures. //! - [`requeue`] — owner-scoped shutdown disposal and failure requeue. //! //! Every method hangs off [`AgentOrgTaskStore`] via inherent `impl` blocks split //! across those submodules, so the public API //! (`agent_org_tasks::AgentOrgTaskStore`) is unchanged. +mod annotations; +#[cfg(test)] mod create; +#[cfg(test)] mod delete; mod dependencies; +mod fsm; mod read; mod requeue; mod update; mod validation; -pub(super) fn normalize_legacy_dependency_rows( - conn: &rusqlite::Connection, -) -> rusqlite::Result<()> { - dependencies::normalize_legacy_dependency_rows(conn) -} - // Names referenced with an explicit `super::` prefix inside the submodule // bodies below. Re-binding them here keeps those references verbatim: from a // submodule, `super::` resolves to this module. use super::{ - eligible_member_ids, TaskExecutionMode, TaskOutput, TASK_COMPLETED_IMMUTABLE_ERROR, TASK_METADATA_ELIGIBLE_MEMBER_IDS, TASK_METADATA_EXECUTION_MODE, TASK_METADATA_OUTPUT, TASK_METADATA_REQUIRED_ROLE, TASK_MUTATION_CONFLICT_ERROR, }; +#[cfg(test)] +use super::{TaskExecutionMode, TaskOutput, TASK_COMPLETED_IMMUTABLE_ERROR}; + pub struct AgentOrgTaskStore; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/read.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/read.rs index 6bc5753453..b27d69fbbd 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/read.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/read.rs @@ -1,7 +1,7 @@ -//! Read paths: full-row `get`/`list`, the narrow operational projection used by -//! recovery and prompt snapshots, the compact byte-budgeted summary page, the -//! open-task-id preview, and (test-only) history listing. +//! Bounded Task reads. History results and annotations stay behind explicit +//! detail/page calls; operational polling never deserializes terminal output. +use base64::Engine; use database::db::get_connection; use rusqlite::{params, OptionalExtension}; @@ -17,38 +17,74 @@ use super::super::helpers::{list_tasks_with_conn, row_to_task, SELECT_COLUMNS}; #[cfg(test)] use super::super::TaskHistoryEvent; use super::super::{ - Task, TaskExecutionMode, TaskGraphIndex, TaskOutputSummary, TaskStatus, TaskSummary, - TaskSummaryPage, TASK_METADATA_ELIGIBLE_MEMBER_IDS, + Task, TaskExecutionMode, TaskGraphIndex, TaskOutputSummary, TaskPageBucket, TaskPageDirection, + TaskStatus, TaskSummary, TaskSummaryPage, TaskTerminalReason, + TASK_METADATA_ELIGIBLE_MEMBER_IDS, }; use super::validation::ensure_task_rows_safe_for_operational_projection; use super::AgentOrgTaskStore; -fn decode_summary_array(raw: String, column: usize) -> rusqlite::Result> { +fn decode_array(raw: String, column: usize) -> rusqlite::Result> { serde_json::from_str(&raw).map_err(|error| { rusqlite::Error::FromSqlConversionFailure(column, rusqlite::types::Type::Text, error.into()) }) } -fn task_summary_scalar_predicate_sql(alias: &str) -> String { - use crate::coordination::agent_org_payload_limits as limits; +fn decode_optional_json( + raw: Option, + column: usize, +) -> rusqlite::Result> { + raw.map(|raw| { + serde_json::from_str(&raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + error.into(), + ) + }) + }) + .transpose() +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct TaskPageCursor { + version: u8, + bucket: TaskPageBucket, + status: Option, + created_at: String, + id: String, +} - format!( - "{alias}.status IN ('pending','in_progress','completed') - AND trim({alias}.id)<>'' - AND {alias}.id=trim({alias}.id) - AND length({alias}.id)<={} - AND length(CAST({alias}.id AS BLOB))<={} - AND length({alias}.created_at)<={} - AND length(CAST({alias}.created_at AS BLOB))<={} - AND length({alias}.updated_at)<={} - AND length(CAST({alias}.updated_at AS BLOB))<={}", - limits::TASK_IDENTIFIER_MAX_CHARS, - limits::TASK_IDENTIFIER_MAX_BYTES, - limits::RFC3339_TIMESTAMP_MAX_CHARS, - limits::RFC3339_TIMESTAMP_MAX_BYTES, - limits::RFC3339_TIMESTAMP_MAX_CHARS, - limits::RFC3339_TIMESTAMP_MAX_BYTES, - ) +fn encode_task_page_cursor( + bucket: TaskPageBucket, + status: Option, + task: &TaskSummary, +) -> Result { + let raw = serde_json::to_vec(&TaskPageCursor { + version: 1, + bucket, + status, + created_at: task.created_at.clone(), + id: task.id.clone(), + }) + .map_err(|error| error.to_string())?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw)) +} + +fn decode_task_page_cursor( + raw: &str, + bucket: TaskPageBucket, + status: Option, +) -> Result<(String, String), String> { + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(raw) + .map_err(|_| "invalid_task_page_cursor".to_string())?; + let cursor: TaskPageCursor = + serde_json::from_slice(&bytes).map_err(|_| "invalid_task_page_cursor".to_string())?; + if cursor.version != 1 || cursor.bucket != bucket || cursor.status != status { + return Err("task_page_cursor_filter_mismatch".to_string()); + } + Ok((cursor.created_at, cursor.id)) } impl AgentOrgTaskStore { @@ -60,27 +96,44 @@ impl AgentOrgTaskStore { } pub fn get(org_run_id: &str, task_id: &str) -> Result, String> { - let conn = get_connection().map_err(|err| err.to_string())?; + let conn = get_connection().map_err(|error| error.to_string())?; let sql = format!( "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks WHERE org_run_id=?1 AND id=?2" ); - conn.query_row(&sql, params![org_run_id, task_id], row_to_task) + let mut task = conn + .query_row(&sql, params![org_run_id, task_id], row_to_task) .optional() - .map_err(|err| err.to_string()) + .map_err(|error| error.to_string())?; + if let Some(task) = task.as_mut() { + // `blocked_by_json` is the only persisted edge. A detail read is + // the explicit, bounded-on-demand place where callers can ask + // for the complete reverse projection omitted from list pages. + let mut stmt = conn + .prepare( + "SELECT downstream.id + FROM agent_org_runtime_tasks downstream, + json_each(downstream.blocked_by_json) edge + WHERE downstream.org_run_id=?1 AND edge.value=?2 + ORDER BY downstream.created_at ASC, downstream.id ASC", + ) + .map_err(|error| error.to_string())?; + task.blocks = stmt + .query_map(params![org_run_id, task_id], |row| row.get::<_, String>(0)) + .map_err(|error| error.to_string())? + .map(|row| row.map_err(|error| error.to_string())) + .collect::, _>>()?; + } + Ok(task) } pub fn list(org_run_id: &str) -> Result, String> { - let conn = get_connection().map_err(|err| err.to_string())?; + let conn = get_connection().map_err(|error| error.to_string())?; list_tasks_with_conn(&conn, org_run_id) } - /// Load the narrow task fields needed by recovery and per-turn prompt - /// snapshots. Full descriptions and TaskOutput metadata intentionally stay - /// behind `get`/`task_get`; a periodic watchdog or model prompt must not - /// deserialize up to 64 KiB of result metadata for every task. pub fn list_operational(org_run_id: &str) -> Result, String> { - let conn = get_connection().map_err(|err| err.to_string())?; + let conn = get_connection().map_err(|error| error.to_string())?; Self::list_operational_with_connection(&conn, org_run_id) } @@ -92,85 +145,89 @@ impl AgentOrgTaskStore { Self::list_operational_after_validated_with_connection(conn, org_run_id) } - /// Internal projection for a caller that has already run the shared - /// Quiescence/corruption assessment in the same SQLite read snapshot. - /// Keeping this separate avoids evaluating the expensive JSON integrity - /// predicate twice per watchdog tick while the public wrapper remains - /// fail-closed for every other caller. pub(crate) fn list_operational_after_validated_with_connection( conn: &rusqlite::Connection, org_run_id: &str, ) -> Result, String> { let mut stmt = conn .prepare( - "SELECT task.id, - task.org_run_id, - substr(task.subject, 1, 200), - task.owner, - task.status, - task.blocks_json, - task.blocked_by_json, - CASE WHEN json_valid(task.metadata_json) - AND json_type(task.metadata_json, '$.eligible_member_ids')='array' - THEN json_extract(task.metadata_json, '$.eligible_member_ids') - ELSE '[]' END, - task.created_at, - task.updated_at - FROM ( - SELECT id, org_run_id, subject, description, active_form, - owner, status, created_at, updated_at, - CASE WHEN length(CAST(blocks_json AS BLOB))<=?2 - THEN blocks_json ELSE '!' END AS blocks_json, - CASE WHEN length(CAST(blocked_by_json AS BLOB))<=?2 - THEN blocked_by_json ELSE '!' END AS blocked_by_json, - CASE WHEN metadata_json IS NULL - OR length(CAST(metadata_json AS BLOB))<=?3 - THEN metadata_json ELSE '!' END AS metadata_json + "WITH operational_ids(id) AS ( + SELECT id FROM agent_org_runtime_tasks - ) task + WHERE org_run_id=?1 AND status IN ('pending','in_progress') + UNION + SELECT CAST(edge.value AS TEXT) + FROM agent_org_runtime_tasks open_task, + json_each( + CASE WHEN json_valid(open_task.blocked_by_json) + THEN open_task.blocked_by_json ELSE '[]' END + ) edge + WHERE open_task.org_run_id=?1 + AND open_task.status IN ('pending','in_progress') + AND edge.type='text' + ) + SELECT task.id, task.org_run_id, substr(task.subject,1,200), + task.owner, task.status, + execution_mode, blocked_by_json, + CASE WHEN task.metadata_json IS NOT NULL + AND json_valid(task.metadata_json) + AND json_type(task.metadata_json,'$.eligible_member_ids')='array' + THEN json_extract(task.metadata_json,'$.eligible_member_ids') + ELSE '[]' END, + task.created_at, task.updated_at + FROM agent_org_runtime_tasks task + JOIN operational_ids ON operational_ids.id=task.id WHERE task.org_run_id=?1 ORDER BY task.created_at ASC, task.id ASC", ) - .map_err(|err| err.to_string())?; + .map_err(|error| error.to_string())?; let rows = stmt - .query_map( - params![ - org_run_id, - crate::coordination::agent_org_payload_limits::TASK_DEPENDENCY_JSON_MAX_BYTES - as i64, - crate::coordination::agent_org_payload_limits::TASK_METADATA_MAX_BYTES as i64, - ], - |row| { - let status_raw: String = row.get(4)?; - let eligible = decode_summary_array(row.get(7)?, 7)?; - let metadata = (!eligible.is_empty()).then( - || serde_json::json!({ (TASK_METADATA_ELIGIBLE_MEMBER_IDS): eligible }), - ); - Ok(Task { - id: row.get(0)?, - org_run_id: row.get(1)?, - subject: row.get(2)?, - description: String::new(), - active_form: None, - owner: row.get(3)?, - status: TaskStatus::from_wire(&status_raw).map_err(|error| { + .query_map(params![org_run_id], |row| { + let status_raw: String = row.get(4)?; + let execution_mode_raw: String = row.get(5)?; + let eligible = decode_array(row.get(7)?, 7)?; + Ok(Task { + id: row.get(0)?, + org_run_id: row.get(1)?, + subject: row.get(2)?, + description: String::new(), + active_form: None, + owner: row.get(3)?, + status: TaskStatus::from_wire(&status_raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + error.into(), + ) + })?, + execution_mode: TaskExecutionMode::from_wire(&execution_mode_raw).map_err( + |error| { rusqlite::Error::FromSqlConversionFailure( - 4, + 5, rusqlite::types::Type::Text, error.into(), ) - })?, - blocks: decode_summary_array(row.get(5)?, 5)?, - blocked_by: decode_summary_array(row.get(6)?, 6)?, - metadata, - created_at: row.get(8)?, - updated_at: row.get(9)?, - }) - }, - ) - .map_err(|err| err.to_string())?; + }, + )?, + blocks: Vec::new(), + blocked_by: decode_array(row.get(6)?, 6)?, + metadata: (!eligible.is_empty()).then( + || serde_json::json!({ (TASK_METADATA_ELIGIBLE_MEMBER_IDS): eligible }), + ), + output: None, + failure_reason: None, + cancel_reason: None, + created_by_participant_id: String::new(), + source_turn_intent_id: String::new(), + originating_message_id: None, + replaces_task_id: None, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) + }) + .map_err(|error| error.to_string())?; let mut tasks = rows - .map(|row| row.map_err(|err| err.to_string())) + .map(|row| row.map_err(|error| error.to_string())) .collect::, _>>()?; TaskGraphIndex::new(&tasks).apply_projection(&mut tasks); Ok(tasks) @@ -183,7 +240,7 @@ impl AgentOrgTaskStore { after_task_id: Option<&str>, limit: usize, ) -> Result { - let conn = get_connection().map_err(|err| err.to_string())?; + let conn = get_connection().map_err(|error| error.to_string())?; Self::list_summary_page_with_connection( &conn, org_run_id, @@ -194,9 +251,6 @@ impl AgentOrgTaskStore { ) } - /// Read one compact task page directly from SQLite. The cursor is first - /// resolved to its `(created_at, id)` tuple, then the page uses that stable - /// pair as its boundary so equal timestamps cannot reorder or skip rows. pub fn list_summary_page_with_connection( conn: &rusqlite::Connection, org_run_id: &str, @@ -205,191 +259,207 @@ impl AgentOrgTaskStore { after_task_id: Option<&str>, limit: usize, ) -> Result { - let bounded_limit = limit.clamp(1, 200); let cursor = after_task_id .map(|task_id| { conn.query_row( - "SELECT created_at, id FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND id=?2 - AND length(id)<=?3 AND length(CAST(id AS BLOB))<=?4 - AND length(created_at)<=?5 - AND length(CAST(created_at AS BLOB))<=?6", - params![ - org_run_id, - task_id, - crate::coordination::agent_org_payload_limits::TASK_IDENTIFIER_MAX_CHARS - as i64, - crate::coordination::agent_org_payload_limits::TASK_IDENTIFIER_MAX_BYTES - as i64, - crate::coordination::agent_org_payload_limits::RFC3339_TIMESTAMP_MAX_CHARS - as i64, - crate::coordination::agent_org_payload_limits::RFC3339_TIMESTAMP_MAX_BYTES - as i64, - ], + "SELECT created_at,id FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id=?2", + params![org_run_id, task_id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), ) .optional() - .map_err(|err| err.to_string())? - .ok_or_else(|| { - format!( - "task_list after_task_id '{task_id}' does not exist or is corrupt in this run" - ) - }) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("task cursor not found in run: {task_id}")) }) .transpose()?; + let mut page = Self::query_summary_page_with_connection( + conn, + org_run_id, + None, + status, + owner, + cursor, + TaskPageDirection::Forward, + limit, + )?; + page.next_cursor = page + .has_more + .then(|| page.tasks.last().map(|task| task.id.clone())) + .flatten(); + page.previous_cursor = None; + Ok(page) + } + + pub fn list_task_page( + org_run_id: &str, + bucket: TaskPageBucket, + status: Option, + cursor: Option<&str>, + direction: TaskPageDirection, + limit: usize, + ) -> Result { + let conn = get_connection().map_err(|error| error.to_string())?; + Self::list_task_page_with_connection( + &conn, org_run_id, bucket, status, cursor, direction, limit, + ) + } + + pub fn list_task_page_with_connection( + conn: &rusqlite::Connection, + org_run_id: &str, + bucket: TaskPageBucket, + status: Option, + cursor: Option<&str>, + direction: TaskPageDirection, + limit: usize, + ) -> Result { + if status.is_some_and(|status| !bucket.accepts(status)) { + return Err("task_page_status_bucket_mismatch".to_string()); + } + let decoded_cursor = cursor + .map(|raw| decode_task_page_cursor(raw, bucket, status)) + .transpose()?; + Self::query_summary_page_with_connection( + conn, + org_run_id, + Some(bucket), + status, + None, + decoded_cursor, + direction, + limit, + ) + } + + #[allow(clippy::too_many_arguments)] + fn query_summary_page_with_connection( + conn: &rusqlite::Connection, + org_run_id: &str, + bucket: Option, + status: Option, + owner: Option<&str>, + cursor: Option<(String, String)>, + direction: TaskPageDirection, + limit: usize, + ) -> Result { + // The legacy model-facing list promises an exact total and a whole- + // board corruption verdict. Current/History UI pages deliberately do + // neither: a long-lived Team can retain substantial history, so those + // reads validate only the at-most `limit + 1` rows they materialize. + if bucket.is_none() { + ensure_task_rows_safe_for_operational_projection(conn, org_run_id)?; + } + let bounded_limit = limit.clamp(1, 200); let (cursor_created_at, cursor_id) = cursor .as_ref() .map(|(created_at, id)| (Some(created_at.as_str()), Some(id.as_str()))) .unwrap_or((None, None)); let status_wire = status.map(|status| status.as_wire()); - - let summary_scalar_predicate = task_summary_scalar_predicate_sql("task"); - let filtered_total_sql = format!( - "SELECT COUNT(*) FROM agent_org_runtime_tasks task - WHERE task.org_run_id=?1 - AND {summary_scalar_predicate} - AND (?2 IS NULL OR task.status=?2) - AND (?3 IS NULL OR task.owner=?3)" - ); - let filtered_total: i64 = conn - .query_row( - &filtered_total_sql, + let bucket_wire = bucket.map(TaskPageBucket::as_wire); + let filtered_total: i64 = if bucket.is_none() { + conn.query_row( + "SELECT COUNT(*) FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND (?2 IS NULL OR status=?2) + AND (?3 IS NULL OR owner=?3)", params![org_run_id, status_wire, owner], |row| row.get(0), ) - .map_err(|err| err.to_string())?; - - let summary_sql = "SELECT - task.id, - substr(task.subject, 1, 200), - substr(task.description, 1, ?6), - CASE WHEN length(task.description) > ?6 THEN 1 ELSE 0 END, - CASE WHEN task.active_form IS NULL THEN NULL - ELSE substr(task.active_form, 1, 1000) END, - task.owner, - task.status, - CASE WHEN json_valid(task.blocks_json) THEN - CASE WHEN json_type(task.blocks_json)='array' THEN - (SELECT json_group_array(value) FROM ( - SELECT value - FROM json_each(task.blocks_json) - WHERE type='text' LIMIT ?8 - )) - ELSE '[]' END - ELSE '[]' END, - CASE WHEN json_valid(task.blocked_by_json) THEN - CASE WHEN json_type(task.blocked_by_json)='array' THEN - (SELECT json_group_array(value) FROM ( - SELECT value - FROM json_each(task.blocked_by_json) - WHERE type='text' LIMIT ?8 - )) - ELSE '[]' END - ELSE '[]' END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.eligible_member_ids')='array' THEN - (SELECT json_group_array(value) FROM ( - SELECT value - FROM json_each(task.metadata_json, '$.eligible_member_ids') - WHERE type='text' LIMIT ?9 - )) - ELSE '[]' END - ELSE '[]' END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.required_role')='text' - THEN substr(json_extract(task.metadata_json, '$.required_role'), 1, 200) - ELSE NULL END - ELSE NULL END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.execution_mode')='text' - THEN substr(json_extract(task.metadata_json, '$.execution_mode'), 1, 20) - ELSE 'build' END - ELSE 'build' END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.output.summary')='text' - THEN substr(json_extract(task.metadata_json, '$.output.summary'), 1, 1000) - ELSE NULL END - ELSE NULL END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.output.artifactIds')='array' THEN - (SELECT json_group_array(value) FROM ( - SELECT value - FROM json_each(task.metadata_json, '$.output.artifactIds') - WHERE type='text' LIMIT ?10 - )) - ELSE '[]' END - ELSE '[]' END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.output.producedByMemberId')='text' - THEN substr(json_extract(task.metadata_json, '$.output.producedByMemberId'), 1, 1000) - ELSE NULL END - ELSE NULL END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.output.producedAt')='text' - THEN substr(json_extract(task.metadata_json, '$.output.producedAt'), 1, 100) - ELSE NULL END - ELSE NULL END, - CASE WHEN json_valid(task.metadata_json) THEN - CASE WHEN json_type(task.metadata_json, '$.output.content')='text' THEN 1 ELSE 0 END - ELSE 0 END, - task.created_at, - task.updated_at, - CASE WHEN json_valid(task.blocks_json) - AND json_type(task.blocks_json)='array' - THEN (SELECT COUNT(*) FROM json_each(task.blocks_json) WHERE type='text') - ELSE 0 END, - CASE WHEN json_valid(task.blocked_by_json) - AND json_type(task.blocked_by_json)='array' - THEN (SELECT COUNT(*) FROM json_each(task.blocked_by_json) WHERE type='text') - ELSE 0 END, - CASE WHEN json_valid(task.metadata_json) - AND json_type(task.metadata_json, '$.eligible_member_ids')='array' - THEN (SELECT COUNT(*) FROM json_each(task.metadata_json, '$.eligible_member_ids') WHERE type='text') - ELSE 0 END, - CASE WHEN json_valid(task.metadata_json) - AND json_type(task.metadata_json, '$.output.artifactIds')='array' - THEN (SELECT COUNT(*) FROM json_each(task.metadata_json, '$.output.artifactIds') WHERE type='text') - ELSE 0 END - FROM ( - SELECT id, org_run_id, subject, description, active_form, - CASE WHEN owner IS NULL THEN NULL - WHEN trim(owner)<>'' - AND length(owner)<={id_chars} - AND length(CAST(owner AS BLOB))<={id_bytes} - THEN owner ELSE NULL END AS owner, - status, created_at, updated_at, - CASE WHEN length(CAST(blocks_json AS BLOB))<=?11 - THEN blocks_json ELSE '!' END AS blocks_json, - CASE WHEN length(CAST(blocked_by_json AS BLOB))<=?11 - THEN blocked_by_json ELSE '!' END AS blocked_by_json, - CASE WHEN metadata_json IS NULL - OR length(CAST(metadata_json AS BLOB))<=?12 - THEN metadata_json ELSE '!' END AS metadata_json - FROM agent_org_runtime_tasks - ) task - WHERE task.org_run_id=?1 - AND {summary_scalar_predicate} - AND (?2 IS NULL OR task.status=?2) - AND (?3 IS NULL OR task.owner=?3) - AND ( - ?4 IS NULL - OR task.created_at > ?4 - OR (task.created_at = ?4 AND task.id > ?5) - ) - ORDER BY task.created_at ASC, task.id ASC - LIMIT ?7" - .replace("{id_chars}", &crate::coordination::agent_org_payload_limits::TASK_IDENTIFIER_MAX_CHARS.to_string()) - .replace("{id_bytes}", &crate::coordination::agent_org_payload_limits::TASK_IDENTIFIER_MAX_BYTES.to_string()) - .replace("{timestamp_chars}", &crate::coordination::agent_org_payload_limits::RFC3339_TIMESTAMP_MAX_CHARS.to_string()) - .replace("{timestamp_bytes}", &crate::coordination::agent_org_payload_limits::RFC3339_TIMESTAMP_MAX_BYTES.to_string()) - .replace("{summary_scalar_predicate}", &summary_scalar_predicate); - let mut stmt = conn.prepare(&summary_sql).map_err(|err| err.to_string())?; + .map_err(|error| error.to_string())? + } else { + 0 + }; + let cursor_predicate = match direction { + TaskPageDirection::Forward => { + "(?5 IS NULL OR task.created_at>?5 OR (task.created_at=?5 AND task.id>?6))" + } + TaskPageDirection::Backward => { + "(?5 IS NULL OR task.created_at "task.created_at ASC, task.id ASC", + TaskPageDirection::Backward => "task.created_at DESC, task.id DESC", + }; + let (blocks_preview, blocks_count) = if bucket.is_none() { + ( + "COALESCE((SELECT json_group_array(id) FROM ( + SELECT downstream.id AS id + FROM agent_org_runtime_tasks downstream, json_each(downstream.blocked_by_json) edge + WHERE downstream.org_run_id=task.org_run_id AND edge.value=task.id + ORDER BY downstream.created_at, downstream.id LIMIT ?9 + )),'[]')", + "(SELECT COUNT(*) FROM agent_org_runtime_tasks downstream, json_each(downstream.blocked_by_json) edge + WHERE downstream.org_run_id=task.org_run_id AND edge.value=task.id)", + ) + } else { + // Reverse dependencies are intentionally absent from a bounded + // Current/History page. `blocked_by` remains canonical and the + // flag tells consumers to request detail if they need a complete + // reverse projection. + ("'[]'", "1") + }; + let corrupt_predicate = super::super::corrupt_task_row_predicate_sql(); + let sql = format!( + "SELECT task.id, + substr(task.subject,1,200), + substr(task.description,1,?7), + length(task.description)>?7, + CASE WHEN task.active_form IS NULL THEN NULL ELSE substr(task.active_form,1,1000) END, + task.owner, task.status, task.execution_mode, + {blocks_preview}, + {blocks_count}, + COALESCE((SELECT json_group_array(value) FROM ( + SELECT value FROM json_each(task.blocked_by_json) LIMIT ?9 + )),'[]'), + (SELECT COUNT(*) FROM json_each(task.blocked_by_json)), + NOT EXISTS ( + SELECT 1 + FROM json_each(task.blocked_by_json) edge + LEFT JOIN agent_org_runtime_tasks blocker + ON blocker.org_run_id=task.org_run_id + AND blocker.id=edge.value + WHERE blocker.id IS NULL OR blocker.status<>'completed' + ), + CASE WHEN task.metadata_json IS NOT NULL + AND json_type(task.metadata_json,'$.eligible_member_ids')='array' + THEN COALESCE((SELECT json_group_array(value) FROM ( + SELECT value FROM json_each(task.metadata_json,'$.eligible_member_ids') LIMIT ?10 + )),'[]') ELSE '[]' END, + CASE WHEN task.metadata_json IS NOT NULL + AND json_type(task.metadata_json,'$.eligible_member_ids')='array' + THEN (SELECT COUNT(*) FROM json_each(task.metadata_json,'$.eligible_member_ids')) ELSE 0 END, + CASE WHEN task.metadata_json IS NOT NULL + AND json_type(task.metadata_json,'$.required_role')='text' + THEN substr(json_extract(task.metadata_json,'$.required_role'),1,200) ELSE NULL END, + CASE WHEN task.output_json IS NULL THEN NULL ELSE substr(json_extract(task.output_json,'$.summary'),1,1000) END, + CASE WHEN task.output_json IS NULL THEN '[]' ELSE COALESCE((SELECT json_group_array(value) FROM ( + SELECT value FROM json_each(task.output_json,'$.artifactIds') LIMIT ?11 + )),'[]') END, + CASE WHEN task.output_json IS NULL THEN 0 ELSE (SELECT COUNT(*) FROM json_each(task.output_json,'$.artifactIds')) END, + CASE WHEN task.output_json IS NULL THEN NULL ELSE json_extract(task.output_json,'$.producedByMemberId') END, + CASE WHEN task.output_json IS NULL THEN NULL ELSE json_extract(task.output_json,'$.producedAt') END, + CASE WHEN task.output_json IS NOT NULL AND json_type(task.output_json,'$.content')='text' THEN 1 ELSE 0 END, + task.failure_reason_json, task.cancel_reason_json, task.replaces_task_id, + task.created_at, task.updated_at, + {corrupt_predicate} + FROM agent_org_runtime_tasks task + WHERE task.org_run_id=?1 + AND (?2 IS NULL OR task.status=?2) + AND (?3 IS NULL OR task.owner=?3) + AND (?4 IS NULL + OR (?4='current' AND task.status IN ('pending','in_progress')) + OR (?4='history' AND task.status IN ('completed','failed','cancelled'))) + AND {cursor_predicate} + ORDER BY {ordering} LIMIT ?8" + ); + let mut stmt = conn.prepare(&sql).map_err(|error| error.to_string())?; let rows = stmt .query_map( params![ org_run_id, status_wire, owner, + bucket_wire, cursor_created_at, cursor_id, TASK_SUMMARY_DESCRIPTION_MAX_CHARS as i64, @@ -397,37 +467,40 @@ impl AgentOrgTaskStore { TASK_SUMMARY_DEPENDENCY_PREVIEW_MAX_COUNT as i64, TASK_SUMMARY_ELIGIBILITY_PREVIEW_MAX_COUNT as i64, TASK_SUMMARY_ARTIFACT_PREVIEW_MAX_COUNT as i64, - crate::coordination::agent_org_payload_limits::TASK_DEPENDENCY_JSON_MAX_BYTES - as i64, - crate::coordination::agent_org_payload_limits::TASK_METADATA_MAX_BYTES as i64, ], |row| { + if row.get::<_, i64>(27)? != 0 { + return Err(rusqlite::Error::FromSqlConversionFailure( + 27, + rusqlite::types::Type::Integer, + "corrupt Task row selected by bounded page".into(), + )); + } let status_raw: String = row.get(6)?; - let execution_mode_raw: String = row.get(11)?; - let output_summary: Option = row.get(12)?; - let artifact_ids = decode_summary_array(row.get(13)?, 13)?; - let artifact_count = row.get::<_, i64>(22)?.max(0) as usize; - let output = output_summary - .map(|summary| { - Ok::(TaskOutputSummary { - summary, - artifact_ids, - artifact_ids_truncated: artifact_count - > TASK_SUMMARY_ARTIFACT_PREVIEW_MAX_COUNT, - produced_by_member_id: row.get(14)?, - produced_at: row.get(15)?, - has_content: row.get::<_, i64>(16)? != 0, - }) - }) - .transpose()?; - let blocks = decode_summary_array(row.get(7)?, 7)?; - let blocked_by = decode_summary_array(row.get(8)?, 8)?; - let eligible_member_ids = decode_summary_array(row.get(9)?, 9)?; + let execution_mode_raw: String = row.get(7)?; + let blocks = decode_array(row.get(8)?, 8)?; + let blocked_by = decode_array(row.get(10)?, 10)?; + let eligible_member_ids = decode_array(row.get(13)?, 13)?; + let output_summary: Option = row.get(16)?; + let artifact_ids = decode_array(row.get(17)?, 17)?; + let artifact_count = row.get::<_, i64>(18)?.max(0) as usize; + let produced_by_member_id = row.get(19)?; + let produced_at = row.get(20)?; + let has_content = row.get::<_, i64>(21)? != 0; + let output = output_summary.map(|summary| TaskOutputSummary { + summary, + artifact_ids, + artifact_ids_truncated: artifact_count + > TASK_SUMMARY_ARTIFACT_PREVIEW_MAX_COUNT, + produced_by_member_id, + produced_at, + has_content, + }); Ok(TaskSummary { id: row.get(0)?, subject: row.get(1)?, description: row.get(2)?, - description_truncated: row.get::<_, i64>(3)? != 0, + description_truncated: row.get(3)?, active_form: row.get(4)?, owner: row.get(5)?, status: TaskStatus::from_wire(&status_raw).map_err(|error| { @@ -438,40 +511,58 @@ impl AgentOrgTaskStore { ) })?, blocks, - blocks_truncated: row.get::<_, i64>(19)?.max(0) as usize - > TASK_SUMMARY_DEPENDENCY_PREVIEW_MAX_COUNT, + blocks_truncated: bucket.is_some() + || row.get::<_, i64>(9)?.max(0) as usize + > TASK_SUMMARY_DEPENDENCY_PREVIEW_MAX_COUNT, blocked_by, - blocked_by_truncated: row.get::<_, i64>(20)?.max(0) as usize + blocked_by_truncated: row.get::<_, i64>(11)?.max(0) as usize > TASK_SUMMARY_DEPENDENCY_PREVIEW_MAX_COUNT, + dependencies_satisfied: row.get::<_, i64>(12)? != 0, eligible_member_ids, - eligible_member_ids_truncated: row.get::<_, i64>(21)?.max(0) as usize + eligible_member_ids_truncated: row.get::<_, i64>(14)?.max(0) as usize > TASK_SUMMARY_ELIGIBILITY_PREVIEW_MAX_COUNT, - required_role: row.get(10)?, - execution_mode: TaskExecutionMode::from_wire(&execution_mode_raw) - .unwrap_or(TaskExecutionMode::Build), + required_role: row.get(15)?, + execution_mode: TaskExecutionMode::from_wire(&execution_mode_raw).map_err( + |error| { + rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + error.into(), + ) + }, + )?, output, - created_at: row.get(17)?, - updated_at: row.get(18)?, + failure_reason: decode_optional_json::( + row.get(22)?, + 22, + )?, + cancel_reason: decode_optional_json::( + row.get(23)?, + 23, + )?, + replaces_task_id: row.get(24)?, + created_at: row.get(25)?, + updated_at: row.get(26)?, }) }, ) - .map_err(|err| err.to_string())?; + .map_err(|error| error.to_string())?; let mut tasks = Vec::new(); - let mut serialized_bytes = 2usize; // surrounding JSON array + let mut serialized_bytes = 2usize; let mut has_more = false; for row in rows { - let task = row.map_err(|err| err.to_string())?; + let task = row.map_err(|error| error.to_string())?; if tasks.len() == bounded_limit { has_more = true; break; } - let task_bytes = serde_json::to_vec(&task) - .map_err(|err| format!("serialize TaskSummary for payload budget failed: {err}"))? + let bytes = serde_json::to_vec(&task) + .map_err(|error| error.to_string())? .len(); let separator = usize::from(!tasks.is_empty()); if serialized_bytes .saturating_add(separator) - .saturating_add(task_bytes) + .saturating_add(bytes) > TASK_SUMMARY_PAGE_MAX_BYTES { has_more = true; @@ -479,25 +570,51 @@ impl AgentOrgTaskStore { } serialized_bytes = serialized_bytes .saturating_add(separator) - .saturating_add(task_bytes); + .saturating_add(bytes); tasks.push(task); } - let next_cursor = has_more - .then(|| tasks.last().map(|task| task.id.clone())) - .flatten(); + if direction == TaskPageDirection::Backward { + tasks.reverse(); + } + let (previous_cursor, next_cursor) = match bucket { + Some(bucket) => { + let previous = match direction { + TaskPageDirection::Forward => cursor + .as_ref() + .and_then(|_| tasks.first()) + .map(|task| encode_task_page_cursor(bucket, status, task)) + .transpose()?, + TaskPageDirection::Backward => has_more + .then(|| tasks.first()) + .flatten() + .map(|task| encode_task_page_cursor(bucket, status, task)) + .transpose()?, + }; + let next = match direction { + TaskPageDirection::Forward => has_more + .then(|| tasks.last()) + .flatten() + .map(|task| encode_task_page_cursor(bucket, status, task)) + .transpose()?, + TaskPageDirection::Backward => cursor + .as_ref() + .and_then(|_| tasks.last()) + .map(|task| encode_task_page_cursor(bucket, status, task)) + .transpose()?, + }; + (previous, next) + } + None => (None, None), + }; Ok(TaskSummaryPage { tasks, filtered_total: filtered_total.max(0) as usize, has_more, next_cursor, + previous_cursor, }) } - /// Return a bounded preview of unresolved task ids from an existing read - /// snapshot. The boolean reports whether more ids exist beyond `limit`. - /// Callers that only render run-level guidance must not load full task - /// rows (and their potentially large descriptions/output metadata) merely - /// to name a few blockers. pub(crate) fn open_task_ids_preview_with_connection( conn: &rusqlite::Connection, org_run_id: &str, @@ -507,46 +624,32 @@ impl AgentOrgTaskStore { let mut stmt = conn .prepare( "SELECT id FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND status<>'completed' - AND trim(id)<>'' - AND length(id)<=?3 - AND length(CAST(id AS BLOB))<=?4 - ORDER BY created_at ASC, id ASC - LIMIT ?2", + WHERE org_run_id=?1 AND status IN ('pending','in_progress') + ORDER BY created_at ASC,id ASC LIMIT ?2", ) - .map_err(|err| err.to_string())?; + .map_err(|error| error.to_string())?; let rows = stmt - .query_map( - params![ - org_run_id, - (bounded_limit + 1) as i64, - crate::coordination::agent_org_payload_limits::TASK_IDENTIFIER_MAX_CHARS as i64, - crate::coordination::agent_org_payload_limits::TASK_IDENTIFIER_MAX_BYTES as i64, - ], - |row| row.get::<_, String>(0), - ) - .map_err(|err| err.to_string())?; + .query_map(params![org_run_id, (bounded_limit + 1) as i64], |row| { + row.get::<_, String>(0) + }) + .map_err(|error| error.to_string())?; let mut ids = Vec::new(); - let mut bytes = 2usize; // surrounding JSON array + let mut bytes = 2usize; let mut truncated = false; for row in rows { - let id = row.map_err(|err| err.to_string())?; - let encoded_id_bytes = serde_json::to_vec(&id) - .map_err(|err| format!("serialize open task id preview failed: {err}"))? + let id = row.map_err(|error| error.to_string())?; + let encoded = serde_json::to_vec(&id) + .map_err(|error| error.to_string())? .len(); let separator = usize::from(!ids.is_empty()); if ids.len() == bounded_limit - || bytes - .saturating_add(encoded_id_bytes) - .saturating_add(separator) + || bytes.saturating_add(separator).saturating_add(encoded) > crate::coordination::agent_org_payload_limits::TASK_OPEN_ID_PREVIEW_MAX_BYTES { truncated = true; break; } - bytes = bytes - .saturating_add(encoded_id_bytes) - .saturating_add(separator); + bytes = bytes.saturating_add(separator).saturating_add(encoded); ids.push(id); } Ok((ids, truncated)) @@ -554,23 +657,20 @@ impl AgentOrgTaskStore { #[cfg(test)] pub fn list_history(org_run_id: &str) -> Result, String> { - let conn = get_connection().map_err(|err| err.to_string())?; + let conn = get_connection().map_err(|error| error.to_string())?; let mut stmt = conn .prepare( - "SELECT id, org_run_id, task_id, event_type, previous_owner, next_owner, - previous_status, next_status, actor_member_id, created_at - FROM agent_org_runtime_task_events - WHERE org_run_id = ?1 - ORDER BY created_at ASC, id ASC", + "SELECT id,org_run_id,task_id,event_type,previous_owner,next_owner, + previous_status,next_status,actor_member_id,actor_kind, + source_turn_intent_id,created_at + FROM agent_org_runtime_task_events WHERE org_run_id=?1 + ORDER BY created_at ASC,id ASC", ) - .map_err(|err| err.to_string())?; + .map_err(|error| error.to_string())?; let rows = stmt .query_map(params![org_run_id], row_to_task_history_event) - .map_err(|err| err.to_string())?; - let mut events = Vec::new(); - for row in rows { - events.push(row.map_err(|err| err.to_string())?); - } - Ok(events) + .map_err(|error| error.to_string())?; + rows.map(|row| row.map_err(|error| error.to_string())) + .collect() } } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs index c6ec0dcf66..1d1c2b1636 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs @@ -1,341 +1,241 @@ -//! Owner-scoped requeue paths: dispose every open task of a member that -//! accepted shutdown (released to the pool or escalated to the coordinator), -//! and requeue a failed member's `in_progress` tasks back to the unassigned -//! pool. +//! Receipt-gated system recovery for failed or intentionally stopped Owners. use database::db::{get_connection, with_sessions_writer}; use rusqlite::params; -use super::super::helpers::{insert_task_history_event, now_rfc3339, row_to_task, SELECT_COLUMNS}; -use super::super::{Task, TaskStatus, TASK_EVENT_ESCALATED_TO_COORDINATOR, TASK_EVENT_RELEASED}; -use super::validation::{ensure_run_allows_task_mutation, validate_task_persistence_invariants}; +use super::super::helpers::{ + encode_metadata, encode_optional_json, insert_task_history_event_as, now_rfc3339, row_to_task, + SELECT_COLUMNS, +}; +use super::super::SystemTaskOperation; +use super::super::{ + SystemArchiveOrRecovery, Task, TaskAnnotationKind, TaskStatus, TaskTerminalReason, + TASK_EVENT_RELEASED, +}; +use super::validation::validate_task_model_invariants; use super::AgentOrgTaskStore; impl AgentOrgTaskStore { - /// Requeue every open task owned by a member that accepted shutdown. - /// Tasks with another eligible peer return to the pool. Tasks without a - /// legal peer move to the coordinator so an intentionally stopped member - /// cannot be resurrected by terminal-session recovery. - /// - /// Returns the list of tasks that were unassigned (full updated - /// rows). Empty list if the member owns nothing or only completed - /// tasks. - pub fn dispose_open_tasks_for_shutdown( + pub(crate) fn recover_owner_failure( + actor: SystemArchiveOrRecovery, org_run_id: &str, owner_member_id: &str, ) -> Result, String> { - let tasks = with_sessions_writer(|| { - Self::dispose_open_tasks_for_shutdown_inner(org_run_id, owner_member_id) - })?; - if !tasks.is_empty() { - crate::coordination::agent_org_run_events::notify_agent_org_run_changed(org_run_id); - } - Ok(tasks) + let mode = match actor.operation() { + SystemTaskOperation::RecoveryRequeue => RecoveryMode::Failure { exhausted: false }, + SystemTaskOperation::RecoveryFail => RecoveryMode::Failure { exhausted: true }, + SystemTaskOperation::ShutdownRelease => { + return Err("system_task_operation_requires_failure_recovery".to_string()) + } + }; + recover_owned_tasks(actor, org_run_id, owner_member_id, mode) } - fn dispose_open_tasks_for_shutdown_inner( + pub(crate) fn release_owner_for_shutdown( + actor: SystemArchiveOrRecovery, org_run_id: &str, owner_member_id: &str, ) -> Result, String> { - let mut conn = get_connection().map_err(|err| err.to_string())?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|err| err.to_string())?; - ensure_run_allows_task_mutation(&tx, org_run_id)?; - - let owned: Vec = { - let sql = format!( - "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks - WHERE org_run_id = ?1 AND owner = ?2 AND status != ?3 - ORDER BY created_at ASC, id ASC" - ); - let mut stmt = tx.prepare(&sql).map_err(|err| err.to_string())?; - let rows = stmt - .query_map( - params![org_run_id, owner_member_id, TaskStatus::Completed.as_wire()], - row_to_task, - ) - .map_err(|err| err.to_string())?; - let mut out = Vec::new(); - for row in rows { - out.push(row.map_err(|err| err.to_string())?); - } - out - }; - - if owned.is_empty() { - tx.commit().map_err(|err| err.to_string())?; - return Ok(Vec::new()); - } - - let now = now_rfc3339(); - let mut updated_rows = Vec::with_capacity(owned.len()); - for task in owned { - let release_to_pool = super::eligible_member_ids(&task) - .iter() - .any(|member_id| member_id != owner_member_id); - tx.execute( - "UPDATE agent_org_runtime_tasks - SET owner = CASE WHEN ?1 THEN NULL ELSE ?2 END, - status = ?3, - updated_at = ?4 - WHERE org_run_id = ?5 AND id = ?6 AND owner = ?7", - params![ - release_to_pool, - crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID, - TaskStatus::Pending.as_wire(), - &now, - org_run_id, - &task.id, - owner_member_id, - ], - ) - .map_err(|err| err.to_string())?; - let mut updated_task = task.clone(); - if release_to_pool { - updated_task.owner = None; - } else { - updated_task.owner = - Some(crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID.to_string()); - } - updated_task.status = TaskStatus::Pending; - updated_task.updated_at = now.clone(); - validate_task_persistence_invariants( - &tx, - org_run_id, - updated_task.owner.as_deref(), - updated_task.status, - updated_task.metadata.as_ref(), - )?; - insert_task_history_event( - &tx, - org_run_id, - &updated_task.id, - if release_to_pool { - TASK_EVENT_RELEASED - } else { - TASK_EVENT_ESCALATED_TO_COORDINATOR - }, - Some(&task), - &updated_task, - Some(owner_member_id), - )?; - updated_rows.push(updated_task); - } - - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, org_run_id)?; - - tx.commit().map_err(|err| err.to_string())?; - Ok(updated_rows) + recover_owned_tasks(actor, org_run_id, owner_member_id, RecoveryMode::Shutdown) } - /// Requeue every `in_progress` task owned by `owner_member_id` after - /// the owner's turn failed. - /// - /// On explicit member failure (issue #272 E4), release every - /// `in_progress` task to the - /// coordinator's unassigned queue (`owner = NULL`, `status = pending`, - /// metadata preserved). Ownerless is a durable "needs assignment" state; - /// workers never self-claim it. + #[cfg(test)] pub fn requeue_in_progress_for_owner( org_run_id: &str, owner_member_id: &str, ) -> Result, String> { - let tasks = with_sessions_writer(|| { - Self::requeue_in_progress_for_owner_inner(org_run_id, owner_member_id) - })?; - if !tasks.is_empty() { - crate::coordination::agent_org_run_events::notify_agent_org_run_changed(org_run_id); - } - Ok(tasks) + let reservation = crate::coordination::agent_org_watchdog::reserve_task_failure_recovery( + org_run_id, + owner_member_id, + )?; + let operation = if reservation.exhausted { + SystemTaskOperation::RecoveryFail + } else { + SystemTaskOperation::RecoveryRequeue + }; + let actor = + SystemArchiveOrRecovery::new(reservation.token, reservation.generation, operation)?; + Self::recover_owner_failure(actor, org_run_id, owner_member_id) } - fn requeue_in_progress_for_owner_inner( + #[cfg(test)] + pub fn dispose_open_tasks_for_shutdown( org_run_id: &str, owner_member_id: &str, ) -> Result, String> { - let mut conn = get_connection().map_err(|err| err.to_string())?; + let reservation = crate::coordination::agent_org_watchdog::reserve_task_shutdown_release( + org_run_id, + owner_member_id, + )?; + let actor = SystemArchiveOrRecovery::new( + reservation.token, + reservation.generation, + SystemTaskOperation::ShutdownRelease, + )?; + Self::release_owner_for_shutdown(actor, org_run_id, owner_member_id) + } +} + +#[derive(Debug, Clone, Copy)] +enum RecoveryMode { + Failure { exhausted: bool }, + Shutdown, +} + +fn recover_owned_tasks( + actor: SystemArchiveOrRecovery, + org_run_id: &str, + owner_member_id: &str, + mode: RecoveryMode, +) -> Result, String> { + let updated = with_sessions_writer(|| -> Result, String> { + let mut conn = get_connection().map_err(|error| error.to_string())?; let tx = conn .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|err| err.to_string())?; - ensure_run_allows_task_mutation(&tx, org_run_id)?; - - let owned: Vec = { - let sql = format!( - "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks - WHERE org_run_id = ?1 AND owner = ?2 AND status = ?3 - ORDER BY created_at ASC, id ASC" - ); - let mut stmt = tx.prepare(&sql).map_err(|err| err.to_string())?; + .map_err(|error| error.to_string())?; + let audit = actor.validate(&tx, org_run_id, owner_member_id)?; + let receipt_id = actor.receipt_id().to_string(); + let action_kind = actor.action_kind(); + let status_predicate = match mode { + RecoveryMode::Failure { .. } => "status='in_progress'", + RecoveryMode::Shutdown => "status IN ('pending','in_progress')", + }; + let sql = format!( + "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND owner=?2 AND {status_predicate} + ORDER BY created_at ASC,id ASC" + ); + let owned = { + let mut stmt = tx.prepare(&sql).map_err(|error| error.to_string())?; let rows = stmt - .query_map( + .query_map(params![org_run_id, owner_member_id], row_to_task) + .map_err(|error| error.to_string())?; + rows.map(|row| row.map_err(|error| error.to_string())) + .collect::, _>>()? + }; + let now = now_rfc3339(); + let mut updated = Vec::with_capacity(owned.len()); + for previous in owned { + let mut task = previous.clone(); + let exhausted = matches!(mode, RecoveryMode::Failure { exhausted: true }); + if exhausted { + task.status = TaskStatus::Failed; + task.failure_reason = Some(TaskTerminalReason { + code: "system.recovery_budget_exhausted".to_string(), + message: "Automatic Owner recovery budget was exhausted".to_string(), + }); + } else { + task.status = TaskStatus::Pending; + task.owner = None; + ensure_recovery_candidate(&mut task, owner_member_id)?; + } + task.updated_at = now.clone(); + validate_task_model_invariants(&tx, &task)?; + let metadata_json = encode_metadata(task.metadata.as_ref())?; + let failure_reason_json = + encode_optional_json("task failure reason", task.failure_reason.as_ref())?; + let changed = tx + .execute( + "UPDATE agent_org_runtime_tasks + SET owner=?1,status=?2,metadata_json=?3,failure_reason_json=?4, + output_json=NULL,cancel_reason_json=NULL,updated_at=?5 + WHERE org_run_id=?6 AND id=?7 AND owner=?8 + AND status IN ('pending','in_progress')", params![ + task.owner.as_deref(), + task.status.as_wire(), + metadata_json.as_deref(), + failure_reason_json.as_deref(), + &task.updated_at, org_run_id, + &task.id, owner_member_id, - TaskStatus::InProgress.as_wire() ], - row_to_task, ) - .map_err(|err| err.to_string())?; - let mut out = Vec::new(); - for row in rows { - out.push(row.map_err(|err| err.to_string())?); + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err(format!( + "task_mutation_conflict: recovery target {} changed", + task.id + )); } - out - }; - - if owned.is_empty() { - tx.commit().map_err(|err| err.to_string())?; - return Ok(Vec::new()); - } - - let now = now_rfc3339(); - let mut updated_rows = Vec::with_capacity(owned.len()); - for task in owned { + insert_task_history_event_as( + &tx, + org_run_id, + &task.id, + TASK_EVENT_RELEASED, + Some(&previous), + &task, + &audit, + )?; + let annotation_body = if exhausted { + "Automatic recovery budget exhausted; Task was failed" + } else if matches!(mode, RecoveryMode::Shutdown) { + "Owner shutdown released Task to the explicit assignment pool" + } else { + "Owner turn failed; Task was restored to ownerless pending" + }; tx.execute( - "UPDATE agent_org_runtime_tasks - SET owner = NULL, status = ?1, updated_at = ?2 - WHERE org_run_id = ?3 AND id = ?4 AND owner = ?5 AND status = ?6", + "INSERT INTO agent_org_runtime_task_annotations( + id,org_run_id,task_id,kind,body,actor_kind, + actor_participant_id,source_turn_intent_id,created_at + ) VALUES (?1,?2,?3,?4,?5,?6,?7,NULL,?8)", params![ - TaskStatus::Pending.as_wire(), - &now, + uuid::Uuid::new_v4().to_string(), org_run_id, &task.id, - owner_member_id, - TaskStatus::InProgress.as_wire(), + TaskAnnotationKind::AuditNote.as_wire(), + annotation_body, + audit.kind.as_wire(), + &audit.participant_id, + &now, ], ) - .map_err(|err| err.to_string())?; - let mut updated_task = task.clone(); - updated_task.owner = None; - updated_task.status = TaskStatus::Pending; - updated_task.updated_at = now.clone(); - insert_task_history_event( - &tx, - org_run_id, - &updated_task.id, - TASK_EVENT_RELEASED, - Some(&task), - &updated_task, - Some(owner_member_id), - )?; - updated_rows.push(updated_task); + .map_err(|error| error.to_string())?; + updated.push(task); } - - crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, org_run_id)?; - - tx.commit().map_err(|err| err.to_string())?; - Ok(updated_rows) + tx.execute( + "UPDATE agent_org_runtime_recovery_attempts SET reservation_token=NULL + WHERE org_run_id=?1 AND reservation_token=?2 + AND action_kind=?3 AND target_key=?4", + params![org_run_id, receipt_id, action_kind, owner_member_id], + ) + .map_err(|error| error.to_string())?; + if !updated.is_empty() { + crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, org_run_id)?; + } + tx.commit().map_err(|error| error.to_string())?; + Ok(updated) + })?; + if !updated.is_empty() { + crate::coordination::agent_org_run_events::notify_agent_org_run_changed(org_run_id); } + Ok(updated) } -#[cfg(test)] -mod migration_tests { - use super::*; - - #[test] - fn dependency_migration_skips_corrupt_run_and_normalizes_valid_run() { - let conn = rusqlite::Connection::open_in_memory().expect("open in-memory database"); - super::super::super::init_schema(&conn).expect("create task schema"); - - let now = now_rfc3339(); - for (id, blocks_json, blocked_by_json) in - [("task-a", r#"["task-b"]"#, "[]"), ("task-b", "[]", "[]")] - { - conn.execute( - "INSERT INTO agent_org_runtime_tasks ( - id, org_run_id, subject, description, status, - blocks_json, blocked_by_json, created_at, updated_at - ) VALUES (?1, 'valid-run', ?1, '', 'pending', ?2, ?3, ?4, ?4)", - params![id, blocks_json, blocked_by_json, &now], - ) - .expect("seed valid legacy task"); - } - conn.execute( - "INSERT INTO agent_org_runtime_tasks ( - id, org_run_id, subject, description, status, - blocks_json, blocked_by_json, created_at, updated_at - ) VALUES ( - 'corrupt-task', 'corrupt-run', 'corrupt', '', 'pending', - 'not-json', '[]', ?1, ?1 - )", - params![&now], - ) - .expect("seed corrupt historical task"); - - super::super::super::init_schema(&conn) - .expect("schema init must survive one corrupt historical run"); - - let (a_blocks, b_blocked_by): (String, String) = conn - .query_row( - "SELECT a.blocks_json, b.blocked_by_json - FROM agent_org_runtime_tasks a - JOIN agent_org_runtime_tasks b - ON b.org_run_id=a.org_run_id AND b.id='task-b' - WHERE a.org_run_id='valid-run' AND a.id='task-a'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .expect("read normalized valid board"); - assert_eq!(a_blocks, r#"["task-b"]"#); - assert_eq!(b_blocked_by, r#"["task-a"]"#); - - let corrupt_blocks: String = conn - .query_row( - "SELECT blocks_json FROM agent_org_runtime_tasks - WHERE org_run_id='corrupt-run' AND id='corrupt-task'", - [], - |row| row.get(0), - ) - .expect("corrupt row remains available for runtime repair"); - assert_eq!(corrupt_blocks, "not-json"); - - let valid_marked: bool = conn - .query_row( - "SELECT EXISTS( - SELECT 1 FROM agent_org_runtime_task_schema_migrations - WHERE name='canonical_blocked_by_v1' AND org_run_id='valid-run' - )", - [], - |row| row.get(0), - ) - .expect("read valid marker"); - let corrupt_marked: bool = conn - .query_row( - "SELECT EXISTS( - SELECT 1 FROM agent_org_runtime_task_schema_migrations - WHERE name='canonical_blocked_by_v1' AND org_run_id='corrupt-run' - )", - [], - |row| row.get(0), - ) - .expect("read corrupt marker"); - assert!(valid_marked, "healthy run receives its own success marker"); - assert!( - !corrupt_marked, - "corrupt run remains unmarked so a later startup can retry" - ); - - conn.execute( - "UPDATE agent_org_runtime_tasks SET blocks_json='[]' - WHERE org_run_id='corrupt-run' AND id='corrupt-task'", - [], - ) - .expect("repair corrupt historical row"); - super::super::super::init_schema(&conn).expect("retry repaired run"); - let corrupt_marked_after_retry: bool = conn - .query_row( - "SELECT EXISTS( - SELECT 1 FROM agent_org_runtime_task_schema_migrations - WHERE name='canonical_blocked_by_v1' AND org_run_id='corrupt-run' - )", - [], - |row| row.get(0), - ) - .expect("read retry marker"); - assert!( - corrupt_marked_after_retry, - "a repaired run is retried and marked independently" - ); +fn ensure_recovery_candidate(task: &mut Task, owner_member_id: &str) -> Result<(), String> { + let mut metadata = match task.metadata.take() { + Some(serde_json::Value::Object(object)) => object, + Some(_) => return Err("task metadata must be an object".to_string()), + None => serde_json::Map::new(), + }; + let mut eligible = match metadata.get(super::super::TASK_METADATA_ELIGIBLE_MEMBER_IDS) { + Some(value) => value + .as_array() + .cloned() + .ok_or_else(|| "eligible_member_ids must be an array".to_string())?, + None => Vec::new(), + }; + if !eligible + .iter() + .any(|value| value.as_str() == Some(owner_member_id)) + { + eligible.push(serde_json::Value::String(owner_member_id.to_string())); } + metadata.insert( + super::super::TASK_METADATA_ELIGIBLE_MEMBER_IDS.to_string(), + serde_json::Value::Array(eligible), + ); + task.metadata = Some(serde_json::Value::Object(metadata)); + Ok(()) } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs index 3a3ed18314..49a0d1890e 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/update.rs @@ -1,36 +1,52 @@ -//! Task mutation: the plan-completion CAS used by Agent Org plan approval, the -//! public partial-update surface (including the optimistic -//! `if_unchanged` variants), and the shared `update_inner` that recanonicalizes -//! dependencies and reports a `TaskMutationOutcome`. +//! Task mutation: the production plan-completion CAS used by Agent Org plan +//! approval, plus a `cfg(test)` legacy fixture surface retained only while +//! older owning-boundary tests are migrated to the typed actor API. -use database::db::{get_connection, with_sessions_writer}; use rusqlite::{params, OptionalExtension}; -use crate::coordination::agent_org_payload_limits::validate_task_dependency_ids; +#[cfg(test)] +use database::db::{get_connection, with_sessions_writer}; + +use super::super::helpers::{ + encode_optional_json, insert_task_history_event_as, now_rfc3339, row_to_task, SELECT_COLUMNS, +}; +use super::super::{ + task_execution_mode, Task, TaskExecutionMode, TaskMutationOutcome, TaskOutput, TaskOutputInput, + TaskOwnerExecution, TaskStatus, TASK_EVENT_UPDATED, +}; +use super::validation::ensure_run_allows_task_mutation; +use super::AgentOrgTaskStore; +#[cfg(test)] use super::super::helpers::{ encode_json_array, encode_metadata, insert_task_history_event, list_tasks_with_conn, - now_rfc3339, row_to_task, SELECT_COLUMNS, }; +#[cfg(test)] use super::super::{ - task_execution_mode, Task, TaskExecutionMode, TaskGraphIndex, TaskMutationOutcome, TaskOutput, - TaskStatus, UpdateTaskPatch, TASK_EVENT_UPDATED, TASK_METADATA_OUTPUT, + TaskGraphIndex, UpdateTaskPatch, TASK_METADATA_EXECUTION_MODE, TASK_METADATA_OUTPUT, }; -use super::dependencies::{canonicalize_dependencies, persist_dependency_projection}; -use super::validation::{ - ensure_run_allows_task_mutation, validate_task_persistence_invariants, - validate_task_text_fields, +#[cfg(test)] +use super::dependencies::{ + canonicalize_dependencies, persist_canonical_blocked_by_for_test_fixture, }; -use super::AgentOrgTaskStore; +#[cfg(test)] +use super::validation::{validate_task_persistence_invariants, validate_task_text_fields}; +#[cfg(test)] +use crate::coordination::agent_org_payload_limits::validate_task_dependency_ids; +#[cfg(test)] fn task_persisted_state_equal(left: &Task, right: &Task) -> bool { left.subject == right.subject && left.description == right.description && left.active_form == right.active_form && left.owner == right.owner && left.status == right.status + && left.execution_mode == right.execution_mode && left.blocked_by == right.blocked_by && left.metadata == right.metadata + && left.output == right.output + && left.failure_reason == right.failure_reason + && left.cancel_reason == right.cancel_reason } impl AgentOrgTaskStore { @@ -39,12 +55,14 @@ impl AgentOrgTaskStore { /// approval-row CAS so neither side can commit without the other. pub(crate) fn complete_planning_task_in_tx( tx: &rusqlite::Transaction<'_>, + actor: TaskOwnerExecution, org_run_id: &str, task_id: &str, - source_member_id: &str, - output: TaskOutput, + output: TaskOutputInput, ) -> Result { ensure_run_allows_task_mutation(tx, org_run_id)?; + let audit = actor.validate(tx, org_run_id, task_id)?; + let source_member_id = audit.participant_id.clone(); let sql = format!( "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks WHERE org_run_id = ?1 AND id = ?2" @@ -56,7 +74,7 @@ impl AgentOrgTaskStore { let Some(previous) = previous else { return Err(format!("task_not_found: {task_id} in run {org_run_id}")); }; - if previous.owner.as_deref() != Some(source_member_id) { + if previous.owner.as_deref() != Some(source_member_id.as_str()) { return Err(format!( "plan_task_owner_mismatch: task {task_id} is owned by {:?}, not {source_member_id}", previous.owner @@ -75,36 +93,30 @@ impl AgentOrgTaskStore { } let mut current = previous.clone(); - let mut metadata = match current.metadata.take() { - Some(serde_json::Value::Object(object)) => object, - Some(_) => return Err("task metadata must be a JSON object".to_string()), - None => serde_json::Map::new(), - }; - metadata.insert(TASK_METADATA_OUTPUT.to_string(), serde_json::json!(output)); - current.metadata = Some(serde_json::Value::Object(metadata)); + current.output = Some(TaskOutput { + summary: output.summary, + content: output.content, + artifact_ids: output.artifact_ids, + produced_by_member_id: source_member_id.clone(), + produced_at: now_rfc3339(), + }); current.status = TaskStatus::Completed; current.updated_at = now_rfc3339(); - validate_task_persistence_invariants( - tx, - org_run_id, - current.owner.as_deref(), - current.status, - current.metadata.as_ref(), - )?; - let metadata_json = encode_metadata(current.metadata.as_ref())?; + super::validation::validate_task_model_invariants(tx, ¤t)?; + let output_json = encode_optional_json("task output", current.output.as_ref())?; let changed = tx .execute( "UPDATE agent_org_runtime_tasks - SET status = ?1, metadata_json = ?2, updated_at = ?3 + SET status = ?1, output_json = ?2, updated_at = ?3 WHERE org_run_id = ?4 AND id = ?5 AND status = ?6 AND owner = ?7", params![ current.status.as_wire(), - metadata_json.as_deref(), + output_json.as_deref(), ¤t.updated_at, org_run_id, task_id, TaskStatus::InProgress.as_wire(), - source_member_id, + &source_member_id, ], ) .map_err(|err| err.to_string())?; @@ -114,14 +126,14 @@ impl AgentOrgTaskStore { super::TASK_MUTATION_CONFLICT_ERROR )); } - insert_task_history_event( + insert_task_history_event_as( tx, org_run_id, task_id, TASK_EVENT_UPDATED, Some(&previous), ¤t, - Some(source_member_id), + &audit, )?; crate::coordination::agent_org_runs::bump_work_revision_in_tx(tx, org_run_id)?; Ok(TaskMutationOutcome { @@ -137,10 +149,12 @@ impl AgentOrgTaskStore { /// Apply a partial update. The full updated row is returned. `Err` on /// missing row so callers can surface a clear "task_not_found" without /// a separate get round-trip. + #[cfg(test)] pub fn update(org_run_id: &str, task_id: &str, patch: UpdateTaskPatch) -> Result { Self::update_with_outcome(org_run_id, task_id, patch).map(|outcome| outcome.current) } + #[cfg(test)] pub fn update_with_outcome( org_run_id: &str, task_id: &str, @@ -155,6 +169,7 @@ impl AgentOrgTaskStore { .map(|(outcome, ())| outcome) } + #[cfg(test)] pub fn update_with_outcome_and_transactional_effects( org_run_id: &str, task_id: &str, @@ -171,6 +186,7 @@ impl AgentOrgTaskStore { /// version that was inspected before authorization. This closes the /// check-then-write race where another turn could reassign a task after a /// member was authorized but before its update transaction began. + #[cfg(test)] pub fn update_with_outcome_if_unchanged( org_run_id: &str, task_id: &str, @@ -187,6 +203,7 @@ impl AgentOrgTaskStore { .map(|(outcome, ())| outcome) } + #[cfg(test)] pub fn update_with_outcome_if_unchanged_and_transactional_effects( org_run_id: &str, task_id: &str, @@ -207,6 +224,7 @@ impl AgentOrgTaskStore { Ok((outcome, effect)) } + #[cfg(test)] fn update_inner( org_run_id: &str, task_id: &str, @@ -284,6 +302,24 @@ impl AgentOrgTaskStore { } if let Some(metadata) = patch.metadata { task.metadata = metadata; + if let Some(metadata) = task + .metadata + .as_mut() + .and_then(serde_json::Value::as_object_mut) + { + if let Some(execution_mode) = metadata + .remove(TASK_METADATA_EXECUTION_MODE) + .and_then(|value| value.as_str().map(str::to_string)) + { + task.execution_mode = TaskExecutionMode::from_wire(&execution_mode)?; + } + if let Some(output) = metadata.remove(TASK_METADATA_OUTPUT) { + task.output = Some( + serde_json::from_value(output) + .map_err(|error| format!("task output has invalid shape: {error}"))?, + ); + } + } } validate_task_text_fields( &task.subject, @@ -324,9 +360,13 @@ impl AgentOrgTaskStore { .find(|candidate| candidate.id == task_id) .cloned() .expect("updated task remains present in candidate graph"); - let blocks_json = encode_json_array(&task.blocks)?; let blocked_by_json = encode_json_array(&task.blocked_by)?; let metadata_json = encode_metadata(task.metadata.as_ref())?; + let output_json = encode_optional_json("task output", task.output.as_ref())?; + let failure_reason_json = + encode_optional_json("task failure reason", task.failure_reason.as_ref())?; + let cancel_reason_json = + encode_optional_json("task cancel reason", task.cancel_reason.as_ref())?; tx.execute( "UPDATE agent_org_runtime_tasks SET @@ -335,20 +375,26 @@ impl AgentOrgTaskStore { active_form = ?3, owner = ?4, status = ?5, - blocks_json = ?6, + execution_mode = ?6, blocked_by_json = ?7, metadata_json = ?8, - updated_at = ?9 - WHERE org_run_id = ?10 AND id = ?11", + output_json = ?9, + failure_reason_json = ?10, + cancel_reason_json = ?11, + updated_at = ?12 + WHERE org_run_id = ?13 AND id = ?14", params![ &task.subject, &task.description, task.active_form.as_deref(), task.owner.as_deref(), task.status.as_wire(), - &blocks_json, + task.execution_mode.as_wire(), &blocked_by_json, metadata_json.as_deref(), + output_json.as_deref(), + failure_reason_json.as_deref(), + cancel_reason_json.as_deref(), &task.updated_at, org_run_id, task_id, @@ -364,7 +410,7 @@ impl AgentOrgTaskStore { &task, task.owner.as_deref(), )?; - persist_dependency_projection(&tx, &candidate_tasks)?; + persist_canonical_blocked_by_for_test_fixture(&tx, &candidate_tasks)?; crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, org_run_id)?; let current_graph = TaskGraphIndex::new(&candidate_tasks); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs index d36301425c..a53ba40253 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/validation.rs @@ -12,17 +12,17 @@ use crate::coordination::agent_org_payload_limits::{ validate_task_identifier, validate_text_len, TASK_ACTIVE_FORM_MAX_BYTES, TASK_ACTIVE_FORM_MAX_CHARS, TASK_DESCRIPTION_MAX_BYTES, TASK_DESCRIPTION_MAX_CHARS, TASK_OUTPUT_CONTENT_MAX_BYTES, TASK_OUTPUT_CONTENT_MAX_CHARS, TASK_OUTPUT_SUMMARY_MAX_BYTES, - TASK_OUTPUT_SUMMARY_MAX_CHARS, TASK_RUN_MAX_TASKS, TASK_SUBJECT_MAX_BYTES, + TASK_OUTPUT_SUMMARY_MAX_CHARS, TASK_RUN_MAX_OPEN_TASKS, TASK_SUBJECT_MAX_BYTES, TASK_SUBJECT_MAX_CHARS, }; -use super::super::{TaskStatus, TASK_RUN_TASK_LIMIT_ERROR}; +use super::super::{Task, TaskStatus, TASK_RUN_TASK_LIMIT_ERROR}; pub(super) fn ensure_task_rows_safe_for_operational_projection( conn: &rusqlite::Connection, run_id: &str, ) -> Result<(), String> { - if super::dependencies::run_is_safe_for_dependency_normalization(conn, run_id)? { + if super::dependencies::run_is_safe_for_operational_projection(conn, run_id)? { Ok(()) } else { Err( @@ -33,22 +33,23 @@ pub(super) fn ensure_task_rows_safe_for_operational_projection( } pub(super) fn ensure_task_run_capacity( - existing_count: usize, + existing_open_count: usize, incoming_count: usize, ) -> Result<(), String> { - let projected_count = existing_count.checked_add(incoming_count).ok_or_else(|| { + let projected_count = existing_open_count.checked_add(incoming_count).ok_or_else(|| { format!( "{TASK_RUN_TASK_LIMIT_ERROR}: task count overflow while checking the Agent Org run capacity" ) })?; - if projected_count <= TASK_RUN_MAX_TASKS { + if projected_count <= TASK_RUN_MAX_OPEN_TASKS { return Ok(()); } Err(format!( - "{TASK_RUN_TASK_LIMIT_ERROR}: run retains {existing_count} tasks and this mutation would add {incoming_count}; maximum total is {TASK_RUN_MAX_TASKS}" + "{TASK_RUN_TASK_LIMIT_ERROR}: run retains {existing_open_count} open tasks and this mutation would add {incoming_count}; maximum open work is {TASK_RUN_MAX_OPEN_TASKS}" )) } +#[cfg(test)] pub(super) fn reject_writable_blocks(blocks: &[String]) -> Result<(), String> { if blocks.is_empty() { Ok(()) @@ -118,6 +119,7 @@ fn collect_roster_member_ids( } } +#[cfg(test)] pub(super) fn validate_task_persistence_invariants( conn: &rusqlite::Connection, org_run_id: &str, @@ -275,3 +277,194 @@ pub(super) fn validate_task_persistence_invariants( } Ok(()) } + +pub(super) fn validate_task_model_invariants( + conn: &rusqlite::Connection, + task: &Task, +) -> Result<(), String> { + validate_task_text_fields( + &task.subject, + &task.description, + task.active_form.as_deref(), + )?; + validate_task_identifier("task id", &task.id)?; + validate_task_identifier( + "task created_by_participant_id", + &task.created_by_participant_id, + )?; + validate_task_identifier("task source_turn_intent_id", &task.source_turn_intent_id)?; + if let Some(message_id) = task.originating_message_id.as_deref() { + validate_task_identifier("task originating_message_id", message_id)?; + } + if let Some(replaces_task_id) = task.replaces_task_id.as_deref() { + validate_task_identifier("task replaces_task_id", replaces_task_id)?; + } + if let Some(owner) = task.owner.as_deref() { + validate_task_identifier("task owner_member_id", owner)?; + if owner == crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID { + return Err("task owner cannot be coordinator".to_string()); + } + } + if matches!( + task.status, + TaskStatus::InProgress | TaskStatus::Completed | TaskStatus::Failed + ) && task.owner.is_none() + { + return Err(format!( + "{} task must retain its canonical owner", + task.status.as_wire() + )); + } + match task.status { + TaskStatus::Pending | TaskStatus::InProgress => { + if task.output.is_some() + || task.failure_reason.is_some() + || task.cancel_reason.is_some() + { + return Err("open task cannot contain terminal result fields".to_string()); + } + } + TaskStatus::Completed => { + if task.output.is_none() + || task.failure_reason.is_some() + || task.cancel_reason.is_some() + { + return Err("completed task requires only output".to_string()); + } + } + TaskStatus::Failed => { + if task.output.is_some() + || task.failure_reason.is_none() + || task.cancel_reason.is_some() + { + return Err("failed task requires only failure_reason".to_string()); + } + } + TaskStatus::Cancelled => { + if task.output.is_some() + || task.failure_reason.is_some() + || task.cancel_reason.is_none() + { + return Err("cancelled task requires only cancel_reason".to_string()); + } + } + } + if let Some(output) = task.output.as_ref() { + validate_required_text( + "task output summary", + &output.summary, + TASK_OUTPUT_SUMMARY_MAX_CHARS, + TASK_OUTPUT_SUMMARY_MAX_BYTES, + )?; + validate_optional_text( + "task output content", + output.content.as_deref(), + TASK_OUTPUT_CONTENT_MAX_CHARS, + TASK_OUTPUT_CONTENT_MAX_BYTES, + )?; + validate_task_identifier( + "task output produced_by_member_id", + &output.produced_by_member_id, + )?; + if task.owner.as_deref() != Some(output.produced_by_member_id.as_str()) { + return Err("task output producer must equal the canonical task owner".to_string()); + } + if chrono::DateTime::parse_from_rfc3339(&output.produced_at).is_err() { + return Err("task output produced_at must be RFC3339".to_string()); + } + crate::coordination::agent_org_payload_limits::validate_task_artifact_ids( + "task output artifact_ids", + &output.artifact_ids, + )?; + } + for (label, reason) in [ + ("task failure reason", task.failure_reason.as_ref()), + ("task cancel reason", task.cancel_reason.as_ref()), + ] { + if let Some(reason) = reason { + validate_required_text(&format!("{label} code"), &reason.code, 128, 512)?; + validate_required_text(&format!("{label} message"), &reason.message, 2_000, 8_000)?; + } + } + let metadata_object = match task.metadata.as_ref() { + None => None, + Some(serde_json::Value::Object(object)) => Some(object), + Some(_) => return Err("task metadata must be a JSON object".to_string()), + }; + if metadata_object.is_some_and(|object| { + object.contains_key(super::TASK_METADATA_EXECUTION_MODE) + || object.contains_key(super::TASK_METADATA_OUTPUT) + }) { + return Err( + "task metadata cannot contain reserved output or execution_mode fields".to_string(), + ); + } + + let mut eligible_member_ids = Vec::new(); + if let Some(value) = + metadata_object.and_then(|object| object.get(super::TASK_METADATA_ELIGIBLE_MEMBER_IDS)) + { + let ids = value + .as_array() + .ok_or_else(|| "eligible_member_ids must be an array".to_string())? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_string) + .ok_or_else(|| "eligible_member_ids must contain strings".to_string()) + }) + .collect::, _>>()?; + validate_task_eligible_member_ids("eligible_member_ids", &ids)?; + if ids + .iter() + .any(|id| id == crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID) + { + return Err("eligible_member_ids cannot include coordinator".to_string()); + } + eligible_member_ids = ids; + } + if task.owner.is_none() && task.status == TaskStatus::Pending && eligible_member_ids.is_empty() + { + return Err("ownerless pending tasks require eligible_member_ids".to_string()); + } + if let Some(required_role) = + metadata_object.and_then(|object| object.get(super::TASK_METADATA_REQUIRED_ROLE)) + { + let required_role = required_role + .as_str() + .ok_or_else(|| "required_role must be a string".to_string())?; + validate_required_text( + "required_role", + required_role, + crate::coordination::agent_org_payload_limits::TASK_REQUIRED_ROLE_MAX_CHARS, + crate::coordination::agent_org_payload_limits::TASK_REQUIRED_ROLE_MAX_BYTES, + )?; + } + + let snapshot_json: String = conn + .query_row( + "SELECT org_snapshot_json FROM agent_org_runtime_runs WHERE id=?1", + params![&task.org_run_id], + |row| row.get::<_, Option>(0), + ) + .optional() + .map_err(|error| error.to_string())? + .flatten() + .ok_or_else(|| "task actor snapshot missing".to_string())?; + let snapshot: crate::definitions::orgs::AgentOrgLaunchSnapshot = + serde_json::from_str(&snapshot_json) + .map_err(|error| format!("task actor snapshot invalid: {error}"))?; + crate::definitions::orgs::validate_launch_snapshot(&snapshot) + .map_err(|error| format!("task actor snapshot invalid: {error}"))?; + let mut roster = HashSet::new(); + collect_roster_member_ids(&snapshot.members, &mut roster); + for member_id in eligible_member_ids.iter().chain(task.owner.iter()) { + if !roster.contains(member_id) { + return Err(format!( + "task participant is outside run roster: {member_id}" + )); + } + } + Ok(()) +} diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs index 50baa7c83f..671981be94 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs @@ -12,6 +12,49 @@ fn make_params(org_run_id: &str, id: &str, subject: &str) -> CreateTaskParams { rusqlite::params![org_run_id, now], ) .expect("seed running parent Agent Org run"); + let members = [ + "member-default", + "member-a", + "member-alpha", + "member-beta", + "alice", + "bob", + "carol", + "member-alice", + "member-producer", + "member-consumer", + ] + .into_iter() + .enumerate() + .map(|(index, member_id)| { + serde_json::json!({ + "memberId": member_id, + "name": member_id, + "role": "Builder", + "agentId": format!("task-test-agent-{index}"), + }) + }) + .collect::>(); + let snapshot = serde_json::json!({ + "schemaVersion": 1, + "orgId": "task-test-org", + "orgName": "Task Test Team", + "coordinatorRole": "Lead", + "coordinatorAgentId": "task-test-coordinator", + "planApprovalPolicy": "coordinator", + "members": members, + "additionalTaskGraphWriterMemberIds": [], + "memberCommunicationLinks": [], + }) + .to_string(); + conn.execute( + "UPDATE agent_org_runtime_runs + SET activation_generation=1, + org_snapshot_json=COALESCE(org_snapshot_json,?2) + WHERE id=?1", + rusqlite::params![org_run_id, snapshot], + ) + .expect("seed canonical Task test snapshot"); CreateTaskParams { id: id.into(), org_run_id: org_run_id.into(), @@ -58,6 +101,7 @@ fn task_store_sandbox() -> test_helpers::test_env::SandboxGuard { let conn = get_connection().expect("test sqlite connection"); crate::coordination::agent_inbox::init_schema(&conn).expect("agent inbox schema"); crate::coordination::agent_org_runs::init_schema(&conn).expect("agent org runs schema"); + crate::coordination::agent_org_watchdog::init_schema(&conn).expect("Agent Org recovery schema"); init_schema(&conn).expect("agent team tasks schema"); sandbox } @@ -68,6 +112,8 @@ fn task_status_wire_round_trip() { TaskStatus::Pending, TaskStatus::InProgress, TaskStatus::Completed, + TaskStatus::Failed, + TaskStatus::Cancelled, ] { assert_eq!(TaskStatus::from_wire(status.as_wire()).unwrap(), status); } @@ -514,12 +560,15 @@ fn corrupt_predicate_flags_ownerless_in_progress_and_spaced_eligibility() { let _ = make_params(&run_id, "template", "seed parent run"); let conn = get_connection().expect("task database"); let now = chrono::Utc::now().to_rfc3339(); + conn.execute_batch("PRAGMA ignore_check_constraints=ON;") + .expect("allow corruption fixture"); conn.execute( "INSERT INTO agent_org_runtime_tasks (id, org_run_id, subject, description, active_form, owner, status, - blocks_json, blocked_by_json, metadata_json, created_at, updated_at) + execution_mode, blocked_by_json, metadata_json, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) VALUES ('ownerless-running', ?1, 'bad running row', '', NULL, NULL, - 'in_progress', '[]', '[]', ?2, ?3, ?3)", + 'in_progress', 'build', '[]', ?2, 'coordinator', 'turn-corrupt', ?3, ?3)", rusqlite::params![ &run_id, serde_json::json!({TASK_METADATA_ELIGIBLE_MEMBER_IDS: ["member-default"]}).to_string(), @@ -530,9 +579,10 @@ fn corrupt_predicate_flags_ownerless_in_progress_and_spaced_eligibility() { conn.execute( "INSERT INTO agent_org_runtime_tasks (id, org_run_id, subject, description, active_form, owner, status, - blocks_json, blocked_by_json, metadata_json, created_at, updated_at) + execution_mode, blocked_by_json, metadata_json, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) VALUES ('spaced-eligibility', ?1, 'bad eligibility row', '', NULL, NULL, - 'pending', '[]', '[]', ?2, ?3, ?3)", + 'pending', 'build', '[]', ?2, 'coordinator', 'turn-corrupt', ?3, ?3)", rusqlite::params![ &run_id, serde_json::json!({TASK_METADATA_ELIGIBLE_MEMBER_IDS: [" member-default "]}) @@ -541,6 +591,8 @@ fn corrupt_predicate_flags_ownerless_in_progress_and_spaced_eligibility() { ], ) .expect("seed spaced eligibility row"); + conn.execute_batch("PRAGMA ignore_check_constraints=OFF;") + .expect("restore constraints"); let predicate = corrupt_task_row_predicate_sql(); let corrupt_count: i64 = conn @@ -568,9 +620,10 @@ fn summary_filtered_total_matches_rows_after_scalar_corruption_filtering() { conn.execute( "INSERT INTO agent_org_runtime_tasks (id, org_run_id, subject, description, active_form, owner, status, - blocks_json, blocked_by_json, metadata_json, created_at, updated_at) + execution_mode, blocked_by_json, metadata_json, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) VALUES (?1, ?2, 'hidden corrupt row', '', NULL, NULL, 'pending', - '[]', '[]', ?3, ?4, ?4)", + 'build', '[]', ?3, 'coordinator', 'turn-corrupt', ?4, ?4)", rusqlite::params![ oversized_id, &run_id, @@ -580,11 +633,9 @@ fn summary_filtered_total_matches_rows_after_scalar_corruption_filtering() { ) .expect("seed oversized historical id"); - let page = AgentOrgTaskStore::list_summary_page(&run_id, None, None, None, 200) - .expect("bounded summary page"); - assert_eq!(page.filtered_total, 1); - assert_eq!(page.tasks.len(), 1); - assert_eq!(page.tasks[0].id, "valid-task"); + let error = AgentOrgTaskStore::list_summary_page(&run_id, None, None, None, 200) + .expect_err("corrupt rows must fail the page closed"); + assert!(error.contains("corrupt"), "{error}"); } #[test] @@ -680,10 +731,17 @@ fn store_rejects_malformed_reserved_dispatch_metadata() { conn.execute( "INSERT INTO agent_org_runtime_tasks (id, org_run_id, subject, description, active_form, owner, status, - blocks_json, blocked_by_json, metadata_json, created_at, updated_at) + execution_mode, blocked_by_json, metadata_json, output_json, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) VALUES ('historical-output-producer', ?1, 'historical', '', NULL, - 'member-default', 'completed', '[]', '[]', ?2, ?3, ?3)", - rusqlite::params![&run_id, historical_metadata.to_string(), now], + 'member-default', 'completed', 'build', '[]', ?2, ?3, + 'coordinator', 'turn-corrupt', ?4, ?4)", + rusqlite::params![ + &run_id, + serde_json::json!({TASK_METADATA_ELIGIBLE_MEMBER_IDS: ["member-default"]}).to_string(), + historical_metadata[TASK_METADATA_OUTPUT].to_string(), + now + ], ) .expect("seed historical oversized producer"); let predicate = corrupt_task_row_predicate_sql(); @@ -715,10 +773,17 @@ fn store_rejects_malformed_reserved_dispatch_metadata() { conn.execute( "INSERT INTO agent_org_runtime_tasks (id, org_run_id, subject, description, active_form, owner, status, - blocks_json, blocked_by_json, metadata_json, created_at, updated_at) + execution_mode, blocked_by_json, metadata_json, output_json, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) VALUES ('historical-output-zone', ?1, 'historical', '', NULL, - 'member-default', 'completed', '[]', '[]', ?2, ?3, ?3)", - rusqlite::params![&run_id, timezone_less_metadata.to_string(), now], + 'member-default', 'completed', 'build', '[]', ?2, ?3, + 'coordinator', 'turn-corrupt', ?4, ?4)", + rusqlite::params![ + &run_id, + serde_json::json!({TASK_METADATA_ELIGIBLE_MEMBER_IDS: ["member-default"]}).to_string(), + timezone_less_metadata[TASK_METADATA_OUTPUT].to_string(), + now + ], ) .expect("seed historical timezone-less output"); let classified: bool = conn @@ -830,7 +895,7 @@ fn list_scopes_by_run_id() { } #[test] -fn update_applies_patch_and_clears_owner() { +fn update_rejects_terminal_completion_without_owner_and_output() { let _sandbox = task_store_sandbox(); let run_id = format!("run-{}", uuid::Uuid::new_v4()); let mut params = make_params(&run_id, "t-1", "draft subject"); @@ -838,7 +903,7 @@ fn update_applies_patch_and_clears_owner() { params.status = TaskStatus::InProgress; AgentOrgTaskStore::create(params).unwrap(); - let updated = AgentOrgTaskStore::update( + let error = AgentOrgTaskStore::update( &run_id, "t-1", UpdateTaskPatch { @@ -849,17 +914,14 @@ fn update_applies_patch_and_clears_owner() { ..Default::default() }, ) - .unwrap(); - - assert_eq!(updated.subject, "final subject"); - assert_eq!(updated.description, "filled in"); - assert_eq!(updated.status, TaskStatus::Completed); - assert!(updated.owner.is_none()); - - // updated_at must have advanced (or at least be present and different - // shape — we can't assert strict > because RFC3339 strings may match - // when the test runs faster than 1s; presence + rewrite is enough). - assert!(!updated.updated_at.is_empty()); + .expect_err("completion cannot clear the canonical Owner or omit output"); + assert!( + error.contains("owner") || error.contains("output"), + "{error}" + ); + let stored = AgentOrgTaskStore::get(&run_id, "t-1").unwrap().unwrap(); + assert_eq!(stored.status, TaskStatus::InProgress); + assert_eq!(stored.owner.as_deref(), Some("member-alpha")); } #[test] @@ -869,6 +931,16 @@ fn store_rejects_completed_task_status_regression() { let mut params = make_params(&run_id, "done-once", "done once"); params.owner = Some("member-alpha".into()); params.status = TaskStatus::Completed; + params.metadata = Some(serde_json::json!({ + TASK_METADATA_ELIGIBLE_MEMBER_IDS: ["member-alpha"], + TASK_METADATA_OUTPUT: { + "summary": "done", + "content": null, + "artifactIds": [], + "producedByMemberId": "member-alpha", + "producedAt": chrono::Utc::now().to_rfc3339(), + } + })); AgentOrgTaskStore::create(params).unwrap(); let err = AgentOrgTaskStore::update( @@ -994,7 +1066,7 @@ fn create_batch_rejects_oversized_internal_graph_before_writes() { let _sandbox = task_store_sandbox(); let run_id = format!("run-{}", uuid::Uuid::new_v4()); let template = make_params(&run_id, "template", "template"); - let graph = (0..=crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_TASKS) + let graph = (0..=crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS) .map(|index| { let mut task = template.clone(); task.id = format!("task-{index}"); @@ -1012,7 +1084,7 @@ fn create_batch_rejects_oversized_internal_graph_before_writes() { #[test] fn run_task_capacity_applies_to_single_and_existing_plus_batch_create() { let _sandbox = task_store_sandbox(); - let maximum = crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_TASKS; + let maximum = crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS; let single_run_id = format!("run-single-capacity-{}", uuid::Uuid::new_v4()); AgentOrgTaskStore::create_batch(make_task_batch(&single_run_id, "seed", maximum - 1), true) @@ -1058,7 +1130,7 @@ fn run_task_capacity_applies_to_single_and_existing_plus_batch_create() { #[test] fn concurrent_single_creates_cannot_cross_run_task_capacity() { let _sandbox = task_store_sandbox(); - let maximum = crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_TASKS; + let maximum = crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS; let run_id = format!("run-concurrent-capacity-{}", uuid::Uuid::new_v4()); AgentOrgTaskStore::create_batch(make_task_batch(&run_id, "seed", maximum - 1), true) .expect("seed one slot below the run capacity"); @@ -1416,67 +1488,6 @@ fn enqueue_task_assigned_rejects_unowned_task() { .unwrap_err(); assert!(err.contains("unowned"), "{err}"); } - -#[test] -fn shutdown_disposition_releases_only_when_peer_is_eligible() { - let _sandbox = task_store_sandbox(); - let run_id = format!("run-{}", uuid::Uuid::new_v4()); - - let mut t1 = make_eligible_params(&run_id, "t1", "S1", &["alice", "bob"]); - t1.owner = Some("alice".into()); - t1.status = TaskStatus::InProgress; - AgentOrgTaskStore::create(t1).unwrap(); - let mut t2 = make_eligible_params(&run_id, "t2", "S2", &["alice"]); - t2.owner = Some("alice".into()); - t2.status = TaskStatus::InProgress; - AgentOrgTaskStore::create(t2).unwrap(); - let mut t3 = make_eligible_params(&run_id, "t3", "S3", &["bob"]); - t3.owner = Some("bob".into()); - t3.status = TaskStatus::InProgress; - AgentOrgTaskStore::create(t3).unwrap(); - let mut t4 = make_eligible_params(&run_id, "t4", "S4", &["alice"]); - t4.owner = Some("alice".into()); - t4.status = TaskStatus::InProgress; - AgentOrgTaskStore::create(t4).unwrap(); - // Mark t2 completed; unassign should leave it alone. - AgentOrgTaskStore::update( - &run_id, - "t2", - UpdateTaskPatch { - status: Some(TaskStatus::Completed), - ..Default::default() - }, - ) - .unwrap(); - // t3 owned by bob — must not be touched. - - let unassigned = AgentOrgTaskStore::dispose_open_tasks_for_shutdown(&run_id, "alice").unwrap(); - assert_eq!(unassigned.len(), 2); - assert_eq!(unassigned[0].id, "t1"); - assert!(unassigned[0].owner.is_none()); - assert_eq!(unassigned[0].status, TaskStatus::Pending); - let escalated = unassigned.iter().find(|task| task.id == "t4").unwrap(); - assert_eq!(escalated.owner.as_deref(), Some("coordinator")); - assert_eq!(escalated.status, TaskStatus::Pending); - let escalated_event = AgentOrgTaskStore::list_history(&run_id) - .unwrap() - .into_iter() - .rev() - .find(|event| event.task_id == "t4") - .unwrap(); - assert_eq!( - escalated_event.event_type, - TASK_EVENT_ESCALATED_TO_COORDINATOR - ); - - // t2 stays completed + owned, t3 stays owned by bob. - let t2 = AgentOrgTaskStore::get(&run_id, "t2").unwrap().unwrap(); - assert_eq!(t2.status, TaskStatus::Completed); - assert_eq!(t2.owner.as_deref(), Some("alice")); - let t3 = AgentOrgTaskStore::get(&run_id, "t3").unwrap().unwrap(); - assert_eq!(t3.owner.as_deref(), Some("bob")); -} - // ============================================================ // ready_unassigned_tasks (single-pass scan) // ============================================================ @@ -1490,9 +1501,17 @@ fn plain_task(id: &str, status: TaskStatus) -> Task { active_form: None, owner: None, status, + execution_mode: TaskExecutionMode::Build, blocks: Vec::new(), blocked_by: Vec::new(), metadata: None, + output: None, + failure_reason: None, + cancel_reason: None, + created_by_participant_id: "coordinator".into(), + source_turn_intent_id: "turn-test".into(), + originating_message_id: None, + replaces_task_id: None, created_at: "2026-01-01T00:00:00Z".into(), updated_at: "2026-01-01T00:00:00Z".into(), } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs index 7bf443fe06..d06cca8746 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs @@ -11,6 +11,9 @@ use crate::foundation::session_bridge::{TurnIntentBridgeSource, TurnIntentBridge use super::agent_org_runs::{AgentOrgRunStatus, COORDINATOR_MEMBER_ID}; +const TASK_WAKE_CANDIDATE_LIMIT: i64 = + crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS as i64 + 1; + pub(crate) const TURN_CONTEXT_INVARIANT_PREFIX: &str = "agent_org_turn_context_invalid:"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -354,6 +357,379 @@ pub(crate) fn accept(request: &AgentOrgTurnAdmission) -> Result, + member_id: &str, +) -> Result { + database::db::with_sessions_writer(|| { + let mut connection = database::db::get_connection().map_err(|error| error.to_string())?; + let transaction = connection + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let context = accept_wake_with_connection( + &transaction, + org_run_id, + session_id, + turn_intent_id, + client_message_id, + member_id, + )?; + transaction.commit().map_err(|error| error.to_string())?; + Ok(context) + }) +} + +fn accept_wake_with_connection( + conn: &Connection, + org_run_id: &str, + session_id: &str, + turn_intent_id: &str, + client_message_id: Option, + member_id: &str, +) -> Result { + if member_id == COORDINATOR_MEMBER_ID { + return accept_with_connection( + conn, + &AgentOrgTurnAdmission::coordinator( + org_run_id, + session_id, + turn_intent_id, + client_message_id, + TurnIntentBridgeSource::Resume, + ), + ); + } + + let (task_id, activation_generation) = + resolve_next_task_wake_binding(conn, org_run_id, session_id, member_id)?; + accept_with_connection( + conn, + &AgentOrgTurnAdmission::task_execution( + org_run_id, + session_id, + turn_intent_id, + client_message_id, + task_id, + member_id, + activation_generation, + ), + ) +} + +/// Re-check the persisted authority immediately before a queued Agent Org +/// Turn is promoted to Running. Admission is intentionally not a lease: Task +/// cancellation/reassignment, dependency changes, member replacement, or an +/// activation-generation fence may happen while the scheduler queue waits. +pub(crate) fn revalidate_context_with_connection( + conn: &Connection, + session_id: &str, + turn_intent_id: &str, +) -> Result { + let context = require_context_with_connection(conn, session_id, turn_intent_id)?; + let run: Option<(Option, Option, i64, String)> = conn + .query_row( + "SELECT root_session_id, org_snapshot_json, activation_generation, status + FROM agent_org_runtime_runs WHERE id=?1", + [&context.org_run_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((root_session_id, snapshot_json, generation, status_raw)) = run else { + return Err(invariant_error(format!( + "run {} disappeared before Turn execution", + context.org_run_id + ))); + }; + let status = AgentOrgRunStatus::parse(&status_raw) + .ok_or_else(|| invariant_error(format!("unknown run status {status_raw:?}")))?; + if status != AgentOrgRunStatus::Running { + return Err(invariant_error(format!( + "Turn execution requires a running Team, found {status}" + ))); + } + let snapshot_json = snapshot_json.ok_or_else(|| { + invariant_error(format!( + "run {} has no immutable launch snapshot", + context.org_run_id + )) + })?; + let snapshot: AgentOrgLaunchSnapshot = serde_json::from_str(&snapshot_json) + .map_err(|error| invariant_error(format!("invalid launch snapshot JSON: {error}")))?; + validate_launch_snapshot(&snapshot) + .map_err(|error| invariant_error(format!("invalid launch snapshot: {error}")))?; + + match context.turn_kind { + AgentOrgTurnKind::Coordinator => { + if root_session_id.as_deref() != Some(session_id) + || context.participant_id != COORDINATOR_MEMBER_ID + || context.activation_generation != Some(generation) + { + return Err(invariant_error( + "Coordinator Turn no longer matches canonical Root/generation".to_string(), + )); + } + resolve_materialization_version_for_context( + conn, + &context, + COORDINATOR_MEMBER_ID, + &snapshot.coordinator_agent_id, + )?; + } + AgentOrgTurnKind::TaskExecution => { + let task_id = context.task_id.as_deref().ok_or_else(|| { + invariant_error("TaskExecution context has no task_id".to_string()) + })?; + let owner_member_id = context.owner_member_id.as_deref().ok_or_else(|| { + invariant_error("TaskExecution context has no owner_member_id".to_string()) + })?; + if context.participant_id != owner_member_id + || context.dispatch_member_id.as_deref() != Some(owner_member_id) + || context.activation_generation != Some(generation) + { + return Err(invariant_error( + "TaskExecution context no longer matches participant/generation".to_string(), + )); + } + let agent_id = snapshot_member_agent_id(&snapshot, owner_member_id)?; + resolve_materialization_version_for_context(conn, &context, owner_member_id, agent_id)?; + validate_task_execution_target(conn, &context.org_run_id, task_id, owner_member_id)?; + } + AgentOrgTurnKind::UserDirectedWork => { + return Err(invariant_error( + "UserDirectedWork execution is not enabled in the PR4 formal wake path".to_string(), + )); + } + } + Ok(context) +} + +fn resolve_next_task_wake_binding( + conn: &Connection, + org_run_id: &str, + session_id: &str, + member_id: &str, +) -> Result<(String, i64), String> { + let generation: Option = conn + .query_row( + "SELECT activation_generation FROM agent_org_runtime_runs + WHERE id=?1 AND status='running'", + [org_run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + let generation = generation.ok_or_else(|| { + invariant_error(format!("Member wake requires running Team {org_run_id}")) + })?; + + let mut statement = conn + .prepare( + "SELECT id, payload_kind, payload_json + FROM agent_org_runtime_inbox + WHERE org_run_id=?1 + AND recipient_member_id=?2 + AND read_at IS NULL + AND payload_kind IN ('task_assigned','plan_approval_response') + AND NOT EXISTS ( + SELECT 1 FROM agent_org_runtime_inbox_delivery_resolutions resolution + WHERE resolution.inbox_id=agent_org_runtime_inbox.id + ) + ORDER BY id ASC + LIMIT ?3", + ) + .map_err(|error| error.to_string())?; + let candidates = statement + .query_map( + params![org_run_id, member_id, TASK_WAKE_CANDIDATE_LIMIT], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + drop(statement); + + if candidates.len() >= TASK_WAKE_CANDIDATE_LIMIT as usize { + return Err(invariant_error(format!( + "Member {member_id} has too many pending formal wake candidates" + ))); + } + + for (inbox_id, payload_kind, payload_json) in candidates { + let message: crate::coordination::agent_inbox::AgentMessage = + match serde_json::from_str(&payload_json) { + Ok(message) => message, + Err(error) => { + tracing::warn!( + run_id = %org_run_id, + member_id, + inbox_id, + error = %error, + "ignoring malformed formal wake candidate" + ); + continue; + } + }; + if let Err(error) = message.validate() { + tracing::warn!( + run_id = %org_run_id, + member_id, + inbox_id, + error = %error, + "ignoring invalid formal wake candidate" + ); + continue; + } + + let task_id = match (payload_kind.as_str(), message) { + ( + "task_assigned", + crate::coordination::agent_inbox::AgentMessage::TaskAssigned { task_id, .. }, + ) if task_is_pending_and_ready(conn, org_run_id, &task_id, member_id)? => task_id, + ( + "plan_approval_response", + crate::coordination::agent_inbox::AgentMessage::PlanApprovalResponse { + request_id, + accepted: false, + .. + }, + ) => { + let task_id = planning_revision_task( + conn, + org_run_id, + session_id, + member_id, + request_id.as_str(), + )?; + let Some(task_id) = task_id else { + continue; + }; + task_id + } + _ => continue, + }; + return Ok((task_id, generation)); + } + + Err(invariant_error(format!( + "Member {member_id} has no canonical ready TaskExecution input" + ))) +} + +fn task_is_pending_and_ready( + conn: &Connection, + org_run_id: &str, + task_id: &str, + member_id: &str, +) -> Result { + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM agent_org_runtime_tasks task + WHERE task.org_run_id=?1 AND task.id=?2 + AND task.owner=?3 AND task.status='pending' + AND NOT EXISTS ( + SELECT 1 + FROM json_each(task.blocked_by_json) edge + LEFT JOIN agent_org_runtime_tasks blocker + ON blocker.org_run_id=task.org_run_id + AND blocker.id=CAST(edge.value AS TEXT) + WHERE edge.type<>'text' + OR blocker.id IS NULL + OR blocker.status<>'completed' + ) + )", + params![org_run_id, task_id, member_id], + |row| row.get(0), + ) + .map_err(|error| error.to_string()) +} + +fn planning_revision_task( + conn: &Connection, + org_run_id: &str, + session_id: &str, + member_id: &str, + request_id: &str, +) -> Result, String> { + conn.query_row( + "SELECT approval.source_task_id + FROM agent_org_runtime_plan_approvals approval + JOIN agent_org_runtime_tasks task + ON task.org_run_id=approval.org_run_id + AND task.id=approval.source_task_id + JOIN agent_org_runtime_turn_contexts source_context + ON source_context.session_id=approval.source_session_id + AND source_context.turn_intent_id=approval.source_turn_intent_id + AND source_context.org_run_id=approval.org_run_id + AND source_context.turn_kind='task_execution' + AND source_context.task_id=approval.source_task_id + AND source_context.owner_member_id=approval.source_member_id + WHERE approval.org_run_id=?1 + AND approval.request_id=?2 + AND approval.status='changes_requested' + AND approval.source_member_id=?3 + AND approval.source_session_id=?4 + AND task.owner=?3 + AND task.status='in_progress' + AND task.execution_mode='plan' + LIMIT 1", + params![org_run_id, request_id, member_id, session_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string()) +} + +fn validate_task_execution_target( + conn: &Connection, + org_run_id: &str, + task_id: &str, + owner_member_id: &str, +) -> Result<(), String> { + let target: Option = conn + .query_row( + "SELECT status FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id=?2 AND owner=?3", + params![org_run_id, task_id, owner_member_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + match target.as_deref() { + Some("pending") + if task_is_pending_and_ready(conn, org_run_id, task_id, owner_member_id)? => + { + Ok(()) + } + Some("in_progress") => Ok(()), + Some(status) => Err(invariant_error(format!( + "TaskExecution target {task_id} is not runnable (status {status})" + ))), + None => Err(invariant_error(format!( + "TaskExecution target {task_id} is missing or no longer owned by {owner_member_id}" + ))), + } +} + /// Connection-scoped admission for lifecycle owners that already hold an /// IMMEDIATE transaction (notably Starting completion). pub(crate) fn accept_with_connection( @@ -693,6 +1069,51 @@ fn resolve_materialization_version( }) } +fn resolve_materialization_version_for_context( + conn: &Connection, + context: &AgentOrgTurnContext, + member_id: &str, + agent_id: &str, +) -> Result { + let version: Option = conn + .query_row( + "SELECT materialization.generation + FROM agent_org_runtime_member_materializations materialization + JOIN agent_sessions session + ON session.session_id=materialization.session_id + WHERE materialization.org_run_id=?1 + AND materialization.member_id=?2 + AND materialization.agent_id=?3 + AND materialization.session_id=?4 + AND materialization.status='succeeded' + AND session.agent_definition_id=?3 + AND session.org_member_id=?2 + AND materialization.generation=( + SELECT MAX(latest.generation) + FROM agent_org_runtime_member_materializations latest + WHERE latest.org_run_id=?1 + AND latest.member_id=?2 + AND latest.status='succeeded' + ) + LIMIT 1", + params![ + &context.org_run_id, + member_id, + agent_id, + &context.session_id, + ], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())?; + version.ok_or_else(|| { + invariant_error(format!( + "session {} is not the latest canonical materialization for {}/{}", + context.session_id, context.org_run_id, member_id + )) + }) +} + fn snapshot_member_agent_id<'a>( snapshot: &'a AgentOrgLaunchSnapshot, member_id: &str, diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs index 8eb996ea73..a9eb08c65f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts/tests.rs @@ -109,15 +109,38 @@ fn create_fixture(conn: &Connection) { org_run_id TEXT NOT NULL, id TEXT NOT NULL, owner TEXT, + status TEXT NOT NULL DEFAULT 'pending', + execution_mode TEXT NOT NULL DEFAULT 'build', + blocked_by_json TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(org_run_id, id), FOREIGN KEY(org_run_id) REFERENCES agent_org_runtime_runs(id) ON DELETE CASCADE ); CREATE TABLE agent_org_runtime_inbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_agent_id TEXT NOT NULL DEFAULT 'agent-member', org_run_id TEXT NOT NULL, recipient_member_id TEXT, + sender_agent_id TEXT NOT NULL DEFAULT '_system', + sender_member_id TEXT, + payload_kind TEXT NOT NULL DEFAULT 'plain', + payload_json TEXT NOT NULL DEFAULT '{}', + request_id TEXT, + created_at TEXT NOT NULL DEFAULT 'now', + read_at TEXT, FOREIGN KEY(org_run_id) REFERENCES agent_org_runtime_runs(id) ON DELETE CASCADE ); + CREATE TABLE agent_org_runtime_inbox_delivery_resolutions ( + inbox_id INTEGER PRIMARY KEY + ); + CREATE TABLE agent_org_runtime_plan_approvals ( + request_id TEXT PRIMARY KEY, + org_run_id TEXT NOT NULL, + source_task_id TEXT NOT NULL, + source_member_id TEXT NOT NULL, + source_session_id TEXT NOT NULL, + source_turn_intent_id TEXT NOT NULL, + status TEXT NOT NULL + ); CREATE TABLE agent_org_runtime_initial_inputs ( org_run_id TEXT PRIMARY KEY, turn_intent_id TEXT NOT NULL, @@ -144,7 +167,9 @@ fn create_fixture(conn: &Connection) { VALUES ('run-a', 'coordinator', 'agent-coordinator', 1, 'session-root', 'succeeded'), ('run-a', 'member-a', 'agent-member', 1, 'session-member', 'succeeded'); - INSERT INTO agent_org_runtime_tasks VALUES ('run-a', 'task-a', 'member-a'); + INSERT INTO agent_org_runtime_tasks + (org_run_id,id,owner,status,execution_mode,blocked_by_json) + VALUES ('run-a', 'task-a', 'member-a', 'pending', 'build', '[]'); INSERT INTO events VALUES ('event-direct', 'session-member'); INSERT INTO agent_org_runtime_inbox (org_run_id, recipient_member_id) VALUES ('run-a', 'member-a'), ('run-a', 'member-a');", @@ -158,6 +183,27 @@ fn connection() -> Connection { conn } +fn insert_task_assignment(conn: &Connection, task_id: &str) -> i64 { + let message = crate::coordination::agent_inbox::AgentMessage::TaskAssigned { + task_id: task_id.to_string(), + subject: format!("Task {task_id}"), + description: String::new(), + assigned_by: "Coordinator".into(), + execution_mode: crate::coordination::agent_org_tasks::TaskExecutionMode::Build, + dependency_outputs: Vec::new(), + }; + let payload = serde_json::to_string(&message).expect("serialize TaskAssigned"); + conn.execute( + "INSERT INTO agent_org_runtime_inbox ( + recipient_agent_id,org_run_id,recipient_member_id,sender_agent_id, + sender_member_id,payload_kind,payload_json,created_at + ) VALUES ('agent-member',?1,?2,'_system',NULL,'task_assigned',?3,'now')", + params![RUN_ID, MEMBER_ID, payload], + ) + .expect("insert TaskAssigned"); + conn.last_insert_rowid() +} + fn accept_in_transaction( conn: &mut Connection, request: &AgentOrgTurnAdmission, @@ -287,6 +333,116 @@ fn coordinator_is_root_scoped_and_never_allocates_member_sequence() { assert_eq!(row_count(&conn, "session_turn_intents"), 3); } +#[test] +fn member_wake_binds_oldest_dependency_ready_assignment_and_revalidates_at_start() { + let mut conn = connection(); + conn.execute_batch( + "INSERT INTO agent_org_runtime_tasks + (org_run_id,id,owner,status,execution_mode,blocked_by_json) + VALUES + ('run-a','blocker','member-a','failed','build','[]'), + ('run-a','task-blocked','member-a','pending','build','[\"blocker\"]');", + ) + .expect("seed blocked formal work"); + let blocked_inbox_id = insert_task_assignment(&conn, "task-blocked"); + let ready_inbox_id = insert_task_assignment(&conn, "task-a"); + + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .expect("wake transaction"); + let context = accept_wake_with_connection( + &transaction, + RUN_ID, + MEMBER_SESSION_ID, + "turn-wake-ready", + Some("wake-ready".into()), + MEMBER_ID, + ) + .expect("accept ready TaskExecution"); + transaction.commit().expect("commit ready admission"); + assert_eq!(context.turn_kind, AgentOrgTurnKind::TaskExecution); + assert_eq!(context.task_id.as_deref(), Some("task-a")); + assert_eq!(context.owner_member_id.as_deref(), Some(MEMBER_ID)); + assert_eq!(context.activation_generation, Some(1)); + assert_eq!(context.member_dispatch_sequence, Some(1)); + assert!( + revalidate_context_with_connection(&conn, MEMBER_SESSION_ID, "turn-wake-ready").is_ok() + ); + let still_unread: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_org_runtime_inbox + WHERE id IN (?1,?2) AND read_at IS NULL", + params![blocked_inbox_id, ready_inbox_id], + |row| row.get(0), + ) + .expect("read deferred Inbox state"); + assert_eq!( + still_unread, 2, + "admission must not acknowledge Inbox input" + ); + + conn.execute( + "UPDATE agent_org_runtime_tasks SET owner='member-reassigned' + WHERE org_run_id=?1 AND id='task-a'", + [RUN_ID], + ) + .expect("race reassign task"); + let error = revalidate_context_with_connection(&conn, MEMBER_SESSION_ID, "turn-wake-ready") + .expect_err("queued stale owner binding must fail before Provider execution"); + assert!(error.contains("no longer owned"), "{error}"); +} + +#[test] +fn failed_or_cancelled_blockers_never_unlock_a_task_wake() { + let mut conn = connection(); + conn.execute_batch( + "UPDATE agent_org_runtime_tasks SET status='cancelled' WHERE id='task-a'; + INSERT INTO agent_org_runtime_tasks + (org_run_id,id,owner,status,execution_mode,blocked_by_json) + VALUES + ('run-a','blocker','member-a','failed','build','[]'), + ('run-a','downstream','member-a','pending','build','[\"blocker\"]');", + ) + .expect("seed failed dependency"); + insert_task_assignment(&conn, "downstream"); + + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .expect("wake transaction"); + let error = accept_wake_with_connection( + &transaction, + RUN_ID, + MEMBER_SESSION_ID, + "turn-blocked", + None, + MEMBER_ID, + ) + .expect_err("failed dependency cannot authorize TaskExecution"); + transaction.rollback().expect("rollback rejected wake"); + assert!(error.contains("no canonical ready"), "{error}"); + + conn.execute( + "UPDATE agent_org_runtime_tasks SET status='completed' + WHERE org_run_id=?1 AND id='blocker'", + [RUN_ID], + ) + .expect("complete dependency"); + let transaction = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .expect("unblocked wake transaction"); + let context = accept_wake_with_connection( + &transaction, + RUN_ID, + MEMBER_SESSION_ID, + "turn-unblocked", + None, + MEMBER_ID, + ) + .expect("completed dependency authorizes TaskExecution"); + transaction.commit().expect("commit unblocked wake"); + assert_eq!(context.task_id.as_deref(), Some("downstream")); +} + #[test] fn every_member_kind_and_source_shares_one_fifo_and_replay_does_not_bump_it() { let mut conn = connection(); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs index 6a04889b04..4b4567ec04 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs @@ -39,8 +39,10 @@ pub use budget::clear_rewake_budget; pub(crate) use budget::create_schema; pub use budget::init_schema; pub(crate) use budget::member_rewake_fingerprint; +pub(crate) use budget::task_failure_recovery_attempts_exhausted; #[cfg(test)] pub use budget::test_only_mark_failed_rewake_attempt; +pub(crate) use budget::{reserve_task_failure_recovery, reserve_task_shutdown_release}; pub use inspect::inspect_stalled_run; pub use plan::{MemberContinuationAction, MemberTaskAssignmentAction, StallRecoveryPlan}; pub use recover::{recover_stalled_run, spawn}; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs index 51a3a3ea4d..be0922bae2 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs @@ -192,6 +192,105 @@ pub(super) struct RecoveryAttemptSnapshot { pub(super) reservation_token: Option, } +#[derive(Debug, Clone)] +pub(crate) struct TaskRecoveryReservation { + pub(crate) token: String, + pub(crate) generation: i64, + pub(crate) exhausted: bool, +} + +pub(crate) fn task_failure_recovery_attempts_exhausted(attempts: i64) -> bool { + attempts > RECOVERY_DELAYS_SECS.len() as i64 +} + +/// Reserve the receipt consumed by the Task Store's system actor. Task state +/// recovery shares the durable recovery-attempt table but uses its own action +/// kind, so it cannot impersonate a member-rewake or coordinator notice. +pub(crate) fn reserve_task_failure_recovery( + run_id: &str, + owner_member_id: &str, +) -> Result { + reserve_task_system_operation(run_id, "task_failure_recovery", owner_member_id, true) +} + +pub(crate) fn reserve_task_shutdown_release( + run_id: &str, + owner_member_id: &str, +) -> Result { + reserve_task_system_operation(run_id, "task_shutdown_release", owner_member_id, false) +} + +fn reserve_task_system_operation( + run_id: &str, + action_kind: &str, + target_key: &str, + budgeted: bool, +) -> Result { + with_sessions_writer(|| -> Result { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let generation: i64 = tx + .query_row( + "SELECT activation_generation FROM agent_org_runtime_runs + WHERE id=?1 AND status='running'", + params![run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("agent_org_run_not_mutable: {run_id}"))?; + let previous_attempts: i64 = tx + .query_row( + "SELECT attempts FROM agent_org_runtime_recovery_attempts + WHERE org_run_id=?1 AND action_kind=?2 AND target_key=?3", + params![run_id, action_kind, target_key], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .unwrap_or(0) + .max(0); + let attempts = if budgeted { + previous_attempts.saturating_add(1) + } else { + 1 + }; + let exhausted = budgeted && task_failure_recovery_attempts_exhausted(attempts); + let token = uuid::Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + tx.execute( + "INSERT INTO agent_org_runtime_recovery_attempts( + org_run_id,action_kind,target_key,reason_fingerprint,attempts, + next_allowed_at,updated_at,reservation_token + ) VALUES (?1,?2,?3,?4,?5,?6,?6,?7) + ON CONFLICT(org_run_id,action_kind,target_key) DO UPDATE SET + reason_fingerprint=excluded.reason_fingerprint, + attempts=excluded.attempts, + next_allowed_at=excluded.next_allowed_at, + updated_at=excluded.updated_at, + reservation_token=excluded.reservation_token", + params![ + run_id, + action_kind, + target_key, + format!("generation:{generation}"), + attempts, + &now, + &token, + ], + ) + .map_err(|error| error.to_string())?; + tx.commit().map_err(|error| error.to_string())?; + Ok(TaskRecoveryReservation { + token, + generation, + exhausted, + }) + }) +} + #[cfg(test)] pub fn test_only_mark_failed_rewake_attempt(run_id: &str, member_id: &str) -> Result { let fingerprint = member_rewake_fingerprint(run_id, member_id, SessionStatus::Failed)?; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/facts.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/facts.rs index 5845c7b7a3..6c1f6285ad 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/facts.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/facts.rs @@ -76,17 +76,21 @@ pub(super) fn corrupt_task_repair_facts( conn: &Connection, run_id: &str, ) -> Result, String> { - let task_count: i64 = conn + const MAX_CORRUPT_TASK_FACTS: usize = 200; + let open_task_count: i64 = conn .query_row( - "SELECT COUNT(*) FROM agent_org_runtime_tasks WHERE org_run_id=?1", + "SELECT COUNT(*) FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND status IN ('pending','in_progress')", params![run_id], |row| row.get(0), ) .map_err(|err| err.to_string())?; - if task_count > crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_TASKS as i64 { + if open_task_count + > crate::coordination::agent_org_payload_limits::TASK_RUN_MAX_OPEN_TASKS as i64 + { return Ok(vec![RecoveryRepairFact::new( - "task_run_limit_exceeded", - [Some(task_count.to_string())], + "task_run_open_limit_exceeded", + [Some(open_task_count.to_string())], )]); } let predicate = agent_org_tasks::corrupt_task_row_predicate_sql(); @@ -96,15 +100,18 @@ pub(super) fn corrupt_task_repair_facts( let sql = format!( "SELECT substr(id,1,1024), length(CAST(id AS BLOB)), substr(status,1,128), - length(CAST(blocks_json AS BLOB)), hex(substr(blocks_json,1,1024)), length(CAST(blocked_by_json AS BLOB)), hex(substr(blocked_by_json,1,1024)), length(CAST(COALESCE(metadata_json,'') AS BLOB)), - hex(substr(COALESCE(metadata_json,''),1,1024)) + hex(substr(COALESCE(metadata_json,''),1,1024)), + length(CAST(COALESCE(output_json,'') AS BLOB)), + length(CAST(COALESCE(failure_reason_json,'') AS BLOB)), + length(CAST(COALESCE(cancel_reason_json,'') AS BLOB)), + COUNT(*) OVER () FROM ( - SELECT id, subject, description, active_form, owner, status, - created_at, updated_at, - CASE WHEN length(CAST(blocks_json AS BLOB))<={dependency_json_max} - THEN blocks_json ELSE '!' END AS blocks_json, + SELECT id, subject, description, active_form, owner, status, execution_mode, + output_json, failure_reason_json, cancel_reason_json, + created_by_participant_id, source_turn_intent_id, + originating_message_id, replaces_task_id, created_at, updated_at, CASE WHEN length(CAST(blocked_by_json AS BLOB))<={dependency_json_max} THEN blocked_by_json ELSE '!' END AS blocked_by_json, CASE WHEN metadata_json IS NULL @@ -113,29 +120,49 @@ pub(super) fn corrupt_task_repair_facts( FROM agent_org_runtime_tasks WHERE org_run_id=?1 ) AS bounded_tasks WHERE {predicate} - ORDER BY id ASC" + ORDER BY id ASC + LIMIT {}", + MAX_CORRUPT_TASK_FACTS + 1 ); let mut stmt = conn.prepare(&sql).map_err(|err| err.to_string())?; let rows = stmt .query_map(params![run_id], |row| { - Ok(RecoveryRepairFact::new( - "corrupt_task_data", - [ - Some(row.get::<_, String>(0)?), - Some(row.get::<_, i64>(1)?.to_string()), - Some(row.get::<_, String>(2)?), - Some(row.get::<_, i64>(3)?.to_string()), - Some(row.get::<_, String>(4)?), - Some(row.get::<_, i64>(5)?.to_string()), - Some(row.get::<_, String>(6)?), - Some(row.get::<_, i64>(7)?.to_string()), - Some(row.get::<_, String>(8)?), - ], + Ok(( + RecoveryRepairFact::new( + "corrupt_task_data", + [ + Some(row.get::<_, String>(0)?), + Some(row.get::<_, i64>(1)?.to_string()), + Some(row.get::<_, String>(2)?), + Some(row.get::<_, i64>(3)?.to_string()), + Some(row.get::<_, String>(4)?), + Some(row.get::<_, i64>(5)?.to_string()), + Some(row.get::<_, String>(6)?), + Some(row.get::<_, i64>(7)?.to_string()), + Some(row.get::<_, i64>(8)?.to_string()), + Some(row.get::<_, i64>(9)?.to_string()), + ], + ), + row.get::<_, i64>(10)?, )) }) .map_err(|err| err.to_string())?; - rows.collect::, _>>() - .map_err(|err| err.to_string()) + let mut facts = Vec::new(); + let mut total_count = 0_i64; + for row in rows { + let (fact, count) = row.map_err(|err| err.to_string())?; + total_count = count; + if facts.len() < MAX_CORRUPT_TASK_FACTS { + facts.push(fact); + } + } + if total_count > MAX_CORRUPT_TASK_FACTS as i64 { + facts.push(RecoveryRepairFact::new( + "corrupt_task_data_truncated", + [Some(total_count.to_string())], + )); + } + Ok(facts) } pub(in crate::core::coordination::agent_org_watchdog) fn task_snapshot_fingerprint( diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/run.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/run.rs index 5b1eda0bc1..f04b451d2d 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/run.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/inspect/run.rs @@ -179,7 +179,7 @@ pub(in crate::core::coordination::agent_org_watchdog) fn inspect_stalled_run_wit } continue; }; - if unsupported_transport_members.contains(owner) && !task.status.is_resolved() { + if unsupported_transport_members.contains(owner) && task.status.is_open() { repair_facts.push(RecoveryRepairFact::new( "unsupported_transport", [Some(task.id.clone()), Some(owner.to_string())], @@ -246,7 +246,7 @@ pub(in crate::core::coordination::agent_org_watchdog) fn inspect_stalled_run_wit let mut owned_open_tasks_by_member: HashMap<&str, Vec> = HashMap::new(); let mut ready_pending_tasks_by_member: HashMap<&str, Vec> = HashMap::new(); for task in &tasks { - if task.status.is_resolved() || pending_plan_task_ids.contains(&task.id) { + if task.status.is_terminal() || pending_plan_task_ids.contains(&task.id) { continue; } if let Some(owner) = task.owner.as_deref() { @@ -362,7 +362,7 @@ pub(in crate::core::coordination::agent_org_watchdog) fn inspect_stalled_run_wit ); append_dependency_integrity_repairs(&tasks, &mut needs_repair, &mut repair_facts); for task in &tasks { - if task.status.is_resolved() { + if task.status.is_terminal() { continue; } if let Some(owner) = task.owner.as_deref() { @@ -475,7 +475,7 @@ pub(in crate::core::coordination::agent_org_watchdog) fn inspect_stalled_run_wit bounded_id_list_preview(&eligible, 8, 160) }; needs_repair.push(format!( - "task {} is still in_progress under member {} but appears stale; task_updated_at={}, owner_updated_at={}, eligible_member_ids=[{}]. The watchdog does not steal work from a Running member based on age alone. Ask the owner to continue/retry, reassign owner_member_id, or repair eligible_member_ids.", + "task {} is still in_progress under member {} but appears stale; task_updated_at={}, owner_updated_at={}, eligible_member_ids=[{}]. The watchdog does not steal work from a Running member based on age alone. Ask the owner to continue/retry, or use task_update operation=cancel_and_replace when the target or Owner must change.", task.id, owner, task.updated_at, @@ -550,7 +550,7 @@ pub(in crate::core::coordination::agent_org_watchdog) fn inspect_stalled_run_wit AgentOrgQuiescenceBlocker::CoordinatorHasNotObservedLatestWork { observed_work_revision, current_work_revision, - } if tasks.iter().all(|task| task.status.is_resolved()) => { + } if tasks.iter().all(|task| task.status.is_terminal()) => { repair_facts.push(RecoveryRepairFact::new( "coordinator_observation", [ diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/recover.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/recover.rs index 52e4c4bb47..05e4977974 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/recover.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/recover.rs @@ -608,7 +608,7 @@ fn insert_coordinator_stall_notice( message: AgentMessage::Plain { summary: "Agent Org recovery needed".to_string(), text: format!( - "The Agent Org watchdog detected stalled work that needs coordinator repair.\n\n{reason}\n\nUse task_list/task_get to inspect the task board, then use task_update owner_member_id or eligible_member_ids to repair dispatch. Never assign work outside eligible_member_ids." + "The Agent Org watchdog detected stalled work that needs coordinator repair.\n\n{reason}\n\nUse task_list/task_get to inspect the task board. For ownerless Pending work, use task_update operation=patch_pending with owner_member_id/eligible_member_ids; for changed in-progress work, use operation=cancel_and_replace. Never assign work outside eligible_member_ids." ), }, }, diff --git a/src-tauri/crates/agent-core/src/core/coordination/schema.rs b/src-tauri/crates/agent-core/src/core/coordination/schema.rs index 5d9a788013..5f8d297625 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/schema.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/schema.rs @@ -24,7 +24,7 @@ const RUNTIME_TABLES: [&str; 15] = [ "agent_org_runtime_recovery_attempts", "agent_org_runtime_tasks", "agent_org_runtime_task_events", - "agent_org_runtime_task_schema_migrations", + "agent_org_runtime_task_annotations", "agent_org_runtime_inbox", "agent_org_runtime_inbox_materializations", "agent_org_runtime_inbox_delivery_resolutions", @@ -103,11 +103,6 @@ pub(super) fn initialize(conn: &Connection) -> SqliteResult<()> { let unknown_objects = unknown_agent_org_objects(&tx)?; tx.commit()?; - // Dependency normalization has its own per-run transactions. Keep it - // outside the schema cutover transaction while preserving the historical - // init behavior for already-canonical runtime data. - agent_org_tasks::normalize_runtime_data(conn)?; - if !unknown_objects.is_empty() { tracing::warn!( event = "agent_org_unknown_schema_objects_preserved", @@ -526,15 +521,20 @@ mod tests { VALUES ('team-a', 'turn-a', 'message-a', 'hello', '{}', 'dispatched', '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z'); INSERT INTO agent_org_runtime_tasks - (id, org_run_id, subject, status, created_at, updated_at) - VALUES ('task-a', 'team-a', 'Task A', 'completed', + (id, org_run_id, subject, status, execution_mode, + created_by_participant_id, source_turn_intent_id, created_at, updated_at) + VALUES ('task-a', 'team-a', 'Task A', 'pending', 'build', + 'coordinator', 'turn-a', '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z'); INSERT INTO agent_org_runtime_task_events - (id, org_run_id, task_id, event_type, created_at) - VALUES ('event-a', 'team-a', 'task-a', 'completed', '2026-08-01T00:00:00Z'); - INSERT INTO agent_org_runtime_task_schema_migrations - (name, org_run_id, applied_at) - VALUES ('canonical_blocked_by_v1', 'team-a', '2026-08-01T00:00:00Z'); + (id, org_run_id, task_id, event_type, actor_member_id, actor_kind, created_at) + VALUES ('event-a', 'team-a', 'task-a', 'created', 'coordinator', 'graph_writer', + '2026-08-01T00:00:00Z'); + INSERT INTO agent_org_runtime_task_annotations + (id, org_run_id, task_id, kind, body, actor_kind, + actor_participant_id, created_at) + VALUES ('annotation-a', 'team-a', 'task-a', 'audit_note', 'fixture', + 'graph_writer', 'coordinator', '2026-08-01T00:00:00Z'); INSERT INTO agent_org_runtime_inbox (recipient_agent_id, recipient_member_id, sender_agent_id, sender_member_id, org_run_id, payload_kind, payload_json, created_at, read_at) @@ -549,10 +549,10 @@ mod tests { VALUES (2, 'team-a', 'cancelled', 'coordinator', 'done', '2026-08-01T00:00:02Z'); INSERT INTO agent_org_runtime_plan_approvals (approval_id, plan_revision_id, request_id, org_run_id, source_task_id, - source_member_id, source_session_id, root_session_id, policy, status, + source_member_id, source_session_id, source_turn_intent_id, root_session_id, policy, status, plan_title, plan_path, plan_content, created_at) VALUES ('approval-a', 'revision-a', 'request-a', 'team-a', 'task-a', - 'member-a', 'session-a', 'root-a', 'coordinator', 'approved', + 'member-a', 'session-a', 'turn-a', 'root-a', 'coordinator', 'approved', 'Plan', '/tmp/plan-a', '# plan', '2026-08-01T00:00:00Z'); INSERT INTO agent_org_runtime_recovery_attempts (org_run_id, action_kind, target_key, reason_fingerprint, attempts, diff --git a/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs b/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs index 4d778e2cb2..204849c972 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/work_item_run_dispatcher.rs @@ -550,6 +550,7 @@ async fn dispatch_session_turn( Some(run_id.clone()), None, None, + None, crate::foundation::session_bridge::TurnIntentBridgeSource::Queue, ) .await diff --git a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs index 9e35ea5bab..8d5c61a50b 100644 --- a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs +++ b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs @@ -5,6 +5,7 @@ use std::sync::atomic::AtomicBool; use async_trait::async_trait; use serde_json::Value; +use tokio::time::{sleep, Duration}; use super::traits::{ finish_reason, usage_key, LLMProvider, LLMResponse, ProviderError, StreamDelta, ToolCallRequest, @@ -14,6 +15,10 @@ const ADDRESS_COMMENTS_MARKER: &str = "Teammates left review comments on this session. Address every comment below"; const ADDRESS_COMMENT_ID_MARKER: &str = " — id: "; const REPLY_SESSION_COMMENT_TOOL: &str = "reply_session_comment"; +const PR4_TASK_FSM_MARKER: &str = "E2E_PR4_TASK_FSM:"; +const CONTROL_WAIT_MARKER: &str = "Create a stoppable window by waiting for about "; +const TASK_GRAPH_CREATE_TOOL: &str = "task_graph_create"; +const TASK_UPDATE_TOOL: &str = "task_update"; pub const E2E_FAKE_PROVIDER_MODEL_PREFIX: &str = "e2e-fake-provider"; @@ -32,19 +37,16 @@ impl E2eFakeProvider { .filter_map(|message| message.get("content").and_then(Value::as_str)) .collect::>() .join("\n"); + let latest_user = latest_model_user(messages).unwrap_or_default(); - if system_text.contains("You are a context compactor") { + if system_text.contains("You are a context compactor") + || latest_user.contains("You are a context compactor") + || (latest_user.contains("") + && latest_user.contains("")) + { return "E2E_FAKE_COMPACT_SUMMARY: older history was compacted without carrying old full markers forward.".to_string(); } - let latest_user = messages - .iter() - .rev() - .find(|message| message.get("role").and_then(Value::as_str) == Some("user")) - .and_then(|message| message.get("content")) - .and_then(content_text) - .unwrap_or_default(); - format!("E2E_FAKE_PROVIDER_REPLY: {latest_user}") } @@ -118,6 +120,307 @@ impl E2eFakeProvider { }) .collect() } + + fn pr4_task_fsm_tool_calls( + messages: &[Value], + tools: Option<&[Value]>, + ) -> Vec { + let Some((latest_user_index, latest_user)) = latest_pr4_user(messages) else { + return Vec::new(); + }; + let Some(scenario_id) = pr4_scenario_id(latest_user.as_str()) else { + return Vec::new(); + }; + let tool_results = messages[latest_user_index + 1..] + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool")) + .collect::>(); + + if is_task_assignment(latest_user.as_str()) { + if !Self::has_tool(tools, TASK_UPDATE_TOOL) { + return Vec::new(); + } + let Some(task_id) = task_assignment_value(&latest_user, "Task ID:", "task_id") else { + return Vec::new(); + }; + let subject = task_assignment_value(&latest_user, "Task assigned by", "subject") + .unwrap_or_default(); + let stage = tool_results.len(); + let arguments = if stage == 0 { + serde_json::json!({ "operation": "start", "id": task_id }) + } else if subject.contains("E2E_PR4_HISTORY:") && stage == 1 { + serde_json::json!({ + "operation": "complete", + "id": task_id, + "output": { + "summary": format!("E2E paged history result for {scenario_id}"), + }, + }) + } else if subject.contains("E2E_PR4_COMPLETE:") && stage == 1 { + serde_json::json!({ + "operation": "append_evidence", + "id": task_id, + "body": format!("E2E production-path evidence for {scenario_id}"), + }) + } else if subject.contains("E2E_PR4_COMPLETE:") && stage == 2 { + serde_json::json!({ + "operation": "complete", + "id": task_id, + "output": { + "summary": format!("E2E completed {scenario_id}"), + "content": "Completed through Provider -> Tool dispatcher -> Task Store.", + "artifact_ids": [format!("e2e-artifact-{scenario_id}")], + }, + }) + } else if subject.contains("E2E_PR4_FAIL:") && stage == 1 { + serde_json::json!({ + "operation": "append_progress", + "id": task_id, + "body": format!("E2E progress before failure for {scenario_id}"), + }) + } else if subject.contains("E2E_PR4_FAIL:") && stage == 2 { + serde_json::json!({ + "operation": "fail", + "id": task_id, + "reason": { + "code": "e2e.expected_failure", + "message": format!("Deterministic E2E failure for {scenario_id}"), + }, + }) + } else if subject.contains("E2E_PR4_LATE:") && stage == 1 { + // The coordinator cancels and replaces this Task while this + // owner callback is delayed. The Store must reject the late + // completion against the original persisted Turn binding. + serde_json::json!({ + "operation": "complete", + "id": task_id, + "output": { "summary": format!("Late output for {scenario_id}") }, + }) + } else { + return Vec::new(); + }; + return vec![ToolCallRequest { + id: format!("e2e-pr4-owner-{scenario_id}-{task_id}-{stage}"), + name: TASK_UPDATE_TOOL.to_string(), + arguments, + thought_signature: None, + }]; + } + + if !Self::has_tool(tools, TASK_GRAPH_CREATE_TOOL) + || !Self::has_tool(tools, TASK_UPDATE_TOOL) + { + return Vec::new(); + } + match tool_results.len() { + 0 => { + let mut tasks = vec![ + serde_json::json!({ + "key": "pending", + "subject": format!("E2E_PR4_PENDING:{scenario_id}"), + "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "execution_mode": "build", + "eligible_member_ids": ["sde-implementer"] + }), + serde_json::json!({ + "key": "complete", + "subject": format!("E2E_PR4_COMPLETE:{scenario_id}"), + "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "owner_member_id": "sde-reviewer", + "execution_mode": "build" + }), + serde_json::json!({ + "key": "fail", + "subject": format!("E2E_PR4_FAIL:{scenario_id}"), + "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "owner_member_id": "sde-tester", + "execution_mode": "build" + }), + serde_json::json!({ + "key": "late", + "subject": format!("E2E_PR4_LATE:{scenario_id}"), + "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "owner_member_id": "sde-planner", + "execution_mode": "build" + }), + ]; + if scenario_id.starts_with("page") { + let owners = [ + "sde-implementer", + "sde-reviewer", + "sde-tester", + "sde-planner", + ]; + tasks.extend((0..20).map(|index| { + serde_json::json!({ + "key": format!("history-{index:02}"), + "subject": format!("E2E_PR4_HISTORY:{scenario_id}:{index:02}"), + "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "owner_member_id": owners[index % owners.len()], + "execution_mode": "build" + }) + })); + } + vec![ToolCallRequest { + id: format!("e2e-pr4-graph-{scenario_id}"), + name: TASK_GRAPH_CREATE_TOOL.to_string(), + arguments: serde_json::json!({ + "allow_parallel_with_existing_open_tasks": true, + "tasks": tasks, + }), + thought_signature: None, + }] + } + 1 => { + let Some(late_task_id) = tool_results + .iter() + .filter_map(|message| message.get("content")) + .filter_map(tool_result_json) + .find_map(|result| { + result + .get("task_id_by_key") + .and_then(|value| value.get("late")) + .and_then(Value::as_str) + .map(str::to_string) + }) + else { + return Vec::new(); + }; + vec![ToolCallRequest { + id: format!("e2e-pr4-replace-{scenario_id}"), + name: TASK_UPDATE_TOOL.to_string(), + arguments: serde_json::json!({ + "operation": "cancel_and_replace", + "id": late_task_id, + "reason": { + "code": "e2e.replaced", + "message": format!("Deterministic E2E replacement for {scenario_id}") + }, + "replacement": { + "subject": format!("E2E_PR4_REPLACEMENT:{scenario_id}"), + "description": "Ownerless replacement remains in Current Work.", + "execution_mode": "build", + "eligible_member_ids": ["sde-planner"] + } + }), + thought_signature: None, + }] + } + _ => Vec::new(), + } + } + + async fn delay_pr4_race_stage(messages: &[Value]) { + let Some((latest_user_index, latest_user)) = latest_pr4_user(messages) else { + return; + }; + let stage = messages[latest_user_index + 1..] + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool")) + .count(); + let task_assignment = is_task_assignment(latest_user.as_str()); + let delay_ms = if task_assignment && latest_user.contains("E2E_PR4_LATE:") && stage == 1 { + 4_000 + } else if task_assignment + && (latest_user.contains("E2E_PR4_COMPLETE:") || latest_user.contains("E2E_PR4_FAIL:")) + && stage == 2 + { + 3_000 + } else if !task_assignment && stage == 1 { + 1_500 + } else { + 0 + }; + if delay_ms > 0 { + sleep(Duration::from_millis(delay_ms)).await; + } + } +} + +fn latest_model_user(messages: &[Value]) -> Option { + messages + .iter() + .rev() + .filter(|message| message.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|message| message.get("content").and_then(content_text)) + .find(|content| !content.trim_start().starts_with("")) +} + +fn control_wait_duration(messages: &[Value]) -> Option { + let latest_user = latest_model_user(messages)?; + let wait_seconds = latest_user + .split_once(CONTROL_WAIT_MARKER)? + .1 + .split_whitespace() + .next()? + .parse::() + .ok()?; + Some(Duration::from_secs(wait_seconds.clamp(1, 60))) +} + +fn latest_pr4_user(messages: &[Value]) -> Option<(usize, String)> { + messages + .iter() + .enumerate() + .rev() + .filter(|(_, message)| message.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|(index, message)| { + message + .get("content") + .and_then(content_text) + .map(|content| (index, content)) + }) + .find(|(_, content)| { + content.contains(PR4_TASK_FSM_MARKER) + && !content.trim_start().starts_with("") + }) +} + +fn pr4_scenario_id(text: &str) -> Option { + let suffix = text.split(PR4_TASK_FSM_MARKER).nth(1)?; + let id = suffix + .chars() + .take_while(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .take(48) + .collect::(); + (!id.is_empty()).then_some(id) +} + +fn line_value<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + text.lines() + .find_map(|line| line.trim().strip_prefix(prefix).map(str::trim)) + .filter(|value| !value.is_empty()) +} + +fn is_task_assignment(text: &str) -> bool { + text.contains("Task assigned by") || text.contains(" Option { + line_value(text, line_prefix) + .map(str::to_string) + .or_else(|| xml_attribute_value(text, "task_assigned", attribute)) +} + +fn xml_attribute_value(text: &str, element: &str, attribute: &str) -> Option { + let element_start = text.find(&format!("<{element} "))?; + let element_tail = &text[element_start..]; + let tag_end = element_tail.find('>')?; + let opening_tag = &element_tail[..tag_end]; + let attribute_prefix = format!("{attribute}=\""); + let value_start = opening_tag.find(&attribute_prefix)? + attribute_prefix.len(); + let value_tail = &opening_tag[value_start..]; + let value_end = value_tail.find('"')?; + let value = &value_tail[..value_end]; + (!value.is_empty()).then(|| value.to_string()) +} + +fn tool_result_json(value: &Value) -> Option { + match value { + Value::String(text) => serde_json::from_str(text).ok(), + Value::Object(_) => Some(value.clone()), + _ => None, + } } fn content_text(value: &Value) -> Option { @@ -144,7 +447,14 @@ impl LLMProvider for E2eFakeProvider { _max_tokens: u32, _temperature: f32, ) -> Result { - let tool_calls = Self::address_comment_tool_calls(messages, tools); + if let Some(duration) = control_wait_duration(messages) { + sleep(duration).await; + } + Self::delay_pr4_race_stage(messages).await; + let mut tool_calls = Self::address_comment_tool_calls(messages, tools); + if tool_calls.is_empty() { + tool_calls = Self::pr4_task_fsm_tool_calls(messages, tools); + } let content = if tool_calls.is_empty() { Some(Self::response_for(messages)) } else { @@ -239,6 +549,13 @@ mod tests { }) } + fn named_tool(name: &str) -> Value { + json!({ + "type": "function", + "function": { "name": name } + }) + } + #[test] fn address_comments_briefing_emits_one_tool_call_per_comment() { let messages = vec![json!({ @@ -285,4 +602,258 @@ mod tests { .is_empty() ); } + + #[test] + fn ordinary_reply_ignores_trailing_system_reminder() { + let messages = vec![ + json!({ "role": "user", "content": "actual user request" }), + json!({ + "role": "user", + "content": "volatile context" + }), + ]; + + assert_eq!( + E2eFakeProvider::response_for(&messages), + "E2E_FAKE_PROVIDER_REPLY: actual user request" + ); + } + + #[test] + fn user_role_compactor_prompt_returns_compact_summary() { + let messages = vec![json!({ + "role": "user", + "content": "You are a context compactor. Summarize the older conversation." + })]; + + assert!(E2eFakeProvider::response_for(&messages).starts_with("E2E_FAKE_COMPACT_SUMMARY:")); + } + + #[test] + fn control_wait_uses_real_user_prompt_and_bounds_seconds() { + let messages = vec![ + json!({ + "role": "user", + "content": concat!( + "Start a harmless task. ", + "Create a stoppable window by waiting for about 45 seconds before the final answer." + ) + }), + json!({ + "role": "user", + "content": "volatile context" + }), + ]; + + assert_eq!( + control_wait_duration(&messages), + Some(Duration::from_secs(45)) + ); + } + + #[test] + fn session_memory_compaction_does_not_replay_pr4_markers() { + let messages = vec![json!({ + "role": "user", + "content": concat!( + "\n", + "Run E2E_PR4_TASK_FSM:stale_page\n", + "\n", + "settled task updates" + ) + })]; + + let response = E2eFakeProvider::response_for(&messages); + + assert!(response.starts_with("E2E_FAKE_COMPACT_SUMMARY:")); + assert!(!response.contains(PR4_TASK_FSM_MARKER)); + } + + #[test] + fn pr4_marker_drives_graph_then_atomic_replacement() { + let tools = [ + named_tool(TASK_GRAPH_CREATE_TOOL), + named_tool(TASK_UPDATE_TOOL), + ]; + let messages = vec![ + json!({ + "role": "user", + "content": "Run E2E_PR4_TASK_FSM:run_1" + }), + json!({ + "role": "user", + "content": "Per-turn context only." + }), + ]; + + let graph = E2eFakeProvider::pr4_task_fsm_tool_calls(&messages, Some(&tools)); + assert_eq!(graph.len(), 1); + assert_eq!(graph[0].name, TASK_GRAPH_CREATE_TOOL); + assert_eq!(graph[0].arguments["tasks"].as_array().unwrap().len(), 4); + assert_eq!( + graph[0].arguments["tasks"][0]["eligible_member_ids"][0], + "sde-implementer" + ); + + let mut with_result = messages; + with_result.push(json!({ + "role": "tool", + "content": json!({ + "created": true, + "task_id_by_key": { "late": "late-task-id" } + }).to_string() + })); + with_result.push(json!({ + "role": "user", + "content": "Updated per-turn context." + })); + let replacement = E2eFakeProvider::pr4_task_fsm_tool_calls(&with_result, Some(&tools)); + assert_eq!(replacement.len(), 1); + assert_eq!(replacement[0].name, TASK_UPDATE_TOOL); + assert_eq!(replacement[0].arguments["operation"], "cancel_and_replace"); + assert_eq!(replacement[0].arguments["id"], "late-task-id"); + assert_eq!( + replacement[0].arguments["replacement"]["eligible_member_ids"][0], + "sde-planner" + ); + } + + #[test] + fn pr4_owner_lifecycle_uses_only_task_update_operations() { + let tools = [named_tool(TASK_UPDATE_TOOL)]; + let assigned = json!({ + "role": "user", + "content": concat!( + "Task assigned by coordinator: E2E_PR4_COMPLETE:run_2\n", + "Task ID: task-complete\n", + "Execution mode: build\n", + "E2E_PR4_TASK_FSM:run_2" + ) + }); + + let start = + E2eFakeProvider::pr4_task_fsm_tool_calls(std::slice::from_ref(&assigned), Some(&tools)); + assert_eq!( + start[0].arguments, + json!({ "operation": "start", "id": "task-complete" }) + ); + + let context = json!({ + "role": "user", + "content": "Per-turn context only." + }); + let evidence_messages = vec![ + assigned.clone(), + context.clone(), + json!({ "role": "tool", "content": "ok" }), + ]; + let evidence = E2eFakeProvider::pr4_task_fsm_tool_calls(&evidence_messages, Some(&tools)); + assert_eq!(evidence[0].arguments["operation"], "append_evidence"); + + let complete_messages = vec![ + assigned, + context, + json!({ "role": "tool", "content": "ok" }), + json!({ "role": "tool", "content": "ok" }), + ]; + let complete = E2eFakeProvider::pr4_task_fsm_tool_calls(&complete_messages, Some(&tools)); + assert_eq!(complete[0].arguments["operation"], "complete"); + assert!(complete[0].arguments["output"].get("produced_at").is_none()); + assert!(complete[0].arguments["output"] + .get("produced_by_member_id") + .is_none()); + } + + #[test] + fn pr4_owner_ignores_state_projection_marker_in_trailing_system_reminder() { + let tools = [named_tool(TASK_UPDATE_TOOL)]; + let messages = vec![ + json!({ + "role": "user", + "content": concat!( + "Task assigned by coordinator: E2E_PR4_COMPLETE:run_3\n", + "Task ID: task-complete\n", + "Execution mode: build\n", + "E2E_PR4_TASK_FSM:run_3" + ) + }), + json!({ + "role": "user", + "content": concat!( + "Current work includes ", + "E2E_PR4_TASK_FSM:stale_projection." + ) + }), + ]; + + let calls = E2eFakeProvider::pr4_task_fsm_tool_calls(&messages, Some(&tools)); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, TASK_UPDATE_TOOL); + assert_eq!( + calls[0].arguments, + json!({ "operation": "start", "id": "task-complete" }) + ); + } + + #[test] + fn pr4_owner_lifecycle_accepts_production_inbox_xml_attachment() { + let tools = [named_tool(TASK_UPDATE_TOOL)]; + let messages = vec![ + json!({ + "role": "user", + "content": concat!( + "\n", + " ", + "", + "E2E_PR4_TASK_FSM:run_4", + "\n", + "" + ) + }), + json!({ + "role": "user", + "content": "Current work also contains E2E_PR4_TASK_FSM:run_4." + }), + ]; + + let calls = E2eFakeProvider::pr4_task_fsm_tool_calls(&messages, Some(&tools)); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, TASK_UPDATE_TOOL); + assert_eq!( + calls[0].arguments, + json!({ "operation": "start", "id": "task-complete" }) + ); + } + + #[test] + fn pr4_owner_tool_call_ids_are_unique_across_same_scenario_tasks() { + let tools = [named_tool(TASK_UPDATE_TOOL)]; + let assignment = |task_id: &str| { + json!({ + "role": "user", + "content": format!( + concat!( + "Task assigned by coordinator: E2E_PR4_HISTORY:run_5:00\n", + "Task ID: {}\n", + "Execution mode: build\n", + "E2E_PR4_TASK_FSM:run_5" + ), + task_id + ) + }) + }; + + let first = + E2eFakeProvider::pr4_task_fsm_tool_calls(&[assignment("task-history-a")], Some(&tools)); + let second = + E2eFakeProvider::pr4_task_fsm_tool_calls(&[assignment("task-history-b")], Some(&tools)); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].id, second[0].id); + assert!(first[0].id.contains("task-history-a")); + assert!(second[0].id.contains("task-history-b")); + } } diff --git a/src-tauri/crates/agent-core/src/core/session/goal_loop.rs b/src-tauri/crates/agent-core/src/core/session/goal_loop.rs index 5d2803cca0..1d47e28fd1 100644 --- a/src-tauri/crates/agent-core/src/core/session/goal_loop.rs +++ b/src-tauri/crates/agent-core/src/core/session/goal_loop.rs @@ -440,6 +440,7 @@ async fn enqueue_continuation( None, None, None, + None, crate::foundation::session_bridge::TurnIntentBridgeSource::Queue, ) .await; diff --git a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs index 35ea045945..930d445b07 100644 --- a/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs +++ b/src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs @@ -292,6 +292,7 @@ pub(super) async fn send_initial_turn( client_message_id, turn_intent_id, None, + None, intent_org_run_id, source, ) @@ -331,6 +332,7 @@ pub(super) async fn send_initial_turn( client_message_id, turn_intent_id, None, + None, intent_org_run_id, crate::foundation::session_bridge::TurnIntentBridgeSource::AgentOrg, ) diff --git a/src-tauri/crates/agent-core/src/core/session/prompt/section_builders/agent_org.rs b/src-tauri/crates/agent-core/src/core/session/prompt/section_builders/agent_org.rs index 02ca5b746c..c2e91bb2a6 100644 --- a/src-tauri/crates/agent-core/src/core/session/prompt/section_builders/agent_org.rs +++ b/src-tauri/crates/agent-core/src/core/session/prompt/section_builders/agent_org.rs @@ -56,17 +56,14 @@ fn build_agent_org_task_snapshot(tasks: Result, String>) -> Vec = tasks - .iter() - .filter(|task| task.status != TaskStatus::Completed) - .collect(); + let mut open_tasks: Vec<&Task> = tasks.iter().filter(|task| task.status.is_open()).collect(); open_tasks.sort_by_key(|task| match task.status { TaskStatus::InProgress => 0, TaskStatus::Pending => 1, - TaskStatus::Completed => 2, + TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled => 2, }); let mut lines = Vec::new(); @@ -75,14 +72,16 @@ fn build_agent_org_task_snapshot(tasks: Result, String>) -> Vec 0 || completed_count > 0 { + let dependency_row_count = tasks.len().saturating_sub(open_tasks.len()); + if omitted_open > 0 || dependency_row_count > 0 { lines.push(format!( - "- Snapshot truncated: {omitted_open} additional open task(s), {completed_count} completed task(s). Use `task_list` for the full board before creating duplicate work." + "- Operational snapshot: {omitted_open} additional open task(s) omitted from this prompt; {dependency_row_count} direct dependency row(s) were loaded only to evaluate readiness. Terminal history is not loaded here. Use `task_list` before creating duplicate work." )); + } else { + lines.push( + "- Terminal history is not loaded into this prompt. Use `task_list` before creating duplicate work." + .to_string(), + ); } lines } @@ -119,12 +118,10 @@ pub(crate) fn build_agent_org_context_section_with_task_snapshot( }; let task_authority_line = match current_member_id { Some(COORDINATOR_MEMBER_ID) => { - "- **Your task authority:** coordinator — you may create, assign, reassign, edit, and repair tasks for every participant, and approve cross-workflow parallel overrides. You may NOT impersonate another member's work: only the current owner may set its task `in_progress`/`completed` or write its `output`. Assignment and dependency unblocking already wake the owner; do not start or complete the task on that member's behalf.".to_string() + "- **Your task authority:** coordinator — you may create tasks, edit or assign pending tasks, cancel pending/in-progress tasks, atomically cancel-and-replace changed in-progress work, append terminal audit notes, and approve cross-workflow parallel overrides. You may NOT reassign or rewrite the core goal of an in-progress task in place, or impersonate its Owner: only the bound Owner may start, complete with output, fail, or append progress/evidence. Assignment and dependency unblocking already wake the Owner; do not perform its lifecycle operation on that member's behalf.".to_string() } Some(member_id) if context.participant_by_member_id(member_id).is_some() => { - format!( - "- **Your task authority:** worker — you may create and modify only tasks for `{member_id}`. Configured Writer grants are not active in this phase. You may not assign or rewrite peer work. Only you may record `in_progress`, `completed`, and `output` for tasks you own." - ) + "- **Your task authority:** worker — configured Writer grants are not active in this phase, so you cannot create, assign, or rewrite the Task graph. For the exact Task bound to your persisted TaskExecution turn, only you may start it, append progress/evidence, complete it with output, or fail it with a reason.".to_string() } _ => "- **Your task authority:** none — non-roster sessions cannot mutate the Agent Org task board.".to_string(), }; @@ -154,7 +151,7 @@ pub(crate) fn build_agent_org_context_section_with_task_snapshot( lines.push(task_authority_line); lines.push(String::new()); lines.push( - "Do NOT use the generic `agent` tool to delegate work to roster members in this Agent Org. Roster members are already materialized as persistent sessions for this run. Use `task_create` and `task_update` only within the task authority stated above. Communication reachability and task authority are separate: being allowed to message a peer never grants permission to assign, reassign, edit, or delete that peer's work. Use `task_list` / `task_get` to inspect current state before an authorized change." + "Do NOT use the generic `agent` tool to delegate work to roster members in this Agent Org. Roster members are already materialized as persistent sessions for this run. Use `task_create` and `task_update` only within the task authority stated above. Communication reachability and task authority are separate: being allowed to message a peer never grants permission to assign, rewrite, cancel, or complete that peer's work. Use `task_list` / `task_get` to inspect current state before an authorized change." .to_string(), ); lines.push(String::new()); @@ -184,7 +181,7 @@ pub(crate) fn build_agent_org_context_section_with_task_snapshot( ); lines.push(String::new()); lines.push( - "When a member receives `TaskAssigned`, it must first call `task_update` for that exact task id with `status=in_progress` before doing the work. When done, the same owning member must call `task_update` with `status=completed` and `output={summary, content?, artifact_ids?}`; `summary` is required. Coordinators and managers must never perform these lifecycle/output calls for another owner. At turn end, the runtime gives a worker at most one bounded correction if a Build task is still `in_progress`; if it remains unresolved, `MemberIdle.unfinished_task_ids` tells the coordinator to retry or reassign instead of waiting silently. Plan tasks awaiting approval are excluded." + "When a member receives `TaskAssigned`, it must first call `task_update` for that exact task id with `operation=start` before doing the work. When done, the same owning member must call `task_update` with `operation=complete` and `output={summary, content?, artifact_ids?}`; `summary` is required. If execution fails, use `operation=fail` with a bounded reason. Coordinators and managers must never perform these lifecycle/output calls for another owner. At turn end, the runtime gives a worker at most one bounded correction if a Build task is still `in_progress`; if it remains unresolved, `MemberIdle.unfinished_task_ids` tells the coordinator to retry or replace instead of waiting silently. Plan tasks awaiting approval are excluded." .to_string(), ); lines.push(String::new()); @@ -194,7 +191,7 @@ pub(crate) fn build_agent_org_context_section_with_task_snapshot( ); lines.push(String::new()); lines.push( - "When you receive `MemberIdle` with non-empty `unfinished_task_ids`, do not wait silently: ask that owner to finish its lifecycle or explicitly reassign the task. When `reason=failed`, the failed member's in-progress tasks become ownerless Pending rows; read failure_reason, inspect eligibility, and choose a new owner explicitly with `task_update owner_member_id`. Workers never self-claim ownerless work. Never assign outside `eligible_member_ids`, and do not ask one member to inspect another member's private failed context. If no recovery is possible, pause and report to the user." + "When you receive `MemberIdle` with non-empty `unfinished_task_ids`, do not wait silently: ask that owner to finish its lifecycle or use `operation=cancel_and_replace` for changed in-progress work. When `reason=failed`, the failed member's in-progress tasks become ownerless Pending rows; inspect eligibility and choose a new owner explicitly with `task_update operation=patch_pending owner_member_id=...`. Workers never self-claim ownerless work. Never assign outside `eligible_member_ids`, and do not ask one member to inspect another member's private failed context. If no recovery is possible, pause and report to the user." .to_string(), ); lines.push(String::new()); diff --git a/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs b/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs index 394ca1f871..8dbae38d6c 100644 --- a/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs +++ b/src-tauri/crates/agent-core/src/core/session/prompt/section_tests.rs @@ -179,12 +179,12 @@ fn agent_org_prompt_uses_task_board_for_roster_delegation() { assert!( section.contains("Your task authority:** coordinator") && section.contains("being allowed to message a peer never grants permission") - && section.contains("may NOT impersonate another member's work"), + && section.contains("may NOT reassign or rewrite the core goal") + && section.contains("impersonate its Owner"), "prompt must separate communication reachability from task authority: {section}" ); assert!( - section - .contains("first call `task_update` for that exact task id with `status=in_progress`") + section.contains("first call `task_update` for that exact task id with `operation=start`") && section.contains("`output={summary, content?, artifact_ids?}`") && section.contains("`summary` is required"), "prompt must state the owner-authored task lifecycle contract: {section}" @@ -208,9 +208,10 @@ fn agent_org_prompt_worker_cannot_confuse_peer_chat_with_delegation() { let section = build_agent_org_context_section(&context, "agent-worker", Some("member-worker")); assert!( section.contains("Your task authority:** worker") - && section.contains("may create and modify only tasks for `member-worker`") - && section.contains("Configured Writer grants are not active in this phase") - && section.contains("Only you may record `in_progress`, `completed`, and `output`"), + && section.contains("configured Writer grants are not active in this phase") + && section.contains("cannot create, assign, or rewrite the Task graph") + && section.contains("exact Task bound to your persisted TaskExecution turn") + && section.contains("only you may start it"), "worker prompt must explain self-only task authority: {section}" ); assert!( @@ -248,7 +249,15 @@ fn agent_org_prompt_includes_bounded_task_snapshot() { status: TaskStatus::Completed, blocks: vec![], blocked_by: vec![], - metadata: None, + metadata: Some(serde_json::json!({ + "output": { + "summary": "Done prompt result", + "content": null, + "artifactIds": [], + "producedByMemberId": "member-worker", + "producedAt": "2026-08-20T00:00:00Z" + } + })), }) .unwrap(); @@ -256,7 +265,7 @@ fn agent_org_prompt_includes_bounded_task_snapshot() { assert!(section.contains("### Current task board snapshot")); assert!(section.contains("`prompt-open` [in_progress] owner=member-worker blocked_by=[prompt-blocker] — Open prompt task")); assert!(!section.contains("Done prompt task")); - assert!(section.contains("Use `task_list` for the full board")); + assert!(section.contains("Terminal history is not loaded into this prompt")); } #[test] @@ -265,7 +274,7 @@ fn agent_org_prompt_snapshot_warns_before_duplicate_task_creation() { let _sandbox = prompt_task_sandbox(); let context = prompt_test_agent_org_context(); let section = build_agent_org_context_section(&context, "agent-coord", None); - assert!(section.contains("No tasks currently exist on this run.")); + assert!(section.contains("No open tasks currently exist on this run")); assert!(section.contains("update it instead of creating a duplicate")); assert!(section.contains( "Ownerless means waiting for explicit coordinator assignment, never an automatic claim pool" diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs index 7a92f77bf7..997d206c90 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs @@ -54,6 +54,46 @@ pub fn drain_and_render_deferred( runtime_member_id: Option<&str>, messages: &mut Vec, session: Option<&AgentSession>, +) -> DrainGuard { + drain_and_render_deferred_impl( + org_context, + recipient_agent_id, + runtime_member_id, + messages, + session, + None, + ) +} + +/// Production typed-drain entry point. A TaskExecution Turn can only claim +/// the single formal Inbox row for its persisted Task binding; Coordinator +/// Turns retain the bounded coordinator Inbox drain. UserDirectedWork is not +/// admitted by PR4's formal wake path and therefore claims nothing here. +pub(crate) fn drain_and_render_deferred_for_turn( + org_context: &AgentOrgRunContext, + recipient_agent_id: &str, + runtime_member_id: Option<&str>, + messages: &mut Vec, + session: Option<&AgentSession>, + turn_context: &crate::coordination::agent_org_turn_contexts::AgentOrgTurnContext, +) -> DrainGuard { + drain_and_render_deferred_impl( + org_context, + recipient_agent_id, + runtime_member_id, + messages, + session, + Some(turn_context), + ) +} + +fn drain_and_render_deferred_impl( + org_context: &AgentOrgRunContext, + recipient_agent_id: &str, + runtime_member_id: Option<&str>, + messages: &mut Vec, + session: Option<&AgentSession>, + turn_context: Option<&crate::coordination::agent_org_turn_contexts::AgentOrgTurnContext>, ) -> DrainGuard { let shutdown_hook = current_member_shutdown_hook(); @@ -91,10 +131,26 @@ pub fn drain_and_render_deferred( return DrainGuard::empty(&org_context.run_id, "unknown"); }; - let unread_result = AgentInboxStore::list_unread_batch_for_member( - recipient_member_id_value, - &org_context.run_id, - ); + let unread_result = match turn_context.map(|context| context.turn_kind) { + Some(crate::coordination::agent_org_turn_contexts::AgentOrgTurnKind::TaskExecution) => { + match turn_context.and_then(|context| context.task_id.as_deref()) { + Some(task_id) => AgentInboxStore::list_unread_task_input_for_member( + recipient_member_id_value, + &org_context.run_id, + task_id, + ), + None => Err("TaskExecution context has no canonical task_id".to_string()), + } + } + Some(crate::coordination::agent_org_turn_contexts::AgentOrgTurnKind::UserDirectedWork) => { + return DrainGuard::empty(&org_context.run_id, recipient_member_id_value); + } + Some(crate::coordination::agent_org_turn_contexts::AgentOrgTurnKind::Coordinator) + | None => AgentInboxStore::list_unread_batch_for_member( + recipient_member_id_value, + &org_context.run_id, + ), + }; let batch = match unread_result { Ok(batch) => batch, @@ -372,10 +428,24 @@ fn apply_payload_side_effects( // strictly less bad than failing the whole drain over a // task table hiccup; the next coordinator turn will // observe whatever state the store is actually in. - match AgentOrgTaskStore::dispose_open_tasks_for_shutdown( - &org_context.run_id, - &member.member_id, - ) { + let disposition = (|| -> Result<_, String> { + let reservation = + crate::coordination::agent_org_watchdog::reserve_task_shutdown_release( + &org_context.run_id, + &member.member_id, + )?; + let actor = crate::coordination::agent_org_tasks::SystemArchiveOrRecovery::new( + reservation.token, + reservation.generation, + crate::coordination::agent_org_tasks::SystemTaskOperation::ShutdownRelease, + )?; + AgentOrgTaskStore::release_owner_for_shutdown( + actor, + &org_context.run_id, + &member.member_id, + ) + })(); + match disposition { Ok(disposed) if !disposed.is_empty() => { let released_count = disposed.iter().filter(|task| task.owner.is_none()).count(); diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/mod.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/mod.rs index 364f789ac2..edc2dc1a41 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/mod.rs @@ -16,6 +16,7 @@ pub use hooks::MemberShutdownHookGuard; pub use hooks::{install_member_shutdown_hook, MemberShutdownHook, NoopMemberShutdownHook}; pub use drain::drain_and_render_deferred; +pub(crate) use drain::drain_and_render_deferred_for_turn; pub use guard::DrainGuard; #[cfg(test)] diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/render.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/render.rs index adac20bc4d..dc6555ec5b 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/render.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/render.rs @@ -9,7 +9,7 @@ use crate::coordination::agent_inbox::{ }; use crate::coordination::agent_org_runs::AgentOrgRunContext; -const TASK_ASSIGNED_LIFECYCLE_INSTRUCTIONS: &str = "Before doing this task, call task_update for this exact task_id with status=\"in_progress\". Only you, the owning member, may record this task's in_progress/completed lifecycle or output. When finished, call task_update with status=\"completed\" and output={summary, content?, artifact_ids?}; summary is required."; +const TASK_ASSIGNED_LIFECYCLE_INSTRUCTIONS: &str = "Before doing this task, call task_update for this exact task_id with operation=\"start\". Only you, the owning member, may record this task's lifecycle or output. When finished, call task_update with operation=\"complete\" and output={summary, content?, artifact_ids?}; summary is required. If execution fails, use operation=\"fail\" with a bounded reason."; pub(super) fn render_inbox_attachment( rows: &[AgentInboxRecord], diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/tests.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/tests.rs index eeffb6df1c..a4206481a3 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/tests.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/tests.rs @@ -30,6 +30,7 @@ fn ensure_inbox_schema() { // gains a new source table. crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); crate::coordination::agent_org_runs::init_schema(&conn).expect("agent org runs schema"); + crate::coordination::agent_org_watchdog::init_schema(&conn).expect("agent org recovery schema"); crate::coordination::agent_inbox::init_schema(&conn).expect("agent inbox schema"); crate::coordination::agent_member_interventions::init_schema(&conn) .expect("member intervention schema"); @@ -657,10 +658,10 @@ fn render_payload_task_assigned_basic() { render_payload(&msg), "Use the rolling-budget helper\ - Before doing this task, call task_update for this exact task_id with status="in_progress". \ - Only you, the owning member, may record this task's in_progress/completed lifecycle or output. \ - When finished, call task_update with status="completed" and output={summary, content?, artifact_ids?}; \ - summary is required." + Before doing this task, call task_update for this exact task_id with operation="start". \ + Only you, the owning member, may record this task's lifecycle or output. \ + When finished, call task_update with operation="complete" and output={summary, content?, artifact_ids?}; \ + summary is required. If execution fails, use operation="fail" with a bounded reason." ); } @@ -1369,7 +1370,15 @@ fn drain_releases_member_tasks_on_accepted_shutdown() { status: TaskStatus::Completed, blocks: vec![], blocked_by: vec![], - metadata: None, + metadata: Some(serde_json::json!({ + "output": { + "summary": "Already done", + "content": null, + "artifactIds": [], + "producedByMemberId": "member-alice-agent", + "producedAt": chrono::Utc::now().to_rfc3339(), + }, + })), }) .unwrap(); AgentInboxStore::insert(InsertInboxParams { diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/member_idle.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/member_idle.rs index 4f65d9da2b..23e5c7758e 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/member_idle.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/member_idle.rs @@ -227,7 +227,7 @@ pub(crate) fn task_lifecycle_stop_feedback( return Ok(None); } Ok(Some(format!( - "Agent Org task lifecycle check: your turn is about to end, but your owned build task(s) [{}] are still in_progress. Do not redo the substantive work. If the work is finished, call task_update for the exact task id with status=completed and output={{summary, content?, artifact_ids?}}. If it is not finished or is blocked, send the coordinator one task-bound explanation with org_send_message and leave the task in_progress. This correction is offered once; do not end silently.", + "Agent Org task lifecycle check: your turn is about to end, but your owned build task(s) [{}] are still in_progress. Do not redo the substantive work. If the work is finished, call task_update for the exact task id with operation=complete and output={{summary, content?, artifact_ids?}}. If execution failed, use operation=fail with a bounded reason. If it is still blocked, send the coordinator one task-bound explanation with org_send_message and leave the task in_progress. This correction is offered once; do not end silently.", task_ids.join(", ") ))) } @@ -288,7 +288,9 @@ pub fn maybe_emit_member_idle_with_details( mod tests { use super::*; use crate::coordination::agent_org_runs::{AgentOrgContextMember, AgentOrgRunContext}; - use crate::coordination::agent_org_tasks::{CreateTaskParams, TASK_METADATA_EXECUTION_MODE}; + use crate::coordination::agent_org_tasks::{ + CreateTaskParams, TASK_METADATA_EXECUTION_MODE, TASK_METADATA_OUTPUT, + }; use std::sync::{Mutex, MutexGuard}; use test_helpers::test_env; @@ -419,6 +421,18 @@ mod tests { } fn seed_lifecycle_task(run_id: &str, id: &str, mode: TaskExecutionMode, status: TaskStatus) { + let mut metadata = serde_json::json!({ + TASK_METADATA_EXECUTION_MODE: mode.as_wire(), + }); + if status == TaskStatus::Completed { + metadata[TASK_METADATA_OUTPUT] = serde_json::json!({ + "summary": "Lifecycle fixture completed", + "content": null, + "artifactIds": [], + "producedByMemberId": "member-worker", + "producedAt": chrono::Utc::now().to_rfc3339(), + }); + } AgentOrgTaskStore::create(CreateTaskParams { id: id.to_string(), org_run_id: run_id.to_string(), @@ -429,9 +443,7 @@ mod tests { status, blocks: Vec::new(), blocked_by: Vec::new(), - metadata: Some(serde_json::json!({ - TASK_METADATA_EXECUTION_MODE: mode.as_wire(), - })), + metadata: Some(metadata), }) .expect("seed lifecycle task"); } diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/orchestrator.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/orchestrator.rs index 9ad37947e5..fd51d4c628 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/orchestrator.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/orchestrator.rs @@ -281,13 +281,28 @@ impl UnifiedMessageProcessor { let message_count_before_inbox = messages.len(); let mut inbox_had_real_input = false; + let persisted_turn_context = if self.runtime.agent_org_context.is_some() { + Some(tokio::task::block_in_place(|| { + let conn = database::db::get_connection().map_err(|error| error.to_string())?; + crate::coordination::agent_org_turn_contexts::require_context_with_connection( + &conn, + session_id, + &context.turn_intent_id, + ) + })?) + } else { + None + }; let mut inbox_guard = self.runtime.agent_org_context.as_ref().map(|org_context| { - inbox_drain::drain_and_render_deferred( + inbox_drain::drain_and_render_deferred_for_turn( org_context, &self.agent_id, self.runtime.agent_org_current_member_id.as_deref(), &mut messages, Some(self.session.as_ref()), + persisted_turn_context + .as_ref() + .expect("Agent Org runtime has persisted typed Turn context"), ) }); if let Some(guard) = inbox_guard.as_mut() { diff --git a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent_org_tasks.rs b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent_org_tasks.rs index c9a15519db..a8bba4bda9 100644 --- a/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent_org_tasks.rs +++ b/src-tauri/crates/agent-core/src/core/tools/builtin_tools/table/agent_org_tasks.rs @@ -6,7 +6,7 @@ pub(super) static TOOLS: &[ToolEntry] = &[ ToolEntry { name: tool_names::TASK_CREATE, description: "Create a task on the Agent Org task board.", - description_detail: "Creates a shared task-board item for the current Agent Org run, including subject, description, active form, owner, status, and dependency edges.", + description_detail: "Creates a pending shared Task for the current Agent Org run, including its graph fields, execution mode, and optional worker Owner.", category: tool_categories::ORCHESTRATION, icon_id: "clipboard-copy", simulator_app: AppChannels, @@ -37,8 +37,8 @@ pub(super) static TOOLS: &[ToolEntry] = &[ }, ToolEntry { name: tool_names::TASK_UPDATE, - description: "Update a task on the Agent Org task board.", - description_detail: "Updates a shared Agent Org task-board item, including status, owner, active form, dependency edges, and deletion semantics.", + description: "Apply one authorized Task operation.", + description_detail: "Applies one tagged graph-admin or Owner-lifecycle operation; mixed authority fields and terminal rewrites are rejected.", category: tool_categories::ORCHESTRATION, icon_id: "clipboard-pen", simulator_app: AppChannels, diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs b/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs index 803ce101c8..e377781ae5 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/coding/render_inline_canvas.rs @@ -647,7 +647,8 @@ impl Tool for ReviseInlineCanvasTool { "Canvas revision requires a dispatching session id".into(), )); } - if !ctx.call_id.is_empty() && target_event_id == tool_names::tool_call_event_id(&ctx.call_id) + if !ctx.call_id.is_empty() + && target_event_id == tool_names::tool_call_event_id(&ctx.call_id) { return Err(ToolError::InvalidParams( "target_event_id cannot identify the revision call itself".into(), diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs index 0cf550eb40..ebf269b22f 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message.rs @@ -362,19 +362,18 @@ impl OrgSendMessageTool { ), "task_assigned" => Err( // `task_assigned` is the inbox notification emitted - // by `task_create`/`task_update`. The assignment row's - // `task_id` must point at a real row in the - // `agent_org_tasks` store and the producers go - // through `AgentOrgTaskStore::create`/`update`, - // which set the canonical `owner` field atomically. + // by typed Task graph mutations. The assignment row's + // `task_id` must point at a real canonical Task row and + // the producer goes through the actor-gated Store + // transaction, which sets `owner` atomically. // Allowing the LLM to forge a `task_assigned` over // the wire would let any member fabricate // assignments without ever touching the task store, // breaking the single-source-of-truth invariant. "kind 'task_assigned' is not LLM-callable — \ it is emitted by the task tools after an explicit \ - assignment; use task_create or task_update to \ - (re)assign a task" + assignment; use task_create or the pending graph-patch \ + operation, or cancel-and-replace active work" .to_string(), ), other => Err(format!( diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs index be8fbb0bf0..b92a630df1 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/send_message/persistence.rs @@ -74,7 +74,7 @@ fn plain_work_context_guidance( let task = all_tasks.iter().find(|task| task.id == related_task_id); let invalid_reason = match task { None => Some("related_task_not_found"), - Some(task) if task.status.is_resolved() => Some("related_task_already_completed"), + Some(task) if task.status.is_terminal() => Some("related_task_already_terminal"), Some(task) if !task_dependencies_resolved(all_tasks, task) => { Some("related_task_dependencies_unresolved") } diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs index ed9326724b..1d8304f2cf 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_create.rs @@ -9,15 +9,16 @@ use crate::coordination::agent_org_payload_limits::{ validate_task_identifier, validate_task_identifier_list, }; use crate::coordination::agent_org_tasks::{ - self, task_dependency_closure, AgentOrgTaskStore, CreateTaskParams, TaskCreateSchedulingPolicy, - TaskExecutionMode, TaskStatus, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, + self, task_dependency_closure, AgentOrgTaskStore, CreatePendingTaskParams, + TaskCreateSchedulingPolicy, TaskExecutionMode, TaskGraphWriterAdmin, + TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, }; use crate::tools::names as tool_names; use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; use super::{ - map_task_write_error, merge_task_metadata, parse_status, task_to_json, - validate_freeform_task_metadata, TaskToolsContext, + map_task_write_error, merge_task_metadata, task_to_json, validate_freeform_task_metadata, + TaskToolsContext, }; /// Explicit decision about when a newly-created task may be dispatched. @@ -97,15 +98,11 @@ pub struct TaskCreateParams { /// shown by the UI while the task is in_progress. #[serde(default)] pub active_form: Option, - /// Optional initial owner member_id. Use `coordinator` for the coordinator - /// or an exact roster member_id. When set, `task_create` posts a + /// Optional initial owner member_id. It must be an exact non-Coordinator + /// roster member_id. When set, `task_create` posts a /// `TaskAssigned` row to the new owner's inbox if the task is pending. #[serde(default)] pub owner_member_id: Option, - /// Optional initial status. Defaults to `pending`. Setting `in_progress` - /// requires `owner_member_id`; task ownership is never inferred. - #[serde(default)] - pub status: Option, /// Required dispatch decision. Use `immediate` only for independent work; /// use `after_dependencies` for review, test, aggregation, or any task /// that consumes another task's output. @@ -140,53 +137,11 @@ pub struct TaskCreateParams { pub struct TaskCreateTool { ctx: Arc, - #[cfg(test)] - pre_persist_hook: Option>, -} - -/// Deterministic test seam for reproducing a task-board change after the -/// tool's advisory read but before the store starts its commit transaction. -#[cfg(test)] -#[derive(Default)] -pub(super) struct TaskCreatePrePersistHook { - reached: tokio::sync::Notify, - resume: tokio::sync::Notify, -} - -#[cfg(test)] -impl TaskCreatePrePersistHook { - pub(super) async fn wait_until_reached(&self) { - self.reached.notified().await; - } - - pub(super) fn resume(&self) { - self.resume.notify_one(); - } - - async fn pause(&self) { - self.reached.notify_one(); - self.resume.notified().await; - } } impl TaskCreateTool { pub fn new(ctx: Arc) -> Self { - Self { - ctx, - #[cfg(test)] - pre_persist_hook: None, - } - } - - #[cfg(test)] - pub(super) fn with_pre_persist_hook( - ctx: Arc, - hook: Arc, - ) -> Self { - Self { - ctx, - pre_persist_hook: Some(hook), - } + Self { ctx } } } @@ -199,11 +154,9 @@ impl Tool for TaskCreateTool { fn description(&self) -> &str { concat!( "Create a task on the org run's task board. The board is shared by every ", - "agent in this Agent Org run, but write authority remains deliberately narrow: ", - "the coordinator may assign any participant, while a member may assign only ", - "itself until additional Writer activation lands. Peer communication does not ", - "grant peer task-assignment authority. ", - "Set `owner_member_id` to `coordinator` or an exact roster member_id for ", + "agent in this Agent Org run. Only the current Coordinator turn may create ", + "or assign graph work; Task Owners use task_update lifecycle operations. ", + "Set `owner_member_id` to an exact worker member_id for ", "direct assignment — a pending assignee will receive a `task_assigned` inbox ", "row on their next turn. If you leave `owner_member_id` unset, the task is ", "parked as awaiting explicit coordinator assignment and MUST provide ", @@ -225,8 +178,7 @@ impl Tool for TaskCreateTool { "deliverable is an explicit plan submitted through `create_plan`; use `build` ", "for implementation, writing, review, testing, research, and all other work. ", "`required_role` is a human-readable hint only; it does not authorize claim ", - "by itself. `status` defaults to `pending`; `in_progress` requires ", - "`owner_member_id` to equal the calling session's member_id." + "by itself. Every new task is persisted as pending." ) } @@ -250,9 +202,19 @@ impl Tool for TaskCreateTool { async fn execute_text( &self, params_value: Value, - _ctx: &CallContext, + call_ctx: &CallContext, ) -> Result { let params: TaskCreateParams = parse_params(params_value)?; + if !self.ctx.is_coordinator() { + return self.ctx.authorization_denied_response( + "task_create", + vec![self.ctx.caller_owner_member_id()], + "Only the Coordinator may create Task graph work. Send the proposal to the Coordinator.", + ); + } + let actor = + TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams)?; validate_freeform_task_metadata(params.metadata.as_ref()) .map_err(ToolError::InvalidParams)?; if params.subject.trim().is_empty() { @@ -322,26 +284,6 @@ impl Tool for TaskCreateTool { ); } } - let status = match params.status.as_deref() { - None => TaskStatus::Pending, - Some(value) => parse_status(value).map_err(ToolError::InvalidParams)?, - }; - if status == TaskStatus::InProgress { - let caller_member_id = self.ctx.caller_owner_member_id(); - match resolved_owner.as_deref() { - Some(owner_member_id) if owner_member_id == caller_member_id => {} - Some(owner_member_id) => { - return Err(ToolError::InvalidParams(format!( - "task_create status=in_progress can only be set by the owning member; caller_member_id={caller_member_id}, owner_member_id={owner_member_id}" - ))); - } - None => { - return Err(ToolError::InvalidParams( - "task_create status=in_progress requires owner_member_id to equal the calling session's member_id".to_string(), - )); - } - } - } let explicit_id = params .id .as_ref() @@ -422,13 +364,10 @@ impl Tool for TaskCreateTool { let covered_dependency_ids = task_dependency_closure(&blocked_by, &existing_tasks); let unlisted_open_tasks = existing_tasks .iter() - .filter(|task| !task.status.is_resolved()) + .filter(|task| task.status.is_open()) .filter(|task| !covered_dependency_ids.contains(&task.id)) .collect::>(); - if !status.is_resolved() - && !unlisted_open_tasks.is_empty() - && !params.allow_parallel_with_unlisted_open_tasks - { + if !unlisted_open_tasks.is_empty() && !params.allow_parallel_with_unlisted_open_tasks { let requires_dependency_confirmation = !blocked_by.is_empty() || unlisted_open_tasks.iter().any(|task| { agent_org_tasks::task_execution_mode(task) == TaskExecutionMode::Plan @@ -467,45 +406,34 @@ impl Tool for TaskCreateTool { }); } - if resolved_owner.is_none() - && status == TaskStatus::Pending - && eligible_member_ids.as_ref().is_none_or(Vec::is_empty) - { + if resolved_owner.is_none() && eligible_member_ids.as_ref().is_none_or(Vec::is_empty) { return Err(ToolError::InvalidParams( "ownerless pending tasks require a non-empty eligible_member_ids list".to_string(), )); } - let metadata = merge_task_metadata( - params.metadata, - eligible_member_ids, - params.required_role, - Some(execution_mode), - None, - ); - - #[cfg(test)] - if let Some(hook) = self.pre_persist_hook.as_ref() { - hook.pause().await; - } + let metadata = + merge_task_metadata(params.metadata, eligible_member_ids, params.required_role); let create_context = Arc::clone(&self.ctx); let allow_parallel_with_unlisted_open_tasks = params.allow_parallel_with_unlisted_open_tasks; let requested_dependency_task_ids = blocked_by.clone(); - let create_params = CreateTaskParams { + let create_params = CreatePendingTaskParams { id, org_run_id: self.ctx.org_context.run_id.clone(), subject: params.subject, description: params.description.unwrap_or_default(), active_form: params.active_form, owner: resolved_owner, - status, - blocks: Vec::new(), + execution_mode, blocked_by, metadata, + originating_message_id: None, + replaces_task_id: None, }; let create_result = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::create_with_transactional_effects( + AgentOrgTaskStore::create_pending_with_transactional_effects( + actor, create_params, TaskCreateSchedulingPolicy { allow_parallel_with_unlisted_open_tasks, diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs index e8192f0dc3..834696b1b8 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_graph_create.rs @@ -10,8 +10,8 @@ use crate::coordination::agent_org_payload_limits::{ validate_task_identifier_list, TASK_GRAPH_CREATE_MAX_TASKS, }; use crate::coordination::agent_org_tasks::{ - self, task_dependency_closure, AgentOrgTaskStore, CreateTaskParams, TaskExecutionMode, - TaskStatus, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, + self, task_dependency_closure, AgentOrgTaskStore, CreatePendingTaskParams, TaskExecutionMode, + TaskGraphWriterAdmin, TASK_GRAPH_OPEN_WORK_CONFLICT_ERROR, }; use crate::tools::names as tool_names; use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; @@ -108,7 +108,7 @@ impl Tool for TaskGraphCreateTool { async fn execute_text( &self, params_value: Value, - _ctx: &CallContext, + call_ctx: &CallContext, ) -> Result { let params: TaskGraphCreateParams = parse_params(params_value)?; if !self.ctx.is_coordinator() { @@ -118,6 +118,9 @@ impl Tool for TaskGraphCreateTool { "Only the coordinator may create a cross-member task graph. Send the proposed graph to the coordinator.", ); } + let actor = + TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams)?; if params.tasks.is_empty() || params.tasks.len() > TASK_GRAPH_CREATE_MAX_TASKS { return Err(ToolError::InvalidParams(format!( "task_graph_create requires 1..={TASK_GRAPH_CREATE_MAX_TASKS} tasks per request" @@ -147,7 +150,7 @@ impl Tool for TaskGraphCreateTool { .collect::>(); let open_existing = existing_tasks .iter() - .filter(|task| !task.status.is_resolved()) + .filter(|task| task.status.is_open()) .map(|task| task.id.clone()) .collect::>(); let directly_referenced_existing = params @@ -266,31 +269,28 @@ impl Tool for TaskGraphCreateTool { node.key )) })?; - let metadata = merge_task_metadata( - node.metadata, - eligible_member_ids, - node.required_role, - Some(execution_mode), - None, - ); - create_params.push(CreateTaskParams { + let metadata = + merge_task_metadata(node.metadata, eligible_member_ids, node.required_role); + create_params.push(CreatePendingTaskParams { id, org_run_id: self.ctx.org_context.run_id.clone(), subject: node.subject, description: node.description.unwrap_or_default(), active_form: node.active_form, owner, - status: TaskStatus::Pending, - blocks: Vec::new(), + execution_mode, blocked_by, metadata, + originating_message_id: None, + replaces_task_id: None, }); } let create_context = Arc::clone(&self.ctx); let allow_parallel = params.allow_parallel_with_existing_open_tasks; let create_result = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::create_batch_with_transactional_effects( + AgentOrgTaskStore::create_pending_batch_with_transactional_effects( + actor, create_params, allow_parallel, |tx, created, all_tasks| { diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_list_get.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_list_get.rs index 9563b33c2c..da2fcb4f01 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_list_get.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_list_get.rs @@ -61,7 +61,7 @@ impl Tool for TaskListTool { "List tasks on the org run's task board. Returns the array in insertion ", "order (`created_at` ascending). ", "Filter with `mine_only=true` to see only the tasks you own, `status` to ", - "narrow by `pending` / `in_progress` / `completed`, or `owner_member_id` ", + "narrow by `pending` / `in_progress` / `completed` / `failed` / `cancelled`, or `owner_member_id` ", "to query a sibling's queue. Combining filters AND-merges them. The response ", "returns compact task summaries with a bounded description; when ", "`description_truncated=true`, call task_get for the complete durable description. ", @@ -277,7 +277,8 @@ impl Tool for TaskGetTool { fn description(&self) -> &str { concat!( "Fetch one task by its durable identifier. Returns the full row (subject, description, ", - "active_form, owner, status, blocks, blocked_by, metadata, timestamps). ", + "active_form, owner, status, execution mode, dependencies, metadata, provenance, ", + "replacement link, and the status-matched output/failure/cancel result). ", "Read-only. Errors if the task does not exist in the current org run." ) } diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs index 26a445fdee..727179f384 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs @@ -1,227 +1,88 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use serde_json::{json, Value}; -use crate::coordination::agent_inbox::{AgentInboxStore, AgentMessage, InsertInboxParams}; use crate::coordination::agent_org_runs::{ AgentOrgContextMember, AgentOrgRunContext, COORDINATOR_MEMBER_ID, }; -use crate::coordination::agent_org_tasks::{ - eligible_member_ids, task_execution_mode, task_output, AgentOrgTaskStore, CreateTaskParams, - TaskExecutionMode, TaskStatus, TASK_DEPENDENCY_CYCLE_ERROR, -}; -use crate::core::session::persistence::{upsert_session, UnifiedSessionRecord}; -use crate::tools::impls::orchestration::org_send_message::{InboxWakeHook, NoopInboxWakeHook}; -use crate::tools::traits::{Tool, ToolError}; +use crate::coordination::agent_org_tasks::{AgentOrgTaskStore, TaskStatus}; +use crate::tools::impls::orchestration::org_send_message::NoopInboxWakeHook; +use crate::tools::traits::{CallContext, Tool, ToolError}; use test_helpers::test_env; -use super::task_create::{TaskCreatePrePersistHook, TaskCreateTool as ProductionTaskCreateTool}; +use super::task_create::TaskCreateTool; use super::task_graph_create::TaskGraphCreateTool; use super::task_list_get::{TaskGetTool, TaskListTool}; use super::task_update::TaskUpdateTool; use super::TaskToolsContext; -fn test_ctx() -> crate::tools::call_context::CallContext { - crate::tools::call_context::CallContext::default() -} - -/// Most task-tool tests exercise ownership, eligibility, output, or update -/// behavior rather than the dispatch-policy parser. Make their independent -/// scheduling choice explicit in one place; dependency-specific tests pass an -/// `after_dependencies` policy themselves and are left untouched. -struct TaskCreateTool { - inner: ProductionTaskCreateTool, - coordinator_default_parallel_override: bool, -} - -impl TaskCreateTool { - fn new(ctx: Arc) -> Self { - let coordinator_default_parallel_override = ctx.is_coordinator(); - Self { - inner: ProductionTaskCreateTool::new(ctx), - coordinator_default_parallel_override, - } - } - - async fn execute_text( - &self, - mut params: Value, - ctx: &crate::tools::call_context::CallContext, - ) -> Result { - if params.get("dispatch_policy").is_none() { - params["dispatch_policy"] = json!("immediate"); - } - if params.get("execution_mode").is_none() { - params["execution_mode"] = json!("build"); - } - if self.coordinator_default_parallel_override - && params - .get("allow_parallel_with_unlisted_open_tasks") - .is_none() - { - params["allow_parallel_with_unlisted_open_tasks"] = json!(true); - } - self.inner.execute_text(params, ctx).await - } -} +const RUN_ID: &str = "run-tools-pr4"; +const ROOT_SESSION: &str = "root-tools-pr4"; +const COORDINATOR_TURN: &str = "turn-coordinator-pr4"; +const ALICE: &str = "m-alice"; +const BOB: &str = "m-bob"; +const ALICE_SESSION: &str = "session-alice-pr4"; fn org_context() -> Arc { Arc::new(AgentOrgRunContext { - run_id: "run-tools-1".into(), - org_id: "org-tools-1".into(), - org_name: "Tools Org".into(), - org_role: "lead engineer".into(), - coordinator_agent_id: "coord-1".into(), + run_id: RUN_ID.into(), + org_id: "org-tools-pr4".into(), + org_name: "Tools PR4 Org".into(), + org_role: "lead".into(), + coordinator_agent_id: "agent-coordinator".into(), coordinator_name: "Coordinator".into(), - coordinator_role: "lead engineer".into(), + coordinator_role: "lead".into(), members: vec![ AgentOrgContextMember { - member_id: "m-alice".into(), + member_id: ALICE.into(), name: "Alice".into(), - role: "engineer".into(), - agent_id: "alice-1".into(), + role: "builder".into(), + agent_id: "agent-alice".into(), }, AgentOrgContextMember { - member_id: "m-bob".into(), + member_id: BOB.into(), name: "Bob".into(), - role: "engineer".into(), - agent_id: "bob-1".into(), + role: "reviewer".into(), + agent_id: "agent-bob".into(), }, ], plan_approval_policy: crate::definitions::orgs::PlanApprovalPolicy::Coordinator, capability_index: Default::default(), - root_session_id: Some("root-tools-1".into()), + root_session_id: Some(ROOT_SESSION.into()), }) } -fn ctx(caller_member_id: &str) -> Arc { - ctx_for_org(org_context(), caller_member_id) -} - -fn ctx_for_org( - org_context: Arc, - caller_member_id: &str, -) -> Arc { - let caller_agent_id = org_context - .require_participant_agent_id(caller_member_id) - .expect("test caller member id resolves"); +fn tools_context(caller_member_id: &str) -> Arc { + let org_context = org_context(); Arc::new(TaskToolsContext { - org_context, - caller_agent_id, + caller_agent_id: org_context + .require_participant_agent_id(caller_member_id) + .expect("participant"), caller_member_id: caller_member_id.to_string(), + org_context, wake_hook: Arc::new(NoopInboxWakeHook), }) } -fn multi_member_org_context() -> Arc { - Arc::new(AgentOrgRunContext { - run_id: "run-flat-tools".into(), - org_id: "org-flat-tools".into(), - org_name: "Flat Tools Org".into(), - org_role: "coordinator".into(), - coordinator_agent_id: "coord-flat".into(), - coordinator_name: "Coordinator".into(), - coordinator_role: "coordinator".into(), - members: vec![ - AgentOrgContextMember { - member_id: "builder".into(), - name: "Builder".into(), - role: "backend".into(), - agent_id: "builder-agent".into(), - }, - AgentOrgContextMember { - member_id: "reviewer".into(), - name: "Reviewer".into(), - role: "implementer".into(), - agent_id: "reviewer-agent".into(), - }, - AgentOrgContextMember { - member_id: "peer".into(), - name: "Peer".into(), - role: "reviewer".into(), - agent_id: "peer-agent".into(), - }, - ], - plan_approval_policy: crate::definitions::orgs::PlanApprovalPolicy::Coordinator, - capability_index: Default::default(), - root_session_id: Some("root-flat-tools".into()), - }) -} - -#[derive(Default, Debug)] -struct RecordingWakeHook { - calls: Mutex>, -} - -impl RecordingWakeHook { - fn snapshot(&self) -> Vec<(String, String)> { - self.calls.lock().unwrap().clone() - } -} - -impl InboxWakeHook for RecordingWakeHook { - fn wake_member(&self, member_id: &str, org_run_id: &str) { - self.calls - .lock() - .unwrap() - .push((member_id.to_string(), org_run_id.to_string())); - } -} - -fn ctx_with_wake( - caller_member_id: &str, - wake_hook: Arc, -) -> Arc { - let org_context = org_context(); - let caller_agent_id = org_context - .require_participant_agent_id(caller_member_id) - .expect("test caller member id resolves"); - Arc::new(TaskToolsContext { - org_context, - caller_agent_id, - caller_member_id: caller_member_id.to_string(), - wake_hook, - }) +fn coordinator_call() -> CallContext { + CallContext::for_turn( + "call-coordinator", + ROOT_SESSION, + COORDINATOR_TURN, + Vec::new(), + ) } -fn shared_sde_ctx(caller_member_id: Option<&str>) -> Arc { - let org_context = Arc::new(AgentOrgRunContext { - run_id: "run-shared-sde".into(), - org_id: "org-shared-sde".into(), - org_name: "Default Agent Org".into(), - org_role: "Coordinator".into(), - coordinator_agent_id: "builtin:sde".into(), - coordinator_name: "Coordinator".into(), - coordinator_role: "Coordinator".into(), - members: vec![AgentOrgContextMember { - member_id: "sde-planner".into(), - name: "Planner".into(), - role: "Plans".into(), - agent_id: "builtin:sde".into(), - }], - plan_approval_policy: crate::definitions::orgs::PlanApprovalPolicy::Coordinator, - capability_index: Default::default(), - root_session_id: Some("root-shared-sde".into()), - }); - Arc::new(TaskToolsContext { - org_context, - caller_agent_id: "builtin:sde".into(), - caller_member_id: caller_member_id - .unwrap_or(COORDINATOR_MEMBER_ID) - .to_string(), - wake_hook: Arc::new(NoopInboxWakeHook), - }) +fn owner_call(turn_id: &str) -> CallContext { + CallContext::for_turn("call-owner", ALICE_SESSION, turn_id, Vec::new()) } -fn task_tools_sandbox() -> test_env::SandboxGuard { +fn sandbox() -> test_env::SandboxGuard { let sandbox = test_env::sandbox(); - let conn = database::db::get_connection().expect("test sqlite connection"); - crate::foundation::persistence::session_snapshots::ensure_tables_with(&conn) - .expect("agent sessions schema"); - crate::session::persistence::init(&conn).expect("unified session schema"); - crate::coordination::init_agent_org_schemas(&conn).expect("canonical Agent Org schemas"); + let conn = database::db::get_connection().expect("test sqlite"); + crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); conn.execute_batch( - "CREATE TABLE IF NOT EXISTS code_sessions ( + "CREATE TABLE code_sessions ( session_id TEXT PRIMARY KEY, cli_agent_type TEXT NOT NULL, status TEXT NOT NULL, @@ -229,7 +90,7 @@ fn task_tools_sandbox() -> test_env::SandboxGuard { org_member_id TEXT, updated_at TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS session_turn_intents ( + CREATE TABLE session_turn_intents ( session_id TEXT NOT NULL, turn_intent_id TEXT NOT NULL, client_message_id TEXT, @@ -238,3043 +99,474 @@ fn task_tools_sandbox() -> test_env::SandboxGuard { status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - PRIMARY KEY (session_id, turn_intent_id) + PRIMARY KEY(session_id,turn_intent_id) );", ) - .expect("cli session schema"); + .expect("base Turn schema"); + crate::coordination::init_agent_org_schemas(&conn).expect("Agent Org schemas"); let now = chrono::Utc::now().to_rfc3339(); - for (run_id, org_id, coordinator_agent_id, root_session_id) in [ - ("run-tools-1", "org-tools-1", "coord-1", "root-tools-1"), - ( - "run-flat-tools", - "org-flat-tools", - "coord-flat", - "root-flat-tools", - ), - ( - "run-shared-sde", - "org-shared-sde", - "builtin:sde", - "root-shared-sde", - ), - ] { - conn.execute( - "INSERT OR IGNORE INTO agent_org_runtime_runs - (id, org_id, coordinator_agent_id, root_session_id, entry_mode, - status, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, 'standalone_session', 'running', ?5, ?5)", - rusqlite::params![run_id, org_id, coordinator_agent_id, root_session_id, &now], - ) - .expect("seed running parent Agent Org run"); - } + let snapshot = serde_json::json!({ + "schemaVersion": 1, + "orgId": "org-tools-pr4", + "orgName": "Tools PR4 Org", + "coordinatorRole": "lead", + "coordinatorAgentId": "agent-coordinator", + "planApprovalPolicy": "coordinator", + "members": [ + {"memberId": ALICE, "name": "Alice", "role": "builder", "agentId": "agent-alice"}, + {"memberId": BOB, "name": "Bob", "role": "reviewer", "agentId": "agent-bob"} + ], + "additionalTaskGraphWriterMemberIds": [], + "memberCommunicationLinks": [], + }) + .to_string(); + conn.execute( + "INSERT INTO agent_org_runtime_runs( + id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, + entry_mode,status,activation_generation,created_at,updated_at + ) VALUES (?1,'org-tools-pr4','agent-coordinator',?2,?3, + 'standalone_session','running',1,?4,?4)", + rusqlite::params![RUN_ID, ROOT_SESSION, snapshot, now], + ) + .expect("running Team"); + insert_coordinator_context(&conn); sandbox } -#[tokio::test] -async fn task_graph_create_inserts_complete_chain_atomically() { - let _sandbox = task_tools_sandbox(); - let tool = TaskGraphCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let result = tool - .execute_text( - json!({ - "tasks": [ - { "key": "plan", "subject": "Plan", "owner_member_id": "m-alice", "execution_mode": "plan" }, - { "key": "write", "subject": "Write", "owner_member_id": "m-bob", "execution_mode": "build", "depends_on": ["plan"] }, - { "key": "review", "subject": "Review", "owner_member_id": "m-alice", "execution_mode": "build", "depends_on": ["write"] }, - { "key": "final", "subject": "Synthesize", "owner_member_id": "coordinator", "execution_mode": "build", "depends_on": ["review"] } - ] - }), - &test_ctx(), - ) - .await - .expect("valid graph"); - let value: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(value["created"], true); - assert_eq!(value["tasks"].as_array().unwrap().len(), 4); - - let plan_id = value["task_id_by_key"]["plan"].as_str().unwrap(); - let write_id = value["task_id_by_key"]["write"].as_str().unwrap(); - let review_id = value["task_id_by_key"]["review"].as_str().unwrap(); - let final_id = value["task_id_by_key"]["final"].as_str().unwrap(); - assert_eq!( - AgentOrgTaskStore::get("run-tools-1", write_id) - .unwrap() - .unwrap() - .blocked_by, - vec![plan_id] - ); - assert_eq!( - AgentOrgTaskStore::get("run-tools-1", review_id) - .unwrap() - .unwrap() - .blocked_by, - vec![write_id] - ); - assert_eq!( - AgentOrgTaskStore::get("run-tools-1", final_id) - .unwrap() - .unwrap() - .blocked_by, - vec![review_id] - ); -} - -#[test] -fn task_graph_create_schema_is_provider_compatible() { - let tool = TaskGraphCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let schema = tool.parameters(); - crate::tools::traits::assert_llm_compatible_schema(&schema) - .expect("task_graph_create schema must remain provider-compatible"); - assert!(schema["required"] - .as_array() - .unwrap() - .contains(&json!("tasks"))); -} - -#[tokio::test] -async fn task_graph_create_is_coordinator_only() { - let _sandbox = task_tools_sandbox(); - let tool = TaskGraphCreateTool::new(ctx("m-alice")); - let result = tool - .execute_text( - json!({ - "tasks": [ - { "key": "self", "subject": "Self", "owner_member_id": "m-alice", "execution_mode": "build" } - ] - }), - &test_ctx(), - ) - .await - .expect("authorization denial is structured guidance"); - let value: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert!(AgentOrgTaskStore::list("run-tools-1").unwrap().is_empty()); +fn insert_base_turn(conn: &rusqlite::Connection, session_id: &str, turn_id: &str) { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO session_turn_intents( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES (?1,?2,?3,'agent_org','running',?4,?4)", + rusqlite::params![session_id, turn_id, RUN_ID, now], + ) + .expect("base Turn"); } -#[tokio::test] -async fn task_graph_create_cycle_leaves_board_empty() { - let _sandbox = task_tools_sandbox(); - let tool = TaskGraphCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let error = tool - .execute_text( - json!({ - "tasks": [ - { "key": "a", "subject": "A", "owner_member_id": "m-alice", "execution_mode": "build", "depends_on": ["b"] }, - { "key": "b", "subject": "B", "owner_member_id": "m-bob", "execution_mode": "build", "depends_on": ["a"] } - ] - }), - &test_ctx(), - ) - .await - .expect_err("cycle must reject the whole graph") - .to_string(); - assert!(error.contains(TASK_DEPENDENCY_CYCLE_ERROR), "{error}"); - assert!(AgentOrgTaskStore::list("run-tools-1").unwrap().is_empty()); +fn insert_coordinator_context(conn: &rusqlite::Connection) { + insert_base_turn(conn, ROOT_SESSION, COORDINATOR_TURN); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + source_kind,source_id,activation_generation,created_at + ) VALUES (?1,?2,?3,'coordinator','coordinator','root_turn',?2,1,?4)", + rusqlite::params![ROOT_SESSION, COORDINATOR_TURN, RUN_ID, now], + ) + .expect("Coordinator context"); } -#[tokio::test] -async fn task_create_rejects_missing_dispatch_policy() { - let _sandbox = task_tools_sandbox(); - let tool = ProductionTaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool - .execute_text( - json!({ "subject": "Ambiguous scheduling", "owner_member_id": "m-alice", "execution_mode": "build" }), - &test_ctx(), +fn insert_owner_context(conn: &rusqlite::Connection, turn_id: &str, task_id: &str) { + insert_base_turn(conn, ALICE_SESSION, turn_id); + let sequence: i64 = conn + .query_row( + "SELECT COALESCE(MAX(member_dispatch_sequence),0)+1 + FROM agent_org_runtime_turn_contexts + WHERE org_run_id=?1 AND dispatch_member_id=?2", + rusqlite::params![RUN_ID, ALICE], + |row| row.get(0), ) - .await - .expect_err("dispatch policy must never silently default to immediate"); - assert!( - matches!(err, ToolError::InvalidParams(message) if message.contains("dispatch_policy")) - ); + .unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + task_id,owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,activation_generation,created_at + ) VALUES (?1,?2,?3,?4,'task_execution',?5,?4,?4,?6, + 'task',?5,1,?7)", + rusqlite::params![ + ALICE_SESSION, + turn_id, + RUN_ID, + ALICE, + task_id, + sequence, + now + ], + ) + .expect("Owner context"); } -#[tokio::test] -async fn task_create_rejects_ownerless_task_with_undeliverable_id() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let oversized_id = - "x".repeat(crate::coordination::agent_org_payload_limits::TASK_IDENTIFIER_MAX_CHARS + 1); - let error = tool +async fn create_owned(id: &str, owner: &str) -> Value { + let result = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "id": oversized_id, - "subject": "Ownerless bounded task", - "eligible_member_ids": ["m-alice"], + "id": id, + "subject": format!("Task {id}"), + "owner_member_id": owner, + "dispatch_policy": "immediate", + "execution_mode": "build", + "allow_parallel_with_unlisted_open_tasks": true }), - &test_ctx(), + &coordinator_call(), ) .await - .expect_err("task id must fit every TaskAssigned delivery path"); - - assert!( - matches!(error, ToolError::InvalidParams(message) if message.contains("task_create.id must be <= 1000 chars")) - ); - assert!(AgentOrgTaskStore::list("run-tools-1") - .expect("inspect rejected board") - .is_empty()); + .expect("create assigned pending Task"); + serde_json::from_str(&result).expect("Task JSON") } #[test] -fn task_create_schema_requires_unambiguous_dispatch_policy() { - let tool = ProductionTaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let schema = tool.parameters(); - crate::tools::traits::assert_llm_compatible_schema(&schema) - .expect("task_create schema must remain provider-compatible"); - let required = schema["required"] - .as_array() - .expect("task_create schema has required fields"); - assert!(required.iter().any(|field| field == "dispatch_policy")); - assert!(required.iter().any(|field| field == "execution_mode")); - let properties = schema["properties"] +fn task_tool_schemas_expose_pending_create_and_tagged_update() { + let create = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let create_schema = create.parameters(); + crate::tools::traits::assert_llm_compatible_schema(&create_schema).expect("task_create schema"); + assert!(!create_schema["properties"] .as_object() - .expect("task_create schema has properties"); - assert!(properties.contains_key("dispatch_policy")); - assert!(properties.contains_key("allow_parallel_with_unlisted_open_tasks")); - assert!(!properties.contains_key("blocked_by")); - assert!(!properties.contains_key("blocks")); -} + .unwrap() + .contains_key("status")); -#[tokio::test] -async fn task_create_rejects_unknown_dependency_id() { - let _sandbox = task_tools_sandbox(); - let tool = ProductionTaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool - .execute_text( - json!({ - "subject": "Review missing work", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "execution_mode": "build", - "dependency_task_ids": ["task-that-does-not-exist"] - }), - &test_ctx(), - ) - .await - .expect_err("unknown dependencies must not create permanently blocked work"); - assert!(matches!( - err, - ToolError::InvalidParams(message) - if message.contains("do not exist") && message.contains("task-that-does-not-exist") - )); + let update = TaskUpdateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let update_schema = update.parameters(); + crate::tools::traits::assert_llm_compatible_schema(&update_schema).expect("task_update schema"); + assert!(update_schema.to_string().contains("operation")); + + let graph = TaskGraphCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + crate::tools::traits::assert_llm_compatible_schema(&graph.parameters()) + .expect("task_graph_create schema"); } #[tokio::test] -async fn task_create_rejects_dispatch_policy_shape_mismatches() { - let _sandbox = task_tools_sandbox(); - let tool = ProductionTaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let error = tool +async fn task_create_uses_persisted_coordinator_context_and_always_creates_pending() { + let _sandbox = sandbox(); + let value = create_owned("created", ALICE).await; + assert_eq!(value["task"]["status"], "pending"); + let stored = AgentOrgTaskStore::get(RUN_ID, "created").unwrap().unwrap(); + assert_eq!(stored.status, TaskStatus::Pending); + assert_eq!(stored.created_by_participant_id, COORDINATOR_MEMBER_ID); + assert_eq!(stored.source_turn_intent_id, COORDINATOR_TURN); + + let status_error = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "subject": "Immediate with hidden dependency", - "owner_member_id": "m-alice", + "id": "forged-state", + "subject": "Forged", + "owner_member_id": ALICE, + "status": "completed", "dispatch_policy": "immediate", "execution_mode": "build", - "dependency_task_ids": ["some-upstream"] + "allow_parallel_with_unlisted_open_tasks": true }), - &test_ctx(), + &coordinator_call(), ) .await - .expect_err("immediate work cannot smuggle a dependency"); - assert!(matches!( - error, - ToolError::InvalidParams(message) if message.contains("cannot include dependency_task_ids") - )); + .expect_err("initial status is not writable"); + assert!(matches!(status_error, ToolError::InvalidParams(_))); - let result = tool + let owner_error = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "subject": "Dependent without upstream", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "execution_mode": "build" + "id": "coordinator-owned", + "subject": "Forbidden", + "owner_member_id": "coordinator", + "dispatch_policy": "immediate", + "execution_mode": "build", + "allow_parallel_with_unlisted_open_tasks": true }), - &test_ctx(), + &coordinator_call(), ) .await - .expect("missing dependency ids are recoverable guidance"); - let value: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(value["requires_dependency_ids"], true); + .expect_err("Coordinator cannot be Owner"); + assert!(owner_error.to_string().contains("formal Task Owner")); } #[tokio::test] -async fn task_create_requires_confirmation_for_omitted_open_tasks() { - let _sandbox = task_tools_sandbox(); - let tool = ProductionTaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - - tool.execute_text( - json!({ - "id": "plan-stage", - "subject": "Plan the deliverable", - "owner_member_id": "m-alice", - "dispatch_policy": "immediate", - "execution_mode": "plan" - }), - &test_ctx(), - ) - .await - .unwrap(); - let premature_build = tool +async fn task_create_rejects_contextless_or_member_graph_writer_calls() { + let _sandbox = sandbox(); + let missing = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)) .execute_text( json!({ - "id": "premature-build", - "subject": "Build before planning finishes", - "owner_member_id": "m-bob", + "id": "missing-context", + "subject": "Missing context", + "owner_member_id": ALICE, "dispatch_policy": "immediate", - "execution_mode": "build" + "execution_mode": "build", + "allow_parallel_with_unlisted_open_tasks": true }), - &test_ctx(), + &CallContext::default(), ) .await - .expect("open planning task returns dependency guidance"); - let premature_value: Value = serde_json::from_str(&premature_build).unwrap(); - assert_eq!(premature_value["requires_dependency_confirmation"], true); - assert_eq!( - premature_value["unlisted_open_tasks"][0]["id"], - "plan-stage" - ); - assert!(AgentOrgTaskStore::get("run-tools-1", "premature-build") + .expect_err("Store actor requires persisted context"); + assert!(missing.to_string().contains("context_required")); + assert!(AgentOrgTaskStore::get(RUN_ID, "missing-context") .unwrap() .is_none()); - tool.execute_text( - json!({ - "id": "implement-stage", - "subject": "Write the deliverable", - "owner_member_id": "m-bob", - "dispatch_policy": "after_dependencies", - "execution_mode": "build", - "dependency_task_ids": ["plan-stage"] - }), - &test_ctx(), - ) - .await - .unwrap(); - let review_guidance = tool + let member = TaskCreateTool::new(tools_context(ALICE)) .execute_text( json!({ - "id": "review-stage", - "subject": "Review the written deliverable", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "execution_mode": "build", - "dependency_task_ids": ["plan-stage"] - }), - &test_ctx(), - ) - .await - .expect("omitted dependency returns recoverable guidance"); - let review_value: Value = serde_json::from_str(&review_guidance).unwrap(); - assert_eq!(review_value["created"], false); - assert_eq!(review_value["requires_dependency_confirmation"], true); - assert_eq!( - review_value["unlisted_open_tasks"][0]["id"], - "implement-stage" - ); - assert!( - AgentOrgTaskStore::get("run-tools-1", "review-stage") - .unwrap() - .is_none(), - "guidance must not leave a partially-created task" - ); - - tool.execute_text( - json!({ - "id": "review-stage", - "subject": "Review the written deliverable", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "execution_mode": "build", - "dependency_task_ids": ["plan-stage", "implement-stage"] - }), - &test_ctx(), - ) - .await - .expect("coordinator can retry with the missing dependency"); - - let synthesis_guidance = tool - .execute_text( - json!({ - "id": "synthesis-stage", - "subject": "Synthesize the final result", - "owner_member_id": "m-bob", - "dispatch_policy": "after_dependencies", - "execution_mode": "build", - "dependency_task_ids": ["plan-stage"] + "id": "member-graph", + "subject": "Member graph", + "owner_member_id": ALICE, + "dispatch_policy": "immediate", + "execution_mode": "build" }), - &test_ctx(), + &owner_call("not-a-context"), ) .await - .expect("final synthesis must confirm every omitted open stage"); - let synthesis_value: Value = serde_json::from_str(&synthesis_guidance).unwrap(); - let omitted_ids = synthesis_value["unlisted_open_tasks"] - .as_array() - .unwrap() - .iter() - .map(|task| task["id"].as_str().unwrap()) - .collect::>(); - assert_eq!(omitted_ids, vec!["implement-stage", "review-stage"]); - assert!(AgentOrgTaskStore::get("run-tools-1", "synthesis-stage") + .expect("tool returns structured self-correcting authorization guidance"); + let denied: Value = serde_json::from_str(&member).expect("authorization JSON"); + assert_eq!(denied["authorization_denied"], true); + assert!(denied["guidance"].as_str().unwrap().contains("Coordinator")); + assert!(AgentOrgTaskStore::get(RUN_ID, "member-graph") .unwrap() .is_none()); } #[tokio::test] -async fn task_create_allows_explicit_parallelism_with_unlisted_open_tasks() { - let _sandbox = task_tools_sandbox(); - let tool = ProductionTaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - - for (id, owner) in [("producer-a", "m-alice"), ("unrelated-b", "m-bob")] { - tool.execute_text( +async fn task_graph_create_is_atomic_and_rejects_cycle_or_coordinator_owner() { + let _sandbox = sandbox(); + let tool = TaskGraphCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let cycle = tool + .execute_text( json!({ - "id": id, - "subject": id, - "owner_member_id": owner, - "dispatch_policy": "immediate", - "execution_mode": "build", - "allow_parallel_with_unlisted_open_tasks": id == "unrelated-b" + "tasks": [ + {"key":"a","subject":"A","owner_member_id":ALICE,"execution_mode":"build","depends_on":["b"]}, + {"key":"b","subject":"B","owner_member_id":BOB,"execution_mode":"build","depends_on":["a"]} + ] }), - &test_ctx(), + &coordinator_call(), ) .await - .unwrap(); - } + .expect_err("cycle rejects graph"); + assert!(cycle.to_string().contains("cycle")); + assert!(AgentOrgTaskStore::list(RUN_ID).unwrap().is_empty()); - let response = tool + let bad_owner = tool .execute_text( json!({ - "id": "consumer-a", - "subject": "Consume only producer A", - "owner_member_id": "coordinator", - "dispatch_policy": "after_dependencies", - "execution_mode": "build", - "dependency_task_ids": ["producer-a"], - "allow_parallel_with_unlisted_open_tasks": true + "tasks": [ + {"key":"a","subject":"A","owner_member_id":"coordinator","execution_mode":"build"} + ] }), - &test_ctx(), + &coordinator_call(), ) .await - .expect("explicit confirmation preserves intentional parallelism"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["already_exists"], false); - assert_eq!(value["task"]["blocked_by"], json!(["producer-a"])); + .expect_err("Coordinator graph node Owner is forbidden"); + assert!(bad_owner.to_string().contains("formal Task Owner")); + + let created: Value = serde_json::from_str( + &tool + .execute_text( + json!({ + "tasks": [ + {"key":"a","subject":"A","owner_member_id":ALICE,"execution_mode":"build"}, + {"key":"b","subject":"B","owner_member_id":BOB,"execution_mode":"build","depends_on":["a"]} + ] + }), + &coordinator_call(), + ) + .await + .expect("valid graph"), + ) + .unwrap(); + assert_eq!(created["tasks"].as_array().unwrap().len(), 2); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn task_create_rechecks_open_work_after_stale_advisory_read() { - let _sandbox = task_tools_sandbox(); - let hook = Arc::new(TaskCreatePrePersistHook::default()); - let candidate_tool = ProductionTaskCreateTool::with_pre_persist_hook( - ctx(COORDINATOR_MEMBER_ID), - Arc::clone(&hook), - ); +#[tokio::test] +async fn owner_tagged_operations_follow_task_execution_context() { + let _sandbox = sandbox(); + create_owned("owned", ALICE).await; + let conn = database::db::get_connection().unwrap(); + insert_owner_context(&conn, "turn-owned", "owned"); + let tool = TaskUpdateTool::new(tools_context(ALICE)); + + let started: Value = serde_json::from_str( + &tool + .execute_text( + json!({"operation":"start","id":"owned"}), + &owner_call("turn-owned"), + ) + .await + .expect("Owner start"), + ) + .unwrap(); + assert_eq!(started["task"]["status"], "in_progress"); + + tool.execute_text( + json!({"operation":"append_progress","id":"owned","body":"halfway"}), + &owner_call("turn-owned"), + ) + .await + .expect("Owner progress"); - // Pause the first request after it has observed an empty board but before - // it asks the store to commit. This recreates the exact TOCTOU window that - // existed when the confirmation gate lived only in the tool layer. - let candidate = tokio::spawn(async move { - candidate_tool + let completed: Value = serde_json::from_str( + &tool .execute_text( json!({ - "id": "candidate-after-stale-read", - "subject": "Candidate created from a stale board snapshot", - "owner_member_id": "m-alice", - "dispatch_policy": "immediate", - "execution_mode": "build" + "operation":"complete", + "id":"owned", + "output":{ + "summary":"done", + "content":"full result", + "artifact_ids":["artifact-1"] + } }), - &test_ctx(), + &owner_call("turn-owned"), ) .await - }); - hook.wait_until_reached().await; + .expect("Owner complete"), + ) + .unwrap(); + assert_eq!(completed["task"]["status"], "completed"); + let stored = AgentOrgTaskStore::get(RUN_ID, "owned").unwrap().unwrap(); + assert_eq!(stored.output.as_ref().unwrap().produced_by_member_id, ALICE); + + let mixed = tool + .execute_text( + json!({"operation":"start","id":"owned","subject":"forged graph field"}), + &owner_call("turn-owned"), + ) + .await + .expect_err("mixed Owner and graph fields are rejected"); + assert!(matches!(mixed, ToolError::InvalidParams(_))); +} - // A genuinely independent request commits new open work while the first - // request is paused. The first request's advisory read can no longer see - // this row, so only a transaction-time graph check can protect the gate. - ProductionTaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)) +#[tokio::test] +async fn coordinator_cannot_submit_output_and_wrong_turn_cannot_start() { + let _sandbox = sandbox(); + create_owned("protected", ALICE).await; + let coordinator = TaskUpdateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let error = coordinator .execute_text( json!({ - "id": "concurrent-open-work", - "subject": "Concurrent independent work", - "owner_member_id": "m-bob", - "dispatch_policy": "immediate", - "execution_mode": "build", - "allow_parallel_with_unlisted_open_tasks": true + "operation":"complete", + "id":"protected", + "output":{"summary":"forged"} }), - &test_ctx(), + &coordinator_call(), ) .await - .expect("concurrent task commits before the stale request resumes"); - hook.resume(); + .expect_err("Coordinator cannot complete Owner Task"); + assert!(error.to_string().contains("Owner lifecycle")); - let response = candidate + let owner = TaskUpdateTool::new(tools_context(ALICE)); + let error = owner + .execute_text( + json!({"operation":"start","id":"protected"}), + &owner_call("missing-turn"), + ) .await - .expect("candidate task join") - .expect("transaction conflict is recoverable guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["created"], false); - assert_eq!(value["requires_dependency_confirmation"], true); - assert_eq!(value["requires_parallel_confirmation"], true); + .expect_err("missing persisted Turn context"); + assert!(error.to_string().contains("missing companion context")); assert_eq!( - value["unlisted_open_task_ids"], - json!(["concurrent-open-work"]) - ); - assert!( - AgentOrgTaskStore::get("run-tools-1", "candidate-after-stale-read") + AgentOrgTaskStore::get(RUN_ID, "protected") .unwrap() - .is_none() - ); - assert_eq!( - AgentOrgTaskStore::list("run-tools-1") - .expect("list board after rejected stale create") - .into_iter() - .map(|task| task.id) - .collect::>(), - vec!["concurrent-open-work"], - "the rejected request must leave no task or partial graph row" - ); - assert!(AgentOrgTaskStore::list_history("run-tools-1") - .expect("list task history") - .iter() - .all(|event| event.task_id != "candidate-after-stale-read")); -} - -#[tokio::test] -async fn task_create_rejects_ownerless_pending_without_eligibility() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let tool = TaskCreateTool::new(Arc::clone(&ctx)); - let err = tool - .execute_text(json!({ "subject": "S1" }), &test_ctx()) - .await - .expect_err("ownerless pending tasks require eligibility"); - assert!( - matches!(err, ToolError::InvalidParams(message) if message.contains("eligible_member_ids")) + .unwrap() + .status, + TaskStatus::Pending ); } #[tokio::test] -async fn task_create_rejects_reserved_metadata_dispatch_keys() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool +async fn cancel_replace_and_late_callback_use_the_store_gate() { + let _sandbox = sandbox(); + create_owned("old", ALICE).await; + let conn = database::db::get_connection().unwrap(); + insert_owner_context(&conn, "turn-old", "old"); + let owner = TaskUpdateTool::new(tools_context(ALICE)); + owner .execute_text( - json!({ - "subject": "Reserved metadata", - "owner_member_id": "m-alice", - "metadata": { "eligible_member_ids": ["m-bob"] } - }), - &test_ctx(), + json!({"operation":"start","id":"old"}), + &owner_call("turn-old"), ) .await - .expect_err("reserved metadata must use typed fields"); - assert!(matches!(err, ToolError::InvalidParams(message) if message.contains("reserved"))); -} + .unwrap(); -#[tokio::test] -async fn task_create_unassigned_with_eligibility_waits_for_coordinator_assignment() { - let _sandbox = task_tools_sandbox(); - let wake_hook = Arc::new(RecordingWakeHook::default()); - let ctx = ctx_with_wake(COORDINATOR_MEMBER_ID, wake_hook.clone()); - let tool = TaskCreateTool::new(ctx); + let coordinator = TaskUpdateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let replaced: Value = serde_json::from_str( + &coordinator + .execute_text( + json!({ + "operation":"cancel_and_replace", + "id":"old", + "reason":{"code":"scope.changed","message":"new goal"}, + "replacement":{ + "id":"replacement", + "subject":"Replacement", + "owner_member_id":BOB, + "execution_mode":"build", + "eligible_member_ids":[BOB] + } + }), + &coordinator_call(), + ) + .await + .expect("cancel and replace"), + ) + .unwrap(); + assert_eq!(replaced["task"]["status"], "cancelled"); + assert_eq!(replaced["replacement"]["status"], "pending"); - let res = tool + let late = owner .execute_text( json!({ - "subject": "S1 eligible", - "eligible_member_ids": ["m-alice"], - "required_role": "engineer", + "operation":"complete", + "id":"old", + "output":{"summary":"late"} }), - &test_ctx(), + &owner_call("turn-old"), ) .await - .expect("task_create succeeds"); - - let value: Value = serde_json::from_str(&res).unwrap(); - assert!(!value["task_assigned_dispatched"].as_bool().unwrap()); - assert_eq!(value["assignment_required"], true); - assert!(value["guidance"] - .as_str() - .is_some_and(|guidance| guidance.contains("explicit owner assignment"))); - assert!(wake_hook.snapshot().is_empty()); - let alice_inbox = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1").unwrap(); - let bob_inbox = AgentInboxStore::list_unread_for_member("m-bob", "run-tools-1").unwrap(); - assert!(alice_inbox.is_empty()); - assert!(bob_inbox.is_empty()); + .expect_err("late callback rejected"); + assert!(late.to_string().contains("requires_in_progress")); } #[tokio::test] -async fn task_create_rejects_coordinator_in_eligible_member_ids() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool +async fn task_list_and_get_cover_five_states_without_loading_detail_in_pages() { + let _sandbox = sandbox(); + create_owned("pending", BOB).await; + create_owned("completed", ALICE).await; + let conn = database::db::get_connection().unwrap(); + insert_owner_context(&conn, "turn-complete", "completed"); + let owner = TaskUpdateTool::new(tools_context(ALICE)); + owner + .execute_text( + json!({"operation":"start","id":"completed"}), + &owner_call("turn-complete"), + ) + .await + .unwrap(); + owner .execute_text( json!({ - "subject": "Invalid eligibility", - "eligible_member_ids": ["coordinator"], + "operation":"complete", + "id":"completed", + "output":{"summary":"summary","content":"detail"} }), - &test_ctx(), - ) - .await - .expect_err("coordinator is not a worker eligibility target"); - match err { - ToolError::InvalidParams(msg) => assert!(msg.contains("eligible_member_ids")), - other => panic!("expected InvalidParams, got {other:?}"), - } -} - -#[tokio::test] -async fn task_create_with_owner_dispatches_inbox() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let tool = TaskCreateTool::new(Arc::clone(&ctx)); - let res = tool - .execute_text( - json!({ - "subject": "S2", - "owner_member_id": "m-alice", - "description": "do the thing", - }), - &test_ctx(), - ) - .await - .expect("task_create succeeds"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert!(value["task_assigned_dispatched"].as_bool().unwrap()); - - let inbox = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1").unwrap(); - assert_eq!(inbox.len(), 1); - let payload: AgentMessage = serde_json::from_str(&inbox[0].payload_json).unwrap(); - match &payload { - AgentMessage::TaskAssigned { - subject, - assigned_by, - .. - } => { - assert_eq!(subject, "S2"); - assert_eq!(assigned_by, "Coordinator"); - } - other => panic!("expected TaskAssigned, got {other:?}"), - } -} - -#[tokio::test] -async fn task_create_duplicate_explicit_id_returns_existing_without_dispatch() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let tool = TaskCreateTool::new(Arc::clone(&ctx)); - let first = tool - .execute_text( - json!({ - "id": "stable-task-id", - "subject": "Original subject", - "owner_member_id": "m-alice", - }), - &test_ctx(), - ) - .await - .expect("first task_create succeeds"); - let first_value: Value = serde_json::from_str(&first).unwrap(); - assert!(!first_value["already_exists"].as_bool().unwrap()); - assert!(first_value["task_assigned_dispatched"].as_bool().unwrap()); - - let second = tool - .execute_text( - json!({ - "id": "stable-task-id", - "subject": "Retry subject should not replace original", - "owner_member_id": "m-bob", - }), - &test_ctx(), - ) - .await - .expect("duplicate task_create returns existing task"); - let second_value: Value = serde_json::from_str(&second).unwrap(); - assert!(second_value["already_exists"].as_bool().unwrap()); - assert!(!second_value["task_assigned_dispatched"].as_bool().unwrap()); - assert_eq!( - second_value["task"]["subject"].as_str().unwrap(), - "Original subject" - ); - assert_eq!(second_value["task"]["owner"].as_str().unwrap(), "m-alice"); - - let alice_inbox = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1").unwrap(); - let bob_inbox = AgentInboxStore::list_unread_for_member("m-bob", "run-tools-1").unwrap(); - assert_eq!(alice_inbox.len(), 1); - assert!(bob_inbox.is_empty()); -} - -#[tokio::test] -async fn task_create_coordinator_in_progress_requires_explicit_owner_member_id() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool - .execute_text( - json!({ - "subject": "Coordinator started work", - "status": "in_progress" - }), - &test_ctx(), - ) - .await - .expect_err("ownerless in_progress task_create is invalid"); - match err { - ToolError::InvalidParams(msg) => assert!(msg.contains("owner_member_id")), - other => panic!("expected InvalidParams, got {other:?}"), - } -} - -#[tokio::test] -async fn task_create_coordinator_can_start_explicit_coordinator_work() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let res = tool - .execute_text( - json!({ - "subject": "Coordinator explicit work", - "status": "in_progress", - "owner_member_id": "coordinator" - }), - &test_ctx(), - ) - .await - .expect("coordinator can explicitly own in-progress work"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["task"]["status"].as_str().unwrap(), "in_progress"); - assert_eq!(value["task"]["owner"].as_str().unwrap(), "coordinator"); -} - -#[tokio::test] -async fn task_create_coordinator_can_assign_member_pending_work() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let res = tool - .execute_text( - json!({ - "subject": "Coordinator assigned member work", - "status": "pending", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .expect("task_create assigns pending member work"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["task"]["status"].as_str().unwrap(), "pending"); - assert_eq!(value["task"]["owner"].as_str().unwrap(), "m-alice"); - assert!(value["task_assigned_dispatched"].as_bool().unwrap()); - let inbox = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1").unwrap(); - assert_eq!(inbox.len(), 1); -} - -#[tokio::test] -async fn task_create_member_in_progress_requires_explicit_owner_member_id() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx("m-alice")); - let err = tool - .execute_text( - json!({ - "subject": "Alice started work", - "status": "in_progress" - }), - &test_ctx(), - ) - .await - .expect_err("ownerless in_progress task_create is invalid"); - match err { - ToolError::InvalidParams(msg) => assert!(msg.contains("owner_member_id")), - other => panic!("expected InvalidParams, got {other:?}"), - } -} - -#[tokio::test] -async fn task_create_coordinator_cannot_start_member_work_in_progress() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool - .execute_text( - json!({ - "subject": "Coordinator attempted member start", - "status": "in_progress", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .expect_err("coordinator cannot start another member's work"); - match err { - ToolError::InvalidParams(msg) => assert!(msg.contains("owning member")), - other => panic!("expected InvalidParams, got {other:?}"), - } -} - -#[tokio::test] -async fn task_create_member_cannot_start_other_member_work_in_progress() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx("m-alice")); - let response = tool - .execute_text( - json!({ - "subject": "Alice attempted Bob start", - "status": "in_progress", - "owner_member_id": "m-bob" - }), - &test_ctx(), - ) - .await - .expect("authorization misuse returns structured guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_create.assign_owner"); - assert_eq!(value["denied_target_member_ids"], json!(["m-bob"])); - assert!(AgentOrgTaskStore::list("run-tools-1").unwrap().is_empty()); -} - -#[tokio::test] -async fn task_create_member_can_start_self_work_in_progress() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx("m-alice")); - let res = tool - .execute_text( - json!({ - "subject": "Alice started self work", - "status": "in_progress", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .expect("member can start self-owned work"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["task"]["status"].as_str().unwrap(), "in_progress"); - assert_eq!(value["task"]["owner"].as_str().unwrap(), "m-alice"); -} - -#[tokio::test] -async fn task_authority_member_cannot_assign_another_member() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx_for_org(multi_member_org_context(), "builder")); - let response = tool - .execute_text( - json!({ - "id": "reviewer-task", - "subject": "Member attempts assignment", - "owner_member_id": "reviewer" - }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["denied_target_member_ids"], json!(["reviewer"])); -} - -#[tokio::test] -async fn task_authority_member_cannot_assign_peer() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx_for_org(multi_member_org_context(), "builder")); - let response = tool - .execute_text( - json!({ - "id": "peer-assignment", - "subject": "Member attempted peer assignment", - "owner_member_id": "peer" - }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["denied_target_member_ids"], json!(["peer"])); - assert!(AgentOrgTaskStore::get("run-flat-tools", "peer-assignment") - .unwrap() - .is_none()); -} - -#[tokio::test] -async fn task_authority_member_cannot_create_cross_peer_candidate_pool() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx("m-alice")); - let response = tool - .execute_text( - json!({ - "id": "cross-peer-pool", - "subject": "Alice attempted a Bob candidate pool", - "eligible_member_ids": ["m-bob"] - }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_create.set_eligibility"); - assert!(AgentOrgTaskStore::get("run-tools-1", "cross-peer-pool") - .unwrap() - .is_none()); -} - -#[tokio::test] -async fn task_authority_only_coordinator_can_override_unlisted_open_work() { - let _sandbox = task_tools_sandbox(); - let tool = ProductionTaskCreateTool::new(ctx("m-alice")); - let response = tool - .execute_text( - json!({ - "subject": "Alice attempted global parallel override", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["not-yet-checked"], - "execution_mode": "build", - "allow_parallel_with_unlisted_open_tasks": true - }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_create.override_unlisted_open_tasks"); - assert!(AgentOrgTaskStore::list("run-tools-1").unwrap().is_empty()); -} - -#[tokio::test] -async fn task_create_shared_agent_coordinator_member_id_explicitly_starts_work() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(shared_sde_ctx(Some(COORDINATOR_MEMBER_ID))); - let res = tool - .execute_text( - json!({ - "subject": "Shared SDE coordinator explicit start", - "status": "in_progress", - "owner_member_id": "coordinator" - }), - &test_ctx(), - ) - .await - .expect("shared-agent coordinator task_create uses member_id only"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["task"]["status"].as_str().unwrap(), "in_progress"); - assert_eq!(value["task"]["owner"].as_str().unwrap(), "coordinator"); -} - -#[tokio::test] -async fn task_create_rejects_unknown_owner() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool - .execute_text( - json!({ "subject": "S3", "owner_member_id": "ghost" }), - &test_ctx(), - ) - .await - .expect_err("must reject unknown owner"); - assert!(matches!(err, ToolError::InvalidParams(_))); -} - -#[tokio::test] -async fn task_create_rejects_dependency_cycle_as_invalid_params() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let err = tool - .execute_text( - json!({ - "id": "cycle-self", - "subject": "S3-cycle", - "eligible_member_ids": ["m-alice"], - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["cycle-self"] - }), - &test_ctx(), - ) - .await - .expect_err("must reject task dependency cycle"); - match err { - ToolError::InvalidParams(msg) => assert!(msg.contains(TASK_DEPENDENCY_CYCLE_ERROR)), - other => panic!("expected InvalidParams, got {other:?}"), - } -} - -#[tokio::test] -async fn task_update_rejects_dependency_cycle_as_invalid_params() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - create - .execute_text( - json!({ - "id": "first-cycle", - "subject": "First", - "eligible_member_ids": ["m-alice"] - }), - &test_ctx(), - ) - .await - .unwrap(); - create - .execute_text( - json!({ - "id": "second-cycle", - "subject": "Second", - "eligible_member_ids": ["m-alice"], - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["first-cycle"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let err = update - .execute_text( - json!({ "id": "first-cycle", "blocked_by": ["second-cycle"] }), - &test_ctx(), - ) - .await - .expect_err("must reject task dependency cycle"); - match err { - ToolError::InvalidParams(msg) => assert!(msg.contains(TASK_DEPENDENCY_CYCLE_ERROR)), - other => panic!("expected InvalidParams, got {other:?}"), - } -} - -#[tokio::test] -async fn task_update_in_progress_without_owner_returns_structured_rejection() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - create - .execute_text( - json!({ "id": "coord-start", "subject": "Coordinator start", "eligible_member_ids": ["m-alice"] }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let response = update - .execute_text( - json!({ "id": "coord-start", "status": "in_progress" }), - &test_ctx(), - ) - .await - .expect("ownerless in_progress misuse returns correction guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["rejected"], true); - assert_eq!(value["rejection_code"], "lifecycle_owner_only"); - assert_eq!(value["mutation_applied"], false); - assert_eq!(value["task"]["status"], "pending"); -} - -#[tokio::test] -async fn task_update_coordinator_can_start_explicit_coordinator_task() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - create - .execute_text( - json!({ - "id": "coordinator-owned-start", - "subject": "Coordinator owned start", - "owner_member_id": "coordinator" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let res = update - .execute_text( - json!({ "id": "coordinator-owned-start", "status": "in_progress" }), - &test_ctx(), - ) - .await - .expect("coordinator starts explicitly owned task"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["task"]["status"].as_str().unwrap(), "in_progress"); - assert_eq!(value["task"]["owner"].as_str().unwrap(), "coordinator"); -} - -#[tokio::test] -async fn task_update_coordinator_cannot_start_member_task_in_progress() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - create - .execute_text( - json!({ - "id": "member-owned-start-attempt", - "subject": "Member owned start attempt", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let response = update - .execute_text( - json!({ "id": "member-owned-start-attempt", "status": "in_progress" }), - &test_ctx(), - ) - .await - .expect("coordinator misuse returns structured correction guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["rejected"], true); - assert_eq!(value["rejection_code"], "lifecycle_owner_only"); - assert_eq!(value["details"]["caller_member_id"], "coordinator"); - assert_eq!(value["details"]["owner_member_id"], "m-alice"); - assert_eq!(value["task"]["status"], "pending"); -} - -#[tokio::test] -async fn task_update_coordinator_cannot_complete_member_task() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coordinator)); - create - .execute_text( - json!({ - "id": "member-owned-completion-attempt", - "subject": "Member owned completion attempt", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(coordinator); - let response = update - .execute_text( - json!({ - "id": "member-owned-completion-attempt", - "status": "completed", - "output": { "summary": "Coordinator guessed it was done" } - }), - &test_ctx(), - ) - .await - .expect("coordinator completion misuse returns structured guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["rejected"], true); - assert_eq!(value["rejection_code"], "output_owner_only"); - assert_eq!(value["mutation_applied"], false); - assert_eq!( - AgentOrgTaskStore::get("run-tools-1", "member-owned-completion-attempt") - .unwrap() - .unwrap() - .status - .as_wire(), - "pending" - ); -} - -#[tokio::test] -async fn task_update_missing_output_summary_returns_structured_rejection() { - let _sandbox = task_tools_sandbox(); - let create = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - create - .execute_text( - json!({ - "id": "missing-summary", - "subject": "Write a durable result", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ - "id": "missing-summary", - "status": "completed", - "output": { "content": "The full result exists, but its summary was omitted." } - }), - &test_ctx(), - ) - .await - .expect("missing summary is a recoverable rejection, not a tool error"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["rejected"], true); - assert_eq!(value["rejection_code"], "missing_output_summary"); - assert_eq!(value["task"]["subject"], "Write a durable result"); - assert_eq!(value["task"]["status"], "pending"); - assert!(value["guidance"].as_str().unwrap().contains("summary")); -} - -#[test] -fn task_update_schema_and_description_require_owner_authored_summary() { - let tool = TaskUpdateTool::new(ctx(COORDINATOR_MEMBER_ID)); - let schema = tool.parameters(); - crate::tools::traits::assert_llm_compatible_schema(&schema) - .expect("task_update schema must remain provider-compatible"); - let output_schema = &schema["properties"]["output"]; - let output_schema = output_schema - .get("anyOf") - .and_then(Value::as_array) - .and_then(|variants| { - variants - .iter() - .find(|variant| variant.get("properties").is_some()) - }) - .unwrap_or(output_schema); - assert!(output_schema["required"] - .as_array() - .is_some_and(|required| required.iter().any(|field| field == "summary"))); - let description = tool.description(); - assert!(description.contains("only the current owner may set")); - assert!(description.contains("summary` is required")); -} - -#[tokio::test] -async fn task_update_member_cannot_start_other_member_task_in_progress() { - let _sandbox = task_tools_sandbox(); - let coord = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coord)); - create - .execute_text( - json!({ - "id": "bob-owned-start-attempt", - "subject": "Bob owned start attempt", - "owner_member_id": "m-bob" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let alice = ctx("m-alice"); - let update = TaskUpdateTool::new(Arc::clone(&alice)); - let response = update - .execute_text( - json!({ "id": "bob-owned-start-attempt", "status": "in_progress" }), - &test_ctx(), - ) - .await - .expect("authorization misuse returns structured guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_update.modify"); - assert_eq!(value["denied_target_member_ids"], json!(["m-bob"])); -} - -#[tokio::test] -async fn task_authority_worker_cannot_delete_peer_task() { - let _sandbox = task_tools_sandbox(); - let create = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - create - .execute_text( - json!({ - "id": "peer-delete-target", - "subject": "Bob's protected task", - "owner_member_id": "m-bob" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ "id": "peer-delete-target", "status": "deleted" }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_update.delete"); - assert!(AgentOrgTaskStore::get("run-tools-1", "peer-delete-target") - .unwrap() - .is_some()); -} - -#[tokio::test] -async fn eligible_worker_cannot_delete_ownerless_task() { - let _sandbox = task_tools_sandbox(); - let create = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - create - .execute_text( - json!({ - "id": "ownerless-delete-target", - "subject": "Coordinator-owned unassigned work", - "eligible_member_ids": ["m-alice"] - }), - &test_ctx(), - ) - .await - .expect("coordinator creates ownerless task"); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ "id": "ownerless-delete-target", "status": "deleted" }), - &test_ctx(), - ) - .await - .expect("authorization misuse returns structured guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_update.delete"); - - let task = AgentOrgTaskStore::get("run-tools-1", "ownerless-delete-target") - .expect("load protected ownerless task") - .expect("task remains after denied delete"); - assert_eq!(task.owner, None); - assert_eq!(task.status, TaskStatus::Pending); - assert_eq!(eligible_member_ids(&task), vec!["m-alice"]); -} - -#[tokio::test] -async fn task_authority_worker_cannot_reassign_own_task_to_peer() { - let _sandbox = task_tools_sandbox(); - let create = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - create - .execute_text( - json!({ - "id": "self-to-peer-reassign", - "subject": "Alice's task", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ - "id": "self-to-peer-reassign", - "owner_member_id": "m-bob" - }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_update.reassign_owner"); - assert_eq!( - AgentOrgTaskStore::get("run-tools-1", "self-to-peer-reassign") - .unwrap() - .unwrap() - .owner - .as_deref(), - Some("m-alice") - ); -} - -#[tokio::test] -async fn task_authority_worker_cannot_unassign_into_preserved_cross_peer_pool() { - let _sandbox = task_tools_sandbox(); - let create = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - create - .execute_text( - json!({ - "id": "cross-peer-release", - "subject": "Alice task with coordinator-approved backups", - "owner_member_id": "m-alice", - "eligible_member_ids": ["m-alice", "m-bob"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ "id": "cross-peer-release", "owner_member_id": null }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!( - value["action"], - "task_update.unassign_for_coordinator_assignment" - ); - assert_eq!(value["denied_target_member_ids"], json!(["m-bob"])); - assert_eq!( - AgentOrgTaskStore::get("run-tools-1", "cross-peer-release") - .unwrap() - .unwrap() - .owner - .as_deref(), - Some("m-alice") - ); -} - -#[tokio::test] -async fn task_authority_member_cannot_edit_other_members_tasks() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx_for_org(multi_member_org_context(), COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(coordinator); - for (id, owner) in [("reviewer-work", "reviewer"), ("peer-work", "peer")] { - create - .execute_text( - json!({ "id": id, "subject": id, "owner_member_id": owner }), - &test_ctx(), - ) - .await - .unwrap(); - } - - let member = TaskUpdateTool::new(ctx_for_org(multi_member_org_context(), "builder")); - let reviewer_response = member - .execute_text( - json!({ - "id": "reviewer-work", - "description": "Unauthorized cross-member clarification" - }), - &test_ctx(), - ) - .await - .unwrap(); - let reviewer_value: Value = serde_json::from_str(&reviewer_response).unwrap(); - assert_eq!(reviewer_value["authorization_denied"], true); - assert_eq!( - reviewer_value["denied_target_member_ids"], - json!(["reviewer"]) - ); - - let peer_response = member - .execute_text( - json!({ "id": "peer-work", "description": "Unauthorized peer edit" }), - &test_ctx(), - ) - .await - .unwrap(); - let peer_value: Value = serde_json::from_str(&peer_response).unwrap(); - assert_eq!(peer_value["authorization_denied"], true); - assert_eq!(peer_value["denied_target_member_ids"], json!(["peer"])); -} - -#[tokio::test] -async fn task_update_shared_agent_member_can_start_own_task() { - let _sandbox = task_tools_sandbox(); - let coord = shared_sde_ctx(None); - let create = TaskCreateTool::new(Arc::clone(&coord)); - create - .execute_text( - json!({ - "id": "shared-member-owned-start", - "subject": "Shared member owned start", - "owner_member_id": "sde-planner" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let planner = shared_sde_ctx(Some("sde-planner")); - let update = TaskUpdateTool::new(Arc::clone(&planner)); - let res = update - .execute_text( - json!({ "id": "shared-member-owned-start", "status": "in_progress" }), - &test_ctx(), - ) - .await - .expect("shared-agent member starts own task"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["task"]["status"].as_str().unwrap(), "in_progress"); - assert_eq!(value["task"]["owner"].as_str().unwrap(), "sde-planner"); -} - -#[tokio::test] -async fn task_update_rejects_completed_to_in_progress_reopen() { - let _sandbox = task_tools_sandbox(); - let tool = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - tool.execute_text( - json!({ - "id": "completed-reopen", - "subject": "Completed task", - "owner_member_id": "m-alice", - }), - &test_ctx(), - ) - .await - .unwrap(); - let alice = ctx("m-alice"); - let update = TaskUpdateTool::new(Arc::clone(&alice)); - update - .execute_text( - json!({ - "id": "completed-reopen", - "status": "completed", - "output": { "summary": "Initial work completed" } - }), - &test_ctx(), - ) - .await - .unwrap(); - - let response = update - .execute_text( - json!({ "id": "completed-reopen", "status": "in_progress" }), - &test_ctx(), - ) - .await - .expect("completed-task reopen returns structured guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert!(value["status_ignored"].as_bool().unwrap()); - assert_eq!(value["task"]["status"], "completed"); - assert!(value["guidance"].as_str().unwrap().contains("follow-up")); -} - -#[tokio::test] -async fn task_update_freeform_metadata_preserves_plan_execution_mode() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - TaskCreateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "plan-metadata-patch", - "subject": "Plan without losing its mode", - "owner_member_id": "m-alice", - "execution_mode": "plan", - "metadata": { "original_note": "keep" } - }), - &test_ctx(), - ) - .await - .unwrap(); - - TaskUpdateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "plan-metadata-patch", - "metadata": { "review_note": "added later" } - }), - &test_ctx(), - ) - .await - .expect("free-form metadata patch succeeds"); - - let task = AgentOrgTaskStore::get("run-tools-1", "plan-metadata-patch") - .unwrap() - .unwrap(); - assert_eq!(task_execution_mode(&task), TaskExecutionMode::Plan); - assert_eq!(task.metadata.as_ref().unwrap()["original_note"], "keep"); - assert_eq!( - task.metadata.as_ref().unwrap()["review_note"], - "added later" - ); -} - -#[tokio::test] -async fn task_update_freeform_metadata_preserves_completed_output() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - TaskCreateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "completed-metadata-patch", - "subject": "Keep completed output", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let alice = ctx("m-alice"); - TaskUpdateTool::new(Arc::clone(&alice)) - .execute_text( - json!({ - "id": "completed-metadata-patch", - "status": "completed", - "output": { - "summary": "Durable result", - "content": "Full durable result" - } - }), - &test_ctx(), - ) - .await - .unwrap(); - - TaskUpdateTool::new(Arc::clone(&alice)) - .execute_text( - json!({ - "id": "completed-metadata-patch", - "metadata": { "audit_note": "verified" } - }), - &test_ctx(), - ) - .await - .expect("completed task accepts a free-form metadata patch"); - - let task = AgentOrgTaskStore::get("run-tools-1", "completed-metadata-patch") - .unwrap() - .unwrap(); - assert_eq!(task.status, TaskStatus::Completed); - let output = task_output(&task).expect("durable output remains present"); - assert_eq!(output.summary, "Durable result"); - assert_eq!(output.content.as_deref(), Some("Full durable result")); - assert_eq!(task.metadata.as_ref().unwrap()["audit_note"], "verified"); -} - -#[tokio::test] -async fn task_update_freeform_metadata_preserves_owned_eligibility_for_safe_requeue() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - TaskCreateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "owned-requeue-metadata-patch", - "subject": "Requeue safely", - "owner_member_id": "m-alice", - "eligible_member_ids": ["m-alice", "m-bob"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - TaskUpdateTool::new(ctx("m-alice")) - .execute_text( - json!({ - "id": "owned-requeue-metadata-patch", - "status": "in_progress" - }), - &test_ctx(), - ) - .await - .unwrap(); - TaskUpdateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "owned-requeue-metadata-patch", - "metadata": { "failure_note": "retry elsewhere" } - }), - &test_ctx(), - ) - .await - .unwrap(); - - TaskUpdateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "owned-requeue-metadata-patch", - "owner_member_id": null, - "status": "pending" - }), - &test_ctx(), - ) - .await - .expect("preserved eligibility makes ownerless requeue valid"); - - let task = AgentOrgTaskStore::get("run-tools-1", "owned-requeue-metadata-patch") - .unwrap() - .unwrap(); - assert_eq!(task.owner, None); - assert_eq!(task.status, TaskStatus::Pending); - assert_eq!(eligible_member_ids(&task), vec!["m-alice", "m-bob"]); - assert_eq!( - task.metadata.as_ref().unwrap()["failure_note"], - "retry elsewhere" - ); -} - -#[tokio::test] -async fn task_update_rejects_reserved_freeform_metadata_keys() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - TaskCreateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "reserved-update-metadata", - "subject": "Reserved update metadata", - "owner_member_id": "m-alice", - "execution_mode": "plan" - }), - &test_ctx(), - ) - .await - .unwrap(); - - let err = TaskUpdateTool::new(Arc::clone(&coordinator)) - .execute_text( - json!({ - "id": "reserved-update-metadata", - "metadata": { - "execution_mode": "build", - "output": { "summary": "forged" } - } - }), - &test_ctx(), - ) - .await - .expect_err("reserved metadata keys must be rejected explicitly"); - assert!(matches!( - err, - ToolError::InvalidParams(message) - if message.contains("reserved") - && message.contains("execution_mode") - && message.contains("output") - )); - let task = AgentOrgTaskStore::get("run-tools-1", "reserved-update-metadata") - .unwrap() - .unwrap(); - assert_eq!(task_execution_mode(&task), TaskExecutionMode::Plan); - assert!(task_output(&task).is_none()); -} - -#[tokio::test] -async fn task_update_member_cannot_self_assign_ownerless_task() { - let _sandbox = task_tools_sandbox(); - let coord = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coord)); - create - .execute_text( - json!({ "id": "alice-start", "subject": "Alice start", "eligible_member_ids": ["m-alice"] }), - &test_ctx(), - ) - .await - .unwrap(); - - let alice = ctx("m-alice"); - let update = TaskUpdateTool::new(Arc::clone(&alice)); - let res = update - .execute_text( - json!({ - "id": "alice-start", - "owner_member_id": "m-alice", - "status": "in_progress" - }), - &test_ctx(), - ) - .await - .expect("ownerless self-assignment returns structured guidance"); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_update.assign_ownerless"); - let task = AgentOrgTaskStore::get("run-tools-1", "alice-start") - .unwrap() - .unwrap(); - assert_eq!(task.owner, None); - assert_eq!(task.status, TaskStatus::Pending); -} - -#[tokio::test] -async fn task_update_ownerless_self_assignment_is_denied_before_dependency_mutation() { - let _sandbox = task_tools_sandbox(); - let coordinator = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - coordinator - .execute_text( - json!({ - "id": "manual-claim-blocker", - "subject": "Produce upstream result", - "owner_member_id": "m-bob" - }), - &test_ctx(), - ) - .await - .unwrap(); - coordinator - .execute_text( - json!({ - "id": "manual-claim-blocked", - "subject": "Consume upstream result", - "eligible_member_ids": ["m-alice", "m-bob"], - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["manual-claim-blocker"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ - "id": "manual-claim-blocked", - "owner_member_id": "m-alice", - "status": "in_progress" - }), - &test_ctx(), - ) - .await - .expect("ownerless self-assignment returns structured guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_update.assign_ownerless"); -} - -#[tokio::test] -async fn task_update_ownerless_self_assignment_is_denied_even_when_member_is_busy() { - let _sandbox = task_tools_sandbox(); - let coordinator = TaskCreateTool::new(ctx(COORDINATOR_MEMBER_ID)); - coordinator - .execute_text( - json!({ - "id": "manual-claim-current", - "subject": "Alice current work", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - TaskUpdateTool::new(ctx("m-alice")) - .execute_text( - json!({ "id": "manual-claim-current", "status": "in_progress" }), - &test_ctx(), - ) - .await - .unwrap(); - coordinator - .execute_text( - json!({ - "id": "manual-claim-second", - "subject": "Another shared task", - "eligible_member_ids": ["m-alice", "m-bob"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ - "id": "manual-claim-second", - "owner_member_id": "m-alice", - "status": "in_progress" - }), - &test_ctx(), - ) - .await - .expect("ownerless self-assignment returns structured guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["authorization_denied"], true); - assert_eq!(value["action"], "task_update.assign_ownerless"); -} - -#[tokio::test] -async fn task_update_reassign_dispatches_inbox() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - let res = create - .execute_text( - json!({ "subject": "S4", "owner_member_id": "m-alice" }), - &test_ctx(), - ) - .await - .unwrap(); - let task_id = serde_json::from_str::(&res).unwrap()["task"]["id"] - .as_str() - .unwrap() - .to_string(); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let res = update - .execute_text( - json!({ "id": task_id, "owner_member_id": "m-bob" }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert!(value["owner_changed"].as_bool().unwrap()); - assert!(value["task_assigned_dispatched"].as_bool().unwrap()); - let bob_inbox = AgentInboxStore::list_unread_for_member("m-bob", "run-tools-1").unwrap(); - assert_eq!(bob_inbox.len(), 1); -} - -#[tokio::test] -async fn task_create_blocked_assigned_task_does_not_dispatch_until_unblocked() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - create - .execute_text( - json!({ "id": "blocker-task", "subject": "Blocker", "owner_member_id": "coordinator" }), - &test_ctx(), - ) - .await - .unwrap(); - let blocked = create - .execute_text( - json!({ - "id": "blocked-task", - "subject": "Blocked work", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["blocker-task"] - }), - &test_ctx(), - ) - .await - .unwrap(); - let blocked_value: Value = serde_json::from_str(&blocked).unwrap(); - assert!(!blocked_value["task_assigned_dispatched"].as_bool().unwrap()); - let alice_before = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1").unwrap(); - assert!(alice_before.is_empty()); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let completed = update - .execute_text( - json!({ - "id": "blocker-task", - "status": "completed", - "output": { "summary": "Blocker result", "content": "Durable input for Alice" } - }), - &test_ctx(), - ) - .await - .unwrap(); - let completed_value: Value = serde_json::from_str(&completed).unwrap(); - assert_eq!( - completed_value["unblocked_task_assigned_ids"] - .as_array() - .unwrap() - .iter() - .map(|value| value.as_str().unwrap()) - .collect::>(), - vec!["blocked-task"] - ); - let alice_after = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1").unwrap(); - assert_eq!(alice_after.len(), 1); -} - -#[tokio::test] -async fn completing_legacy_blocks_only_edge_dispatches_downstream_once() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coordinator)); - create - .execute_text( - json!({ - "id": "legacy-upstream", - "subject": "Historical upstream", - "owner_member_id": "coordinator" - }), - &test_ctx(), - ) - .await - .expect("create upstream"); - create - .execute_text( - json!({ - "id": "legacy-downstream", - "subject": "Historical downstream", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .expect("create downstream"); - - // Simulate a historical row that stored only the reverse `blocks` - // direction. New writes reject that representation and canonicalize to - // downstream.blocked_by, so only a raw fixture can preserve it. - let conn = database::db::get_connection().expect("test sqlite connection"); - conn.execute( - "UPDATE agent_org_runtime_tasks SET blocks_json='[\"legacy-downstream\"]' - WHERE org_run_id='run-tools-1' AND id='legacy-upstream'", - [], - ) - .expect("seed legacy blocks edge"); - conn.execute( - "UPDATE agent_org_runtime_tasks SET blocked_by_json='[]' - WHERE org_run_id='run-tools-1' AND id='legacy-downstream'", - [], - ) - .expect("keep downstream legacy-only"); - conn.execute("DELETE FROM agent_org_runtime_inbox", []) - .expect("remove create-time assignment noise"); - - let update = TaskUpdateTool::new(Arc::clone(&coordinator)); - let response = update - .execute_text( - json!({ - "id": "legacy-upstream", - "status": "completed", - "output": { - "summary": "Legacy blocker completed", - "content": "Durable legacy dependency output" - } - }), - &test_ctx(), - ) - .await - .expect("complete legacy upstream"); - let value: Value = serde_json::from_str(&response).expect("decode update result"); - assert_eq!( - value["unblocked_task_assigned_ids"], - json!(["legacy-downstream"]) - ); - - let assignments = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .expect("load downstream assignments") - .into_iter() - .filter(|row| { - matches!( - row.decode_payload(), - Ok(AgentMessage::TaskAssigned { ref task_id, .. }) - if task_id == "legacy-downstream" - ) - }) - .collect::>(); - assert_eq!( - assignments.len(), - 1, - "legacy edge must dispatch exactly once" - ); - match assignments[0] - .decode_payload() - .expect("decode legacy downstream assignment") - { - AgentMessage::TaskAssigned { - dependency_outputs, .. - } => { - assert_eq!(dependency_outputs.len(), 1); - assert_eq!(dependency_outputs[0].task_id, "legacy-upstream"); - assert_eq!( - dependency_outputs[0].content.as_deref(), - Some("Durable legacy dependency output") - ); - } - other => panic!("expected TaskAssigned, got {other:?}"), - } -} - -#[tokio::test] -async fn completing_upstream_task_delivers_durable_output_to_downstream_member() { - let _sandbox = task_tools_sandbox(); - let wake_hook = Arc::new(RecordingWakeHook::default()); - let coordinator = ctx_with_wake(COORDINATOR_MEMBER_ID, wake_hook.clone()); - let create = TaskCreateTool::new(Arc::clone(&coordinator)); - create - .execute_text( - json!({ - "id": "draft-task", - "subject": "Write the draft", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - create - .execute_text( - json!({ - "id": "review-task", - "subject": "Review the draft", - "owner_member_id": "m-bob", - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["draft-task"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - let alice = ctx_with_wake("m-alice", wake_hook.clone()); - let update = TaskUpdateTool::new(alice); - let response = update - .execute_text( - json!({ - "id": "draft-task", - "status": "completed", - "output": { - "summary": "Eight episode summaries drafted", - "content": "Episode 1 ... Episode 8 ...", - "artifact_ids": ["artifact://dragon-draft"] - } - }), - &test_ctx(), - ) - .await - .expect("upstream completion succeeds"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!( - value["task"]["output"]["summary"], - "Eight episode summaries drafted" - ); - assert!(value["task_completed_notified"].as_bool().unwrap()); - - let bob_inbox = AgentInboxStore::list_unread_for_member("m-bob", "run-tools-1").unwrap(); - assert_eq!(bob_inbox.len(), 1); - match bob_inbox[0] - .decode_payload() - .expect("decode downstream assignment") - { - AgentMessage::TaskAssigned { - task_id, - dependency_outputs, - .. - } => { - assert_eq!(task_id, "review-task"); - assert_eq!(dependency_outputs.len(), 1); - assert_eq!(dependency_outputs[0].task_id, "draft-task"); - assert_eq!( - dependency_outputs[0].content.as_deref(), - Some("Episode 1 ... Episode 8 ...") - ); - } - other => panic!("expected downstream TaskAssigned, got {other:?}"), - } - - let coordinator_inbox = - AgentInboxStore::list_unread_for_member(COORDINATOR_MEMBER_ID, "run-tools-1").unwrap(); - assert!(coordinator_inbox.iter().any(|row| matches!( - row.decode_payload(), - Ok(AgentMessage::TaskCompleted { ref task_id, .. }) if task_id == "draft-task" - ))); - let wakes = wake_hook.snapshot(); - assert!(wakes.contains(&("m-bob".to_string(), "run-tools-1".to_string()))); - assert!(wakes.contains(&(COORDINATOR_MEMBER_ID.to_string(), "run-tools-1".to_string()))); -} - -#[tokio::test] -async fn three_stage_dependency_chain_dispatches_one_stage_at_a_time() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coordinator)); - - create - .execute_text( - json!({ - "id": "implement-stage", - "subject": "Write the episode summaries", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - create - .execute_text( - json!({ - "id": "review-stage", - "subject": "Review the episode summaries", - "owner_member_id": "m-bob", - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["implement-stage"] - }), - &test_ctx(), - ) - .await - .unwrap(); - create - .execute_text( - json!({ - "id": "test-stage", - "subject": "Verify the reviewed result", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["review-stage"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - let initial_alice = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .unwrap() - .into_iter() - .filter_map(|row| row.decode_payload().ok()) - .filter_map(|message| match message { - AgentMessage::TaskAssigned { task_id, .. } => Some(task_id), - _ => None, - }) - .collect::>(); - assert_eq!(initial_alice, vec!["implement-stage"]); - assert!( - AgentInboxStore::list_unread_for_member("m-bob", "run-tools-1") - .unwrap() - .is_empty() - ); - - let implementer = TaskUpdateTool::new(ctx("m-alice")); - let implement_result = implementer - .execute_text( - json!({ - "id": "implement-stage", - "status": "completed", - "output": { "summary": "Draft complete", "content": "Draft text" } - }), - &test_ctx(), - ) - .await - .unwrap(); - let implement_value: Value = serde_json::from_str(&implement_result).unwrap(); - assert_eq!( - implement_value["unblocked_task_assigned_ids"], - json!(["review-stage"]) - ); - - let reviewer = TaskUpdateTool::new(ctx("m-bob")); - let review_result = reviewer - .execute_text( - json!({ - "id": "review-stage", - "status": "completed", - "output": { "summary": "Review complete", "content": "Approved draft" } - }), - &test_ctx(), - ) - .await - .unwrap(); - let review_value: Value = serde_json::from_str(&review_result).unwrap(); - assert_eq!( - review_value["unblocked_task_assigned_ids"], - json!(["test-stage"]) - ); - - let alice_assignments = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .unwrap() - .into_iter() - .filter_map(|row| row.decode_payload().ok()) - .filter_map(|message| match message { - AgentMessage::TaskAssigned { - task_id, - dependency_outputs, - .. - } => Some((task_id, dependency_outputs)), - _ => None, - }) - .collect::>(); - assert_eq!(alice_assignments.len(), 2); - assert_eq!(alice_assignments[1].0, "test-stage"); - assert_eq!(alice_assignments[1].1.len(), 1); - assert_eq!(alice_assignments[1].1[0].task_id, "review-stage"); - assert_eq!( - alice_assignments[1].1[0].content.as_deref(), - Some("Approved draft") - ); -} - -#[tokio::test] -async fn every_task_completion_requires_durable_output() { - let _sandbox = task_tools_sandbox(); - let coordinator = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coordinator)); - create - .execute_text( - json!({ - "id": "source-without-output", - "subject": "Produce source", - "owner_member_id": "m-alice" - }), - &test_ctx(), - ) - .await - .unwrap(); - create - .execute_text( - json!({ - "id": "consumer", - "subject": "Consume source", - "owner_member_id": "m-bob", - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["source-without-output"] - }), - &test_ctx(), - ) - .await - .unwrap(); - - let update = TaskUpdateTool::new(ctx("m-alice")); - let response = update - .execute_text( - json!({ "id": "source-without-output", "status": "completed" }), - &test_ctx(), - ) - .await - .expect("missing output returns recoverable guidance"); - let value: Value = serde_json::from_str(&response).unwrap(); - assert_eq!(value["rejected"], true); - assert_eq!(value["rejection_code"], "completion_requires_output"); - assert_eq!(value["mutation_applied"], false); - assert_eq!(value["task"]["status"], "pending"); - assert!( - AgentInboxStore::list_unread_for_member("m-bob", "run-tools-1") - .unwrap() - .is_empty() - ); -} - -#[tokio::test] -async fn task_update_clearing_blockers_on_assigned_pending_dispatches_once() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - create - .execute_text( - json!({ "id": "manual-blocker", "subject": "Manual blocker", "eligible_member_ids": ["m-alice"] }), - &test_ctx(), - ) - .await - .unwrap(); - create - .execute_text( - json!({ - "id": "manually-unblocked", - "subject": "Manual unblock", - "owner_member_id": "m-alice", - "dispatch_policy": "after_dependencies", - "dependency_task_ids": ["manual-blocker"] - }), - &test_ctx(), - ) - .await - .unwrap(); - assert!( - AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .unwrap() - .is_empty() - ); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let res = update - .execute_text( - json!({ "id": "manually-unblocked", "blocked_by": [] }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert!(value["task_assigned_dispatched"].as_bool().unwrap()); - assert_eq!( - AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .unwrap() - .len(), - 1 - ); - - let repeat = update - .execute_text( - json!({ "id": "manually-unblocked", "description": "metadata update" }), - &test_ctx(), + &owner_call("turn-complete"), ) .await .unwrap(); - let repeat_value: Value = serde_json::from_str(&repeat).unwrap(); - assert!(!repeat_value["task_assigned_dispatched"].as_bool().unwrap()); - assert_eq!( - AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .unwrap() - .len(), - 1 - ); -} - -#[tokio::test] -async fn task_update_unassign_does_not_dispatch_inbox() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - let res = create - .execute_text( - json!({ "subject": "S5", "owner_member_id": "m-alice" }), - &test_ctx(), - ) - .await - .unwrap(); - let task_id = serde_json::from_str::(&res).unwrap()["task"]["id"] - .as_str() - .unwrap() - .to_string(); - - let before = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .unwrap() - .len(); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let res = update - .execute_text( - json!({ "id": task_id, "owner_member_id": null, "eligible_member_ids": ["m-alice"] }), - &test_ctx(), - ) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert!(value["owner_changed"].as_bool().unwrap()); - assert!(!value["task_assigned_dispatched"].as_bool().unwrap()); - let after = AgentInboxStore::list_unread_for_member("m-alice", "run-tools-1") - .unwrap() - .len(); - assert_eq!(before, after); -} - -#[tokio::test] -async fn task_update_status_deleted_removes_row() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - let res = create - .execute_text( - json!({ "subject": "S6", "eligible_member_ids": ["m-alice"] }), - &test_ctx(), - ) - .await - .unwrap(); - let task_id = serde_json::from_str::(&res).unwrap()["task"]["id"] - .as_str() - .unwrap() - .to_string(); - - let update = TaskUpdateTool::new(Arc::clone(&ctx)); - let res = update - .execute_text(json!({ "id": task_id, "status": "deleted" }), &test_ctx()) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert!(value["deleted"].as_bool().unwrap()); - assert!(AgentOrgTaskStore::get("run-tools-1", &task_id) - .unwrap() - .is_none()); -} - -#[tokio::test] -async fn task_list_filters_by_owner_and_mine() { - let _sandbox = task_tools_sandbox(); - let coord = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coord)); - for (subject, owner) in [("L1", Some("m-alice")), ("L2", Some("m-bob")), ("L3", None)] { - let mut req = json!({ "subject": subject }); - if let Some(o) = owner { - req["owner_member_id"] = json!(o); - } else { - req["eligible_member_ids"] = json!(["m-alice"]); - } - create.execute_text(req, &test_ctx()).await.unwrap(); - } - let coord_list = TaskListTool::new(Arc::clone(&coord)); - let res = coord_list - .execute_text(json!({}), &test_ctx()) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["total"].as_u64().unwrap(), 3); - let res = coord_list - .execute_text(json!({ "owner_member_id": "m-alice" }), &test_ctx()) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["total"].as_u64().unwrap(), 1); - assert_eq!(value["filtered_total"].as_u64().unwrap(), 1); - assert_eq!(value["run_summary"]["total"].as_u64().unwrap(), 3); - assert_eq!(value["run_summary"]["open"].as_u64().unwrap(), 3); - // Alice only sees her tasks via mine_only. - let alice = ctx("m-alice"); - let alice_list = TaskListTool::new(alice); - let res = alice_list - .execute_text(json!({ "mine_only": true }), &test_ctx()) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["total"].as_u64().unwrap(), 1); -} - -#[tokio::test] -async fn task_list_pages_compact_summaries_with_a_stable_cursor() { - let _sandbox = task_tools_sandbox(); - let coord = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coord)); - let long_description = "界".repeat( - crate::coordination::agent_org_payload_limits::TASK_SUMMARY_DESCRIPTION_MAX_CHARS + 20, - ); - for subject in ["Page 1", "Page 2", "Page 3"] { - let description = if subject == "Page 1" { - long_description.clone() - } else { - format!("full description for {subject}") - }; - create - .execute_text( - json!({ - "subject": subject, - "description": description, - "owner_member_id": "m-alice", - }), - &test_ctx(), - ) - .await - .expect("create paged task"); - } - - let list = TaskListTool::new(Arc::clone(&coord)); - let first: Value = serde_json::from_str( - &list - .execute_text(json!({ "limit": 2 }), &test_ctx()) - .await - .expect("first task page"), - ) - .expect("decode first page"); - assert_eq!(first["total"], 2); - assert_eq!(first["filtered_total"], 3); - assert_eq!(first["page"]["has_more"], true); - assert!(first["page"]["next_cursor"].is_string()); - assert_eq!( - first["tasks"][0]["description"] - .as_str() - .expect("description preview") - .chars() - .count(), - crate::coordination::agent_org_payload_limits::TASK_SUMMARY_DESCRIPTION_MAX_CHARS - ); - assert_eq!(first["tasks"][0]["description_truncated"], true); - assert_eq!(first["tasks"][1]["description_truncated"], false); - assert!( - first["tasks"][0].get("metadata").is_none(), - "task_list must not repeat raw task_get metadata" - ); - - let detail: Value = serde_json::from_str( - &TaskGetTool::new(Arc::clone(&coord)) - .execute_text(json!({ "id": first["tasks"][0]["id"] }), &test_ctx()) - .await - .expect("get full task detail"), - ) - .expect("decode task detail"); - assert_eq!(detail["task"]["description"], long_description); - let second: Value = serde_json::from_str( + let list = TaskListTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let history: Value = serde_json::from_str( &list .execute_text( - json!({ - "limit": 2, - "after_task_id": first["page"]["next_cursor"], - }), - &test_ctx(), - ) - .await - .expect("second task page"), - ) - .expect("decode second page"); - assert_eq!(second["total"], 1); - assert_eq!(second["page"]["has_more"], false); - assert_eq!(second["tasks"][0]["subject"], "Page 3"); - assert_eq!(second["run_summary"]["total"], 3); -} - -#[tokio::test] -async fn task_list_defaults_to_fifty_compact_rows() { - let _sandbox = task_tools_sandbox(); - let coord = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&coord)); - for index in 0..51 { - create - .execute_text( - json!({ - "subject": format!("Default page {index:02}"), - "description": "small", - "owner_member_id": "m-alice", - }), - &test_ctx(), + json!({"status":"completed","limit":50}), + &coordinator_call(), ) .await - .expect("create default-page task"); - } - - let value: Value = serde_json::from_str( - &TaskListTool::new(coord) - .execute_text(json!({}), &test_ctx()) - .await - .expect("list default task page"), - ) - .expect("decode default task page"); - assert_eq!(value["total"], 50); - assert_eq!(value["filtered_total"], 51); - assert_eq!(value["page"]["limit"], 50); - assert_eq!(value["page"]["has_more"], true); - assert!(value["page"]["next_cursor"].is_string()); -} - -fn seed_task_list_current_turn_quiescence_fixture(materialize_inbox: bool) -> i64 { - let now = chrono::Utc::now().to_rfc3339(); - let conn = database::db::get_connection().expect("test sqlite connection"); - conn.execute( - "UPDATE agent_org_runtime_runs - SET root_session_id='root-tools-1', status='running', updated_at=?2 - WHERE id=?1", - rusqlite::params!["run-tools-1", &now], - ) - .expect("reset running Agent Org run"); - upsert_session(&UnifiedSessionRecord { - session_id: "root-tools-1".to_string(), - name: "Coordinator".to_string(), - status: "running".to_string(), - session_type: "agent".to_string(), - created_at: now.clone(), - updated_at: now.clone(), - ..Default::default() - }) - .expect("seed running coordinator session"); - AgentOrgTaskStore::create(CreateTaskParams { - id: "current-turn-finished-task".to_string(), - org_run_id: "run-tools-1".to_string(), - subject: "Finished work presented to the coordinator".to_string(), - description: String::new(), - active_form: None, - owner: Some("m-alice".to_string()), - status: TaskStatus::Completed, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: None, - }) - .expect("seed resolved task board"); - conn.execute( - "INSERT INTO session_turn_intents - (session_id, turn_intent_id, org_run_id, source, status, created_at, updated_at) - VALUES ('root-tools-1', 'current-coordinator-turn', 'run-tools-1', - 'agent_org', 'running', ?1, ?1)", - rusqlite::params![&now], - ) - .expect("seed current coordinator turn intent"); - let inbox = AgentInboxStore::insert(InsertInboxParams { - recipient_agent_id: "coord-1".to_string(), - recipient_member_id: Some(COORDINATOR_MEMBER_ID.to_string()), - sender_agent_id: "alice-1".to_string(), - sender_member_id: Some("m-alice".to_string()), - org_run_id: Some("run-tools-1".to_string()), - message: AgentMessage::Plain { - summary: "Worker result".to_string(), - text: "The finished work is in this coordinator turn.".to_string(), - }, - }) - .expect("seed unread coordinator inbox row"); - if materialize_inbox { - crate::session::persistence::materialize_agent_org_inbox_transcript( - "root-tools-1", - &[inbox.id], - "current-turn-inbox-transcript", - "current-turn-inbox-intent", - "worker result", - ) - .expect("materialize current coordinator inbox row"); - } - crate::coordination::agent_org_runs::AgentOrgRunStore::stage_coordinator_work_revision( - "run-tools-1", - ) - .expect("stage the current work revision"); - inbox.id -} - -fn has_quiescence_blocker(value: &Value, field: &str, kind: &str, count: i64) -> bool { - value["run_summary"][field] - .as_array() - .expect("Quiescence blocker array") - .iter() - .any(|blocker| blocker["kind"] == kind && blocker["count"] == count) -} - -#[tokio::test] -async fn task_list_projects_exact_current_root_turn_and_materialized_inbox() { - let _sandbox = task_tools_sandbox(); - let inbox_id = seed_task_list_current_turn_quiescence_fixture(true); - let call_ctx = crate::tools::call_context::CallContext::for_turn( - "task-list-current-turn", - "root-tools-1", - "current-coordinator-turn", - vec![inbox_id], - ); - - let result = TaskListTool::new(ctx(COORDINATOR_MEMBER_ID)) - .execute_text(json!({}), &call_ctx) - .await - .expect("list tasks from the current coordinator turn"); - let value: Value = serde_json::from_str(&result).expect("decode task_list result"); - - assert_eq!(value["run_summary"]["open"], 0); - assert_eq!( - value["run_summary"]["pending_worker_turn_intent_count"], 1, - "the raw snapshot still includes the currently running coordinator intent" - ); - assert_eq!(value["run_summary"]["unread_inbox_count"], 1); - assert!(has_quiescence_blocker( - &value, - "current_quiescence_blockers", - "in_flight_turn_intents", - 1, - )); - assert!(has_quiescence_blocker( - &value, - "current_quiescence_blockers", - "unread_inbox", - 1, - )); - assert_eq!(value["run_summary"]["completion_ready"], true); - assert_eq!(value["run_summary"]["completion_blockers"], json!([])); -} - -#[tokio::test] -async fn task_list_current_turn_projection_keeps_unrelated_durable_blockers() { - let _sandbox = task_tools_sandbox(); - let projected_inbox_id = seed_task_list_current_turn_quiescence_fixture(true); - let now = chrono::Utc::now().to_rfc3339(); - let conn = database::db::get_connection().expect("test sqlite connection"); - conn.execute( - "INSERT INTO session_turn_intents - (session_id, turn_intent_id, org_run_id, source, status, created_at, updated_at) - VALUES ('reviewer-session', 'unrelated-queued-turn', 'run-tools-1', - 'agent_org', 'queued', ?1, ?1)", - rusqlite::params![&now], - ) - .expect("seed unrelated queued turn"); - let unrelated_inbox = AgentInboxStore::insert(InsertInboxParams { - recipient_agent_id: "coord-1".to_string(), - recipient_member_id: Some(COORDINATOR_MEMBER_ID.to_string()), - sender_agent_id: "alice-1".to_string(), - sender_member_id: Some("m-alice".to_string()), - org_run_id: Some("run-tools-1".to_string()), - message: AgentMessage::Plain { - summary: "Later worker result".to_string(), - text: "This row was not materialized into the current turn.".to_string(), - }, - }) - .expect("seed unrelated unread inbox row"); - let call_ctx = crate::tools::call_context::CallContext::for_turn( - "task-list-current-turn", - "root-tools-1", - "current-coordinator-turn", - vec![projected_inbox_id], - ); - - let result = TaskListTool::new(ctx(COORDINATOR_MEMBER_ID)) - .execute_text(json!({}), &call_ctx) - .await - .expect("list tasks without hiding unrelated blockers"); - let value: Value = serde_json::from_str(&result).expect("decode task_list result"); - - assert_ne!(unrelated_inbox.id, projected_inbox_id); - assert_eq!(value["run_summary"]["pending_worker_turn_intent_count"], 2); - assert_eq!(value["run_summary"]["unread_inbox_count"], 2); - assert_eq!(value["run_summary"]["completion_ready"], false); - assert!(has_quiescence_blocker( - &value, - "completion_blockers", - "in_flight_turn_intents", - 1, - )); - assert!(has_quiescence_blocker( - &value, - "completion_blockers", - "unread_inbox", - 1, - )); -} - -#[tokio::test] -async fn task_list_current_turn_projection_fails_closed_for_wrong_identity_or_receipt() { - let _sandbox = task_tools_sandbox(); - let inbox_id = seed_task_list_current_turn_quiescence_fixture(true); - let list = TaskListTool::new(ctx(COORDINATOR_MEMBER_ID)); - - let wrong_session_ctx = crate::tools::call_context::CallContext::for_turn( - "task-list-wrong-session", - "reviewer-session", - "current-coordinator-turn", - vec![inbox_id], - ); - let wrong_session: Value = serde_json::from_str( - &list - .execute_text(json!({}), &wrong_session_ctx) - .await - .expect("wrong-session call still returns diagnostics"), - ) - .expect("decode wrong-session result"); - assert_eq!(wrong_session["run_summary"]["completion_ready"], false); - assert!(has_quiescence_blocker( - &wrong_session, - "completion_blockers", - "in_flight_turn_intents", - 1, - )); - assert!(has_quiescence_blocker( - &wrong_session, - "completion_blockers", - "unread_inbox", - 1, - )); - - let wrong_intent_ctx = crate::tools::call_context::CallContext::for_turn( - "task-list-wrong-intent", - "root-tools-1", - "another-turn-intent", - vec![inbox_id], - ); - let wrong_intent: Value = serde_json::from_str( - &list - .execute_text(json!({}), &wrong_intent_ctx) - .await - .expect("wrong-intent call still returns diagnostics"), - ) - .expect("decode wrong-intent result"); - assert_eq!(wrong_intent["run_summary"]["completion_ready"], false); - assert!(has_quiescence_blocker( - &wrong_intent, - "completion_blockers", - "in_flight_turn_intents", - 1, - )); - assert!(has_quiescence_blocker( - &wrong_intent, - "completion_blockers", - "unread_inbox", - 1, - )); - - database::db::get_connection() - .expect("test sqlite connection") - .execute( - "DELETE FROM agent_org_runtime_inbox_materializations WHERE inbox_id=?1", - rusqlite::params![inbox_id], - ) - .expect("remove the receipt to exercise fail-closed validation"); - let missing_receipt_ctx = crate::tools::call_context::CallContext::for_turn( - "task-list-missing-receipt", - "root-tools-1", - "current-coordinator-turn", - vec![inbox_id], - ); - let missing_receipt: Value = serde_json::from_str( - &list - .execute_text(json!({}), &missing_receipt_ctx) - .await - .expect("missing-receipt call still returns diagnostics"), - ) - .expect("decode missing-receipt result"); - assert_eq!(missing_receipt["run_summary"]["completion_ready"], false); - assert!(has_quiescence_blocker( - &missing_receipt, - "completion_blockers", - "unread_inbox", - 1, - )); - assert!(!has_quiescence_blocker( - &missing_receipt, - "completion_blockers", - "in_flight_turn_intents", - 1, - )); -} - -#[tokio::test] -async fn task_list_completion_certificate_blocks_while_reviewer_is_running() { - let _sandbox = task_tools_sandbox(); - let now = chrono::Utc::now().to_rfc3339(); - let conn = database::db::get_connection().unwrap(); - conn.execute( - "INSERT OR REPLACE INTO agent_org_runtime_runs - (id, org_id, coordinator_agent_id, root_session_id, entry_mode, status, created_at, updated_at) - VALUES ('run-tools-1', 'org-tools-1', 'coord-1', 'root-tools-1', 'standalone_session', 'running', ?1, ?1)", - rusqlite::params![now], - ) - .unwrap(); - for record in [ - UnifiedSessionRecord { - session_id: "root-tools-1".to_string(), - name: "Coordinator".to_string(), - status: "running".to_string(), - session_type: "agent".to_string(), - created_at: now.clone(), - updated_at: now.clone(), - ..Default::default() - }, - UnifiedSessionRecord { - session_id: "reviewer-session".to_string(), - name: "Reviewer".to_string(), - status: "running".to_string(), - session_type: crate::core::session::persistence::session_type::ORG_MEMBER.to_string(), - parent_session_id: Some("root-tools-1".to_string()), - agent_definition_id: Some("reviewer-agent".to_string()), - org_member_id: Some("m-alice".to_string()), - created_at: now.clone(), - updated_at: now.clone(), - ..Default::default() - }, - ] { - upsert_session(&record).unwrap(); - } - AgentOrgTaskStore::create(CreateTaskParams { - id: "finished-task".to_string(), - org_run_id: "run-tools-1".to_string(), - subject: "Finished producer".to_string(), - description: String::new(), - active_form: None, - owner: Some("m-bob".to_string()), - status: TaskStatus::Completed, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: None, - }) - .unwrap(); - crate::coordination::agent_org_runs::AgentOrgRunStore::stage_coordinator_work_revision( - "run-tools-1", - ) - .expect("stage current work revision"); - conn.execute( - "INSERT INTO session_turn_intents - (session_id, turn_intent_id, org_run_id, source, status, created_at, updated_at) - VALUES ('root-tools-1', 'current-review-turn', 'run-tools-1', - 'agent_org', 'running', ?1, ?1)", - rusqlite::params![chrono::Utc::now().to_rfc3339()], - ) - .expect("seed the current coordinator intent"); - let call_ctx = crate::tools::call_context::CallContext::for_turn( - "task-list-review-turn", - "root-tools-1", - "current-review-turn", - Vec::new(), - ); - - let list = TaskListTool::new(ctx(COORDINATOR_MEMBER_ID)); - let result = list - .execute_text(json!({ "status": "" }), &call_ctx) - .await - .expect("blank optional filter is ignored"); - let value: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(value["run_summary"]["open"], 0); - assert_eq!(value["run_summary"]["completion_ready"], false); - assert_eq!( - value["run_summary"]["active_member_ids"], - json!(["m-alice"]) - ); - assert!(value["run_summary"]["completion_blockers"] - .as_array() - .unwrap() - .iter() - .any(|blocker| blocker["kind"] == "sessions_active")); - - conn.execute( - "UPDATE agent_sessions SET status='idle', updated_at=?2 WHERE session_id=?1", - rusqlite::params!["reviewer-session", chrono::Utc::now().to_rfc3339()], - ) - .unwrap(); - conn.execute( - "INSERT INTO session_turn_intents - (session_id, turn_intent_id, org_run_id, source, status, created_at, updated_at) - VALUES (?1, 'queued-review', 'run-tools-1', 'resume', 'queued', ?2, ?2)", - rusqlite::params!["reviewer-session", chrono::Utc::now().to_rfc3339()], - ) - .unwrap(); - let result = list.execute_text(json!({}), &call_ctx).await.unwrap(); - let value: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(value["run_summary"]["completion_ready"], false); - assert_eq!(value["run_summary"]["pending_worker_turn_intent_count"], 2); - assert!(value["run_summary"]["completion_blockers"] - .as_array() - .unwrap() - .iter() - .any(|blocker| blocker["kind"] == "in_flight_turn_intents")); - - conn.execute( - "UPDATE session_turn_intents SET status='completed', updated_at=?2 - WHERE session_id=?1 AND turn_intent_id='queued-review'", - rusqlite::params!["reviewer-session", chrono::Utc::now().to_rfc3339()], + .expect("completed list"), ) .unwrap(); - let result = list.execute_text(json!({}), &call_ctx).await.unwrap(); - let value: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(value["run_summary"]["completion_ready"], true); - assert_eq!(value["run_summary"]["completion_blockers"], json!([])); -} + assert_eq!(history["tasks"][0]["status"], "completed"); + assert_eq!(history["tasks"][0]["output"]["summary"], "summary"); + assert!(history["tasks"][0]["output"].get("content").is_none()); -#[tokio::test] -async fn task_list_surfaces_corrupt_task_data_without_false_empty_completion() { - let _sandbox = task_tools_sandbox(); - let now = chrono::Utc::now().to_rfc3339(); - let conn = database::db::get_connection().unwrap(); - conn.execute( - "INSERT OR REPLACE INTO agent_org_runtime_runs - (id, org_id, coordinator_agent_id, root_session_id, entry_mode, status, created_at, updated_at) - VALUES ('run-tools-1', 'org-tools-1', 'coord-1', 'root-tools-1', 'standalone_session', 'running', ?1, ?1)", - rusqlite::params![&now], - ) - .unwrap(); - upsert_session(&UnifiedSessionRecord { - session_id: "root-tools-1".to_string(), - name: "Coordinator".to_string(), - status: "running".to_string(), - session_type: "agent".to_string(), - created_at: now.clone(), - updated_at: now, - ..Default::default() - }) - .unwrap(); - AgentOrgTaskStore::create(CreateTaskParams { - id: "corrupt-task".to_string(), - org_run_id: "run-tools-1".to_string(), - subject: "Corrupt persisted task".to_string(), - description: String::new(), - active_form: None, - owner: Some("m-alice".to_string()), - status: TaskStatus::Completed, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: None, - }) - .unwrap(); - conn.execute( - "UPDATE agent_org_runtime_tasks SET blocks_json='not-json' WHERE id='corrupt-task'", - [], - ) - .unwrap(); - crate::coordination::agent_org_runs::AgentOrgRunStore::stage_coordinator_work_revision( - "run-tools-1", + let detail: Value = serde_json::from_str( + &TaskGetTool::new(tools_context(COORDINATOR_MEMBER_ID)) + .execute_text(json!({"id":"completed"}), &coordinator_call()) + .await + .expect("Task detail"), ) .unwrap(); + assert_eq!(detail["task"]["output"]["content"], "detail"); - let result = TaskListTool::new(ctx(COORDINATOR_MEMBER_ID)) - .execute_text(json!({}), &test_ctx()) - .await - .expect("corrupt board still returns canonical diagnostics"); - let value: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(value["tasks"].as_array().unwrap().len(), 1); - assert_eq!(value["tasks"][0]["id"], "corrupt-task"); - assert_eq!(value["tasks"][0]["blocks"], json!([])); - assert_eq!(value["run_summary"]["total"], 1); - assert_eq!(value["run_summary"]["corrupt_task_count"], 1); - assert_eq!(value["run_summary"]["completion_ready"], false); - assert!(value["run_summary"]["completion_blockers"] - .as_array() - .unwrap() - .iter() - .any(|blocker| blocker["kind"] == "corrupt_task_data")); -} - -#[tokio::test] -async fn task_get_returns_full_row() { - let _sandbox = task_tools_sandbox(); - let ctx = ctx(COORDINATOR_MEMBER_ID); - let create = TaskCreateTool::new(Arc::clone(&ctx)); - let res = create - .execute_text( - json!({ "subject": "G1", "description": "details", "eligible_member_ids": ["m-alice"] }), - &test_ctx(), - ) - .await - .unwrap(); - let task_id = serde_json::from_str::(&res).unwrap()["task"]["id"] - .as_str() - .unwrap() - .to_string(); - let get = TaskGetTool::new(Arc::clone(&ctx)); - let res = get - .execute_text(json!({ "id": task_id }), &test_ctx()) - .await - .unwrap(); - let value: Value = serde_json::from_str(&res).unwrap(); - assert_eq!(value["task"]["subject"], "G1"); - assert_eq!(value["task"]["description"], "details"); + for status in ["pending", "in_progress", "completed", "failed", "cancelled"] { + let parsed = crate::coordination::agent_org_tasks::TaskStatus::from_wire(status) + .expect("all formal statuses parse"); + assert_eq!(parsed.as_wire(), status); + } } diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs index 32f52a8a15..99340d5dcd 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs @@ -5,73 +5,119 @@ use schemars::JsonSchema; use serde::Deserialize; use serde_json::{json, Map, Value}; -use crate::coordination::agent_org_payload_limits::{ - validate_task_identifier, validate_task_identifier_list, -}; use crate::coordination::agent_org_tasks::{ - task_output, AgentOrgTaskStore, Task, TaskOutput, TaskStatus, UpdateTaskPatch, + AgentOrgTaskStore, CreatePendingTaskParams, PendingTaskGraphPatch, TaskAnnotationKind, + TaskExecutionMode, TaskGraphWriterAdmin, TaskOutputInput, TaskOwnerExecution, + TaskTerminalReason, }; use crate::tools::names as tool_names; -use crate::tools::traits::{params_schema, parse_params, CallContext, Tool, ToolError}; +use crate::tools::traits::{parse_params, CallContext, Tool, ToolError}; use super::{ - map_task_write_error, merge_task_metadata, parse_status, task_to_json, - validate_freeform_task_metadata, TaskToolsContext, + map_task_write_error, task_to_json, validate_freeform_task_metadata, TaskOutboxCommit, + TaskToolsContext, }; -/// Params for `task_update`. Every mutable field is optional; only -/// fields explicitly set on the request are written. To clear ownership, -/// pass `owner_member_id: null`. Setting `status: "deleted"` deletes the row. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum TaskUpdateParams { + PatchPending { + id: String, + #[serde(default)] + subject: Option, + #[serde(default)] + description: Option, + #[serde(default)] + active_form: Option, + #[serde(default)] + clear_active_form: bool, + #[serde(default)] + owner_member_id: Option, + #[serde(default)] + clear_owner: bool, + #[serde(default)] + execution_mode: Option, + #[serde(default)] + blocked_by: Option>, + #[serde(default)] + metadata: Option, + #[serde(default)] + eligible_member_ids: Option>, + #[serde(default)] + required_role: Option, + }, + Start { + id: String, + }, + Complete { + id: String, + output: TaskOutputParams, + }, + Fail { + id: String, + reason: TaskReasonParams, + }, + Cancel { + id: String, + reason: TaskReasonParams, + }, + CancelAndReplace { + id: String, + reason: TaskReasonParams, + replacement: ReplacementTaskParams, + }, + AppendProgress { + id: String, + body: String, + }, + AppendEvidence { + id: String, + body: String, + }, + AppendAuditNote { + id: String, + body: String, + }, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TaskOutputParams { + pub summary: String, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub artifact_ids: Vec, +} + #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct TaskUpdateParams { - /// Durable task identifier to update. Required. - pub id: String, +pub struct TaskReasonParams { + pub code: String, + pub message: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReplacementTaskParams { #[serde(default)] - pub subject: Option, + pub id: Option, + pub subject: String, #[serde(default)] pub description: Option, #[serde(default)] pub active_form: Option, - /// New owner member_id. Use `coordinator`, an exact roster member_id, - /// or explicit null to unassign. Agent IDs and display names are not accepted. - #[serde(default)] - pub owner_member_id: Option, - /// New status. One of: `pending`, `in_progress`, `completed`, or the - /// special sentinel `deleted` (which removes the row). #[serde(default)] - pub status: Option, - /// Canonical dependency ids this task waits for. The reciprocal `blocks` - /// projection is derived by the store and is not independently writable. + pub owner_member_id: Option, + pub execution_mode: String, #[serde(default)] - pub blocked_by: Option>, - /// Free-form metadata patch. Object keys are merged into the existing - /// metadata; reserved Agent Org fields must use their typed parameters. + pub blocked_by: Vec, #[serde(default)] pub metadata: Option, #[serde(default)] pub eligible_member_ids: Option>, #[serde(default)] pub required_role: Option, - /// Durable result produced by the owning member. Required whenever a task - /// is completed; never put cross-session handoff content in another - /// session's inbox/thread reference. - #[serde(default)] - pub output: Option, -} - -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TaskOutputParams { - /// Short result summary suitable for coordinator notifications. Required - /// whenever `output` is supplied. - pub summary: String, - /// Full inline result when the handoff is text-sized. - #[serde(default)] - pub content: Option, - /// Durable artifact/file identifiers for large or file-backed results. - #[serde(default)] - pub artifact_ids: Vec, } pub struct TaskUpdateTool { @@ -84,92 +130,6 @@ impl TaskUpdateTool { } } -#[derive(Debug, Clone, Copy)] -enum TaskUpdateRejectionCode { - MissingOutputSummary, - InvalidOutput, - OutputRequiresCompletion, - OutputOwnerOnly, - LifecycleOwnerOnly, - CompletionRequiresOutput, -} - -impl TaskUpdateRejectionCode { - const fn as_str(self) -> &'static str { - match self { - Self::MissingOutputSummary => "missing_output_summary", - Self::InvalidOutput => "invalid_output", - Self::OutputRequiresCompletion => "output_requires_completion", - Self::OutputOwnerOnly => "output_owner_only", - Self::LifecycleOwnerOnly => "lifecycle_owner_only", - Self::CompletionRequiresOutput => "completion_requires_output", - } - } -} - -fn task_update_rejected_response( - task_id: &str, - task: Option<&Task>, - code: TaskUpdateRejectionCode, - guidance: &str, - details: Value, -) -> Result { - serde_json::to_string(&json!({ - "rejected": true, - "rejection_code": code.as_str(), - "action": "task_update", - "task_id": task_id, - "task": task.map(task_to_json), - "mutation_applied": false, - "guidance": guidance, - "details": details, - })) - .map_err(|err| { - ToolError::ExecutionFailed(format!( - "task_update: failed to serialize rejection guidance: {err}" - )) - }) -} - -fn output_has_missing_or_non_string_summary(params: &Value) -> bool { - params - .get("output") - .and_then(Value::as_object) - .is_some_and(|output| output.get("summary").and_then(Value::as_str).is_none()) -} - -/// Merge caller-controlled metadata keys onto the durable task metadata. -/// -/// Reserved fields have already been rejected by -/// `validate_freeform_task_metadata`, so starting from `prior` guarantees a -/// free-form patch cannot accidentally erase scheduling mode, eligibility, or -/// a completed task's durable output. Non-object metadata keeps the historical -/// `merge_task_metadata` representation under the free-form `value` key. -fn merge_freeform_metadata_patch( - prior: Option<&Value>, - freeform_patch: Option, -) -> Option { - let mut merged = match prior { - Some(Value::Object(object)) => object.clone(), - Some(other) => { - let mut object = Map::new(); - object.insert("value".to_string(), other.clone()); - object - } - None => Map::new(), - }; - - match freeform_patch { - Some(Value::Object(patch)) => merged.extend(patch), - Some(other) => { - merged.insert("value".to_string(), other); - } - None => {} - } - - (!merged.is_empty()).then_some(Value::Object(merged)) -} - #[async_trait] impl Tool for TaskUpdateTool { fn name(&self) -> &str { @@ -177,569 +137,569 @@ impl Tool for TaskUpdateTool { } fn description(&self) -> &str { - concat!( - "Update a task on the org run's task board. Only the fields you set are ", - "written; missing fields keep their current value. Task write authority ", - "has two separate parts. Administrative authority is capability-scoped: ", - "the coordinator may create, assign, reassign, edit, and repair every task; a ", - "member may administer only its own tasks until additional Writer activation ", - "lands. Work-authorship authority is always owner-only: ", - "only the current owner may set `status=\"in_progress\"`, set ", - "`status=\"completed\"`, or write `output`. Assignment or dependency unblocking ", - "already wakes the owner, so a coordinator/manager must not start or complete ", - "the owner's task on its behalf. Peer messaging does not grant task authority. ", - "Special semantics:\n", - " - `owner_member_id=null` unassigns the task into an explicit ", - " coordinator-assignment state. Set or preserve `eligible_member_ids` ", - " so the coordinator can choose a valid replacement; workers do not ", - " self-claim ownerless tasks.\n", - " - `owner_member_id=\"coordinator\"` or `owner_member_id=\"\"` ", - " reassigns the task and posts a `task_assigned` inbox row to a pending ", - " member owner. Agent IDs and display names are not accepted.\n", - " - `eligible_member_ids` sets or repairs the allowed replacement ", - " candidates for ownerless tasks. Only the coordinator assigns from that ", - " list; `required_role` is display/prompt context only.\n", - " - `status=\"deleted\"` removes the row from the board (sentinel value — \n", - " `deleted` is not stored; the row is deleted instead).\n", - "Completed tasks cannot be reopened by setting `status=\"in_progress\"`; ", - "they also cannot be moved back to `pending`; create a follow-up task instead. ", - "When you begin an assigned task you own, first set `status=\"in_progress\"`. ", - "When completing work, always pass `status=\"completed\"` together with ", - "`output={summary, content?, artifact_ids?}`; `summary` is required. Durable ", - "output is how the coordinator and downstream members read the result because ", - "they cannot read your private session history. Use this tool to reassign work ", - "mid-run, mark your own progress, or retire a task." - ) - } - - fn category(&self) -> &str { - crate::tools::categories::ORCHESTRATION + "Apply one explicit Task operation. Coordinator operations manage pending graph fields, cancellation, replacement, and terminal audit notes. Owner operations start, complete, fail, or append progress/evidence to that Owner's persisted in-progress Task. Mixed graph and Owner fields are rejected by the tagged operation schema." } fn llm_description(&self) -> Option { Some(format!( - "{}\n\nYour task authority: {}\nAuthorized owner_member_id values for this caller: {}\nUse only `owner_member_id`; do not pass agent_id or display name as ownership. For `eligible_member_ids`, use only worker member_ids from the same authorized catalog except `coordinator`; do not use display names or agent_definition_id.", + "{}\n\nYour Task authority: {}. New work is always created pending; only an Owner TaskExecution turn can start, complete, or fail it.", self.description(), self.ctx.task_authority_summary(), - self.ctx.authorized_task_target_catalog() )) } + fn category(&self) -> &str { + crate::tools::categories::ORCHESTRATION + } + fn parameters(&self) -> Value { - params_schema::() + // `TaskUpdateParams` stays a serde-tagged enum so the runtime parser + // rejects fields that belong to a different actor/operation. Schemars + // represents that enum as a top-level `oneOf`, however, and several + // function-calling providers silently discard such schemas. Advertise + // one flat portable object and enforce per-operation requiredness in + // the typed parser below. + json!({ + "type": "object", + "additionalProperties": false, + "required": ["operation", "id"], + "properties": { + "operation": { + "type": "string", + "enum": [ + "patch_pending", "start", "complete", "fail", "cancel", + "cancel_and_replace", "append_progress", "append_evidence", + "append_audit_note" + ] + }, + "id": { "type": "string" }, + "subject": { "type": "string" }, + "description": { "type": "string" }, + "active_form": { "type": "string" }, + "clear_active_form": { "type": "boolean" }, + "owner_member_id": { "type": "string" }, + "clear_owner": { "type": "boolean" }, + "execution_mode": { "type": "string", "enum": ["plan", "build"] }, + "blocked_by": { "type": "array", "items": { "type": "string" } }, + "metadata": { "type": "object", "additionalProperties": true }, + "eligible_member_ids": { "type": "array", "items": { "type": "string" } }, + "required_role": { "type": "string" }, + "body": { "type": "string" }, + "output": { + "type": "object", + "additionalProperties": false, + "required": ["summary"], + "properties": { + "summary": { "type": "string" }, + "content": { "type": "string" }, + "artifact_ids": { "type": "array", "items": { "type": "string" } } + } + }, + "reason": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + }, + "replacement": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "execution_mode"], + "properties": { + "id": { "type": "string" }, + "subject": { "type": "string" }, + "description": { "type": "string" }, + "active_form": { "type": "string" }, + "owner_member_id": { "type": "string" }, + "execution_mode": { "type": "string", "enum": ["plan", "build"] }, + "blocked_by": { "type": "array", "items": { "type": "string" } }, + "metadata": { "type": "object", "additionalProperties": true }, + "eligible_member_ids": { "type": "array", "items": { "type": "string" } }, + "required_role": { "type": "string" } + } + } + } + }) } async fn execute_text( &self, params_value: Value, - _ctx: &CallContext, + call_ctx: &CallContext, ) -> Result { - let task_id_hint = params_value - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .unwrap_or_default() - .to_string(); - if output_has_missing_or_non_string_summary(¶ms_value) { - let task = if task_id_hint.is_empty() { - None - } else { + let params: TaskUpdateParams = parse_params(params_value)?; + match params { + TaskUpdateParams::PatchPending { + id, + subject, + description, + active_form, + clear_active_form, + owner_member_id, + clear_owner, + execution_mode, + blocked_by, + metadata, + eligible_member_ids, + required_role, + } => { + if clear_active_form && active_form.is_some() { + return Err(ToolError::InvalidParams( + "patch_pending cannot set and clear active_form together".to_string(), + )); + } + if clear_owner && owner_member_id.is_some() { + return Err(ToolError::InvalidParams( + "patch_pending cannot set and clear owner together".to_string(), + )); + } + validate_freeform_task_metadata(metadata.as_ref()) + .map_err(ToolError::InvalidParams)?; + let actor = self.graph_actor(call_ctx)?; + let run_id = self.ctx.org_context.run_id.clone(); + let prior_id = id.clone(); + let prior = + tokio::task::spawn_blocking(move || AgentOrgTaskStore::get(&run_id, &prior_id)) + .await + .map_err(join_error)? + .map_err(ToolError::ExecutionFailed)? + .ok_or_else(|| ToolError::InvalidParams(format!("task_not_found: {id}")))?; + let owner = owner_member_id + .as_deref() + .map(|owner| self.ctx.resolve_owner_member_id(owner)) + .transpose() + .map_err(ToolError::InvalidParams)?; + reject_coordinator_owner(owner.as_deref())?; + let eligible_member_ids = eligible_member_ids + .map(|ids| self.ctx.resolve_eligible_member_ids(ids)) + .transpose() + .map_err(ToolError::InvalidParams)?; + let metadata = merge_graph_metadata( + prior.metadata.clone(), + metadata, + eligible_member_ids, + required_role, + )?; + let patch = PendingTaskGraphPatch { + subject, + description, + active_form: clear_active_form.then_some(None).or(active_form.map(Some)), + owner: clear_owner.then_some(None).or(owner.map(Some)), + execution_mode: execution_mode + .as_deref() + .map(TaskExecutionMode::from_wire) + .transpose() + .map_err(ToolError::InvalidParams)?, + blocked_by, + metadata: Some(metadata), + }; + let update_context = Arc::clone(&self.ctx); + let run_id = self.ctx.org_context.run_id.clone(); + let expected_updated_at = prior.updated_at; + let (outcome, outbox) = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::patch_pending_with_transactional_effects( + actor, + &run_id, + &id, + &expected_updated_at, + patch, + |tx, outcome, tasks| { + update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + }) + .await + .map_err(join_error)? + .map_err(map_task_write_error)?; + self.finish_mutation(outcome, outbox) + } + TaskUpdateParams::Start { id } => { + let actor = self.owner_actor(call_ctx)?; + let update_context = Arc::clone(&self.ctx); let run_id = self.ctx.org_context.run_id.clone(); - let read_task_id = task_id_hint.clone(); - tokio::task::spawn_blocking(move || AgentOrgTaskStore::get(&run_id, &read_task_id)) + let (outcome, outbox) = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::owner_start_with_transactional_effects( + actor, + &run_id, + &id, + |tx, outcome, tasks| { + update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + }) + .await + .map_err(join_error)? + .map_err(map_task_write_error)?; + self.finish_mutation(outcome, outbox) + } + TaskUpdateParams::Complete { id, output } => { + let actor = self.owner_actor(call_ctx)?; + let output = normalize_output(output).map_err(ToolError::InvalidParams)?; + let update_context = Arc::clone(&self.ctx); + let run_id = self.ctx.org_context.run_id.clone(); + let (outcome, outbox) = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::owner_complete_with_transactional_effects( + actor, + &run_id, + &id, + output, + |tx, outcome, tasks| { + update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + }) + .await + .map_err(join_error)? + .map_err(map_task_write_error)?; + self.finish_mutation(outcome, outbox) + } + TaskUpdateParams::Fail { id, reason } => { + let actor = self.owner_actor(call_ctx)?; + let reason = normalize_reason(reason).map_err(ToolError::InvalidParams)?; + let update_context = Arc::clone(&self.ctx); + let run_id = self.ctx.org_context.run_id.clone(); + let (outcome, outbox) = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::owner_fail_with_transactional_effects( + actor, + &run_id, + &id, + reason, + |tx, outcome, tasks| { + update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + }) + .await + .map_err(join_error)? + .map_err(map_task_write_error)?; + self.finish_mutation(outcome, outbox) + } + TaskUpdateParams::Cancel { id, reason } => { + let actor = self.graph_actor(call_ctx)?; + let reason = normalize_reason(reason).map_err(ToolError::InvalidParams)?; + let prior = self.read_task(&id).await?; + let update_context = Arc::clone(&self.ctx); + let run_id = self.ctx.org_context.run_id.clone(); + let expected_updated_at = prior.updated_at; + let (outcome, outbox) = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::cancel_with_transactional_effects( + actor, + &run_id, + &id, + &expected_updated_at, + reason, + |tx, outcome, tasks| { + update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) + }, + ) + }) + .await + .map_err(join_error)? + .map_err(map_task_write_error)?; + self.finish_mutation(outcome, outbox) + } + TaskUpdateParams::CancelAndReplace { + id, + reason, + replacement, + } => { + let actor = self.graph_actor(call_ctx)?; + let reason = normalize_reason(reason).map_err(ToolError::InvalidParams)?; + let prior = self.read_task(&id).await?; + let replacement = self.replacement_params(replacement)?; + let update_context = Arc::clone(&self.ctx); + let run_id = self.ctx.org_context.run_id.clone(); + let expected_updated_at = prior.updated_at; + let (outcome, replacement, outbox) = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( + actor, + &run_id, + &id, + &expected_updated_at, + reason, + replacement, + |tx, outcome, replacement, tasks| { + let mut outbox = update_context + .persist_task_update_outbox_in_tx(tx, outcome, tasks)?; + let created = update_context.persist_created_tasks_outbox_in_tx( + tx, + std::slice::from_ref(replacement), + tasks, + )?; + merge_outbox(&mut outbox, created); + Ok(outbox) + }, + ) + }) + .await + .map_err(join_error)? + .map_err(map_task_write_error)?; + self.ctx.wake_committed_task_outbox(&outbox); + serde_json::to_string(&json!({ + "task": task_to_json(&outcome.current), + "replacement": task_to_json(&replacement), + "status_changed": true, + "replacement_created": true, + })) + .map_err(|error| ToolError::ExecutionFailed(error.to_string())) + } + TaskUpdateParams::AppendProgress { id, body } => { + self.append_owner_annotation(call_ctx, id, TaskAnnotationKind::Progress, body) .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("task_update read worker failed: {err}")) - })? - .map_err(ToolError::ExecutionFailed)? - }; - return task_update_rejected_response( - &task_id_hint, - task.as_ref(), - TaskUpdateRejectionCode::MissingOutputSummary, - "Task output needs a non-empty `summary`. Retry task_update with status=completed and output={summary, content?, artifact_ids?}.", - json!({ "required_field": "output.summary" }), - ); - } - let owner_member_id_value = params_value.get("owner_member_id").cloned(); - let params: TaskUpdateParams = parse_params(params_value)?; - validate_freeform_task_metadata(params.metadata.as_ref()) - .map_err(ToolError::InvalidParams)?; - let task_id = params.id.trim().to_string(); - if task_id.is_empty() { - return Err(ToolError::InvalidParams( - "task_update requires a non-empty `id`".into(), - )); - } - validate_task_identifier("task_update.id", &task_id).map_err(ToolError::InvalidParams)?; - let org_run_id = self.ctx.org_context.run_id.clone(); - let output_requested = params.output.is_some(); - let output_params = params.output; - let delete_requested = matches!(params.status.as_deref(), Some("deleted")); - - let mut patch = UpdateTaskPatch::default(); - if let Some(subject) = params.subject { - if subject.trim().is_empty() { - return Err(ToolError::InvalidParams( - "task_update: `subject` cannot be empty".into(), - )); } - patch.subject = Some(subject); - } - if let Some(description) = params.description { - patch.description = Some(description); - } - if let Some(active_form) = params.active_form { - patch.active_form = Some(if active_form.trim().is_empty() { - None - } else { - Some(active_form) - }); - } - if let Some(owner_member_id_value) = owner_member_id_value { - if owner_member_id_value.is_null() { - patch.owner = Some(None); - } else if let Some(owner_member_id) = owner_member_id_value.as_str() { - let resolved_owner = self - .ctx - .resolve_owner_member_id(owner_member_id) - .map_err(ToolError::InvalidParams)?; - patch.owner = Some(Some(resolved_owner)); - } else { - return Err(ToolError::InvalidParams( - "task_update: `owner_member_id` must be a string member_id or null".into(), - )); + TaskUpdateParams::AppendEvidence { id, body } => { + self.append_owner_annotation(call_ctx, id, TaskAnnotationKind::Evidence, body) + .await } - } - if let Some(status) = params - .status - .as_deref() - .filter(|status| *status != "deleted") - { - patch.status = Some(parse_status(status).map_err(ToolError::InvalidParams)?); - } - if let Some(blocked_by) = params.blocked_by { - validate_task_identifier_list("task_update.blocked_by", &blocked_by) - .map_err(ToolError::InvalidParams)?; - patch.blocked_by = Some(blocked_by); - } - let freeform_metadata_patch = params.metadata; - - // Capture the prior owner so we know whether to dispatch a - // TaskAssigned row when the patch resolves to a new owner. We - // do this before applying so we don't have to re-query after. - let read_run_id = org_run_id.clone(); - let read_task_id = task_id.clone(); - let prior = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::get(&read_run_id, &read_task_id) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("task_update read worker failed: {err}")) - })? - .map_err(ToolError::ExecutionFailed)? - .ok_or_else(|| { - ToolError::ExecutionFailed(format!( - "task_update: task '{task_id}' not found in run '{org_run_id}'" - )) - })?; - let prior_owner = prior.owner.clone(); - let caller_member_id = self.ctx.caller_owner_member_id(); - if output_params.is_some() && params.status.as_deref() != Some("completed") { - return task_update_rejected_response( - &task_id, - Some(&prior), - TaskUpdateRejectionCode::OutputRequiresCompletion, - "`output` is accepted only when the owning member completes the task. Retry with status=completed, or omit output for a non-completion update.", - json!({ "required_status": "completed" }), - ); - } - let assigning_ownerless_task = - prior.owner.is_none() && patch.owner.as_ref().is_some_and(|owner| owner.is_some()); - if assigning_ownerless_task && !self.ctx.is_coordinator() { - return self.ctx.authorization_denied_response( - "task_update.assign_ownerless", - patch - .owner - .as_ref() - .and_then(|owner| owner.clone()) - .into_iter() - .collect(), - "Ownerless means waiting for coordinator assignment. Workers cannot self-claim or assign an ownerless task; ask the coordinator to choose the owner explicitly.", - ); - } - if !self.ctx.can_administer_task(&prior) { - let task_targets = prior - .owner - .clone() - .into_iter() - .chain(crate::coordination::agent_org_tasks::eligible_member_ids( - &prior, - )) - .collect::>(); - let denied = self.ctx.unauthorized_task_target_member_ids(&task_targets); - return self.ctx.authorization_denied_response( - if delete_requested { - "task_update.delete" - } else { - "task_update.modify" - }, - denied, - "You may modify only your own tasks. Ask the coordinator to modify, reassign, or delete another member's work.", - ); - } - - // Sentinel: status = "deleted" deletes only after task-scope - // authorization. Previously every member with task_update could delete - // any shared-board row before ownership was even read. - if delete_requested { - let delete_run_id = org_run_id.clone(); - let delete_task_id = task_id.clone(); - let expected_updated_at = prior.updated_at.clone(); - let removed = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::delete_if_unchanged( - &delete_run_id, - &delete_task_id, - &expected_updated_at, - ) - }) - .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("task_update delete worker failed: {err}")) - })? - .map_err(map_task_write_error)?; - let body = json!({ - "deleted": removed, - "id": task_id, - }); - return serde_json::to_string(&body).map_err(|err| { - ToolError::ExecutionFailed(format!( - "task_update: failed to serialize delete result: {err}" - )) - }); - } - - if let Some(Some(owner_member_id)) = patch.owner.as_ref() { - let denied = self - .ctx - .unauthorized_task_target_member_ids(std::slice::from_ref(owner_member_id)); - if !denied.is_empty() { - return self.ctx.authorization_denied_response( - "task_update.reassign_owner", - denied, - "You may reassign work only to yourself. Ask the coordinator to reassign work to another member.", - ); + TaskUpdateParams::AppendAuditNote { id, body } => { + let actor = self.graph_actor(call_ctx)?; + let run_id = self.ctx.org_context.run_id.clone(); + let annotation = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::append_audit_annotation(actor, &run_id, &id, body) + }) + .await + .map_err(join_error)? + .map_err(map_task_write_error)?; + serde_json::to_string(&json!({ "annotation": annotation })) + .map_err(|error| ToolError::ExecutionFailed(error.to_string())) } } - if prior.status == TaskStatus::Completed - && patch - .status - .is_some_and(|status| status != TaskStatus::Completed) - { - let body = json!({ - "task": task_to_json(&prior), - "status_ignored": true, - "guidance": "Completed tasks are immutable. Create a follow-up/revision task and reference this task in blocked_by instead of reopening it.", - }); - return serde_json::to_string(&body).map_err(|err| { - ToolError::ExecutionFailed(format!( - "task_update: failed to serialize completed-task guidance: {err}" - )) - }); - } + } - if output_requested && prior_owner.as_deref() != Some(caller_member_id.as_str()) { - let owner = prior_owner.as_deref().unwrap_or("unowned"); - return task_update_rejected_response( - &task_id, - Some(&prior), - TaskUpdateRejectionCode::OutputOwnerOnly, - "Only the task owner may submit this task's output. The coordinator or manager may assign and repair the task, but must wait for the owner to report its own result.", - json!({ - "caller_member_id": caller_member_id, - "owner_member_id": owner, - }), - ); - } - let output = match output_params { - Some(output) => match validate_task_output(output, &caller_member_id) { - Ok(output) => Some(output), - Err(guidance) => { - return task_update_rejected_response( - &task_id, - Some(&prior), - TaskUpdateRejectionCode::InvalidOutput, - &guidance, - json!({ "field": "output" }), - ); - } - }, - None => None, - }; - if freeform_metadata_patch.is_some() - || params.eligible_member_ids.is_some() - || params.required_role.is_some() - || output.is_some() - { - let eligible_member_ids = params - .eligible_member_ids - .map(|member_ids| self.ctx.resolve_eligible_member_ids(member_ids)) - .transpose() - .map_err(ToolError::InvalidParams)?; - if let Some(member_ids) = eligible_member_ids.as_ref() { - let denied = self.ctx.unauthorized_task_target_member_ids(member_ids); - if !denied.is_empty() { - return self.ctx.authorization_denied_response( - "task_update.set_eligibility", - denied, - "An ownerless task may list only candidates you are authorized to manage. Ask the coordinator to repair cross-peer and cross-branch eligibility.", - ); - } - } - let base_metadata = - merge_freeform_metadata_patch(prior.metadata.as_ref(), freeform_metadata_patch); - patch.metadata = Some(merge_task_metadata( - base_metadata, - eligible_member_ids, - params.required_role, - None, - output, + fn is_read_only(&self) -> bool { + false + } +} + +impl TaskUpdateTool { + fn graph_actor(&self, call_ctx: &CallContext) -> Result { + if !self.ctx.is_coordinator() { + return Err(ToolError::InvalidParams( + "This task_update operation requires the Coordinator graph writer".to_string(), )); } - let effective_owner = patch.owner.clone().unwrap_or_else(|| prior.owner.clone()); - let effective_status = patch.status.unwrap_or(prior.status); - let effective_metadata = patch - .metadata - .as_ref() - .and_then(|value| value.as_ref()) - .or(prior.metadata.as_ref()); - let effective_eligible_member_ids = effective_metadata - .and_then(|metadata| { - metadata - .get(crate::coordination::agent_org_tasks::TASK_METADATA_ELIGIBLE_MEMBER_IDS) - }) - .and_then(serde_json::Value::as_array) - .map(|values| { - values - .iter() - .filter_map(serde_json::Value::as_str) - .map(str::trim) - .filter(|member_id| !member_id.is_empty()) - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - if effective_owner.is_none() { - let denied = self - .ctx - .unauthorized_task_target_member_ids(&effective_eligible_member_ids); - if !denied.is_empty() { - return self.ctx.authorization_denied_response( - "task_update.unassign_for_coordinator_assignment", - denied, - "Making a task ownerless cannot expand your authority to peers or another branch. Ownerless work waits for the coordinator; ask the coordinator to release or reassign it for those members.", - ); - } - } - if effective_owner.is_none() - && effective_status == TaskStatus::Pending - && effective_eligible_member_ids.is_empty() - { + TaskGraphWriterAdmin::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams) + } + + fn owner_actor(&self, call_ctx: &CallContext) -> Result { + if self.ctx.is_coordinator() { return Err(ToolError::InvalidParams( - "unassigning a pending task requires a non-empty eligible_member_ids list" - .to_string(), + "Coordinator cannot execute an Owner lifecycle operation".to_string(), )); } - if patch.status == Some(TaskStatus::InProgress) { - if prior.status == TaskStatus::Completed { - return Err(ToolError::InvalidParams( - "task_update status=in_progress cannot reopen a completed task; create a new follow-up task or explicitly assign new pending work".to_string(), - )); - } - let target_owner = patch - .owner - .as_ref() - .and_then(|owner| owner.as_ref()) - .or(prior_owner.as_ref()); - match target_owner { - Some(owner_member_id) if owner_member_id == &caller_member_id => {} - Some(owner_member_id) => { - return task_update_rejected_response( - &task_id, - Some(&prior), - TaskUpdateRejectionCode::LifecycleOwnerOnly, - "Only the task owner may mark its work in progress. Assignment and dependency unblocking already wake the owner; wait for that member to record its own start.", - json!({ - "requested_status": "in_progress", - "caller_member_id": caller_member_id, - "owner_member_id": owner_member_id, - }), - ); - } - None => { - return task_update_rejected_response( - &task_id, - Some(&prior), - TaskUpdateRejectionCode::LifecycleOwnerOnly, - "An ownerless task cannot be marked in progress. The coordinator must assign it to a member first; workers never self-claim ownerless work.", - json!({ - "requested_status": "in_progress", - "caller_member_id": caller_member_id, - "owner_member_id": Value::Null, - }), - ); - } - } - } + TaskOwnerExecution::new(call_ctx.session_id.clone(), call_ctx.turn_intent_id.clone()) + .map_err(ToolError::InvalidParams) + } - if patch.status == Some(TaskStatus::Completed) && prior.status != TaskStatus::Completed { - let target_owner = patch - .owner - .as_ref() - .and_then(|owner| owner.as_ref()) - .or(prior_owner.as_ref()); - if target_owner != Some(&caller_member_id) { - let owner = target_owner.map(String::as_str).unwrap_or("unowned"); - return task_update_rejected_response( - &task_id, - Some(&prior), - TaskUpdateRejectionCode::LifecycleOwnerOnly, - "Only the task owner may complete its work. The coordinator or manager may reassign or repair the task, but must not certify another member's result.", - json!({ - "requested_status": "completed", - "caller_member_id": caller_member_id, - "owner_member_id": owner, - }), - ); - } + async fn read_task( + &self, + id: &str, + ) -> Result { + let run_id = self.ctx.org_context.run_id.clone(); + let task_id = id.to_string(); + tokio::task::spawn_blocking(move || AgentOrgTaskStore::get(&run_id, &task_id)) + .await + .map_err(join_error)? + .map_err(ToolError::ExecutionFailed)? + .ok_or_else(|| ToolError::InvalidParams(format!("task_not_found: {id}"))) + } - let effective_output = patch - .metadata - .as_ref() - .and_then(|metadata| metadata.as_ref()) - .and_then(|metadata| { - metadata.get(crate::coordination::agent_org_tasks::TASK_METADATA_OUTPUT) - }) - .cloned() - .and_then(|value| serde_json::from_value::(value).ok()) - .or_else(|| task_output(&prior)); - if effective_output.is_none() { - return task_update_rejected_response( - &task_id, - Some(&prior), - TaskUpdateRejectionCode::CompletionRequiresOutput, - "Every completed task needs durable output. Retry task_update with status=completed and output={summary, content?, artifact_ids?}; summary is required so the coordinator and downstream members can understand the result.", - json!({ "required_field": "output.summary" }), - ); - } + fn replacement_params( + &self, + replacement: ReplacementTaskParams, + ) -> Result { + validate_freeform_task_metadata(replacement.metadata.as_ref()) + .map_err(ToolError::InvalidParams)?; + let owner = replacement + .owner_member_id + .as_deref() + .map(|owner| self.ctx.resolve_owner_member_id(owner)) + .transpose() + .map_err(ToolError::InvalidParams)?; + reject_coordinator_owner(owner.as_deref())?; + let eligible_member_ids = replacement + .eligible_member_ids + .map(|ids| self.ctx.resolve_eligible_member_ids(ids)) + .transpose() + .map_err(ToolError::InvalidParams)?; + if owner.is_none() && eligible_member_ids.as_ref().is_none_or(Vec::is_empty) { + return Err(ToolError::InvalidParams( + "ownerless replacement requires eligible_member_ids".to_string(), + )); } + let metadata = merge_graph_metadata( + None, + replacement.metadata, + eligible_member_ids, + replacement.required_role, + )?; + Ok(CreatePendingTaskParams { + id: replacement + .id + .filter(|id| !id.trim().is_empty()) + .unwrap_or_else(crate::coordination::agent_org_tasks::new_task_id), + org_run_id: self.ctx.org_context.run_id.clone(), + subject: replacement.subject, + description: replacement.description.unwrap_or_default(), + active_form: replacement.active_form, + owner, + execution_mode: TaskExecutionMode::from_wire(&replacement.execution_mode) + .map_err(ToolError::InvalidParams)?, + blocked_by: replacement.blocked_by, + metadata, + originating_message_id: None, + replaces_task_id: None, + }) + } - let update_context = Arc::clone(&self.ctx); - let update_run_id = org_run_id.clone(); - let update_task_id = task_id.clone(); - let expected_updated_at = prior.updated_at.clone(); - let (outcome, outbox) = tokio::task::spawn_blocking(move || { - AgentOrgTaskStore::update_with_outcome_if_unchanged_and_transactional_effects( - &update_run_id, - &update_task_id, - &expected_updated_at, - patch, - |tx, outcome, tasks| { - update_context.persist_task_update_outbox_in_tx(tx, outcome, tasks) - }, - ) + async fn append_owner_annotation( + &self, + call_ctx: &CallContext, + id: String, + kind: TaskAnnotationKind, + body: String, + ) -> Result { + let actor = self.owner_actor(call_ctx)?; + let run_id = self.ctx.org_context.run_id.clone(); + let annotation = tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::append_owner_annotation(actor, &run_id, &id, kind, body) }) .await - .map_err(|err| { - ToolError::ExecutionFailed(format!("task_update mutation worker failed: {err}")) - })? + .map_err(join_error)? .map_err(map_task_write_error)?; - self.ctx.wake_committed_task_outbox(&outbox); - let updated = outcome.current; - let owner_changed = outcome.owner_changed; - let status_changed = outcome.status_changed; - let task_assigned_dispatched = outbox - .task_assigned_ids - .iter() - .any(|task_id| task_id == &updated.id); - let unblocked_task_assigned_ids = outbox.unblocked_task_assigned_ids; - let assignment_required_task_ids = outbox.assignment_required_task_ids; - let has_assignment_required = !assignment_required_task_ids.is_empty(); - let remaining_open_task_count = outbox.remaining_open_task_count; - let task_completed_notified = outbox.task_completed_notified; - - let body = json!({ - "task": task_to_json(&updated), - "owner_changed": owner_changed, - "status_changed": status_changed, - "task_assigned_dispatched": task_assigned_dispatched, - "assignment_required_task_ids": assignment_required_task_ids, - "guidance": has_assignment_required.then_some("Ownerless tasks are waiting for explicit coordinator assignment. No worker will self-claim or be woken."), - "unblocked_task_assigned_ids": unblocked_task_assigned_ids, - "task_completed_notified": task_completed_notified, - "remaining_open_task_count": remaining_open_task_count, - }); - serde_json::to_string(&body).map_err(|err| { - ToolError::ExecutionFailed(format!("task_update: failed to serialize result: {err}")) - }) + serde_json::to_string(&json!({ "annotation": annotation })) + .map_err(|error| ToolError::ExecutionFailed(error.to_string())) } - fn is_read_only(&self) -> bool { - false + fn finish_mutation( + &self, + outcome: crate::coordination::agent_org_tasks::TaskMutationOutcome, + outbox: TaskOutboxCommit, + ) -> Result { + self.ctx.wake_committed_task_outbox(&outbox); + serde_json::to_string(&json!({ + "task": task_to_json(&outcome.current), + "owner_changed": outcome.owner_changed, + "status_changed": outcome.status_changed, + "task_assigned_dispatched": outbox.task_assigned_ids.contains(&outcome.current.id), + "unblocked_task_assigned_ids": outbox.unblocked_task_assigned_ids, + "assignment_required_task_ids": outbox.assignment_required_task_ids, + "task_completed_notified": outbox.task_completed_notified, + "remaining_open_task_count": outbox.remaining_open_task_count, + })) + .map_err(|error| ToolError::ExecutionFailed(error.to_string())) } } -fn validate_task_output( - output: TaskOutputParams, - produced_by_member_id: &str, -) -> Result { +fn normalize_output(output: TaskOutputParams) -> Result { let summary = output.summary.trim(); if summary.is_empty() { - return Err( - "Task output needs a non-empty `summary`. Briefly state what was delivered and retry." - .to_string(), - ); - } - if summary.chars().count() > 1_000 { - return Err( - "Task output `summary` must be 1000 characters or fewer; shorten it and retry." - .to_string(), - ); + return Err("Task output requires a non-empty summary".to_string()); } let content = output .content .map(|content| content.trim().to_string()) .filter(|content| !content.is_empty()); - if content - .as_ref() - .is_some_and(|content| content.chars().count() > 20_000) - { - return Err( - "Task output `content` must be 20000 characters or fewer; store larger results as artifacts and reference their ids instead." - .to_string(), - ); - } let mut artifact_ids = Vec::new(); for artifact_id in output.artifact_ids { let artifact_id = artifact_id.trim(); - if artifact_id.is_empty() { - continue; - } - if artifact_id.chars().count() > 1_000 { - return Err( - "Each task output `artifact_ids` entry must be 1000 characters or fewer." - .to_string(), - ); - } - if !artifact_ids.iter().any(|existing| existing == artifact_id) { + if !artifact_id.is_empty() && !artifact_ids.iter().any(|existing| existing == artifact_id) { artifact_ids.push(artifact_id.to_string()); } } - Ok(TaskOutput { + Ok(TaskOutputInput { summary: summary.to_string(), content, artifact_ids, - produced_by_member_id: produced_by_member_id.to_string(), - produced_at: chrono::Utc::now().to_rfc3339(), }) } + +fn normalize_reason(reason: TaskReasonParams) -> Result { + let code = reason.code.trim(); + let message = reason.message.trim(); + if code.is_empty() || message.is_empty() { + return Err("Task reason requires non-empty code and message".to_string()); + } + Ok(TaskTerminalReason { + code: code.to_string(), + message: message.to_string(), + }) +} + +fn merge_graph_metadata( + existing: Option, + patch: Option, + eligible_member_ids: Option>, + required_role: Option, +) -> Result, ToolError> { + let mut object = match existing { + Some(Value::Object(object)) => object, + Some(_) => { + return Err(ToolError::ExecutionFailed( + "persisted task metadata is not an object".to_string(), + )) + } + None => Map::new(), + }; + if let Some(patch) = patch { + let patch = patch.as_object().ok_or_else(|| { + ToolError::InvalidParams("metadata patch must be an object".to_string()) + })?; + for (key, value) in patch { + if value.is_null() { + object.remove(key); + } else { + object.insert(key.clone(), value.clone()); + } + } + } + if let Some(ids) = eligible_member_ids { + object.insert("eligible_member_ids".to_string(), json!(ids)); + } + if let Some(role) = required_role { + let role = role.trim(); + if role.is_empty() { + object.remove("required_role"); + } else { + object.insert("required_role".to_string(), Value::String(role.to_string())); + } + } + Ok((!object.is_empty()).then_some(Value::Object(object))) +} + +fn reject_coordinator_owner(owner: Option<&str>) -> Result<(), ToolError> { + if owner == Some(crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID) { + Err(ToolError::InvalidParams( + "Coordinator cannot be a formal Task Owner".to_string(), + )) + } else { + Ok(()) + } +} + +fn merge_outbox(target: &mut TaskOutboxCommit, incoming: TaskOutboxCommit) { + target.task_assigned_ids.extend(incoming.task_assigned_ids); + target + .unblocked_task_assigned_ids + .extend(incoming.unblocked_task_assigned_ids); + target.task_completed_notified |= incoming.task_completed_notified; + target.remaining_open_task_count = incoming.remaining_open_task_count; + target + .assignment_required_task_ids + .extend(incoming.assignment_required_task_ids); + target.wake_member_ids.extend(incoming.wake_member_ids); + target.task_assigned_ids.sort(); + target.task_assigned_ids.dedup(); + target.assignment_required_task_ids.sort(); + target.assignment_required_task_ids.dedup(); + target.wake_member_ids.sort(); + target.wake_member_ids.dedup(); +} + +fn join_error(error: tokio::task::JoinError) -> ToolError { + ToolError::ExecutionFailed(format!("task_update worker failed: {error}")) +} diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs index 7df294b6f2..b4ff59dd2a 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/tasks.rs @@ -4,10 +4,9 @@ //! Registration policy (see `init/tool_assembly.rs`): //! - Available **only** when the session has an `AgentOrgRunContext` //! (i.e. it is the coordinator or one of the org members). -//! - Coordinator and members both get the full set, but writes are -//! authority-checked at the tool boundary: coordinator → anyone; -//! members → self only until additional Writer activation lands in PR7. -//! Tool availability is not task-administration authority. +//! - Tool availability is broader than mutation authority. The Task Store +//! validates a persisted Coordinator turn for graph operations and a +//! persisted Owner TaskExecution turn for lifecycle operations. //! - Outside an org run the tools are not registered (so plain //! single-agent sessions can't accidentally create dangling task //! rows). @@ -17,10 +16,9 @@ //! a `TaskAssigned` row to the new owner's inbox via //! `agent_org_tasks::enqueue_task_assigned`. The wake hook fires so //! the recipient's session is brought up to drain its inbox. -//! - `task_update` with `status="deleted"` deletes the row instead of -//! updating it. `deleted` is not a stored status — it is a sentinel -//! value that means "remove this row from the board" so the LLM -//! does not need a separate `task_delete` tool. +//! - `task_update` accepts an explicit tagged operation. No operation can mix +//! graph-admin fields with Owner lifecycle fields, and no delete sentinel +//! exists in the formal Task lifecycle. use std::collections::HashSet; use std::sync::Arc; @@ -32,11 +30,10 @@ use crate::coordination::agent_inbox::{ }; use crate::coordination::agent_org_runs::{AgentOrgRunContext, COORDINATOR_MEMBER_ID}; use crate::coordination::agent_org_tasks::{ - self, eligible_member_ids as task_eligible_member_ids, Task, TaskExecutionMode, - TaskMutationOutcome, TaskOutput, TaskStatus, TaskSummary, TASK_COMPLETED_IMMUTABLE_ERROR, - TASK_DELETE_HAS_DEPENDENTS_ERROR, TASK_DELETE_IS_DELIVERY_REPLACEMENT_ERROR, - TASK_DEPENDENCY_CYCLE_ERROR, TASK_DEPENDENCY_LIMIT_ERROR, TASK_METADATA_ELIGIBLE_MEMBER_IDS, - TASK_METADATA_EXECUTION_MODE, TASK_METADATA_OUTPUT, TASK_METADATA_REQUIRED_ROLE, + self, eligible_member_ids as task_eligible_member_ids, Task, TaskMutationOutcome, TaskStatus, + TaskSummary, TASK_COMPLETED_IMMUTABLE_ERROR, TASK_DELETE_HAS_DEPENDENTS_ERROR, + TASK_DELETE_IS_DELIVERY_REPLACEMENT_ERROR, TASK_DEPENDENCY_CYCLE_ERROR, + TASK_DEPENDENCY_LIMIT_ERROR, TASK_METADATA_ELIGIBLE_MEMBER_IDS, TASK_METADATA_REQUIRED_ROLE, TASK_MUTATION_CONFLICT_ERROR, TASK_RUN_TASK_LIMIT_ERROR, }; use crate::tools::impls::orchestration::org_send_message::InboxWakeHook; @@ -151,7 +148,7 @@ impl TaskToolsContext { if self.is_coordinator() { "coordinator: may create, assign, reassign, edit, and repair tasks for every participant, but may not impersonate another owner by setting that member's in_progress/completed lifecycle or writing that member's output" } else { - "worker: may manage only its own tasks and must update its own lifecycle/output" + "worker: may only start, annotate, complete, or fail the exact Task bound to its persisted TaskExecution turn" } } @@ -198,22 +195,6 @@ impl TaskToolsContext { }) } - pub(crate) fn can_administer_task(&self, task: &Task) -> bool { - if self.is_coordinator() { - return true; - } - - let allowed = self - .org_context - .allowed_task_target_member_ids_for(&self.caller_member_id); - match task.owner.as_deref() { - Some(owner_member_id) => allowed.iter().any(|member_id| member_id == owner_member_id), - // Eligibility is a candidate list, not ownership or authority. - // Ownerless work is administered only by the coordinator. - None => false, - } - } - pub(crate) fn caller_display_name(&self) -> String { self.org_context .participant_display_name(&self.caller_member_id) @@ -233,7 +214,7 @@ impl TaskToolsContext { return Err("owner_member_id must not be empty".to_string()); } if owner_member_id == COORDINATOR_MEMBER_ID { - return Ok(COORDINATOR_MEMBER_ID.to_string()); + return Err("Coordinator cannot be a formal Task Owner".to_string()); } if self .org_context @@ -252,8 +233,7 @@ impl TaskToolsContext { .collect::>() .join(", "); Err(format!( - "owner_member_id '{owner_member_id}' is not valid for this Agent Org run; use one of: [{}, {}]", - COORDINATOR_MEMBER_ID, known + "owner_member_id '{owner_member_id}' is not valid for this Agent Org run; use one of: [{known}] (Coordinator cannot own a formal Task)" )) } @@ -273,7 +253,7 @@ impl TaskToolsContext { } if member_id == COORDINATOR_MEMBER_ID { return Err( - "eligible_member_ids cannot include coordinator; use owner_member_id for coordinator-owned work" + "eligible_member_ids cannot include coordinator; Coordinator cannot own or execute formal Task work" .to_string(), ); } @@ -306,11 +286,11 @@ impl TaskToolsContext { let mut outbox = TaskOutboxCommit { remaining_open_task_count: all_tasks .iter() - .filter(|task| !task.status.is_resolved()) + .filter(|task| task.status.is_open()) .count(), assignment_required_task_ids: all_tasks .iter() - .filter(|task| task.owner.is_none() && !task.status.is_resolved()) + .filter(|task| task.owner.is_none() && task.status.is_open()) .map(|task| task.id.clone()) .collect(), ..TaskOutboxCommit::default() @@ -334,11 +314,11 @@ impl TaskToolsContext { let mut outbox = TaskOutboxCommit { remaining_open_task_count: all_tasks .iter() - .filter(|task| !task.status.is_resolved()) + .filter(|task| task.status.is_open()) .count(), assignment_required_task_ids: all_tasks .iter() - .filter(|task| task.owner.is_none() && !task.status.is_resolved()) + .filter(|task| task.owner.is_none() && task.status.is_open()) .map(|task| task.id.clone()) .collect(), ..TaskOutboxCommit::default() @@ -358,10 +338,9 @@ impl TaskToolsContext { if task.status != TaskStatus::Pending || task.owner.is_none() { continue; } - // `TaskGraphIndex` normalizes both canonical downstream - // `blocked_by` edges and historical upstream `blocks` edges. - // Looking only at the raw field here strands legacy graphs: - // the task is ready, but its TaskAssigned outbox is skipped. + // `TaskGraphIndex` derives reverse edges from canonical + // downstream `blocked_by` rows, so readiness and dispatch use + // one persisted dependency direction. if !graph.blocked_by(&task.id).contains(&updated.id) || !graph.is_ready(task) { continue; } @@ -473,8 +452,6 @@ pub(crate) fn merge_task_metadata( metadata: Option, eligible_member_ids: Option>, required_role: Option, - execution_mode: Option, - output: Option, ) -> Option { let mut object = match metadata { Some(Value::Object(object)) => object, @@ -503,16 +480,6 @@ pub(crate) fn merge_task_metadata( ); } } - if let Some(execution_mode) = execution_mode { - object.insert( - TASK_METADATA_EXECUTION_MODE.to_string(), - Value::String(execution_mode.as_wire().to_string()), - ); - } - if let Some(output) = output { - object.insert(TASK_METADATA_OUTPUT.to_string(), json!(output)); - } - (!object.is_empty()).then_some(Value::Object(object)) } @@ -523,8 +490,8 @@ pub(crate) fn validate_freeform_task_metadata(metadata: Option<&Value>) -> Resul let reserved: Vec<&str> = [ TASK_METADATA_ELIGIBLE_MEMBER_IDS, TASK_METADATA_REQUIRED_ROLE, - TASK_METADATA_EXECUTION_MODE, - TASK_METADATA_OUTPUT, + "execution_mode", + "output", ] .into_iter() .filter(|key| object.contains_key(*key)) @@ -541,7 +508,9 @@ pub(crate) fn validate_freeform_task_metadata(metadata: Option<&Value>) -> Resul pub(crate) fn parse_status(value: &str) -> Result { TaskStatus::from_wire(value).map_err(|err| { - format!("invalid status: {err} (expected: pending | in_progress | completed)") + format!( + "invalid status: {err} (expected: pending | in_progress | completed | failed | cancelled)" + ) }) } @@ -580,13 +549,17 @@ pub(crate) fn task_to_json(task: &Task) -> Value { "required_role": required_role, "execution_mode": agent_org_tasks::task_execution_mode(task).as_wire(), "output": agent_org_tasks::task_output(task), + "failure_reason": task.failure_reason, + "cancel_reason": task.cancel_reason, + "created_by_participant_id": task.created_by_participant_id, + "source_turn_intent_id": task.source_turn_intent_id, + "originating_message_id": task.originating_message_id, + "replaces_task_id": task.replaces_task_id, "metadata": task.metadata.as_ref().and_then(|metadata| { let mut metadata = metadata.as_object()?.clone(); for reserved_key in [ agent_org_tasks::TASK_METADATA_ELIGIBLE_MEMBER_IDS, agent_org_tasks::TASK_METADATA_REQUIRED_ROLE, - agent_org_tasks::TASK_METADATA_EXECUTION_MODE, - agent_org_tasks::TASK_METADATA_OUTPUT, ] { metadata.remove(reserved_key); } @@ -616,6 +589,9 @@ pub(crate) fn compact_task_summary_to_json(task: &TaskSummary) -> Value { "required_role": task.required_role, "execution_mode": task.execution_mode.as_wire(), "output": task.output, + "failure_reason": task.failure_reason, + "cancel_reason": task.cancel_reason, + "replaces_task_id": task.replaces_task_id, "created_at": task.created_at, "updated_at": task.updated_at, }) diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs b/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs index 447c360d23..bf30d4eb96 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/plan_mode/create_plan.rs @@ -511,6 +511,7 @@ impl Tool for CreatePlanTool { source_task_id: source_task.id.clone(), source_member_id: sender_member_id.to_string(), source_session_id: session_id.clone(), + source_turn_intent_id: ctx.turn_intent_id.clone(), root_session_id, policy: org_ctx.plan_approval_policy, plan_title: slot.title.clone(), diff --git a/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs b/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs index 9718622471..475dcb37f1 100644 --- a/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/tests/ui_metadata_tests.rs @@ -172,11 +172,7 @@ fn revise_canvas_has_its_own_chat_row_labels() { assert_ne!(revise.label_running, render.label_running); for action in revise.actions { - for label in [ - action.label_running, - action.label_done, - action.label_failed, - ] { + for label in [action.label_running, action.label_done, action.label_failed] { let label = label.expect("revise action label"); assert!( label.starts_with("tools.reviseInlineCanvas"), diff --git a/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs b/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs index e21b974a6c..0d3e3d44c0 100644 --- a/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs +++ b/src-tauri/crates/agent-core/src/orchestrator_notify/mod.rs @@ -395,8 +395,8 @@ pub async fn notify_orchestrator_session_terminal( ); let transition_result = tokio::task::spawn_blocking(move || { - use project_management::orchestrator::state_machine; use core_types::workflow::LinkedSessionStatus; + use project_management::orchestrator::state_machine; // Proof-of-work collection shells out to git and MUST run before // the atomic mutation opens its BEGIN IMMEDIATE transaction — a @@ -405,85 +405,72 @@ pub async fn notify_orchestrator_session_terminal( // on-device). Bounded so a sick git also can't stall completion. let collected_proof = if matches!(status, AgentSessionStatus::Completed) { let diff_repo = worktree_path.as_deref().unwrap_or(&workspace_path); - collect_proof_of_work_data_bounded( - diff_repo, - std::time::Duration::from_secs(10), - ) + collect_proof_of_work_data_bounded(diff_repo, std::time::Duration::from_secs(10)) } else { None }; let apply_transition = |slug: &str| -> Result { - state_machine::mutate_work_item( - slug, - &work_item_id, - |frontmatter| { - // Stale-signal rejection (design §12.4): a terminal - // event from a session that no longer holds the - // execution claim must not complete a newer episode. - if let Some(active_session) = frontmatter - .execution_lock - .as_ref() - .and_then(|lock| lock.active_session_id.as_deref()) - { - if active_session != session_id_owned { - tracing::warn!( - "[orchestrator] ignoring stale terminal from session {} \ + state_machine::mutate_work_item(slug, &work_item_id, |frontmatter| { + // Stale-signal rejection (design §12.4): a terminal + // event from a session that no longer holds the + // execution claim must not complete a newer episode. + if let Some(active_session) = frontmatter + .execution_lock + .as_ref() + .and_then(|lock| lock.active_session_id.as_deref()) + { + if active_session != session_id_owned { + tracing::warn!( + "[orchestrator] ignoring stale terminal from session {} \ (active claim: {}) for work_item {}", - session_id_owned, - active_session, - frontmatter.short_id - ); - return state_machine::TransitionResult::Ignored; - } + session_id_owned, + active_session, + frontmatter.short_id + ); + return state_machine::TransitionResult::Ignored; } - let linked_status = match status { - AgentSessionStatus::Completed => { - LinkedSessionStatus::Completed - } - AgentSessionStatus::Failed => { - LinkedSessionStatus::Failed - } - AgentSessionStatus::Cancelled => { - LinkedSessionStatus::Cancelled - } - _ => LinkedSessionStatus::Completed, - }; - - let agent_role = frontmatter - .linked_sessions - .iter() - .find(|ls| ls.session_id == session_id_owned) - .map(|ls| ls.agent_role.clone()); + } + let linked_status = match status { + AgentSessionStatus::Completed => LinkedSessionStatus::Completed, + AgentSessionStatus::Failed => LinkedSessionStatus::Failed, + AgentSessionStatus::Cancelled => LinkedSessionStatus::Cancelled, + _ => LinkedSessionStatus::Completed, + }; + + let agent_role = frontmatter + .linked_sessions + .iter() + .find(|ls| ls.session_id == session_id_owned) + .map(|ls| ls.agent_role.clone()); + + state_machine::complete_linked_session( + frontmatter, + &session_id_owned, + linked_status, + estimate_cost_usd(total_tokens), + total_tokens, + ); - state_machine::complete_linked_session( + let _ = agent_role; + match status { + AgentSessionStatus::Completed => { + if let Some(ref collected) = collected_proof { + apply_proof_of_work(frontmatter, collected); + } + state_machine::on_session_complete(frontmatter) + } + AgentSessionStatus::Failed => state_machine::on_session_failed( frontmatter, &session_id_owned, - linked_status, - estimate_cost_usd(total_tokens), - total_tokens, - ); - - let _ = agent_role; - match status { - AgentSessionStatus::Completed => { - if let Some(ref collected) = collected_proof { - apply_proof_of_work(frontmatter, collected); - } - state_machine::on_session_complete(frontmatter) - } - AgentSessionStatus::Failed => state_machine::on_session_failed( - frontmatter, - &session_id_owned, - "Session failed", - ), - _ => { - state_machine::cancel(frontmatter); - state_machine::TransitionResult::Completed - } + "Session failed", + ), + _ => { + state_machine::cancel(frontmatter); + state_machine::TransitionResult::Completed } - }, - ) + } + }) }; if let Some(ref slug) = db_project_slug { @@ -719,4 +706,3 @@ async fn notify_routine_fire_work_item_terminal( mod handlers; use handlers::{apply_proof_of_work, collect_proof_of_work_data_bounded}; - diff --git a/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs b/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs index 69cadd3ede..57de7cae94 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/debug/org_runtime.rs @@ -94,6 +94,14 @@ pub struct SessionOrgRuntimeSnapshot { pub has_pre_plan_mode: bool, } +/// Test configuration only: mutation tools still pass through their real +/// provider -> dispatcher -> CallContext -> Store path after this rollout +/// gate is opened. The callee rejects ordinary non-WebDriver binaries. +#[tauri::command] +pub async fn debug_agent_org_enable_redesign() -> Result<(), String> { + crate::coordination::agent_org_runs::enable_agent_org_for_webdriver_test() +} + #[tauri::command] pub async fn debug_session_org_runtime_snapshot( state: tauri::State<'_, AgentAppState>, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs b/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs index 9051590203..3b8cb8823a 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/interaction.rs @@ -539,6 +539,7 @@ pub(crate) async fn plan_approval_response_impl( None, None, None, + None, crate::foundation::session_bridge::TurnIntentBridgeSource::UserSubmit, ) .await diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/entry_points.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/entry_points.rs index fd2e603ec1..b295a4b8e1 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/entry_points.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/entry_points.rs @@ -50,6 +50,7 @@ pub async fn send_message_impl_for_job_wake( None, None, None, + None, TurnIntentBridgeSource::Resume, ) .await @@ -79,6 +80,7 @@ pub async fn send_message_impl_for_org_wake( Some(format!("agent-org-wake:{org_run_id}:{member_id}")), None, Some(org_run_id.to_string()), + Some(member_id.to_string()), Some(org_run_id.to_string()), TurnIntentBridgeSource::Resume, ) @@ -122,6 +124,7 @@ pub async fn send_message_impl_for_test( None, None, None, + None, TurnIntentBridgeSource::UserSubmit, ) .await diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs index 5f2222d355..7ac46279c6 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs @@ -1,9 +1,10 @@ -//! Durable Agent Org wake claiming and control-row mode resolution. +//! Durable Agent Org wake claiming and task-bound mode resolution. //! //! A background wake is enqueued from a snapshot, so both halves here re-read //! durable state at the moment it matters: the session row is claimed -//! atomically when the scheduler actually starts the turn, and the turn's exec -//! mode is re-resolved from the bounded inbox batch that same turn will drain. +//! atomically when the scheduler actually starts the turn, and a +//! TaskExecution turn's exec mode is read from the first-class column on the +//! exact Task bound by its persisted Turn context. /// Atomically claim a queued Agent Org Wake at the moment the scheduler /// actually starts it. A pre-enqueue status check is only a snapshot: the Run @@ -131,205 +132,76 @@ pub(super) fn promote_agent_org_direct_session_to_running( .map_err(|error| error.to_string()) } -/// Resolve the execution mode for one background Agent Org wake from unread -/// control envelopes in durable inbox order. -/// -/// Every applicable row updates the candidate, so the latest valid control -/// signal wins. TaskAssigned is only a doorbell: its mode is re-read from the -/// current durable task rather than trusted from a possibly stale payload. -/// Direct human turns never call this resolver. +/// Resolve the execution mode for one admitted background Agent Org wake. +/// `TaskAssigned` is only a doorbell: the persisted Task context identifies +/// the one authoritative row, and unknown/corrupt mode values fail closed +/// instead of falling back to Build. Coordinator wakes have no Task mode. pub(super) fn resolve_agent_org_wake_mode( session_id: &str, run_id: &str, + turn_intent_id: &str, ) -> Result, String> { - use crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID; use crate::coordination::agent_org_tasks::TaskExecutionMode; + use crate::coordination::agent_org_turn_contexts::AgentOrgTurnKind; use rusqlite::{params, OptionalExtension, TransactionBehavior}; let mut conn = database::db::get_connection().map_err(|error| error.to_string())?; let tx = conn .transaction_with_behavior(TransactionBehavior::Deferred) .map_err(|error| error.to_string())?; - let member_id: String = tx - .query_row( - "SELECT org_member_id FROM agent_sessions - WHERE session_id=?1 AND org_member_id IS NOT NULL", - params![session_id], - |row| row.get(0), - ) - .optional() - .map_err(|error| error.to_string())? - .ok_or_else(|| format!("Agent Org wake session {session_id} has no canonical member_id"))?; + let context = crate::coordination::agent_org_turn_contexts::require_context_with_connection( + &tx, + session_id, + turn_intent_id, + )?; + if context.org_run_id != run_id { + return Err(format!( + "Agent Org wake context run mismatch: expected {run_id}, found {}", + context.org_run_id + )); + } - let mut stmt = tx - .prepare( - "WITH delivery_candidates AS ( - SELECT id, payload_kind, payload_json, sender_member_id - FROM agent_org_runtime_inbox - WHERE org_run_id=?1 - AND recipient_member_id=?2 - AND read_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM agent_org_runtime_inbox_delivery_resolutions resolution - WHERE resolution.inbox_id=agent_org_runtime_inbox.id - ) - ORDER BY id ASC - LIMIT ?3 - ), delivery_window AS ( - SELECT id, payload_kind, payload_json, sender_member_id, - ROW_NUMBER() OVER (ORDER BY id ASC) AS ordinal, - SUM(length(CAST(payload_json AS BLOB))) OVER ( - ORDER BY id ASC ROWS UNBOUNDED PRECEDING - ) AS cumulative_payload_bytes - FROM delivery_candidates - ), control AS ( - SELECT id, payload_kind, payload_json, sender_member_id - FROM delivery_window - WHERE (ordinal=1 OR cumulative_payload_bytes<=?4) - AND payload_kind IN ( - 'task_assigned', - 'plan_approval_response', - 'exec_mode_set_request' - ) - AND json_valid(payload_json) - ) - SELECT control.payload_kind, - control.sender_member_id, - assigned.owner, - assigned.status, - CASE WHEN json_valid(assigned.metadata_json) THEN - CASE WHEN json_type(assigned.metadata_json, '$.execution_mode')='text' - THEN json_extract(assigned.metadata_json, '$.execution_mode') - ELSE 'build' END - ELSE 'build' END AS durable_task_mode, - approval.source_member_id, - approval_task.owner, - approval_task.status, - CASE WHEN json_type(control.payload_json, '$.accepted') IN ('true','false') - THEN json_extract(control.payload_json, '$.accepted') - ELSE NULL END, - CASE WHEN json_type(control.payload_json, '$.next_mode')='text' - THEN json_extract(control.payload_json, '$.next_mode') - ELSE NULL END, - CASE WHEN json_type(control.payload_json, '$.mode')='text' - THEN json_extract(control.payload_json, '$.mode') - ELSE NULL END, - EXISTS( - SELECT 1 FROM agent_org_runtime_tasks owned - WHERE owned.org_run_id=?1 - AND owned.owner=?2 - AND owned.status IN ('pending','in_progress') - ) AS has_open_owned_task - FROM control - LEFT JOIN agent_org_runtime_tasks assigned - ON control.payload_kind='task_assigned' - AND json_type(control.payload_json, '$.task_id')='text' - AND assigned.org_run_id=?1 - AND assigned.id=json_extract(control.payload_json, '$.task_id') - LEFT JOIN agent_org_runtime_plan_approvals approval - ON control.payload_kind='plan_approval_response' - AND json_type(control.payload_json, '$.request_id')='text' - AND approval.org_run_id=?1 - AND approval.request_id=json_extract(control.payload_json, '$.request_id') - LEFT JOIN agent_org_runtime_tasks approval_task - ON approval_task.org_run_id=?1 - AND approval_task.id=approval.source_task_id - ORDER BY control.id DESC", - ) - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map( - params![ - run_id, - &member_id, - crate::coordination::agent_inbox::MAX_INBOX_DRAIN_ROWS as i64, - crate::coordination::agent_inbox::MAX_INBOX_DRAIN_PAYLOAD_BYTES as i64, - ], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, Option>(9)?, - row.get::<_, Option>(10)?, - row.get::<_, bool>(11)?, - )) - }, - ) - .map_err(|error| error.to_string())?; - let mut resolved = None; - for row in rows { - let ( - kind, - sender_member_id, - assigned_owner, - assigned_status, - durable_task_mode, - approval_source_member, - approval_task_owner, - approval_task_status, - accepted, - next_mode, - requested_mode, - has_open_owned_task, - ) = row.map_err(|error| error.to_string())?; - let mode = match kind.as_str() { - "task_assigned" - if assigned_owner.as_deref() == Some(member_id.as_str()) - && matches!(assigned_status.as_deref(), Some("pending" | "in_progress")) => - { - durable_task_mode - .as_deref() - .and_then(|mode| TaskExecutionMode::from_wire(mode).ok()) - .map(|mode| match mode { - TaskExecutionMode::Build => crate::session::AgentExecMode::Build, - TaskExecutionMode::Plan => crate::session::AgentExecMode::Plan, - }) - } - "plan_approval_response" - if sender_member_id.as_deref() == Some(COORDINATOR_MEMBER_ID) - && approval_source_member.as_deref() == Some(member_id.as_str()) - && approval_task_owner.as_deref() == Some(member_id.as_str()) - && matches!( - approval_task_status.as_deref(), - Some("pending" | "in_progress") - ) => - { - next_mode - .as_deref() - .and_then(crate::session::AgentExecMode::parse) - .or_else(|| { - accepted.map(|accepted| { - if accepted { - crate::session::AgentExecMode::Build - } else { - crate::session::AgentExecMode::Plan - } - }) - }) + let resolved = match context.turn_kind { + AgentOrgTurnKind::Coordinator => None, + AgentOrgTurnKind::TaskExecution => { + let task_id = context + .task_id + .as_deref() + .ok_or_else(|| "TaskExecution wake context has no canonical task_id".to_string())?; + let owner_member_id = context.owner_member_id.as_deref().ok_or_else(|| { + "TaskExecution wake context has no canonical owner_member_id".to_string() + })?; + let row: Option<(String, String)> = tx + .query_row( + "SELECT execution_mode, status + FROM agent_org_runtime_tasks + WHERE org_run_id=?1 AND id=?2 AND owner=?3", + params![run_id, task_id, owner_member_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((execution_mode, status)) = row else { + return Err(format!( + "TaskExecution wake target {task_id} is missing or no longer owned by {owner_member_id}" + )); + }; + if !matches!(status.as_str(), "pending" | "in_progress") { + return Err(format!( + "TaskExecution wake target {task_id} is no longer open ({status})" + )); } - "exec_mode_set_request" - if sender_member_id.as_deref() == Some(COORDINATOR_MEMBER_ID) - && has_open_owned_task => - { - requested_mode - .as_deref() - .and_then(crate::session::AgentExecMode::parse) - } - _ => None, - }; - if mode.is_some() { - resolved = mode; - break; + Some(match TaskExecutionMode::from_wire(&execution_mode)? { + TaskExecutionMode::Build => crate::session::AgentExecMode::Build, + TaskExecutionMode::Plan => crate::session::AgentExecMode::Plan, + }) } - } - drop(stmt); + AgentOrgTurnKind::UserDirectedWork => { + return Err( + "UserDirectedWork is not enabled in the PR4 formal wake mode resolver".to_string(), + ); + } + }; tx.commit().map_err(|error| error.to_string())?; Ok(resolved) } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs index f3586fb877..1a4c82b72a 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/send.rs @@ -152,6 +152,7 @@ pub(crate) async fn send_message_impl( client_message_id: Option, turn_intent_id: Option, org_wake_run_id: Option, + org_wake_member_id: Option, intent_org_run_id: Option, source: TurnIntentBridgeSource, ) -> Result { @@ -248,21 +249,34 @@ pub(crate) async fn send_message_impl( (None, None) => preflight_org_run_id, }; - // PR3 admits only canonical Coordinator/Root turns. Member Task/direct/ - // group/inbox producers must use their typed authority constructors in - // their owning PR; a legacy Member call fails here before base intent or - // scheduler state can be written. + // Every Agent Org turn receives its persisted typed authority before a + // base intent or scheduler item can exist. Background Member wakes derive + // one TaskExecution binding from canonical unread formal work; ordinary + // direct submissions in this PR remain Coordinator-only. if let Some(run_id) = effective_intent_org_run_id.as_deref() { - let admission = - crate::coordination::agent_org_turn_contexts::AgentOrgTurnAdmission::coordinator( - run_id, - &session_id, - &effective_turn_intent_id, - client_message_id.clone(), - source, - ); - tokio::task::spawn_blocking(move || { - crate::coordination::agent_org_turn_contexts::accept(&admission) + let admission_run_id = run_id.to_string(); + let admission_session_id = session_id.clone(); + let admission_turn_intent_id = effective_turn_intent_id.clone(); + let admission_client_message_id = client_message_id.clone(); + let admission_wake_member_id = org_wake_member_id.clone(); + tokio::task::spawn_blocking(move || match admission_wake_member_id { + Some(member_id) => crate::coordination::agent_org_turn_contexts::accept_wake( + &admission_run_id, + &admission_session_id, + &admission_turn_intent_id, + admission_client_message_id, + &member_id, + ), + None => { + let admission = crate::coordination::agent_org_turn_contexts::AgentOrgTurnAdmission::coordinator( + admission_run_id, + admission_session_id, + admission_turn_intent_id, + admission_client_message_id, + source, + ); + crate::coordination::agent_org_turn_contexts::accept(&admission) + } }) .await .map_err(|error| format!("Agent Org Turn admission worker failed: {error}"))??; @@ -520,8 +534,9 @@ pub(crate) async fn send_message_impl( let inbox_control_mode = if let Some(run_id) = org_wake_run_id.as_deref() { let mode_session_id = session_id.clone(); let mode_run_id = run_id.to_string(); + let mode_turn_intent_id = effective_turn_intent_id.clone(); tokio::task::spawn_blocking(move || { - resolve_agent_org_wake_mode(&mode_session_id, &mode_run_id) + resolve_agent_org_wake_mode(&mode_session_id, &mode_run_id, &mode_turn_intent_id) }) .await .map_err(|error| format!("Agent Org pre-turn mode resolver failed: {error}"))?? @@ -601,7 +616,7 @@ pub(crate) async fn send_message_impl( .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|err| err.to_string())?; if status_wake_run_id.is_some() || status_intent_run_id.is_some() { - crate::coordination::agent_org_turn_contexts::require_context_with_connection( + crate::coordination::agent_org_turn_contexts::revalidate_context_with_connection( &tx, &status_sid, &status_turn_intent_id, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs index 8549879557..0e8f823f28 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/tests.rs @@ -14,22 +14,15 @@ use super::send::{ ensure_agent_org_turn_is_runnable, should_divert_to_mid_turn_steering, terminal_intent_status_override, }; -use crate::coordination::agent_inbox::{ - AgentInboxStore, AgentMessage, InsertInboxParams, RequestId, -}; use crate::coordination::agent_member_interventions::{ can_enter_member_intervention, AgentMemberInterventionStore, EnterMemberInterventionParams, }; -use crate::coordination::agent_org_plan_approvals::{ - AgentOrgPlanApprovalStore, CreateAgentOrgPlanApprovalParams, -}; use crate::coordination::agent_org_runs::COORDINATOR_MEMBER_ID; use crate::coordination::agent_org_runs::{ AgentOrgRunEntryMode, AgentOrgRunStatus, AgentOrgRunStore, CreateAgentOrgRunParams, }; use crate::coordination::agent_org_tasks::{ - enqueue_task_assigned_to_with_tasks, AgentOrgTaskStore, CreateTaskParams, TaskStatus, - TASK_METADATA_ELIGIBLE_MEMBER_IDS, TASK_METADATA_EXECUTION_MODE, + AgentOrgTaskStore, CreateTaskParams, TaskStatus, TASK_METADATA_EXECUTION_MODE, }; use crate::definitions::orgs::{FlatOrgMember, OrgDefinition, PlanApprovalPolicy}; use crate::session::{AgentExecMode, SessionStatus}; @@ -139,21 +132,55 @@ fn setup_wake_mode_fixture(execution_mode: &str, task_status: TaskStatus) -> Wak } } -fn insert_control(fixture: &WakeModeFixture, sender_member_id: &str, message: AgentMessage) -> i64 { - AgentInboxStore::insert(InsertInboxParams { - recipient_agent_id: "planner-agent".into(), - recipient_member_id: Some(fixture.member_id.clone()), - sender_agent_id: if sender_member_id == COORDINATOR_MEMBER_ID { - "coordinator-agent".into() - } else { - "peer-agent".into() - }, - sender_member_id: Some(sender_member_id.into()), - org_run_id: Some(fixture.run_id.clone()), - message, - }) - .expect("insert control row") - .id +fn seed_task_execution_context(fixture: &WakeModeFixture, turn_intent_id: &str) { + let conn = database::db::get_connection().expect("test db"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_turn_intents ( + session_id TEXT NOT NULL, + turn_intent_id TEXT NOT NULL, + client_message_id TEXT, + org_run_id TEXT, + source TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(session_id, turn_intent_id) + );", + ) + .expect("canonical Turn Intent test schema"); + let generation: i64 = conn + .query_row( + "SELECT activation_generation FROM agent_org_runtime_runs WHERE id=?1", + [&fixture.run_id], + |row| row.get(0), + ) + .expect("run generation"); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO session_turn_intents ( + session_id, turn_intent_id, client_message_id, org_run_id, + source, status, created_at, updated_at + ) VALUES (?1,?2,NULL,?3,'agent_org','queued',?4,?4)", + rusqlite::params![&fixture.session_id, turn_intent_id, &fixture.run_id, &now], + ) + .expect("seed base Turn"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts ( + session_id, turn_intent_id, org_run_id, participant_id, turn_kind, + task_id, owner_member_id, dispatch_member_id, member_dispatch_sequence, + source_kind, source_id, activation_generation, created_at + ) VALUES (?1,?2,?3,?4,'task_execution',?5,?4,?4,1,'task',?5,?6,?7)", + rusqlite::params![ + &fixture.session_id, + turn_intent_id, + &fixture.run_id, + &fixture.member_id, + &fixture.task_id, + generation, + &now, + ], + ) + .expect("seed typed TaskExecution context"); } #[test] @@ -390,225 +417,35 @@ fn direct_worker_message_is_a_member_takeover() { } #[test] -fn plan_changes_request_controls_the_first_revision_wake() { - let fixture = setup_wake_mode_fixture("plan", TaskStatus::InProgress); - let approval = AgentOrgPlanApprovalStore::create_pending(CreateAgentOrgPlanApprovalParams { - request_id: "revision-request".into(), - org_run_id: fixture.run_id.clone(), - source_task_id: fixture.task_id.clone(), - source_member_id: fixture.member_id.clone(), - source_session_id: fixture.session_id.clone(), - root_session_id: "root-session".into(), - policy: PlanApprovalPolicy::Coordinator, - plan_title: "Initial plan".into(), - plan_path: AgentOrgPlanApprovalStore::managed_plan_path_for_session( - &fixture.session_id, - "initial.plan.md", - ) - .expect("managed initial plan path") - .to_string_lossy() - .into_owned(), - plan_content: "# Initial".into(), - }) - .expect("create plan approval"); - insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::PlanApprovalResponse { - request_id: RequestId(approval.request_id), - accepted: false, - feedback: Some("revise scope".into()), - next_mode: Some(AgentExecMode::Plan), - }, - ); - - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id) - .expect("resolve revision mode"), - Some(AgentExecMode::Plan) - ); -} +fn task_wake_mode_comes_from_the_bound_tasks_first_class_column() { + let fixture = setup_wake_mode_fixture("plan", TaskStatus::Pending); + seed_task_execution_context(&fixture, "task-wake-plan"); -#[test] -fn coordinator_exec_override_controls_the_first_wake() { - let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); - insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::ExecModeSetRequest { - request_id: RequestId("override-plan".into()), - mode: AgentExecMode::Plan, - reason: Some("plan before implementation".into()), - }, - ); assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id) - .expect("resolve override"), + resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id, "task-wake-plan",) + .expect("resolve typed Task wake mode"), Some(AgentExecMode::Plan) ); } #[test] -fn latest_applicable_control_wins_and_task_mode_comes_from_durable_state() { +fn task_wake_mode_fails_closed_for_corrupt_first_class_value() { let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); - insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::ExecModeSetRequest { - request_id: RequestId("first-plan".into()), - mode: AgentExecMode::Plan, - reason: None, - }, - ); - let tasks = AgentOrgTaskStore::list(&fixture.run_id).expect("list task board"); - let task = tasks - .iter() - .find(|task| task.id == fixture.task_id) - .expect("controlled task"); - enqueue_task_assigned_to_with_tasks( - task, - &tasks, - "planner-agent", - &fixture.member_id, - "coordinator-agent", - Some(COORDINATOR_MEMBER_ID), - "Coordinator", + seed_task_execution_context(&fixture, "task-wake-corrupt"); + let conn = database::db::get_connection().expect("test db"); + conn.execute_batch("PRAGMA ignore_check_constraints=ON;") + .expect("simulate corrupt canonical row"); + conn.execute( + "UPDATE agent_org_runtime_tasks SET execution_mode='future_mode' + WHERE org_run_id=?1 AND id=?2", + rusqlite::params![&fixture.run_id, &fixture.task_id], ) - .expect("insert later TaskAssigned"); - - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id) - .expect("resolve latest signal"), - Some(AgentExecMode::Build), - "later TaskAssigned wins, and its mode is re-read from the durable Build task" - ); -} - -#[test] -fn forged_peer_exec_override_is_ignored() { - let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); - insert_control( - &fixture, - "peer", - AgentMessage::ExecModeSetRequest { - request_id: RequestId("forged-plan".into()), - mode: AgentExecMode::Plan, - reason: None, - }, - ); - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id) - .expect("ignore forged override"), - None - ); -} - -#[test] -fn control_beyond_current_drain_row_batch_does_not_change_this_turn_mode() { - let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); - for index in 0..crate::coordination::agent_inbox::MAX_INBOX_DRAIN_ROWS { - insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::Plain { - summary: format!("older-{index}"), - text: "ordinary work context".into(), - }, - ); - } - insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::ExecModeSetRequest { - request_id: RequestId("future-plan".into()), - mode: AgentExecMode::Plan, - reason: None, - }, - ); - - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id) - .expect("future batch control must not affect this turn"), - None - ); -} - -#[test] -fn control_beyond_current_drain_byte_budget_does_not_change_this_turn_mode() { - let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); - let large_text = "🧭".repeat(19_000); - for index in 0..20 { - insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::Plain { - summary: format!("large-{index}"), - text: large_text.clone(), - }, - ); - } - insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::ExecModeSetRequest { - request_id: RequestId("later-byte-plan".into()), - mode: AgentExecMode::Plan, - reason: None, - }, - ); - - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id) - .expect("byte-deferred control must not affect this turn"), - None - ); -} - -#[test] -fn consumed_plan_control_does_not_repeat_on_next_turn() { - let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); - let row_id = insert_control( - &fixture, - COORDINATOR_MEMBER_ID, - AgentMessage::ExecModeSetRequest { - request_id: RequestId("one-shot-plan".into()), - mode: AgentExecMode::Plan, - reason: None, - }, - ); - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id).unwrap(), - Some(AgentExecMode::Plan) - ); - AgentInboxStore::mark_many_read(&[row_id]).expect("commit successful wake"); - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id).unwrap(), - None - ); -} - -#[test] -fn ownerless_plan_task_does_not_select_member_mode() { - let fixture = setup_wake_mode_fixture("build", TaskStatus::Pending); - AgentOrgTaskStore::create(CreateTaskParams { - id: "plan-from-pool".to_string(), - org_run_id: fixture.run_id.clone(), - subject: "Plan the work".to_string(), - description: String::new(), - active_form: None, - owner: None, - status: TaskStatus::Pending, - blocks: Vec::new(), - blocked_by: Vec::new(), - metadata: Some(serde_json::json!({ - TASK_METADATA_ELIGIBLE_MEMBER_IDS: ["planner"], - TASK_METADATA_EXECUTION_MODE: "plan", - })), - }) - .expect("seed ownerless task"); - - assert_eq!( - resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id).expect("resolve mode"), - None - ); + .expect("corrupt execution mode"); + conn.execute_batch("PRAGMA ignore_check_constraints=OFF;") + .expect("restore checks"); + + let error = + resolve_agent_org_wake_mode(&fixture.session_id, &fixture.run_id, "task-wake-corrupt") + .expect_err("unknown execution mode must not default to Build"); + assert!(error.contains("invalid task execution_mode"), "{error}"); } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/mod.rs b/src-tauri/crates/agent-core/src/state/commands/session/mod.rs index dee3d8cf7d..ff9a68445a 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/mod.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/mod.rs @@ -214,6 +214,7 @@ pub async fn agent_send_message( turnIntentId, None, None, + None, source, ) .await diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/intervention.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/intervention.rs index 1f5576ec41..f29f690ea0 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/intervention.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/intervention.rs @@ -234,6 +234,7 @@ pub async fn agent_org_send_user_message_to_member_impl( None, None, None, + None, Some(org_run_id), crate::foundation::session_bridge::TurnIntentBridgeSource::AgentOrg, ) diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/mod.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/mod.rs index 8974730785..de5c2b5cbb 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/mod.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/mod.rs @@ -24,6 +24,7 @@ mod intervention; mod lifecycle; mod plan_approval; mod run_view; +mod task_pages; #[cfg(test)] mod tests; @@ -33,3 +34,4 @@ pub use intervention::*; pub use lifecycle::*; pub use plan_approval::*; pub use run_view::*; +pub use task_pages::*; diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs index 104cee25ce..33941eb2e1 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/run_view.rs @@ -27,7 +27,8 @@ use crate::coordination::agent_org_runs::{ WorkerSessionRuntime, COORDINATOR_MEMBER_ID, }; use crate::coordination::agent_org_tasks::{ - AgentOrgTaskStore, Task, TaskExecutionMode, TaskSummary, + AgentOrgTaskStore, Task, TaskExecutionMode, TaskOutputSummary, TaskPageBucket, + TaskPageDirection, TaskSummary, }; use crate::state::AgentAppState; @@ -43,8 +44,11 @@ pub struct AgentOrgTaskRuntime { pub description_truncated: bool, pub blocks_truncated: bool, pub blocked_by_truncated: bool, + pub dependencies_satisfied: bool, pub execution_mode: TaskExecutionMode, #[serde(skip_serializing_if = "Option::is_none")] + pub output_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub owner_member: Option, #[serde(skip_serializing_if = "Option::is_none")] pub owner_runtime: Option, @@ -132,6 +136,8 @@ pub struct AgentOrgRunTaskOverview { pub pending: usize, pub in_progress: usize, pub completed: usize, + pub failed: usize, + pub cancelled: usize, pub corrupt: usize, pub visible: usize, pub truncated: bool, @@ -157,7 +163,7 @@ pub enum AgentOrgRunPhase { /// Keep the bridge payload bounded; durable history remains available through /// the explicitly paginated inbox/history surfaces. const RUN_VIEW_INBOX_LIMIT: usize = 200; -const RUN_VIEW_TASK_LIMIT: usize = 200; +const RUN_VIEW_TASK_LIMIT: usize = 50; #[tauri::command] pub async fn agent_org_session_run_view( @@ -208,19 +214,32 @@ pub(super) fn build_agent_org_run_view( .ok_or_else(|| format!("Agent Org run {} no longer exists", context.run_id))?; let run_status = run_status_value.as_str().to_string(); - let task_page = AgentOrgTaskStore::list_summary_page_with_connection( + let task_page = AgentOrgTaskStore::list_task_page_with_connection( &tx, &context.run_id, + TaskPageBucket::Current, None, None, - None, + TaskPageDirection::Forward, RUN_VIEW_TASK_LIMIT, )?; + let (failed_task_count, cancelled_task_count): (i64, i64) = tx + .query_row( + "SELECT + COALESCE(SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN status='cancelled' THEN 1 ELSE 0 END),0) + FROM agent_org_runtime_tasks WHERE org_run_id=?1", + params![&context.run_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| error.to_string())?; let task_overview = AgentOrgRunTaskOverview { total: quiescence.facts.task_count, pending: quiescence.facts.pending_task_count, in_progress: quiescence.facts.in_progress_task_count, completed: quiescence.facts.completed_task_count, + failed: failed_task_count.max(0) as usize, + cancelled: cancelled_task_count.max(0) as usize, corrupt: quiescence.facts.corrupt_task_count, visible: task_page.tasks.len(), truncated: task_page.has_more, @@ -394,6 +413,7 @@ pub(super) fn tasks_for_context( tasks .into_iter() .map(|summary| { + let output_summary = summary.output.clone(); let owner_member = summary .owner .as_ref() @@ -411,12 +431,20 @@ pub(super) fn tasks_for_context( active_form: summary.active_form, owner: summary.owner, status: summary.status, + execution_mode, blocks: summary.blocks, blocked_by: summary.blocked_by, // Eligibility, role and output summaries are available from // `task_list`; full metadata/output content is intentionally // detail-only and never crosses the polling bridge. metadata: None, + output: None, + failure_reason: summary.failure_reason, + cancel_reason: summary.cancel_reason, + created_by_participant_id: String::new(), + source_turn_intent_id: String::new(), + originating_message_id: None, + replaces_task_id: summary.replaces_task_id, created_at: summary.created_at, updated_at: summary.updated_at, }; @@ -426,7 +454,9 @@ pub(super) fn tasks_for_context( description_truncated: summary.description_truncated, blocks_truncated: summary.blocks_truncated, blocked_by_truncated: summary.blocked_by_truncated, + dependencies_satisfied: summary.dependencies_satisfied, execution_mode, + output_summary, owner_member, owner_runtime, } diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/task_pages.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/task_pages.rs new file mode 100644 index 0000000000..a20fe152b4 --- /dev/null +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/task_pages.rs @@ -0,0 +1,129 @@ +//! Demand-driven Task history, detail, and annotation commands. +//! +//! These reads deliberately stay outside the frequently-polled Run View. They +//! use bounded keyset pages and never reconcile, wake, or mutate the run. + +use std::collections::HashMap; + +use serde::Serialize; + +use crate::coordination::agent_org_tasks::{ + AgentOrgTaskStore, Task, TaskAnnotationPage, TaskPageBucket, TaskPageDirection, TaskStatus, +}; +use crate::state::AgentAppState; + +use super::context::{require_session_member_id, session_org_read_context}; +use super::run_view::{tasks_for_context, AgentOrgTaskRuntime}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentOrgTaskPage { + pub bucket: TaskPageBucket, + pub status: Option, + pub tasks: Vec, + pub has_more: bool, + pub next_cursor: Option, + pub previous_cursor: Option, +} + +#[tauri::command] +pub async fn agent_org_session_task_page( + state: tauri::State<'_, AgentAppState>, + session_id: String, + bucket: TaskPageBucket, + status: Option, + cursor: Option, + direction: Option, + limit: Option, +) -> Result { + crate::coordination::agent_org_runs::require_agent_org_redesign()?; + let read_context = session_org_read_context(&state, &session_id) + .await? + .ok_or_else(|| format!("Agent Org context not found for session {session_id}"))?; + require_session_member_id(&read_context, &session_id)?; + let context = read_context + .context + .ok_or_else(|| format!("Agent Org run context not found for session {session_id}"))?; + tokio::task::spawn_blocking(move || { + let conn = database::db::get_connection().map_err(|error| error.to_string())?; + let tx = conn + .unchecked_transaction() + .map_err(|error| error.to_string())?; + let page = AgentOrgTaskStore::list_task_page_with_connection( + &tx, + &context.run_id, + bucket, + status, + cursor.as_deref(), + direction.unwrap_or(TaskPageDirection::Forward), + limit.unwrap_or(50), + )?; + let tasks = tasks_for_context(&context, page.tasks, &HashMap::new()); + tx.commit().map_err(|error| error.to_string())?; + Ok(AgentOrgTaskPage { + bucket, + status, + tasks, + has_more: page.has_more, + next_cursor: page.next_cursor, + previous_cursor: page.previous_cursor, + }) + }) + .await + .map_err(|error| format!("Agent Org Task page worker failed: {error}"))? +} + +#[tauri::command] +pub async fn agent_org_session_task_detail( + state: tauri::State<'_, AgentAppState>, + session_id: String, + task_id: String, +) -> Result { + crate::coordination::agent_org_runs::require_agent_org_redesign()?; + let read_context = session_org_read_context(&state, &session_id) + .await? + .ok_or_else(|| format!("Agent Org context not found for session {session_id}"))?; + require_session_member_id(&read_context, &session_id)?; + let run_id = read_context + .context + .ok_or_else(|| format!("Agent Org run context not found for session {session_id}"))? + .run_id; + tokio::task::spawn_blocking(move || { + AgentOrgTaskStore::get(&run_id, &task_id)? + .ok_or_else(|| format!("task_not_found: {task_id} in run {run_id}")) + }) + .await + .map_err(|error| format!("Agent Org Task detail worker failed: {error}"))? +} + +#[tauri::command] +pub async fn agent_org_session_task_annotation_page( + state: tauri::State<'_, AgentAppState>, + session_id: String, + task_id: String, + cursor: Option, + limit: Option, +) -> Result { + crate::coordination::agent_org_runs::require_agent_org_redesign()?; + let read_context = session_org_read_context(&state, &session_id) + .await? + .ok_or_else(|| format!("Agent Org context not found for session {session_id}"))?; + require_session_member_id(&read_context, &session_id)?; + let run_id = read_context + .context + .ok_or_else(|| format!("Agent Org run context not found for session {session_id}"))? + .run_id; + tokio::task::spawn_blocking(move || { + if AgentOrgTaskStore::get(&run_id, &task_id)?.is_none() { + return Err(format!("task_not_found: {task_id} in run {run_id}")); + } + AgentOrgTaskStore::list_annotation_page( + &run_id, + &task_id, + cursor.as_deref(), + limit.unwrap_or(50), + ) + }) + .await + .map_err(|error| format!("Agent Org annotation page worker failed: {error}"))? +} diff --git a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs index 9ac1fcbcc7..f5df201700 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/org_tasks/tests.rs @@ -179,9 +179,17 @@ fn task_for_resume(owner: Option<&str>, status: TaskStatus) -> Task { active_form: None, owner: owner.map(str::to_string), status, + execution_mode: TaskExecutionMode::Build, blocks: Vec::new(), blocked_by: Vec::new(), metadata: None, + output: None, + failure_reason: None, + cancel_reason: None, + created_by_participant_id: "coordinator".to_string(), + source_turn_intent_id: "turn-test".to_string(), + originating_message_id: None, + replaces_task_id: None, created_at: "2026-05-28T00:00:00Z".to_string(), updated_at: "2026-05-28T00:00:00Z".to_string(), } @@ -194,6 +202,8 @@ fn run_phase_projects_completed_work_as_finalizing_then_idle() { pending: 0, in_progress: 0, completed: 1, + failed: 0, + cancelled: 0, corrupt: 0, visible: 1, truncated: false, @@ -211,6 +221,8 @@ fn run_phase_projects_completed_work_as_finalizing_then_idle() { pending: 0, in_progress: 0, completed: 0, + failed: 0, + cancelled: 0, corrupt: 0, visible: 0, truncated: false, @@ -307,7 +319,9 @@ fn task_runtime_projects_execution_mode_on_the_wire() { description_truncated: false, blocks_truncated: false, blocked_by_truncated: false, + dependencies_satisfied: true, execution_mode: TaskExecutionMode::Plan, + output_summary: None, owner_member: None, owner_runtime: None, }; @@ -333,11 +347,15 @@ fn run_view_task_omits_durable_metadata_and_output() { blocks_truncated: false, blocked_by: Vec::new(), blocked_by_truncated: false, + dependencies_satisfied: true, eligible_member_ids: vec!["member-builder".to_string()], eligible_member_ids_truncated: false, required_role: None, execution_mode: TaskExecutionMode::Build, output: None, + failure_reason: None, + cancel_reason: None, + replaces_task_id: None, created_at: "2026-05-28T00:00:00Z".to_string(), updated_at: "2026-05-28T00:00:00Z".to_string(), }], @@ -380,7 +398,9 @@ fn run_phase_projects_quiet_user_plan_gate_as_awaiting_approval() { description_truncated: false, blocks_truncated: false, blocked_by_truncated: false, + dependencies_satisfied: true, execution_mode: TaskExecutionMode::Plan, + output_summary: None, owner_member: None, owner_runtime: None, }; @@ -389,6 +409,8 @@ fn run_phase_projects_quiet_user_plan_gate_as_awaiting_approval() { pending: 0, in_progress: 1, completed: 0, + failed: 0, + cancelled: 0, corrupt: 0, visible: 1, truncated: false, @@ -401,6 +423,7 @@ fn run_phase_projects_quiet_user_plan_gate_as_awaiting_approval() { source_task_id: task.task.id.clone(), source_member_id: "member-planner".to_string(), source_session_id: "planner-session".to_string(), + source_turn_intent_id: "planner-turn".to_string(), root_session_id: "root-shared-agent".to_string(), policy: crate::definitions::orgs::PlanApprovalPolicy::User, status: crate::coordination::agent_org_plan_approvals::AgentOrgPlanApprovalStatus::Pending, diff --git a/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs b/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs index b695564039..1d2c59c917 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/persistence_tests.rs @@ -124,9 +124,17 @@ fn seed_run_owned_rows(run_id: &str) { ) .expect("seed run inbox history"); conn.execute( - "INSERT INTO agent_org_runtime_tasks ( - id, org_run_id, subject, status, created_at, updated_at - ) VALUES (?1, ?2, 'delete me', 'completed', ?3, ?3)", + r#"INSERT INTO agent_org_runtime_tasks ( + id, org_run_id, subject, description, owner, status, + execution_mode, blocked_by_json, output_json, + created_by_participant_id, source_turn_intent_id, + created_at, updated_at + ) VALUES ( + ?1, ?2, 'delete me', '', 'worker', 'completed', + 'build', '[]', + '{"summary":"delete me","content":null,"artifactIds":[],"producedByMemberId":"worker","producedAt":"2026-07-16T00:00:00Z"}', + 'coordinator', 'delete-test-fixture', ?3, ?3 + )"#, rusqlite::params![format!("task-{run_id}"), run_id, "2026-07-16T00:00:00Z"], ) .expect("seed run task history"); diff --git a/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs b/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs index cdc6e893a7..c13ed500c4 100644 --- a/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs +++ b/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs @@ -420,7 +420,7 @@ async fn failed_member_finalize_releases_task_for_coordinator_assignment() { assert!(failure_reason.contains("awaiting_coordinator_assignment")); assert!(failure_reason.contains("eligible_member_ids: [member-worker, member-peer]")); assert!(failure_reason.contains("required_role: implement")); - assert!(failure_reason.contains("task_update owner_member_id")); + assert!(failure_reason.contains("task_update operation=patch_pending owner_member_id")); assert_eq!(call.unfinished_task_ids, vec!["failed-task"]); } diff --git a/src-tauri/src/agent_sessions/event_pipeline/extractors/misc_extractor.rs b/src-tauri/src/agent_sessions/event_pipeline/extractors/misc_extractor.rs index 694feb24b6..fafa8a4a2c 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/extractors/misc_extractor.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/extractors/misc_extractor.rs @@ -353,10 +353,13 @@ fn legacy_task_rejection_guidance( ) -> Option { let message = recoverable_task_validation_message(result_object)?; let guidance = if message.contains("missing field `summary`") + || message.contains("Task output requires a non-empty summary") || message.contains("output.summary must not be empty") { - "Task output needs a non-empty summary. Retry with status=completed and output={summary, content?, artifact_ids?}." - } else if message.contains("status=in_progress can only be set by the owning member") { + "Task output needs a non-empty summary. Retry with operation=complete and output={summary, content?, artifact_ids?}." + } else if message.contains("status=in_progress can only be set by the owning member") + || message.contains("operation requires an Owner TaskExecution") + { "Only the task owner may mark its work in progress. Assignment already wakes the owner; wait for that member to record its own start." } else if message.contains("status=completed can only be set by the owning member") || message.contains("output can only be written by the owning member") diff --git a/src-tauri/src/api/agent/test/agent_org.rs b/src-tauri/src/api/agent/test/agent_org.rs index 16d3fba896..4a95885196 100644 --- a/src-tauri/src/api/agent/test/agent_org.rs +++ b/src-tauri/src/api/agent/test/agent_org.rs @@ -58,7 +58,6 @@ use agent_core::coordination::agent_inbox::AgentInboxStore; use agent_core::coordination::agent_org_runs::{ AgentOrgContextMember, AgentOrgRunContext, AgentOrgRunStore, }; -use agent_core::coordination::agent_org_tasks::TASK_METADATA_ELIGIBLE_MEMBER_IDS; use agent_core::definitions::orgs::{ all_member_links, AgentOrgCapabilityIndex, AgentOrgLaunchSnapshot, AgentOrgsStore, FlatOrgMember, OrgDefinition, @@ -2662,272 +2661,17 @@ pub async fn test_agent_org_run_cleanup( /// `POST /test/agent-org/tasks/seed` /// -/// Insert one row directly via [`AgentOrgTaskStore::create`]. This -/// bypasses the LLM-callable -/// `task_create` tool so a deterministic E2E can plant a task in any -/// initial state (e.g. unowned, owned-and-in-progress, completed) and -/// then exercise the read paths (`drain-inbox`, explicit assignment, -/// shutdown-driven unassign, etc.). -/// -/// Body: -/// ```json -/// { -/// "id": "task-1", -/// "org_run_id": "run-...", -/// "subject": "Refactor auth", -/// "description": "...", -/// "active_form": "Refactoring auth", -/// "owner": "alice-agent", -/// "status": "in_progress", -/// "blocks": [], -/// "blocked_by": [] -/// } -/// ``` -/// -/// `description`/`active_form` default to empty/null. `status` defaults -/// to `"pending"`. `owner` defaults to null (awaiting assignment). Returns -/// `{ok, id}` on success. +/// PR4 makes the Task Store the final lifecycle authority. Test and debug +/// surfaces may prepare run identity/configuration or read durable state, +/// but they must not manufacture Task rows or terminal states. pub async fn test_agent_org_tasks_seed( - Json(body): Json, + Json(_body): Json, ) -> Json { - use agent_core::coordination::agent_org_plan_approvals::{ - AgentOrgPlanApprovalStore, CreateAgentOrgPlanApprovalParams, - }; - use agent_core::coordination::agent_org_runs::COORDINATOR_MEMBER_ID; - use agent_core::coordination::agent_org_tasks::{ - AgentOrgTaskStore, CreateTaskParams, TaskStatus, TASK_METADATA_EXECUTION_MODE, - }; - use agent_core::core::session::persistence::{ - session_type, upsert_session, UnifiedSessionRecord, - }; - use agent_core::core::session::SessionStatus; - use agent_core::definitions::orgs::PlanApprovalPolicy; - - let Some(obj) = body.as_object() else { - return Json(serde_json::json!({ "ok": false, "error": "body must be an object" })); - }; - let id = match obj.get("id").and_then(|v| v.as_str()) { - Some(s) if !s.trim().is_empty() => s.to_string(), - _ => return Json(serde_json::json!({ "ok": false, "error": "id is required" })), - }; - let org_run_id = match obj.get("org_run_id").and_then(|v| v.as_str()) { - Some(s) if !s.trim().is_empty() => s.to_string(), - _ => return Json(serde_json::json!({ "ok": false, "error": "org_run_id is required" })), - }; - let subject = match obj.get("subject").and_then(|v| v.as_str()) { - Some(s) if !s.trim().is_empty() => s.to_string(), - _ => return Json(serde_json::json!({ "ok": false, "error": "subject is required" })), - }; - let description = obj - .get("description") - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_default(); - let active_form = obj - .get("active_form") - .and_then(|v| v.as_str()) - .map(str::to_string); - let owner = obj - .get("owner") - .and_then(|v| v.as_str()) - .filter(|s| !s.trim().is_empty()) - .map(str::to_string); - let status_str = obj - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("pending"); - let status = match status_str { - "pending" => TaskStatus::Pending, - "in_progress" => TaskStatus::InProgress, - "completed" => TaskStatus::Completed, - other => { - return Json(serde_json::json!({ - "ok": false, - "error": format!("unknown status '{other}' — expected pending|in_progress|completed"), - })) - } - }; - let blocks = obj - .get("blocks") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect::>() - }) - .unwrap_or_default(); - let blocked_by = obj - .get("blocked_by") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect::>() - }) - .unwrap_or_default(); - let requested_eligible_member_ids = obj - .get("eligible_member_ids") - .and_then(|value| value.as_array()) - .map(|values| { - values - .iter() - .filter_map(|value| value.as_str().map(str::to_string)) - .collect::>() - }); - let execution_mode = match obj.get("execution_mode").and_then(|value| value.as_str()) { - None => None, - Some("build") => Some("build".to_string()), - Some("plan") => Some("plan".to_string()), - Some(other) => { - return Json(serde_json::json!({ - "ok": false, - "error": format!("unknown execution_mode '{other}' — expected build|plan"), - })) - } - }; - let pending_plan_approval = obj.get("pending_plan_approval").cloned(); - - let mut params = CreateTaskParams { - id: id.clone(), - org_run_id, - subject, - description, - active_form, - owner, - status, - blocks, - blocked_by, - metadata: None, - }; - - match tokio::task::spawn_blocking(move || { - let eligible_member_ids = match requested_eligible_member_ids { - Some(member_ids) => Some(member_ids), - None if params.owner.is_none() && params.status == TaskStatus::Pending => Some( - AgentOrgRunStore::context_for_run(¶ms.org_run_id)? - .map(|context| { - context - .members - .into_iter() - .map(|member| member.member_id) - .collect() - }) - .unwrap_or_default(), - ), - None => None, - }; - let mut metadata = serde_json::Map::new(); - if let Some(eligible_member_ids) = eligible_member_ids { - metadata.insert( - TASK_METADATA_ELIGIBLE_MEMBER_IDS.to_string(), - serde_json::json!(eligible_member_ids), - ); - } - if let Some(execution_mode) = execution_mode { - metadata.insert( - TASK_METADATA_EXECUTION_MODE.to_string(), - serde_json::Value::String(execution_mode), - ); - } - if !metadata.is_empty() { - params.metadata = Some(serde_json::Value::Object(metadata)); - } - - let task = AgentOrgTaskStore::create(params)?; - let approval = if let Some(pending) = pending_plan_approval { - let pending = pending - .as_object() - .ok_or_else(|| "pending_plan_approval must be an object".to_string())?; - let request_id = pending - .get("request_id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "pending_plan_approval.request_id is required".to_string())? - .to_string(); - let source_member_id = pending - .get("source_member_id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "pending_plan_approval.source_member_id is required".to_string())? - .to_string(); - - let fixture_id = uuid::Uuid::new_v4(); - let root_session_id = format!("agent-org-plan-root-{fixture_id}"); - let source_session_id = format!("agent-org-plan-source-{fixture_id}"); - let workspace_path = - std::env::temp_dir().join(format!("orgii-agent-org-plan-e2e-{fixture_id}")); - std::fs::create_dir_all(&workspace_path).map_err(|err| err.to_string())?; - let workspace_path = workspace_path.to_string_lossy().into_owned(); - let now = chrono::Utc::now().to_rfc3339(); - - upsert_session(&UnifiedSessionRecord { - session_id: root_session_id.clone(), - name: "Agent Org plan E2E root".to_string(), - status: SessionStatus::Idle.as_str().to_string(), - session_type: session_type::GENERIC.to_string(), - workspace_path: Some(workspace_path.clone()), - agent_definition_id: Some("coord".to_string()), - org_member_id: Some(COORDINATOR_MEMBER_ID.to_string()), - created_at: now.clone(), - updated_at: now.clone(), - ..Default::default() - }) - .map_err(|err| err.to_string())?; - upsert_session(&UnifiedSessionRecord { - session_id: source_session_id.clone(), - name: "Agent Org plan E2E source".to_string(), - status: SessionStatus::Idle.as_str().to_string(), - session_type: session_type::ORG_MEMBER.to_string(), - workspace_path: Some(workspace_path), - agent_definition_id: Some("alice-agent".to_string()), - org_member_id: Some(source_member_id.clone()), - parent_session_id: Some(root_session_id.clone()), - created_at: now.clone(), - updated_at: now, - ..Default::default() - }) - .map_err(|err| err.to_string())?; - - let plan_path = AgentOrgPlanApprovalStore::managed_plan_path_for_session( - &source_session_id, - &format!("{fixture_id}.plan.md"), - )? - .to_string_lossy() - .into_owned(); - Some(AgentOrgPlanApprovalStore::create_pending( - CreateAgentOrgPlanApprovalParams { - request_id, - org_run_id: task.org_run_id.clone(), - source_task_id: task.id.clone(), - source_member_id, - source_session_id, - root_session_id, - policy: PlanApprovalPolicy::Coordinator, - plan_title: "Typed message E2E plan".to_string(), - plan_path, - plan_content: "# Typed message E2E plan\n\nRevise this plan.".to_string(), - }, - )?) - } else { - None - }; - Ok::<_, String>((task, approval)) - }) - .await - { - Err(join_err) => Json(serde_json::json!({ - "ok": false, - "error": format!("spawn_blocking join error: {join_err}"), - })), - Ok(Err(err)) => Json(serde_json::json!({ "ok": false, "error": err })), - Ok(Ok((_, approval))) => Json(serde_json::json!({ - "ok": true, - "id": id, - "plan_approval_id": approval.map(|value| value.approval_id), - })), - } + Json(serde_json::json!({ + "ok": false, + "error": "task_seed_disabled: execute the deterministic provider through the real Tool and CallContext path", + })) } - /// `POST /test/agent-org/tasks/list` /// /// Body: `{ "org_run_id": "run-..." }` diff --git a/src-tauri/src/api/agent/test/workspace.rs b/src-tauri/src/api/agent/test/workspace.rs index aa42b26eb2..7b3152d52a 100644 --- a/src-tauri/src/api/agent/test/workspace.rs +++ b/src-tauri/src/api/agent/test/workspace.rs @@ -697,6 +697,20 @@ pub async fn test_session_seed_compacted_history( /// hit the real LLM). /// `POST /agent/test/session/llm-history` — debug-only projection of the /// exact durable history returned by Rust agent `load_llm_history`. +fn projected_compact_boundary_count(messages: &[serde_json::Value]) -> usize { + messages + .iter() + .filter(|message| { + let content = message + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or(""); + content.starts_with("[Conversation summary —") + || content.starts_with("[Session Memory —") + }) + .count() +} + pub async fn test_session_llm_history( Json(body): Json, ) -> Json { @@ -729,18 +743,7 @@ pub async fn test_session_llm_history( .unwrap_or("") }) .collect(); - let compact_boundary_count = messages - .iter() - .filter(|message| { - let content = message - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or(""); - message.get("role").and_then(|value| value.as_str()) == Some("system") - && (content.starts_with("[Conversation summary —") - || content.starts_with("[Session Memory —")) - }) - .count(); + let compact_boundary_count = projected_compact_boundary_count(&messages); Json(serde_json::json!({ "ok": true, "sessionId": session_id, @@ -763,6 +766,28 @@ pub async fn test_session_llm_history( } } +#[cfg(test)] +mod llm_history_projection_tests { + use super::projected_compact_boundary_count; + + #[test] + fn compact_boundary_count_survives_role_normalization() { + let messages = vec![ + serde_json::json!({ + "role": "user", + "content": "[Conversation summary — 2 earlier messages compacted]\n\nsummary" + }), + serde_json::json!({ "role": "user", "content": "recent user" }), + serde_json::json!({ + "role": "system", + "content": "[Session Memory — 4 earlier messages compacted]\n\nsummary" + }), + ]; + + assert_eq!(projected_compact_boundary_count(&messages), 2); + } +} + /// `POST /agent/test/session/seed-raw-history` — seed un-compacted /// user/assistant rows so E2E can trigger auto-compaction through the real turn. pub async fn test_session_seed_raw_history( diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 4a61b1b91f..6d3012ac16 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -790,6 +790,7 @@ agent_core::definitions::commands::agent_orgs_list, agent_core::definitions::commands::agent_orgs_save_trusted_settings, agent_core::definitions::commands::agent_orgs_remove, agent_core::definitions::commands::agent_org_run_list, +agent_core::state::commands::session::debug::org_runtime::debug_agent_org_enable_redesign, agent_core::state::commands::session::debug::org_runtime::debug_session_org_runtime_snapshot, agent_core::state::commands::session::debug::org_runtime::debug_session_execute_tool, agent_core::state::commands::session::debug::org_runtime::debug_session_execute_org_tool, @@ -798,6 +799,9 @@ agent_core::state::commands::session::debug::org_runtime::debug_agent_org_emit_m agent_core::state::commands::session::debug::org_runtime::debug_agent_org_inbox_list, agent_core::state::commands::session::debug::org_runtime::debug_agent_org_tasks_list, agent_core::state::commands::session::org_tasks::agent_org_session_run_view, +agent_core::state::commands::session::org_tasks::agent_org_session_task_page, +agent_core::state::commands::session::org_tasks::agent_org_session_task_detail, +agent_core::state::commands::session::org_tasks::agent_org_session_task_annotation_page, agent_core::state::commands::session::org_tasks::agent_org_group_chat_history_page, agent_core::state::commands::session::org_tasks::agent_org_plan_approval_detail, agent_core::state::commands::session::org_tasks::agent_org_plan_approval_respond, diff --git a/src/api/tauri/agent/orgTasks.test.ts b/src/api/tauri/agent/orgTasks.test.ts new file mode 100644 index 0000000000..a8a7433fde --- /dev/null +++ b/src/api/tauri/agent/orgTasks.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { + AGENT_ORG_TASK_STATUS, + agentOrgTaskStatusSatisfiesDependency, + isAgentOrgTaskOpenStatus, + isAgentOrgTaskTerminalStatus, +} from "./orgTasks"; + +describe("Agent Org Task status semantics", () => { + it.each([ + [AGENT_ORG_TASK_STATUS.PENDING, true, false, false], + [AGENT_ORG_TASK_STATUS.IN_PROGRESS, true, false, false], + [AGENT_ORG_TASK_STATUS.COMPLETED, false, true, true], + [AGENT_ORG_TASK_STATUS.FAILED, false, true, false], + [AGENT_ORG_TASK_STATUS.CANCELLED, false, true, false], + ] as const)( + "keeps open, terminal, and dependency semantics distinct for %s", + (status, open, terminal, satisfiesDependency) => { + expect(isAgentOrgTaskOpenStatus(status)).toBe(open); + expect(isAgentOrgTaskTerminalStatus(status)).toBe(terminal); + expect(agentOrgTaskStatusSatisfiesDependency(status)).toBe( + satisfiesDependency + ); + } + ); +}); diff --git a/src/api/tauri/agent/orgTasks.ts b/src/api/tauri/agent/orgTasks.ts index 8d2b5e3c91..149733e11b 100644 --- a/src/api/tauri/agent/orgTasks.ts +++ b/src/api/tauri/agent/orgTasks.ts @@ -6,11 +6,36 @@ export const AGENT_ORG_TASK_STATUS = { PENDING: "pending", IN_PROGRESS: "in_progress", COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", } as const; export type AgentOrgTaskStatus = (typeof AGENT_ORG_TASK_STATUS)[keyof typeof AGENT_ORG_TASK_STATUS]; +export function isAgentOrgTaskTerminalStatus( + status: AgentOrgTaskStatus +): boolean { + return ( + status === AGENT_ORG_TASK_STATUS.COMPLETED || + status === AGENT_ORG_TASK_STATUS.FAILED || + status === AGENT_ORG_TASK_STATUS.CANCELLED + ); +} + +export function isAgentOrgTaskOpenStatus(status: AgentOrgTaskStatus): boolean { + return ( + status === AGENT_ORG_TASK_STATUS.PENDING || + status === AGENT_ORG_TASK_STATUS.IN_PROGRESS + ); +} + +export function agentOrgTaskStatusSatisfiesDependency( + status: AgentOrgTaskStatus +): boolean { + return status === AGENT_ORG_TASK_STATUS.COMPLETED; +} + export interface AgentOrgMemberIntervention { orgRunId: string; memberId: string; @@ -122,6 +147,8 @@ export interface AgentOrgRunTaskOverview { pending: number; inProgress: number; completed: number; + failed: number; + cancelled: number; corrupt: number; visible: number; truncated: boolean; @@ -135,6 +162,7 @@ export interface AgentOrgPlanApprovalSummary { sourceTaskId: string; sourceMemberId: string; sourceSessionId: string; + sourceTurnIntentId: string; rootSessionId: string; policy: "coordinator" | "user" | "automatic"; status: @@ -156,6 +184,7 @@ export interface AgentOrgPlanApproval { sourceTaskId: string; sourceMemberId: string; sourceSessionId: string; + sourceTurnIntentId: string; rootSessionId: string; policy: "coordinator" | "user" | "automatic"; status: @@ -228,12 +257,74 @@ export interface AgentOrgTask { blockedBy: string[]; /** True when the polling/list projection carries only a prefix. */ blockedByTruncated?: boolean; + /** Backend-authoritative readiness for bounded Current/History pages. */ + dependenciesSatisfied?: boolean; metadata?: unknown; executionMode: "build" | "plan"; + output?: AgentOrgTaskOutput | null; + outputSummary?: AgentOrgTaskOutputSummary | null; + failureReason?: AgentOrgTaskTerminalReason | null; + cancelReason?: AgentOrgTaskTerminalReason | null; + createdByParticipantId?: string; + sourceTurnIntentId?: string; + originatingMessageId?: string | null; + replacesTaskId?: string | null; createdAt: string; updatedAt: string; } +export interface AgentOrgTaskOutput { + summary: string; + content?: string | null; + artifactIds: string[]; + producedByMemberId: string; + producedAt: string; +} + +export interface AgentOrgTaskOutputSummary { + summary: string; + artifactIds: string[]; + artifactIdsTruncated: boolean; + producedByMemberId?: string | null; + producedAt?: string | null; + hasContent: boolean; +} + +export interface AgentOrgTaskTerminalReason { + code: string; + message: string; +} + +export type AgentOrgTaskPageBucket = "current" | "history"; +export type AgentOrgTaskPageDirection = "forward" | "backward"; + +export interface AgentOrgTaskPage { + bucket: AgentOrgTaskPageBucket; + status?: AgentOrgTaskStatus | null; + tasks: AgentOrgTask[]; + hasMore: boolean; + nextCursor?: string | null; + previousCursor?: string | null; +} + +export interface AgentOrgTaskAnnotation { + id: string; + orgRunId: string; + taskId: string; + kind: "progress" | "evidence" | "audit_note"; + body: string; + actorKind: string; + actorParticipantId: string; + sourceTurnIntentId?: string | null; + createdAt: string; +} + +export interface AgentOrgTaskAnnotationPage { + annotations: AgentOrgTaskAnnotation[]; + hasMore: boolean; + nextCursor?: string | null; +} + export interface AgentOrgInboxPreviewRow { id: number; recipientAgentId: string; @@ -281,6 +372,48 @@ export async function getAgentOrgSessionRunView( }); } +export async function getAgentOrgTaskPage(input: { + sessionId: string; + bucket: AgentOrgTaskPageBucket; + status?: AgentOrgTaskStatus | null; + cursor?: string | null; + direction?: AgentOrgTaskPageDirection; + limit?: number; +}): Promise { + return invokeTauri("agent_org_session_task_page", { + sessionId: input.sessionId, + bucket: input.bucket, + status: input.status ?? null, + cursor: input.cursor ?? null, + direction: input.direction ?? "forward", + limit: input.limit ?? 50, + }); +} + +export async function getAgentOrgTaskDetail(input: { + sessionId: string; + taskId: string; +}): Promise { + return invokeTauri("agent_org_session_task_detail", input); +} + +export async function getAgentOrgTaskAnnotationPage(input: { + sessionId: string; + taskId: string; + cursor?: string | null; + limit?: number; +}): Promise { + return invokeTauri( + "agent_org_session_task_annotation_page", + { + sessionId: input.sessionId, + taskId: input.taskId, + cursor: input.cursor ?? null, + limit: input.limit ?? 50, + } + ); +} + export async function getAgentOrgGroupChatHistoryPage(input: { sessionId: string; beforeId?: number | null; diff --git a/src/app/root/AppBootstrap.tsx b/src/app/root/AppBootstrap.tsx index 87389ee779..871b077a04 100644 --- a/src/app/root/AppBootstrap.tsx +++ b/src/app/root/AppBootstrap.tsx @@ -77,7 +77,8 @@ export const AppBootstrap: FC = () => { - {process.env.NODE_ENV !== "production" && } + {(process.env.NODE_ENV !== "production" || + process.env.ORGII_E2E === "1") && } diff --git a/src/app/root/E2EBootstrap.tsx b/src/app/root/E2EBootstrap.tsx index 5ed53ae01e..43f1358e1b 100644 --- a/src/app/root/E2EBootstrap.tsx +++ b/src/app/root/E2EBootstrap.tsx @@ -84,10 +84,15 @@ export const E2EBootstrap: FC = () => { useEffect(() => { if (typeof window === "undefined") return; - // Defense-in-depth: callers should already gate this component behind - // `process.env.NODE_ENV !== "production"` in AppBootstrap, but refuse to - // install `window.__e2e` here too so a missed gate cannot leak the helper. - if (process.env.NODE_ENV === "production") return; + // Defense-in-depth: optimized WebDriver builds explicitly opt in through + // a compile-time constant. Ordinary production bundles still refuse to + // install the helper and tree-shake this component away. + if ( + process.env.NODE_ENV === "production" && + process.env.ORGII_E2E !== "1" + ) { + return; + } const { pinSession, diff --git a/src/app/root/e2e/helpers/agentOrgs.ts b/src/app/root/e2e/helpers/agentOrgs.ts index 54fb1642bd..1f5023d470 100644 --- a/src/app/root/e2e/helpers/agentOrgs.ts +++ b/src/app/root/e2e/helpers/agentOrgs.ts @@ -8,6 +8,7 @@ type AgentOrgE2EHelpers = Pick< E2EHelpers, | "listAgentOrgs" | "removeAgentOrg" + | "debugAgentOrgEnableRedesign" | "debugSessionOrgRuntimeSnapshot" | "debugSessionExecuteTool" | "debugSessionExecuteOrgTool" @@ -30,6 +31,17 @@ type AgentOrgE2EHelpers = Pick< >; export function createAgentOrgHelpers(): AgentOrgE2EHelpers { + const debugAgentOrgEnableRedesign = async (): Promise< + Result<{ enabled: true }> + > => { + try { + await invoke("debug_agent_org_enable_redesign"); + return { ok: true, enabled: true }; + } catch (err) { + return asError(err); + } + }; + const listAgentOrgs = async (): Promise> => { try { const orgs = (await invoke("agent_orgs_list")) as Json[]; @@ -535,6 +547,7 @@ export function createAgentOrgHelpers(): AgentOrgE2EHelpers { }; return { + debugAgentOrgEnableRedesign, listAgentOrgs, removeAgentOrg, debugSessionOrgRuntimeSnapshot, diff --git a/src/app/root/e2e/types.ts b/src/app/root/e2e/types.ts index 2bb1c7758b..86a40514c6 100644 --- a/src/app/root/e2e/types.ts +++ b/src/app/root/e2e/types.ts @@ -495,6 +495,7 @@ export interface E2EHelpers { debugSessionOrgRuntimeSnapshot: ( sessionId: string ) => Promise>; + debugAgentOrgEnableRedesign: () => Promise>; debugSessionExecuteTool: ( sessionId: string, toolName: string, diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx index 9e991e36dc..efc783d0bf 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx @@ -1,10 +1,14 @@ import { useSetAtom } from "jotai"; -import React, { memo, useCallback, useState } from "react"; +import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AGENT_ORG_RUN_PHASE, + AGENT_ORG_TASK_STATUS, type AgentOrgRunView, + type AgentOrgTaskPage, + type AgentOrgTaskStatus, + getAgentOrgTaskPage, pauseAgentOrgRun, resumeAgentOrgRun, } from "@src/api/tauri/agent"; @@ -12,6 +16,8 @@ import Button from "@src/components/Button"; import { createLogger } from "@src/hooks/logger"; import { useRefreshSpin } from "@src/hooks/ui"; import { + ArrowDown01Icon, + ArrowRight01Icon, CancelCircleIcon, CheckmarkCircle01Icon, HierarchyCircle01Icon, @@ -51,6 +57,30 @@ const AgentOrgOverviewPanel: React.FC = memo( const { t } = useTranslation("sessions"); const [expanded, setExpanded] = useState(true); const [isTogglingPause, setIsTogglingPause] = useState(false); + const [historyExpanded, setHistoryExpanded] = useState(false); + const [historyStatus, setHistoryStatus] = useState( + AGENT_ORG_TASK_STATUS.COMPLETED + ); + const [historyPage, setHistoryPage] = useState( + null + ); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(false); + const historyRequestIdRef = useRef(0); + const currentSessionIdRef = useRef(currentSessionId); + const currentRunId = view?.context.runId ?? null; + const currentRunIdRef = useRef(currentRunId); + currentSessionIdRef.current = currentSessionId; + currentRunIdRef.current = currentRunId; + + useEffect(() => { + historyRequestIdRef.current += 1; + setHistoryExpanded(false); + setHistoryStatus(AGENT_ORG_TASK_STATUS.COMPLETED); + setHistoryPage(null); + setHistoryLoading(false); + setHistoryError(false); + }, [currentRunId, currentSessionId]); const handleRefresh = useCallback(() => onRefresh(), [onRefresh]); const { spinClass, handleClick: handleRefreshClick } = useRefreshSpin( handleRefresh, @@ -58,6 +88,80 @@ const AgentOrgOverviewPanel: React.FC = memo( ); const setActiveSessionId = useSetAtom(activeSessionIdAtom); + const loadHistoryPage = useCallback( + async ( + status: AgentOrgTaskStatus, + cursor?: string | null, + direction: "forward" | "backward" = "forward" + ) => { + const sessionId = currentSessionId; + const runId = currentRunId; + const requestId = ++historyRequestIdRef.current; + setHistoryLoading(true); + setHistoryError(false); + setHistoryPage(null); + try { + const page = await getAgentOrgTaskPage({ + sessionId, + bucket: "history", + status, + cursor, + direction, + }); + if ( + historyRequestIdRef.current === requestId && + currentSessionIdRef.current === sessionId && + currentRunIdRef.current === runId + ) { + setHistoryPage(page); + } + } catch (historyLoadError) { + logger.error( + "Failed to load Agent Team Task history:", + historyLoadError + ); + if ( + historyRequestIdRef.current === requestId && + currentSessionIdRef.current === sessionId && + currentRunIdRef.current === runId + ) { + setHistoryError(true); + } + } finally { + if ( + historyRequestIdRef.current === requestId && + currentSessionIdRef.current === sessionId && + currentRunIdRef.current === runId + ) { + setHistoryLoading(false); + } + } + }, + [currentRunId, currentSessionId] + ); + + const handleHistoryToggle = useCallback(() => { + const next = !historyExpanded; + setHistoryExpanded(next); + if (next && historyPage === null && !historyLoading) { + void loadHistoryPage(historyStatus); + } + }, [ + historyExpanded, + historyLoading, + historyPage, + historyStatus, + loadHistoryPage, + ]); + + const handleHistoryStatus = useCallback( + (status: AgentOrgTaskStatus) => { + setHistoryStatus(status); + void loadHistoryPage(status); + }, + [loadHistoryPage] + ); + const isRunning = view?.runStatus === "running"; const isPaused = view?.runStatus === "paused"; const runPhaseLabel = view @@ -354,58 +458,167 @@ const AgentOrgOverviewPanel: React.FC = memo( ) : null} - {view.tasks.length > 0 && ( -
-
- - - {t("planner.agentOrgTasks.title")} - -
+
+
+ + + {t("planner.agentOrgTasks.currentWork")} + +
+ {view.tasks.length > 0 ? ( - {view.taskOverview.truncated && ( + ) : ( +
+ {t("planner.agentOrgTasks.currentEmpty")} +
+ )} + {view.taskOverview.truncated && ( +
+ {t("planner.agentOrgOverview.taskWindowTruncated", { + visible: view.taskOverview.visible, + total: + view.taskOverview.pending + view.taskOverview.inProgress, + })} +
+ )} +
+ +
+ + + {historyExpanded && ( +
- {t("planner.agentOrgOverview.taskWindowTruncated", { - visible: view.taskOverview.visible, - total: view.taskOverview.total, - })} + {( + [ + AGENT_ORG_TASK_STATUS.COMPLETED, + AGENT_ORG_TASK_STATUS.FAILED, + AGENT_ORG_TASK_STATUS.CANCELLED, + ] as const + ).map((status) => ( + + ))}
- )} -
- )} + {historyLoading && ( +
+ {t("planner.agentOrgTasks.loadingHistory")} +
+ )} + {historyError && ( +
+ {t("planner.agentOrgTasks.historyLoadFailed")} +
+ )} + {!historyLoading && historyPage?.tasks.length === 0 && ( +
+ {t("planner.agentOrgTasks.historyEmpty")} +
+ )} + {historyPage && historyPage.tasks.length > 0 && ( + + )} + {historyPage && + (historyPage.previousCursor || historyPage.nextCursor) && ( +
+ + +
+ )} +
+ )} +
)} diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskList.tsx b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskList.tsx index ff1dab6c5a..df71c9c446 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskList.tsx +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskList.tsx @@ -1,12 +1,19 @@ -import React, { memo, useState } from "react"; +import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AGENT_ORG_TASK_STATUS, type AgentOrgTask, + type AgentOrgTaskAnnotationPage, type AgentOrgTaskStatus, + agentOrgTaskStatusSatisfiesDependency, + getAgentOrgTaskAnnotationPage, + getAgentOrgTaskDetail, + isAgentOrgTaskTerminalStatus, } from "@src/api/tauri/agent"; import { + ArrowDown01Icon, + ArrowRight01Icon, BubbleChatIcon, ChevronsDownUpIcon, HugeiconsIcon, @@ -43,7 +50,11 @@ function TaskStatusChip({ blocked: boolean; label: string; }) { - if (blocked && status !== AGENT_ORG_TASK_STATUS.COMPLETED) { + if ( + blocked && + (status === AGENT_ORG_TASK_STATUS.PENDING || + status === AGENT_ORG_TASK_STATUS.IN_PROGRESS) + ) { return ( + {label} + + ); + } + + if (status === AGENT_ORG_TASK_STATUS.CANCELLED) { + return ( + + {label} + + ); + } + return ( ) { + if (task.dependenciesSatisfied !== undefined) { + return !task.dependenciesSatisfied; + } return task.blockedBy.some((taskId) => { const blocker = tasksById.get(taskId); - return blocker?.status !== AGENT_ORG_TASK_STATUS.COMPLETED; + return ( + blocker === undefined || + !agentOrgTaskStatusSatisfiesDependency(blocker.status) + ); }); } +function retainVisibleRecords( + records: Record, + visibleTaskIds: Set +): Record { + const entries = Object.entries(records).filter(([taskId]) => + visibleTaskIds.has(taskId) + ); + return entries.length === Object.keys(records).length + ? records + : Object.fromEntries(entries); +} + function AgentOrgTaskSubject({ task, done, @@ -159,7 +220,7 @@ function AgentOrgTaskSubject({ return ( + )} + {expandedTaskId === task.id && terminal && ( +
+ {loadingTaskId === task.id && ( +
+ {t("planner.agentOrgTasks.loadingDetails")} +
+ )} + {detailErrorTaskId === task.id && ( +
+ {t("planner.agentOrgTasks.detailLoadFailed")} +
+ )} + {(details[task.id]?.output ?? task.outputSummary) && ( +
+
+ {t("planner.agentOrgTasks.output")} +
+ {details[task.id]?.output?.summary ?? + task.outputSummary?.summary} + {details[task.id]?.output?.content && ( +
+ {details[task.id].output?.content} +
+ )} +
+ )} + {(details[task.id]?.failureReason ?? task.failureReason) && ( +
+ { + (details[task.id]?.failureReason ?? task.failureReason) + ?.message + } +
+ )} + {(details[task.id]?.cancelReason ?? task.cancelReason) && ( +
+ { + (details[task.id]?.cancelReason ?? task.cancelReason) + ?.message + } +
+ )} + {(annotationPages[task.id]?.annotations.length ?? 0) > 0 && ( +
+
+ {t("planner.agentOrgTasks.annotations")} +
+ {annotationPages[task.id].annotations.map( + (annotation) => ( +
+ + {annotation.kind.replace("_", " ")} + + {annotation.body} +
+ ) + )} + {annotationPages[task.id].hasMore && ( + + )} +
+ )} +
+ )} ); })} diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts new file mode 100644 index 0000000000..52a7830587 --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts @@ -0,0 +1,549 @@ +// @vitest-environment jsdom +import React, { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { + AgentOrgRunView, + AgentOrgTask, + AgentOrgTaskAnnotationPage, + AgentOrgTaskStatus, +} from "@src/api/tauri/agent"; + +import AgentOrgOverviewPanel from "./AgentOrgOverviewPanel"; +import { AgentOrgTaskList } from "./AgentOrgTaskList"; + +const mocks = vi.hoisted(() => ({ + getPage: vi.fn(), + getDetail: vi.fn(), + getAnnotations: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("jotai", () => ({ useSetAtom: () => vi.fn() })); +vi.mock("@src/store/session", () => ({ activeSessionIdAtom: {} })); +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ error: vi.fn() }), +})); +vi.mock("@src/hooks/ui", () => ({ + useRefreshSpin: (callback: () => Promise) => ({ + spinClass: "", + handleClick: callback, + }), +})); + +vi.mock("@src/api/tauri/agent", () => ({ + AGENT_ORG_RUN_PHASE: { + COORDINATING: "coordinating", + FINALIZING: "finalizing", + }, + AGENT_ORG_TASK_STATUS: { + PENDING: "pending", + IN_PROGRESS: "in_progress", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", + }, + isAgentOrgTaskTerminalStatus: (status: string) => + status === "completed" || status === "failed" || status === "cancelled", + agentOrgTaskStatusSatisfiesDependency: (status: string) => + status === "completed", + getAgentOrgTaskPage: mocks.getPage, + getAgentOrgTaskDetail: mocks.getDetail, + getAgentOrgTaskAnnotationPage: mocks.getAnnotations, + pauseAgentOrgRun: mocks.pause, + resumeAgentOrgRun: mocks.resume, +})); + +vi.mock("@src/components/Button", () => ({ + default: (props: Record) => + createElement( + "button", + { + type: "button", + disabled: props.disabled as boolean | undefined, + onClick: props.onClick as (() => void) | undefined, + "aria-label": props["aria-label"] as string | undefined, + "data-testid": props["data-testid"] as string | undefined, + }, + props.children as React.ReactNode + ), +})); + +vi.mock("./ComposerStackHeader", () => ({ + default: ({ + label, + onToggle, + actions, + badges, + }: { + label: string; + onToggle: () => void; + actions?: React.ReactNode; + badges?: React.ReactNode; + }) => + createElement( + "div", + null, + createElement("button", { type: "button", onClick: onToggle }, label), + badges, + actions + ), + ComposerStackHeaderCountBadge: ({ + children, + }: { + children: React.ReactNode; + }) => createElement("span", null, children), +})); + +vi.mock("./AgentOrgPlanApprovalCard", () => ({ default: () => null })); + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +function task(id: string, status: AgentOrgTaskStatus): AgentOrgTask { + return { + id, + orgRunId: "run-pr4", + subject: `Task ${id}`, + description: `Description ${id}`, + owner: "member-a", + status, + blocks: [], + blockedBy: [], + executionMode: "build", + createdAt: "2026-08-20T00:00:00Z", + updatedAt: "2026-08-20T00:01:00Z", + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function runView(): AgentOrgRunView { + return { + context: { + runId: "run-pr4", + orgId: "org-pr4", + orgName: "PR4 Team", + orgRole: "lead", + coordinatorAgentId: "coordinator-agent", + coordinatorName: "Coordinator", + coordinatorRole: "lead", + members: [], + planApprovalPolicy: "coordinator", + rootSessionId: "root-session", + }, + runStatus: "running", + runPhase: "coordinating", + members: [], + tasks: [task("pending", "pending"), task("active", "in_progress")], + taskOverview: { + total: 5, + pending: 1, + inProgress: 1, + completed: 1, + failed: 1, + cancelled: 1, + corrupt: 0, + visible: 2, + truncated: false, + }, + inbox: [], + unreadInboxCount: 0, + pendingPlanApprovals: [], + }; +} + +describe("Agent Org PR4 Task panel", () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + mocks.getPage.mockReset(); + mocks.getDetail.mockReset(); + mocks.getAnnotations.mockReset(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("does not read History until expansion and requests one filtered page at a time", async () => { + mocks.getPage + .mockResolvedValueOnce({ + bucket: "history", + status: "completed", + tasks: [task("done", "completed")], + hasMore: false, + }) + .mockResolvedValueOnce({ + bucket: "history", + status: "failed", + tasks: [ + { + ...task("failed", "failed"), + failureReason: { + code: "execution.failed", + message: "provider failed", + }, + }, + ], + hasMore: false, + }); + + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view: runView(), + error: null, + currentSessionId: "root-session", + onRefresh: vi.fn().mockResolvedValue(undefined), + }) + ); + }); + expect(mocks.getPage).not.toHaveBeenCalled(); + expect( + container.querySelectorAll('[data-testid="agent-org-overview-task-row"]') + ).toHaveLength(2); + + await act(async () => { + container + .querySelector( + '[data-testid="agent-org-task-history-toggle"]' + ) + ?.click(); + }); + expect(mocks.getPage).toHaveBeenNthCalledWith(1, { + sessionId: "root-session", + bucket: "history", + status: "completed", + cursor: undefined, + direction: "forward", + }); + expect( + container.querySelector( + '[data-task-id="done"][data-task-status="completed"]' + ) + ).not.toBeNull(); + + await act(async () => { + container + .querySelector( + '[data-testid="agent-org-task-history-filter-failed"]' + ) + ?.click(); + }); + expect(mocks.getPage).toHaveBeenCalledTimes(2); + expect(mocks.getPage).toHaveBeenLastCalledWith({ + sessionId: "root-session", + bucket: "history", + status: "failed", + cursor: undefined, + direction: "forward", + }); + }); + + it("discards a late History response after switching teams", async () => { + const oldPage = deferred<{ + bucket: "history"; + status: "completed"; + tasks: AgentOrgTask[]; + hasMore: boolean; + }>(); + mocks.getPage.mockReturnValueOnce(oldPage.promise).mockResolvedValueOnce({ + bucket: "history", + status: "completed", + tasks: [task("new-team-done", "completed")], + hasMore: false, + }); + + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view: runView(), + error: null, + currentSessionId: "old-session", + onRefresh: vi.fn().mockResolvedValue(undefined), + }) + ); + }); + await act(async () => { + container + .querySelector( + '[data-testid="agent-org-task-history-toggle"]' + ) + ?.click(); + }); + + await act(async () => { + root.render( + createElement(AgentOrgOverviewPanel, { + view: runView(), + error: null, + currentSessionId: "new-session", + onRefresh: vi.fn().mockResolvedValue(undefined), + }) + ); + }); + await act(async () => { + oldPage.resolve({ + bucket: "history", + status: "completed", + tasks: [task("old-team-done", "completed")], + hasMore: false, + }); + await oldPage.promise; + }); + expect( + container.querySelector('[data-task-id="old-team-done"]') + ).toBeNull(); + + await act(async () => { + container + .querySelector( + '[data-testid="agent-org-task-history-toggle"]' + ) + ?.click(); + }); + expect(mocks.getPage).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: "new-session" }) + ); + expect( + container.querySelector('[data-task-id="new-team-done"]') + ).not.toBeNull(); + }); + + it("renders all five states and loads output plus annotations only on detail expansion", async () => { + const completed = { + ...task("done", "completed"), + outputSummary: { + summary: "bounded summary", + artifactIds: [], + artifactIdsTruncated: false, + hasContent: true, + }, + }; + mocks.getDetail.mockResolvedValue({ + ...completed, + output: { + summary: "bounded summary", + content: "full output body", + artifactIds: [], + producedByMemberId: "member-a", + producedAt: "2026-08-20T00:01:00Z", + }, + }); + mocks.getAnnotations + .mockResolvedValueOnce({ + annotations: [ + { + id: "note-1", + orgRunId: "run-pr4", + taskId: "done", + kind: "audit_note", + body: "reviewed after completion", + actorKind: "graph_writer", + actorParticipantId: "coordinator", + createdAt: "2026-08-20T00:02:00Z", + }, + ], + hasMore: true, + nextCursor: "note-cursor-1", + }) + .mockResolvedValueOnce({ + annotations: [ + { + id: "note-2", + orgRunId: "run-pr4", + taskId: "done", + kind: "evidence", + body: "second annotation page", + actorKind: "owner_execution", + actorParticipantId: "member-a", + createdAt: "2026-08-20T00:03:00Z", + }, + ], + hasMore: false, + }); + + await act(async () => { + root.render( + createElement(AgentOrgTaskList, { + tasks: [ + { + ...task("pending", "pending"), + blockedBy: ["completed-outside-page"], + dependenciesSatisfied: true, + }, + task("active", "in_progress"), + completed, + { + ...task("failed", "failed"), + failureReason: { + code: "execution.failed", + message: "failed reason", + }, + }, + { + ...task("cancelled", "cancelled"), + cancelReason: { code: "scope.changed", message: "cancel reason" }, + }, + ], + listTestId: "task-list", + rowTestId: "task-row", + currentSessionId: "root-session", + }) + ); + }); + expect( + Array.from(container.querySelectorAll('[data-testid="task-row"]')).map( + (row) => row.getAttribute("data-task-status") + ) + ).toEqual(["pending", "in_progress", "completed", "failed", "cancelled"]); + expect( + container + .querySelector('[data-task-id="pending"]') + ?.getAttribute("data-task-blocked") + ).toBe("false"); + expect(mocks.getDetail).not.toHaveBeenCalled(); + expect(mocks.getAnnotations).not.toHaveBeenCalled(); + + const doneRow = container.querySelector( + '[data-task-id="done"]' + ); + await act(async () => { + doneRow + ?.querySelector( + '[data-testid="agent-org-task-detail-toggle"]' + ) + ?.click(); + }); + expect(mocks.getDetail).toHaveBeenCalledTimes(1); + expect(mocks.getAnnotations).toHaveBeenCalledTimes(1); + expect(doneRow?.textContent).toContain("full output body"); + expect(doneRow?.textContent).toContain("reviewed after completion"); + + await act(async () => { + doneRow + ?.querySelector( + '[data-testid="agent-org-task-annotations-load-more"]' + ) + ?.click(); + }); + expect(mocks.getAnnotations).toHaveBeenLastCalledWith({ + sessionId: "root-session", + taskId: "done", + cursor: "note-cursor-1", + }); + expect(doneRow?.textContent).toContain("second annotation page"); + }); + + it("discards late detail and annotation responses after switching teams", async () => { + const oldDetail = deferred(); + const oldAnnotations = deferred(); + const completed = task("shared-task-id", "completed"); + mocks.getDetail + .mockReturnValueOnce(oldDetail.promise) + .mockResolvedValueOnce({ + ...completed, + output: { + summary: "new team output", + artifactIds: [], + producedByMemberId: "member-a", + producedAt: "2026-08-20T00:04:00Z", + }, + }); + mocks.getAnnotations + .mockReturnValueOnce(oldAnnotations.promise) + .mockResolvedValueOnce({ annotations: [], hasMore: false }); + + await act(async () => { + root.render( + createElement(AgentOrgTaskList, { + tasks: [completed], + listTestId: "task-list", + rowTestId: "task-row", + currentSessionId: "old-session", + }) + ); + }); + await act(async () => { + container + .querySelector( + '[data-testid="agent-org-task-detail-toggle"]' + ) + ?.click(); + }); + + await act(async () => { + root.render( + createElement(AgentOrgTaskList, { + tasks: [completed], + listTestId: "task-list", + rowTestId: "task-row", + currentSessionId: "new-session", + }) + ); + }); + await act(async () => { + oldDetail.resolve({ + ...completed, + output: { + summary: "old team output", + artifactIds: [], + producedByMemberId: "member-a", + producedAt: "2026-08-20T00:02:00Z", + }, + }); + oldAnnotations.resolve({ annotations: [], hasMore: false }); + await Promise.all([oldDetail.promise, oldAnnotations.promise]); + }); + expect(container.textContent).not.toContain("old team output"); + + await act(async () => { + container + .querySelector( + '[data-testid="agent-org-task-detail-toggle"]' + ) + ?.click(); + }); + expect(mocks.getDetail).toHaveBeenLastCalledWith({ + sessionId: "new-session", + taskId: "shared-task-id", + }); + expect(container.textContent).toContain("new team output"); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/useAgentOrgIntervention.test.ts b/src/engines/ChatPanel/InputArea/components/useAgentOrgIntervention.test.ts index fa3a7add09..bcb415c02e 100644 --- a/src/engines/ChatPanel/InputArea/components/useAgentOrgIntervention.test.ts +++ b/src/engines/ChatPanel/InputArea/components/useAgentOrgIntervention.test.ts @@ -77,6 +77,8 @@ function runView(): AgentOrgRunView { pending: 0, inProgress: 0, completed: 0, + failed: 0, + cancelled: 0, corrupt: 0, visible: 0, truncated: false, diff --git a/src/engines/ChatPanel/InputArea/components/useAgentOrgPlanApprovalDetail.test.ts b/src/engines/ChatPanel/InputArea/components/useAgentOrgPlanApprovalDetail.test.ts index 7c79b505cc..e55b94ea71 100644 --- a/src/engines/ChatPanel/InputArea/components/useAgentOrgPlanApprovalDetail.test.ts +++ b/src/engines/ChatPanel/InputArea/components/useAgentOrgPlanApprovalDetail.test.ts @@ -23,6 +23,7 @@ function approvalSummary(): AgentOrgPlanApprovalSummary { sourceTaskId: "task-1", sourceMemberId: "planner", sourceSessionId: "planner-session", + sourceTurnIntentId: "planner-turn", rootSessionId: "root-session", policy: "user", status: "pending", diff --git a/src/engines/ChatPanel/InputArea/components/useAgentOrgRunView.test.ts b/src/engines/ChatPanel/InputArea/components/useAgentOrgRunView.test.ts index 4f6cdb570d..53aa880670 100644 --- a/src/engines/ChatPanel/InputArea/components/useAgentOrgRunView.test.ts +++ b/src/engines/ChatPanel/InputArea/components/useAgentOrgRunView.test.ts @@ -86,6 +86,8 @@ function runView(): AgentOrgRunView { pending: 0, inProgress: 0, completed: 0, + failed: 0, + cancelled: 0, corrupt: 0, visible: 0, truncated: false, diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index 4919f74bf3..87d885b4af 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -2417,7 +2417,24 @@ "statusPending": "Ausstehend", "statusInProgress": "In Arbeit", "statusCompleted": "Erledigt", - "statusBlocked": "Blockiert" + "statusBlocked": "Blockiert", + "statusFailed": "Fehlgeschlagen", + "statusCancelled": "Abgebrochen", + "currentWork": "Aktuelle Arbeit", + "currentEmpty": "Keine aktuelle Arbeit", + "history": "Verlauf", + "historyFilter": "Aufgabenverlauf filtern", + "historyEmpty": "Keine Aufgaben für diesen Filter", + "loadingHistory": "Verlauf wird geladen…", + "historyLoadFailed": "Verlauf konnte nicht geladen werden", + "previousPage": "Zurück", + "nextPage": "Weiter", + "details": "Details", + "loadingDetails": "Details werden geladen…", + "detailLoadFailed": "Details konnten nicht geladen werden", + "output": "Ergebnis", + "annotations": "Fortschritt und Prüfnotizen", + "loadMoreAnnotations": "Mehr laden" }, "agentOrgOverview": { "title": "Agent-Team-Übersicht", diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 0c0a05739e..612fdca22c 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -2523,7 +2523,24 @@ "statusPending": "Pending", "statusInProgress": "In progress", "statusCompleted": "Done", - "statusBlocked": "Blocked" + "statusBlocked": "Blocked", + "statusFailed": "Failed", + "statusCancelled": "Cancelled", + "currentWork": "Current work", + "currentEmpty": "No current work", + "history": "History", + "historyFilter": "Filter task history", + "historyEmpty": "No tasks in this history filter", + "loadingHistory": "Loading history…", + "historyLoadFailed": "History failed to load", + "previousPage": "Previous", + "nextPage": "Next", + "details": "Details", + "loadingDetails": "Loading details…", + "detailLoadFailed": "Task details failed to load", + "output": "Output", + "annotations": "Progress and audit notes", + "loadMoreAnnotations": "Load more" }, "agentOrgOverview": { "title": "Agent Team overview", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index d2470b7325..12f035f1d2 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -2419,7 +2419,24 @@ "statusPending": "Pendiente", "statusInProgress": "En curso", "statusCompleted": "Completado", - "statusBlocked": "Bloqueado" + "statusBlocked": "Bloqueado", + "statusFailed": "Fallido", + "statusCancelled": "Cancelado", + "currentWork": "Trabajo actual", + "currentEmpty": "No hay trabajo actual", + "history": "Historial", + "historyFilter": "Filtrar historial de tareas", + "historyEmpty": "No hay tareas con este filtro", + "loadingHistory": "Cargando historial…", + "historyLoadFailed": "No se pudo cargar el historial", + "previousPage": "Anterior", + "nextPage": "Siguiente", + "details": "Detalles", + "loadingDetails": "Cargando detalles…", + "detailLoadFailed": "No se pudieron cargar los detalles", + "output": "Resultado", + "annotations": "Progreso y notas de auditoría", + "loadMoreAnnotations": "Cargar más" }, "agentOrgOverview": { "title": "Vista general del Equipo Agent", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index f6b276b15e..c51d080bce 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -2419,7 +2419,24 @@ "statusPending": "En attente", "statusInProgress": "En cours", "statusCompleted": "Terminé", - "statusBlocked": "Bloqué" + "statusBlocked": "Bloqué", + "statusFailed": "Échoué", + "statusCancelled": "Annulé", + "currentWork": "Travail en cours", + "currentEmpty": "Aucun travail en cours", + "history": "Historique", + "historyFilter": "Filtrer l’historique des tâches", + "historyEmpty": "Aucune tâche pour ce filtre", + "loadingHistory": "Chargement de l’historique…", + "historyLoadFailed": "Échec du chargement de l’historique", + "previousPage": "Précédent", + "nextPage": "Suivant", + "details": "Détails", + "loadingDetails": "Chargement des détails…", + "detailLoadFailed": "Échec du chargement des détails", + "output": "Résultat", + "annotations": "Progression et notes d’audit", + "loadMoreAnnotations": "Afficher plus" }, "agentOrgOverview": { "title": "Vue d’ensemble de l’Équipe Agent", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index 1953d69b84..ab963eea55 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -2418,7 +2418,24 @@ "statusPending": "保留中", "statusInProgress": "進行中", "statusCompleted": "完了", - "statusBlocked": "ブロック中" + "statusBlocked": "ブロック中", + "statusFailed": "失敗", + "statusCancelled": "キャンセル済み", + "currentWork": "現在の作業", + "currentEmpty": "現在の作業はありません", + "history": "履歴", + "historyFilter": "タスク履歴を絞り込む", + "historyEmpty": "この条件のタスクはありません", + "loadingHistory": "履歴を読み込み中…", + "historyLoadFailed": "履歴の読み込みに失敗しました", + "previousPage": "前へ", + "nextPage": "次へ", + "details": "詳細", + "loadingDetails": "詳細を読み込み中…", + "detailLoadFailed": "詳細の読み込みに失敗しました", + "output": "結果", + "annotations": "進捗と監査メモ", + "loadMoreAnnotations": "さらに読み込む" }, "agentOrgOverview": { "title": "Agent チーム概要", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index 1dd637a213..d47e8f50d8 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -2419,7 +2419,24 @@ "statusAssigned": "할당됨", "statusInProgress": "진행 중", "statusCompleted": "완료됨", - "statusBlocked": "차단됨" + "statusBlocked": "차단됨", + "statusFailed": "실패", + "statusCancelled": "취소됨", + "currentWork": "현재 작업", + "currentEmpty": "현재 작업이 없습니다", + "history": "기록", + "historyFilter": "작업 기록 필터", + "historyEmpty": "이 필터에 해당하는 작업이 없습니다", + "loadingHistory": "기록 불러오는 중…", + "historyLoadFailed": "기록을 불러오지 못했습니다", + "previousPage": "이전", + "nextPage": "다음", + "details": "세부 정보", + "loadingDetails": "세부 정보 불러오는 중…", + "detailLoadFailed": "세부 정보를 불러오지 못했습니다", + "output": "결과", + "annotations": "진행 및 감사 메모", + "loadMoreAnnotations": "더 보기" }, "agentOrgOverview": { "title": "Agent 팀 개요", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index 8897668f94..6d11ac5252 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -2481,7 +2481,24 @@ "statusPending": "Oczekuje", "statusInProgress": "W toku", "statusCompleted": "Ukończone", - "statusBlocked": "Zablokowane" + "statusBlocked": "Zablokowane", + "statusFailed": "Niepowodzenie", + "statusCancelled": "Anulowane", + "currentWork": "Bieżąca praca", + "currentEmpty": "Brak bieżącej pracy", + "history": "Historia", + "historyFilter": "Filtruj historię zadań", + "historyEmpty": "Brak zadań dla tego filtra", + "loadingHistory": "Ładowanie historii…", + "historyLoadFailed": "Nie udało się załadować historii", + "previousPage": "Poprzednia", + "nextPage": "Następna", + "details": "Szczegóły", + "loadingDetails": "Ładowanie szczegółów…", + "detailLoadFailed": "Nie udało się załadować szczegółów", + "output": "Wynik", + "annotations": "Postęp i notatki audytu", + "loadMoreAnnotations": "Wczytaj więcej" }, "agentOrgOverview": { "title": "Przegląd Zespołu Agent", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index e49fe8051d..e74804f9fd 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -2443,7 +2443,24 @@ "statusPending": "Pendente", "statusInProgress": "Em andamento", "statusCompleted": "Concluído", - "statusBlocked": "Bloqueado" + "statusBlocked": "Bloqueado", + "statusFailed": "Falhou", + "statusCancelled": "Cancelado", + "currentWork": "Trabalho atual", + "currentEmpty": "Nenhum trabalho atual", + "history": "Histórico", + "historyFilter": "Filtrar histórico de tarefas", + "historyEmpty": "Nenhuma tarefa neste filtro", + "loadingHistory": "Carregando histórico…", + "historyLoadFailed": "Falha ao carregar o histórico", + "previousPage": "Anterior", + "nextPage": "Próxima", + "details": "Detalhes", + "loadingDetails": "Carregando detalhes…", + "detailLoadFailed": "Falha ao carregar os detalhes", + "output": "Resultado", + "annotations": "Progresso e notas de auditoria", + "loadMoreAnnotations": "Carregar mais" }, "agentOrgOverview": { "title": "Visão geral da Equipe Agent", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 2237b25762..ce5b1591c7 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -2463,7 +2463,24 @@ "statusAssigned": "Назначено", "statusInProgress": "В работе", "statusCompleted": "Готово", - "statusBlocked": "Заблокировано" + "statusBlocked": "Заблокировано", + "statusFailed": "Ошибка", + "statusCancelled": "Отменено", + "currentWork": "Текущая работа", + "currentEmpty": "Нет текущей работы", + "history": "История", + "historyFilter": "Фильтр истории задач", + "historyEmpty": "Нет задач для этого фильтра", + "loadingHistory": "Загрузка истории…", + "historyLoadFailed": "Не удалось загрузить историю", + "previousPage": "Назад", + "nextPage": "Далее", + "details": "Подробности", + "loadingDetails": "Загрузка подробностей…", + "detailLoadFailed": "Не удалось загрузить подробности", + "output": "Результат", + "annotations": "Ход работы и аудит", + "loadMoreAnnotations": "Загрузить ещё" }, "agentOrgOverview": { "title": "Обзор Команды Agent", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index 17a0d80c24..249ad0489d 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -2420,7 +2420,24 @@ "statusAssigned": "Atandı", "statusInProgress": "Devam ediyor", "statusCompleted": "Tamamlandı", - "statusBlocked": "Engelli" + "statusBlocked": "Engelli", + "statusFailed": "Başarısız", + "statusCancelled": "İptal edildi", + "currentWork": "Mevcut çalışma", + "currentEmpty": "Mevcut çalışma yok", + "history": "Geçmiş", + "historyFilter": "Görev geçmişini filtrele", + "historyEmpty": "Bu filtrede görev yok", + "loadingHistory": "Geçmiş yükleniyor…", + "historyLoadFailed": "Geçmiş yüklenemedi", + "previousPage": "Önceki", + "nextPage": "Sonraki", + "details": "Ayrıntılar", + "loadingDetails": "Ayrıntılar yükleniyor…", + "detailLoadFailed": "Ayrıntılar yüklenemedi", + "output": "Çıktı", + "annotations": "İlerleme ve denetim notları", + "loadMoreAnnotations": "Daha fazla yükle" }, "agentOrgOverview": { "title": "Agent Ekibi genel görünümü", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index 7af8d833a8..8a3b1cf98f 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -2416,7 +2416,24 @@ "statusPending": "Đang chờ", "statusInProgress": "Đang làm", "statusCompleted": "Hoàn tất", - "statusBlocked": "Bị chặn" + "statusBlocked": "Bị chặn", + "statusFailed": "Thất bại", + "statusCancelled": "Đã hủy", + "currentWork": "Công việc hiện tại", + "currentEmpty": "Không có công việc hiện tại", + "history": "Lịch sử", + "historyFilter": "Lọc lịch sử tác vụ", + "historyEmpty": "Không có tác vụ trong bộ lọc này", + "loadingHistory": "Đang tải lịch sử…", + "historyLoadFailed": "Không thể tải lịch sử", + "previousPage": "Trước", + "nextPage": "Tiếp", + "details": "Chi tiết", + "loadingDetails": "Đang tải chi tiết…", + "detailLoadFailed": "Không thể tải chi tiết", + "output": "Kết quả", + "annotations": "Tiến độ và ghi chú kiểm tra", + "loadMoreAnnotations": "Tải thêm" }, "agentOrgOverview": { "title": "Tổng quan Nhóm Agent", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 09f1d83f8d..2a745f2913 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -2434,7 +2434,24 @@ "statusPending": "待處理", "statusInProgress": "進行中", "statusCompleted": "已完成", - "statusBlocked": "被阻塞" + "statusBlocked": "被阻塞", + "statusFailed": "失敗", + "statusCancelled": "已取消", + "currentWork": "目前工作", + "currentEmpty": "暫無目前工作", + "history": "歷史", + "historyFilter": "篩選任務歷史", + "historyEmpty": "此篩選下暫無任務", + "loadingHistory": "正在載入歷史…", + "historyLoadFailed": "歷史載入失敗", + "previousPage": "上一頁", + "nextPage": "下一頁", + "details": "詳情", + "loadingDetails": "正在載入詳情…", + "detailLoadFailed": "任務詳情載入失敗", + "output": "結果", + "annotations": "進度與稽核記錄", + "loadMoreAnnotations": "載入更多" }, "agentOrgOverview": { "title": "Agent 團隊概覽", diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 36936fae0f..7aff80debd 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -2488,7 +2488,24 @@ "statusAssigned": "已分配", "statusInProgress": "进行中", "statusCompleted": "已完成", - "statusBlocked": "被阻塞" + "statusBlocked": "被阻塞", + "statusFailed": "失败", + "statusCancelled": "已取消", + "currentWork": "当前工作", + "currentEmpty": "暂无当前工作", + "history": "历史", + "historyFilter": "筛选任务历史", + "historyEmpty": "此筛选下暂无任务", + "loadingHistory": "正在加载历史…", + "historyLoadFailed": "历史加载失败", + "previousPage": "上一页", + "nextPage": "下一页", + "details": "详情", + "loadingDetails": "正在加载详情…", + "detailLoadFailed": "任务详情加载失败", + "output": "结果", + "annotations": "进度与审计记录", + "loadMoreAnnotations": "加载更多" }, "agentOrgOverview": { "title": "Agent 团队概览", diff --git a/src/modules/WorkStation/Chat/Communication/TodoKanban.tsx b/src/modules/WorkStation/Chat/Communication/TodoKanban.tsx index 26524d497e..31777619e1 100644 --- a/src/modules/WorkStation/Chat/Communication/TodoKanban.tsx +++ b/src/modules/WorkStation/Chat/Communication/TodoKanban.tsx @@ -94,13 +94,12 @@ type TodoLifecycleStatus = | "pending" | "in_progress" | "completed" + | "failed" | "cancelled"; -// `manage_todo` (and the equivalent Cursor IDE `todo_write` tool, normalized -// by `cursor_db_history.rs`) only emits four status values: `pending`, -// `in_progress`, `completed`, `cancelled`. Both schemas are pinned in their -// respective JSON-schema enums, so we map them as exact-string matches — -// no substring `includes()` games, no silent fallback for unknown values. +// `manage_todo` and Cursor `todo_write` emit four states; Agent Org's durable +// Task board additionally emits `failed`. Map the union by exact value so a +// failed/cancelled formal Task can never fall into the pending column. function normalizeLifecycleStatus( status: string | undefined ): TodoLifecycleStatus { @@ -111,6 +110,8 @@ function normalizeLifecycleStatus( return "completed"; case "cancelled": return "cancelled"; + case "failed": + return "failed"; case "pending": case "": return "pending"; @@ -118,7 +119,7 @@ function normalizeLifecycleStatus( if (process.env.NODE_ENV !== "production") { log.warn( `[TodoKanban] Unknown todo status ${JSON.stringify(status)} — ` + - `routing to 'pending'. Expected one of: pending, in_progress, completed, cancelled.` + `routing to 'pending'. Expected one of: pending, in_progress, completed, failed, cancelled.` ); } return "pending"; @@ -133,6 +134,7 @@ function lifecycleToColumn(status: TodoLifecycleStatus): TaskStatus { case "completed": return "completed"; case "cancelled": + case "failed": return "cancelled"; case "pending": case "in_progress": @@ -481,7 +483,7 @@ export const TodoKanban: React.FC = ({ if (lifecycle === "completed") { updatedIcon = CheckmarkCircle01Icon; updatedColor = "var(--color-success-6)"; - } else if (lifecycle === "cancelled") { + } else if (lifecycle === "cancelled" || lifecycle === "failed") { updatedIcon = CancelCircleIcon; updatedColor = "var(--color-danger-6)"; } @@ -519,6 +521,7 @@ export const TodoKanban: React.FC = ({ priority: todo.priority, metaLines, status: lifecycleToColumn(lifecycle), + resultStatus: lifecycle === "failed" ? "failed" : undefined, }; }), [todos, timeline, yesterdayLabel, nowLabel, minutesAgoLabel] diff --git a/src/modules/WorkStation/Chat/Communication/__tests__/TodoKanban.test.ts b/src/modules/WorkStation/Chat/Communication/__tests__/TodoKanban.test.ts index e0bf04016a..e844fb8cb2 100644 --- a/src/modules/WorkStation/Chat/Communication/__tests__/TodoKanban.test.ts +++ b/src/modules/WorkStation/Chat/Communication/__tests__/TodoKanban.test.ts @@ -248,6 +248,25 @@ describe("buildAgentOrgTaskTimeline", () => { }); }); + it("preserves failed and cancelled instead of folding them into pending", () => { + const tasks = ["failed", "cancelled"].map((status, index) => ({ + id: `terminal-${index}`, + orgRunId: "run-1", + subject: `Terminal ${index}`, + description: "", + status: status as AgentOrgTask["status"], + blocks: [], + blockedBy: [], + executionMode: "build" as const, + createdAt: "2026-07-10T10:00:00Z", + updatedAt: "2026-07-10T10:05:00Z", + })); + + expect( + buildAgentOrgTaskTimeline(tasks).todos.map((todo) => todo.status) + ).toEqual(["failed", "cancelled"]); + }); + it("prefers an available durable snapshot over event-projected tasks", () => { const ghostMessage: MessageEntry = { eventId: "legacy-ghost", diff --git a/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs b/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs index b2986997ef..3c0e2f4aec 100644 --- a/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs +++ b/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs @@ -118,6 +118,107 @@ async function pauseDefaultAgentOrgRuns(label) { } } +async function waitForPr4ProductionScenario(sessionId, scenarioId) { + let latestTasks = []; + let latestRunId = null; + let latestReadError = null; + try { + await browser.waitUntil( + async () => { + try { + const view = unwrap( + await invokeE2E("agentOrgSessionRunView", sessionId), + `agentOrgSessionRunView(PR4 ${scenarioId})` + ).view; + latestRunId = view?.context?.runId ?? latestRunId; + if (!latestRunId) return false; + latestTasks = unwrap( + await invokeE2E("debugAgentOrgTasksList", latestRunId), + `debugAgentOrgTasksList(PR4 ${scenarioId})` + ).tasks; + latestReadError = null; + const subject = (prefix) => + latestTasks.find( + (task) => task?.subject === `${prefix}:${scenarioId}` + ); + const pagedHistory = latestTasks.filter((task) => + String(task?.subject ?? "").startsWith( + `E2E_PR4_HISTORY:${scenarioId}:` + ) + ); + return Boolean( + subject("E2E_PR4_PENDING")?.status === + AGENT_ORG_TASK_STATUS.PENDING && + subject("E2E_PR4_REPLACEMENT")?.status === + AGENT_ORG_TASK_STATUS.PENDING && + subject("E2E_PR4_COMPLETE")?.status === + AGENT_ORG_TASK_STATUS.COMPLETED && + subject("E2E_PR4_FAIL")?.status === AGENT_ORG_TASK_STATUS.FAILED && + subject("E2E_PR4_LATE")?.status === + AGENT_ORG_TASK_STATUS.CANCELLED && + pagedHistory.length === 20 && + pagedHistory.every( + (task) => task.status === AGENT_ORG_TASK_STATUS.COMPLETED + ) + ); + } catch (error) { + latestReadError = String(error); + return false; + } + }, + { + timeout: REPLY_TIMEOUT_MS, + interval: 500, + timeoutMsg: `PR4 production scenario ${scenarioId} did not settle`, + } + ); + } catch (error) { + throw new Error( + `PR4 production scenario ${scenarioId} did not settle: ${JSON.stringify({ latestRunId, latestReadError, latestTasks })}`, + { cause: error } + ); + } + return latestTasks; +} + +async function waitForRenderedTaskHistory(status, expectedCount, label) { + let state = null; + try { + await browser.waitUntil( + async () => { + state = await execJS(` + const rows = Array.from(document.querySelectorAll('[data-testid="agent-org-task-history-row"]')); + return { + rows: rows.map((row) => ({ + id: row.getAttribute('data-task-id') || '', + status: row.getAttribute('data-task-status') || '', + text: row.textContent || '', + })), + nextDisabled: document.querySelector('[data-testid="agent-org-task-history-next-page"]')?.disabled ?? null, + previousDisabled: document.querySelector('[data-testid="agent-org-task-history-previous-page"]')?.disabled ?? null, + error: document.querySelector('[role="alert"]')?.textContent || '', + }; + `); + return ( + state.rows.length === expectedCount && + state.rows.every((row) => row.status === status) + ); + }, + { + timeout: RENDER_TIMEOUT_MS, + interval: 250, + timeoutMsg: `Task History did not render ${label}`, + } + ); + } catch (error) { + throw new Error( + `Task History did not render ${label}: ${JSON.stringify(state)}`, + { cause: error } + ); + } + return state; +} + describe("Agent Org group chat and plan rendered UI", () => { before(async () => { assertE2ERepoFixture(); @@ -202,6 +303,263 @@ describe("Agent Org group chat and plan rendered UI", () => { ); }); + it("enforces the PR4 Task FSM through the packaged production Tool path", async () => { + const account = await getApiAccount(); + const model = selectPreferredModel(account); + await configureCreatorForDefaultAgentOrg({ account, model }); + await selectRenderedExecMode("build"); + await selectRenderedDefaultAgentOrg(); + + const scenarioIds = [1, 2, 3].map((index) => `page${index}_${RUN_ID}`); + const sessionId = await sendFromRenderedCreator( + `Run ${`E2E_PR4_TASK_FSM:${scenarioIds[0]}`}` + ); + if (!sessionId) { + throw new Error("PR4 production-path launch did not create a session"); + } + + await waitForAgentOrgRunView( + sessionId, + (view) => + (view?.tasks ?? []).some( + (task) => task.status === AGENT_ORG_TASK_STATUS.IN_PROGRESS + ) && + (view?.tasks ?? []).some( + (task) => task.status === AGENT_ORG_TASK_STATUS.PENDING + ), + "PR4 Current Work exposes pending and in-progress" + ); + + const scenarioTasks = []; + scenarioTasks.push( + await waitForPr4ProductionScenario(sessionId, scenarioIds[0]) + ); + for (const scenarioId of scenarioIds.slice(1)) { + await sendRenderedChatPrompt(`Run E2E_PR4_TASK_FSM:${scenarioId}`); + scenarioTasks.push( + await waitForPr4ProductionScenario(sessionId, scenarioId) + ); + } + + const settledTasks = scenarioTasks.at(-1) ?? []; + const statusCounts = settledTasks.reduce((counts, task) => { + counts[task.status] = (counts[task.status] ?? 0) + 1; + return counts; + }, {}); + const duplicateSubjects = Object.entries( + settledTasks.reduce((counts, task) => { + counts[task.subject] = (counts[task.subject] ?? 0) + 1; + return counts; + }, {}) + ).filter(([, count]) => count > 1); + if ( + settledTasks.length !== 75 || + statusCounts[AGENT_ORG_TASK_STATUS.PENDING] !== 6 || + statusCounts[AGENT_ORG_TASK_STATUS.COMPLETED] !== 63 || + statusCounts[AGENT_ORG_TASK_STATUS.FAILED] !== 3 || + statusCounts[AGENT_ORG_TASK_STATUS.CANCELLED] !== 3 + ) { + throw new Error( + `PR4 production scenarios created an unexpected Task set: ${JSON.stringify({ taskCount: settledTasks.length, statusCounts, duplicateSubjects })}` + ); + } + + for (let index = 0; index < scenarioIds.length; index += 1) { + const scenarioId = scenarioIds[index]; + const tasks = scenarioTasks[index]; + const find = (prefix) => + tasks.find((task) => task.subject === `${prefix}:${scenarioId}`); + const completed = find("E2E_PR4_COMPLETE"); + const failed = find("E2E_PR4_FAIL"); + const cancelled = find("E2E_PR4_LATE"); + const replacement = find("E2E_PR4_REPLACEMENT"); + if ( + completed?.output?.producedByMemberId !== + DEFAULT_AGENT_ORG_MEMBER_IDS.REVIEWER || + !completed?.output?.producedAt || + failed?.failureReason?.code !== "e2e.expected_failure" || + cancelled?.cancelReason?.code !== "e2e.replaced" || + cancelled?.output != null || + replacement?.replacesTaskId !== cancelled?.id || + replacement?.owner != null || + !replacement?.sourceTurnIntentId || + replacement?.createdByParticipantId !== AGENT_ORG_COORDINATOR_MEMBER_ID + ) { + throw new Error( + `PR4 provenance/result invariant failed for ${scenarioId}: ${JSON.stringify({ completed, failed, cancelled, replacement })}` + ); + } + } + + await openAgentOrgOverviewPanel("PR4 Current/History"); + const collapsed = await execJS(` + return { + expanded: document.querySelector('[data-testid="agent-org-task-history-toggle"]')?.getAttribute('aria-expanded'), + hasHistoryList: Boolean(document.querySelector('[data-testid="agent-org-task-history-list"]')), + currentStatuses: Array.from(document.querySelectorAll('[data-testid="agent-org-overview-task-row"]')).map((row) => row.getAttribute('data-task-status')), + }; + `); + if ( + collapsed?.expanded !== "false" || + collapsed?.hasHistoryList !== false || + !collapsed?.currentStatuses.every( + (status) => status === AGENT_ORG_TASK_STATUS.PENDING + ) + ) { + throw new Error( + `PR4 collapsed History/Current Work invariant failed: ${JSON.stringify(collapsed)}` + ); + } + + const historyToggle = await execJS( + js.click('[data-testid="agent-org-task-history-toggle"]') + ); + if (historyToggle !== "clicked") { + throw new Error(`PR4 History toggle failed: ${historyToggle}`); + } + const completedFirstPage = await waitForRenderedTaskHistory( + AGENT_ORG_TASK_STATUS.COMPLETED, + 50, + "completed first page" + ); + if (completedFirstPage.nextDisabled !== false) { + throw new Error( + `PR4 completed first page did not expose a next cursor: ${JSON.stringify(completedFirstPage)}` + ); + } + const firstPageFirstId = completedFirstPage.rows[0]?.id; + const nextPage = await execJS( + js.click('[data-testid="agent-org-task-history-next-page"]') + ); + if (nextPage !== "clicked") { + throw new Error(`PR4 History next page failed: ${nextPage}`); + } + const completedSecondPage = await waitForRenderedTaskHistory( + AGENT_ORG_TASK_STATUS.COMPLETED, + 13, + "completed second page" + ); + if ( + completedSecondPage.previousDisabled !== false || + completedSecondPage.rows[0]?.id === firstPageFirstId + ) { + throw new Error( + `PR4 completed second page cursor invariant failed: ${JSON.stringify(completedSecondPage)}` + ); + } + await execJS( + js.click('[data-testid="agent-org-task-history-previous-page"]') + ); + await waitForRenderedTaskHistory( + AGENT_ORG_TASK_STATUS.COMPLETED, + 50, + "completed previous page" + ); + + const completedTaskId = scenarioTasks[0].find( + (task) => task.subject === `E2E_PR4_COMPLETE:${scenarioIds[0]}` + )?.id; + if (!completedTaskId) { + throw new Error( + "PR4 completed Task id was missing from the durable snapshot" + ); + } + let completedDetail = await execJS(` + const row = Array.from(document.querySelectorAll('[data-testid="agent-org-task-history-row"]')) + .find((candidate) => candidate.getAttribute('data-task-id') === ${JSON.stringify(completedTaskId)}); + const toggle = row?.querySelector('[data-testid="agent-org-task-detail-toggle"]'); + if (!toggle) return 'missing'; + toggle.click(); + return 'clicked'; + `); + if (completedDetail === "missing") { + await execJS( + js.click('[data-testid="agent-org-task-history-next-page"]') + ); + await waitForRenderedTaskHistory( + AGENT_ORG_TASK_STATUS.COMPLETED, + 13, + "completed detail page" + ); + completedDetail = await execJS(` + const row = Array.from(document.querySelectorAll('[data-testid="agent-org-task-history-row"]')) + .find((candidate) => candidate.getAttribute('data-task-id') === ${JSON.stringify(completedTaskId)}); + const toggle = row?.querySelector('[data-testid="agent-org-task-detail-toggle"]'); + if (!toggle) return 'missing'; + toggle.click(); + return 'clicked'; + `); + } + if (completedDetail !== "clicked") { + throw new Error(`PR4 completed detail toggle failed: ${completedDetail}`); + } + await browser.waitUntil( + async () => { + const detail = await execJS(` + const row = Array.from(document.querySelectorAll('[data-testid="agent-org-task-history-row"]')) + .find((candidate) => candidate.getAttribute('data-task-id') === ${JSON.stringify(completedTaskId)}); + return row?.querySelector('[data-testid="agent-org-task-detail"]')?.textContent || ''; + `); + return ( + detail.includes(`E2E completed ${scenarioIds[0]}`) && + detail.includes(`E2E production-path evidence for ${scenarioIds[0]}`) + ); + }, + { timeout: RENDER_TIMEOUT_MS, interval: 250 } + ); + + await execJS( + js.click('[data-testid="agent-org-task-history-filter-failed"]') + ); + await waitForRenderedTaskHistory( + AGENT_ORG_TASK_STATUS.FAILED, + 3, + "failed filter" + ); + const failedDetail = await execJS(` + const row = document.querySelector('[data-testid="agent-org-task-history-row"]'); + row?.querySelector('[data-testid="agent-org-task-detail-toggle"]')?.click(); + return Boolean(row); + `); + if (!failedDetail) throw new Error("PR4 failed detail row was missing"); + await browser.waitUntil( + async () => + String( + await execJS( + `return document.querySelector('[data-testid="agent-org-task-detail"]')?.textContent || '';` + ) + ).includes("Deterministic E2E failure"), + { timeout: RENDER_TIMEOUT_MS, interval: 250 } + ); + + await execJS( + js.click('[data-testid="agent-org-task-history-filter-cancelled"]') + ); + await waitForRenderedTaskHistory( + AGENT_ORG_TASK_STATUS.CANCELLED, + 3, + "cancelled filter" + ); + const cancelledState = await execJS(` + const rows = Array.from(document.querySelectorAll('[data-testid="agent-org-task-history-row"]')); + return rows.map((row) => ({ + status: row.getAttribute('data-task-status'), + text: row.textContent || '', + })); + `); + if ( + !cancelledState.every( + (row) => + row.status === AGENT_ORG_TASK_STATUS.CANCELLED && + !row.text.includes("Late output") + ) + ) { + throw new Error( + `PR4 cancelled filter exposed a late result: ${JSON.stringify(cancelledState)}` + ); + } + }); + it("allows switching to a member with inbox activity but no tasks", async () => { const account = await getApiAccount(); const model = selectPreferredModel(account); diff --git a/tests/e2e/support/core/agentOrgUiDriver.mjs b/tests/e2e/support/core/agentOrgUiDriver.mjs index b0bf106c3f..bf945748b6 100644 --- a/tests/e2e/support/core/agentOrgUiDriver.mjs +++ b/tests/e2e/support/core/agentOrgUiDriver.mjs @@ -62,6 +62,8 @@ export const AGENT_ORG_TASK_STATUS = { PENDING: "pending", IN_PROGRESS: "in_progress", COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", }; const AGENT_SESSION_ACTIVE_STATUSES = new Set([ "running", @@ -306,7 +308,9 @@ export const js = { }; export async function waitForApp() { - await waitForFrontendReady(); + if (!process.env.E2E_APP_BINARY) { + await waitForFrontendReady(); + } await browser.setTimeout({ script: 10_000 }); await browser.waitUntil( async () => { @@ -345,6 +349,7 @@ export async function waitForApp() { && window.__e2e.getActiveSessionId && window.__e2e.getSessionAggregateRow && window.__e2e.promptDump + && window.__e2e.debugAgentOrgEnableRedesign && window.__e2e.agentOrgSessionRunView && window.__e2e.agentOrgSessionInterventionState && window.__e2e.agentOrgSendGroupChatMessage @@ -360,6 +365,10 @@ export async function waitForApp() { timeoutMsg: "required __e2e helpers never exposed", } ); + unwrap( + await invokeE2E("debugAgentOrgEnableRedesign"), + "enable Agent Org redesign in WebDriver artifact" + ); let shellState = null; await browser.waitUntil( async () => { @@ -525,6 +534,10 @@ export async function configureCreatorForAgentOrg({ }) { await navigateToWorkstationCode("before configure creator"); unwrap(await invokeE2E("resetToNewSession"), "resetToNewSession"); + unwrap( + await invokeE2E("debugAgentOrgEnableRedesign"), + "re-enable Agent Org redesign after creator reset" + ); unwrap( await invokeE2E("setAgentOrgMemberDraftConfig", {}, agentOrgId), "clear agent org member draft config" @@ -1097,54 +1110,102 @@ export async function selectRenderedOrgMemberAgentDefinition({ export async function sendRenderedChatPrompt(prompt) { const inputSelector = '[data-testid="chat-input"] [contenteditable="true"]'; + const marker = prompt.match(/([A-Z0-9_]+_[a-zA-Z0-9_]+_\d+)/)?.[1] ?? prompt; console.log("[agent-org-send] waiting for chat input"); await browser.waitUntil(async () => execJS(js.exists(inputSelector)), { timeout: MOUNT_TIMEOUT_MS, timeoutMsg: `chat input (${inputSelector}) never mounted`, }); - console.log("[agent-org-send] chat input mounted, typing"); - const typeResult = await execJS(js.type(inputSelector, prompt)); - if (typeResult !== "typed") { - throw new Error(`chat input did not accept typed prompt: ${typeResult}`); - } - await browser.pause(300); - const editorText = await execJS(js.editorText(inputSelector)); - if (!String(editorText ?? "").includes(prompt)) { - throw new Error(`chat input text mismatch: ${editorText}`); - } - console.log("[agent-org-send] typed, waiting for send button"); - await browser.waitUntil( - async () => { - const sendState = await execJS(js.sendState); - return sendState?.state === "submit" && !sendState.disabled; - }, - { - timeout: RENDER_TIMEOUT_MS, - timeoutMsg: `chat-send-button never became ready: ${JSON.stringify(await execJS(js.sendState))}`, + console.log("[agent-org-send] chat input mounted, stabilizing draft"); + let typeResult = null; + let editorText = null; + let sendState = null; + let clickResult = null; + let acceptedState = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + acceptedState = unwrap( + await invokeE2E("inspectChatState"), + `inspectChatState(rendered-send-${attempt}-before)` + ); + if (JSON.stringify(acceptedState).includes(marker)) return; + + try { + await browser.waitUntil( + async () => { + editorText = await execJS(js.editorText(inputSelector)); + if (!String(editorText ?? "").includes(prompt)) { + typeResult = await execJS(js.type(inputSelector, prompt)); + if (typeResult !== "typed") { + throw new Error( + `chat input did not accept prompt: ${typeResult}` + ); + } + return false; + } + + sendState = await execJS(js.sendState); + if (sendState?.state !== "submit" || sendState.disabled) return false; + + clickResult = await execJS(` + const isVisible = (node) => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + const visibleInputShells = Array.from(document.querySelectorAll('[data-testid="chat-input"]')).filter(isVisible); + const activeInputShell = visibleInputShells[visibleInputShells.length - 1] ?? null; + const editor = activeInputShell?.querySelector('[contenteditable="true"]') ?? null; + const button = activeInputShell?.querySelector('[data-testid="chat-send-button"]') ?? null; + if (!editor || !(editor.textContent || "").includes(${JSON.stringify(prompt)})) { + return "draft-lost:" + (editor?.textContent || "").slice(0, 120); + } + if (!button) return "missing"; + if (button.disabled) return "disabled"; + button.scrollIntoView({ block: "center", inline: "center" }); + button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + button.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 })); + button.click(); + return "clicked"; + `); + return clickResult === "clicked"; + }, + { + timeout: RENDER_TIMEOUT_MS, + interval: 100, + timeoutMsg: + "active chat editor never retained the prompt through submit", + } + ); + } catch (error) { + throw new Error( + `chat prompt submit failed: editor=${JSON.stringify(String(editorText ?? "").slice(0, 120))} type=${typeResult} send=${JSON.stringify(sendState)} click=${clickResult} cause=${String(error?.message ?? error)}` + ); + } + + try { + await browser.waitUntil( + async () => { + acceptedState = unwrap( + await invokeE2E("inspectChatState"), + `inspectChatState(rendered-send-${attempt}-acceptance)` + ); + return JSON.stringify(acceptedState).includes(marker); + }, + { + timeout: 5_000, + interval: 250, + timeoutMsg: `rendered send did not create authoritative message ${marker}`, + } + ); + console.log("[agent-org-send] authoritative message accepted"); + return; + } catch (_error) { + if (attempt === 2) break; } - ); - console.log("[agent-org-send] send button ready, clicking"); - const clickResult = await execJS(` - const visibleInputShells = Array.from(document.querySelectorAll('[data-testid="chat-input"]')).filter((inputShell) => { - const rect = inputShell.getBoundingClientRect(); - const style = window.getComputedStyle(inputShell); - return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; - }); - const activeInputShell = visibleInputShells[visibleInputShells.length - 1] ?? null; - const button = activeInputShell?.querySelector('[data-testid="chat-send-button"]') ?? null; - if (!button) return "missing"; - if (button.disabled) return "disabled"; - button.scrollIntoView({ block: "center", inline: "center" }); - button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); - button.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 })); - button.click(); - return "clicked"; - `); - if (clickResult !== "clicked") { - throw new Error( - `chat-send-button did not click: ${clickResult} ${JSON.stringify(await execJS(js.sendState))}` - ); } + throw new Error( + `rendered send never created authoritative message ${marker}; editor=${JSON.stringify(String(editorText ?? "").slice(0, 120))} type=${typeResult} send=${JSON.stringify(sendState)} click=${clickResult} state=${JSON.stringify(acceptedState).slice(0, 1200)}` + ); } export async function waitForRenderedGroupChatActive(label) { diff --git a/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs b/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs index 120b0af6d9..48729d9b16 100644 --- a/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs +++ b/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs @@ -247,43 +247,69 @@ async function clickMainAction(expectedState, label, timeout = 15_000) { } async function typeAndSubmitWithShortcut(inputSelector, prompt) { - const typed = await execJS(js.clearAndType(inputSelector, prompt)); + let typed = await execJS(js.clearAndType(inputSelector, prompt)); if (!typed.includes(prompt)) { throw new Error(`Failed to type prompt: ${typed}`); } - await browser.pause(300); - const shortcutResult = await execJS(` - const isVisible = (node) => { - const style = window.getComputedStyle(node); - const rect = node.getBoundingClientRect(); - return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; - }; - const visibleInputShells = Array.from(document.querySelectorAll('[data-testid="chat-input"]')).filter(isVisible); - const activeInputShell = visibleInputShells[visibleInputShells.length - 1] ?? null; - const scopedEditors = activeInputShell - ? Array.from(activeInputShell.querySelectorAll(${JSON.stringify(inputSelector)})).filter(isVisible) - : []; - const editors = scopedEditors.length > 0 - ? scopedEditors - : Array.from(document.querySelectorAll(${JSON.stringify(inputSelector)})).filter(isVisible); - const element = editors[editors.length - 1] ?? null; - if (!element) return "missing"; - element.focus(); - if (!(element.textContent || "").includes(${JSON.stringify(prompt)})) { - return "wrong-editor:" + (element.textContent || "").slice(0, 120); - } - const event = new KeyboardEvent("keydown", { - key: "Enter", - code: "Enter", - bubbles: true, - cancelable: true, - metaKey: true, - }); - element.dispatchEvent(event); - return event.defaultPrevented ? "submitted" : "not-handled"; - `); - if (shortcutResult !== "submitted") { - throw new Error(`Shortcut submit failed: ${shortcutResult}`); + let shortcutResult = null; + try { + await browser.waitUntil( + async () => { + shortcutResult = await execJS(` + const isVisible = (node) => { + const style = window.getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }; + const visibleInputShells = Array.from(document.querySelectorAll('[data-testid="chat-input"]')).filter(isVisible); + const activeInputShell = visibleInputShells[visibleInputShells.length - 1] ?? null; + const scopedEditors = activeInputShell + ? Array.from(activeInputShell.querySelectorAll(${JSON.stringify(inputSelector)})).filter(isVisible) + : []; + const editors = scopedEditors.length > 0 + ? scopedEditors + : Array.from(document.querySelectorAll(${JSON.stringify(inputSelector)})).filter(isVisible); + const element = editors[editors.length - 1] ?? null; + if (!element) return "missing"; + element.focus(); + if (!(element.textContent || "").includes(${JSON.stringify(prompt)})) { + return "wrong-editor:" + (element.textContent || "").slice(0, 120); + } + const event = new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + bubbles: true, + cancelable: true, + metaKey: true, + }); + element.dispatchEvent(event); + return event.defaultPrevented ? "submitted" : "not-handled"; + `); + if ( + shortcutResult === "missing" || + shortcutResult.startsWith("wrong-editor:") + ) { + typed = await execJS(js.clearAndType(inputSelector, prompt)); + if (!typed.includes(prompt)) { + throw new Error(`Failed to retype prompt: ${typed}`); + } + return false; + } + if (shortcutResult === "not-handled") { + throw new Error(shortcutResult); + } + return shortcutResult === "submitted"; + }, + { + timeout: 10_000, + interval: 100, + timeoutMsg: "active chat editor never retained the queued prompt", + } + ); + } catch (error) { + throw new Error( + `Shortcut submit failed: ${shortcutResult}; typed=${String(typed).slice(0, 120)}; cause=${String(error?.message ?? error)}` + ); } const markerMatch = prompt.match(/([A-Z0-9_]+_[a-zA-Z0-9_]+_\d+)/); diff --git a/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs b/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs index 7278bd8682..c19a175bfe 100644 --- a/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs +++ b/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs @@ -221,7 +221,7 @@ const js = { element.click(); return "clicked"; `, - clickWhenState: (selector, expectedState) => ` + clickWhenState: (selector, expectedState, expectedEditorText = null) => ` const isVisible = (element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); @@ -238,6 +238,11 @@ const js = { if (element.disabled) return "disabled"; const state = element.getAttribute("data-state"); if (state !== ${JSON.stringify(expectedState)}) return "state:" + String(state); + const expectedEditorText = ${JSON.stringify(expectedEditorText)}; + const editor = activeInputShell?.querySelector('[contenteditable="true"]') ?? null; + if (expectedEditorText && !(editor?.textContent || "").includes(expectedEditorText)) { + return "draft-lost:" + (editor?.textContent || "").slice(0, 120); + } element.scrollIntoView({ block: "center", inline: "center" }); element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window, button: 0 })); element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window, button: 0 })); @@ -1552,29 +1557,72 @@ async function waitForChatInput() { } async function typeAndClickSend(inputSelector, prompt) { - const typed = await execJS(js.clearAndType(inputSelector, prompt)); - if (!typed.includes(prompt)) - throw new Error(`Failed to type prompt: ${typed}`); - await browser.pause(500); - await browser.waitUntil( - async () => { - const state = await execJS(js.sendState); - return state && state.state === "submit" && !state.disabled; - }, - { - timeout: 15_000, - timeoutMsg: `send button never became submit after typing ${JSON.stringify(prompt.slice(0, 80))}; sendState=${JSON.stringify(await execJS(js.sendState))}; active=${JSON.stringify(await invokeE2E("getActiveSessionId"))}; chat=${JSON.stringify(summarizeChatState(await invokeE2E("inspectChatState")))}; dump=${JSON.stringify(summarizePageDump(await execJS(js.pageDump)))}`, - } - ); - const clicked = await execJS( - js.clickWhenState('[data-testid="chat-send-button"]', "submit") - ); - if (clicked !== "clicked") { - const state = await execJS(js.sendState); - throw new Error( - `send click failed: ${clicked}; state=${JSON.stringify(state)}` + const marker = prompt.match(/([A-Z0-9_]+_[a-zA-Z0-9_]+_\d+)/)?.[1] ?? prompt; + let typed = null; + let clicked = null; + let acceptedState = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + acceptedState = await inspectChatState( + `direct-send-${attempt}-before-acceptance-check` ); + if (JSON.stringify(acceptedState).includes(marker)) return; + + await browser.waitUntil( + async () => { + const editorText = await execJS(js.editorText); + if (!String(editorText ?? "").includes(prompt)) { + typed = await execJS(js.clearAndType(inputSelector, prompt)); + if (!typed.includes(prompt)) { + throw new Error(`Failed to type prompt: ${typed}`); + } + return false; + } + const state = await execJS(js.sendState); + if (!state || state.state !== "submit" || state.disabled) return false; + clicked = await execJS( + js.clickWhenState( + '[data-testid="chat-send-button"]', + "submit", + prompt + ) + ); + if (clicked?.startsWith("draft-lost:")) return false; + if (clicked !== "clicked") { + throw new Error( + `send click failed: ${clicked}; state=${JSON.stringify(state)}` + ); + } + return true; + }, + { + timeout: 15_000, + interval: 100, + timeoutMsg: `send button never accepted ${JSON.stringify(prompt.slice(0, 80))}; sendState=${JSON.stringify(await execJS(js.sendState))}; active=${JSON.stringify(await invokeE2E("getActiveSessionId"))}; chat=${JSON.stringify(summarizeChatState(await invokeE2E("inspectChatState")))}; dump=${JSON.stringify(summarizePageDump(await execJS(js.pageDump)))}`, + } + ); + + try { + await browser.waitUntil( + async () => { + acceptedState = await inspectChatState( + `direct-send-${attempt}-acceptance` + ); + return JSON.stringify(acceptedState).includes(marker); + }, + { + timeout: 5_000, + interval: 250, + timeoutMsg: `clicked send did not create authoritative message ${marker}`, + } + ); + return; + } catch (_error) { + if (attempt === 2) break; + } } + throw new Error( + `send click never created authoritative message ${marker}; typed=${JSON.stringify(String(typed ?? "").slice(0, 120))} clicked=${clicked} state=${JSON.stringify(summarizeChatState(acceptedState))}` + ); } async function selectConfiguredWorkspaceIfNeeded(repoPath) { diff --git a/tests/e2e/wdio.conf.mjs b/tests/e2e/wdio.conf.mjs index 476f9500ac..3fc5b409ff 100644 --- a/tests/e2e/wdio.conf.mjs +++ b/tests/e2e/wdio.conf.mjs @@ -15,7 +15,10 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "..", ".."); -const appBinary = resolve(repoRoot, "src-tauri/target/debug/org2"); +const packagedAppBinary = process.env.E2E_APP_BINARY?.trim(); +const appBinary = packagedAppBinary + ? resolve(packagedAppBinary) + : resolve(repoRoot, "src-tauri/target/debug/org2"); // Load tests/e2e/.env so specs can read OPENAI_API_KEY etc. via process.env. // Quiet failure is fine — the .env is optional; without it, tests fall back to @@ -783,8 +786,14 @@ export const config = { if (reuseServices) return; assertManagedPortsAvailable(); cleanWebDriverEnvironment(); - startFrontendServer(); - buildWebDriverApp(); + if (packagedAppBinary) { + if (!existsSync(appBinary)) { + throw new Error(`E2E_APP_BINARY does not exist: ${appBinary}`); + } + } else { + startFrontendServer(); + buildWebDriverApp(); + } startTauriWebDriver(); }, before: async function () { From a5c9bd6e2b743a8663126c6bc157cc7502ed84f3 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sat, 22 Aug 2026 15:25:37 +0800 Subject: [PATCH 2/7] fix(agent-org): harden task recovery budgets Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../agent_org_runs/store/lifecycle.rs | 46 +- .../coordination/agent_org_tasks/actor.rs | 8 +- .../coordination/agent_org_tasks/pr4_tests.rs | 481 ++++++++++++++---- .../agent_org_tasks/store/requeue.rs | 372 +++++++++----- .../coordination/agent_org_tasks/tests.rs | 49 -- .../coordination/agent_org_turn_contexts.rs | 102 ++++ .../core/coordination/agent_org_watchdog.rs | 5 +- .../coordination/agent_org_watchdog/budget.rs | 119 ++++- src-tauri/crates/agent-core/src/lifecycle.rs | 20 +- .../agent-core/src/tests/lifecycle_tests.rs | 134 ++++- .../cli/session_runner/finalize.rs | 13 +- 11 files changed, 1023 insertions(+), 326 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs index 70d22ad1ed..7f41b0921a 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_runs/store/lifecycle.rs @@ -76,11 +76,10 @@ impl AgentOrgRunStore { Ok(changed) } - /// Apply the normal failed-member task disposition after crash recovery - /// has converted stranded Running sessions to Abandoned. Every still - /// in-progress Task returns to ownerless pending with its candidate roster - /// preserved. No peer or Coordinator is auto-promoted. The Team remains - /// Running and is then reconciled from Quiescence facts. + /// Apply failed-Turn recovery after crash cleanup has converted a stranded + /// Member session to Abandoned. Recovery proceeds only when one persisted + /// running TaskExecution identifies the exact Task; a missing or ambiguous + /// binding fails closed instead of mutating every Task owned by the Member. pub fn requeue_abandoned_member_tasks_on_startup() -> Result { let mut changed = 0usize; for run in Self::list_running_runs(100)? { @@ -91,22 +90,29 @@ impl AgentOrgRunStore { let Some(member_id) = worker.member_id.as_deref() else { continue; }; - let reservation = - crate::coordination::agent_org_watchdog::reserve_task_failure_recovery( - &run.id, member_id, - )?; - let operation = if reservation.exhausted { - crate::coordination::agent_org_tasks::SystemTaskOperation::RecoveryFail - } else { - crate::coordination::agent_org_tasks::SystemTaskOperation::RecoveryRequeue + let failed_turn_intent_id = { + let conn = get_connection().map_err(|error| error.to_string())?; + crate::coordination::agent_org_turn_contexts::unique_running_task_execution_turn_for_recovery( + &conn, + &run.id, + &worker.session_id, + member_id, + )? }; - let actor = crate::coordination::agent_org_tasks::SystemArchiveOrRecovery::new( - reservation.token, - reservation.generation, - operation, - )?; - changed += - AgentOrgTaskStore::recover_owner_failure(actor, &run.id, member_id)?.len(); + let Some(failed_turn_intent_id) = failed_turn_intent_id else { + tracing::warn!( + run_id = %run.id, + session_id = %worker.session_id, + member_id, + "abandoned Agent Org Member has no unique persisted TaskExecution; refusing Task recovery" + ); + continue; + }; + changed += AgentOrgTaskStore::recover_task_execution_failure( + &worker.session_id, + &failed_turn_intent_id, + )? + .len(); } } Ok(changed) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs index e9952e07bf..5b095b9479 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/actor.rs @@ -158,7 +158,7 @@ impl SystemArchiveOrRecovery { &self, conn: &rusqlite::Connection, org_run_id: &str, - owner_member_id: &str, + target_key: &str, ) -> Result { validate_run_and_generation(conn, org_run_id, Some(self.generation))?; let action_kind = match self.operation { @@ -173,7 +173,7 @@ impl SystemArchiveOrRecovery { FROM agent_org_runtime_recovery_attempts WHERE org_run_id=?1 AND reservation_token=?2 AND action_kind=?3 AND target_key=?4", - params![org_run_id, &self.receipt_id, action_kind, owner_member_id], + params![org_run_id, &self.receipt_id, action_kind, target_key], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() @@ -216,10 +216,6 @@ impl SystemArchiveOrRecovery { SystemTaskOperation::ShutdownRelease => "task_shutdown_release", } } - - pub(crate) const fn operation(&self) -> SystemTaskOperation { - self.operation - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs index 67441eb76a..25e3e8671b 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs @@ -19,7 +19,16 @@ fn fixture() -> Fixture { let sandbox = test_helpers::test_env::sandbox(); let conn = get_connection().expect("PR4 test database"); conn.execute_batch( - "CREATE TABLE session_turn_intents ( + "CREATE TABLE agent_sessions ( + session_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + agent_definition_id TEXT, + org_member_id TEXT + ); + CREATE TABLE session_turn_intents ( session_id TEXT NOT NULL, turn_intent_id TEXT NOT NULL, client_message_id TEXT, @@ -62,10 +71,53 @@ fn fixture() -> Fixture { params![RUN_ID, ROOT_SESSION, snapshot, now], ) .expect("running Team"); + for (session_id, member_id, agent_id) in [ + (MEMBER_A_SESSION, MEMBER_A, "agent-a"), + (MEMBER_B_SESSION, MEMBER_B, "agent-b"), + ] { + insert_member_session(&conn, member_id, agent_id, session_id, 1); + } insert_coordinator_context(&conn, COORDINATOR_TURN, 1); Fixture { _sandbox: sandbox } } +fn insert_member_session( + conn: &rusqlite::Connection, + member_id: &str, + agent_id: &str, + session_id: &str, + generation: i64, +) { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_sessions( + session_id,name,status,created_at,updated_at, + agent_definition_id,org_member_id + ) VALUES (?1,?2,'idle',?3,?3,?4,?2)", + params![session_id, member_id, now, agent_id], + ) + .expect("canonical Member session"); + insert_member_materialization(conn, member_id, agent_id, session_id, generation); +} + +fn insert_member_materialization( + conn: &rusqlite::Connection, + member_id: &str, + agent_id: &str, + session_id: &str, + generation: i64, +) { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_org_runtime_member_materializations( + org_run_id,member_id,agent_id,generation,session_id, + authority_class,status,created_at,updated_at + ) VALUES (?1,?2,?3,?4,?5,'formal','succeeded',?6,?6)", + params![RUN_ID, member_id, agent_id, generation, session_id, now], + ) + .expect("canonical Member materialization"); +} + fn insert_base_turn(conn: &rusqlite::Connection, session_id: &str, turn_id: &str) { let now = chrono::Utc::now().to_rfc3339(); conn.execute( @@ -174,6 +226,82 @@ fn start(task_id: &str, session_id: &str, turn_id: &str) -> TaskMutationOutcome .0 } +fn assign_pending(task_id: &str, owner_member_id: &str) -> Task { + let current = AgentOrgTaskStore::get(RUN_ID, task_id) + .unwrap() + .expect("Task to assign"); + AgentOrgTaskStore::patch_pending_with_transactional_effects( + graph_actor(), + RUN_ID, + task_id, + ¤t.updated_at, + PendingTaskGraphPatch { + owner: Some(Some(owner_member_id.to_string())), + ..Default::default() + }, + |_tx, _outcome, _tasks| Ok(()), + ) + .expect("assign pending Task") + .0 + .current +} + +fn recovery_attempts(task_id: &str) -> i64 { + get_connection() + .unwrap() + .query_row( + "SELECT attempts FROM agent_org_runtime_recovery_attempts + WHERE org_run_id=?1 AND action_kind='task_failure_recovery' + AND target_key=?2", + params![RUN_ID, task_id], + |row| row.get(0), + ) + .optional() + .unwrap() + .unwrap_or(0) +} + +fn recovery_event_count() -> i64 { + get_connection() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM agent_org_runtime_recovery_attempts + WHERE org_run_id=?1 AND action_kind='task_failure_recovery_event'", + [RUN_ID], + |row| row.get(0), + ) + .unwrap() +} + +fn fail_turn(session_id: &str, turn_id: &str) -> Vec { + AgentOrgTaskStore::recover_task_execution_failure(session_id, turn_id) + .expect("recover exact failed TaskExecution") +} + +fn bind_and_start( + conn: &rusqlite::Connection, + member_id: &str, + session_id: &str, + task_id: &str, + turn_id: &str, + generation: i64, +) { + insert_owner_context(conn, member_id, session_id, turn_id, task_id, generation); + start(task_id, session_id, turn_id); +} + +fn bind_start_and_fail( + conn: &rusqlite::Connection, + member_id: &str, + session_id: &str, + task_id: &str, + turn_id: &str, + generation: i64, +) -> Vec { + bind_and_start(conn, member_id, session_id, task_id, turn_id, generation); + fail_turn(session_id, turn_id) +} + #[test] fn canonical_schema_and_five_state_semantics_are_frozen() { let _fixture = fixture(); @@ -746,145 +874,290 @@ fn ten_thousand_task_history_uses_bounded_keyset_pages() { } #[test] -fn recovery_receipt_requeues_then_exhaustion_fails_without_coordinator_owner() { +fn recovery_budget_is_per_task_and_never_resets_on_owner_generation_or_reopen() { let _fixture = fixture(); - create(pending("recover", Some(MEMBER_A), vec![])); let conn = get_connection().unwrap(); - insert_owner_context( + create(pending("task-a", None, vec![])); + create(pending("task-b", None, vec![])); + assign_pending("task-a", MEMBER_A); + assign_pending("task-b", MEMBER_A); + + for (task_id, turn_id) in [("task-a", "turn-a-1"), ("task-b", "turn-b-1")] { + assert_eq!( + bind_start_and_fail(&conn, MEMBER_A, MEMBER_A_SESSION, task_id, turn_id, 1)[0].status, + TaskStatus::Pending + ); + } + assert_eq!(recovery_attempts("task-a"), 1); + assert_eq!(recovery_attempts("task-b"), 1); + + crate::coordination::agent_org_watchdog::init_schema(&conn).unwrap(); + assign_pending("task-a", MEMBER_B); + bind_start_and_fail(&conn, MEMBER_B, MEMBER_B_SESSION, "task-a", "turn-a-2", 1); + assert_eq!(recovery_attempts("task-a"), 2); + + assign_pending("task-a", MEMBER_A); + conn.execute( + "UPDATE agent_org_runtime_runs SET activation_generation=2 WHERE id=?1", + [RUN_ID], + ) + .unwrap(); + let restarted_session = "pr4-member-a-session-gen2"; + insert_member_session(&conn, MEMBER_A, "agent-a", restarted_session, 2); + bind_start_and_fail(&conn, MEMBER_A, restarted_session, "task-a", "turn-a-3", 2); + assert_eq!(recovery_attempts("task-a"), 3); + assert_eq!(recovery_attempts("task-b"), 1); +} + +#[test] +fn first_three_runtime_failures_requeue_and_fourth_is_terminal() { + let _fixture = fixture(); + let conn = get_connection().unwrap(); + create(pending("recover", None, vec![])); + + for attempt in 1..=4 { + assign_pending("recover", MEMBER_A); + let turn_id = format!("turn-recover-{attempt}"); + let recovered = + bind_start_and_fail(&conn, MEMBER_A, MEMBER_A_SESSION, "recover", &turn_id, 1); + assert_eq!(recovery_attempts("recover"), attempt); + if attempt <= 3 { + assert_eq!(recovered[0].status, TaskStatus::Pending); + assert_eq!(recovered[0].owner, None); + assert!(eligible_member_ids(&recovered[0]).contains(&MEMBER_A.to_string())); + } else { + assert_eq!(recovered[0].status, TaskStatus::Failed); + assert_eq!(recovered[0].owner.as_deref(), Some(MEMBER_A)); + assert_eq!( + recovered[0].failure_reason.as_ref().unwrap().code, + "system.recovery_budget_exhausted" + ); + } + } +} + +#[test] +fn replacement_and_explicit_owner_failure_do_not_share_or_consume_budget() { + let _fixture = fixture(); + let conn = get_connection().unwrap(); + create(pending("original", None, vec![])); + assign_pending("original", MEMBER_A); + bind_start_and_fail( &conn, MEMBER_A, MEMBER_A_SESSION, - "turn-recover", - "recover", + "original", + "turn-original-1", 1, ); - start("recover", MEMBER_A_SESSION, "turn-recover"); - let reservation = - crate::coordination::agent_org_watchdog::reserve_task_failure_recovery(RUN_ID, MEMBER_A) - .unwrap(); - let wrong_actor = SystemArchiveOrRecovery::new( - reservation.token.clone(), - reservation.generation, - SystemTaskOperation::RecoveryFail, - ) - .unwrap(); - let mismatch = AgentOrgTaskStore::recover_owner_failure(wrong_actor, RUN_ID, MEMBER_A) - .expect_err("a non-exhausted durable receipt cannot fail the Task"); - assert_eq!(mismatch, "system_task_recovery_operation_mismatch"); - assert_eq!( - AgentOrgTaskStore::get(RUN_ID, "recover") - .unwrap() - .unwrap() - .status, - TaskStatus::InProgress + assign_pending("original", MEMBER_A); + bind_and_start( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "original", + "turn-original-2", + 1, ); - let actor = SystemArchiveOrRecovery::new( - reservation.token, - reservation.generation, - SystemTaskOperation::RecoveryRequeue, - ) - .unwrap(); - let recovered = AgentOrgTaskStore::recover_owner_failure(actor, RUN_ID, MEMBER_A).unwrap(); - assert_eq!(recovered.len(), 1); - assert_eq!(recovered[0].status, TaskStatus::Pending); - assert_eq!(recovered[0].owner, None); - assert!(eligible_member_ids(&recovered[0]).contains(&MEMBER_A.to_string())); - - let recovered_pending = recovered[0].clone(); - AgentOrgTaskStore::patch_pending_with_transactional_effects( + let original = AgentOrgTaskStore::get(RUN_ID, "original").unwrap().unwrap(); + AgentOrgTaskStore::cancel_and_replace_with_transactional_effects( graph_actor(), RUN_ID, - "recover", - &recovered_pending.updated_at, - PendingTaskGraphPatch { - owner: Some(Some(MEMBER_A.to_string())), - ..Default::default() + "original", + &original.updated_at, + TaskTerminalReason { + code: "scope.changed".to_string(), + message: "replace failed work".to_string(), }, - |_tx, _outcome, _tasks| Ok(()), + pending("replacement", Some(MEMBER_B), vec![]), + |_tx, _outcome, _replacement, _tasks| Ok(()), ) .unwrap(); - insert_owner_context( + bind_start_and_fail( + &conn, + MEMBER_B, + MEMBER_B_SESSION, + "replacement", + "turn-replacement-1", + 1, + ); + assert_eq!(recovery_attempts("original"), 1); + assert_eq!(recovery_attempts("replacement"), 1); + + create(pending("explicit-failure", Some(MEMBER_A), vec![])); + bind_and_start( &conn, MEMBER_A, MEMBER_A_SESSION, - "turn-recover-again", - "recover", + "explicit-failure", + "turn-explicit-failure", 1, ); - start("recover", MEMBER_A_SESSION, "turn-recover-again"); - conn.execute( - "UPDATE agent_org_runtime_recovery_attempts SET attempts=3 - WHERE org_run_id=?1 AND action_kind='task_failure_recovery' AND target_key=?2", - params![RUN_ID, MEMBER_A], + AgentOrgTaskStore::owner_fail_with_transactional_effects( + owner_actor(MEMBER_A_SESSION, "turn-explicit-failure"), + RUN_ID, + "explicit-failure", + TaskTerminalReason { + code: "execution.failed".to_string(), + message: "Owner reported a terminal failure".to_string(), + }, + |_tx, _outcome, _tasks| Ok(()), ) .unwrap(); - let reservation = - crate::coordination::agent_org_watchdog::reserve_task_failure_recovery(RUN_ID, MEMBER_A) - .unwrap(); - assert!(reservation.exhausted); - let wrong_actor = SystemArchiveOrRecovery::new( - reservation.token.clone(), - reservation.generation, - SystemTaskOperation::RecoveryRequeue, + assert_eq!(recovery_attempts("explicit-failure"), 0); + crate::coordination::agent_org_watchdog::clear_rewake_budget(RUN_ID, MEMBER_A).unwrap(); + assert_eq!(recovery_attempts("original"), 1); + assert_eq!(recovery_attempts("replacement"), 1); +} + +#[test] +fn recovery_replay_is_idempotent_and_only_mutates_the_bound_task() { + let _fixture = fixture(); + let conn = get_connection().unwrap(); + for (task_id, turn_id) in [("exact", "turn-exact"), ("sibling", "turn-sibling")] { + create(pending(task_id, Some(MEMBER_A), vec![])); + bind_and_start(&conn, MEMBER_A, MEMBER_A_SESSION, task_id, turn_id, 1); + } + let ambiguous = crate::coordination::agent_org_turn_contexts::unique_running_task_execution_turn_for_recovery( + &conn, + RUN_ID, + MEMBER_A_SESSION, + MEMBER_A, ) - .unwrap(); - let mismatch = AgentOrgTaskStore::recover_owner_failure(wrong_actor, RUN_ID, MEMBER_A) - .expect_err("an exhausted durable receipt cannot requeue the Task"); - assert_eq!(mismatch, "system_task_recovery_operation_mismatch"); - let actor = SystemArchiveOrRecovery::new( - reservation.token, - reservation.generation, - SystemTaskOperation::RecoveryFail, + .expect_err("startup must refuse an ambiguous session-level recovery target"); + assert!( + ambiguous.contains("multiple running TaskExecution"), + "{ambiguous}" + ); + + assert_eq!(fail_turn(MEMBER_A_SESSION, "turn-exact").len(), 1); + assert!(fail_turn(MEMBER_A_SESSION, "turn-exact").is_empty()); + assert_eq!(recovery_attempts("exact"), 1); + assert_eq!(recovery_event_count(), 1); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "sibling") + .unwrap() + .unwrap() + .status, + TaskStatus::InProgress + ); + + let missing = + AgentOrgTaskStore::recover_task_execution_failure(MEMBER_A_SESSION, "missing-turn") + .expect_err("a session without the exact failed Turn cannot consume budget"); + assert!(missing.contains("missing companion context"), "{missing}"); + assert_eq!(recovery_event_count(), 1); + + conn.execute( + "UPDATE agent_org_runtime_runs SET activation_generation=2 WHERE id=?1", + [RUN_ID], ) .unwrap(); - let failed = AgentOrgTaskStore::recover_owner_failure(actor, RUN_ID, MEMBER_A).unwrap(); - assert_eq!(failed[0].status, TaskStatus::Failed); + insert_member_session(&conn, MEMBER_A, "agent-a", "pr4-member-a-session-gen2", 2); + let stale = AgentOrgTaskStore::recover_task_execution_failure(MEMBER_A_SESSION, "turn-sibling") + .expect_err("an old activation generation must fail closed"); + assert!(stale.contains("generation"), "{stale}"); + assert_eq!(recovery_attempts("sibling"), 0); + assert_eq!(recovery_event_count(), 1); +} + +#[test] +fn an_unprocessed_old_turn_cannot_recover_a_new_execution_of_the_same_task() { + let _fixture = fixture(); + let conn = get_connection().unwrap(); + create(pending("restarted", Some(MEMBER_A), vec![])); + bind_and_start( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "restarted", + "turn-restarted-old", + 1, + ); + AgentOrgTaskStore::dispose_open_tasks_for_shutdown(RUN_ID, MEMBER_A).unwrap(); + assign_pending("restarted", MEMBER_A); + bind_and_start( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "restarted", + "turn-restarted-new", + 1, + ); + + let stale = + AgentOrgTaskStore::recover_task_execution_failure(MEMBER_A_SESSION, "turn-restarted-old") + .expect_err("an old unprocessed Turn cannot recover the current execution"); + assert!(stale.contains("turn_or_target_changed"), "{stale}"); + assert_eq!(recovery_attempts("restarted"), 0); + assert_eq!(recovery_event_count(), 0); assert_eq!( - failed[0].failure_reason.as_ref().unwrap().code, - "system.recovery_budget_exhausted" + fail_turn(MEMBER_A_SESSION, "turn-restarted-new")[0].status, + TaskStatus::Pending ); - assert_ne!(failed[0].owner.as_deref(), Some("coordinator")); + assert_eq!(recovery_attempts("restarted"), 1); +} - create(pending("corrupt-recovery", Some(MEMBER_B), vec![])); - insert_owner_context( +#[test] +fn recovery_mutation_failure_rolls_back_budget_and_concurrent_replay_counts_once() { + let _fixture = fixture(); + let conn = get_connection().unwrap(); + create(pending("corrupt", Some(MEMBER_B), vec![])); + bind_and_start( &conn, MEMBER_B, MEMBER_B_SESSION, - "turn-corrupt-recovery", - "corrupt-recovery", + "corrupt", + "turn-corrupt", 1, ); - start( - "corrupt-recovery", - MEMBER_B_SESSION, - "turn-corrupt-recovery", - ); conn.execute( "UPDATE agent_org_runtime_tasks SET metadata_json='{\"eligible_member_ids\":\"member-b\"}' - WHERE org_run_id=?1 AND id='corrupt-recovery'", + WHERE org_run_id=?1 AND id='corrupt'", [RUN_ID], ) .unwrap(); - let reservation = - crate::coordination::agent_org_watchdog::reserve_task_failure_recovery(RUN_ID, MEMBER_B) - .unwrap(); - let actor = SystemArchiveOrRecovery::new( - reservation.token, - reservation.generation, - SystemTaskOperation::RecoveryRequeue, - ) - .unwrap(); - let error = AgentOrgTaskStore::recover_owner_failure(actor, RUN_ID, MEMBER_B) - .expect_err("system recovery must not silently repair corrupt eligibility metadata"); + let error = AgentOrgTaskStore::recover_task_execution_failure(MEMBER_B_SESSION, "turn-corrupt") + .expect_err("Task mutation failure must roll back receipt and budget"); assert_eq!(error, "eligible_member_ids must be an array"); - let persisted_status: String = conn - .query_row( - "SELECT status FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND id='corrupt-recovery'", - [RUN_ID], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(persisted_status, TaskStatus::InProgress.as_wire()); + assert_eq!(recovery_attempts("corrupt"), 0); + assert_eq!(recovery_event_count(), 0); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "corrupt") + .unwrap() + .unwrap() + .status, + TaskStatus::InProgress + ); + + create(pending("concurrent", Some(MEMBER_A), vec![])); + bind_and_start( + &conn, + MEMBER_A, + MEMBER_A_SESSION, + "concurrent", + "turn-concurrent", + 1, + ); + let workers = (0..2) + .map(|_| { + std::thread::spawn(|| { + AgentOrgTaskStore::recover_task_execution_failure( + MEMBER_A_SESSION, + "turn-concurrent", + ) + .unwrap() + .len() + }) + }) + .collect::>(); + let mut mutation_counts = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + mutation_counts.sort_unstable(); + assert_eq!(mutation_counts, vec![0, 1]); + assert_eq!(recovery_attempts("concurrent"), 1); + assert_eq!(recovery_event_count(), 1); } diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs index 1d1c2b1636..13994171be 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/requeue.rs @@ -1,8 +1,9 @@ //! Receipt-gated system recovery for failed or intentionally stopped Owners. use database::db::{get_connection, with_sessions_writer}; -use rusqlite::params; +use rusqlite::{params, OptionalExtension}; +use super::super::actor::TaskActorAudit; use super::super::helpers::{ encode_metadata, encode_optional_json, insert_task_history_event_as, now_rfc3339, row_to_task, SELECT_COLUMNS, @@ -16,19 +17,133 @@ use super::validation::validate_task_model_invariants; use super::AgentOrgTaskStore; impl AgentOrgTaskStore { - pub(crate) fn recover_owner_failure( - actor: SystemArchiveOrRecovery, - org_run_id: &str, - owner_member_id: &str, + pub(crate) fn recover_task_execution_failure( + session_id: &str, + failed_turn_intent_id: &str, ) -> Result, String> { - let mode = match actor.operation() { - SystemTaskOperation::RecoveryRequeue => RecoveryMode::Failure { exhausted: false }, - SystemTaskOperation::RecoveryFail => RecoveryMode::Failure { exhausted: true }, - SystemTaskOperation::ShutdownRelease => { - return Err("system_task_operation_requires_failure_recovery".to_string()) + let updated = with_sessions_writer(|| -> Result, String> { + let mut conn = get_connection().map_err(|error| error.to_string())?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(|error| error.to_string())?; + let context = crate::coordination::agent_org_turn_contexts::require_task_failure_recovery_context_with_connection( + &tx, + session_id, + failed_turn_intent_id, + )?; + let task_id = context + .task_id + .as_deref() + .ok_or_else(|| "task_failure_recovery_context_missing_task".to_string())?; + let owner_member_id = context + .owner_member_id + .as_deref() + .ok_or_else(|| "task_failure_recovery_context_missing_owner".to_string())?; + let activation_generation = context + .activation_generation + .ok_or_else(|| "task_failure_recovery_context_missing_generation".to_string())?; + let fingerprint = + crate::coordination::agent_org_watchdog::task_failure_recovery_fingerprint( + task_id, + failed_turn_intent_id, + activation_generation, + ); + if crate::coordination::agent_org_watchdog::task_failure_recovery_already_processed_with_connection( + &tx, + &context.org_run_id, + &fingerprint, + )? { + return Ok(Vec::new()); } - }; - recover_owned_tasks(actor, org_run_id, owner_member_id, mode) + + // Revalidate after the idempotency probe so a replay of an already + // committed failure is a no-op, while an unprocessed late callback + // still fails closed on Task/Owner/run/snapshot/generation drift. + crate::coordination::agent_org_turn_contexts::revalidate_context_with_connection( + &tx, + session_id, + failed_turn_intent_id, + )?; + let sql = format!( + "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks task + WHERE task.org_run_id=?1 AND task.id=?2 AND task.owner=?3 + AND task.status='in_progress' + AND EXISTS ( + SELECT 1 FROM agent_org_runtime_task_events event + WHERE event.org_run_id=task.org_run_id + AND event.task_id=task.id + AND event.previous_status='pending' + AND event.next_status='in_progress' + AND event.actor_kind='owner_execution' + AND event.actor_member_id=?3 + AND event.source_turn_intent_id=?4 + AND event.created_at=task.updated_at + AND event.rowid=( + SELECT MAX(latest.rowid) + FROM agent_org_runtime_task_events latest + WHERE latest.org_run_id=task.org_run_id + AND latest.task_id=task.id + ) + )" + ); + let previous = tx + .query_row( + &sql, + params![ + &context.org_run_id, + task_id, + owner_member_id, + failed_turn_intent_id + ], + row_to_task, + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| { + format!( + "task_failure_recovery_turn_or_target_changed: {}/{}", + context.org_run_id, task_id + ) + })?; + let reservation = crate::coordination::agent_org_watchdog::reserve_task_failure_recovery_with_connection( + &tx, + &context.org_run_id, + task_id, + &fingerprint, + activation_generation, + )?; + let operation = if reservation.exhausted { + SystemTaskOperation::RecoveryFail + } else { + SystemTaskOperation::RecoveryRequeue + }; + let actor = + SystemArchiveOrRecovery::new(reservation.token, reservation.generation, operation)?; + let mut audit = actor.validate(&tx, &context.org_run_id, task_id)?; + audit.turn_intent_id = Some(failed_turn_intent_id.to_string()); + let updated = recover_task_in_tx( + &tx, + previous, + owner_member_id, + RecoveryMode::Failure { + exhausted: reservation.exhausted, + }, + &audit, + )?; + release_reservation_in_tx(&tx, &context.org_run_id, &actor, task_id)?; + crate::coordination::agent_org_runs::bump_work_revision_in_tx( + &tx, + &context.org_run_id, + )?; + tx.commit().map_err(|error| error.to_string())?; + Ok(vec![updated]) + })?; + if let Some(task) = updated.first() { + crate::coordination::agent_org_run_events::notify_agent_org_run_changed( + &task.org_run_id, + ); + } + Ok(updated) } pub(crate) fn release_owner_for_shutdown( @@ -36,26 +151,7 @@ impl AgentOrgTaskStore { org_run_id: &str, owner_member_id: &str, ) -> Result, String> { - recover_owned_tasks(actor, org_run_id, owner_member_id, RecoveryMode::Shutdown) - } - - #[cfg(test)] - pub fn requeue_in_progress_for_owner( - org_run_id: &str, - owner_member_id: &str, - ) -> Result, String> { - let reservation = crate::coordination::agent_org_watchdog::reserve_task_failure_recovery( - org_run_id, - owner_member_id, - )?; - let operation = if reservation.exhausted { - SystemTaskOperation::RecoveryFail - } else { - SystemTaskOperation::RecoveryRequeue - }; - let actor = - SystemArchiveOrRecovery::new(reservation.token, reservation.generation, operation)?; - Self::recover_owner_failure(actor, org_run_id, owner_member_id) + release_owned_tasks_for_shutdown(actor, org_run_id, owner_member_id) } #[cfg(test)] @@ -82,11 +178,10 @@ enum RecoveryMode { Shutdown, } -fn recover_owned_tasks( +fn release_owned_tasks_for_shutdown( actor: SystemArchiveOrRecovery, org_run_id: &str, owner_member_id: &str, - mode: RecoveryMode, ) -> Result, String> { let updated = with_sessions_writer(|| -> Result, String> { let mut conn = get_connection().map_err(|error| error.to_string())?; @@ -94,15 +189,9 @@ fn recover_owned_tasks( .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|error| error.to_string())?; let audit = actor.validate(&tx, org_run_id, owner_member_id)?; - let receipt_id = actor.receipt_id().to_string(); - let action_kind = actor.action_kind(); - let status_predicate = match mode { - RecoveryMode::Failure { .. } => "status='in_progress'", - RecoveryMode::Shutdown => "status IN ('pending','in_progress')", - }; let sql = format!( "SELECT {SELECT_COLUMNS} FROM agent_org_runtime_tasks - WHERE org_run_id=?1 AND owner=?2 AND {status_predicate} + WHERE org_run_id=?1 AND owner=?2 AND status IN ('pending','in_progress') ORDER BY created_at ASC,id ASC" ); let owned = { @@ -113,94 +202,17 @@ fn recover_owned_tasks( rows.map(|row| row.map_err(|error| error.to_string())) .collect::, _>>()? }; - let now = now_rfc3339(); let mut updated = Vec::with_capacity(owned.len()); for previous in owned { - let mut task = previous.clone(); - let exhausted = matches!(mode, RecoveryMode::Failure { exhausted: true }); - if exhausted { - task.status = TaskStatus::Failed; - task.failure_reason = Some(TaskTerminalReason { - code: "system.recovery_budget_exhausted".to_string(), - message: "Automatic Owner recovery budget was exhausted".to_string(), - }); - } else { - task.status = TaskStatus::Pending; - task.owner = None; - ensure_recovery_candidate(&mut task, owner_member_id)?; - } - task.updated_at = now.clone(); - validate_task_model_invariants(&tx, &task)?; - let metadata_json = encode_metadata(task.metadata.as_ref())?; - let failure_reason_json = - encode_optional_json("task failure reason", task.failure_reason.as_ref())?; - let changed = tx - .execute( - "UPDATE agent_org_runtime_tasks - SET owner=?1,status=?2,metadata_json=?3,failure_reason_json=?4, - output_json=NULL,cancel_reason_json=NULL,updated_at=?5 - WHERE org_run_id=?6 AND id=?7 AND owner=?8 - AND status IN ('pending','in_progress')", - params![ - task.owner.as_deref(), - task.status.as_wire(), - metadata_json.as_deref(), - failure_reason_json.as_deref(), - &task.updated_at, - org_run_id, - &task.id, - owner_member_id, - ], - ) - .map_err(|error| error.to_string())?; - if changed != 1 { - return Err(format!( - "task_mutation_conflict: recovery target {} changed", - task.id - )); - } - insert_task_history_event_as( + updated.push(recover_task_in_tx( &tx, - org_run_id, - &task.id, - TASK_EVENT_RELEASED, - Some(&previous), - &task, + previous, + owner_member_id, + RecoveryMode::Shutdown, &audit, - )?; - let annotation_body = if exhausted { - "Automatic recovery budget exhausted; Task was failed" - } else if matches!(mode, RecoveryMode::Shutdown) { - "Owner shutdown released Task to the explicit assignment pool" - } else { - "Owner turn failed; Task was restored to ownerless pending" - }; - tx.execute( - "INSERT INTO agent_org_runtime_task_annotations( - id,org_run_id,task_id,kind,body,actor_kind, - actor_participant_id,source_turn_intent_id,created_at - ) VALUES (?1,?2,?3,?4,?5,?6,?7,NULL,?8)", - params![ - uuid::Uuid::new_v4().to_string(), - org_run_id, - &task.id, - TaskAnnotationKind::AuditNote.as_wire(), - annotation_body, - audit.kind.as_wire(), - &audit.participant_id, - &now, - ], - ) - .map_err(|error| error.to_string())?; - updated.push(task); + )?); } - tx.execute( - "UPDATE agent_org_runtime_recovery_attempts SET reservation_token=NULL - WHERE org_run_id=?1 AND reservation_token=?2 - AND action_kind=?3 AND target_key=?4", - params![org_run_id, receipt_id, action_kind, owner_member_id], - ) - .map_err(|error| error.to_string())?; + release_reservation_in_tx(&tx, org_run_id, &actor, owner_member_id)?; if !updated.is_empty() { crate::coordination::agent_org_runs::bump_work_revision_in_tx(&tx, org_run_id)?; } @@ -213,6 +225,118 @@ fn recover_owned_tasks( Ok(updated) } +fn recover_task_in_tx( + tx: &rusqlite::Transaction<'_>, + previous: Task, + owner_member_id: &str, + mode: RecoveryMode, + audit: &TaskActorAudit, +) -> Result { + let mut task = previous.clone(); + let exhausted = matches!(mode, RecoveryMode::Failure { exhausted: true }); + if exhausted { + task.status = TaskStatus::Failed; + task.failure_reason = Some(TaskTerminalReason { + code: "system.recovery_budget_exhausted".to_string(), + message: "Automatic Owner recovery budget was exhausted".to_string(), + }); + } else { + task.status = TaskStatus::Pending; + task.owner = None; + ensure_recovery_candidate(&mut task, owner_member_id)?; + } + task.updated_at = now_rfc3339(); + validate_task_model_invariants(tx, &task)?; + let metadata_json = encode_metadata(task.metadata.as_ref())?; + let failure_reason_json = + encode_optional_json("task failure reason", task.failure_reason.as_ref())?; + let changed = tx + .execute( + "UPDATE agent_org_runtime_tasks + SET owner=?1,status=?2,metadata_json=?3,failure_reason_json=?4, + output_json=NULL,cancel_reason_json=NULL,updated_at=?5 + WHERE org_run_id=?6 AND id=?7 AND owner=?8 + AND status IN ('pending','in_progress')", + params![ + task.owner.as_deref(), + task.status.as_wire(), + metadata_json.as_deref(), + failure_reason_json.as_deref(), + &task.updated_at, + &task.org_run_id, + &task.id, + owner_member_id, + ], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err(format!( + "task_mutation_conflict: recovery target {} changed", + task.id + )); + } + insert_task_history_event_as( + tx, + &task.org_run_id, + &task.id, + TASK_EVENT_RELEASED, + Some(&previous), + &task, + audit, + )?; + let annotation_body = if exhausted { + "Automatic recovery budget exhausted; Task was failed" + } else if matches!(mode, RecoveryMode::Shutdown) { + "Owner shutdown released Task to the explicit assignment pool" + } else { + "Owner turn failed; Task was restored to ownerless pending" + }; + tx.execute( + "INSERT INTO agent_org_runtime_task_annotations( + id,org_run_id,task_id,kind,body,actor_kind, + actor_participant_id,source_turn_intent_id,created_at + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", + params![ + uuid::Uuid::new_v4().to_string(), + &task.org_run_id, + &task.id, + TaskAnnotationKind::AuditNote.as_wire(), + annotation_body, + audit.kind.as_wire(), + &audit.participant_id, + audit.turn_intent_id.as_deref(), + &task.updated_at, + ], + ) + .map_err(|error| error.to_string())?; + Ok(task) +} + +fn release_reservation_in_tx( + tx: &rusqlite::Transaction<'_>, + org_run_id: &str, + actor: &SystemArchiveOrRecovery, + target_key: &str, +) -> Result<(), String> { + let changed = tx + .execute( + "UPDATE agent_org_runtime_recovery_attempts SET reservation_token=NULL + WHERE org_run_id=?1 AND reservation_token=?2 + AND action_kind=?3 AND target_key=?4", + params![ + org_run_id, + actor.receipt_id(), + actor.action_kind(), + target_key + ], + ) + .map_err(|error| error.to_string())?; + if changed != 1 { + return Err("system_task_recovery_receipt_release_failed".to_string()); + } + Ok(()) +} + fn ensure_recovery_candidate(task: &mut Task, owner_member_id: &str) -> Result<(), String> { let mut metadata = match task.metadata.take() { Some(serde_json::Value::Object(object)) => object, diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs index 671981be94..d3551ae848 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/tests.rs @@ -1269,55 +1269,6 @@ fn authorized_mutation_precondition_rejects_stale_update_and_delete() { assert_eq!(stored.description, ""); } -#[test] -fn requeue_in_progress_for_owner_releases_to_coordinator_assignment() { - let _sandbox = task_store_sandbox(); - let run_id = format!("run-{}", uuid::Uuid::new_v4()); - let mut params = make_eligible_params(&run_id, "t-1", "claim me", &["member-alpha"]); - params.owner = Some("member-alpha".into()); - params.status = TaskStatus::InProgress; - AgentOrgTaskStore::create(params).unwrap(); - - let requeued = AgentOrgTaskStore::requeue_in_progress_for_owner(&run_id, "member-alpha") - .expect("requeue in-progress work"); - - assert_eq!(requeued.len(), 1); - assert_eq!(requeued[0].owner, None); - assert_eq!(requeued[0].status, TaskStatus::Pending); - let stored = AgentOrgTaskStore::get(&run_id, "t-1").unwrap().unwrap(); - assert_eq!(stored.owner, None); - assert_eq!(stored.status, TaskStatus::Pending); -} - -#[test] -fn requeue_in_progress_for_owner_preserves_eligibility_metadata() { - let _sandbox = task_store_sandbox(); - let run_id = format!("run-{}", uuid::Uuid::new_v4()); - let mut params = make_eligible_params( - &run_id, - "t-shared", - "claim me", - &["member-alpha", "member-beta"], - ); - params.owner = Some("member-alpha".into()); - params.status = TaskStatus::InProgress; - AgentOrgTaskStore::create(params).unwrap(); - - let requeued = AgentOrgTaskStore::requeue_in_progress_for_owner(&run_id, "member-alpha") - .expect("requeue in-progress work"); - - assert_eq!(requeued.len(), 1); - assert_eq!( - requeued[0].owner, None, - "failed owner is removed before coordinator reassignment" - ); - assert_eq!(requeued[0].status, TaskStatus::Pending); - assert_eq!( - eligible_member_ids(&requeued[0]), - vec!["member-alpha".to_string(), "member-beta".to_string()] - ); -} - #[test] fn task_history_records_create_update_and_release_for_reassignment() { let _sandbox = task_store_sandbox(); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs index d06cca8746..a8d8336676 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs @@ -517,6 +517,108 @@ pub(crate) fn revalidate_context_with_connection( Ok(context) } +/// Resolve the persisted TaskExecution identity used by failure recovery. +/// A session-level status or Member id is never sufficient: the failed Turn +/// must name one Task, Owner, run, source, and activation generation. +pub(crate) fn require_task_failure_recovery_context_with_connection( + conn: &Connection, + session_id: &str, + turn_intent_id: &str, +) -> Result { + let context = require_context_with_connection(conn, session_id, turn_intent_id)?; + let task_id = context + .task_id + .as_deref() + .ok_or_else(|| invariant_error("failure recovery context has no task_id".to_string()))?; + let owner_member_id = context.owner_member_id.as_deref().ok_or_else(|| { + invariant_error("failure recovery context has no owner_member_id".to_string()) + })?; + if context.turn_kind != AgentOrgTurnKind::TaskExecution + || context.participant_id != owner_member_id + || context.dispatch_member_id.as_deref() != Some(owner_member_id) + || context.source_kind != AgentOrgTurnSourceKind::Task + || context.source_id != task_id + || context + .activation_generation + .filter(|value| *value > 0) + .is_none() + { + return Err(invariant_error( + "failure recovery context is not an exact TaskExecution binding".to_string(), + )); + } + let base: Option<(Option, String)> = conn + .query_row( + "SELECT org_run_id,status FROM session_turn_intents + WHERE session_id=?1 AND turn_intent_id=?2", + params![session_id, turn_intent_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((base_run_id, base_status)) = base else { + return Err(invariant_error( + "failure recovery context has no base Turn".to_string(), + )); + }; + if base_run_id.as_deref() != Some(context.org_run_id.as_str()) + || !matches!(base_status.as_str(), "running" | "failed") + { + return Err(invariant_error(format!( + "failure recovery base Turn is not running/failed in run {}", + context.org_run_id + ))); + } + Ok(context) +} + +/// Startup crash recovery may only continue when exactly one persisted, +/// running TaskExecution belongs to the abandoned Member session. +pub(crate) fn unique_running_task_execution_turn_for_recovery( + conn: &Connection, + org_run_id: &str, + session_id: &str, + owner_member_id: &str, +) -> Result, String> { + let mut statement = conn + .prepare( + "SELECT context.turn_intent_id + FROM agent_org_runtime_turn_contexts context + JOIN session_turn_intents base + ON base.session_id=context.session_id + AND base.turn_intent_id=context.turn_intent_id + WHERE context.org_run_id=?1 + AND context.session_id=?2 + AND context.turn_kind='task_execution' + AND context.participant_id=?3 + AND context.owner_member_id=?3 + AND context.dispatch_member_id=?3 + AND context.task_id IS NOT NULL + AND context.source_kind='task' + AND context.source_id=context.task_id + AND context.activation_generation IS NOT NULL + AND base.org_run_id=?1 + AND base.status='running' + ORDER BY context.created_at DESC,context.context_id DESC + LIMIT 2", + ) + .map_err(|error| error.to_string())?; + let turns = statement + .query_map(params![org_run_id, session_id, owner_member_id], |row| { + row.get::<_, String>(0) + }) + .map_err(|error| error.to_string())? + .collect::>>() + .map_err(|error| error.to_string())?; + match turns.as_slice() { + [] => Ok(None), + [turn_intent_id] => Ok(Some(turn_intent_id.clone())), + _ => Err(invariant_error(format!( + "abandoned Member session {session_id} has multiple running TaskExecution contexts" + ))), + } +} + fn resolve_next_task_wake_binding( conn: &Connection, org_run_id: &str, diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs index 4b4567ec04..60a90cee8f 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog.rs @@ -42,7 +42,10 @@ pub(crate) use budget::member_rewake_fingerprint; pub(crate) use budget::task_failure_recovery_attempts_exhausted; #[cfg(test)] pub use budget::test_only_mark_failed_rewake_attempt; -pub(crate) use budget::{reserve_task_failure_recovery, reserve_task_shutdown_release}; +pub(crate) use budget::{ + reserve_task_failure_recovery_with_connection, reserve_task_shutdown_release, + task_failure_recovery_already_processed_with_connection, task_failure_recovery_fingerprint, +}; pub use inspect::inspect_stalled_run; pub use plan::{MemberContinuationAction, MemberTaskAssignmentAction, StallRecoveryPlan}; pub use recover::{recover_stalled_run, spawn}; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs index be0922bae2..a092ad9409 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_watchdog/budget.rs @@ -203,14 +203,121 @@ pub(crate) fn task_failure_recovery_attempts_exhausted(attempts: i64) -> bool { attempts > RECOVERY_DELAYS_SECS.len() as i64 } -/// Reserve the receipt consumed by the Task Store's system actor. Task state -/// recovery shares the durable recovery-attempt table but uses its own action -/// kind, so it cannot impersonate a member-rewake or coordinator notice. -pub(crate) fn reserve_task_failure_recovery( +const TASK_FAILURE_RECOVERY: &str = "task_failure_recovery"; +const TASK_FAILURE_RECOVERY_EVENT: &str = "task_failure_recovery_event"; + +pub(crate) fn task_failure_recovery_fingerprint( + task_id: &str, + failed_turn_intent_id: &str, + activation_generation: i64, +) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(task_id.as_bytes()); + hasher.update(&[0]); + hasher.update(failed_turn_intent_id.as_bytes()); + hasher.update(&[0]); + hasher.update(&activation_generation.to_le_bytes()); + hasher.finalize().to_hex().to_string() +} + +pub(crate) fn task_failure_recovery_already_processed_with_connection( + conn: &Connection, run_id: &str, - owner_member_id: &str, + fingerprint: &str, +) -> Result { + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM agent_org_runtime_recovery_attempts + WHERE org_run_id=?1 AND action_kind=?2 AND target_key=?3 + )", + params![run_id, TASK_FAILURE_RECOVERY_EVENT, fingerprint], + |row| row.get(0), + ) + .map_err(|error| error.to_string()) +} + +/// Reserve one automatic recovery for an exact failed TaskExecution. +/// +/// The caller owns the Task mutation transaction. The failure-event receipt, +/// per-Task budget increment, Task mutation, and final reservation release +/// therefore commit or roll back together. +pub(crate) fn reserve_task_failure_recovery_with_connection( + conn: &Connection, + run_id: &str, + task_id: &str, + fingerprint: &str, + activation_generation: i64, ) -> Result { - reserve_task_system_operation(run_id, "task_failure_recovery", owner_member_id, true) + let current_generation: i64 = conn + .query_row( + "SELECT activation_generation FROM agent_org_runtime_runs + WHERE id=?1 AND status='running'", + [run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("agent_org_run_not_mutable: {run_id}"))?; + if current_generation != activation_generation { + return Err("task_actor_generation_mismatch".to_string()); + } + + let now = Utc::now().to_rfc3339(); + let inserted = conn + .execute( + "INSERT INTO agent_org_runtime_recovery_attempts( + org_run_id,action_kind,target_key,reason_fingerprint,attempts, + next_allowed_at,updated_at,reservation_token + ) VALUES (?1,?2,?3,?3,1,?4,?4,NULL) + ON CONFLICT(org_run_id,action_kind,target_key) DO NOTHING", + params![run_id, TASK_FAILURE_RECOVERY_EVENT, fingerprint, &now], + ) + .map_err(|error| error.to_string())?; + if inserted != 1 { + return Err("task_failure_recovery_event_already_processed".to_string()); + } + + let previous_attempts: i64 = conn + .query_row( + "SELECT attempts FROM agent_org_runtime_recovery_attempts + WHERE org_run_id=?1 AND action_kind=?2 AND target_key=?3", + params![run_id, TASK_FAILURE_RECOVERY, task_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .unwrap_or(0) + .max(0); + let attempts = previous_attempts.saturating_add(1); + let exhausted = task_failure_recovery_attempts_exhausted(attempts); + let token = uuid::Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO agent_org_runtime_recovery_attempts( + org_run_id,action_kind,target_key,reason_fingerprint,attempts, + next_allowed_at,updated_at,reservation_token + ) VALUES (?1,?2,?3,?4,?5,?6,?6,?7) + ON CONFLICT(org_run_id,action_kind,target_key) DO UPDATE SET + reason_fingerprint=excluded.reason_fingerprint, + attempts=excluded.attempts, + next_allowed_at=excluded.next_allowed_at, + updated_at=excluded.updated_at, + reservation_token=excluded.reservation_token", + params![ + run_id, + TASK_FAILURE_RECOVERY, + task_id, + fingerprint, + attempts, + &now, + &token, + ], + ) + .map_err(|error| error.to_string())?; + Ok(TaskRecoveryReservation { + token, + generation: activation_generation, + exhausted, + }) } pub(crate) fn reserve_task_shutdown_release( diff --git a/src-tauri/crates/agent-core/src/lifecycle.rs b/src-tauri/crates/agent-core/src/lifecycle.rs index 3efa0fcd7f..ab72401fb0 100644 --- a/src-tauri/crates/agent-core/src/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/lifecycle.rs @@ -322,6 +322,7 @@ fn parse_agent_exec_mode(value: Option<&str>) -> Option, requeue_work: bool, ) -> Result, String> { let Some(record) = @@ -339,7 +340,11 @@ fn requeue_agent_org_member_in_progress_work( .require_participant_agent_id(&member_id)? .to_string(); let requeued = if requeue_work { - AgentOrgTaskStore::requeue_in_progress_for_owner(&context.run_id, &member_id)? + let failed_turn_intent_id = failed_turn_intent_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "task_failure_recovery_requires_exact_turn_context".to_string())?; + AgentOrgTaskStore::recover_task_execution_failure(session_id, failed_turn_intent_id)? } else { Vec::new() }; @@ -355,11 +360,12 @@ fn requeue_agent_org_member_in_progress_work( pub fn finalize_agent_org_member_turn( app_handle: Option<&tauri::AppHandle>, session_id: &str, + turn_intent_id: Option<&str>, response: &Result, ) { let outcome = crate::tools::impls::orchestration::member_idle::run_agent_org_blocking_section(|| { - requeue_agent_org_member_in_progress_work(session_id, response.is_err()) + requeue_agent_org_member_in_progress_work(session_id, turn_intent_id, response.is_err()) }); let reconcile_run_id = outcome .as_ref() @@ -596,9 +602,17 @@ pub async fn finalize_session( // writes able to stall unrelated async sessions. let sid = session_id.to_string(); let response = response.clone(); + let turn_intent_id = terminal_turn + .as_ref() + .and_then(|signal| signal.turn_intent_id.clone()); let app_handle = app_handle.cloned(); if let Err(err) = tokio::task::spawn_blocking(move || { - finalize_agent_org_member_turn(app_handle.as_ref(), &sid, &response); + finalize_agent_org_member_turn( + app_handle.as_ref(), + &sid, + turn_intent_id.as_deref(), + &response, + ); }) .await { diff --git a/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs b/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs index c13ed500c4..e9620614c3 100644 --- a/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs +++ b/src-tauri/crates/agent-core/src/tests/lifecycle_tests.rs @@ -191,8 +191,8 @@ fn seed_run(member_agent_id: &str) -> String { status: crate::session::SessionStatus::Running.as_str().to_string(), session_type: session_type::ORG_MEMBER.to_string(), created_at: now.clone(), - updated_at: now, - agent_definition_id: None, + updated_at: now.clone(), + agent_definition_id: Some(member_agent_id.to_string()), org_member_id: Some("member-worker".to_string()), parent_session_id: Some("root-session".to_string()), agent_exec_mode: Some(crate::session::AgentExecMode::Ask.as_str().to_string()), @@ -211,6 +211,22 @@ fn seed_run(member_agent_id: &str) -> String { routine_fire_id: None, }) .expect("create run"); + let conn = database::db::get_connection().expect("test sqlite connection"); + conn.execute( + "INSERT INTO agent_org_runtime_member_materializations( + org_run_id,member_id,agent_id,generation,session_id, + authority_class,status,created_at,updated_at + ) VALUES (?1,'member-worker',?2,1,'member-session', + 'formal','succeeded',?3,?3) + ON CONFLICT(org_run_id,member_id,generation) DO UPDATE SET + agent_id=excluded.agent_id, + session_id=excluded.session_id, + authority_class=excluded.authority_class, + status=excluded.status, + updated_at=excluded.updated_at", + rusqlite::params![&run.id, member_agent_id, now], + ) + .expect("seed canonical Member materialization"); run.id } @@ -238,6 +254,41 @@ fn seed_in_progress_task_with_metadata( .expect("create in-progress task"); } +fn seed_task_execution_turn(run_id: &str, task_id: &str, turn_id: &str) { + let conn = database::db::get_connection().expect("test sqlite connection"); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO session_turn_intents( + session_id,turn_intent_id,org_run_id,source,status,created_at,updated_at + ) VALUES ('member-session',?1,?2,'agent_org','running',?3,?3)", + rusqlite::params![turn_id, run_id, now], + ) + .expect("seed failed base Turn"); + conn.execute( + "INSERT INTO agent_org_runtime_turn_contexts( + session_id,turn_intent_id,org_run_id,participant_id,turn_kind, + task_id,owner_member_id,dispatch_member_id,member_dispatch_sequence, + source_kind,source_id,activation_generation,created_at + ) VALUES ('member-session',?1,?2,'member-worker','task_execution', + ?3,'member-worker','member-worker',1, + 'task',?3,1,?4)", + rusqlite::params![turn_id, run_id, task_id, now], + ) + .expect("seed exact TaskExecution context"); + conn.execute( + "INSERT INTO agent_org_runtime_task_events( + id,org_run_id,task_id,event_type,previous_owner,next_owner, + previous_status,next_status,actor_member_id,actor_kind, + source_turn_intent_id,created_at + ) SELECT ?1,task.org_run_id,task.id,'updated',task.owner,task.owner, + 'pending','in_progress','member-worker','owner_execution',?2,task.updated_at + FROM agent_org_runtime_tasks task + WHERE task.org_run_id=?3 AND task.id=?4 AND task.status='in_progress'", + rusqlite::params![uuid::Uuid::new_v4().to_string(), turn_id, run_id, task_id], + ) + .expect("seed owning Task start event"); +} + #[test] fn successful_empty_coordinator_finalize_does_not_observe_staged_work() { let _serial = test_serial_guard(); @@ -269,7 +320,7 @@ fn successful_empty_coordinator_finalize_does_not_observe_staged_work() { // This is the lifecycle shape of WakeNoop: processing returned Ok, // but no provider turn ran. Finalization must not promote a staged // revision merely because the outer scheduler call succeeded. - finalize_agent_org_member_turn(None, "root-session", &Ok(String::new())); + finalize_agent_org_member_turn(None, "root-session", None, &Ok(String::new())); let progress = AgentOrgRunStore::progress(&run_id) .expect("load progress after no-op") @@ -287,10 +338,12 @@ fn requeue_member_work_uses_context_agent_reference_without_self_wake() { let _sandbox = test_helpers::test_env::sandbox(); let run_id = seed_run("claude_code"); seed_in_progress_task(&run_id, "cli-task"); + seed_task_execution_turn(&run_id, "cli-task", "turn-cli-task"); - let snapshot = requeue_agent_org_member_in_progress_work("member-session", true) - .expect("requeue succeeds") - .expect("member snapshot"); + let snapshot = + requeue_agent_org_member_in_progress_work("member-session", Some("turn-cli-task"), true) + .expect("requeue succeeds") + .expect("member snapshot"); assert_eq!(snapshot.member_agent_id, "claude_code"); assert_eq!(snapshot.requeued_tasks.len(), 1); @@ -316,6 +369,16 @@ async fn successful_member_finalize_keeps_in_progress_work_owned() { let _sandbox = test_helpers::test_env::sandbox(); let run_id = seed_run("builtin:sde"); seed_in_progress_task(&run_id, "active-task"); + let conn = database::db::get_connection().expect("test sqlite connection"); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO agent_org_runtime_recovery_attempts( + org_run_id,action_kind,target_key,reason_fingerprint,attempts, + next_allowed_at,updated_at,reservation_token + ) VALUES (?1,'task_failure_recovery','active-task','historical',2,?2,?2,NULL)", + rusqlite::params![&run_id, now], + ) + .expect("seed historical Task recovery budget"); assert!( crate::coordination::agent_org_watchdog::test_only_mark_failed_rewake_attempt( @@ -333,7 +396,7 @@ async fn successful_member_finalize_keeps_in_progress_work_owned() { ); let ok = Ok("done with this turn".to_string()); - finalize_agent_org_member_turn(None, "member-session", &ok); + finalize_agent_org_member_turn(None, "member-session", None, &ok); assert!( crate::coordination::agent_org_watchdog::test_only_mark_failed_rewake_attempt( &run_id, @@ -362,6 +425,16 @@ async fn successful_member_finalize_keeps_in_progress_work_owned() { .filter(|event| event.event_type == "released") .collect::>(); assert!(release_events.is_empty()); + let task_recovery_attempts: i64 = conn + .query_row( + "SELECT attempts FROM agent_org_runtime_recovery_attempts + WHERE org_run_id=?1 AND action_kind='task_failure_recovery' + AND target_key='active-task'", + [&run_id], + |row| row.get(0), + ) + .expect("Task recovery budget survives a successful Turn"); + assert_eq!(task_recovery_attempts, 2); } #[tokio::test(flavor = "multi_thread")] @@ -379,9 +452,10 @@ async fn failed_member_finalize_releases_task_for_coordinator_assignment() { TASK_METADATA_REQUIRED_ROLE: "implement", })), ); + seed_task_execution_turn(&run_id, "failed-task", "turn-failed-task"); let error = Err("HTTP 429: rate limit exceeded".to_string()); - finalize_agent_org_member_turn(None, "member-session", &error); + finalize_agent_org_member_turn(None, "member-session", Some("turn-failed-task"), &error); let task = AgentOrgTaskStore::get(&run_id, "failed-task") .unwrap() @@ -438,9 +512,10 @@ async fn failed_member_finalize_releases_even_when_only_failed_member_is_eligibl TASK_METADATA_ELIGIBLE_MEMBER_IDS: ["member-worker"], })), ); + seed_task_execution_turn(&run_id, "solo-task", "turn-solo-task"); let error = Err("HTTP 500: provider exploded".to_string()); - finalize_agent_org_member_turn(None, "member-session", &error); + finalize_agent_org_member_turn(None, "member-session", Some("turn-solo-task"), &error); let task = AgentOrgTaskStore::get(&run_id, "solo-task") .unwrap() @@ -454,3 +529,44 @@ async fn failed_member_finalize_releases_even_when_only_failed_member_is_eligibl assert!(failure_reason.contains("awaiting_coordinator_assignment")); assert!(failure_reason.contains("eligible_member_ids: [member-worker]")); } + +#[test] +fn startup_recovery_requeues_only_the_uniquely_bound_task_and_is_idempotent() { + let _serial = test_serial_guard(); + let _sandbox = test_helpers::test_env::sandbox(); + let run_id = seed_run("builtin:sde"); + seed_in_progress_task(&run_id, "crashed-task"); + seed_in_progress_task(&run_id, "unbound-sibling"); + seed_task_execution_turn(&run_id, "crashed-task", "turn-crashed-task"); + crate::session::persistence::update_status( + "member-session", + crate::session::SessionStatus::Abandoned, + ) + .expect("mark stale Member session abandoned"); + + assert_eq!( + AgentOrgRunStore::requeue_abandoned_member_tasks_on_startup() + .expect("recover exact abandoned TaskExecution"), + 1 + ); + assert_eq!( + AgentOrgTaskStore::get(&run_id, "crashed-task") + .unwrap() + .unwrap() + .status, + TaskStatus::Pending + ); + assert_eq!( + AgentOrgTaskStore::get(&run_id, "unbound-sibling") + .unwrap() + .unwrap() + .status, + TaskStatus::InProgress, + "startup must not batch-recover every Task owned by the Member" + ); + assert_eq!( + AgentOrgRunStore::requeue_abandoned_member_tasks_on_startup() + .expect("startup replay is a no-op"), + 0 + ); +} diff --git a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs index 45cd1da6f3..00d7691f91 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs @@ -409,9 +409,9 @@ pub(super) async fn finalize_session_run( clear_live_status(agent, session_id, cli_session_id_out.as_deref()); } - // For CLI sessions that are Agent Org members, requeue any in-progress work - // and notify the coordinator that this member is idle/available. This mirrors - // the Rust-native member path in `agent_core::lifecycle::finalize_session`. + // For CLI sessions that are Agent Org members, recover only the Task bound + // to this exact failed Turn and notify the coordinator that this member is + // idle/available. This mirrors the Rust-native member finalization path. // app_handle is unavailable in the CLI runner, so inbox-wake via AppHandle is // skipped (fire-and-forget; the coordinator will drain on its next turn boundary). if is_org_member { @@ -423,7 +423,12 @@ pub(super) async fn finalize_session_run( .unwrap_or("unknown error") .to_string()) }; - agent_core::lifecycle::finalize_agent_org_member_turn(None, session_id, &outcome); + agent_core::lifecycle::finalize_agent_org_member_turn( + None, + session_id, + turn_intent_id, + &outcome, + ); } // Flush any pending streaming deltas before signaling session end From ec5614af42c806df422cb22078878c31bd5b3e58 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Sat, 22 Aug 2026 16:23:37 +0800 Subject: [PATCH 3/7] refactor(agent-org): replace delivery labels with domain names Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../coordination/agent_inbox/store_drain.rs | 2 +- .../core/coordination/agent_org_tasks/mod.rs | 7 +- .../coordination/agent_org_tasks/store/fsm.rs | 2 +- ..._tests.rs => task_store_contract_tests.rs} | 28 +-- .../coordination/agent_org_turn_contexts.rs | 2 +- .../agent-core/src/core/providers/e2e_fake.rs | 161 ++++++++++-------- .../turn/processor/inbox_drain/drain.rs | 2 +- .../orchestration/agent_org/task_tests.rs | 18 +- .../commands/session/message/org_wake.rs | 2 +- src-tauri/src/api/agent/test/agent_org.rs | 6 +- .../components/AgentOrgTaskPanel.test.ts | 14 +- .../core/agent-org-group-chat-ui.spec.mjs | 76 +++++---- 12 files changed, 172 insertions(+), 148 deletions(-) rename src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/{pr4_tests.rs => task_store_contract_tests.rs} (98%) diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs index 74e3df1523..d46eede852 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_inbox/store_drain.rs @@ -19,7 +19,7 @@ impl AgentInboxStore { /// Load the single formal Inbox input bound to one persisted /// `TaskExecution` context. /// - /// A generic member drain is intentionally too broad for PR4: it could + /// A generic member drain is intentionally too broad for a task-bound Turn: it could /// acknowledge another Task's assignment or a user-directed message in /// the same provider Turn. Pending Tasks consume their oldest matching /// `TaskAssigned`; an in-progress Plan Task consumes its oldest matching diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs index 93678b2d9e..df6030e567 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/mod.rs @@ -27,7 +27,7 @@ pub use graph::TaskGraphIndex; pub use store::AgentOrgTaskStore; #[cfg(test)] -mod pr4_tests; +mod task_store_contract_tests; #[cfg(test)] mod tests; @@ -576,8 +576,9 @@ pub struct PendingTaskGraphPatch { pub metadata: Option>, } -/// Test-only compatibility patch for pre-PR4 Store fixtures. Production -/// code cannot compile this type and must use the typed actor requests. +/// Test-only compatibility patch for Store fixtures that exercise the +/// untyped update surface. Production code cannot compile this type and must +/// use the typed actor requests. #[derive(Debug, Clone, Default)] #[cfg(test)] pub struct UpdateTaskPatch { diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs index 2853351f5c..3099533672 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/store/fsm.rs @@ -1,4 +1,4 @@ -//! Canonical PR4 Task state machine. Every method validates its typed actor +//! Canonical Agent Org Task state machine. Every method validates its typed actor //! against persisted Turn context after beginning the IMMEDIATE transaction. use database::db::{get_connection, with_sessions_writer}; diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/task_store_contract_tests.rs similarity index 98% rename from src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs rename to src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/task_store_contract_tests.rs index 25e3e8671b..6ae3f88701 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/pr4_tests.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_tasks/task_store_contract_tests.rs @@ -3,13 +3,13 @@ use rusqlite::{params, OptionalExtension}; use super::*; -const RUN_ID: &str = "pr4-run"; -const ROOT_SESSION: &str = "pr4-root-session"; -const COORDINATOR_TURN: &str = "pr4-coordinator-turn"; +const RUN_ID: &str = "task-store-contract-run"; +const ROOT_SESSION: &str = "task-store-contract-root-session"; +const COORDINATOR_TURN: &str = "task-store-contract-coordinator-turn"; const MEMBER_A: &str = "member-a"; const MEMBER_B: &str = "member-b"; -const MEMBER_A_SESSION: &str = "pr4-member-a-session"; -const MEMBER_B_SESSION: &str = "pr4-member-b-session"; +const MEMBER_A_SESSION: &str = "task-store-contract-member-a-session"; +const MEMBER_B_SESSION: &str = "task-store-contract-member-b-session"; struct Fixture { _sandbox: test_helpers::test_env::SandboxGuard, @@ -17,7 +17,7 @@ struct Fixture { fn fixture() -> Fixture { let sandbox = test_helpers::test_env::sandbox(); - let conn = get_connection().expect("PR4 test database"); + let conn = get_connection().expect("Task Store contract test database"); conn.execute_batch( "CREATE TABLE agent_sessions ( session_id TEXT PRIMARY KEY, @@ -49,8 +49,8 @@ fn fixture() -> Fixture { let now = chrono::Utc::now().to_rfc3339(); let snapshot = serde_json::json!({ "schemaVersion": 1, - "orgId": "pr4-org", - "orgName": "PR4 Team", + "orgId": "task-store-contract-org", + "orgName": "Task Store Contract Team", "coordinatorRole": "Lead", "coordinatorAgentId": "agent-coordinator", "planApprovalPolicy": "coordinator", @@ -66,7 +66,7 @@ fn fixture() -> Fixture { "INSERT INTO agent_org_runtime_runs( id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, entry_mode,status,activation_generation,created_at,updated_at - ) VALUES (?1,'pr4-org','agent-coordinator',?2,?3, + ) VALUES (?1,'task-store-contract-org','agent-coordinator',?2,?3, 'standalone_session','running',1,?4,?4)", params![RUN_ID, ROOT_SESSION, snapshot, now], ) @@ -902,7 +902,7 @@ fn recovery_budget_is_per_task_and_never_resets_on_owner_generation_or_reopen() [RUN_ID], ) .unwrap(); - let restarted_session = "pr4-member-a-session-gen2"; + let restarted_session = "task-store-contract-member-a-session-gen2"; insert_member_session(&conn, MEMBER_A, "agent-a", restarted_session, 2); bind_start_and_fail(&conn, MEMBER_A, restarted_session, "task-a", "turn-a-3", 2); assert_eq!(recovery_attempts("task-a"), 3); @@ -1053,7 +1053,13 @@ fn recovery_replay_is_idempotent_and_only_mutates_the_bound_task() { [RUN_ID], ) .unwrap(); - insert_member_session(&conn, MEMBER_A, "agent-a", "pr4-member-a-session-gen2", 2); + insert_member_session( + &conn, + MEMBER_A, + "agent-a", + "task-store-contract-member-a-session-gen2", + 2, + ); let stale = AgentOrgTaskStore::recover_task_execution_failure(MEMBER_A_SESSION, "turn-sibling") .expect_err("an old activation generation must fail closed"); assert!(stale.contains("generation"), "{stale}"); diff --git a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs index a8d8336676..4e790dc211 100644 --- a/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs +++ b/src-tauri/crates/agent-core/src/core/coordination/agent_org_turn_contexts.rs @@ -510,7 +510,7 @@ pub(crate) fn revalidate_context_with_connection( } AgentOrgTurnKind::UserDirectedWork => { return Err(invariant_error( - "UserDirectedWork execution is not enabled in the PR4 formal wake path".to_string(), + "UserDirectedWork execution is not enabled in the task-bound wake path".to_string(), )); } } diff --git a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs index 8d5c61a50b..2e0b344dca 100644 --- a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs +++ b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs @@ -15,7 +15,7 @@ const ADDRESS_COMMENTS_MARKER: &str = "Teammates left review comments on this session. Address every comment below"; const ADDRESS_COMMENT_ID_MARKER: &str = " — id: "; const REPLY_SESSION_COMMENT_TOOL: &str = "reply_session_comment"; -const PR4_TASK_FSM_MARKER: &str = "E2E_PR4_TASK_FSM:"; +const AGENT_ORG_TASK_FSM_MARKER: &str = "E2E_AGENT_ORG_TASK_FSM:"; const CONTROL_WAIT_MARKER: &str = "Create a stoppable window by waiting for about "; const TASK_GRAPH_CREATE_TOOL: &str = "task_graph_create"; const TASK_UPDATE_TOOL: &str = "task_update"; @@ -121,14 +121,14 @@ impl E2eFakeProvider { .collect() } - fn pr4_task_fsm_tool_calls( + fn agent_org_task_fsm_tool_calls( messages: &[Value], tools: Option<&[Value]>, ) -> Vec { - let Some((latest_user_index, latest_user)) = latest_pr4_user(messages) else { + let Some((latest_user_index, latest_user)) = latest_task_fsm_user(messages) else { return Vec::new(); }; - let Some(scenario_id) = pr4_scenario_id(latest_user.as_str()) else { + let Some(scenario_id) = task_fsm_scenario_id(latest_user.as_str()) else { return Vec::new(); }; let tool_results = messages[latest_user_index + 1..] @@ -148,7 +148,7 @@ impl E2eFakeProvider { let stage = tool_results.len(); let arguments = if stage == 0 { serde_json::json!({ "operation": "start", "id": task_id }) - } else if subject.contains("E2E_PR4_HISTORY:") && stage == 1 { + } else if subject.contains("E2E_TASK_FSM_HISTORY:") && stage == 1 { serde_json::json!({ "operation": "complete", "id": task_id, @@ -156,13 +156,13 @@ impl E2eFakeProvider { "summary": format!("E2E paged history result for {scenario_id}"), }, }) - } else if subject.contains("E2E_PR4_COMPLETE:") && stage == 1 { + } else if subject.contains("E2E_TASK_FSM_COMPLETE:") && stage == 1 { serde_json::json!({ "operation": "append_evidence", "id": task_id, "body": format!("E2E production-path evidence for {scenario_id}"), }) - } else if subject.contains("E2E_PR4_COMPLETE:") && stage == 2 { + } else if subject.contains("E2E_TASK_FSM_COMPLETE:") && stage == 2 { serde_json::json!({ "operation": "complete", "id": task_id, @@ -172,13 +172,13 @@ impl E2eFakeProvider { "artifact_ids": [format!("e2e-artifact-{scenario_id}")], }, }) - } else if subject.contains("E2E_PR4_FAIL:") && stage == 1 { + } else if subject.contains("E2E_TASK_FSM_FAIL:") && stage == 1 { serde_json::json!({ "operation": "append_progress", "id": task_id, "body": format!("E2E progress before failure for {scenario_id}"), }) - } else if subject.contains("E2E_PR4_FAIL:") && stage == 2 { + } else if subject.contains("E2E_TASK_FSM_FAIL:") && stage == 2 { serde_json::json!({ "operation": "fail", "id": task_id, @@ -187,7 +187,7 @@ impl E2eFakeProvider { "message": format!("Deterministic E2E failure for {scenario_id}"), }, }) - } else if subject.contains("E2E_PR4_LATE:") && stage == 1 { + } else if subject.contains("E2E_TASK_FSM_LATE:") && stage == 1 { // The coordinator cancels and replaces this Task while this // owner callback is delayed. The Store must reject the late // completion against the original persisted Turn binding. @@ -200,7 +200,7 @@ impl E2eFakeProvider { return Vec::new(); }; return vec![ToolCallRequest { - id: format!("e2e-pr4-owner-{scenario_id}-{task_id}-{stage}"), + id: format!("e2e-task-fsm-owner-{scenario_id}-{task_id}-{stage}"), name: TASK_UPDATE_TOOL.to_string(), arguments, thought_signature: None, @@ -217,29 +217,29 @@ impl E2eFakeProvider { let mut tasks = vec![ serde_json::json!({ "key": "pending", - "subject": format!("E2E_PR4_PENDING:{scenario_id}"), - "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "subject": format!("E2E_TASK_FSM_PENDING:{scenario_id}"), + "description": format!("{AGENT_ORG_TASK_FSM_MARKER}{scenario_id}"), "execution_mode": "build", "eligible_member_ids": ["sde-implementer"] }), serde_json::json!({ "key": "complete", - "subject": format!("E2E_PR4_COMPLETE:{scenario_id}"), - "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "subject": format!("E2E_TASK_FSM_COMPLETE:{scenario_id}"), + "description": format!("{AGENT_ORG_TASK_FSM_MARKER}{scenario_id}"), "owner_member_id": "sde-reviewer", "execution_mode": "build" }), serde_json::json!({ "key": "fail", - "subject": format!("E2E_PR4_FAIL:{scenario_id}"), - "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "subject": format!("E2E_TASK_FSM_FAIL:{scenario_id}"), + "description": format!("{AGENT_ORG_TASK_FSM_MARKER}{scenario_id}"), "owner_member_id": "sde-tester", "execution_mode": "build" }), serde_json::json!({ "key": "late", - "subject": format!("E2E_PR4_LATE:{scenario_id}"), - "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "subject": format!("E2E_TASK_FSM_LATE:{scenario_id}"), + "description": format!("{AGENT_ORG_TASK_FSM_MARKER}{scenario_id}"), "owner_member_id": "sde-planner", "execution_mode": "build" }), @@ -254,15 +254,15 @@ impl E2eFakeProvider { tasks.extend((0..20).map(|index| { serde_json::json!({ "key": format!("history-{index:02}"), - "subject": format!("E2E_PR4_HISTORY:{scenario_id}:{index:02}"), - "description": format!("{PR4_TASK_FSM_MARKER}{scenario_id}"), + "subject": format!("E2E_TASK_FSM_HISTORY:{scenario_id}:{index:02}"), + "description": format!("{AGENT_ORG_TASK_FSM_MARKER}{scenario_id}"), "owner_member_id": owners[index % owners.len()], "execution_mode": "build" }) })); } vec![ToolCallRequest { - id: format!("e2e-pr4-graph-{scenario_id}"), + id: format!("e2e-task-fsm-graph-{scenario_id}"), name: TASK_GRAPH_CREATE_TOOL.to_string(), arguments: serde_json::json!({ "allow_parallel_with_existing_open_tasks": true, @@ -287,7 +287,7 @@ impl E2eFakeProvider { return Vec::new(); }; vec![ToolCallRequest { - id: format!("e2e-pr4-replace-{scenario_id}"), + id: format!("e2e-task-fsm-replace-{scenario_id}"), name: TASK_UPDATE_TOOL.to_string(), arguments: serde_json::json!({ "operation": "cancel_and_replace", @@ -297,7 +297,7 @@ impl E2eFakeProvider { "message": format!("Deterministic E2E replacement for {scenario_id}") }, "replacement": { - "subject": format!("E2E_PR4_REPLACEMENT:{scenario_id}"), + "subject": format!("E2E_TASK_FSM_REPLACEMENT:{scenario_id}"), "description": "Ownerless replacement remains in Current Work.", "execution_mode": "build", "eligible_member_ids": ["sde-planner"] @@ -310,8 +310,8 @@ impl E2eFakeProvider { } } - async fn delay_pr4_race_stage(messages: &[Value]) { - let Some((latest_user_index, latest_user)) = latest_pr4_user(messages) else { + async fn delay_task_fsm_race_stage(messages: &[Value]) { + let Some((latest_user_index, latest_user)) = latest_task_fsm_user(messages) else { return; }; let stage = messages[latest_user_index + 1..] @@ -319,18 +319,20 @@ impl E2eFakeProvider { .filter(|message| message.get("role").and_then(Value::as_str) == Some("tool")) .count(); let task_assignment = is_task_assignment(latest_user.as_str()); - let delay_ms = if task_assignment && latest_user.contains("E2E_PR4_LATE:") && stage == 1 { - 4_000 - } else if task_assignment - && (latest_user.contains("E2E_PR4_COMPLETE:") || latest_user.contains("E2E_PR4_FAIL:")) - && stage == 2 - { - 3_000 - } else if !task_assignment && stage == 1 { - 1_500 - } else { - 0 - }; + let delay_ms = + if task_assignment && latest_user.contains("E2E_TASK_FSM_LATE:") && stage == 1 { + 4_000 + } else if task_assignment + && (latest_user.contains("E2E_TASK_FSM_COMPLETE:") + || latest_user.contains("E2E_TASK_FSM_FAIL:")) + && stage == 2 + { + 3_000 + } else if !task_assignment && stage == 1 { + 1_500 + } else { + 0 + }; if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } @@ -358,7 +360,7 @@ fn control_wait_duration(messages: &[Value]) -> Option { Some(Duration::from_secs(wait_seconds.clamp(1, 60))) } -fn latest_pr4_user(messages: &[Value]) -> Option<(usize, String)> { +fn latest_task_fsm_user(messages: &[Value]) -> Option<(usize, String)> { messages .iter() .enumerate() @@ -371,13 +373,13 @@ fn latest_pr4_user(messages: &[Value]) -> Option<(usize, String)> { .map(|content| (index, content)) }) .find(|(_, content)| { - content.contains(PR4_TASK_FSM_MARKER) + content.contains(AGENT_ORG_TASK_FSM_MARKER) && !content.trim_start().starts_with("") }) } -fn pr4_scenario_id(text: &str) -> Option { - let suffix = text.split(PR4_TASK_FSM_MARKER).nth(1)?; +fn task_fsm_scenario_id(text: &str) -> Option { + let suffix = text.split(AGENT_ORG_TASK_FSM_MARKER).nth(1)?; let id = suffix .chars() .take_while(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) @@ -450,10 +452,10 @@ impl LLMProvider for E2eFakeProvider { if let Some(duration) = control_wait_duration(messages) { sleep(duration).await; } - Self::delay_pr4_race_stage(messages).await; + Self::delay_task_fsm_race_stage(messages).await; let mut tool_calls = Self::address_comment_tool_calls(messages, tools); if tool_calls.is_empty() { - tool_calls = Self::pr4_task_fsm_tool_calls(messages, tools); + tool_calls = Self::agent_org_task_fsm_tool_calls(messages, tools); } let content = if tool_calls.is_empty() { Some(Self::response_for(messages)) @@ -652,12 +654,12 @@ mod tests { } #[test] - fn session_memory_compaction_does_not_replay_pr4_markers() { + fn session_memory_compaction_does_not_replay_task_fsm_markers() { let messages = vec![json!({ "role": "user", "content": concat!( "\n", - "Run E2E_PR4_TASK_FSM:stale_page\n", + "Run E2E_AGENT_ORG_TASK_FSM:stale_page\n", "\n", "settled task updates" ) @@ -666,11 +668,11 @@ mod tests { let response = E2eFakeProvider::response_for(&messages); assert!(response.starts_with("E2E_FAKE_COMPACT_SUMMARY:")); - assert!(!response.contains(PR4_TASK_FSM_MARKER)); + assert!(!response.contains(AGENT_ORG_TASK_FSM_MARKER)); } #[test] - fn pr4_marker_drives_graph_then_atomic_replacement() { + fn task_fsm_marker_drives_graph_then_atomic_replacement() { let tools = [ named_tool(TASK_GRAPH_CREATE_TOOL), named_tool(TASK_UPDATE_TOOL), @@ -678,7 +680,7 @@ mod tests { let messages = vec![ json!({ "role": "user", - "content": "Run E2E_PR4_TASK_FSM:run_1" + "content": "Run E2E_AGENT_ORG_TASK_FSM:run_1" }), json!({ "role": "user", @@ -686,7 +688,7 @@ mod tests { }), ]; - let graph = E2eFakeProvider::pr4_task_fsm_tool_calls(&messages, Some(&tools)); + let graph = E2eFakeProvider::agent_org_task_fsm_tool_calls(&messages, Some(&tools)); assert_eq!(graph.len(), 1); assert_eq!(graph[0].name, TASK_GRAPH_CREATE_TOOL); assert_eq!(graph[0].arguments["tasks"].as_array().unwrap().len(), 4); @@ -707,7 +709,8 @@ mod tests { "role": "user", "content": "Updated per-turn context." })); - let replacement = E2eFakeProvider::pr4_task_fsm_tool_calls(&with_result, Some(&tools)); + let replacement = + E2eFakeProvider::agent_org_task_fsm_tool_calls(&with_result, Some(&tools)); assert_eq!(replacement.len(), 1); assert_eq!(replacement[0].name, TASK_UPDATE_TOOL); assert_eq!(replacement[0].arguments["operation"], "cancel_and_replace"); @@ -719,20 +722,22 @@ mod tests { } #[test] - fn pr4_owner_lifecycle_uses_only_task_update_operations() { + fn task_owner_lifecycle_uses_only_task_update_operations() { let tools = [named_tool(TASK_UPDATE_TOOL)]; let assigned = json!({ "role": "user", "content": concat!( - "Task assigned by coordinator: E2E_PR4_COMPLETE:run_2\n", + "Task assigned by coordinator: E2E_TASK_FSM_COMPLETE:run_2\n", "Task ID: task-complete\n", "Execution mode: build\n", - "E2E_PR4_TASK_FSM:run_2" + "E2E_AGENT_ORG_TASK_FSM:run_2" ) }); - let start = - E2eFakeProvider::pr4_task_fsm_tool_calls(std::slice::from_ref(&assigned), Some(&tools)); + let start = E2eFakeProvider::agent_org_task_fsm_tool_calls( + std::slice::from_ref(&assigned), + Some(&tools), + ); assert_eq!( start[0].arguments, json!({ "operation": "start", "id": "task-complete" }) @@ -747,7 +752,8 @@ mod tests { context.clone(), json!({ "role": "tool", "content": "ok" }), ]; - let evidence = E2eFakeProvider::pr4_task_fsm_tool_calls(&evidence_messages, Some(&tools)); + let evidence = + E2eFakeProvider::agent_org_task_fsm_tool_calls(&evidence_messages, Some(&tools)); assert_eq!(evidence[0].arguments["operation"], "append_evidence"); let complete_messages = vec![ @@ -756,7 +762,8 @@ mod tests { json!({ "role": "tool", "content": "ok" }), json!({ "role": "tool", "content": "ok" }), ]; - let complete = E2eFakeProvider::pr4_task_fsm_tool_calls(&complete_messages, Some(&tools)); + let complete = + E2eFakeProvider::agent_org_task_fsm_tool_calls(&complete_messages, Some(&tools)); assert_eq!(complete[0].arguments["operation"], "complete"); assert!(complete[0].arguments["output"].get("produced_at").is_none()); assert!(complete[0].arguments["output"] @@ -765,28 +772,28 @@ mod tests { } #[test] - fn pr4_owner_ignores_state_projection_marker_in_trailing_system_reminder() { + fn task_owner_ignores_state_projection_marker_in_trailing_system_reminder() { let tools = [named_tool(TASK_UPDATE_TOOL)]; let messages = vec![ json!({ "role": "user", "content": concat!( - "Task assigned by coordinator: E2E_PR4_COMPLETE:run_3\n", + "Task assigned by coordinator: E2E_TASK_FSM_COMPLETE:run_3\n", "Task ID: task-complete\n", "Execution mode: build\n", - "E2E_PR4_TASK_FSM:run_3" + "E2E_AGENT_ORG_TASK_FSM:run_3" ) }), json!({ "role": "user", "content": concat!( "Current work includes ", - "E2E_PR4_TASK_FSM:stale_projection." + "E2E_AGENT_ORG_TASK_FSM:stale_projection." ) }), ]; - let calls = E2eFakeProvider::pr4_task_fsm_tool_calls(&messages, Some(&tools)); + let calls = E2eFakeProvider::agent_org_task_fsm_tool_calls(&messages, Some(&tools)); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, TASK_UPDATE_TOOL); @@ -797,7 +804,7 @@ mod tests { } #[test] - fn pr4_owner_lifecycle_accepts_production_inbox_xml_attachment() { + fn task_owner_lifecycle_accepts_production_inbox_xml_attachment() { let tools = [named_tool(TASK_UPDATE_TOOL)]; let messages = vec![ json!({ @@ -805,19 +812,19 @@ mod tests { "content": concat!( "\n", " ", - "", - "E2E_PR4_TASK_FSM:run_4", + "", + "E2E_AGENT_ORG_TASK_FSM:run_4", "\n", "" ) }), json!({ "role": "user", - "content": "Current work also contains E2E_PR4_TASK_FSM:run_4." + "content": "Current work also contains E2E_AGENT_ORG_TASK_FSM:run_4." }), ]; - let calls = E2eFakeProvider::pr4_task_fsm_tool_calls(&messages, Some(&tools)); + let calls = E2eFakeProvider::agent_org_task_fsm_tool_calls(&messages, Some(&tools)); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, TASK_UPDATE_TOOL); @@ -828,27 +835,31 @@ mod tests { } #[test] - fn pr4_owner_tool_call_ids_are_unique_across_same_scenario_tasks() { + fn task_owner_tool_call_ids_are_unique_across_same_scenario_tasks() { let tools = [named_tool(TASK_UPDATE_TOOL)]; let assignment = |task_id: &str| { json!({ "role": "user", "content": format!( concat!( - "Task assigned by coordinator: E2E_PR4_HISTORY:run_5:00\n", + "Task assigned by coordinator: E2E_TASK_FSM_HISTORY:run_5:00\n", "Task ID: {}\n", "Execution mode: build\n", - "E2E_PR4_TASK_FSM:run_5" + "E2E_AGENT_ORG_TASK_FSM:run_5" ), task_id ) }) }; - let first = - E2eFakeProvider::pr4_task_fsm_tool_calls(&[assignment("task-history-a")], Some(&tools)); - let second = - E2eFakeProvider::pr4_task_fsm_tool_calls(&[assignment("task-history-b")], Some(&tools)); + let first = E2eFakeProvider::agent_org_task_fsm_tool_calls( + &[assignment("task-history-a")], + Some(&tools), + ); + let second = E2eFakeProvider::agent_org_task_fsm_tool_calls( + &[assignment("task-history-b")], + Some(&tools), + ); assert_eq!(first.len(), 1); assert_eq!(second.len(), 1); diff --git a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs index 997d206c90..915d431fe2 100644 --- a/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs +++ b/src-tauri/crates/agent-core/src/core/session/turn/processor/inbox_drain/drain.rs @@ -68,7 +68,7 @@ pub fn drain_and_render_deferred( /// Production typed-drain entry point. A TaskExecution Turn can only claim /// the single formal Inbox row for its persisted Task binding; Coordinator /// Turns retain the bounded coordinator Inbox drain. UserDirectedWork is not -/// admitted by PR4's formal wake path and therefore claims nothing here. +/// admitted by the task-bound wake path and therefore claims nothing here. pub(crate) fn drain_and_render_deferred_for_turn( org_context: &AgentOrgRunContext, recipient_agent_id: &str, diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs index 727179f384..61ef4c12c0 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs @@ -16,18 +16,18 @@ use super::task_list_get::{TaskGetTool, TaskListTool}; use super::task_update::TaskUpdateTool; use super::TaskToolsContext; -const RUN_ID: &str = "run-tools-pr4"; -const ROOT_SESSION: &str = "root-tools-pr4"; -const COORDINATOR_TURN: &str = "turn-coordinator-pr4"; +const RUN_ID: &str = "run-task-tools"; +const ROOT_SESSION: &str = "root-task-tools"; +const COORDINATOR_TURN: &str = "turn-coordinator-task-tools"; const ALICE: &str = "m-alice"; const BOB: &str = "m-bob"; -const ALICE_SESSION: &str = "session-alice-pr4"; +const ALICE_SESSION: &str = "session-alice-task-tools"; fn org_context() -> Arc { Arc::new(AgentOrgRunContext { run_id: RUN_ID.into(), - org_id: "org-tools-pr4".into(), - org_name: "Tools PR4 Org".into(), + org_id: "org-task-tools".into(), + org_name: "Task Tools Org".into(), org_role: "lead".into(), coordinator_agent_id: "agent-coordinator".into(), coordinator_name: "Coordinator".into(), @@ -107,8 +107,8 @@ fn sandbox() -> test_env::SandboxGuard { let now = chrono::Utc::now().to_rfc3339(); let snapshot = serde_json::json!({ "schemaVersion": 1, - "orgId": "org-tools-pr4", - "orgName": "Tools PR4 Org", + "orgId": "org-task-tools", + "orgName": "Task Tools Org", "coordinatorRole": "lead", "coordinatorAgentId": "agent-coordinator", "planApprovalPolicy": "coordinator", @@ -124,7 +124,7 @@ fn sandbox() -> test_env::SandboxGuard { "INSERT INTO agent_org_runtime_runs( id,org_id,coordinator_agent_id,root_session_id,org_snapshot_json, entry_mode,status,activation_generation,created_at,updated_at - ) VALUES (?1,'org-tools-pr4','agent-coordinator',?2,?3, + ) VALUES (?1,'org-task-tools','agent-coordinator',?2,?3, 'standalone_session','running',1,?4,?4)", rusqlite::params![RUN_ID, ROOT_SESSION, snapshot, now], ) diff --git a/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs b/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs index 7ac46279c6..41ef2605b6 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/message/org_wake.rs @@ -198,7 +198,7 @@ pub(super) fn resolve_agent_org_wake_mode( } AgentOrgTurnKind::UserDirectedWork => { return Err( - "UserDirectedWork is not enabled in the PR4 formal wake mode resolver".to_string(), + "UserDirectedWork is not enabled in the task-bound wake mode resolver".to_string(), ); } }; diff --git a/src-tauri/src/api/agent/test/agent_org.rs b/src-tauri/src/api/agent/test/agent_org.rs index 4a95885196..70bbc604b5 100644 --- a/src-tauri/src/api/agent/test/agent_org.rs +++ b/src-tauri/src/api/agent/test/agent_org.rs @@ -2661,9 +2661,9 @@ pub async fn test_agent_org_run_cleanup( /// `POST /test/agent-org/tasks/seed` /// -/// PR4 makes the Task Store the final lifecycle authority. Test and debug -/// surfaces may prepare run identity/configuration or read durable state, -/// but they must not manufacture Task rows or terminal states. +/// The Task Store is the final lifecycle authority. Test and debug surfaces +/// may prepare run identity/configuration or read durable state, but they must +/// not manufacture Task rows or terminal states. pub async fn test_agent_org_tasks_seed( Json(_body): Json, ) -> Json { diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts index 52a7830587..11b8e6a50b 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgTaskPanel.test.ts @@ -119,7 +119,7 @@ const reactActEnvironment = globalThis as typeof globalThis & { function task(id: string, status: AgentOrgTaskStatus): AgentOrgTask { return { id, - orgRunId: "run-pr4", + orgRunId: "run-task-panel", subject: `Task ${id}`, description: `Description ${id}`, owner: "member-a", @@ -143,9 +143,9 @@ function deferred() { function runView(): AgentOrgRunView { return { context: { - runId: "run-pr4", - orgId: "org-pr4", - orgName: "PR4 Team", + runId: "run-task-panel", + orgId: "org-task-panel", + orgName: "Task Panel Team", orgRole: "lead", coordinatorAgentId: "coordinator-agent", coordinatorName: "Coordinator", @@ -175,7 +175,7 @@ function runView(): AgentOrgRunView { }; } -describe("Agent Org PR4 Task panel", () => { +describe("Agent Org Task panel", () => { let container: HTMLDivElement; let root: Root; @@ -371,7 +371,7 @@ describe("Agent Org PR4 Task panel", () => { annotations: [ { id: "note-1", - orgRunId: "run-pr4", + orgRunId: "run-task-panel", taskId: "done", kind: "audit_note", body: "reviewed after completion", @@ -387,7 +387,7 @@ describe("Agent Org PR4 Task panel", () => { annotations: [ { id: "note-2", - orgRunId: "run-pr4", + orgRunId: "run-task-panel", taskId: "done", kind: "evidence", body: "second annotation page", diff --git a/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs b/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs index 3c0e2f4aec..7fa74ccfff 100644 --- a/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs +++ b/tests/e2e/specs/core/agent-org-group-chat-ui.spec.mjs @@ -118,7 +118,7 @@ async function pauseDefaultAgentOrgRuns(label) { } } -async function waitForPr4ProductionScenario(sessionId, scenarioId) { +async function waitForTaskFsmProductionScenario(sessionId, scenarioId) { let latestTasks = []; let latestRunId = null; let latestReadError = null; @@ -128,13 +128,13 @@ async function waitForPr4ProductionScenario(sessionId, scenarioId) { try { const view = unwrap( await invokeE2E("agentOrgSessionRunView", sessionId), - `agentOrgSessionRunView(PR4 ${scenarioId})` + `agentOrgSessionRunView(Task FSM ${scenarioId})` ).view; latestRunId = view?.context?.runId ?? latestRunId; if (!latestRunId) return false; latestTasks = unwrap( await invokeE2E("debugAgentOrgTasksList", latestRunId), - `debugAgentOrgTasksList(PR4 ${scenarioId})` + `debugAgentOrgTasksList(Task FSM ${scenarioId})` ).tasks; latestReadError = null; const subject = (prefix) => @@ -143,18 +143,19 @@ async function waitForPr4ProductionScenario(sessionId, scenarioId) { ); const pagedHistory = latestTasks.filter((task) => String(task?.subject ?? "").startsWith( - `E2E_PR4_HISTORY:${scenarioId}:` + `E2E_TASK_FSM_HISTORY:${scenarioId}:` ) ); return Boolean( - subject("E2E_PR4_PENDING")?.status === + subject("E2E_TASK_FSM_PENDING")?.status === AGENT_ORG_TASK_STATUS.PENDING && - subject("E2E_PR4_REPLACEMENT")?.status === + subject("E2E_TASK_FSM_REPLACEMENT")?.status === AGENT_ORG_TASK_STATUS.PENDING && - subject("E2E_PR4_COMPLETE")?.status === + subject("E2E_TASK_FSM_COMPLETE")?.status === AGENT_ORG_TASK_STATUS.COMPLETED && - subject("E2E_PR4_FAIL")?.status === AGENT_ORG_TASK_STATUS.FAILED && - subject("E2E_PR4_LATE")?.status === + subject("E2E_TASK_FSM_FAIL")?.status === + AGENT_ORG_TASK_STATUS.FAILED && + subject("E2E_TASK_FSM_LATE")?.status === AGENT_ORG_TASK_STATUS.CANCELLED && pagedHistory.length === 20 && pagedHistory.every( @@ -169,12 +170,12 @@ async function waitForPr4ProductionScenario(sessionId, scenarioId) { { timeout: REPLY_TIMEOUT_MS, interval: 500, - timeoutMsg: `PR4 production scenario ${scenarioId} did not settle`, + timeoutMsg: `Task FSM production scenario ${scenarioId} did not settle`, } ); } catch (error) { throw new Error( - `PR4 production scenario ${scenarioId} did not settle: ${JSON.stringify({ latestRunId, latestReadError, latestTasks })}`, + `Task FSM production scenario ${scenarioId} did not settle: ${JSON.stringify({ latestRunId, latestReadError, latestTasks })}`, { cause: error } ); } @@ -303,7 +304,7 @@ describe("Agent Org group chat and plan rendered UI", () => { ); }); - it("enforces the PR4 Task FSM through the packaged production Tool path", async () => { + it("enforces the Agent Org Task FSM through the packaged production Tool path", async () => { const account = await getApiAccount(); const model = selectPreferredModel(account); await configureCreatorForDefaultAgentOrg({ account, model }); @@ -312,10 +313,12 @@ describe("Agent Org group chat and plan rendered UI", () => { const scenarioIds = [1, 2, 3].map((index) => `page${index}_${RUN_ID}`); const sessionId = await sendFromRenderedCreator( - `Run ${`E2E_PR4_TASK_FSM:${scenarioIds[0]}`}` + `Run ${`E2E_AGENT_ORG_TASK_FSM:${scenarioIds[0]}`}` ); if (!sessionId) { - throw new Error("PR4 production-path launch did not create a session"); + throw new Error( + "Task FSM production-path launch did not create a session" + ); } await waitForAgentOrgRunView( @@ -327,17 +330,17 @@ describe("Agent Org group chat and plan rendered UI", () => { (view?.tasks ?? []).some( (task) => task.status === AGENT_ORG_TASK_STATUS.PENDING ), - "PR4 Current Work exposes pending and in-progress" + "Task FSM Current Work exposes pending and in-progress" ); const scenarioTasks = []; scenarioTasks.push( - await waitForPr4ProductionScenario(sessionId, scenarioIds[0]) + await waitForTaskFsmProductionScenario(sessionId, scenarioIds[0]) ); for (const scenarioId of scenarioIds.slice(1)) { - await sendRenderedChatPrompt(`Run E2E_PR4_TASK_FSM:${scenarioId}`); + await sendRenderedChatPrompt(`Run E2E_AGENT_ORG_TASK_FSM:${scenarioId}`); scenarioTasks.push( - await waitForPr4ProductionScenario(sessionId, scenarioId) + await waitForTaskFsmProductionScenario(sessionId, scenarioId) ); } @@ -360,7 +363,7 @@ describe("Agent Org group chat and plan rendered UI", () => { statusCounts[AGENT_ORG_TASK_STATUS.CANCELLED] !== 3 ) { throw new Error( - `PR4 production scenarios created an unexpected Task set: ${JSON.stringify({ taskCount: settledTasks.length, statusCounts, duplicateSubjects })}` + `Task FSM production scenarios created an unexpected Task set: ${JSON.stringify({ taskCount: settledTasks.length, statusCounts, duplicateSubjects })}` ); } @@ -369,10 +372,10 @@ describe("Agent Org group chat and plan rendered UI", () => { const tasks = scenarioTasks[index]; const find = (prefix) => tasks.find((task) => task.subject === `${prefix}:${scenarioId}`); - const completed = find("E2E_PR4_COMPLETE"); - const failed = find("E2E_PR4_FAIL"); - const cancelled = find("E2E_PR4_LATE"); - const replacement = find("E2E_PR4_REPLACEMENT"); + const completed = find("E2E_TASK_FSM_COMPLETE"); + const failed = find("E2E_TASK_FSM_FAIL"); + const cancelled = find("E2E_TASK_FSM_LATE"); + const replacement = find("E2E_TASK_FSM_REPLACEMENT"); if ( completed?.output?.producedByMemberId !== DEFAULT_AGENT_ORG_MEMBER_IDS.REVIEWER || @@ -386,12 +389,12 @@ describe("Agent Org group chat and plan rendered UI", () => { replacement?.createdByParticipantId !== AGENT_ORG_COORDINATOR_MEMBER_ID ) { throw new Error( - `PR4 provenance/result invariant failed for ${scenarioId}: ${JSON.stringify({ completed, failed, cancelled, replacement })}` + `Task FSM provenance/result invariant failed for ${scenarioId}: ${JSON.stringify({ completed, failed, cancelled, replacement })}` ); } } - await openAgentOrgOverviewPanel("PR4 Current/History"); + await openAgentOrgOverviewPanel("Task FSM Current/History"); const collapsed = await execJS(` return { expanded: document.querySelector('[data-testid="agent-org-task-history-toggle"]')?.getAttribute('aria-expanded'), @@ -407,7 +410,7 @@ describe("Agent Org group chat and plan rendered UI", () => { ) ) { throw new Error( - `PR4 collapsed History/Current Work invariant failed: ${JSON.stringify(collapsed)}` + `Task FSM collapsed History/Current Work invariant failed: ${JSON.stringify(collapsed)}` ); } @@ -415,7 +418,7 @@ describe("Agent Org group chat and plan rendered UI", () => { js.click('[data-testid="agent-org-task-history-toggle"]') ); if (historyToggle !== "clicked") { - throw new Error(`PR4 History toggle failed: ${historyToggle}`); + throw new Error(`Task FSM History toggle failed: ${historyToggle}`); } const completedFirstPage = await waitForRenderedTaskHistory( AGENT_ORG_TASK_STATUS.COMPLETED, @@ -424,7 +427,7 @@ describe("Agent Org group chat and plan rendered UI", () => { ); if (completedFirstPage.nextDisabled !== false) { throw new Error( - `PR4 completed first page did not expose a next cursor: ${JSON.stringify(completedFirstPage)}` + `Task FSM completed first page did not expose a next cursor: ${JSON.stringify(completedFirstPage)}` ); } const firstPageFirstId = completedFirstPage.rows[0]?.id; @@ -432,7 +435,7 @@ describe("Agent Org group chat and plan rendered UI", () => { js.click('[data-testid="agent-org-task-history-next-page"]') ); if (nextPage !== "clicked") { - throw new Error(`PR4 History next page failed: ${nextPage}`); + throw new Error(`Task FSM History next page failed: ${nextPage}`); } const completedSecondPage = await waitForRenderedTaskHistory( AGENT_ORG_TASK_STATUS.COMPLETED, @@ -444,7 +447,7 @@ describe("Agent Org group chat and plan rendered UI", () => { completedSecondPage.rows[0]?.id === firstPageFirstId ) { throw new Error( - `PR4 completed second page cursor invariant failed: ${JSON.stringify(completedSecondPage)}` + `Task FSM completed second page cursor invariant failed: ${JSON.stringify(completedSecondPage)}` ); } await execJS( @@ -457,11 +460,11 @@ describe("Agent Org group chat and plan rendered UI", () => { ); const completedTaskId = scenarioTasks[0].find( - (task) => task.subject === `E2E_PR4_COMPLETE:${scenarioIds[0]}` + (task) => task.subject === `E2E_TASK_FSM_COMPLETE:${scenarioIds[0]}` )?.id; if (!completedTaskId) { throw new Error( - "PR4 completed Task id was missing from the durable snapshot" + "Task FSM completed Task id was missing from the durable snapshot" ); } let completedDetail = await execJS(` @@ -491,7 +494,9 @@ describe("Agent Org group chat and plan rendered UI", () => { `); } if (completedDetail !== "clicked") { - throw new Error(`PR4 completed detail toggle failed: ${completedDetail}`); + throw new Error( + `Task FSM completed detail toggle failed: ${completedDetail}` + ); } await browser.waitUntil( async () => { @@ -521,7 +526,8 @@ describe("Agent Org group chat and plan rendered UI", () => { row?.querySelector('[data-testid="agent-org-task-detail-toggle"]')?.click(); return Boolean(row); `); - if (!failedDetail) throw new Error("PR4 failed detail row was missing"); + if (!failedDetail) + throw new Error("Task FSM failed detail row was missing"); await browser.waitUntil( async () => String( @@ -555,7 +561,7 @@ describe("Agent Org group chat and plan rendered UI", () => { ) ) { throw new Error( - `PR4 cancelled filter exposed a late result: ${JSON.stringify(cancelledState)}` + `Task FSM cancelled filter exposed a late result: ${JSON.stringify(cancelledState)}` ); } }); From 3cbfb3c000f598b5502caf1e929fbd4ba05db32a Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Mon, 24 Aug 2026 00:39:52 +0800 Subject: [PATCH 4/7] fix(agent-org): accept empty provider placeholders Normalize semantically empty fields from unified provider schemas at the authoritative task_update boundary while preserving required-field validation, unknown-field rejection, and non-empty cross-operation rejection. Move the role-aware Task operation contract onto the PR4 branch and cover provider, dispatcher, typed-parser, store, history, and restart behavior. Verification: - cargo test -p agent_core: 3161 passed, 0 failed, 2 ignored - cargo fmt --all -- --check: passed - cargo clippy -p agent_core --all-targets -- -D warnings: passed - cargo check -p e2e-test: passed - pnpm run typecheck and pnpm run lint: passed - focused Task FSM WDIO: 1 passed - BuildFast live provider acceptance: 3 rounds reached 2/2 completed and persisted across restart - Run finalization wake warning remains deferred as an out-of-scope inbox lifecycle issue Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../agent-core/src/core/providers/e2e_fake.rs | 58 +- .../orchestration/agent_org/task_tests.rs | 461 +++++++++++++++- .../orchestration/agent_org/task_update.rs | 497 +++++++++++++++--- 3 files changed, 924 insertions(+), 92 deletions(-) diff --git a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs index 2e0b344dca..985fae3a34 100644 --- a/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs +++ b/src-tauri/crates/agent-core/src/core/providers/e2e_fake.rs @@ -20,6 +20,46 @@ const CONTROL_WAIT_MARKER: &str = "Create a stoppable window by waiting for abou const TASK_GRAPH_CREATE_TOOL: &str = "task_graph_create"; const TASK_UPDATE_TOOL: &str = "task_update"; +fn task_update_arguments_with_empty_placeholders(arguments: Value) -> Value { + let Value::Object(mut arguments) = arguments else { + return arguments; + }; + let Value::Object(placeholders) = serde_json::json!({ + "subject": null, + "description": "", + "active_form": " \t", + "clear_active_form": false, + "owner_member_id": "", + "clear_owner": false, + "execution_mode": "", + "blocked_by": [], + "metadata": {}, + "eligible_member_ids": [], + "required_role": "\n", + "body": "", + "output": {"summary": "", "content": "", "artifact_ids": []}, + "reason": {"code": "", "message": ""}, + "replacement": { + "id": "", + "subject": "", + "description": null, + "active_form": "", + "owner_member_id": "", + "execution_mode": "", + "blocked_by": [], + "metadata": {}, + "eligible_member_ids": [], + "required_role": "" + } + }) else { + unreachable!("task_update placeholder fixture must be an object"); + }; + for (key, value) in placeholders { + arguments.entry(key).or_insert(value); + } + Value::Object(arguments) +} + pub const E2E_FAKE_PROVIDER_MODEL_PREFIX: &str = "e2e-fake-provider"; pub fn is_e2e_fake_provider_model(model: &str) -> bool { @@ -202,7 +242,7 @@ impl E2eFakeProvider { return vec![ToolCallRequest { id: format!("e2e-task-fsm-owner-{scenario_id}-{task_id}-{stage}"), name: TASK_UPDATE_TOOL.to_string(), - arguments, + arguments: task_update_arguments_with_empty_placeholders(arguments), thought_signature: None, }]; } @@ -289,7 +329,7 @@ impl E2eFakeProvider { vec![ToolCallRequest { id: format!("e2e-task-fsm-replace-{scenario_id}"), name: TASK_UPDATE_TOOL.to_string(), - arguments: serde_json::json!({ + arguments: task_update_arguments_with_empty_placeholders(serde_json::json!({ "operation": "cancel_and_replace", "id": late_task_id, "reason": { @@ -302,7 +342,7 @@ impl E2eFakeProvider { "execution_mode": "build", "eligible_member_ids": ["sde-planner"] } - }), + })), thought_signature: None, }] } @@ -740,7 +780,9 @@ mod tests { ); assert_eq!( start[0].arguments, - json!({ "operation": "start", "id": "task-complete" }) + task_update_arguments_with_empty_placeholders( + json!({ "operation": "start", "id": "task-complete" }) + ) ); let context = json!({ @@ -799,7 +841,9 @@ mod tests { assert_eq!(calls[0].name, TASK_UPDATE_TOOL); assert_eq!( calls[0].arguments, - json!({ "operation": "start", "id": "task-complete" }) + task_update_arguments_with_empty_placeholders( + json!({ "operation": "start", "id": "task-complete" }) + ) ); } @@ -830,7 +874,9 @@ mod tests { assert_eq!(calls[0].name, TASK_UPDATE_TOOL); assert_eq!( calls[0].arguments, - json!({ "operation": "start", "id": "task-complete" }) + task_update_arguments_with_empty_placeholders( + json!({ "operation": "start", "id": "task-complete" }) + ) ); } diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs index 61ef4c12c0..66def4aecf 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_tests.rs @@ -7,6 +7,7 @@ use crate::coordination::agent_org_runs::{ }; use crate::coordination::agent_org_tasks::{AgentOrgTaskStore, TaskStatus}; use crate::tools::impls::orchestration::org_send_message::NoopInboxWakeHook; +use crate::tools::registry::ToolRegistry; use crate::tools::traits::{CallContext, Tool, ToolError}; use test_helpers::test_env; @@ -207,6 +208,46 @@ async fn create_owned(id: &str, owner: &str) -> Value { serde_json::from_str(&result).expect("Task JSON") } +fn strict_provider_task_update_payload(operation: &str, id: &str) -> Value { + json!({ + "operation": operation, + "id": id, + "subject": null, + "description": "", + "active_form": " \t ", + "clear_active_form": false, + "owner_member_id": "", + "clear_owner": false, + "execution_mode": "", + "blocked_by": [], + "metadata": {}, + "eligible_member_ids": [], + "required_role": "\n", + "body": "", + "output": { + "summary": "", + "content": " ", + "artifact_ids": [] + }, + "reason": { + "code": "", + "message": " \n" + }, + "replacement": { + "id": "", + "subject": " ", + "description": null, + "active_form": "", + "owner_member_id": "\t", + "execution_mode": "", + "blocked_by": [], + "metadata": {}, + "eligible_member_ids": [], + "required_role": "" + } + }) +} + #[test] fn task_tool_schemas_expose_pending_create_and_tagged_update() { let create = TaskCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)); @@ -220,7 +261,32 @@ fn task_tool_schemas_expose_pending_create_and_tagged_update() { let update = TaskUpdateTool::new(tools_context(COORDINATOR_MEMBER_ID)); let update_schema = update.parameters(); crate::tools::traits::assert_llm_compatible_schema(&update_schema).expect("task_update schema"); - assert!(update_schema.to_string().contains("operation")); + assert!(update_schema.to_string().contains("patch_pending")); + assert!(!update_schema.to_string().contains("\"start\"")); + assert!(update_schema["properties"].get("active_form").is_some()); + assert!(update_schema["properties"].get("output").is_none()); + + let owner_update = TaskUpdateTool::new(tools_context(ALICE)); + let owner_update_schema = owner_update.parameters(); + crate::tools::traits::assert_llm_compatible_schema(&owner_update_schema) + .expect("Owner task_update schema"); + assert!(owner_update_schema.to_string().contains("\"start\"")); + assert!(!owner_update_schema.to_string().contains("patch_pending")); + assert!(owner_update_schema["properties"] + .get("active_form") + .is_none()); + assert!(owner_update_schema["properties"].get("output").is_some()); + let owner_wire = + crate::providers::responses_common::convert_tools(Some(&[owner_update.to_schema()])) + .expect("Responses tool conversion"); + let owner_wire_params = &owner_wire[0]["parameters"]; + assert!(owner_wire_params["properties"].get("active_form").is_none()); + assert!(owner_wire_params["properties"].get("output").is_some()); + assert!(owner_wire_params["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "output")); let graph = TaskGraphCreateTool::new(tools_context(COORDINATOR_MEMBER_ID)); crate::tools::traits::assert_llm_compatible_schema(&graph.parameters()) @@ -413,33 +479,394 @@ async fn owner_tagged_operations_follow_task_execution_context() { let stored = AgentOrgTaskStore::get(RUN_ID, "owned").unwrap().unwrap(); assert_eq!(stored.output.as_ref().unwrap().produced_by_member_id, ALICE); - let mixed = tool - .execute_text( - json!({"operation":"start","id":"owned","subject":"forged graph field"}), - &owner_call("turn-owned"), + let mixed: Value = serde_json::from_str( + &tool + .execute_text( + json!({"operation":"start","id":"owned","subject":"forged graph field"}), + &owner_call("turn-owned"), + ) + .await + .expect("recoverable mixed-operation misuse returns guidance"), + ) + .expect("correction JSON"); + assert_eq!(mixed["needs_correction"], true); + assert_eq!(mixed["unexpected_fields"], json!(["subject"])); + assert_eq!( + mixed["expected_call"], + json!({"operation":"start","id":""}) + ); +} + +#[tokio::test] +async fn owner_task_update_schema_and_parser_handle_real_strict_provider_payloads() { + let _sandbox = sandbox(); + create_owned("portable", ALICE).await; + let conn = database::db::get_connection().unwrap(); + insert_owner_context(&conn, "turn-portable", "portable"); + + let mut registry = ToolRegistry::new(); + registry.register(Box::new(TaskUpdateTool::new(tools_context(ALICE)))); + let call = owner_call("turn-portable"); + + let correction = registry + .execute( + "task_update", + json!({ + "active_form": "Reading AGENTS.md", + "blocked_by": null, + "body": null, + "clear_active_form": false, + "clear_owner": false, + "description": null, + "eligible_member_ids": null, + "execution_mode": null, + "id": "portable", + "metadata": null, + "operation": "start", + "output": null, + "owner_member_id": null, + "reason": null, + "replacement": null, + "required_role": null, + "subject": null + }), + &call, + ) + .await + .expect("recoverable provider misuse is a structured tool result"); + let correction: Value = serde_json::from_str(&correction.text).expect("correction JSON"); + assert_eq!(correction["needs_correction"], true); + assert_eq!(correction["unexpected_fields"], json!(["active_form"])); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "portable") + .unwrap() + .unwrap() + .status, + TaskStatus::Pending + ); + + let started = registry + .execute( + "task_update", + strict_provider_task_update_payload("start", "portable"), + &call, ) .await - .expect_err("mixed Owner and graph fields are rejected"); - assert!(matches!(mixed, ToolError::InvalidParams(_))); + .expect("semantic-empty strict-provider placeholders do not block start"); + let started: Value = serde_json::from_str(&started.text).expect("started task JSON"); + assert_eq!(started["task"]["status"], "in_progress"); + + let mut complete_payload = strict_provider_task_update_payload("complete", "portable"); + complete_payload["output"] = json!({ + "summary": "done", + "content": "full result", + "artifact_ids": ["artifact-portable"] + }); + let completed = registry + .execute("task_update", complete_payload, &call) + .await + .expect("strict-provider complete payload is normalized by the real registry path"); + let completed: Value = serde_json::from_str(&completed.text).expect("completed task JSON"); + assert_eq!(completed["task"]["status"], "completed"); + assert_eq!(completed["task"]["output"]["summary"], "done"); + + let history = AgentOrgTaskStore::list_history(RUN_ID).expect("Task history"); + assert_eq!(history.len(), 3); + assert_eq!(history[0].event_type, "created"); + assert_eq!(history[0].previous_status, None); + assert_eq!(history[0].next_status, Some(TaskStatus::Pending)); + assert_eq!(history[1].event_type, "updated"); + assert_eq!(history[1].previous_status, Some(TaskStatus::Pending)); + assert_eq!(history[1].next_status, Some(TaskStatus::InProgress)); + assert_eq!(history[1].actor_member_id.as_deref(), Some(ALICE)); + assert_eq!(history[2].event_type, "updated"); + assert_eq!(history[2].previous_status, Some(TaskStatus::InProgress)); + assert_eq!(history[2].next_status, Some(TaskStatus::Completed)); + assert_eq!(history[2].actor_member_id.as_deref(), Some(ALICE)); + let history_ids = history + .iter() + .map(|event| event.id.clone()) + .collect::>(); + + drop(registry); + let reconciled = crate::coordination::reconcile_agent_org_turns_after_restart(&conn) + .expect("restart reconciliation"); + assert_eq!(reconciled, 0); + let restarted = AgentOrgTaskStore::get(RUN_ID, "portable") + .expect("restart Task read") + .expect("completed Task persists"); + assert_eq!(restarted.status, TaskStatus::Completed); + assert_eq!(restarted.output.as_ref().unwrap().summary, "done"); + let restarted_history = AgentOrgTaskStore::list_history(RUN_ID).expect("restart history read"); + assert_eq!( + restarted_history + .iter() + .map(|event| event.id.clone()) + .collect::>(), + history_ids, + "restart reconciliation must not synthesize completion history" + ); } #[tokio::test] -async fn coordinator_cannot_submit_output_and_wrong_turn_cannot_start() { +async fn create_fail_and_cancel_accept_only_semantic_empty_cross_operation_placeholders() { let _sandbox = sandbox(); - create_owned("protected", ALICE).await; - let coordinator = TaskUpdateTool::new(tools_context(COORDINATOR_MEMBER_ID)); - let error = coordinator - .execute_text( + + let mut create_registry = ToolRegistry::new(); + create_registry.register(Box::new(TaskCreateTool::new(tools_context( + COORDINATOR_MEMBER_ID, + )))); + let created = create_registry + .execute( + "task_create", json!({ - "operation":"complete", - "id":"protected", - "output":{"summary":"forged"} + "id": "create-portable", + "subject": "Portable create", + "description": "", + "active_form": null, + "owner_member_id": ALICE, + "dispatch_policy": "immediate", + "execution_mode": "build", + "dependency_task_ids": [], + "allow_parallel_with_unlisted_open_tasks": true, + "metadata": {}, + "eligible_member_ids": null, + "required_role": null + }), + &coordinator_call(), + ) + .await + .expect("task_create keeps its existing strict-provider null contract"); + let created: Value = serde_json::from_str(&created.text).expect("created Task JSON"); + assert_eq!(created["task"]["status"], "pending"); + + create_owned("fail-portable", ALICE).await; + let conn = database::db::get_connection().unwrap(); + insert_owner_context(&conn, "turn-fail-portable", "fail-portable"); + let mut owner_registry = ToolRegistry::new(); + owner_registry.register(Box::new(TaskUpdateTool::new(tools_context(ALICE)))); + let owner_call = owner_call("turn-fail-portable"); + owner_registry + .execute( + "task_update", + strict_provider_task_update_payload("start", "fail-portable"), + &owner_call, + ) + .await + .expect("start before fail"); + let mut fail_payload = strict_provider_task_update_payload("fail", "fail-portable"); + fail_payload["reason"] = json!({ + "code": "verification.failed", + "message": "deterministic failure" + }); + let failed = owner_registry + .execute("task_update", fail_payload, &owner_call) + .await + .expect("fail accepts empty placeholders from other operations"); + let failed: Value = serde_json::from_str(&failed.text).expect("failed Task JSON"); + assert_eq!(failed["task"]["status"], "failed"); + + create_owned("cancel-portable", ALICE).await; + let mut coordinator_registry = ToolRegistry::new(); + coordinator_registry.register(Box::new(TaskUpdateTool::new(tools_context( + COORDINATOR_MEMBER_ID, + )))); + let mut cancel_payload = strict_provider_task_update_payload("cancel", "cancel-portable"); + cancel_payload["reason"] = json!({ + "code": "scope.cancelled", + "message": "no longer required" + }); + let cancelled = coordinator_registry + .execute("task_update", cancel_payload, &coordinator_call()) + .await + .expect("cancel accepts empty placeholders from other operations"); + let cancelled: Value = serde_json::from_str(&cancelled.text).expect("cancelled Task JSON"); + assert_eq!(cancelled["task"]["status"], "cancelled"); +} + +#[tokio::test] +async fn task_update_placeholder_normalization_stays_fail_closed() { + let _sandbox = sandbox(); + create_owned("strict-negative", ALICE).await; + create_owned("strict-cancel", ALICE).await; + let conn = database::db::get_connection().unwrap(); + insert_owner_context(&conn, "turn-strict-negative", "strict-negative"); + + let mut owner_registry = ToolRegistry::new(); + owner_registry.register(Box::new(TaskUpdateTool::new(tools_context(ALICE)))); + let owner_call = owner_call("turn-strict-negative"); + + let mut unknown = strict_provider_task_update_payload("start", "strict-negative"); + unknown["unknown_provider_field"] = json!(""); + let unknown = owner_registry + .execute("task_update", unknown, &owner_call) + .await + .expect("unknown empty field returns structured correction"); + let unknown: Value = serde_json::from_str(&unknown.text).expect("unknown-field correction"); + assert_eq!( + unknown["unexpected_fields"], + json!(["unknown_provider_field"]) + ); + + let wrong_shape = owner_registry + .execute( + "task_update", + json!({"operation":"start", "id":"strict-negative", "body":{}}), + &owner_call, + ) + .await + .expect("wrong-shaped empty field returns structured correction"); + let wrong_shape: Value = + serde_json::from_str(&wrong_shape.text).expect("wrong-shape correction"); + assert_eq!(wrong_shape["unexpected_fields"], json!(["body"])); + + let meaningful = owner_registry + .execute( + "task_update", + json!({ + "operation":"start", + "id":"strict-negative", + "body":"real progress", + "output":{"summary":"forged output"}, + "reason":{"code":"forged", "message":"forged reason"} }), + &owner_call, + ) + .await + .expect("meaningful cross-operation fields return structured correction"); + let meaningful: Value = + serde_json::from_str(&meaningful.text).expect("meaningful-field correction"); + assert_eq!( + meaningful["unexpected_fields"], + json!(["body", "output", "reason"]) + ); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "strict-negative") + .unwrap() + .unwrap() + .status, + TaskStatus::Pending + ); + assert_eq!( + AgentOrgTaskStore::list_history(RUN_ID) + .unwrap() + .iter() + .filter(|event| event.task_id == "strict-negative") + .count(), + 1, + "rejected payloads must not write Task history" + ); + + owner_registry + .execute( + "task_update", + strict_provider_task_update_payload("start", "strict-negative"), + &owner_call, + ) + .await + .expect("valid start"); + let missing_output = owner_registry + .execute( + "task_update", + json!({"operation":"complete", "id":"strict-negative"}), + &owner_call, + ) + .await + .expect("missing current-operation field returns structured correction"); + let missing_output: Value = + serde_json::from_str(&missing_output.text).expect("missing-output correction"); + assert_eq!(missing_output["needs_correction"], true); + assert!(missing_output["reason"] + .as_str() + .unwrap() + .contains("missing field `output`")); + + let empty_output = owner_registry + .execute( + "task_update", + strict_provider_task_update_payload("complete", "strict-negative"), + &owner_call, + ) + .await + .expect_err("empty current-operation output remains invalid"); + assert!(empty_output.contains("non-empty summary")); + let empty_reason = owner_registry + .execute( + "task_update", + strict_provider_task_update_payload("fail", "strict-negative"), + &owner_call, + ) + .await + .expect_err("empty current-operation reason remains invalid"); + assert!(empty_reason.contains("requires non-empty code and message")); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "strict-negative") + .unwrap() + .unwrap() + .status, + TaskStatus::InProgress + ); + assert_eq!( + AgentOrgTaskStore::list_history(RUN_ID) + .unwrap() + .iter() + .filter(|event| event.task_id == "strict-negative") + .count(), + 2, + "invalid terminal calls must not write Task history" + ); + + let mut coordinator_registry = ToolRegistry::new(); + coordinator_registry.register(Box::new(TaskUpdateTool::new(tools_context( + COORDINATOR_MEMBER_ID, + )))); + let cancel_error = coordinator_registry + .execute( + "task_update", + strict_provider_task_update_payload("cancel", "strict-cancel"), &coordinator_call(), ) .await - .expect_err("Coordinator cannot complete Owner Task"); - assert!(error.to_string().contains("Owner lifecycle")); + .expect_err("empty current-operation cancel reason remains invalid"); + assert!(cancel_error.contains("requires non-empty code and message")); + assert_eq!( + AgentOrgTaskStore::get(RUN_ID, "strict-cancel") + .unwrap() + .unwrap() + .status, + TaskStatus::Pending + ); +} + +#[tokio::test] +async fn coordinator_cannot_submit_output_and_wrong_turn_cannot_start() { + let _sandbox = sandbox(); + create_owned("protected", ALICE).await; + let coordinator = TaskUpdateTool::new(tools_context(COORDINATOR_MEMBER_ID)); + let correction: Value = serde_json::from_str( + &coordinator + .execute_text( + json!({ + "operation":"complete", + "id":"protected", + "output":{"summary":"forged"} + }), + &coordinator_call(), + ) + .await + .expect("role misuse returns correction without mutating the Task"), + ) + .expect("role correction JSON"); + assert_eq!(correction["needs_correction"], true); + assert!(correction["reason"] + .as_str() + .unwrap() + .contains("outside this caller's persisted Task authority")); + assert!(!correction["allowed_operations"] + .as_array() + .unwrap() + .iter() + .any(|operation| operation == "complete")); let owner = TaskUpdateTool::new(tools_context(ALICE)); let error = owner diff --git a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs index 99340d5dcd..5dac64634e 100644 --- a/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs +++ b/src-tauri/crates/agent-core/src/core/tools/impls/orchestration/agent_org/task_update.rs @@ -120,6 +120,337 @@ pub struct ReplacementTaskParams { pub required_role: Option, } +const COORDINATOR_TASK_UPDATE_OPERATIONS: &[&str] = &[ + "patch_pending", + "cancel", + "cancel_and_replace", + "append_audit_note", +]; +const OWNER_TASK_UPDATE_OPERATIONS: &[&str] = &[ + "start", + "complete", + "fail", + "append_progress", + "append_evidence", +]; +const TASK_UPDATE_FIELDS: &[&str] = &[ + "operation", + "id", + "subject", + "description", + "active_form", + "clear_active_form", + "owner_member_id", + "clear_owner", + "execution_mode", + "blocked_by", + "metadata", + "eligible_member_ids", + "required_role", + "body", + "output", + "reason", + "replacement", +]; +const TASK_OUTPUT_FIELDS: &[&str] = &["summary", "content", "artifact_ids"]; +const TASK_REASON_FIELDS: &[&str] = &["code", "message"]; +const REPLACEMENT_TASK_FIELDS: &[&str] = &[ + "id", + "subject", + "description", + "active_form", + "owner_member_id", + "execution_mode", + "blocked_by", + "metadata", + "eligible_member_ids", + "required_role", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TaskUpdateAuthority { + Coordinator, + Owner, +} + +struct TaskUpdateOperationContract { + authority: TaskUpdateAuthority, + allowed_fields: &'static [&'static str], +} + +fn task_update_operation_contract(operation: &str) -> Option { + let contract = match operation { + "patch_pending" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Coordinator, + allowed_fields: &[ + "operation", + "id", + "subject", + "description", + "active_form", + "clear_active_form", + "owner_member_id", + "clear_owner", + "execution_mode", + "blocked_by", + "metadata", + "eligible_member_ids", + "required_role", + ], + }, + "start" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Owner, + allowed_fields: &["operation", "id"], + }, + "complete" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Owner, + allowed_fields: &["operation", "id", "output"], + }, + "fail" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Owner, + allowed_fields: &["operation", "id", "reason"], + }, + "cancel" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Coordinator, + allowed_fields: &["operation", "id", "reason"], + }, + "cancel_and_replace" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Coordinator, + allowed_fields: &["operation", "id", "reason", "replacement"], + }, + "append_progress" | "append_evidence" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Owner, + allowed_fields: &["operation", "id", "body"], + }, + "append_audit_note" => TaskUpdateOperationContract { + authority: TaskUpdateAuthority::Coordinator, + allowed_fields: &["operation", "id", "body"], + }, + _ => return None, + }; + Some(contract) +} + +fn is_semantically_empty_json_placeholder(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(value) => value.trim().is_empty(), + Value::Array(values) => values.is_empty(), + Value::Object(values) => values.values().all(is_semantically_empty_json_placeholder), + Value::Bool(_) | Value::Number(_) => false, + } +} + +fn is_empty_known_object_placeholder(value: &Value, known_fields: &[&str]) -> bool { + let Value::Object(values) = value else { + return false; + }; + values.iter().all(|(key, value)| { + known_fields.contains(&key.as_str()) && is_semantically_empty_json_placeholder(value) + }) +} + +fn is_removable_cross_operation_placeholder(field: &str, value: &Value) -> bool { + if !TASK_UPDATE_FIELDS.contains(&field) { + return false; + } + if value.is_null() + || value.as_str().is_some_and(|value| value.trim().is_empty()) + || value.as_array().is_some_and(Vec::is_empty) + { + return true; + } + if matches!(field, "clear_active_form" | "clear_owner") && value == &Value::Bool(false) { + return true; + } + match field { + "output" => is_empty_known_object_placeholder(value, TASK_OUTPUT_FIELDS), + "reason" => is_empty_known_object_placeholder(value, TASK_REASON_FIELDS), + "replacement" => is_empty_known_object_placeholder(value, REPLACEMENT_TASK_FIELDS), + "metadata" => value + .as_object() + .is_some_and(|_| is_semantically_empty_json_placeholder(value)), + _ => false, + } +} + +fn task_update_operation_example(operation: &str) -> Value { + match operation { + "patch_pending" => json!({ + "operation": "patch_pending", + "id": "", + "subject": "" + }), + "start" => json!({"operation": "start", "id": ""}), + "complete" => json!({ + "operation": "complete", + "id": "", + "output": {"summary": ""} + }), + "fail" => json!({ + "operation": "fail", + "id": "", + "reason": {"code": "", "message": ""} + }), + "cancel" => json!({ + "operation": "cancel", + "id": "", + "reason": {"code": "", "message": ""} + }), + "cancel_and_replace" => json!({ + "operation": "cancel_and_replace", + "id": "", + "reason": {"code": "", "message": ""}, + "replacement": { + "subject": "", + "execution_mode": "build" + } + }), + "append_progress" => json!({ + "operation": "append_progress", + "id": "", + "body": "" + }), + "append_evidence" => json!({ + "operation": "append_evidence", + "id": "", + "body": "" + }), + "append_audit_note" => json!({ + "operation": "append_audit_note", + "id": "", + "body": "" + }), + _ => Value::Null, + } +} + +fn task_update_correction( + is_coordinator: bool, + operation: Option<&str>, + unexpected_fields: Vec, + reason: &str, +) -> Value { + let allowed_operations = if is_coordinator { + COORDINATOR_TASK_UPDATE_OPERATIONS + } else { + OWNER_TASK_UPDATE_OPERATIONS + }; + let contract = operation.and_then(task_update_operation_contract); + let allowed_fields = contract + .as_ref() + .map(|contract| contract.allowed_fields) + .unwrap_or(&[]); + let expected_call = operation + .map(task_update_operation_example) + .unwrap_or(Value::Null); + json!({ + "needs_correction": true, + "tool": tool_names::TASK_UPDATE, + "operation": operation, + "reason": reason, + "unexpected_fields": unexpected_fields, + "allowed_operations": allowed_operations, + "allowed_fields": allowed_fields, + "expected_call": expected_call, + "guidance": "Retry task_update once using only the fields in expected_call/allowed_fields. Do not copy fields from another operation." + }) +} + +fn task_output_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["summary"], + "properties": { + "summary": { "type": "string" }, + "content": { "type": "string" }, + "artifact_ids": { "type": "array", "items": { "type": "string" } } + } + }) +} + +fn task_reason_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + }) +} + +fn replacement_task_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["subject", "execution_mode"], + "properties": { + "id": { "type": "string" }, + "subject": { "type": "string" }, + "description": { "type": "string" }, + "active_form": { "type": "string" }, + "owner_member_id": { "type": "string" }, + "execution_mode": { "type": "string", "enum": ["plan", "build"] }, + "blocked_by": { "type": "array", "items": { "type": "string" } }, + "metadata": { "type": "object", "additionalProperties": true }, + "eligible_member_ids": { "type": "array", "items": { "type": "string" } }, + "required_role": { "type": "string" } + } + }) +} + +fn task_update_parameters(is_coordinator: bool) -> Value { + if is_coordinator { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["operation", "id"], + "properties": { + "operation": { + "type": "string", + "enum": COORDINATOR_TASK_UPDATE_OPERATIONS, + "description": "Coordinator only. patch_pending uses graph fields; cancel uses reason; cancel_and_replace uses reason+replacement; append_audit_note uses body. Omit fields from every other operation." + }, + "id": { "type": "string" }, + "subject": { "type": "string", "description": "patch_pending only" }, + "description": { "type": "string", "description": "patch_pending only" }, + "active_form": { "type": "string", "description": "patch_pending only" }, + "clear_active_form": { "type": "boolean", "description": "patch_pending only" }, + "owner_member_id": { "type": "string", "description": "patch_pending only" }, + "clear_owner": { "type": "boolean", "description": "patch_pending only" }, + "execution_mode": { "type": "string", "enum": ["plan", "build"], "description": "patch_pending only" }, + "blocked_by": { "type": "array", "items": { "type": "string" }, "description": "patch_pending only" }, + "metadata": { "type": "object", "additionalProperties": true, "description": "patch_pending only" }, + "eligible_member_ids": { "type": "array", "items": { "type": "string" }, "description": "patch_pending only" }, + "required_role": { "type": "string", "description": "patch_pending only" }, + "body": { "type": "string", "description": "append_audit_note only" }, + "reason": task_reason_schema(), + "replacement": replacement_task_schema() + } + }) + } else { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["operation", "id"], + "properties": { + "operation": { + "type": "string", + "enum": OWNER_TASK_UPDATE_OPERATIONS, + "description": "Owner only. start accepts exactly operation+id; complete adds output; fail adds reason; append_progress/append_evidence add body. Omit fields from every other operation." + }, + "id": { "type": "string" }, + "body": { "type": "string", "description": "append_progress or append_evidence only" }, + "output": task_output_schema(), + "reason": task_reason_schema() + } + }) + } +} + pub struct TaskUpdateTool { ctx: Arc, } @@ -156,81 +487,26 @@ impl Tool for TaskUpdateTool { // `TaskUpdateParams` stays a serde-tagged enum so the runtime parser // rejects fields that belong to a different actor/operation. Schemars // represents that enum as a top-level `oneOf`, however, and several - // function-calling providers silently discard such schemas. Advertise - // one flat portable object and enforce per-operation requiredness in - // the typed parser below. - json!({ - "type": "object", - "additionalProperties": false, - "required": ["operation", "id"], - "properties": { - "operation": { - "type": "string", - "enum": [ - "patch_pending", "start", "complete", "fail", "cancel", - "cancel_and_replace", "append_progress", "append_evidence", - "append_audit_note" - ] - }, - "id": { "type": "string" }, - "subject": { "type": "string" }, - "description": { "type": "string" }, - "active_form": { "type": "string" }, - "clear_active_form": { "type": "boolean" }, - "owner_member_id": { "type": "string" }, - "clear_owner": { "type": "boolean" }, - "execution_mode": { "type": "string", "enum": ["plan", "build"] }, - "blocked_by": { "type": "array", "items": { "type": "string" } }, - "metadata": { "type": "object", "additionalProperties": true }, - "eligible_member_ids": { "type": "array", "items": { "type": "string" } }, - "required_role": { "type": "string" }, - "body": { "type": "string" }, - "output": { - "type": "object", - "additionalProperties": false, - "required": ["summary"], - "properties": { - "summary": { "type": "string" }, - "content": { "type": "string" }, - "artifact_ids": { "type": "array", "items": { "type": "string" } } - } - }, - "reason": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { "type": "string" }, - "message": { "type": "string" } - } - }, - "replacement": { - "type": "object", - "additionalProperties": false, - "required": ["subject", "execution_mode"], - "properties": { - "id": { "type": "string" }, - "subject": { "type": "string" }, - "description": { "type": "string" }, - "active_form": { "type": "string" }, - "owner_member_id": { "type": "string" }, - "execution_mode": { "type": "string", "enum": ["plan", "build"] }, - "blocked_by": { "type": "array", "items": { "type": "string" } }, - "metadata": { "type": "object", "additionalProperties": true }, - "eligible_member_ids": { "type": "array", "items": { "type": "string" } }, - "required_role": { "type": "string" } - } - } - } - }) + // function-calling providers silently discard such schemas. Keep the + // portable flat object, but expose only operations and fields this + // session's persisted org role may actually use. This prevents strict + // providers from filling Coordinator-only fields into an Owner + // `start` call while the typed parser remains the authority boundary. + task_update_parameters(self.ctx.is_coordinator()) } async fn execute_text( &self, - params_value: Value, + mut params_value: Value, call_ctx: &CallContext, ) -> Result { - let params: TaskUpdateParams = parse_params(params_value)?; + let params = match self.parse_model_params(&mut params_value) { + Ok(params) => params, + Err(correction) => { + return serde_json::to_string(&correction) + .map_err(|error| ToolError::ExecutionFailed(error.to_string())) + } + }; match params { TaskUpdateParams::PatchPending { id, @@ -475,6 +751,89 @@ impl Tool for TaskUpdateTool { } impl TaskUpdateTool { + fn parse_model_params(&self, params_value: &mut Value) -> Result { + let Some(params) = params_value.as_object_mut() else { + return Err(task_update_correction( + self.ctx.is_coordinator(), + None, + Vec::new(), + "task_update parameters must be a JSON object", + )); + }; + let operation = params + .get("operation") + .and_then(Value::as_str) + .map(str::to_string); + let Some(operation_name) = operation.as_deref() else { + return Err(task_update_correction( + self.ctx.is_coordinator(), + None, + Vec::new(), + "operation is required and must be a string", + )); + }; + let Some(contract) = task_update_operation_contract(operation_name) else { + return Err(task_update_correction( + self.ctx.is_coordinator(), + Some(operation_name), + Vec::new(), + "unknown task_update operation", + )); + }; + let expected_authority = if self.ctx.is_coordinator() { + TaskUpdateAuthority::Coordinator + } else { + TaskUpdateAuthority::Owner + }; + if contract.authority != expected_authority { + return Err(task_update_correction( + self.ctx.is_coordinator(), + Some(operation_name), + Vec::new(), + "operation is outside this caller's persisted Task authority", + )); + } + + // Some providers populate every property in a portable flat schema. + // Normalize only known fields from another operation when their value + // is semantically empty. Fields used by the selected operation remain + // untouched for the typed parser, and unknown or meaningful fields + // stay fail-closed. + let keys = params.keys().cloned().collect::>(); + let mut unexpected_fields = Vec::new(); + for key in keys { + if contract.allowed_fields.contains(&key.as_str()) { + continue; + } + let removable_placeholder = params + .get(&key) + .is_some_and(|value| is_removable_cross_operation_placeholder(&key, value)); + if removable_placeholder { + params.remove(&key); + } else { + unexpected_fields.push(key); + } + } + unexpected_fields.sort(); + if !unexpected_fields.is_empty() { + return Err(task_update_correction( + self.ctx.is_coordinator(), + Some(operation_name), + unexpected_fields, + "fields from another task_update operation are not accepted", + )); + } + + parse_params(params_value.take()).map_err(|error| { + task_update_correction( + self.ctx.is_coordinator(), + Some(operation_name), + Vec::new(), + &error.to_string(), + ) + }) + } + fn graph_actor(&self, call_ctx: &CallContext) -> Result { if !self.ctx.is_coordinator() { return Err(ToolError::InvalidParams( From f33047ef421a0336649766c8501846c4be68e028 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Tue, 1 Sep 2026 10:23:53 +0800 Subject: [PATCH 5/7] fix(ci): satisfy task validation gates --- src-tauri/src/api/agent/test/workspace.rs | 44 +++++++++---------- .../agent/{ => __tests__}/orgTasks.test.ts | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) rename src/api/tauri/agent/{ => __tests__}/orgTasks.test.ts (97%) diff --git a/src-tauri/src/api/agent/test/workspace.rs b/src-tauri/src/api/agent/test/workspace.rs index 7b3152d52a..bb7b2dc3e9 100644 --- a/src-tauri/src/api/agent/test/workspace.rs +++ b/src-tauri/src/api/agent/test/workspace.rs @@ -766,28 +766,6 @@ pub async fn test_session_llm_history( } } -#[cfg(test)] -mod llm_history_projection_tests { - use super::projected_compact_boundary_count; - - #[test] - fn compact_boundary_count_survives_role_normalization() { - let messages = vec![ - serde_json::json!({ - "role": "user", - "content": "[Conversation summary — 2 earlier messages compacted]\n\nsummary" - }), - serde_json::json!({ "role": "user", "content": "recent user" }), - serde_json::json!({ - "role": "system", - "content": "[Session Memory — 4 earlier messages compacted]\n\nsummary" - }), - ]; - - assert_eq!(projected_compact_boundary_count(&messages), 2); - } -} - /// `POST /agent/test/session/seed-raw-history` — seed un-compacted /// user/assistant rows so E2E can trigger auto-compaction through the real turn. pub async fn test_session_seed_raw_history( @@ -1225,3 +1203,25 @@ pub async fn test_session_aggregate_list_via_cmd( })), } } + +#[cfg(test)] +mod llm_history_projection_tests { + use super::projected_compact_boundary_count; + + #[test] + fn compact_boundary_count_survives_role_normalization() { + let messages = vec![ + serde_json::json!({ + "role": "user", + "content": "[Conversation summary — 2 earlier messages compacted]\n\nsummary" + }), + serde_json::json!({ "role": "user", "content": "recent user" }), + serde_json::json!({ + "role": "system", + "content": "[Session Memory — 4 earlier messages compacted]\n\nsummary" + }), + ]; + + assert_eq!(projected_compact_boundary_count(&messages), 2); + } +} diff --git a/src/api/tauri/agent/orgTasks.test.ts b/src/api/tauri/agent/__tests__/orgTasks.test.ts similarity index 97% rename from src/api/tauri/agent/orgTasks.test.ts rename to src/api/tauri/agent/__tests__/orgTasks.test.ts index a8a7433fde..a7efaeb25d 100644 --- a/src/api/tauri/agent/orgTasks.test.ts +++ b/src/api/tauri/agent/__tests__/orgTasks.test.ts @@ -5,7 +5,7 @@ import { agentOrgTaskStatusSatisfiesDependency, isAgentOrgTaskOpenStatus, isAgentOrgTaskTerminalStatus, -} from "./orgTasks"; +} from "../orgTasks"; describe("Agent Org Task status semantics", () => { it.each([ From f9b69014b6df0636d2cb0b61cb371a542382d0e9 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Tue, 1 Sep 2026 12:09:05 +0800 Subject: [PATCH 6/7] fix(agent-org): make failed-task reassignment actionable --- src-tauri/crates/agent-core/src/lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/crates/agent-core/src/lifecycle.rs b/src-tauri/crates/agent-core/src/lifecycle.rs index ab72401fb0..ef5f6cc35d 100644 --- a/src-tauri/crates/agent-core/src/lifecycle.rs +++ b/src-tauri/crates/agent-core/src/lifecycle.rs @@ -307,7 +307,7 @@ fn member_failure_recovery_guidance(failure_reason: &str, requeued_tasks: &[Task lines.extend([ String::new(), "Recommended recovery:".to_string(), - "1. Inspect the failure and choose a replacement owner explicitly with task_update owner_member_id.".to_string(), + "1. Inspect the failure and choose a replacement owner explicitly with task_update operation=patch_pending owner_member_id=...".to_string(), "2. If retrying the same member is appropriate, explicitly assign the task back to it; the system will not do so automatically.".to_string(), "3. Never assign outside eligible_member_ids; repair eligibility first when the intended replacement is missing.".to_string(), "4. If no eligible member is available, pause and report to the user.".to_string(), From 86906b842d897f5e8e9eb47d1208759490694df0 Mon Sep 17 00:00:00 2001 From: Shibo Sheng Date: Wed, 2 Sep 2026 00:41:32 +0800 Subject: [PATCH 7/7] style(agent-org): align task views with tailwind v4 --- .../InputArea/components/AgentOrgOverviewPanel.tsx | 8 ++++---- .../InputArea/components/AgentOrgTaskList.tsx | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx index efc783d0bf..9234790f6d 100644 --- a/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx +++ b/src/engines/ChatPanel/InputArea/components/AgentOrgOverviewPanel.tsx @@ -501,7 +501,7 @@ const AgentOrgOverviewPanel: React.FC = memo(
)} {(details[task.id]?.output ?? task.outputSummary) && ( -
+
{t("planner.agentOrgTasks.output")}
{details[task.id]?.output?.summary ?? task.outputSummary?.summary} {details[task.id]?.output?.content && ( -
+
{details[task.id].output?.content}
)}
)} {(details[task.id]?.failureReason ?? task.failureReason) && ( -
+
{ (details[task.id]?.failureReason ?? task.failureReason) ?.message @@ -614,7 +614,7 @@ export const AgentOrgTaskList: React.FC = memo(
)} {(details[task.id]?.cancelReason ?? task.cancelReason) && ( -
+
{ (details[task.id]?.cancelReason ?? task.cancelReason) ?.message @@ -639,7 +639,7 @@ export const AgentOrgTaskList: React.FC = memo( {annotationPages[task.id].hasMore && (