@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
package/Cargo.toml
CHANGED
|
@@ -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::
|
|
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
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|