@team-agent/installer 0.5.78 → 0.5.81

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.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/models.rs +31 -0
  4. package/crates/team-agent/src/cli/status_port/brief.rs +40 -16
  5. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  6. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  7. package/crates/team-agent/src/cli/tests/status_brief.rs +199 -0
  8. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  9. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  10. package/crates/team-agent/src/coordinator/tests/s3_bounded.rs +239 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +21 -13
  12. package/crates/team-agent/src/leader/start.rs +46 -74
  13. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +21 -5
  14. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  15. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  16. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +2 -11
  17. package/crates/team-agent/src/mcp_server/tools.rs +1 -3
  18. package/crates/team-agent/src/messaging/delivery.rs +154 -72
  19. package/crates/team-agent/src/messaging/results.rs +1 -2
  20. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  21. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  22. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  23. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  24. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  25. package/crates/team-agent/src/state/mod.rs +1 -0
  26. package/crates/team-agent/src/state/persist.rs +47 -5
  27. package/crates/team-agent/src/state/projection.rs +31 -1
  28. package/crates/team-agent/src/state/repository/tests.rs +81 -0
  29. package/crates/team-agent/src/state/repository.rs +169 -1
  30. package/crates/team-agent/src/tmux_backend/tests.rs +28 -0
  31. package/crates/team-agent/src/tmux_backend.rs +31 -9
  32. package/crates/team-agent/src/transport.rs +6 -0
  33. package/package.json +4 -4
@@ -1,5 +1,7 @@
1
1
  //!
2
- //! StateRepository facade.
2
+ //! StateRepository facade. Task/note commits apply bounded mutations under the
3
+ //! existing state-save lock; coordinator commits carry only observation deltas.
4
+ //! Other writer clusters retain their legacy dispatch below.
3
5
  //!
4
6
  //! Ref:
5
7
  //! - `.team/artifacts/s1a-state-repository-design.md` (skeleton)
