feat(activity): SRCP v1 step gates, pause/resume/stop control (v1.2.0)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
from .activity import (
|
||||
JobStopped,
|
||||
checkpoint,
|
||||
emit,
|
||||
finish,
|
||||
get_control_path,
|
||||
get_job_id,
|
||||
get_run_mode,
|
||||
interruptible_sleep,
|
||||
interruptible_sleep_sync,
|
||||
job_context,
|
||||
rpa_step,
|
||||
step,
|
||||
@@ -68,14 +73,19 @@ except ImportError:
|
||||
rpa = None # type: ignore[assignment,misc]
|
||||
|
||||
__all__ = [
|
||||
"JobStopped",
|
||||
"call_sibling_json",
|
||||
"call_sibling_json_array",
|
||||
"checkpoint",
|
||||
"emit",
|
||||
"finish",
|
||||
"get_control_path",
|
||||
"get_job_id",
|
||||
"get_run_mode",
|
||||
"get_runs_dir",
|
||||
"ensure_runs_dir",
|
||||
"interruptible_sleep",
|
||||
"interruptible_sleep_sync",
|
||||
"job_context",
|
||||
"rpa_step",
|
||||
"step",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""
|
||||
Skill Activity Stream (SAS) v1 — Run Journal + stdout result contract.
|
||||
Skill Activity Stream (SAS) v1 + Skill Runtime Control Protocol (SRCP) v1.
|
||||
|
||||
emit() / journal events never touch stdout (Agent/bash tool result stays clean).
|
||||
finish() is the only API that writes a single-line result JSON to stdout.
|
||||
|
||||
SRCP: host writes {job_id}.control.json; kit consults at step gates (emit,
|
||||
rpa_step, interruptible_sleep) for pause / resume / stop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,22 +24,47 @@ from typing import Any, Callable, Iterator, Optional, TypeVar
|
||||
from .runtime_env import ensure_runs_dir, get_runs_dir
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_CONTROL_SCHEMA_VERSION = 1
|
||||
_VALID_EMIT_TYPES = frozenset({"activity", "progress", "warn", "debug"})
|
||||
_VALID_CONTROL_COMMANDS = frozenset({"none", "pause", "resume", "stop"})
|
||||
_RESULT_MAX_CHARS = 4096
|
||||
_DEFAULT_CONTROL_POLL_SEC = 0.2
|
||||
|
||||
_cached_job_id: Optional[str] = None
|
||||
_journal_path: Optional[str] = None
|
||||
_meta_written: bool = False
|
||||
_journal_file: Optional[Any] = None
|
||||
_step_index: int = 0
|
||||
_current_step_name: Optional[str] = None
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
class JobStopped(Exception):
|
||||
"""Raised when the host requests stop via control.json."""
|
||||
|
||||
|
||||
def _activity_debug_enabled() -> bool:
|
||||
v = (os.getenv("JIANGCHANG_ACTIVITY_DEBUG") or "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _control_disabled() -> bool:
|
||||
v = (os.getenv("JIANGCHANG_CONTROL_DISABLED") or "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _control_poll_sec() -> float:
|
||||
raw = (os.getenv("JIANGCHANG_CONTROL_POLL_SEC") or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_CONTROL_POLL_SEC
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_CONTROL_POLL_SEC
|
||||
return max(0.05, value)
|
||||
|
||||
|
||||
def get_run_mode() -> str:
|
||||
mode = (os.getenv("JIANGCHANG_RUN_MODE") or "").strip().lower()
|
||||
if mode in ("job", "cli"):
|
||||
@@ -54,6 +82,12 @@ def get_job_id() -> str:
|
||||
return _cached_job_id
|
||||
|
||||
|
||||
def get_control_path() -> str:
|
||||
"""Path to the host-owned control file for the current job."""
|
||||
ensure_runs_dir()
|
||||
return os.path.join(get_runs_dir(), f"{get_job_id()}.control.json")
|
||||
|
||||
|
||||
def _get_journal_path() -> str:
|
||||
global _journal_path
|
||||
if _journal_path is None:
|
||||
@@ -93,6 +127,103 @@ def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _read_control_payload() -> dict[str, Any] | None:
|
||||
path = get_control_path()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _read_control_command() -> str:
|
||||
payload = _read_control_payload()
|
||||
if not payload:
|
||||
return "none"
|
||||
command = payload.get("command")
|
||||
if not isinstance(command, str):
|
||||
return "none"
|
||||
normalized = command.strip().lower()
|
||||
if normalized not in _VALID_CONTROL_COMMANDS:
|
||||
return "none"
|
||||
return normalized
|
||||
|
||||
|
||||
def _write_lifecycle_event(
|
||||
status: str,
|
||||
text: str,
|
||||
*,
|
||||
step_name: str | None = None,
|
||||
skill: str | None = None,
|
||||
) -> None:
|
||||
event: dict[str, Any] = {
|
||||
"type": "lifecycle",
|
||||
"status": status,
|
||||
"text": text,
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"control_schema_version": _CONTROL_SCHEMA_VERSION,
|
||||
"ts": _now_ms(),
|
||||
"step_index": _step_index,
|
||||
}
|
||||
name = step_name or _current_step_name
|
||||
if name:
|
||||
event["step_name"] = name
|
||||
if skill is not None:
|
||||
event["skill"] = skill
|
||||
_write_journal_line(event)
|
||||
|
||||
|
||||
def _wait_while_paused(*, step_name: str | None = None) -> None:
|
||||
poll = _control_poll_sec()
|
||||
while True:
|
||||
command = _read_control_command()
|
||||
if command == "stop":
|
||||
_write_lifecycle_event("stopping", "任务已停止", step_name=step_name)
|
||||
raise JobStopped("Job stopped by host.")
|
||||
if command in ("none", "resume"):
|
||||
_write_lifecycle_event("resumed", "继续执行", step_name=step_name)
|
||||
return
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
def _apply_control(*, advance_step: bool, step_name: str | None = None) -> None:
|
||||
if _control_disabled():
|
||||
return
|
||||
|
||||
global _step_index, _current_step_name
|
||||
if advance_step:
|
||||
_step_index += 1
|
||||
if step_name:
|
||||
_current_step_name = step_name
|
||||
|
||||
command = _read_control_command()
|
||||
if command == "stop":
|
||||
_write_lifecycle_event("stopping", "任务已停止", step_name=step_name)
|
||||
raise JobStopped("Job stopped by host.")
|
||||
if command == "pause":
|
||||
_write_lifecycle_event("paused", "已暂停,等待继续", step_name=step_name)
|
||||
_wait_while_paused(step_name=step_name)
|
||||
|
||||
|
||||
def checkpoint() -> None:
|
||||
"""
|
||||
Consult host control without advancing the step index.
|
||||
|
||||
Use inside long waits (interruptible_sleep) between step boundaries.
|
||||
"""
|
||||
_apply_control(advance_step=False)
|
||||
|
||||
|
||||
def _step_gate(step_name: str | None) -> None:
|
||||
"""Step boundary: advance index, then pause/stop gate."""
|
||||
_apply_control(advance_step=True, step_name=step_name)
|
||||
|
||||
|
||||
def _build_event(
|
||||
type_: str,
|
||||
text: str,
|
||||
@@ -130,7 +261,7 @@ def _build_event(
|
||||
return event
|
||||
|
||||
|
||||
def emit(
|
||||
def _emit_core(
|
||||
text: str,
|
||||
*,
|
||||
type: str = "activity",
|
||||
@@ -141,7 +272,6 @@ def emit(
|
||||
item_id: str | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
"""Append one SAS event to the Run Journal. Never writes to stdout."""
|
||||
if type == "debug" and not _activity_debug_enabled():
|
||||
return
|
||||
if type not in _VALID_EMIT_TYPES:
|
||||
@@ -159,11 +289,67 @@ def emit(
|
||||
_write_journal_line(event)
|
||||
|
||||
|
||||
def emit(
|
||||
text: str,
|
||||
*,
|
||||
type: str = "activity",
|
||||
skill: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
item_id: str | None = None,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
"""Append one SAS event to the Run Journal. Never writes to stdout."""
|
||||
gate_name = stage or text
|
||||
_step_gate(gate_name)
|
||||
_emit_core(
|
||||
text,
|
||||
type=type,
|
||||
skill=skill,
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
item_id=item_id,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def step(text: str, **kwargs: Any) -> None:
|
||||
"""Shorthand for emit(type='activity')."""
|
||||
emit(text, type="activity", **kwargs)
|
||||
|
||||
|
||||
async def interruptible_sleep(seconds: float, *, chunk: float | None = None) -> None:
|
||||
"""asyncio.sleep in slices; each slice consults host control (pause/stop)."""
|
||||
if seconds <= 0:
|
||||
return
|
||||
poll = chunk if chunk is not None else _control_poll_sec()
|
||||
poll = max(0.05, poll)
|
||||
deadline = time.monotonic() + seconds
|
||||
while True:
|
||||
checkpoint()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
await asyncio.sleep(min(poll, remaining))
|
||||
|
||||
|
||||
def interruptible_sleep_sync(seconds: float, *, chunk: float | None = None) -> None:
|
||||
"""time.sleep in slices; each slice consults host control (pause/stop)."""
|
||||
if seconds <= 0:
|
||||
return
|
||||
poll = chunk if chunk is not None else _control_poll_sec()
|
||||
poll = max(0.05, poll)
|
||||
deadline = time.monotonic() + seconds
|
||||
while True:
|
||||
checkpoint()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
time.sleep(min(poll, remaining))
|
||||
|
||||
|
||||
def _serialize_result(result: dict[str, Any]) -> str:
|
||||
line = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(line) <= _RESULT_MAX_CHARS:
|
||||
@@ -269,6 +455,14 @@ def job_context(
|
||||
|
||||
try:
|
||||
yield
|
||||
except JobStopped as exc:
|
||||
finish(
|
||||
status="failed",
|
||||
message=str(exc) or "用户停止",
|
||||
skill=skill,
|
||||
error_code="JOB_STOPPED",
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
finish(status="failed", message=str(exc), skill=skill)
|
||||
raise
|
||||
@@ -293,7 +487,7 @@ def job_context(
|
||||
|
||||
|
||||
def rpa_step(name: str | None = None) -> Callable[[F], F]:
|
||||
"""Decorator for sync/async RPA steps: emit enter/success/warn on failure."""
|
||||
"""Decorator for sync/async RPA steps: gate, emit enter/success/warn on failure."""
|
||||
|
||||
def decorator(fn: F) -> F:
|
||||
step_name = name or fn.__name__
|
||||
@@ -302,26 +496,32 @@ def rpa_step(name: str | None = None) -> Callable[[F], F]:
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
emit(f"▶ {step_name}")
|
||||
_step_gate(step_name)
|
||||
_emit_core(f"▶ {step_name}")
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
emit(f"✓ {step_name}")
|
||||
_emit_core(f"✓ {step_name}")
|
||||
return result
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception as exc:
|
||||
emit(f"✗ {step_name}: {exc}", type="warn")
|
||||
_emit_core(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return async_wrapper # type: ignore[return-value]
|
||||
|
||||
@functools.wraps(fn)
|
||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
emit(f"▶ {step_name}")
|
||||
_step_gate(step_name)
|
||||
_emit_core(f"▶ {step_name}")
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
emit(f"✓ {step_name}")
|
||||
_emit_core(f"✓ {step_name}")
|
||||
return result
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception as exc:
|
||||
emit(f"✗ {step_name}: {exc}", type="warn")
|
||||
_emit_core(f"✗ {step_name}: {exc}", type="warn")
|
||||
raise
|
||||
|
||||
return sync_wrapper # type: ignore[return-value]
|
||||
@@ -330,8 +530,9 @@ def rpa_step(name: str | None = None) -> Callable[[F], F]:
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear module-level journal state (tests only)."""
|
||||
"""Clear module-level journal and control state (tests only)."""
|
||||
global _cached_job_id, _journal_path, _meta_written, _journal_file
|
||||
global _step_index, _current_step_name
|
||||
if _journal_file is not None:
|
||||
try:
|
||||
_journal_file.close()
|
||||
@@ -341,3 +542,5 @@ def _reset_for_tests() -> None:
|
||||
_journal_path = None
|
||||
_meta_written = False
|
||||
_journal_file = None
|
||||
_step_index = 0
|
||||
_current_step_name = None
|
||||
|
||||
Reference in New Issue
Block a user