@team-agent/installer 0.2.3 → 0.2.5
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/package.json +1 -1
- package/src/team_agent/abnormal_track.py +253 -0
- package/src/team_agent/cli/commands.py +17 -1
- package/src/team_agent/cli/parser.py +2 -2
- package/src/team_agent/compiler.py +1 -1
- package/src/team_agent/coordinator/lifecycle.py +20 -2
- package/src/team_agent/display/__init__.py +31 -0
- package/src/team_agent/display/adaptive.py +425 -0
- package/src/team_agent/display/backend.py +46 -0
- package/src/team_agent/display/close.py +6 -0
- package/src/team_agent/display/rebuild.py +102 -0
- package/src/team_agent/display/tiling.py +156 -0
- package/src/team_agent/display/worker_window.py +4 -0
- package/src/team_agent/display/workspace.py +36 -127
- package/src/team_agent/idle_predicate.py +200 -0
- package/src/team_agent/idle_takeover.py +59 -0
- package/src/team_agent/idle_takeover_wiring.py +111 -0
- package/src/team_agent/launch/core.py +13 -4
- package/src/team_agent/leader/__init__.py +444 -61
- package/src/team_agent/message_store/agent_health.py +6 -2
- package/src/team_agent/message_store/core.py +51 -18
- package/src/team_agent/message_store/leader_notification_log.py +63 -38
- package/src/team_agent/message_store/result_watchers.py +17 -11
- package/src/team_agent/message_store/schema.py +19 -2
- package/src/team_agent/message_store/schema_migration.py +386 -0
- package/src/team_agent/messaging/delivery.py +45 -2
- package/src/team_agent/messaging/leader_panes.py +115 -21
- package/src/team_agent/messaging/send.py +33 -0
- package/src/team_agent/messaging/tmux_io.py +49 -10
- package/src/team_agent/messaging/trust_auto_answer.py +11 -3
- package/src/team_agent/provider_state/README.md +78 -0
- package/src/team_agent/provider_state/__init__.py +86 -0
- package/src/team_agent/provider_state/claude.py +86 -0
- package/src/team_agent/provider_state/codex.py +84 -0
- package/src/team_agent/provider_state/common.py +207 -0
- package/src/team_agent/provider_state/registry.py +118 -0
- package/src/team_agent/restart/orchestration.py +9 -9
- package/src/team_agent/runtime.py +62 -12
- package/src/team_agent/spec.py +4 -3
- package/src/team_agent/wake.py +58 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sqlite3
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MANAGED_TABLE_LAYOUTS: dict[str, tuple[str, ...]] = {
|
|
12
|
+
"messages": (
|
|
13
|
+
"message_id", "owner_team_id", "task_id", "sender", "recipient", "reply_to",
|
|
14
|
+
"requires_ack", "status", "content", "artifact_refs", "created_at", "updated_at",
|
|
15
|
+
"delivered_at", "acknowledged_at", "error", "delivery_attempts",
|
|
16
|
+
),
|
|
17
|
+
"results": ("result_id", "owner_team_id", "task_id", "agent_id", "envelope", "status", "created_at"),
|
|
18
|
+
"scheduled_events": (
|
|
19
|
+
"id", "owner_team_id", "due_at", "target", "kind", "payload_json", "status",
|
|
20
|
+
"created_at", "fired_at", "result_json",
|
|
21
|
+
),
|
|
22
|
+
"delivery_tokens": (
|
|
23
|
+
"message_id", "unique_token", "injected_at", "visible_at", "consumed_at",
|
|
24
|
+
"failed_at", "failure_reason",
|
|
25
|
+
),
|
|
26
|
+
"agent_health": (
|
|
27
|
+
"owner_team_id", "agent_id", "status", "last_output_at", "context_usage_pct",
|
|
28
|
+
"current_task_id", "updated_at",
|
|
29
|
+
),
|
|
30
|
+
"peer_allowlist": ("a", "b", "created_at"),
|
|
31
|
+
"result_watchers": (
|
|
32
|
+
"watcher_id", "owner_team_id", "task_id", "agent_id", "message_id", "leader_id",
|
|
33
|
+
"status", "created_at", "completed_at", "result_id", "notified_message_id", "error",
|
|
34
|
+
),
|
|
35
|
+
"leader_notification_log": (
|
|
36
|
+
"result_id", "leader_session_uuid", "notified_message_id", "notified_at",
|
|
37
|
+
"leader_pane_id_at_notify", "envelope_content_hash", "owner_team_id",
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
CREATE_TABLE_SQL: dict[str, str] = {
|
|
43
|
+
"messages": """
|
|
44
|
+
create table {table} (
|
|
45
|
+
message_id text primary key,
|
|
46
|
+
owner_team_id text,
|
|
47
|
+
task_id text,
|
|
48
|
+
sender text,
|
|
49
|
+
recipient text,
|
|
50
|
+
reply_to text,
|
|
51
|
+
requires_ack integer,
|
|
52
|
+
status text,
|
|
53
|
+
content text,
|
|
54
|
+
artifact_refs text,
|
|
55
|
+
created_at text,
|
|
56
|
+
updated_at text,
|
|
57
|
+
delivered_at text,
|
|
58
|
+
acknowledged_at text,
|
|
59
|
+
error text,
|
|
60
|
+
delivery_attempts integer not null default 0
|
|
61
|
+
)
|
|
62
|
+
""",
|
|
63
|
+
"results": """
|
|
64
|
+
create table {table} (
|
|
65
|
+
result_id text primary key,
|
|
66
|
+
owner_team_id text,
|
|
67
|
+
task_id text not null,
|
|
68
|
+
agent_id text not null,
|
|
69
|
+
envelope text not null,
|
|
70
|
+
status text not null,
|
|
71
|
+
created_at text not null
|
|
72
|
+
)
|
|
73
|
+
""",
|
|
74
|
+
"scheduled_events": """
|
|
75
|
+
create table {table} (
|
|
76
|
+
id integer primary key,
|
|
77
|
+
owner_team_id text,
|
|
78
|
+
due_at text not null,
|
|
79
|
+
target text not null,
|
|
80
|
+
kind text not null,
|
|
81
|
+
payload_json text not null,
|
|
82
|
+
status text not null,
|
|
83
|
+
created_at text not null,
|
|
84
|
+
fired_at text,
|
|
85
|
+
result_json text
|
|
86
|
+
)
|
|
87
|
+
""",
|
|
88
|
+
"delivery_tokens": """
|
|
89
|
+
create table {table} (
|
|
90
|
+
message_id text primary key,
|
|
91
|
+
unique_token text not null,
|
|
92
|
+
injected_at text not null,
|
|
93
|
+
visible_at text,
|
|
94
|
+
consumed_at text,
|
|
95
|
+
failed_at text,
|
|
96
|
+
failure_reason text
|
|
97
|
+
)
|
|
98
|
+
""",
|
|
99
|
+
"agent_health": """
|
|
100
|
+
create table {table} (
|
|
101
|
+
owner_team_id text,
|
|
102
|
+
agent_id text not null,
|
|
103
|
+
status text not null,
|
|
104
|
+
last_output_at text,
|
|
105
|
+
context_usage_pct integer,
|
|
106
|
+
current_task_id text,
|
|
107
|
+
updated_at text not null,
|
|
108
|
+
unique(owner_team_id, agent_id)
|
|
109
|
+
)
|
|
110
|
+
""",
|
|
111
|
+
"peer_allowlist": """
|
|
112
|
+
create table {table} (
|
|
113
|
+
a text not null,
|
|
114
|
+
b text not null,
|
|
115
|
+
created_at text not null,
|
|
116
|
+
primary key (a, b)
|
|
117
|
+
)
|
|
118
|
+
""",
|
|
119
|
+
"result_watchers": """
|
|
120
|
+
create table {table} (
|
|
121
|
+
watcher_id text primary key,
|
|
122
|
+
owner_team_id text,
|
|
123
|
+
task_id text,
|
|
124
|
+
agent_id text,
|
|
125
|
+
message_id text,
|
|
126
|
+
leader_id text not null,
|
|
127
|
+
status text not null,
|
|
128
|
+
created_at text not null,
|
|
129
|
+
completed_at text,
|
|
130
|
+
result_id text,
|
|
131
|
+
notified_message_id text,
|
|
132
|
+
error text
|
|
133
|
+
)
|
|
134
|
+
""",
|
|
135
|
+
"leader_notification_log": """
|
|
136
|
+
create table {table} (
|
|
137
|
+
result_id text not null,
|
|
138
|
+
leader_session_uuid text not null,
|
|
139
|
+
notified_message_id text not null,
|
|
140
|
+
notified_at text not null,
|
|
141
|
+
leader_pane_id_at_notify text,
|
|
142
|
+
envelope_content_hash text,
|
|
143
|
+
owner_team_id text,
|
|
144
|
+
primary key (result_id, leader_session_uuid)
|
|
145
|
+
)
|
|
146
|
+
""",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
SCHEMA_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {
|
|
151
|
+
1: lambda _conn: None,
|
|
152
|
+
2: lambda _conn: None,
|
|
153
|
+
3: lambda _conn: None,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def table_layout(conn: sqlite3.Connection, table: str) -> tuple[str, ...]:
|
|
158
|
+
return tuple(str(row["name"]) for row in conn.execute(f"pragma table_info({table})").fetchall())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def schema_diagnosis(workspace: Path, *, schema_version: int) -> dict[str, Any]:
|
|
162
|
+
db_path = workspace / ".team" / "runtime" / "team.db"
|
|
163
|
+
backup_path = _backup_path(db_path, _read_user_version(db_path))
|
|
164
|
+
if not db_path.exists():
|
|
165
|
+
return {
|
|
166
|
+
"ok": True,
|
|
167
|
+
"status": "missing",
|
|
168
|
+
"db_path": str(db_path),
|
|
169
|
+
"schema_version": schema_version,
|
|
170
|
+
"user_version": 0,
|
|
171
|
+
"layout_diffs": {},
|
|
172
|
+
"recommended_action": "No team.db exists yet; initialize_schema will create it on first use.",
|
|
173
|
+
"would_backup_path": str(backup_path),
|
|
174
|
+
}
|
|
175
|
+
conn = sqlite3.connect(db_path)
|
|
176
|
+
conn.row_factory = sqlite3.Row
|
|
177
|
+
try:
|
|
178
|
+
user_version = _pragma_user_version(conn)
|
|
179
|
+
diffs = _layout_diffs(conn)
|
|
180
|
+
finally:
|
|
181
|
+
conn.close()
|
|
182
|
+
return {
|
|
183
|
+
"ok": not diffs and user_version == schema_version,
|
|
184
|
+
"status": "ok" if not diffs and user_version == schema_version else "schema_repair_available",
|
|
185
|
+
"db_path": str(db_path),
|
|
186
|
+
"schema_version": schema_version,
|
|
187
|
+
"user_version": user_version,
|
|
188
|
+
"layout_diffs": diffs,
|
|
189
|
+
"recommended_action": "run team-agent doctor --fix-schema --json" if diffs else "none",
|
|
190
|
+
"would_backup_path": str(backup_path),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ensure_table_layout(
|
|
195
|
+
conn: sqlite3.Connection,
|
|
196
|
+
*,
|
|
197
|
+
schema_version: int,
|
|
198
|
+
db_path: Path | None = None,
|
|
199
|
+
) -> list[dict[str, Any]]:
|
|
200
|
+
conn.row_factory = sqlite3.Row
|
|
201
|
+
_run_version_migrations(conn, schema_version)
|
|
202
|
+
diffs = _layout_diffs(conn)
|
|
203
|
+
if not diffs:
|
|
204
|
+
return []
|
|
205
|
+
db_path = db_path or _db_path_from_conn(conn)
|
|
206
|
+
if db_path is None:
|
|
207
|
+
raise RuntimeError("cannot rebuild team.db layout without a database path")
|
|
208
|
+
backup_path = _backup_path(db_path, _pragma_user_version(conn))
|
|
209
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
shutil.copy2(db_path, backup_path)
|
|
211
|
+
events = _rebuild_tables(conn, diffs, backup_path)
|
|
212
|
+
_emit_rebuild_events(db_path, events)
|
|
213
|
+
return events
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def fix_schema_layout(workspace: Path, *, schema_version: int) -> dict[str, Any]:
|
|
217
|
+
db_path = workspace / ".team" / "runtime" / "team.db"
|
|
218
|
+
if not db_path.exists():
|
|
219
|
+
return schema_diagnosis(workspace, schema_version=schema_version)
|
|
220
|
+
lock_check = _db_lock_status(db_path)
|
|
221
|
+
if lock_check is not None:
|
|
222
|
+
from team_agent.events import EventLog
|
|
223
|
+
EventLog(workspace).write("schema.layout_rebuild_blocked", reason=lock_check, db_path=str(db_path))
|
|
224
|
+
return {"ok": False, "status": "blocked", "reason": lock_check, "event": "schema.layout_rebuild_blocked", "db_path": str(db_path)}
|
|
225
|
+
conn = sqlite3.connect(db_path, timeout=30.0, isolation_level=None)
|
|
226
|
+
conn.row_factory = sqlite3.Row
|
|
227
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
228
|
+
try:
|
|
229
|
+
events = ensure_table_layout(conn, schema_version=schema_version, db_path=db_path)
|
|
230
|
+
conn.execute(f"pragma user_version = {schema_version}")
|
|
231
|
+
finally:
|
|
232
|
+
conn.close()
|
|
233
|
+
diagnosis = schema_diagnosis(workspace, schema_version=schema_version)
|
|
234
|
+
diagnosis.update({"fixed": True, "rebuilds": events})
|
|
235
|
+
return diagnosis
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _run_version_migrations(conn: sqlite3.Connection, schema_version: int) -> None:
|
|
239
|
+
current = _pragma_user_version(conn)
|
|
240
|
+
for version in range(current, schema_version + 1):
|
|
241
|
+
SCHEMA_MIGRATIONS.get(version, lambda _conn: None)(conn)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _layout_diffs(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
|
245
|
+
diffs: dict[str, dict[str, Any]] = {}
|
|
246
|
+
for table, expected in MANAGED_TABLE_LAYOUTS.items():
|
|
247
|
+
actual = table_layout(conn, table)
|
|
248
|
+
if not actual:
|
|
249
|
+
continue
|
|
250
|
+
if actual != expected:
|
|
251
|
+
diffs[table] = {
|
|
252
|
+
"expected": list(expected),
|
|
253
|
+
"actual": list(actual),
|
|
254
|
+
}
|
|
255
|
+
return diffs
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _rebuild_tables(
|
|
259
|
+
conn: sqlite3.Connection,
|
|
260
|
+
diffs: dict[str, dict[str, Any]],
|
|
261
|
+
backup_path: Path,
|
|
262
|
+
) -> list[dict[str, Any]]:
|
|
263
|
+
events: list[dict[str, Any]] = []
|
|
264
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
265
|
+
try:
|
|
266
|
+
for table, diff in diffs.items():
|
|
267
|
+
expected = MANAGED_TABLE_LAYOUTS[table]
|
|
268
|
+
actual = tuple(diff["actual"])
|
|
269
|
+
before = _table_count(conn, table)
|
|
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}")
|
|
282
|
+
after = _table_count(conn, table)
|
|
283
|
+
if before != after:
|
|
284
|
+
raise RuntimeError(f"schema rebuild row count changed for {table}: {before} != {after}")
|
|
285
|
+
events.append({
|
|
286
|
+
"table": table,
|
|
287
|
+
"from_layout_columns": list(actual),
|
|
288
|
+
"to_layout_columns": list(expected),
|
|
289
|
+
"backup_path": str(backup_path),
|
|
290
|
+
"row_count_before": before,
|
|
291
|
+
"row_count_after": after,
|
|
292
|
+
})
|
|
293
|
+
conn.execute("COMMIT")
|
|
294
|
+
except Exception:
|
|
295
|
+
conn.execute("ROLLBACK")
|
|
296
|
+
raise
|
|
297
|
+
return events
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _emit_rebuild_events(db_path: Path, events: list[dict[str, Any]]) -> None:
|
|
301
|
+
workspace = _workspace_from_db_path(db_path)
|
|
302
|
+
if workspace is None:
|
|
303
|
+
return
|
|
304
|
+
from team_agent.events import EventLog
|
|
305
|
+
log = EventLog(workspace)
|
|
306
|
+
for event in events:
|
|
307
|
+
log.write("schema.layout_rebuild", **event)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _db_lock_status(db_path: Path) -> str | None:
|
|
311
|
+
conn = sqlite3.connect(db_path, timeout=0.0, isolation_level=None)
|
|
312
|
+
try:
|
|
313
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
314
|
+
conn.execute("ROLLBACK")
|
|
315
|
+
return None
|
|
316
|
+
except sqlite3.OperationalError as exc:
|
|
317
|
+
message = str(exc).lower()
|
|
318
|
+
if "locked" in message or "busy" in message:
|
|
319
|
+
return "active_lock"
|
|
320
|
+
raise
|
|
321
|
+
finally:
|
|
322
|
+
conn.close()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _table_count(conn: sqlite3.Connection, table: str) -> int:
|
|
326
|
+
row = conn.execute(f"select count(*) as n from {table}").fetchone()
|
|
327
|
+
return int(row["n"])
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _pragma_user_version(conn: sqlite3.Connection) -> int:
|
|
331
|
+
row = conn.execute("pragma user_version").fetchone()
|
|
332
|
+
return int(row["user_version"])
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _read_user_version(db_path: Path) -> int:
|
|
336
|
+
if not db_path.exists():
|
|
337
|
+
return 0
|
|
338
|
+
conn = sqlite3.connect(db_path)
|
|
339
|
+
conn.row_factory = sqlite3.Row
|
|
340
|
+
try:
|
|
341
|
+
return _pragma_user_version(conn)
|
|
342
|
+
finally:
|
|
343
|
+
conn.close()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _db_path_from_conn(conn: sqlite3.Connection) -> Path | None:
|
|
347
|
+
row = conn.execute("pragma database_list").fetchone()
|
|
348
|
+
if not row:
|
|
349
|
+
return None
|
|
350
|
+
filename = str(row["file"])
|
|
351
|
+
return Path(filename) if filename else None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _workspace_from_db_path(db_path: Path) -> Path | None:
|
|
355
|
+
parts = db_path.parts
|
|
356
|
+
if len(parts) >= 3 and parts[-3:] == (".team", "runtime", "team.db"):
|
|
357
|
+
return db_path.parent.parent.parent
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _backup_path(db_path: Path, user_version: int) -> Path:
|
|
362
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
363
|
+
return db_path.with_name(f"team.db.pre-migration-{stamp}-from-v{user_version}.bak")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _maybe_fault_after_insert() -> None:
|
|
367
|
+
value = (
|
|
368
|
+
os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_FAULT")
|
|
369
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_FAULT_AFTER_INSERT")
|
|
370
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_CRASH_AFTER_INSERT")
|
|
371
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_MIGRATION_CRASH_POINT")
|
|
372
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_REBUILD_CRASH_POINT")
|
|
373
|
+
or os.environ.get("TEAM_AGENT_SCHEMA_REBUILD_FAULT")
|
|
374
|
+
or os.environ.get("GAP46_SCHEMA_MIGRATION_FAULT")
|
|
375
|
+
or ""
|
|
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:
|
|
386
|
+
os._exit(97)
|
|
@@ -15,6 +15,40 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def _tmux_pane_width(target: str) -> dict[str, Any]:
|
|
19
|
+
"""Query the tmux pane width (display columns) for ``target``.
|
|
20
|
+
|
|
21
|
+
Live wiring seam for the trust-prompt truncation matcher: returns
|
|
22
|
+
``{"ok": True, "pane_width": <int>}`` on success or
|
|
23
|
+
``{"ok": False, "error": "..."}`` on any failure / timeout / unparseable
|
|
24
|
+
output. Fail-safe by design: NEVER returns a default width. Callers must
|
|
25
|
+
treat failure as "no boundary signal" and let the workspace matcher fall
|
|
26
|
+
back to exact equality, so a hard-truncated prompt is never auto-answered
|
|
27
|
+
on guesswork.
|
|
28
|
+
"""
|
|
29
|
+
from team_agent.messaging.deps import run_cmd
|
|
30
|
+
try:
|
|
31
|
+
proc = run_cmd(
|
|
32
|
+
["tmux", "display-message", "-p", "-t", str(target), "-F", "#{pane_width}"],
|
|
33
|
+
timeout=2,
|
|
34
|
+
)
|
|
35
|
+
except Exception as exc: # pragma: no cover - defensive; tmux not present, timeout, etc.
|
|
36
|
+
return {"ok": False, "error": f"tmux_query_failed:{exc.__class__.__name__}"}
|
|
37
|
+
if getattr(proc, "returncode", 1) != 0:
|
|
38
|
+
err = (getattr(proc, "stderr", "") or "").strip().splitlines()
|
|
39
|
+
return {"ok": False, "error": err[0] if err else "tmux_query_nonzero"}
|
|
40
|
+
text = (getattr(proc, "stdout", "") or "").strip()
|
|
41
|
+
if not text:
|
|
42
|
+
return {"ok": False, "error": "empty_output"}
|
|
43
|
+
try:
|
|
44
|
+
width = int(text.splitlines()[0].strip())
|
|
45
|
+
except (ValueError, IndexError):
|
|
46
|
+
return {"ok": False, "error": "unparseable_output"}
|
|
47
|
+
if width <= 0:
|
|
48
|
+
return {"ok": False, "error": "non_positive_width"}
|
|
49
|
+
return {"ok": True, "pane_width": width}
|
|
50
|
+
|
|
51
|
+
|
|
18
52
|
# Spark MEDIUM sweep #3 (2026-05-26): retry_needed bounded backoff. Each entry is
|
|
19
53
|
# the delay (seconds) BEFORE the attempt with that number runs; attempt 1 was the
|
|
20
54
|
# original delivery, attempt 2 fires 5s after retry_needed, attempt 3 fires 15s
|
|
@@ -85,12 +119,21 @@ def _deliver_pending_message(
|
|
|
85
119
|
# Bypassed entirely when opt-out (default) — the existing failed envelope
|
|
86
120
|
# is preserved.
|
|
87
121
|
from team_agent.messaging.leader_panes import attempt_trust_auto_answer
|
|
122
|
+
pane_target = injection.get("pane_id") or target
|
|
123
|
+
# Live wiring: query the tmux pane width now and hand it to the trust
|
|
124
|
+
# matcher via state["pane_width"]. On failure we leave pane_width
|
|
125
|
+
# absent so the matcher falls back to exact equality (fail-safe — a
|
|
126
|
+
# right-edge truncated prefix is never auto-answered on guesswork).
|
|
127
|
+
width_query = _tmux_pane_width(pane_target)
|
|
128
|
+
trust_state = dict(state) if isinstance(state, dict) else {}
|
|
129
|
+
if width_query.get("ok"):
|
|
130
|
+
trust_state["pane_width"] = width_query["pane_width"]
|
|
88
131
|
answer = attempt_trust_auto_answer(
|
|
89
132
|
workspace,
|
|
90
|
-
|
|
133
|
+
pane_target,
|
|
91
134
|
injection.get("pane_capture_tail") or "",
|
|
92
135
|
EventLog(workspace),
|
|
93
|
-
state=
|
|
136
|
+
state=trust_state,
|
|
94
137
|
)
|
|
95
138
|
if answer.get("answered"):
|
|
96
139
|
# Spark MEDIUM #4 (2026-05-26): replace the fixed 0.3s sleep with a
|
|
@@ -392,6 +392,9 @@ def _broadcast_ambiguous_candidates(
|
|
|
392
392
|
team_id=team_id,
|
|
393
393
|
uuid_prefix=_uuid_prefix(owner_identity),
|
|
394
394
|
debounce_bucket=bucket,
|
|
395
|
+
# C16/C22: two or more live candidates remain; each must explicitly claim
|
|
396
|
+
# with --confirm, so the broadcast carries the closed-enum lease reason.
|
|
397
|
+
reason="force_confirm_required",
|
|
395
398
|
)
|
|
396
399
|
for candidate in candidates:
|
|
397
400
|
pane_id = str(candidate.get("pane_id") or "")
|
|
@@ -560,7 +563,8 @@ def attempt_trust_auto_answer(
|
|
|
560
563
|
reason="pane_id_missing",
|
|
561
564
|
)
|
|
562
565
|
return {"ok": False, "answered": False, "reason": "pane_id_missing"}
|
|
563
|
-
|
|
566
|
+
pane_width = state.get("pane_width") if isinstance(state, dict) else None
|
|
567
|
+
if not _capture_tail_references_workspace(pane_capture_tail, workspace, pane_width):
|
|
564
568
|
event_log.write(
|
|
565
569
|
"leader_panes.trust_auto_answer_refused",
|
|
566
570
|
pane_id=pane_id,
|
|
@@ -568,9 +572,15 @@ def attempt_trust_auto_answer(
|
|
|
568
572
|
reason="workspace_dir_mismatch",
|
|
569
573
|
)
|
|
570
574
|
return {"ok": False, "answered": False, "reason": "workspace_dir_mismatch"}
|
|
575
|
+
# Round-5 (post Round-1..4 withdrawal): Codex's trust prompt already
|
|
576
|
+
# highlights `1. Yes, continue` as the default choice; a plain Enter
|
|
577
|
+
# accepts it. Sending the digit `1` first creates a stray `1` keystroke
|
|
578
|
+
# buffered as input once Codex hooks up its keyboard handler, which
|
|
579
|
+
# later becomes a real user turn that competes with the brief paste.
|
|
580
|
+
# Drop the digit; submit Enter only.
|
|
571
581
|
answer = _tmux_inject_text(
|
|
572
582
|
str(pane_id),
|
|
573
|
-
"
|
|
583
|
+
"",
|
|
574
584
|
"Enter",
|
|
575
585
|
f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
|
|
576
586
|
attempts=1,
|
|
@@ -653,44 +663,128 @@ def _reset_spec_opt_in_deprecation_state() -> None:
|
|
|
653
663
|
_SPEC_OPT_IN_DEPRECATION_WARNED = False
|
|
654
664
|
|
|
655
665
|
|
|
656
|
-
def _capture_tail_references_workspace(tail: str, workspace: Path) -> bool:
|
|
657
|
-
"""
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
666
|
+
def _capture_tail_references_workspace(tail: str, workspace: Path, pane_width: int | None = None) -> bool:
|
|
667
|
+
"""Decide whether the Codex trust-prompt tail names the worker's own
|
|
668
|
+
workspace cwd. The runtime cwd is the source of truth; the prompt path is a
|
|
669
|
+
consistency guard. Match cases (one converged helper per token):
|
|
670
|
+
|
|
671
|
+
- exact canonical equality (the unchanged baseline);
|
|
672
|
+
- mid-ellipsis ``head…tail`` / ``head...tail`` where head is a prefix of
|
|
673
|
+
the runtime cwd and tail is its suffix;
|
|
674
|
+
- hard right-edge truncation: the canonical runtime cwd starts with the
|
|
675
|
+
canonical captured path AND the captured token reaches the capture
|
|
676
|
+
line's right boundary (pane_width).
|
|
677
|
+
|
|
678
|
+
Without a pane_width signal, prefix matching is forbidden — the captured
|
|
679
|
+
path is treated as a complete token and must exactly equal the runtime cwd
|
|
680
|
+
(this is what stops ``/repo`` from sliding into ``/repo-backup``).
|
|
681
|
+
"""
|
|
663
682
|
if not tail:
|
|
664
683
|
return False
|
|
665
684
|
workspace_canonical = _canonicalize_path(workspace)
|
|
666
685
|
if not workspace_canonical:
|
|
667
686
|
return False
|
|
668
|
-
for
|
|
669
|
-
if
|
|
687
|
+
for token, source_line in _candidate_path_lines_from_prompt(tail):
|
|
688
|
+
if _workspace_matches_token(workspace_canonical, token, source_line, pane_width):
|
|
670
689
|
return True
|
|
671
690
|
return False
|
|
672
691
|
|
|
673
692
|
|
|
674
|
-
_PATH_LINE_RE = re.compile(r"(/[\w
|
|
693
|
+
_PATH_LINE_RE = re.compile(r"(/[\w\-./~+@…]+)")
|
|
694
|
+
_ELLIPSIS_TOKENS = ("…", "...")
|
|
675
695
|
|
|
676
696
|
|
|
677
|
-
def
|
|
678
|
-
"""Pull
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
697
|
+
def _candidate_path_lines_from_prompt(tail: str) -> list[tuple[str, str]]:
|
|
698
|
+
"""Pull (path_token, source_line) pairs out of the prompt's tail. The
|
|
699
|
+
source line is the line AFTER stripping Codex box-drawing glyphs, so the
|
|
700
|
+
matcher can locate the token's end column relative to the visible width."""
|
|
701
|
+
pairs: list[tuple[str, str]] = []
|
|
702
|
+
seen: set[tuple[str, str]] = set()
|
|
682
703
|
for raw_line in tail.splitlines():
|
|
683
704
|
line = raw_line.strip()
|
|
684
|
-
# Codex draws box-glyph prefixes (▌ ▎ │) that need to be stripped.
|
|
685
705
|
for glyph in ("▌", "▎", "│"):
|
|
686
706
|
line = line.lstrip(glyph).strip()
|
|
687
707
|
if not line:
|
|
688
708
|
continue
|
|
689
709
|
for match in _PATH_LINE_RE.finditer(line):
|
|
690
710
|
token = match.group(1).rstrip("/")
|
|
691
|
-
if
|
|
692
|
-
|
|
693
|
-
|
|
711
|
+
if not token:
|
|
712
|
+
continue
|
|
713
|
+
key = (token, line)
|
|
714
|
+
if key in seen:
|
|
715
|
+
continue
|
|
716
|
+
seen.add(key)
|
|
717
|
+
pairs.append(key)
|
|
718
|
+
return pairs
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _candidate_paths_from_prompt(tail: str) -> list[str]:
|
|
722
|
+
"""Backwards-compatible token-only view (kept for any external callers)."""
|
|
723
|
+
out: list[str] = []
|
|
724
|
+
for token, _line in _candidate_path_lines_from_prompt(tail):
|
|
725
|
+
if token not in out:
|
|
726
|
+
out.append(token)
|
|
727
|
+
return out
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _workspace_matches_token(
|
|
731
|
+
workspace_canonical: str,
|
|
732
|
+
token: str,
|
|
733
|
+
source_line: str,
|
|
734
|
+
pane_width: int | None,
|
|
735
|
+
) -> bool:
|
|
736
|
+
"""The converged trust-prompt match logic.
|
|
737
|
+
|
|
738
|
+
Order matters:
|
|
739
|
+
1. exact canonical equality;
|
|
740
|
+
2. mid-ellipsis head/tail match;
|
|
741
|
+
3. right-edge hard truncation (prefix + boundary-reached).
|
|
742
|
+
A captured token that does NOT reach the line's right boundary is treated
|
|
743
|
+
as a complete short path and must equal the runtime cwd exactly.
|
|
744
|
+
"""
|
|
745
|
+
# 1. Exact canonical equality.
|
|
746
|
+
captured_canonical = _canonicalize_path(Path(token))
|
|
747
|
+
if not captured_canonical:
|
|
748
|
+
return False
|
|
749
|
+
if captured_canonical == workspace_canonical:
|
|
750
|
+
return True
|
|
751
|
+
# 2. Mid-ellipsis: split on … or ..., require head ⊑ workspace and workspace ⊐ tail.
|
|
752
|
+
for ellipsis in _ELLIPSIS_TOKENS:
|
|
753
|
+
if ellipsis in token:
|
|
754
|
+
head, _, tail_part = token.partition(ellipsis)
|
|
755
|
+
head_canonical = _canonicalize_path(Path(head)) if head.startswith("/") else head
|
|
756
|
+
if not head_canonical or not tail_part:
|
|
757
|
+
return False
|
|
758
|
+
return (
|
|
759
|
+
workspace_canonical.startswith(head_canonical)
|
|
760
|
+
and workspace_canonical.endswith(tail_part)
|
|
761
|
+
)
|
|
762
|
+
# 3. Right-edge hard truncation: prefix + boundary.
|
|
763
|
+
if not _token_reaches_right_edge(token, source_line, pane_width):
|
|
764
|
+
# No boundary signal → captured must be a complete token; exact already
|
|
765
|
+
# failed → mismatch (this rejects /repo vs /repo-backup both ways).
|
|
766
|
+
return False
|
|
767
|
+
return (
|
|
768
|
+
workspace_canonical == captured_canonical
|
|
769
|
+
or workspace_canonical.startswith(captured_canonical + "/")
|
|
770
|
+
or workspace_canonical.startswith(captured_canonical)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _token_reaches_right_edge(token: str, source_line: str, pane_width: int | None) -> bool:
|
|
775
|
+
"""The token reaches the capture line's right boundary iff the line is wide
|
|
776
|
+
enough to be at pane capacity AND the token sits flush against the line's
|
|
777
|
+
end. Without a pane_width we cannot prove truncation — return False so the
|
|
778
|
+
caller falls back to exact-equality (this is the C/repo vs C/repo-backup
|
|
779
|
+
safeguard)."""
|
|
780
|
+
if not pane_width or pane_width <= 0:
|
|
781
|
+
return False
|
|
782
|
+
rstripped = source_line.rstrip()
|
|
783
|
+
if not rstripped.endswith(token):
|
|
784
|
+
return False
|
|
785
|
+
# Allow a one-column tolerance for trailing whitespace stripped from the
|
|
786
|
+
# raw capture; the line must be at pane capacity to count as hard-cut.
|
|
787
|
+
return len(rstripped) >= max(1, pane_width - 1)
|
|
694
788
|
|
|
695
789
|
|
|
696
790
|
def _canonicalize_path(p: Path | str) -> str:
|
|
@@ -77,6 +77,7 @@ def _send_message_unlocked(
|
|
|
77
77
|
return gate
|
|
78
78
|
owner_team_id = team_state_key(state)
|
|
79
79
|
leader_id = _leader_id(state, spec)
|
|
80
|
+
_flag_rebind_required_when_unbound_plain_shell_leader(workspace, state, spec, sender, leader_id, event_log)
|
|
80
81
|
|
|
81
82
|
if isinstance(target, list):
|
|
82
83
|
if watch_result:
|
|
@@ -134,6 +135,38 @@ def _send_message_unlocked(
|
|
|
134
135
|
)
|
|
135
136
|
|
|
136
137
|
|
|
138
|
+
def _flag_rebind_required_when_unbound_plain_shell_leader(
|
|
139
|
+
workspace: Path,
|
|
140
|
+
state: dict[str, Any],
|
|
141
|
+
spec: dict[str, Any],
|
|
142
|
+
sender: str,
|
|
143
|
+
leader_id: str,
|
|
144
|
+
event_log: EventLog,
|
|
145
|
+
) -> None:
|
|
146
|
+
# Gap 39 C5: a leader send from a plain shell (no $TMUX_PANE) must never self-bind
|
|
147
|
+
# the caller as the leader receiver. When the lease is fully unbound, flag a
|
|
148
|
+
# rebind_required so the message stays queued and the operator knows to reconnect
|
|
149
|
+
# from a real tmux leader pane. Only fires for an unbound lease + no caller pane.
|
|
150
|
+
import os
|
|
151
|
+
from team_agent.messaging.deps import _leader_receiver_is_direct
|
|
152
|
+
if not _is_leader_sender(sender, leader_id):
|
|
153
|
+
return
|
|
154
|
+
if isinstance(state.get("team_owner"), dict) and state["team_owner"].get("pane_id"):
|
|
155
|
+
return
|
|
156
|
+
if _leader_receiver_is_direct(state.get("leader_receiver")):
|
|
157
|
+
return
|
|
158
|
+
if os.environ.get("TEAM_AGENT_LEADER_PANE_ID") or os.environ.get("TMUX_PANE"):
|
|
159
|
+
return
|
|
160
|
+
event_log.write(
|
|
161
|
+
"leader_receiver.rebind_required",
|
|
162
|
+
reason="not_in_tmux_pane",
|
|
163
|
+
old_pane_id=(state.get("leader_receiver") or {}).get("pane_id"),
|
|
164
|
+
new_pane_id=None,
|
|
165
|
+
team_id=team_state_key(state),
|
|
166
|
+
recovery_action="run team-agent claim-leader --confirm from the leader's tmux pane",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
137
170
|
def _send_single_message_unlocked(
|
|
138
171
|
workspace: Path,
|
|
139
172
|
state: dict[str, Any],
|