@team-agent/installer 0.2.10 → 0.3.0
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 +744 -0
- package/Cargo.toml +34 -0
- package/crates/team-agent/Cargo.toml +33 -0
- package/crates/team-agent/src/cli/adapters.rs +1343 -0
- package/crates/team-agent/src/cli/diagnose.rs +554 -0
- package/crates/team-agent/src/cli/emit.rs +1077 -0
- package/crates/team-agent/src/cli/helpers.rs +88 -0
- package/crates/team-agent/src/cli/leader.rs +216 -0
- package/crates/team-agent/src/cli/mod.rs +1141 -0
- package/crates/team-agent/src/cli/profile.rs +306 -0
- package/crates/team-agent/src/cli/send.rs +215 -0
- package/crates/team-agent/src/cli/status.rs +179 -0
- package/crates/team-agent/src/cli/status_port.rs +502 -0
- package/crates/team-agent/src/cli/tests/base.rs +616 -0
- package/crates/team-agent/src/cli/tests/compile.rs +96 -0
- package/crates/team-agent/src/cli/tests/divergence.rs +509 -0
- package/crates/team-agent/src/cli/tests/lane_c.rs +333 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +395 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +675 -0
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +390 -0
- package/crates/team-agent/src/cli/tests/mod.rs +97 -0
- package/crates/team-agent/src/cli/tests/peer_allow.rs +137 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +302 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +305 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +385 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +182 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +236 -0
- package/crates/team-agent/src/cli/tests/verb_validate.rs +184 -0
- package/crates/team-agent/src/cli/types.rs +605 -0
- package/crates/team-agent/src/compiler/tests.rs +701 -0
- package/crates/team-agent/src/compiler.rs +489 -0
- package/crates/team-agent/src/coordinator/backoff.rs +153 -0
- package/crates/team-agent/src/coordinator/health.rs +436 -0
- package/crates/team-agent/src/coordinator/mod.rs +80 -0
- package/crates/team-agent/src/coordinator/orphan.rs +179 -0
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +255 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +262 -0
- package/crates/team-agent/src/coordinator/tests/daemon.rs +323 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +263 -0
- package/crates/team-agent/src/coordinator/tests/main_preserved.rs +136 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +310 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +261 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +227 -0
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +256 -0
- package/crates/team-agent/src/coordinator/tests/watch.rs +167 -0
- package/crates/team-agent/src/coordinator/tick.rs +2032 -0
- package/crates/team-agent/src/coordinator/types.rs +584 -0
- package/crates/team-agent/src/db/migration.rs +716 -0
- package/crates/team-agent/src/db/mod.rs +23 -0
- package/crates/team-agent/src/db/schema.rs +378 -0
- package/crates/team-agent/src/event_log.rs +375 -0
- package/crates/team-agent/src/fake_worker.rs +253 -0
- package/crates/team-agent/src/leader/helpers.rs +190 -0
- package/crates/team-agent/src/leader/inject.rs +33 -0
- package/crates/team-agent/src/leader/lease.rs +1063 -0
- package/crates/team-agent/src/leader/mod.rs +99 -0
- package/crates/team-agent/src/leader/owner_bind.rs +292 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +525 -0
- package/crates/team-agent/src/leader/rediscover.rs +1099 -0
- package/crates/team-agent/src/leader/start.rs +273 -0
- package/crates/team-agent/src/leader/takeover.rs +235 -0
- package/crates/team-agent/src/leader/tests/basics.rs +183 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +234 -0
- package/crates/team-agent/src/leader/tests/identity.rs +206 -0
- package/crates/team-agent/src/leader/tests/idle.rs +271 -0
- package/crates/team-agent/src/leader/tests/lease_api.rs +225 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +253 -0
- package/crates/team-agent/src/leader/tests/mod.rs +125 -0
- package/crates/team-agent/src/leader/tests/rediscover.rs +351 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +204 -0
- package/crates/team-agent/src/leader/types.rs +487 -0
- package/crates/team-agent/src/lib.rs +85 -0
- package/crates/team-agent/src/lifecycle/display.rs +228 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +112 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +227 -0
- package/crates/team-agent/src/lifecycle/launch.rs +1833 -0
- package/crates/team-agent/src/lifecycle/mod.rs +62 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +533 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +517 -0
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +41 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +268 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +780 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +208 -0
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +242 -0
- package/crates/team-agent/src/lifecycle/restart.rs +76 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +455 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +989 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +583 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +933 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +265 -0
- package/crates/team-agent/src/lifecycle/tests.rs +27 -0
- package/crates/team-agent/src/lifecycle/types.rs +685 -0
- package/crates/team-agent/src/main.rs +41 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +228 -0
- package/crates/team-agent/src/mcp_server/mod.rs +183 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +312 -0
- package/crates/team-agent/src/mcp_server/tests/golden.rs +283 -0
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +244 -0
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +189 -0
- package/crates/team-agent/src/mcp_server/tests/send.rs +222 -0
- package/crates/team-agent/src/mcp_server/tests/tools.rs +158 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +159 -0
- package/crates/team-agent/src/mcp_server/tests.rs +38 -0
- package/crates/team-agent/src/mcp_server/tools.rs +603 -0
- package/crates/team-agent/src/mcp_server/types.rs +421 -0
- package/crates/team-agent/src/mcp_server/wire.rs +388 -0
- package/crates/team-agent/src/message_store.rs +767 -0
- package/crates/team-agent/src/messaging/activity.rs +433 -0
- package/crates/team-agent/src/messaging/delivery.rs +542 -0
- package/crates/team-agent/src/messaging/helpers.rs +209 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +340 -0
- package/crates/team-agent/src/messaging/mod.rs +147 -0
- package/crates/team-agent/src/messaging/peers.rs +32 -0
- package/crates/team-agent/src/messaging/results.rs +537 -0
- package/crates/team-agent/src/messaging/scheduler.rs +344 -0
- package/crates/team-agent/src/messaging/selftest.rs +100 -0
- package/crates/team-agent/src/messaging/send.rs +582 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +357 -0
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +122 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +293 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +1422 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +437 -0
- package/crates/team-agent/src/messaging/trust.rs +192 -0
- package/crates/team-agent/src/messaging/types.rs +355 -0
- package/crates/team-agent/src/messaging/watchers.rs +591 -0
- package/crates/team-agent/src/model/enums.rs +311 -0
- package/crates/team-agent/src/model/errors.rs +17 -0
- package/crates/team-agent/src/model/ids.rs +155 -0
- package/crates/team-agent/src/model/mod.rs +22 -0
- package/crates/team-agent/src/model/paths.rs +228 -0
- package/crates/team-agent/src/model/permissions.rs +567 -0
- package/crates/team-agent/src/model/routing.rs +340 -0
- package/crates/team-agent/src/model/spec.rs +680 -0
- package/crates/team-agent/src/model/task_graph.rs +380 -0
- package/crates/team-agent/src/model/testdata/fuzz.golden.yaml +43 -0
- package/crates/team-agent/src/model/testdata/fuzz.yaml +43 -0
- package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +207 -0
- package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +206 -0
- package/crates/team-agent/src/model/testdata/team.spec.yaml +206 -0
- package/crates/team-agent/src/model/yaml/tests.rs +288 -0
- package/crates/team-agent/src/model/yaml.rs +800 -0
- package/crates/team-agent/src/packaging/install.rs +305 -0
- package/crates/team-agent/src/packaging/migrate.rs +30 -0
- package/crates/team-agent/src/packaging/mod.rs +82 -0
- package/crates/team-agent/src/packaging/repair.rs +24 -0
- package/crates/team-agent/src/packaging/tests.rs +829 -0
- package/crates/team-agent/src/packaging/types.rs +369 -0
- package/crates/team-agent/src/provider/adapter.rs +801 -0
- package/crates/team-agent/src/provider/approvals/mod.rs +2 -0
- package/crates/team-agent/src/provider/approvals/parsing.rs +452 -0
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +163 -0
- package/crates/team-agent/src/provider/classify.rs +456 -0
- package/crates/team-agent/src/provider/faults.rs +136 -0
- package/crates/team-agent/src/provider/helpers.rs +41 -0
- package/crates/team-agent/src/provider/mod.rs +53 -0
- package/crates/team-agent/src/provider/startup_prompt.rs +423 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +239 -0
- package/crates/team-agent/src/provider/tests/classify.rs +240 -0
- package/crates/team-agent/src/provider/tests/faults.rs +120 -0
- package/crates/team-agent/src/provider/tests/idle.rs +208 -0
- package/crates/team-agent/src/provider/tests/wire.rs +213 -0
- package/crates/team-agent/src/provider/tests.rs +31 -0
- package/crates/team-agent/src/provider/types.rs +424 -0
- package/crates/team-agent/src/state/identity.rs +656 -0
- package/crates/team-agent/src/state/mod.rs +58 -0
- package/crates/team-agent/src/state/owner_gate.rs +423 -0
- package/crates/team-agent/src/state/persist.rs +712 -0
- package/crates/team-agent/src/state/projection.rs +657 -0
- package/crates/team-agent/src/state/selector.rs +105 -0
- package/crates/team-agent/src/state/testdata/state-rich.canonical.json +133 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +586 -0
- package/crates/team-agent/src/tmux_backend.rs +758 -0
- package/crates/team-agent/src/transport/test_support.rs +252 -0
- package/crates/team-agent/src/transport/tests/behavior.rs +327 -0
- package/crates/team-agent/src/transport/tests/mod.rs +199 -0
- package/crates/team-agent/src/transport/tests/wire.rs +527 -0
- package/crates/team-agent/src/transport.rs +774 -0
- package/npm/install.mjs +90 -106
- package/package.json +15 -13
- package/crates/team-agent-core/Cargo.toml +0 -12
- package/crates/team-agent-core/src/lib.rs +0 -332
- package/crates/team-agent-core/src/main.rs +0 -152
- package/pyproject.toml +0 -18
- package/scripts/install.py +0 -88
- package/scripts/run_regression_tests.py +0 -83
- package/src/team_agent/__init__.py +0 -3
- package/src/team_agent/__main__.py +0 -5
- package/src/team_agent/_legacy_pane_discovery.py +0 -186
- package/src/team_agent/abnormal_track.py +0 -253
- package/src/team_agent/approvals/__init__.py +0 -65
- package/src/team_agent/approvals/constants.py +0 -6
- package/src/team_agent/approvals/parsing.py +0 -176
- package/src/team_agent/approvals/runtime_prompts.py +0 -171
- package/src/team_agent/approvals/status.py +0 -176
- package/src/team_agent/cli/__init__.py +0 -137
- package/src/team_agent/cli/commands.py +0 -481
- package/src/team_agent/cli/e2e.py +0 -202
- package/src/team_agent/cli/helpers.py +0 -226
- package/src/team_agent/cli/parser.py +0 -540
- package/src/team_agent/compiler.py +0 -334
- package/src/team_agent/coordinator/__init__.py +0 -53
- package/src/team_agent/coordinator/__main__.py +0 -83
- package/src/team_agent/coordinator/lifecycle.py +0 -363
- package/src/team_agent/coordinator/metadata.py +0 -61
- package/src/team_agent/coordinator/paths.py +0 -17
- package/src/team_agent/diagnose/__init__.py +0 -48
- package/src/team_agent/diagnose/checks.py +0 -101
- package/src/team_agent/diagnose/comms.py +0 -213
- package/src/team_agent/diagnose/health.py +0 -241
- package/src/team_agent/diagnose/orphan_cleanup.py +0 -364
- package/src/team_agent/diagnose/preflight.py +0 -194
- package/src/team_agent/diagnose/quick_start.py +0 -324
- package/src/team_agent/display/__init__.py +0 -92
- package/src/team_agent/display/adaptive.py +0 -511
- package/src/team_agent/display/backend.py +0 -46
- package/src/team_agent/display/close.py +0 -154
- package/src/team_agent/display/ghostty.py +0 -77
- package/src/team_agent/display/rebuild.py +0 -102
- package/src/team_agent/display/tiling.py +0 -156
- package/src/team_agent/display/worker_window.py +0 -114
- package/src/team_agent/display/workspace.py +0 -382
- package/src/team_agent/errors.py +0 -10
- package/src/team_agent/events.py +0 -84
- package/src/team_agent/fake_worker.py +0 -80
- package/src/team_agent/idle_predicate.py +0 -200
- package/src/team_agent/idle_takeover.py +0 -59
- package/src/team_agent/idle_takeover_wiring.py +0 -111
- package/src/team_agent/launch/__init__.py +0 -41
- package/src/team_agent/launch/bootstrap.py +0 -85
- package/src/team_agent/launch/config.py +0 -106
- package/src/team_agent/launch/core.py +0 -301
- package/src/team_agent/launch/requirements.py +0 -57
- package/src/team_agent/leader/__init__.py +0 -926
- package/src/team_agent/leader_binding.py +0 -183
- package/src/team_agent/lifecycle/__init__.py +0 -5
- package/src/team_agent/lifecycle/agents.py +0 -278
- package/src/team_agent/lifecycle/operations.py +0 -411
- package/src/team_agent/lifecycle/paste_buffer_hygiene.py +0 -39
- package/src/team_agent/lifecycle/start.py +0 -363
- package/src/team_agent/mcp_server/__init__.py +0 -42
- package/src/team_agent/mcp_server/__main__.py +0 -7
- package/src/team_agent/mcp_server/contracts.py +0 -148
- package/src/team_agent/mcp_server/normalize.py +0 -257
- package/src/team_agent/mcp_server/server.py +0 -150
- package/src/team_agent/mcp_server/tools.py +0 -352
- package/src/team_agent/message_store/__init__.py +0 -23
- package/src/team_agent/message_store/agent_health.py +0 -113
- package/src/team_agent/message_store/core.py +0 -497
- package/src/team_agent/message_store/leader_notification_log.py +0 -198
- package/src/team_agent/message_store/result_watchers.py +0 -251
- package/src/team_agent/message_store/schema.py +0 -308
- package/src/team_agent/message_store/schema_migration.py +0 -448
- package/src/team_agent/messaging/__init__.py +0 -1
- package/src/team_agent/messaging/activity_detector.py +0 -254
- package/src/team_agent/messaging/delivery.py +0 -473
- package/src/team_agent/messaging/deps.py +0 -247
- package/src/team_agent/messaging/idle_alerts.py +0 -423
- package/src/team_agent/messaging/internal_delivery.py +0 -46
- package/src/team_agent/messaging/leader.py +0 -497
- package/src/team_agent/messaging/leader_api_errors.py +0 -216
- package/src/team_agent/messaging/leader_panes.py +0 -673
- package/src/team_agent/messaging/owner_bypass.py +0 -29
- package/src/team_agent/messaging/result_delivery.py +0 -539
- package/src/team_agent/messaging/results.py +0 -447
- package/src/team_agent/messaging/scheduler.py +0 -450
- package/src/team_agent/messaging/send.py +0 -532
- package/src/team_agent/messaging/session_drift.py +0 -94
- package/src/team_agent/messaging/tmux_io.py +0 -506
- package/src/team_agent/messaging/tmux_prompt.py +0 -338
- package/src/team_agent/messaging/trust_auto_answer.py +0 -52
- package/src/team_agent/orchestrator/__init__.py +0 -376
- package/src/team_agent/orchestrator/plan.py +0 -122
- package/src/team_agent/orchestrator/state.py +0 -128
- package/src/team_agent/paths.py +0 -45
- package/src/team_agent/permissions.py +0 -123
- package/src/team_agent/profiles/__init__.py +0 -82
- package/src/team_agent/profiles/constants.py +0 -19
- package/src/team_agent/profiles/core.py +0 -407
- package/src/team_agent/profiles/helpers.py +0 -69
- package/src/team_agent/profiles/provider_env.py +0 -188
- package/src/team_agent/profiles/smoke.py +0 -201
- package/src/team_agent/provider_cli/__init__.py +0 -43
- package/src/team_agent/provider_cli/adapter.py +0 -172
- package/src/team_agent/provider_cli/base.py +0 -48
- package/src/team_agent/provider_cli/claude.py +0 -457
- package/src/team_agent/provider_cli/codex.py +0 -336
- package/src/team_agent/provider_cli/copilot.py +0 -8
- package/src/team_agent/provider_cli/fake.py +0 -39
- package/src/team_agent/provider_cli/gemini.py +0 -95
- package/src/team_agent/provider_cli/opencode.py +0 -8
- package/src/team_agent/provider_cli/prompt.py +0 -62
- package/src/team_agent/provider_cli/registry.py +0 -18
- package/src/team_agent/provider_cli/unsupported.py +0 -32
- package/src/team_agent/provider_state/README.md +0 -78
- package/src/team_agent/provider_state/__init__.py +0 -86
- package/src/team_agent/provider_state/claude.py +0 -86
- package/src/team_agent/provider_state/codex.py +0 -84
- package/src/team_agent/provider_state/common.py +0 -207
- package/src/team_agent/provider_state/registry.py +0 -118
- package/src/team_agent/providers.py +0 -163
- package/src/team_agent/quality_gates.py +0 -104
- package/src/team_agent/restart/__init__.py +0 -34
- package/src/team_agent/restart/orchestration.py +0 -554
- package/src/team_agent/restart/selection.py +0 -89
- package/src/team_agent/restart/snapshot.py +0 -70
- package/src/team_agent/routing.py +0 -84
- package/src/team_agent/runtime.py +0 -1239
- package/src/team_agent/rust_core.py +0 -327
- package/src/team_agent/sessions/__init__.py +0 -25
- package/src/team_agent/sessions/capture.py +0 -143
- package/src/team_agent/sessions/inventory.py +0 -44
- package/src/team_agent/sessions/resume.py +0 -135
- package/src/team_agent/simple_yaml.py +0 -236
- package/src/team_agent/spec.py +0 -370
- package/src/team_agent/state.py +0 -602
- package/src/team_agent/status/__init__.py +0 -63
- package/src/team_agent/status/approvals.py +0 -52
- package/src/team_agent/status/compact.py +0 -158
- package/src/team_agent/status/constants.py +0 -18
- package/src/team_agent/status/inbox.py +0 -58
- package/src/team_agent/status/peek.py +0 -117
- package/src/team_agent/status/queries.py +0 -199
- package/src/team_agent/task_graph.py +0 -80
- package/src/team_agent/terminal.py +0 -57
- package/src/team_agent/wake.py +0 -58
- package/src/team_agent/watch/__init__.py +0 -145
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// GROUP E — _fail_leader_delivery: bug-52 fallback-log semantics. ok=True but
|
|
5
|
+
// status=FallbackLog (NOT a real submit). leader.py:394-436.
|
|
6
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
#[test]
|
|
9
|
+
fn fail_leader_delivery_returns_fallback_log_ok_true_not_submitted() {
|
|
10
|
+
let ws = tmp_ws("faillead");
|
|
11
|
+
let payload = json(serde_json::json!({
|
|
12
|
+
"to": "leader", "content": "hi", "sender": "coordinator"
|
|
13
|
+
}));
|
|
14
|
+
let out = fail_leader_delivery(
|
|
15
|
+
&ws,
|
|
16
|
+
&payload,
|
|
17
|
+
DeliveryRefusal::LeaderNotAttached,
|
|
18
|
+
Some("No direct leader tmux pane is attached. Run team-agent attach-leader."),
|
|
19
|
+
)
|
|
20
|
+
.unwrap();
|
|
21
|
+
// leader.py:423-431 — ok True, status fallback_log, channel fallback_inbox.
|
|
22
|
+
assert!(out.ok);
|
|
23
|
+
assert_eq!(out.status, DeliveryStatus::FallbackLog);
|
|
24
|
+
assert_eq!(out.reason, Some(DeliveryRefusal::LeaderNotAttached));
|
|
25
|
+
// The audit must be distinguishable from a real submit (Delivered).
|
|
26
|
+
assert_ne!(out.status, DeliveryStatus::Delivered);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
30
|
+
// GROUP F — session_drift_refusal: None-vs-refused fallthrough chain.
|
|
31
|
+
// session_drift.py:69-91.
|
|
32
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
33
|
+
|
|
34
|
+
#[test]
|
|
35
|
+
fn session_drift_refusal_none_for_no_target_leader_or_broadcast() {
|
|
36
|
+
let ws = tmp_ws("driftnone");
|
|
37
|
+
let log = EventLog::new(&ws);
|
|
38
|
+
let state = json(serde_json::json!({"agents": {}}));
|
|
39
|
+
// target == leader_id → None (no refusal).
|
|
40
|
+
assert!(
|
|
41
|
+
session_drift_refusal(&state, "leader", "leader", "s", None, &log)
|
|
42
|
+
.unwrap()
|
|
43
|
+
.is_none()
|
|
44
|
+
);
|
|
45
|
+
// target == "*" (broadcast) → None.
|
|
46
|
+
assert!(session_drift_refusal(&state, "*", "leader", "s", None, &log)
|
|
47
|
+
.unwrap()
|
|
48
|
+
.is_none());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[test]
|
|
52
|
+
fn session_drift_refusal_none_when_status_not_drift() {
|
|
53
|
+
let ws = tmp_ws("driftok");
|
|
54
|
+
let log = EventLog::new(&ws);
|
|
55
|
+
let state = json(serde_json::json!({"agents": {"w1": {"status": "idle"}}}));
|
|
56
|
+
assert!(session_drift_refusal(&state, "w1", "leader", "s", None, &log)
|
|
57
|
+
.unwrap()
|
|
58
|
+
.is_none());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[test]
|
|
62
|
+
fn session_drift_refusal_refuses_when_agent_in_drift() {
|
|
63
|
+
// session_drift.py:84-91 → ok False, reason session_drift, action reset-agent.
|
|
64
|
+
let ws = tmp_ws("driftrefuse");
|
|
65
|
+
let log = EventLog::new(&ws);
|
|
66
|
+
let state = json(serde_json::json!({
|
|
67
|
+
"agents": {"w1": {"status": "session_drift",
|
|
68
|
+
"session_drift": {"stored_session_id": "S", "actual_thread_id": "A"}}}
|
|
69
|
+
}));
|
|
70
|
+
let out = session_drift_refusal(&state, "w1", "leader", "leader", None, &log)
|
|
71
|
+
.unwrap()
|
|
72
|
+
.expect("drift agent must be refused");
|
|
73
|
+
assert!(!out.ok);
|
|
74
|
+
assert_eq!(out.status, DeliveryStatus::Refused);
|
|
75
|
+
assert_eq!(out.reason, Some(DeliveryRefusal::SessionDrift));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
79
|
+
// GROUP G — classify_agent_activity: every branch incl. the uncertain
|
|
80
|
+
// fallthrough iron law. activity_detector.py:90-146 (golden probed).
|
|
81
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
82
|
+
|
|
83
|
+
#[test]
|
|
84
|
+
fn classify_pane_in_mode_is_uncertain_high_confidence() {
|
|
85
|
+
let state = json(serde_json::json!({}));
|
|
86
|
+
let a = classify_agent_activity(&state, "", true, None, None);
|
|
87
|
+
assert_eq!(a.status, ActivityStatus::Uncertain);
|
|
88
|
+
assert_eq!(a.confidence, 0.9);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[test]
|
|
92
|
+
fn classify_idle_prompt_is_idle() {
|
|
93
|
+
// "❯ \n" matches the Claude idle prompt → idle 0.9.
|
|
94
|
+
let state = json(serde_json::json!({}));
|
|
95
|
+
let a = classify_agent_activity(&state, "❯ \n", false, None, None);
|
|
96
|
+
assert_eq!(a.status, ActivityStatus::Idle);
|
|
97
|
+
assert_eq!(a.confidence, 0.9);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[test]
|
|
101
|
+
fn classify_working_indicator_is_working() {
|
|
102
|
+
let state = json(serde_json::json!({}));
|
|
103
|
+
let a = classify_agent_activity(&state, "Working (5s)", false, None, None);
|
|
104
|
+
assert_eq!(a.status, ActivityStatus::Working);
|
|
105
|
+
assert_eq!(a.confidence, 0.9);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#[test]
|
|
109
|
+
fn classify_stale_working_is_stuck() {
|
|
110
|
+
// elapsed >= stuck_timeout (300) → stuck 0.85.
|
|
111
|
+
let state = json(serde_json::json!({}));
|
|
112
|
+
let a = classify_agent_activity(&state, "Working (400s)", false, None, None);
|
|
113
|
+
assert_eq!(a.status, ActivityStatus::Stuck);
|
|
114
|
+
assert_eq!(a.confidence, 0.85);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[test]
|
|
118
|
+
fn classify_no_signal_is_uncertain_never_idle() {
|
|
119
|
+
// THE IRON LAW: no decisive prompt/working signal → uncertain 0.5, NOT idle.
|
|
120
|
+
let state = json(serde_json::json!({}));
|
|
121
|
+
let a = classify_agent_activity(&state, "random prose nothing", false, None, None);
|
|
122
|
+
assert_eq!(a.status, ActivityStatus::Uncertain);
|
|
123
|
+
assert_eq!(a.confidence, 0.5);
|
|
124
|
+
assert_ne!(a.status, ActivityStatus::Idle);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#[test]
|
|
128
|
+
fn classify_recent_provider_output_is_working_low_confidence() {
|
|
129
|
+
// age <= 120 with provider/no command → working 0.7.
|
|
130
|
+
let state = json(serde_json::json!({}));
|
|
131
|
+
let now = chrono::Utc::now();
|
|
132
|
+
let recent = (now - chrono::Duration::seconds(30)).to_rfc3339();
|
|
133
|
+
let a = classify_agent_activity(&state, "prose", false, None, Some(&recent));
|
|
134
|
+
assert_eq!(a.status, ActivityStatus::Working);
|
|
135
|
+
assert_eq!(a.confidence, 0.7);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// STAGE-B REGRESSION RED (dispatch-to-just-launched-agent → deferred_busy never closes the round-trip).
|
|
139
|
+
// golden activity_detector.py (classify_agent_activity): the provider IDLE PROMPT is checked FIRST as a
|
|
140
|
+
// scrollback-position signal (C14, "provider idle prompt is the latest scrollback signal" → idle 0.9),
|
|
141
|
+
// BEFORE the `age<=120 → working 0.7` recent-output branch (:56). Rust (activity.rs:192) fires
|
|
142
|
+
// `recent_rfc3339(last_output_at,120) → Working` BEFORE `latest_prompt_signal` (:200), so a just-launched
|
|
143
|
+
// agent (startup banner = recent output, but pane shows the idle prompt awaiting input) mis-classifies
|
|
144
|
+
// WORKING → sync_agent_health writes agent_health=WORKING → recipient_is_busy → send.deferred_busy.
|
|
145
|
+
// Golden evidence (probe): classify(idle-prompt scrollback, recent last_output_at) = idle 0.9 regardless
|
|
146
|
+
// of active_task. FIX = reorder: latest_prompt_signal (idle/working scrollback-position) BEFORE the
|
|
147
|
+
// last_output_at age block.
|
|
148
|
+
#[test]
|
|
149
|
+
fn classify_idle_prompt_beats_recent_output_for_just_launched_agent() {
|
|
150
|
+
let state = json(serde_json::json!({}));
|
|
151
|
+
let recent = chrono::Utc::now().to_rfc3339();
|
|
152
|
+
let a = classify_agent_activity(&state, "codex ready\n❯ \n", false, Some("codex"), Some(&recent));
|
|
153
|
+
assert_eq!(
|
|
154
|
+
a.status,
|
|
155
|
+
ActivityStatus::Idle,
|
|
156
|
+
"just-launched agent showing the idle prompt must classify IDLE (golden idle-prompt-position is the \
|
|
157
|
+
latest scrollback signal, checked before the age<=120 recent-output branch), not WORKING because \
|
|
158
|
+
the startup banner is recent (activity.rs:192 recent-output fires before latest_prompt_signal:200) \
|
|
159
|
+
— the Stage B dispatch deferred_busy regression. got {a:?}"
|
|
160
|
+
);
|
|
161
|
+
assert_eq!(a.confidence, 0.9, "golden idle-prompt confidence is 0.9; got {a:?}");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
165
|
+
// GROUP H — attempt_trust_auto_answer: own-vs-foreign realpath + fail-safe
|
|
166
|
+
// pane-width + opt-in gate + reason byte-locks. leader_panes.py:383-470.
|
|
167
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
168
|
+
|
|
169
|
+
#[test]
|
|
170
|
+
fn trust_auto_answer_pane_id_missing_reason() {
|
|
171
|
+
// leader_panes.py:417-424 — pane_id None → pane_id_missing (after opt-in).
|
|
172
|
+
let ws = tmp_ws("trustpane");
|
|
173
|
+
let log = EventLog::new(&ws);
|
|
174
|
+
let t = NoopTransport;
|
|
175
|
+
let out = attempt_trust_auto_answer(
|
|
176
|
+
&ws,
|
|
177
|
+
&t,
|
|
178
|
+
None,
|
|
179
|
+
"some prompt",
|
|
180
|
+
&PaneWidthQuery::Ok { pane_width: 120 },
|
|
181
|
+
&log,
|
|
182
|
+
)
|
|
183
|
+
.unwrap();
|
|
184
|
+
assert!(!out.ok);
|
|
185
|
+
assert!(!out.answered);
|
|
186
|
+
assert_eq!(out.reason, "pane_id_missing");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[test]
|
|
190
|
+
fn trust_auto_answer_foreign_workspace_refused() {
|
|
191
|
+
// leader_panes.py:430-444 — prompt names a FOREIGN dir → workspace_dir_mismatch,
|
|
192
|
+
// action prompt_leader. (own-vs-foreign realpath gate.)
|
|
193
|
+
let ws = tmp_ws("trustforeign");
|
|
194
|
+
let log = EventLog::new(&ws);
|
|
195
|
+
let t = NoopTransport;
|
|
196
|
+
let pane = PaneId::new("%7");
|
|
197
|
+
let foreign_tail = "Allow Codex to access /some/other/foreign/dir ?";
|
|
198
|
+
let out = attempt_trust_auto_answer(
|
|
199
|
+
&ws,
|
|
200
|
+
&t,
|
|
201
|
+
Some(&pane),
|
|
202
|
+
foreign_tail,
|
|
203
|
+
&PaneWidthQuery::Ok { pane_width: 120 },
|
|
204
|
+
&log,
|
|
205
|
+
)
|
|
206
|
+
.unwrap();
|
|
207
|
+
assert!(!out.answered);
|
|
208
|
+
assert_eq!(out.reason, "workspace_dir_mismatch");
|
|
209
|
+
assert_eq!(out.action.as_deref(), Some("prompt_leader"));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn trust_auto_answer_own_workspace_realpath_equal_answers() {
|
|
214
|
+
// Exact canonical equality of the prompt path with the workspace → answered.
|
|
215
|
+
let ws = tmp_ws("trustown");
|
|
216
|
+
let canonical = std::fs::canonicalize(&ws).unwrap();
|
|
217
|
+
let log = EventLog::new(&ws);
|
|
218
|
+
let t = NoopTransport;
|
|
219
|
+
let pane = PaneId::new("%7");
|
|
220
|
+
let tail = format!("Allow Codex to write to {} ?", canonical.display());
|
|
221
|
+
let out = attempt_trust_auto_answer(
|
|
222
|
+
&canonical,
|
|
223
|
+
&t,
|
|
224
|
+
Some(&pane),
|
|
225
|
+
&tail,
|
|
226
|
+
&PaneWidthQuery::Ok { pane_width: 240 },
|
|
227
|
+
&log,
|
|
228
|
+
)
|
|
229
|
+
.unwrap();
|
|
230
|
+
assert!(out.answered, "own-workspace realpath-equal prompt must auto-answer");
|
|
231
|
+
assert_eq!(out.reason, "trust_auto_answered");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
235
|
+
// GROUP I — PaneWidthQuery fail-safe (bug-064/082): Failed NEVER carries a
|
|
236
|
+
// default width; tmux_pane_width returns Failed on any query failure.
|
|
237
|
+
// delivery.py:20-51.
|
|
238
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn pane_width_failed_forces_exact_match_never_default() {
|
|
242
|
+
// GROUP-I fail-safe (bug-064/082, folded from the old structural placeholder):
|
|
243
|
+
// calls the REAL attempt_trust_auto_answer. A FOREIGN path that is merely a
|
|
244
|
+
// truncated PREFIX of this workspace would only match with a width signal that
|
|
245
|
+
// proves right-edge truncation (leader_panes.py:_token_reaches_right_edge). With
|
|
246
|
+
// PaneWidthQuery::Failed there is NO width signal and NO default width to leak,
|
|
247
|
+
// so the matcher MUST fall back to exact canonical equality → the prefix does
|
|
248
|
+
// NOT match → workspace_dir_mismatch / prompt_leader. Probed golden:
|
|
249
|
+
// leader_panes.py:430-444 with pane_width=None (Failed) → workspace_dir_mismatch.
|
|
250
|
+
let ws = tmp_ws("panewidthfailsafe");
|
|
251
|
+
let canonical = std::fs::canonicalize(&ws).unwrap();
|
|
252
|
+
let log = EventLog::new(&ws);
|
|
253
|
+
let t = NoopTransport;
|
|
254
|
+
let pane = PaneId::new("%7");
|
|
255
|
+
// A right-edge-truncated prefix of the real workspace path (drop the last char):
|
|
256
|
+
// would auto-answer IF a width signal proved truncation — but Failed forbids that.
|
|
257
|
+
let canon_str = canonical.to_string_lossy();
|
|
258
|
+
let truncated_prefix = &canon_str[..canon_str.len().saturating_sub(1)];
|
|
259
|
+
let tail = format!("Allow Codex to write to {truncated_prefix}");
|
|
260
|
+
let out = attempt_trust_auto_answer(
|
|
261
|
+
&canonical,
|
|
262
|
+
&t,
|
|
263
|
+
Some(&pane),
|
|
264
|
+
&tail,
|
|
265
|
+
&PaneWidthQuery::Failed {
|
|
266
|
+
error: "tmux_query_failed:CalledProcessError".to_string(),
|
|
267
|
+
},
|
|
268
|
+
&log,
|
|
269
|
+
)
|
|
270
|
+
.unwrap();
|
|
271
|
+
// fail-safe: no width → exact-equality only → truncated prefix refused.
|
|
272
|
+
assert!(!out.answered, "Failed pane-width must NOT enable prefix/truncation matching");
|
|
273
|
+
assert_eq!(out.reason, "workspace_dir_mismatch");
|
|
274
|
+
assert_eq!(out.action.as_deref(), Some("prompt_leader"));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#[test]
|
|
278
|
+
fn tmux_pane_width_failure_yields_failed_not_default() {
|
|
279
|
+
// delivery.py:37-50 — any failure path returns Failed (never a guessed width).
|
|
280
|
+
let t = NoopTransport;
|
|
281
|
+
let target = Target::Pane(PaneId::new("%nonexistent"));
|
|
282
|
+
let q = tmux_pane_width(&t, &target);
|
|
283
|
+
assert!(
|
|
284
|
+
matches!(q, PaneWidthQuery::Failed { .. }),
|
|
285
|
+
"query failure must be fail-safe Failed, never a default width"
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
290
|
+
// GROUP J — trust retry status machine: bounded attempt → exhausted terminal.
|
|
291
|
+
// delivery.py:221-319 (_handle_trust_retry_needed).
|
|
292
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
293
|
+
|
|
294
|
+
#[test]
|
|
295
|
+
fn handle_trust_retry_below_max_schedules_retry() {
|
|
296
|
+
// attempt 1 (< 4) → next_attempt 2 scheduled, status retry_scheduled,
|
|
297
|
+
// stage trust_auto_answer_dismissal_wait. NOT marked terminal-failed.
|
|
298
|
+
let ws = tmp_ws("trustretry1");
|
|
299
|
+
let store = store_for(&ws);
|
|
300
|
+
let log = EventLog::new(&ws);
|
|
301
|
+
let payload = TrustRetryPayload {
|
|
302
|
+
message_id: "m1".to_string(),
|
|
303
|
+
attempt: 1,
|
|
304
|
+
max_attempts: TRUST_RETRY_MAX_ATTEMPTS,
|
|
305
|
+
first_target: PaneId::new("%7"),
|
|
306
|
+
};
|
|
307
|
+
let out = handle_trust_retry_needed(&store, &payload, &log).unwrap();
|
|
308
|
+
assert_eq!(out.status, DeliveryStatus::RetryScheduled);
|
|
309
|
+
assert_eq!(out.stage, Some(DeliveryStage::TrustAutoAnswerDismissalWait));
|
|
310
|
+
assert!(!out.ok);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn handle_trust_retry_at_max_is_exhausted_terminal() {
|
|
315
|
+
// attempt == 4 (== MAX) → next_attempt 5 > MAX → terminal exhausted, marks
|
|
316
|
+
// message failed, emits trust_auto_answer_exhausted. delivery.py:246-266.
|
|
317
|
+
let ws = tmp_ws("trustretry4");
|
|
318
|
+
let store = store_for(&ws);
|
|
319
|
+
let log = EventLog::new(&ws);
|
|
320
|
+
let payload = TrustRetryPayload {
|
|
321
|
+
message_id: "m1".to_string(),
|
|
322
|
+
attempt: TRUST_RETRY_MAX_ATTEMPTS,
|
|
323
|
+
max_attempts: TRUST_RETRY_MAX_ATTEMPTS,
|
|
324
|
+
first_target: PaneId::new("%7"),
|
|
325
|
+
};
|
|
326
|
+
let out = handle_trust_retry_needed(&store, &payload, &log).unwrap();
|
|
327
|
+
// delivery.py:257-259 — terminal exhausted: ok False, status the dedicated
|
|
328
|
+
// trust_auto_answer_exhausted (a bounded-loop termination guarantee, NOT a
|
|
329
|
+
// refusal reason — `reason` stays None at the typed boundary).
|
|
330
|
+
assert_eq!(out.status, DeliveryStatus::TrustAutoAnswerExhausted);
|
|
331
|
+
assert!(!out.ok);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
335
|
+
// GROUP K — send_message target resolution / fallback chain (send.py:36-372).
|
|
336
|
+
// RED via unimplemented!(); golden status/reason encoded in assertions.
|
|
337
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
338
|
+
|
|
339
|
+
#[test]
|
|
340
|
+
fn send_message_target_not_in_team_is_refused() {
|
|
341
|
+
// send.py:259-261 — non-leader, non-team target → refused/target_not_in_team.
|
|
342
|
+
let ws = tmp_ws("sendrefuse");
|
|
343
|
+
let opts = SendOptions::default();
|
|
344
|
+
let out = send_message(
|
|
345
|
+
&ws,
|
|
346
|
+
&MessageTarget::Single("ghost".to_string()),
|
|
347
|
+
"hi",
|
|
348
|
+
&opts,
|
|
349
|
+
)
|
|
350
|
+
.unwrap();
|
|
351
|
+
assert_eq!(out.status, DeliveryStatus::Refused);
|
|
352
|
+
assert_eq!(out.reason, Some(DeliveryRefusal::TargetNotInTeam));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#[test]
|
|
356
|
+
fn send_message_broadcast_empty_team_skips_no_recipients() {
|
|
357
|
+
// send.py:391-393 — "*" with no team recipients →
|
|
358
|
+
// {"ok": False, "status": "failed", "reason": "no_team_recipients", "to": "*"}.
|
|
359
|
+
// Post-#230 N31/N32 funnel implementation (cr-approved): broadcast now expands the
|
|
360
|
+
// recipient set via `broadcast_recipients(state, sender, team)` and routes each
|
|
361
|
+
// recipient through the SAME primitives as a single send (leader → primitive,
|
|
362
|
+
// peer → send_message). The assertions stay the same: with no agents seeded and
|
|
363
|
+
// sender="leader" (default opts.sender), `broadcast_recipients` returns an empty
|
|
364
|
+
// list — outcome is Failed/no-recipients with channel="*". The "*" channel label
|
|
365
|
+
// is preserved through the new `fanout_send(..., channel_label="*")` parameter so
|
|
366
|
+
// legacy consumers can still tell broadcast (`*`) apart from explicit fanout list.
|
|
367
|
+
let ws = tmp_ws("sendbcast");
|
|
368
|
+
let opts = SendOptions::default();
|
|
369
|
+
let out = send_message(&ws, &MessageTarget::Broadcast, "hi", &opts).unwrap();
|
|
370
|
+
assert!(!out.ok);
|
|
371
|
+
assert_eq!(out.status, DeliveryStatus::Failed);
|
|
372
|
+
assert_eq!(
|
|
373
|
+
out.reason, None,
|
|
374
|
+
"no_team_recipients is a failed-status terminal, not a typed refusal reason"
|
|
375
|
+
);
|
|
376
|
+
assert_eq!(
|
|
377
|
+
out.channel.as_deref(),
|
|
378
|
+
Some("*"),
|
|
379
|
+
"broadcast outcome must carry the '*' channel (send.py to='*'); fanout_send(channel_label=\"*\") preserves this"
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn send_message_fanout_empty_recipients_fails() {
|
|
385
|
+
// send.py:456-457 — fanout with no usable recipients → ok False,
|
|
386
|
+
// no_fanout_recipients. (Dedup-then-deliver happy path needs team fixtures.)
|
|
387
|
+
let ws = tmp_ws("sendfanout");
|
|
388
|
+
let opts = SendOptions::default();
|
|
389
|
+
let out = send_message(&ws, &MessageTarget::Fanout(vec![]), "hi", &opts).unwrap();
|
|
390
|
+
assert!(!out.ok);
|
|
391
|
+
assert_eq!(out.status, DeliveryStatus::Failed);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
395
|
+
// GROUP L — apply_worker_sender_bypass: owner-gate first-door bypass event.
|
|
396
|
+
// owner_bypass.py:9-26.
|
|
397
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
398
|
+
|
|
399
|
+
#[test]
|
|
400
|
+
fn worker_sender_bypass_false_for_leader_sender() {
|
|
401
|
+
// owner_bypass.py — leader sender never bypasses (worker_sender_bypasses=None).
|
|
402
|
+
let ws = tmp_ws("bypassleader");
|
|
403
|
+
let log = EventLog::new(&ws);
|
|
404
|
+
let state = json(serde_json::json!({"agents": {"w1": {}}}));
|
|
405
|
+
let bypassed = apply_worker_sender_bypass(
|
|
406
|
+
&state,
|
|
407
|
+
Some("leader"),
|
|
408
|
+
&MessageTarget::Single("w1".to_string()),
|
|
409
|
+
None,
|
|
410
|
+
&log,
|
|
411
|
+
)
|
|
412
|
+
.unwrap();
|
|
413
|
+
assert!(!bypassed);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#[test]
|
|
417
|
+
#[serial_test::serial(env)]
|
|
418
|
+
fn worker_sender_bypass_true_for_known_worker_sender() {
|
|
419
|
+
// owner_bypass.py:18-26 — worker in agents bypasses, writes
|
|
420
|
+
// send.bypassed_owner_gate_worker_sender.
|
|
421
|
+
// Isolate from ambient TEAM_AGENT_ID: the env identity gate only activates when
|
|
422
|
+
// TEAM_AGENT_ID is SET (and != sender → deny, see p2_owner_bypass_denies_*). Unset
|
|
423
|
+
// here so the agents-membership bypass is tested deterministically regardless of the
|
|
424
|
+
// process env (workers run with TEAM_AGENT_ID set; the leader does not).
|
|
425
|
+
let _g = ENV_LOCK_MSG.lock().unwrap_or_else(|p| p.into_inner());
|
|
426
|
+
let _e = EnvGuardMsg::set("TEAM_AGENT_ID", None);
|
|
427
|
+
let ws = tmp_ws("bypassworker");
|
|
428
|
+
let log = EventLog::new(&ws);
|
|
429
|
+
let state = json(serde_json::json!({"agents": {"w1": {}}}));
|
|
430
|
+
let bypassed = apply_worker_sender_bypass(
|
|
431
|
+
&state,
|
|
432
|
+
Some("w1"),
|
|
433
|
+
&MessageTarget::Single("w2".to_string()),
|
|
434
|
+
None,
|
|
435
|
+
&log,
|
|
436
|
+
)
|
|
437
|
+
.unwrap();
|
|
438
|
+
assert!(bypassed);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
442
|
+
// GROUP M — report_result intake (results.py:191-227): validate envelope,
|
|
443
|
+
// queue leader notify (channel coordinator), return ok shape.
|
|
444
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
445
|
+
|
|
446
|
+
#[test]
|
|
447
|
+
fn report_result_valid_envelope_returns_ok_with_result_id() {
|
|
448
|
+
let ws = tmp_ws("report");
|
|
449
|
+
let envelope = json(serde_json::json!({
|
|
450
|
+
"schema_version": "result_envelope_v1",
|
|
451
|
+
"task_id": "t1", "agent_id": "alice", "status": "success",
|
|
452
|
+
"summary": "done", "changes": [], "tests": [], "risks": [],
|
|
453
|
+
"artifacts": [], "next_actions": []
|
|
454
|
+
}));
|
|
455
|
+
let out = report_result(&ws, &envelope).unwrap();
|
|
456
|
+
// results.py:216-227 — ok True with result_id/task_id/agent_id echoed.
|
|
457
|
+
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
|
|
458
|
+
assert_eq!(
|
|
459
|
+
out.get("task_id").and_then(|v| v.as_str()),
|
|
460
|
+
Some("t1")
|
|
461
|
+
);
|
|
462
|
+
assert_eq!(
|
|
463
|
+
out.get("agent_id").and_then(|v| v.as_str()),
|
|
464
|
+
Some("alice")
|
|
465
|
+
);
|
|
466
|
+
assert!(out.get("result_id").and_then(|v| v.as_str()).is_some());
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#[test]
|
|
470
|
+
fn report_result_funnels_into_leader_delivery_primitive_not_queued_scheduled_event() {
|
|
471
|
+
// #230 N31/N32 funnel (cr verdict §3 I-3 + MUST-8):
|
|
472
|
+
//
|
|
473
|
+
// [OLD assertion] report_result inserted a parallel `scheduled_events(kind='send',
|
|
474
|
+
// target='leader', status='pending')` row + returned `notification_status="queued"`,
|
|
475
|
+
// and the worker-facing tool body claimed success while leader had not yet seen the
|
|
476
|
+
// result. That queued-only path was MUST-8 / I-3 violating — `notification_status=
|
|
477
|
+
// queued` was returned as success but the leader pane never actually got the text.
|
|
478
|
+
//
|
|
479
|
+
// [NEW assertion] report_result now synchronously funnels through the shared leader-
|
|
480
|
+
// delivery primitive (`send_to_leader_receiver`), creating a `messages` row that
|
|
481
|
+
// `deliver_pending_messages` picks up on the same tick (NO `scheduled_events` row).
|
|
482
|
+
// Without a bound leader pane (this fixture has no `leader_receiver.pane_id`), the
|
|
483
|
+
// primitive returns I-4 `rebind_required` (Blocked / ok=false) — the row is persisted
|
|
484
|
+
// as `failed` for audit and the tool body's `notification_status` is `rebind_required`,
|
|
485
|
+
// NOT a misleading `queued` success. The contract grep that bans `queue_report_result_
|
|
486
|
+
// notification` / `notification_status="queued[_only]"` literals in `results.rs` is the
|
|
487
|
+
// direct mechanical counterpart of this assertion.
|
|
488
|
+
let ws = tmp_ws("reportnotify");
|
|
489
|
+
crate::state::persist::save_runtime_state(
|
|
490
|
+
&ws,
|
|
491
|
+
&serde_json::json!({
|
|
492
|
+
"session_name": null,
|
|
493
|
+
"leader": {"id": "leader"},
|
|
494
|
+
"agents": {"worker": {"status": "running"}},
|
|
495
|
+
"tasks": [{"id": "task_1", "status": "running", "assignee": "worker"}]
|
|
496
|
+
}),
|
|
497
|
+
)
|
|
498
|
+
.unwrap();
|
|
499
|
+
let store = store_for(&ws);
|
|
500
|
+
let envelope = json(serde_json::json!({
|
|
501
|
+
"schema_version": "result_envelope_v1",
|
|
502
|
+
"task_id": "task_1",
|
|
503
|
+
"agent_id": "worker",
|
|
504
|
+
"status": "success",
|
|
505
|
+
"summary": "done",
|
|
506
|
+
"changes": [],
|
|
507
|
+
"tests": [{"command": "cargo test", "status": "passed"}],
|
|
508
|
+
"risks": [],
|
|
509
|
+
"artifacts": [],
|
|
510
|
+
"next_actions": []
|
|
511
|
+
}));
|
|
512
|
+
|
|
513
|
+
let out = report_result(&ws, &envelope).unwrap();
|
|
514
|
+
let result_id = out
|
|
515
|
+
.get("result_id")
|
|
516
|
+
.and_then(|v| v.as_str())
|
|
517
|
+
.expect("report_result returns generated result_id");
|
|
518
|
+
assert!(
|
|
519
|
+
result_id.starts_with("res_"),
|
|
520
|
+
"MessageStore.add_result generates res_* ids; got {result_id}"
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
// No `scheduled_events` rows: the queued parallel path is gone.
|
|
524
|
+
let conn = seed_conn(&store);
|
|
525
|
+
let scheduled_count: i64 = conn
|
|
526
|
+
.query_row("select count(*) from scheduled_events", [], |row| row.get(0))
|
|
527
|
+
.unwrap();
|
|
528
|
+
assert_eq!(
|
|
529
|
+
scheduled_count, 0,
|
|
530
|
+
"N31/N32 funnel: report_result must NOT insert a parallel scheduled_events 'send' row; the leader-delivery primitive is the single funnel"
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
// Tool body: no `queued`/`queued_only` notification_status. Without a bound leader
|
|
534
|
+
// pane this fixture surfaces I-4 `rebind_required` (ok=false on the leader delivery,
|
|
535
|
+
// but the result row + audit trail are durable for rebind replay).
|
|
536
|
+
assert_eq!(
|
|
537
|
+
out.get("notification_status").and_then(|v| v.as_str()),
|
|
538
|
+
Some("rebind_required"),
|
|
539
|
+
"I-4: unbound leader pane → rebind_required, never queued/queued_only success"
|
|
540
|
+
);
|
|
541
|
+
assert_eq!(
|
|
542
|
+
out.get("leader_notified").and_then(|v| v.as_bool()),
|
|
543
|
+
Some(false),
|
|
544
|
+
"I-4: leader_notified=false when no leader pane is bound"
|
|
545
|
+
);
|
|
546
|
+
assert!(
|
|
547
|
+
out.get("notification_event_id").is_some_and(|v| v.is_null()),
|
|
548
|
+
"no scheduled_events row → notification_event_id is null"
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// Audit events: the funnel emits leader_receiver.delivery_blocked (I-4 rebind),
|
|
552
|
+
// and the legacy mcp.report_result_notify_queued audit is gone.
|
|
553
|
+
let events_path = ws.join(".team").join("logs").join("events.jsonl");
|
|
554
|
+
let event_lines = std::fs::read_to_string(events_path)
|
|
555
|
+
.expect("report_result writes events.jsonl");
|
|
556
|
+
assert!(
|
|
557
|
+
event_lines.contains("\"leader_receiver.delivery_blocked\""),
|
|
558
|
+
"I-4 rebind path must emit leader_receiver.delivery_blocked audit; got {event_lines}",
|
|
559
|
+
);
|
|
560
|
+
assert!(
|
|
561
|
+
!event_lines.contains("mcp.report_result_notify_queued"),
|
|
562
|
+
"legacy queued-notification audit must be gone; got {event_lines}",
|
|
563
|
+
);
|
|
564
|
+
assert!(
|
|
565
|
+
event_lines.contains("\"mcp.report_result\""),
|
|
566
|
+
"report_result still emits its own audit event; got {event_lines}",
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn report_result_invalid_envelope_errors_validation() {
|
|
572
|
+
// validate_result_envelope raises ValidationError → MessagingError::Validation.
|
|
573
|
+
let ws = tmp_ws("reportbad");
|
|
574
|
+
let envelope = json(serde_json::json!({"schema_version": "result_envelope_v1"}));
|
|
575
|
+
let err = report_result(&ws, &envelope).unwrap_err();
|
|
576
|
+
assert!(
|
|
577
|
+
matches!(err, MessagingError::Validation(_)),
|
|
578
|
+
"missing required fields must surface as Validation, got {err:?}"
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
583
|
+
// GROUP N — notify_result_watchers dedupe (exactly-once, Gap 32/38).
|
|
584
|
+
// result_delivery.py:38-132. superseded for duplicate watchers same result.
|
|
585
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
586
|
+
|
|
587
|
+
#[test]
|
|
588
|
+
fn notify_result_watchers_no_match_returns_empty() {
|
|
589
|
+
// result_delivery.py:51-52 — no candidate watcher matches → empty list.
|
|
590
|
+
let ws = tmp_ws("notifyempty");
|
|
591
|
+
let log = EventLog::new(&ws);
|
|
592
|
+
let result = json(serde_json::json!({
|
|
593
|
+
"result_id": "r1", "task_id": "t1", "agent_id": "alice"
|
|
594
|
+
}));
|
|
595
|
+
let watchers = vec![json(serde_json::json!({
|
|
596
|
+
"watcher_id": "w-x", "task_id": "OTHER", "agent_id": "alice"
|
|
597
|
+
}))];
|
|
598
|
+
let notices = notify_result_watchers(
|
|
599
|
+
&ws,
|
|
600
|
+
&result,
|
|
601
|
+
&log,
|
|
602
|
+
Some(&watchers),
|
|
603
|
+
None,
|
|
604
|
+
)
|
|
605
|
+
.unwrap();
|
|
606
|
+
assert!(notices.is_empty());
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#[test]
|
|
610
|
+
fn notify_result_watchers_supersedes_duplicate_watchers() {
|
|
611
|
+
// result_delivery.py:53-78 — two watchers same (task,agent,result): earliest is
|
|
612
|
+
// primary, the other gets superseded (ok False, notice records superseded).
|
|
613
|
+
let ws = tmp_ws("notifysup");
|
|
614
|
+
let log = EventLog::new(&ws);
|
|
615
|
+
let result = json(serde_json::json!({
|
|
616
|
+
"result_id": "r1", "task_id": "t1", "agent_id": "alice"
|
|
617
|
+
}));
|
|
618
|
+
let watchers = vec![
|
|
619
|
+
json(serde_json::json!({
|
|
620
|
+
"watcher_id": "w-early", "task_id": "t1", "agent_id": "alice",
|
|
621
|
+
"created_at": "2026-06-02T10:00:00+00:00"
|
|
622
|
+
})),
|
|
623
|
+
json(serde_json::json!({
|
|
624
|
+
"watcher_id": "w-late", "task_id": "t1", "agent_id": "alice",
|
|
625
|
+
"created_at": "2026-06-02T11:00:00+00:00"
|
|
626
|
+
})),
|
|
627
|
+
];
|
|
628
|
+
let notices =
|
|
629
|
+
notify_result_watchers(&ws, &result, &log, Some(&watchers), None).unwrap();
|
|
630
|
+
// The late watcher must appear as a superseded (not-ok) notice — exactly-once.
|
|
631
|
+
let superseded = notices
|
|
632
|
+
.iter()
|
|
633
|
+
.find(|n| n.watcher_id == "w-late")
|
|
634
|
+
.expect("late watcher must be reported");
|
|
635
|
+
assert!(!superseded.ok, "duplicate watcher must be superseded, not re-delivered");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
639
|
+
// GROUP O — requeue_after_claim_leader: notified_message_id must SURVIVE (Gap
|
|
640
|
+
// 32) — already-notified watchers are NOT requeued. result_delivery.py:428-506.
|
|
641
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
642
|
+
|
|
643
|
+
#[test]
|
|
644
|
+
fn requeue_after_claim_leader_skips_already_notified() {
|
|
645
|
+
// Gap 32 (result_delivery.py:467-471) — SEEDED dedupe gate: two same-team
|
|
646
|
+
// watchers, one already notified (notified_message_id set), one un-notified.
|
|
647
|
+
// requeue must return ONLY the un-notified watcher; the notified one is NOT
|
|
648
|
+
// requeued and its notified_message_id SURVIVES (clearing it would cause a
|
|
649
|
+
// second injection). Probed golden: requeued == [w_un] (result_id null,
|
|
650
|
+
// prior_state "pending"); notified watcher keeps notified_message_id.
|
|
651
|
+
let ws = tmp_ws("requeue");
|
|
652
|
+
let store = store_for(&ws);
|
|
653
|
+
let log = EventLog::new(&ws);
|
|
654
|
+
let team = TeamKey::new("team-a");
|
|
655
|
+
let pane = PaneId::new("%new-leader");
|
|
656
|
+
|
|
657
|
+
let w_un = seed_watcher(&store, "w-unnotified", "team-a", "t1", "alice", "pending", None, None);
|
|
658
|
+
let w_notified = seed_watcher(
|
|
659
|
+
&store, "w-notified", "team-a", "t2", "bob", "pending", None, Some("msg_already"),
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
let requeued =
|
|
663
|
+
requeue_after_claim_leader(&ws, &store, &log, &team, &pane, None).unwrap();
|
|
664
|
+
|
|
665
|
+
// ONLY the un-notified watcher requeues (the notified one is the dedupe gate).
|
|
666
|
+
let ids: Vec<&str> = requeued.iter().map(|n| n.watcher_id.as_str()).collect();
|
|
667
|
+
assert_eq!(ids, vec![w_un.as_str()], "exactly the un-notified watcher requeues");
|
|
668
|
+
assert!(
|
|
669
|
+
!requeued.iter().any(|n| n.watcher_id == w_notified),
|
|
670
|
+
"already-notified watcher must NOT be requeued (Gap 32)"
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// Gap 32 survival: notified_message_id is preserved on the skipped watcher.
|
|
674
|
+
let (_status, notified) = watcher_state(&store, &w_notified);
|
|
675
|
+
assert_eq!(
|
|
676
|
+
notified.as_deref(),
|
|
677
|
+
Some("msg_already"),
|
|
678
|
+
"notified_message_id MUST survive requeue — clearing it re-injects (Gap 32)"
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#[test]
|
|
683
|
+
fn requeue_delivery_exhausted_watchers_reopens_only_exhausted() {
|
|
684
|
+
let ws = tmp_ws("requeueexhausted");
|
|
685
|
+
let store = store_for(&ws);
|
|
686
|
+
let log = EventLog::new(&ws);
|
|
687
|
+
let team = TeamKey::new("team-a");
|
|
688
|
+
let pane = PaneId::new("%leader");
|
|
689
|
+
|
|
690
|
+
let rid = seed_result(&store, "res_exhausted", "t1", "alice", "success");
|
|
691
|
+
let exhausted = seed_watcher(
|
|
692
|
+
&store,
|
|
693
|
+
"w-exhausted",
|
|
694
|
+
"team-a",
|
|
695
|
+
"t1",
|
|
696
|
+
"alice",
|
|
697
|
+
"delivery_exhausted",
|
|
698
|
+
Some(&rid),
|
|
699
|
+
None,
|
|
700
|
+
);
|
|
701
|
+
let notified = seed_watcher(
|
|
702
|
+
&store,
|
|
703
|
+
"w-exhausted-notified",
|
|
704
|
+
"team-a",
|
|
705
|
+
"t2",
|
|
706
|
+
"bob",
|
|
707
|
+
"delivery_exhausted",
|
|
708
|
+
Some("res_skip"),
|
|
709
|
+
Some("msg_done"),
|
|
710
|
+
);
|
|
711
|
+
let failed = seed_watcher(
|
|
712
|
+
&store,
|
|
713
|
+
"w-failed",
|
|
714
|
+
"team-a",
|
|
715
|
+
"t3",
|
|
716
|
+
"carol",
|
|
717
|
+
"notify_failed",
|
|
718
|
+
Some("res_failed"),
|
|
719
|
+
None,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
let requeued =
|
|
723
|
+
requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
|
|
724
|
+
|
|
725
|
+
assert_eq!(requeued.len(), 1, "only delivery_exhausted unnotified watchers requeue");
|
|
726
|
+
let notice = &requeued[0];
|
|
727
|
+
assert_eq!(notice.watcher_id, exhausted);
|
|
728
|
+
assert_eq!(notice.result_id.as_deref(), Some(rid.as_str()));
|
|
729
|
+
assert_eq!(notice.prior_state.as_deref(), Some("delivery_exhausted"));
|
|
730
|
+
// R8 (golden result_watchers.py:95): attach requeue flips delivery_exhausted -> notify_failed (NOT pending).
|
|
731
|
+
assert_eq!(notice.status.as_deref(), Some("notify_failed"));
|
|
732
|
+
|
|
733
|
+
let (status, _notified_id) = watcher_state(&store, &exhausted);
|
|
734
|
+
// R8 (golden): attach requeue leaves the watcher at notify_failed and DEFERS retry to the coordinator
|
|
735
|
+
// tick — it does NOT immediately re-deliver (only the claim path retries). So the persisted status is
|
|
736
|
+
// notify_failed, not 'notified'.
|
|
737
|
+
assert_eq!(status, "notify_failed", "attach requeue flips to notify_failed and defers retry (golden)");
|
|
738
|
+
let (status, notified_id) = watcher_state(&store, ¬ified);
|
|
739
|
+
assert_eq!(status, "delivery_exhausted");
|
|
740
|
+
assert_eq!(notified_id.as_deref(), Some("msg_done"));
|
|
741
|
+
let (status, _notified_id) = watcher_state(&store, &failed);
|
|
742
|
+
assert_eq!(status, "notify_failed", "non-exhausted watcher is not selected");
|
|
743
|
+
|
|
744
|
+
// R8 (golden leader/__init__.py:46-50): result_watcher.requeued is the ATTACH form
|
|
745
|
+
// {watcher_id, trigger:"attach_leader", new_pane_id} — NOT the claim-style {prior_state,claimed_pane_id,team_id}.
|
|
746
|
+
let events = log.tail(0).unwrap();
|
|
747
|
+
let ev = events.iter().rev()
|
|
748
|
+
.find(|event| event.get("event").and_then(|v| v.as_str()) == Some("result_watcher.requeued"))
|
|
749
|
+
.expect("result_watcher.requeued event");
|
|
750
|
+
let keys: std::collections::BTreeSet<&str> = ev.as_object().unwrap().keys()
|
|
751
|
+
.map(String::as_str).filter(|k| *k != "ts" && *k != "event").collect();
|
|
752
|
+
let expected: std::collections::BTreeSet<&str> = ["watcher_id", "trigger", "new_pane_id"].into_iter().collect();
|
|
753
|
+
assert_eq!(keys, expected, "result_watcher.requeued must be golden attach form {{watcher_id, trigger, new_pane_id}}");
|
|
754
|
+
assert_eq!(ev.get("watcher_id").and_then(|v| v.as_str()), Some("w-exhausted"));
|
|
755
|
+
assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
|
|
756
|
+
assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader"));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
760
|
+
// GROUP P — stuck_cancel owner-gate + invalid alert type refusal.
|
|
761
|
+
// scheduler.py:262-294.
|
|
762
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
763
|
+
|
|
764
|
+
#[test]
|
|
765
|
+
fn stuck_cancel_none_alert_type_expands_to_all() {
|
|
766
|
+
// alert_type None == Python "all" → sorted(_ALERT_TYPES) expansion.
|
|
767
|
+
let ws = tmp_ws("stuckcancel");
|
|
768
|
+
let out = stuck_cancel(&ws, "w1", None, "leader").unwrap();
|
|
769
|
+
// The suppression result must enumerate all three alert types.
|
|
770
|
+
let types = out
|
|
771
|
+
.get("alert_types")
|
|
772
|
+
.and_then(|v| v.as_array())
|
|
773
|
+
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect::<Vec<_>>());
|
|
774
|
+
assert_eq!(
|
|
775
|
+
types,
|
|
776
|
+
Some(vec![
|
|
777
|
+
"cross_worker_deadlock".to_string(),
|
|
778
|
+
"idle_fallback".to_string(),
|
|
779
|
+
"stuck".to_string()
|
|
780
|
+
])
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
785
|
+
// GROUP Q — collect intake (results.py:45-167): valid result advances task,
|
|
786
|
+
// returns collected_results + delivered_messages + results counts shape.
|
|
787
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
788
|
+
|
|
789
|
+
#[test]
|
|
790
|
+
fn collect_without_spec_surfaces_validation_error() {
|
|
791
|
+
// results.py:46-48 — collect() reads spec_path then load_spec(); against a
|
|
792
|
+
// workspace with NO team.spec.yaml, load_spec RAISES ValidationError
|
|
793
|
+
// ("Cannot read <path>: ...", spec.py:18-20) BEFORE any collection. The
|
|
794
|
+
// previous `.unwrap()` (expecting an Ok dict with present-only keys) was wrong:
|
|
795
|
+
// the real Python collect on a bare workspace does not return a dict, it raises.
|
|
796
|
+
// At the typed boundary that surfaces as MessagingError::Validation.
|
|
797
|
+
//
|
|
798
|
+
// The full collected_results-count golden (seed an uncollected result, assert it
|
|
799
|
+
// collects and the task advances) is DEFERRED: it requires a valid on-disk
|
|
800
|
+
// team.spec.yaml + runtime state, whose formats are owned by the spec/state lanes
|
|
801
|
+
// (this file may not edit them). seed_result() exists for the retry path that
|
|
802
|
+
// does NOT need a spec; the collect happy-path needs an integration fixture.
|
|
803
|
+
let ws = tmp_ws("collect");
|
|
804
|
+
let err = collect(&ws, None, false).unwrap_err();
|
|
805
|
+
assert!(
|
|
806
|
+
matches!(err, MessagingError::Validation(_)),
|
|
807
|
+
"collect without a team spec must surface Validation, got {err:?}"
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
#[test]
|
|
812
|
+
fn collect_accepts_message_scoped_result_for_matching_recipient() {
|
|
813
|
+
let ws = tmp_ws("collectmsgok");
|
|
814
|
+
std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
|
|
815
|
+
let store = store_for(&ws);
|
|
816
|
+
let message_id = store
|
|
817
|
+
.create_message(None, "leader", "w1", "please reply", None, false, None)
|
|
818
|
+
.unwrap();
|
|
819
|
+
seed_result(&store, "res_msg_ok", &message_id, "w1", "success");
|
|
820
|
+
|
|
821
|
+
let out = collect(&ws, None, false).unwrap();
|
|
822
|
+
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
|
|
823
|
+
let collected = out
|
|
824
|
+
.get("collected_results")
|
|
825
|
+
.and_then(|v| v.as_array())
|
|
826
|
+
.expect("collected_results");
|
|
827
|
+
assert_eq!(collected.len(), 1);
|
|
828
|
+
assert_eq!(collected[0].get("task_id").and_then(|v| v.as_str()), Some(message_id.as_str()));
|
|
829
|
+
assert_eq!(collected[0].get("agent_id").and_then(|v| v.as_str()), Some("w1"));
|
|
830
|
+
assert_eq!(collected[0].get("scope").and_then(|v| v.as_str()), Some("message"));
|
|
831
|
+
// D3 (leader-adjudicated): golden collected_results entry is EXACTLY the 8-key summary for BOTH
|
|
832
|
+
// scopes; golden's task_status feeds only the `collect.result` EVENT, never the entry. So a
|
|
833
|
+
// message-scope entry carries NO task_status key (the prior `Some("message_scoped")` lock encoded a
|
|
834
|
+
// port divergence — dropped per ruling).
|
|
835
|
+
assert!(
|
|
836
|
+
collected[0].get("task_status").is_none(),
|
|
837
|
+
"collected_results entry must NOT carry task_status (golden 8-key summary; event-only); got {:?}",
|
|
838
|
+
collected[0]
|
|
839
|
+
);
|
|
840
|
+
let keys: Vec<&str> = collected[0].as_object().expect("entry is an object").keys().map(String::as_str).collect();
|
|
841
|
+
assert_eq!(
|
|
842
|
+
keys,
|
|
843
|
+
vec!["result_id", "task_id", "agent_id", "status", "summary", "tests", "created_at", "scope"],
|
|
844
|
+
"message-scope collected_results entry must be EXACTLY the golden 8 keys in order; got {keys:?}"
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
#[test]
|
|
849
|
+
fn collect_rejects_message_scoped_result_without_matching_recipient() {
|
|
850
|
+
let ws = tmp_ws("collectmsgbad");
|
|
851
|
+
std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
|
|
852
|
+
let store = store_for(&ws);
|
|
853
|
+
let message_id = store
|
|
854
|
+
.create_message(None, "leader", "w1", "please reply", None, false, None)
|
|
855
|
+
.unwrap();
|
|
856
|
+
seed_result(&store, "res_msg_bad", &message_id, "w2", "success");
|
|
857
|
+
|
|
858
|
+
let out = collect(&ws, None, false).unwrap();
|
|
859
|
+
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(false));
|
|
860
|
+
assert!(
|
|
861
|
+
out.get("collected_results")
|
|
862
|
+
.and_then(|v| v.as_array())
|
|
863
|
+
.is_some_and(Vec::is_empty),
|
|
864
|
+
"recipient mismatch must not collect as message-scoped"
|
|
865
|
+
);
|
|
866
|
+
let invalid = out
|
|
867
|
+
.get("invalid_results")
|
|
868
|
+
.and_then(|v| v.as_array())
|
|
869
|
+
.expect("invalid_results");
|
|
870
|
+
assert_eq!(invalid.len(), 1);
|
|
871
|
+
assert_eq!(invalid[0].get("task_id").and_then(|v| v.as_str()), Some(message_id.as_str()));
|
|
872
|
+
assert_eq!(
|
|
873
|
+
invalid[0].get("error").and_then(|v| v.as_str()),
|
|
874
|
+
Some(format!("unknown task id: {message_id}").as_str())
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
#[test]
|
|
879
|
+
fn allow_peer_talk_records_bidirectional_allowlist_and_event() {
|
|
880
|
+
let ws = tmp_ws("allowpeer");
|
|
881
|
+
let out = allow_peer_talk(&ws, "alice", "bob").unwrap();
|
|
882
|
+
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true));
|
|
883
|
+
assert_eq!(out.get("a").and_then(|v| v.as_str()), Some("alice"));
|
|
884
|
+
assert_eq!(out.get("b").and_then(|v| v.as_str()), Some("bob"));
|
|
885
|
+
assert_eq!(out.get("status").and_then(|v| v.as_str()), Some("compat_noop"));
|
|
886
|
+
assert_eq!(
|
|
887
|
+
out.get("reason").and_then(|v| v.as_str()),
|
|
888
|
+
Some("team_scoped_peer_messages_enabled")
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
let store = store_for(&ws);
|
|
892
|
+
let conn = seed_conn(&store);
|
|
893
|
+
let rows = conn
|
|
894
|
+
.prepare("select a, b from peer_allowlist order by a, b")
|
|
895
|
+
.unwrap()
|
|
896
|
+
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
|
|
897
|
+
.unwrap()
|
|
898
|
+
.collect::<Result<Vec<_>, _>>()
|
|
899
|
+
.unwrap();
|
|
900
|
+
assert_eq!(
|
|
901
|
+
rows,
|
|
902
|
+
vec![
|
|
903
|
+
("alice".to_string(), "bob".to_string()),
|
|
904
|
+
("bob".to_string(), "alice".to_string()),
|
|
905
|
+
]
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
let events = EventLog::new(&ws).tail(10).unwrap();
|
|
909
|
+
let event = events
|
|
910
|
+
.iter()
|
|
911
|
+
.find(|event| event.get("event").and_then(|v| v.as_str()) == Some("communication.peer_allowed"))
|
|
912
|
+
.expect("communication.peer_allowed event");
|
|
913
|
+
assert_eq!(event.get("a").and_then(|v| v.as_str()), Some("alice"));
|
|
914
|
+
assert_eq!(event.get("b").and_then(|v| v.as_str()), Some("bob"));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
918
|
+
// GROUP R — run_comms_selftest: §84 / MUST-NOT-13 zero-provider-SDK gate.
|
|
919
|
+
// diagnose/comms.py:21-47. The whole point: assert zero provider client calls.
|
|
920
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
921
|
+
|
|
922
|
+
#[test]
|
|
923
|
+
fn run_comms_selftest_zero_provider_sdk_passes_and_locks_scope() {
|
|
924
|
+
let ws = tmp_ws("selftestok");
|
|
925
|
+
let driver = ZeroSdkDriver {
|
|
926
|
+
run_id: Some("fixedrunid01".to_string()),
|
|
927
|
+
calls: ProviderSdkCalls::default(),
|
|
928
|
+
};
|
|
929
|
+
let report = run_comms_selftest(&ws, None, &driver).unwrap();
|
|
930
|
+
// diagnose/comms.py:44 scope == "binding_consistency".
|
|
931
|
+
assert_eq!(report.scope, "binding_consistency");
|
|
932
|
+
assert_eq!(report.run_id, "fixedrunid01");
|
|
933
|
+
// The mechanical gate: provider_sdk_calls check is a Pass with all-zero evidence.
|
|
934
|
+
assert_eq!(report.provider_sdk_calls.status, CheckStatus::Pass);
|
|
935
|
+
match &report.provider_sdk_calls.evidence {
|
|
936
|
+
CheckEvidence::ProviderSdkCalls(calls) => assert!(calls.is_zero()),
|
|
937
|
+
other => panic!("expected ProviderSdkCalls evidence, got {other:?}"),
|
|
938
|
+
}
|
|
939
|
+
assert!(report.ok);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
#[test]
|
|
943
|
+
fn run_comms_selftest_nonzero_provider_sdk_fails_gate() {
|
|
944
|
+
// Any non-zero SDK call count → provider_sdk_calls check FAILS, report not ok.
|
|
945
|
+
let ws = tmp_ws("selftestbad");
|
|
946
|
+
let driver = ZeroSdkDriver {
|
|
947
|
+
run_id: Some("r2".to_string()),
|
|
948
|
+
calls: ProviderSdkCalls {
|
|
949
|
+
anthropic: 1,
|
|
950
|
+
openai: 0,
|
|
951
|
+
httpx: 0,
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
let report = run_comms_selftest(&ws, None, &driver).unwrap();
|
|
955
|
+
assert_eq!(report.provider_sdk_calls.status, CheckStatus::Fail);
|
|
956
|
+
assert!(!report.ok);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
#[test]
|
|
960
|
+
fn run_comms_selftest_contract_suite_is_deferred() {
|
|
961
|
+
// diagnose/comms.py:132-139 — contract_suite is always deferred (test files
|
|
962
|
+
// not shipped) and counts as a pass for the overall gate.
|
|
963
|
+
let ws = tmp_ws("selftestdefer");
|
|
964
|
+
let driver = ZeroSdkDriver {
|
|
965
|
+
run_id: Some("r3".to_string()),
|
|
966
|
+
calls: ProviderSdkCalls::default(),
|
|
967
|
+
};
|
|
968
|
+
let report = run_comms_selftest(&ws, None, &driver).unwrap();
|
|
969
|
+
assert_eq!(report.contract_suite.status, CheckStatus::Deferred);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
973
|
+
// GROUP S — evaluate_idle_behavior: claimed_status normalization
|
|
974
|
+
// (IDLE/WORKING/RUNNING → not_challenged). diagnose/comms.py:50-94.
|
|
975
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
976
|
+
|
|
977
|
+
#[test]
|
|
978
|
+
fn evaluate_idle_behavior_recognized_status_is_not_challenged() {
|
|
979
|
+
// diagnose/comms.py:86-94 — claimed_status in {IDLE,WORKING,RUNNING} (case-
|
|
980
|
+
// insensitive) and no driver result → status not_challenged, ok True.
|
|
981
|
+
let ws = tmp_ws("idleeval");
|
|
982
|
+
let driver = ZeroSdkDriver {
|
|
983
|
+
run_id: None,
|
|
984
|
+
calls: ProviderSdkCalls::default(),
|
|
985
|
+
};
|
|
986
|
+
let out = evaluate_idle_behavior(&ws, "w1", "IDLE", None, &driver).unwrap();
|
|
987
|
+
assert_eq!(out.status, CheckStatus::NotChallenged);
|
|
988
|
+
assert!(out.ok);
|
|
989
|
+
assert_eq!(out.agent_id, "w1");
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
993
|
+
// GROUP T — deliver_pending_message claim atomicity + status machine.
|
|
994
|
+
// delivery.py:63-218. missing message / unknown recipient / already-claimed.
|
|
995
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
996
|
+
|
|
997
|
+
#[test]
|
|
998
|
+
fn deliver_pending_message_missing_message_fails() {
|
|
999
|
+
// delivery.py:73-75 — no such message row → ok False, status failed,
|
|
1000
|
+
// reason message_missing.
|
|
1001
|
+
let ws = tmp_ws("delivermissing");
|
|
1002
|
+
let store = store_for(&ws);
|
|
1003
|
+
let log = EventLog::new(&ws);
|
|
1004
|
+
let t = NoopTransport;
|
|
1005
|
+
let out = deliver_pending_message(&ws, &store, &t, "nope", &log, &serde_json::json!({})).unwrap();
|
|
1006
|
+
assert!(!out.ok);
|
|
1007
|
+
assert_eq!(out.status, DeliveryStatus::Failed);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1011
|
+
// GROUP U — fire_due_scheduled_events: exhaustive ScheduledKind dispatch +
|
|
1012
|
+
// send dedupe. scheduler.py:41-121. Returns fired event-id list.
|
|
1013
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1014
|
+
|
|
1015
|
+
#[test]
|
|
1016
|
+
fn fire_due_scheduled_events_fires_each_scheduled_kind() {
|
|
1017
|
+
// SEEDED exhaustive-dispatch contract (scheduler.py:41-121): seed one due row
|
|
1018
|
+
// of EACH ScheduledKind (send / health_ping / trust_retry). The dispatch loop
|
|
1019
|
+
// must fire all three (one match arm per kind, no runtime fallback) and return
|
|
1020
|
+
// each fired event id. Probed golden: a due health_ping fires → marked 'done'
|
|
1021
|
+
// with {"ok":true,"status":"logged"} and its id appears in the fired list;
|
|
1022
|
+
// every due row's id is appended regardless of kind (scheduler.py:118).
|
|
1023
|
+
let ws = tmp_ws("scheduler");
|
|
1024
|
+
let store = store_for(&ws);
|
|
1025
|
+
let log = EventLog::new(&ws);
|
|
1026
|
+
let t = NoopTransport;
|
|
1027
|
+
|
|
1028
|
+
let send_id = seed_scheduled_event(
|
|
1029
|
+
&store,
|
|
1030
|
+
ScheduledKind::Send,
|
|
1031
|
+
"%w1",
|
|
1032
|
+
&serde_json::json!({"content": "ping", "attempt": 1, "max_attempts": 1}),
|
|
1033
|
+
);
|
|
1034
|
+
let ping_id =
|
|
1035
|
+
seed_scheduled_event(&store, ScheduledKind::HealthPing, "%w1", &serde_json::json!({}));
|
|
1036
|
+
let trust_id = seed_scheduled_event(
|
|
1037
|
+
&store,
|
|
1038
|
+
ScheduledKind::TrustRetry,
|
|
1039
|
+
"%w1",
|
|
1040
|
+
&serde_json::json!({"message_id": "m1", "attempt": 1, "max_attempts": 4}),
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
let fired = fire_due_scheduled_events(&ws, &store, &t, &log).unwrap();
|
|
1044
|
+
|
|
1045
|
+
// Every seeded due kind must be dispatched and its id returned (exhaustive,
|
|
1046
|
+
// no kind silently dropped via a fallthrough).
|
|
1047
|
+
for id in [send_id, ping_id, trust_id] {
|
|
1048
|
+
assert!(
|
|
1049
|
+
fired.contains(&id),
|
|
1050
|
+
"scheduled event id {id} (each ScheduledKind) must fire; got {fired:?}"
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
assert_eq!(fired.len(), 3, "exactly the three seeded due events fire, no extras");
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1057
|
+
// GROUP V — retry_result_deliveries: re-route notify_failed watchers with
|
|
1058
|
+
// dedupe_reason rebind_retry. result_delivery.py:19-35.
|
|
1059
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1060
|
+
|
|
1061
|
+
#[test]
|
|
1062
|
+
fn retry_result_deliveries_retries_notify_failed_watcher() {
|
|
1063
|
+
// SEEDED contract (result_delivery.py:18-34): retry_result_deliveries scans
|
|
1064
|
+
// retryable_result_watchers (status in pending/notify_failed), resolves each
|
|
1065
|
+
// watcher's result via result_by_id, and re-routes through notify_result_watchers
|
|
1066
|
+
// with dedupe_reason="rebind_retry". Seed a notify_failed watcher + its matching
|
|
1067
|
+
// result row → the watcher IS retried and a WatcherNotice for it is returned.
|
|
1068
|
+
// Probed golden: notices == [{watcher_id, result_id, ok, ...}] for the seeded
|
|
1069
|
+
// watcher (delivery ok depends on full team state; the retry-was-attempted
|
|
1070
|
+
// contract is the invariant — an empty store would NOT exercise it).
|
|
1071
|
+
let ws = tmp_ws("retrydeliv");
|
|
1072
|
+
let store = store_for(&ws);
|
|
1073
|
+
let log = EventLog::new(&ws);
|
|
1074
|
+
|
|
1075
|
+
let rid = seed_result(&store, "res_r1", "t1", "alice", "success");
|
|
1076
|
+
let w = seed_watcher(
|
|
1077
|
+
&store, "w-failed", "team-a", "t1", "alice", "notify_failed", Some(&rid), None,
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
let notices = retry_result_deliveries(&ws, &log).unwrap();
|
|
1081
|
+
|
|
1082
|
+
assert_eq!(notices.len(), 1, "the single notify_failed watcher must be retried");
|
|
1083
|
+
let notice = ¬ices[0];
|
|
1084
|
+
assert_eq!(notice.watcher_id, w, "the retried notice names the seeded watcher");
|
|
1085
|
+
assert_eq!(
|
|
1086
|
+
notice.result_id.as_deref(),
|
|
1087
|
+
Some(rid.as_str()),
|
|
1088
|
+
"retry resolves and carries the watcher's result_id (rebind_retry path)"
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1093
|
+
// GROUP W — collect_results_and_notify_watchers orchestration shape.
|
|
1094
|
+
// results.py:430-447.
|
|
1095
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1096
|
+
|
|
1097
|
+
#[test]
|
|
1098
|
+
fn collect_results_and_notify_watchers_returns_concrete_ok_shape() {
|
|
1099
|
+
// SEEDED contract (results.py:430-447): with NO uncollected results, collect() is
|
|
1100
|
+
// skipped (the `if store.results(uncollected_only=True)` guard is false), so the
|
|
1101
|
+
// result stays {ok:true, collected_results:[]}; a seeded notify_failed watcher
|
|
1102
|
+
// whose result_id has no matching results row is resolved to None by
|
|
1103
|
+
// retry_result_deliveries → skipped → notified stays []. Probed golden (against
|
|
1104
|
+
// exactly this fixture): {"ok": true, "collected": 0, "notified": []}.
|
|
1105
|
+
// (The previous test asserted only out["ok"].is_some(), trivially passed by
|
|
1106
|
+
// {"ok": false}.)
|
|
1107
|
+
let ws = tmp_ws("collectnotify");
|
|
1108
|
+
let store = store_for(&ws);
|
|
1109
|
+
let log = EventLog::new(&ws);
|
|
1110
|
+
|
|
1111
|
+
seed_watcher(
|
|
1112
|
+
&store, "w-orphan", "team-a", "t1", "alice", "notify_failed", Some("res_missing"), None,
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
let out = collect_results_and_notify_watchers(&ws, &log).unwrap();
|
|
1116
|
+
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true), "ok==true");
|
|
1117
|
+
assert_eq!(
|
|
1118
|
+
out.get("collected").and_then(|v| v.as_i64()),
|
|
1119
|
+
Some(0),
|
|
1120
|
+
"no uncollected results → collected==0"
|
|
1121
|
+
);
|
|
1122
|
+
assert_eq!(
|
|
1123
|
+
out.get("notified").and_then(|v| v.as_array()).map(|a| a.len()),
|
|
1124
|
+
Some(0),
|
|
1125
|
+
"orphan watcher (missing result) is skipped → notified empty"
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1130
|
+
// GROUP X — delivered_result_message content-level dedupe lookup +
|
|
1131
|
+
// result_id_from_text dual (scheduler send dedupe path). result_delivery.py:394.
|
|
1132
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1133
|
+
|
|
1134
|
+
#[test]
|
|
1135
|
+
fn delivered_result_message_none_in_fresh_store() {
|
|
1136
|
+
let ws = tmp_ws("delivdedupe");
|
|
1137
|
+
let store = store_for(&ws);
|
|
1138
|
+
let found = delivered_result_message(&store, "r1", None, None).unwrap();
|
|
1139
|
+
assert!(found.is_none());
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
#[test]
|
|
1143
|
+
fn delivered_result_message_empty_result_id_is_none() {
|
|
1144
|
+
// result_delivery.py:401-402 — empty result_id short-circuits to None.
|
|
1145
|
+
let ws = tmp_ws("delivdedupe2");
|
|
1146
|
+
let store = store_for(&ws);
|
|
1147
|
+
let found = delivered_result_message(&store, "", None, None).unwrap();
|
|
1148
|
+
assert!(found.is_none());
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1152
|
+
// collect #223 — task-scoped collect + send --task validation (RED).
|
|
1153
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1154
|
+
|
|
1155
|
+
// (c) a result whose task_id ∈ state.tasks collects as scope:"task"; the task row advances to
|
|
1156
|
+
// "done" (success → done, runtime.py:1066); results.collected ≥ 1. Proves the collect-READ works
|
|
1157
|
+
// once state.tasks is seeded — so the #223 fix target is the upstream seeding, not collect.
|
|
1158
|
+
#[test]
|
|
1159
|
+
fn collect_task_scoped_result_collects_and_marks_task_done() {
|
|
1160
|
+
let ws = tmp_ws("collecttask223");
|
|
1161
|
+
std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
|
|
1162
|
+
crate::state::persist::save_runtime_state(
|
|
1163
|
+
&ws,
|
|
1164
|
+
&serde_json::json!({
|
|
1165
|
+
"session_name": "team-x",
|
|
1166
|
+
"agents": { "w1": { "provider": "codex" } },
|
|
1167
|
+
"tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
|
|
1168
|
+
}),
|
|
1169
|
+
).unwrap();
|
|
1170
|
+
let store = store_for(&ws);
|
|
1171
|
+
seed_result(&store, "res_t2", "t2", "w1", "success");
|
|
1172
|
+
|
|
1173
|
+
let out = collect(&ws, None, false).unwrap();
|
|
1174
|
+
assert_eq!(out.get("ok").and_then(|v| v.as_bool()), Some(true), "no invalid → ok:true");
|
|
1175
|
+
let cr = out.get("collected_results").and_then(|v| v.as_array()).expect("collected_results");
|
|
1176
|
+
assert_eq!(cr.len(), 1, "the seeded t2 result must collect");
|
|
1177
|
+
assert_eq!(cr[0].get("scope").and_then(|v| v.as_str()), Some("task"), "t2 ∈ state.tasks → scope:task");
|
|
1178
|
+
assert_eq!(cr[0].get("task_id").and_then(|v| v.as_str()), Some("t2"));
|
|
1179
|
+
assert_eq!(cr[0].get("agent_id").and_then(|v| v.as_str()), Some("w1"));
|
|
1180
|
+
assert!(
|
|
1181
|
+
out.get("results").and_then(|r| r.get("collected")).and_then(|v| v.as_i64()).unwrap_or(0) >= 1,
|
|
1182
|
+
"results.collected must be ≥ 1"
|
|
1183
|
+
);
|
|
1184
|
+
let st = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1185
|
+
let t2_status = st.get("tasks").and_then(|v| v.as_array())
|
|
1186
|
+
.and_then(|ts| ts.iter().find(|t| t.get("id").and_then(|v| v.as_str()) == Some("t2")))
|
|
1187
|
+
.and_then(|t| t.get("status")).and_then(|v| v.as_str());
|
|
1188
|
+
assert_eq!(t2_status, Some("done"), "success result → task row status 'done' (runtime.py:1066)");
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// (c-C1) collect OUTPUT shape: collected_results entries are the 8-KEY SUMMARY (NO inlined
|
|
1192
|
+
// envelope; carry summary+tests) and the full envelopes live in a SEPARATE top-level `collected`
|
|
1193
|
+
// list (golden results.py:86/131). Rust inlines `envelope`+`owner_team_id` and emits no
|
|
1194
|
+
// `collected` list → RED.
|
|
1195
|
+
#[test]
|
|
1196
|
+
fn collect_output_matches_golden_collected_shape() {
|
|
1197
|
+
let ws = tmp_ws("collectshape223");
|
|
1198
|
+
std::fs::write(ws.join("team.spec.yaml"), "version: 1\n").unwrap();
|
|
1199
|
+
crate::state::persist::save_runtime_state(
|
|
1200
|
+
&ws,
|
|
1201
|
+
&serde_json::json!({
|
|
1202
|
+
"session_name": "team-x",
|
|
1203
|
+
"agents": { "w1": { "provider": "codex" } },
|
|
1204
|
+
"tasks": [ { "id": "t2", "assignee": "w1", "title": "t2", "status": "pending" } ]
|
|
1205
|
+
}),
|
|
1206
|
+
).unwrap();
|
|
1207
|
+
let store = store_for(&ws);
|
|
1208
|
+
seed_result(&store, "res_t2s", "t2", "w1", "success");
|
|
1209
|
+
|
|
1210
|
+
let out = collect(&ws, None, false).unwrap();
|
|
1211
|
+
let cr = out.get("collected_results").and_then(|v| v.as_array()).expect("collected_results");
|
|
1212
|
+
let e = &cr[0];
|
|
1213
|
+
// C1: collected_results entry is the 8-key SUMMARY — NO envelope inlined; carries summary+tests.
|
|
1214
|
+
assert!(e.get("envelope").is_none(),
|
|
1215
|
+
"collected_results entry must NOT inline `envelope` (golden 8-key summary); the full envelope belongs in `collected`. got {e:?}");
|
|
1216
|
+
assert!(e.get("summary").is_some() && e.get("tests").is_some(),
|
|
1217
|
+
"collected_results summary entry must carry `summary`+`tests` (golden results.py:131)");
|
|
1218
|
+
// C1: the full envelopes live in a separate top-level `collected` list.
|
|
1219
|
+
let collected = out.get("collected").and_then(|v| v.as_array())
|
|
1220
|
+
.expect("golden collect returns a top-level `collected` list of full envelopes");
|
|
1221
|
+
assert!(
|
|
1222
|
+
collected.first().and_then(|env| env.get("schema_version")).and_then(|v| v.as_str())
|
|
1223
|
+
== Some("result_envelope_v1"),
|
|
1224
|
+
"collected[0] must be the full result_envelope_v1 envelope; got {collected:?}"
|
|
1225
|
+
);
|
|
1226
|
+
|
|
1227
|
+
// ── STRENGTHENED (option-B byte-parity, leader-adjudicated 0700cff review) ──
|
|
1228
|
+
// D3 — task-scope collected_results entry must be EXACTLY the golden 8 keys, in order, NO task_status.
|
|
1229
|
+
let keys: Vec<&str> = e.as_object().expect("entry is an object").keys().map(String::as_str).collect();
|
|
1230
|
+
assert_eq!(
|
|
1231
|
+
keys,
|
|
1232
|
+
vec!["result_id", "task_id", "agent_id", "status", "summary", "tests", "created_at", "scope"],
|
|
1233
|
+
"collected_results entry must be EXACTLY the golden 8 keys in order (results.py:131; no task_status/envelope/owner_team_id); got {keys:?}"
|
|
1234
|
+
);
|
|
1235
|
+
// D1+D2 — collect RETURN top-level key order must match golden EXACTLY: delivered_messages BEFORE
|
|
1236
|
+
// invalid_results, AND a `coordinator` key (mirroring golden _ensure_coordinator_after_collect).
|
|
1237
|
+
let top: Vec<&str> = out.as_object().expect("collect result is an object").keys().map(String::as_str).collect();
|
|
1238
|
+
assert_eq!(
|
|
1239
|
+
top,
|
|
1240
|
+
vec!["ok", "collected", "collected_results", "delivered_messages", "invalid_results", "results", "state_file", "coordinator"],
|
|
1241
|
+
"collect return top-level key order must match golden return shape; got {top:?}"
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// (d) send --task <unknown id> must RAISE golden "unknown task id" (runtime.py:1032 _find_task),
|
|
1246
|
+
// not silently create a message. Rust send_message attaches task_id without validating → Ok. RED.
|
|
1247
|
+
// block_until_delivered=false isolates the task-validation from any delivery side-effect.
|
|
1248
|
+
#[test]
|
|
1249
|
+
fn send_with_unknown_task_id_raises_unknown_task() {
|
|
1250
|
+
let ws = tmp_ws("sendunknowntask223");
|
|
1251
|
+
crate::state::persist::save_runtime_state(
|
|
1252
|
+
&ws,
|
|
1253
|
+
&serde_json::json!({
|
|
1254
|
+
"session_name": "team-x",
|
|
1255
|
+
"agents": { "w1": { "provider": "codex" } },
|
|
1256
|
+
"tasks": []
|
|
1257
|
+
}),
|
|
1258
|
+
).unwrap();
|
|
1259
|
+
let _ = store_for(&ws);
|
|
1260
|
+
let opts = SendOptions {
|
|
1261
|
+
task_id: Some(crate::model::ids::TaskId::new("t2-unknown")),
|
|
1262
|
+
block_until_delivered: false,
|
|
1263
|
+
..SendOptions::default()
|
|
1264
|
+
};
|
|
1265
|
+
let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "go", &opts);
|
|
1266
|
+
match out {
|
|
1267
|
+
Err(e) => {
|
|
1268
|
+
// SURFACED error = the CLI `error` field = CliError::from(MessagingError).to_string()
|
|
1269
|
+
// (to_payload uses self.to_string(), types.rs:59). Must EQUAL golden's bare message —
|
|
1270
|
+
// NO "validation:" variant prefix (golden runtime.py:1032 surfaces str(exc)).
|
|
1271
|
+
let surfaced = crate::cli::CliError::from(e).to_string();
|
|
1272
|
+
assert_eq!(
|
|
1273
|
+
surfaced, "unknown task id: t2-unknown",
|
|
1274
|
+
"surfaced CLI error must EQUAL golden's message with NO variant prefix; got {surfaced:?}"
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
Ok(o) => panic!(
|
|
1278
|
+
"send --task <unknown id> must RAISE 'unknown task id' (golden runtime.py:1032 _find_task), \
|
|
1279
|
+
not silently create a message; got Ok({o:?})"
|
|
1280
|
+
),
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1285
|
+
// P0 REGRESSION (0700cff "send 0 bytes, nothing queued" / coordinator never delivers).
|
|
1286
|
+
// golden gates the unknown-task RAISE on route_task_id (send.py:204 `if task_id and route_task_id`);
|
|
1287
|
+
// delivery/fanout/internal sends pass route_task_id=False (internal_delivery.py:44, send.py:412/481)
|
|
1288
|
+
// → the task is a label, NOT validated. 0700cff's UNCONDITIONAL task_exists gate broke every
|
|
1289
|
+
// task-tagged delivery/internal send at CREATION time. The gate the OfflineTransport tests missed.
|
|
1290
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1291
|
+
|
|
1292
|
+
// (a) [REGRESSION GATE] route_task_id=false + task_id NOT in state.tasks → send SUCCEEDS and the
|
|
1293
|
+
// message is QUEUED (real create path; no transport). Must NOT raise "unknown task id".
|
|
1294
|
+
#[test]
|
|
1295
|
+
fn send_route_task_id_false_skips_task_validation_and_queues() {
|
|
1296
|
+
let ws = tmp_ws("sendroutefalse");
|
|
1297
|
+
crate::state::persist::save_runtime_state(
|
|
1298
|
+
&ws,
|
|
1299
|
+
&serde_json::json!({ "session_name": "team-x", "agents": { "w1": { "provider": "codex" } }, "tasks": [] }),
|
|
1300
|
+
).unwrap();
|
|
1301
|
+
let _ = store_for(&ws);
|
|
1302
|
+
let opts = SendOptions {
|
|
1303
|
+
task_id: Some(crate::model::ids::TaskId::new("t-not-seeded")),
|
|
1304
|
+
route_task_id: false,
|
|
1305
|
+
block_until_delivered: false,
|
|
1306
|
+
..SendOptions::default()
|
|
1307
|
+
};
|
|
1308
|
+
let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "deliver me", &opts)
|
|
1309
|
+
.expect("route_task_id=false must NOT validate the task — golden delivery/internal path queues regardless of state.tasks");
|
|
1310
|
+
assert!(
|
|
1311
|
+
out.message_id.is_some(),
|
|
1312
|
+
"the message must be CREATED (message_id present) on the route_task_id=false path; got {out:?}"
|
|
1313
|
+
);
|
|
1314
|
+
// real queue verification (not an Ok shell): the message landed in w1's inbox.
|
|
1315
|
+
let inbox = store_for(&ws).inbox("w1", 10, None).expect("inbox");
|
|
1316
|
+
assert!(
|
|
1317
|
+
!inbox.is_empty(),
|
|
1318
|
+
"the task-tagged message must be QUEUED for w1 on the delivery/internal path; inbox empty (0 bytes queued = the P0)"
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// (c) [LOCK] route_task_id=true + task_id IN state.tasks → send SUCCEEDS (routing happy-path).
|
|
1323
|
+
#[test]
|
|
1324
|
+
fn send_route_task_id_true_known_task_succeeds() {
|
|
1325
|
+
let ws = tmp_ws("sendrouteknown");
|
|
1326
|
+
crate::state::persist::save_runtime_state(
|
|
1327
|
+
&ws,
|
|
1328
|
+
&serde_json::json!({
|
|
1329
|
+
"session_name": "team-x",
|
|
1330
|
+
"agents": { "w1": { "provider": "codex" } },
|
|
1331
|
+
"tasks": [ { "id": "t-known", "assignee": "w1", "title": "t", "status": "pending" } ]
|
|
1332
|
+
}),
|
|
1333
|
+
).unwrap();
|
|
1334
|
+
let _ = store_for(&ws);
|
|
1335
|
+
let opts = SendOptions {
|
|
1336
|
+
task_id: Some(crate::model::ids::TaskId::new("t-known")),
|
|
1337
|
+
route_task_id: true,
|
|
1338
|
+
block_until_delivered: false,
|
|
1339
|
+
..SendOptions::default()
|
|
1340
|
+
};
|
|
1341
|
+
let out = send_message(&ws, &MessageTarget::Single("w1".to_string()), "go", &opts)
|
|
1342
|
+
.expect("route_task_id=true with a KNOWN task must succeed");
|
|
1343
|
+
assert!(out.message_id.is_some(), "known-task routing send must create the message; got {out:?}");
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1347
|
+
// R8 byte-parity (leader attach requeue, advisor-ruled + e3eac28-reconciled):
|
|
1348
|
+
// drive a watcher to delivery_exhausted via notify_result_watchers (attempts>=MAX) — proving the
|
|
1349
|
+
// requeue input is REAL (non-空过) — then attach-requeue and assert the golden observable contract:
|
|
1350
|
+
// D2 status: delivery_exhausted -> notify_failed (golden result_watchers.py:95), NOT 'pending'.
|
|
1351
|
+
// D1 ✦ team-scoped + unnotified SELECTION (anti cross-team pollution / CP-1) — KEEP.
|
|
1352
|
+
// D3 result_watcher.requeued payload == golden attach form {watcher_id, trigger:"attach_leader", new_pane_id}.
|
|
1353
|
+
// (D4 leader_receiver.requeued_exhausted_watchers + D6 string return are the attach-wrapper/CLI layer —
|
|
1354
|
+
// lease.rs:140 + cli/mod.rs:1088 — flagged for the porter; D5 event-layer is internal/optional.)
|
|
1355
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1356
|
+
#[test]
|
|
1357
|
+
fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
1358
|
+
let ws = tmp_ws("r8requeue");
|
|
1359
|
+
let store = store_for(&ws);
|
|
1360
|
+
let log = EventLog::new(&ws);
|
|
1361
|
+
let team = TeamKey::new("team-a");
|
|
1362
|
+
let pane = PaneId::new("%leader-new");
|
|
1363
|
+
|
|
1364
|
+
// --- Sub-A: DRIVE w-r8 (team-a) to delivery_exhausted via notify_result_watchers (attempts>=MAX) ---
|
|
1365
|
+
let rid = seed_result(&store, "res_r8", "t1", "alice", "success");
|
|
1366
|
+
seed_watcher(&store, "w-r8", "team-a", "t1", "alice", "pending", Some(&rid), None);
|
|
1367
|
+
// attempts are EVENT-counted (result_watcher.notify_failed/retry_notified) — seed MAX prior failures.
|
|
1368
|
+
for n in 0..u64::from(RESULT_DELIVERY_MAX_ATTEMPTS) {
|
|
1369
|
+
log.write(
|
|
1370
|
+
"result_watcher.notify_failed",
|
|
1371
|
+
json(serde_json::json!({"watcher_id": "w-r8", "result_id": rid.as_str(), "status": "notify_failed", "error": "x", "n": n})),
|
|
1372
|
+
).unwrap();
|
|
1373
|
+
}
|
|
1374
|
+
let result_env = json(serde_json::json!({"result_id": rid.as_str(), "task_id": "t1", "agent_id": "alice"}));
|
|
1375
|
+
let watcher_view = json(serde_json::json!({
|
|
1376
|
+
"watcher_id": "w-r8", "task_id": "t1", "agent_id": "alice",
|
|
1377
|
+
"created_at": "2026-01-01T00:00:00Z", "owner_team_id": "team-a",
|
|
1378
|
+
"leader_id": "leader", "result_id": rid.as_str()
|
|
1379
|
+
}));
|
|
1380
|
+
notify_result_watchers(&ws, &result_env, &log, Some(&[watcher_view]), None).unwrap();
|
|
1381
|
+
let (driven, _) = watcher_state(&store, "w-r8");
|
|
1382
|
+
assert_eq!(driven, "delivery_exhausted",
|
|
1383
|
+
"PRECONDITION: notify_result_watchers at attempts>=MAX must persist delivery_exhausted (watchers.rs:161-168) — \
|
|
1384
|
+
proves the attach-requeue input is real, not 空过");
|
|
1385
|
+
|
|
1386
|
+
// selection-lock fixtures: cross-team exhausted + notified exhausted (Gap-32) + pending.
|
|
1387
|
+
let team_b = seed_watcher(&store, "w-teamb", "team-b", "t2", "bob", "delivery_exhausted", Some("res_b"), None);
|
|
1388
|
+
let notif = seed_watcher(&store, "w-notified", "team-a", "t3", "carol", "delivery_exhausted", Some("res_c"), Some("msg_done"));
|
|
1389
|
+
seed_watcher(&store, "w-pending", "team-a", "t4", "dave", "pending", Some("res_d"), None);
|
|
1390
|
+
|
|
1391
|
+
// --- Sub-B: attach requeue (golden contract) ---
|
|
1392
|
+
let requeued = requeue_delivery_exhausted_watchers(&ws, &store, &log, &team, &pane).unwrap();
|
|
1393
|
+
|
|
1394
|
+
// D2: team-a exhausted -> notify_failed (NOT pending).
|
|
1395
|
+
let (st_a, _) = watcher_state(&store, "w-r8");
|
|
1396
|
+
assert_eq!(st_a, "notify_failed",
|
|
1397
|
+
"D2: attach requeue must flip delivery_exhausted -> 'notify_failed' (golden result_watchers.py:95), not 'pending'");
|
|
1398
|
+
// D1 ✦ team-scoped: cross-team exhausted watcher must NOT requeue onto team-a's pane.
|
|
1399
|
+
let (st_b, _) = watcher_state(&store, &team_b);
|
|
1400
|
+
assert_eq!(st_b, "delivery_exhausted",
|
|
1401
|
+
"D1 ✦: team-scoped selection — a team-b exhausted watcher must NOT be requeued by a team-a attach (anti cross-team pollution / CP-1)");
|
|
1402
|
+
// Gap-32: a notified watcher is never requeued; its notified_message_id survives.
|
|
1403
|
+
let (st_n, nid) = watcher_state(&store, ¬if);
|
|
1404
|
+
assert_eq!(st_n, "delivery_exhausted", "Gap-32: notified watcher not requeued");
|
|
1405
|
+
assert_eq!(nid.as_deref(), Some("msg_done"), "Gap-32: notified_message_id preserved");
|
|
1406
|
+
// only the team-a unnotified exhausted watcher requeues.
|
|
1407
|
+
let ids: Vec<&str> = requeued.iter().map(|n| n.watcher_id.as_str()).collect();
|
|
1408
|
+
assert_eq!(ids, vec!["w-r8"], "only team-a unnotified delivery_exhausted watcher requeues");
|
|
1409
|
+
|
|
1410
|
+
// D3: result_watcher.requeued payload == golden ATTACH form {watcher_id, trigger, new_pane_id}.
|
|
1411
|
+
let events = log.tail(0).unwrap();
|
|
1412
|
+
let ev = events.iter().rev()
|
|
1413
|
+
.find(|e| e.get("event").and_then(|v| v.as_str()) == Some("result_watcher.requeued"))
|
|
1414
|
+
.expect("result_watcher.requeued event");
|
|
1415
|
+
let keys: std::collections::BTreeSet<&str> = ev.as_object().unwrap().keys()
|
|
1416
|
+
.map(String::as_str).filter(|k| *k != "ts" && *k != "event").collect();
|
|
1417
|
+
let expected: std::collections::BTreeSet<&str> = ["watcher_id", "trigger", "new_pane_id"].into_iter().collect();
|
|
1418
|
+
assert_eq!(keys, expected,
|
|
1419
|
+
"D3: result_watcher.requeued must be golden ATTACH form {{watcher_id, trigger, new_pane_id}} (leader/__init__.py:46-50), not claim-style; got {keys:?}");
|
|
1420
|
+
assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
|
|
1421
|
+
assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader-new"));
|
|
1422
|
+
}
|