@team-agent/installer 0.2.3 → 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/src/team_agent/abnormal_track.py +253 -0
- 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/core.py +30 -4
- package/src/team_agent/message_store/leader_notification_log.py +47 -26
- 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
|
@@ -11,9 +11,20 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from contextlib import closing
|
|
13
13
|
from datetime import datetime, timedelta, timezone
|
|
14
|
+
import sqlite3
|
|
15
|
+
import time
|
|
14
16
|
from typing import Any
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
def _sqlite_locked(exc: sqlite3.OperationalError) -> bool:
|
|
20
|
+
message = str(exc).lower()
|
|
21
|
+
return (
|
|
22
|
+
"database is locked" in message
|
|
23
|
+
or "database table is locked" in message
|
|
24
|
+
or "database schema is locked" in message
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
17
28
|
def claim_leader_notification_delivery(
|
|
18
29
|
store: Any,
|
|
19
30
|
*,
|
|
@@ -28,32 +39,42 @@ def claim_leader_notification_delivery(
|
|
|
28
39
|
rowcount=0 means a prior row exists for (result_id, leader_session_uuid); SELECT
|
|
29
40
|
it and return so the caller can decide to suppress (same envelope_hash) or surface
|
|
30
41
|
legitimate-duplicate (different envelope_hash)."""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
delay = 0.05
|
|
43
|
+
row = None
|
|
44
|
+
for attempt in range(6):
|
|
45
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
46
|
+
try:
|
|
47
|
+
with closing(store.connect()) as conn:
|
|
48
|
+
with conn:
|
|
49
|
+
cur = conn.execute(
|
|
50
|
+
"insert or ignore into leader_notification_log("
|
|
51
|
+
" result_id, leader_session_uuid, notified_message_id, notified_at,"
|
|
52
|
+
" leader_pane_id_at_notify, envelope_content_hash, owner_team_id"
|
|
53
|
+
") values (?, ?, ?, ?, ?, ?, ?)",
|
|
54
|
+
(
|
|
55
|
+
result_id, leader_session_uuid, proposed_message_id, now,
|
|
56
|
+
pane_id, envelope_hash, owner_team_id,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
if cur.rowcount == 1:
|
|
60
|
+
return {
|
|
61
|
+
"status": "claimed_by_you",
|
|
62
|
+
"notified_message_id": proposed_message_id,
|
|
63
|
+
"notified_at": now,
|
|
64
|
+
"envelope_content_hash": envelope_hash,
|
|
65
|
+
}
|
|
66
|
+
row = conn.execute(
|
|
67
|
+
"select notified_message_id, notified_at, envelope_content_hash, "
|
|
68
|
+
"leader_pane_id_at_notify from leader_notification_log "
|
|
69
|
+
"where result_id = ? and leader_session_uuid = ?",
|
|
70
|
+
(result_id, leader_session_uuid),
|
|
71
|
+
).fetchone()
|
|
72
|
+
break
|
|
73
|
+
except sqlite3.OperationalError as exc:
|
|
74
|
+
if not _sqlite_locked(exc) or attempt == 5:
|
|
75
|
+
raise
|
|
76
|
+
time.sleep(delay)
|
|
77
|
+
delay *= 2
|
|
57
78
|
if row is None:
|
|
58
79
|
# Should not happen (INSERT OR IGNORE returned 0 → row must exist), but be defensive.
|
|
59
80
|
return {"status": "claimed_by_you", "notified_message_id": proposed_message_id,
|
|
@@ -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],
|
|
@@ -32,6 +32,49 @@ def _tmux_inject_text(
|
|
|
32
32
|
*,
|
|
33
33
|
bypass_non_input_gate: bool = False,
|
|
34
34
|
) -> dict[str, Any]:
|
|
35
|
+
# Round-5 follow-up: empty-text Enter path (used by trust auto-answer to
|
|
36
|
+
# accept Codex's default `1. Yes, continue` choice with a plain Enter).
|
|
37
|
+
# tmux rejects set-buffer / paste-buffer of an empty string, so the
|
|
38
|
+
# buffer-paste route would leave the trust prompt stuck. Issue
|
|
39
|
+
# `send-keys -t <target> <submit_key>` directly and bypass the buffer
|
|
40
|
+
# path entirely.
|
|
41
|
+
if text == "":
|
|
42
|
+
proc = run_cmd(["tmux", "send-keys", "-t", target, submit_key], timeout=10)
|
|
43
|
+
if proc.returncode != 0:
|
|
44
|
+
return {
|
|
45
|
+
"ok": False,
|
|
46
|
+
"stage": "send-keys",
|
|
47
|
+
"error": proc.stderr.strip() or "tmux send-keys failed",
|
|
48
|
+
"attempts": [
|
|
49
|
+
{
|
|
50
|
+
"attempt": 1,
|
|
51
|
+
"submitted": False,
|
|
52
|
+
"verification": "send_keys_failed",
|
|
53
|
+
"submit_key": submit_key,
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"verification": "send_keys_failed",
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
"ok": True,
|
|
60
|
+
"stage": "submitted",
|
|
61
|
+
"visible": True,
|
|
62
|
+
"submitted": True,
|
|
63
|
+
"verification": "empty_text_send_keys",
|
|
64
|
+
"submit_verification": f"{submit_key}_sent_direct",
|
|
65
|
+
"turn_verification": "not_required",
|
|
66
|
+
"attempts": [
|
|
67
|
+
{
|
|
68
|
+
"attempt": 1,
|
|
69
|
+
"submitted": True,
|
|
70
|
+
"verification": "empty_text_send_keys",
|
|
71
|
+
"submit_key": submit_key,
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"submit_attempts": [
|
|
75
|
+
{"attempt": 1, "submitted": True, "verification": "send_keys"}
|
|
76
|
+
],
|
|
77
|
+
}
|
|
35
78
|
token_match = re.search(r"\[team-agent-token:([^\]]+)\]", text)
|
|
36
79
|
token = token_match.group(1) if token_match else ""
|
|
37
80
|
attempt_log: list[dict[str, Any]] = []
|
|
@@ -134,6 +177,11 @@ def _tmux_inject_text(
|
|
|
134
177
|
"submit_attempts": submit.get("attempts"),
|
|
135
178
|
}
|
|
136
179
|
submit_verification = _leader_submit_verification(submit.get("verification"), verification, submit_key)
|
|
180
|
+
# Gap 42: paste+submit success is authoritative for delivery. The post-submit
|
|
181
|
+
# turn-boundary probe is observation metadata only, never a delivery gate — a
|
|
182
|
+
# busy / compacting recipient that has not yet shown a new prompt marker is
|
|
183
|
+
# still a successful delivery. Real paste/submit failures are caught and
|
|
184
|
+
# returned above; this point is only reached after submit reported ok.
|
|
137
185
|
turn_visible, turn_verification, turn_capture = _wait_for_leader_new_turn(
|
|
138
186
|
target,
|
|
139
187
|
text,
|
|
@@ -142,16 +190,7 @@ def _tmux_inject_text(
|
|
|
142
190
|
timeout=2.0,
|
|
143
191
|
)
|
|
144
192
|
if not turn_visible:
|
|
145
|
-
|
|
146
|
-
"ok": False,
|
|
147
|
-
"stage": "turn-boundary-verification",
|
|
148
|
-
"error": f"leader turn boundary not verified: {turn_verification}",
|
|
149
|
-
"attempts": attempt_log,
|
|
150
|
-
"verification": verification,
|
|
151
|
-
"submit_verification": submit_verification,
|
|
152
|
-
"turn_verification": turn_verification,
|
|
153
|
-
"submit_attempts": submit.get("attempts"),
|
|
154
|
-
}
|
|
193
|
+
turn_verification = "not_yet_observed"
|
|
155
194
|
return {
|
|
156
195
|
"ok": True,
|
|
157
196
|
"stage": "submitted",
|
|
@@ -18,14 +18,22 @@ def retry_injection_after_trust_auto_answer(
|
|
|
18
18
|
buffer_name: str,
|
|
19
19
|
provider: str,
|
|
20
20
|
) -> dict[str, Any]:
|
|
21
|
-
from team_agent.messaging.delivery import _wait_for_trust_prompt_dismissal
|
|
21
|
+
from team_agent.messaging.delivery import _tmux_pane_width, _wait_for_trust_prompt_dismissal
|
|
22
22
|
from team_agent.messaging.leader_panes import attempt_trust_auto_answer
|
|
23
|
+
pane_target = injection.get("pane_id") or target
|
|
24
|
+
# Live wiring: query tmux pane width now and pass via state["pane_width"]
|
|
25
|
+
# (symmetric with _deliver_pending_message). Fail-safe on query failure —
|
|
26
|
+
# leave pane_width absent so the matcher falls back to exact equality.
|
|
27
|
+
width_query = _tmux_pane_width(pane_target)
|
|
28
|
+
trust_state = dict(state) if isinstance(state, dict) else {}
|
|
29
|
+
if width_query.get("ok"):
|
|
30
|
+
trust_state["pane_width"] = width_query["pane_width"]
|
|
23
31
|
answer = attempt_trust_auto_answer(
|
|
24
32
|
workspace,
|
|
25
|
-
|
|
33
|
+
pane_target,
|
|
26
34
|
injection.get("pane_capture_tail") or "",
|
|
27
35
|
event_log,
|
|
28
|
-
state=
|
|
36
|
+
state=trust_state,
|
|
29
37
|
)
|
|
30
38
|
if not answer.get("answered"):
|
|
31
39
|
return injection
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Adding a provider idle/turn-state adapter
|
|
2
|
+
|
|
3
|
+
Gap 32 decides every node's idle/working/abnormal state from a deterministic
|
|
4
|
+
FILE FACT — the provider's own session-log/rollout turn-lifecycle records — never
|
|
5
|
+
from the pane screen. The predicate, abnormal track, and wake layers are
|
|
6
|
+
**provider-neutral and reused unchanged**. To support a brand-new CLI you fill the
|
|
7
|
+
small checklist below; you do not touch any neutral module.
|
|
8
|
+
|
|
9
|
+
## What you add (only two places)
|
|
10
|
+
|
|
11
|
+
1. `src/team_agent/provider_state/<provider>.py` — a thin reader that translates
|
|
12
|
+
that CLI's session records into normalized lifecycle facts.
|
|
13
|
+
2. one entry in `src/team_agent/provider_state/registry.py` — pure infra DATA.
|
|
14
|
+
|
|
15
|
+
Everything else (`idle_predicate.py`, `abnormal_track.py`, `wake.py`,
|
|
16
|
+
`idle_takeover.py`) is provider-neutral and must stay free of provider names
|
|
17
|
+
(there is a grep test, C6).
|
|
18
|
+
|
|
19
|
+
## The checklist
|
|
20
|
+
|
|
21
|
+
### 1. Session/rollout file location
|
|
22
|
+
- Where does this CLI write its per-session log? (root dir + path layout)
|
|
23
|
+
- How does the framework already learn each agent's path? (it is captured into
|
|
24
|
+
runtime state per agent as `rollout_path`; confirm yours lands there.)
|
|
25
|
+
- Record it under the registry entry `file_location`.
|
|
26
|
+
|
|
27
|
+
### 2. Turn-lifecycle event types (do the empirical capture FIRST)
|
|
28
|
+
Capture REAL records from a live session for each state and record the exact
|
|
29
|
+
record `type`/field. These become the contract fixtures (real-fixture-first):
|
|
30
|
+
- **turn-started / open turn** — the marker that a turn is in flight.
|
|
31
|
+
- **turn-complete** — the close that means idle.
|
|
32
|
+
- **interrupted** — user ESC / abort (idle_interrupted, idle + red note).
|
|
33
|
+
- **blocked / approval** — awaiting a human decision (blocked_on_human).
|
|
34
|
+
- **error / failed** — a structured terminal fault record.
|
|
35
|
+
Implement these as `extract_facts(records) -> (facts, diagnostics)` in your reader,
|
|
36
|
+
emitting `team_agent.provider_state.common` fact kinds: `TURN_OPEN`,
|
|
37
|
+
`TURN_COMPLETE`, `INTERRUPTED`, `FAILED`, `APPROVAL`, `ERROR`. Fault facts should
|
|
38
|
+
carry `signature`, `turn_id`, and `raw` (the original record). Filter out trailing
|
|
39
|
+
metadata/telemetry records so the verdict is the last LIFECYCLE fact, not the last
|
|
40
|
+
physical line.
|
|
41
|
+
|
|
42
|
+
Reference markers already implemented:
|
|
43
|
+
- Claude transcript: assistant `stop_reason==end_turn` (idle) / `==tool_use`
|
|
44
|
+
(working); user text `[Request interrupted by user]` (interrupted); user
|
|
45
|
+
`tool_result is_error==true` and system `subtype==api_error,level==error` (faults).
|
|
46
|
+
- Codex rollout: `event_msg payload.type==task_started|task_complete`;
|
|
47
|
+
`turn_aborted reason==interrupted`; app-server `turn.status==failed` and
|
|
48
|
+
`*/requestApproval`.
|
|
49
|
+
|
|
50
|
+
### 3. Black/white list seed entries
|
|
51
|
+
- `error_lists.whitelist` — record/string patterns that are benign → skip.
|
|
52
|
+
- `error_lists.blacklist` — known error signatures → notify (`api error`,
|
|
53
|
+
`rate limit`, `overloaded`, traceback/panic, provider `failed`, ...).
|
|
54
|
+
- Precedence is whitelist > blacklist > default-notify (catch-bias for structured
|
|
55
|
+
faults only). Lists are DATA — adding a pattern is one edit + one fixture.
|
|
56
|
+
|
|
57
|
+
### 4. Optional hook accelerator
|
|
58
|
+
- Does the CLI expose hooks that fire on turn boundaries (e.g. a `Stop`/`Notify`
|
|
59
|
+
program)? If so they can push a fact row to wake the watcher faster — but the
|
|
60
|
+
file fact remains the source of truth (the hook is validated against the file,
|
|
61
|
+
never the sole signal).
|
|
62
|
+
|
|
63
|
+
### 5. Process/identity facts for the liveness guard
|
|
64
|
+
- How to read the provider process identity (start-time / cmdline) so an open
|
|
65
|
+
turn whose process was replaced (PID reuse) classifies as `crashed_mid_turn`,
|
|
66
|
+
never eternal `working` (C4). `provider_state.common.process_is_live` already
|
|
67
|
+
implements the comparison given `{"expected": {...}, "current": {...}}`.
|
|
68
|
+
|
|
69
|
+
## Reused unchanged (do NOT modify per provider)
|
|
70
|
+
- `idle_predicate.evaluate_takeover_reminder` — all-idle + arm-after-delegation +
|
|
71
|
+
monotonic debounce + edge ack.
|
|
72
|
+
- `abnormal_track.process_abnormal_records` / `detect_whole_team_gone` — dedup,
|
|
73
|
+
catch-bias, coordinator-independent whole-team-gone.
|
|
74
|
+
- `wake` — file-change watch + mtime gate.
|
|
75
|
+
- `idle_takeover` — the public facade.
|
|
76
|
+
|
|
77
|
+
If you find yourself editing a neutral module to add a provider, stop — the fact
|
|
78
|
+
you need belongs in the reader or the registry entry instead.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Provider turn-state readers behind one shared interface (Gap 32 §6).
|
|
2
|
+
|
|
3
|
+
``read_turn_state`` is the single entry the rest of the runtime uses; provider
|
|
4
|
+
dispatch happens here (and in registry data), so the neutral predicate /
|
|
5
|
+
abnormal / wake modules never name a provider.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from team_agent.provider_state.registry import get_provider_registry
|
|
14
|
+
|
|
15
|
+
_READER_CACHE: dict[str, Any] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_turn_state(
|
|
19
|
+
provider: str,
|
|
20
|
+
session_log_text: str,
|
|
21
|
+
*,
|
|
22
|
+
process: Any = None,
|
|
23
|
+
file_silence_seconds: float = 0,
|
|
24
|
+
registry: Any = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Classify a node's turn state from its provider session-log text.
|
|
27
|
+
|
|
28
|
+
Returns the stable dict shape: state / turn_id / reason / source /
|
|
29
|
+
annotations / diagnostics. A missing/unknown provider or an unreadable
|
|
30
|
+
file fails safe to ``unknown`` (never idle, Gap 32 C5).
|
|
31
|
+
"""
|
|
32
|
+
_ = file_silence_seconds # open-turn beats silence (C14); silence never forces idle
|
|
33
|
+
reader = _reader_for(provider, registry)
|
|
34
|
+
if reader is None:
|
|
35
|
+
return {
|
|
36
|
+
"state": "unknown",
|
|
37
|
+
"turn_id": None,
|
|
38
|
+
"reason": "unknown_provider",
|
|
39
|
+
"source": "registry",
|
|
40
|
+
"annotations": [],
|
|
41
|
+
"diagnostics": [{"kind": "unknown_provider", "provider": provider}],
|
|
42
|
+
}
|
|
43
|
+
return reader.classify(session_log_text, process=process)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_fault_facts(provider: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
47
|
+
"""Extract normalized fault/approval facts from already-parsed provider
|
|
48
|
+
records, using the provider reader. The abnormal track consumes these
|
|
49
|
+
without naming a provider.
|
|
50
|
+
"""
|
|
51
|
+
reader = _reader_for(provider)
|
|
52
|
+
if reader is None or not hasattr(reader, "extract_facts"):
|
|
53
|
+
return []
|
|
54
|
+
facts, _diag = reader.extract_facts(records or [])
|
|
55
|
+
fault_kinds = {"error", "failed", "approval"}
|
|
56
|
+
out: list[dict[str, Any]] = []
|
|
57
|
+
for fact in facts:
|
|
58
|
+
if fact.get("kind") in fault_kinds:
|
|
59
|
+
enriched = dict(fact)
|
|
60
|
+
enriched.setdefault("provider", provider)
|
|
61
|
+
out.append(enriched)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _reader_for(provider: str, registry: Any = None) -> Any:
|
|
66
|
+
if provider in _READER_CACHE:
|
|
67
|
+
return _READER_CACHE[provider]
|
|
68
|
+
entry = None
|
|
69
|
+
if isinstance(registry, dict):
|
|
70
|
+
entry = registry.get(provider) if provider in registry else registry
|
|
71
|
+
if not isinstance(entry, dict) or "reader_module" not in entry:
|
|
72
|
+
entry = get_provider_registry(provider)
|
|
73
|
+
if not isinstance(entry, dict):
|
|
74
|
+
return None
|
|
75
|
+
module_name = entry.get("reader_module")
|
|
76
|
+
if not module_name:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
module = importlib.import_module(module_name)
|
|
80
|
+
except ImportError:
|
|
81
|
+
return None
|
|
82
|
+
_READER_CACHE[provider] = module
|
|
83
|
+
return module
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["read_turn_state", "read_fault_facts", "get_provider_registry"]
|