@team-agent/installer 0.2.1 → 0.2.3
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/approvals/runtime_prompts.py +1 -1
- package/src/team_agent/cli/commands.py +122 -6
- package/src/team_agent/cli/parser.py +42 -1
- package/src/team_agent/coordinator/__main__.py +21 -2
- package/src/team_agent/coordinator/lifecycle.py +11 -0
- package/src/team_agent/diagnose/orphan_cleanup.py +364 -0
- package/src/team_agent/events.py +47 -0
- package/src/team_agent/launch/core.py +2 -1
- package/src/team_agent/leader/__init__.py +273 -60
- package/src/team_agent/lifecycle/agents.py +54 -2
- package/src/team_agent/lifecycle/operations.py +87 -9
- package/src/team_agent/lifecycle/start.py +1 -1
- package/src/team_agent/message_store/core.py +8 -7
- package/src/team_agent/message_store/leader_notification_log.py +132 -0
- package/src/team_agent/message_store/result_watchers.py +144 -1
- package/src/team_agent/message_store/schema.py +31 -2
- package/src/team_agent/messaging/delivery.py +293 -1
- package/src/team_agent/messaging/idle_alerts.py +109 -9
- package/src/team_agent/messaging/leader.py +179 -10
- package/src/team_agent/messaging/leader_api_errors.py +216 -0
- package/src/team_agent/messaging/leader_panes.py +393 -23
- package/src/team_agent/messaging/result_delivery.py +219 -4
- package/src/team_agent/messaging/results.py +12 -21
- package/src/team_agent/messaging/scheduler.py +24 -2
- package/src/team_agent/messaging/send.py +21 -26
- package/src/team_agent/messaging/tmux_io.py +153 -23
- package/src/team_agent/messaging/tmux_prompt.py +87 -0
- package/src/team_agent/messaging/trust_auto_answer.py +44 -0
- package/src/team_agent/restart/orchestration.py +207 -4
- package/src/team_agent/runtime.py +7 -7
- package/src/team_agent/rust_core.py +157 -3
- package/src/team_agent/sessions/capture.py +65 -15
- package/src/team_agent/spec.py +59 -0
- package/src/team_agent/state.py +153 -10
- package/src/team_agent/status/inbox.py +33 -3
- package/src/team_agent/status/queries.py +32 -1
- package/src/team_agent/watch/__init__.py +145 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
3
5
|
from team_agent.messaging.deps import (
|
|
4
6
|
EventLog,
|
|
5
7
|
RuntimeError,
|
|
@@ -9,6 +11,7 @@ from team_agent.messaging.deps import (
|
|
|
9
11
|
_tmux_current_client_pane_info as _runtime_tmux_current_client_pane_info,
|
|
10
12
|
_tmux_list_panes as _runtime_tmux_list_panes,
|
|
11
13
|
_tmux_pane_info as _runtime_tmux_pane_info,
|
|
14
|
+
_tmux_inject_text,
|
|
12
15
|
core_list_targets,
|
|
13
16
|
datetime,
|
|
14
17
|
os,
|
|
@@ -20,6 +23,8 @@ from team_agent.messaging.deps import (
|
|
|
20
23
|
from pathlib import Path
|
|
21
24
|
from typing import Any
|
|
22
25
|
|
|
26
|
+
_AMBIGUOUS_DEBOUNCE_SECONDS = 60
|
|
27
|
+
|
|
23
28
|
def _resolve_leader_pane(
|
|
24
29
|
pane: str | None,
|
|
25
30
|
provider: str,
|
|
@@ -208,17 +213,40 @@ def _target_fingerprint(pane_info: dict[str, Any]) -> str:
|
|
|
208
213
|
)
|
|
209
214
|
|
|
210
215
|
|
|
216
|
+
def is_bound_pane_still_valid(state: dict[str, Any], store: Any | None = None) -> dict[str, Any]:
|
|
217
|
+
receiver = dict(state.get("leader_receiver") or {})
|
|
218
|
+
owner = state.get("team_owner") if isinstance(state.get("team_owner"), dict) else {}
|
|
219
|
+
if owner and owner.get("leader_session_uuid") and not receiver.get("leader_session_uuid"):
|
|
220
|
+
receiver["leader_session_uuid"] = owner["leader_session_uuid"]
|
|
221
|
+
return _validate_leader_receiver(receiver)
|
|
222
|
+
|
|
223
|
+
|
|
211
224
|
def _rediscover_leader_receiver(
|
|
212
225
|
receiver: dict[str, Any],
|
|
213
226
|
event_log: EventLog,
|
|
214
227
|
owner_identity: dict[str, Any] | None = None,
|
|
228
|
+
invalidation_reason: str | None = None,
|
|
229
|
+
team_id: str | None = None,
|
|
215
230
|
) -> dict[str, Any]:
|
|
216
231
|
provider = str(receiver.get("provider") or "codex")
|
|
217
|
-
if provider
|
|
218
|
-
return {"status": "missing", "reason": "
|
|
232
|
+
if provider == "fake":
|
|
233
|
+
return {"status": "missing", "reason": "rediscovery_not_supported_for_fake"}
|
|
219
234
|
targets = core_list_targets()
|
|
220
235
|
if not targets.get("ok"):
|
|
221
236
|
event_log.write("leader_receiver.rediscover_failed", provider=provider, error=targets.get("error"))
|
|
237
|
+
# Stage 15 CI fix: when the tmux target scan itself fails (no server, no daemon,
|
|
238
|
+
# CI env without tmux), the caller has no way to recover unless we also emit
|
|
239
|
+
# rebind_required. Without this, _refresh_leader_receiver_or_flag_rebind silently
|
|
240
|
+
# returns and report_result queues against the stale pane with zero audit signal.
|
|
241
|
+
event_log.write(
|
|
242
|
+
"leader_receiver.rebind_required",
|
|
243
|
+
old_pane_id=receiver.get("pane_id"),
|
|
244
|
+
reason=invalidation_reason,
|
|
245
|
+
provider=provider,
|
|
246
|
+
team_id=team_id,
|
|
247
|
+
rediscovery_status="failed",
|
|
248
|
+
error=targets.get("error"),
|
|
249
|
+
)
|
|
222
250
|
return {"status": "failed", "error": targets.get("error")}
|
|
223
251
|
candidates = [
|
|
224
252
|
target
|
|
@@ -228,16 +256,26 @@ def _rediscover_leader_receiver(
|
|
|
228
256
|
if owner_identity:
|
|
229
257
|
owner_candidates = [target for target in candidates if _target_matches_owner_identity(target, owner_identity)]
|
|
230
258
|
if len(owner_candidates) == 1:
|
|
231
|
-
return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity)
|
|
259
|
+
return _rediscovered_receiver(receiver, provider, owner_candidates[0], event_log, owner_identity, invalidation_reason)
|
|
232
260
|
if len(owner_candidates) > 1:
|
|
261
|
+
incident = _broadcast_ambiguous_candidates(
|
|
262
|
+
receiver,
|
|
263
|
+
provider,
|
|
264
|
+
owner_candidates,
|
|
265
|
+
event_log,
|
|
266
|
+
owner_identity,
|
|
267
|
+
team_id,
|
|
268
|
+
)
|
|
233
269
|
event_log.write(
|
|
234
270
|
"leader_receiver.rediscover_ambiguous",
|
|
235
271
|
provider=provider,
|
|
236
272
|
old_target=receiver.get("pane_id"),
|
|
237
273
|
candidates=[target.get("pane_id") for target in owner_candidates],
|
|
238
274
|
owner_identity=owner_identity,
|
|
275
|
+
incident_id=incident.get("incident_id"),
|
|
276
|
+
deduped=incident.get("deduped"),
|
|
239
277
|
)
|
|
240
|
-
return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity}
|
|
278
|
+
return {"status": "ambiguous", "candidates": owner_candidates, "owner_identity": owner_identity, **incident}
|
|
241
279
|
event_log.write(
|
|
242
280
|
"leader_receiver.rediscover_missing",
|
|
243
281
|
provider=provider,
|
|
@@ -245,9 +283,19 @@ def _rediscover_leader_receiver(
|
|
|
245
283
|
owner_identity=owner_identity,
|
|
246
284
|
candidate_count=len(candidates),
|
|
247
285
|
)
|
|
286
|
+
event_log.write(
|
|
287
|
+
"leader_receiver.rebind_required",
|
|
288
|
+
old_pane_id=receiver.get("pane_id"),
|
|
289
|
+
reason=invalidation_reason,
|
|
290
|
+
provider=provider,
|
|
291
|
+
team_id=team_id,
|
|
292
|
+
uuid_prefix=_uuid_prefix(owner_identity),
|
|
293
|
+
owner_identity=owner_identity,
|
|
294
|
+
recovery_action="open the owning leader pane or run team-agent claim-leader --confirm from a matching pane",
|
|
295
|
+
)
|
|
248
296
|
return {"status": "missing", "owner_identity": owner_identity}
|
|
249
297
|
if len(candidates) == 1:
|
|
250
|
-
return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None)
|
|
298
|
+
return _rediscovered_receiver(receiver, provider, candidates[0], event_log, None, invalidation_reason)
|
|
251
299
|
if len(candidates) > 1:
|
|
252
300
|
event_log.write(
|
|
253
301
|
"leader_receiver.rediscover_ambiguous",
|
|
@@ -255,12 +303,19 @@ def _rediscover_leader_receiver(
|
|
|
255
303
|
old_target=receiver.get("pane_id"),
|
|
256
304
|
candidates=[target.get("pane_id") for target in candidates],
|
|
257
305
|
)
|
|
306
|
+
event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="ambiguous")
|
|
258
307
|
return {"status": "ambiguous", "candidates": candidates}
|
|
259
308
|
event_log.write("leader_receiver.rediscover_missing", provider=provider, old_target=receiver.get("pane_id"))
|
|
309
|
+
event_log.write("leader_receiver.rebind_required", old_pane_id=receiver.get("pane_id"), reason=invalidation_reason, provider=provider, team_id=team_id, rediscovery_status="missing")
|
|
260
310
|
return {"status": "missing"}
|
|
261
311
|
|
|
262
312
|
|
|
263
313
|
def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[str, Any]) -> bool:
|
|
314
|
+
expected_uuid = owner_identity.get("leader_session_uuid")
|
|
315
|
+
if expected_uuid:
|
|
316
|
+
actual_uuid = _target_leader_session_uuid(target)
|
|
317
|
+
if actual_uuid:
|
|
318
|
+
return actual_uuid == expected_uuid
|
|
264
319
|
env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
|
|
265
320
|
return (
|
|
266
321
|
env.get("TEAM_AGENT_LEADER_PANE_ID") == (owner_identity.get("pane_id") or "")
|
|
@@ -269,14 +324,31 @@ def _target_matches_owner_identity(target: dict[str, Any], owner_identity: dict[
|
|
|
269
324
|
)
|
|
270
325
|
|
|
271
326
|
|
|
272
|
-
def
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
327
|
+
def _target_leader_session_uuid(target: dict[str, Any]) -> str:
|
|
328
|
+
env = target.get("leader_env") if isinstance(target.get("leader_env"), dict) else {}
|
|
329
|
+
return str(target.get("leader_session_uuid") or env.get("TEAM_AGENT_LEADER_SESSION_UUID") or "")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _leader_uuid_for_bound_pane(receiver: dict[str, Any], pane_info: dict[str, Any]) -> str:
|
|
333
|
+
direct = _target_leader_session_uuid(pane_info) or _target_leader_session_uuid(receiver)
|
|
334
|
+
if direct:
|
|
335
|
+
return direct
|
|
336
|
+
targets = core_list_targets()
|
|
337
|
+
if not targets.get("ok"):
|
|
338
|
+
return ""
|
|
339
|
+
pane_id = pane_info.get("pane_id")
|
|
340
|
+
for target in targets.get("targets", []):
|
|
341
|
+
if target.get("pane_id") == pane_id:
|
|
342
|
+
return _target_leader_session_uuid(target)
|
|
343
|
+
return ""
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _uuid_prefix(owner_identity: dict[str, Any] | None) -> str:
|
|
347
|
+
return str((owner_identity or {}).get("leader_session_uuid") or "")[:8]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _receiver_from_target(target: dict[str, Any], provider: str, leader_uuid: str | None, owner_epoch: int | None = None) -> dict[str, Any]:
|
|
351
|
+
receiver = {
|
|
280
352
|
"mode": "direct_tmux",
|
|
281
353
|
"status": "attached",
|
|
282
354
|
"provider": provider,
|
|
@@ -289,8 +361,83 @@ def _rediscovered_receiver(
|
|
|
289
361
|
"pane_current_command": target["pane_current_command"],
|
|
290
362
|
"fingerprint": target.get("fingerprint") or _target_fingerprint(target),
|
|
291
363
|
"attached_at": datetime.now(timezone.utc).isoformat(),
|
|
292
|
-
"discovery": "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate",
|
|
293
364
|
}
|
|
365
|
+
if leader_uuid:
|
|
366
|
+
receiver["leader_session_uuid"] = leader_uuid
|
|
367
|
+
if owner_epoch is not None:
|
|
368
|
+
receiver["owner_epoch"] = owner_epoch
|
|
369
|
+
return receiver
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _broadcast_ambiguous_candidates(
|
|
373
|
+
receiver: dict[str, Any],
|
|
374
|
+
provider: str,
|
|
375
|
+
candidates: list[dict[str, Any]],
|
|
376
|
+
event_log: EventLog,
|
|
377
|
+
owner_identity: dict[str, Any],
|
|
378
|
+
team_id: str | None,
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
candidate_ids = sorted(str(candidate.get("pane_id")) for candidate in candidates)
|
|
381
|
+
bucket = _ambiguous_debounce_bucket()
|
|
382
|
+
incident_id = hashlib.sha256("\0".join([str(team_id or ""), *candidate_ids, bucket]).encode("utf-8")).hexdigest()[:16]
|
|
383
|
+
if any(event.get("event") == "leader_receiver.ambiguous_candidates" and event.get("incident_id") == incident_id for event in event_log.tail(200)):
|
|
384
|
+
return {"incident_id": incident_id, "deduped": True}
|
|
385
|
+
prompt = _ambiguous_candidate_prompt(team_id, len(candidates))
|
|
386
|
+
event_log.write(
|
|
387
|
+
"leader_receiver.ambiguous_candidates",
|
|
388
|
+
incident_id=incident_id,
|
|
389
|
+
old_pane_id=receiver.get("pane_id"),
|
|
390
|
+
candidates=candidate_ids,
|
|
391
|
+
provider=provider,
|
|
392
|
+
team_id=team_id,
|
|
393
|
+
uuid_prefix=_uuid_prefix(owner_identity),
|
|
394
|
+
debounce_bucket=bucket,
|
|
395
|
+
)
|
|
396
|
+
for candidate in candidates:
|
|
397
|
+
pane_id = str(candidate.get("pane_id") or "")
|
|
398
|
+
injected = _tmux_inject_text(
|
|
399
|
+
pane_id,
|
|
400
|
+
prompt,
|
|
401
|
+
"Enter",
|
|
402
|
+
f"team-agent-leader-ambiguous-{incident_id}-{pane_id.strip('%')}",
|
|
403
|
+
provider=provider,
|
|
404
|
+
)
|
|
405
|
+
event_log.write(
|
|
406
|
+
"leader_receiver.ambiguous_candidate_queued",
|
|
407
|
+
incident_id=incident_id,
|
|
408
|
+
pane_id=pane_id,
|
|
409
|
+
ok=bool(injected.get("ok")),
|
|
410
|
+
error=injected.get("error"),
|
|
411
|
+
)
|
|
412
|
+
return {"incident_id": incident_id, "deduped": False}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _ambiguous_debounce_bucket() -> str:
|
|
416
|
+
now = datetime.now(timezone.utc)
|
|
417
|
+
epoch = int(now.timestamp() // _AMBIGUOUS_DEBOUNCE_SECONDS) * _AMBIGUOUS_DEBOUNCE_SECONDS
|
|
418
|
+
return datetime.fromtimestamp(epoch, timezone.utc).isoformat()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _ambiguous_candidate_prompt(team_id: str | None, candidate_count: int) -> str:
|
|
422
|
+
others = max(candidate_count - 1, 0)
|
|
423
|
+
return (
|
|
424
|
+
f"Team `{team_id or 'current'}` has no bound leader. This window and {others} other window(s) all qualify. "
|
|
425
|
+
"To claim this window as the team leader, run: `team-agent claim-leader --confirm`. "
|
|
426
|
+
"Only the first such call wins; subsequent calls from other windows will be refused."
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _rediscovered_receiver(
|
|
431
|
+
receiver: dict[str, Any],
|
|
432
|
+
provider: str,
|
|
433
|
+
target: dict[str, Any],
|
|
434
|
+
event_log: EventLog,
|
|
435
|
+
owner_identity: dict[str, Any] | None,
|
|
436
|
+
invalidation_reason: str | None = None,
|
|
437
|
+
) -> dict[str, Any]:
|
|
438
|
+
leader_uuid = _target_leader_session_uuid(target) or (owner_identity or {}).get("leader_session_uuid") or receiver.get("leader_session_uuid")
|
|
439
|
+
updated = _receiver_from_target(target, provider, leader_uuid)
|
|
440
|
+
updated["discovery"] = "stale_rediscovery_owner_identity" if owner_identity else "stale_rediscovery_unique_candidate"
|
|
294
441
|
event_log.write(
|
|
295
442
|
"leader_receiver.rediscovered",
|
|
296
443
|
provider=provider,
|
|
@@ -299,6 +446,14 @@ def _rediscovered_receiver(
|
|
|
299
446
|
candidate_count=1,
|
|
300
447
|
owner_identity=owner_identity,
|
|
301
448
|
)
|
|
449
|
+
event_log.write(
|
|
450
|
+
"leader_receiver.rebind_applied",
|
|
451
|
+
old_pane_id=receiver.get("pane_id"),
|
|
452
|
+
new_pane_id=updated["pane_id"],
|
|
453
|
+
reason=invalidation_reason,
|
|
454
|
+
owner_identity=owner_identity,
|
|
455
|
+
uuid_prefix=_uuid_prefix(owner_identity),
|
|
456
|
+
)
|
|
302
457
|
return {"status": "updated", "receiver": updated, "owner_identity": owner_identity}
|
|
303
458
|
|
|
304
459
|
|
|
@@ -306,6 +461,26 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
|
|
|
306
461
|
pane_info = _runtime_tmux_pane_info(receiver.get("pane_id"))
|
|
307
462
|
if not pane_info:
|
|
308
463
|
return {"ok": False, "reason": "leader_pane_missing", "error": "tmux pane does not exist"}
|
|
464
|
+
provider = str(receiver.get("provider") or "codex")
|
|
465
|
+
if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
|
|
466
|
+
return {
|
|
467
|
+
"ok": False,
|
|
468
|
+
"reason": "leader_pane_wrong_command",
|
|
469
|
+
"error": f"pane command {pane_info.get('pane_current_command')!r} is not a leader host",
|
|
470
|
+
"pane": pane_info,
|
|
471
|
+
}
|
|
472
|
+
expected_uuid = receiver.get("leader_session_uuid")
|
|
473
|
+
if expected_uuid:
|
|
474
|
+
actual_uuid = _leader_uuid_for_bound_pane(receiver, pane_info)
|
|
475
|
+
if not actual_uuid:
|
|
476
|
+
return {"ok": False, "reason": "leader_uuid_missing", "error": "bound pane has no TEAM_AGENT_LEADER_SESSION_UUID", "pane": pane_info}
|
|
477
|
+
if actual_uuid != expected_uuid:
|
|
478
|
+
return {
|
|
479
|
+
"ok": False,
|
|
480
|
+
"reason": "leader_uuid_mismatch",
|
|
481
|
+
"error": "bound pane TEAM_AGENT_LEADER_SESSION_UUID does not match stored team owner",
|
|
482
|
+
"pane": pane_info,
|
|
483
|
+
}
|
|
309
484
|
capture = run_cmd(["tmux", "capture-pane", "-p", "-S", "-40", "-t", pane_info["pane_id"]], timeout=5)
|
|
310
485
|
if capture.returncode != 0:
|
|
311
486
|
return {
|
|
@@ -314,14 +489,7 @@ def _validate_leader_receiver(receiver: dict[str, Any]) -> dict[str, Any]:
|
|
|
314
489
|
"error": capture.stderr.strip() or "tmux capture-pane failed",
|
|
315
490
|
"pane": pane_info,
|
|
316
491
|
}
|
|
317
|
-
warning
|
|
318
|
-
provider = str(receiver.get("provider") or "codex")
|
|
319
|
-
if not _leader_command_looks_usable(pane_info.get("pane_current_command", ""), provider):
|
|
320
|
-
warning = (
|
|
321
|
-
f"pane command {pane_info.get('pane_current_command')!r} is not a typical {provider} host; "
|
|
322
|
-
"continuing because tmux capture works"
|
|
323
|
-
)
|
|
324
|
-
return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": warning}
|
|
492
|
+
return {"ok": True, "pane": pane_info, "capture": capture.stdout, "warning": None}
|
|
325
493
|
|
|
326
494
|
|
|
327
495
|
def _leader_command_looks_usable(command: str, provider: str) -> bool:
|
|
@@ -330,7 +498,209 @@ def _leader_command_looks_usable(command: str, provider: str) -> bool:
|
|
|
330
498
|
command_name = Path(command).name
|
|
331
499
|
if provider == "codex":
|
|
332
500
|
return command_name in {"codex", "node", "nodejs"}
|
|
333
|
-
|
|
501
|
+
if provider in {"claude", "claude_code"}:
|
|
502
|
+
return command_name in {"claude", "claude.exe"}
|
|
503
|
+
return command_name in {"codex", "node", "nodejs", "claude", "claude.exe"}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def attempt_trust_auto_answer(
|
|
507
|
+
workspace: Path,
|
|
508
|
+
pane_id: str | None,
|
|
509
|
+
pane_capture_tail: str,
|
|
510
|
+
event_log: EventLog,
|
|
511
|
+
*,
|
|
512
|
+
spec: dict[str, Any] | None = None,
|
|
513
|
+
state: dict[str, Any] | None = None,
|
|
514
|
+
) -> dict[str, Any]:
|
|
515
|
+
"""Gap 29 (Slice 2 Stage 2) — opt-in auto-answer of the codex first-run trust prompt.
|
|
516
|
+
|
|
517
|
+
Called by the inject path when developer's structured envelope reports
|
|
518
|
+
detected=='codex_trust_prompt'. Auto-answers ONLY when both:
|
|
519
|
+
(1) runtime is opted in. The PREFERRED opt-in is the per-session env var
|
|
520
|
+
TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE in {1,true,yes,on}. The legacy
|
|
521
|
+
spec.runtime.auto_trust_own_workspace=True path is still honoured for
|
|
522
|
+
backwards compatibility but is DEPRECATED (constitution-reviewer F3:
|
|
523
|
+
a YAML field permanently erases the trust prompt's cognitive moment
|
|
524
|
+
across all sessions, defeating its purpose). The spec path will be
|
|
525
|
+
removed in 0.3.0.
|
|
526
|
+
(2) the trust-prompt pane capture references this workspace's absolute path
|
|
527
|
+
(so a worker can only trust its own dir, never some arbitrary path).
|
|
528
|
+
|
|
529
|
+
On match, sends '1' + Enter to the pane and emits
|
|
530
|
+
leader_panes.trust_auto_answered. Default is opt-out — every refusal returns
|
|
531
|
+
answered=False with a structured reason and the existing failure envelope
|
|
532
|
+
bubbles up unchanged.
|
|
533
|
+
|
|
534
|
+
Return: {"ok": bool, "answered": bool, "reason": str, ...}
|
|
535
|
+
"""
|
|
536
|
+
if spec is None and state is not None:
|
|
537
|
+
spec_path_str = state.get("spec_path")
|
|
538
|
+
if spec_path_str:
|
|
539
|
+
try:
|
|
540
|
+
from team_agent.spec import load_spec as _load_spec
|
|
541
|
+
spec = _load_spec(Path(spec_path_str))
|
|
542
|
+
except Exception:
|
|
543
|
+
spec = None
|
|
544
|
+
if not _auto_trust_opt_in(spec, event_log=event_log):
|
|
545
|
+
# Spark LOW #6: emit a structured event so the not-opted-in branch is
|
|
546
|
+
# as observable as the workspace_dir_mismatch / tmux_send_keys_failed
|
|
547
|
+
# branches. Keeps the decision matrix uniformly auditable.
|
|
548
|
+
event_log.write(
|
|
549
|
+
"leader_panes.trust_auto_answer_skipped",
|
|
550
|
+
pane_id=pane_id,
|
|
551
|
+
workspace=str(workspace),
|
|
552
|
+
reason="not_opted_in",
|
|
553
|
+
)
|
|
554
|
+
return {"ok": False, "answered": False, "reason": "not_opted_in"}
|
|
555
|
+
if not pane_id:
|
|
556
|
+
event_log.write(
|
|
557
|
+
"leader_panes.trust_auto_answer_skipped",
|
|
558
|
+
pane_id=None,
|
|
559
|
+
workspace=str(workspace),
|
|
560
|
+
reason="pane_id_missing",
|
|
561
|
+
)
|
|
562
|
+
return {"ok": False, "answered": False, "reason": "pane_id_missing"}
|
|
563
|
+
if not _capture_tail_references_workspace(pane_capture_tail, workspace):
|
|
564
|
+
event_log.write(
|
|
565
|
+
"leader_panes.trust_auto_answer_refused",
|
|
566
|
+
pane_id=pane_id,
|
|
567
|
+
workspace=str(workspace),
|
|
568
|
+
reason="workspace_dir_mismatch",
|
|
569
|
+
)
|
|
570
|
+
return {"ok": False, "answered": False, "reason": "workspace_dir_mismatch"}
|
|
571
|
+
answer = _tmux_inject_text(
|
|
572
|
+
str(pane_id),
|
|
573
|
+
"1",
|
|
574
|
+
"Enter",
|
|
575
|
+
f"team-agent-trust-auto-answer-{str(pane_id).strip('%') or 'pane'}",
|
|
576
|
+
attempts=1,
|
|
577
|
+
provider="fake",
|
|
578
|
+
bypass_non_input_gate=True,
|
|
579
|
+
)
|
|
580
|
+
if not answer.get("ok"):
|
|
581
|
+
error = answer.get("error") or "tmux send-keys failed"
|
|
582
|
+
event_log.write(
|
|
583
|
+
"leader_panes.trust_auto_answer_failed",
|
|
584
|
+
pane_id=pane_id,
|
|
585
|
+
workspace=str(workspace),
|
|
586
|
+
error=error,
|
|
587
|
+
)
|
|
588
|
+
return {"ok": False, "answered": False, "reason": "tmux_send_keys_failed", "error": error}
|
|
589
|
+
event_log.write(
|
|
590
|
+
"leader_panes.trust_auto_answered",
|
|
591
|
+
pane_id=pane_id,
|
|
592
|
+
workspace=str(workspace),
|
|
593
|
+
opted_in=True,
|
|
594
|
+
)
|
|
595
|
+
return {"ok": True, "answered": True, "reason": "trust_auto_answered"}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
_SPEC_OPT_IN_DEPRECATION_MESSAGE = (
|
|
599
|
+
"WARNING: spec.runtime.auto_trust_own_workspace is deprecated. "
|
|
600
|
+
"Use env TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE=1 per session instead. "
|
|
601
|
+
"Spec-field will be removed in 0.3.0."
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _auto_trust_opt_in(spec: dict[str, Any] | None, *, event_log: EventLog | None = None) -> bool:
|
|
606
|
+
"""Constitution-reviewer F3 (2026-05-26): env-var per-session opt-in is the
|
|
607
|
+
preferred path. spec.runtime.auto_trust_own_workspace remains honoured for
|
|
608
|
+
backwards compatibility but emits a one-shot stderr deprecation warning AND
|
|
609
|
+
a structured trust_auto_answer_spec_opt_in_deprecated event so a normalized
|
|
610
|
+
YAML field is auditable from a fresh log."""
|
|
611
|
+
spec_opted_in = (
|
|
612
|
+
isinstance(spec, dict)
|
|
613
|
+
and bool((spec.get("runtime") or {}).get("auto_trust_own_workspace"))
|
|
614
|
+
)
|
|
615
|
+
if spec_opted_in:
|
|
616
|
+
_emit_spec_opt_in_deprecation(event_log)
|
|
617
|
+
env = os.environ.get("TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE", "").strip().lower()
|
|
618
|
+
env_opted_in = env in {"1", "true", "yes", "on"}
|
|
619
|
+
return env_opted_in or spec_opted_in
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _emit_spec_opt_in_deprecation(event_log: EventLog | None) -> None:
|
|
623
|
+
"""Emit the deprecation warning once per process. The structured event still
|
|
624
|
+
fires per call so an audit log captures every yaml-driven decision."""
|
|
625
|
+
import sys
|
|
626
|
+
global _SPEC_OPT_IN_DEPRECATION_WARNED
|
|
627
|
+
if not _SPEC_OPT_IN_DEPRECATION_WARNED:
|
|
628
|
+
try:
|
|
629
|
+
print(_SPEC_OPT_IN_DEPRECATION_MESSAGE, file=sys.stderr, flush=True)
|
|
630
|
+
except Exception:
|
|
631
|
+
pass
|
|
632
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = True
|
|
633
|
+
if event_log is not None:
|
|
634
|
+
try:
|
|
635
|
+
event_log.write(
|
|
636
|
+
"trust_auto_answer_spec_opt_in_deprecated",
|
|
637
|
+
preferred_opt_in="env:TEAM_AGENT_AUTO_TRUST_OWN_WORKSPACE",
|
|
638
|
+
deprecated_field="spec.runtime.auto_trust_own_workspace",
|
|
639
|
+
removal_target_version="0.3.0",
|
|
640
|
+
)
|
|
641
|
+
except Exception:
|
|
642
|
+
pass
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = False
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _reset_spec_opt_in_deprecation_state() -> None:
|
|
649
|
+
"""Test-only helper: reset the per-process one-shot guard so multiple cases
|
|
650
|
+
in the same interpreter can each observe the warning. Not part of the
|
|
651
|
+
public API."""
|
|
652
|
+
global _SPEC_OPT_IN_DEPRECATION_WARNED
|
|
653
|
+
_SPEC_OPT_IN_DEPRECATION_WARNED = False
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _capture_tail_references_workspace(tail: str, workspace: Path) -> bool:
|
|
657
|
+
"""Spark MEDIUM #5: a raw substring match accepted '/repo' inside
|
|
658
|
+
'/repo-backup' and rejected symlinked / trailing-slash spellings. We now
|
|
659
|
+
canonicalize the workspace via Path.resolve, parse candidate absolute paths
|
|
660
|
+
out of the prompt tail (one token per line after stripping codex box-drawing
|
|
661
|
+
glyphs), canonicalize each candidate the same way, and only return True on
|
|
662
|
+
boundary-safe canonical equality."""
|
|
663
|
+
if not tail:
|
|
664
|
+
return False
|
|
665
|
+
workspace_canonical = _canonicalize_path(workspace)
|
|
666
|
+
if not workspace_canonical:
|
|
667
|
+
return False
|
|
668
|
+
for candidate in _candidate_paths_from_prompt(tail):
|
|
669
|
+
if _canonicalize_path(Path(candidate)) == workspace_canonical:
|
|
670
|
+
return True
|
|
671
|
+
return False
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
_PATH_LINE_RE = re.compile(r"(/[\w\-./~+@]+)")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _candidate_paths_from_prompt(tail: str) -> list[str]:
|
|
678
|
+
"""Pull every absolute-path-shaped token out of the prompt's tail. Codex
|
|
679
|
+
renders the trust prompt's directory inside box-drawing glyphs and on its
|
|
680
|
+
own line; strip leading/trailing whitespace and glyph noise before matching."""
|
|
681
|
+
paths: list[str] = []
|
|
682
|
+
for raw_line in tail.splitlines():
|
|
683
|
+
line = raw_line.strip()
|
|
684
|
+
# Codex draws box-glyph prefixes (▌ ▎ │) that need to be stripped.
|
|
685
|
+
for glyph in ("▌", "▎", "│"):
|
|
686
|
+
line = line.lstrip(glyph).strip()
|
|
687
|
+
if not line:
|
|
688
|
+
continue
|
|
689
|
+
for match in _PATH_LINE_RE.finditer(line):
|
|
690
|
+
token = match.group(1).rstrip("/")
|
|
691
|
+
if token and token not in paths:
|
|
692
|
+
paths.append(token)
|
|
693
|
+
return paths
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _canonicalize_path(p: Path | str) -> str:
|
|
697
|
+
try:
|
|
698
|
+
resolved = Path(p).expanduser().resolve(strict=False)
|
|
699
|
+
except OSError:
|
|
700
|
+
return ""
|
|
701
|
+
text = resolved.as_posix()
|
|
702
|
+
# Strip a trailing slash so boundary-safe equality holds.
|
|
703
|
+
return text.rstrip("/") if text != "/" else "/"
|
|
334
704
|
|
|
335
705
|
|
|
336
706
|
def _choose_leader_submit_key(provider: str, capture_text: str) -> tuple[str, str]:
|