@team-agent/installer 0.5.1 → 0.5.2
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 +118 -9
- package/Cargo.toml +2 -2
- package/crates/team-agent/Cargo.toml +1 -0
- package/crates/team-agent/src/app_server_test_support.rs +258 -0
- package/crates/team-agent/src/cli/adapters.rs +32 -3
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
- package/crates/team-agent/src/cli/diagnose.rs +14 -5
- package/crates/team-agent/src/cli/emit.rs +74 -1
- package/crates/team-agent/src/cli/mod.rs +70 -25
- package/crates/team-agent/src/cli/named_address.rs +111 -0
- package/crates/team-agent/src/cli/send.rs +98 -3
- package/crates/team-agent/src/cli/status_port.rs +44 -6
- package/crates/team-agent/src/cli/tests/named_address.rs +131 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +17 -0
- package/crates/team-agent/src/codex_app_server.rs +488 -0
- package/crates/team-agent/src/conpty/backend.rs +710 -0
- package/crates/team-agent/src/conpty/mod.rs +32 -0
- package/crates/team-agent/src/coordinator/backoff.rs +45 -6
- package/crates/team-agent/src/diagnose/orphans.rs +11 -3
- package/crates/team-agent/src/leader/lease.rs +144 -7
- package/crates/team-agent/src/leader/owner_bind.rs +5 -2
- package/crates/team-agent/src/leader/start.rs +18 -5
- package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
- package/crates/team-agent/src/lib.rs +23 -0
- package/crates/team-agent/src/lifecycle/launch.rs +127 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
- package/crates/team-agent/src/messaging/delivery.rs +329 -9
- package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
- package/crates/team-agent/src/messaging/mod.rs +2 -2
- package/crates/team-agent/src/messaging/results.rs +78 -21
- package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
- package/crates/team-agent/src/tmux_backend.rs +31 -0
- package/crates/team-agent/src/transport_factory.rs +632 -0
- package/package.json +4 -4
|
@@ -112,6 +112,10 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
112
112
|
"claim-leader" => cmd_claim_leader(&claim_leader_args(args, cwd)).map(emit_result),
|
|
113
113
|
// Real dispatch: `cmd_attach_leader` writes the `leader_receiver` binding.
|
|
114
114
|
"attach-leader" => cmd_attach_leader(&attach_leader_args(args, cwd)?).map(emit_result),
|
|
115
|
+
"attach-app-server-leader" => {
|
|
116
|
+
cmd_attach_app_server_leader(&attach_app_server_leader_args(args, cwd)?)
|
|
117
|
+
.map(emit_result)
|
|
118
|
+
}
|
|
115
119
|
"identity" => cmd_identity(&identity_args(args, cwd)).map(emit_result),
|
|
116
120
|
"approvals" => cmd_approvals(&approvals_args(args, cwd)).map(emit_result),
|
|
117
121
|
"inbox" => cmd_inbox(&inbox_args(args, cwd)?).map(emit_result),
|
|
@@ -167,6 +171,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
|
|
|
167
171
|
"takeover",
|
|
168
172
|
"claim-leader",
|
|
169
173
|
"attach-leader",
|
|
174
|
+
"attach-app-server-leader",
|
|
170
175
|
"identity",
|
|
171
176
|
"approvals",
|
|
172
177
|
"inbox",
|
|
@@ -210,6 +215,23 @@ fn is_known_subcommand(command: &str) -> bool {
|
|
|
210
215
|
DISPATCH_COMMANDS.contains(&command) || SPEC_ONLY_HELP_COMMANDS.contains(&command)
|
|
211
216
|
}
|
|
212
217
|
|
|
218
|
+
/// Test-only public accessor for `command_help` — allows integration
|
|
219
|
+
/// tests to grep the help copy without depending on internal parser
|
|
220
|
+
/// machinery.
|
|
221
|
+
pub fn __test_command_help(command: Option<&str>) -> String {
|
|
222
|
+
command_help(command)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Test-only public accessor for `quick_start_args` — allows
|
|
226
|
+
/// integration tests to exercise the parser without going through
|
|
227
|
+
/// stdio + the full `main` entrypoint.
|
|
228
|
+
pub fn __test_quick_start_args(
|
|
229
|
+
args: &[String],
|
|
230
|
+
cwd: &std::path::Path,
|
|
231
|
+
) -> Result<crate::cli::types::QuickStartArgs, crate::cli::CliError> {
|
|
232
|
+
quick_start_args(args, cwd)
|
|
233
|
+
}
|
|
234
|
+
|
|
213
235
|
fn command_help(command: Option<&str>) -> String {
|
|
214
236
|
match command {
|
|
215
237
|
None => {
|
|
@@ -222,7 +244,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
222
244
|
)
|
|
223
245
|
}
|
|
224
246
|
Some("init") => "usage: team-agent init [--workspace WORKSPACE] [--force] [--json]".to_string(),
|
|
225
|
-
Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.".to_string(),
|
|
247
|
+
Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.\n\n--backend selects the worker transport (Phase 1d Batch 2): tmux (default on POSIX; unchanged behavior), conpty (Windows-native ConPTY worker transport; requires the shim binary and Windows host).".to_string(),
|
|
226
248
|
Some("start") => "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]".to_string(),
|
|
227
249
|
Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
|
|
228
250
|
Some("send") => "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]\n\nMVP: name-based cross-workspace addressing assumes trusted local caller; no auth gate.".to_string(),
|
|
@@ -247,6 +269,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
247
269
|
Some("takeover") => "usage: team-agent takeover [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
|
|
248
270
|
Some("claim-leader") => "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
|
|
249
271
|
Some("attach-leader") => "usage: team-agent attach-leader [--workspace WORKSPACE] [--team TEAM] [--pane PANE] [--provider PROVIDER] [--confirm] [--json]".to_string(),
|
|
272
|
+
Some("attach-app-server-leader") => "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]".to_string(),
|
|
250
273
|
Some("identity") => "usage: team-agent identity [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
251
274
|
Some("approvals") => "usage: team-agent approvals [AGENT] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
|
|
252
275
|
Some("inbox") => "usage: team-agent inbox AGENT [--workspace WORKSPACE] [--team TEAM] [--limit N] [--since CURSOR] [--json]".to_string(),
|
|
@@ -670,12 +693,18 @@ struct ParsedArgs {
|
|
|
670
693
|
pane: Option<String>,
|
|
671
694
|
to_name: Option<String>,
|
|
672
695
|
provider: Option<String>,
|
|
696
|
+
socket: Option<String>,
|
|
697
|
+
thread_id: Option<String>,
|
|
673
698
|
message_id: Option<String>,
|
|
674
699
|
content: Option<String>,
|
|
675
700
|
primary_error: Option<String>,
|
|
676
701
|
agent_id: Option<String>,
|
|
677
702
|
task_id: Option<String>,
|
|
678
703
|
result_json: Option<String>,
|
|
704
|
+
/// 0.5.x Phase 1d Batch 2: quick-start `--backend <tmux|conpty>`.
|
|
705
|
+
/// Raw string (validated at the quick-start builder); the factory
|
|
706
|
+
/// enforces literal semantics.
|
|
707
|
+
backend: Option<String>,
|
|
679
708
|
}
|
|
680
709
|
|
|
681
710
|
fn parse_args(args: &[String]) -> ParsedArgs {
|
|
@@ -717,6 +746,7 @@ fn parse_args(args: &[String]) -> ParsedArgs {
|
|
|
717
746
|
}
|
|
718
747
|
"--force" => parsed.force = true,
|
|
719
748
|
"--no-display" => parsed.no_display = true,
|
|
749
|
+
"--backend" => parsed.backend = next_arg(args, &mut i),
|
|
720
750
|
"--discard-session" => parsed.discard_session = true,
|
|
721
751
|
"--role-file" => parsed.role_file = next_arg(args, &mut i),
|
|
722
752
|
"--as" => parsed.as_agent = next_arg(args, &mut i),
|
|
@@ -753,6 +783,8 @@ fn parse_args(args: &[String]) -> ParsedArgs {
|
|
|
753
783
|
"--pane" => parsed.pane = next_arg(args, &mut i),
|
|
754
784
|
"--to-name" => parsed.to_name = next_arg(args, &mut i),
|
|
755
785
|
"--provider" => parsed.provider = next_arg(args, &mut i),
|
|
786
|
+
"--socket" => parsed.socket = next_arg(args, &mut i),
|
|
787
|
+
"--thread-id" => parsed.thread_id = next_arg(args, &mut i),
|
|
756
788
|
"--message-id" => parsed.message_id = next_arg(args, &mut i),
|
|
757
789
|
"--content" => parsed.content = next_arg(args, &mut i),
|
|
758
790
|
"--primary-error" => parsed.primary_error = next_arg(args, &mut i),
|
|
@@ -770,6 +802,12 @@ fn parse_args(args: &[String]) -> ParsedArgs {
|
|
|
770
802
|
other if other.starts_with("--provider=") => {
|
|
771
803
|
parsed.provider = Some(other.trim_start_matches("--provider=").to_string());
|
|
772
804
|
}
|
|
805
|
+
other if other.starts_with("--socket=") => {
|
|
806
|
+
parsed.socket = Some(other.trim_start_matches("--socket=").to_string());
|
|
807
|
+
}
|
|
808
|
+
other if other.starts_with("--thread-id=") => {
|
|
809
|
+
parsed.thread_id = Some(other.trim_start_matches("--thread-id=").to_string());
|
|
810
|
+
}
|
|
773
811
|
other if other.starts_with('-') => {}
|
|
774
812
|
other => parsed.positionals.push(other.to_string()),
|
|
775
813
|
}
|
|
@@ -833,6 +871,20 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
|
|
|
833
871
|
} else {
|
|
834
872
|
workspace.join(agents_dir)
|
|
835
873
|
};
|
|
874
|
+
// 0.5.x Phase 1d Batch 2: validate the `--backend` literal up-front
|
|
875
|
+
// so users get a fast, actionable error instead of a downstream
|
|
876
|
+
// factory refusal. Accept the same literals as the factory
|
|
877
|
+
// `RequestedTransportBackend::parse_literal`.
|
|
878
|
+
if let Some(literal) = parsed.backend.as_deref() {
|
|
879
|
+
let normalized = literal.trim().to_ascii_lowercase();
|
|
880
|
+
if normalized != "tmux" && normalized != "conpty" {
|
|
881
|
+
return Err(CliError::Usage(format!(
|
|
882
|
+
"--backend must be `tmux` or `conpty`, got {literal:?}. \
|
|
883
|
+
`pty` is not a supported literal in Phase 1d (design \
|
|
884
|
+
§Non-Goals + CR C-1 ②)."
|
|
885
|
+
)));
|
|
886
|
+
}
|
|
887
|
+
}
|
|
836
888
|
Ok(QuickStartArgs {
|
|
837
889
|
workspace,
|
|
838
890
|
agents_dir,
|
|
@@ -841,6 +893,7 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
|
|
|
841
893
|
yes: parsed.yes,
|
|
842
894
|
no_display: parsed.no_display,
|
|
843
895
|
json: parsed.json,
|
|
896
|
+
backend: parsed.backend,
|
|
844
897
|
})
|
|
845
898
|
}
|
|
846
899
|
|
|
@@ -1079,6 +1132,26 @@ fn attach_leader_args(args: &[String], cwd: &Path) -> Result<AttachLeaderArgs, C
|
|
|
1079
1132
|
})
|
|
1080
1133
|
}
|
|
1081
1134
|
|
|
1135
|
+
fn attach_app_server_leader_args(
|
|
1136
|
+
args: &[String],
|
|
1137
|
+
cwd: &Path,
|
|
1138
|
+
) -> Result<AttachAppServerLeaderArgs, CliError> {
|
|
1139
|
+
let parsed = parse_args(args);
|
|
1140
|
+
Ok(AttachAppServerLeaderArgs {
|
|
1141
|
+
workspace: workspace(&parsed, cwd),
|
|
1142
|
+
team: parsed.team,
|
|
1143
|
+
socket: parsed
|
|
1144
|
+
.socket
|
|
1145
|
+
.filter(|socket| !socket.is_empty())
|
|
1146
|
+
.ok_or_else(|| CliError::Usage("missing --socket".to_string()))?,
|
|
1147
|
+
thread_id: parsed
|
|
1148
|
+
.thread_id
|
|
1149
|
+
.filter(|thread_id| !thread_id.is_empty())
|
|
1150
|
+
.ok_or_else(|| CliError::Usage("missing --thread-id".to_string()))?,
|
|
1151
|
+
json: parsed.json,
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1082
1155
|
fn identity_args(args: &[String], cwd: &Path) -> IdentityArgs {
|
|
1083
1156
|
let parsed = parse_args(args);
|
|
1084
1157
|
IdentityArgs {
|
|
@@ -53,6 +53,7 @@ pub(crate) const SEND_REMINDER: &str = "Message delivered. Wait for the worker t
|
|
|
53
53
|
pub(crate) const STATUS_REMINDER: &str = "To wait for results use --watch-result or team-agent collect. Do not capture-pane worker terminals.";
|
|
54
54
|
|
|
55
55
|
pub mod adapters;
|
|
56
|
+
pub mod attach_app_server_leader;
|
|
56
57
|
pub mod diagnose;
|
|
57
58
|
pub mod emit;
|
|
58
59
|
pub mod helpers;
|
|
@@ -64,6 +65,7 @@ pub mod status;
|
|
|
64
65
|
pub mod types;
|
|
65
66
|
|
|
66
67
|
pub use adapters::*;
|
|
68
|
+
pub use attach_app_server_leader::*;
|
|
67
69
|
pub use diagnose::*;
|
|
68
70
|
pub use emit::*;
|
|
69
71
|
pub use leader::*;
|
|
@@ -120,14 +122,16 @@ pub mod lifecycle_port {
|
|
|
120
122
|
team_id: Option<&str>,
|
|
121
123
|
yes: bool,
|
|
122
124
|
open_display: bool,
|
|
125
|
+
backend: Option<&str>,
|
|
123
126
|
) -> Result<Value, CliError> {
|
|
124
|
-
match crate::lifecycle::
|
|
127
|
+
match crate::lifecycle::quick_start_in_workspace_with_display_and_backend(
|
|
125
128
|
workspace,
|
|
126
129
|
agents_dir,
|
|
127
130
|
name,
|
|
128
131
|
yes,
|
|
129
132
|
team_id,
|
|
130
133
|
open_display,
|
|
134
|
+
backend,
|
|
131
135
|
) {
|
|
132
136
|
Ok(report) => Ok(quick_start_value(report)),
|
|
133
137
|
Err(e) => Ok(error_value(e)),
|
|
@@ -192,16 +196,40 @@ pub mod lifecycle_port {
|
|
|
192
196
|
crate::tmux_backend::attach_command_for_workspace(cwd, session, window.as_str())
|
|
193
197
|
}
|
|
194
198
|
/// `runtime.shutdown`(`cmd_shutdown`)。
|
|
199
|
+
///
|
|
200
|
+
/// 0.5.x Phase 1d Batch 5: the workspace-transport branch now routes
|
|
201
|
+
/// through `transport_factory::resolve_read_only_transport` so a
|
|
202
|
+
/// conpty team's shutdown reaches the shim rather than a
|
|
203
|
+
/// no-op tmux backend. Legacy tmux endpoint literal in state still
|
|
204
|
+
/// takes the `shutdown_transport_for_endpoint` tmux channel helper
|
|
205
|
+
/// path (intentional — this is the "attached explicit tmux
|
|
206
|
+
/// endpoint" branch and stays tmux-typed).
|
|
207
|
+
///
|
|
208
|
+
/// `shutdown_with_transport_and_state` is generic over `&dyn
|
|
209
|
+
/// Transport`, so we hand the boxed factory backend down as
|
|
210
|
+
/// `&*box`. Factory refusal falls back to the workspace tmux
|
|
211
|
+
/// backend byte-equivalent to today so daemon liveness paths
|
|
212
|
+
/// don't crash.
|
|
195
213
|
pub fn shutdown(workspace: &Path, keep_logs: bool, team: Option<&str>) -> Result<Value, CliError> {
|
|
196
214
|
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
197
215
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
198
216
|
let state = shutdown_state_for_team(&run_ws, team)?;
|
|
199
|
-
|
|
200
|
-
shutdown_transport_for_endpoint(endpoint)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
217
|
+
if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
|
|
218
|
+
let transport = shutdown_transport_for_endpoint(endpoint);
|
|
219
|
+
return shutdown_with_transport_and_state(
|
|
220
|
+
workspace, keep_logs, team, &transport, Some(state),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
let boxed: Box<dyn crate::transport::Transport> =
|
|
224
|
+
match crate::transport_factory::resolve_read_only_transport(
|
|
225
|
+
&run_ws,
|
|
226
|
+
Some(&state),
|
|
227
|
+
crate::transport_factory::TransportPurpose::Shutdown,
|
|
228
|
+
) {
|
|
229
|
+
Ok(r) => r.backend,
|
|
230
|
+
Err(_) => Box::new(shutdown_workspace_transport(&run_ws)),
|
|
231
|
+
};
|
|
232
|
+
shutdown_with_transport_and_state(workspace, keep_logs, team, boxed.as_ref(), Some(state))
|
|
205
233
|
}
|
|
206
234
|
|
|
207
235
|
/// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
|
|
@@ -1191,24 +1219,41 @@ pub mod lifecycle_port {
|
|
|
1191
1219
|
}
|
|
1192
1220
|
}
|
|
1193
1221
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1222
|
+
// 0.5.x Phase 1d Batch 5 (design §Batch 5 point 4): a ConPTY
|
|
1223
|
+
// team's residual check MUST NOT read the workspace tmux socket
|
|
1224
|
+
// + shared default tmux socket as evidence for/against ConPTY
|
|
1225
|
+
// residual. Those probes are meaningless for a shim-owned pane
|
|
1226
|
+
// universe: they will always return `has_session=false` (no
|
|
1227
|
+
// tmux session exists) which would look like "no residual" but
|
|
1228
|
+
// gives zero honest information about whether the shim / child
|
|
1229
|
+
// panes have actually been reaped. Skip the tmux fallback
|
|
1230
|
+
// probes when the primary transport is ConPTY; the primary
|
|
1231
|
+
// transport check above already covered the honest question
|
|
1232
|
+
// ("does the shim still have this session?").
|
|
1233
|
+
let primary_is_conpty = matches!(
|
|
1234
|
+
transport.kind(),
|
|
1235
|
+
crate::transport::BackendKind::ConPty
|
|
1236
|
+
);
|
|
1237
|
+
if !primary_is_conpty {
|
|
1238
|
+
let workspace_transport = shutdown_workspace_transport(workspace);
|
|
1239
|
+
match crate::transport::Transport::has_session(&workspace_transport, session) {
|
|
1240
|
+
Ok(true) => residual = true,
|
|
1241
|
+
Ok(false) => {}
|
|
1242
|
+
Err(err) if tmux_absent_error(&err.to_string()) => {}
|
|
1243
|
+
Err(err) => {
|
|
1244
|
+
error.get_or_insert_with(|| err.to_string());
|
|
1245
|
+
residual = true;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
let default_transport = crate::tmux_backend::TmuxBackend::new();
|
|
1249
|
+
match crate::transport::Transport::has_session(&default_transport, session) {
|
|
1250
|
+
Ok(true) => residual = true,
|
|
1251
|
+
Ok(false) => {}
|
|
1252
|
+
Err(err) if tmux_absent_error(&err.to_string()) => {}
|
|
1253
|
+
Err(err) => {
|
|
1254
|
+
error.get_or_insert_with(|| err.to_string());
|
|
1255
|
+
residual = true;
|
|
1256
|
+
}
|
|
1212
1257
|
}
|
|
1213
1258
|
}
|
|
1214
1259
|
let sessions = if residual {
|
|
@@ -98,6 +98,8 @@ pub(crate) struct ResolvedNamedAddress {
|
|
|
98
98
|
pub session_name: Option<String>,
|
|
99
99
|
pub window_name: Option<String>,
|
|
100
100
|
pub tmux_endpoint: Option<String>,
|
|
101
|
+
pub transport_kind: Option<String>,
|
|
102
|
+
pub app_server: Option<Value>,
|
|
101
103
|
pub state_pane_id: Option<String>,
|
|
102
104
|
pub state_pane_stale: bool,
|
|
103
105
|
pub agent_status: Option<String>,
|
|
@@ -428,6 +430,8 @@ fn resolve_worker(
|
|
|
428
430
|
session_name: Some(session.to_string()),
|
|
429
431
|
window_name: Some(window.to_string()),
|
|
430
432
|
tmux_endpoint: transport.tmux_endpoint(),
|
|
433
|
+
transport_kind: Some("direct_tmux".to_string()),
|
|
434
|
+
app_server: None,
|
|
431
435
|
state_pane_id,
|
|
432
436
|
state_pane_stale,
|
|
433
437
|
agent_status,
|
|
@@ -541,6 +545,28 @@ fn resolve_leader(
|
|
|
541
545
|
state.get("leader_receiver")
|
|
542
546
|
}
|
|
543
547
|
.ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
|
|
548
|
+
if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
|
|
549
|
+
let mut err = NamedAddressError::new(
|
|
550
|
+
NamedAddressErrorKind::NameNotLive,
|
|
551
|
+
"leader_receiver mode/transport_kind conflict",
|
|
552
|
+
);
|
|
553
|
+
err.action =
|
|
554
|
+
"Rebind the leader: team-agent claim-leader, takeover, or attach-app-server-leader"
|
|
555
|
+
.to_string();
|
|
556
|
+
err.log = format!(
|
|
557
|
+
"name={team}/leader mode={mode} transport_kind={transport_kind} expected=single_transport_kind"
|
|
558
|
+
);
|
|
559
|
+
return Err(err);
|
|
560
|
+
}
|
|
561
|
+
if crate::codex_app_server::receiver_is_app_server(receiver) {
|
|
562
|
+
return resolve_app_server_leader(
|
|
563
|
+
sender_workspace,
|
|
564
|
+
target_workspace,
|
|
565
|
+
team,
|
|
566
|
+
parsed,
|
|
567
|
+
receiver,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
544
570
|
let pane_id = string_field(receiver, "pane_id")
|
|
545
571
|
.filter(|pane| !pane.is_empty())
|
|
546
572
|
.ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
|
|
@@ -566,6 +592,8 @@ fn resolve_leader(
|
|
|
566
592
|
session_name: session.map(str::to_string),
|
|
567
593
|
window_name: window.map(str::to_string),
|
|
568
594
|
tmux_endpoint: socket,
|
|
595
|
+
transport_kind: Some("direct_tmux".to_string()),
|
|
596
|
+
app_server: None,
|
|
569
597
|
state_pane_id: Some(pane_id.to_string()),
|
|
570
598
|
state_pane_stale: false,
|
|
571
599
|
agent_status: None,
|
|
@@ -597,6 +625,87 @@ fn resolve_leader(
|
|
|
597
625
|
}
|
|
598
626
|
}
|
|
599
627
|
|
|
628
|
+
fn receiver_transport_conflict(receiver: &Value) -> Option<(String, String)> {
|
|
629
|
+
let mode = receiver.get("mode").and_then(Value::as_str)?;
|
|
630
|
+
let transport_kind = receiver.get("transport_kind").and_then(Value::as_str)?;
|
|
631
|
+
if !mode.is_empty() && !transport_kind.is_empty() && mode != transport_kind {
|
|
632
|
+
return Some((mode.to_string(), transport_kind.to_string()));
|
|
633
|
+
}
|
|
634
|
+
None
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
fn resolve_app_server_leader(
|
|
638
|
+
sender_workspace: &Path,
|
|
639
|
+
target_workspace: &Path,
|
|
640
|
+
team: &str,
|
|
641
|
+
parsed: &ParsedNamedAddress,
|
|
642
|
+
receiver: &Value,
|
|
643
|
+
) -> Result<ResolvedNamedAddress, NamedAddressError> {
|
|
644
|
+
let expected = crate::codex_app_server::binding_from_receiver(receiver).map_err(|error| {
|
|
645
|
+
let mut err = NamedAddressError::new(
|
|
646
|
+
NamedAddressErrorKind::NameNotLive,
|
|
647
|
+
format!("app-server leader receiver is incomplete: {error}"),
|
|
648
|
+
);
|
|
649
|
+
err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket <socket> --thread-id <thread_id>");
|
|
650
|
+
err.log = format!("name={team}/leader expected=leader_receiver.app_server");
|
|
651
|
+
err
|
|
652
|
+
})?;
|
|
653
|
+
let actual = crate::codex_app_server::attach_probe(&expected.socket, &expected.thread_id)
|
|
654
|
+
.map_err(|error| {
|
|
655
|
+
let mut err = NamedAddressError::new(
|
|
656
|
+
NamedAddressErrorKind::NameNotLive,
|
|
657
|
+
format!("app-server leader thread is not live: {error}"),
|
|
658
|
+
);
|
|
659
|
+
err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket {} --thread-id {}", expected.socket, expected.thread_id);
|
|
660
|
+
err.log = format!(
|
|
661
|
+
"name={team}/leader socket={} thread_id={} reason={}",
|
|
662
|
+
expected.socket,
|
|
663
|
+
expected.thread_id,
|
|
664
|
+
error
|
|
665
|
+
);
|
|
666
|
+
err
|
|
667
|
+
})?;
|
|
668
|
+
if actual.thread_id != expected.thread_id
|
|
669
|
+
|| actual.session_id != expected.session_id
|
|
670
|
+
|| actual.cwd != expected.cwd
|
|
671
|
+
|| actual.cli_version != expected.cli_version
|
|
672
|
+
{
|
|
673
|
+
let mut err = NamedAddressError::new(
|
|
674
|
+
NamedAddressErrorKind::NameNotLive,
|
|
675
|
+
"app-server leader thread identity tuple is stale",
|
|
676
|
+
);
|
|
677
|
+
err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket {} --thread-id {}", expected.socket, expected.thread_id);
|
|
678
|
+
err.log = format!(
|
|
679
|
+
"name={team}/leader expected_thread={} actual_thread={} expected_session={} actual_session={} expected_cwd={} actual_cwd={}",
|
|
680
|
+
expected.thread_id,
|
|
681
|
+
actual.thread_id,
|
|
682
|
+
expected.session_id,
|
|
683
|
+
actual.session_id,
|
|
684
|
+
expected.cwd,
|
|
685
|
+
actual.cwd
|
|
686
|
+
);
|
|
687
|
+
return Err(err);
|
|
688
|
+
}
|
|
689
|
+
Ok(ResolvedNamedAddress {
|
|
690
|
+
raw_name: parsed.display_name(),
|
|
691
|
+
target_kind: NamedTargetKind::Leader,
|
|
692
|
+
sender_workspace: sender_workspace.to_path_buf(),
|
|
693
|
+
target_workspace: target_workspace.to_path_buf(),
|
|
694
|
+
team_key: Some(team.to_string()),
|
|
695
|
+
agent_id: None,
|
|
696
|
+
pane_id: String::new(),
|
|
697
|
+
session_name: None,
|
|
698
|
+
window_name: None,
|
|
699
|
+
tmux_endpoint: None,
|
|
700
|
+
transport_kind: Some("codex_app_server".to_string()),
|
|
701
|
+
app_server: receiver.get("app_server").cloned(),
|
|
702
|
+
state_pane_id: None,
|
|
703
|
+
state_pane_stale: false,
|
|
704
|
+
agent_status: None,
|
|
705
|
+
warning: None,
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
|
|
600
709
|
fn resolve_session_window(
|
|
601
710
|
sender_workspace: &Path,
|
|
602
711
|
target_workspace: &Path,
|
|
@@ -619,6 +728,8 @@ fn resolve_session_window(
|
|
|
619
728
|
session_name: Some(session.to_string()),
|
|
620
729
|
window_name: Some(window.to_string()),
|
|
621
730
|
tmux_endpoint: transport.tmux_endpoint(),
|
|
731
|
+
transport_kind: Some("direct_tmux".to_string()),
|
|
732
|
+
app_server: None,
|
|
622
733
|
state_pane_id: None,
|
|
623
734
|
state_pane_stale: false,
|
|
624
735
|
agent_status: None,
|
|
@@ -17,7 +17,9 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
17
17
|
}
|
|
18
18
|
let content = args.message.join(" ");
|
|
19
19
|
if content.is_empty() {
|
|
20
|
-
return Err(CliError::Usage(
|
|
20
|
+
return Err(CliError::Usage(
|
|
21
|
+
"--to-name requires a non-empty message".to_string(),
|
|
22
|
+
));
|
|
21
23
|
}
|
|
22
24
|
let (resolved, transport) =
|
|
23
25
|
match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
|
|
@@ -52,7 +54,9 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
|
|
|
52
54
|
}
|
|
53
55
|
let content = args.message.join(" ");
|
|
54
56
|
if content.is_empty() {
|
|
55
|
-
return Err(CliError::Usage(
|
|
57
|
+
return Err(CliError::Usage(
|
|
58
|
+
"--pane requires a non-empty message".to_string(),
|
|
59
|
+
));
|
|
56
60
|
}
|
|
57
61
|
let mut value = send_to_pane_direct(
|
|
58
62
|
&args.workspace,
|
|
@@ -191,13 +195,22 @@ fn send_to_named_pane_direct(
|
|
|
191
195
|
|
|
192
196
|
let message_id = format!("named_send_{}", chrono::Utc::now().timestamp_millis());
|
|
193
197
|
let rendered = render_message(sender, task_id, content, &message_id);
|
|
194
|
-
let target = Target::Pane(PaneId::new(&resolved.pane_id));
|
|
195
198
|
let sender_run_workspace = crate::model::paths::canonical_run_workspace(sender_workspace)
|
|
196
199
|
.unwrap_or_else(|_| sender_workspace.to_path_buf());
|
|
197
200
|
let event_log = crate::event_log::EventLog::new(&sender_run_workspace);
|
|
198
201
|
if let Some(warning) = &resolved.warning {
|
|
199
202
|
eprintln!("warning: {warning}");
|
|
200
203
|
}
|
|
204
|
+
if resolved.transport_kind.as_deref() == Some("codex_app_server") {
|
|
205
|
+
return send_to_named_app_server_leader(
|
|
206
|
+
&event_log,
|
|
207
|
+
resolved,
|
|
208
|
+
&message_id,
|
|
209
|
+
&rendered,
|
|
210
|
+
sender,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
let target = Target::Pane(PaneId::new(&resolved.pane_id));
|
|
201
214
|
let report = transport
|
|
202
215
|
.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
|
|
203
216
|
.map_err(|e| {
|
|
@@ -241,6 +254,88 @@ fn send_to_named_pane_direct(
|
|
|
241
254
|
Ok(value)
|
|
242
255
|
}
|
|
243
256
|
|
|
257
|
+
fn send_to_named_app_server_leader(
|
|
258
|
+
event_log: &crate::event_log::EventLog,
|
|
259
|
+
resolved: &crate::cli::named_address::ResolvedNamedAddress,
|
|
260
|
+
message_id: &str,
|
|
261
|
+
rendered: &str,
|
|
262
|
+
sender: &str,
|
|
263
|
+
) -> Result<serde_json::Value, CliError> {
|
|
264
|
+
let receiver = serde_json::json!({
|
|
265
|
+
"mode": "codex_app_server",
|
|
266
|
+
"transport_kind": "codex_app_server",
|
|
267
|
+
"app_server": resolved.app_server.clone().unwrap_or(serde_json::Value::Null),
|
|
268
|
+
});
|
|
269
|
+
let binding = crate::codex_app_server::binding_from_receiver(&receiver)
|
|
270
|
+
.map_err(|error| CliError::Runtime(format!("invalid app-server named leader: {error}")))?;
|
|
271
|
+
let target_kind = named_target_kind_wire(resolved.target_kind);
|
|
272
|
+
let base_event = serde_json::json!({
|
|
273
|
+
"to_name": resolved.raw_name,
|
|
274
|
+
"target_kind": target_kind,
|
|
275
|
+
"sender": sender,
|
|
276
|
+
"sender_workspace": resolved.sender_workspace.display().to_string(),
|
|
277
|
+
"target_workspace": resolved.target_workspace.display().to_string(),
|
|
278
|
+
"team_key": resolved.team_key,
|
|
279
|
+
"agent_id": resolved.agent_id,
|
|
280
|
+
"transport_kind": "codex_app_server",
|
|
281
|
+
"socket": binding.socket,
|
|
282
|
+
"thread_id": binding.thread_id,
|
|
283
|
+
"message_id": message_id,
|
|
284
|
+
});
|
|
285
|
+
match crate::codex_app_server::submit_to_bound_thread(&binding, message_id, rendered) {
|
|
286
|
+
Ok(submit) => {
|
|
287
|
+
let event = merge_json(
|
|
288
|
+
base_event.clone(),
|
|
289
|
+
serde_json::json!({
|
|
290
|
+
"ok": true,
|
|
291
|
+
"turn_id": submit.turn_id,
|
|
292
|
+
"turn_status": submit.turn_status,
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
let _ = event_log.write("send.name_app_server", event.clone());
|
|
296
|
+
Ok(event)
|
|
297
|
+
}
|
|
298
|
+
Err(crate::codex_app_server::AppServerError::LeaderBusy(message)) => {
|
|
299
|
+
let event = merge_json(
|
|
300
|
+
base_event.clone(),
|
|
301
|
+
serde_json::json!({
|
|
302
|
+
"ok": false,
|
|
303
|
+
"status": "retry_scheduled",
|
|
304
|
+
"reason": "leader_busy",
|
|
305
|
+
"channel": "leader_busy",
|
|
306
|
+
"error": message,
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
let _ = event_log.write("send.name_app_server", event.clone());
|
|
310
|
+
Ok(event)
|
|
311
|
+
}
|
|
312
|
+
Err(error) => {
|
|
313
|
+
let event = merge_json(
|
|
314
|
+
base_event.clone(),
|
|
315
|
+
serde_json::json!({
|
|
316
|
+
"ok": false,
|
|
317
|
+
"status": "refused",
|
|
318
|
+
"reason": error.code(),
|
|
319
|
+
"channel": "rebind_required",
|
|
320
|
+
"error": error.to_string(),
|
|
321
|
+
"action": "run team-agent attach-app-server-leader for the target team",
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
let _ = event_log.write("send.name_app_server", event.clone());
|
|
325
|
+
Ok(event)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
fn merge_json(mut left: serde_json::Value, right: serde_json::Value) -> serde_json::Value {
|
|
331
|
+
if let (Some(left), Some(right)) = (left.as_object_mut(), right.as_object()) {
|
|
332
|
+
for (key, value) in right {
|
|
333
|
+
left.insert(key.clone(), value.clone());
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
left
|
|
337
|
+
}
|
|
338
|
+
|
|
244
339
|
fn named_target_kind_wire(kind: crate::cli::named_address::NamedTargetKind) -> &'static str {
|
|
245
340
|
match kind {
|
|
246
341
|
crate::cli::named_address::NamedTargetKind::Worker => "worker",
|
|
@@ -280,7 +280,29 @@ use rusqlite::params;
|
|
|
280
280
|
if let (Some(session), Some(agents)) = (session, state.get("agents").and_then(Value::as_object)) {
|
|
281
281
|
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
282
282
|
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
283
|
-
|
|
283
|
+
// 0.5.x Phase 1d Batch 3: use the factory-resolved backend
|
|
284
|
+
// so conpty teams get their scrollback from the shim rather
|
|
285
|
+
// than a fake tmux capture that always returns empty. Tmux
|
|
286
|
+
// teams take the same code path as before (byte-equivalent).
|
|
287
|
+
let resolved = crate::transport_factory::resolve_read_only_transport(
|
|
288
|
+
&run_ws,
|
|
289
|
+
Some(state),
|
|
290
|
+
crate::transport_factory::TransportPurpose::Status,
|
|
291
|
+
);
|
|
292
|
+
let backend: Box<dyn crate::transport::Transport> = match resolved {
|
|
293
|
+
Ok(r) => r.backend,
|
|
294
|
+
Err(_) => {
|
|
295
|
+
// Read-path fallback: refused factory means we don't
|
|
296
|
+
// try to inspect approval prompts. Empty vec = no
|
|
297
|
+
// waiting approvals, honest.
|
|
298
|
+
return Ok(json!({
|
|
299
|
+
"ok": true,
|
|
300
|
+
"waiting": false,
|
|
301
|
+
"waiting_count": 0,
|
|
302
|
+
"approvals": [],
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
};
|
|
284
306
|
for (agent_id, agent_state) in agents {
|
|
285
307
|
if agent.is_some_and(|wanted| wanted != agent_id) {
|
|
286
308
|
continue;
|
|
@@ -469,14 +491,30 @@ use rusqlite::params;
|
|
|
469
491
|
}
|
|
470
492
|
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
471
493
|
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
472
|
-
|
|
494
|
+
// 0.5.x Phase 1d Batch 3: route through the factory so a
|
|
495
|
+
// conpty team does NOT get its `has_session` probe served by a
|
|
496
|
+
// tmux backend (which would always return false and drive the
|
|
497
|
+
// reader into a false `tmux_session_missing` state — design
|
|
498
|
+
// §Batch 3 Verification anchor). Tmux teams see byte-equivalent
|
|
499
|
+
// behavior because factory Layer 3 (legacy tmux endpoint) uses
|
|
500
|
+
// the same `tmux_backend_for_runtime_state_or_workspace` shape.
|
|
501
|
+
let resolved = crate::transport_factory::resolve_read_only_transport(
|
|
473
502
|
&run_ws,
|
|
474
503
|
Some(state),
|
|
504
|
+
crate::transport_factory::TransportPurpose::Status,
|
|
475
505
|
);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
506
|
+
match resolved {
|
|
507
|
+
Ok(r) => r
|
|
508
|
+
.backend
|
|
509
|
+
.has_session(&crate::transport::SessionName::new(name))
|
|
510
|
+
.unwrap_or(false),
|
|
511
|
+
Err(_) => {
|
|
512
|
+
// Factory refused (e.g. explicit conpty without a
|
|
513
|
+
// resolvable team_key). Honest: return false rather
|
|
514
|
+
// than pretend a tmux session exists.
|
|
515
|
+
false
|
|
516
|
+
}
|
|
517
|
+
}
|
|
480
518
|
}
|
|
481
519
|
|
|
482
520
|
fn message_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
|