feat(a4,c3b): complete A-4 Phase 2 agent knowledge migration & Option B isolation removal
- Option B / C-3b: Deprecate isolation.root and remove its 4 legacy consumers in lib.sh, workspace_uuid.py, verify_session.py, and stop_session.sh. - M2~M7 Migration: Implement artifact_path, verify_artifact, purge_artifacts, spawn_spec, resume_spec, auth_ok, discover, ready_tokens, exit_key, and delegate_agent_key across BaseAgentAdapter and all 4 adapters (Claude, Agy, Hermes, Cline). - Migrate shell duplications in lib.sh, create_session.sh, resume_session.sh, stop_session.sh, and reconcile.sh to python -m lib_py.agents facts. - Hardening & Corrections: Fix Cline resume_spec flag to '-i --id', apply shlex.quote across all facts variables with MAM_ prefix, add safe fallback in wait_for_tui_ready. - Add comprehensive contract tests in tests/test_a4_adapter_contract.py and sync IMPROVEMENTS.md / LOG.md. - Verified: 100% PASS across unit, component, contract, and integration tests.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# 📋 Cross Review Report — Job 8fc5b0bd (P3-1 / A-4 Phase 2 Reviewer-feedback fix)
|
||||
|
||||
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||
- **Job**: 8fc5b0bd (follow-up to Job 59467505 NOT PASS)
|
||||
- **Scope**: Verify the implementation addressed the 4 blocking issues from the prior NOT PASS review.
|
||||
- **Date**: 2026-08-16
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The implementer addressed **all 4 blocking issues** raised in the prior NOT PASS review
|
||||
(Job 59467505). The `cline resume_spec --resume` bug is fixed (`--id`), `auth_ok`/`discover`
|
||||
are implemented in all 4 adapters, `resume_session.sh` and `reconcile.sh` are migrated to the
|
||||
adapter layer, and contract tests for `spawn_spec`/`resume_spec`/`auth_ok`/`discover` values
|
||||
were added and pass. 132 change-relevant tests pass with 0 failures; `py_compile` is clean;
|
||||
`IMPROVEMENTS.md`/`LOG.md` are synchronized. One minor non-blocking observation remains
|
||||
(create_session.sh auth not yet wired to `adapter.auth_ok`), which is out of the brief's
|
||||
explicit scope.
|
||||
|
||||
**Verdict: PASS.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Prior NOT PASS Issues — Resolution Status
|
||||
|
||||
### 2.1 [FIXED] cline `resume_spec` used non-existent `--resume` flag
|
||||
- **Prior**: `cline.py:76` emitted `--resume`, but `cline --help` only exposes `--id`.
|
||||
- **Now**: `cline.py:75-78` emits `f"{binary} -i --id {session_uuid}"` (materialized) /
|
||||
`f"{binary} -i"` (non-materialized).
|
||||
- **Verification**: `cline --help` → `--id <session-id> Resume an existing session by ID`
|
||||
(no `--resume`). Contract test `test_adapter_spawn_and_resume_specs` (line 181-182)
|
||||
asserts `cline -i --id u1` (materialized) and `cline -i` (non-materialized). ✅
|
||||
|
||||
### 2.2 [FIXED] `auth_ok` / `discover` unimplemented (2 of 7 adapter methods)
|
||||
- **Prior**: `auth_ok` and `discover` were absent from `base.py` and all adapters.
|
||||
- **Now**:
|
||||
- `base.py:100-104` declares both as abstract (`raise NotImplementedError`).
|
||||
- `claude.py:99-110` — `auth_ok` dual-mode (`run_cmd` callable for test injection /
|
||||
`subprocess` for prod; checks `claude auth status` → `"loggedIn":true`).
|
||||
- `agy.py:94-96` — `auth_ok` checks `~/.gemini/oauth_creds.json` or antigravity-oauth-token.
|
||||
- `hermes.py:80-81` / `cline.py:80-81` — `auth_ok` returns `True` (no auth gate).
|
||||
- `claude.py:112-121` — `discover` globs `{claude_dir}/{ws_key}/*.jsonl`, verifies each.
|
||||
- `agy.py:98-108` — `discover` reads `last_conversations.json[ws]`, verifies artifact.
|
||||
- `hermes.py:83-96` — `discover` queries `state.db` sessions by `cwd`.
|
||||
- `cline.py:83-100` — `discover` scans `~/.cline/data/sessions/*`, verifies each.
|
||||
- `workspace_uuid.py:74-81` — disk-scan fan-out replaced by `adapter.discover(ctx)`.
|
||||
- **Contract tests**: `test_adapter_auth_ok` (line 184-201) and `test_adapter_discover`
|
||||
(line 203-256) verify all 4 agents. Both pass. ✅
|
||||
|
||||
### 2.3 [FIXED] `resume_session.sh` / `reconcile.sh` not migrated to adapters
|
||||
- **resume_session.sh** (line 83-93): `CMD_FULL` now computed via
|
||||
`adapter.resume_spec('$RESOLVED_BIN', '$UUID', mat)` where `mat = adapter.verify_artifact(...)`.
|
||||
The `materialized` flag (artifact exists on disk) selects `-r`/`--session-id` (claude) or
|
||||
`--id`/bare (cline) — a behavioral improvement: do not attempt to resume a session whose
|
||||
artifact is absent. Hardcoded fallback case retained as a safety net. `_iso_root` branch
|
||||
fully removed. ✅
|
||||
- **reconcile.sh**:
|
||||
- `row_agent(s)` (line 590-591) delegates to `agent_of_row(s)` from registry.
|
||||
- `_pin_and_verify_resume` (line 438-441) uses `_get_own_key(agent)` from registry.
|
||||
- `OWN_KEY_BY_AGENT` (line 593-595) built from `_get_own_key(a)` for all 4 agents.
|
||||
- Auto-register `cmd_full` (line 540-541) uses `_adapter.spawn_spec(agent)`.
|
||||
- The 4-way hardcoded spawn/own-key fan-outs are now adapter-driven. ✅
|
||||
|
||||
### 2.4 [FIXED] No contract tests for `spawn_spec` / `resume_spec` values
|
||||
- **Now**: `test_adapter_spawn_and_resume_specs` (line 164-182) asserts exact output strings
|
||||
for all 4 agents' `spawn_spec` and `resume_spec` (materialized + non-materialized):
|
||||
- claude: `--dangerously-skip-permissions --session-id u1` (spawn) / `-r u1` (resume,mat)
|
||||
- agy: `--dangerously-skip-permissions` (spawn) / `--conversation u1` (resume,mat)
|
||||
- hermes: `hermes` (spawn) / `hermes --resume u1` (resume,mat)
|
||||
- cline: `cline -i` (spawn) / `cline -i --id u1` (resume,mat) / `cline -i` (resume,!mat)
|
||||
- Plus `test_adapter_auth_ok` and `test_adapter_discover`. Total: 9 contract tests, all pass. ✅
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Execution (Independent)
|
||||
|
||||
| Group | Files | Result | Time |
|
||||
|---|---|---|---|
|
||||
| Contract | test_a4_adapter_contract.py | **9 passed** | 0.13s |
|
||||
| Unit | test_tier1_unit.py, test_orc_onboard.py | **66 passed** | 13.48s |
|
||||
| UUID | test_uuid_target.py | **12 passed** | 78.35s |
|
||||
| Tier2 | test_tier2_component.py, test_b4_session_created.py | **45 passed** | 56.05s |
|
||||
| **Total** | | **132 passed, 0 failed** | — |
|
||||
|
||||
- `py_compile` clean on all 9 changed `.py` files.
|
||||
- Removed tests (`test_t11_legacy_isolation_row`, `test_comp_stop_safe_path_checking`)
|
||||
correctly tested the now-deprecated `isolation.root` feature — removals are justified.
|
||||
- Full-suite count per LOG.md: 259 passed (consistent with +3 new contract tests over prior 256).
|
||||
|
||||
---
|
||||
|
||||
## 4. Documentation Sync
|
||||
|
||||
- `IMPROVEMENTS.md`: A-4 marked ✅완료 (P3-1), C-3b ✅완료; completed 17→19, pending 8→6.
|
||||
- `LOG.md`: New P3-1 section documents every migrated file (base/adapters/__main__/verify_session/
|
||||
workspace_uuid/atomic_yaml/lib.sh/create/resume/reconcile/stop/tests), records the
|
||||
`cline resume_spec -i --id` fix, and the 259-pass result.
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-Blocking Observations
|
||||
|
||||
### 5.1 `create_session.sh` auth not yet wired to `adapter.auth_ok`
|
||||
`create_session.sh:96-119` still contains a 4-way hardcoded auth fan-out (claude/agy/hermes/cline).
|
||||
The `auth_ok` adapter method is now implemented and tested but is **not yet invoked** from this
|
||||
script, leaving two sources of truth for auth logic. The brief explicitly scoped shell-script
|
||||
migration to `resume_session.sh` and `reconcile.sh` only, so this is **out of scope for this round**
|
||||
and not a blocker. Recommendation: wire `create_session.sh` auth to `adapter.auth_ok` in a future
|
||||
increment to close the last auth fan-out.
|
||||
|
||||
### 5.2 reconcile.sh entry-field metadata still agent-branched
|
||||
`reconcile.sh:564-583` still branches on agent for entry metadata (claude `tui` block, agy
|
||||
`mcp_attachments`, `child_pid`). These are agent-specific *metadata* with no corresponding adapter
|
||||
method (no `entry_metadata` defined), so they are arguably not "agent command knowledge" and
|
||||
remain acceptable. Not a blocker.
|
||||
|
||||
### 5.3 Environmental e2e hang (pre-existing, not a regression)
|
||||
Orphaned `reconcile.sh --subscribe --idle-timeout 0` processes accumulate from the
|
||||
subprocess-spawning test suites (test_tier2/test_b4/test_uuid). These caused the prior review's
|
||||
environmental hang and are a pre-existing infrastructure issue, **not** a regression introduced by
|
||||
this change. All orphans were cleaned (0 remaining) before final test runs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Lint / Compile / Loss Checks
|
||||
|
||||
- **Lint/compile**: `py_compile` clean on `base.py`, all 4 adapters, `__main__.py`,
|
||||
`verify_session.py`, `workspace_uuid.py`, `atomic_yaml.py`.
|
||||
- **No lost functionality**: removed `mam_session_iso_root` (lib.sh), `iso_root_of`
|
||||
(workspace_uuid.py), isolation validity check (atomic_yaml.py) — all consumers of the
|
||||
deprecated `isolation.root` row; removed tests aligned with removed features.
|
||||
- **No orphaned imports**: adapters import `os/json/glob/sqlite3/subprocess/shutil` as needed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Behavioral-Change Assessment
|
||||
|
||||
The `materialized` parameter in `resume_spec` is a deliberate, contract-tested behavioral
|
||||
improvement: when the session artifact is absent (`verify_artifact` False), the adapter starts a
|
||||
fresh session bound to the UUID (`--session-id` for claude, bare `-i` for cline) instead of
|
||||
attempting to resume a non-existent history (`-r`/`--id`). This avoids resume failures on missing
|
||||
artifacts. The fallback case in `resume_session.sh:86-92` preserves the materialized forms, so the
|
||||
shell and adapter agree when artifacts exist.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verdict
|
||||
|
||||
All 4 prior blocking issues are resolved with verified code + passing contract tests. 132
|
||||
change-relevant tests pass (0 failures). Documentation is synchronized. The one remaining item
|
||||
(create_session.sh auth wiring) is explicitly out of the brief's scope and non-blocking. No
|
||||
design-level rework is needed.
|
||||
|
||||
[VERDICT: PASS]
|
||||
+19
-53
@@ -60,7 +60,7 @@ done
|
||||
|
||||
# Central TUI dialog and readiness validation tokens (OP-6)
|
||||
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
|
||||
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|projects'
|
||||
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome'
|
||||
|
||||
# Workspace-relative defaults with environment overrides (Phase Z)
|
||||
HOME_DIR="${HOME_DIR:-$HOME}"
|
||||
@@ -1097,25 +1097,6 @@ mam_workspace_key() {
|
||||
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mam_session_iso_root <session_name>
|
||||
# ---------------------------------------------------------------------------
|
||||
mam_session_iso_root() {
|
||||
MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import json, os
|
||||
name = os.environ.get('MAM_ISO_SESSION', '')
|
||||
try:
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
except Exception:
|
||||
d = {}
|
||||
for s in (d.get('herdr_sessions') or []):
|
||||
if s.get('name') == name:
|
||||
iso = s.get('isolation')
|
||||
if isinstance(iso, dict) and iso.get('root'):
|
||||
print(iso['root'])
|
||||
break
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_session_name <workspace> <agent> [role]
|
||||
@@ -1354,18 +1335,14 @@ capture_conversation_id() {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session isolation — Universal Global Config (Option A, Job 536a6625)
|
||||
# Session isolation — Universal Global Config (Option B, P3-1)
|
||||
#
|
||||
# Config-home isolation (.mam/agent_homes/<uuid>/) was removed in favor of:
|
||||
# Config-home isolation (.mam/agent_homes/<uuid>/) and legacy isolation.root
|
||||
# row consumers were completely deprecated and removed in favor of:
|
||||
# 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini,
|
||||
# ~/.hermes, ~/.cline user configuration and credential stores.
|
||||
# 2. Process Isolation: each agent-workspace pair runs in its own herdr pane.
|
||||
# 3. Conversation Isolation: session UUIDs discriminate conversation history.
|
||||
# The backward-compat stubs (provision_isolation / isolation_lever /
|
||||
# isolation_env_prefix / isolation_cmd_args) were removed in P2-2 (C-3a);
|
||||
# they had zero production callers. The `isolation.root` row field is still
|
||||
# consumed (C-3b) — see verify_session_uuid / find_workspace_uuid /
|
||||
# mam_session_iso_root / stop_session.sh purge guard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1528,11 +1505,22 @@ start_watchdog() {
|
||||
}
|
||||
|
||||
# wait_for_tui_ready <session_name> <agent>
|
||||
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
|
||||
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
|
||||
wait_for_tui_ready() {
|
||||
local sess="$1" agent="$2"
|
||||
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)).
|
||||
local tokens="${MAM_READY_TOKENS:-}"
|
||||
if [ -z "$tokens" ]; then
|
||||
local _facts=""
|
||||
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
|
||||
tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
||||
[ -n "$tokens" ] && eval "tokens=$tokens"
|
||||
fi
|
||||
if [ -z "$tokens" ]; then
|
||||
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
|
||||
return 1
|
||||
fi
|
||||
local i
|
||||
|
||||
for i in {1..30}; do
|
||||
if _pane_dialog_open "$sess"; then
|
||||
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
|
||||
@@ -1545,32 +1533,10 @@ wait_for_tui_ready() {
|
||||
local content
|
||||
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
|
||||
if [ -n "$content" ]; then
|
||||
case "$agent" in
|
||||
claude)
|
||||
if echo "$content" | grep -E -q "$_MAM_READY_TOKENS_CLAUDE" 2>/dev/null; then
|
||||
echo "✅ Claude TUI detected ready."
|
||||
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then
|
||||
echo "✅ $agent TUI detected ready."
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
if echo "$content" | grep -q "Antigravity" 2>/dev/null; then
|
||||
echo "✅ Antigravity TUI detected ready."
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
hermes)
|
||||
if echo "$content" | grep -q "Hermes" 2>/dev/null; then
|
||||
echo "✅ Hermes TUI detected ready."
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
cline)
|
||||
if echo "$content" | grep -E -q "Cline|history|Chat|What can I do|slash commands" 2>/dev/null; then
|
||||
echo "✅ Cline TUI detected ready."
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
|
||||
|
||||
import sys, json
|
||||
import sys, json, shlex
|
||||
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
|
||||
|
||||
def main():
|
||||
@@ -16,11 +16,15 @@ def main():
|
||||
print(f"ERROR: Unknown agent {agent_name!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# Emit shell-eval friendly facts
|
||||
print(f"AGENT_NAME={adapter.name}")
|
||||
print(f"OWN_KEY={adapter.own_key}")
|
||||
print(f"INPUT_PROMPT={adapter.input_prompt or ''}")
|
||||
print(f"INPUT_PLACEHOLDER={adapter.input_placeholder or ''}")
|
||||
print(f"INPUT_RULE_PATTERN={adapter.input_rule_pattern or ''}")
|
||||
q = shlex.quote
|
||||
print(f"MAM_AGENT_NAME={q(adapter.name)}")
|
||||
print(f"MAM_OWN_KEY={q(adapter.own_key)}")
|
||||
print(f"MAM_INPUT_PROMPT={q(adapter.input_prompt or '')}")
|
||||
print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}")
|
||||
print(f"MAM_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}")
|
||||
print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}")
|
||||
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
|
||||
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
|
||||
|
||||
elif cmd == 'resolve':
|
||||
name = sys.argv[2] if len(sys.argv) > 2 else ''
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# agy.py — Antigravity (agy) agent adapter
|
||||
|
||||
from lib_py.agents.base import BaseAgentAdapter
|
||||
import os, json, sqlite3, shutil
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||
|
||||
class AgyAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
@@ -11,6 +11,22 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
||||
def own_key(self) -> str:
|
||||
return 'agy_conversation_id_own'
|
||||
|
||||
@property
|
||||
def ready_tokens(self) -> str:
|
||||
return 'Antigravity'
|
||||
|
||||
@property
|
||||
def exit_key(self) -> str:
|
||||
return 'Exit'
|
||||
|
||||
@property
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'antigravity-cli'
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('conversation_id', 'conversation_db', 'conversation_brain_dir')
|
||||
|
||||
@property
|
||||
def input_prompt(self) -> str:
|
||||
return '>'
|
||||
@@ -22,3 +38,71 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
def input_rule_pattern(self) -> str:
|
||||
return '─{10,}'
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.home_dir}/.gemini/antigravity-cli/conversations/{uuid}.db"
|
||||
|
||||
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
path = self.artifact_path(uuid, ctx)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||
return False
|
||||
if ctx.mode == "discover":
|
||||
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
cache_match = False
|
||||
if os.path.exists(lc):
|
||||
try:
|
||||
with open(lc) as f:
|
||||
lc_data = json.load(f)
|
||||
cache_match = (lc_data.get(ctx.cwd) == uuid)
|
||||
except Exception:
|
||||
cache_match = False
|
||||
if not cache_match:
|
||||
if uuid in (ctx.row.get("_sibling_claimed_uuids") or []):
|
||||
return False
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
||||
conn.close()
|
||||
if not r or r[0] < 1:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
purged = []
|
||||
db = self.artifact_path(uuid, ctx)
|
||||
if os.path.exists(db):
|
||||
os.remove(db)
|
||||
purged.append(db)
|
||||
brain = f"{ctx.home_dir}/.gemini/antigravity-cli/brain/{uuid}"
|
||||
if os.path.isdir(brain):
|
||||
shutil.rmtree(brain, ignore_errors=True)
|
||||
purged.append(brain)
|
||||
return purged
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
return f"{binary} --dangerously-skip-permissions"
|
||||
|
||||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||
if materialized and session_uuid:
|
||||
return f"{binary} --dangerously-skip-permissions --conversation {session_uuid}"
|
||||
return f"{binary} --dangerously-skip-permissions"
|
||||
|
||||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||
home = os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
|
||||
return os.path.exists(f"{home}/.gemini/oauth_creds.json") or os.path.exists(f"{home}/.gemini/antigravity-cli/antigravity-oauth-token")
|
||||
|
||||
def discover(self, ctx: DiscoveryContext) -> list:
|
||||
lc = f"{ctx.home_dir}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
if os.path.exists(lc):
|
||||
try:
|
||||
with open(lc) as f:
|
||||
cand = json.load(f).get(ctx.workspace)
|
||||
if cand and self.verify_artifact(cand, ctx):
|
||||
return [cand]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# claude.py — Claude Code agent adapter
|
||||
|
||||
from lib_py.agents.base import BaseAgentAdapter
|
||||
import os, json, glob, subprocess
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
@@ -11,6 +12,22 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
def own_key(self) -> str:
|
||||
return 'claude_session_id_own'
|
||||
|
||||
@property
|
||||
def ready_tokens(self) -> str:
|
||||
return 'Anthropic|Assistant|Chat|Welcome'
|
||||
|
||||
@property
|
||||
def exit_key(self) -> str:
|
||||
return '/exit'
|
||||
|
||||
@property
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'claude-code'
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')
|
||||
|
||||
@property
|
||||
def input_prompt(self) -> str:
|
||||
return '❯'
|
||||
@@ -22,3 +39,83 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
def input_rule_pattern(self) -> str:
|
||||
return '─{10,}'
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
|
||||
|
||||
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
path = self.artifact_path(uuid, ctx)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||
return False
|
||||
try:
|
||||
valid_session = False
|
||||
found_cwd = None
|
||||
with open(path) as f:
|
||||
for _ in range(50):
|
||||
line = f.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
if payload.get("sessionId") == uuid:
|
||||
valid_session = True
|
||||
if payload.get("cwd"):
|
||||
found_cwd = payload.get("cwd")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not valid_session:
|
||||
return False
|
||||
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
jsonl = self.artifact_path(uuid, ctx)
|
||||
if os.path.exists(jsonl):
|
||||
os.remove(jsonl)
|
||||
return [jsonl]
|
||||
return []
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
if use_wrapper or not session_uuid:
|
||||
return f"{binary} --dangerously-skip-permissions"
|
||||
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
|
||||
|
||||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||
if materialized and session_uuid:
|
||||
return f"{binary} --dangerously-skip-permissions -r {session_uuid}"
|
||||
elif session_uuid:
|
||||
return f"{binary} --dangerously-skip-permissions --session-id {session_uuid}"
|
||||
return f"{binary} --dangerously-skip-permissions"
|
||||
|
||||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||
if run_cmd is not None:
|
||||
try:
|
||||
rc, stdout, stderr = run_cmd(['claude', 'auth', 'status'])
|
||||
return rc == 0 and ('"loggedIn":true' in (stdout or '').replace(' ', ''))
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
res = subprocess.run(['claude', 'auth', 'status'], capture_output=True, text=True)
|
||||
return res.returncode == 0 and ('"loggedIn":true' in (res.stdout or '').replace(' ', ''))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def discover(self, ctx: DiscoveryContext) -> list:
|
||||
key = workspace_key(ctx.workspace)
|
||||
proj = f"{ctx.claude_dir}/{key}"
|
||||
candidates = []
|
||||
if os.path.isdir(proj):
|
||||
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||||
cand = os.path.basename(j)[:-6]
|
||||
if cand and self.verify_artifact(cand, ctx):
|
||||
candidates.append(cand)
|
||||
return candidates
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# cline.py — Cline agent adapter
|
||||
|
||||
from lib_py.agents.base import BaseAgentAdapter
|
||||
import os, json, shutil, glob
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
class ClineAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
@@ -11,6 +12,22 @@ class ClineAgentAdapter(BaseAgentAdapter):
|
||||
def own_key(self) -> str:
|
||||
return 'cline_conversation_id_own'
|
||||
|
||||
@property
|
||||
def ready_tokens(self) -> str:
|
||||
return 'Cline|history|Chat|What can I do|slash commands'
|
||||
|
||||
@property
|
||||
def exit_key(self) -> str:
|
||||
return '/exit'
|
||||
|
||||
@property
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'cline-agent'
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id',)
|
||||
|
||||
@property
|
||||
def input_prompt(self) -> str:
|
||||
return '❯'
|
||||
@@ -22,3 +39,62 @@ class ClineAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
def input_rule_pattern(self) -> str:
|
||||
return '─{10,}'
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.home_dir}/.cline/data/sessions/{uuid}/{uuid}.json"
|
||||
|
||||
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
path = self.artifact_path(uuid, ctx)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
if ctx.epoch and os.path.getmtime(path) < ctx.epoch:
|
||||
return False
|
||||
try:
|
||||
with open(path) as f:
|
||||
sdata = json.load(f)
|
||||
if sdata.get("session_id") != uuid:
|
||||
return False
|
||||
found_cwd = sdata.get("cwd") or sdata.get("workspace_root")
|
||||
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
purged = []
|
||||
sessions_dir = f"{ctx.home_dir}/.cline/data/sessions/{uuid}"
|
||||
if os.path.isdir(sessions_dir):
|
||||
shutil.rmtree(sessions_dir)
|
||||
purged.append(sessions_dir)
|
||||
return purged
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
return f"{binary} -i"
|
||||
|
||||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||
if materialized and session_uuid:
|
||||
return f"{binary} -i --id {session_uuid}"
|
||||
return f"{binary} -i"
|
||||
|
||||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||
return True
|
||||
|
||||
def discover(self, ctx: DiscoveryContext) -> list:
|
||||
sessions_dir = f"{ctx.home_dir}/.cline/data/sessions"
|
||||
if os.path.isdir(sessions_dir):
|
||||
files = []
|
||||
for folder in glob.glob(f"{sessions_dir}/*"):
|
||||
if os.path.isdir(folder):
|
||||
fn = os.path.basename(folder)
|
||||
jf = f"{folder}/{fn}.json"
|
||||
if os.path.exists(jf):
|
||||
files.append(jf)
|
||||
files.sort(key=os.path.getmtime, reverse=True)
|
||||
candidates = []
|
||||
for j in files:
|
||||
cand = os.path.basename(j)[:-5]
|
||||
if cand and self.verify_artifact(cand, ctx):
|
||||
candidates.append(cand)
|
||||
return candidates
|
||||
return []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# hermes.py — Hermes agent adapter
|
||||
|
||||
from lib_py.agents.base import BaseAgentAdapter
|
||||
import os, sys, sqlite3
|
||||
from typing import Optional, Any
|
||||
from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext
|
||||
from lib_py.verify_session import workspace_key
|
||||
|
||||
class HermesAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
@@ -10,3 +11,86 @@ class HermesAgentAdapter(BaseAgentAdapter):
|
||||
@property
|
||||
def own_key(self) -> str:
|
||||
return 'hermes_conversation_id_own'
|
||||
|
||||
@property
|
||||
def ready_tokens(self) -> str:
|
||||
return 'Hermes'
|
||||
|
||||
@property
|
||||
def exit_key(self) -> str:
|
||||
return '/exit'
|
||||
|
||||
@property
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'hermes-agent'
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id',)
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.home_dir}/.hermes/sessions/session_{uuid}.json"
|
||||
|
||||
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
hdb = f"{ctx.home_dir}/.hermes/state.db"
|
||||
if not os.path.exists(hdb):
|
||||
return False
|
||||
if ctx.epoch and os.path.getmtime(hdb) < ctx.epoch:
|
||||
return False
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT cwd FROM sessions WHERE id=?", (uuid,)).fetchone()
|
||||
conn.close()
|
||||
if not r:
|
||||
return False
|
||||
found_cwd = r[0]
|
||||
if found_cwd and workspace_key(found_cwd) != workspace_key(ctx.cwd):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
purged = []
|
||||
json_file = self.artifact_path(uuid, ctx)
|
||||
if os.path.exists(json_file):
|
||||
os.remove(json_file)
|
||||
purged.append(json_file)
|
||||
hdb = f"{ctx.home_dir}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
conn.execute("DELETE FROM sessions WHERE id=?", (uuid,))
|
||||
conn.execute("DELETE FROM messages WHERE session_id=?", (uuid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
purged.append(f"db records for session: {uuid}")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"WARN: purge hermes db records failed: {e}\n")
|
||||
return purged
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
return binary
|
||||
|
||||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||
if materialized and session_uuid:
|
||||
return f"{binary} --resume {session_uuid}"
|
||||
return binary
|
||||
|
||||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||
return True
|
||||
|
||||
def discover(self, ctx: DiscoveryContext) -> list:
|
||||
hdb = f"{ctx.home_dir}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ctx.workspace,)).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
cand = r[0]
|
||||
if cand and self.verify_artifact(cand, ctx):
|
||||
return [cand]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
@@ -13,10 +13,24 @@ class SpawnSpec:
|
||||
self.env = env or {}
|
||||
|
||||
class DiscoveryContext:
|
||||
def __init__(self, workspace: str, agent_name: str, home_dir: Optional[str] = None):
|
||||
def __init__(self, workspace: str, agent_name: str = "", home_dir: Optional[str] = None,
|
||||
claude_dir: Optional[str] = None, epoch: int = 0,
|
||||
row: Optional[Dict[str, Any]] = None, mode: str = "discover"):
|
||||
self.workspace = workspace
|
||||
self.agent_name = agent_name
|
||||
self.home_dir = resolve_home(home_dir)
|
||||
self.claude_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{self.home_dir}/.claude/projects")
|
||||
self.epoch = epoch
|
||||
self.row = row or {}
|
||||
self.mode = mode
|
||||
|
||||
@property
|
||||
def ws_key(self) -> str:
|
||||
return workspace_key(self.workspace)
|
||||
|
||||
@property
|
||||
def cwd(self) -> str:
|
||||
return (self.row.get("pane", {}).get("cwd", "") if isinstance(self.row, dict) else "") or self.workspace
|
||||
|
||||
class BaseAgentAdapter:
|
||||
@property
|
||||
@@ -27,6 +41,22 @@ class BaseAgentAdapter:
|
||||
def own_key(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def ready_tokens(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def exit_key(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def delegate_agent_key(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def input_prompt(self) -> Optional[str]:
|
||||
return None
|
||||
@@ -51,3 +81,24 @@ class BaseAgentAdapter:
|
||||
|
||||
def verify_session(self, ws: str, uuid: str, row: Optional[Dict[str, Any]] = None, mode: str = "discover") -> bool:
|
||||
return verify_session_uuid(ws, self.name, uuid, row=row, mode=mode)
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_artifact(self, uuid: str, ctx: DiscoveryContext) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> list:
|
||||
raise NotImplementedError
|
||||
|
||||
def spawn_spec(self, binary: str, session_uuid: str = "", use_wrapper: bool = False) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def resume_spec(self, binary: str, session_uuid: str, materialized: bool = False) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def auth_ok(self, run_cmd: Optional[Any] = None) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def discover(self, ctx: DiscoveryContext) -> list:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -27,10 +27,6 @@ def atomic_dump_yaml_main():
|
||||
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
|
||||
if not isinstance(s.get('pane'), dict):
|
||||
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} missing pane")
|
||||
iso = s.get('isolation')
|
||||
if iso is not None:
|
||||
if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'):
|
||||
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root")
|
||||
orc_uuids = d.get('orchestrator_uuids')
|
||||
if orc_uuids is not None:
|
||||
if not isinstance(orc_uuids, list):
|
||||
|
||||
@@ -83,12 +83,11 @@ from lib_py.paths import resolve_home
|
||||
|
||||
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
|
||||
import os, json, sqlite3
|
||||
home = resolve_home(home_dir)
|
||||
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
|
||||
row = row or {}
|
||||
_iso = row.get("isolation")
|
||||
iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None
|
||||
from lib_py.paths import resolve_home
|
||||
from lib_py.agents.registry import get_adapter
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
|
||||
row = row or {}
|
||||
epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0
|
||||
cwd = row.get("pane", {}).get("cwd", "") or ws
|
||||
|
||||
@@ -103,104 +102,9 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
|
||||
and not row.get("session_id_verified")):
|
||||
return True
|
||||
|
||||
if agent == "claude":
|
||||
base = (iso_root + "/projects") if iso_root else c_dir
|
||||
key = workspace_key(ws)
|
||||
path = f"{base}/{key}/{uuid}.jsonl"
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
if epoch and os.path.getmtime(path) < epoch:
|
||||
return False
|
||||
try:
|
||||
valid_session = False
|
||||
found_cwd = None
|
||||
with open(path) as f:
|
||||
for _ in range(50):
|
||||
line = f.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
if payload.get("sessionId") == uuid:
|
||||
valid_session = True
|
||||
if payload.get("cwd"):
|
||||
found_cwd = payload.get("cwd")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not valid_session:
|
||||
return False
|
||||
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
|
||||
return False
|
||||
except Exception:
|
||||
adapter = get_adapter(agent)
|
||||
if not adapter:
|
||||
return False
|
||||
|
||||
elif agent == "agy":
|
||||
base = f"{iso_root or home}/.gemini/antigravity-cli/conversations"
|
||||
path = f"{base}/{uuid}.db"
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
if epoch and os.path.getmtime(path) < epoch:
|
||||
return False
|
||||
if mode == "discover":
|
||||
lc = f"{iso_root or home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
cache_match = False
|
||||
if os.path.exists(lc):
|
||||
try:
|
||||
with open(lc) as f:
|
||||
lc_data = json.load(f)
|
||||
cache_match = (lc_data.get(cwd) == uuid)
|
||||
except Exception:
|
||||
cache_match = False
|
||||
if not cache_match:
|
||||
if uuid in (row.get("_sibling_claimed_uuids") or []):
|
||||
return False
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
||||
conn.close()
|
||||
if not r or r[0] < 1:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
elif agent == "hermes":
|
||||
hdb = f"{iso_root or home}/.hermes/state.db"
|
||||
if not os.path.exists(hdb):
|
||||
return False
|
||||
if epoch and os.path.getmtime(hdb) < epoch:
|
||||
return False
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT cwd FROM sessions WHERE id=?", (uuid,)).fetchone()
|
||||
conn.close()
|
||||
if not r:
|
||||
return False
|
||||
found_cwd = r[0]
|
||||
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
elif agent == "cline":
|
||||
base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions"
|
||||
path = f"{base}/{uuid}/{uuid}.json"
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
if epoch and os.path.getmtime(path) < epoch:
|
||||
return False
|
||||
try:
|
||||
with open(path) as f:
|
||||
sdata = json.load(f)
|
||||
if sdata.get("session_id") != uuid:
|
||||
return False
|
||||
found_cwd = sdata.get("cwd") or sdata.get("workspace_root")
|
||||
if found_cwd and workspace_key(found_cwd) != workspace_key(cwd):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return True
|
||||
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home_dir, claude_dir=claude_dir, epoch=epoch, row=row, mode=mode)
|
||||
return adapter.verify_artifact(uuid, ctx)
|
||||
|
||||
@@ -11,10 +11,6 @@ OWN_KEY = {
|
||||
'cline': 'cline_conversation_id_own'
|
||||
}
|
||||
|
||||
def iso_root_of(s):
|
||||
iso = s.get('isolation')
|
||||
return iso.get('root') if isinstance(iso, dict) else None
|
||||
|
||||
from lib_py.paths import resolve_home
|
||||
|
||||
def find_workspace_uuid_main():
|
||||
@@ -64,109 +60,24 @@ def find_workspace_uuid_main():
|
||||
for s in sessions:
|
||||
if s.get('name') != target:
|
||||
continue
|
||||
iso = iso_root_of(s)
|
||||
cand = s.get(OWN_KEY.get(agent, ''), None)
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||
emit(cand)
|
||||
if iso:
|
||||
key = workspace_key(ws)
|
||||
if agent == 'claude':
|
||||
for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||||
cand = os.path.basename(j)[:-6]
|
||||
if cand and verify_session_uuid(ws, agent, cand, s):
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True):
|
||||
cand = os.path.basename(j)[:-3]
|
||||
if cand and verify_session_uuid(ws, agent, cand, s):
|
||||
emit(cand)
|
||||
elif agent == 'hermes':
|
||||
hdb = f"{iso}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
cand = r[0]
|
||||
if verify_session_uuid(ws, agent, cand, s):
|
||||
emit(cand)
|
||||
except Exception:
|
||||
pass
|
||||
elif agent == 'cline':
|
||||
for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True):
|
||||
fn = os.path.basename(folder)
|
||||
if os.path.exists(f"{folder}/{fn}.json"):
|
||||
if verify_session_uuid(ws, agent, fn, s):
|
||||
emit(fn)
|
||||
print('')
|
||||
sys.exit(0)
|
||||
|
||||
for s in sessions:
|
||||
name = s.get('name', '')
|
||||
if agent == 'claude' and name.endswith('-creator-claude'):
|
||||
cand = s.get('claude_session_id_own')
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||
emit(cand)
|
||||
if agent == 'agy' and name.endswith('-creator-agy'):
|
||||
cand = s.get('agy_conversation_id_own')
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||
emit(cand)
|
||||
if agent == 'hermes' and name.endswith('-creator-hermes'):
|
||||
cand = s.get('hermes_conversation_id_own')
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||
emit(cand)
|
||||
if agent == 'cline' and name.endswith('-creator-cline'):
|
||||
cand = s.get('cline_conversation_id_own')
|
||||
if name.endswith(f"-creator-{agent}"):
|
||||
cand = s.get(OWN_KEY.get(agent, ''))
|
||||
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
||||
emit(cand)
|
||||
|
||||
if agent == 'claude':
|
||||
key = workspace_key(ws)
|
||||
proj = f"{claude_project_dir}/{key}"
|
||||
if os.path.isdir(proj):
|
||||
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||||
cand = os.path.basename(j)[:-6]
|
||||
if cand and verify_session_uuid(ws, agent, cand):
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
if os.path.exists(lc):
|
||||
cand = None
|
||||
try:
|
||||
cand = json.load(open(lc)).get(ws)
|
||||
except Exception:
|
||||
cand = None
|
||||
if cand and verify_session_uuid(ws, agent, cand):
|
||||
emit(cand)
|
||||
elif agent == 'hermes':
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
cand = None
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
cand = r[0]
|
||||
except Exception:
|
||||
cand = None
|
||||
if cand and verify_session_uuid(ws, agent, cand):
|
||||
emit(cand)
|
||||
elif agent == 'cline':
|
||||
sessions_dir = f"{home}/.cline/data/sessions"
|
||||
if os.path.isdir(sessions_dir):
|
||||
candidates = []
|
||||
for session_folder in glob.glob(f"{sessions_dir}/*"):
|
||||
if os.path.isdir(session_folder):
|
||||
folder_name = os.path.basename(session_folder)
|
||||
json_file = f"{session_folder}/{folder_name}.json"
|
||||
if os.path.exists(json_file):
|
||||
candidates.append(json_file)
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
for j in candidates:
|
||||
cand = os.path.basename(j)[:-5]
|
||||
if cand and verify_session_uuid(ws, agent, cand):
|
||||
from lib_py.agents.registry import get_adapter
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
|
||||
adapter = get_adapter(agent)
|
||||
if adapter:
|
||||
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home, claude_dir=claude_project_dir)
|
||||
for cand in adapter.discover(ctx):
|
||||
emit(cand)
|
||||
|
||||
ai = d.get('agent_identities') if isinstance(d, dict) else None
|
||||
@@ -197,19 +108,6 @@ def find_workspace_uuid_main():
|
||||
|
||||
ai_agent = ai.get(agent) or {}
|
||||
if ai_agent.get('project_cwd') == ws:
|
||||
if agent == 'claude':
|
||||
cand = ai_agent.get('session_id')
|
||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
cand = ai_agent.get('conversation_id')
|
||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
||||
emit(cand)
|
||||
elif agent == 'hermes':
|
||||
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
|
||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
||||
emit(cand)
|
||||
elif agent == 'cline':
|
||||
cand = ai_agent.get('session_id') or ai_agent.get('conversation_id')
|
||||
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
||||
emit(cand)
|
||||
|
||||
@@ -156,12 +156,18 @@ if [ "$AGENT" = "claude" ]; then
|
||||
SESSION_UUID="$(mam_gen_uuid)"
|
||||
fi
|
||||
|
||||
# Retrieve agent facts once
|
||||
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$AGENT" 2>/dev/null || true)"
|
||||
|
||||
CMD_FULL="$("$(_delegate_py_bin)" -c "from lib_py.agents.registry import get_adapter; a = get_adapter('$AGENT'); print(a.spawn_spec('$RESOLVED_BIN', '$SESSION_UUID', False) if a else '')" 2>/dev/null || true)"
|
||||
if [ -z "$CMD_FULL" ]; then
|
||||
case "$AGENT" in
|
||||
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
|
||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
||||
hermes) CMD_FULL="${RESOLVED_BIN}" ;;
|
||||
cline) CMD_FULL="${RESOLVED_BIN} -i" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
spawn() {
|
||||
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
|
||||
@@ -238,16 +244,7 @@ fi
|
||||
# agent-sessions.yaml 에 append
|
||||
DELEGATE_JOB_ID=""
|
||||
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
|
||||
delegate_agent=""
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
delegate_agent="claude-code"
|
||||
elif [ "$AGENT" = "hermes" ]; then
|
||||
delegate_agent="hermes-agent"
|
||||
elif [ "$AGENT" = "cline" ]; then
|
||||
delegate_agent="cline-agent"
|
||||
else
|
||||
delegate_agent="antigravity-cli"
|
||||
fi
|
||||
delegate_agent="${MAM_DELEGATE_AGENT_KEY:-antigravity-cli}"
|
||||
agent_session="herdr:$SESSION_NAME"
|
||||
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
|
||||
echo "Submitted delegated job: $DELEGATE_JOB_ID"
|
||||
|
||||
@@ -433,14 +433,12 @@ def pane_meta(session, srv):
|
||||
return None
|
||||
|
||||
|
||||
from lib_py.agents.registry import get_adapter, own_key as _get_own_key, agent_of_row
|
||||
|
||||
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
|
||||
own_key = {
|
||||
'claude': 'claude_session_id_own',
|
||||
'agy': 'agy_conversation_id_own',
|
||||
'hermes': 'hermes_conversation_id_own',
|
||||
'cline': 'cline_conversation_id_own'
|
||||
}[agent]
|
||||
s[own_key] = uuid
|
||||
own_key_field = _get_own_key(agent)
|
||||
if own_key_field:
|
||||
s[own_key_field] = uuid
|
||||
s['last_visible_status'] = 'pinned'
|
||||
resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'),
|
||||
'--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run']
|
||||
@@ -539,14 +537,8 @@ if herdr_confirmed:
|
||||
ws_root_abs = os.path.realpath(workspace_root)
|
||||
if not pane_cwd_abs or not (pane_cwd_abs == ws_root_abs or pane_cwd_abs.startswith(ws_root_abs + os.sep)):
|
||||
continue
|
||||
if agent == 'claude':
|
||||
cmd_full = 'claude --dangerously-skip-permissions'
|
||||
elif agent == 'agy':
|
||||
cmd_full = 'agy --dangerously-skip-permissions'
|
||||
elif agent == 'hermes':
|
||||
cmd_full = 'hermes'
|
||||
elif agent == 'cline':
|
||||
cmd_full = 'cline -i'
|
||||
_adapter = get_adapter(agent)
|
||||
cmd_full = _adapter.spawn_spec(agent) if _adapter else agent
|
||||
server_opt = f"-L {srv} " if srv != 'default' else ""
|
||||
# The shim resolves this from the pane's root process. Fall back to now
|
||||
# only if that failed: 'now' can merely over-estimate creation time,
|
||||
@@ -596,24 +588,10 @@ if herdr_confirmed:
|
||||
actions.append(f"registered: {name}")
|
||||
|
||||
def row_agent(s):
|
||||
cmd = ((s.get('pane') or {}).get('cmd') or '').strip()
|
||||
if cmd in ('claude', 'agy', 'hermes', 'cline'):
|
||||
return cmd
|
||||
full = ((s.get('pane') or {}).get('cmd_full') or '')
|
||||
for a in ('claude', 'agy', 'hermes', 'cline'):
|
||||
if a in full:
|
||||
return a
|
||||
name = s.get('name', '')
|
||||
for a in ('claude', 'agy', 'hermes', 'cline'):
|
||||
if name.endswith('-creator-' + a):
|
||||
return a
|
||||
return None
|
||||
return agent_of_row(s)
|
||||
|
||||
OWN_KEY_BY_AGENT = {
|
||||
'claude': 'claude_session_id_own',
|
||||
'agy': 'agy_conversation_id_own',
|
||||
'hermes': 'hermes_conversation_id_own',
|
||||
'cline': 'cline_conversation_id_own'
|
||||
a: _get_own_key(a) for a in ('claude', 'agy', 'hermes', 'cline')
|
||||
}
|
||||
|
||||
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
|
||||
|
||||
@@ -80,28 +80,17 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
|
||||
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
CLAUDE_ID_FLAG="-r"
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
_ws_key="$(mam_workspace_key "$WORKSPACE")"
|
||||
_iso_root="$(mam_session_iso_root "$SESSION_NAME" 2>/dev/null || true)"
|
||||
if [ -n "$_iso_root" ]; then
|
||||
_proj_dir="$_iso_root/projects"
|
||||
else
|
||||
_proj_dir="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
|
||||
fi
|
||||
if [ ! -f "${_proj_dir}/${_ws_key}/${UUID}.jsonl" ]; then
|
||||
CLAUDE_ID_FLAG="--session-id"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Determine CMD_FULL
|
||||
# Determine CMD_FULL via adapter
|
||||
CMD_FULL="$("$(_delegate_py_bin)" -c "from lib_py.agents.registry import get_adapter; from lib_py.agents.base import DiscoveryContext; a = get_adapter('$AGENT'); ctx = DiscoveryContext('$WORKSPACE', '$AGENT'); mat = a.verify_artifact('$UUID', ctx) if a else False; print(a.resume_spec('$RESOLVED_BIN', '$UUID', mat) if a else '')" 2>/dev/null || true)"
|
||||
if [ -z "$CMD_FULL" ]; then
|
||||
case "$AGENT" in
|
||||
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
|
||||
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;;
|
||||
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
|
||||
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
|
||||
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
|
||||
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Validate binary exists and is executable
|
||||
if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then
|
||||
|
||||
@@ -173,13 +173,7 @@ delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating"
|
||||
graceful_stop() {
|
||||
local pane_pid exitkey
|
||||
pane_pid=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
|
||||
case "$AGENT" in
|
||||
claude) exitkey="/exit" ;;
|
||||
agy) exitkey="Exit" ;;
|
||||
hermes) exitkey="/exit" ;;
|
||||
cline) exitkey="/exit" ;;
|
||||
*) exitkey="/exit" ;;
|
||||
esac
|
||||
exitkey="$("$(_delegate_py_bin)" -c "from lib_py.agents.registry import get_adapter; a = get_adapter('$AGENT'); print(a.exit_key if a else '/exit')" 2>/dev/null || echo "/exit")"
|
||||
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
|
||||
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain"
|
||||
_wait_session_gone "$SESSION_NAME" 5 || true
|
||||
@@ -272,83 +266,28 @@ if captured and not purge:
|
||||
target['cline_conversation_id_own'] = captured
|
||||
target['resumable'] = True
|
||||
|
||||
# --purge-conversation: 워크스페이스 격리된 UUID 의 디스크 artifact 만 삭제 (P0-C)
|
||||
# T6: stop-purge 시 격리 디렉터리 청소 및 경로 가드
|
||||
iso = target.get('isolation')
|
||||
if purge and iso:
|
||||
iso_root = iso.get('root')
|
||||
iso_uuid = iso.get('uuid')
|
||||
if iso_root and iso_uuid:
|
||||
ws_abs = os.path.abspath(ws) if ws else ""
|
||||
expected_homes_dir = os.path.join(ws_abs, '.mam', 'agent_homes')
|
||||
expected_iso_root = os.path.join(expected_homes_dir, iso_uuid)
|
||||
if (os.path.abspath(iso_root) == os.path.abspath(expected_iso_root) and
|
||||
os.path.abspath(iso_root).startswith(os.path.abspath(expected_homes_dir) + os.sep)):
|
||||
if os.path.isdir(iso_root):
|
||||
shutil.rmtree(iso_root)
|
||||
print(f"purged isolated home: {iso_root}", flush=True)
|
||||
else:
|
||||
print(f"WARN: isolated home path check failed: {iso_root}", flush=True)
|
||||
|
||||
if purge and purge_uuid:
|
||||
if agent == 'claude':
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
jsonl = f"{claude_project_dir}/{key}/{purge_uuid}.jsonl"
|
||||
if os.path.exists(jsonl):
|
||||
os.remove(jsonl)
|
||||
print(f"purged: {jsonl}", flush=True)
|
||||
target['claude_session_id_own'] = None
|
||||
elif agent == 'agy':
|
||||
db = f"{home}/.gemini/antigravity-cli/conversations/{purge_uuid}.db"
|
||||
if os.path.exists(db):
|
||||
os.remove(db)
|
||||
print(f"purged: {db}", flush=True)
|
||||
brain = f"{home}/.gemini/antigravity-cli/brain/{purge_uuid}"
|
||||
if os.path.isdir(brain):
|
||||
shutil.rmtree(brain, ignore_errors=True)
|
||||
print(f"purged: {brain}", flush=True)
|
||||
target['agy_conversation_id_own'] = None
|
||||
elif agent == 'hermes':
|
||||
json_file = f"{home}/.hermes/sessions/session_{purge_uuid}.json"
|
||||
if os.path.exists(json_file):
|
||||
os.remove(json_file)
|
||||
print(f"purged: {json_file}", flush=True)
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
import sqlite3
|
||||
hconn = sqlite3.connect(hdb)
|
||||
hconn.execute("DELETE FROM sessions WHERE id=?", (purge_uuid,))
|
||||
hconn.execute("DELETE FROM messages WHERE session_id=?", (purge_uuid,))
|
||||
hconn.commit()
|
||||
hconn.close()
|
||||
print(f"purged db records for session: {purge_uuid}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"WARN: purge hermes db records failed: {e}", flush=True)
|
||||
target['hermes_conversation_id_own'] = None
|
||||
elif agent == 'cline':
|
||||
sessions_dir = f"{home}/.cline/data/sessions/{purge_uuid}"
|
||||
if os.path.isdir(sessions_dir):
|
||||
shutil.rmtree(sessions_dir)
|
||||
print(f"purged: {sessions_dir}", flush=True)
|
||||
target['cline_conversation_id_own'] = None
|
||||
from lib_py.agents.registry import get_adapter
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
adapter = get_adapter(agent)
|
||||
if adapter:
|
||||
ctx = DiscoveryContext(workspace=ws, agent_name=agent, home_dir=home)
|
||||
for item in adapter.purge_artifacts(purge_uuid, ctx):
|
||||
print(f"purged: {item}", flush=True)
|
||||
target[adapter.own_key] = None
|
||||
# agent_identities 는 cache — 이 워크스페이스 것일 때만 비운다
|
||||
ai = (d.get('agent_identities') or {}).get(agent) or {}
|
||||
if ai.get('project_cwd') == ws:
|
||||
if agent == 'claude' and ai.get('session_id') == purge_uuid:
|
||||
if adapter and (ai.get('session_id') == purge_uuid or ai.get('conversation_id') == purge_uuid):
|
||||
for field in adapter.identity_cache_fields:
|
||||
ai.pop(field, None)
|
||||
if 'session_id' in adapter.identity_cache_fields or 'session_jsonl' in adapter.identity_cache_fields:
|
||||
ai['session_id'] = None
|
||||
ai['session_jsonl'] = None
|
||||
ai.pop('session_size_bytes', None)
|
||||
ai.pop('session_lines', None)
|
||||
elif agent == 'agy' and ai.get('conversation_id') == purge_uuid:
|
||||
if 'conversation_id' in adapter.identity_cache_fields:
|
||||
ai['conversation_id'] = None
|
||||
ai['conversation_db'] = None
|
||||
ai['conversation_brain_dir'] = None
|
||||
elif agent == 'hermes' and ai.get('session_id') == purge_uuid:
|
||||
ai['session_id'] = None
|
||||
elif agent == 'cline' and ai.get('session_id') == purge_uuid:
|
||||
ai['session_id'] = None
|
||||
elif purge and not purge_uuid:
|
||||
print("WARN: --purge-conversation requested but no workspace-scoped UUID resolved; nothing purged", flush=True)
|
||||
|
||||
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
||||
|
||||
- **최종 갱신일**: 2026-08-16 (P2-2/C-3a/C-4 격리 빈 스텁 4종 및 미사용 심볼 3종 제거, --isolate no-op 회귀 가드 신설, 전체 256/256 회귀 통과 반영)
|
||||
- **최종 갱신일**: 2026-08-16 (P3-1/A-4 Phase 2 M2~M7 에이전트 지식 계층 및 Option B / C-3b 완료, Reviewer 피드백 반영, 전체 259/259 회귀 통과 반영)
|
||||
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
|
||||
- **총 추적 미해결 과제**: **8건** (아키텍처 2건, 엣지케이스 4건, 오케스트레이션 0건, 레거시 잔재 2건)
|
||||
- **완료된 과제**: **17건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, B-8, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard, Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix, P2-2-C3a-C4-LegacyCleanup)
|
||||
- **총 추적 미해결 과제**: **6건** (아키텍처 1건, 엣지케이스 4건, 오케스트레이션 0건, 레거시 잔재 1건)
|
||||
- **완료된 과제**: **19건** (A-1, A-3, A-4, A-5, B-1, B-3, B-4, B-7, B-8, C-1, C-2, C-3b, O-1, O-2, O-3, O-4-OrcOnboard, Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix, P2-2-C3a-C4-LegacyCleanup)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 1. 🔴 아키텍처 결함 (Architecture Flaws — 2건)
|
||||
## 1. 🔴 아키텍처 결함 (Architecture Flaws — 1건)
|
||||
|
||||
### **A-2: 공개 브로커 + HMAC 인증 Off + 와일드카드 전파**
|
||||
- **현상**: `mqtt_common.py`의 기본 브로커가 공개 서버(`broker.hivemq.com`)이고, 잡 생성 시 `auth_token` 이 **한 번도 발급되지 않아**(실측 26/26 잡이 `auth_token=None`) `verify_hmac` 의 `if not auth_token: return True` 경로가 항상 타집니다. 발행자는 워크스페이스 지문 토픽을 채택하지 않고 전역 `python/mqtt/jobs/<job_id>/events` 로 발행하며, `reconcile.sh:237` 이 같은 전역 토픽을 구독합니다. (HMAC 구현 자체는 정상입니다 — 토큰이 없어 검증이 공허해지는 것이 원인입니다.)
|
||||
- **파급 효과**: 외부에서 유입되는 malicious `error` 이벤트 수신 시 `reconcile.sh`가 라이브 에이전트 pane을 `kill-session`으로 강제 파괴하는 치명적 보안/안정성 위험이 존재합니다.
|
||||
|
||||
### **A-4 (설계 제안): 에이전트 지식 산재 — `BaseAgentAdapter` 어댑터 계층 도입 (Rev.2)**
|
||||
### **A-4 (✅ 완료 — P3-1): 에이전트 지식 산재 — `BaseAgentAdapter` 어댑터 계층 도입 (Rev.2)**
|
||||
|
||||
> 결함 조치가 아니라 **구조 개선 제안**입니다. 상세 설계·실측 근거는 `.mam/jobs/44062a63/claude-reports/report-final.md` 및 `744ac67a` 를 참조하십시오.
|
||||
|
||||
@@ -314,9 +314,9 @@ CHANGES_DIFF=$(
|
||||
### 6.5 ⚠️ 실행 전 반드시 확인할 정정 사항
|
||||
|
||||
|
||||
1. **C-3 은 그대로 실행하면 회귀를 만듭니다.** 항목이 성격이 다른 둘을 묶고 있습니다.
|
||||
1. **C-3 (✅ 완료)**:
|
||||
- **C-3a (✅ 완료)**: `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4종 빈 스텁 및 이를 고정하던 공허한 테스트 4건을 제거하고 `--isolate`/`--no-isolate` no-op 회귀 가드로 대체했습니다.
|
||||
- **C-3b (보류 — A-4 M2 결정 사항)**: `isolation.root` 소비자(`lib.sh` `verify_session_uuid` 의 `iso_root` 분기, `mam_session_iso_root`, `find_workspace_uuid` 격리 분기, `stop_session.sh:277` purge 가드). **b4a1d094 / 44062a63 에서 방금 의도적으로 되살린 코드**이며, 지우면 그 수정이 되돌아갑니다.
|
||||
- **C-3b (✅ 완료 — P3-1 / Option B)**: `isolation.root` 4개 소비자(`lib.sh` `verify_session_uuid` 의 `iso_root` 분기, `mam_session_iso_root`, `find_workspace_uuid` 격리 분기, `stop_session.sh:277` purge 가드 및 `atomic_yaml.py:30-33` 유효성 검사)를 완전히 폐기하고 Universal Global Config 및 어댑터 기반 단일 경로로 이관했습니다.
|
||||
2. **C-4 (✅ 완료)**:
|
||||
- `_HERDR_SHIM_DIR_PATTERN` — **사용 중입니다** (`lib.sh:83` 정의, `lib.sh:105` 사용). 목록대로 지우면 shim 경로 판정이 깨집니다.
|
||||
- `local_herdr` — 참조 0건, **이미 제거됨**.
|
||||
|
||||
@@ -6,9 +6,25 @@
|
||||
|
||||
---
|
||||
|
||||
## 📌 1. 금일 작업 내용 요약
|
||||
### 1) **P3-1 (A-4 Phase 2 / Option B / C-3b / M2~M7): 에이전트 지식 계층 마이그레이션 및 isolation.root 완전 폐기** — **완료**
|
||||
- **배경**: 에이전트별 아티팩트 경로, 검증 로직, 재개/시작 스펙, 토큰, 종료 키, 인증(`auth_ok`), 자동 발견(`discover`)이 스크립트 전반에 하드코딩 산재되어 있던 문제(A-4)와, Universal Global Config 전환 후에도 4개 지점에 남아있던 `isolation.root` 소비자(C-3b)를 완전 폐기하고 어댑터 단일 소스로 일원화.
|
||||
- **주요 구현**:
|
||||
- [`.agents/skills/lib_py/agents/base.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/base.py): `DiscoveryContext` 확장 (`ws_key`, `cwd`, `home_dir`, `claude_dir`, `epoch`, `row`, `mode`), `BaseAgentAdapter` 추상 인터페이스 정의 (`ready_tokens`, `exit_key`, `delegate_agent_key`, `identity_cache_fields`, `artifact_path`, `verify_artifact`, `purge_artifacts`, `spawn_spec`, `resume_spec`, `auth_ok`, `discover`).
|
||||
- [`.agents/skills/lib_py/agents/adapters/`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/adapters/): `claude.py`, `agy.py`, `hermes.py`, `cline.py` 4개 어댑터 구체 클래스 구현 (Cline `resume_spec` `-i --id` 수정 반영, `auth_ok`, `discover` 구현).
|
||||
- [`.agents/skills/lib_py/agents/__main__.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/agents/__main__.py): 8개 `MAM_*` 변수 `shlex.quote` 셸 브리지 완성.
|
||||
- [`.agents/skills/lib_py/verify_session.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/verify_session.py): `adapter.verify_artifact(uuid, ctx)` 위임 및 `iso_root` 분기 제거.
|
||||
- [`.agents/skills/lib_py/workspace_uuid.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/workspace_uuid.py): `iso_root_of` 제거 및 디스크 스캔을 `adapter.discover(ctx)`로 단일화.
|
||||
- [`.agents/skills/lib_py/atomic_yaml.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib_py/atomic_yaml.py): 레거시 `isolation` 유효성 검사 절 제거.
|
||||
- [`.agents/skills/lib.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh): `mam_session_iso_root` 제거, `wait_for_tui_ready` 셀프 컨테인드 토큰 폴백 및 claude `projects` 토큰 제거.
|
||||
- [`.agents/skills/multi-agent-mux-create/scripts/create_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh): `CMD_FULL` (어댑터 `spawn_spec`) 및 `delegate_agent` (`delegate_agent_key`) 이관.
|
||||
- [`.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh): `_iso_root` 분기 완전 제거 및 `CMD_FULL` 을 `adapter.resume_spec`으로 이관.
|
||||
- [`.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh): `row_agent` / `_pin_and_verify_resume` / `OWN_KEY_BY_AGENT` / auto-register `cmd_full` 을 어댑터 레이어로 이관.
|
||||
- [`.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh): `exitkey` (`adapter.exit_key`), `adapter.purge_artifacts`, `adapter.identity_cache_fields` 위임 및 레거시 격리 삭제 블록 제거.
|
||||
- [`tests/test_a4_adapter_contract.py`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/tests/test_a4_adapter_contract.py): facts eval 계약, 어댑터 속성 계약, 복합 아티팩트 삭제(purge_artifacts), spawn/resume_spec 계약, auth_ok 계약, discover 계약 테스트 9종 추가.
|
||||
- [`IMPROVEMENTS.md`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/IMPROVEMENTS.md): A-4 및 C-3b 완료 처리 및 19개 과제 완결 상태 동기화.
|
||||
- **검증**: `pytest` 실행 결과 **259 passed in 493s (100%)**.
|
||||
|
||||
### 1) **P2-2 (C-3a / C-4): 레거시 격리 스텁 및 미사용 심볼 제거** — **완료**
|
||||
### 2) **P2-2 (C-3a / C-4): 레거시 격리 스텁 및 미사용 심볼 제거** — **완료**
|
||||
- **배경**: 격리 구조가 Universal Global Config(Job `536a6625`)로 전환되며 남은 빈 스텁 4종과, 그 빈 출력만 재확인하던 공허한 테스트 4건, 그리고 참조 0회 심볼 3종 정리.
|
||||
- **주요 구현**:
|
||||
- [`.agents/skills/lib.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/lib.sh): 빈 스텁 4종(`provision_isolation`, `isolation_lever`, `isolation_env_prefix`, `isolation_cmd_args`) 및 `_REAL_HERDR_PATH` 대입·export 제거, 주석에 C-3b 경계 명시.
|
||||
|
||||
@@ -45,3 +45,216 @@ def test_agent_of_row_priority():
|
||||
# Priority 3: pane.cmd exact match
|
||||
row3 = {'pane': {'cmd': 'cline'}}
|
||||
assert agent_of_row(row3) == 'cline'
|
||||
|
||||
def test_adapter_required_properties():
|
||||
from lib_py.agents.base import BaseAgentAdapter
|
||||
base = BaseAgentAdapter()
|
||||
for prop in ('name', 'own_key', 'ready_tokens', 'exit_key', 'delegate_agent_key', 'identity_cache_fields'):
|
||||
with pytest.raises(NotImplementedError):
|
||||
getattr(base, prop)
|
||||
|
||||
expected = {
|
||||
'claude': ('Anthropic|Assistant|Chat|Welcome', '/exit', 'claude-code', ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')),
|
||||
'agy': ('Antigravity', 'Exit', 'antigravity-cli', ('conversation_id', 'conversation_db', 'conversation_brain_dir')),
|
||||
'hermes': ('Hermes', '/exit', 'hermes-agent', ('session_id',)),
|
||||
'cline': ('Cline|history|Chat|What can I do|slash commands', '/exit', 'cline-agent', ('session_id',)),
|
||||
}
|
||||
for agent, (toks, exitk, delk, cache_f) in expected.items():
|
||||
adapter = get_adapter(agent)
|
||||
assert adapter is not None
|
||||
assert adapter.ready_tokens == toks
|
||||
assert adapter.exit_key == exitk
|
||||
assert adapter.delegate_agent_key == delk
|
||||
assert adapter.identity_cache_fields == cache_f
|
||||
|
||||
def test_facts_bridge_eval_contract():
|
||||
import subprocess, sys
|
||||
from pathlib import Path
|
||||
env = os.environ.copy()
|
||||
skills_dir = str(Path(__file__).resolve().parent.parent / ".agents" / "skills")
|
||||
env["PYTHONPATH"] = f"{skills_dir}:{env.get('PYTHONPATH', '')}"
|
||||
for agent in ('claude', 'agy', 'hermes', 'cline'):
|
||||
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "facts", agent], capture_output=True, text=True, env=env)
|
||||
assert res.returncode == 0
|
||||
facts_output = res.stdout
|
||||
|
||||
# Verify eval in bash with set -euo pipefail
|
||||
bash_cmd = f"""
|
||||
set -euo pipefail
|
||||
eval {shlex_quote(facts_output)}
|
||||
echo "AGENT=$MAM_AGENT_NAME|OWN=$MAM_OWN_KEY|TOK=$MAM_READY_TOKENS|EXIT=$MAM_EXIT_KEY|DEL=$MAM_DELEGATE_AGENT_KEY|PH=$MAM_INPUT_PLACEHOLDER"
|
||||
"""
|
||||
res_bash = subprocess.run(["bash", "-c", bash_cmd], capture_output=True, text=True)
|
||||
assert res_bash.returncode == 0, f"Bash eval failed for {agent}:\nStdout: {res_bash.stdout}\nStderr: {res_bash.stderr}"
|
||||
if agent == 'cline':
|
||||
assert "PH=Ask anything..." in res_bash.stdout
|
||||
|
||||
def shlex_quote(s):
|
||||
import shlex
|
||||
return shlex.quote(s)
|
||||
|
||||
def test_purge_artifacts_composite(tmp_path):
|
||||
import sqlite3
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
ws = str(tmp_path / "ws")
|
||||
home = str(tmp_path / "home")
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
os.makedirs(home, exist_ok=True)
|
||||
|
||||
# 1. Claude
|
||||
claude_adapter = get_adapter('claude')
|
||||
claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
|
||||
c_path = claude_adapter.artifact_path('uuid-c', claude_ctx)
|
||||
os.makedirs(os.path.dirname(c_path), exist_ok=True)
|
||||
with open(c_path, 'w') as f:
|
||||
f.write('{"sessionId": "uuid-c"}')
|
||||
assert os.path.exists(c_path)
|
||||
purged_c = claude_adapter.purge_artifacts('uuid-c', claude_ctx)
|
||||
assert len(purged_c) == 1
|
||||
assert not os.path.exists(c_path)
|
||||
|
||||
# 2. Agy (both DB file and brain dir)
|
||||
agy_adapter = get_adapter('agy')
|
||||
agy_ctx = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home)
|
||||
agy_db = agy_adapter.artifact_path('uuid-a', agy_ctx)
|
||||
os.makedirs(os.path.dirname(agy_db), exist_ok=True)
|
||||
with open(agy_db, 'w') as f:
|
||||
f.write('mock db')
|
||||
agy_brain = f"{home}/.gemini/antigravity-cli/brain/uuid-a"
|
||||
os.makedirs(agy_brain, exist_ok=True)
|
||||
with open(f"{agy_brain}/note.txt", 'w') as f:
|
||||
f.write('brain note')
|
||||
purged_a = agy_adapter.purge_artifacts('uuid-a', agy_ctx)
|
||||
assert len(purged_a) == 2
|
||||
assert not os.path.exists(agy_db)
|
||||
assert not os.path.exists(agy_brain)
|
||||
|
||||
# 3. Hermes (JSON file and SQLite rows)
|
||||
hermes_adapter = get_adapter('hermes')
|
||||
hermes_ctx = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home)
|
||||
h_json = hermes_adapter.artifact_path('uuid-h', hermes_ctx)
|
||||
os.makedirs(os.path.dirname(h_json), exist_ok=True)
|
||||
with open(h_json, 'w') as f:
|
||||
f.write('{}')
|
||||
h_db = f"{home}/.hermes/state.db"
|
||||
os.makedirs(os.path.dirname(h_db), exist_ok=True)
|
||||
conn = sqlite3.connect(h_db)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT, cwd TEXT)")
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS messages (session_id TEXT, msg TEXT)")
|
||||
conn.execute("INSERT INTO sessions VALUES (?, ?)", ('uuid-h', ws))
|
||||
conn.execute("INSERT INTO messages VALUES (?, ?)", ('uuid-h', 'hello'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
purged_h = hermes_adapter.purge_artifacts('uuid-h', hermes_ctx)
|
||||
assert len(purged_h) == 2
|
||||
assert not os.path.exists(h_json)
|
||||
conn = sqlite3.connect(h_db)
|
||||
assert conn.execute("SELECT count(*) FROM sessions WHERE id='uuid-h'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT count(*) FROM messages WHERE session_id='uuid-h'").fetchone()[0] == 0
|
||||
conn.close()
|
||||
|
||||
# 4. Cline (sessions dir)
|
||||
cline_adapter = get_adapter('cline')
|
||||
cline_ctx = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home)
|
||||
cline_dir = f"{home}/.cline/data/sessions/uuid-cl"
|
||||
os.makedirs(cline_dir, exist_ok=True)
|
||||
with open(f"{cline_dir}/uuid-cl.json", 'w') as f:
|
||||
f.write('{"session_id": "uuid-cl"}')
|
||||
purged_cl = cline_adapter.purge_artifacts('uuid-cl', cline_ctx)
|
||||
assert len(purged_cl) == 1
|
||||
assert not os.path.exists(cline_dir)
|
||||
|
||||
def test_adapter_spawn_and_resume_specs():
|
||||
claude = get_adapter('claude')
|
||||
assert claude.spawn_spec('claude', 'u1') == 'claude --dangerously-skip-permissions --session-id u1'
|
||||
assert claude.spawn_spec('claude', '', use_wrapper=True) == 'claude --dangerously-skip-permissions'
|
||||
assert claude.resume_spec('claude', 'u1', materialized=True) == 'claude --dangerously-skip-permissions -r u1'
|
||||
assert claude.resume_spec('claude', 'u1', materialized=False) == 'claude --dangerously-skip-permissions --session-id u1'
|
||||
|
||||
agy = get_adapter('agy')
|
||||
assert agy.spawn_spec('agy', 'u1') == 'agy --dangerously-skip-permissions'
|
||||
assert agy.resume_spec('agy', 'u1', materialized=True) == 'agy --dangerously-skip-permissions --conversation u1'
|
||||
|
||||
hermes = get_adapter('hermes')
|
||||
assert hermes.spawn_spec('hermes', 'u1') == 'hermes'
|
||||
assert hermes.resume_spec('hermes', 'u1', materialized=True) == 'hermes --resume u1'
|
||||
|
||||
cline = get_adapter('cline')
|
||||
assert cline.spawn_spec('cline', 'u1') == 'cline -i'
|
||||
assert cline.resume_spec('cline', 'u1', materialized=True) == 'cline -i --id u1'
|
||||
assert cline.resume_spec('cline', 'u1', materialized=False) == 'cline -i'
|
||||
|
||||
def test_adapter_auth_ok(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME_DIR", str(tmp_path))
|
||||
# Claude auth runner
|
||||
claude = get_adapter('claude')
|
||||
assert claude.auth_ok(run_cmd=lambda cmd: (0, '{"loggedIn": true}', '')) is True
|
||||
assert claude.auth_ok(run_cmd=lambda cmd: (1, '{"loggedIn": false}', 'error')) is False
|
||||
|
||||
# Agy auth file check
|
||||
agy = get_adapter('agy')
|
||||
assert agy.auth_ok() is False
|
||||
oauth_file = tmp_path / ".gemini" / "oauth_creds.json"
|
||||
oauth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
oauth_file.write_text("{}")
|
||||
assert agy.auth_ok() is True
|
||||
|
||||
# Hermes & Cline always True
|
||||
assert get_adapter('hermes').auth_ok() is True
|
||||
assert get_adapter('cline').auth_ok() is True
|
||||
|
||||
def test_adapter_discover(tmp_path):
|
||||
import sqlite3
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
ws = str(tmp_path / "ws")
|
||||
home = str(tmp_path / "home")
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
os.makedirs(home, exist_ok=True)
|
||||
|
||||
# 1. Claude
|
||||
claude = get_adapter('claude')
|
||||
ctx_c = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
|
||||
c_proj = f"{ctx_c.claude_dir}/{ctx_c.ws_key}"
|
||||
os.makedirs(c_proj, exist_ok=True)
|
||||
with open(f"{c_proj}/u-c1.jsonl", 'w') as f:
|
||||
f.write('{"sessionId": "u-c1"}\n')
|
||||
assert claude.discover(ctx_c) == ['u-c1']
|
||||
|
||||
# 2. Agy
|
||||
agy = get_adapter('agy')
|
||||
ctx_a = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home)
|
||||
a_db = f"{home}/.gemini/antigravity-cli/conversations/u-a1.db"
|
||||
os.makedirs(os.path.dirname(a_db), exist_ok=True)
|
||||
conn = sqlite3.connect(a_db)
|
||||
conn.execute("CREATE TABLE steps (id INT)")
|
||||
conn.execute("INSERT INTO steps VALUES (1)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
os.makedirs(os.path.dirname(lc), exist_ok=True)
|
||||
with open(lc, 'w') as f:
|
||||
import json
|
||||
json.dump({ws: 'u-a1'}, f)
|
||||
assert agy.discover(ctx_a) == ['u-a1']
|
||||
|
||||
# 3. Hermes
|
||||
hermes = get_adapter('hermes')
|
||||
ctx_h = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home)
|
||||
h_db = f"{home}/.hermes/state.db"
|
||||
os.makedirs(os.path.dirname(h_db), exist_ok=True)
|
||||
conn = sqlite3.connect(h_db)
|
||||
conn.execute("CREATE TABLE sessions (id TEXT, cwd TEXT, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||
conn.execute("INSERT INTO sessions (id, cwd) VALUES ('u-h1', ?)", (ws,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert hermes.discover(ctx_h) == ['u-h1']
|
||||
|
||||
# 4. Cline
|
||||
cline = get_adapter('cline')
|
||||
ctx_cl = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home)
|
||||
cl_sess = f"{home}/.cline/data/sessions/u-cl1"
|
||||
os.makedirs(cl_sess, exist_ok=True)
|
||||
with open(f"{cl_sess}/u-cl1.json", 'w') as f:
|
||||
f.write('{"session_id": "u-cl1", "cwd": "' + ws + '"}')
|
||||
assert cline.discover(ctx_cl) == ['u-cl1']
|
||||
|
||||
@@ -219,20 +219,6 @@ def test_o10_revalidate_normal_subagent(mam_sandbox):
|
||||
assert run_verify_uuid(str(mam_sandbox), "claude", sub_uuid, row=row, mode="revalidate", env=env)
|
||||
|
||||
|
||||
# O-11: verify_session_uuid respects isolation root
|
||||
def test_o11_isolation_root_respected(mam_sandbox):
|
||||
orc_uuid = "01eae7cf-1db6-4395-ba48-5fb02f4b6b1f"
|
||||
iso_root = mam_sandbox / "iso"
|
||||
make_claude_transcript(mam_sandbox, orc_uuid, target_dir=iso_root)
|
||||
|
||||
row = {
|
||||
"name": "my-orc",
|
||||
"claude_session_id_own": orc_uuid,
|
||||
"isolation": {"root": str(iso_root), "uuid": "iso-1"},
|
||||
"pane": {"cwd": str(mam_sandbox)}
|
||||
}
|
||||
env = {"AGENT_SESSIONS_YAML": str(mam_sandbox / ".mam" / "agent-sessions.yaml"), "HOME_DIR": str(mam_sandbox)}
|
||||
assert run_verify_uuid(str(mam_sandbox), "claude", orc_uuid, row=row, mode="revalidate", env=env)
|
||||
|
||||
|
||||
# O-12: orc_onboard.sh --uuid explicitly adds UUID
|
||||
|
||||
@@ -293,31 +293,9 @@ d['herdr_sessions'] = [{
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 3: Stop Session (5 Test Cases)
|
||||
# FEATURE 3: Stop Session (4 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_stop_safe_path_checking(mam_sandbox):
|
||||
"""Verify path guards block directory deletion if isolation path check fails."""
|
||||
# Mock a terminated session where isolation root is set outside .mam folder
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-purge-guard-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'},
|
||||
'isolation': {
|
||||
'uuid': 'some-uuid',
|
||||
'root': '/tmp/unauthorized_path_outside_mam'
|
||||
}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
# Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
assert "WARN: isolated home path check failed" in res.stdout
|
||||
|
||||
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
||||
"""Verify that stop_session.sh updates state in SQLite to stopped."""
|
||||
mutation = """
|
||||
|
||||
@@ -342,44 +342,6 @@ def test_t10_resume_workspace_paths(mam_sandbox, mock_herdr, mock_agents):
|
||||
assert f"would spawn:" in res.stdout
|
||||
|
||||
|
||||
def test_t11_legacy_isolation_row(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""T-11: legacy isolation row resolved from isolation.root"""
|
||||
iso_root = str(mam_sandbox / "iso_root")
|
||||
ws = str(mam_sandbox / "iso_ws")
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
|
||||
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
|
||||
proj_dir = os.path.join(iso_root, "projects", ws_key)
|
||||
os.makedirs(proj_dir, exist_ok=True)
|
||||
u_val = str(uuid.uuid4())
|
||||
with open(os.path.join(proj_dir, f"{u_val}.jsonl"), "w") as f:
|
||||
f.write(json.dumps({"type": "queue-operation", "sessionId": u_val}) + "\n")
|
||||
f.write(json.dumps({"type": "user", "sessionId": u_val, "cwd": ws}) + "\n")
|
||||
|
||||
mutation = f"""
|
||||
entry = {{
|
||||
'name': 'iso-session',
|
||||
'status': 'stopped',
|
||||
'role': 'creator',
|
||||
'claude_session_id_own': '{u_val}',
|
||||
'isolation': {{'root': '{iso_root}', 'uuid': 'iso-uuid-1'}},
|
||||
'pane': {{'cwd': '{ws}'}}
|
||||
}}
|
||||
d.setdefault('herdr_sessions', []).append(entry)
|
||||
"""
|
||||
res_m = run_mutation(mam_sandbox, mutation)
|
||||
assert res_m.returncode == 0, f"mutation failed: {res_m.stderr}"
|
||||
|
||||
resolve_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
||||
cmd = [
|
||||
"bash", str(resolve_script),
|
||||
"--workspace", ws,
|
||||
"--agent", "claude",
|
||||
"--session", "iso-session"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == u_val
|
||||
|
||||
|
||||
def test_t12_other_workspace_assigned_row_revalidate_fails(mam_sandbox):
|
||||
|
||||
Reference in New Issue
Block a user