@team-agent/installer 0.5.20 → 0.5.22

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 CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.20"
578
+ version = "0.5.22"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.20"
12
+ version = "0.5.22"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -142,11 +142,25 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
142
142
  {
143
143
  return Ok(cmd_send_result(amb, args.json));
144
144
  }
145
+ if let Some(value) = dirty_topology_refusal_value(&selected, args.team.as_deref()) {
146
+ return Ok(cmd_send_result(value, args.json));
147
+ }
148
+ let coordinator_ensure = if target_has_known_worker(&selected.state, &target, &opts.sender) {
149
+ loud_ensure_coordinator(&selected)?
150
+ } else {
151
+ None
152
+ };
153
+ if let Some(value) =
154
+ coordinator_ensure_unavailable_value(coordinator_ensure.as_ref(), &target, &content, &opts)
155
+ {
156
+ return Ok(cmd_send_result(value, args.json));
157
+ }
145
158
  let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
146
159
  if opts.watch_result {
147
160
  outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
148
161
  }
149
162
  let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
163
+ append_loud_ensure_fields(&mut value, coordinator_ensure.as_ref());
150
164
  if opts.watch_result && initial_delivery_allows_watch(outcome.status) {
151
165
  if let Some(obj) = value.as_object_mut() {
152
166
  obj.insert("watch".to_string(), watch_notice_json(&target, &opts));
@@ -156,6 +170,194 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
156
170
  Ok(cmd_send_result(value, args.json))
157
171
  }
158
172
 
173
+ fn dirty_topology_refusal_value(
174
+ selected: &crate::state::selector::SelectedTeam,
175
+ requested_team: Option<&str>,
176
+ ) -> Option<Value> {
177
+ let issue_ids = crate::topology::restart_dirty_topology_issue_ids(&selected.state);
178
+ if issue_ids.is_empty() {
179
+ return None;
180
+ }
181
+ let session_name = selected
182
+ .state
183
+ .get("session_name")
184
+ .and_then(Value::as_str)
185
+ .unwrap_or_default()
186
+ .to_string();
187
+ let reason = issue_ids
188
+ .first()
189
+ .cloned()
190
+ .unwrap_or_else(|| "dirty_topology".to_string());
191
+ let repair_team = requested_team
192
+ .filter(|team| !team.is_empty())
193
+ .unwrap_or(selected.team_key.as_str());
194
+ Some(json!({
195
+ "ok": false,
196
+ "status": "refused_dirty_topology",
197
+ "reason": reason,
198
+ "session_name": session_name,
199
+ "error": "send refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before sending",
200
+ "issues": issue_ids
201
+ .iter()
202
+ .map(|id| json!({"id": id}))
203
+ .collect::<Vec<_>>(),
204
+ "next_actions": [
205
+ "team-agent diagnose --json",
206
+ format!("team-agent claim-leader --team {repair_team} --confirm --json"),
207
+ format!("team-agent takeover --team {repair_team} --confirm --json")
208
+ ],
209
+ }))
210
+ }
211
+
212
+ fn target_has_known_worker(state: &Value, target: &MessageTarget, sender: &str) -> bool {
213
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
214
+ return false;
215
+ };
216
+ match target {
217
+ MessageTarget::Single(target) => agents.contains_key(target),
218
+ MessageTarget::Broadcast => agents.keys().any(|agent| agent != sender),
219
+ MessageTarget::Fanout(recipients) => recipients
220
+ .iter()
221
+ .any(|recipient| agents.contains_key(recipient)),
222
+ }
223
+ }
224
+
225
+ #[derive(Debug, Clone)]
226
+ struct LoudEnsureResult {
227
+ previous_status: String,
228
+ start: crate::coordinator::StartReport,
229
+ }
230
+
231
+ fn loud_ensure_coordinator(
232
+ selected: &crate::state::selector::SelectedTeam,
233
+ ) -> Result<Option<LoudEnsureResult>, CliError> {
234
+ if in_process_unit_test() {
235
+ return Ok(None);
236
+ }
237
+ let workspace = crate::coordinator::WorkspacePath::new(selected.run_workspace.clone());
238
+ let previous = crate::coordinator::coordinator_health(&workspace);
239
+ if previous.ok {
240
+ return Ok(None);
241
+ }
242
+ let previous_status = coordinator_health_status_wire(previous.status).to_string();
243
+ let start = crate::coordinator::start_coordinator_with_team(
244
+ &workspace,
245
+ Some(selected.team_key.as_str()),
246
+ )
247
+ .map_err(|error| CliError::Runtime(error.to_string()))?;
248
+ if !start.ok {
249
+ return Ok(Some(LoudEnsureResult {
250
+ previous_status,
251
+ start,
252
+ }));
253
+ }
254
+ if matches!(
255
+ start.status,
256
+ crate::coordinator::StartOutcome::Started
257
+ | crate::coordinator::StartOutcome::StartedAfterRotation
258
+ ) {
259
+ crate::event_log::EventLog::new(&selected.run_workspace)
260
+ .write(
261
+ "coordinator.ensure_restarted",
262
+ json!({
263
+ "coordinator_previous_status": previous_status,
264
+ "status": start.status,
265
+ "pid": start.pid.map(|pid| pid.get()),
266
+ "previous_pid": start.previous_pid.map(|pid| pid.get()),
267
+ "binary_path": start.binary_path,
268
+ "binary_version": start.binary_version,
269
+ "rotation_reason": start.rotation_reason,
270
+ }),
271
+ )
272
+ .map_err(|error| CliError::Runtime(error.to_string()))?;
273
+ return Ok(Some(LoudEnsureResult {
274
+ previous_status,
275
+ start,
276
+ }));
277
+ }
278
+ Ok(None)
279
+ }
280
+
281
+ #[cfg(test)]
282
+ fn in_process_unit_test() -> bool {
283
+ true
284
+ }
285
+
286
+ #[cfg(not(test))]
287
+ fn in_process_unit_test() -> bool {
288
+ false
289
+ }
290
+
291
+ fn coordinator_ensure_unavailable_value(
292
+ ensure: Option<&LoudEnsureResult>,
293
+ target: &MessageTarget,
294
+ content: &str,
295
+ opts: &SendOptions,
296
+ ) -> Option<Value> {
297
+ let ensure = ensure?;
298
+ if ensure.start.ok {
299
+ return None;
300
+ }
301
+ let warning = format!(
302
+ "coordinator is not running; message was not queued for {}. Run `team-agent diagnose` or restart the team before sending again.",
303
+ first_target(target)
304
+ );
305
+ let mut value = json!({
306
+ "ok": false,
307
+ "status": "degraded",
308
+ "delivery_status": "degraded",
309
+ "delivered": false,
310
+ "target": target_json(target),
311
+ "agent_id": first_target(target),
312
+ "content_length_bytes": content.len(),
313
+ "sender": opts.sender,
314
+ "message_id": Value::Null,
315
+ "message_status": "degraded",
316
+ "verification": warning,
317
+ "stage": Value::Null,
318
+ "reason": "coordinator_unavailable",
319
+ "channel": "coordinator_unavailable",
320
+ });
321
+ append_loud_ensure_fields(&mut value, Some(ensure));
322
+ Some(value)
323
+ }
324
+
325
+ fn append_loud_ensure_fields(value: &mut Value, ensure: Option<&LoudEnsureResult>) {
326
+ let Some(ensure) = ensure else {
327
+ return;
328
+ };
329
+ if !ensure.start.ok {
330
+ return;
331
+ }
332
+ if let Some(obj) = value.as_object_mut() {
333
+ obj.insert("coordinator_auto_restarted".to_string(), json!(true));
334
+ obj.insert(
335
+ "coordinator_previous_status".to_string(),
336
+ json!(ensure.previous_status),
337
+ );
338
+ obj.insert(
339
+ "coordinator".to_string(),
340
+ coordinator_start_json(&ensure.start),
341
+ );
342
+ }
343
+ }
344
+
345
+ fn coordinator_start_json(report: &crate::coordinator::StartReport) -> Value {
346
+ let summary = crate::lifecycle::CoordinatorStartSummary::from_start_report(report);
347
+ crate::lifecycle::coordinator_start_summary_value(&summary)
348
+ }
349
+
350
+ fn coordinator_health_status_wire(
351
+ status: crate::coordinator::CoordinatorHealthStatus,
352
+ ) -> &'static str {
353
+ match status {
354
+ crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
355
+ crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
356
+ crate::coordinator::CoordinatorHealthStatus::Running => "running",
357
+ crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
358
+ }
359
+ }
360
+
159
361
  /// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
