@team-agent/installer 0.2.11 → 0.3.1
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 +1204 -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 +1207 -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 +557 -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 +1084 -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 +526 -0
- package/crates/team-agent/src/leader/rediscover.rs +1101 -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 +237 -0
- package/crates/team-agent/src/leader/tests/identity.rs +206 -0
- package/crates/team-agent/src/leader/tests/idle.rs +272 -0
- package/crates/team-agent/src/leader/tests/lease_api.rs +225 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +410 -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 +489 -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 +2109 -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 +985 -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 +710 -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 +187 -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 +468 -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 +743 -0
- package/crates/team-agent/src/messaging/helpers.rs +209 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +329 -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 +553 -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 +578 -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 +659 -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 +765 -0
- package/crates/team-agent/src/tmux_backend.rs +810 -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 +118 -112
- 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 -119
- package/src/team_agent/coordinator/lifecycle.py +0 -411
- 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 -218
- package/src/team_agent/idle_takeover.py +0 -59
- package/src/team_agent/idle_takeover_wiring.py +0 -114
- 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 -262
- package/src/team_agent/messaging/delivery.py +0 -504
- 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 -503
- 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 -91
- 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 -1243
- 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 -144
- 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 -693
- 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
|
@@ -1,926 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import hashlib
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
import signal
|
|
7
|
-
import shlex
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
import time
|
|
11
|
-
from datetime import datetime, timezone
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Any
|
|
14
|
-
|
|
15
|
-
from team_agent.events import EventLog
|
|
16
|
-
from team_agent.state import apply_first_time_leader_binding, derive_leader_session_uuid, leader_env_exports, load_runtime_state, save_runtime_state, save_team_scoped_state, select_runtime_state, team_state_key, validate_leader_uuid_from_targets
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def attach_leader(workspace: Path, pane: str | None = None, provider: str = "codex") -> dict[str, Any]:
|
|
20
|
-
from team_agent.message_store import MessageStore
|
|
21
|
-
from team_agent.runtime import _attach_leader_to_state, _runtime_lock, ensure_workspace_dirs
|
|
22
|
-
ensure_workspace_dirs(workspace)
|
|
23
|
-
# MED1/MED3: attach is a lease mutation; hold the single lease mutex so the state
|
|
24
|
-
# change + event emission + dual-state write happen in one critical section.
|
|
25
|
-
with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
|
|
26
|
-
state = load_runtime_state(workspace)
|
|
27
|
-
event_log = EventLog(workspace)
|
|
28
|
-
receiver, validation = _attach_leader_to_state(
|
|
29
|
-
workspace,
|
|
30
|
-
state,
|
|
31
|
-
pane=pane,
|
|
32
|
-
provider=provider,
|
|
33
|
-
event_log=event_log,
|
|
34
|
-
source="manual",
|
|
35
|
-
)
|
|
36
|
-
save_runtime_state(workspace, state)
|
|
37
|
-
requeued = MessageStore(workspace).requeue_delivery_exhausted_watchers()
|
|
38
|
-
if requeued:
|
|
39
|
-
event_log.write(
|
|
40
|
-
"leader_receiver.requeued_exhausted_watchers",
|
|
41
|
-
watcher_ids=requeued,
|
|
42
|
-
count=len(requeued),
|
|
43
|
-
trigger="attach_leader",
|
|
44
|
-
)
|
|
45
|
-
for watcher_id in requeued:
|
|
46
|
-
event_log.write(
|
|
47
|
-
"result_watcher.requeued",
|
|
48
|
-
watcher_id=watcher_id,
|
|
49
|
-
trigger="attach_leader",
|
|
50
|
-
new_pane_id=receiver.get("pane_id"),
|
|
51
|
-
)
|
|
52
|
-
return {
|
|
53
|
-
"ok": True,
|
|
54
|
-
"leader_receiver": receiver,
|
|
55
|
-
"validation": validation,
|
|
56
|
-
"requeued_exhausted_watchers": requeued,
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def start_leader(
|
|
61
|
-
provider: str,
|
|
62
|
-
provider_args: list[str],
|
|
63
|
-
workspace: Path,
|
|
64
|
-
*,
|
|
65
|
-
attach_existing: bool = False,
|
|
66
|
-
confirm_attach: bool = False,
|
|
67
|
-
attach_session: str | None = None,
|
|
68
|
-
) -> None:
|
|
69
|
-
plan = leader_start_plan(provider, provider_args, workspace, attach_existing=attach_existing, confirm_attach=confirm_attach, attach_session=attach_session)
|
|
70
|
-
if plan.get("leader_session_uuid_source") == "override":
|
|
71
|
-
EventLog(workspace).write("leader_session_uuid.override", source="explicit-override", uuid_prefix=str(plan.get("leader_session_uuid") or "")[:12], team_id=plan.get("team_id"))
|
|
72
|
-
if plan["mode"] == "new_tmux_session" and not sys.stdin.isatty():
|
|
73
|
-
plan = dict(plan)
|
|
74
|
-
argv = list(plan["argv"])
|
|
75
|
-
argv.insert(2, "-d")
|
|
76
|
-
plan["argv"] = argv
|
|
77
|
-
plan["detached"] = True
|
|
78
|
-
EventLog(workspace).write("leader.start", provider=provider, workspace=str(workspace), mode=plan["mode"], session_name=plan.get("session_name"), argv=_leader_plan_log_argv(plan), leader_session_uuid_source=plan.get("leader_session_uuid_source"), uuid_prefix=str(plan.get("leader_session_uuid") or "")[:12] or None)
|
|
79
|
-
_run_leader_plan(plan, workspace)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def leader_start_plan(
|
|
83
|
-
provider: str,
|
|
84
|
-
provider_args: list[str],
|
|
85
|
-
workspace: Path,
|
|
86
|
-
*,
|
|
87
|
-
attach_existing: bool = False,
|
|
88
|
-
confirm_attach: bool = False,
|
|
89
|
-
attach_session: str | None = None,
|
|
90
|
-
) -> dict[str, Any]:
|
|
91
|
-
from team_agent.runtime import (
|
|
92
|
-
RuntimeError,
|
|
93
|
-
_tmux_session_exists,
|
|
94
|
-
ensure_workspace_dirs,
|
|
95
|
-
get_adapter,
|
|
96
|
-
shutil_which,
|
|
97
|
-
)
|
|
98
|
-
workspace = workspace.resolve()
|
|
99
|
-
ensure_workspace_dirs(workspace)
|
|
100
|
-
adapter = get_adapter(provider)
|
|
101
|
-
if not adapter.is_installed():
|
|
102
|
-
raise RuntimeError(f"Provider {provider} command {adapter.command_name!r} not found")
|
|
103
|
-
argv = [adapter.command_name, *provider_args]
|
|
104
|
-
identity = _leader_identity_context(workspace)
|
|
105
|
-
leader_env = _leader_provider_env(provider, identity)
|
|
106
|
-
if attach_session:
|
|
107
|
-
if not confirm_attach:
|
|
108
|
-
raise RuntimeError("--attach-session requires --confirm")
|
|
109
|
-
return {"mode": "attach_existing", "provider": provider, "workspace": str(workspace), "session_name": attach_session, "argv": ["tmux", "attach-session", "-t", attach_session]}
|
|
110
|
-
if os.environ.get("TMUX"):
|
|
111
|
-
return {"mode": "exec_provider", "provider": provider, "workspace": str(workspace), "argv": argv, "env": {**os.environ, **leader_env}, **identity}
|
|
112
|
-
if not shutil_which("tmux"):
|
|
113
|
-
raise RuntimeError("tmux is not installed; install tmux 3.3+ or start the leader from an existing tmux pane")
|
|
114
|
-
session_name = leader_session_name(provider, workspace)
|
|
115
|
-
if _tmux_session_exists(session_name):
|
|
116
|
-
return {"mode": "attach_existing", "provider": provider, "workspace": str(workspace), "session_name": session_name, "argv": ["tmux", "attach-session", "-t", session_name]}
|
|
117
|
-
exports = " ".join(f"{key}={shlex.quote(value)}" for key, value in leader_env.items())
|
|
118
|
-
if os.environ.get("PATH"):
|
|
119
|
-
exports = f"{exports} PATH={shlex.quote(os.environ['PATH'])}"
|
|
120
|
-
shell = f"cd {shlex.quote(str(workspace))} && export {exports} && exec {shlex.join(argv)}"
|
|
121
|
-
tmux_args = ["tmux", "new-session", "-s", session_name, "-n", provider, "-c", str(workspace)]
|
|
122
|
-
return {
|
|
123
|
-
"mode": "new_tmux_session",
|
|
124
|
-
"provider": provider,
|
|
125
|
-
"workspace": str(workspace),
|
|
126
|
-
"session_name": session_name,
|
|
127
|
-
"argv": [*tmux_args, "sh", "-lc", shell],
|
|
128
|
-
"leader_env": leader_env,
|
|
129
|
-
**identity,
|
|
130
|
-
"detached": False,
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _run_leader_plan(plan: dict[str, Any], workspace: Path) -> None:
|
|
135
|
-
session_name = plan.get("session_name")
|
|
136
|
-
proc: subprocess.Popen[Any] | None = None
|
|
137
|
-
sigints = 0
|
|
138
|
-
|
|
139
|
-
def stop_process_tree() -> None:
|
|
140
|
-
if session_name and plan["mode"] == "new_tmux_session":
|
|
141
|
-
subprocess.run(["tmux", "kill-session", "-t", str(session_name)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
142
|
-
if proc and proc.poll() is None:
|
|
143
|
-
proc.terminate()
|
|
144
|
-
|
|
145
|
-
def handle_sigint(signum: int, _frame: Any) -> None:
|
|
146
|
-
nonlocal sigints
|
|
147
|
-
sigints += 1
|
|
148
|
-
if proc and proc.poll() is None:
|
|
149
|
-
try:
|
|
150
|
-
proc.send_signal(signum)
|
|
151
|
-
except ProcessLookupError:
|
|
152
|
-
pass
|
|
153
|
-
if sigints >= 2:
|
|
154
|
-
stop_process_tree()
|
|
155
|
-
|
|
156
|
-
old_sigint = signal.signal(signal.SIGINT, handle_sigint)
|
|
157
|
-
try:
|
|
158
|
-
if plan["mode"] == "exec_provider":
|
|
159
|
-
os.chdir(workspace)
|
|
160
|
-
proc = subprocess.Popen(plan["argv"], env=plan.get("env"))
|
|
161
|
-
if plan.get("detached") and session_name:
|
|
162
|
-
proc.wait()
|
|
163
|
-
while _tmux_session_exists_local(str(session_name)):
|
|
164
|
-
time.sleep(0.2)
|
|
165
|
-
else:
|
|
166
|
-
proc.wait()
|
|
167
|
-
finally:
|
|
168
|
-
signal.signal(signal.SIGINT, old_sigint)
|
|
169
|
-
_print_team_running_reminder(workspace)
|
|
170
|
-
raise SystemExit(proc.returncode if proc else 1)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _tmux_session_exists_local(session_name: str) -> bool:
|
|
174
|
-
proc = subprocess.run(["tmux", "has-session", "-t", session_name], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
175
|
-
return proc.returncode == 0
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _print_team_running_reminder(workspace: Path) -> None:
|
|
179
|
-
state = load_runtime_state(workspace)
|
|
180
|
-
team_name = state.get("session_name")
|
|
181
|
-
if not team_name or not _tmux_session_exists_local(str(team_name)):
|
|
182
|
-
return
|
|
183
|
-
print(f"team {team_name} is still running; run team-agent shutdown to close it OR team-agent attach-leader to reconnect.")
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def leader_session_name(provider: str, workspace: Path) -> str:
|
|
187
|
-
digest = hashlib.sha1(str(workspace.resolve()).encode("utf-8")).hexdigest()[:8]
|
|
188
|
-
folder = re.sub(r"[^A-Za-z0-9_.-]", "_", workspace.name)[:48].strip("._-") or "workspace"
|
|
189
|
-
return f"team-agent-leader-{provider}-{folder}-{digest}"
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def _leader_identity_context(workspace: Path, team: str | None = None, state: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
193
|
-
state = state or _load_identity_state(workspace, team)
|
|
194
|
-
team_id = team_state_key(state)
|
|
195
|
-
machine = _identity_machine_fingerprint(state)
|
|
196
|
-
user = _identity_os_user()
|
|
197
|
-
override = os.environ.get("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE") or ""
|
|
198
|
-
leader_uuid = override or _state_leader_session_uuid(state) or derive_leader_session_uuid(
|
|
199
|
-
machine,
|
|
200
|
-
str(workspace.resolve()),
|
|
201
|
-
user,
|
|
202
|
-
team_id,
|
|
203
|
-
)
|
|
204
|
-
return {
|
|
205
|
-
"leader_session_uuid": leader_uuid,
|
|
206
|
-
"leader_session_uuid_source": "override" if override else "derived",
|
|
207
|
-
"machine_fingerprint": machine,
|
|
208
|
-
"workspace_abspath": str(workspace.resolve()),
|
|
209
|
-
"os_user": user,
|
|
210
|
-
"team_id": team_id,
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _load_identity_state(workspace: Path, team: str | None) -> dict[str, Any]:
|
|
215
|
-
try:
|
|
216
|
-
return select_runtime_state(workspace, team)
|
|
217
|
-
except Exception:
|
|
218
|
-
return load_runtime_state(workspace)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _identity_machine_fingerprint(state: dict[str, Any]) -> str:
|
|
222
|
-
for record in (state.get("team_owner"), state.get("leader_receiver")):
|
|
223
|
-
if isinstance(record, dict) and record.get("machine_fingerprint"):
|
|
224
|
-
return str(record["machine_fingerprint"])
|
|
225
|
-
return os.environ.get("TEAM_AGENT_MACHINE_FINGERPRINT") or os.uname().nodename
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _identity_os_user() -> str:
|
|
229
|
-
return os.environ.get("USER") or os.environ.get("USERNAME") or ""
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def _state_leader_session_uuid(state: dict[str, Any]) -> str:
|
|
233
|
-
for record in (state.get("team_owner"), state.get("leader_receiver")):
|
|
234
|
-
if isinstance(record, dict) and record.get("leader_session_uuid"):
|
|
235
|
-
return str(record["leader_session_uuid"])
|
|
236
|
-
return ""
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _leader_provider_env(provider: str, identity: dict[str, Any]) -> dict[str, str]:
|
|
240
|
-
return {
|
|
241
|
-
"TEAM_AGENT_LEADER_PROVIDER": provider,
|
|
242
|
-
"TEAM_AGENT_LEADER_SESSION_UUID": str(identity["leader_session_uuid"]),
|
|
243
|
-
"TEAM_AGENT_MACHINE_FINGERPRINT": str(identity["machine_fingerprint"]),
|
|
244
|
-
"TEAM_AGENT_WORKSPACE": str(identity["workspace_abspath"]),
|
|
245
|
-
"TEAM_AGENT_TEAM_ID": str(identity["team_id"]),
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def _leader_plan_log_argv(plan: dict[str, Any]) -> list[str]:
|
|
250
|
-
uuid_value = str(plan.get("leader_session_uuid") or "")
|
|
251
|
-
if not uuid_value:
|
|
252
|
-
return plan["argv"]
|
|
253
|
-
return [str(part).replace(uuid_value, f"{uuid_value[:12]}...") for part in plan["argv"]]
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def attach_leader_to_state(
|
|
257
|
-
workspace: Path,
|
|
258
|
-
state: dict[str, Any],
|
|
259
|
-
pane: str | None,
|
|
260
|
-
provider: str,
|
|
261
|
-
event_log: EventLog,
|
|
262
|
-
source: str,
|
|
263
|
-
require_current: bool = False,
|
|
264
|
-
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
265
|
-
from team_agent.runtime import (
|
|
266
|
-
RuntimeError,
|
|
267
|
-
_leader_command_provider,
|
|
268
|
-
_resolve_leader_pane,
|
|
269
|
-
_target_fingerprint,
|
|
270
|
-
_validate_leader_receiver,
|
|
271
|
-
core_list_targets,
|
|
272
|
-
get_adapter,
|
|
273
|
-
run_cmd,
|
|
274
|
-
)
|
|
275
|
-
get_adapter(provider)
|
|
276
|
-
pane_info, discovery = _resolve_leader_pane(pane, provider, workspace=workspace, require_current=require_current)
|
|
277
|
-
inferred_provider = _leader_command_provider(pane_info.get("pane_current_command", ""))
|
|
278
|
-
receiver_provider = inferred_provider or provider
|
|
279
|
-
identity = _leader_identity_context(workspace, state=state)
|
|
280
|
-
if identity.get("leader_session_uuid_source") == "override":
|
|
281
|
-
event_log.write("leader_session_uuid.override", source="explicit-override", uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12], team_id=identity.get("team_id"))
|
|
282
|
-
receiver = {
|
|
283
|
-
"mode": "direct_tmux",
|
|
284
|
-
"status": "attached",
|
|
285
|
-
"provider": receiver_provider,
|
|
286
|
-
"pane_id": pane_info["pane_id"],
|
|
287
|
-
"session_name": pane_info["session_name"],
|
|
288
|
-
"window_index": pane_info["window_index"],
|
|
289
|
-
"window_name": pane_info["window_name"],
|
|
290
|
-
"pane_index": pane_info["pane_index"],
|
|
291
|
-
"pane_tty": pane_info["pane_tty"],
|
|
292
|
-
"pane_current_command": pane_info["pane_current_command"],
|
|
293
|
-
"fingerprint": _target_fingerprint(pane_info),
|
|
294
|
-
"attached_at": datetime.now(timezone.utc).isoformat(),
|
|
295
|
-
"discovery": discovery,
|
|
296
|
-
}
|
|
297
|
-
if not state.get("team_owner") and source in {"launch", "quick_start"}:
|
|
298
|
-
validation = apply_first_time_leader_binding(workspace, state, receiver, pane_info, identity, source)
|
|
299
|
-
if not validation["ok"]:
|
|
300
|
-
event_log.write("leader_receiver.attach_failed", target=pane or pane_info.get("pane_id"), discovery=discovery, provider=provider, reason=validation["reason"], error=validation.get("error"), source=source, first_time=True, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12])
|
|
301
|
-
raise RuntimeError(f"leader pane validation failed: {validation['reason']}")
|
|
302
|
-
_set_tmux_leader_environment(receiver, identity, event_log, run_cmd)
|
|
303
|
-
event_log.write("leader_receiver.attached", target=receiver["pane_id"], session_name=receiver["session_name"], window_index=receiver["window_index"], window_name=receiver["window_name"], pane_index=receiver["pane_index"], pane_tty=receiver["pane_tty"], pane_current_command=receiver["pane_current_command"], provider=receiver_provider, requested_provider=provider if receiver_provider != provider else None, discovery=discovery, source=source, first_time=True, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12], leader_session_uuid_source=identity.get("leader_session_uuid_source"))
|
|
304
|
-
return receiver, validation
|
|
305
|
-
owner_record = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
|
|
306
|
-
if receiver_provider != "fake":
|
|
307
|
-
# C10/C12: carry the recorded owner's identity rather than re-deriving one
|
|
308
|
-
# that can drift under symlinked/worktree paths.
|
|
309
|
-
receiver["leader_session_uuid"] = str(owner_record.get("leader_session_uuid") or identity["leader_session_uuid"])
|
|
310
|
-
if receiver_provider != provider:
|
|
311
|
-
receiver["requested_provider"] = provider
|
|
312
|
-
targets = core_list_targets()
|
|
313
|
-
validation = validate_leader_uuid_from_targets(receiver, targets)
|
|
314
|
-
if validation["ok"]:
|
|
315
|
-
validation = _validate_leader_receiver(receiver)
|
|
316
|
-
if not validation["ok"]:
|
|
317
|
-
readopt = _try_readopt_leader_pane(workspace, state, receiver, pane_info, targets, owner_record, receiver_provider, source, event_log)
|
|
318
|
-
if readopt is not None:
|
|
319
|
-
return readopt, {"ok": True, "pane": pane_info, "readopted": True, "warning": None}
|
|
320
|
-
event_log.write("leader_receiver.attach_failed", target=pane or pane_info.get("pane_id"), discovery=discovery, provider=provider, reason=validation["reason"], error=validation.get("error"), source=source, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12])
|
|
321
|
-
raise RuntimeError(_strict_leader_validation_error(validation))
|
|
322
|
-
if validation.get("warning"):
|
|
323
|
-
receiver["warning"] = validation["warning"]
|
|
324
|
-
state["leader_receiver"] = receiver
|
|
325
|
-
event_log.write("leader_receiver.attached", target=receiver["pane_id"], session_name=receiver["session_name"], window_index=receiver["window_index"], window_name=receiver["window_name"], pane_index=receiver["pane_index"], pane_tty=receiver["pane_tty"], pane_current_command=receiver["pane_current_command"], provider=receiver_provider, requested_provider=provider if receiver_provider != provider else None, discovery=discovery, source=source, uuid_prefix=str(identity.get("leader_session_uuid") or "")[:12], leader_session_uuid_source=identity.get("leader_session_uuid_source"))
|
|
326
|
-
return receiver, validation
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def _set_tmux_leader_environment(receiver: dict[str, Any], identity: dict[str, Any], event_log: EventLog, run_cmd: Any) -> None:
|
|
330
|
-
session_name = receiver.get("session_name")
|
|
331
|
-
if not session_name:
|
|
332
|
-
return
|
|
333
|
-
failures: dict[str, str] = {}
|
|
334
|
-
for key, value in leader_env_exports(receiver, identity).items():
|
|
335
|
-
proc = run_cmd(["tmux", "set-environment", "-t", str(session_name), key, value], timeout=5)
|
|
336
|
-
if proc.returncode != 0:
|
|
337
|
-
failures[key] = proc.stderr.strip() or "tmux set-environment failed"
|
|
338
|
-
event_log.write(
|
|
339
|
-
"leader_receiver.first_time_env_seeded",
|
|
340
|
-
pane_id=receiver.get("pane_id"),
|
|
341
|
-
session_name=session_name,
|
|
342
|
-
ok=not failures,
|
|
343
|
-
failed_keys=sorted(failures),
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
def _strict_leader_validation_error(validation: dict[str, Any]) -> str:
|
|
347
|
-
return (
|
|
348
|
-
f"leader pane validation failed: {validation['reason']}. "
|
|
349
|
-
"tmux leader pane validation could not bind the recorded pane. "
|
|
350
|
-
"first quick-start uses cwd+command match only; this team already has team_owner "
|
|
351
|
-
"so strict UUID gate applies; use team-agent takeover --confirm if you intend to take over"
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def leader_identity(workspace: Path, team: str | None = None) -> dict[str, Any]:
|
|
356
|
-
state = _load_identity_state(workspace, team)
|
|
357
|
-
identity = _leader_identity_context(workspace, team=team, state=state)
|
|
358
|
-
receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
|
|
359
|
-
return {
|
|
360
|
-
"ok": True,
|
|
361
|
-
"uuid_prefix": str(identity["leader_session_uuid"])[:12],
|
|
362
|
-
"machine_fingerprint": identity["machine_fingerprint"],
|
|
363
|
-
"workspace_abspath": identity["workspace_abspath"],
|
|
364
|
-
"os_user": identity["os_user"],
|
|
365
|
-
"team_id": identity["team_id"],
|
|
366
|
-
"current_pane_id": os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE") or None,
|
|
367
|
-
"last_seen_at": receiver.get("attached_at") or receiver.get("last_seen_at"),
|
|
368
|
-
"source": identity["leader_session_uuid_source"],
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
_LEASE_REASON_ENUM = frozenset(
|
|
373
|
-
{
|
|
374
|
-
"vacant_acquired",
|
|
375
|
-
"previous_owner_pane_dead",
|
|
376
|
-
"previous_owner_alive_refused",
|
|
377
|
-
"owner_epoch_advanced",
|
|
378
|
-
"force_confirm_required",
|
|
379
|
-
"caller_not_leader_shaped",
|
|
380
|
-
"caller_cwd_mismatch",
|
|
381
|
-
"not_in_tmux_pane",
|
|
382
|
-
}
|
|
383
|
-
)
|
|
384
|
-
_LEASE_REBIND_REQUIRED_REASONS = frozenset(
|
|
385
|
-
{"not_in_tmux_pane", "caller_not_leader_shaped", "caller_cwd_mismatch"}
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
# MED1/MED3 (spark, 2026-05-27): one lease mutex serializes every lease mutation —
|
|
389
|
-
# takeover, claim-leader, attach-leader, and autobind. It is the "send" lock so that
|
|
390
|
-
# ownership transfer also serializes against the send mutator (a concurrent send by
|
|
391
|
-
# the old owner cannot race a rebind). takeover must stay on this lock for the same
|
|
392
|
-
# reason, so the three verbs share a single named critical section.
|
|
393
|
-
LEADER_OWNERSHIP_LOCK = "send"
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def _lease_caller_pane() -> str:
|
|
397
|
-
return os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE") or ""
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def _lease_epoch(owner: dict[str, Any] | None, receiver: dict[str, Any] | None) -> int:
|
|
401
|
-
return int((owner or {}).get("owner_epoch") or (receiver or {}).get("owner_epoch") or 0)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def _pane_is_live_leader(target: dict[str, Any] | None) -> bool:
|
|
405
|
-
# C1/C2: liveness is a live tmux probe. A pane is a live leader if the
|
|
406
|
-
# process tree carries the leader session env (set even when a child command
|
|
407
|
-
# is foreground), or the pane's current command is a provider leader host.
|
|
408
|
-
if not isinstance(target, dict):
|
|
409
|
-
return False
|
|
410
|
-
from team_agent.messaging.leader_panes import _leader_command_looks_usable, _leader_command_provider, _target_leader_session_uuid
|
|
411
|
-
if _target_leader_session_uuid(target):
|
|
412
|
-
return True
|
|
413
|
-
command = str(target.get("pane_current_command", ""))
|
|
414
|
-
return _leader_command_looks_usable(command, "") or _leader_command_provider(command) is not None
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
def _owner_pane_is_live(target: dict[str, Any] | None, owner_record: dict[str, Any] | None) -> bool:
|
|
418
|
-
# MED2 (spark, 2026-05-27): a recorded owner is only "live" when the candidate
|
|
419
|
-
# pane carries the OWNER's identity, not merely a leader-looking command name.
|
|
420
|
-
# When the owner has a recorded leader_session_uuid, that uuid is the identity:
|
|
421
|
-
# a stray node/claude pane without the matching uuid is not the owner (so a
|
|
422
|
-
# dead-owner recover proceeds), and the real owner is still live even with a
|
|
423
|
-
# non-leader foreground command as long as its session uuid is in the tree.
|
|
424
|
-
if not isinstance(target, dict):
|
|
425
|
-
return False
|
|
426
|
-
owner = owner_record or {}
|
|
427
|
-
owner_uuid = str(owner.get("leader_session_uuid") or "")
|
|
428
|
-
if owner_uuid:
|
|
429
|
-
from team_agent.messaging.leader_panes import _target_leader_session_uuid
|
|
430
|
-
return _target_leader_session_uuid(target) == owner_uuid
|
|
431
|
-
# No recorded uuid: fall back to provider identity (process tree / command for
|
|
432
|
-
# the owner's provider) rather than any leader-looking command.
|
|
433
|
-
owner_provider = str(owner.get("provider") or "")
|
|
434
|
-
if owner_provider:
|
|
435
|
-
from team_agent.messaging.leader_panes import _leader_command_looks_usable, _target_leader_session_uuid
|
|
436
|
-
if _target_leader_session_uuid(target):
|
|
437
|
-
return True
|
|
438
|
-
return _leader_command_looks_usable(str(target.get("pane_current_command", "")), owner_provider)
|
|
439
|
-
return _pane_is_live_leader(target)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def _cwd_inside_workspace(cwd: str | None, workspace: Path) -> bool:
|
|
443
|
-
# C7/C8: realpath both sides; membership is subtree containment.
|
|
444
|
-
if not cwd:
|
|
445
|
-
return True
|
|
446
|
-
ws = os.path.realpath(str(workspace.resolve()))
|
|
447
|
-
candidate = os.path.realpath(str(cwd))
|
|
448
|
-
return candidate == ws or candidate.startswith(ws + os.sep)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def _caller_pane_eligibility(target: dict[str, Any] | None, workspace: Path) -> dict[str, Any]:
|
|
452
|
-
# C5: acquire binds the caller pane only when it is leader-shaped and its cwd
|
|
453
|
-
# is inside the workspace. A plain shell / worker pane never self-binds.
|
|
454
|
-
if not _pane_is_live_leader(target):
|
|
455
|
-
return {"ok": False, "reason": "caller_not_leader_shaped", "action": "run team-agent claim-leader from a leader (claude/codex) tmux pane"}
|
|
456
|
-
if not _cwd_inside_workspace((target or {}).get("pane_current_path"), workspace):
|
|
457
|
-
return {"ok": False, "reason": "caller_cwd_mismatch", "action": "run from a leader pane whose cwd is inside this workspace"}
|
|
458
|
-
return {"ok": True}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
def _lease_refused(reason: str, *, action: str | None = None, **extra: Any) -> dict[str, Any]:
|
|
462
|
-
result: dict[str, Any] = {"ok": False, "status": "refused", "reason": reason}
|
|
463
|
-
if action:
|
|
464
|
-
result["action"] = action
|
|
465
|
-
result.update(extra)
|
|
466
|
-
return result
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
def _emit_lease_refusal(
|
|
470
|
-
event_log: EventLog,
|
|
471
|
-
reason: str,
|
|
472
|
-
owner: dict[str, Any] | None,
|
|
473
|
-
old_pane: str | None,
|
|
474
|
-
new_pane: str | None,
|
|
475
|
-
team_id: str | None,
|
|
476
|
-
host: str,
|
|
477
|
-
os_user: str,
|
|
478
|
-
) -> None:
|
|
479
|
-
# C20/C21/C22: every refusal emits a structured audit event with a closed-enum
|
|
480
|
-
# reason, redacted uuid prefix, old/new pane id, host, and OS user.
|
|
481
|
-
name = "leader_receiver.rebind_required" if reason in _LEASE_REBIND_REQUIRED_REASONS else "leader_receiver.claim_refused"
|
|
482
|
-
event_log.write(
|
|
483
|
-
name,
|
|
484
|
-
reason=reason,
|
|
485
|
-
old_pane_id=old_pane,
|
|
486
|
-
new_pane_id=new_pane,
|
|
487
|
-
uuid_prefix=str((owner or {}).get("leader_session_uuid") or "")[:8],
|
|
488
|
-
team_id=team_id,
|
|
489
|
-
host=host,
|
|
490
|
-
os_user=os_user,
|
|
491
|
-
)
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def _try_readopt_leader_pane(
|
|
495
|
-
workspace: Path,
|
|
496
|
-
state: dict[str, Any],
|
|
497
|
-
receiver: dict[str, Any],
|
|
498
|
-
pane_info: dict[str, Any],
|
|
499
|
-
targets: dict[str, Any],
|
|
500
|
-
owner_record: dict[str, Any],
|
|
501
|
-
receiver_provider: str,
|
|
502
|
-
source: str,
|
|
503
|
-
event_log: EventLog,
|
|
504
|
-
) -> dict[str, Any] | None:
|
|
505
|
-
# C4/C11/C12: attach-leader converges on the lease claim. When the strict UUID
|
|
506
|
-
# gate would refuse, re-adopt the pane instead IF it is a live workspace leader
|
|
507
|
-
# (real injected uuid + cwd inside the workspace subtree) and the lease is either
|
|
508
|
-
# vacant or already owned by that same identity. A genuinely different live owner
|
|
509
|
-
# still requires explicit takeover.
|
|
510
|
-
from team_agent.messaging.leader_panes import _leader_command_looks_usable, _target_leader_session_uuid
|
|
511
|
-
target_list = targets.get("targets", []) if isinstance(targets, dict) and targets.get("ok") else []
|
|
512
|
-
pane_target = next((item for item in target_list if isinstance(item, dict) and str(item.get("pane_id")) == str(pane_info.get("pane_id"))), None)
|
|
513
|
-
pane_uuid = _target_leader_session_uuid(pane_target or {}) or _target_leader_session_uuid(pane_info) or str(owner_record.get("leader_session_uuid") or receiver.get("leader_session_uuid") or "")
|
|
514
|
-
if not _cwd_inside_workspace(pane_info.get("pane_current_path"), workspace):
|
|
515
|
-
return None
|
|
516
|
-
if not _leader_command_looks_usable(str(pane_info.get("pane_current_command", "")), receiver_provider):
|
|
517
|
-
return None
|
|
518
|
-
owner_pane = str(owner_record.get("pane_id") or "")
|
|
519
|
-
owner_uuid = str(owner_record.get("leader_session_uuid") or "")
|
|
520
|
-
target_uuid = _target_leader_session_uuid(pane_target or {})
|
|
521
|
-
if owner_pane and owner_pane != str(pane_info.get("pane_id") or "") and (not owner_uuid or target_uuid != owner_uuid):
|
|
522
|
-
return None
|
|
523
|
-
epoch = _lease_epoch(owner_record, receiver) + (1 if owner_record else 0)
|
|
524
|
-
receiver.update({
|
|
525
|
-
"pane_id": pane_info["pane_id"],
|
|
526
|
-
"session_name": pane_info.get("session_name"),
|
|
527
|
-
"window_index": pane_info.get("window_index"),
|
|
528
|
-
"window_name": pane_info.get("window_name"),
|
|
529
|
-
"pane_index": pane_info.get("pane_index"),
|
|
530
|
-
"pane_tty": pane_info.get("pane_tty"),
|
|
531
|
-
"pane_current_command": pane_info.get("pane_current_command"),
|
|
532
|
-
"leader_session_uuid": pane_uuid,
|
|
533
|
-
"owner_epoch": epoch,
|
|
534
|
-
"discovery": "attach_readopt",
|
|
535
|
-
})
|
|
536
|
-
receiver.pop("warning", None)
|
|
537
|
-
old_pane = owner_record.get("pane_id") or (state.get("leader_receiver") or {}).get("pane_id")
|
|
538
|
-
state["team_owner"] = {
|
|
539
|
-
"pane_id": pane_info["pane_id"],
|
|
540
|
-
"provider": receiver.get("provider") or receiver_provider,
|
|
541
|
-
"machine_fingerprint": owner_record.get("machine_fingerprint") or pane_info.get("machine_fingerprint") or "",
|
|
542
|
-
"leader_session_uuid": pane_uuid,
|
|
543
|
-
"owner_epoch": epoch,
|
|
544
|
-
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
545
|
-
"claimed_via": "attach-leader",
|
|
546
|
-
}
|
|
547
|
-
state["leader_receiver"] = receiver
|
|
548
|
-
_write_lease_dual_state(workspace, state)
|
|
549
|
-
if old_pane and old_pane != pane_info["pane_id"]:
|
|
550
|
-
event_log.write("owner.adopted_on_restart", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
|
|
551
|
-
event_log.write("leader_receiver.rebind_applied", reason="attach_readopt", old_pane_id=old_pane, new_pane_id=pane_info["pane_id"], owner_epoch=epoch, uuid_prefix=pane_uuid[:8], team_id=team_state_key(state))
|
|
552
|
-
event_log.write("leader_receiver.attached", target=pane_info["pane_id"], session_name=pane_info.get("session_name"), provider=receiver.get("provider"), discovery="attach_readopt", source=source, owner_epoch=epoch, uuid_prefix=pane_uuid[:8])
|
|
553
|
-
return receiver
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
def _detect_dual_state_divergence(workspace: Path, state: dict[str, Any]) -> dict[str, Any] | None:
|
|
557
|
-
# C18: the workspace-level state.json and the team-level runtime snapshot must
|
|
558
|
-
# agree on owner_uuid, receiver_pane_id, and owner_epoch. Detect a pre-existing
|
|
559
|
-
# split so the repair can be audited.
|
|
560
|
-
session = state.get("session_name")
|
|
561
|
-
if not session:
|
|
562
|
-
return None
|
|
563
|
-
from team_agent.restart.snapshot import load_snapshot_state, team_runtime_snapshot_dir
|
|
564
|
-
snap_path = team_runtime_snapshot_dir(workspace, str(session)) / "state.json"
|
|
565
|
-
if not snap_path.exists():
|
|
566
|
-
return None
|
|
567
|
-
snap = load_snapshot_state(snap_path) or {}
|
|
568
|
-
ws_owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
|
|
569
|
-
snap_owner = snap.get("team_owner") if isinstance(snap.get("team_owner"), dict) else {}
|
|
570
|
-
ws_receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
|
|
571
|
-
snap_receiver = snap.get("leader_receiver") if isinstance(snap.get("leader_receiver"), dict) else {}
|
|
572
|
-
diverged = (
|
|
573
|
-
ws_owner.get("pane_id") != snap_owner.get("pane_id")
|
|
574
|
-
or ws_owner.get("leader_session_uuid") != snap_owner.get("leader_session_uuid")
|
|
575
|
-
or _lease_epoch(ws_owner, ws_receiver) != _lease_epoch(snap_owner, snap_receiver)
|
|
576
|
-
or ws_receiver.get("pane_id") != snap_receiver.get("pane_id")
|
|
577
|
-
)
|
|
578
|
-
if not diverged:
|
|
579
|
-
return None
|
|
580
|
-
return {
|
|
581
|
-
"workspace_owner_pane": ws_owner.get("pane_id"),
|
|
582
|
-
"team_owner_pane": snap_owner.get("pane_id"),
|
|
583
|
-
"workspace_receiver_pane": ws_receiver.get("pane_id"),
|
|
584
|
-
"team_receiver_pane": snap_receiver.get("pane_id"),
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
def _write_lease_dual_state(workspace: Path, state: dict[str, Any]) -> None:
|
|
589
|
-
# C17: write team_owner + leader_receiver to both state locations in one lock
|
|
590
|
-
# hold. The workspace-level state.json and the team-level runtime snapshot
|
|
591
|
-
# (teams/<session>/state.json) must never diverge after a lease mutation.
|
|
592
|
-
save_team_scoped_state(workspace, state)
|
|
593
|
-
if state.get("session_name"):
|
|
594
|
-
from team_agent.restart.snapshot import save_team_runtime_snapshot
|
|
595
|
-
save_team_runtime_snapshot(workspace, state)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
def _claim_lease_no_incident(
|
|
599
|
-
workspace: Path,
|
|
600
|
-
state: dict[str, Any],
|
|
601
|
-
team: str | None,
|
|
602
|
-
team_id: str,
|
|
603
|
-
caller_pane: str,
|
|
604
|
-
confirm: bool,
|
|
605
|
-
event_log: EventLog,
|
|
606
|
-
) -> dict[str, Any]:
|
|
607
|
-
# Gap 39 unified lease: no ambiguous incident is recorded, so this is a direct
|
|
608
|
-
# acquire/claim against live evidence (not the Gap 26 broadcast-claim flow).
|
|
609
|
-
from team_agent.runtime import core_list_targets
|
|
610
|
-
owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
|
|
611
|
-
receiver = state.get("leader_receiver") if isinstance(state.get("leader_receiver"), dict) else {}
|
|
612
|
-
precheck_epoch = _lease_epoch(owner, receiver)
|
|
613
|
-
host = str(owner.get("machine_fingerprint") or _identity_machine_fingerprint(state))
|
|
614
|
-
os_user = _identity_os_user()
|
|
615
|
-
|
|
616
|
-
if not caller_pane:
|
|
617
|
-
_emit_lease_refusal(event_log, "not_in_tmux_pane", owner, receiver.get("pane_id"), None, team_id, host, os_user)
|
|
618
|
-
return _lease_refused("not_in_tmux_pane", action="run team-agent claim-leader from the leader's tmux pane")
|
|
619
|
-
|
|
620
|
-
targets_result = core_list_targets()
|
|
621
|
-
targets = targets_result.get("targets", []) if isinstance(targets_result, dict) and targets_result.get("ok") else []
|
|
622
|
-
by_pane = {str(item.get("pane_id")): item for item in targets if isinstance(item, dict)}
|
|
623
|
-
|
|
624
|
-
bound_pane = receiver.get("pane_id") or owner.get("pane_id")
|
|
625
|
-
bound_alive = _owner_pane_is_live(by_pane.get(str(bound_pane)), owner) if bound_pane else False
|
|
626
|
-
|
|
627
|
-
if bound_pane and str(bound_pane) == str(caller_pane):
|
|
628
|
-
return {
|
|
629
|
-
"ok": True,
|
|
630
|
-
"status": "already_bound",
|
|
631
|
-
"leader_receiver": receiver or None,
|
|
632
|
-
"team_owner": owner or None,
|
|
633
|
-
"owner_epoch": precheck_epoch,
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
caller_target = by_pane.get(str(caller_pane))
|
|
637
|
-
eligibility = _caller_pane_eligibility(caller_target, workspace)
|
|
638
|
-
if not eligibility["ok"]:
|
|
639
|
-
_emit_lease_refusal(event_log, eligibility["reason"], owner, bound_pane, caller_pane, team_id, host, os_user)
|
|
640
|
-
return _lease_refused(eligibility["reason"], action=eligibility.get("action"))
|
|
641
|
-
|
|
642
|
-
if bound_alive and not confirm:
|
|
643
|
-
# C4/C13: a live recorded owner is never stolen without --confirm. The audit
|
|
644
|
-
# reason classifies the WHY (owner alive); the result hint tells the operator
|
|
645
|
-
# the action (rerun with --confirm).
|
|
646
|
-
_emit_lease_refusal(event_log, "previous_owner_alive_refused", owner, bound_pane, caller_pane, team_id, host, os_user)
|
|
647
|
-
return _lease_refused(
|
|
648
|
-
"force_confirm_required",
|
|
649
|
-
action="rerun with --confirm to take over the live leader pane",
|
|
650
|
-
bound_pane_id=bound_pane,
|
|
651
|
-
owner_epoch=precheck_epoch,
|
|
652
|
-
)
|
|
653
|
-
|
|
654
|
-
# C3/C15: revalidate under the lock. Re-read both the persisted epoch and live
|
|
655
|
-
# liveness; if the epoch advanced or a previously-dead owner revived since the
|
|
656
|
-
# precheck, abort the claim without double-binding (lost the epoch race).
|
|
657
|
-
locked_state = select_runtime_state(workspace, team)
|
|
658
|
-
locked_owner = locked_state.get("team_owner") if isinstance(locked_state.get("team_owner"), dict) else {}
|
|
659
|
-
locked_receiver = locked_state.get("leader_receiver") if isinstance(locked_state.get("leader_receiver"), dict) else {}
|
|
660
|
-
locked_epoch = _lease_epoch(locked_owner, locked_receiver)
|
|
661
|
-
recheck_result = core_list_targets()
|
|
662
|
-
recheck_targets = recheck_result.get("targets", []) if isinstance(recheck_result, dict) and recheck_result.get("ok") else []
|
|
663
|
-
recheck_by_pane = {str(item.get("pane_id")): item for item in recheck_targets if isinstance(item, dict)}
|
|
664
|
-
revived = bool(bound_pane) and not bound_alive and _owner_pane_is_live(recheck_by_pane.get(str(bound_pane)), owner)
|
|
665
|
-
if locked_epoch != precheck_epoch or (revived and not confirm):
|
|
666
|
-
_emit_lease_refusal(event_log, "owner_epoch_advanced", locked_owner or owner, bound_pane, caller_pane, team_id, host, os_user)
|
|
667
|
-
return _lease_refused(
|
|
668
|
-
"owner_epoch_advanced",
|
|
669
|
-
bound_pane_id=bound_pane,
|
|
670
|
-
owner_epoch=max(locked_epoch, precheck_epoch),
|
|
671
|
-
)
|
|
672
|
-
|
|
673
|
-
divergence = _detect_dual_state_divergence(workspace, state)
|
|
674
|
-
# C10/C12: the caller pane's injected TEAM_AGENT_LEADER_SESSION_UUID is the
|
|
675
|
-
# authoritative identity for the bind; fall back to the recorded owner/receiver
|
|
676
|
-
# uuid, then to the deterministic derivation.
|
|
677
|
-
from team_agent.messaging.leader_panes import _target_leader_session_uuid
|
|
678
|
-
next_epoch = precheck_epoch + 1
|
|
679
|
-
leader_uuid = str(
|
|
680
|
-
_target_leader_session_uuid(caller_target or {})
|
|
681
|
-
or owner.get("leader_session_uuid")
|
|
682
|
-
or receiver.get("leader_session_uuid")
|
|
683
|
-
or _leader_identity_context(workspace, team=team, state=state)["leader_session_uuid"]
|
|
684
|
-
)
|
|
685
|
-
new_receiver = _receiver_from_claim_target(caller_target, receiver, leader_uuid, next_epoch)
|
|
686
|
-
new_owner = {
|
|
687
|
-
"pane_id": caller_pane,
|
|
688
|
-
"provider": new_receiver.get("provider") or owner.get("provider") or "codex",
|
|
689
|
-
"machine_fingerprint": host,
|
|
690
|
-
"leader_session_uuid": leader_uuid,
|
|
691
|
-
"owner_epoch": next_epoch,
|
|
692
|
-
"claimed_at": datetime.now(timezone.utc).isoformat(),
|
|
693
|
-
"claimed_via": "claim-leader",
|
|
694
|
-
}
|
|
695
|
-
state["team_owner"] = new_owner
|
|
696
|
-
state["leader_receiver"] = new_receiver
|
|
697
|
-
_write_lease_dual_state(workspace, state)
|
|
698
|
-
dead_owner = bool(bound_pane) and not bound_alive
|
|
699
|
-
reason = "previous_owner_pane_dead" if dead_owner else "vacant_acquired"
|
|
700
|
-
if dead_owner:
|
|
701
|
-
event_log.write(
|
|
702
|
-
"owner.adopted_on_restart",
|
|
703
|
-
reason=reason,
|
|
704
|
-
old_pane_id=bound_pane,
|
|
705
|
-
new_pane_id=caller_pane,
|
|
706
|
-
owner_epoch=next_epoch,
|
|
707
|
-
uuid_prefix=leader_uuid[:8],
|
|
708
|
-
team_id=team_id,
|
|
709
|
-
host=host,
|
|
710
|
-
os_user=os_user,
|
|
711
|
-
)
|
|
712
|
-
event_log.write(
|
|
713
|
-
"leader_receiver.rebind_applied",
|
|
714
|
-
reason=reason,
|
|
715
|
-
old_pane_id=bound_pane,
|
|
716
|
-
new_pane_id=caller_pane,
|
|
717
|
-
owner_epoch=next_epoch,
|
|
718
|
-
uuid_prefix=leader_uuid[:8],
|
|
719
|
-
team_id=team_id,
|
|
720
|
-
)
|
|
721
|
-
event_log.write(
|
|
722
|
-
"owner_epoch_advanced",
|
|
723
|
-
reason=reason,
|
|
724
|
-
old_pane_id=bound_pane,
|
|
725
|
-
new_pane_id=caller_pane,
|
|
726
|
-
owner_epoch=next_epoch,
|
|
727
|
-
uuid_prefix=leader_uuid[:8],
|
|
728
|
-
team_id=team_id,
|
|
729
|
-
)
|
|
730
|
-
if divergence:
|
|
731
|
-
# C18/C19: the workspace-level and team-level state had diverged before this
|
|
732
|
-
# mutation; the single dual-write above re-converged them.
|
|
733
|
-
event_log.write("leader_receiver.state_divergence_repaired", team_id=team_id, owner_epoch=next_epoch, new_pane_id=caller_pane, **divergence)
|
|
734
|
-
return {
|
|
735
|
-
"ok": True,
|
|
736
|
-
"status": "claimed",
|
|
737
|
-
"leader_receiver": new_receiver,
|
|
738
|
-
"team_owner": new_owner,
|
|
739
|
-
"owner_epoch": next_epoch,
|
|
740
|
-
"reason": reason,
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def claim_leader(workspace: Path, team: str | None = None, confirm: bool = False) -> dict[str, Any]:
|
|
745
|
-
from team_agent.runtime import RuntimeError, _runtime_lock, core_list_targets
|
|
746
|
-
current_pane = _lease_caller_pane()
|
|
747
|
-
with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
|
|
748
|
-
state = select_runtime_state(workspace, team)
|
|
749
|
-
event_log = EventLog(workspace)
|
|
750
|
-
team_id = team_state_key(state)
|
|
751
|
-
incident = _latest_ambiguous_incident(event_log, team_id)
|
|
752
|
-
if not incident:
|
|
753
|
-
return _claim_lease_no_incident(workspace, state, team, team_id, current_pane, confirm, event_log)
|
|
754
|
-
if not current_pane:
|
|
755
|
-
return {"ok": False, "status": "refused", "reason": "no_caller_pane", "action": "run from a tmux leader pane"}
|
|
756
|
-
candidates = [str(item) for item in incident.get("candidates", [])]
|
|
757
|
-
if current_pane not in candidates:
|
|
758
|
-
return {"ok": False, "status": "refused", "reason": "caller_not_candidate", "candidates": candidates}
|
|
759
|
-
receiver = state.get("leader_receiver") or {}
|
|
760
|
-
if receiver.get("pane_id") == current_pane:
|
|
761
|
-
return {"ok": True, "status": "already_bound", "leader_receiver": receiver}
|
|
762
|
-
if _incident_already_claimed(event_log, str(incident.get("incident_id"))):
|
|
763
|
-
return _claim_lost_race(receiver)
|
|
764
|
-
if receiver.get("pane_id") in candidates and receiver.get("pane_id") != incident.get("old_pane_id"):
|
|
765
|
-
return _claim_lost_race(receiver)
|
|
766
|
-
if not confirm:
|
|
767
|
-
return {"ok": True, "status": "dry_run", "would_bind_pane_id": current_pane, "candidates": candidates}
|
|
768
|
-
targets = core_list_targets()
|
|
769
|
-
if not targets.get("ok"):
|
|
770
|
-
raise RuntimeError(str(targets.get("error") or "tmux target scan failed"))
|
|
771
|
-
target = next((item for item in targets.get("targets", []) if item.get("pane_id") == current_pane), None)
|
|
772
|
-
if not target:
|
|
773
|
-
return {"ok": False, "status": "refused", "reason": "candidate_pane_missing", "pane_id": current_pane}
|
|
774
|
-
owner = state.setdefault("team_owner", {})
|
|
775
|
-
expected_uuid = str(owner.get("leader_session_uuid") or _leader_identity_context(workspace, team=team, state=state)["leader_session_uuid"])
|
|
776
|
-
target_uuid = _target_leader_session_uuid(target)
|
|
777
|
-
if target_uuid != expected_uuid:
|
|
778
|
-
return {"ok": False, "status": "refused", "reason": "leader_session_uuid_mismatch", "uuid_prefix": expected_uuid[:12]}
|
|
779
|
-
epoch = int(owner.get("owner_epoch") or receiver.get("owner_epoch") or 0) + 1
|
|
780
|
-
owner.update({"pane_id": current_pane, "owner_epoch": epoch, "claimed_at": datetime.now(timezone.utc).isoformat(), "claimed_via": "claim-leader"})
|
|
781
|
-
state["leader_receiver"] = _receiver_from_claim_target(target, receiver, expected_uuid, epoch)
|
|
782
|
-
# HIGH (spark, 2026-05-27): the multi-candidate claim branch must write both
|
|
783
|
-
# state locations atomically (workspace state.json + team/<session> snapshot),
|
|
784
|
-
# exactly like the no-incident lease path, so the branches never split state.
|
|
785
|
-
divergence = _detect_dual_state_divergence(workspace, state)
|
|
786
|
-
_write_lease_dual_state(workspace, state)
|
|
787
|
-
if divergence:
|
|
788
|
-
event_log.write("leader_receiver.state_divergence_repaired", team_id=team_id, owner_epoch=epoch, new_pane_id=current_pane, **divergence)
|
|
789
|
-
losers = [pane for pane in candidates if pane != current_pane]
|
|
790
|
-
event_log.write(
|
|
791
|
-
"leader_receiver.claim_applied",
|
|
792
|
-
incident_id=incident.get("incident_id"),
|
|
793
|
-
winner_pane_id=current_pane,
|
|
794
|
-
losers=losers,
|
|
795
|
-
owner_epoch=epoch,
|
|
796
|
-
uuid_prefix=expected_uuid[:12],
|
|
797
|
-
)
|
|
798
|
-
# Stage 11.9 (Gap 26 Mac mini Scenario 3): result watchers that stalled while the
|
|
799
|
-
# broadcast was waiting for a human claim need fresh budget against the newly bound
|
|
800
|
-
# pane. Per-watcher leader_receiver.claim_requeue events + immediate retry.
|
|
801
|
-
from team_agent.message_store import MessageStore
|
|
802
|
-
from team_agent.messaging.result_delivery import requeue_after_claim_leader
|
|
803
|
-
requeued = requeue_after_claim_leader(
|
|
804
|
-
workspace,
|
|
805
|
-
MessageStore(workspace),
|
|
806
|
-
event_log,
|
|
807
|
-
team_state_key(state),
|
|
808
|
-
current_pane,
|
|
809
|
-
incident_ts=incident.get("ts"),
|
|
810
|
-
)
|
|
811
|
-
response: dict[str, Any] = {
|
|
812
|
-
"ok": True,
|
|
813
|
-
"status": "claimed",
|
|
814
|
-
"leader_receiver": state["leader_receiver"],
|
|
815
|
-
"owner_epoch": epoch,
|
|
816
|
-
"losers": losers,
|
|
817
|
-
"requeued_watchers": [item["watcher_id"] for item in requeued],
|
|
818
|
-
}
|
|
819
|
-
# Stage 13 (silent-loss arm mailbox-hint route, 2026-05-26 second roundtable):
|
|
820
|
-
# the framework cannot guarantee every worker message reached the leader pane during
|
|
821
|
-
# the ambiguous-state window (retry budgets may have exhausted before the human
|
|
822
|
-
# claimed). Pointing the leader agent at the inbox lets it self-recover by reading
|
|
823
|
-
# the messages that landed in storage but never injected to a pane.
|
|
824
|
-
incident_ts = incident.get("ts")
|
|
825
|
-
if incident_ts:
|
|
826
|
-
response["inbox_hint"] = {
|
|
827
|
-
"message": (
|
|
828
|
-
"During the previous ambiguous-leader state, some worker messages may "
|
|
829
|
-
"not have been auto-delivered to this pane. Run the command below to "
|
|
830
|
-
"retrieve them."
|
|
831
|
-
),
|
|
832
|
-
"command": f"team-agent inbox leader --since {incident_ts}",
|
|
833
|
-
"since": incident_ts,
|
|
834
|
-
"incident_id": incident.get("incident_id"),
|
|
835
|
-
}
|
|
836
|
-
return response
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
def _latest_ambiguous_incident(event_log: EventLog, team_id: str) -> dict[str, Any] | None:
|
|
840
|
-
for event in reversed(event_log.tail(200)):
|
|
841
|
-
if event.get("event") != "leader_receiver.ambiguous_candidates":
|
|
842
|
-
continue
|
|
843
|
-
if event.get("team_id") in {None, team_id}:
|
|
844
|
-
return event
|
|
845
|
-
return None
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
def _incident_already_claimed(event_log: EventLog, incident_id: str) -> bool:
|
|
849
|
-
return any(event.get("event") == "leader_receiver.claim_applied" and event.get("incident_id") == incident_id for event in event_log.tail(200))
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
def _claim_lost_race(receiver: dict[str, Any]) -> dict[str, Any]:
|
|
853
|
-
return {"ok": False, "status": "refused", "reason": "owner_epoch_advanced", "error": f"team already bound to pane {receiver.get('pane_id')}; you lost the race", "bound_pane_id": receiver.get("pane_id"), "owner_epoch": receiver.get("owner_epoch")}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
def _target_leader_session_uuid(target: dict[str, Any]) -> str:
|
|
857
|
-
env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
|
|
858
|
-
return str(target.get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
def _receiver_from_claim_target(target: dict[str, Any], previous: dict[str, Any], leader_uuid: str, owner_epoch: int) -> dict[str, Any]:
|
|
862
|
-
return {
|
|
863
|
-
"mode": "direct_tmux",
|
|
864
|
-
"status": "attached",
|
|
865
|
-
"provider": previous.get("provider") or "codex",
|
|
866
|
-
"pane_id": target["pane_id"],
|
|
867
|
-
"session_name": target.get("session_name"),
|
|
868
|
-
"window_index": str(target.get("window_index")),
|
|
869
|
-
"window_name": target.get("window_name"),
|
|
870
|
-
"pane_index": str(target.get("pane_index")),
|
|
871
|
-
"pane_tty": target.get("pane_tty"),
|
|
872
|
-
"pane_current_command": target.get("pane_current_command"),
|
|
873
|
-
"leader_session_uuid": leader_uuid,
|
|
874
|
-
"owner_epoch": owner_epoch,
|
|
875
|
-
"attached_at": datetime.now(timezone.utc).isoformat(),
|
|
876
|
-
"discovery": "claim_leader",
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
def autobind_leader_receiver_from_env(
|
|
881
|
-
workspace: Path,
|
|
882
|
-
provider: str,
|
|
883
|
-
source: str,
|
|
884
|
-
) -> dict[str, Any] | None:
|
|
885
|
-
tmux_pane = os.environ.get("TMUX_PANE")
|
|
886
|
-
if not tmux_pane:
|
|
887
|
-
return None
|
|
888
|
-
from team_agent.runtime import _runtime_lock, ensure_workspace_dirs
|
|
889
|
-
ensure_workspace_dirs(workspace)
|
|
890
|
-
# MED1/MED3: the startup autobind is a lease mutation; hold the single lease
|
|
891
|
-
# mutex so it cannot interleave with takeover / claim / attach / send.
|
|
892
|
-
with _runtime_lock(workspace, LEADER_OWNERSHIP_LOCK):
|
|
893
|
-
state = load_runtime_state(workspace)
|
|
894
|
-
event_log = EventLog(workspace)
|
|
895
|
-
try:
|
|
896
|
-
receiver, _validation = attach_leader_to_state(
|
|
897
|
-
workspace,
|
|
898
|
-
state,
|
|
899
|
-
pane=tmux_pane,
|
|
900
|
-
provider=provider,
|
|
901
|
-
event_log=event_log,
|
|
902
|
-
source=source,
|
|
903
|
-
)
|
|
904
|
-
except Exception as exc:
|
|
905
|
-
event_log.write(
|
|
906
|
-
"leader_receiver.autobind_skipped",
|
|
907
|
-
pane=tmux_pane,
|
|
908
|
-
provider=provider,
|
|
909
|
-
source=source,
|
|
910
|
-
error=str(exc),
|
|
911
|
-
)
|
|
912
|
-
return None
|
|
913
|
-
save_runtime_state(workspace, state)
|
|
914
|
-
return receiver
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
__all__ = [
|
|
918
|
-
"attach_leader",
|
|
919
|
-
"attach_leader_to_state",
|
|
920
|
-
"autobind_leader_receiver_from_env",
|
|
921
|
-
"claim_leader",
|
|
922
|
-
"leader_identity",
|
|
923
|
-
"leader_session_name",
|
|
924
|
-
"leader_start_plan",
|
|
925
|
-
"start_leader",
|
|
926
|
-
]
|