feat(agent-team): quality-gated task completion + member shutdown (#3374 PR3) (#3596)

This commit is contained in:
oxoxDev
2026-06-11 18:03:29 +05:30
committed by GitHub
parent 643c830e96
commit 324146b491
10 changed files with 905 additions and 31 deletions
+75
View File
@@ -123,3 +123,78 @@ describe('agentTeamApi.listMessages', () => {
});
});
});
describe('agentTeamApi.completeTask', () => {
it('unwraps { result } and defaults evidence + requireEvidence', async () => {
mockCall.mockResolvedValueOnce({ result: { kind: 'completed', id: 'task-1', status: 'done' } });
const outcome = await agentTeamApi.completeTask({
teamId: 'team-1',
taskId: 'task-1',
memberId: 'm1',
});
expect(outcome.kind).toBe('completed');
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.agent_team_complete_task',
params: {
teamId: 'team-1',
taskId: 'task-1',
memberId: 'm1',
evidence: [],
requireEvidence: false,
},
});
});
it('forwards evidence + requireEvidence and surfaces a gateFailed outcome', async () => {
mockCall.mockResolvedValueOnce({
result: { kind: 'gateFailed', reasons: ['completion requires at least one evidence link'] },
});
const outcome = await agentTeamApi.completeTask({
teamId: 'team-1',
taskId: 'task-1',
memberId: 'm1',
evidence: ['proof'],
requireEvidence: true,
});
expect(outcome).toEqual({
kind: 'gateFailed',
reasons: ['completion requires at least one evidence link'],
});
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.agent_team_complete_task',
params: {
teamId: 'team-1',
taskId: 'task-1',
memberId: 'm1',
evidence: ['proof'],
requireEvidence: true,
},
});
});
it('throws when a required id is missing, without calling core', async () => {
await expect(
agentTeamApi.completeTask({ teamId: '', taskId: 't', memberId: 'm' })
).rejects.toThrow('required');
expect(mockCall).not.toHaveBeenCalled();
});
});
describe('agentTeamApi.shutdownMember', () => {
it('unwraps { result } to the MemberShutdown', async () => {
const result = { member: { id: 'm1', memberStatus: 'stopped' }, releasedTaskIds: ['task-1'] };
mockCall.mockResolvedValueOnce({ result });
const outcome = await agentTeamApi.shutdownMember('team-1', 'm1');
expect(outcome).toBe(result);
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.agent_team_shutdown_member',
params: { teamId: 'team-1', memberId: 'm1' },
});
});
it('throws when teamId or memberId is empty, without calling core', async () => {
await expect(agentTeamApi.shutdownMember('', 'm1')).rejects.toThrow('required');
await expect(agentTeamApi.shutdownMember('team-1', '')).rejects.toThrow('required');
expect(mockCall).not.toHaveBeenCalled();
});
});
+69 -5
View File
@@ -1,11 +1,13 @@
/**
* Frontend client for the durable agent-team coordination surface (#3374).
*
* Wraps the read paths of the `openhuman.agent_team_*` controller family that
* landed with the durable team ledger (PR1, #3546): `agent_team_list`,
* `agent_team_get`, and `agent_team_list_messages`. PR2 is a READ-ONLY surface
* — creating teams, assigning/claiming tasks and posting messages is the
* agents' job (and a later PR), so only the three read methods live here.
* Wraps the `openhuman.agent_team_*` controller family from the durable team
* ledger (PR1, #3546): the read paths `agent_team_list`, `agent_team_get`, and
* `agent_team_list_messages`, plus two lifecycle writes added with quality-gated
* completion — `agent_team_complete_task` and `agent_team_shutdown_member`.
* Creating teams, assigning and claiming tasks, and posting messages stay the
* agents' job (driven over the same controllers from the run loop), so those
* write methods are deliberately absent here.
*
* The Rust controllers serialize their row types with
* `#[serde(rename_all = "camelCase")]`, so the wire payload is already camelCase
@@ -86,6 +88,24 @@ export interface TeamView {
tasks: AgentTeamTask[];
}
/**
* Outcome of a completion attempt. Mirrors Rust `CompletionOutcome`, which is an
* internally-tagged enum on `kind`: `completed` flattens the resulting task onto
* the object; `gateFailed` carries the unmet-invariant reasons; the rest are
* bare tags.
*/
export type CompletionOutcome =
| ({ kind: 'completed' } & AgentTeamTask)
| { kind: 'gateFailed'; reasons: string[] }
| { kind: 'notClaimed' }
| { kind: 'unknownTask' };
/** Result of stopping a member. Mirrors Rust `MemberShutdown`. */
export interface MemberShutdown {
member: AgentTeamMember;
releasedTaskIds: string[];
}
/** Parsed payload of a `team_message` run event. Mirrors the Rust `json!` body. */
export interface TeamMessagePayload {
from: string;
@@ -194,4 +214,48 @@ export const agentTeamApi = {
log('listMessages received=%d', messages.length);
return messages;
},
/**
* Complete a claimed task, gating its transition to `done`. The core checks
* the quality invariants (dependencies done, the completer is the claimant and
* any pre-assigned owner, evidence present when `requireEvidence`) and returns
* a {@link CompletionOutcome}: `completed`, `gateFailed` (with reasons),
* `notClaimed`, or `unknownTask`. Evidence links accumulate across retries.
*/
completeTask: async (params: {
teamId: string;
taskId: string;
memberId: string;
evidence?: string[];
requireEvidence?: boolean;
}): Promise<CompletionOutcome> => {
const { teamId, taskId, memberId, evidence = [], requireEvidence = false } = params;
if (!teamId || !taskId || !memberId) {
throw new Error('agentTeamApi.completeTask: teamId, taskId and memberId are required');
}
log('completeTask teamId=%s taskId=%s requireEvidence=%o', teamId, taskId, requireEvidence);
const response = await callCoreRpc<{ result: CompletionOutcome }>({
method: 'openhuman.agent_team_complete_task',
params: { teamId, taskId, memberId, evidence, requireEvidence },
});
log('completeTask kind=%s', response.result.kind);
return response.result;
},
/**
* Stop a member and release any task it is actively working on back to `todo`.
* Returns the stopped member plus the ids that were released.
*/
shutdownMember: async (teamId: string, memberId: string): Promise<MemberShutdown> => {
if (!teamId || !memberId) {
throw new Error('agentTeamApi.shutdownMember: teamId and memberId are required');
}
log('shutdownMember teamId=%s memberId=%s', teamId, memberId);
const response = await callCoreRpc<{ result: MemberShutdown }>({
method: 'openhuman.agent_team_shutdown_member',
params: { teamId, memberId },
});
log('shutdownMember released=%d', response.result.releasedTaskIds.length);
return response.result;
},
};
@@ -8,11 +8,13 @@
//! never in the main chat context — so a coordination session can be listed,
//! inspected, and resumed.
//!
//! PR1 scope (this module today): the durable model + 8 read/write controllers
//! Scope (this module today): the durable model + 10 read/write controllers
//! (`create`, `list`, `get`, `assign_task`, `claim_task`, `message_member`,
//! `list_messages`, `close`), the atomic compare-and-swap claim primitive, and
//! dependency validation (self / unknown / cycle). Live agent execution
//! (spawning workers, driving the run loop) and the UI are follow-up PRs.
//! `list_messages`, `complete_task`, `shutdown_member`, `close`), the atomic
//! compare-and-swap claim primitive, dependency validation (self / unknown /
//! cycle), and quality-gated task completion (dependencies done, claimant owns
//! the task, evidence present when required). Live agent execution (spawning
//! workers, driving the run loop) and the message-send UI are a follow-up PR.
//!
//! Namespace note: `agent_team` is distinct from the existing `team` domain,
//! which manages backend org/team membership.
@@ -22,11 +24,11 @@ mod schemas;
pub mod types;
pub use ops::{
assign_task, claim_task, close_team, create_team, get_team, list_messages, list_teams,
message_member, NewMember,
assign_task, claim_task, close_team, complete_task, create_team, get_team, list_messages,
list_teams, message_member, shutdown_member, NewMember,
};
pub use schemas::{
all_controller_schemas as all_agent_team_controller_schemas,
all_registered_controllers as all_agent_team_registered_controllers,
};
pub use types::{TeamError, TeamView};
pub use types::{MemberShutdown, TeamError, TeamView};
@@ -17,11 +17,11 @@ use crate::openhuman::config::Config;
use crate::openhuman::session_db::run_ledger::{
self, AgentTeam, AgentTeamListRequest, AgentTeamListResponse, AgentTeamMemberStatus,
AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTask, AgentTeamTaskStatus,
AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, RunEvent, RunEventAppend,
RunEventListRequest,
AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, CompletionOutcome, RunEvent,
RunEventAppend, RunEventListRequest,
};
use super::types::{TeamError, TeamView};
use super::types::{MemberShutdown, TeamError, TeamView};
const LOG_PREFIX: &str = "[agent_team]";
const TEAM_MESSAGE_EVENT: &str = "team_message";
@@ -287,6 +287,63 @@ pub fn close_team(config: &Config, team_id: &str, summary: Option<&str>) -> Resu
Ok(team)
}
/// Complete a claimed task, gating its transition to `done`.
///
/// Validates the completing member belongs to the team, then delegates to the
/// run-ledger completion CAS, which enforces the quality gate (dependencies
/// done, claimant owns the task, evidence present when `require_evidence`) and
/// only flips the task to `done` when every invariant holds.
pub fn complete_task(
config: &Config,
team_id: &str,
task_id: &str,
member_id: &str,
evidence: &[String],
require_evidence: bool,
) -> Result<CompletionOutcome> {
log::debug!(
"{LOG_PREFIX} complete_task.entry team={team_id} task={task_id} member={member_id}"
);
let members = run_ledger::list_agent_team_members(config, team_id)?;
if !members.iter().any(|m| m.id == member_id) {
return Err(anyhow!(TeamError::UnknownMember {
member_id: member_id.to_string(),
}));
}
let outcome = run_ledger::complete_agent_team_task(
config,
team_id,
task_id,
member_id,
evidence,
require_evidence,
)?;
log::debug!("{LOG_PREFIX} complete_task.exit team={team_id} task={task_id}");
Ok(outcome)
}
/// Stop a team member, releasing any task it was actively working on.
///
/// Unknown member ids surface as [`TeamError::UnknownMember`]; otherwise returns
/// the stopped member plus the ids of tasks released back to `todo`.
pub fn shutdown_member(config: &Config, team_id: &str, member_id: &str) -> Result<MemberShutdown> {
log::debug!("{LOG_PREFIX} shutdown_member.entry team={team_id} member={member_id}");
let (member, released_task_ids) =
run_ledger::shutdown_agent_team_member(config, team_id, member_id)?.ok_or_else(|| {
anyhow!(TeamError::UnknownMember {
member_id: member_id.to_string(),
})
})?;
log::debug!(
"{LOG_PREFIX} shutdown_member.exit team={team_id} member={member_id} released={}",
released_task_ids.len()
);
Ok(MemberShutdown {
member,
released_task_ids,
})
}
fn team_view(config: &Config, team_id: &str) -> Result<TeamView> {
let team = run_ledger::get_agent_team(config, team_id)?
.ok_or_else(|| anyhow!("team missing after creation: {team_id}"))?;
@@ -524,4 +581,229 @@ mod tests {
assert_eq!(messages[0].payload["content"], "first");
assert_eq!(messages[1].payload["content"], "second");
}
/// Create a single-member team and return `(team_id, member_id)`.
fn solo_team(config: &Config, name: &str) -> (String, String) {
let view = create_team(
config,
"lead",
None,
None,
&[NewMember {
name: name.into(),
agent_id: None,
}],
)
.unwrap();
let member_id = view.members[0].id.clone();
(view.team.id, member_id)
}
#[test]
fn complete_task_gate_passes_and_marks_done() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let (team_id, alice) = solo_team(&config, "alice");
let task = assign_task(&config, &team_id, "ship it", None, None, &[]).unwrap();
let claim = claim_task(&config, &team_id, &task.id, &alice, "tok-1").unwrap();
assert!(matches!(claim, ClaimOutcome::Claimed(_)));
let outcome = complete_task(
&config,
&team_id,
&task.id,
&alice,
&["https://ci/run/1".to_string()],
true,
)
.unwrap();
match outcome {
CompletionOutcome::Completed(done) => {
assert_eq!(done.status, AgentTeamTaskStatus::Done);
assert_eq!(done.gate_status, "passed");
assert_eq!(done.gate_reason, None);
assert_eq!(done.evidence, vec!["https://ci/run/1".to_string()]);
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[test]
fn complete_task_requires_evidence_then_recovers() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let (team_id, alice) = solo_team(&config, "alice");
let task = assign_task(&config, &team_id, "ship it", None, None, &[]).unwrap();
claim_task(&config, &team_id, &task.id, &alice, "tok-1").unwrap();
// No evidence + require_evidence → gate fails, task stays in progress.
let failed = complete_task(&config, &team_id, &task.id, &alice, &[], true).unwrap();
match failed {
CompletionOutcome::GateFailed { reasons } => {
assert!(
reasons.iter().any(|r| r.contains("evidence")),
"{reasons:?}"
);
}
other => panic!("expected GateFailed, got {other:?}"),
}
let mid = run_ledger::get_agent_team_task(&config, &task.id)
.unwrap()
.unwrap();
assert_eq!(mid.status, AgentTeamTaskStatus::InProgress);
assert_eq!(mid.gate_status, "failed");
// Retry with evidence → passes.
let ok = complete_task(
&config,
&team_id,
&task.id,
&alice,
&["proof".to_string()],
true,
)
.unwrap();
assert!(matches!(ok, CompletionOutcome::Completed(_)));
}
#[test]
fn complete_task_is_not_double_completable() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let (team_id, alice) = solo_team(&config, "alice");
let task = assign_task(&config, &team_id, "ship it", None, None, &[]).unwrap();
claim_task(&config, &team_id, &task.id, &alice, "tok-1").unwrap();
let first = complete_task(&config, &team_id, &task.id, &alice, &[], false).unwrap();
assert!(matches!(first, CompletionOutcome::Completed(_)));
// A task that is already `done` is no longer in progress, so a second
// completion is rejected (the `status = 'in_progress'` UPDATE guard makes
// the CAS airtight even under a concurrent double-complete).
let second = complete_task(&config, &team_id, &task.id, &alice, &[], false).unwrap();
assert_eq!(second, CompletionOutcome::NotClaimed);
}
#[test]
fn complete_task_rejects_non_claimant() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let view = create_team(
&config,
"lead",
None,
None,
&[
NewMember {
name: "alice".into(),
agent_id: None,
},
NewMember {
name: "bob".into(),
agent_id: None,
},
],
)
.unwrap();
let team_id = view.team.id.clone();
let alice = view.members[0].id.clone();
let bob = view.members[1].id.clone();
let task = assign_task(&config, &team_id, "ship it", None, None, &[]).unwrap();
claim_task(&config, &team_id, &task.id, &alice, "tok-1").unwrap();
// Bob is a member but not the claimant → NotClaimed.
let outcome = complete_task(&config, &team_id, &task.id, &bob, &[], false).unwrap();
assert_eq!(outcome, CompletionOutcome::NotClaimed);
// Unknown member → typed error (not an outcome).
let err = complete_task(&config, &team_id, &task.id, "ghost", &[], false).unwrap_err();
assert_eq!(
team_err(err),
TeamError::UnknownMember {
member_id: "ghost".into()
}
);
}
#[test]
fn complete_task_owner_mismatch_fails_gate() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let view = create_team(
&config,
"lead",
None,
None,
&[
NewMember {
name: "alice".into(),
agent_id: None,
},
NewMember {
name: "bob".into(),
agent_id: None,
},
],
)
.unwrap();
let team_id = view.team.id.clone();
let alice = view.members[0].id.clone();
let bob = view.members[1].id.clone();
// Task owned by bob, but alice claims + tries to complete.
let task = assign_task(&config, &team_id, "ship it", None, Some(&bob), &[]).unwrap();
claim_task(&config, &team_id, &task.id, &alice, "tok-1").unwrap();
let outcome = complete_task(&config, &team_id, &task.id, &alice, &[], false).unwrap();
match outcome {
CompletionOutcome::GateFailed { reasons } => {
assert!(
reasons.iter().any(|r| r.contains("owned by")),
"{reasons:?}"
);
}
other => panic!("expected GateFailed, got {other:?}"),
}
}
#[test]
fn shutdown_member_releases_in_progress_tasks() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let (team_id, alice) = solo_team(&config, "alice");
let task = assign_task(&config, &team_id, "ship it", None, None, &[]).unwrap();
claim_task(&config, &team_id, &task.id, &alice, "tok-1").unwrap();
let result = shutdown_member(&config, &team_id, &alice).unwrap();
assert_eq!(result.released_task_ids, vec![task.id.clone()]);
assert_eq!(result.member.member_status, AgentTeamMemberStatus::Stopped);
// Task is back to todo and unclaimed → another teammate could claim it.
let released = run_ledger::get_agent_team_task(&config, &task.id)
.unwrap()
.unwrap();
assert_eq!(released.status, AgentTeamTaskStatus::Todo);
assert_eq!(released.claimed_by_member_id, None);
assert_eq!(released.claim_token, None);
}
#[test]
fn shutdown_member_unknown_errors() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let (team_id, _alice) = solo_team(&config, "alice");
let err = shutdown_member(&config, &team_id, "ghost").unwrap_err();
assert_eq!(
team_err(err),
TeamError::UnknownMember {
member_id: "ghost".into()
}
);
}
}
@@ -1,9 +1,10 @@
//! Controller schemas + JSON-RPC dispatchers for durable agent-team
//! coordination (#3374). Namespace `agent_team`.
//!
//! Surface (PR1): create a team with members, list/get teams, assign
//! dependency-aware tasks, atomically claim a task, exchange teammate messages,
//! list those messages, and close a team. Live agent execution is a follow-up.
//! Surface: create a team with members, list/get teams, assign dependency-aware
//! tasks, atomically claim a task, exchange + list teammate messages, complete a
//! claimed task behind a quality gate, shut a member down (releasing its tasks),
//! and close a team. Live agent execution is a follow-up.
use serde_json::{Map, Value};
@@ -25,6 +26,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schema_for("agent_team_claim_task"),
schema_for("agent_team_message_member"),
schema_for("agent_team_list_messages"),
schema_for("agent_team_complete_task"),
schema_for("agent_team_shutdown_member"),
schema_for("agent_team_close"),
]
}
@@ -60,6 +63,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schema_for("agent_team_list_messages"),
handler: handle_list_messages,
},
RegisteredController {
schema: schema_for("agent_team_complete_task"),
handler: handle_complete_task,
},
RegisteredController {
schema: schema_for("agent_team_shutdown_member"),
handler: handle_shutdown_member,
},
RegisteredController {
schema: schema_for("agent_team_close"),
handler: handle_close,
@@ -157,6 +168,45 @@ fn schema_for(function: &str) -> ControllerSchema {
],
outputs: vec![json_output("messages", "Array of message events.")],
},
"agent_team_complete_task" => ControllerSchema {
namespace: "agent_team",
function: "complete_task",
description:
"Complete a claimed task, gating its transition to done behind quality checks.",
inputs: vec![
required_str("teamId", "Team id."),
required_str("taskId", "Task id to complete."),
required_str(
"memberId",
"Member completing the task (must be the claimant).",
),
json_input(
"evidence",
"Array of evidence links to attach on completion.",
),
optional_bool(
"requireEvidence",
"Require at least one evidence link to pass the gate (default false).",
),
],
outputs: vec![json_output(
"result",
"CompletionOutcome: completed | gateFailed | notClaimed | unknownTask.",
)],
},
"agent_team_shutdown_member" => ControllerSchema {
namespace: "agent_team",
function: "shutdown_member",
description: "Stop a team member and release any task it is actively working on.",
inputs: vec![
required_str("teamId", "Team id."),
required_str("memberId", "Member id to stop."),
],
outputs: vec![json_output(
"result",
"MemberShutdown: the stopped member and releasedTaskIds.",
)],
},
"agent_team_close" => ControllerSchema {
namespace: "agent_team",
function: "close",
@@ -324,6 +374,52 @@ fn handle_list_messages(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_complete_task(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "agent_team_rpc", "[agent_team_rpc][{cid}] complete_task.entry");
let config = config_rpc::load_config_with_timeout().await.inspect_err(|err| {
log::warn!(target: "agent_team_rpc", "[agent_team_rpc][{cid}] complete_task.config_failed err={err}");
})?;
let team_id = require_str(&params, "teamId")?;
let task_id = require_str(&params, "taskId")?;
let member_id = require_str(&params, "memberId")?;
let evidence = opt_str_array(&params, "evidence");
let require_evidence = params
.get("requireEvidence")
.and_then(Value::as_bool)
.unwrap_or(false);
log::debug!(target: "agent_team_rpc", "[agent_team_rpc][{cid}] complete_task.parsed team={team_id} task={task_id} evidence={} requireEvidence={require_evidence}", evidence.len());
let outcome = ops::complete_task(
&config,
&team_id,
&task_id,
&member_id,
&evidence,
require_evidence,
)
.map_err(|e| log_err(&cid, "complete_task", e))?;
log::debug!(target: "agent_team_rpc", "[agent_team_rpc][{cid}] complete_task.success team={team_id} task={task_id}");
to_json(serde_json::json!({ "result": outcome }))
})
}
fn handle_shutdown_member(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "agent_team_rpc", "[agent_team_rpc][{cid}] shutdown_member.entry");
let config = config_rpc::load_config_with_timeout().await.inspect_err(|err| {
log::warn!(target: "agent_team_rpc", "[agent_team_rpc][{cid}] shutdown_member.config_failed err={err}");
})?;
let team_id = require_str(&params, "teamId")?;
let member_id = require_str(&params, "memberId")?;
let result = ops::shutdown_member(&config, &team_id, &member_id)
.map_err(|e| log_err(&cid, "shutdown_member", e))?;
log::debug!(target: "agent_team_rpc", "[agent_team_rpc][{cid}] shutdown_member.success team={team_id} member={member_id} released={}", result.released_task_ids.len());
to_json(serde_json::json!({ "result": result }))
})
}
fn handle_close(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
@@ -438,6 +534,15 @@ fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema {
}
}
fn optional_bool(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment,
required: false,
}
}
fn json_input(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
@@ -465,8 +570,16 @@ mod tests {
let schemas = all_controller_schemas();
let registered = all_registered_controllers();
assert_eq!(schemas.len(), registered.len());
assert_eq!(schemas.len(), 8);
assert_eq!(schemas.len(), 10);
assert!(schemas.iter().all(|s| s.namespace == "agent_team"));
assert_eq!(schema_for("agent_team_claim_task").function, "claim_task");
assert_eq!(
schema_for("agent_team_complete_task").function,
"complete_task"
);
assert_eq!(
schema_for("agent_team_shutdown_member").function,
"shutdown_member"
);
}
}
@@ -18,6 +18,15 @@ pub struct TeamView {
pub tasks: Vec<AgentTeamTask>,
}
/// Result of shutting a member down: the stopped member plus the ids of any
/// `in_progress` tasks that were released back to `todo` for another teammate.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MemberShutdown {
pub member: AgentTeamMember,
pub released_task_ids: Vec<String>,
}
/// A validation / coordination problem surfaced by the agent-team ops.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "detail")]
+9 -9
View File
@@ -10,18 +10,18 @@ pub mod store;
pub mod types;
pub use ops::{
append_run_event, claim_agent_team_task, get_agent_run, get_agent_team, get_agent_team_member,
get_agent_team_task, get_workflow_run, list_agent_runs, list_agent_team_members,
list_agent_team_tasks, list_agent_teams, list_recent_run_events, list_workflow_runs,
upsert_agent_run, upsert_agent_team, upsert_agent_team_member, upsert_agent_team_task,
upsert_run_telemetry, upsert_workflow_run,
append_run_event, claim_agent_team_task, complete_agent_team_task, get_agent_run,
get_agent_team, get_agent_team_member, get_agent_team_task, get_workflow_run, list_agent_runs,
list_agent_team_members, list_agent_team_tasks, list_agent_teams, list_recent_run_events,
list_workflow_runs, shutdown_agent_team_member, upsert_agent_run, upsert_agent_team,
upsert_agent_team_member, upsert_agent_team_task, upsert_run_telemetry, upsert_workflow_run,
};
pub use types::{
AgentRun, AgentRunKind, AgentRunListRequest, AgentRunListResponse, AgentRunStatus,
AgentRunUpsert, AgentTeam, AgentTeamListRequest, AgentTeamListResponse, AgentTeamMember,
AgentTeamMemberStatus, AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTask,
AgentTeamTaskStatus, AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, RunEvent,
RunEventAppend, RunEventListRequest, RunEventListResponse, RunTelemetry, RunTelemetryUpsert,
WorkflowRun, WorkflowRunListRequest, WorkflowRunListResponse, WorkflowRunStatus,
WorkflowRunUpsert,
AgentTeamTaskStatus, AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, CompletionOutcome,
RunEvent, RunEventAppend, RunEventListRequest, RunEventListResponse, RunTelemetry,
RunTelemetryUpsert, WorkflowRun, WorkflowRunListRequest, WorkflowRunListResponse,
WorkflowRunStatus, WorkflowRunUpsert,
};
+235 -3
View File
@@ -10,9 +10,9 @@ use super::types::{
AgentRun, AgentRunListRequest, AgentRunListResponse, AgentRunStatus, AgentRunUpsert, AgentTeam,
AgentTeamListRequest, AgentTeamListResponse, AgentTeamMember, AgentTeamMemberStatus,
AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTask, AgentTeamTaskStatus,
AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, RunEvent, RunEventAppend,
RunEventListRequest, RunEventListResponse, RunTelemetry, RunTelemetryUpsert, WorkflowRun,
WorkflowRunListRequest, WorkflowRunListResponse, WorkflowRunUpsert,
AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, CompletionOutcome, RunEvent,
RunEventAppend, RunEventListRequest, RunEventListResponse, RunTelemetry, RunTelemetryUpsert,
WorkflowRun, WorkflowRunListRequest, WorkflowRunListResponse, WorkflowRunUpsert,
};
const LOG_PREFIX: &str = "[session_db:run_ledger]";
@@ -857,6 +857,238 @@ pub fn claim_agent_team_task(
Ok(outcome)
}
/// Quality-gate a task's completion and, on pass, transition it to `done`.
///
/// Runs inside a single transaction so the gate evaluation and the status flip
/// observe one consistent snapshot:
/// 1. Resolve the task by `(id, team_id)`; absent → [`CompletionOutcome::UnknownTask`].
/// 2. The completer must be the current claimant and the task must be
/// `in_progress`; otherwise [`CompletionOutcome::NotClaimed`].
/// 3. Evaluate the quality gate (every dependency `done`, claimant matches any
/// pre-assigned owner, evidence present when `require_evidence`). Any unmet
/// invariant records `gate_status = "failed"` + the joined reasons and leaves
/// the task `in_progress` → [`CompletionOutcome::GateFailed`].
/// 4. On pass, merge `evidence`, set `status = "done"`, `gate_status = "passed"`,
/// clear `gate_reason`, re-fetch → [`CompletionOutcome::Completed`].
pub fn complete_agent_team_task(
config: &Config,
team_id: &str,
task_id: &str,
member_id: &str,
evidence: &[String],
require_evidence: bool,
) -> Result<CompletionOutcome> {
log::debug!(
"{LOG_PREFIX} complete_agent_team_task.entry team={team_id} task={task_id} member={member_id}"
);
let outcome = crate::openhuman::session_db::store::with_connection(config, |conn| {
init_run_ledger_schema(conn)?;
// 1. Resolve the task within this team.
let task = match get_agent_team_task_inner(conn, task_id)? {
Some(task) if task.team_id == team_id => task,
_ => {
log::debug!(
"{LOG_PREFIX} complete_agent_team_task.unknown team={team_id} task={task_id}"
);
return Ok(CompletionOutcome::UnknownTask);
}
};
// 2. Only the current claimant may complete, and only while in progress.
let is_claimant = task.claimed_by_member_id.as_deref() == Some(member_id);
let in_progress = task.status == AgentTeamTaskStatus::InProgress;
if !is_claimant || !in_progress {
log::debug!(
"{LOG_PREFIX} complete_agent_team_task.not_claimed team={team_id} task={task_id} claimant={is_claimant} in_progress={in_progress}"
);
return Ok(CompletionOutcome::NotClaimed);
}
// Merge prior evidence with the newly-supplied links (de-duplicated,
// order-preserving) so a retry that adds evidence accumulates it.
let mut merged_evidence = task.evidence.clone();
for link in evidence {
if !merged_evidence.iter().any(|e| e == link) {
merged_evidence.push(link.clone());
}
}
// 3. Quality gate.
let reasons =
evaluate_completion_gate(conn, team_id, &task, &merged_evidence, require_evidence)?;
let now = Utc::now();
if !reasons.is_empty() {
let joined = reasons.join("; ");
conn.execute(
"UPDATE agent_team_tasks
SET gate_status = 'failed', gate_reason = ?1, updated_at = ?2
WHERE id = ?3 AND team_id = ?4",
params![joined, now.to_rfc3339(), task_id, team_id],
)
.context("record failed completion gate")?;
log::debug!(
"{LOG_PREFIX} complete_agent_team_task.gate_failed team={team_id} task={task_id} reasons={}",
reasons.len()
);
return Ok(CompletionOutcome::GateFailed { reasons });
}
// 4. Gate passed — flip to done. The WHERE clause is the real CAS: the
// `claimed_by_member_id` guard stops a concurrent shutdown/unclaim from
// completing a task it no longer holds, and the `status = 'in_progress'`
// guard stops a concurrent double-complete by the same member (the
// snapshot check above is a read, not part of the swap — only one of two
// racing UPDATEs flips `in_progress -> done`).
let evidence_json =
serde_json::to_string(&merged_evidence).context("serialize completion evidence")?;
let rows_affected = conn
.execute(
"UPDATE agent_team_tasks
SET status = 'done', gate_status = 'passed', gate_reason = NULL,
evidence_json = ?1, updated_at = ?2
WHERE id = ?3 AND team_id = ?4 AND claimed_by_member_id = ?5
AND status = 'in_progress'",
params![evidence_json, now.to_rfc3339(), task_id, team_id, member_id],
)
.context("complete agent team task")?;
if rows_affected == 0 {
log::debug!(
"{LOG_PREFIX} complete_agent_team_task.lost_claim team={team_id} task={task_id}"
);
return Ok(CompletionOutcome::NotClaimed);
}
let done = get_agent_team_task_inner(conn, task_id)?
.context("completed task missing after update")?;
Ok(CompletionOutcome::Completed(Box::new(done)))
})?;
log::debug!(
"{LOG_PREFIX} complete_agent_team_task.exit team={team_id} task={task_id} outcome={}",
match &outcome {
CompletionOutcome::Completed(_) => "completed",
CompletionOutcome::GateFailed { .. } => "gate_failed",
CompletionOutcome::NotClaimed => "not_claimed",
CompletionOutcome::UnknownTask => "unknown",
}
);
Ok(outcome)
}
/// Evaluate the quality-gate invariants for a completing task. Returns one
/// human-readable reason per unmet invariant (empty = gate passes).
fn evaluate_completion_gate(
conn: &Connection,
team_id: &str,
task: &AgentTeamTask,
merged_evidence: &[String],
require_evidence: bool,
) -> Result<Vec<String>> {
let mut reasons = Vec::new();
// Every dependency must still be `done` (defends against a dependency that
// regressed after this task was claimed).
for dep_id in &task.depends_on {
let dep_status: Option<String> = conn
.query_row(
"SELECT status FROM agent_team_tasks WHERE id = ?1 AND team_id = ?2",
params![dep_id, team_id],
|row| row.get(0),
)
.optional()?;
if dep_status.as_deref() != Some(AgentTeamTaskStatus::Done.as_str()) {
reasons.push(format!("dependency {dep_id} is not done"));
}
}
// No overlapping ownership: a pre-assigned owner must be the one completing.
if let Some(owner) = &task.owner_member_id {
if Some(owner.as_str()) != task.claimed_by_member_id.as_deref() {
reasons.push(format!(
"task is owned by {owner} but claimed by {}",
task.claimed_by_member_id.as_deref().unwrap_or("nobody")
));
}
}
// Evidence gate.
if require_evidence && merged_evidence.is_empty() {
reasons.push("completion requires at least one evidence link".to_string());
}
Ok(reasons)
}
/// Stop a team member and release any task it is actively working on.
///
/// In one transaction: unclaim the member's `in_progress` tasks back to `todo`
/// (clearing claimant + token so another teammate can pick them up), then mark
/// the member `stopped` and clear its `current_task_id`. Returns the updated
/// member plus the ids of the tasks that were released, or `None` if the member
/// is not part of the team.
pub fn shutdown_agent_team_member(
config: &Config,
team_id: &str,
member_id: &str,
) -> Result<Option<(AgentTeamMember, Vec<String>)>> {
log::debug!("{LOG_PREFIX} shutdown_agent_team_member.entry team={team_id} member={member_id}");
let result = crate::openhuman::session_db::store::with_connection(config, |conn| {
init_run_ledger_schema(conn)?;
// Existence + team-membership check only; the row is intentionally not
// reused — the caller-facing member is re-read after the UPDATEs below so
// it reflects the stopped state.
match get_agent_team_member_inner(conn, member_id)? {
Some(found) if found.team_id == team_id => {}
_ => {
log::debug!(
"{LOG_PREFIX} shutdown_agent_team_member.unknown team={team_id} member={member_id}"
);
return Ok(None);
}
}
// Collect the ids first so the caller can report exactly what was freed.
let released: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM agent_team_tasks
WHERE team_id = ?1 AND claimed_by_member_id = ?2 AND status = 'in_progress'",
)?;
let ids = stmt.query_map(params![team_id, member_id], |row| row.get::<_, String>(0))?;
let mut out = Vec::new();
for id in ids {
out.push(id?);
}
out
};
let now = Utc::now();
conn.execute(
"UPDATE agent_team_tasks
SET claimed_by_member_id = NULL, claim_token = NULL, status = 'todo', updated_at = ?1
WHERE team_id = ?2 AND claimed_by_member_id = ?3 AND status = 'in_progress'",
params![now.to_rfc3339(), team_id, member_id],
)
.context("release tasks on member shutdown")?;
conn.execute(
"UPDATE agent_team_members
SET member_status = 'stopped', current_task_id = NULL, updated_at = ?1
WHERE id = ?2 AND team_id = ?3",
params![now.to_rfc3339(), member_id, team_id],
)
.context("stop agent team member")?;
let member = get_agent_team_member_inner(conn, member_id)?
.context("member missing after shutdown")?;
Ok(Some((member, released)))
})?;
log::debug!(
"{LOG_PREFIX} shutdown_agent_team_member.exit team={team_id} member={member_id} released={}",
result.as_ref().map(|(_, r)| r.len()).unwrap_or(0)
);
Ok(result)
}
fn get_agent_team_inner(conn: &Connection, id: &str) -> Result<Option<AgentTeam>> {
let mut stmt = conn.prepare(
"SELECT id, parent_thread_id, lead_agent_id, status, summary,
@@ -524,3 +524,24 @@ pub enum ClaimOutcome {
/// No task matched the given team + task id.
UnknownTask,
}
/// Outcome of a completion attempt on a team task.
///
/// Completion gates a task's transition to `done` behind quality invariants
/// (dependencies done, claimer owns the task, evidence present when required).
/// A failed gate leaves the task `in_progress` with `gate_status = "failed"`
/// and the reasons recorded, so a teammate can fix and retry.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum CompletionOutcome {
/// The task passed its quality gate and is now `done`. Boxed to keep the
/// enum small (the task payload dwarfs the other variants).
Completed(Box<AgentTeamTask>),
/// One or more quality-gate invariants failed; carries human-readable
/// reasons for each unmet invariant.
GateFailed { reasons: Vec<String> },
/// The task is not claimed by the completing member, or is not in progress.
NotClaimed,
/// No task matched the given team + task id.
UnknownTask,
}
+76
View File
@@ -3380,6 +3380,82 @@ async fn json_rpc_agent_team_coordination_roundtrip() {
Some("claimed")
);
// Complete B with no evidence but requireEvidence → quality gate fails.
let complete_b_blocked = post_json_rpc(
&rpc_base,
9360,
"openhuman.agent_team_complete_task",
json!({
"teamId": team_id,
"taskId": task_b_id,
"memberId": bob_id,
"evidence": [],
"requireEvidence": true
}),
)
.await;
let complete_b_blocked_outer =
assert_no_jsonrpc_error(&complete_b_blocked, "agent_team_complete_task B gate-fail");
assert_eq!(
complete_b_blocked_outer
.get("result")
.and_then(|r| r.get("kind"))
.and_then(serde_json::Value::as_str),
Some("gateFailed")
);
// Complete B again with evidence → passes the gate, task is now done.
let complete_b_ok = post_json_rpc(
&rpc_base,
9361,
"openhuman.agent_team_complete_task",
json!({
"teamId": team_id,
"taskId": task_b_id,
"memberId": bob_id,
"evidence": ["https://ci/run/42"],
"requireEvidence": true
}),
)
.await;
let complete_b_ok_outer =
assert_no_jsonrpc_error(&complete_b_ok, "agent_team_complete_task B done");
assert_eq!(
complete_b_ok_outer
.get("result")
.and_then(|r| r.get("kind"))
.and_then(serde_json::Value::as_str),
Some("completed")
);
// Shut alice down → member stopped (her task A is already done, so nothing
// is released back to the queue).
let shutdown_alice = post_json_rpc(
&rpc_base,
9362,
"openhuman.agent_team_shutdown_member",
json!({ "teamId": team_id, "memberId": alice_id }),
)
.await;
let shutdown_alice_outer =
assert_no_jsonrpc_error(&shutdown_alice, "agent_team_shutdown_member alice");
assert_eq!(
shutdown_alice_outer
.get("result")
.and_then(|r| r.get("member"))
.and_then(|m| m.get("memberStatus"))
.and_then(serde_json::Value::as_str),
Some("stopped")
);
assert_eq!(
shutdown_alice_outer
.get("result")
.and_then(|r| r.get("releasedTaskIds"))
.and_then(serde_json::Value::as_array)
.map(|ids| ids.len()),
Some(0)
);
// Message bob from alice, then list messages.
let message = post_json_rpc(
&rpc_base,