160
362
  /// membership check. Constructs `Target::Pane`, renders the message with
161
363
  /// the standard protocol block (Team Agent message from sender + token),
@@ -552,7 +552,19 @@ fn sleep_seconds(seconds: f64) {
552
552
  if seconds <= 0.0 {
553
553
  return;
554
554
  }
555
- std::thread::sleep(std::time::Duration::from_secs_f64(seconds));
555
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs_f64(seconds);
556
+ loop {
557
+ #[cfg(unix)]
558
+ if SIGNAL_STOP_REQUESTED.load(std::sync::atomic::Ordering::SeqCst) {
559
+ return;
560
+ }
561
+ let now = std::time::Instant::now();
562
+ if now >= deadline {
563
+ return;
564
+ }
565
+ let remaining = deadline.saturating_duration_since(now);
566
+ std::thread::sleep(remaining.min(std::time::Duration::from_millis(100)));
567
+ }
556
568
  }
557
569
 
558
570
  /// 子进程退出错误(daemon bin 顶层用 anyhow,但 lib 入口仍给 typed)。
@@ -661,4 +673,22 @@ mod tests {
661
673
  "panic marker must carry a backtrace; event={panic_event}"
662
674
  );
663
675
  }
676
+
677
+ #[cfg(unix)]
678
+ #[test]
679
+ fn signal_stop_request_interrupts_daemon_sleep_without_consuming_signal() {
680
+ SIGNAL_STOP_NUMBER.store(libc::SIGTERM, std::sync::atomic::Ordering::SeqCst);
681
+ SIGNAL_STOP_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst);
682
+
683
+ let started = std::time::Instant::now();
684
+ sleep_seconds(1.0);
685
+ let elapsed = started.elapsed();
686
+ let signal = take_signal_stop_request();
687
+
688
+ assert_eq!(signal, Some("SIGTERM"));
689
+ assert!(
690
+ elapsed < std::time::Duration::from_millis(500),
691
+ "daemon sleep must wake promptly after SIGTERM stop flag; elapsed={elapsed:?}"
692
+ );
693
+ }
664
694
  }
