Skill: stanrails/restart-recovery (version 1.0.0)

--- name: restart-recovery description: Make OpenClaw agent workflows restart-safe using checkpoint files, idempotent step tracking, wake/resume handoff, and stale-checkpoint monitoring. Use when users ask to recover from restarts, preserve progress across updates/config restarts, or implement checkpoint → restart → wake → resume patterns. --- # Restart Recovery Implement restart-safe execution with this sequence: 1. checkpoint 2. restart 3. wake 4. resume from file ## Use bundled scripts - Use `scripts/checkpoint_tool.py` for deterministic checkpoint lifecycle: - `start`, `update`, `resume`, `complete`, `list` - Use `scripts/checkpoint_selfcheck.py` for stale unfinished checkpoint alerts without LLM/tool-token usage. ## Required operating rules - Write checkpoints before any restart-prone operation (config patch/apply, update, service restart, long multi-step jobs). - Use atomic file writes (`.tmp` then rename). - Track completed and remaining steps explicitly. - Include an idempotency key per workflow to avoid duplicate side effects after resume. - Never write secrets/tokens to checkpoint files. - Acquire a resume lock before continuing unfinished work. ## Recommended checkpoint location - Per agent: `memory/checkpoints/*.json` - Shared/default workspace flows: `memory/checkpoints/*.json` at workspace root ## Startup instruction to add in AGENTS.md Add this exact section: ```md ## Restart-safe workflow rule On startup, check `memory/checkpoints/*.json` for unfinished workflows. If found, acquire resume lock, validate checkpoint schema/hash, and continue from the last completed idempotent step. ``` ## No-LLM stale checkpoint monitor Use host scheduler (launchd/systemd/cron), not LLM cron jobs. - Run every 10 minutes. - Alert only when unfinished checkpoints are older than threshold. - Log to local file for audit. ## Suggested execution flow 1. `checkpoint_tool.py start` before risky step. 2. Perform step. 3. `checkpoint_tool.py update --complete <step> --step <next>`. 4. If restart happens, wake session/process. 5. On startup/re-entry, `checkpoint_tool.py resume` and continue. 6. `checkpoint_tool.py complete` when done. ## Validation checklist - Simulate mid-work restart and verify resume from last completed step. - Confirm idempotency (no duplicate sends/writes/actions). - Confirm stale-check script only alerts after threshold. - Confirm old checkpoint cleanup policy (expiry).

path: references/checkpoint-schema.json
content: {
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "OpenClaw Restart Checkpoint",
  "type": "object",
  "required": ["schemaVersion", "workflowId", "agentId", "status", "currentStep", "completedSteps", "remainingSteps", "idempotencyKey", "updatedAt"],
  "properties": {
    "schemaVersion": { "type": "string", "const": "1.0" },
    "workflowId": { "type": "string" },
    "agentId": { "type": "string" },
    "status": { "type": "string", "enum": ["in_progress", "blocked", "done", "failed"] },
    "currentStep": { "type": "string" },
    "completedSteps": { "type": "array", "items": { "type": "string" } },
    "remainingSteps": { "type": "array", "items": { "type": "string" } },
    "handoffTo": { "type": ["string", "null"] },
    "notes": { "type": "string" },
    "idempotencyKey": { "type": "string" },
    "checkpointHash": { "type": "string" },
    "updatedAt": { "type": "string", "format": "date-time" },
    "expiresAt": { "type": "string", "format": "date-time" }
  },
  "additionalProperties": true
}


path: scripts/checkpoint_selfcheck.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from datetime import datetime, timezone, timedelta
from pathlib import Path


def parse_args():
    ap = argparse.ArgumentParser(description="No-LLM stale-checkpoint detector")
    ap.add_argument("--root", default=str(Path.cwd()), help="Workspace root")
    ap.add_argument("--stale-min", type=int, default=10)
    ap.add_argument("--json", action="store_true")
    return ap.parse_args()


def main():
    args = parse_args()
    now = datetime.now(timezone.utc)
    cutoff = now - timedelta(minutes=args.stale_min)
    root = Path(args.root)

    roots = [root / "memory" / "checkpoints"]
    agents_dir = root / "agents"
    if agents_dir.exists():
        for d in agents_dir.iterdir():
            roots.append(d / "memory" / "checkpoints")

    findings = []
    for chk_root in roots:
        if not chk_root.exists():
            continue
        for p in chk_root.glob("*.json"):
            if p.name == "CHECKPOINT_SCHEMA.json":
                continue
            try:
                obj = json.loads(p.read_text(encoding="utf-8"))
            except Exception:
                continue
            if obj.get("status") not in {"in_progress", "blocked"}:
                continue
            ts = obj.get("updatedAt")
            if not ts:
                continue
            try:
                dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
            except Exception:
                continue
            if dt <= cutoff:
                findings.append({
                    "workflowId": obj.get("workflowId", p.stem),
                    "agentId": obj.get("agentId", "unknown"),
                    "file": p.as_posix(),
                    "ageMinutes": int((now - dt).total_seconds() / 60),
                })

    if args.json:
        print(json.dumps({"status": "ok" if not findings else "alert", "count": len(findings), "findings": findings}, indent=2))
        return

    if not findings:
        print("OK")
        return

    print(f"ALERT stale_checkpoints={len(findings)} threshold_min={args.stale_min}")
    for f in findings[:50]:
        print(f"- {f['agentId']}:{f['workflowId']} age={f['ageMinutes']}m file={f['file']}")


