@team-agent/installer 0.5.41 → 0.5.43
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/adapters.rs +129 -54
- package/crates/team-agent/src/cli/diagnose.rs +1 -2
- package/crates/team-agent/src/cli/emit.rs +1 -7
- package/crates/team-agent/src/cli/helpers.rs +3 -1
- package/crates/team-agent/src/cli/leader.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +27 -7
- package/crates/team-agent/src/cli/named_address.rs +14 -5
- package/crates/team-agent/src/cli/profile.rs +19 -7
- package/crates/team-agent/src/cli/send.rs +9 -3
- package/crates/team-agent/src/cli/status.rs +8 -30
- package/crates/team-agent/src/cli/status_port.rs +1341 -1272
- package/crates/team-agent/src/cli/tests/base.rs +738 -660
- package/crates/team-agent/src/cli/tests/compile.rs +45 -18
- package/crates/team-agent/src/cli/tests/divergence.rs +462 -444
- package/crates/team-agent/src/cli/tests/lane_c.rs +365 -282
- package/crates/team-agent/src/cli/tests/leader_watch.rs +356 -329
- package/crates/team-agent/src/cli/tests/main_preserved.rs +672 -564
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +284 -224
- package/crates/team-agent/src/cli/tests/mod.rs +17 -7
- package/crates/team-agent/src/cli/tests/named_address.rs +8 -2
- package/crates/team-agent/src/cli/tests/peer_allow.rs +10 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +314 -273
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +26 -15
- package/crates/team-agent/src/cli/tests/status_send.rs +707 -629
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +20 -4
- package/crates/team-agent/src/cli/tests/verb_profile.rs +46 -17
- package/crates/team-agent/src/cli/tests/verb_validate.rs +15 -3
- package/crates/team-agent/src/codex_app_server.rs +2 -5
- package/crates/team-agent/src/compiler/tests.rs +139 -33
- package/crates/team-agent/src/compiler.rs +55 -22
- package/crates/team-agent/src/conpty/backend.rs +23 -33
- package/crates/team-agent/src/coordinator/backoff.rs +2 -7
- package/crates/team-agent/src/coordinator/conpty_shim.rs +55 -67
- package/crates/team-agent/src/coordinator/health.rs +46 -31
- package/crates/team-agent/src/coordinator/mod.rs +3 -3
- package/crates/team-agent/src/coordinator/orphan.rs +22 -10
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +47 -52
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +55 -19
- package/crates/team-agent/src/coordinator/tests/basics.rs +179 -41
- package/crates/team-agent/src/coordinator/tests/daemon.rs +53 -13
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +78 -19
- package/crates/team-agent/src/coordinator/tests/main_preserved.rs +61 -11
- package/crates/team-agent/src/coordinator/tests/mod.rs +33 -39
- package/crates/team-agent/src/coordinator/tests/spine.rs +52 -12
- package/crates/team-agent/src/coordinator/tests/takeover.rs +73 -15
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +50 -15
- package/crates/team-agent/src/coordinator/tests/watch.rs +74 -20
- package/crates/team-agent/src/coordinator/tick.rs +19 -1
- package/crates/team-agent/src/db/message_store.rs +138 -30
- package/crates/team-agent/src/db/migration.rs +249 -61
- package/crates/team-agent/src/db/schema.rs +303 -82
- package/crates/team-agent/src/diagnose/comms.rs +9 -2
- package/crates/team-agent/src/diagnose/mod.rs +1 -3
- package/crates/team-agent/src/diagnose/orphans.rs +79 -61
- package/crates/team-agent/src/event_log.rs +70 -16
- package/crates/team-agent/src/layout/manager.rs +15 -4
- package/crates/team-agent/src/layout/mod.rs +4 -4
- package/crates/team-agent/src/layout/overlay.rs +10 -3
- package/crates/team-agent/src/layout/placement.rs +5 -1
- package/crates/team-agent/src/layout/recovery.rs +4 -2
- package/crates/team-agent/src/layout/runtime_sessions.rs +7 -7
- package/crates/team-agent/src/layout/sessions.rs +17 -9
- package/crates/team-agent/src/layout/tmux_endpoint.rs +1 -1
- package/crates/team-agent/src/layout/worker_env.rs +52 -19
- package/crates/team-agent/src/leader/helpers.rs +7 -1
- package/crates/team-agent/src/leader/lease.rs +195 -82
- package/crates/team-agent/src/leader/owner_bind.rs +55 -22
- package/crates/team-agent/src/leader/rediscover/tests.rs +88 -24
- package/crates/team-agent/src/leader/rediscover.rs +74 -25
- package/crates/team-agent/src/leader/start.rs +75 -54
- package/crates/team-agent/src/leader/takeover.rs +46 -11
- package/crates/team-agent/src/leader/tests/basics.rs +320 -167
- package/crates/team-agent/src/leader/tests/byte_findings.rs +361 -219
- package/crates/team-agent/src/leader/tests/identity.rs +428 -356
- package/crates/team-agent/src/leader/tests/idle.rs +285 -254
- package/crates/team-agent/src/leader/tests/lease_api.rs +338 -274
- package/crates/team-agent/src/leader/tests/lease_claim.rs +643 -593
- package/crates/team-agent/src/leader/tests/mod.rs +115 -99
- package/crates/team-agent/src/leader/tests/rediscover.rs +74 -22
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +237 -211
- package/crates/team-agent/src/lib.rs +4 -4
- package/crates/team-agent/src/lifecycle/display.rs +7 -3
- package/crates/team-agent/src/lifecycle/launch.rs +6 -1
- package/crates/team-agent/src/lifecycle/mod.rs +9 -1
- package/crates/team-agent/src/lifecycle/profile_launch.rs +77 -34
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +3 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -6
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +1 -4
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +6 -5
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -22
- package/crates/team-agent/src/lifecycle/restart/remove.rs +45 -35
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +63 -17
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +251 -84
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +198 -48
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +152 -32
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -5
- package/crates/team-agent/src/lifecycle/tests.rs +2 -2
- package/crates/team-agent/src/main.rs +4 -4
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +47 -20
- package/crates/team-agent/src/mcp_server/mod.rs +11 -2
- package/crates/team-agent/src/mcp_server/types.rs +14 -3
- package/crates/team-agent/src/mcp_server/wire.rs +230 -68
- package/crates/team-agent/src/messaging/delivery.rs +20 -18
- package/crates/team-agent/src/messaging/helpers.rs +25 -4
- package/crates/team-agent/src/messaging/leader_receiver.rs +4 -5
- package/crates/team-agent/src/messaging/mod.rs +2 -3
- package/crates/team-agent/src/messaging/selftest.rs +46 -26
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +47 -12
- package/crates/team-agent/src/messaging/tests/runtime.rs +57 -22
- package/crates/team-agent/src/messaging/tests/spine.rs +154 -40
- package/crates/team-agent/src/messaging/tests/wave2.rs +31 -32
- package/crates/team-agent/src/messaging/trust.rs +25 -2
- package/crates/team-agent/src/messaging/watchers.rs +37 -9
- package/crates/team-agent/src/model/enums.rs +65 -16
- package/crates/team-agent/src/model/ids.rs +12 -3
- package/crates/team-agent/src/model/paths.rs +28 -7
- package/crates/team-agent/src/model/permissions.rs +176 -33
- package/crates/team-agent/src/model/routing.rs +66 -20
- package/crates/team-agent/src/model/spec.rs +365 -69
- package/crates/team-agent/src/model/task_graph.rs +36 -9
- package/crates/team-agent/src/model/yaml/tests.rs +24 -6
- package/crates/team-agent/src/model/yaml.rs +7 -6
- package/crates/team-agent/src/packaging/install.rs +23 -9
- package/crates/team-agent/src/packaging/migrate.rs +5 -7
- package/crates/team-agent/src/packaging/mod.rs +9 -1
- package/crates/team-agent/src/packaging/repair.rs +13 -6
- package/crates/team-agent/src/packaging/tests.rs +63 -16
- package/crates/team-agent/src/packaging/types.rs +22 -7
- package/crates/team-agent/src/platform/argv.rs +4 -1
- package/crates/team-agent/src/platform/file_lock.rs +22 -8
- package/crates/team-agent/src/platform/process.rs +21 -22
- package/crates/team-agent/src/provider/adapters/claude.rs +1 -3
- package/crates/team-agent/src/provider/approvals/parsing.rs +134 -26
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +13 -3
- package/crates/team-agent/src/provider/classify.rs +127 -42
- package/crates/team-agent/src/provider/faults.rs +17 -5
- package/crates/team-agent/src/provider/helpers.rs +6 -5
- package/crates/team-agent/src/provider/startup_prompt.rs +75 -27
- package/crates/team-agent/src/state/repository.rs +27 -14
- package/crates/team-agent/src/tmux_backend/tests.rs +1637 -1398
- package/crates/team-agent/src/tmux_backend.rs +80 -44
- package/crates/team-agent/src/topology.rs +27 -20
- package/crates/team-agent/src/transport/test_support.rs +20 -23
- package/crates/team-agent/src/transport/tests/behavior.rs +292 -293
- package/crates/team-agent/src/transport/tests/mod.rs +178 -187
- package/crates/team-agent/src/transport/tests/wire.rs +561 -525
- package/crates/team-agent/src/transport.rs +12 -17
- package/crates/team-agent/src/transport_factory.rs +29 -14
- package/package.json +4 -4
|
@@ -107,186 +107,241 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
107
107
|
std::fs::write(ws.join("team.spec.yaml"), spec).unwrap();
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
110
|
+
// ── ACK-CRACK [P1 byte-shape] — acknowledge_idle must write golden's TTL suppression shape ───────
|
|
111
|
+
// golden runtime.py:680-688: manual-acknowledge persists
|
|
112
|
+
// coordinator.idle_acknowledged[team] = {acknowledged_at, expires_at, ttl_seconds}
|
|
113
|
+
// coordinator.suppressed_idle_alerts[team][worker].idle_fallback =
|
|
114
|
+
// {suppressed_at, suppressed_by:"manual_acknowledge", manual_acknowledge:true, expires_at, ttl_seconds}
|
|
115
|
+
// The clear logic does datetime.fromisoformat(entry["expires_at"]); a MISSING expires_at -> ValueError
|
|
116
|
+
// -> "invalid_suppression_timestamp" -> immediate self-clear (latent crack once detect_idle_fallbacks
|
|
117
|
+
// is ported). So BOTH idle_acknowledged and the entry MUST carry a non-empty expires_at.
|
|
118
|
+
#[test]
|
|
119
|
+
fn acknowledge_idle_writes_golden_ttl_suppression_shape() {
|
|
120
|
+
let ws = tmp_workspace();
|
|
121
|
+
crate::state::persist::save_runtime_state(
|
|
122
|
+
&ws,
|
|
123
|
+
&serde_json::json!({
|
|
124
|
+
"active_team_key": "teamX",
|
|
125
|
+
"agents": {"w1": {"status": "running", "provider": "codex"}}
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
.unwrap();
|
|
129
|
+
let _ = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
|
|
130
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
131
|
+
let ack = &state["coordinator"]["idle_acknowledged"]["teamX"];
|
|
132
|
+
assert!(
|
|
133
|
+
ack.get("expires_at")
|
|
134
|
+
.and_then(serde_json::Value::as_str)
|
|
135
|
+
.is_some_and(|s| !s.is_empty()),
|
|
136
|
+
"ACK-CRACK: idle_acknowledged[team] must carry a non-empty expires_at (golden); got {ack}"
|
|
137
|
+
);
|
|
138
|
+
assert!(
|
|
139
|
+
ack.get("ttl_seconds").is_some(),
|
|
140
|
+
"idle_acknowledged[team] must carry ttl_seconds; got {ack}"
|
|
141
|
+
);
|
|
142
|
+
let entry = &state["coordinator"]["suppressed_idle_alerts"]["teamX"]["w1"]["idle_fallback"];
|
|
143
|
+
assert!(
|
|
139
144
|
entry.get("expires_at").and_then(serde_json::Value::as_str).is_some_and(|s| !s.is_empty()),
|
|
140
145
|
"ACK-CRACK: the manual-ack suppression entry must carry expires_at (else clear logic ValueErrors \
|
|
141
146
|
-> instant self-clear); got {entry}"
|
|
142
147
|
);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
148
|
+
assert_eq!(
|
|
149
|
+
entry["suppressed_by"],
|
|
150
|
+
serde_json::json!("manual_acknowledge"),
|
|
151
|
+
"golden suppressed_by; got {entry}"
|
|
152
|
+
);
|
|
153
|
+
assert_eq!(
|
|
154
|
+
entry["manual_acknowledge"],
|
|
155
|
+
serde_json::json!(true),
|
|
156
|
+
"golden manual_acknowledge:true; got {entry}"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
// ── ACK return-shape [P1 byte-parity] — acknowledge_idle must RETURN golden's keys ───────────────
|
|
160
|
+
// golden runtime.py:691: return {ok, team, agent_id, acknowledged_at, expires_at, ttl_seconds}.
|
|
161
|
+
// Rust (cli/mod.rs) returns only {ok, team, ttl_seconds} -> missing agent_id, acknowledged_at,
|
|
162
|
+
// expires_at. RED. (acknowledged_at/expires_at are the same values written into idle_acknowledged.)
|
|
163
|
+
#[test]
|
|
164
|
+
fn acknowledge_idle_return_carries_golden_keys() {
|
|
165
|
+
let ws = tmp_workspace();
|
|
166
|
+
crate::state::persist::save_runtime_state(
|
|
154
167
|
&ws,
|
|
155
168
|
&serde_json::json!({ "active_team_key": "teamX", "agents": {"w1": {"status": "running", "provider": "codex"}} }),
|
|
156
169
|
)
|
|
157
170
|
.unwrap();
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
let r = lifecycle_port::acknowledge_idle(&ws, None).expect("acknowledge_idle ok");
|
|
172
|
+
let obj = r.as_object().expect("ack returns a dict");
|
|
173
|
+
for key in [
|
|
174
|
+
"ok",
|
|
175
|
+
"team",
|
|
176
|
+
"agent_id",
|
|
177
|
+
"acknowledged_at",
|
|
178
|
+
"expires_at",
|
|
179
|
+
"ttl_seconds",
|
|
180
|
+
] {
|
|
168
181
|
assert!(
|
|
169
|
-
obj.
|
|
170
|
-
"ACK return-shape:
|
|
182
|
+
obj.contains_key(key),
|
|
183
|
+
"ACK return-shape: golden return carries `{key}` (runtime.py:691: ok/team/agent_id/\
|
|
184
|
+
acknowledged_at/expires_at/ttl_seconds); Rust omits it. got keys {:?}",
|
|
185
|
+
obj.keys().collect::<Vec<_>>()
|
|
171
186
|
);
|
|
172
|
-
let _ = std::fs::remove_dir_all(&ws);
|
|
173
187
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
assert!(
|
|
189
|
+
obj.get("expires_at")
|
|
190
|
+
.and_then(serde_json::Value::as_str)
|
|
191
|
+
.is_some_and(|s| !s.is_empty()),
|
|
192
|
+
"ACK return-shape: expires_at must be a non-empty timestamp; got {r}"
|
|
193
|
+
);
|
|
194
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
195
|
+
}
|
|
196
|
+
// ── BUG-2 [real bug] — inbox must RETURN the stored messages, not a hardcoded []. ────────────────
|
|
197
|
+
// Golden status/inbox.py:35-38 -> MessageStore.inbox(agent_id) (core.py:242, owner_team_id=None):
|
|
198
|
+
// select <MESSAGE_SELECT> from messages where sender = ? or recipient = ? order by created_at desc
|
|
199
|
+
// limit ? -> then reversed(rows) (chronological asc). At THIS call site owner_team_id is None, so
|
|
200
|
+
// there is NO team filter — a sent-and-stored message (recipient=w1, status='accepted') must show
|
|
201
|
+
// in `inbox w1`. Rust mod.rs:144 is a stub: `let _=(workspace,limit,as_json); "messages":[]`. So
|
|
202
|
+
// the row is in team.db but inbox always returns [] -> RED. The shape test above only proves the
|
|
203
|
+
// empty-state envelope; THIS proves the message actually surfaces.
|
|
204
|
+
#[test]
|
|
205
|
+
fn inbox_returns_stored_message_for_recipient() {
|
|
206
|
+
let ws = tmp_workspace();
|
|
207
|
+
let store = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
208
|
+
let mid = store
|
|
209
|
+
.create_message(None, "leader", "w1", "hello w1", None, true, None)
|
|
210
|
+
.unwrap();
|
|
211
|
+
let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
|
|
212
|
+
let messages = v["messages"].as_array().expect("messages array");
|
|
213
|
+
assert_eq!(
|
|
192
214
|
messages.len(),
|
|
193
215
|
1,
|
|
194
216
|
"golden inbox(w1) must return the stored recipient=w1 row; the stub returns [] -> RED. got {v}"
|
|
195
217
|
);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
218
|
+
let m = &messages[0];
|
|
219
|
+
assert_eq!(
|
|
220
|
+
m["message_id"],
|
|
221
|
+
json!(mid),
|
|
222
|
+
"the returned row is the message we stored"
|
|
223
|
+
);
|
|
224
|
+
assert_eq!(m["recipient"], json!("w1"));
|
|
225
|
+
assert_eq!(m["sender"], json!("leader"));
|
|
226
|
+
assert_eq!(m["content"], json!("hello w1"));
|
|
227
|
+
assert_eq!(
|
|
228
|
+
m["status"],
|
|
229
|
+
json!("accepted"),
|
|
230
|
+
"create_message persists status='accepted'"
|
|
231
|
+
);
|
|
232
|
+
// NULL owner_team_id semantics: status.inbox() calls MessageStore.inbox(agent) with
|
|
233
|
+
// owner_team_id=None (no team clause), so a NULL-owner message MUST surface for its recipient.
|
|
234
|
+
assert_eq!(
|
|
235
|
+
m["owner_team_id"],
|
|
236
|
+
json!(null),
|
|
237
|
+
"the stored message's owner_team_id is NULL and still returned"
|
|
238
|
+
);
|
|
239
|
+
// byte-faithful raw-row columns: requires_ack is the 0/1 INT; artifact_refs the literal text "[]".
|
|
240
|
+
assert_eq!(
|
|
241
|
+
m["requires_ack"],
|
|
242
|
+
json!(1),
|
|
243
|
+
"requires_ack is the 0/1 int, not a bool"
|
|
244
|
+
);
|
|
245
|
+
assert_eq!(
|
|
246
|
+
m["artifact_refs"],
|
|
247
|
+
json!("[]"),
|
|
248
|
+
"artifact_refs is the raw text column, not parsed"
|
|
249
|
+
);
|
|
250
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
251
|
+
}
|
|
252
|
+
// ── BUG-2 (match scope) — inbox(agent) returns rows where sender==agent OR recipient==agent, and
|
|
253
|
+
// EXCLUDES messages for other agents. Membership+exclusion form (not strict index order) so the
|
|
254
|
+
// test is deterministic regardless of created_at sub-second ties; golden order is chronological asc. ─
|
|
255
|
+
#[test]
|
|
256
|
+
fn inbox_matches_sender_or_recipient_and_excludes_others() {
|
|
257
|
+
let ws = tmp_workspace();
|
|
258
|
+
let store = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
259
|
+
store
|
|
260
|
+
.create_message(None, "leader", "w1", "to w1", None, true, None)
|
|
261
|
+
.unwrap();
|
|
262
|
+
store
|
|
263
|
+
.create_message(None, "w1", "leader", "from w1", None, true, None)
|
|
264
|
+
.unwrap();
|
|
265
|
+
store
|
|
266
|
+
.create_message(None, "leader", "w2", "unrelated to w2", None, true, None)
|
|
267
|
+
.unwrap();
|
|
268
|
+
let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
|
|
269
|
+
let messages = v["messages"].as_array().expect("messages array");
|
|
270
|
+
let mut contents: Vec<String> = messages
|
|
271
|
+
.iter()
|
|
272
|
+
.map(|m| m["content"].as_str().unwrap().to_string())
|
|
273
|
+
.collect();
|
|
274
|
+
contents.sort();
|
|
275
|
+
assert_eq!(
|
|
226
276
|
contents,
|
|
227
277
|
vec!["from w1".to_string(), "to w1".to_string()],
|
|
228
278
|
"inbox(w1) must return BOTH the recipient=w1 and sender=w1 rows and EXCLUDE the w2 message; \
|
|
229
279
|
the stub returns [] -> RED. got {contents:?}"
|
|
230
280
|
);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
281
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
282
|
+
}
|
|
283
|
+
// ── BUG-4 [real bug] — peek must resolve the agent terminal via state `session:window` (golden),
|
|
284
|
+
// NOT a stored `pane_id` field. A live worker present in state (with a `window`, on the team's `-L`
|
|
285
|
+
// socket) must NOT be fabricated as "agent pane not found". ──────────────────────────────────────
|
|
286
|
+
// Golden status/peek.py:35-44: agent = state["agents"][id]; window = agent.get("window", id);
|
|
287
|
+
// if not session_name or not _tmux_window_exists(session_name, window): raise "agent terminal is
|
|
288
|
+
// not available: <id>"; else `tmux capture-pane -t session:window`. It NEVER reads a stored pane_id.
|
|
289
|
+
// Probed live (/tmp/probe_peek.py): a present worker whose window is absent on the socket raises
|
|
290
|
+
// `agent terminal is not available: w1`; a missing agent raises `unknown agent id: <id>`. Rust
|
|
291
|
+
// cmd_peek (adapters.rs:231 + agent_pane_id:279) keys off agent_state pane_id/pane/tmux_pane_id and
|
|
292
|
+
// returns {ok:false,error:"agent pane not found"} when absent — so a NORMAL live worker (window in
|
|
293
|
+
// state, no pane_id field) is mis-reported as not found. That is the CP-1 pane-resolution divergence.
|
|
294
|
+
//
|
|
295
|
+
// Deterministic without real tmux: on a host with no live session, the golden-correct peek resolves
|
|
296
|
+
// session:window, finds the window absent on the socket, and yields "agent terminal is not available:
|
|
297
|
+
// w1" — NOT "agent pane not found". (The window-on-socket -> real raw-screen capture positive case is
|
|
298
|
+
// real-machine; see the #[ignore] dispatch_routes_peek_real_machine.)
|
|
299
|
+
#[test]
|
|
300
|
+
fn peek_resolves_live_worker_via_session_window_not_pane_id_field() {
|
|
301
|
+
let ws = tmp_workspace();
|
|
302
|
+
// a live worker: present in state with a `window`, session_name set, but NO stored pane_id field.
|
|
303
|
+
// session name is unique so a real tmux session on the dev host can't accidentally satisfy it.
|
|
304
|
+
crate::state::persist::save_runtime_state(
|
|
305
|
+
&ws,
|
|
306
|
+
&json!({
|
|
307
|
+
"session_name": "team-peek-red-probe-x9q",
|
|
308
|
+
"agents": {"w1": {"status": "running", "provider": "codex", "window": "w1"}}
|
|
309
|
+
}),
|
|
310
|
+
)
|
|
311
|
+
.unwrap();
|
|
312
|
+
let args = PeekArgs {
|
|
313
|
+
agent: "w1".to_string(),
|
|
314
|
+
workspace: ws.clone(),
|
|
315
|
+
tail: 20,
|
|
316
|
+
head: None,
|
|
317
|
+
search: None,
|
|
318
|
+
allow_raw_screen: true,
|
|
319
|
+
json: true,
|
|
320
|
+
};
|
|
321
|
+
let text = outcome_text(cmd_peek(&args));
|
|
322
|
+
assert!(
|
|
273
323
|
!text.contains("agent pane not found"),
|
|
274
324
|
"peek keys off a stored pane_id field and fabricates 'agent pane not found' for a live worker \
|
|
275
325
|
that has a `window` in state; golden resolves session:window and never reads pane_id. got: {text}"
|
|
276
326
|
);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
327
|
+
assert!(
|
|
328
|
+
text.contains("agent terminal is not available: w1"),
|
|
329
|
+
"golden status/peek.py: a worker whose window is not on the socket yields \
|
|
280
330
|
'agent terminal is not available: w1' (window-existence via session:window), NOT a \
|
|
281
331
|
pane_id-keyed error. got: {text}"
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
332
|
+
);
|
|
333
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
334
|
+
}
|
|
335
|
+
#[test]
|
|
336
|
+
fn ux_doctor_secret_scan_is_present_and_non_triggering_for_normal_paths() {
|
|
337
|
+
let ws = tmp_workspace();
|
|
338
|
+
std::fs::write(
|
|
339
|
+
ws.join("normal-role.md"),
|
|
340
|
+
"---\nname: worker\nprovider: codex\n---\nUse /tmp/team-agent.\n",
|
|
341
|
+
)
|
|
342
|
+
.unwrap();
|
|
343
|
+
let value = json_output(
|
|
344
|
+
cmd_doctor(&DoctorArgs {
|
|
290
345
|
spec: None,
|
|
291
346
|
workspace: ws.clone(),
|
|
292
347
|
gate: None,
|
|
@@ -297,16 +352,23 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
297
352
|
cleanup_orphans: false,
|
|
298
353
|
confirm: false,
|
|
299
354
|
json: true,
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
355
|
+
})
|
|
356
|
+
.expect("doctor"),
|
|
357
|
+
);
|
|
358
|
+
assert_eq!(value.pointer("/secret_scan/ok"), Some(&json!(true)));
|
|
359
|
+
assert_eq!(value.pointer("/secret_scan/findings"), Some(&json!([])));
|
|
360
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
361
|
+
}
|
|
362
|
+
#[test]
|
|
363
|
+
fn ux_doctor_secret_scan_findings_name_the_exact_trigger() {
|
|
364
|
+
let ws = tmp_workspace();
|
|
365
|
+
std::fs::write(
|
|
366
|
+
ws.join("leaky-role.md"),
|
|
367
|
+
"OPENAI_API_KEY=sk-test-red-contract\n",
|
|
368
|
+
)
|
|
369
|
+
.unwrap();
|
|
370
|
+
let value = json_output(
|
|
371
|
+
cmd_doctor(&DoctorArgs {
|
|
310
372
|
spec: None,
|
|
311
373
|
workspace: ws.clone(),
|
|
312
374
|
gate: None,
|
|
@@ -317,449 +379,495 @@ fn seed_team_spec(ws: &std::path::Path) {
|
|
|
317
379
|
cleanup_orphans: false,
|
|
318
380
|
confirm: false,
|
|
319
381
|
json: true,
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
382
|
+
})
|
|
383
|
+
.expect("doctor"),
|
|
384
|
+
);
|
|
385
|
+
let finding = value
|
|
386
|
+
.pointer("/secret_scan/findings/0")
|
|
387
|
+
.and_then(serde_json::Value::as_object)
|
|
388
|
+
.expect("secret-scan must report the concrete trigger");
|
|
389
|
+
for key in ["path", "line", "rule", "match_excerpt"] {
|
|
390
|
+
assert!(
|
|
391
|
+
finding.contains_key(key),
|
|
392
|
+
"secret-scan finding missing `{key}`: {finding:?}"
|
|
393
|
+
);
|
|
329
394
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
395
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
396
|
+
}
|
|
397
|
+
#[test]
|
|
398
|
+
fn ux_wait_ready_does_not_report_ready_true_without_ready_runtime_state() {
|
|
399
|
+
let ws = tmp_workspace();
|
|
400
|
+
crate::state::persist::save_runtime_state(
|
|
401
|
+
&ws,
|
|
402
|
+
&json!({
|
|
403
|
+
"agents": {"w1": {"status": "starting"}},
|
|
404
|
+
"tasks": [{"id": "t1", "assignee": "w1", "status": "pending"}],
|
|
405
|
+
"leader_receiver": {"status": "attached"},
|
|
406
|
+
}),
|
|
407
|
+
)
|
|
408
|
+
.unwrap();
|
|
409
|
+
let value = json_output(
|
|
410
|
+
cmd_wait_ready(&WaitReadyArgs {
|
|
343
411
|
workspace: ws.clone(),
|
|
344
412
|
timeout: 0.0,
|
|
345
413
|
json: true,
|
|
346
414
|
team: None,
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
415
|
+
})
|
|
416
|
+
.expect("wait-ready"),
|
|
417
|
+
);
|
|
418
|
+
assert_eq!(
|
|
419
|
+
value["ok"],
|
|
420
|
+
json!(false),
|
|
421
|
+
"wait-ready must not fake success before workers are ready"
|
|
422
|
+
);
|
|
423
|
+
assert_eq!(value.pointer("/readiness/ready"), Some(&json!(false)));
|
|
424
|
+
assert!(
|
|
425
|
+
value["summary"]
|
|
426
|
+
.as_str()
|
|
427
|
+
.unwrap_or("")
|
|
428
|
+
.contains("not ready"),
|
|
429
|
+
"wait-ready false state should explain not-ready status, got {value:?}"
|
|
430
|
+
);
|
|
431
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
432
|
+
}
|
|
433
|
+
#[test]
|
|
434
|
+
fn wait_ready_fake_quick_start_counts_mcp_config_and_task_prompt_delivery() {
|
|
435
|
+
let ws = tmp_workspace();
|
|
436
|
+
let mcp_config = ws
|
|
437
|
+
.join(".team")
|
|
438
|
+
.join("runtime")
|
|
439
|
+
.join("agents")
|
|
440
|
+
.join("fake_impl")
|
|
441
|
+
.join("mcp_config.json");
|
|
442
|
+
std::fs::create_dir_all(mcp_config.parent().unwrap()).unwrap();
|
|
443
|
+
std::fs::write(&mcp_config, r#"{"mcpServers":{"team-agent":{}}}"#).unwrap();
|
|
444
|
+
crate::state::persist::save_runtime_state(
|
|
445
|
+
&ws,
|
|
446
|
+
&json!({
|
|
447
|
+
"session_name": "team-fake-ready",
|
|
448
|
+
"agents": {
|
|
449
|
+
"fake_impl": {
|
|
450
|
+
"status": "running",
|
|
451
|
+
"provider": "fake",
|
|
452
|
+
"mcp_config": mcp_config.to_string_lossy(),
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
"tasks": [{
|
|
456
|
+
"id": "task_impl",
|
|
457
|
+
"assignee": "fake_impl",
|
|
458
|
+
"status": "pending",
|
|
459
|
+
}],
|
|
460
|
+
"leader_receiver": {"status": "attached"},
|
|
461
|
+
}),
|
|
462
|
+
)
|
|
463
|
+
.unwrap();
|
|
464
|
+
let store = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
465
|
+
store
|
|
466
|
+
.create_message(
|
|
467
|
+
Some("task_impl"),
|
|
468
|
+
"leader",
|
|
469
|
+
"fake_impl",
|
|
470
|
+
"initial task prompt",
|
|
471
|
+
None,
|
|
472
|
+
true,
|
|
473
|
+
None,
|
|
380
474
|
)
|
|
381
475
|
.unwrap();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
.create_message(Some("task_impl"), "leader", "fake_impl", "initial task prompt", None, true, None)
|
|
385
|
-
.unwrap();
|
|
386
|
-
let value = json_output(cmd_wait_ready(&WaitReadyArgs {
|
|
476
|
+
let value = json_output(
|
|
477
|
+
cmd_wait_ready(&WaitReadyArgs {
|
|
387
478
|
workspace: ws.clone(),
|
|
388
479
|
timeout: 0.0,
|
|
389
480
|
json: true,
|
|
390
481
|
team: None,
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
)
|
|
397
|
-
|
|
482
|
+
})
|
|
483
|
+
.expect("wait-ready"),
|
|
484
|
+
);
|
|
485
|
+
assert_eq!(
|
|
486
|
+
value.pointer("/readiness/mcp_ready"),
|
|
487
|
+
Some(&json!(true)),
|
|
488
|
+
"fake quick-start readiness must treat an existing per-agent mcp_config file as mcp_ready"
|
|
489
|
+
);
|
|
490
|
+
assert_eq!(
|
|
398
491
|
value.pointer("/readiness/task_prompt_delivered"),
|
|
399
492
|
Some(&json!(true)),
|
|
400
493
|
"fake quick-start readiness must treat message_counts>0 / persisted initial task prompt as task_prompt_delivered"
|
|
401
494
|
);
|
|
402
|
-
|
|
495
|
+
assert_eq!(
|
|
403
496
|
value.pointer("/readiness/ready"),
|
|
404
497
|
Some(&json!(true)),
|
|
405
498
|
"process_started + cli_prompt_ready alone is incomplete; mcp_ready and task_prompt_delivered must also be satisfied"
|
|
406
499
|
);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
500
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
501
|
+
}
|
|
502
|
+
fn valid_result_envelope() -> serde_json::Value {
|
|
503
|
+
json!({
|
|
504
|
+
"schema_version": "result_envelope_v1",
|
|
505
|
+
"task_id": "task_impl",
|
|
506
|
+
"agent_id": "fake_impl",
|
|
507
|
+
"status": "success",
|
|
508
|
+
"summary": "done",
|
|
509
|
+
"artifacts": [],
|
|
510
|
+
"changes": [],
|
|
511
|
+
"tests": [{"command": "cargo test", "status": "passed"}],
|
|
512
|
+
"risks": [],
|
|
513
|
+
"next_actions": []
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
fn seed_collect_state(ws: &std::path::Path) {
|
|
517
|
+
seed_team_spec(ws);
|
|
518
|
+
crate::state::persist::save_runtime_state(
|
|
519
|
+
ws,
|
|
520
|
+
&json!({
|
|
521
|
+
"agents": {"fake_impl": {"status": "idle"}},
|
|
522
|
+
"tasks": [{
|
|
523
|
+
"id": "task_impl",
|
|
524
|
+
"title": "Fake implementation",
|
|
525
|
+
"type": "implementation",
|
|
526
|
+
"assignee": "fake_impl",
|
|
527
|
+
"deps": [],
|
|
528
|
+
"acceptance": ["fake result collected"],
|
|
529
|
+
"status": "pending",
|
|
530
|
+
"requires_tools": [],
|
|
531
|
+
"files": [],
|
|
532
|
+
"risk": "low"
|
|
533
|
+
}],
|
|
534
|
+
"session_name": Value::Null,
|
|
535
|
+
"active_team_key": Value::Null,
|
|
536
|
+
"spec_path": ws.join("team.spec.yaml").to_string_lossy()
|
|
537
|
+
}),
|
|
538
|
+
)
|
|
539
|
+
.unwrap();
|
|
540
|
+
}
|
|
541
|
+
fn seed_uncollected_result(ws: &std::path::Path, result_id: &str) {
|
|
542
|
+
let store = crate::message_store::MessageStore::open(ws).unwrap();
|
|
543
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
544
|
+
conn.execute(
|
|
452
545
|
"insert into results(
|
|
453
546
|
result_id, owner_team_id, task_id, agent_id, envelope, status, created_at
|
|
454
547
|
) values (?1, null, 'task_impl', 'fake_impl', ?2, 'success', '2026-06-02T10:00:00+00:00')",
|
|
455
548
|
rusqlite::params![result_id, valid_result_envelope().to_string()],
|
|
456
549
|
)
|
|
457
550
|
.unwrap();
|
|
551
|
+
}
|
|
552
|
+
fn read_state(ws: &std::path::Path) -> serde_json::Value {
|
|
553
|
+
serde_json::from_str(
|
|
554
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(ws)).unwrap(),
|
|
555
|
+
)
|
|
556
|
+
.unwrap()
|
|
557
|
+
}
|
|
558
|
+
fn read_events(ws: &std::path::Path) -> Vec<serde_json::Value> {
|
|
559
|
+
crate::event_log::EventLog::new(ws).tail(50).unwrap()
|
|
560
|
+
}
|
|
561
|
+
fn seeded_team_key(ws: &std::path::Path) -> String {
|
|
562
|
+
ws.file_name().unwrap().to_string_lossy().to_string()
|
|
563
|
+
}
|
|
564
|
+
fn json_output(result: CmdResult) -> serde_json::Value {
|
|
565
|
+
match result.output {
|
|
566
|
+
CmdOutput::Json(v) => v,
|
|
567
|
+
other => panic!("expected JSON output, got {other:?}"),
|
|
458
568
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
)
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
team: None,
|
|
504
|
-
from_spec: false,
|
|
505
|
-
confirm: false,
|
|
506
|
-
force: false,
|
|
507
|
-
json: true,
|
|
508
|
-
})
|
|
509
|
-
.unwrap(),
|
|
510
|
-
);
|
|
511
|
-
assert_eq!(out["ok"], json!(false));
|
|
512
|
-
assert_eq!(out["status"], json!("refused"));
|
|
513
|
-
assert_eq!(out["reason"], json!("remove_agent_flags_required"));
|
|
514
|
-
for flag in ["--from-spec", "--confirm", "--force"] {
|
|
515
|
-
assert!(
|
|
516
|
-
out["error"].as_str().is_some_and(|s| s.contains(flag))
|
|
517
|
-
&& out["action"].as_str().is_some_and(|s| s.contains(flag))
|
|
518
|
-
&& out["command"].as_str().is_some_and(|s| s.contains(flag)),
|
|
519
|
-
"refusal must mention {flag} in error/action/copyable command; got {out}"
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
let with_confirm = json_output(
|
|
523
|
-
cmd_remove_agent(&RemoveAgentArgs {
|
|
524
|
-
agent: "fake_impl".to_string(),
|
|
525
|
-
workspace: ws.clone(),
|
|
526
|
-
team: None,
|
|
527
|
-
from_spec: false,
|
|
528
|
-
confirm: true,
|
|
529
|
-
force: false,
|
|
530
|
-
json: true,
|
|
531
|
-
})
|
|
532
|
-
.unwrap(),
|
|
569
|
+
}
|
|
570
|
+
fn seed_remove_agent_workspace(ws: &std::path::Path, status: &str) {
|
|
571
|
+
seed_team_spec(ws);
|
|
572
|
+
crate::state::persist::save_runtime_state(
|
|
573
|
+
ws,
|
|
574
|
+
&json!({
|
|
575
|
+
"session_name": "team-agent-fake-e2e",
|
|
576
|
+
"agents": {
|
|
577
|
+
"fake_impl": {
|
|
578
|
+
"status": status,
|
|
579
|
+
"provider": "fake",
|
|
580
|
+
"window": "fake_impl"
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
"spec_path": ws.join("team.spec.yaml").to_string_lossy()
|
|
584
|
+
}),
|
|
585
|
+
)
|
|
586
|
+
.unwrap();
|
|
587
|
+
}
|
|
588
|
+
#[test]
|
|
589
|
+
fn remove_agent_spec_running_refusal_lists_all_required_flags_once() {
|
|
590
|
+
let ws = tmp_workspace();
|
|
591
|
+
seed_remove_agent_workspace(&ws, "running");
|
|
592
|
+
let out = json_output(
|
|
593
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
594
|
+
agent: "fake_impl".to_string(),
|
|
595
|
+
workspace: ws.clone(),
|
|
596
|
+
team: None,
|
|
597
|
+
from_spec: false,
|
|
598
|
+
confirm: false,
|
|
599
|
+
force: false,
|
|
600
|
+
json: true,
|
|
601
|
+
})
|
|
602
|
+
.unwrap(),
|
|
603
|
+
);
|
|
604
|
+
assert_eq!(out["ok"], json!(false));
|
|
605
|
+
assert_eq!(out["status"], json!("refused"));
|
|
606
|
+
assert_eq!(out["reason"], json!("remove_agent_flags_required"));
|
|
607
|
+
for flag in ["--from-spec", "--confirm", "--force"] {
|
|
608
|
+
assert!(
|
|
609
|
+
out["error"].as_str().is_some_and(|s| s.contains(flag))
|
|
610
|
+
&& out["action"].as_str().is_some_and(|s| s.contains(flag))
|
|
611
|
+
&& out["command"].as_str().is_some_and(|s| s.contains(flag)),
|
|
612
|
+
"refusal must mention {flag} in error/action/copyable command; got {out}"
|
|
533
613
|
);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
614
|
+
}
|
|
615
|
+
let with_confirm = json_output(
|
|
616
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
617
|
+
agent: "fake_impl".to_string(),
|
|
618
|
+
workspace: ws.clone(),
|
|
619
|
+
team: None,
|
|
620
|
+
from_spec: false,
|
|
621
|
+
confirm: true,
|
|
622
|
+
force: false,
|
|
623
|
+
json: true,
|
|
624
|
+
})
|
|
625
|
+
.unwrap(),
|
|
626
|
+
);
|
|
627
|
+
assert_eq!(with_confirm["reason"], json!("remove_agent_flags_required"));
|
|
628
|
+
for flag in ["--from-spec", "--confirm", "--force"] {
|
|
629
|
+
assert!(
|
|
537
630
|
with_confirm["error"].as_str().is_some_and(|s| s.contains(flag))
|
|
538
631
|
&& with_confirm["action"].as_str().is_some_and(|s| s.contains(flag))
|
|
539
632
|
&& with_confirm["command"].as_str().is_some_and(|s| s.contains(flag)),
|
|
540
633
|
"refusal with --confirm must still give the full command including {flag}; got {with_confirm}"
|
|
541
634
|
);
|
|
542
|
-
}
|
|
543
|
-
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
544
|
-
assert!(
|
|
545
|
-
state["agents"].get("fake_impl").is_some(),
|
|
546
|
-
"refused remove-agent must not delete the spec-defined running agent"
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
#[test]
|
|
550
|
-
fn remove_agent_running_refusal_is_not_success_envelope() {
|
|
551
|
-
let ws = tmp_workspace();
|
|
552
|
-
seed_remove_agent_workspace(&ws, "running");
|
|
553
|
-
let out = json_output(
|
|
554
|
-
cmd_remove_agent(&RemoveAgentArgs {
|
|
555
|
-
agent: "fake_impl".to_string(),
|
|
556
|
-
workspace: ws.clone(),
|
|
557
|
-
team: None,
|
|
558
|
-
from_spec: true,
|
|
559
|
-
confirm: true,
|
|
560
|
-
force: false,
|
|
561
|
-
json: true,
|
|
562
|
-
})
|
|
563
|
-
.unwrap(),
|
|
564
|
-
);
|
|
565
|
-
assert_eq!(out["ok"], json!(false));
|
|
566
|
-
assert_eq!(out["status"], json!("refused"));
|
|
567
|
-
assert_eq!(out["reason"], json!("force_required"));
|
|
568
|
-
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
569
|
-
assert!(
|
|
570
|
-
state["agents"].get("fake_impl").is_some(),
|
|
571
|
-
"refused remove-agent must not delete the running agent"
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
#[test]
|
|
575
|
-
fn remove_agent_from_spec_refusal_is_not_success_envelope() {
|
|
576
|
-
let ws = tmp_workspace();
|
|
577
|
-
seed_remove_agent_workspace(&ws, "stopped");
|
|
578
|
-
let out = json_output(
|
|
579
|
-
cmd_remove_agent(&RemoveAgentArgs {
|
|
580
|
-
agent: "fake_impl".to_string(),
|
|
581
|
-
workspace: ws.clone(),
|
|
582
|
-
team: None,
|
|
583
|
-
from_spec: false,
|
|
584
|
-
confirm: true,
|
|
585
|
-
force: false,
|
|
586
|
-
json: true,
|
|
587
|
-
})
|
|
588
|
-
.unwrap(),
|
|
589
|
-
);
|
|
590
|
-
assert_eq!(out["ok"], json!(false));
|
|
591
|
-
assert_eq!(out["status"], json!("refused"));
|
|
592
|
-
assert_eq!(out["reason"], json!("from_spec_confirm_required"));
|
|
593
|
-
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
594
|
-
assert!(
|
|
595
|
-
state["agents"].get("fake_impl").is_some(),
|
|
596
|
-
"refused remove-agent must not delete the spec-defined agent"
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
#[test]
|
|
600
|
-
fn collect_uncollected_result_marks_db_and_outputs_result() {
|
|
601
|
-
let ws = tmp_workspace();
|
|
602
|
-
seed_collect_state(&ws);
|
|
603
|
-
seed_uncollected_result(&ws, "res_collect_red");
|
|
604
|
-
let out = json_output(
|
|
605
|
-
cmd_collect(&CollectArgs {
|
|
606
|
-
workspace: ws.clone(),
|
|
607
|
-
result_file: None,
|
|
608
|
-
json: true,
|
|
609
|
-
team: None,
|
|
610
|
-
})
|
|
611
|
-
.unwrap(),
|
|
612
|
-
);
|
|
613
|
-
assert_eq!(out["ok"], json!(true));
|
|
614
|
-
assert_eq!(out["collected_results"][0]["result_id"], json!("res_collect_red"));
|
|
615
|
-
assert_eq!(out["collected_results"][0]["scope"], json!("task"));
|
|
616
|
-
assert_eq!(
|
|
617
|
-
out["results"],
|
|
618
|
-
json!({"total": 1, "uncollected": 0, "collected": 1, "invalid": 0, "by_status": {}})
|
|
619
|
-
);
|
|
620
|
-
let store = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
621
|
-
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
622
|
-
let status: String = conn
|
|
623
|
-
.query_row(
|
|
624
|
-
"select status from results where result_id = 'res_collect_red'",
|
|
625
|
-
[],
|
|
626
|
-
|row| row.get(0),
|
|
627
|
-
)
|
|
628
|
-
.unwrap();
|
|
629
|
-
assert_eq!(status, "collected");
|
|
630
|
-
let state = read_state(&ws);
|
|
631
|
-
assert_eq!(state["tasks"][0]["status"], json!("done"));
|
|
632
|
-
assert_eq!(state["tasks"][0]["accepted_result_id"], json!("res_collect_red"));
|
|
633
|
-
assert!(
|
|
634
|
-
read_events(&ws)
|
|
635
|
-
.iter()
|
|
636
|
-
.any(|e| e["event"] == json!("collect.result") && e["result_id"] == json!("res_collect_red")),
|
|
637
|
-
"collect must emit collect.result for the stored result"
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
#[test]
|
|
641
|
-
fn stuck_cancel_persists_suppression_and_stuck_list_reads_state() {
|
|
642
|
-
let ws = tmp_workspace();
|
|
643
|
-
seed_collect_state(&ws);
|
|
644
|
-
let out = json_output(
|
|
645
|
-
cmd_stuck_cancel(&StuckCancelArgs {
|
|
646
|
-
agent: "fake_impl".to_string(),
|
|
647
|
-
workspace: ws.clone(),
|
|
648
|
-
alert_type: None,
|
|
649
|
-
json: true,
|
|
650
|
-
team: None,
|
|
651
|
-
})
|
|
652
|
-
.unwrap(),
|
|
653
|
-
);
|
|
654
|
-
let team_key = seeded_team_key(&ws);
|
|
655
|
-
assert_eq!(out["ok"], json!(true));
|
|
656
|
-
assert_eq!(out["alert_types"], json!(["cross_worker_deadlock", "idle_fallback", "stuck"]));
|
|
657
|
-
assert!(out["suppressed"]["idle_fallback"]["snapshot"]["assigned_task_ids"]
|
|
658
|
-
.as_array()
|
|
659
|
-
.unwrap()
|
|
660
|
-
.contains(&json!("task_impl")));
|
|
661
|
-
let state = read_state(&ws);
|
|
662
|
-
assert_eq!(
|
|
663
|
-
state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]["suppressed_by"],
|
|
664
|
-
json!("leader")
|
|
665
|
-
);
|
|
666
|
-
assert!(
|
|
667
|
-
read_events(&ws)
|
|
668
|
-
.iter()
|
|
669
|
-
.any(|e| e["event"] == json!("coordinator.idle_alert_suppressed")
|
|
670
|
-
&& e["agent_id"] == json!("fake_impl")),
|
|
671
|
-
"stuck_cancel must write coordinator.idle_alert_suppressed"
|
|
672
|
-
);
|
|
673
|
-
let listed = json_output(
|
|
674
|
-
cmd_stuck_list(&StuckListArgs {
|
|
675
|
-
workspace: ws.clone(),
|
|
676
|
-
json: true,
|
|
677
|
-
team: None,
|
|
678
|
-
})
|
|
679
|
-
.unwrap(),
|
|
680
|
-
);
|
|
681
|
-
assert_eq!(
|
|
682
|
-
listed["suppressed_idle_alerts"]["fake_impl"]["stuck"]["suppressed_by"],
|
|
683
|
-
json!("leader"),
|
|
684
|
-
"stuck-list must read the persisted state mirror, not return a hard-coded empty list"
|
|
685
|
-
);
|
|
686
635
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
636
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
637
|
+
assert!(
|
|
638
|
+
state["agents"].get("fake_impl").is_some(),
|
|
639
|
+
"refused remove-agent must not delete the spec-defined running agent"
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
#[test]
|
|
643
|
+
fn remove_agent_running_refusal_is_not_success_envelope() {
|
|
644
|
+
let ws = tmp_workspace();
|
|
645
|
+
seed_remove_agent_workspace(&ws, "running");
|
|
646
|
+
let out = json_output(
|
|
647
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
648
|
+
agent: "fake_impl".to_string(),
|
|
649
|
+
workspace: ws.clone(),
|
|
650
|
+
team: None,
|
|
651
|
+
from_spec: true,
|
|
652
|
+
confirm: true,
|
|
653
|
+
force: false,
|
|
654
|
+
json: true,
|
|
655
|
+
})
|
|
656
|
+
.unwrap(),
|
|
657
|
+
);
|
|
658
|
+
assert_eq!(out["ok"], json!(false));
|
|
659
|
+
assert_eq!(out["status"], json!("refused"));
|
|
660
|
+
assert_eq!(out["reason"], json!("force_required"));
|
|
661
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
662
|
+
assert!(
|
|
663
|
+
state["agents"].get("fake_impl").is_some(),
|
|
664
|
+
"refused remove-agent must not delete the running agent"
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
#[test]
|
|
668
|
+
fn remove_agent_from_spec_refusal_is_not_success_envelope() {
|
|
669
|
+
let ws = tmp_workspace();
|
|
670
|
+
seed_remove_agent_workspace(&ws, "stopped");
|
|
671
|
+
let out = json_output(
|
|
672
|
+
cmd_remove_agent(&RemoveAgentArgs {
|
|
673
|
+
agent: "fake_impl".to_string(),
|
|
674
|
+
workspace: ws.clone(),
|
|
675
|
+
team: None,
|
|
676
|
+
from_spec: false,
|
|
677
|
+
confirm: true,
|
|
678
|
+
force: false,
|
|
679
|
+
json: true,
|
|
680
|
+
})
|
|
681
|
+
.unwrap(),
|
|
682
|
+
);
|
|
683
|
+
assert_eq!(out["ok"], json!(false));
|
|
684
|
+
assert_eq!(out["status"], json!("refused"));
|
|
685
|
+
assert_eq!(out["reason"], json!("from_spec_confirm_required"));
|
|
686
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
687
|
+
assert!(
|
|
688
|
+
state["agents"].get("fake_impl").is_some(),
|
|
689
|
+
"refused remove-agent must not delete the spec-defined agent"
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
#[test]
|
|
693
|
+
fn collect_uncollected_result_marks_db_and_outputs_result() {
|
|
694
|
+
let ws = tmp_workspace();
|
|
695
|
+
seed_collect_state(&ws);
|
|
696
|
+
seed_uncollected_result(&ws, "res_collect_red");
|
|
697
|
+
let out = json_output(
|
|
698
|
+
cmd_collect(&CollectArgs {
|
|
699
|
+
workspace: ws.clone(),
|
|
700
|
+
result_file: None,
|
|
701
|
+
json: true,
|
|
702
|
+
team: None,
|
|
703
|
+
})
|
|
704
|
+
.unwrap(),
|
|
705
|
+
);
|
|
706
|
+
assert_eq!(out["ok"], json!(true));
|
|
707
|
+
assert_eq!(
|
|
708
|
+
out["collected_results"][0]["result_id"],
|
|
709
|
+
json!("res_collect_red")
|
|
710
|
+
);
|
|
711
|
+
assert_eq!(out["collected_results"][0]["scope"], json!("task"));
|
|
712
|
+
assert_eq!(
|
|
713
|
+
out["results"],
|
|
714
|
+
json!({"total": 1, "uncollected": 0, "collected": 1, "invalid": 0, "by_status": {}})
|
|
715
|
+
);
|
|
716
|
+
let store = crate::message_store::MessageStore::open(&ws).unwrap();
|
|
717
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
718
|
+
let status: String = conn
|
|
719
|
+
.query_row(
|
|
720
|
+
"select status from results where result_id = 'res_collect_red'",
|
|
721
|
+
[],
|
|
722
|
+
|row| row.get(0),
|
|
723
|
+
)
|
|
724
|
+
.unwrap();
|
|
725
|
+
assert_eq!(status, "collected");
|
|
726
|
+
let state = read_state(&ws);
|
|
727
|
+
assert_eq!(state["tasks"][0]["status"], json!("done"));
|
|
728
|
+
assert_eq!(
|
|
729
|
+
state["tasks"][0]["accepted_result_id"],
|
|
730
|
+
json!("res_collect_red")
|
|
731
|
+
);
|
|
732
|
+
assert!(
|
|
733
|
+
read_events(&ws)
|
|
734
|
+
.iter()
|
|
735
|
+
.any(|e| e["event"] == json!("collect.result")
|
|
736
|
+
&& e["result_id"] == json!("res_collect_red")),
|
|
737
|
+
"collect must emit collect.result for the stored result"
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
#[test]
|
|
741
|
+
fn stuck_cancel_persists_suppression_and_stuck_list_reads_state() {
|
|
742
|
+
let ws = tmp_workspace();
|
|
743
|
+
seed_collect_state(&ws);
|
|
744
|
+
let out = json_output(
|
|
745
|
+
cmd_stuck_cancel(&StuckCancelArgs {
|
|
693
746
|
agent: "fake_impl".to_string(),
|
|
694
747
|
workspace: ws.clone(),
|
|
695
748
|
alert_type: None,
|
|
696
749
|
json: true,
|
|
697
|
-
team:
|
|
750
|
+
team: None,
|
|
698
751
|
})
|
|
699
|
-
.
|
|
700
|
-
|
|
752
|
+
.unwrap(),
|
|
753
|
+
);
|
|
754
|
+
let team_key = seeded_team_key(&ws);
|
|
755
|
+
assert_eq!(out["ok"], json!(true));
|
|
756
|
+
assert_eq!(
|
|
757
|
+
out["alert_types"],
|
|
758
|
+
json!(["cross_worker_deadlock", "idle_fallback", "stuck"])
|
|
759
|
+
);
|
|
760
|
+
assert!(
|
|
761
|
+
out["suppressed"]["idle_fallback"]["snapshot"]["assigned_task_ids"]
|
|
762
|
+
.as_array()
|
|
763
|
+
.unwrap()
|
|
764
|
+
.contains(&json!("task_impl"))
|
|
765
|
+
);
|
|
766
|
+
let state = read_state(&ws);
|
|
767
|
+
assert_eq!(
|
|
768
|
+
state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
|
|
769
|
+
["suppressed_by"],
|
|
770
|
+
json!("leader")
|
|
771
|
+
);
|
|
772
|
+
assert!(
|
|
773
|
+
read_events(&ws)
|
|
774
|
+
.iter()
|
|
775
|
+
.any(|e| e["event"] == json!("coordinator.idle_alert_suppressed")
|
|
776
|
+
&& e["agent_id"] == json!("fake_impl")),
|
|
777
|
+
"stuck_cancel must write coordinator.idle_alert_suppressed"
|
|
778
|
+
);
|
|
779
|
+
let listed = json_output(
|
|
780
|
+
cmd_stuck_list(&StuckListArgs {
|
|
781
|
+
workspace: ws.clone(),
|
|
782
|
+
json: true,
|
|
783
|
+
team: None,
|
|
784
|
+
})
|
|
785
|
+
.unwrap(),
|
|
786
|
+
);
|
|
787
|
+
assert_eq!(
|
|
788
|
+
listed["suppressed_idle_alerts"]["fake_impl"]["stuck"]["suppressed_by"],
|
|
789
|
+
json!("leader"),
|
|
790
|
+
"stuck-list must read the persisted state mirror, not return a hard-coded empty list"
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
#[test]
|
|
795
|
+
fn stuck_cancel_explicit_team_is_rejected_until_backend_is_scoped() {
|
|
796
|
+
let ws = tmp_workspace();
|
|
797
|
+
seed_collect_state(&ws);
|
|
798
|
+
let err = cmd_stuck_cancel(&StuckCancelArgs {
|
|
799
|
+
agent: "fake_impl".to_string(),
|
|
800
|
+
workspace: ws.clone(),
|
|
801
|
+
alert_type: None,
|
|
802
|
+
json: true,
|
|
803
|
+
team: Some("current".to_string()),
|
|
804
|
+
})
|
|
805
|
+
.expect_err("explicit --team must not silently write global stuck suppression");
|
|
806
|
+
assert!(
|
|
701
807
|
err.to_string().contains("not supported yet"),
|
|
702
808
|
"stuck-cancel --team must be an explicit refusal until backend supports scoped writes; got {err}"
|
|
703
809
|
);
|
|
704
|
-
|
|
705
|
-
|
|
810
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
811
|
+
}
|
|
706
812
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
813
|
+
#[test]
|
|
814
|
+
fn stuck_cancel_invalid_alert_type_is_rejected() {
|
|
815
|
+
let ws = tmp_workspace();
|
|
816
|
+
seed_collect_state(&ws);
|
|
817
|
+
let code = run(
|
|
818
|
+
&cli_argv(&[
|
|
819
|
+
"stuck-cancel",
|
|
820
|
+
"fake_impl",
|
|
821
|
+
"--workspace",
|
|
822
|
+
&ws.to_string_lossy(),
|
|
823
|
+
"--alert-type",
|
|
824
|
+
"bogus",
|
|
825
|
+
"--json",
|
|
826
|
+
]),
|
|
827
|
+
&ws,
|
|
828
|
+
);
|
|
829
|
+
assert_eq!(
|
|
724
830
|
code,
|
|
725
831
|
ExitCode::Error,
|
|
726
832
|
"Python rejects alert_type outside stuck/idle_fallback/cross_worker_deadlock/all; Rust must not silently coerce bogus to stuck"
|
|
727
833
|
);
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
)
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
834
|
+
}
|
|
835
|
+
#[test]
|
|
836
|
+
fn acknowledge_idle_records_manual_idle_fallback_suppression_and_event() {
|
|
837
|
+
let ws = tmp_workspace();
|
|
838
|
+
seed_collect_state(&ws);
|
|
839
|
+
let out = json_output(
|
|
840
|
+
cmd_acknowledge_idle(&AcknowledgeIdleArgs {
|
|
841
|
+
team: None,
|
|
842
|
+
workspace: ws.clone(),
|
|
843
|
+
json: true,
|
|
844
|
+
})
|
|
845
|
+
.unwrap(),
|
|
846
|
+
);
|
|
847
|
+
let team_key = seeded_team_key(&ws);
|
|
848
|
+
assert_eq!(out["ok"], json!(true));
|
|
849
|
+
assert_eq!(out["team"], json!(team_key));
|
|
850
|
+
assert_eq!(out["ttl_seconds"], json!(1800));
|
|
851
|
+
let state = read_state(&ws);
|
|
852
|
+
let ack = &state["coordinator"]["idle_acknowledged"][&team_key];
|
|
853
|
+
assert_eq!(ack["ttl_seconds"], json!(1800));
|
|
854
|
+
assert!(ack["acknowledged_at"].as_str().is_some());
|
|
855
|
+
assert_eq!(
|
|
856
|
+
state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
|
|
857
|
+
["suppressed_by"],
|
|
858
|
+
json!("manual_acknowledge")
|
|
859
|
+
);
|
|
860
|
+
assert_eq!(
|
|
861
|
+
state["coordinator"]["suppressed_idle_alerts"][&team_key]["fake_impl"]["idle_fallback"]
|
|
862
|
+
["manual_acknowledge"],
|
|
863
|
+
json!(true)
|
|
864
|
+
);
|
|
865
|
+
assert!(
|
|
866
|
+
read_events(&ws)
|
|
867
|
+
.iter()
|
|
868
|
+
.any(|e| e["event"] == json!("coordinator.idle_acknowledged")
|
|
869
|
+
&& e["team"] == json!(team_key)
|
|
870
|
+
&& e["ttl_seconds"] == json!(1800)),
|
|
871
|
+
"acknowledge-idle must emit coordinator.idle_acknowledged"
|
|
872
|
+
);
|
|
873
|
+
}
|