@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,262 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// GROUP A — typed-enum / event byte-lock (committed model derive; GREEN baseline)
|
|
5
|
+
// 这些钉死 serde 契约,确保 events.jsonl 字节不漂移(§3/§22)。
|
|
6
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
#[test]
|
|
9
|
+
fn protocol_version_is_two() {
|
|
10
|
+
// metadata.py:13 COORDINATOR_PROTOCOL_VERSION = 2
|
|
11
|
+
assert_eq!(PROTOCOL_VERSION, 2);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn default_tick_interval_is_five_seconds() {
|
|
16
|
+
// __main__.py:101 DEFAULT_TICK_INTERVAL_SEC = 5.0 (Gap 36c)
|
|
17
|
+
assert_eq!(DEFAULT_TICK_INTERVAL_SEC, 5.0);
|
|
18
|
+
assert_eq!(BACKOFF_MAX_SEC, 60.0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[test]
|
|
22
|
+
fn rotation_marker_is_byte_exact() {
|
|
23
|
+
// watch.py:22 — byte-for-byte (note the U+2014 em dash).
|
|
24
|
+
assert_eq!(
|
|
25
|
+
ROTATION_MARKER,
|
|
26
|
+
"[watch] log rotated; archived segment events.jsonl.1 not replayed — historical replay deferred to a future --replay flag"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[test]
|
|
31
|
+
fn status_enums_serialize_to_exact_python_strings() {
|
|
32
|
+
let cases: &[(String, &str)] = &[
|
|
33
|
+
(serde_json::to_string(&CoordinatorHealthStatus::Missing).unwrap(), "\"missing\""),
|
|
34
|
+
(serde_json::to_string(&CoordinatorHealthStatus::InvalidPid).unwrap(), "\"invalid_pid\""),
|
|
35
|
+
(serde_json::to_string(&CoordinatorHealthStatus::Running).unwrap(), "\"running\""),
|
|
36
|
+
(serde_json::to_string(&CoordinatorHealthStatus::Stale).unwrap(), "\"stale\""),
|
|
37
|
+
(serde_json::to_string(&StartOutcome::AlreadyRunning).unwrap(), "\"already_running\""),
|
|
38
|
+
(serde_json::to_string(&StartOutcome::RestartIncompatibleStopFailed).unwrap(), "\"restart_incompatible_stop_failed\""),
|
|
39
|
+
(serde_json::to_string(&StartOutcome::SchemaIncompatible).unwrap(), "\"schema_incompatible\""),
|
|
40
|
+
(serde_json::to_string(&StartOutcome::Started).unwrap(), "\"started\""),
|
|
41
|
+
(serde_json::to_string(&StopOutcome::Missing).unwrap(), "\"missing\""),
|
|
42
|
+
(serde_json::to_string(&StopOutcome::InvalidPidRemoved).unwrap(), "\"invalid_pid_removed\""),
|
|
43
|
+
(serde_json::to_string(&StopOutcome::KillFailed).unwrap(), "\"kill_failed\""),
|
|
44
|
+
(serde_json::to_string(&StopOutcome::Stopped).unwrap(), "\"stopped\""),
|
|
45
|
+
(serde_json::to_string(&TickStopReason::TmuxSessionMissing).unwrap(), "\"tmux_session_missing\""),
|
|
46
|
+
(serde_json::to_string(&TickStopReason::PersistenceDegraded).unwrap(), "\"persistence_degraded\""),
|
|
47
|
+
(serde_json::to_string(&MetadataSource::Boot).unwrap(), "\"boot\""),
|
|
48
|
+
(serde_json::to_string(&MetadataSource::Start).unwrap(), "\"start\""),
|
|
49
|
+
(serde_json::to_string(&AbnormalDecision::Skip).unwrap(), "\"skip\""),
|
|
50
|
+
(serde_json::to_string(&AbnormalDecision::NotifyBlacklist).unwrap(), "\"notify_blacklist\""),
|
|
51
|
+
(serde_json::to_string(&AbnormalDecision::NotifyDefault).unwrap(), "\"notify_default\""),
|
|
52
|
+
(serde_json::to_string(&WholeTeamGoneClass::Alive).unwrap(), "\"alive\""),
|
|
53
|
+
(serde_json::to_string(&WholeTeamGoneClass::CleanShutdown).unwrap(), "\"clean_shutdown\""),
|
|
54
|
+
(serde_json::to_string(&WholeTeamGoneClass::RestartInProgress).unwrap(), "\"restart_in_progress\""),
|
|
55
|
+
(serde_json::to_string(&WholeTeamGoneClass::UnexpectedExit).unwrap(), "\"unexpected_exit\""),
|
|
56
|
+
];
|
|
57
|
+
for (got, want) in cases {
|
|
58
|
+
assert_eq!(got, want);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[test]
|
|
63
|
+
fn coordinator_event_tags_are_byte_stable() {
|
|
64
|
+
// 逐一钉 events.jsonl tag(events.py 稳定契约)。
|
|
65
|
+
let pid = Pid(4242);
|
|
66
|
+
let cases: Vec<(CoordinatorEvent, &str)> = vec![
|
|
67
|
+
(CoordinatorEvent::Boot { workspace: "/w".into(), once: true }, "coordinator.boot"),
|
|
68
|
+
(CoordinatorEvent::Started { pid, log: "/l".into() }, "coordinator.started"),
|
|
69
|
+
(CoordinatorEvent::Stopped { pid }, "coordinator.stopped"),
|
|
70
|
+
(CoordinatorEvent::Exit { stop: true }, "coordinator.exit"),
|
|
71
|
+
(CoordinatorEvent::SessionMissing { session: "s".into() }, "coordinator.session_missing"),
|
|
72
|
+
(
|
|
73
|
+
CoordinatorEvent::OrphanSelfTerminate { initial_ppid: 9, current_ppid: 1, workspace: "/w".into() },
|
|
74
|
+
"coordinator.orphan_self_terminate",
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
CoordinatorEvent::TickError { error: "boom".into(), exc_type: "OSError".into(), consecutive_failures: 1, next_sleep_sec: 5.0 },
|
|
78
|
+
"coordinator.tick_error",
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
CoordinatorEvent::TickErrorSuppressed { consecutive_failures: 2, next_sleep_sec: 10.0 },
|
|
82
|
+
"coordinator.tick_error.suppressed",
|
|
83
|
+
),
|
|
84
|
+
(CoordinatorEvent::TickRecovered { consecutive_failures: 3 }, "coordinator.tick_recovered"),
|
|
85
|
+
(
|
|
86
|
+
CoordinatorEvent::RestartIncompatible { pid: Some(pid), expected_protocol: 2, expected_schema: 3 },
|
|
87
|
+
"coordinator.restart_incompatible",
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
CoordinatorEvent::RestartIncompatibleStopFailed { pid: Some(pid) },
|
|
91
|
+
"coordinator.restart_incompatible_stop_failed",
|
|
92
|
+
),
|
|
93
|
+
(
|
|
94
|
+
CoordinatorEvent::SchemaIncompatible { table: Some("messages".into()), missing_columns: vec!["owner_team_id".into()] },
|
|
95
|
+
"coordinator.schema_incompatible",
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
CoordinatorEvent::IdleTakeoverUnknownPersistent {
|
|
99
|
+
node_id: "w1".into(),
|
|
100
|
+
provider: Some(Provider::Codex),
|
|
101
|
+
auth_mode: None,
|
|
102
|
+
consecutive_ticks: 60,
|
|
103
|
+
rollout_path: None,
|
|
104
|
+
},
|
|
105
|
+
"idle_takeover.unknown_persistent",
|
|
106
|
+
),
|
|
107
|
+
(
|
|
108
|
+
CoordinatorEvent::AbnormalNotify { signature: "sig".into(), turn_id: None, decision: AbnormalDecision::NotifyDefault },
|
|
109
|
+
"abnormal.notify",
|
|
110
|
+
),
|
|
111
|
+
(
|
|
112
|
+
CoordinatorEvent::AbnormalWholeTeamGone { classification: WholeTeamGoneClass::UnexpectedExit },
|
|
113
|
+
"abnormal.whole_team_gone",
|
|
114
|
+
),
|
|
115
|
+
(CoordinatorEvent::LeaderNotificationLogPruned { removed: 7 }, "leader_notification.log_pruned"),
|
|
116
|
+
(CoordinatorEvent::LeaderNotificationPruneFailed { error: "io".into() }, "leader_notification.prune_failed"),
|
|
117
|
+
(
|
|
118
|
+
CoordinatorEvent::RuntimeStateSaveFailed { phase: "tick_end".into(), error: "replace".into(), exc_type: "OSError".into() },
|
|
119
|
+
"runtime.state.save_failed",
|
|
120
|
+
),
|
|
121
|
+
];
|
|
122
|
+
for (evt, want_tag) in &cases {
|
|
123
|
+
let json = serde_json::to_value(evt).unwrap();
|
|
124
|
+
assert_eq!(json["event"], *want_tag, "tag mismatch for {want_tag}");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[test]
|
|
129
|
+
fn tick_error_event_carries_exact_python_fields() {
|
|
130
|
+
// __main__.py:69-74 — error / exc_type / consecutive_failures / next_sleep_sec.
|
|
131
|
+
let evt = CoordinatorEvent::TickError {
|
|
132
|
+
error: "os.replace failed".into(),
|
|
133
|
+
exc_type: "OSError".into(),
|
|
134
|
+
consecutive_failures: 4,
|
|
135
|
+
next_sleep_sec: 40.0,
|
|
136
|
+
};
|
|
137
|
+
let json = serde_json::to_value(&evt).unwrap();
|
|
138
|
+
assert_eq!(json["event"], "coordinator.tick_error");
|
|
139
|
+
assert_eq!(json["error"], "os.replace failed");
|
|
140
|
+
assert_eq!(json["exc_type"], "OSError");
|
|
141
|
+
assert_eq!(json["consecutive_failures"], 4);
|
|
142
|
+
assert_eq!(json["next_sleep_sec"], 40.0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[test]
|
|
146
|
+
fn unknown_persistent_event_provider_none_is_null_not_missing() {
|
|
147
|
+
// lifecycle.py:407 rollout_path=node.get(...) — None 漏穿堵死(bug-085):
|
|
148
|
+
// provider/rollout_path 缺失序列化为 JSON null,绝不省略键。
|
|
149
|
+
let evt = CoordinatorEvent::IdleTakeoverUnknownPersistent {
|
|
150
|
+
node_id: "w7".into(),
|
|
151
|
+
provider: None,
|
|
152
|
+
auth_mode: None,
|
|
153
|
+
consecutive_ticks: 72,
|
|
154
|
+
rollout_path: None,
|
|
155
|
+
};
|
|
156
|
+
let json = serde_json::to_value(&evt).unwrap();
|
|
157
|
+
assert_eq!(json["event"], "idle_takeover.unknown_persistent");
|
|
158
|
+
assert_eq!(json["node_id"], "w7");
|
|
159
|
+
assert!(json.get("provider").is_some(), "provider key MUST be present (None→null)");
|
|
160
|
+
assert!(json["provider"].is_null(), "provider None → JSON null");
|
|
161
|
+
assert!(json.get("auth_mode").is_some(), "auth_mode key MUST be present");
|
|
162
|
+
assert!(json["auth_mode"].is_null(), "auth_mode None → JSON null");
|
|
163
|
+
assert!(json.get("rollout_path").is_some(), "rollout_path key MUST be present");
|
|
164
|
+
assert!(json["rollout_path"].is_null());
|
|
165
|
+
assert_eq!(json["consecutive_ticks"], 72);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[test]
|
|
169
|
+
fn coordinator_metadata_json_field_names_are_stable() {
|
|
170
|
+
// metadata.py:50-57 — 稳定 coordinator.json 契约。
|
|
171
|
+
let m = meta(123, PROTOCOL_VERSION, GOLDEN_SCHEMA_VERSION);
|
|
172
|
+
let json = serde_json::to_value(&m).unwrap();
|
|
173
|
+
assert_eq!(json["pid"], 123);
|
|
174
|
+
assert_eq!(json["protocol_version"], 2);
|
|
175
|
+
assert_eq!(json["message_store_schema_version"], 3);
|
|
176
|
+
assert_eq!(json["source"], "boot");
|
|
177
|
+
assert_eq!(json["updated_at"], "2026-06-02T00:00:00+00:00");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
181
|
+
// GROUP B — pure functions (backoff / orphan / metadata_ok) — RED via unimplemented!()
|
|
182
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
183
|
+
|
|
184
|
+
#[test]
|
|
185
|
+
fn backoff_sequence_is_5_10_20_40_60_60() {
|
|
186
|
+
// __main__.py:65 min(interval * 2^min(failures-1, 5), 60.0). interval=5.0.
|
|
187
|
+
// Golden (probe): [5, 10, 20, 40, 60, 60, 60, ...].
|
|
188
|
+
assert_eq!(backoff_sleep_sec(5.0, 1), 5.0);
|
|
189
|
+
assert_eq!(backoff_sleep_sec(5.0, 2), 10.0);
|
|
190
|
+
assert_eq!(backoff_sleep_sec(5.0, 3), 20.0);
|
|
191
|
+
assert_eq!(backoff_sleep_sec(5.0, 4), 40.0);
|
|
192
|
+
assert_eq!(backoff_sleep_sec(5.0, 5), 60.0);
|
|
193
|
+
assert_eq!(backoff_sleep_sec(5.0, 6), 60.0);
|
|
194
|
+
assert_eq!(backoff_sleep_sec(5.0, 9), 60.0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn backoff_caps_at_60_even_with_large_interval() {
|
|
199
|
+
// min(.., 60.0) — interval 30 → 30,60,(cap)60...
|
|
200
|
+
assert_eq!(backoff_sleep_sec(30.0, 1), 30.0);
|
|
201
|
+
assert_eq!(backoff_sleep_sec(30.0, 2), 60.0);
|
|
202
|
+
assert_eq!(backoff_sleep_sec(30.0, 7), 60.0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[test]
|
|
206
|
+
fn orphan_self_terminate_requires_all_three_conditions() {
|
|
207
|
+
// __main__.py:52 — current_ppid != initial_ppid ∧ current_ppid == 1 ∧ !workspace.exists().
|
|
208
|
+
let missing = WorkspacePath::new("/tmp/team-agent-NONEXISTENT-coord-orphan-xyz");
|
|
209
|
+
// 全三成立 → true.
|
|
210
|
+
assert!(should_orphan_self_terminate(9000, 1, &missing));
|
|
211
|
+
// ppid 未变(== initial)→ false,即便 ppid==1 且 workspace 不存在。
|
|
212
|
+
assert!(!should_orphan_self_terminate(1, 1, &missing));
|
|
213
|
+
// ppid != 1(被正常 supervisor 收养)→ false。
|
|
214
|
+
assert!(!should_orphan_self_terminate(9000, 4242, &missing));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[test]
|
|
218
|
+
fn orphan_self_terminate_false_when_workspace_exists() {
|
|
219
|
+
// workspace 仍在磁盘 → 绝不自杀,即便 ppid 变成 1(card §91)。
|
|
220
|
+
let alive = WorkspacePath::new("/tmp"); // /tmp 存在
|
|
221
|
+
assert!(!should_orphan_self_terminate(9000, 1, &alive));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[test]
|
|
225
|
+
fn metadata_ok_requires_all_three_to_match() {
|
|
226
|
+
// metadata.py:37-43 — pid ∧ protocol_version==2 ∧ schema_version==3.
|
|
227
|
+
let good = meta(555, PROTOCOL_VERSION, GOLDEN_SCHEMA_VERSION);
|
|
228
|
+
assert!(coordinator_metadata_ok(Some(&good), Pid(555)));
|
|
229
|
+
// pid 不符 → false。
|
|
230
|
+
assert!(!coordinator_metadata_ok(Some(&good), Pid(999)));
|
|
231
|
+
// protocol_version 不符(bump 触发 restart_incompatible)→ false。
|
|
232
|
+
let bad_proto = meta(555, 1, GOLDEN_SCHEMA_VERSION);
|
|
233
|
+
assert!(!coordinator_metadata_ok(Some(&bad_proto), Pid(555)));
|
|
234
|
+
// schema_version 不符 → false(不可静默继续旧 schema 写库,card §89)。
|
|
235
|
+
let bad_schema = meta(555, PROTOCOL_VERSION, 2);
|
|
236
|
+
assert!(!coordinator_metadata_ok(Some(&bad_schema), Pid(555)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn metadata_ok_none_is_false() {
|
|
241
|
+
// metadata.py:38 — bool(metadata and ...) — None → False.
|
|
242
|
+
assert!(!coordinator_metadata_ok(None, Pid(1)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
246
|
+
// GROUP C — paths (paths.py) — RED
|
|
247
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
248
|
+
|
|
249
|
+
#[test]
|
|
250
|
+
fn coordinator_paths_end_with_expected_filenames() {
|
|
251
|
+
// paths.py:8-17 — runtime_dir(ws)/coordinator.{pid,json,log}.
|
|
252
|
+
let w = ws();
|
|
253
|
+
assert!(coordinator_pid_path(&w).ends_with("coordinator.pid"));
|
|
254
|
+
assert!(coordinator_meta_path(&w).ends_with("coordinator.json"));
|
|
255
|
+
assert!(coordinator_log_path(&w).ends_with("coordinator.log"));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// NOTE: schema_health / health / start / stop / tick are `&self` methods on
|
|
259
|
+
// `Coordinator`. They are NO LONGER deferred: `Coordinator::for_test` (a #[cfg(test)]
|
|
260
|
+
// constructor) + the local `MockTransport` (all ~15 Transport methods stubbed, recording
|
|
261
|
+
// calls) + `MockRegistry` give a fully constructible Coordinator. Their behavior contracts
|
|
262
|
+
// are ASSERTED in GROUP I/J below (RED via the unimplemented production bodies).
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// (B) coordinator daemon — health gate + idempotent start_coordinator + --once daemon boot.
|
|
5
|
+
// coordinator_health / start_coordinator are unimplemented!() skeletons (panic today = RED). Golden
|
|
6
|
+
// coordinator/lifecycle.py:28-121. HARD: no in-process test spawns a real daemon — the spawn /
|
|
7
|
+
// multi-tick / real-Coordinator paths are #[ignore] real-machine.
|
|
8
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
9
|
+
|
|
10
|
+
/// A unique workspace with the db schema created (so coordinator_health's schema_ok can be true) and
|
|
11
|
+
/// the runtime dir present (so coordinator.pid / coordinator.json writes land).
|
|
12
|
+
fn daemon_ws() -> (WorkspacePath, std::path::PathBuf) {
|
|
13
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
14
|
+
static N: AtomicU64 = AtomicU64::new(0);
|
|
15
|
+
let dir = std::env::temp_dir().join(format!("ta-rs-daemon-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
|
16
|
+
std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
|
|
17
|
+
let _ = crate::message_store::MessageStore::open(&dir).unwrap(); // create the schema (schema_ok)
|
|
18
|
+
(WorkspacePath::new(dir.clone()), dir)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// coordinator_health (lifecycle.py:28-46): pid_path missing -> ok:false / status missing.
|
|
22
|
+
#[test]
|
|
23
|
+
fn coordinator_health_missing_pid_is_not_ok() {
|
|
24
|
+
let (wp, _dir) = daemon_ws();
|
|
25
|
+
let h = coordinator_health(&wp);
|
|
26
|
+
assert!(!h.ok, "no coordinator.pid -> not healthy");
|
|
27
|
+
assert_eq!(h.status, CoordinatorHealthStatus::Missing);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// coordinator_health: pid running (this process) + metadata pid/protocol/schema all match -> healthy.
|
|
31
|
+
#[test]
|
|
32
|
+
fn coordinator_health_running_with_matching_metadata_is_ok() {
|
|
33
|
+
let (wp, _dir) = daemon_ws();
|
|
34
|
+
let me = Pid(std::process::id());
|
|
35
|
+
write_coordinator_metadata(&wp, me, MetadataSource::Boot).unwrap();
|
|
36
|
+
std::fs::write(coordinator_pid_path(&wp), me.0.to_string()).unwrap();
|
|
37
|
+
let h = coordinator_health(&wp);
|
|
38
|
+
assert_eq!(h.status, CoordinatorHealthStatus::Running, "a live pid -> status running");
|
|
39
|
+
assert!(h.metadata_ok, "pid+protocol+schema all match -> metadata_ok");
|
|
40
|
+
assert!(h.ok, "running ∧ metadata_ok ∧ schema_ok -> healthy");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// coordinator_health: a pid that is NOT running -> ok:false / status stale (stale != missing).
|
|
44
|
+
#[test]
|
|
45
|
+
fn coordinator_health_dead_pid_is_stale_not_ok() {
|
|
46
|
+
let (wp, _dir) = daemon_ws();
|
|
47
|
+
let dead = Pid(4_000_000); // far above the macOS/Linux pid ceiling -> kill(pid,0)=ESRCH -> not running
|
|
48
|
+
write_coordinator_metadata(&wp, dead, MetadataSource::Boot).unwrap();
|
|
49
|
+
std::fs::write(coordinator_pid_path(&wp), dead.0.to_string()).unwrap();
|
|
50
|
+
let h = coordinator_health(&wp);
|
|
51
|
+
assert_eq!(h.status, CoordinatorHealthStatus::Stale, "a dead pid -> status stale");
|
|
52
|
+
assert!(!h.ok, "a stale daemon is not healthy");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// start_coordinator (lifecycle.py:49-54) IDEMPOTENT: already-healthy -> AlreadyRunning no-op, NO spawn.
|
|
56
|
+
#[test]
|
|
57
|
+
fn start_coordinator_when_healthy_is_already_running_no_spawn() {
|
|
58
|
+
let (wp, _dir) = daemon_ws();
|
|
59
|
+
let me = Pid(std::process::id());
|
|
60
|
+
write_coordinator_metadata(&wp, me, MetadataSource::Boot).unwrap();
|
|
61
|
+
std::fs::write(coordinator_pid_path(&wp), me.0.to_string()).unwrap();
|
|
62
|
+
let report = start_coordinator(&wp).expect("start_coordinator");
|
|
63
|
+
assert_eq!(report.status, StartOutcome::AlreadyRunning, "a healthy coordinator -> AlreadyRunning (no spawn)");
|
|
64
|
+
assert!(report.ok);
|
|
65
|
+
assert_eq!(report.pid, Some(me));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// start_coordinator: a fresh workspace DECIDES Started. The actual `team-agent coordinator` daemon
|
|
69
|
+
// subprocess spawn is the real-machine boundary (#[ignore]).
|
|
70
|
+
#[test]
|
|
71
|
+
#[ignore = "real-machine: start_coordinator spawns the `team-agent coordinator` daemon subprocess"]
|
|
72
|
+
fn start_coordinator_fresh_workspace_decides_started() {
|
|
73
|
+
let (wp, _dir) = daemon_ws();
|
|
74
|
+
let report = start_coordinator(&wp).expect("start_coordinator");
|
|
75
|
+
assert_eq!(report.status, StartOutcome::Started);
|
|
76
|
+
assert!(report.ok && report.pid.is_some());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// run_daemon --once: writes the boot pid/metadata + runs exactly one tick + returns Ok. run_daemon
|
|
80
|
+
// constructs a real Coordinator (TmuxBackend) internally with NO injection seam, so a single tick
|
|
81
|
+
// would touch real tmux — #[ignore] real-machine until a run_daemon_with_coordinator(args, coord) seam
|
|
82
|
+
// (mirroring lifecycle::launch_with_transport) exists. SURFACED to the leader.
|
|
83
|
+
#[test]
|
|
84
|
+
#[ignore = "real-machine: run_daemon builds a real Coordinator (TmuxBackend); needs a \
|
|
85
|
+
run_daemon_with_coordinator(args, coord) seam (mirror launch_with_transport) for OS-safe \
|
|
86
|
+
single-tick testing"]
|
|
87
|
+
fn run_daemon_once_writes_boot_metadata_and_returns_ok() {
|
|
88
|
+
let (wp, dir) = daemon_ws();
|
|
89
|
+
crate::state::persist::save_runtime_state(&dir, &serde_json::json!({"session_name": "team-x", "agents": {}})).unwrap();
|
|
90
|
+
let r = run_daemon(DaemonArgs { workspace: wp.clone(), once: true, tick_interval_sec: None });
|
|
91
|
+
assert!(r.is_ok(), "run_daemon --once must return Ok; got {r:?}");
|
|
92
|
+
assert!(coordinator_meta_path(&wp).exists(), "run_daemon must write the coordinator boot metadata");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
96
|
+
// HOST-B P1 — coordinator transient-session race (timeout-tolerated vs definitive-stop fork).
|
|
97
|
+
//
|
|
98
|
+
// GOLDEN (truth source, settle by it):
|
|
99
|
+
// - terminal.py:12-13 run_cmd(args, timeout=timeout, check=False)
|
|
100
|
+
// - runtime.py:1010-14 _tmux_session_exists -> run_cmd(["tmux","has-session","-t",s], timeout=5);
|
|
101
|
+
// return proc.returncode == 0
|
|
102
|
+
// - lifecycle.py:276-9 if session_name and not _tmux_session_exists(name):
|
|
103
|
+
// emit coordinator.session_missing; return {ok:False, stop:True,
|
|
104
|
+
// reason:"tmux_session_missing"} # stops on the FIRST definitive miss
|
|
105
|
+
// - __main__.py:60-97 a tick that RAISES (`except Exception`) -> exponential backoff + retry +
|
|
106
|
+
// (on the next clean tick) coordinator.tick_recovered [TOLERATED];
|
|
107
|
+
// a tick that returns stop -> break (then coordinator.exit).
|
|
108
|
+
//
|
|
109
|
+
// THE CRUX: golden's ONLY tolerance for a transient session-missing is the 5s subprocess timeout.
|
|
110
|
+
// - SLOW/HUNG has-session (>5s) -> subprocess.TimeoutExpired -> daemon `except` -> backoff + retry
|
|
111
|
+
// (server recovers -> next tick fine). In Rust the timeout surfaces from RealCommandRunner as
|
|
112
|
+
// io::ErrorKind::TimedOut -> TmuxBackend::has_session maps it to a TransportError (NOT Ok(false))
|
|
113
|
+
// -> Coordinator::tick() returns Err (a TOLERATED error the daemon backs off on).
|
|
114
|
+
// - FAST DEFINITIVE non-zero (session genuinely gone) -> returncode != 0 -> has_session=false ->
|
|
115
|
+
// tick() returns Ok{stop:true, reason:tmux_session_missing}. A genuine miss MUST still stop.
|
|
116
|
+
// NO grace-window, NO K-consecutive counting: slow=tolerated(retry), genuine-fast-miss=stop.
|
|
117
|
+
//
|
|
118
|
+
// These three are REGRESSION-LOCKS, not REDs: the tick/backend mapping (`?` propagation) and the
|
|
119
|
+
// daemon backoff/recover loop are ALREADY correct today. The actual gap is purely in
|
|
120
|
+
// RealCommandRunner::run lacking the 5s timeout (the #[ignore] real-machine RED lives in
|
|
121
|
+
// tmux_backend.rs::real_command_runner_enforces_golden_5s_timeout_on_hang). These locks guard the
|
|
122
|
+
// tick/daemon semantics from regressing when the porter adds that timeout seam.
|
|
123
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
124
|
+
|
|
125
|
+
/// A staged tmux `CommandRunner` for the transient-session-race fork. Each `run` pops the next
|
|
126
|
+
/// staged step (then repeats `last`): `Timeout` models a >5s hung has-session that the golden 5s
|
|
127
|
+
/// subprocess timeout converts into `io::ErrorKind::TimedOut`; `Exit(success)` models a definitive
|
|
128
|
+
/// tmux exit (`success=false` => session genuinely gone). Records every argv it is asked to run so a
|
|
129
|
+
/// test can assert the probe was exactly `tmux has-session -t <s>`.
|
|
130
|
+
#[derive(Clone)]
|
|
131
|
+
enum RunnerStep {
|
|
132
|
+
/// >5s hang -> RealCommandRunner returns Err(TimedOut) (golden subprocess.TimeoutExpired).
|
|
133
|
+
Timeout,
|
|
134
|
+
/// fast definitive tmux exit; `false` => returncode!=0 => session genuinely gone.
|
|
135
|
+
Exit(bool),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
struct StagedTmuxRunner {
|
|
139
|
+
steps: std::sync::Mutex<std::collections::VecDeque<RunnerStep>>,
|
|
140
|
+
last: RunnerStep,
|
|
141
|
+
seen: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
impl crate::tmux_backend::CommandRunner for StagedTmuxRunner {
|
|
145
|
+
fn run(&self, argv: &[String]) -> Result<crate::tmux_backend::CommandOutput, std::io::Error> {
|
|
146
|
+
self.seen.lock().unwrap().push(argv.to_vec());
|
|
147
|
+
let step = self
|
|
148
|
+
.steps
|
|
149
|
+
.lock()
|
|
150
|
+
.unwrap()
|
|
151
|
+
.pop_front()
|
|
152
|
+
.unwrap_or_else(|| self.last.clone());
|
|
153
|
+
match step {
|
|
154
|
+
RunnerStep::Timeout => Err(std::io::Error::new(
|
|
155
|
+
std::io::ErrorKind::TimedOut,
|
|
156
|
+
"tmux has-session exceeded the golden 5s timeout (subprocess.TimeoutExpired analog)",
|
|
157
|
+
)),
|
|
158
|
+
RunnerStep::Exit(success) => Ok(crate::tmux_backend::CommandOutput {
|
|
159
|
+
success,
|
|
160
|
+
code: Some(if success { 0 } else { 1 }),
|
|
161
|
+
stdout: String::new(),
|
|
162
|
+
stderr: if success { String::new() } else { "can't find session".to_string() },
|
|
163
|
+
}),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Build a real `Coordinator` over a real `TmuxBackend` whose OS edge is the staged runner above,
|
|
169
|
+
/// seeding a TRUTHY `session_name` so the tmux-session gate actually runs. Returns
|
|
170
|
+
/// `(coord, workspace_dir, recorded_argv)`. The workspace + schema mirror `daemon_ws`.
|
|
171
|
+
fn coord_over_staged_tmux(
|
|
172
|
+
session_name: &str,
|
|
173
|
+
steps: Vec<RunnerStep>,
|
|
174
|
+
last: RunnerStep,
|
|
175
|
+
) -> (
|
|
176
|
+
Coordinator,
|
|
177
|
+
std::path::PathBuf,
|
|
178
|
+
std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
|
|
179
|
+
) {
|
|
180
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
181
|
+
static N: AtomicU64 = AtomicU64::new(0);
|
|
182
|
+
let dir = std::env::temp_dir().join(format!(
|
|
183
|
+
"ta-rs-coord-session-race-{}-{}",
|
|
184
|
+
std::process::id(),
|
|
185
|
+
N.fetch_add(1, Ordering::Relaxed)
|
|
186
|
+
));
|
|
187
|
+
std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
|
|
188
|
+
let _ = crate::message_store::MessageStore::open(&dir).unwrap(); // create the schema
|
|
189
|
+
crate::state::persist::save_runtime_state(
|
|
190
|
+
&dir,
|
|
191
|
+
&serde_json::json!({ "session_name": session_name }),
|
|
192
|
+
)
|
|
193
|
+
.unwrap();
|
|
194
|
+
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
195
|
+
let runner = StagedTmuxRunner {
|
|
196
|
+
steps: std::sync::Mutex::new(steps.into_iter().collect()),
|
|
197
|
+
last,
|
|
198
|
+
seen: std::sync::Arc::clone(&seen),
|
|
199
|
+
};
|
|
200
|
+
let backend = crate::tmux_backend::TmuxBackend::with_runner(Box::new(runner));
|
|
201
|
+
let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
|
|
202
|
+
let coord = Coordinator::for_test(
|
|
203
|
+
WorkspacePath::new(dir.clone()),
|
|
204
|
+
reg,
|
|
205
|
+
Box::new(backend),
|
|
206
|
+
None,
|
|
207
|
+
None,
|
|
208
|
+
);
|
|
209
|
+
(coord, dir, seen)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── 2(a) tick TOLERATES a has-session TIMEOUT as Err (NOT a definitive miss) — LOCK ───────────────
|
|
213
|
+
#[test]
|
|
214
|
+
fn tick_tolerates_has_session_timeout_as_transport_err_not_session_missing() {
|
|
215
|
+
// A has-session that times out (>5s) surfaces as io::ErrorKind::TimedOut from RealCommandRunner.
|
|
216
|
+
// TmuxBackend::has_session maps the runner io::Error to a TransportError (NOT Ok(false)), and
|
|
217
|
+
// Coordinator::tick() propagates it via `?` as TickError::Transport — a TOLERATED error the
|
|
218
|
+
// daemon backs off on. It must NEVER be read as Ok{stop:true, reason:tmux_session_missing}.
|
|
219
|
+
// LOCK (already GREEN via `?` propagation): guards this from regressing when the 5s timeout seam
|
|
220
|
+
// is added to RealCommandRunner.
|
|
221
|
+
let (coord, _dir, seen) =
|
|
222
|
+
coord_over_staged_tmux("team-spine", vec![RunnerStep::Timeout], RunnerStep::Timeout);
|
|
223
|
+
let err = coord.tick().expect_err(
|
|
224
|
+
"a has-session TIMEOUT is a tolerated transport Err (daemon backs off), NOT a definitive \
|
|
225
|
+
session-missing stop",
|
|
226
|
+
);
|
|
227
|
+
assert!(
|
|
228
|
+
matches!(err, TickError::Transport(_)),
|
|
229
|
+
"a transient has-session timeout must surface as TickError::Transport (tolerated/backoff); got {err:?}"
|
|
230
|
+
);
|
|
231
|
+
let calls = seen.lock().unwrap().clone();
|
|
232
|
+
assert_eq!(
|
|
233
|
+
calls.len(),
|
|
234
|
+
1,
|
|
235
|
+
"tick must short-circuit at the gate on a has-session error (exactly one probe); got {calls:?}"
|
|
236
|
+
);
|
|
237
|
+
assert_eq!(
|
|
238
|
+
calls[0],
|
|
239
|
+
vec![
|
|
240
|
+
"tmux".to_string(),
|
|
241
|
+
"has-session".to_string(),
|
|
242
|
+
"-t".to_string(),
|
|
243
|
+
"team-spine".to_string(),
|
|
244
|
+
],
|
|
245
|
+
"the tolerated error must come from the golden `tmux has-session -t <s>` probe"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── 2(b) a FAST DEFINITIVE has-session miss STILL stops — LOCK (byte-parity) ──────────────────────
|
|
250
|
+
#[test]
|
|
251
|
+
fn tick_genuine_fast_session_miss_still_stops() {
|
|
252
|
+
// lifecycle.py:277-279 — a FAST definitive non-zero has-session (returncode != 0 => session
|
|
253
|
+
// genuinely gone) => {ok:false, stop:true, reason:tmux_session_missing}. The OTHER side of the
|
|
254
|
+
// fork from the timeout case: a definitive miss MUST still stop the daemon. LOCK (already GREEN):
|
|
255
|
+
// guards the genuine-miss stop from being swallowed when the timeout tolerance is added.
|
|
256
|
+
let (coord, _dir, _seen) =
|
|
257
|
+
coord_over_staged_tmux("team-spine", vec![RunnerStep::Exit(false)], RunnerStep::Exit(false));
|
|
258
|
+
let report = coord
|
|
259
|
+
.tick()
|
|
260
|
+
.expect("a definitive miss is a typed stop report, not an Err");
|
|
261
|
+
assert!(!report.ok, "a definitive session miss => ok=false");
|
|
262
|
+
assert!(
|
|
263
|
+
report.stop,
|
|
264
|
+
"a FAST definitive has-session miss still stops the daemon (byte-parity, lifecycle.py:279)"
|
|
265
|
+
);
|
|
266
|
+
assert_eq!(
|
|
267
|
+
report.reason,
|
|
268
|
+
Some(TickStopReason::TmuxSessionMissing),
|
|
269
|
+
"reason=tmux_session_missing"
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── 3. daemon TOLERATES a transient tick Err: backoff + recover, NO exit on the error — LOCK ──────
|
|
274
|
+
#[test]
|
|
275
|
+
fn run_daemon_backs_off_on_transient_tick_err_then_recovers_without_exiting() {
|
|
276
|
+
// __main__.py:60-97 — a tick that RAISES is caught, logged as coordinator.tick_error, and the
|
|
277
|
+
// loop BACKS OFF + retries (it does NOT break/exit on the error); the next clean tick logs
|
|
278
|
+
// coordinator.tick_recovered. Here: tick #1's has-session TIMES OUT (TimedOut -> tick Err ->
|
|
279
|
+
// tolerated), tick #2's has-session is a definitive miss (-> Ok{stop:true} -> the loop breaks on
|
|
280
|
+
// the GENUINE stop, after recovering). The healthy daemon must NOT be torn down by the transient
|
|
281
|
+
// timeout. LOCK (the daemon backoff loop is already wired): guards the tolerate+recover path.
|
|
282
|
+
let (coord, dir, _seen) = coord_over_staged_tmux(
|
|
283
|
+
"team-spine",
|
|
284
|
+
vec![RunnerStep::Timeout, RunnerStep::Exit(false)],
|
|
285
|
+
RunnerStep::Exit(false),
|
|
286
|
+
);
|
|
287
|
+
let args = DaemonArgs {
|
|
288
|
+
workspace: WorkspacePath::new(dir.clone()),
|
|
289
|
+
once: false,
|
|
290
|
+
tick_interval_sec: Some(0.01), // tiny backoff so the test is fast
|
|
291
|
+
};
|
|
292
|
+
let result = run_daemon_with_coordinator(&args, &coord);
|
|
293
|
+
assert!(
|
|
294
|
+
result.is_ok(),
|
|
295
|
+
"a single transient has-session timeout must NOT abort the daemon; got {result:?}"
|
|
296
|
+
);
|
|
297
|
+
let events = read_event_log_dir(&dir);
|
|
298
|
+
let tags: Vec<&str> = events
|
|
299
|
+
.iter()
|
|
300
|
+
.filter_map(|e| e.get("event").and_then(|v| v.as_str()))
|
|
301
|
+
.collect();
|
|
302
|
+
let err_idx = tags
|
|
303
|
+
.iter()
|
|
304
|
+
.position(|t| *t == "coordinator.tick_error")
|
|
305
|
+
.unwrap_or_else(|| panic!("a transient tick Err must log coordinator.tick_error; got {tags:?}"));
|
|
306
|
+
let rec_idx = tags
|
|
307
|
+
.iter()
|
|
308
|
+
.position(|t| *t == "coordinator.tick_recovered")
|
|
309
|
+
.unwrap_or_else(|| panic!("the recovering Ok tick must log coordinator.tick_recovered; got {tags:?}"));
|
|
310
|
+
let exit_idx = tags
|
|
311
|
+
.iter()
|
|
312
|
+
.position(|t| *t == "coordinator.exit")
|
|
313
|
+
.unwrap_or_else(|| panic!("the daemon must log coordinator.exit once it stops; got {tags:?}"));
|
|
314
|
+
assert!(
|
|
315
|
+
err_idx < rec_idx,
|
|
316
|
+
"tick_recovered must FOLLOW tick_error (backoff then recover); got {tags:?}"
|
|
317
|
+
);
|
|
318
|
+
assert!(
|
|
319
|
+
rec_idx < exit_idx,
|
|
320
|
+
"the daemon must NOT exit on the transient error — coordinator.exit appears only AFTER \
|
|
321
|
+
recovery + the genuine stop; got {tags:?}"
|
|
322
|
+
);
|
|
323
|
+
}
|