@@ -632,12 +632,44 @@ fn coordinator_metadata_mismatch_reason_with_identity(
632
632
  if binary_version != identity.binary_version {
633
633
  return Some(CoordinatorMetadataMismatchReason::BinaryVersionMismatch);
634
634
  }
635
- if binary_path != identity.binary_path {
635
+ if !binary_path_matches_current_identity(binary_path, &identity.binary_path) {
636
636
  return Some(CoordinatorMetadataMismatchReason::BinaryPathMismatch);
637
637
  }
638
638
  None
639
639
  }
640
640
 
641
+ fn binary_path_matches_current_identity(metadata_path: &str, identity_path: &str) -> bool {
642
+ if metadata_path == identity_path {
643
+ return true;
644
+ }
645
+ test_harness_binary_path_matches(metadata_path)
646
+ }
647
+
648
+ /// Test harness escape hatch for integration tests whose process identity is the
649
+ /// test binary while fixture metadata intentionally points at the built CLI.
650
+ /// Production must not infer this from path shape; callers must set the
651
+ /// `TEAM_AGENT_TEST_HARNESS_BINARY_PATH_MATCH` env explicitly to either the
652
+ /// expected binary path or the target directory containing `team-agent`.
653
+ fn test_harness_binary_path_matches(metadata_path: &str) -> bool {
654
+ let Ok(path) = std::env::var("TEAM_AGENT_TEST_HARNESS_BINARY_PATH_MATCH") else {
655
+ return false;
656
+ };
657
+ let path = PathBuf::from(path);
658
+ path_matches(metadata_path, &path)
659
+ || path_matches(metadata_path, &path.join("team-agent"))
660
+ || path
661
+ .parent()
662
+ .is_some_and(|parent| path_matches(metadata_path, &parent.join("team-agent")))
663
+ }
664
+
665
+ fn path_matches(metadata_path: &str, path: &Path) -> bool {
666
+ path.to_string_lossy() == metadata_path
667
+ || path
668
+ .canonicalize()
669
+ .ok()
670
+ .is_some_and(|path| path.to_string_lossy() == metadata_path)
671
+ }
672
+
641
673
  /// `write_coordinator_metadata`(`metadata.py:46-61`)。写 `coordinator.json`(pretty indent=2),
642
674
  /// `updated_at = now(utc).isoformat()`。
643
675
  pub fn write_coordinator_metadata(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.20",
3
+ "version": "0.5.22",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.20",
24
- "@team-agent/cli-darwin-x64": "0.5.20",
25
- "@team-agent/cli-linux-x64": "0.5.20"
23
+ "@team-agent/cli-darwin-arm64": "0.5.22",
24
+ "@team-agent/cli-darwin-x64": "0.5.22",
25
+ "@team-agent/cli-linux-x64": "0.5.22"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",