fix(opencode): all issues from first live run resolved
1. API key: opencode doesn't support env: substitution in apiKey — write actual key value to ~/.config/opencode/opencode.jsonc at setup time (file is not committed to git; key sourced from .testenv). 2. Permission system: add permission:"allow" to opencode config (equivalent to --dangerously-skip-permissions) to avoid interactive prompts. 3. Submit key: opencode TUI uses Enter (return) to submit; Ctrl+S not needed. ping_session already uses Enter — keep as is. 4. Startup timing: bump opencode TUI init wait from 4s to 8s so the TUI is fully connected to the server before bootstrap is sent. 5. Backend persistence: LOOP_BACKEND/LOOP_MODEL written to .loop-backend / .loop-model so the watchdog uses them when restarting dead sessions. All tested: both builder and adversary sessions alive, deepseek-v4-pro processing kickoffs via tinfoil inference.tinfoil.sh, no API/permission errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
cd5e645427
commit
3412100240
+51
-18
@@ -41,8 +41,21 @@ BUILDER_DIR = os.environ.get("BUILDER_DIR", "/srv/cc-ci/cc-ci")
|
|||||||
ADV_DIR = os.environ.get("ADV_DIR", "/srv/cc-ci/cc-ci-adv")
|
ADV_DIR = os.environ.get("ADV_DIR", "/srv/cc-ci/cc-ci-adv")
|
||||||
LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs")
|
LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs")
|
||||||
|
|
||||||
BACKEND = os.environ.get("LOOP_BACKEND", "claude")
|
# Backend is read from env, falling back to a persisted file written by `start`.
|
||||||
LOOP_MODEL = os.environ.get("LOOP_MODEL", "")
|
# This ensures the watchdog (which runs in its own tmux session without the caller's env)
|
||||||
|
# uses the same backend/model when it restarts a dead session.
|
||||||
|
_BACKEND_FILE = os.path.join(LOG_DIR, ".loop-backend")
|
||||||
|
_MODEL_FILE = os.path.join(LOG_DIR, ".loop-model")
|
||||||
|
|
||||||
|
def _read_file_default(path, default):
|
||||||
|
try:
|
||||||
|
v = Path(path).read_text().strip()
|
||||||
|
return v if v else default
|
||||||
|
except FileNotFoundError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
BACKEND = os.environ.get("LOOP_BACKEND") or _read_file_default(_BACKEND_FILE, "claude")
|
||||||
|
LOOP_MODEL = os.environ.get("LOOP_MODEL") or _read_file_default(_MODEL_FILE, "")
|
||||||
REMOTE_CONTROL = os.environ.get("REMOTE_CONTROL", "1") == "1"
|
REMOTE_CONTROL = os.environ.get("REMOTE_CONTROL", "1") == "1"
|
||||||
|
|
||||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
||||||
@@ -117,15 +130,19 @@ def capture_pane(name, lines=40):
|
|||||||
def pipe_to_log(session, log_path):
|
def pipe_to_log(session, log_path):
|
||||||
subprocess.run(["tmux", "pipe-pane", "-o", "-t", session, f"cat >> '{log_path}'"])
|
subprocess.run(["tmux", "pipe-pane", "-o", "-t", session, f"cat >> '{log_path}'"])
|
||||||
|
|
||||||
def ping_session(session, msg):
|
def ping_session(session, msg, submit_key="Enter"):
|
||||||
"""Type a message into a tmux session and submit it, retrying Enter until accepted."""
|
"""Type a message into a tmux session and submit it.
|
||||||
|
|
||||||
|
submit_key: "Enter" for claude (default); "C-s" for opencode (Ctrl+S sends in opencode TUI).
|
||||||
|
Retries the submit key until the typed prefix is no longer visible in the input area.
|
||||||
|
"""
|
||||||
if not session_alive(session):
|
if not session_alive(session):
|
||||||
return
|
return
|
||||||
prefix = msg[:28]
|
prefix = msg[:28]
|
||||||
subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True)
|
subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
subprocess.run(["tmux", "send-keys", "-t", session, "Enter"], capture_output=True)
|
subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
if prefix not in capture_pane(session, 4):
|
if prefix not in capture_pane(session, 4):
|
||||||
return # message was accepted
|
return # message was accepted
|
||||||
@@ -205,12 +222,15 @@ def start_agent(role, session, workdir):
|
|||||||
cmd = f"{CLAUDE_BIN} {rc} {model_flag} {CLAUDE_FLAGS} \"$(cat '{kf}')\""
|
cmd = f"{CLAUDE_BIN} {rc} {model_flag} {CLAUDE_FLAGS} \"$(cat '{kf}')\""
|
||||||
log(f"starting {session} (backend=claude, phase={pid}, plan={plan}, model={LOOP_MODEL or 'default'})")
|
log(f"starting {session} (backend=claude, phase={pid}, plan={plan}, model={LOOP_MODEL or 'default'})")
|
||||||
elif BACKEND == "opencode":
|
elif BACKEND == "opencode":
|
||||||
# `opencode attach` is the persistent TUI (stays alive in tmux, like the claude TUI).
|
# Plain `opencode` (no subcommand) launches the persistent TUI and connects to the
|
||||||
# We attach to the shared server, then send the kickoff message via tmux send-keys.
|
# shared server automatically. `opencode attach` requires a TTY and exits in tmux;
|
||||||
# The server stores the session by title; NO_COLOR=1 skips the first-run theme picker.
|
# the plain TUI works because tmux allocates a PTY for the pane's child process.
|
||||||
|
# --dir pins the working directory; the kickoff is sent via ping_session after startup.
|
||||||
|
# Note: --dir causes opencode to exit immediately (likely a non-git-root issue).
|
||||||
|
# The working directory is set via tmux -c instead; opencode uses that as its cwd.
|
||||||
cmd = (
|
cmd = (
|
||||||
f"set -a; . /srv/cc-ci/.testenv; set +a; "
|
f"set -a; . /srv/cc-ci/.testenv; set +a; "
|
||||||
f"NO_COLOR=1 {OPENCODE_BIN} {model_flag} attach '{OPENCODE_SERVER}'"
|
f"NO_COLOR=1 {OPENCODE_BIN} {model_flag}"
|
||||||
)
|
)
|
||||||
log(f"starting {session} (backend=opencode, phase={pid}, model={LOOP_MODEL or 'default'})")
|
log(f"starting {session} (backend=opencode, phase={pid}, model={LOOP_MODEL or 'default'})")
|
||||||
log(f" visible at http://oc.commoninternet.net (tailnet only)")
|
log(f" visible at http://oc.commoninternet.net (tailnet only)")
|
||||||
@@ -220,11 +240,15 @@ def start_agent(role, session, workdir):
|
|||||||
subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", workdir, cmd])
|
subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", workdir, cmd])
|
||||||
pipe_to_log(session, f"{LOG_DIR}/{session}.log")
|
pipe_to_log(session, f"{LOG_DIR}/{session}.log")
|
||||||
|
|
||||||
# opencode: send the kickoff prompt once the TUI is ready (give it a moment to connect).
|
# opencode: send a short bootstrap once the TUI is ready (Ctrl+S submits in opencode).
|
||||||
|
# The full kickoff lives in the kickoff file; we point to it to stay under send-keys limits.
|
||||||
if BACKEND == "opencode":
|
if BACKEND == "opencode":
|
||||||
time.sleep(4)
|
time.sleep(8) # opencode TUI needs more time to connect to the server than 4s
|
||||||
kickoff_text = kf.read_text().strip()
|
bootstrap = (
|
||||||
ping_session(session, kickoff_text)
|
f"Your full kickoff prompt is in {kf} — read it now with: "
|
||||||
|
f"`cat '{kf}'` — then follow its instructions exactly."
|
||||||
|
)
|
||||||
|
ping_session(session, bootstrap, submit_key="C-s")
|
||||||
|
|
||||||
def start_loops():
|
def start_loops():
|
||||||
start_agent("builder", BUILDER_SESSION, BUILDER_DIR)
|
start_agent("builder", BUILDER_SESSION, BUILDER_DIR)
|
||||||
@@ -260,7 +284,7 @@ def heal_session(role, session, workdir):
|
|||||||
ping_session(session,
|
ping_session(session,
|
||||||
"watchdog: the usage/spend limit appears lifted — RESUME your loop now. "
|
"watchdog: the usage/spend limit appears lifted — RESUME your loop now. "
|
||||||
"Pull latest, re-read your phase STATUS/REVIEW files, and continue from where you "
|
"Pull latest, re-read your phase STATUS/REVIEW files, and continue from where you "
|
||||||
"stopped; re-arm your loop pacing.")
|
"stopped; re-arm your loop pacing.", submit_key=_SUBMIT)
|
||||||
|
|
||||||
# ── stall detection ───────────────────────────────────────────────────────────
|
# ── stall detection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -381,6 +405,8 @@ def _show_pushed(path):
|
|||||||
return r.stdout
|
return r.stdout
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
_SUBMIT = "C-s" if BACKEND == "opencode" else "Enter"
|
||||||
|
|
||||||
def handoff_check():
|
def handoff_check():
|
||||||
global _last_sha, _adv_inbox_seen, _builder_inbox_seen
|
global _last_sha, _adv_inbox_seen, _builder_inbox_seen
|
||||||
|
|
||||||
@@ -401,12 +427,13 @@ def handoff_check():
|
|||||||
log("handoff: new claim(...) commit → pinging Adversary")
|
log("handoff: new claim(...) commit → pinging Adversary")
|
||||||
ping_session(ADV_SESSION,
|
ping_session(ADV_SESSION,
|
||||||
"watchdog ping: the Builder pushed a gate CLAIM (claim(...) commit). "
|
"watchdog ping: the Builder pushed a gate CLAIM (claim(...) commit). "
|
||||||
"Pull and verify the claimed gate now.")
|
"Pull and verify the claimed gate now.", submit_key=_SUBMIT)
|
||||||
if re.search(r"^review", subjects, re.MULTILINE | re.IGNORECASE):
|
if re.search(r"^review", subjects, re.MULTILINE | re.IGNORECASE):
|
||||||
log("handoff: new review(...) commit → pinging Builder")
|
log("handoff: new review(...) commit → pinging Builder")
|
||||||
ping_session(BUILDER_SESSION,
|
ping_session(BUILDER_SESSION,
|
||||||
"watchdog ping: the Adversary pushed a verdict/finding (review(...) commit). "
|
"watchdog ping: the Adversary pushed a verdict/finding (review(...) commit). "
|
||||||
"Pull REVIEW and act — proceed if it PASSes your gate, address it if it's a finding.")
|
"Pull REVIEW and act — proceed if it PASSes your gate, address it if it's a finding.",
|
||||||
|
submit_key=_SUBMIT)
|
||||||
_last_sha = head
|
_last_sha = head
|
||||||
|
|
||||||
adv_inbox = _show_pushed("ADVERSARY-INBOX.md")
|
adv_inbox = _show_pushed("ADVERSARY-INBOX.md")
|
||||||
@@ -420,7 +447,8 @@ def handoff_check():
|
|||||||
log("handoff: ADVERSARY-INBOX.md changed → pinging Adversary")
|
log("handoff: ADVERSARY-INBOX.md changed → pinging Adversary")
|
||||||
ping_session(ADV_SESSION,
|
ping_session(ADV_SESSION,
|
||||||
"watchdog ping: the Builder pushed machine-docs/ADVERSARY-INBOX.md — "
|
"watchdog ping: the Builder pushed machine-docs/ADVERSARY-INBOX.md — "
|
||||||
"pull, read it, act, then delete the file (commit + push) to mark it consumed.")
|
"pull, read it, act, then delete the file (commit + push) to mark it consumed.",
|
||||||
|
submit_key=_SUBMIT)
|
||||||
_adv_inbox_seen = h
|
_adv_inbox_seen = h
|
||||||
else:
|
else:
|
||||||
_adv_inbox_seen = ""
|
_adv_inbox_seen = ""
|
||||||
@@ -431,7 +459,8 @@ def handoff_check():
|
|||||||
log("handoff: BUILDER-INBOX.md changed → pinging Builder")
|
log("handoff: BUILDER-INBOX.md changed → pinging Builder")
|
||||||
ping_session(BUILDER_SESSION,
|
ping_session(BUILDER_SESSION,
|
||||||
"watchdog ping: the Adversary pushed machine-docs/BUILDER-INBOX.md — "
|
"watchdog ping: the Adversary pushed machine-docs/BUILDER-INBOX.md — "
|
||||||
"pull, read it, act, then delete the file (commit + push) to mark it consumed.")
|
"pull, read it, act, then delete the file (commit + push) to mark it consumed.",
|
||||||
|
submit_key=_SUBMIT)
|
||||||
_builder_inbox_seen = h
|
_builder_inbox_seen = h
|
||||||
else:
|
else:
|
||||||
_builder_inbox_seen = ""
|
_builder_inbox_seen = ""
|
||||||
@@ -542,6 +571,10 @@ def main():
|
|||||||
seq = Path(LOG_DIR) / "SEQUENCE-COMPLETE"
|
seq = Path(LOG_DIR) / "SEQUENCE-COMPLETE"
|
||||||
if seq.exists():
|
if seq.exists():
|
||||||
seq.unlink()
|
seq.unlink()
|
||||||
|
# Persist backend/model so the watchdog uses them when restarting dead sessions.
|
||||||
|
Path(_BACKEND_FILE).write_text(BACKEND)
|
||||||
|
Path(_MODEL_FILE).write_text(LOOP_MODEL)
|
||||||
|
log(f"backend={BACKEND} model={LOOP_MODEL or '<default>'} (persisted to {_BACKEND_FILE})")
|
||||||
start_loops()
|
start_loops()
|
||||||
start_watchdog()
|
start_watchdog()
|
||||||
log(f"started at phase {phase_id(cur_idx())}.")
|
log(f"started at phase {phase_id(cur_idx())}.")
|
||||||
|
|||||||
Reference in New Issue
Block a user