@team-agent/installer 0.5.92 → 0.5.94
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/mod.rs +370 -441
- package/crates/team-agent/src/cli/models.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +154 -30
- package/crates/team-agent/src/coordinator/health.rs +92 -30
- package/crates/team-agent/src/coordinator/tick.rs +62 -8
- package/crates/team-agent/src/coordinator/types.rs +2 -0
- package/crates/team-agent/src/kill_audit.rs +41 -2
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +48 -14
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +4 -8
- package/crates/team-agent/src/lifecycle/launch.rs +17 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +10 -7
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +91 -4
- package/crates/team-agent/src/platform/process.rs +4 -45
- package/crates/team-agent/src/tmux_backend.rs +113 -3
- package/crates/team-agent/src/transport/test_support.rs +30 -0
- package/crates/team-agent/src/transport.rs +41 -0
- package/package.json +4 -4
|
@@ -160,6 +160,40 @@ fn coordinator_team_scoped_state(
|
|
|
160
160
|
crate::state::projection::select_runtime_state(workspace, Some(selected)).ok()
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
fn live_sibling_team_state(
|
|
164
|
+
state: &Value,
|
|
165
|
+
missing_session: &str,
|
|
166
|
+
transport: &dyn crate::transport::Transport,
|
|
167
|
+
) -> Result<Option<(String, Value)>, crate::transport::TransportError> {
|
|
168
|
+
let Some(teams) = state.get("teams").and_then(Value::as_object) else {
|
|
169
|
+
return Ok(None);
|
|
170
|
+
};
|
|
171
|
+
for (team_key, team_state) in teams {
|
|
172
|
+
let status = team_state
|
|
173
|
+
.get("status")
|
|
174
|
+
.and_then(Value::as_str)
|
|
175
|
+
.map(|status| status.to_ascii_lowercase());
|
|
176
|
+
if status.is_some_and(|status| status != "alive" && status != "running") {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
let Some(session_name) = team_state
|
|
180
|
+
.get("session_name")
|
|
181
|
+
.and_then(Value::as_str)
|
|
182
|
+
.filter(|session| !session.is_empty() && *session != missing_session)
|
|
183
|
+
else {
|
|
184
|
+
continue;
|
|
185
|
+
};
|
|
186
|
+
let session = crate::transport::SessionName::new(session_name);
|
|
187
|
+
if transport.has_session(&session)? {
|
|
188
|
+
return Ok(Some((
|
|
189
|
+
team_key.clone(),
|
|
190
|
+
crate::state::projection::project_top_level_view(state, team_key),
|
|
191
|
+
)));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
Ok(None)
|
|
195
|
+
}
|
|
196
|
+
|
|
163
197
|
// ===========================================================================
|
|
164
198
|
// Coordinator struct(daemon lifecycle + tick orchestration)
|
|
165
199
|
// ===========================================================================
|
|
@@ -330,20 +364,40 @@ impl Coordinator {
|
|
|
330
364
|
.get("session_name")
|
|
331
365
|
.and_then(Value::as_str)
|
|
332
366
|
.filter(|s| !s.is_empty())
|
|
367
|
+
.map(str::to_owned)
|
|
333
368
|
{
|
|
334
|
-
let session = crate::transport::SessionName::new(session_name);
|
|
369
|
+
let session = crate::transport::SessionName::new(&session_name);
|
|
335
370
|
if !self.transport.has_session(&session)? {
|
|
336
371
|
event_log.write(
|
|
337
372
|
"coordinator.session_missing",
|
|
338
373
|
serde_json::json!({"session": session_name}),
|
|
339
374
|
)?;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
375
|
+
if let Some((sibling_team, sibling_state)) =
|
|
376
|
+
live_sibling_team_state(&state, &session_name, self.transport.as_ref())?
|
|
377
|
+
{
|
|
378
|
+
event_log.write(
|
|
379
|
+
"coordinator.session_missing_sibling_alive",
|
|
380
|
+
serde_json::json!({
|
|
381
|
+
"session": session_name,
|
|
382
|
+
"sibling_team": sibling_team,
|
|
383
|
+
"action": "continue_with_sibling_team",
|
|
384
|
+
}),
|
|
385
|
+
)?;
|
|
386
|
+
state = sibling_state;
|
|
387
|
+
} else {
|
|
388
|
+
notify_session_missing(
|
|
389
|
+
self.workspace.as_path(),
|
|
390
|
+
&state,
|
|
391
|
+
&event_log,
|
|
392
|
+
&session_name,
|
|
393
|
+
)?;
|
|
394
|
+
return Ok(empty_tick_report(
|
|
395
|
+
false,
|
|
396
|
+
true,
|
|
397
|
+
Some(TickStopReason::TmuxSessionMissing),
|
|
398
|
+
None,
|
|
399
|
+
));
|
|
400
|
+
}
|
|
347
401
|
}
|
|
348
402
|
}
|
|
349
403
|
|
|
@@ -164,6 +164,8 @@ pub enum StopOutcome {
|
|
|
164
164
|
Missing,
|
|
165
165
|
/// pid 非整数 → 清文件返回(`lifecycle.py:238`)。
|
|
166
166
|
InvalidPidRemoved,
|
|
167
|
+
/// The pid file points to a live process owned by another workspace.
|
|
168
|
+
NotOwned,
|
|
167
169
|
/// SIGTERM 失败(`lifecycle.py:243`)。
|
|
168
170
|
KillFailed,
|
|
169
171
|
/// SIGTERM + 清 pid/meta 成功(`lifecycle.py:247`)。
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
use std::path::Path;
|
|
4
4
|
|
|
5
|
-
use serde_json::json;
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
6
|
|
|
7
7
|
use crate::event_log::{EventLog, EventLogError};
|
|
8
8
|
use crate::transport::{BackendKind, Transport};
|
|
@@ -29,6 +29,45 @@ pub(crate) fn pre_kill_audit(
|
|
|
29
29
|
"argv": argv,
|
|
30
30
|
"endpoint": endpoint,
|
|
31
31
|
"targets": targets,
|
|
32
|
+
"workspace": Value::Null,
|
|
33
|
+
"team": Value::Null,
|
|
34
|
+
"generation": Value::Null,
|
|
35
|
+
"owner_evidence": "legacy_unscoped",
|
|
36
|
+
"reason": "legacy_callsite",
|
|
37
|
+
}),
|
|
38
|
+
)?;
|
|
39
|
+
Ok(())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub(crate) fn pre_kill_audit_scoped(
|
|
43
|
+
event_log: &EventLog,
|
|
44
|
+
transport: &dyn Transport,
|
|
45
|
+
caller: &str,
|
|
46
|
+
action: &str,
|
|
47
|
+
targets: &[String],
|
|
48
|
+
workspace: &Path,
|
|
49
|
+
team: Option<&str>,
|
|
50
|
+
generation: Option<&str>,
|
|
51
|
+
owner_evidence: &str,
|
|
52
|
+
reason: &str,
|
|
53
|
+
) -> Result<(), EventLogError> {
|
|
54
|
+
let endpoint = transport.tmux_endpoint();
|
|
55
|
+
let argv = destructive_argv(transport.kind(), endpoint.as_deref(), action, targets);
|
|
56
|
+
event_log.write(
|
|
57
|
+
"transport.pre_kill_audit",
|
|
58
|
+
json!({
|
|
59
|
+
"phase": "pre_call",
|
|
60
|
+
"caller": caller,
|
|
61
|
+
"caller_pid": std::process::id(),
|
|
62
|
+
"action": action,
|
|
63
|
+
"argv": argv,
|
|
64
|
+
"endpoint": endpoint,
|
|
65
|
+
"targets": targets,
|
|
66
|
+
"workspace": workspace.to_string_lossy(),
|
|
67
|
+
"team": team,
|
|
68
|
+
"generation": generation,
|
|
69
|
+
"owner_evidence": owner_evidence,
|
|
70
|
+
"reason": reason,
|
|
32
71
|
}),
|
|
33
72
|
)?;
|
|
34
73
|
Ok(())
|
|
@@ -57,7 +96,7 @@ fn destructive_argv(
|
|
|
57
96
|
vec![format!("{kind:?}").to_ascii_lowercase()]
|
|
58
97
|
};
|
|
59
98
|
argv.push(action.to_string());
|
|
60
|
-
if action == KILL_SESSION {
|
|
99
|
+
if action == KILL_SESSION || action == "kill-pane" {
|
|
61
100
|
for target in targets {
|
|
62
101
|
argv.push("-t".to_string());
|
|
63
102
|
argv.push(target.clone());
|
|
@@ -124,11 +124,7 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
|
|
127
|
-
self.transport
|
|
128
|
-
.query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
|
|
129
|
-
.ok()
|
|
130
|
-
.flatten()
|
|
131
|
-
.filter(|command| !command.trim().is_empty())
|
|
127
|
+
query_caller_pane_command(self.transport, pane)
|
|
132
128
|
}
|
|
133
129
|
|
|
134
130
|
fn attach(
|
|
@@ -142,7 +138,7 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
142
138
|
self.applied_grant = None;
|
|
143
139
|
self.registry_receipt = None;
|
|
144
140
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
145
|
-
let targets = match self.transport
|
|
141
|
+
let targets = match list_caller_targets(self.transport) {
|
|
146
142
|
Ok(targets) => targets,
|
|
147
143
|
Err(_) => {
|
|
148
144
|
self.last_failure_reason = Some("caller_pane_unobservable");
|
|
@@ -290,11 +286,19 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
|
|
|
290
286
|
self.last_failure_reason = Some("receiver_missing");
|
|
291
287
|
return false;
|
|
292
288
|
};
|
|
289
|
+
let leader_transport = receiver
|
|
290
|
+
.get("tmux_socket")
|
|
291
|
+
.and_then(serde_json::Value::as_str)
|
|
292
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
293
|
+
.map(crate::transport_factory::leader_endpoint_transport);
|
|
294
|
+
let live_channel_transport: &dyn Transport = leader_transport
|
|
295
|
+
.as_deref()
|
|
296
|
+
.unwrap_or(self.transport);
|
|
293
297
|
if !matches!(
|
|
294
298
|
crate::messaging::resolve_live_leader_channel(
|
|
295
299
|
workspace,
|
|
296
300
|
receiver,
|
|
297
|
-
|
|
301
|
+
live_channel_transport,
|
|
298
302
|
),
|
|
299
303
|
crate::messaging::LeaderChannelResolution::Live(_)
|
|
300
304
|
) {
|
|
@@ -875,6 +879,42 @@ fn existing_runtime_identity_view(
|
|
|
875
879
|
state.clone()
|
|
876
880
|
}
|
|
877
881
|
|
|
882
|
+
fn caller_read_transport() -> Option<Box<dyn Transport>> {
|
|
883
|
+
let endpoint = crate::tmux_backend::socket_name_from_tmux_env()?;
|
|
884
|
+
// Unit tests use an injected transport even when they seed a synthetic TMUX
|
|
885
|
+
// tuple. Never make those hermetic tests depend on a host tmux server; the
|
|
886
|
+
// production path below remains a read-only endpoint probe.
|
|
887
|
+
#[cfg(test)]
|
|
888
|
+
if !Path::new(&endpoint).exists() {
|
|
889
|
+
return None;
|
|
890
|
+
}
|
|
891
|
+
Some(Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(
|
|
892
|
+
&endpoint,
|
|
893
|
+
)))
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
fn query_caller_pane_command(transport: &dyn Transport, pane: &PaneId) -> Option<String> {
|
|
897
|
+
let caller_transport = caller_read_transport();
|
|
898
|
+
let query_transport: &dyn Transport = caller_transport
|
|
899
|
+
.as_deref()
|
|
900
|
+
.unwrap_or(transport);
|
|
901
|
+
query_transport
|
|
902
|
+
.query(&Target::Pane(pane.clone()), PaneField::PaneCurrentCommand)
|
|
903
|
+
.ok()
|
|
904
|
+
.flatten()
|
|
905
|
+
.filter(|command| !command.trim().is_empty())
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
fn list_caller_targets(
|
|
909
|
+
transport: &dyn Transport,
|
|
910
|
+
) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
|
|
911
|
+
if let Some(caller_transport) = caller_read_transport() {
|
|
912
|
+
caller_transport.list_targets()
|
|
913
|
+
} else {
|
|
914
|
+
transport.list_targets()
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
878
918
|
fn preflight_fresh_leader_identity(
|
|
879
919
|
transport: &dyn Transport,
|
|
880
920
|
) -> Option<(String, Vec<String>, Vec<String>)> {
|
|
@@ -900,13 +940,7 @@ fn preflight_fresh_leader_identity(
|
|
|
900
940
|
let Some(pane) = pane else {
|
|
901
941
|
return None;
|
|
902
942
|
};
|
|
903
|
-
let command = transport
|
|
904
|
-
.query(
|
|
905
|
-
&Target::Pane(PaneId::new(pane)),
|
|
906
|
-
PaneField::PaneCurrentCommand,
|
|
907
|
-
)
|
|
908
|
-
.ok()
|
|
909
|
-
.flatten()
|
|
943
|
+
let command = query_caller_pane_command(transport, &PaneId::new(pane))
|
|
910
944
|
.unwrap_or_default();
|
|
911
945
|
let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
912
946
|
.ok()
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//! contract:
|
|
4
4
|
//! provides:
|
|
5
5
|
//! - name: quick_start_tmux_backend
|
|
6
|
-
//! what:
|
|
6
|
+
//! what: 按 workspace 派生私有 socket;调用方 tmux 只用于 leader receiver 绑定
|
|
7
7
|
//! - name: annotate_runtime_transport
|
|
8
8
|
//! what: 把 transport 种类与来源写进 state,tmux 时再写 endpoint 三字段
|
|
9
9
|
//! - name: attach_commands_for_runtime_windows
|
|
@@ -37,15 +37,11 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
|
|
|
37
37
|
use super::*;
|
|
38
38
|
|
|
39
39
|
/// ---
|
|
40
|
-
/// purpose: 定出 quick-start
|
|
41
|
-
/// returns:
|
|
40
|
+
/// purpose: 定出 quick-start 使用的私有 tmux 后端
|
|
41
|
+
/// returns: 始终按 workspace 派生 endpoint;调用方 $TMUX 仅供 leader receiver 身份绑定
|
|
42
42
|
/// ---
|
|
43
43
|
pub(crate) fn quick_start_tmux_backend(workspace: &Path) -> crate::tmux_backend::TmuxBackend {
|
|
44
|
-
|
|
45
|
-
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&endpoint)
|
|
46
|
-
} else {
|
|
47
|
-
crate::tmux_backend::TmuxBackend::for_workspace(workspace)
|
|
48
|
-
}
|
|
44
|
+
crate::tmux_backend::TmuxBackend::for_workspace(workspace)
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
/// ---
|
|
@@ -146,6 +146,23 @@ pub fn launch_with_transport_in_workspace(
|
|
|
146
146
|
transport,
|
|
147
147
|
&started,
|
|
148
148
|
)?;
|
|
149
|
+
let owner_team = load_runtime_state(workspace)
|
|
150
|
+
.ok()
|
|
151
|
+
.map(|state| crate::state::projection::team_state_key(&state))
|
|
152
|
+
.filter(|team| !team.is_empty())
|
|
153
|
+
.unwrap_or_else(|| session_name.as_str().to_string());
|
|
154
|
+
let generation = started
|
|
155
|
+
.first()
|
|
156
|
+
.map(|agent| agent.spawned_at.as_str())
|
|
157
|
+
.unwrap_or(session_name.as_str());
|
|
158
|
+
transport
|
|
159
|
+
.set_session_owner_with_generation(
|
|
160
|
+
&session_name,
|
|
161
|
+
workspace,
|
|
162
|
+
&owner_team,
|
|
163
|
+
generation,
|
|
164
|
+
)
|
|
165
|
+
.map_err(|error| LifecycleError::Transport(error.to_string()))?;
|
|
149
166
|
// 0.5.38: per-worker timing tags (source="launch") so operators can
|
|
150
167
|
// trace which worker's spawn dominates wall time. Zeros for now on
|
|
151
168
|
// the sub-timings — Step 1 first enables shape assertion; a later
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
mod hermetic_guard;
|
|
3
3
|
|
|
4
4
|
use super::*;
|
|
5
|
+
use crate::tmux_backend::TmuxBackend;
|
|
5
6
|
use crate::transport::test_support::OfflineTransport;
|
|
6
7
|
use serde_json::json;
|
|
7
8
|
use serial_test::serial;
|
|
@@ -1428,19 +1429,21 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
|
|
|
1428
1429
|
|
|
1429
1430
|
#[test]
|
|
1430
1431
|
#[serial(env)]
|
|
1431
|
-
fn
|
|
1432
|
+
fn quick_start_tmux_backend_uses_workspace_private_endpoint() {
|
|
1432
1433
|
let leader_socket = "/tmp/team-agent-layout-leader-socket";
|
|
1433
1434
|
let _tmux = EnvVarGuard::set("TMUX", &format!("{leader_socket},123,0"));
|
|
1434
|
-
let
|
|
1435
|
+
let workspace = std::path::Path::new("/tmp/layout-workspace");
|
|
1436
|
+
let backend = quick_start_tmux_backend(workspace);
|
|
1437
|
+
let expected = TmuxBackend::for_workspace(workspace);
|
|
1435
1438
|
|
|
1436
1439
|
assert_eq!(
|
|
1437
|
-
crate::transport::Transport::tmux_endpoint(&backend)
|
|
1438
|
-
|
|
1439
|
-
"quick-start
|
|
1440
|
+
crate::transport::Transport::tmux_endpoint(&backend),
|
|
1441
|
+
crate::transport::Transport::tmux_endpoint(&expected),
|
|
1442
|
+
"quick-start workers must use the workspace-private socket, not ambient $TMUX"
|
|
1440
1443
|
);
|
|
1441
1444
|
assert_eq!(
|
|
1442
|
-
selected_tmux_socket_source(&backend,
|
|
1443
|
-
Some("
|
|
1445
|
+
selected_tmux_socket_source(&backend, workspace),
|
|
1446
|
+
Some("workspace")
|
|
1444
1447
|
);
|
|
1445
1448
|
}
|
|
1446
1449
|
|
|
@@ -5,7 +5,7 @@ use crate::cli::{
|
|
|
5
5
|
SendArgs, StatusArgs,
|
|
6
6
|
};
|
|
7
7
|
use crate::transport::test_support::OfflineTransport;
|
|
8
|
-
use crate::transport::WindowName;
|
|
8
|
+
use crate::transport::{PaneId, PaneInfo, SessionName, WindowName};
|
|
9
9
|
use serde_json::{json, Map, Value};
|
|
10
10
|
use std::collections::{BTreeMap, BTreeSet};
|
|
11
11
|
use std::path::{Path, PathBuf};
|
|
@@ -229,7 +229,21 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
|
|
|
229
229
|
let team = two_worker_team_dir(&hermetic);
|
|
230
230
|
let workspace = team.parent().expect("workspace").to_path_buf();
|
|
231
231
|
seed_healthy_coordinator(&workspace);
|
|
232
|
-
let
|
|
232
|
+
let _caller_pane = EnvVarGuard::set("TMUX_PANE", "%caller");
|
|
233
|
+
let launch_transport = codex_ready_transport().with_targets(vec![PaneInfo {
|
|
234
|
+
pane_id: PaneId::new("%caller"),
|
|
235
|
+
session: SessionName::new("caller"),
|
|
236
|
+
window_index: Some(0),
|
|
237
|
+
window_name: Some(WindowName::new("caller")),
|
|
238
|
+
pane_index: Some(0),
|
|
239
|
+
tty: None,
|
|
240
|
+
current_command: Some("codex".to_string()),
|
|
241
|
+
current_path: Some(workspace.clone()),
|
|
242
|
+
active: true,
|
|
243
|
+
pane_pid: None,
|
|
244
|
+
leader_env: BTreeMap::new(),
|
|
245
|
+
}])
|
|
246
|
+
.with_pane_current_command("%caller", "codex");
|
|
233
247
|
let quick_start = quick_start_with_transport_in_workspace_with_display(
|
|
234
248
|
&workspace,
|
|
235
249
|
&team,
|
|
@@ -275,9 +289,49 @@ fn run_phase_golden(spec: PhaseGolden) -> Value {
|
|
|
275
289
|
to_name: None,
|
|
276
290
|
to_leader: None,
|
|
277
291
|
});
|
|
292
|
+
let runtime_state = crate::state::persist::load_runtime_state(&workspace)
|
|
293
|
+
.expect("quick-start state written before shutdown fixture");
|
|
294
|
+
let generation = runtime_state
|
|
295
|
+
.get("generation")
|
|
296
|
+
.and_then(Value::as_str)
|
|
297
|
+
.or_else(|| {
|
|
298
|
+
runtime_state
|
|
299
|
+
.get("agents")
|
|
300
|
+
.and_then(Value::as_object)
|
|
301
|
+
.and_then(|agents| {
|
|
302
|
+
agents.values().find_map(|agent| {
|
|
303
|
+
agent.get("spawned_at").and_then(Value::as_str)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
.or_else(|| {
|
|
308
|
+
runtime_state
|
|
309
|
+
.pointer("/teams/teamdir/agents")
|
|
310
|
+
.and_then(Value::as_object)
|
|
311
|
+
.and_then(|agents| {
|
|
312
|
+
agents.values().find_map(|agent| {
|
|
313
|
+
agent.get("spawned_at").and_then(Value::as_str)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
.unwrap_or("phase-golden");
|
|
278
318
|
let lifecycle_transport = codex_ready_transport()
|
|
279
319
|
.with_session_present(true)
|
|
280
|
-
.with_windows(vec![WindowName::new("w1"), WindowName::new("w2")])
|
|
320
|
+
.with_windows(vec![WindowName::new("w1"), WindowName::new("w2")])
|
|
321
|
+
.with_targets(vec![PaneInfo {
|
|
322
|
+
pane_id: PaneId::new("%w1"),
|
|
323
|
+
session: SessionName::new("team-phasegolden"),
|
|
324
|
+
window_index: Some(0),
|
|
325
|
+
window_name: Some(WindowName::new("w1")),
|
|
326
|
+
pane_index: Some(0),
|
|
327
|
+
tty: None,
|
|
328
|
+
current_command: Some("codex".to_string()),
|
|
329
|
+
current_path: Some(workspace.clone()),
|
|
330
|
+
active: true,
|
|
331
|
+
pane_pid: None,
|
|
332
|
+
leader_env: BTreeMap::new(),
|
|
333
|
+
}])
|
|
334
|
+
.with_session_owner(&workspace, "teamdir", generation);
|
|
281
335
|
let lifecycle = (spec.lifecycle_op)(&workspace, &lifecycle_transport, spec.team_key);
|
|
282
336
|
let shutdown = lifecycle_port::shutdown_with_transport(
|
|
283
337
|
&workspace,
|
|
@@ -660,9 +714,12 @@ fn normalize_value(value: Value, ctx: &mut NormalizeCtx, key: Option<&str>) -> V
|
|
|
660
714
|
|
|
661
715
|
fn normalize_string(text: String, ctx: &mut NormalizeCtx, key: Option<&str>) -> Value {
|
|
662
716
|
let key = key.unwrap_or_default();
|
|
663
|
-
if key_is_timestamp(key) {
|
|
717
|
+
if key_is_timestamp(key) || key == "generation" {
|
|
664
718
|
return json!("<TS>");
|
|
665
719
|
}
|
|
720
|
+
if key == "workspace_hash" {
|
|
721
|
+
return json!("<ID>");
|
|
722
|
+
}
|
|
666
723
|
if text == crate::packaging::Version::current().as_str() {
|
|
667
724
|
return json!("<VERSION>");
|
|
668
725
|
}
|
|
@@ -696,9 +753,39 @@ fn normalize_path_string(text: &str, ctx: &NormalizeCtx) -> String {
|
|
|
696
753
|
out = out.replace(alias, "<TMP>");
|
|
697
754
|
}
|
|
698
755
|
out = normalize_tmux_socket_dir(&out);
|
|
756
|
+
out = normalize_hermetic_root(&out);
|
|
757
|
+
out = normalize_registry_hash(&out);
|
|
699
758
|
normalize_socket_token(&out)
|
|
700
759
|
}
|
|
701
760
|
|
|
761
|
+
fn normalize_registry_hash(text: &str) -> String {
|
|
762
|
+
let Some(start) = text.rfind("/leaders/") else {
|
|
763
|
+
return text.to_string();
|
|
764
|
+
};
|
|
765
|
+
let file_start = start + "/leaders/".len();
|
|
766
|
+
let Some(separator) = text[file_start..].find("__") else {
|
|
767
|
+
return text.to_string();
|
|
768
|
+
};
|
|
769
|
+
let separator = file_start + separator;
|
|
770
|
+
text[..file_start].to_owned() + "<ID>" + &text[separator..]
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
fn normalize_hermetic_root(text: &str) -> String {
|
|
774
|
+
let marker = "ta-phase-golden-";
|
|
775
|
+
let Some(start) = text.find(marker) else {
|
|
776
|
+
return text.to_string();
|
|
777
|
+
};
|
|
778
|
+
let end = text[start..]
|
|
779
|
+
.find('/')
|
|
780
|
+
.map(|offset| start + offset)
|
|
781
|
+
.unwrap_or(text.len());
|
|
782
|
+
let segment = &text[start..end];
|
|
783
|
+
let Some(phase) = text[start + marker.len()..end].split('-').next() else {
|
|
784
|
+
return text.to_string();
|
|
785
|
+
};
|
|
786
|
+
text.replacen(segment, &format!("{marker}{phase}"), 1)
|
|
787
|
+
}
|
|
788
|
+
|
|
702
789
|
fn value_looks_like_endpoint_path(text: &str) -> bool {
|
|
703
790
|
text.starts_with('/')
|
|
704
791
|
|| text.starts_with("ta-")
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
//! `GetExitCodeProcess`, `TerminateProcess`, Toolhelp snapshot,
|
|
13
13
|
//! Job Objects for shim-owned worker teardown)
|
|
14
14
|
//!
|
|
15
|
-
//! CR C-6: `terminate_pid`
|
|
16
|
-
//!
|
|
15
|
+
//! CR C-6: `terminate_pid` returns `TerminationOutcome` so a caller
|
|
16
|
+
//! that requested `TerminateGraceful`
|
|
17
17
|
//! can see that Windows downgraded to `TerminateForce` and emit a
|
|
18
18
|
//! `platform.terminate_force_only` event (N38 交底).
|
|
19
19
|
//!
|
|
@@ -41,7 +41,7 @@ pub enum SignalKind {
|
|
|
41
41
|
TerminateForce,
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/// Result of a `terminate_pid`
|
|
44
|
+
/// Result of a `terminate_pid` call. `Graceful`
|
|
45
45
|
/// means the OS honored the request kind; `ForceOnly` means Windows
|
|
46
46
|
/// downgraded a `TerminateGraceful` to `TerminateProcess` (CR C-6
|
|
47
47
|
/// N38 交底 — caller must emit a `platform.terminate_force_only`
|
|
@@ -55,7 +55,7 @@ pub enum TerminationOutcome {
|
|
|
55
55
|
/// (no grace period). Includes a machine-readable reason so
|
|
56
56
|
/// callers can log it in the audit event.
|
|
57
57
|
ForceOnly { reason: &'static str },
|
|
58
|
-
/// The pid
|
|
58
|
+
/// The pid was already gone by the time the call resolved.
|
|
59
59
|
/// Not an error.
|
|
60
60
|
AlreadyGone,
|
|
61
61
|
}
|
|
@@ -216,34 +216,6 @@ mod unix_impl {
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
/// Send a signal to a process group (`kill(-pgid, ...)`).
|
|
220
|
-
/// Byte-equivalent to `cli/mod.rs:1854` (`libc::kill(-pgid, signal)`)
|
|
221
|
-
/// used by `send_process_signal_group`.
|
|
222
|
-
pub fn terminate_group(
|
|
223
|
-
group_id: u32,
|
|
224
|
-
kind: SignalKind,
|
|
225
|
-
) -> Result<TerminationOutcome, io::Error> {
|
|
226
|
-
let signal = match kind {
|
|
227
|
-
SignalKind::TerminateGraceful => libc::SIGTERM,
|
|
228
|
-
SignalKind::TerminateForce => libc::SIGKILL,
|
|
229
|
-
};
|
|
230
|
-
let pgid_t = match libc::pid_t::try_from(group_id) {
|
|
231
|
-
Ok(p) => p,
|
|
232
|
-
Err(_) => return Ok(TerminationOutcome::AlreadyGone),
|
|
233
|
-
};
|
|
234
|
-
// `-pgid` targets every process in that group. Preserves the
|
|
235
|
-
// existing shutdown semantics inline in cli/mod.rs.
|
|
236
|
-
let rc = unsafe { libc::kill(-pgid_t, signal) };
|
|
237
|
-
if rc == 0 {
|
|
238
|
-
return Ok(TerminationOutcome::Requested);
|
|
239
|
-
}
|
|
240
|
-
let err = io::Error::last_os_error();
|
|
241
|
-
match err.raw_os_error() {
|
|
242
|
-
Some(code) if code == libc::ESRCH => Ok(TerminationOutcome::AlreadyGone),
|
|
243
|
-
_ => Err(err),
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
219
|
/// Best-effort waitpid so a killed child doesn't accumulate as a
|
|
248
220
|
/// zombie. Byte-equivalent to `coordinator/health.rs:350-358` and
|
|
249
221
|
/// `cli/mod.rs:1871-1879`.
|
|
@@ -434,19 +406,6 @@ mod windows_impl {
|
|
|
434
406
|
})
|
|
435
407
|
}
|
|
436
408
|
|
|
437
|
-
pub fn terminate_group(
|
|
438
|
-
_group_id: u32,
|
|
439
|
-
_kind: SignalKind,
|
|
440
|
-
) -> Result<TerminationOutcome, io::Error> {
|
|
441
|
-
// Windows has no `-pgid` sentinel. Design §Route B: "owned
|
|
442
|
-
// worker teardown: prefer Job Objects in the ConPTY shim".
|
|
443
|
-
// Job-object teardown is a shim-side concern, not a top-level
|
|
444
|
-
// API. Return AlreadyGone honestly here so shutdown code
|
|
445
|
-
// falls back to per-pid termination (the caller's outer
|
|
446
|
-
// loop retries per-pid after the group attempt is a no-op).
|
|
447
|
-
Ok(TerminationOutcome::AlreadyGone)
|
|
448
|
-
}
|
|
449
|
-
|
|
450
409
|
pub fn reap_child_if_possible(_pid: u32) {
|
|
451
410
|
// Windows has no zombie waitpid model. Child handles are
|
|
452
411
|
// owned by the spawner and closed at drop; nothing to do.
|