@team-agent/installer 0.5.57 → 0.5.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +30 -2
- package/crates/team-agent/src/cli/mod.rs +1 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +68 -25
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +89 -95
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +104 -0
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +109 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +49 -0
- package/crates/team-agent/src/lifecycle/launch.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +1 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +53 -1
- package/crates/team-agent/src/mcp_server/tools.rs +8 -1
- package/crates/team-agent/src/provider/session/capture.rs +37 -1
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +85 -0
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +65 -0
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +138 -0
- package/crates/team-agent/src/provider/session/context_fork.rs +30 -135
- package/crates/team-agent/src/provider/session/mod.rs +2 -1
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +1 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
use super::spec::{command_spec, CommandKind, CommandTier, ALL_DISPATCH_KINDS, COMMAND_SPECS};
|
|
5
5
|
use super::*;
|
|
6
|
-
use std::io::Write as _;
|
|
6
|
+
use std::io::{ErrorKind, Write as _};
|
|
7
7
|
|
|
8
8
|
/// `emit`(`helpers.py:12-23`):`--json`→`json.dumps(indent=2, ensure_ascii=False, sort_keys=True)`;
|
|
9
9
|
/// 否则 dict 逐键 `key: value`(嵌套 dict/list 内联 compact json,`ensure_ascii=False`)、非 dict 直接 print。
|
|
@@ -77,12 +77,40 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
|
|
|
77
77
|
/// Print a handler's CmdResult to stdout (emit formats json/human), then surface its exit code.
|
|
78
78
|
/// (parser.py: `print(emit(result, as_json))` then the ok→exit mapping.)
|
|
79
79
|
fn emit_result(r: CmdResult) -> ExitCode {
|
|
80
|
+
let persisted_message_id = match &r.output {
|
|
81
|
+
CmdOutput::Json(value) => value
|
|
82
|
+
.get("message_id")
|
|
83
|
+
.and_then(Value::as_str)
|
|
84
|
+
.map(ToString::to_string),
|
|
85
|
+
_ => None,
|
|
86
|
+
};
|
|
80
87
|
if let Some(text) = emit(&r.output, r.as_json) {
|
|
81
|
-
|
|
88
|
+
if let Err(error) = write_stdout_line(&text) {
|
|
89
|
+
if let Some(message_id) = persisted_message_id {
|
|
90
|
+
let stderr = std::io::stderr();
|
|
91
|
+
let mut stderr = stderr.lock();
|
|
92
|
+
let _ = writeln!(
|
|
93
|
+
stderr,
|
|
94
|
+
"stdout unavailable after durable send; persisted_message_id={message_id}"
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return if error.kind() == ErrorKind::BrokenPipe {
|
|
98
|
+
r.exit
|
|
99
|
+
} else {
|
|
100
|
+
ExitCode::Error
|
|
101
|
+
};
|
|
102
|
+
}
|
|
82
103
|
}
|
|
83
104
|
r.exit
|
|
84
105
|
}
|
|
85
106
|
|
|
107
|
+
fn write_stdout_line(text: &str) -> std::io::Result<()> {
|
|
108
|
+
let stdout = std::io::stdout();
|
|
109
|
+
let mut stdout = stdout.lock();
|
|
110
|
+
stdout.write_all(text.as_bytes())?;
|
|
111
|
+
stdout.write_all(b"\n")
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliError> {
|
|
87
115
|
let Some(spec) = command_spec(command) else {
|
|
88
116
|
return Ok(emit_unknown_subcommand_usage(command));
|
|
@@ -2484,6 +2484,7 @@ pub mod lifecycle_port {
|
|
|
2484
2484
|
"new_agent_id": report.new_agent_id.as_str(),
|
|
2485
2485
|
"session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
2486
2486
|
"new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
2487
|
+
"backing_state": report.backing_state,
|
|
2487
2488
|
})),
|
|
2488
2489
|
Err(e) => Ok(error_value(e)),
|
|
2489
2490
|
}
|
|
@@ -37,8 +37,8 @@ pub(super) fn maybe_enqueue_offline_leader_mailbox(
|
|
|
37
37
|
Ok(s) => s,
|
|
38
38
|
Err(_) => return Ok(None),
|
|
39
39
|
};
|
|
40
|
-
let
|
|
41
|
-
if
|
|
40
|
+
let attachment_history = target_team_mailbox_history(&state, &team_key);
|
|
41
|
+
if attachment_history == MailboxAttachmentHistory::NotEligible {
|
|
42
42
|
return Ok(None);
|
|
43
43
|
}
|
|
44
44
|
let event_log = crate::event_log::EventLog::new(&target_workspace);
|
|
@@ -53,47 +53,90 @@ pub(super) fn maybe_enqueue_offline_leader_mailbox(
|
|
|
53
53
|
)
|
|
54
54
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
55
55
|
let message_id = outcome.message_id.clone().unwrap_or_else(|| "".to_string());
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
56
|
+
let receipt = match attachment_history {
|
|
57
|
+
MailboxAttachmentHistory::NeverAttached => json!({
|
|
58
|
+
"ok": true,
|
|
59
|
+
"status": "deferred",
|
|
60
|
+
"deferred_reason": "never_attached",
|
|
61
|
+
"message_status": "queued_until_leader_attach",
|
|
62
|
+
"channel": "leader_mailbox",
|
|
63
|
+
"delivered": false,
|
|
64
|
+
"to_name": to_name,
|
|
65
|
+
"target_workspace": target_workspace.display().to_string(),
|
|
66
|
+
"team_key": team_key,
|
|
67
|
+
"recipient": "leader",
|
|
68
|
+
"leader_attached": false,
|
|
69
|
+
"message_id": message_id,
|
|
70
|
+
}),
|
|
71
|
+
MailboxAttachmentHistory::PreviouslyAttached => json!({
|
|
72
|
+
"ok": true,
|
|
73
|
+
"status": "queued_until_leader_attach",
|
|
74
|
+
"deferred_reason": "leader_currently_unattached",
|
|
75
|
+
"message_status": "queued_until_leader_attach",
|
|
76
|
+
"channel": "leader_mailbox",
|
|
77
|
+
"delivered": false,
|
|
78
|
+
"to_name": to_name,
|
|
79
|
+
"target_workspace": target_workspace.display().to_string(),
|
|
80
|
+
"team_key": team_key,
|
|
81
|
+
"recipient": "leader",
|
|
82
|
+
"leader_attached": false,
|
|
83
|
+
"message_id": message_id,
|
|
84
|
+
}),
|
|
85
|
+
MailboxAttachmentHistory::NotEligible => unreachable!("refused before persistence"),
|
|
86
|
+
};
|
|
87
|
+
Ok(Some(receipt))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
91
|
+
enum MailboxAttachmentHistory {
|
|
92
|
+
PreviouslyAttached,
|
|
93
|
+
NeverAttached,
|
|
94
|
+
NotEligible,
|
|
69
95
|
}
|
|
70
96
|
|
|
71
|
-
/// Positive-source
|
|
97
|
+
/// Positive-source mailbox eligibility per offline-mailbox-toname-design.md §4:
|
|
72
98
|
/// - target workspace has state and the team key is present + not archived/down;
|
|
73
|
-
/// - AND
|
|
74
|
-
///
|
|
99
|
+
/// - AND a persisted session name establishes a durable replay target;
|
|
100
|
+
/// - a persisted receiver attachment timestamp distinguishes previously attached
|
|
101
|
+
/// teams from teams whose first successful leader attachment is still pending.
|
|
75
102
|
///
|
|
76
103
|
/// We deliberately do NOT poll coordinator health here — enqueuing is
|
|
77
104
|
/// safe even when the coordinator is transiently down; attach-leader
|
|
78
105
|
/// itself replays via `requeue_blocked_leader_messages` regardless.
|
|
79
|
-
|
|
106
|
+
fn target_team_mailbox_history(state: &Value, team_key: &str) -> MailboxAttachmentHistory {
|
|
80
107
|
let team = state
|
|
81
108
|
.get("teams")
|
|
82
109
|
.and_then(|v| v.as_object())
|
|
83
110
|
.and_then(|teams| teams.get(team_key));
|
|
84
111
|
let Some(team) = team else {
|
|
85
|
-
return
|
|
112
|
+
return MailboxAttachmentHistory::NotEligible;
|
|
86
113
|
};
|
|
87
114
|
let status = team
|
|
88
115
|
.get("status")
|
|
89
116
|
.and_then(|v| v.as_str())
|
|
90
117
|
.unwrap_or("alive");
|
|
91
118
|
if matches!(status, "archived" | "down" | "stopped") {
|
|
92
|
-
return
|
|
119
|
+
return MailboxAttachmentHistory::NotEligible;
|
|
93
120
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
team.get("session_name")
|
|
121
|
+
let has_session = team
|
|
122
|
+
.get("session_name")
|
|
97
123
|
.and_then(|v| v.as_str())
|
|
98
|
-
.is_some_and(|s| !s.is_empty())
|
|
124
|
+
.is_some_and(|s| !s.is_empty());
|
|
125
|
+
if !has_session {
|
|
126
|
+
return MailboxAttachmentHistory::NotEligible;
|
|
127
|
+
}
|
|
128
|
+
let previously_attached = team
|
|
129
|
+
.get("leader_receiver")
|
|
130
|
+
.and_then(Value::as_object)
|
|
131
|
+
.is_some_and(|receiver| {
|
|
132
|
+
receiver
|
|
133
|
+
.get("attached_at")
|
|
134
|
+
.and_then(Value::as_str)
|
|
135
|
+
.is_some_and(|value| !value.is_empty())
|
|
136
|
+
});
|
|
137
|
+
if previously_attached {
|
|
138
|
+
MailboxAttachmentHistory::PreviouslyAttached
|
|
139
|
+
} else {
|
|
140
|
+
MailboxAttachmentHistory::NeverAttached
|
|
141
|
+
}
|
|
99
142
|
}
|
|
@@ -155,7 +155,7 @@ pub(crate) struct CommandSpec {
|
|
|
155
155
|
#[rustfmt::skip]
|
|
156
156
|
pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
157
157
|
CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
158
|
-
CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for
|
|
158
|
+
CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for an in-team short name or fully-qualified logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--presentation-sink leader|casefile|silent --message-class CLASS [--case-id CASE]] [--json]\nTO forms are co-equal: an in-team short name (for example `team-agent send reviewer \"Review\"`) or `<workspace>::<team>/<agent>`.", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
|
|
159
159
|
CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
160
160
|
CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
|
|
161
161
|
CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
|
|
@@ -57,66 +57,13 @@ pub fn fork_agent_with_transport(
|
|
|
57
57
|
let text = std::fs::read_to_string(&read_spec_path)
|
|
58
58
|
.map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
|
|
59
59
|
let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
60
|
-
if
|
|
60
|
+
if fork_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
|
|
61
61
|
return Err(LifecycleError::RequirementUnmet(format!(
|
|
62
62
|
"agent id already exists: {as_agent_id}"
|
|
63
63
|
)));
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
})?;
|
|
68
|
-
// Fork requires the complete source tuple before treating session_id as
|
|
69
|
-
// resumable truth; a scalar-only row has no confirmed backing.
|
|
70
|
-
let source_agent_state = state
|
|
71
|
-
.get("agents")
|
|
72
|
-
.and_then(|v| v.get(source_agent_id.as_str()))
|
|
73
|
-
.ok_or_else(|| {
|
|
74
|
-
LifecycleError::Provider(format!(
|
|
75
|
-
"cannot fork {source_agent_id}: source agent row not in state"
|
|
76
|
-
))
|
|
77
|
-
})?;
|
|
78
|
-
let tuple_field_ok = |field: &str| -> bool {
|
|
79
|
-
source_agent_state
|
|
80
|
-
.get(field)
|
|
81
|
-
.and_then(|v| v.as_str())
|
|
82
|
-
.is_some_and(|s| !s.is_empty())
|
|
83
|
-
};
|
|
84
|
-
let session_id_str = source_agent_state
|
|
85
|
-
.get("session_id")
|
|
86
|
-
.and_then(|v| v.as_str())
|
|
87
|
-
.filter(|s| !s.is_empty());
|
|
88
|
-
let rollout_path_str = source_agent_state
|
|
89
|
-
.get("rollout_path")
|
|
90
|
-
.and_then(|v| v.as_str())
|
|
91
|
-
.filter(|s| !s.is_empty());
|
|
92
|
-
if session_id_str.is_none()
|
|
93
|
-
|| rollout_path_str.is_none()
|
|
94
|
-
|| !tuple_field_ok("captured_at")
|
|
95
|
-
|| !tuple_field_ok("captured_via")
|
|
96
|
-
{
|
|
97
|
-
return Err(LifecycleError::Provider(format!(
|
|
98
|
-
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
99
|
-
(session_id+rollout_path+captured_at+captured_via required)"
|
|
100
|
-
)));
|
|
101
|
-
}
|
|
102
|
-
let Some(source_backing_raw) = rollout_path_str else {
|
|
103
|
-
return Err(LifecycleError::Provider(format!(
|
|
104
|
-
"cannot fork {source_agent_id}: source session backing is missing"
|
|
105
|
-
)));
|
|
106
|
-
};
|
|
107
|
-
let source_backing = Path::new(source_backing_raw);
|
|
108
|
-
if !source_backing.is_file() {
|
|
109
|
-
return Err(LifecycleError::Provider(format!(
|
|
110
|
-
"cannot fork {source_agent_id}: source session backing is not readable: {}",
|
|
111
|
-
source_backing.display()
|
|
112
|
-
)));
|
|
113
|
-
}
|
|
114
|
-
let Some(source_session_id) = session_id_str else {
|
|
115
|
-
return Err(LifecycleError::Provider(format!(
|
|
116
|
-
"cannot fork {source_agent_id}: source session id is missing"
|
|
117
|
-
)));
|
|
118
|
-
};
|
|
119
|
-
let session_id = crate::provider::SessionId::new(source_session_id.to_string());
|
|
65
|
+
// Source existence authority: state.get("agents"), matching clone-agent.
|
|
66
|
+
let (session_id, source_backing) = fork_source_tuple(&state, source_agent_id)?;
|
|
120
67
|
let session_name = state
|
|
121
68
|
.get("session_name")
|
|
122
69
|
.and_then(|v| v.as_str())
|
|
@@ -158,7 +105,7 @@ pub fn fork_agent_with_transport(
|
|
|
158
105
|
crate::model::spec::validate_spec(&new_spec, &validate_ws)
|
|
159
106
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
160
107
|
write_spec_atomic(&spec_path, &new_spec)?;
|
|
161
|
-
let new_agent =
|
|
108
|
+
let new_agent = fork_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
|
|
162
109
|
LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
|
|
163
110
|
})?;
|
|
164
111
|
let provider = new_agent
|
|
@@ -296,13 +243,21 @@ pub fn fork_agent_with_transport(
|
|
|
296
243
|
plan.provider_projects_root = source_backing.parent().map(Path::to_path_buf);
|
|
297
244
|
}
|
|
298
245
|
let window = WindowName::new(as_agent_id.as_str());
|
|
246
|
+
let mut claude_fork = prepare_claude_fork_backing(
|
|
247
|
+
provider,
|
|
248
|
+
&plan,
|
|
249
|
+
&source_backing,
|
|
250
|
+
&session_id,
|
|
251
|
+
)
|
|
252
|
+
.map_err(|error| {
|
|
253
|
+
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
254
|
+
cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
|
|
255
|
+
error
|
|
256
|
+
})?;
|
|
257
|
+
// The framework-created Claude snapshot is only fork input, not provider
|
|
258
|
+
// proof. Observe changes made after materialization so a spawn-only
|
|
259
|
+
// provider cannot turn the copied source backing into a Verified result.
|
|
299
260
|
let backing_before = crate::provider::session::ContextBackingSnapshot::capture(provider, &plan);
|
|
300
|
-
let mut claude_fork = prepare_claude_fork_backing(provider, &plan, source_backing, &session_id)
|
|
301
|
-
.map_err(|error| {
|
|
302
|
-
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
303
|
-
cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
|
|
304
|
-
error
|
|
305
|
-
})?;
|
|
306
261
|
let mut env =
|
|
307
262
|
inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
|
|
308
263
|
apply_profile_launch_env(&mut env, &profile_launch);
|
|
@@ -401,7 +356,7 @@ pub fn fork_agent_with_transport(
|
|
|
401
356
|
})?;
|
|
402
357
|
let convergence_deadline =
|
|
403
358
|
crate::provider::session::context_fork_convergence_deadline(provider);
|
|
404
|
-
let
|
|
359
|
+
let context_outcome = crate::provider::session::observe_context_fork(
|
|
405
360
|
provider,
|
|
406
361
|
&session_id,
|
|
407
362
|
&plan,
|
|
@@ -411,9 +366,40 @@ pub fn fork_agent_with_transport(
|
|
|
411
366
|
&workspace,
|
|
412
367
|
&spawned_at,
|
|
413
368
|
convergence_deadline,
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
|
|
369
|
+
);
|
|
370
|
+
let context_proof = match context_outcome {
|
|
371
|
+
crate::provider::session::ContextForkOutcome::Verified(proof) => Some(proof),
|
|
372
|
+
crate::provider::session::ContextForkOutcome::Pending(pending) => {
|
|
373
|
+
if let Err(error) = finalize_pending_fork_state(ForkPendingFinalizeInput {
|
|
374
|
+
workspace: &workspace,
|
|
375
|
+
team_key: &fork_team,
|
|
376
|
+
source_agent_id,
|
|
377
|
+
agent_id: as_agent_id,
|
|
378
|
+
spec_agent: new_agent,
|
|
379
|
+
safety: &safety,
|
|
380
|
+
plan: &plan,
|
|
381
|
+
profile_launch: &profile_launch,
|
|
382
|
+
spawn: &spawn,
|
|
383
|
+
profile_dir: &profile_dir,
|
|
384
|
+
dynamic_role_file: materialized_role.path(),
|
|
385
|
+
pending: &pending,
|
|
386
|
+
spawn_epoch,
|
|
387
|
+
}) {
|
|
388
|
+
rollback_fork_after_spawn(
|
|
389
|
+
&workspace,
|
|
390
|
+
transport,
|
|
391
|
+
&session_name,
|
|
392
|
+
&window,
|
|
393
|
+
&mcp_config_path,
|
|
394
|
+
as_agent_id,
|
|
395
|
+
&profile_launch,
|
|
396
|
+
&fork_team,
|
|
397
|
+
);
|
|
398
|
+
return Err(error);
|
|
399
|
+
}
|
|
400
|
+
None
|
|
401
|
+
}
|
|
402
|
+
crate::provider::session::ContextForkOutcome::Rejected(error) => {
|
|
417
403
|
rollback_fork_after_spawn(
|
|
418
404
|
&workspace,
|
|
419
405
|
transport,
|
|
@@ -427,33 +413,35 @@ pub fn fork_agent_with_transport(
|
|
|
427
413
|
return Err(LifecycleError::Provider(error.to_string()));
|
|
428
414
|
}
|
|
429
415
|
};
|
|
430
|
-
if let
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
416
|
+
if let Some(context_proof) = context_proof.as_ref() {
|
|
417
|
+
if let Err(error) = finalize_fork_state(ForkFinalizeInput {
|
|
418
|
+
workspace: &workspace,
|
|
419
|
+
team_key: &fork_team,
|
|
420
|
+
source_agent_id,
|
|
421
|
+
agent_id: as_agent_id,
|
|
422
|
+
spec_agent: new_agent,
|
|
423
|
+
safety: &safety,
|
|
424
|
+
plan: &plan,
|
|
425
|
+
profile_launch: &profile_launch,
|
|
426
|
+
spawn: &spawn,
|
|
427
|
+
profile_dir: &profile_dir,
|
|
428
|
+
dynamic_role_file: materialized_role.path(),
|
|
429
|
+
context_proof: &context_proof,
|
|
430
|
+
spawned_at: &spawned_at,
|
|
431
|
+
spawn_epoch,
|
|
432
|
+
}) {
|
|
433
|
+
rollback_fork_after_spawn(
|
|
434
|
+
&workspace,
|
|
435
|
+
transport,
|
|
436
|
+
&session_name,
|
|
437
|
+
&window,
|
|
438
|
+
&mcp_config_path,
|
|
439
|
+
as_agent_id,
|
|
440
|
+
&profile_launch,
|
|
441
|
+
&fork_team,
|
|
442
|
+
);
|
|
443
|
+
return Err(error);
|
|
444
|
+
}
|
|
457
445
|
}
|
|
458
446
|
if let Err(error) =
|
|
459
447
|
verify_fork_registration(&workspace, &fork_team, as_agent_id, &spawn, &window)
|
|
@@ -487,6 +475,11 @@ pub fn fork_agent_with_transport(
|
|
|
487
475
|
if let Some(materialized) = copilot_fork.as_mut() {
|
|
488
476
|
materialized.keep();
|
|
489
477
|
}
|
|
478
|
+
let backing_state = if context_proof.is_some() {
|
|
479
|
+
ForkBackingState::Verified
|
|
480
|
+
} else {
|
|
481
|
+
ForkBackingState::PendingContextFork
|
|
482
|
+
};
|
|
490
483
|
Ok(ForkAgentReport {
|
|
491
484
|
source_agent_id: source_agent_id.clone(),
|
|
492
485
|
new_agent_id: as_agent_id.clone(),
|
|
@@ -495,6 +488,7 @@ pub fn fork_agent_with_transport(
|
|
|
495
488
|
state_file: crate::state::persist::runtime_state_path(&workspace),
|
|
496
489
|
coordinator_started,
|
|
497
490
|
},
|
|
498
|
-
session_id:
|
|
491
|
+
session_id: context_proof.map(|proof| proof.new_session_id),
|
|
492
|
+
backing_state,
|
|
499
493
|
})
|
|
500
494
|
}
|
|
@@ -172,6 +172,110 @@ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), Li
|
|
|
172
172
|
.map_err(|error| LifecycleError::StatePersist(error.to_string()))
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
pub(super) struct ForkPendingFinalizeInput<'a> {
|
|
176
|
+
pub workspace: &'a Path,
|
|
177
|
+
pub team_key: &'a str,
|
|
178
|
+
pub source_agent_id: &'a AgentId,
|
|
179
|
+
pub agent_id: &'a AgentId,
|
|
180
|
+
pub spec_agent: &'a Value,
|
|
181
|
+
pub safety: &'a DangerousApproval,
|
|
182
|
+
pub plan: &'a crate::provider::CommandPlan,
|
|
183
|
+
pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
|
|
184
|
+
pub spawn: &'a crate::transport::SpawnResult,
|
|
185
|
+
pub profile_dir: &'a Path,
|
|
186
|
+
pub dynamic_role_file: &'a Path,
|
|
187
|
+
pub pending: &'a crate::provider::session::PendingContextFork,
|
|
188
|
+
pub spawn_epoch: u64,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pub(super) fn finalize_pending_fork_state(
|
|
192
|
+
input: ForkPendingFinalizeInput<'_>,
|
|
193
|
+
) -> Result<(), LifecycleError> {
|
|
194
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
195
|
+
workspace: input.workspace,
|
|
196
|
+
operation: "fork-agent-pending",
|
|
197
|
+
team: Some(input.team_key),
|
|
198
|
+
agent_id: Some(input.agent_id),
|
|
199
|
+
})?;
|
|
200
|
+
let mut next_state = crate::state::selector::resolve_active_team(
|
|
201
|
+
input.workspace,
|
|
202
|
+
Some(input.team_key),
|
|
203
|
+
crate::state::selector::SelectorMode::RequireSpec,
|
|
204
|
+
)
|
|
205
|
+
.map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
|
|
206
|
+
.state;
|
|
207
|
+
upsert_pending_forked_agent_state(
|
|
208
|
+
&mut next_state,
|
|
209
|
+
input.source_agent_id,
|
|
210
|
+
input.agent_id,
|
|
211
|
+
input.spec_agent,
|
|
212
|
+
input.safety,
|
|
213
|
+
input.plan,
|
|
214
|
+
input.profile_launch,
|
|
215
|
+
input.spawn,
|
|
216
|
+
Some(input.profile_dir),
|
|
217
|
+
input.dynamic_role_file,
|
|
218
|
+
input.pending,
|
|
219
|
+
input.spawn_epoch,
|
|
220
|
+
)?;
|
|
221
|
+
maybe_fail_fork_after_spawn("save_runtime_state")?;
|
|
222
|
+
crate::state::repository::StateRepository::new(input.workspace)
|
|
223
|
+
.save(
|
|
224
|
+
crate::state::repository::StateWriteIntent::ForkAgent {
|
|
225
|
+
team_key: input.team_key,
|
|
226
|
+
agent_id: input.agent_id.as_str(),
|
|
227
|
+
},
|
|
228
|
+
&next_state,
|
|
229
|
+
)
|
|
230
|
+
.map_err(|error| LifecycleError::StatePersist(error.to_string()))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pub(crate) fn finalize_pending_fork_capture(
|
|
234
|
+
agent: &mut serde_json::Map<String, serde_json::Value>,
|
|
235
|
+
captured: &crate::provider::CapturedSession,
|
|
236
|
+
) -> bool {
|
|
237
|
+
let Some(session_id) = captured.session_id.as_ref() else {
|
|
238
|
+
return false;
|
|
239
|
+
};
|
|
240
|
+
let Some(rollout_path) = captured.rollout_path.as_ref() else {
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
if agent
|
|
244
|
+
.get("fork_source_session_id")
|
|
245
|
+
.and_then(serde_json::Value::as_str)
|
|
246
|
+
== Some(session_id.as_str())
|
|
247
|
+
{
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
agent.insert(
|
|
251
|
+
"session_id".to_string(),
|
|
252
|
+
serde_json::json!(session_id.as_str()),
|
|
253
|
+
);
|
|
254
|
+
agent.insert(
|
|
255
|
+
"rollout_path".to_string(),
|
|
256
|
+
serde_json::json!(rollout_path.as_path().to_string_lossy()),
|
|
257
|
+
);
|
|
258
|
+
agent.insert(
|
|
259
|
+
"captured_at".to_string(),
|
|
260
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
261
|
+
);
|
|
262
|
+
agent.insert(
|
|
263
|
+
"captured_via".to_string(),
|
|
264
|
+
serde_json::to_value(captured.captured_via).unwrap_or(serde_json::Value::Null),
|
|
265
|
+
);
|
|
266
|
+
agent.insert(
|
|
267
|
+
"attribution_confidence".to_string(),
|
|
268
|
+
serde_json::to_value(captured.attribution_confidence).unwrap_or(serde_json::Value::Null),
|
|
269
|
+
);
|
|
270
|
+
agent.remove("_pending_session_id");
|
|
271
|
+
agent.remove("attribution_ambiguous");
|
|
272
|
+
agent.remove("fork_source_session_id");
|
|
273
|
+
agent.remove("pending_target_agent");
|
|
274
|
+
agent.remove("pending_grace_secs");
|
|
275
|
+
agent.insert("capture_state".to_string(), serde_json::json!("captured"));
|
|
276
|
+
true
|
|
277
|
+
}
|
|
278
|
+
|
|
175
279
|
pub(super) fn verify_fork_registration(
|
|
176
280
|
workspace: &Path,
|
|
177
281
|
team_key: &str,
|