@@ -142,8 +144,152 @@ impl<'a> StateRepository<'a> {
142
144
  where
143
145
  F: FnOnce(&mut Value),
144
146
  {
147
+ #[cfg(test)]
148
+ test_support::before_commit();
145
149
  route_reapply(self.workspace, intent, state, reapply)
146
150
  }
151
+
152
+ /// Commit a task/note delta once against latest under state-save. Return
153
+ /// the committed Team view for callers that produce Markdown afterwards.
154
+ pub fn commit<F>(&self, intent: StateWriteIntent<'_>, update: F) -> Result<Value, StateError>
155
+ where
156
+ F: FnOnce(&mut Value),
157
+ {
158
+ #[cfg(test)]
159
+ test_support::before_commit();
160
+ let team_key = match intent {
161
+ // Assignment's existing reconcile helper updates the root task
162
+ // array and the explicit Team array independently (legacy parity).
163
+ StateWriteIntent::McpAssignTask { .. } => None,
164
+ StateWriteIntent::McpUpdateStateNote { team_key } => team_key,
165
+ StateWriteIntent::ResultCollection { owner_team_id } => owner_team_id,
166
+ _ => return Err(StateError::SaveFailed("intent does not accept a task/note delta".into())),
167
+ };
168
+ let committed = super::persist::update_runtime_state(self.workspace, |latest| {
169
+ let mut selected = bounded_team_view(latest, team_key)?;
170
+ update(&mut selected);
171
+ Ok(match team_key {
172
+ Some(key) => super::projection::merge_committed_team(latest, &selected, key),
173
+ None => selected,
174
+ })
175
+ })?;
176
+ bounded_team_view(&committed, team_key)
177
+ }
178
+
179
+ /// Fence the sampled incarnation with the existing persist guards, then
180
+ /// commit only this tick's agent observations and coordinator bookkeeping.
181
+ pub fn commit_observations(
182
+ &self,
183
+ intent: StateWriteIntent<'_>,
184
+ before: &Value,
185
+ observed: &Value,
186
+ ) -> Result<(), StateError> {
187
+ let StateWriteIntent::CoordinatorTick { team_key } = intent else {
188
+ return Err(StateError::SaveFailed("intent does not accept observations".into()));
189
+ };
190
+ let sampled_legacy =
191
+ legacy_single_team_state(before) && legacy_single_team_state(observed);
192
+ let sampled_identity = legacy_team_identity(before);
193
+ super::persist::update_runtime_state(self.workspace, |latest| {
194
+ if sampled_legacy {
195
+ if sampled_identity != legacy_team_identity(observed)
196
+ || !legacy_single_team_state(latest)
197
+ || sampled_identity != legacy_team_identity(latest)
198
+ {
199
+ return Err(StateError::SaveConflict(
200
+ "legacy coordinator observation scope changed before commit".to_string(),
201
+ ));
202
+ }
203
+ let mut checked = observed.clone();
204
+ super::persist::merge_ordinary_state(&mut checked, latest)?;
205
+ if !legacy_single_team_state(&checked)
206
+ || sampled_identity != legacy_team_identity(&checked)
207
+ {
208
+ return Err(StateError::SaveConflict(
209
+ "legacy coordinator observation scope changed during validation"
210
+ .to_string(),
211
+ ));
212
+ }
213
+ let mut selected = latest.clone();
214
+ for field in ["agents", "coordinator"] {
215
+ if before[field] != checked[field] {
216
+ apply_observation_delta(
217
+ &before[field],
218
+ &checked[field],
219
+ &mut selected[field],
220
+ );
221
+ }
222
+ }
223
+ return Ok(selected);
224
+ }
225
+ let mut checked = super::projection::merge_committed_team(latest, observed, team_key);
226
+ super::persist::merge_ordinary_state(&mut checked, latest)?;
227
+ let checked = bounded_team_view(&checked, Some(team_key))?;
228
+ let mut selected = bounded_team_view(latest, Some(team_key))?;
229
+ for field in ["agents", "coordinator"] {
230
+ if before[field] != checked[field] {
231
+ apply_observation_delta(&before[field], &checked[field], &mut selected[field]);
232
+ }
233
+ }
234
+ Ok(super::projection::merge_committed_team(latest, &selected, team_key))
235
+ })?;
236
+ Ok(())
237
+ }
238
+ }
239
+
240
+ fn legacy_single_team_state(state: &Value) -> bool {
241
+ match state.get("teams") {
242
+ None => true,
243
+ Some(Value::Object(teams)) => teams.is_empty(),
244
+ Some(_) => false,
245
+ }
246
+ }
247
+
248
+ fn legacy_team_identity(state: &Value) -> [Option<String>; 5] {
249
+ [
250
+ "active_team_key",
251
+ "team_key",
252
+ "session_name",
253
+ "team_dir",
254
+ "spec_path",
255
+ ]
256
+ .map(|field| {
257
+ state
258
+ .get(field)
259
+ .and_then(Value::as_str)
260
+ .filter(|value| !value.is_empty())
261
+ .map(str::to_string)
262
+ })
263
+ }
264
+
265
+ fn bounded_team_view(state: &Value, team_key: Option<&str>) -> Result<Value, StateError> {
266
+ let Some(key) = team_key else { return Ok(state.clone()); };
267
+ if state.get("teams").and_then(Value::as_object).is_some_and(|teams| teams.contains_key(key)) {
268
+ return Ok(super::projection::project_top_level_view(state, key));
269
+ }
270
+ if state.get("teams").and_then(Value::as_object).is_some_and(|teams| !teams.is_empty()) {
271
+ return Err(StateError::TeamSelect(format!("team no longer exists: {key}")));
272
+ }
273
+ Ok(state.clone())
274
+ }
275
+
276
+ // Diffing is limited to the two observation blocks above, never a whole-state
277
+ // merge. Equal fields retain a concurrent writer's value; removed fields clear.
278
+ fn apply_observation_delta(before: &Value, observed: &Value, latest: &mut Value) {
279
+ if before == observed { return; }
280
+ if let Some(observed) = observed.as_object() {
281
+ if !latest.is_object() { *latest = serde_json::json!({}); }
282
+ if let Some(before) = before.as_object() {
283
+ for key in before.keys().filter(|key| !observed.contains_key(*key)) {
284
+ if let Some(latest) = latest.as_object_mut() { latest.remove(key); }
285
+ }
286
+ }
287
+ for (key, value) in observed {
288
+ apply_observation_delta(&before[key], value, &mut latest[key]);
289
+ }
290
+ } else {
291
+ *latest = observed.clone();
292
+ }
147
293
  }
148
294
 
149
295
  /// Closed semantic catalog of workspace state writes. Every allowlisted
@@ -555,6 +701,11 @@ fn route_reapply<F>(
555
701
  where
556
702
  F: FnOnce(&mut Value),
557
703
  {
704
+ if matches!(&intent, StateWriteIntent::McpAssignTask { .. }
705
+ | StateWriteIntent::McpUpdateStateNote { .. } | StateWriteIntent::ResultCollection { .. })
706
+ {
707
+ return StateRepository::new(workspace).commit(intent, reapply).map(|_| ());
708
+ }
558
709
  if reapply_scope(&intent) == ReapplyScope::Team {
559
710
  helper_write_team_scoped_reapply(workspace, state, reapply)
560
711
  } else {
@@ -564,3 +715,20 @@ where
564
715
 
565
716
  #[cfg(test)]
566
717
  mod tests;
718
+
719
+ #[cfg(test)]
720
+ pub(crate) mod test_support {
721
+ thread_local! {
722
+ static BEFORE_COMMIT: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const {
723
+ std::cell::RefCell::new(None)
724
+ };
725
+ }
726
+
727
+ pub(crate) fn set_before_commit(hook: impl FnOnce() + 'static) {
728
+ BEFORE_COMMIT.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
729
+ }
730
+
731
+ pub(super) fn before_commit() {
732
+ if let Some(hook) = BEFORE_COMMIT.with(|slot| slot.borrow_mut().take()) { hook(); }
733
+ }
734
+ }
@@ -355,6 +355,34 @@ fn backend_with(default: MockResp, queued: Vec<MockResp>) -> (TmuxBackend, Recor
355
355
  (TmuxBackend::with_runner(Box::new(runner)), recorded)
356
356
  }
357
357
 
358
+ #[test]
359
+ fn provider_exit_receipt_accepts_only_shell_exit_status() {
360
+ for (text, expected) in [
361
+ ("", None),
362
+ ("running", None),
363
+ ("[team-agent] pi exited with 0", None),
364
+ ("256", None),
365
+ ("0\n", Some(0)),
366
+ ("17\n", Some(17)),
367
+ ("143\n", Some(143)),
368
+ ] {
369
+ let (backend, recorded) = backend_with(MockResp::Out(ok(text)), Vec::new());
370
+ assert_eq!(
371
+ backend.provider_exit_status(&PaneId::new("%47")).unwrap(),
372
+ expected
373
+ );
374
+ let calls = recorded.lock().unwrap();
375
+ assert_eq!(calls.len(), 1);
376
+ assert_eq!(calls[0][4], "%47");
377
+ assert_eq!(calls[0][5], "#{@team-agent-provider-exit-status}");
378
+ }
379
+ let (backend, _) = backend_with(MockResp::Out(fail(1, "pane unavailable")), Vec::new());
380
+ assert_eq!(
381
+ backend.provider_exit_status(&PaneId::new("%47")).unwrap(),
382
+ None
383
+ );
384
+ }
385
+
358
386
  fn backend_with_stdin(
359
387
  default: MockResp,
360
388
  queued: Vec<MockResp>,
@@ -2656,24 +2656,22 @@ fn append_caller_context_assignments(parts: &mut Vec<String>, provider: Option<&
2656
2656
  ));
2657
2657
  }
2658
2658
 
2659
- /// 0.4.x (CR R6): single-source marker prefix. The exit marker emitted by
2660
- /// `leader_shell_wrapper_command` and the substring detected by
2661
- /// `leader_provider_health` MUST share this prefix exactly. Format:
2659
+ /// Public exit-marker text emitted by `leader_shell_wrapper_command`. Format:
2662
2660
  /// `"[team-agent] {provider_label} exited with {rc}"`.
2663
2661
  pub const LEADER_PROVIDER_EXIT_MARKER_PREFIX: &str = "[team-agent]";
2664
2662
  pub const LEADER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
2663
+ const PROVIDER_EXIT_STATUS_OPTION: &str = "@team-agent-provider-exit-status";
2665
2664
 
2666
2665
  /// 0.4.x (CR R6): build the leader exit marker text for `provider_label`.
2667
- /// Used by both the shell wrapper (printf source) and the health check
2668
- /// (capture substring) so they cannot drift.
2666
+ /// Used by the shell wrapper for human-readable output.
2669
2667
  /// ---
2670
2668
  /// purpose: 单一来源地拼出 leader pane 的 provider 退出标记文本
2671
2669
  /// params:
2672
2670
  /// provider_label: 人类可读的 provider 名,原样嵌入标记
2673
2671
  /// returns: 前缀 + provider 名 + 后缀 拼成的标记(不含退出码,退出码由 shell 的 printf 补)
2674
2672
  /// boundary:
2675
- /// - 写标记的 shell wrapper 与读标记的健康检查必须都用这一个函数,禁止各写一份字面量
2676
- /// - 这是内容信号:pane 里出现同样文本(如 cat 日志)就会误触发,判定强度到此为止
2673
+ /// - shell wrapper 统一使用此显示标记,进程状态另由监督 shell 收据证明
2674
+ /// - 仅供显示;历史正文可包含相同文本,不得据此判定进程退出
2677
2675
  /// ---
2678
2676
  pub fn leader_provider_exit_marker(provider_label: &str) -> String {
2679
2677
  format!(
@@ -2905,6 +2903,11 @@ pub fn leader_shell_wrapper_command(
2905
2903
  parts.push(key.clone());
2906
2904
  parts.push("&&".to_string());
2907
2905
  }
2906
+ // Clear any prior receipt before invoking the provider. Terminal content,
2907
+ // including replayed sessions, must never be treated as an exit receipt.
2908
+ parts.push(format!(
2909
+ "{{ tmux -S \"${{TMUX%%,*}}\" set-option -p -t \"$TMUX_PANE\" {PROVIDER_EXIT_STATUS_OPTION} running >/dev/null 2>&1 || :; }} &&"
2910
+ ));
2908
2911
  // 3. env exports + provider (NO `exec` so the provider is a child).
2909
2912
  // 0.4.x ordering fix: skip keys present in env_unset so KEY=val does not
2910
2913
  // re-introduce a just-unset variable from the inherited env map.
@@ -2918,6 +2921,9 @@ pub fn leader_shell_wrapper_command(
2918
2921
  parts.push(";".to_string());
2919
2922
  // 4. exit marker + inert shell tail
2920
2923
  parts.push("rc=$?;".to_string());
2924
+ parts.push(format!(
2925
+ "tmux -S \"${{TMUX%%,*}}\" set-option -p -t \"$TMUX_PANE\" {PROVIDER_EXIT_STATUS_OPTION} \"$rc\" >/dev/null 2>&1;"
2926
+ ));
2921
2927
  parts.push("printf".to_string());
2922
2928
  // CR R6: marker text comes from single-source `leader_provider_exit_marker`.
2923
2929
  parts.push(shell_quote(&format!(
@@ -2930,8 +2936,8 @@ pub fn leader_shell_wrapper_command(
2930
2936
  }
2931
2937
 
2932
2938
  fn inert_pane_tail_command() -> String {
2933
- // `sh` is deliberate: provider-exit health checks use the shell basename
2934
- // to reach the exit-marker branch. `sh -c` does not read commands from
2939
+ // `sh` is deliberate: exited-wrapper input classifiers recognize shell
2940
+ // basenames. `sh -c` does not read commands from
2935
2941
  // stdin; disabling echo also prevents input from appearing accepted.
2936
2942
  let script = r#"trap '' INT QUIT; stty -echo 2>/dev/null; printf "%s\n" "[team-agent] Provider exited; this pane no longer accepts input. Restart from another pane with the appropriate team-agent start command."; while :; do sleep 3600 & wait "$!"; done"#;
2937
2943
  format!("exec /bin/sh -c {}", shell_quote(script))
@@ -3743,6 +3749,22 @@ impl Transport for TmuxBackend {
3743
3749
  Ok(Some(output.stdout.trim().to_string()))
3744
3750
  }
3745
3751
 
3752
+ fn provider_exit_status(&self, pane: &PaneId) -> Result<Option<u8>, TransportError> {
3753
+ let argv = self.tmux_argv(&[
3754
+ "tmux".to_string(),
3755
+ "display-message".to_string(),
3756
+ "-p".to_string(),
3757
+ "-t".to_string(),
3758
+ pane.as_str().to_string(),
3759
+ format!("#{{{PROVIDER_EXIT_STATUS_OPTION}}}"),
3760
+ ]);
3761
+ let output = self.runner.run(&argv)?;
3762
+ if !output.success {
3763
+ return Ok(None);
3764
+ }
3765
+ Ok(output.stdout.trim().parse::<u8>().ok())
3766
+ }
3767
+
3746
3768
  fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
3747
3769
  let argv = self.tmux_argv(&[
3748
3770
  "tmux".to_string(),
@@ -981,6 +981,12 @@ pub trait Transport: Send + Sync {
981
981
  /// pane 存活三态(`PaneLiveness`,bug-085 穷尽 match;unknown ≠ dead ≠ live)。
982
982
  fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError>;
983
983
 
984
+ /// Exit status recorded by the provider's supervising wrapper, never inferred
985
+ /// from terminal text. None means running or no authoritative receipt.
986
+ fn provider_exit_status(&self, _pane: &PaneId) -> Result<Option<u8>, TransportError> {
987
+ Ok(None)
988
+ }
989
+
984
990
  /// Cheap direct pane existence check when a backend can prove it. `Ok(None)`
985
991
  /// preserves the existing Unknown boundary.
986
992
  fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.78",
3
+ "version": "0.5.81",
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.78",
24
- "@team-agent/cli-darwin-x64": "0.5.78",
25
- "@team-agent/cli-linux-x64": "0.5.78"
23
+ "@team-agent/cli-darwin-arm64": "0.5.81",
24
+ "@team-agent/cli-darwin-x64": "0.5.81",
25
+ "@team-agent/cli-linux-x64": "0.5.81"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",