@team-agent/installer 0.2.2 → 0.2.4
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/schemas/team.schema.json +6 -0
- package/src/team_agent/abnormal_track.py +253 -0
- package/src/team_agent/approvals/runtime_prompts.py +1 -1
- package/src/team_agent/cli/commands.py +104 -3
- package/src/team_agent/cli/parser.py +10 -1
- package/src/team_agent/compiler.py +1 -1
- package/src/team_agent/coordinator/lifecycle.py +23 -2
- package/src/team_agent/diagnose/orphan_cleanup.py +199 -28
- 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 +14 -4
- package/src/team_agent/leader/__init__.py +444 -61
- package/src/team_agent/lifecycle/operations.py +1 -0
- package/src/team_agent/lifecycle/start.py +1 -1
- package/src/team_agent/message_store/core.py +38 -11
- package/src/team_agent/message_store/leader_notification_log.py +47 -26
- package/src/team_agent/message_store/schema.py +8 -2
- package/src/team_agent/messaging/delivery.py +336 -1
- package/src/team_agent/messaging/leader.py +13 -4
- package/src/team_agent/messaging/leader_api_errors.py +216 -0
- package/src/team_agent/messaging/leader_panes.py +294 -0
- package/src/team_agent/messaging/scheduler.py +12 -0
- package/src/team_agent/messaging/send.py +54 -26
- package/src/team_agent/messaging/tmux_io.py +202 -33
- package/src/team_agent/messaging/tmux_prompt.py +87 -0
- package/src/team_agent/messaging/trust_auto_answer.py +52 -0
- 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 +215 -12
- package/src/team_agent/runtime.py +65 -15
- package/src/team_agent/sessions/capture.py +65 -15
- package/src/team_agent/spec.py +63 -3
- package/src/team_agent/status/queries.py +32 -1
- package/src/team_agent/wake.py +58 -0
- package/src/team_agent/watch/__init__.py +145 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Gap 28 (Slice 2 Stage 2): observe-only detection of leader-pane API errors.
|
|
2
|
+
|
|
3
|
+
The coordinator tick captures the leader pane scrollback once per cycle, scans it for
|
|
4
|
+
known upstream-API error patterns (Claude/Codex CLI errors that occur mid-turn), and
|
|
5
|
+
emits a structured `leader.api_error` audit event. The intent is observability — auto-
|
|
6
|
+
retry belongs to the upstream CLI; this module never touches the pane.
|
|
7
|
+
|
|
8
|
+
Event schema (logged via EventLog.write):
|
|
9
|
+
|
|
10
|
+
event: 'leader.api_error'
|
|
11
|
+
ts: ISO-8601 UTC (added by EventLog)
|
|
12
|
+
leader_session_uuid: str | None
|
|
13
|
+
error_class: 'Overloaded' | 'RateLimit' | 'Timeout' |
|
|
14
|
+
'NetworkError' | 'Unknown'
|
|
15
|
+
provider: 'claude' | 'codex' | 'claude_code' | str | None
|
|
16
|
+
partial_response_streamed: bool (heuristic: assistant text before the error)
|
|
17
|
+
worker_dispatch_just_before: list[str] (leader→worker msg_ids in the prior 60s)
|
|
18
|
+
retry_count: int (always 0 — the framework does not retry today)
|
|
19
|
+
matched_pattern_snippet: str (the captured error line, ≤160 chars)
|
|
20
|
+
|
|
21
|
+
Detection dedupes within the coordinator state via a (error_class, snippet-tail)
|
|
22
|
+
fingerprint stored under `state['coordinator']['last_api_error_fingerprint']`. A
|
|
23
|
+
clean tick (no error pattern present) clears the fingerprint so the next genuine
|
|
24
|
+
error re-emits. This keeps event volume bounded while still catching distinct
|
|
25
|
+
errors as they occur.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from datetime import datetime, timedelta, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
|
|
34
|
+
from team_agent.events import EventLog
|
|
35
|
+
from team_agent.message_store import MessageStore
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Spark MEDIUM sweeps (2026-05-26):
|
|
39
|
+
# (#3) Require an API/provider context marker near the error keyword. Bare '503' /
|
|
40
|
+
# 'fetch failed' / 'timed out' in user text used to false-fire.
|
|
41
|
+
# (#7) Match across short sliding windows of 1-3 adjacent lines so wrapped tmux
|
|
42
|
+
# output ("claude:\n request timed out") still resolves to a single
|
|
43
|
+
# detection. Window joined with a single space; capped at _WINDOW_MAX_CHARS
|
|
44
|
+
# so the scan stays bounded.
|
|
45
|
+
_API_CONTEXT = (
|
|
46
|
+
r"(?:API\s+Error|HTTP\s*Error|HTTPError|request\s+failed|"
|
|
47
|
+
r"codex|claude|Anthropic|OpenAI|TypeError)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Patterns operate against a sliding window of up to 3 joined lines. The window
|
|
51
|
+
# never contains '\n' (lines are joined with a single space), so `[^\n]` and `.`
|
|
52
|
+
# behave the same; we use `[^\n]` for self-documentation.
|
|
53
|
+
_ERROR_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
54
|
+
# Overloaded — keyword itself already includes the "API Error:" prefix.
|
|
55
|
+
(re.compile(r"API\s+Error:\s*Overloaded", re.IGNORECASE), "Overloaded"),
|
|
56
|
+
# RateLimit — 429 with "Too Many Requests" is sufficiently specific; require it
|
|
57
|
+
# appear AFTER an API context marker OR before "Too Many Requests" tightly.
|
|
58
|
+
(re.compile(rf"(?:{_API_CONTEXT}[^\n]*\b429\b|\b429\s+Too\s+Many\s+Requests)", re.IGNORECASE), "RateLimit"),
|
|
59
|
+
# 5xx — must share a window with an API-context marker on either side.
|
|
60
|
+
(re.compile(rf"{_API_CONTEXT}[^\n]{{0,200}}\b5(?:00|02|03|04)\b", re.IGNORECASE), "NetworkError"),
|
|
61
|
+
(re.compile(rf"\b5(?:00|02|03|04)\b[^\n]{{0,200}}{_API_CONTEXT}", re.IGNORECASE), "NetworkError"),
|
|
62
|
+
# fetch failed — needs an API-context marker in the same window. The TypeError
|
|
63
|
+
# marker on its own counts (Node fetch frames the error this way).
|
|
64
|
+
(re.compile(rf"{_API_CONTEXT}[^\n]{{0,200}}fetch\s+failed", re.IGNORECASE), "NetworkError"),
|
|
65
|
+
(re.compile(rf"fetch\s+failed[^\n]{{0,200}}{_API_CONTEXT}", re.IGNORECASE), "NetworkError"),
|
|
66
|
+
# Timeout — likewise requires an API-context marker in the window, except for
|
|
67
|
+
# the unambiguous syscall token ETIMEDOUT.
|
|
68
|
+
(re.compile(rf"{_API_CONTEXT}[^\n]{{0,200}}(?:request|connection)\s+(?:timed\s+out|timeout)", re.IGNORECASE), "Timeout"),
|
|
69
|
+
(re.compile(rf"(?:request|connection)\s+(?:timed\s+out|timeout)[^\n]{{0,200}}{_API_CONTEXT}", re.IGNORECASE), "Timeout"),
|
|
70
|
+
(re.compile(r"\bETIMEDOUT\b", re.IGNORECASE), "Timeout"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
_RECENT_LINE_WINDOW = 100 # scan only the most recent N lines
|
|
74
|
+
_SLIDING_WINDOW_LINES = 3 # join up to 3 adjacent lines per scan window
|
|
75
|
+
_WINDOW_MAX_CHARS = 400 # discard windows beyond this length to bound work
|
|
76
|
+
_DISPATCH_WINDOW_SECONDS = 60 # leader→worker sends counted within this lookback
|
|
77
|
+
_PARTIAL_RESPONSE_HEAD_BYTES = 4000
|
|
78
|
+
|
|
79
|
+
_PARTIAL_RESPONSE_HINT = re.compile(
|
|
80
|
+
r"(?:^|\n)\s*(?:Assistant|⏺|●|> |I'll |I will |I'm |I am |Let me )",
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def detect_leader_api_errors(
|
|
86
|
+
workspace: Path,
|
|
87
|
+
state: dict[str, Any],
|
|
88
|
+
store: MessageStore,
|
|
89
|
+
event_log: EventLog,
|
|
90
|
+
*,
|
|
91
|
+
capture_fn: Callable[[str], dict[str, Any]] | None = None,
|
|
92
|
+
now_fn: Callable[[], datetime] | None = None,
|
|
93
|
+
) -> list[dict[str, Any]]:
|
|
94
|
+
"""Coordinator-tick entry point. Returns a list of emitted events (0 or 1)."""
|
|
95
|
+
receiver = state.get("leader_receiver") or {}
|
|
96
|
+
pane = receiver.get("pane_id") if receiver.get("mode") == "direct_tmux" else None
|
|
97
|
+
if not pane:
|
|
98
|
+
return []
|
|
99
|
+
capture_fn = capture_fn or _default_capture_fn()
|
|
100
|
+
capture = capture_fn(str(pane))
|
|
101
|
+
if not capture.get("ok"):
|
|
102
|
+
return []
|
|
103
|
+
scrollback = str(capture.get("capture") or "")
|
|
104
|
+
coordinator_state = state.setdefault("coordinator", {})
|
|
105
|
+
found = _match_first_error(scrollback)
|
|
106
|
+
if not found:
|
|
107
|
+
if coordinator_state.get("last_api_error_fingerprint"):
|
|
108
|
+
coordinator_state["last_api_error_fingerprint"] = None
|
|
109
|
+
return []
|
|
110
|
+
error_class, snippet = found
|
|
111
|
+
fingerprint = f"{error_class}::{snippet[-120:]}"
|
|
112
|
+
if coordinator_state.get("last_api_error_fingerprint") == fingerprint:
|
|
113
|
+
return []
|
|
114
|
+
coordinator_state["last_api_error_fingerprint"] = fingerprint
|
|
115
|
+
now = (now_fn() if now_fn else datetime.now(timezone.utc))
|
|
116
|
+
cutoff_iso = (now - timedelta(seconds=_DISPATCH_WINDOW_SECONDS)).isoformat()
|
|
117
|
+
leader_uuid = (
|
|
118
|
+
str((state.get("team_owner") or {}).get("leader_session_uuid") or "")
|
|
119
|
+
or str(receiver.get("leader_session_uuid") or "")
|
|
120
|
+
or None
|
|
121
|
+
)
|
|
122
|
+
provider = str(receiver.get("provider") or "") or None
|
|
123
|
+
event = event_log.write(
|
|
124
|
+
"leader.api_error",
|
|
125
|
+
leader_session_uuid=leader_uuid,
|
|
126
|
+
error_class=error_class,
|
|
127
|
+
provider=provider,
|
|
128
|
+
partial_response_streamed=_scrollback_has_partial_response(scrollback, snippet),
|
|
129
|
+
worker_dispatch_just_before=_recent_leader_dispatches(store, cutoff_iso),
|
|
130
|
+
retry_count=0,
|
|
131
|
+
matched_pattern_snippet=snippet[:160],
|
|
132
|
+
)
|
|
133
|
+
return [event]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _default_capture_fn() -> Callable[[str], dict[str, Any]]:
|
|
137
|
+
from team_agent.messaging.deps import _capture_tmux_pane_text
|
|
138
|
+
return _capture_tmux_pane_text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _match_first_error(scrollback: str) -> tuple[str, str] | None:
|
|
142
|
+
"""Spark MEDIUM #7: sliding window of 1..N adjacent lines. Lines inside a
|
|
143
|
+
window are joined with a single space so a wrapped pair such as
|
|
144
|
+
claude:
|
|
145
|
+
request timed out
|
|
146
|
+
is detected as one event without permitting unbounded cross-line matches.
|
|
147
|
+
Latest window wins so the freshest error is reported."""
|
|
148
|
+
if not scrollback:
|
|
149
|
+
return None
|
|
150
|
+
lines = [line.strip() for line in scrollback.splitlines()[-_RECENT_LINE_WINDOW:]]
|
|
151
|
+
if not lines:
|
|
152
|
+
return None
|
|
153
|
+
best: tuple[int, str, str] | None = None
|
|
154
|
+
for start in range(len(lines)):
|
|
155
|
+
for size in range(1, _SLIDING_WINDOW_LINES + 1):
|
|
156
|
+
end = start + size
|
|
157
|
+
if end > len(lines):
|
|
158
|
+
break
|
|
159
|
+
window = " ".join(line for line in lines[start:end] if line)
|
|
160
|
+
if not window:
|
|
161
|
+
continue
|
|
162
|
+
# Spark MEDIUM sweep #3 (2026-05-26): tail-preserve instead of
|
|
163
|
+
# dropping the window wholesale. Errors land at the END of verbose
|
|
164
|
+
# diagnostics (stack traces, retry chatter, etc.). If we discarded
|
|
165
|
+
# any window over the cap we silently lost recall on long wrapped
|
|
166
|
+
# output. Scanning the LAST _WINDOW_MAX_CHARS still bounds regex
|
|
167
|
+
# cost while keeping the freshest context — the bit most likely to
|
|
168
|
+
# contain the actual provider error keyword.
|
|
169
|
+
if len(window) > _WINDOW_MAX_CHARS:
|
|
170
|
+
window = window[-_WINDOW_MAX_CHARS:]
|
|
171
|
+
for pattern, error_class in _ERROR_PATTERNS:
|
|
172
|
+
match = pattern.search(window)
|
|
173
|
+
if not match:
|
|
174
|
+
continue
|
|
175
|
+
snippet = window[:240]
|
|
176
|
+
if best is None or start > best[0]:
|
|
177
|
+
best = (start, error_class, snippet)
|
|
178
|
+
# First match per window is enough; later windows may override.
|
|
179
|
+
break
|
|
180
|
+
if best is None:
|
|
181
|
+
return None
|
|
182
|
+
return best[1], best[2]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _scrollback_has_partial_response(scrollback: str, error_snippet: str) -> bool:
|
|
186
|
+
idx = scrollback.rfind(error_snippet)
|
|
187
|
+
if idx == -1:
|
|
188
|
+
return False
|
|
189
|
+
head = scrollback[max(0, idx - _PARTIAL_RESPONSE_HEAD_BYTES): idx]
|
|
190
|
+
return bool(_PARTIAL_RESPONSE_HINT.search(head))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _recent_leader_dispatches(store: MessageStore, cutoff_iso: str) -> list[str]:
|
|
194
|
+
out: list[str] = []
|
|
195
|
+
try:
|
|
196
|
+
rows = store.messages()
|
|
197
|
+
except Exception:
|
|
198
|
+
return out
|
|
199
|
+
for row in rows:
|
|
200
|
+
sender = str(row.get("sender") or "")
|
|
201
|
+
if sender not in {"leader", "Leader"} and not _looks_like_leader_sender(sender):
|
|
202
|
+
continue
|
|
203
|
+
created = str(row.get("created_at") or "")
|
|
204
|
+
if not created or created < cutoff_iso:
|
|
205
|
+
continue
|
|
206
|
+
msg_id = str(row.get("message_id") or "")
|
|
207
|
+
if msg_id:
|
|
208
|
+
out.append(msg_id)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _looks_like_leader_sender(sender: str) -> bool:
|
|
213
|
+
return sender.startswith("leader") or sender.lower() == "leader"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = ["detect_leader_api_errors"]
|
|
@@ -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 "")
|
|
@@ -503,6 +506,297 @@ def _leader_command_looks_usable(command: str, provider: str) -> bool:
|
|
|
503
506
|
return command_name in {"codex", "node", "nodejs", "claude", "claude.exe"}
|
|
504
507
|
|
|
505
508
|
|
|
509
|
+
def attempt_trust_auto_answer(
|
|
510
|
+
workspace: Path,
|
|
511
|
+
pane_id: str | None,
|
|
512
|
+
pane_capture_tail: str,
|
|
513
|
+
event_log: EventLog,
|
|
514
|
+
*,
|
|
515
|
+
spec: dict[str, Any] | None = None,
|
|
516
|
+
state: dict[str, Any] | None = None,
|
|
517
|
+
) -> dict[str, Any]:
|
|
518
|
+
"""Gap 29 (Slice 2 Stage 2) — opt-in auto-answer of the codex first-run trust prompt.
|
|
519
|
+
|
|
520
|
+
Called by the inject path when developer's structured envelope reports
|
|
521
|
+
detected=='codex_trust_prompt'. Auto-answers ONLY when both:
|
|
522
|
+
(1) runtime is opted in. The PREFERRED opt-in is the per-session env var
|
|
523
|
+
TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE in {1,true,yes,on}. The legacy
|
|
524
|
+
spec.runtime.auto_trust_own_workspace=True path is still honoured for
|
|
525
|
+
backwards compatibility but is DEPRECATED (constitution-reviewer F3:
|
|
526
|
+
a YAML field permanently erases the trust prompt's cognitive moment
|
|
527
|
+
across all sessions, defeating its purpose). The spec path will be
|
|
528
|
+
removed in 0.3.0.
|
|
529
|
+
(2) the trust-prompt pane capture references this workspace's absolute path
|
|
530
|
+
(so a worker can only trust its own dir, never some arbitrary path).
|
|
531
|
+
|
|
532
|
+
On match, sends '1' + Enter to the pane and emits
|
|
533
|
+
leader_panes.trust_auto_answered. Default is opt-out — every refusal returns
|
|
534
|
+
answered=False with a structured reason and the existing failure envelope
|
|
535
|
+
bubbles up unchanged.
|
|
536
|
+
|
|
537
|
+
Return: {"ok": bool, "answered": bool, "reason": str, ...}
|
|
538
|
+
"""
|
|
539
|
+
if spec is None and state is not None:
|
|
540
|
+
spec_path_str = state.get("spec_path")
|
|
541
|
+
if spec_path_str:
|
|
542
|
+
try:
|
|
543
|
+
from team_agent.spec import load_spec as _load_spec
|
|
544
|
+
spec = _load_spec(Path(spec_path_str))
|
|
545
|
+
except Exception:
|
|
546
|
+
spec = None
|
|
547
|
+
if not _auto_trust_opt_in(spec, event_log=event_log):
|
|
548
|
+
# Spark LOW #6: emit a structured event so the not-opted-in branch is
|
|
549
|
+
# as observable as the workspace_dir_mismatch / tmux_send_keys_failed
|
|
550
|
+
# branches. Keeps the decision matrix uniformly auditable.
|
|
551
|
+
event_log.write(
|
|
552
|
+
"leader_panes.trust_auto_answer_skipped",
|
|
553
|
+
pane_id=pane_id,
|
|
554
|
+
workspace=str(workspace),
|
|
555
|
+
reason="not_opted_in",
|
|
556
|
+
)
|
|
557
|
+
return {"ok": False, "answered": False, "reason": "not_opted_in"}
|
|
558
|
+
if not pane_id:
|
|
559
|
+
event_log.write(
|
|
560
|
+
"leader_panes.trust_auto_answer_skipped",
|
|
561
|
+
pane_id=None,
|
|
562
|
+
workspace=str(workspace),
|
|
563
|
+
reason="pane_id_missing",
|
|
564
|
+
)
|
|
565
|
+
return {"ok": False, "answered": False, "reason": "pane_id_missing"}
|
|
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):
|
|
568
|
+
event_log.write(
|
|
569
|
+
"leader_panes.trust_auto_answer_refused",
|
|
570
|
+
pane_id=pane_id,
|
|
571
|
+
workspace=str(workspace),
|
|
572
|
+
reason="workspace_dir_mismatch",
|
|
573
|
+
)
|
|
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.
|
|
581
|
+
answer = _tmux_inject_text(
|
|
582
|
+
str(pane_id),
|
|
583
|
+
"",
|
|
584
|
+
"Enter",
|
|
585
|
+
f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
|
|
586
|
+
attempts=1,
|
|
587
|
+
provider="fake",
|
|
588
|
+
bypass_non_input_gate=True,
|
|
589
|
+
)
|
|
590
|
+
if not answer.get("ok"):
|
|
591
|
+
error = answer.get("error") or "tmux send-keys failed"
|
|
592
|
+
event_log.write(
|
|
593
|
+
"leader_panes.trust_auto_answer_failed",
|
|
594
|
+
pane_id=pane_id,
|
|
595
|
+
workspace=str(workspace),
|
|
596
|
+
error=error,
|
|
597
|
+
)
|
|
598
|
+
return {"ok": False, "answered": False, "reason": "tmux_send_keys_failed", "error": error}
|
|
599
|
+
event_log.write(
|
|
600
|
+
"leader_panes.trust_auto_answered",
|
|
601
|
+
pane_id=pane_id,
|
|
602
|
+
workspace=str(workspace),
|
|
603
|
+
opted_in=True,
|
|
604
|
+
)
|
|
605
|
+
return {"ok": True, "answered": True, "reason": "trust_auto_answered"}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
_SPEC_OPT_IN_DEPRECATION_MESSAGE = (
|
|
609
|
+
"WARNING: spec.runtime.auto_trust_own_workspace is deprecated. "
|
|
610
|
+
"Use env TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE=1 per session instead. "
|
|
611
|
+
"Spec-field will be removed in 0.3.0."
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _auto_trust_opt_in(spec: dict[str, Any] | None, *, event_log: EventLog | None = None) -> bool:
|
|
616
|
+
"""Constitution-reviewer F3 (2026-05-26): env-var per-session opt-in is the
|
|
617
|
+
preferred path. spec.runtime.auto_trust_own_workspace remains honoured for
|
|
618
|
+
backwards compatibility but emits a one-shot stderr deprecation warning AND
|
|
619
|
+
a structured trust_auto_answer_spec_opt_in_deprecated event so a normalized
|
|
620
|
+
YAML field is auditable from a fresh log."""
|
|
621
|
+
spec_opted_in = (
|
|
622
|
+
isinstance(spec, dict)
|
|
623
|
+
and bool((spec.get("runtime") or {}).get("auto_trust_own_workspace"))
|
|
624
|
+
)
|
|
625
|
+
if spec_opted_in:
|
|
626
|
+
_emit_spec_opt_in_deprecation(event_log)
|
|
627
|
+
env = os.environ.get("TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE", "").strip().lower()
|
|
628
|
+
env_opted_in = env in {"1", "true", "yes", "on"}
|
|
629
|
+
return env_opted_in or spec_opted_in
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _emit_spec_opt_in_deprecation(event_log: EventLog | None) -> None:
|
|
633
|
+
"""Emit the deprecation warning once per process. The structured event still
|
|
634
|
+
fires per call so an audit log captures every yaml-driven decision."""
|
|
635
|
+
import sys
|
|
636
|
+
global _SPEC_OPT_IN_DEPRECATION_WARNED
|
|
637
|
+
if not _SPEC_OPT_IN_DEPRECATION_WARNED:
|
|
638
|
+
try:
|
|
639
|
+
print(_SPEC_OPT_IN_DEPRECATION_MESSAGE, file=sys.stderr, flush=True)
|
|
640
|
+
except Exception:
|
|
641
|
+
pass
|
|
642
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = True
|
|
643
|
+
if event_log is not None:
|
|
644
|
+
try:
|
|
645
|
+
event_log.write(
|
|
646
|
+
"trust_auto_answer_spec_opt_in_deprecated",
|
|
647
|
+
preferred_opt_in="env:TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE",
|
|
648
|
+
deprecated_field="spec.runtime.auto_trust_own_workspace",
|
|
649
|
+
removal_target_version="0.3.0",
|
|
650
|
+
)
|
|
651
|
+
except Exception:
|
|
652
|
+
pass
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = False
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _reset_spec_opt_in_deprecation_state() -> None:
|
|
659
|
+
"""Test-only helper: reset the per-process one-shot guard so multiple cases
|
|
660
|
+
in the same interpreter can each observe the warning. Not part of the
|
|
661
|
+
public API."""
|
|
662
|
+
global _SPEC_OPT_IN_DEPRECATION_WARNED
|
|
663
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = False
|
|
664
|
+
|
|
665
|
+
|
|
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
|
+
"""
|
|
682
|
+
if not tail:
|
|
683
|
+
return False
|
|
684
|
+
workspace_canonical = _canonicalize_path(workspace)
|
|
685
|
+
if not workspace_canonical:
|
|
686
|
+
return False
|
|
687
|
+
for token, source_line in _candidate_path_lines_from_prompt(tail):
|
|
688
|
+
if _workspace_matches_token(workspace_canonical, token, source_line, pane_width):
|
|
689
|
+
return True
|
|
690
|
+
return False
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
_PATH_LINE_RE = re.compile(r"(/[\w\-./~+@…]+)")
|
|
694
|
+
_ELLIPSIS_TOKENS = ("…", "...")
|
|
695
|
+
|
|
696
|
+
|
|
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()
|
|
703
|
+
for raw_line in tail.splitlines():
|
|
704
|
+
line = raw_line.strip()
|
|
705
|
+
for glyph in ("▌", "▎", "│"):
|
|
706
|
+
line = line.lstrip(glyph).strip()
|
|
707
|
+
if not line:
|
|
708
|
+
continue
|
|
709
|
+
for match in _PATH_LINE_RE.finditer(line):
|
|
710
|
+
token = match.group(1).rstrip("/")
|
|
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)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _canonicalize_path(p: Path | str) -> str:
|
|
791
|
+
try:
|
|
792
|
+
resolved = Path(p).expanduser().resolve(strict=False)
|
|
793
|
+
except OSError:
|
|
794
|
+
return ""
|
|
795
|
+
text = resolved.as_posix()
|
|
796
|
+
# Strip a trailing slash so boundary-safe equality holds.
|
|
797
|
+
return text.rstrip("/") if text != "/" else "/"
|
|
798
|
+
|
|
799
|
+
|
|
506
800
|
def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]:
|
|
507
801
|
if provider != "codex":
|
|
508
802
|
return "Enter", "non_codex_provider"
|
|
@@ -84,6 +84,18 @@ def _fire_due_scheduled_events(workspace: Path, store: MessageStore, event_log:
|
|
|
84
84
|
elif row["kind"] == "health_ping":
|
|
85
85
|
result = {"ok": True, "status": "logged"}
|
|
86
86
|
event_log.write("coordinator.health_ping", target=row["target"], payload=payload)
|
|
87
|
+
elif row["kind"] == "trust_retry":
|
|
88
|
+
# Spark MEDIUM sweep #3 (2026-05-26) — bounded-backoff consumer
|
|
89
|
+
# for delivery.py:_handle_trust_retry_needed. payload carries the
|
|
90
|
+
# message_id and current attempt; _execute_trust_retry resets the
|
|
91
|
+
# row to 'accepted', re-runs _deliver_pending_message with the
|
|
92
|
+
# attempt threaded through, and either delivers, reschedules, or
|
|
93
|
+
# hits the terminal trust_auto_answer_exhausted branch.
|
|
94
|
+
from team_agent.messaging.delivery import _execute_trust_retry
|
|
95
|
+
result = _execute_trust_retry(
|
|
96
|
+
workspace, store, event_log, payload,
|
|
97
|
+
owner_team_id=row.get("owner_team_id"),
|
|
98
|
+
)
|
|
87
99
|
else:
|
|
88
100
|
result = {"ok": False, "error": f"unknown scheduled event kind: {row['kind']}"}
|
|
89
101
|
if not result.get("ok") and row["kind"] == "send":
|
|
@@ -34,19 +34,10 @@ from pathlib import Path
|
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
36
|
def send_message(
|
|
37
|
-
workspace: Path,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
sender: str = "leader",
|
|
42
|
-
requires_ack: bool = True,
|
|
43
|
-
confirm_human: bool = False,
|
|
44
|
-
wait_visible: bool = True,
|
|
45
|
-
timeout: float = 30.0,
|
|
46
|
-
lock_timeout: float = 5.0,
|
|
47
|
-
watch_result: bool = False,
|
|
48
|
-
block_until_delivered: bool = True,
|
|
49
|
-
team: str | None = None,
|
|
37
|
+
workspace: Path, target: str | list[str] | None, content: str, task_id: str | None = None,
|
|
38
|
+
sender: str = "leader", requires_ack: bool = True, confirm_human: bool = False,
|
|
39
|
+
wait_visible: bool = True, timeout: float = 30.0, lock_timeout: float = 5.0,
|
|
40
|
+
watch_result: bool = False, block_until_delivered: bool = True, team: str | None = None,
|
|
50
41
|
) -> dict[str, Any]:
|
|
51
42
|
with _runtime_lock(workspace, "send", timeout=lock_timeout):
|
|
52
43
|
return _send_message_unlocked(
|
|
@@ -66,18 +57,10 @@ def send_message(
|
|
|
66
57
|
|
|
67
58
|
|
|
68
59
|
def _send_message_unlocked(
|
|
69
|
-
workspace: Path,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
sender: str = "leader",
|
|
74
|
-
requires_ack: bool = True,
|
|
75
|
-
confirm_human: bool = False,
|
|
76
|
-
wait_visible: bool = True,
|
|
77
|
-
timeout: float = 30.0,
|
|
78
|
-
watch_result: bool = False,
|
|
79
|
-
block_until_delivered: bool = True,
|
|
80
|
-
team: str | None = None,
|
|
60
|
+
workspace: Path, target: str | list[str] | None, content: str, task_id: str | None = None,
|
|
61
|
+
sender: str = "leader", requires_ack: bool = True, confirm_human: bool = False,
|
|
62
|
+
wait_visible: bool = True, timeout: float = 30.0, watch_result: bool = False,
|
|
63
|
+
block_until_delivered: bool = True, team: str | None = None,
|
|
81
64
|
) -> dict[str, Any]:
|
|
82
65
|
if team is None:
|
|
83
66
|
ambiguous = ambiguous_team_target_result(load_runtime_state(workspace))
|
|
@@ -94,6 +77,7 @@ def _send_message_unlocked(
|
|
|
94
77
|
return gate
|
|
95
78
|
owner_team_id = team_state_key(state)
|
|
96
79
|
leader_id = _leader_id(state, spec)
|
|
80
|
+
_flag_rebind_required_when_unbound_plain_shell_leader(workspace, state, spec, sender, leader_id, event_log)
|
|
97
81
|
|
|
98
82
|
if isinstance(target, list):
|
|
99
83
|
if watch_result:
|
|
@@ -151,6 +135,38 @@ def _send_message_unlocked(
|
|
|
151
135
|
)
|
|
152
136
|
|
|
153
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
|
+
|
|
154
170
|
def _send_single_message_unlocked(
|
|
155
171
|
workspace: Path,
|
|
156
172
|
state: dict[str, Any],
|
|
@@ -336,6 +352,8 @@ def _send_single_message_unlocked(
|
|
|
336
352
|
"submit_verification": delivered_result.get("submit_verification"),
|
|
337
353
|
"turn_verification": delivered_result.get("turn_verification"),
|
|
338
354
|
}
|
|
355
|
+
result.update({key: delivered_result[key] for key in ("reason", "stage") if delivered_result.get(key)})
|
|
356
|
+
result.update(_structured_delivery_refusal(delivered_result))
|
|
339
357
|
if delivered_result.get("queued"):
|
|
340
358
|
result["queued"] = True
|
|
341
359
|
result["reason"] = delivered_result.get("reason")
|
|
@@ -490,7 +508,7 @@ def _broadcast_targets(state: dict[str, Any], spec: dict[str, Any], sender: str)
|
|
|
490
508
|
|
|
491
509
|
|
|
492
510
|
def _compact_broadcast_delivery(result: dict[str, Any]) -> dict[str, Any]:
|
|
493
|
-
keys = ["ok", "status", "message_id", "to", "reason", "channel"]
|
|
511
|
+
keys = ["ok", "status", "message_id", "to", "reason", "channel", "detected", "pane_id", "pane_mode", "pane_capture_tail", "stage", "verification"]
|
|
494
512
|
return {key: result[key] for key in keys if key in result}
|
|
495
513
|
|
|
496
514
|
|
|
@@ -498,3 +516,13 @@ def _compact_fanout_delivery(result: dict[str, Any]) -> dict[str, Any]:
|
|
|
498
516
|
compact = _compact_broadcast_delivery(result)
|
|
499
517
|
compact["delivered"] = bool(result.get("submitted") or result.get("visible") or result.get("status") in {"submitted", "visible", "delivered", "acknowledged"})
|
|
500
518
|
return compact
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _structured_delivery_refusal(delivered_result: dict[str, Any]) -> dict[str, Any]:
|
|
522
|
+
attempts = delivered_result.get("paste_attempts")
|
|
523
|
+
if not isinstance(attempts, list):
|
|
524
|
+
return {}
|
|
525
|
+
for attempt in attempts:
|
|
526
|
+
if isinstance(attempt, dict) and attempt.get("reason") == "recipient_pane_in_non_input_mode":
|
|
527
|
+
return {key: attempt[key] for key in ("detected", "pane_id", "pane_mode", "pane_capture_tail") if key in attempt}
|
|
528
|
+
return {}
|