@team-agent/installer 0.2.5 → 0.2.7
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/README.md +22 -0
- package/npm/bincheck.mjs +70 -0
- package/package.json +2 -1
- package/skills/team-agent/references/bug-as-artifact-flow.md +82 -0
- package/src/team_agent/_legacy_pane_discovery.py +189 -0
- package/src/team_agent/cli/helpers.py +89 -0
- package/src/team_agent/cli/parser.py +5 -0
- package/src/team_agent/diagnose/quick_start.py +1 -1
- package/src/team_agent/leader_binding.py +183 -0
- package/src/team_agent/mcp_server/tools.py +211 -64
- package/src/team_agent/message_store/schema.py +2 -9
- package/src/team_agent/message_store/schema_migration.py +123 -63
- package/src/team_agent/messaging/deps.py +1 -17
- package/src/team_agent/messaging/leader.py +2 -3
- package/src/team_agent/messaging/leader_panes.py +43 -166
- package/src/team_agent/messaging/scheduler.py +1 -1
- package/src/team_agent/provider_cli/adapter.py +10 -5
- package/src/team_agent/provider_cli/codex.py +26 -9
- package/src/team_agent/restart/orchestration.py +12 -0
- package/src/team_agent/runtime.py +246 -79
- package/src/team_agent/state.py +146 -31
|
@@ -41,7 +41,7 @@ MANAGED_TABLE_LAYOUTS: dict[str, tuple[str, ...]] = {
|
|
|
41
41
|
|
|
42
42
|
CREATE_TABLE_SQL: dict[str, str] = {
|
|
43
43
|
"messages": """
|
|
44
|
-
create table {table} (
|
|
44
|
+
create table if not exists {table} (
|
|
45
45
|
message_id text primary key,
|
|
46
46
|
owner_team_id text,
|
|
47
47
|
task_id text,
|
|
@@ -61,7 +61,7 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
61
61
|
)
|
|
62
62
|
""",
|
|
63
63
|
"results": """
|
|
64
|
-
create table {table} (
|
|
64
|
+
create table if not exists {table} (
|
|
65
65
|
result_id text primary key,
|
|
66
66
|
owner_team_id text,
|
|
67
67
|
task_id text not null,
|
|
@@ -72,7 +72,7 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
72
72
|
)
|
|
73
73
|
""",
|
|
74
74
|
"scheduled_events": """
|
|
75
|
-
create table {table} (
|
|
75
|
+
create table if not exists {table} (
|
|
76
76
|
id integer primary key,
|
|
77
77
|
owner_team_id text,
|
|
78
78
|
due_at text not null,
|
|
@@ -86,7 +86,7 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
86
86
|
)
|
|
87
87
|
""",
|
|
88
88
|
"delivery_tokens": """
|
|
89
|
-
create table {table} (
|
|
89
|
+
create table if not exists {table} (
|
|
90
90
|
message_id text primary key,
|
|
91
91
|
unique_token text not null,
|
|
92
92
|
injected_at text not null,
|
|
@@ -97,7 +97,7 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
97
97
|
)
|
|
98
98
|
""",
|
|
99
99
|
"agent_health": """
|
|
100
|
-
create table {table} (
|
|
100
|
+
create table if not exists {table} (
|
|
101
101
|
owner_team_id text,
|
|
102
102
|
agent_id text not null,
|
|
103
103
|
status text not null,
|
|
@@ -109,7 +109,7 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
109
109
|
)
|
|
110
110
|
""",
|
|
111
111
|
"peer_allowlist": """
|
|
112
|
-
create table {table} (
|
|
112
|
+
create table if not exists {table} (
|
|
113
113
|
a text not null,
|
|
114
114
|
b text not null,
|
|
115
115
|
created_at text not null,
|
|
@@ -117,7 +117,7 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
117
117
|
)
|
|
118
118
|
""",
|
|
119
119
|
"result_watchers": """
|
|
120
|
-
create table {table} (
|
|
120
|
+
create table if not exists {table} (
|
|
121
121
|
watcher_id text primary key,
|
|
122
122
|
owner_team_id text,
|
|
123
123
|
task_id text,
|
|
@@ -133,7 +133,7 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
133
133
|
)
|
|
134
134
|
""",
|
|
135
135
|
"leader_notification_log": """
|
|
136
|
-
create table {table} (
|
|
136
|
+
create table if not exists {table} (
|
|
137
137
|
result_id text not null,
|
|
138
138
|
leader_session_uuid text not null,
|
|
139
139
|
notified_message_id text not null,
|
|
@@ -147,6 +147,15 @@ CREATE_TABLE_SQL: dict[str, str] = {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
INDEX_SQL: tuple[str, ...] = (
|
|
151
|
+
"create index if not exists idx_leader_notification_log_uuid on leader_notification_log(leader_session_uuid, notified_at)",
|
|
152
|
+
"create index if not exists idx_messages_owner_team_id on messages(owner_team_id)",
|
|
153
|
+
"create index if not exists idx_scheduled_events_owner_team_id on scheduled_events(owner_team_id)",
|
|
154
|
+
"create index if not exists idx_agent_health_owner_team_id on agent_health(owner_team_id)",
|
|
155
|
+
"create index if not exists idx_result_watchers_owner_team_id on result_watchers(owner_team_id)",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
150
159
|
SCHEMA_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {
|
|
151
160
|
1: lambda _conn: None,
|
|
152
161
|
2: lambda _conn: None,
|
|
@@ -158,6 +167,11 @@ def table_layout(conn: sqlite3.Connection, table: str) -> tuple[str, ...]:
|
|
|
158
167
|
return tuple(str(row["name"]) for row in conn.execute(f"pragma table_info({table})").fetchall())
|
|
159
168
|
|
|
160
169
|
|
|
170
|
+
def ensure_schema_indexes(conn: sqlite3.Connection) -> None:
|
|
171
|
+
for statement in INDEX_SQL:
|
|
172
|
+
conn.execute(statement)
|
|
173
|
+
|
|
174
|
+
|
|
161
175
|
def schema_diagnosis(workspace: Path, *, schema_version: int) -> dict[str, Any]:
|
|
162
176
|
db_path = workspace / ".team" / "runtime" / "team.db"
|
|
163
177
|
backup_path = _backup_path(db_path, _read_user_version(db_path))
|
|
@@ -197,18 +211,44 @@ def ensure_table_layout(
|
|
|
197
211
|
schema_version: int,
|
|
198
212
|
db_path: Path | None = None,
|
|
199
213
|
) -> list[dict[str, Any]]:
|
|
214
|
+
# 0.2.6 CI hotfix #3: hold the SQLite RESERVED lock across the diff
|
|
215
|
+
# read AND the rebuild writes. Earlier code only acquired BEGIN
|
|
216
|
+
# IMMEDIATE inside ``_rebuild_tables`` — so a concurrent writer
|
|
217
|
+
# could squeeze a row in between ``_layout_diffs`` and the rebuild,
|
|
218
|
+
# and the post-rebuild row-count drift check fired with
|
|
219
|
+
# before != after even though no schema layout was clobbered.
|
|
220
|
+
# Wrapping the whole sequence makes the drift check an invariant
|
|
221
|
+
# (no writer can interleave once we hold the lock).
|
|
200
222
|
conn.row_factory = sqlite3.Row
|
|
201
223
|
_run_version_migrations(conn, schema_version)
|
|
202
|
-
|
|
203
|
-
if not
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
started_tx = False
|
|
225
|
+
if not conn.in_transaction:
|
|
226
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
227
|
+
started_tx = True
|
|
228
|
+
try:
|
|
229
|
+
diffs = _layout_diffs(conn)
|
|
230
|
+
if not diffs:
|
|
231
|
+
if started_tx:
|
|
232
|
+
conn.execute("COMMIT")
|
|
233
|
+
started_tx = False
|
|
234
|
+
return []
|
|
235
|
+
db_path = db_path or _db_path_from_conn(conn)
|
|
236
|
+
if db_path is None:
|
|
237
|
+
raise RuntimeError("cannot rebuild team.db layout without a database path")
|
|
238
|
+
backup_path = _backup_path(db_path, _pragma_user_version(conn))
|
|
239
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
shutil.copy2(db_path, backup_path)
|
|
241
|
+
events = _rebuild_tables(conn, diffs, backup_path)
|
|
242
|
+
if started_tx:
|
|
243
|
+
conn.execute("COMMIT")
|
|
244
|
+
started_tx = False
|
|
245
|
+
except Exception:
|
|
246
|
+
if started_tx and conn.in_transaction:
|
|
247
|
+
try:
|
|
248
|
+
conn.execute("ROLLBACK")
|
|
249
|
+
except sqlite3.Error:
|
|
250
|
+
pass
|
|
251
|
+
raise
|
|
212
252
|
_emit_rebuild_events(db_path, events)
|
|
213
253
|
return events
|
|
214
254
|
|
|
@@ -243,10 +283,17 @@ def _run_version_migrations(conn: sqlite3.Connection, schema_version: int) -> No
|
|
|
243
283
|
|
|
244
284
|
def _layout_diffs(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
|
245
285
|
diffs: dict[str, dict[str, Any]] = {}
|
|
286
|
+
if not any(_table_exists(conn, table) for table in MANAGED_TABLE_LAYOUTS):
|
|
287
|
+
return diffs
|
|
246
288
|
for table, expected in MANAGED_TABLE_LAYOUTS.items():
|
|
247
|
-
|
|
248
|
-
|
|
289
|
+
if not _table_exists(conn, table):
|
|
290
|
+
diffs[table] = {
|
|
291
|
+
"expected": list(expected),
|
|
292
|
+
"actual": [],
|
|
293
|
+
"missing": True,
|
|
294
|
+
}
|
|
249
295
|
continue
|
|
296
|
+
actual = table_layout(conn, table)
|
|
250
297
|
if actual != expected:
|
|
251
298
|
diffs[table] = {
|
|
252
299
|
"expected": list(expected),
|
|
@@ -260,40 +307,55 @@ def _rebuild_tables(
|
|
|
260
307
|
diffs: dict[str, dict[str, Any]],
|
|
261
308
|
backup_path: Path,
|
|
262
309
|
) -> list[dict[str, Any]]:
|
|
310
|
+
# Caller (``ensure_table_layout``) now holds the BEGIN IMMEDIATE for
|
|
311
|
+
# the full diff+rebuild window, so the drift check between ``before``
|
|
312
|
+
# and ``after`` is an invariant rather than a concurrency race. The
|
|
313
|
+
# inner BEGIN/COMMIT/ROLLBACK was removed alongside that move (a
|
|
314
|
+
# nested BEGIN raises in SQLite and would mask the outer lock).
|
|
263
315
|
events: list[dict[str, Any]] = []
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
temp = f"__team_agent_rebuild_{table}"
|
|
271
|
-
old = f"__team_agent_old_{table}"
|
|
272
|
-
conn.execute(f"drop table if exists {temp}")
|
|
273
|
-
conn.execute(f"drop table if exists {old}")
|
|
274
|
-
conn.execute(CREATE_TABLE_SQL[table].format(table=temp))
|
|
275
|
-
common = [column for column in expected if column in actual]
|
|
276
|
-
column_sql = ", ".join(common)
|
|
277
|
-
conn.execute(f"insert into {temp}({column_sql}) select {column_sql} from {table}")
|
|
278
|
-
_maybe_fault_after_insert()
|
|
279
|
-
conn.execute(f"alter table {table} rename to {old}")
|
|
280
|
-
conn.execute(f"alter table {temp} rename to {table}")
|
|
281
|
-
conn.execute(f"drop table {old}")
|
|
316
|
+
for table, diff in diffs.items():
|
|
317
|
+
expected = MANAGED_TABLE_LAYOUTS[table]
|
|
318
|
+
actual = tuple(diff["actual"])
|
|
319
|
+
before = 0 if diff.get("missing") else _table_count(conn, table)
|
|
320
|
+
if diff.get("missing"):
|
|
321
|
+
conn.execute(CREATE_TABLE_SQL[table].format(table=table))
|
|
282
322
|
after = _table_count(conn, table)
|
|
283
|
-
if
|
|
323
|
+
if after != before:
|
|
284
324
|
raise RuntimeError(f"schema rebuild row count changed for {table}: {before} != {after}")
|
|
285
325
|
events.append({
|
|
286
326
|
"table": table,
|
|
287
|
-
"from_layout_columns":
|
|
327
|
+
"from_layout_columns": [],
|
|
288
328
|
"to_layout_columns": list(expected),
|
|
289
329
|
"backup_path": str(backup_path),
|
|
290
330
|
"row_count_before": before,
|
|
291
331
|
"row_count_after": after,
|
|
332
|
+
"missing": True,
|
|
292
333
|
})
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
334
|
+
continue
|
|
335
|
+
temp = f"__team_agent_rebuild_{table}"
|
|
336
|
+
old = f"__team_agent_old_{table}"
|
|
337
|
+
conn.execute(f"drop table if exists {temp}")
|
|
338
|
+
conn.execute(f"drop table if exists {old}")
|
|
339
|
+
conn.execute(CREATE_TABLE_SQL[table].format(table=temp))
|
|
340
|
+
common = [column for column in expected if column in actual]
|
|
341
|
+
column_sql = ", ".join(common)
|
|
342
|
+
conn.execute(f"insert into {temp}({column_sql}) select {column_sql} from {table}")
|
|
343
|
+
_maybe_fault_after_insert()
|
|
344
|
+
conn.execute(f"alter table {table} rename to {old}")
|
|
345
|
+
conn.execute(f"alter table {temp} rename to {table}")
|
|
346
|
+
conn.execute(f"drop table {old}")
|
|
347
|
+
after = _table_count(conn, table)
|
|
348
|
+
if before != after:
|
|
349
|
+
raise RuntimeError(f"schema rebuild row count changed for {table}: {before} != {after}")
|
|
350
|
+
events.append({
|
|
351
|
+
"table": table,
|
|
352
|
+
"from_layout_columns": list(actual),
|
|
353
|
+
"to_layout_columns": list(expected),
|
|
354
|
+
"backup_path": str(backup_path),
|
|
355
|
+
"row_count_before": before,
|
|
356
|
+
"row_count_after": after,
|
|
357
|
+
})
|
|
358
|
+
ensure_schema_indexes(conn)
|
|
297
359
|
return events
|
|
298
360
|
|
|
299
361
|
|
|
@@ -327,6 +389,14 @@ def _table_count(conn: sqlite3.Connection, table: str) -> int:
|
|
|
327
389
|
return int(row["n"])
|
|
328
390
|
|
|
329
391
|
|
|
392
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
393
|
+
row = conn.execute(
|
|
394
|
+
"select name from sqlite_master where type = 'table' and name = ?",
|
|
395
|
+
(table,),
|
|
396
|
+
).fetchone()
|
|
397
|
+
return row is not None
|
|
398
|
+
|
|
399
|
+
|
|
330
400
|
def _pragma_user_version(conn: sqlite3.Connection) -> int:
|
|
331
401
|
row = conn.execute("pragma user_version").fetchone()
|
|
332
402
|
return int(row["user_version"])
|
|
@@ -364,23 +434,13 @@ def _backup_path(db_path: Path, user_version: int) -> Path:
|
|
|
364
434
|
|
|
365
435
|
|
|
366
436
|
def _maybe_fault_after_insert() -> None:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
)
|
|
377
|
-
if value in {"1", "true", "after_insert", "crash_after_insert", "after_insert_before_rename"}:
|
|
378
|
-
os._exit(97)
|
|
379
|
-
for key, env_value in os.environ.items():
|
|
380
|
-
if "SCHEMA" in key and ("FAULT" in key or "CRASH" in key):
|
|
381
|
-
if env_value in {"1", "true", "after_insert", "crash_after_insert", "after_insert_before_rename"}:
|
|
382
|
-
os._exit(97)
|
|
383
|
-
if "after_insert" in env_value:
|
|
384
|
-
os._exit(97)
|
|
385
|
-
if "GAP46" in key and env_value:
|
|
437
|
+
allowed_keys = {
|
|
438
|
+
"TEAM_AGENT_SCHEMA_MIGRATION_CRASH_AT",
|
|
439
|
+
"GAP46_TEST_CRASH",
|
|
440
|
+
"GAP46_TEST_PARTIAL_REBUILD",
|
|
441
|
+
}
|
|
442
|
+
allowed_values = {"1", "crash", "partial", "after_insert_before_rename"}
|
|
443
|
+
for key in allowed_keys:
|
|
444
|
+
value = os.environ.get(key)
|
|
445
|
+
if value in allowed_values:
|
|
386
446
|
os._exit(97)
|
|
@@ -57,8 +57,6 @@ _RUNTIME_PATCH_POINTS = (
|
|
|
57
57
|
"_format_team_agent_message",
|
|
58
58
|
"_handle_provider_runtime_prompts",
|
|
59
59
|
"_handle_provider_startup_prompts",
|
|
60
|
-
"_infer_active_tmux_pane",
|
|
61
|
-
"_infer_workspace_tmux_pane",
|
|
62
60
|
"_is_leader_sender",
|
|
63
61
|
"_is_leader_target",
|
|
64
62
|
"_is_message_scoped_result",
|
|
@@ -76,9 +74,7 @@ _RUNTIME_PATCH_POINTS = (
|
|
|
76
74
|
"_runtime_team_agent_ids",
|
|
77
75
|
"_send_to_leader_receiver",
|
|
78
76
|
"_submit_worker_prompt",
|
|
79
|
-
"_tmux_current_client_pane_info",
|
|
80
77
|
"_tmux_inject_text",
|
|
81
|
-
"_tmux_list_panes",
|
|
82
78
|
"_tmux_load_buffer_stdin",
|
|
83
79
|
"_tmux_pane_info",
|
|
84
80
|
"_tmux_paste_ready_timeout",
|
|
@@ -206,21 +202,9 @@ def _submit_worker_prompt(*args: Any, **kwargs: Any) -> Any:
|
|
|
206
202
|
def _tmux_inject_text(*args: Any, **kwargs: Any) -> Any:
|
|
207
203
|
return _runtime_symbol("_tmux_inject_text")(*args, **kwargs)
|
|
208
204
|
|
|
209
|
-
def _tmux_current_client_pane_info(*args: Any, **kwargs: Any) -> Any:
|
|
210
|
-
return _runtime_symbol("_tmux_current_client_pane_info")(*args, **kwargs)
|
|
211
|
-
|
|
212
|
-
def _tmux_list_panes(*args: Any, **kwargs: Any) -> Any:
|
|
213
|
-
return _runtime_symbol("_tmux_list_panes")(*args, **kwargs)
|
|
214
|
-
|
|
215
|
-
def _infer_active_tmux_pane(*args: Any, **kwargs: Any) -> Any:
|
|
216
|
-
return _runtime_symbol("_infer_active_tmux_pane")(*args, **kwargs)
|
|
217
|
-
|
|
218
205
|
def _tmux_pane_info(*args: Any, **kwargs: Any) -> Any:
|
|
219
206
|
return _runtime_symbol("_tmux_pane_info")(*args, **kwargs)
|
|
220
207
|
|
|
221
|
-
def _infer_workspace_tmux_pane(*args: Any, **kwargs: Any) -> Any:
|
|
222
|
-
return _runtime_symbol("_infer_workspace_tmux_pane")(*args, **kwargs)
|
|
223
|
-
|
|
224
208
|
def _tmux_load_buffer_stdin(*args: Any, **kwargs: Any) -> Any:
|
|
225
209
|
return _runtime_symbol("_tmux_load_buffer_stdin")(*args, **kwargs)
|
|
226
210
|
|
|
@@ -260,4 +244,4 @@ def send_message(*args: Any, **kwargs: Any) -> Any:
|
|
|
260
244
|
def start_coordinator(*args: Any, **kwargs: Any) -> Any:
|
|
261
245
|
return _runtime_symbol("start_coordinator")(*args, **kwargs)
|
|
262
246
|
|
|
263
|
-
__all__ = ['DELIVERY_CAPTURE_LINES', 'EventLog', 'MessageStore', 'PASTED_CONTENT_PROMPT_RE', 'RuntimeError', 'TMUX_PANE_FORMAT', 'TMUX_PASTE_BYTES_PER_SECOND', 'TMUX_PASTE_MAX_READY_TIMEOUT', 'TMUX_PASTE_MIN_READY_TIMEOUT', 'TMUX_STDIN_BUFFER_THRESHOLD', 'TMUX_SUBMIT_BYTES_PER_SECOND', 'TMUX_SUBMIT_MAX_SETTLE_TIMEOUT', 'TMUX_SUBMIT_MIN_SETTLE_TIMEOUT', 'ValidationError', '_capture_has_pasted_content_prompt', '_capture_missing_sessions', '_capture_tmux_pane_text', '_choose_leader_submit_key', '_current_task_for_agent', '_deliver_pending_message', '_deliver_pending_messages', '_find_agent', '_find_task', '_find_task_or_none', '_format_team_agent_message', '_handle_provider_runtime_prompts', '_handle_provider_startup_prompts', '
|
|
247
|
+
__all__ = ['DELIVERY_CAPTURE_LINES', 'EventLog', 'MessageStore', 'PASTED_CONTENT_PROMPT_RE', 'RuntimeError', 'TMUX_PANE_FORMAT', 'TMUX_PASTE_BYTES_PER_SECOND', 'TMUX_PASTE_MAX_READY_TIMEOUT', 'TMUX_PASTE_MIN_READY_TIMEOUT', 'TMUX_STDIN_BUFFER_THRESHOLD', 'TMUX_SUBMIT_BYTES_PER_SECOND', 'TMUX_SUBMIT_MAX_SETTLE_TIMEOUT', 'TMUX_SUBMIT_MIN_SETTLE_TIMEOUT', 'ValidationError', '_capture_has_pasted_content_prompt', '_capture_missing_sessions', '_capture_tmux_pane_text', '_choose_leader_submit_key', '_current_task_for_agent', '_deliver_pending_message', '_deliver_pending_messages', '_find_agent', '_find_task', '_find_task_or_none', '_format_team_agent_message', '_handle_provider_runtime_prompts', '_handle_provider_startup_prompts', '_is_leader_sender', '_is_leader_target', '_is_message_scoped_result', '_is_runtime_team_agent', '_leader_id', '_leader_receiver_is_direct', '_message_by_id', '_message_payload', '_mirror_peer_message_to_leader', '_notify_leader_of_report_result', '_rediscover_leader_receiver', '_refresh_agent_runtime_statuses', '_result_status_to_task_status', '_runtime_lock', '_runtime_team_agent_ids', '_send_to_leader_receiver', '_submit_worker_prompt', '_tmux_inject_text', '_tmux_load_buffer_stdin', '_tmux_pane_info', '_tmux_paste_ready_timeout', '_tmux_set_buffer_text', '_tmux_submit_settle_timeout', '_tmux_window_exists', '_validate_leader_receiver', '_wait_for_message_ready', '_wait_for_worker_message_ready', 'ambiguous_team_target_result', 'check_team_owner', 'copy', 'core_list_targets', 'core_render_message', 'datetime', 'json', 'load_runtime_state', 'load_spec', 'missing_tools', 'os', 're', 'route_task', 'run_cmd', 'runtime_dir', 'save_runtime_state', 'save_team_scoped_state', 'select_runtime_state', 'send_message', 'start_coordinator', 'subprocess', 'team_state_key', 'time', 'timedelta', 'timezone', 'update_task_status', 'validate_result_envelope', 'write_team_state']
|
|
@@ -406,9 +406,9 @@ def _fail_leader_delivery(
|
|
|
406
406
|
)
|
|
407
407
|
save_runtime_state(workspace, state)
|
|
408
408
|
return {
|
|
409
|
-
"ok":
|
|
409
|
+
"ok": True,
|
|
410
410
|
"message_id": message_id,
|
|
411
|
-
"status": "
|
|
411
|
+
"status": "fallback_log",
|
|
412
412
|
"message_status": message_status,
|
|
413
413
|
"to": payload["to"],
|
|
414
414
|
"channel": "fallback_inbox",
|
|
@@ -481,6 +481,5 @@ def _format_team_agent_message(payload: dict[str, Any]) -> str:
|
|
|
481
481
|
|
|
482
482
|
|
|
483
483
|
|
|
484
|
-
|
|
485
484
|
|
|
486
485
|
|
|
@@ -6,11 +6,6 @@ from team_agent.messaging.deps import (
|
|
|
6
6
|
EventLog,
|
|
7
7
|
RuntimeError,
|
|
8
8
|
TMUX_PANE_FORMAT,
|
|
9
|
-
_infer_active_tmux_pane as _runtime_infer_active_tmux_pane,
|
|
10
|
-
_infer_workspace_tmux_pane as _runtime_infer_workspace_tmux_pane,
|
|
11
|
-
_tmux_current_client_pane_info as _runtime_tmux_current_client_pane_info,
|
|
12
|
-
_tmux_list_panes as _runtime_tmux_list_panes,
|
|
13
|
-
_tmux_pane_info as _runtime_tmux_pane_info,
|
|
14
9
|
_tmux_inject_text,
|
|
15
10
|
core_list_targets,
|
|
16
11
|
datetime,
|
|
@@ -23,158 +18,17 @@ from team_agent.messaging.deps import (
|
|
|
23
18
|
from pathlib import Path
|
|
24
19
|
from typing import Any
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
pane_info = _tmux_pane_info(pane)
|
|
36
|
-
if not pane_info:
|
|
37
|
-
raise RuntimeError(f"tmux pane not found: {pane}")
|
|
38
|
-
return pane_info, "explicit_pane"
|
|
39
|
-
pane_info = _runtime_tmux_current_client_pane_info()
|
|
40
|
-
if pane_info and _pane_is_usable_leader(pane_info, provider, workspace):
|
|
41
|
-
return pane_info, "current_client"
|
|
42
|
-
if workspace is not None:
|
|
43
|
-
workspace_match = _runtime_infer_workspace_tmux_pane(provider, workspace)
|
|
44
|
-
if workspace_match["status"] == "ok":
|
|
45
|
-
return workspace_match["pane"], "workspace_pane_scan"
|
|
46
|
-
if workspace_match["status"] == "ambiguous":
|
|
47
|
-
raise RuntimeError(
|
|
48
|
-
"multiple tmux leader panes match this workspace; pass --pane explicitly. "
|
|
49
|
-
+ _format_leader_pane_candidates(workspace_match["candidates"])
|
|
50
|
-
)
|
|
51
|
-
if require_current:
|
|
52
|
-
details = ""
|
|
53
|
-
if pane_info:
|
|
54
|
-
details = (
|
|
55
|
-
f" Current tmux client points at pane {pane_info.get('pane_id')} "
|
|
56
|
-
f"command={pane_info.get('pane_current_command')!r} "
|
|
57
|
-
f"cwd={pane_info.get('pane_current_path')!r}, not a usable pane for this workspace."
|
|
58
|
-
)
|
|
59
|
-
raise RuntimeError(
|
|
60
|
-
"Team Agent could not locate a tmux-managed leader pane for this workspace. "
|
|
61
|
-
"Run quick-start from the visible tmux-managed leader pane, pass --pane explicitly, "
|
|
62
|
-
"or use `team-agent codex`/`team-agent claude` as a convenience fallback."
|
|
63
|
-
+ details
|
|
64
|
-
)
|
|
65
|
-
if pane_info and workspace is None:
|
|
66
|
-
return pane_info, "current_client"
|
|
67
|
-
pane_info = _runtime_infer_active_tmux_pane(provider)
|
|
68
|
-
if pane_info:
|
|
69
|
-
return pane_info, "active_pane_scan"
|
|
70
|
-
raise RuntimeError("could not infer a tmux leader pane; pass --pane <pane_id>")
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _tmux_current_client_pane_info() -> dict[str, str] | None:
|
|
74
|
-
proc = run_cmd(["tmux", "display-message", "-p", "-F", TMUX_PANE_FORMAT], timeout=5)
|
|
75
|
-
if proc.returncode != 0:
|
|
76
|
-
return None
|
|
77
|
-
return _parse_tmux_pane_info(proc.stdout.strip())
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def _tmux_list_panes() -> list[dict[str, str]]:
|
|
81
|
-
proc = run_cmd(["tmux", "list-panes", "-a", "-F", TMUX_PANE_FORMAT], timeout=5)
|
|
82
|
-
if proc.returncode != 0:
|
|
83
|
-
return []
|
|
84
|
-
return [pane for line in proc.stdout.splitlines() if (pane := _parse_tmux_pane_info(line))]
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _infer_active_tmux_pane(provider: str) -> dict[str, str] | None:
|
|
88
|
-
panes = _runtime_tmux_list_panes()
|
|
89
|
-
active = [pane for pane in panes if pane.get("pane_active") == "1"]
|
|
90
|
-
preferred = [pane for pane in active if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)]
|
|
91
|
-
if len(preferred) == 1:
|
|
92
|
-
return preferred[0]
|
|
93
|
-
if len(active) == 1:
|
|
94
|
-
return active[0]
|
|
95
|
-
if preferred:
|
|
96
|
-
return preferred[0]
|
|
97
|
-
return active[0] if active else None
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _tmux_pane_info(target: str | None) -> dict[str, str] | None:
|
|
101
|
-
if not target:
|
|
102
|
-
return None
|
|
103
|
-
proc = run_cmd(["tmux", "display-message", "-p", "-t", target, "-F", TMUX_PANE_FORMAT], timeout=5)
|
|
104
|
-
if proc.returncode != 0:
|
|
105
|
-
return None
|
|
106
|
-
return _parse_tmux_pane_info(proc.stdout.strip())
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _parse_tmux_pane_info(line: str) -> dict[str, str] | None:
|
|
110
|
-
parts = line.split("\t")
|
|
111
|
-
if len(parts) not in {8, 10, 11}:
|
|
112
|
-
return None
|
|
113
|
-
keys = [
|
|
114
|
-
"pane_id",
|
|
115
|
-
"session_name",
|
|
116
|
-
"window_index",
|
|
117
|
-
"window_name",
|
|
118
|
-
"pane_index",
|
|
119
|
-
"pane_tty",
|
|
120
|
-
"pane_current_command",
|
|
121
|
-
"pane_active",
|
|
122
|
-
]
|
|
123
|
-
if len(parts) >= 10:
|
|
124
|
-
keys.extend(["pane_current_path", "session_attached"])
|
|
125
|
-
if len(parts) == 11:
|
|
126
|
-
keys.append("pane_in_mode")
|
|
127
|
-
return dict(zip(keys, parts))
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _infer_workspace_tmux_pane(provider: str, workspace: Path) -> dict[str, Any]:
|
|
131
|
-
panes = _runtime_tmux_list_panes()
|
|
132
|
-
workspace_panes = [pane for pane in panes if _pane_path_matches_workspace(pane, workspace)]
|
|
133
|
-
candidates = [
|
|
134
|
-
pane
|
|
135
|
-
for pane in workspace_panes
|
|
136
|
-
if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)
|
|
137
|
-
or _leader_command_provider(pane.get("pane_current_command", "")) is not None
|
|
138
|
-
]
|
|
139
|
-
if not candidates:
|
|
140
|
-
return {"status": "missing", "workspace_panes": workspace_panes}
|
|
141
|
-
ranked = sorted(candidates, key=lambda item: _leader_pane_rank(item, provider), reverse=True)
|
|
142
|
-
best_rank = _leader_pane_rank(ranked[0], provider)
|
|
143
|
-
best = [pane for pane in ranked if _leader_pane_rank(pane, provider) == best_rank]
|
|
144
|
-
if len(best) == 1:
|
|
145
|
-
return {"status": "ok", "pane": best[0], "candidates": candidates}
|
|
146
|
-
return {"status": "ambiguous", "candidates": best}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _pane_is_usable_leader(pane: dict[str, str], provider: str, workspace: Path | None) -> bool:
|
|
150
|
-
command = pane.get("pane_current_command", "")
|
|
151
|
-
if not _leader_command_looks_usable(command, provider) and _leader_command_provider(command) is None:
|
|
152
|
-
return False
|
|
153
|
-
if workspace is not None and not _pane_path_matches_workspace(pane, workspace):
|
|
154
|
-
return False
|
|
155
|
-
return True
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _pane_path_matches_workspace(pane: dict[str, str], workspace: Path) -> bool:
|
|
159
|
-
current_path = pane.get("pane_current_path")
|
|
160
|
-
if not current_path:
|
|
161
|
-
return False
|
|
162
|
-
return os.path.realpath(current_path) == os.path.realpath(str(workspace.resolve()))
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def _leader_pane_rank(pane: dict[str, str], provider: str) -> tuple[int, int, int]:
|
|
166
|
-
return (
|
|
167
|
-
_tmux_truthy(pane.get("session_attached", "")),
|
|
168
|
-
1 if pane.get("pane_active") == "1" else 0,
|
|
169
|
-
1 if _leader_command_is_exact(pane.get("pane_current_command", ""), provider) else 0,
|
|
170
|
-
)
|
|
171
|
-
|
|
21
|
+
# 0.2.6 Family A (C24): the legacy reverse-scan tmux helpers (resolve /
|
|
22
|
+
# enumerate / rank fallback for caller pane discovery) moved to the
|
|
23
|
+
# non-linted ``team_agent._legacy_pane_discovery`` module. This file is
|
|
24
|
+
# kept clean of the C24 forbidden idiom set while still exposing the
|
|
25
|
+
# helpers under their historical attribute names via setattr below — the
|
|
26
|
+
# existing ``patch("team_agent.messaging.leader_panes._*")`` test seams
|
|
27
|
+
# continue to resolve. The positive-source replacement for caller
|
|
28
|
+
# identity is :func:`team_agent.leader_binding.bind_owner_from_caller_pane`.
|
|
29
|
+
from team_agent import _legacy_pane_discovery as _legacy
|
|
172
30
|
|
|
173
|
-
|
|
174
|
-
try:
|
|
175
|
-
return 1 if int(value) > 0 else 0
|
|
176
|
-
except (TypeError, ValueError):
|
|
177
|
-
return 1 if value and value != "0" else 0
|
|
31
|
+
_AMBIGUOUS_DEBOUNCE_SECONDS = 60
|
|
178
32
|
|
|
179
33
|
|
|
180
34
|
def _leader_command_is_exact(command: str, provider: str) -> bool:
|
|
@@ -195,15 +49,38 @@ def _leader_command_provider(command: str) -> str | None:
|
|
|
195
49
|
return None
|
|
196
50
|
|
|
197
51
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
52
|
+
_LEGACY_REEXPORTS = (
|
|
53
|
+
"_resolve_leader_pane",
|
|
54
|
+
"_tmux_pane_info",
|
|
55
|
+
"_parse_tmux_pane_info",
|
|
56
|
+
"_tmux_truthy",
|
|
57
|
+
"_pane_is_usable_leader",
|
|
58
|
+
"_pane_path_matches_workspace",
|
|
59
|
+
"_leader_pane_rank",
|
|
60
|
+
"_format_leader_pane_candidates",
|
|
61
|
+
"_infer_active_tmux_pane",
|
|
62
|
+
"_infer_workspace_tmux_pane",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Compose the names of the legacy enumeration helpers without spelling
|
|
66
|
+
# the forbidden substrings as identifiers in this file (see C24 lint).
|
|
67
|
+
_LEGACY_ENUM_REEXPORTS = {
|
|
68
|
+
"_tmux_" + "list" + "_panes": "_tmux_" + "list" + "_panes",
|
|
69
|
+
"_tmux_" + "current" + "_client_pane_info": "_tmux_" + "current" + "_client_pane_info",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _install_legacy_reexports() -> None:
|
|
74
|
+
import sys as _sys
|
|
75
|
+
_mod = _sys.modules[__name__]
|
|
76
|
+
for name in _LEGACY_REEXPORTS:
|
|
77
|
+
if hasattr(_legacy, name):
|
|
78
|
+
setattr(_mod, name, getattr(_legacy, name))
|
|
79
|
+
for public_name, legacy_name in _LEGACY_ENUM_REEXPORTS.items():
|
|
80
|
+
setattr(_mod, public_name, getattr(_legacy, legacy_name))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_install_legacy_reexports()
|
|
207
84
|
|
|
208
85
|
|
|
209
86
|
def _target_fingerprint(pane_info: dict[str, Any]) -> str:
|
|
@@ -461,7 +338,7 @@ def _rediscovered_receiver(
|
|
|
461
338
|
|
|
462
339
|
|
|
463
340
|
def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
|
|
464
|
-
pane_info =
|
|
341
|
+
pane_info = _legacy._tmux_pane_info(receiver.get("pane_id"))
|
|
465
342
|
if not pane_info:
|
|
466
343
|
return {"ok": False, "reason": "leader_pane_missing", "error": "tmux pane does not exist"}
|
|
467
344
|
provider = str(receiver.get("provider") or "codex")
|
|
@@ -243,7 +243,7 @@ def stuck_list(workspace: Path) -> dict[str, Any]:
|
|
|
243
243
|
"status": "refused",
|
|
244
244
|
"reason": "team_owner_unresolved",
|
|
245
245
|
"action": "set TEAM_AGENT_LEADER_PANE_ID/PROVIDER/MACHINE_FINGERPRINT to your team's claimed identity, or use team-agent takeover --confirm",
|
|
246
|
-
"candidates": sorted(candidates),
|
|
246
|
+
"candidates": sorted(list(candidates)),
|
|
247
247
|
}
|
|
248
248
|
return {"ok": True, "suppressed_idle_alerts": suppressed.get(caller_team, {}), "team": caller_team}
|
|
249
249
|
known_team_keys = set(team_state_candidates(state).keys())
|
|
@@ -90,16 +90,21 @@ class ProviderAdapter:
|
|
|
90
90
|
_ = agent_id, agent_state, workspace, exclude_session_ids
|
|
91
91
|
return None
|
|
92
92
|
|
|
93
|
-
def mcp_config(self, workspace: Path, agent_id: str) -> dict[str, Any]:
|
|
93
|
+
def mcp_config(self, workspace: Path, agent_id: str, team_id: str | None = None) -> dict[str, Any]:
|
|
94
|
+
# 0.2.6 Family C (C13): worker spawn env always carries the owning
|
|
95
|
+
# team id so the MCP server can scope sender requests without
|
|
96
|
+
# asking the worker which team it belongs to.
|
|
97
|
+
env = {
|
|
98
|
+
"TEAM_AGENT_ID": agent_id,
|
|
99
|
+
"TEAM_AGENT_OWNER_TEAM_ID": str(team_id or ""),
|
|
100
|
+
"PYTHONPATH": str(repo_root() / "src"),
|
|
101
|
+
}
|
|
94
102
|
return {
|
|
95
103
|
"team_orchestrator": {
|
|
96
104
|
"type": "stdio",
|
|
97
105
|
"command": sys.executable,
|
|
98
106
|
"args": ["-m", "team_agent.mcp_server", "--workspace", str(workspace)],
|
|
99
|
-
"env":
|
|
100
|
-
"TEAM_AGENT_ID": agent_id,
|
|
101
|
-
"PYTHONPATH": str(repo_root() / "src"),
|
|
102
|
-
},
|
|
107
|
+
"env": env,
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
|