if __name__ == "__main__":
    main()


path: scripts/checkpoint_tool.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path

ROOT = Path.cwd() / "memory" / "checkpoints"
ROOT.mkdir(parents=True, exist_ok=True)


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def cp_path(workflow_id: str) -> Path:
    return ROOT / f"{workflow_id}.json"


def lock_path(workflow_id: str) -> Path:
    return ROOT / f"{workflow_id}.lock"


def calc_hash(obj: dict) -> str:
    blob = json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
    return hashlib.sha256(blob).hexdigest()


def atomic_write(path: Path, obj: dict) -> None:
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
    tmp.replace(path)


def load(path: Path) -> dict:
    return json.loads(path.read_text(encoding="utf-8"))


def cmd_start(args):
    p = cp_path(args.workflow)
    if p.exists() and not args.force:
        raise SystemExit(f"checkpoint exists: {p} (use --force)")
    obj = {
        "schemaVersion": "1.0",
        "workflowId": args.workflow,
        "agentId": args.agent,
        "status": "in_progress",
        "currentStep": args.step,
        "completedSteps": [],
        "remainingSteps": args.remaining,
        "handoffTo": args.handoff,
        "notes": args.notes or "",
        "idempotencyKey": args.idempotency,
        "updatedAt": now_iso(),
        "expiresAt": (datetime.now(timezone.utc) + timedelta(days=args.ttl_days)).isoformat(),
    }
    obj["checkpointHash"] = calc_hash({k: v for k, v in obj.items() if k != "checkpointHash"})
    atomic_write(p, obj)
    print(p)


def cmd_update(args):
    p = cp_path(args.workflow)
    obj = load(p)
    if args.complete:
        if args.complete not in obj["completedSteps"]:
            obj["completedSteps"].append(args.complete)
        obj["remainingSteps"] = [s for s in obj["remainingSteps"] if s != args.complete]
    if args.step:
        obj["currentStep"] = args.step
    if args.status:
        obj["status"] = args.status
    if args.notes is not None:
        obj["notes"] = args.notes
    obj["updatedAt"] = now_iso()
    obj["checkpointHash"] = calc_hash({k: v for k, v in obj.items() if k != "checkpointHash"})
    atomic_write(p, obj)
    print(p)


def cmd_complete(args):
    p = cp_path(args.workflow)
    obj = load(p)
    obj["status"] = "done"
    obj["remainingSteps"] = []
    obj["updatedAt"] = now_iso()
    obj["checkpointHash"] = calc_hash({k: v for k, v in obj.items() if k != "checkpointHash"})
    atomic_write(p, obj)
    print(p)


def cmd_list(_args):
    for p in sorted(ROOT.glob("*.json")):
        if p.name == "CHECKPOINT_SCHEMA.json":
            continue
        try:
            obj = load(p)
            print(f"{obj.get('workflowId')}\t{obj.get('status')}\t{obj.get('currentStep')}\t{obj.get('updatedAt')}")
        except Exception:
            print(f"{p.name}\tINVALID")


def cmd_resume(args):
    now = datetime.now(timezone.utc)
    for p in sorted(ROOT.glob("*.json")):
        if p.name == "CHECKPOINT_SCHEMA.json":
            continue
        obj = load(p)
        if obj.get("status") not in {"in_progress", "blocked"}:
            continue
        exp = obj.get("expiresAt")
        if exp:
            try:
                if datetime.fromisoformat(exp) < now:
                    continue
            except Exception:
                pass
        l = lock_path(obj["workflowId"])
        if l.exists() and not args.steal_lock:
            continue
        l.write_text(now_iso(), encoding="utf-8")
        print(json.dumps(obj, indent=2))
        return
    print("NO_CHECKPOINT")


def main():
    ap = argparse.ArgumentParser(description="Checkpoint helper for restart-safe workflows")
    sp = ap.add_subparsers(dest="cmd", required=True)

    s = sp.add_parser("start")
    s.add_argument("--workflow", required=True)
    s.add_argument("--agent", required=True)
    s.add_argument("--step", required=True)
    s.add_argument("--remaining", nargs="*", default=[])
    s.add_argument("--idempotency", required=True)
    s.add_argument("--handoff", default=None)
    s.add_argument("--notes", default="")
    s.add_argument("--ttl-days", type=int, default=7)
    s.add_argument("--force", action="store_true")
    s.set_defaults(func=cmd_start)

    u = sp.add_parser("update")
    u.add_argument("--workflow", required=True)
    u.add_argument("--step")
    u.add_argument("--complete")
    u.add_argument("--status", choices=["in_progress", "blocked", "done", "failed"])
    u.add_argument("--notes")
    u.set_defaults(func=cmd_update)

    c = sp.add_parser("complete")
    c.add_argument("--workflow", required=True)
    c.set_defaults(func=cmd_complete)

    l = sp.add_parser("list")
    l.set_defaults(func=cmd_list)

    r = sp.add_parser("resume")
    r.add_argument("--steal-lock", action="store_true")
    r.set_defaults(func=cmd_resume)

    args = ap.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)