Files
zk-data-agent/src/background_runtime.py
T
Abdelrahman Abdallah 3f31cee395 add claw-code-agent/src/plan_runtime.py and claw-code-agent/src/background_runtime.py. The agent now has a real
persistent plan runtime with update_plan, plan_get, and plan_clear, plus plan-to-task sync wired through claw-code-agent/src/
  agent_tools.py, claw-code-agent/src/agent_runtime.py, claw-code-agent/src/agent_context.py, claw-code-agent/src/agent_prompting.py, and claw-code-agent/src/
  agent_slash_commands.py. New local plan slash commands are /plan and /planner.
2026-04-03 18:31:25 +02:00

372 lines
13 KiB
Python

from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
DEFAULT_BACKGROUND_DIR = Path('.port_sessions') / 'background'
_DETACHED_PROCESSES: dict[int, subprocess.Popen[Any]] = {}
@dataclass(frozen=True)
class BackgroundSessionRecord:
background_id: str
pid: int
prompt: str
workspace_cwd: str
model: str
mode: str
status: str
log_path: str
record_path: str
started_at: str
command: tuple[str, ...]
finished_at: str | None = None
exit_code: int | None = None
stop_reason: str | None = None
session_id: str | None = None
session_path: str | None = None
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> 'BackgroundSessionRecord':
return cls(
background_id=str(payload.get('background_id') or ''),
pid=int(payload.get('pid') or 0),
prompt=str(payload.get('prompt') or ''),
workspace_cwd=str(payload.get('workspace_cwd') or ''),
model=str(payload.get('model') or ''),
mode=str(payload.get('mode') or 'agent'),
status=str(payload.get('status') or 'unknown'),
log_path=str(payload.get('log_path') or ''),
record_path=str(payload.get('record_path') or ''),
started_at=str(payload.get('started_at') or ''),
command=tuple(
str(item)
for item in payload.get('command', [])
if isinstance(item, (str, int, float))
),
finished_at=(
str(payload.get('finished_at'))
if isinstance(payload.get('finished_at'), str) and payload.get('finished_at')
else None
),
exit_code=(
int(payload.get('exit_code'))
if isinstance(payload.get('exit_code'), int)
else None
),
stop_reason=(
str(payload.get('stop_reason'))
if isinstance(payload.get('stop_reason'), str) and payload.get('stop_reason')
else None
),
session_id=(
str(payload.get('session_id'))
if isinstance(payload.get('session_id'), str) and payload.get('session_id')
else None
),
session_path=(
str(payload.get('session_path'))
if isinstance(payload.get('session_path'), str) and payload.get('session_path')
else None
),
)
class BackgroundSessionRuntime:
def __init__(self, root: Path | None = None) -> None:
self.root = (root or DEFAULT_BACKGROUND_DIR).resolve()
self.root.mkdir(parents=True, exist_ok=True)
def create_id(self) -> str:
return f'bg_{uuid4().hex[:12]}'
def record_path(self, background_id: str) -> Path:
return self.root / f'{background_id}.json'
def log_path(self, background_id: str) -> Path:
return self.root / f'{background_id}.log'
def launch(
self,
command: list[str],
*,
prompt: str,
workspace_cwd: Path,
model: str,
mode: str = 'agent',
background_id: str | None = None,
process_cwd: Path | None = None,
) -> BackgroundSessionRecord:
background_id = background_id or self.create_id()
log_path = self.log_path(background_id)
record_path = self.record_path(background_id)
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open('a', encoding='utf-8') as handle:
process = subprocess.Popen(
command,
stdout=handle,
stderr=subprocess.STDOUT,
cwd=str(process_cwd or Path.cwd()),
start_new_session=True,
)
_DETACHED_PROCESSES[process.pid] = process
record = BackgroundSessionRecord(
background_id=background_id,
pid=process.pid,
prompt=prompt,
workspace_cwd=str(workspace_cwd),
model=model,
mode=mode,
status='running',
log_path=str(log_path),
record_path=str(record_path),
started_at=_utc_now(),
command=tuple(command),
)
self.save_record(record)
return record
def save_record(self, record: BackgroundSessionRecord) -> Path:
path = Path(record.record_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(record), ensure_ascii=True, indent=2), encoding='utf-8')
return path
def load_record(self, background_id: str) -> BackgroundSessionRecord:
data = json.loads(self.record_path(background_id).read_text(encoding='utf-8'))
record = BackgroundSessionRecord.from_dict(data)
return self.refresh_record(record)
def list_records(self) -> tuple[BackgroundSessionRecord, ...]:
records: list[BackgroundSessionRecord] = []
for path in sorted(self.root.glob('bg_*.json')):
try:
payload = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
continue
records.append(self.refresh_record(BackgroundSessionRecord.from_dict(payload)))
return tuple(sorted(records, key=lambda item: item.started_at, reverse=True))
def refresh_record(self, record: BackgroundSessionRecord) -> BackgroundSessionRecord:
if record.status != 'running':
return record
if _is_process_running(record.pid):
return record
updated = BackgroundSessionRecord(
background_id=record.background_id,
pid=record.pid,
prompt=record.prompt,
workspace_cwd=record.workspace_cwd,
model=record.model,
mode=record.mode,
status='exited',
log_path=record.log_path,
record_path=record.record_path,
started_at=record.started_at,
command=record.command,
finished_at=record.finished_at or _utc_now(),
exit_code=record.exit_code,
stop_reason=record.stop_reason,
session_id=record.session_id,
session_path=record.session_path,
)
self.save_record(updated)
process = _DETACHED_PROCESSES.pop(record.pid, None)
if process is not None and process.returncode is None:
process.returncode = updated.exit_code
return updated
def mark_finished(
self,
background_id: str,
*,
exit_code: int,
stop_reason: str | None = None,
session_id: str | None = None,
session_path: str | None = None,
status: str | None = None,
) -> BackgroundSessionRecord:
record = self.load_record(background_id)
final_status = status or ('completed' if exit_code == 0 else 'failed')
updated = BackgroundSessionRecord(
background_id=record.background_id,
pid=record.pid,
prompt=record.prompt,
workspace_cwd=record.workspace_cwd,
model=record.model,
mode=record.mode,
status=final_status,
log_path=record.log_path,
record_path=record.record_path,
started_at=record.started_at,
command=record.command,
finished_at=_utc_now(),
exit_code=exit_code,
stop_reason=stop_reason,
session_id=session_id,
session_path=session_path,
)
self.save_record(updated)
process = _DETACHED_PROCESSES.pop(record.pid, None)
if process is not None and process.returncode is None:
process.returncode = updated.exit_code
return updated
def kill(self, background_id: str) -> BackgroundSessionRecord:
record = self.load_record(background_id)
if record.status != 'running':
return record
try:
os.killpg(record.pid, signal.SIGTERM)
except OSError:
try:
os.kill(record.pid, signal.SIGTERM)
except OSError:
pass
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
try:
waited_pid, _ = os.waitpid(record.pid, os.WNOHANG)
if waited_pid == record.pid:
break
except ChildProcessError:
break
except OSError:
break
if not _is_process_running(record.pid):
break
time.sleep(0.05)
updated = BackgroundSessionRecord(
background_id=record.background_id,
pid=record.pid,
prompt=record.prompt,
workspace_cwd=record.workspace_cwd,
model=record.model,
mode=record.mode,
status='killed',
log_path=record.log_path,
record_path=record.record_path,
started_at=record.started_at,
command=record.command,
finished_at=_utc_now(),
exit_code=-signal.SIGTERM,
stop_reason='killed',
session_id=record.session_id,
session_path=record.session_path,
)
self.save_record(updated)
process = _DETACHED_PROCESSES.pop(record.pid, None)
if process is not None and process.returncode is None:
process.returncode = updated.exit_code
return updated
def read_logs(self, background_id: str, *, tail: int | None = None) -> str:
record = self.load_record(background_id)
path = Path(record.log_path)
if not path.exists():
return ''
text = path.read_text(encoding='utf-8', errors='replace')
if tail is None or tail <= 0:
return text
lines = text.splitlines()
return '\n'.join(lines[-tail:])
def render_ps(self) -> str:
records = self.list_records()
lines = ['# Background Sessions', '']
if not records:
lines.append('No local background sessions are currently recorded.')
return '\n'.join(lines)
for record in records:
parts = [
record.background_id,
f'status={record.status}',
f'pid={record.pid}',
f'model={record.model}',
f'cwd={record.workspace_cwd}',
]
if record.exit_code is not None:
parts.append(f'exit_code={record.exit_code}')
lines.append('- ' + '; '.join(parts))
lines.append(f' prompt: {_snapshot_text(record.prompt)}')
return '\n'.join(lines)
def render_logs(self, background_id: str, *, tail: int | None = None) -> str:
record = self.load_record(background_id)
log_text = self.read_logs(background_id, tail=tail)
lines = [
'# Background Logs',
'',
f'- Background session: {record.background_id}',
f'- Status: {record.status}',
f'- PID: {record.pid}',
f'- Log path: {record.log_path}',
'',
log_text.rstrip() or '(empty log)',
]
return '\n'.join(lines)
def render_attach(self, background_id: str, *, tail: int | None = None) -> str:
record = self.load_record(background_id)
lines = [
'# Background Attach',
'',
f'- Background session: {record.background_id}',
f'- Status: {record.status}',
f'- Workspace cwd: {record.workspace_cwd}',
]
if record.session_id:
lines.append(f'- Agent session id: {record.session_id}')
if record.session_path:
lines.append(f'- Agent session path: {record.session_path}')
lines.extend(['', self.read_logs(background_id, tail=tail).rstrip() or '(empty log)'])
return '\n'.join(lines)
def build_background_worker_command(
*,
background_id: str,
prompt: str,
forwarded_args: list[str],
) -> list[str]:
return [
sys.executable,
'-m',
'src.main',
'agent-bg-worker',
background_id,
prompt,
*forwarded_args,
]
def _is_process_running(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _snapshot_text(text: str, limit: int = 140) -> str:
normalized = ' '.join(text.split())
if len(normalized) <= limit:
return normalized
return normalized[: limit - 3] + '...'