@team-agent/installer 0.5.23 → 0.5.25
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/diagnose.rs +13 -0
- package/crates/team-agent/src/leader/lease.rs +54 -34
- package/crates/team-agent/src/leader/rediscover/tests.rs +6 -1
- package/crates/team-agent/src/leader/tests/lease_api.rs +40 -22
- package/crates/team-agent/src/leader/tests/lease_claim.rs +12 -29
- package/crates/team-agent/src/lifecycle/helpers.rs +40 -7
- package/crates/team-agent/src/lifecycle/restart/agent.rs +3 -6
- package/crates/team-agent/src/lifecycle/restart/remove.rs +6 -11
- package/crates/team-agent/src/mcp_server/helpers.rs +86 -27
- package/crates/team-agent/src/mcp_server/normalize.rs +3 -0
- package/crates/team-agent/src/mcp_server/tools.rs +113 -32
- package/crates/team-agent/src/messaging/results.rs +17 -0
- package/crates/team-agent/src/state/paths.rs +30 -31
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -171,10 +171,23 @@ pub(crate) fn diagnose_runtime_for_workspace(
|
|
|
171
171
|
backend: &dyn Transport,
|
|
172
172
|
) -> (Value, Value) {
|
|
173
173
|
let (mut issues, mut repairs) = diagnose_runtime(state, backend);
|
|
174
|
+
append_legacy_snapshot_issue(workspace, state, &mut issues);
|
|
174
175
|
append_coordinator_health_issue(workspace, state, &mut issues, &mut repairs);
|
|
175
176
|
(issues, repairs)
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
fn append_legacy_snapshot_issue(workspace: &std::path::Path, state: &Value, issues: &mut Value) {
|
|
180
|
+
let Ok(Some(details)) = crate::leader::detect_dual_state_divergence(workspace, state) else {
|
|
181
|
+
return;
|
|
182
|
+
};
|
|
183
|
+
if let Some(items) = issues.as_array_mut() {
|
|
184
|
+
items.push(json!({
|
|
185
|
+
"id": "legacy_snapshot_stale",
|
|
186
|
+
"details": details,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
178
191
|
fn append_coordinator_health_issue(
|
|
179
192
|
workspace: &std::path::Path,
|
|
180
193
|
state: &Value,
|
|
@@ -1615,33 +1615,14 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
|
|
|
1615
1615
|
.and_then(|v| serde_json::from_value(v).ok())
|
|
1616
1616
|
}
|
|
1617
1617
|
|
|
1618
|
-
/// `_write_lease_dual_state`
|
|
1619
|
-
///
|
|
1618
|
+
/// `_write_lease_dual_state` — Foundation-0 F0-2: the historical dual
|
|
1619
|
+
/// write to the legacy per-session snapshot has been retired
|
|
1620
|
+
/// (`.team/artifacts/foundation-0-slice-design.md` §§4-5). This helper
|
|
1621
|
+
/// now persists ONLY the canonical root state; retaining the public
|
|
1622
|
+
/// name for 0.5.x call-site stability. The B0 legacy snapshot is
|
|
1623
|
+
/// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
|
|
1620
1624
|
pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
|
|
1621
1625
|
crate::state::persist::save_runtime_state(workspace, state)?;
|
|
1622
|
-
if let Some(session_name) = state.get("session_name").and_then(Value::as_str) {
|
|
1623
|
-
write_team_snapshot_atomic(workspace, session_name, state)?;
|
|
1624
|
-
}
|
|
1625
|
-
Ok(())
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
fn write_team_snapshot_atomic(
|
|
1629
|
-
workspace: &Path,
|
|
1630
|
-
session_name: &str,
|
|
1631
|
-
state: &Value,
|
|
1632
|
-
) -> Result<(), LeaderError> {
|
|
1633
|
-
let snap_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name);
|
|
1634
|
-
let parent = snap_path
|
|
1635
|
-
.parent()
|
|
1636
|
-
.ok_or_else(|| LeaderError::Validation("team snapshot path has no parent".to_string()))?;
|
|
1637
|
-
std::fs::create_dir_all(parent)?;
|
|
1638
|
-
let tmp = parent.join(format!("state.json.tmp-{}", std::process::id()));
|
|
1639
|
-
let data = serde_json::to_vec_pretty(state)?;
|
|
1640
|
-
std::fs::write(&tmp, data)?;
|
|
1641
|
-
if let Err(error) = std::fs::rename(&tmp, &snap_path) {
|
|
1642
|
-
let _ = std::fs::remove_file(&tmp);
|
|
1643
|
-
return Err(error.into());
|
|
1644
|
-
}
|
|
1645
1626
|
Ok(())
|
|
1646
1627
|
}
|
|
1647
1628
|
|
|
@@ -1769,17 +1750,22 @@ fn compact_team_state_preserving_claim_fields(state: &Value, target_key: &str) -
|
|
|
1769
1750
|
entry
|
|
1770
1751
|
}
|
|
1771
1752
|
|
|
1772
|
-
///
|
|
1773
|
-
///
|
|
1774
|
-
///
|
|
1775
|
-
|
|
1753
|
+
/// Foundation-0 F0-2: reader for legacy per-session snapshot vs
|
|
1754
|
+
/// canonical root state. Diagnostic-only after the dual-write retirement
|
|
1755
|
+
/// (`.team/artifacts/foundation-0-slice-design.md` §§4-5); product
|
|
1756
|
+
/// authority code never consults this — it exists so `status`/`diagnose`
|
|
1757
|
+
/// can surface `legacy_snapshot_stale` when the on-disk sidecar has
|
|
1758
|
+
/// drifted. Every touch of the legacy path constants below is marked
|
|
1759
|
+
/// `B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ` so the RED3 grep guard admits
|
|
1760
|
+
/// them as documented exceptions.
|
|
1761
|
+
pub fn detect_dual_state_divergence( // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only entry point; no product save/route consumer.
|
|
1776
1762
|
workspace: &Path,
|
|
1777
1763
|
state: &Value,
|
|
1778
1764
|
) -> Result<Option<Value>, LeaderError> {
|
|
1779
1765
|
let Some(session_name) = state.get("session_name").and_then(Value::as_str) else {
|
|
1780
1766
|
return Ok(None);
|
|
1781
1767
|
};
|
|
1782
|
-
let snap_path = readable_team_snapshot_path(workspace, session_name);
|
|
1768
|
+
let snap_path = readable_team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic legacy-shape path lookup.
|
|
1783
1769
|
if !snap_path.exists() {
|
|
1784
1770
|
return Ok(None);
|
|
1785
1771
|
}
|
|
@@ -1794,10 +1780,13 @@ pub fn detect_dual_state_divergence(
|
|
|
1794
1780
|
.or_else(|| get_path_u64(state, &["leader_receiver", "owner_epoch"]));
|
|
1795
1781
|
let team_epoch = get_path_u64(&snap, &["team_owner", "owner_epoch"])
|
|
1796
1782
|
.or_else(|| get_path_u64(&snap, &["leader_receiver", "owner_epoch"]));
|
|
1783
|
+
let workspace_agent_bindings = agent_binding_summary(state);
|
|
1784
|
+
let team_agent_bindings = agent_binding_summary(&snap);
|
|
1797
1785
|
let diverged = workspace_owner_pane != team_owner_pane
|
|
1798
1786
|
|| workspace_owner_uuid != team_owner_uuid
|
|
1799
1787
|
|| workspace_receiver_pane != team_receiver_pane
|
|
1800
|
-
|| workspace_epoch != team_epoch
|
|
1788
|
+
|| workspace_epoch != team_epoch
|
|
1789
|
+
|| workspace_agent_bindings != team_agent_bindings;
|
|
1801
1790
|
if !diverged {
|
|
1802
1791
|
return Ok(None);
|
|
1803
1792
|
}
|
|
@@ -1810,16 +1799,47 @@ pub fn detect_dual_state_divergence(
|
|
|
1810
1799
|
"team_receiver_pane": team_receiver_pane,
|
|
1811
1800
|
"workspace_owner_epoch": workspace_epoch,
|
|
1812
1801
|
"team_owner_epoch": team_epoch,
|
|
1802
|
+
"workspace_agent_bindings": workspace_agent_bindings,
|
|
1803
|
+
"team_agent_bindings": team_agent_bindings,
|
|
1804
|
+
"_legacy_snapshot_stale": true,
|
|
1813
1805
|
})))
|
|
1814
1806
|
}
|
|
1815
1807
|
|
|
1816
|
-
fn
|
|
1817
|
-
let
|
|
1808
|
+
fn agent_binding_summary(state: &Value) -> Value {
|
|
1809
|
+
let mut out = serde_json::Map::new();
|
|
1810
|
+
let Some(agents) = state.get("agents").and_then(Value::as_object) else {
|
|
1811
|
+
return Value::Object(out);
|
|
1812
|
+
};
|
|
1813
|
+
for (agent_id, agent) in agents {
|
|
1814
|
+
let mut binding = serde_json::Map::new();
|
|
1815
|
+
for key in [
|
|
1816
|
+
"pane_id",
|
|
1817
|
+
"pane_pid",
|
|
1818
|
+
"tmux_endpoint",
|
|
1819
|
+
"tmux_socket",
|
|
1820
|
+
"window",
|
|
1821
|
+
"window_name",
|
|
1822
|
+
] {
|
|
1823
|
+
if let Some(value) = agent.get(key) {
|
|
1824
|
+
binding.insert(key.to_string(), value.clone());
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
if !binding.is_empty() {
|
|
1828
|
+
out.insert(agent_id.clone(), Value::Object(binding));
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
Value::Object(out)
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
fn readable_team_snapshot_path(workspace: &Path, session_name: &str) -> PathBuf { // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: diagnostic-only path resolver.
|
|
1835
|
+
let safe_path = crate::lifecycle::helpers::team_snapshot_path(workspace, session_name); // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ: reuses helpers safe legacy path.
|
|
1818
1836
|
if safe_path.exists() {
|
|
1819
1837
|
return safe_path;
|
|
1820
1838
|
}
|
|
1839
|
+
// Raw legacy `runtime/teams` fallback for pre-safe-shape snapshots. // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ
|
|
1840
|
+
let legacy_dir = "teams"; // B0_DIAGNOSTIC_LEGACY_SNAPSHOT_READ
|
|
1821
1841
|
crate::model::paths::runtime_dir(workspace)
|
|
1822
|
-
.join(
|
|
1842
|
+
.join(legacy_dir)
|
|
1823
1843
|
.join(session_name)
|
|
1824
1844
|
.join("state.json")
|
|
1825
1845
|
}
|
|
@@ -135,7 +135,12 @@ fn try_readopt_writes_owner_receiver_dual_state_and_events() {
|
|
|
135
135
|
assert_eq!(owner_view["claimed_via"], json!("attach-leader"));
|
|
136
136
|
assert_eq!(owner_view["owner_epoch"], json!(4));
|
|
137
137
|
assert_eq!(receiver_view["discovery"], json!("attach_readopt"));
|
|
138
|
-
|
|
138
|
+
// Foundation-0 F0-2: the try_readopt success path no longer side-
|
|
139
|
+
// effects a legacy per-session snapshot; canonical teams.<key> is
|
|
140
|
+
// the sole authority. The legacy sidecar stays absent unless a
|
|
141
|
+
// diagnostic writer explicitly created it
|
|
142
|
+
// (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
|
|
143
|
+
assert!(!crate::model::paths::runtime_dir(&ws)
|
|
139
144
|
.join("teams")
|
|
140
145
|
.join("sess")
|
|
141
146
|
.join("state.json")
|
|
@@ -112,7 +112,15 @@ use super::*;
|
|
|
112
112
|
// 强化:带 session_name 时必须落 BOTH —— workspace state.json + team/<session> snapshot,
|
|
113
113
|
// 两份 team_owner.pane_id / owner_epoch 必须一致(永不分叉)。空 body Ok(()) 会被这里抓。
|
|
114
114
|
#[test]
|
|
115
|
-
|
|
115
|
+
// Foundation-0 F0-2 conversion of the C17 dual-write invariant test:
|
|
116
|
+
// legacy per-session snapshot dual-write has been retired
|
|
117
|
+
// (`.team/artifacts/foundation-0-slice-design.md` §§4-5). The lease
|
|
118
|
+
// helper now persists ONLY the canonical root state; a legacy
|
|
119
|
+
// snapshot is diagnostic-only and never re-appears as a side effect
|
|
120
|
+
// of `write_lease_dual_state`. This test enforces exactly that:
|
|
121
|
+
// canonical root gets the owner tuple, and the legacy per-session
|
|
122
|
+
// path stays absent unless a diagnostic path explicitly writes it.
|
|
123
|
+
fn write_lease_dual_state_persists_canonical_root_only_no_legacy_side_effect() {
|
|
116
124
|
let ws = std::env::temp_dir().join(format!("ta_rs_dual_{}", std::process::id()));
|
|
117
125
|
std::fs::create_dir_all(&ws).unwrap();
|
|
118
126
|
let state = serde_json::json!({
|
|
@@ -121,32 +129,33 @@ use super::*;
|
|
|
121
129
|
"leader_receiver": {"pane_id": "%1", "owner_epoch": 2},
|
|
122
130
|
});
|
|
123
131
|
write_lease_dual_state(&ws, &state).unwrap();
|
|
124
|
-
//
|
|
132
|
+
// Canonical root state.json IS written; F0-2 keeps this as the
|
|
133
|
+
// single product authority.
|
|
125
134
|
let ws_path = crate::state::persist::runtime_state_path(&ws);
|
|
126
135
|
let ws_state: serde_json::Value =
|
|
127
136
|
serde_json::from_str(&std::fs::read_to_string(&ws_path).expect("workspace state.json 必须存在")).unwrap();
|
|
128
137
|
assert_eq!(ws_state["team_owner"]["pane_id"], serde_json::json!("%1"));
|
|
129
138
|
assert_eq!(ws_state["team_owner"]["owner_epoch"], serde_json::json!(2));
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
assert_eq!(
|
|
139
|
-
ws_state["team_owner"]["pane_id"], snap_state["team_owner"]["pane_id"],
|
|
140
|
-
"workspace 与 team snapshot 的 owner pane 不得分叉"
|
|
141
|
-
);
|
|
142
|
-
assert_eq!(
|
|
143
|
-
ws_state["team_owner"]["owner_epoch"], snap_state["team_owner"]["owner_epoch"],
|
|
144
|
-
"workspace 与 team snapshot 的 owner_epoch 不得分叉"
|
|
139
|
+
// Legacy per-session snapshot MUST NOT be a side effect of the
|
|
140
|
+
// lease save path any more — this is the RED2 authority-write
|
|
141
|
+
// guard shape at the unit-test layer.
|
|
142
|
+
let legacy_snap = crate::lifecycle::helpers::team_snapshot_path(&ws, "team-sess");
|
|
143
|
+
assert!(
|
|
144
|
+
!legacy_snap.exists(),
|
|
145
|
+
"F0-2: write_lease_dual_state must not write the legacy per-session snapshot; found {}",
|
|
146
|
+
legacy_snap.display(),
|
|
145
147
|
);
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
#[test]
|
|
149
151
|
fn lease_divergence_reads_restart_snapshot_for_special_session_names() {
|
|
152
|
+
// Foundation-0 F0-2 conversion: `write_lease_dual_state` no longer
|
|
153
|
+
// side-effects a legacy snapshot; the divergence detector is a
|
|
154
|
+
// diagnostic reader that fires only when a diagnostic writer
|
|
155
|
+
// (e.g. `lifecycle::save_team_runtime_snapshot`) has produced
|
|
156
|
+
// the sidecar. Sequence exercised here: canonical root save →
|
|
157
|
+
// diagnostic snapshot save with a different pane → divergence
|
|
158
|
+
// reader observes the drift.
|
|
150
159
|
let ws = std::env::temp_dir().join(format!("ta_rs_dual_special_{}", std::process::id()));
|
|
151
160
|
std::fs::create_dir_all(&ws).unwrap();
|
|
152
161
|
let state = serde_json::json!({
|
|
@@ -160,17 +169,21 @@ use super::*;
|
|
|
160
169
|
.join("teams")
|
|
161
170
|
.join("team:proj-中文")
|
|
162
171
|
.join("state.json");
|
|
172
|
+
// F0-2: canonical save must not create either the restart-safe
|
|
173
|
+
// or the raw legacy snapshot as a side effect.
|
|
163
174
|
assert!(
|
|
164
|
-
safe_path.exists(),
|
|
165
|
-
"
|
|
175
|
+
!safe_path.exists(),
|
|
176
|
+
"F0-2: canonical write_lease_dual_state must not create the restart-safe legacy snapshot: {}",
|
|
166
177
|
safe_path.display()
|
|
167
178
|
);
|
|
168
179
|
assert!(
|
|
169
180
|
!raw_path.exists(),
|
|
170
|
-
"
|
|
181
|
+
"F0-2: canonical write_lease_dual_state must not create the raw legacy snapshot: {}",
|
|
171
182
|
raw_path.display()
|
|
172
183
|
);
|
|
173
184
|
|
|
185
|
+
// Diagnostic write path with a divergent pane so we can exercise
|
|
186
|
+
// the divergence reader below.
|
|
174
187
|
let restart_state = serde_json::json!({
|
|
175
188
|
"session_name": "team:proj-中文",
|
|
176
189
|
"team_owner": {"pane_id": "%9", "owner_epoch": 2, "leader_session_uuid": "uuuu"},
|
|
@@ -180,13 +193,18 @@ use super::*;
|
|
|
180
193
|
assert_eq!(restart_path, safe_path);
|
|
181
194
|
assert!(
|
|
182
195
|
restart_path.exists(),
|
|
183
|
-
"
|
|
196
|
+
"diagnostic snapshot must be written to a real file: {}",
|
|
184
197
|
restart_path.display()
|
|
185
198
|
);
|
|
186
199
|
|
|
200
|
+
// F0-2 RED1 diagnostic marker is present on the on-disk file.
|
|
201
|
+
let snap_state: serde_json::Value =
|
|
202
|
+
serde_json::from_str(&std::fs::read_to_string(&restart_path).unwrap()).unwrap();
|
|
203
|
+
assert_eq!(snap_state["_not_authoritative"], serde_json::json!(true));
|
|
204
|
+
|
|
187
205
|
let d = detect_dual_state_divergence(&ws, &state)
|
|
188
206
|
.unwrap()
|
|
189
|
-
.expect("divergence detector must read the
|
|
207
|
+
.expect("divergence detector must read the diagnostic snapshot");
|
|
190
208
|
assert_eq!(d["workspace_owner_pane"], serde_json::json!("%1"));
|
|
191
209
|
assert_eq!(d["team_owner_pane"], serde_json::json!("%9"));
|
|
192
210
|
assert_eq!(d["workspace_receiver_pane"], serde_json::json!("%1"));
|
|
@@ -10,13 +10,16 @@ use super::*;
|
|
|
10
10
|
/// (bound-pane liveness + caller eligibility) deterministically with no tmux.
|
|
11
11
|
#[test]
|
|
12
12
|
#[serial_test::serial(env)]
|
|
13
|
-
|
|
13
|
+
// Foundation-0 F0-2 conversion: `claim_lease_no_incident` used to
|
|
14
|
+
// dual-write the legacy per-session snapshot; that is retired
|
|
15
|
+
// (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Canonical
|
|
16
|
+
// teams.<team_key> now carries the sole authoritative owner tuple.
|
|
17
|
+
fn claim_lease_no_incident_vacant_acquire_advances_epoch_and_writes_canonical_only() {
|
|
14
18
|
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
15
19
|
let _e = EnvGuard::apply(&[("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None)]);
|
|
16
20
|
let ws = p2_temp_ws("claim_vacant");
|
|
17
21
|
let team_id = TeamKey::new("current");
|
|
18
22
|
let caller = PaneId::new("%5");
|
|
19
|
-
// empty (vacant) state with a session so dual-state writes both files.
|
|
20
23
|
let mut state = serde_json::json!({"session_name": "team-agent-x"});
|
|
21
24
|
let event_log = crate::event_log::EventLog::new(&ws);
|
|
22
25
|
let live = seeded_liveness(&["%5"]);
|
|
@@ -35,45 +38,25 @@ use super::*;
|
|
|
35
38
|
let receiver = r.receiver.as_ref().expect("claimed → receiver");
|
|
36
39
|
assert_eq!(receiver.pane_id, caller);
|
|
37
40
|
assert_eq!(receiver.discovery, Some(Discovery::ClaimLeader));
|
|
38
|
-
// Stage 3d (identity-boundary unified plan, architect direction
|
|
39
|
-
// 2026-06-23): canonical owner now lives at teams.<team_key>; the
|
|
40
|
-
// top-level dual-write was dropped. Read through the ownership
|
|
41
|
-
// repository which preserves the legacy precedence for callers
|
|
42
|
-
// that still consult top-level on migration states.
|
|
43
41
|
let ws_path = crate::state::persist::runtime_state_path(&ws);
|
|
44
42
|
let ws_state: serde_json::Value =
|
|
45
43
|
serde_json::from_str(&std::fs::read_to_string(&ws_path).unwrap()).unwrap();
|
|
46
|
-
// The canonical team_key is what `team_state_key(state)` derives
|
|
47
|
-
// (here: state's session_name "team-agent-x" since no explicit
|
|
48
|
-
// team_key/team_dir/spec_path was seeded). The test's `team_id`
|
|
49
|
-
// parameter is the LOOKUP key but `claim_lease_no_incident` uses
|
|
50
|
-
// the state-derived key for canonical writes. Stage 5 will unify
|
|
51
|
-
// these via TeamRuntimePaths.
|
|
52
44
|
let canonical_key = crate::state::projection::team_state_key(&ws_state);
|
|
53
45
|
let ws_owner =
|
|
54
46
|
crate::state::ownership::read_owner_value(&ws_state, &canonical_key)
|
|
55
47
|
.expect("Stage 3d: vacant acquire writes canonical owner");
|
|
56
48
|
assert_eq!(ws_owner["pane_id"], serde_json::json!("%5"));
|
|
57
49
|
assert_eq!(ws_owner["owner_epoch"], serde_json::json!(1));
|
|
58
|
-
// team_id parameter is currently unused by the canonical write
|
|
59
|
-
// (architect Stage 5 will close this gap); silence the warning.
|
|
60
50
|
let _ = &team_id;
|
|
51
|
+
// F0-2: canonical claim path must NOT side-effect the legacy
|
|
52
|
+
// per-session snapshot any more.
|
|
61
53
|
let snap_path = crate::model::paths::runtime_dir(&ws)
|
|
62
54
|
.join("teams").join("team-agent-x").join("state.json");
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
let snap_owner = snap
|
|
69
|
-
.get("team_owner")
|
|
70
|
-
.or_else(|| {
|
|
71
|
-
let key = crate::state::projection::team_state_key(&snap);
|
|
72
|
-
snap.get("teams").and_then(|t| t.get(key)).and_then(|e| e.get("team_owner"))
|
|
73
|
-
})
|
|
74
|
-
.expect("dual-state snapshot owner");
|
|
75
|
-
assert_eq!(snap_owner["pane_id"], serde_json::json!("%5"), "dual-state snapshot owner");
|
|
76
|
-
assert_eq!(snap_owner["owner_epoch"], serde_json::json!(1));
|
|
55
|
+
assert!(
|
|
56
|
+
!snap_path.exists(),
|
|
57
|
+
"F0-2: vacant claim must not write a legacy per-session snapshot; found {}",
|
|
58
|
+
snap_path.display(),
|
|
59
|
+
);
|
|
77
60
|
}
|
|
78
61
|
|
|
79
62
|
// RED — DEAD-OWNER RECOVER: bound pane %1 absent from live set, caller %5
|
|
@@ -6,8 +6,24 @@ use std::path::{Path, PathBuf};
|
|
|
6
6
|
|
|
7
7
|
use super::*;
|
|
8
8
|
|
|
9
|
-
/// `save_team_runtime_snapshot(workspace, state)`
|
|
10
|
-
///
|
|
9
|
+
/// `save_team_runtime_snapshot(workspace, state)` — Foundation-0 F0-2:
|
|
10
|
+
/// this is now a **diagnostic-only** legacy per-session snapshot writer.
|
|
11
|
+
/// Root/projection `.team/runtime/state.json` is the sole product
|
|
12
|
+
/// authority; the file this writes carries `_not_authoritative:true`
|
|
13
|
+
/// and pointers back to the canonical path so no reader can mistake it
|
|
14
|
+
/// for the real runtime state. See
|
|
15
|
+
/// `.team/artifacts/foundation-0-slice-design.md` §§4-5 F0-2.
|
|
16
|
+
///
|
|
17
|
+
/// Product save paths (attach/claim/start-agent/restart/stop/remove)
|
|
18
|
+
/// must NOT call this — the RED2 grep guard enforces that. Retained
|
|
19
|
+
/// callers are diagnostic/migration/test paths only.
|
|
20
|
+
///
|
|
21
|
+
/// Preserves the legacy path shape (`.team/runtime/teams/<session>/state.json`)
|
|
22
|
+
/// so 0.5.x operators inspecting the file see the diagnostic marker
|
|
23
|
+
/// rather than being surprised by a missing file. The B3 canonical
|
|
24
|
+
/// team-key layout will live under a different path
|
|
25
|
+
/// (`teams/<team_key>/...`), so this file also cannot be mistaken for
|
|
26
|
+
/// the future canonical shape.
|
|
11
27
|
pub fn save_team_runtime_snapshot(
|
|
12
28
|
workspace: &Path,
|
|
13
29
|
state: &serde_json::Value,
|
|
@@ -16,17 +32,34 @@ pub fn save_team_runtime_snapshot(
|
|
|
16
32
|
.get("session_name")
|
|
17
33
|
.and_then(|v| v.as_str())
|
|
18
34
|
.ok_or_else(|| LifecycleError::StatePersist("session_name is required".to_string()))?;
|
|
19
|
-
// golden restart/snapshot.py:46-47 — team_runtime_snapshot_dir = runtime_dir(workspace)/teams/<safe>,
|
|
20
|
-
// and paths.py:25-26 runtime_dir = workspace/.team/runtime. Use the crate path helper so the snapshot
|
|
21
|
-
// lands at <ws>/.team/runtime/teams/<safe>, matching golden AND the rest of the crate (not the
|
|
22
|
-
// ".team"-less <ws>/runtime/teams that the original port wrote).
|
|
23
35
|
let path = team_snapshot_path(workspace, session_name);
|
|
24
36
|
let dir = path
|
|
25
37
|
.parent()
|
|
26
38
|
.ok_or_else(|| LifecycleError::StatePersist("snapshot path has no parent".to_string()))?;
|
|
27
39
|
fs::create_dir_all(&dir).map_err(|e| persist_err("create snapshot dir", &e))?;
|
|
40
|
+
let canonical_path = crate::state::persist::runtime_state_path(workspace);
|
|
41
|
+
let generated_at = chrono::Utc::now().to_rfc3339();
|
|
42
|
+
// F0-2 RED1: annotate the snapshot payload with diagnostic-only
|
|
43
|
+
// metadata so any reader can immediately see it is derived, not
|
|
44
|
+
// authoritative. `_canonical_state_path` points at the real
|
|
45
|
+
// runtime authority; `_derived_from` names the code path that
|
|
46
|
+
// produced this file; `_generated_at` records freshness so stale
|
|
47
|
+
// snapshots are easy to identify.
|
|
48
|
+
let mut annotated = state.clone();
|
|
49
|
+
if let Some(obj) = annotated.as_object_mut() {
|
|
50
|
+
obj.insert("_not_authoritative".to_string(), serde_json::json!(true));
|
|
51
|
+
obj.insert(
|
|
52
|
+
"_canonical_state_path".to_string(),
|
|
53
|
+
serde_json::json!(canonical_path.to_string_lossy()),
|
|
54
|
+
);
|
|
55
|
+
obj.insert(
|
|
56
|
+
"_derived_from".to_string(),
|
|
57
|
+
serde_json::json!("lifecycle::save_team_runtime_snapshot"),
|
|
58
|
+
);
|
|
59
|
+
obj.insert("_generated_at".to_string(), serde_json::json!(generated_at));
|
|
60
|
+
}
|
|
28
61
|
let tmp = dir.join("state.json.tmp");
|
|
29
|
-
let data = serde_json::to_vec_pretty(
|
|
62
|
+
let data = serde_json::to_vec_pretty(&annotated)
|
|
30
63
|
.map_err(|e| LifecycleError::StatePersist(format!("serialize snapshot: {e}")))?;
|
|
31
64
|
fs::write(&tmp, data).map_err(|e| persist_err("write snapshot temp", &e))?;
|
|
32
65
|
fs::rename(&tmp, &path).map_err(|e| persist_err("replace snapshot", &e))?;
|
|
@@ -915,12 +915,9 @@ pub(super) fn stop_agent_at_paths(
|
|
|
915
915
|
)
|
|
916
916
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
917
917
|
// golden operations.py:96-99: snapshot (side-effect), then state_file = write_team_state path.
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
if session_name_present(&state) {
|
|
922
|
-
crate::lifecycle::helpers::save_team_runtime_snapshot(workspace, &state)?;
|
|
923
|
-
}
|
|
918
|
+
// Foundation-0 F0-2: legacy per-session snapshot dual-write retired
|
|
919
|
+
// (`.team/artifacts/foundation-0-slice-design.md` §§4-5). Root state
|
|
920
|
+
// remains the only save target on the stop path.
|
|
924
921
|
let state_file = write_team_state(spec_workspace, &spec, &state)?;
|
|
925
922
|
write_stop_complete_event(workspace, agent_id, &target_str, stopped)?;
|
|
926
923
|
Ok(StopAgentReport {
|
|
@@ -186,17 +186,12 @@ fn remove_agent_at_paths(
|
|
|
186
186
|
);
|
|
187
187
|
match result {
|
|
188
188
|
Ok(success) => {
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
.and_then(|v| v.as_str())
|
|
196
|
-
.is_some_and(|s| !s.is_empty())
|
|
197
|
-
{
|
|
198
|
-
let _ = crate::lifecycle::helpers::save_team_runtime_snapshot(workspace, &success.removed_state)?;
|
|
199
|
-
}
|
|
189
|
+
// Foundation-0 F0-2: the historical dual-write to the legacy
|
|
190
|
+
// per-session snapshot has been retired
|
|
191
|
+
// (`.team/artifacts/foundation-0-slice-design.md` §§4-5).
|
|
192
|
+
// Root/projection is the sole runtime authority; the
|
|
193
|
+
// snapshot writer stayed in `lifecycle::helpers` only for
|
|
194
|
+
// diagnostic/migration/test callers.
|
|
200
195
|
write_remove_complete_event(
|
|
201
196
|
paths.run_workspace,
|
|
202
197
|
agent_id,
|
|
@@ -280,8 +280,10 @@ pub(crate) fn latest_reportable_message_for(
|
|
|
280
280
|
agent_id: &str,
|
|
281
281
|
owner_team_id: Option<&str>,
|
|
282
282
|
) -> Option<String> {
|
|
283
|
-
|
|
284
|
-
|
|
283
|
+
match direct_message_attribution_for(workspace, agent_id, owner_team_id) {
|
|
284
|
+
DirectMessageAttribution::Reportable(message_id) => Some(message_id),
|
|
285
|
+
DirectMessageAttribution::BlockedByNewer { .. } | DirectMessageAttribution::None => None,
|
|
286
|
+
}
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
pub(crate) fn current_reportable_message_for(
|
|
@@ -306,6 +308,49 @@ pub(crate) fn latest_delivered_direct_message_for(
|
|
|
306
308
|
latest_reportable_message_from_db(&conn, agent_id, owner_team_id)
|
|
307
309
|
}
|
|
308
310
|
|
|
311
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
312
|
+
pub(crate) enum DirectMessageAttribution {
|
|
313
|
+
Reportable(String),
|
|
314
|
+
BlockedByNewer {
|
|
315
|
+
message_id: String,
|
|
316
|
+
status: String,
|
|
317
|
+
error: Option<String>,
|
|
318
|
+
},
|
|
319
|
+
None,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
pub(crate) fn direct_message_attribution_for(
|
|
323
|
+
workspace: &Path,
|
|
324
|
+
agent_id: &str,
|
|
325
|
+
owner_team_id: Option<&str>,
|
|
326
|
+
) -> DirectMessageAttribution {
|
|
327
|
+
use crate::db::message_store::MessageStore;
|
|
328
|
+
let Ok(store) = MessageStore::open(workspace) else {
|
|
329
|
+
return DirectMessageAttribution::None;
|
|
330
|
+
};
|
|
331
|
+
let Ok(conn) = crate::db::schema::open_db(store.db_path()) else {
|
|
332
|
+
return DirectMessageAttribution::None;
|
|
333
|
+
};
|
|
334
|
+
if let Some(message_id) = current_turn_message_for(workspace, &conn, agent_id, owner_team_id) {
|
|
335
|
+
return DirectMessageAttribution::Reportable(message_id);
|
|
336
|
+
}
|
|
337
|
+
match latest_direct_message_from_db(&conn, agent_id, owner_team_id) {
|
|
338
|
+
Some(candidate)
|
|
339
|
+
if reportable_message_status(&candidate.status, candidate.error.as_deref()) =>
|
|
340
|
+
{
|
|
341
|
+
DirectMessageAttribution::Reportable(candidate.message_id)
|
|
342
|
+
}
|
|
343
|
+
Some(candidate) if blocks_historical_fallback(&candidate.status) => {
|
|
344
|
+
DirectMessageAttribution::BlockedByNewer {
|
|
345
|
+
message_id: candidate.message_id,
|
|
346
|
+
status: candidate.status,
|
|
347
|
+
error: candidate.error,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
Some(_) | None => DirectMessageAttribution::None,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
309
354
|
fn current_turn_message_for(
|
|
310
355
|
workspace: &Path,
|
|
311
356
|
conn: &rusqlite::Connection,
|
|
@@ -419,9 +464,27 @@ fn latest_reportable_message_from_db(
|
|
|
419
464
|
agent_id: &str,
|
|
420
465
|
owner_team_id: Option<&str>,
|
|
421
466
|
) -> Option<String> {
|
|
422
|
-
let
|
|
423
|
-
|
|
424
|
-
|
|
467
|
+
let candidate = latest_direct_message_from_db(conn, agent_id, owner_team_id)?;
|
|
468
|
+
if reportable_message_status(&candidate.status, candidate.error.as_deref()) {
|
|
469
|
+
Some(candidate.message_id)
|
|
470
|
+
} else {
|
|
471
|
+
None
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
struct DirectMessageCandidate {
|
|
476
|
+
message_id: String,
|
|
477
|
+
status: String,
|
|
478
|
+
error: Option<String>,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
fn latest_direct_message_from_db(
|
|
482
|
+
conn: &rusqlite::Connection,
|
|
483
|
+
agent_id: &str,
|
|
484
|
+
owner_team_id: Option<&str>,
|
|
485
|
+
) -> Option<DirectMessageCandidate> {
|
|
486
|
+
conn.query_row(
|
|
487
|
+
"select m.message_id, m.status, m.error from messages m
|
|
425
488
|
where m.recipient = ?1
|
|
426
489
|
and (?2 is null or m.owner_team_id = ?2)
|
|
427
490
|
and (m.task_id is null or m.task_id = '')
|
|
@@ -437,29 +500,25 @@ fn latest_reportable_message_from_db(
|
|
|
437
500
|
order by m.created_at desc,
|
|
438
501
|
case when m.status = 'delivered' then 0 else 1 end
|
|
439
502
|
limit 1",
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
let (message_id, status, error) = row;
|
|
453
|
-
if reportable_message_status(&status, error.as_deref()) {
|
|
454
|
-
Some(message_id)
|
|
455
|
-
} else {
|
|
456
|
-
None
|
|
457
|
-
}
|
|
503
|
+
params![agent_id, owner_team_id],
|
|
504
|
+
|row| {
|
|
505
|
+
Ok(DirectMessageCandidate {
|
|
506
|
+
message_id: row.get::<_, String>(0)?,
|
|
507
|
+
status: row.get::<_, String>(1)?,
|
|
508
|
+
error: row.get::<_, Option<String>>(2)?,
|
|
509
|
+
})
|
|
510
|
+
},
|
|
511
|
+
)
|
|
512
|
+
.optional()
|
|
513
|
+
.ok()
|
|
514
|
+
.flatten()
|
|
458
515
|
}
|
|
459
516
|
|
|
460
517
|
fn reportable_message_status(status: &str, error: Option<&str>) -> bool {
|
|
461
|
-
matches!(
|
|
462
|
-
status
|
|
463
|
-
|
|
464
|
-
|
|
518
|
+
matches!(status, "delivered" | "submitted" | "injected" | "visible")
|
|
519
|
+
&& (status == "delivered" || error.is_none())
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn blocks_historical_fallback(status: &str) -> bool {
|
|
523
|
+
matches!(status, "failed" | "refused" | "blocked")
|
|
465
524
|
}
|
|
@@ -20,10 +20,10 @@ use crate::state::persist::{
|
|
|
20
20
|
use crate::messaging::{self, MessageTarget, SendOptions};
|
|
21
21
|
|
|
22
22
|
use super::helpers::{
|
|
23
|
-
current_reportable_message_for, delivery_outcome_value,
|
|
24
|
-
insert_array, is_worker_recipient, json_dumps_default,
|
|
23
|
+
current_reportable_message_for, delivery_outcome_value, direct_message_attribution_for,
|
|
24
|
+
ensure_object, enum_value, insert_array, is_worker_recipient, json_dumps_default,
|
|
25
25
|
latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
|
|
26
|
-
requires_ack_for_target, tool_runtime_error,
|
|
26
|
+
requires_ack_for_target, tool_runtime_error, DirectMessageAttribution,
|
|
27
27
|
};
|
|
28
28
|
use super::normalize::{
|
|
29
29
|
compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
|
|
@@ -336,42 +336,81 @@ impl TeamOrchestratorTools {
|
|
|
336
336
|
// message-scoped fallback, before defaulting to "manual".
|
|
337
337
|
// 1. explicit arg
|
|
338
338
|
// 2. current physical direct turn for this agent
|
|
339
|
-
// 3. latest
|
|
340
|
-
// 4. latest nonterminal assigned task in teams.<owner>.tasks
|
|
341
|
-
//
|
|
339
|
+
// 3. bounded latest reportable direct message with no result yet
|
|
340
|
+
// 4. latest nonterminal assigned task in teams.<owner>.tasks,
|
|
341
|
+
// only when no newer failed/refused/blocked direct turn blocks fallback
|
|
342
342
|
// 5. "manual" — truly uncorrelated; collect still rejects
|
|
343
343
|
let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
|
|
344
|
-
let
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
344
|
+
let mut attributed_message_id = None;
|
|
345
|
+
let mut attribution_scope = None;
|
|
346
|
+
let mut task_id_source = None;
|
|
347
|
+
let resolved = if let Some(task_id) = task_id {
|
|
348
|
+
task_id.to_string()
|
|
349
|
+
} else if let Some(agent) = self.agent_id.as_ref() {
|
|
350
|
+
if let Some(message_id) = current_reportable_message_for(
|
|
351
|
+
&self.workspace,
|
|
352
|
+
agent.as_str(),
|
|
353
|
+
owner_team_id_str.as_deref(),
|
|
354
|
+
) {
|
|
355
|
+
attributed_message_id = Some(message_id.clone());
|
|
356
|
+
attribution_scope = Some("message");
|
|
357
|
+
task_id_source = Some("current_turn_message");
|
|
358
|
+
message_id
|
|
359
|
+
} else {
|
|
360
|
+
match direct_message_attribution_for(
|
|
361
|
+
&self.workspace,
|
|
362
|
+
agent.as_str(),
|
|
363
|
+
owner_team_id_str.as_deref(),
|
|
364
|
+
) {
|
|
365
|
+
DirectMessageAttribution::Reportable(message_id) => {
|
|
366
|
+
attributed_message_id = Some(message_id.clone());
|
|
367
|
+
attribution_scope = Some("message");
|
|
368
|
+
task_id_source = Some("direct_message");
|
|
369
|
+
message_id
|
|
370
|
+
}
|
|
371
|
+
DirectMessageAttribution::BlockedByNewer {
|
|
372
|
+
message_id,
|
|
373
|
+
status,
|
|
374
|
+
error,
|
|
375
|
+
} => {
|
|
376
|
+
push_report_warning(
|
|
377
|
+
obj,
|
|
378
|
+
serde_json::json!({
|
|
379
|
+
"code": "result_attribution_blocked_by_newer_direct_message",
|
|
380
|
+
"field": "task_id",
|
|
381
|
+
"severity": "warning",
|
|
382
|
+
"advisory": true,
|
|
383
|
+
"message_id": message_id,
|
|
384
|
+
"message_status": status,
|
|
385
|
+
"message_error": error,
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
"manual".to_string()
|
|
389
|
+
}
|
|
390
|
+
DirectMessageAttribution::None => latest_task_for_assignee(
|
|
367
391
|
&self.workspace,
|
|
368
392
|
agent.as_str(),
|
|
369
393
|
owner_team_id_str.as_deref(),
|
|
370
394
|
)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
395
|
+
.unwrap_or_else(|| "manual".to_string()),
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
"manual".to_string()
|
|
400
|
+
};
|
|
374
401
|
obj.insert("task_id".to_string(), Value::String(resolved));
|
|
402
|
+
if let Some(message_id) = attributed_message_id {
|
|
403
|
+
obj.entry("attributed_message_id")
|
|
404
|
+
.or_insert(Value::String(message_id));
|
|
405
|
+
}
|
|
406
|
+
if let Some(scope) = attribution_scope {
|
|
407
|
+
obj.entry("attribution_scope")
|
|
408
|
+
.or_insert(Value::String(scope.to_string()));
|
|
409
|
+
}
|
|
410
|
+
if let Some(source) = task_id_source {
|
|
411
|
+
obj.entry("task_id_source")
|
|
412
|
+
.or_insert(Value::String(source.to_string()));
|
|
413
|
+
}
|
|
375
414
|
}
|
|
376
415
|
if !obj.contains_key("agent_id") {
|
|
377
416
|
let resolved = agent_id
|
|
@@ -411,6 +450,7 @@ impl TeamOrchestratorTools {
|
|
|
411
450
|
let normalized = normalize_report_envelope(&base);
|
|
412
451
|
let warnings = report_result_integrity_warnings(&base, &normalized);
|
|
413
452
|
let mut env_value = normalized_envelope_value(&normalized);
|
|
453
|
+
copy_report_attribution_fields(&base, &mut env_value);
|
|
414
454
|
if !warnings.is_empty() {
|
|
415
455
|
if let Some(obj) = env_value.as_object_mut() {
|
|
416
456
|
obj.insert("warnings".to_string(), Value::Array(warnings));
|
|
@@ -977,6 +1017,47 @@ fn merge_object_fields(existing: &mut Value, incoming: &Value) {
|
|
|
977
1017
|
}
|
|
978
1018
|
}
|
|
979
1019
|
|
|
1020
|
+
fn copy_report_attribution_fields(source: &Value, target: &mut Value) {
|
|
1021
|
+
let Some(src) = source.as_object() else {
|
|
1022
|
+
return;
|
|
1023
|
+
};
|
|
1024
|
+
let Some(dst) = target.as_object_mut() else {
|
|
1025
|
+
return;
|
|
1026
|
+
};
|
|
1027
|
+
for key in [
|
|
1028
|
+
"attributed_message_id",
|
|
1029
|
+
"attribution_scope",
|
|
1030
|
+
"task_id_source",
|
|
1031
|
+
] {
|
|
1032
|
+
if let Some(value) = src.get(key) {
|
|
1033
|
+
dst.entry(key.to_string()).or_insert(value.clone());
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
fn push_report_warning(obj: &mut serde_json::Map<String, Value>, warning: Value) {
|
|
1039
|
+
let Some(code) = warning.get("code").and_then(Value::as_str) else {
|
|
1040
|
+
return;
|
|
1041
|
+
};
|
|
1042
|
+
let Some(field) = warning.get("field").and_then(Value::as_str) else {
|
|
1043
|
+
return;
|
|
1044
|
+
};
|
|
1045
|
+
match obj.get_mut("warnings") {
|
|
1046
|
+
Some(Value::Array(warnings)) => {
|
|
1047
|
+
let exists = warnings.iter().any(|existing| {
|
|
1048
|
+
existing.get("code").and_then(Value::as_str) == Some(code)
|
|
1049
|
+
&& existing.get("field").and_then(Value::as_str) == Some(field)
|
|
1050
|
+
});
|
|
1051
|
+
if !exists {
|
|
1052
|
+
warnings.push(warning);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
_ => {
|
|
1056
|
+
obj.insert("warnings".to_string(), Value::Array(vec![warning]));
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
980
1061
|
fn write_team_tasks(state: &mut Value, team_key: &str, tasks: Vec<Value>) {
|
|
981
1062
|
let Some(root) = state.as_object_mut() else {
|
|
982
1063
|
return;
|
|
@@ -697,6 +697,7 @@ fn report_result_for_owner_team_inner(
|
|
|
697
697
|
"task_id".to_string(),
|
|
698
698
|
serde_json::Value::String(task_id.to_string()),
|
|
699
699
|
);
|
|
700
|
+
copy_report_attribution_fields(envelope, &mut out);
|
|
700
701
|
out.insert(
|
|
701
702
|
"agent_id".to_string(),
|
|
702
703
|
serde_json::Value::String(agent_id.to_string()),
|
|
@@ -927,6 +928,7 @@ fn report_result_for_owner_team_inner(
|
|
|
927
928
|
"task_id".to_string(),
|
|
928
929
|
serde_json::Value::String(task_id.to_string()),
|
|
929
930
|
);
|
|
931
|
+
copy_report_attribution_fields(envelope, &mut out);
|
|
930
932
|
out.insert(
|
|
931
933
|
"agent_id".to_string(),
|
|
932
934
|
serde_json::Value::String(agent_id.to_string()),
|
|
@@ -968,6 +970,21 @@ fn report_result_for_owner_team_inner(
|
|
|
968
970
|
Ok(serde_json::Value::Object(out))
|
|
969
971
|
}
|
|
970
972
|
|
|
973
|
+
fn copy_report_attribution_fields(
|
|
974
|
+
envelope: &serde_json::Value,
|
|
975
|
+
out: &mut serde_json::Map<String, serde_json::Value>,
|
|
976
|
+
) {
|
|
977
|
+
for key in [
|
|
978
|
+
"attributed_message_id",
|
|
979
|
+
"attribution_scope",
|
|
980
|
+
"task_id_source",
|
|
981
|
+
] {
|
|
982
|
+
if let Some(value) = envelope.get(key) {
|
|
983
|
+
out.insert(key.to_string(), value.clone());
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
971
988
|
fn fallback_primary_error_text(cli_primary_error: Option<&str>, observed: String) -> String {
|
|
972
989
|
match cli_primary_error.filter(|error| !error.trim().is_empty()) {
|
|
973
990
|
Some(error) => format!("{error}; {observed}"),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//! paths or behaviour. It introduces a shared vocabulary that Stage 2 (owner
|
|
6
6
|
//! repository), Stage 5 (per-team runtime state), and Stage 6 (per-team
|
|
7
7
|
//! coordinator) will all consume, so that no later stage has to hand-build
|
|
8
|
-
//! `.team/runtime/<team_key>/...` paths or guess the canonical team_key from
|
|
8
|
+
//! `.team/runtime/teams/<team_key>/...` paths or guess the canonical team_key from
|
|
9
9
|
//! a display name.
|
|
10
10
|
//!
|
|
11
11
|
//! Canonical rules:
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
//! - For the foundation slice, `TeamScope::new(team_key)` accepts whatever
|
|
16
16
|
//! the caller already resolved (state/selector + state/persist already do
|
|
17
17
|
//! this work). Slug/hash promotion to global uniqueness lands in Stage 5.
|
|
18
|
-
//! - `TeamRuntimePaths` is the SINGLE place where the
|
|
19
|
-
//! `.team/runtime/<team_key>/` layout is constructed. Anywhere
|
|
20
|
-
//! code today hand-joins
|
|
21
|
-
//!
|
|
18
|
+
//! - `TeamRuntimePaths` is the SINGLE place where the future B3
|
|
19
|
+
//! `.team/runtime/teams/<team_key>/` layout is constructed. Anywhere
|
|
20
|
+
//! downstream code today hand-joins runtime team paths should migrate to
|
|
21
|
+
//! call a method on `TeamRuntimePaths` in a later stage.
|
|
22
22
|
//!
|
|
23
23
|
//! Single-team behaviour: unchanged. The foundation does not move data, does
|
|
24
24
|
//! not introduce new files, and is invoked by zero existing call sites yet.
|
|
@@ -72,15 +72,15 @@ impl TeamScope {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/// Single source of `.team/runtime/<team_key>/...` path construction.
|
|
75
|
+
/// Single source of future B3 `.team/runtime/teams/<team_key>/...` path construction.
|
|
76
76
|
///
|
|
77
77
|
/// Stage 2 (owner repository), Stage 5 (per-team state), Stage 6 (per-team
|
|
78
78
|
/// coordinator), and Stage 7 (per-team tmux socket) will all migrate from
|
|
79
79
|
/// hand-built paths to `TeamRuntimePaths`. Today nothing reads from these
|
|
80
80
|
/// methods yet — the foundation just owns the layout decision so when later
|
|
81
|
-
/// stages start writing `.team/runtime/<team_key>/state.json` (Stage 5)
|
|
82
|
-
/// `.team/runtime/<team_key>/coordinator.pid` (Stage 6), there is
|
|
83
|
-
/// one place to change the layout.
|
|
81
|
+
/// stages start writing `.team/runtime/teams/<team_key>/state.json` (Stage 5)
|
|
82
|
+
/// or `.team/runtime/teams/<team_key>/coordinator.pid` (Stage 6), there is
|
|
83
|
+
/// exactly one place to change the layout.
|
|
84
84
|
#[derive(Debug, Clone)]
|
|
85
85
|
pub struct TeamRuntimePaths {
|
|
86
86
|
workspace: PathBuf,
|
|
@@ -110,34 +110,36 @@ impl TeamRuntimePaths {
|
|
|
110
110
|
&self.team_key
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/// `.team/runtime/<team_key>/` — the team's
|
|
114
|
-
///
|
|
115
|
-
///
|
|
113
|
+
/// `.team/runtime/teams/<team_key>/` — the team's future B3 runtime
|
|
114
|
+
/// directory. It is not used as product authority until the B3 migration;
|
|
115
|
+
/// current callers must keep using `runtime_state_path`.
|
|
116
116
|
pub fn team_dir(&self) -> PathBuf {
|
|
117
|
-
runtime_dir(&self.workspace)
|
|
117
|
+
runtime_dir(&self.workspace)
|
|
118
|
+
.join("teams")
|
|
119
|
+
.join(&self.team_key)
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
///
|
|
121
|
-
///
|
|
122
|
-
///
|
|
122
|
+
/// Transitional runtime spec path. Mirrors the existing
|
|
123
|
+
/// `runtime_spec_path` helper while specs remain at
|
|
124
|
+
/// `.team/runtime/<team_key>/team.spec.yaml`.
|
|
123
125
|
pub fn spec_path(&self) -> PathBuf {
|
|
124
126
|
runtime_spec_path(&self.workspace, &self.team_key)
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
/// `.team/runtime/<team_key>/state.json` — the canonical per-team
|
|
128
|
-
/// path that
|
|
129
|
-
/// code; callers must continue using `runtime_state_path` until
|
|
129
|
+
/// `.team/runtime/teams/<team_key>/state.json` — the canonical per-team
|
|
130
|
+
/// state path that B3 will start writing. Not used by current product
|
|
131
|
+
/// code; callers must continue using `runtime_state_path` until B3
|
|
130
132
|
/// migrates the truth source.
|
|
131
133
|
pub fn state_path(&self) -> PathBuf {
|
|
132
134
|
self.team_dir().join("state.json")
|
|
133
135
|
}
|
|
134
136
|
|
|
135
|
-
/// `.team/runtime/<team_key>/coordinator.pid` —
|
|
137
|
+
/// `.team/runtime/teams/<team_key>/coordinator.pid` — future sidecar location.
|
|
136
138
|
pub fn coordinator_pid_path(&self) -> PathBuf {
|
|
137
139
|
self.team_dir().join("coordinator.pid")
|
|
138
140
|
}
|
|
139
141
|
|
|
140
|
-
/// `.team/runtime/<team_key>/coordinator.log` —
|
|
142
|
+
/// `.team/runtime/teams/<team_key>/coordinator.log` — future sidecar location.
|
|
141
143
|
pub fn coordinator_log_path(&self) -> PathBuf {
|
|
142
144
|
self.team_dir().join("coordinator.log")
|
|
143
145
|
}
|
|
@@ -240,23 +242,20 @@ mod tests {
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
#[test]
|
|
243
|
-
fn
|
|
245
|
+
fn team_runtime_paths_layout_preannounces_b3_state_dir() {
|
|
244
246
|
let paths = TeamRuntimePaths::new(PathBuf::from("/ws/proj"), "alpha");
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
);
|
|
247
|
+
let b3_team_dir = PathBuf::from("/ws/proj/.team/runtime")
|
|
248
|
+
.join("teams")
|
|
249
|
+
.join("alpha");
|
|
250
|
+
assert_eq!(paths.team_dir(), b3_team_dir);
|
|
249
251
|
assert_eq!(
|
|
250
252
|
paths.spec_path(),
|
|
251
253
|
PathBuf::from("/ws/proj/.team/runtime/alpha/team.spec.yaml")
|
|
252
254
|
);
|
|
253
|
-
assert_eq!(
|
|
254
|
-
paths.state_path(),
|
|
255
|
-
PathBuf::from("/ws/proj/.team/runtime/alpha/state.json")
|
|
256
|
-
);
|
|
255
|
+
assert_eq!(paths.state_path(), b3_team_dir.join("state.json"));
|
|
257
256
|
assert_eq!(
|
|
258
257
|
paths.coordinator_pid_path(),
|
|
259
|
-
|
|
258
|
+
b3_team_dir.join("coordinator.pid")
|
|
260
259
|
);
|
|
261
260
|
}
|
|
262
261
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.25",
|
|
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.25",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.25",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.25"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|