Skill: ffffff9331/long-run-mode (version 0.1.4)

--- name: long-run-mode description: Long-task execution protocol for sustained multi-step work. 当用户明确说“长任务模式”或“进入长任务模式”时触发。除这类口令外，其他任何表达——包括“继续”“接着来”“往下做”“一直做，不停”“持续推进”“不要停下来问”——都不触发长任务模式，只按普通任务处理。Handles task framing, state recording, visible progress heartbeats, session routing, and watchdog-based recovery. --- # Long Run Mode 把这个技能当成**长任务执行协议**，不是普通提示词。 ## 触发边界（最严格版硬规则） - **当用户明确说出 `长任务模式` 或 `进入长任务模式` 时，才触发长任务模式。** - 除 `进入长任务模式` 外，其他任何表达一律**不触发**，包括但不限于：`继续`、`接着来`、`往下做`、`一直做，不停`、`持续推进`、`不要停下来问`。 - 遇到上述非口令表达时，只按普通任务续做；不要登记长任务状态、不要启动保活/看门狗、不要套用长任务前台心跳规则。 - 如果上下文里已经处于长任务模式，后续普通 `继续` 只表示在**已开启的长任务**里继续执行；但**开启动作本身**仍然只能由 `进入长任务模式` 触发。 核心目标只有两条： 1. 长任务不能悄悄空转 2. 用户必须持续看得见任务还活着 ## 先读什么 - 状态字段：`references/state-schema.md` - 执行协议：`references/protocol.md` ## 脚本索引 ### 主路径 - `scripts/task_state.py`：登记、更新、清理任务状态与发送相关时间戳。 - `scripts/validate_task_start.py`：校验任务是否真正绑定到目标 `origin_session_key`；只有它返回 `ok: true` 才能宣称启动成功。 - `scripts/check_foreground_progress.py`：检查单任务前台进度是否到点。 - `scripts/build_progress_update.py`：构造单任务前台短进度消息。 - `scripts/foreground_tick.py`：单任务前台心跳入口。 - `scripts/work_cycle_tick.py`：每轮工作循环后的前台钩子；若 `must_send_now=true`，必须先发再继续。 ### 前台自动触发 / 分发辅助 - `scripts/emit_foreground_due.py`：扫描所有到点前台任务，生成前台自动消息事件。 - `scripts/dispatch_foreground_due.py`：把前台自动消息事件整理成 dispatcher 计划；真正发送仍需 OpenClaw tool/agent 层执行。 ### 保活 / 恢复 / 审计 - `scripts/keepalive_tick.py`：检查单任务是否长时间无活动、是否需要续跑。 - `scripts/emit_keepalive_resume.py`：生成单任务保活续跑事件。 - `scripts/run_keepalive_once.py`：批量跑一次保活检查，产出可执行计划。 - `scripts/watchdog.py`：审计长任务是否失联、超时、卡住或到检查点。 - `scripts/run_watchdog_once.py`：批量跑一次 watchdog，并生成后续恢复计划。 - `scripts/recover_due_tasks.py`：根据 watchdog 结果整理恢复建议。 - `scripts/emit_recovery_message.py`：把恢复建议转成可发送的恢复消息事件。 - `scripts/build_sessions_send_payloads.py`：把恢复消息事件整理成 `sessions_send` payload。 ### 运维 / 清理辅助 - `scripts/cleanup_state.py`：清理旧状态、测试残留、坏锁和缺少 `origin_session_key` 的脏记录。 - `scripts/install_watchdog_cron.sh`：安装 watchdog cron 示例。 - `scripts/watchdog_loop.sh`：本地循环跑 watchdog 的辅助脚本。 ## 启动动作 进入长任务后，先在回复里明确： ```text 当前状态：分析中 / 执行中 / 等待外部 / 被阻塞 / 需转派 / 已完成 任务目标：... 完成条件：... 阻塞条件：... 所属边界：... 下一步：... ``` 然后立刻登记状态： ```bash python3 skills/long-run-mode/scripts/task_state.py set \ --task "<任务名>" \ --session "<人类可读会话名>" \ --origin-session-key "<真实目标 sessionKey>" \ --state "analyzing|executing|waiting|blocked|handoff|done" \ --goal "<任务目标>" \ --done-when "<完成条件>" \ --blocked-when "<阻塞条件>" \ --boundary "<所属边界>" \ --next-check "<ISO时间或相对描述>" \ --note "<当前下一步>" \ --progress-every "3m" ``` 登记后必须立刻跑一次启动校验： ```bash python3 skills/long-run-mode/scripts/validate_task_start.py \ --task "<任务名>" \ --expect-session-key "<真实目标 sessionKey>" ``` 只有当校验返回 `ok: true` 时，才能对用户说“已进入长任务模式”或“已开启长任务模式”。 硬规则： - 对 `analyzing` / `executing` / `waiting` 这类“已启动态”，**必须提供真实 `--origin-session-key`**。 - 如果拿不到真实目标会话，就只能先说明“还没绑定成功”，不能宣称“已进入长任务模式”或“已开启长任务模式”。 - `session` 只是人类可读名字；真正的发送、保活、恢复都靠 `origin_session_key`。 如果你要最基础的“会话别断”保活链，直接再加一层 1 分钟无活动检测： ```bash python3 skills/long-run-mode/scripts/keepalive_tick.py --task "<任务名>" --idle-seconds 60 python3 skills/long-run-mode/scripts/run_keepalive_once.py --idle-seconds 60 ``` 可选环境变量： - `OPENCLAW_BIN`：覆盖 `openclaw` 可执行文件名或路径 - `OPENCLAW_SESSIONS_FILE`：覆盖 sessions 索引文件路径 - `LOG_FILE`：覆盖 watchdog/cron 日志输出位置 它会综合判断这些活动信号的最近值： - `last_visible_reply_at` - `last_tool_result_at` - `last_progress_at` - `updated_at` 如果任务仍处于 `analyzing` / `executing`，且这些信号都沉默超过阈值，就返回 `should_resume: true`。 如果要直接产出“保活续跑事件”，用： ```bash python3 skills/long-run-mode/scripts/emit_keepalive_resume.py --task "<任务名>" --idle-seconds 60 ``` 它会返回： - `delivery: session` - `sessionKey` - `message`（续跑消息） - `onSuccess: task_state.py mark-resume` 默认目标是：**检测到长时间无输出后，先生成可审计的续跑计划。** 只有显式设置 `LONG_RUN_MODE_AUTO_RESUME=1` 时，才会真正把当前任务续起来。 ## 状态机 只允许： - `analyzing` - `executing` - `waiting` - `blocked` - `handoff` - `done` 禁止模糊状态。 ## 主规则：前台心跳优先 **前台可见进度是主路径，watchdog 只是兜底。** 铁律：**长任务一旦开始，后续每次继续推进前，若距上次可见进度已超 `3m`（或任务自己的 `progress_every`），必须先发一条短进度，不能先做别的。** 只要任务还在 `analyzing` 或 `executing`： - 超过 `progress_every`（默认 `5m`）没有可见更新，就必须发一条短进度 - 当前执行流不能靠“想起来再检查”；必须把前台心跳嵌进每一轮工作循环 - 每完成一轮有意义推进后，固定跑一次循环钩子；如果到点，**先汇报，再继续下一轮** - 短进度必须包含三件事： - 正在看什么 - 刚确认什么 - 下一步是什么 推荐直接走工作循环钩子： ```bash python3 skills/long-run-mode/scripts/work_cycle_tick.py --task "<任务名>" --step "<这一轮刚做了什么>" ``` 它会： - 先判断现在是否到点 - 如果到点，返回 `must_send_now: true`，这不是建议，而是硬信号 - 如果没到点，明确告诉你继续下一轮 强协议： - 只要返回 `must_send_now: true`，当前这一轮就**不得继续埋头做事** - 这是前台心跳，默认**直接回当前会话**，不要默认走跨会话 `sessions_send` - `session` / `boundary` 字段表示任务归属边界，不等于真实可发送会话；真正可发送目标以 `origin_session_key` 为准 - 只有后台回收、跨会话转派、handoff 时，才应尝试 `sessions_send` - 成功后必须执行： ```bash python3 skills/long-run-mode/scripts/task_state.py touch-progress --task "<任务名>" ``` - 失败后必须执行： ```bash python3 skills/long-run-mode/scripts/task_state.py mark-progress-attempt \ --task "<任务名>" \ --error "<失败原因>" ``` - 如果连续触发前台心跳但仍未送达，应转 `blocked`，不能继续假装“还在测” 如果只想单独看前台心跳，也可以： ```bash python3 skills/long-run-mode/scripts/foreground_tick.py --task "<任务名>" ``` 如果要给宿主/自动化接一个“前台到点事件流”，用： ```bash python3 skills/long-run-mode/scripts/emit_foreground_due.py ``` 它只负责产出**前台自动消息事件**： - `delivery: session` - `sessionKey` - `session_label` - `message` - `onSuccess: touch-progress` - `onError: mark-progress-attempt` 注意：它产出的是事件，不等于已经发出；仍需由 OpenClaw tool/agent 层真正 `sessions_send`。 若 `sessions_send` 返回 `timeout`，必须记为 `unknown`，不能直接记成失败；同一消息在短冷却窗口内不得重复发。 如果要继续往前接 dispatcher 计划，用： ```bash python3 skills/long-run-mode/scripts/dispatch_foreground_due.py ``` 如果需要拆开调试，也可以分两步： ```bash python3 skills/long-run-mode/scripts/check_foreground_progress.py --task "<任务名>" python3 skills/long-run-mode/scripts/build_progress_update.py --task "<任务名>" ``` 成功发出后： ```bash python3 skills/long-run-mode/scripts/task_state.py touch-progress --task "<任务名>" ``` 如果尝试发了但失败： ```bash python3 skills/long-run-mode/scripts/task_state.py mark-progress-attempt \ --task "<任务名>" \ --error "<失败原因>" ``` ## 边界与转派 - `openclaw manager / App / 控制 UI` → `openclaw-control-ui` - `PM 交易 / Telegram / 监控 / 告警 / 日志链路` → PM 对应会话 - `主会话规则 / 记忆 / 全局整理` → 主会话 如果当前会话不该继续做，转 `handoff`，不要硬做。 ## waiting 规则 进入 `waiting` 只能三选一： 1. 后台执行并约定检查点 2. 使用 cron / 唤醒机制约定回收时间 3. 明确宣布等待外部，并写清在等什么 进入 `waiting` 后立刻更新状态文件。 ## blocked 规则 出现任一情况立刻转 `blocked`： - 连续 3 次工具失败 - 超过 10 分钟没有推进 - 缺权限、token、登录、浏览器接管 - 缺必要用户确认 阻塞时必须说明： - 卡在哪里 - 缺什么 - 建议下一步 ## 跟进审计兜底 当任务失联、超时、卡住或等待态到检查点时，用审计/回收脚本兜底： ```bash python3 skills/long-run-mode/scripts/watchdog.py check python3 skills/long-run-mode/scripts/watchdog.py check --json python3 skills/long-run-mode/scripts/recover_due_tasks.py --json python3 skills/long-run-mode/scripts/build_sessions_send_payloads.py --json ``` 标准动作： 1. 生成 `sessions_send` payload 2. 逐条尝试发送到目标会话 3. 成功后回写状态 4. 失败则记录错误，不得谎报“已通知” ## 真值规则 以下三者必须严格区分： - 已生成消息 - 已尝试发送 - 用户已真正收到 只有真正发到会话后，才能更新： - `last_progress_at` - `last_progress_sent_at` ## 发布前验收 如果要发表，这个 skill 至少要过： 1. **可见性**：15 分钟长任务里至少自动出现 2 次短进度 2. **诚实性**：发送失败时能看到失败时间与原因 3. **回收性**：任务失联后能真正发回目标会话，而不是只生成 payload ## 可发布约束 对外上传前必须再确认： - skill 目录里不包含 `.task-state.json`、`watchdog.log`、`__pycache__/`、`*.pyc` - 运行态状态文件由安装后的实际环境自行生成，不随 skill 分发 - 不允许写死某个固定 `sessionKey` 作为默认兜底目标 - 缺少 `origin_session_key` 时应直接报错，而不是静默退回到别的会话 - 对外描述时只能承诺已验证能力；不要把“事件已生成”写成“消息已送达” �已送达” �已送达” �已送达” 已送达”

path: references/protocol.md
content: # 长任务执行协议

## 1. 定位
`long-run-mode` 不是单纯的 watchdog 集合，而是一套长任务执行协议：
- 前台进度心跳是主路径
- 后台超时回收是兜底路径
- 长任务“启动成功”必须以**真实目标会话绑定成功**为前提；只写状态文件不算启动成功

## 2. 可见进度的定义
满足以下三条才算一条有效进度：
1. 用户所在会话能看到
2. 内容说明了“正在看什么 / 刚确认什么 / 下一步是什么”
3. 成功发送后才回写 `last_progress_sent_at`

仅仅生成 payload，不算可见进度。

## 3. 发送真值
区分三种状态：
- 已生成消息
- 已尝试发送
- 已成功送达目标会话

字段约定：
- `last_progress_attempt_at`: 尝试发送时间
- `last_progress_sent_at`: 成功发送时间
- `last_progress_send_error`: 最近失败原因
- `last_progress_at`: 最近一次确认用户已收到进度的时间

## 4. 前台主路径
只要任务在 `analyzing` 或 `executing`：
- 默认每 `5m` 必须出现一条短进度
- 当前执行流自己负责发出，不依赖 cron/watchdog 作为主路径
- 长任务一旦开始，后续每次继续推进前，若距上次可见进度已超 `3m`（或任务自己的 `progress_every`），必须先发一条短进度，不能先做别的
- 推荐把 `work_cycle_tick.py --task "<任务名>" --step "<这一轮动作>"` 当成每轮推进后的固定钩子
- `foreground_tick.py` 是单任务前台心跳入口，`work_cycle_tick.py` 是更贴近真实执行流的循环钩子
- `emit_foreground_due.py` 是“自动触发器骨架”：扫描所有到点前台任务，产出带 `sessionKey` 与 `session_label` 的前台自动消息事件，供宿主或自动化接入
- `dispatch_foreground_due.py` 把这些事件整理成 dispatcher 计划；真正 `sessions_send` 仍需由 OpenClaw tool/agent 层执行
- `sessions_send timeout` 必须记为 `unknown`，不能直接记成 `failed`
- 同一 `message_hash` 若最近仍处于 `unknown` 冷却窗口内，`emit_foreground_due.py` 必须跳过，避免重复发
- 如果返回 `must_send_now: true`，这代表**必须立即发送**，不是建议动作
- 前台心跳应默认发往该任务绑定的真实 `sessionKey`；不要把项目边界标签误当成真实可发送会话
- 成功后必须 `touch-progress`；失败后必须 `mark-progress-attempt`
- 若前台心跳连续触发仍未送达，应转 `blocked`

## 5. 自动触发与后台兜底
前台自动触发骨架：
- `emit_foreground_due.py` 扫描 `watchdog.py check --json` 中的 `progress` 项
- 为每个到点任务构造带真实 `sessionKey` 的 `delivery: session` 事件
- `dispatch_foreground_due.py` 再把事件整理成 dispatcher 计划
- 只有 OpenClaw tool/agent 层真正 `sessions_send` 成功后，才能执行 `touch-progress`
- 如果发送失败，必须执行 `mark-progress-attempt`

审计/回收兜底只处理这些情况：
- `waiting` 到检查点
- `analyzing` 停留过久
- `executing` 超出检查窗口
- 长时间没有可见输出

当前改造阶段新增约束：
- 长任务锁已从“全局单例”升级为“每会话单例”
- 同一 `origin_session_key` 不得同时持有多个长任务
- 不同会话允许各自持有一个长任务
- 后续 keepalive / recovery / foreground 消息都应逐步改为只认 `origin_session_key`

## 6. 失败判定
出现以下任一情况，视为 skill 本轮失效：
- 超过 `progress_every` 没有任何可见进度
- 发送失败但没有记录 `last_progress_send_error`
- 只生成 payload 就宣称“已经提醒”
- 回收脚本检测到了任务，但没有真正尝试发送
- 只执行了 `task_state.py set`，却没有通过 `validate_task_start.py` 校验就宣称“已进入长任务模式”或“已开启长任务模式”

## 7. 发布口径
发布时只能承诺已经稳定实现的能力。
若“自动可见进度”尚未稳定，就只能写成：
- 状态登记
- 前台进度协议
- 后台回收辅助

不能写成全自动、稳定提醒、完全闭环。

## 8. 可发布约束
为了让别人安装后可直接使用，发布包必须满足：
- 不包含运行态文件：`.task-state.json`、`watchdog.log`、`__pycache__/`、`*.pyc`
- 不依赖任何固定机器路径或固定用户目录
- 不依赖固定会话 key；`origin_session_key` 必须来自实际任务登记
- 若缺少 `origin_session_key`，应报错，不允许偷偷 fallback 到某个默认会话
- `openclaw` 可执行文件路径、sessions 索引路径、日志输出路径都应允许通过环境变量覆盖
- 自动续跑必须显式开关控制；默认应只生成计划，不自动后台拉起 agent
- 默认日志输出不要污染 skill 根目录
- 文档中区分“生成事件”“尝试发送”“成功送达”，不要把生成 payload 写成已经送达
�
�经送达
��已经送达
�
�经送达


path: references/state-schema.md
content: # 长任务状态字段

## 存储位置
默认状态文件：
- `skills/long-run-mode/.task-state.json`

## 字段
- `task`: 任务名，建议稳定不乱改。
- `session`: 兼容保留字段；当前应视为人类可读会话名的旧镜像，不应用于真实发送目标判断。
- `session_label`: 当前负责推进的可读会话名/标签；给人看，不等于真实可发送会话。
- `origin_session_key`: 任务真实绑定的会话 key；前台心跳、恢复、保活续跑都应优先使用它。对 `analyzing|executing|waiting` 这类“已启动态”，缺少它应直接报错，不允许只登记状态却宣称已启动。
- `state`: `analyzing|executing|waiting|blocked|handoff|done`
- `goal`: 任务目标。
- `done_when`: 完成条件。
- `blocked_when`: 阻塞条件。
- `boundary`: 所属项目边界。
- `next_check`: 下次检查点。
- `note`: 当前备注/下一步。
- `updated_at`: 最近更新时间（ISO-8601）。
- `progress_every`: 期望的前台可见进度间隔，默认 `5m`。
- `active_long_runs_by_session`: 锁表；键是 `origin_session_key`，值是当前持有该会话长任务锁的任务名。
- `last_progress_at`: 最近一次确认“用户已收到进度”的时间。
- `last_progress_attempt_at`: 最近一次尝试发送进度的时间。
- `last_progress_sent_at`: 最近一次成功发送进度的时间。
- `last_progress_send_error`: 最近一次进度发送失败原因；成功后应清空。

## 使用建议
- 同一任务名反复 `set` 视为更新，不新建重复记录。
- 状态一变就更新，不要等任务做完再补。
- `next_check` 可写绝对时间，也可先写 `10m`、`30m` 这类相对描述，后续由 watchdog 决定是否需要标准化。
- `handoff` 用于明确转派到其他会话，而不是“暂时不管”。
- 状态文件现已支持 `active_long_runs_by_session`：表示“每个真实会话当前持有的长任务锁”。同一 `origin_session_key` 同时只允许一个长任务；不同会话可各自持有一个。


path: scripts/build_progress_update.py
content: #!/usr/bin/env python3
import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception as e:
        raise RuntimeError(f'failed to read state file: {e}')


def build_message(item):
    note = item.get('note') or '继续沿当前链路推进'
    goal = item.get('goal') or '继续推进当前长任务'
    state = item.get('state') or 'analyzing'
    next_check = item.get('next_check') or '待更新'
    lines = [
        f'长任务进度提醒：{item.get("task")}',
        f'当前状态：{state}',
        f'正在看：{note}',
        f'刚确认：{goal}',
        f'下一步：继续沿当前链路推进，并在 {next_check} 前给出新的阶段结论。',
    ]
    return '\n'.join(lines)


def main():
    p = argparse.ArgumentParser(description='Build a foreground progress update message for a tracked long-run task')
    p.add_argument('--task', required=True)
    args = p.parse_args()

    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1

    payload = {
        'task': item.get('task'),
        'session_label': item.get('session_label') or item.get('session'),
        'origin_session_key': item.get('origin_session_key'),
        'state': item.get('state'),
        'message': build_message(item),
    }
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/build_sessions_send_payloads.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
EMIT = ROOT / 'scripts' / 'emit_recovery_message.py'


def load_messages(stale_minutes: int):
    cmd = [sys.executable, str(EMIT), '--json', '--stale-minutes', str(stale_minutes)]
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'emit_recovery_message.py failed with code {proc.returncode}')
    return json.loads(proc.stdout)


def main():
    p = argparse.ArgumentParser(description='Build tool-ready payloads for sessions_send from overdue long-run tasks')
    p.add_argument('--stale-minutes', type=int, default=10)
    p.add_argument('--json', action='store_true', help='kept for symmetry; JSON is always emitted')
    args = p.parse_args()

    payload = load_messages(args.stale_minutes)
    messages = payload.get('messages', [])
    missing = [item.get('task') for item in messages if not item.get('origin_session_key')]
    if missing:
        raise RuntimeError(f'missing origin_session_key for task(s): {", ".join(missing)}')

    out = {
        'due_count': payload.get('due_count', 0),
        'progress_count': payload.get('progress_count', 0),
        'tool': 'sessions_send',
        'payloads': [
            {
                'sessionKey': item['origin_session_key'],
                'label': item['session'],
                'message': item['message'],
                'kind': item.get('kind'),
                'timeoutSeconds': 30,
            }
            for item in messages
        ],
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 0 if out['due_count'] == 0 and out.get('progress_count', 0) == 0 else 1


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/check_foreground_progress.py
content: #!/usr/bin/env python3
import argparse
import json
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'
REL_RE = re.compile(r'^(\d+)([mh])$')
ACTIVE_STATES = {'analyzing', 'executing'}


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception as e:
        raise RuntimeError(f'failed to read state file: {e}')


def parse_iso(value: str):
    if not value:
        return None
    try:
        return datetime.fromisoformat(value)
    except Exception:
        return None


def parse_relative(value: str, anchor: datetime):
    if not value:
        return None
    m = REL_RE.match(value.strip())
    if not m:
        return None
    count = int(m.group(1))
    unit = m.group(2)
    if unit == 'm':
        return anchor + timedelta(minutes=count)
    if unit == 'h':
        return anchor + timedelta(hours=count)
    return None


def main():
    p = argparse.ArgumentParser(description='Check whether a tracked task must emit a foreground progress update now')
    p.add_argument('--task', required=True)
    args = p.parse_args()

    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1

    state = item.get('state')
    if state not in ACTIVE_STATES:
        print(json.dumps({'task': args.task, 'due': False, 'reason': f'state={state}'}, ensure_ascii=False, indent=2))
        return 0

    now = datetime.now().astimezone()
    updated = parse_iso(item.get('updated_at', '')) or now
    last_sent = parse_iso(item.get('last_progress_sent_at', '')) or parse_iso(item.get('last_progress_at', '')) or updated
    every = item.get('progress_every', '5m')
    due_at = parse_relative(every, last_sent)
    due = bool(due_at and now >= due_at)

    origin_session_key = item.get('origin_session_key')
    if not origin_session_key:
        raise RuntimeError(f'missing origin_session_key for task: {args.task}')

    session_label = item.get('session_label') or item.get('session')
    out = {
        'task': item.get('task'),
        'session': session_label,
        'session_label': session_label,
        'origin_session_key': origin_session_key,
        'state': state,
        'progress_every': every,
        'last_progress_sent_at': item.get('last_progress_sent_at'),
        'last_progress_at': item.get('last_progress_at'),
        'updated_at': item.get('updated_at'),
        'due': due,
        'reason': f'progress_due ({every})' if due else 'within_window',
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 1 if due else 0


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/cleanup_state.py
content: #!/usr/bin/env python3
import argparse
import json
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'
ACTIVE_STATES = {'analyzing', 'executing', 'waiting'}


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}, 'active_long_runs_by_session': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception:
        return {'tasks': {}, 'active_long_runs_by_session': {}}


def save_state(data):
    STATE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')


def normalize_task(item):
    if not isinstance(item, dict):
        return item, []
    changes = []
    session_label = item.get('session_label') or item.get('session')
    if session_label and item.get('session_label') != session_label:
        item['session_label'] = session_label
        changes.append('set_session_label')
    if session_label and item.get('session') != session_label:
        item['session'] = session_label
        changes.append('normalize_session_to_label')
    return item, changes


def main():
    p = argparse.ArgumentParser(description='Clean up long-run-mode state file and drop invalid test/stale records')
    p.add_argument('--apply', action='store_true')
    p.add_argument('--drop-task', action='append', default=[])
    p.add_argument('--drop-test-prefix', action='append', default=['回归-'])
    args = p.parse_args()

    data = load_state()
    tasks = data.get('tasks', {}) or {}
    locks = data.get('active_long_runs_by_session', {}) or {}
    report = {'normalized': [], 'dropped': [], 'lock_repairs': []}

    new_tasks = {}
    for name, item in tasks.items():
        if name in args.drop_task or any(name.startswith(prefix) for prefix in args.drop_test_prefix):
            report['dropped'].append({'task': name, 'reason': 'matched_drop_rule'})
            continue
        item, changes = normalize_task(item)
        state = item.get('state')
        origin_session_key = item.get('origin_session_key')
        if state in ACTIVE_STATES and not origin_session_key:
            report['dropped'].append({'task': name, 'reason': 'active_state_missing_origin_session_key'})
            continue
        if changes:
            report['normalized'].append({'task': name, 'changes': changes})
        new_tasks[name] = item

    new_locks = {}
    for session_key, task_name in locks.items():
        if task_name not in new_tasks:
            report['lock_repairs'].append({'session_key': session_key, 'old_task': task_name, 'action': 'drop_orphan_lock'})
            continue
        task = new_tasks[task_name]
        if task.get('origin_session_key') != session_key:
            report['lock_repairs'].append({'session_key': session_key, 'task': task_name, 'action': 'replace_with_task_origin_session_key', 'new_session_key': task.get('origin_session_key')})
            if task.get('origin_session_key'):
                new_locks[task['origin_session_key']] = task_name
            continue
        new_locks[session_key] = task_name

    for task_name, task in new_tasks.items():
        session_key = task.get('origin_session_key')
        state = task.get('state')
        if state in ACTIVE_STATES and session_key and new_locks.get(session_key) != task_name:
            new_locks[session_key] = task_name
            report['lock_repairs'].append({'session_key': session_key, 'task': task_name, 'action': 'restore_missing_lock'})

    cleaned = {'tasks': new_tasks, 'active_long_runs_by_session': new_locks}
    report['task_count_before'] = len(tasks)
    report['task_count_after'] = len(new_tasks)
    report['lock_count_before'] = len(locks)
    report['lock_count_after'] = len(new_locks)
    report['apply'] = args.apply

    if args.apply:
        save_state(cleaned)

    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: scripts/dispatch_foreground_due.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
EMIT = ROOT / 'scripts' / 'emit_foreground_due.py'


def run_json(cmd):
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'command failed with code {proc.returncode}')
    try:
        data = json.loads(proc.stdout)
    except Exception as e:
        raise RuntimeError(f'failed to parse JSON: {e}')
    return proc.returncode, data


def main():
    p = argparse.ArgumentParser(description='Build dispatcher plan for foreground due events; actual sessions_send must be done by OpenClaw tool layer')
    p.add_argument('--unknown-cooldown-minutes', type=int, default=2)
    args = p.parse_args()

    _, emitted = run_json([sys.executable, str(EMIT), '--unknown-cooldown-minutes', str(args.unknown_cooldown_minutes)])
    dispatches = []
    for event in emitted.get('events', []):
        dispatches.append({
            'tool': 'sessions_send',
            'sessionKey': event.get('sessionKey'),
            'message': event.get('message'),
            'task': event.get('task'),
            'delivery': event.get('delivery'),
            'delivery_id': event.get('delivery_id'),
            'message_hash': event.get('message_hash'),
            'session_label': event.get('session_label'),
            'origin_session_key': event.get('sessionKey'),
            'onSuccess': event.get('onSuccess'),
            'onUnknown': event.get('onUnknown'),
            'onError': event.get('onError'),
        })
    out = {
        'dispatch_count': len(dispatches),
        'dispatches': dispatches,
        'skipped_unknown_count': emitted.get('skipped_unknown_count', 0),
        'skipped_unknown': emitted.get('skipped_unknown', []),
        'note': '此脚本只生成 dispatcher 计划；真正 sessions_send 需由 OpenClaw agent/tool 层执行。timeout 应记为 unknown，不要直接当 failed 重发。',
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 0 if not dispatches else 1


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/emit_foreground_due.py
content: #!/usr/bin/env python3
import argparse
import hashlib
import json
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
WATCHDOG = ROOT / 'scripts' / 'watchdog.py'
BUILD = ROOT / 'scripts' / 'build_progress_update.py'
STATE_FILE = ROOT / '.task-state.json'
DEFAULT_UNKNOWN_COOLDOWN_MINUTES = 2


def run_json(cmd):
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'command failed with code {proc.returncode}')
    try:
        data = json.loads(proc.stdout)
    except Exception as e:
        raise RuntimeError(f'failed to parse JSON: {e}')
    return proc.returncode, data


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception:
        return {'tasks': {}}


def parse_iso(value):
    if not value:
        return None
    try:
        return datetime.fromisoformat(value)
    except Exception:
        return None


def parse_progress_every_minutes(value):
    if not value:
        return 3
    text = str(value).strip().lower()
    if text.endswith('m'):
        try:
            return max(1, int(text[:-1]))
        except Exception:
            return 3
    try:
        return max(1, int(text))
    except Exception:
        return 3


def build_progress_slot(progress_every):
    minutes = parse_progress_every_minutes(progress_every)
    now = datetime.now().astimezone()
    epoch_minutes = int(now.timestamp() // 60)
    return epoch_minutes // minutes


def build_delivery_meta(task, built, task_state):
    progress_every = task_state.get('progress_every') or '3m'
    progress_slot = build_progress_slot(progress_every)
    raw = json.dumps({
        'task': task,
        'state': built.get('state'),
        'sessionKey': built.get('origin_session_key'),
        'progressEvery': progress_every,
        'progressSlot': progress_slot,
    }, ensure_ascii=False, sort_keys=True)
    message_hash = hashlib.sha256(raw.encode('utf-8')).hexdigest()
    delivery_id = f'fg-{message_hash[:16]}'
    return delivery_id, message_hash


def should_skip_unknown(item, message_hash, cooldown_minutes):
    if item.get('last_progress_delivery_status') != 'unknown':
        return False
    if item.get('last_progress_message_hash') != message_hash:
        return False
    unknown_at = parse_iso(item.get('last_progress_unknown_at'))
    if not unknown_at:
        return False
    now = datetime.now().astimezone()
    return now - unknown_at < timedelta(minutes=cooldown_minutes)


def main():
    p = argparse.ArgumentParser(description='Emit foreground progress events for due long-run tasks')
    p.add_argument('--stale-minutes', type=int, default=10)
    p.add_argument('--unknown-cooldown-minutes', type=int, default=DEFAULT_UNKNOWN_COOLDOWN_MINUTES)
    args = p.parse_args()

    state = load_state().get('tasks', {})
    _, report = run_json([sys.executable, str(WATCHDOG), 'check', '--json', '--stale-minutes', str(args.stale_minutes)])
    events = []
    skipped_unknown = []
    for item in report.get('progress', []):
        task = item.get('task')
        if not task:
            continue
        _, built = run_json([sys.executable, str(BUILD), '--task', task])
        task_state = state.get(task, {})
        delivery_id, message_hash = build_delivery_meta(task, built, task_state)
        if should_skip_unknown(task_state, message_hash, args.unknown_cooldown_minutes):
            skipped_unknown.append({
                'task': task,
                'delivery_id': delivery_id,
                'message_hash': message_hash,
                'reason': 'recent_unknown_delivery',
            })
            continue
        events.append({
            'kind': 'foreground-progress',
            'delivery': 'session',
            'sessionKey': built.get('origin_session_key'),
            'task': task,
            'session_label': built.get('session_label') or item.get('session_label') or item.get('session'),
            'state': built.get('state') or item.get('state'),
            'reason': item.get('reason'),
            'message': built.get('message'),
            'delivery_id': delivery_id,
            'message_hash': message_hash,
            'onSuccess': {
                'script': 'task_state.py mark-progress-sent',
                'task': task,
                'note': item.get('note'),
                'delivery_id': delivery_id,
                'message_hash': message_hash,
            },
            'onUnknown': {
                'script': 'task_state.py mark-progress-unknown',
                'task': task,
                'note': item.get('note'),
                'delivery_id': delivery_id,
                'message_hash': message_hash,
            },
            'onError': {
                'script': 'task_state.py mark-progress-failed',
                'task': task,
                'note': item.get('note'),
                'delivery_id': delivery_id,
                'message_hash': message_hash,
            },
        })

    out = {
        'event_count': len(events),
        'events': events,
        'skipped_unknown_count': len(skipped_unknown),
        'skipped_unknown': skipped_unknown,
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 0 if not events else 1


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/emit_keepalive_resume.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
KEEPALIVE = ROOT / 'scripts' / 'keepalive_tick.py'


def run_json(cmd):
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'command failed with code {proc.returncode}')
    try:
        data = json.loads(proc.stdout)
    except Exception as e:
        raise RuntimeError(f'failed to parse JSON: {e}')
    return proc.returncode, data


def main():
    p = argparse.ArgumentParser(description='Emit keepalive resume event for a long-run task when idle too long')
    p.add_argument('--task', required=True)
    p.add_argument('--idle-seconds', type=int, default=60)
    args = p.parse_args()

    _, tick = run_json([sys.executable, str(KEEPALIVE), '--task', args.task, '--idle-seconds', str(args.idle_seconds)])
    if not tick.get('should_resume'):
        out = {
            'event_count': 0,
            'events': [],
            'reason': tick.get('reason', 'still_active'),
        }
        print(json.dumps(out, ensure_ascii=False, indent=2))
        return 0

    session_key = tick.get('origin_session_key')
    if not session_key:
        raise RuntimeError('missing origin_session_key; cannot emit portable keepalive event')

    event = {
        'kind': 'keepalive-resume',
        'delivery': 'session',
        'sessionKey': session_key,
        'task': tick.get('task'),
        'state': tick.get('state'),
        'message': tick.get('resume_message'),
        'reason': tick.get('reason'),
        'idle_for_seconds': tick.get('idle_for_seconds'),
        'last_activity_source': tick.get('last_activity_source'),
        'onSuccess': {
            'script': 'task_state.py mark-resume',
            'task': tick.get('task'),
            'reason': tick.get('reason') or 'idle_timeout',
            'note': '保活续跑已触发，继续沿上次锚点推进',
        }
    }
    out = {
        'event_count': 1,
        'events': [event],
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 1


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/emit_recovery_message.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
RECOVER = ROOT / 'scripts' / 'recover_due_tasks.py'


def load_report(stale_minutes: int):
    cmd = [sys.executable, str(RECOVER), '--json', '--stale-minutes', str(stale_minutes)]
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'recover_due_tasks.py failed with code {proc.returncode}')
    return json.loads(proc.stdout)


def build_message(item):
    recovery = item['recovery']
    lines = [
        f"长任务{'进度' if item.get('kind') == 'progress' else '回收'}提醒：{item['task']}",
        f"当前状态：{item['state']}",
        f"所属边界：{item['boundary']}",
        f"负责会话：{item['session']}",
        f"触发原因：{item['reason']}",
        f"任务目标：{item['goal']}",
        f"当前备注：{item['note']}",
        f"建议动作：{recovery['kind']}",
        f"建议发往：{recovery['suggested_session']}",
        '建议消息：',
        recovery['suggested_message'],
    ]
    return '\n'.join(lines)


def main():
    p = argparse.ArgumentParser(description='Emit human-readable recovery messages for overdue tasks')
    p.add_argument('--stale-minutes', type=int, default=10)
    p.add_argument('--json', action='store_true')
    args = p.parse_args()

    report = load_report(args.stale_minutes)
    items = report.get('items', [])

    if args.json:
        missing = [item.get('task') for item in items if not item.get('origin_session_key')]
        if missing:
            raise RuntimeError(f'missing origin_session_key for task(s): {", ".join(missing)}')
        payload = {
            'due_count': report.get('due_count', 0),
            'progress_count': report.get('progress_count', 0),
            'messages': [
                {
                    'task': item['task'],
                    'session': item['session'],
                    'origin_session_key': item['origin_session_key'],
                    'kind': item.get('kind'),
                    'message': build_message(item),
                }
                for item in items
            ],
        }
        print(json.dumps(payload, ensure_ascii=False, indent=2))
        return 0 if payload['due_count'] == 0 and payload.get('progress_count', 0) == 0 else 1

    if not items:
        print('emit: no recovery messages to send')
        return 0

    for idx, item in enumerate(items, start=1):
        if idx > 1:
            print('\n' + '=' * 60 + '\n')
        print(build_message(item))

    return 1


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/foreground_tick.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
CHECK = ROOT / 'scripts' / 'check_foreground_progress.py'
BUILD = ROOT / 'scripts' / 'build_progress_update.py'


def run_json(cmd):
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'command failed with code {proc.returncode}')
    try:
        data = json.loads(proc.stdout)
    except Exception as e:
        raise RuntimeError(f'failed to parse JSON: {e}')
    return proc.returncode, data


def main():
    p = argparse.ArgumentParser(description='Foreground heartbeat helper: decide whether to report progress now and build the message if due')
    p.add_argument('--task', required=True)
    args = p.parse_args()

    status_code, check = run_json([sys.executable, str(CHECK), '--task', args.task])
    out = {
        'task': args.task,
        'due': bool(check.get('due')),
        'check': check,
        'message': None,
        'delivery': 'session',
        'sessionKey': check.get('origin_session_key') or check.get('session'),
        'session_label': check.get('session_label') or check.get('session'),
        'state': check.get('state'),
    }
    if status_code == 1 and check.get('due'):
        _, built = run_json([sys.executable, str(BUILD), '--task', args.task])
        built_session_key = built.get('origin_session_key')
        if not built_session_key:
            raise RuntimeError('missing origin_session_key in built progress update')
        out['message'] = built.get('message')
        out['state'] = built.get('state')
        out['sessionKey'] = built_session_key
        out['session_label'] = built.get('session_label') or out.get('session_label')

    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 1 if out['due'] else 0


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/install_watchdog_cron.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3}"
INTERVAL="${1:-5}"
LOG_FILE="${LOG_FILE:-${TMPDIR:-/tmp}/long-run-mode-watchdog.log}"

cat <<EOF
# 建议添加到 crontab 的条目（每 ${INTERVAL} 分钟跑一次兜底回收）
# 可选环境变量：PYTHON_BIN、LOG_FILE、OPENCLAW_BIN、OPENCLAW_SESSIONS_FILE、LONG_RUN_MODE_AUTO_RESUME
# 默认只做检查/生成计划；只有显式设置 LONG_RUN_MODE_AUTO_RESUME=1 时才允许自动续跑
*/${INTERVAL} * * * * cd "$ROOT/.." && PYTHON_BIN="$PYTHON_BIN" LOG_FILE="$LOG_FILE" OPENCLAW_BIN="${OPENCLAW_BIN:-openclaw}" OPENCLAW_SESSIONS_FILE="${OPENCLAW_SESSIONS_FILE:-}" LONG_RUN_MODE_AUTO_RESUME="${LONG_RUN_MODE_AUTO_RESUME:-}" "$PYTHON_BIN" "$ROOT/scripts/run_watchdog_once.py" >> "$LOG_FILE" 2>&1
EOF


path: scripts/keepalive_tick.py
content: #!/usr/bin/env python3
import argparse
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'
ACTIVE_STATES = {'analyzing', 'executing'}


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception as e:
        raise RuntimeError(f'failed to read state file: {e}')


def parse_iso(value):
    if not value:
        return None
    try:
        return datetime.fromisoformat(value)
    except Exception:
        return None


def latest_activity(item):
    candidates = {
        'last_visible_reply_at': parse_iso(item.get('last_visible_reply_at')),
        'last_tool_result_at': parse_iso(item.get('last_tool_result_at')),
        'last_progress_at': parse_iso(item.get('last_progress_at')),
        'updated_at': parse_iso(item.get('updated_at')),
    }
    valid = {k: v for k, v in candidates.items() if v is not None}
    if not valid:
        return None, None
    source, ts = max(valid.items(), key=lambda kv: kv[1])
    return source, ts


def build_resume_message(item, idle_for_seconds, source):
    note = item.get('note') or '继续沿上次锚点推进'
    goal = item.get('goal') or '继续推进当前任务'
    return '\n'.join([
        f'保活续跑：{item.get("task")}',
        f'当前状态：{item.get("state") or "analyzing"}',
        f'静默时长：约 {idle_for_seconds} 秒（最近活动源：{source or "unknown"}）',
        f'任务目标：{goal}',
        f'立即继续：{note}',
        '要求：不要停在说明，直接继续当前任务，并尽快给出新的可见进展。',
    ])


def main():
    p = argparse.ArgumentParser(description='Keepalive check for long-run tasks: resume if idle for too long')
    p.add_argument('--task', required=True)
    p.add_argument('--idle-seconds', type=int, default=60)
    args = p.parse_args()

    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1

    state = item.get('state')
    source, ts = latest_activity(item)
    now = datetime.now().astimezone()
    idle_for = None if ts is None else int((now - ts).total_seconds())
    should_resume = state in ACTIVE_STATES and (idle_for is None or idle_for > args.idle_seconds)

    session_label = item.get('session_label') or item.get('session')
    out = {
        'task': args.task,
        'state': state,
        'origin_session_key': item.get('origin_session_key'),
        'session_label': session_label,
        'active_states': sorted(ACTIVE_STATES),
        'idle_seconds_threshold': args.idle_seconds,
        'last_activity_source': source,
        'last_activity_at': None if ts is None else ts.isoformat(timespec='seconds'),
        'idle_for_seconds': idle_for,
        'should_resume': should_resume,
        'reason': 'idle_timeout' if should_resume else 'still_active',
        'resume_message': build_resume_message(item, idle_for or args.idle_seconds, source) if should_resume else None,
        'instruction': '若 should_resume=true：先发 resume_message 到当前会话，再执行 task_state.py mark-resume --task <任务名> --reason idle_timeout。',
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 1 if should_resume else 0


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/recover_due_tasks.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
WATCHDOG = ROOT / 'scripts' / 'watchdog.py'


def run_watchdog(stale_minutes: int):
    cmd = [sys.executable, str(WATCHDOG), 'check', '--json', '--stale-minutes', str(stale_minutes)]
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'watchdog.py failed with code {proc.returncode}')
    return json.loads(proc.stdout)


def require_origin_session_key(item):
    session_key = item.get('origin_session_key')
    if not session_key:
        raise RuntimeError(f"missing origin_session_key for task: {item.get('task')}")
    return session_key


def suggest_action(item):
    state = item.get('state')
    session = item.get('session_label') or item.get('session')
    origin_session_key = require_origin_session_key(item)
    reason = item.get('reason')
    note = item.get('note')

    if state == 'waiting':
        return {
            'kind': 'check-waiting',
            'summary': f'任务处于等待态但已到检查点，去确认外部条件是否已满足：{note}',
            'suggested_session': session,
            'suggested_session_key': origin_session_key,
            'suggested_message': f'长任务回收：你之前处于 waiting，原因是 {note}。现在检查等待条件是否已满足；若仍未满足，更新 next_check；若已满足，继续推进并回报结果。',
        }

    if state == 'executing':
        return {
            'kind': 'check-progress',
            'summary': f'任务仍在执行态但超过检查窗口，确认是否卡住：{note}',
            'suggested_session': session,
            'suggested_session_key': origin_session_key,
            'suggested_message': f'长任务回收：你之前处于 executing，但已超出检查窗口（{reason}）。请立刻回报当前进展；若已阻塞，转 blocked 并说明缺什么；若仍可继续，更新状态并继续推进。',
        }

    if state == 'analyzing':
        return {
            'kind': 'force-decision',
            'summary': f'分析态停留过久，要求给结论或切执行：{note}',
            'suggested_session': session,
            'suggested_session_key': origin_session_key,
            'suggested_message': f'长任务回收：你之前处于 analyzing，但已超出检查窗口（{reason}）。请在本轮给出阶段结论，并明确进入 executing、blocked 或 handoff，不能继续停留在纯分析。',
        }

    return {
        'kind': 'manual-review',
        'summary': f'任务需要人工查看：{note}',
        'suggested_session': session,
        'suggested_session_key': origin_session_key,
        'suggested_message': f'长任务回收：任务状态为 {state}，触发原因 {reason}。请检查并更新状态。',
    }


def suggest_progress(item):
    return {
        'kind': 'progress-ping',
        'summary': f'任务持续推进中，需要发一条可见进度：{item.get("note")}',
        'suggested_session': item.get('session'),
        'suggested_session_key': require_origin_session_key(item),
        'suggested_message': f'长任务进度：当前仍处于 {item.get("state")}。正在处理：{item.get("note")}。刚确认：{item.get("goal")}。下一步：继续沿当前链路推进，并在本轮更新学习结论。',
    }


def build_report(data):
    due = data.get('due', [])
    progress = data.get('progress', [])
    items = []
    for item in due:
        session_label = item.get('session_label') or item.get('session')
        items.append({
            'task': item.get('task'),
            'state': item.get('state'),
            'session': session_label,
            'session_label': session_label,
            'origin_session_key': require_origin_session_key(item),
            'boundary': item.get('boundary'),
            'reason': item.get('reason'),
            'goal': item.get('goal'),
            'next_check': item.get('next_check'),
            'updated_at': item.get('updated_at'),
            'note': item.get('note'),
            'kind': 'recovery',
            'recovery': suggest_action(item),
        })
    for item in progress:
        session_label = item.get('session_label') or item.get('session')
        items.append({
            'task': item.get('task'),
            'state': item.get('state'),
            'session': session_label,
            'session_label': session_label,
            'origin_session_key': require_origin_session_key(item),
            'boundary': item.get('boundary'),
            'reason': item.get('reason'),
            'goal': item.get('goal'),
            'next_check': item.get('next_check'),
            'updated_at': item.get('updated_at'),
            'note': item.get('note'),
            'kind': 'progress',
            'recovery': suggest_progress(item),
        })
    return {
        'due_count': len(due),
        'progress_count': len(progress),
        'items': items,
    }


def print_human(report):
    if report['due_count'] == 0 and report.get('progress_count', 0) == 0:
        print('recover: no overdue tasks')
        return 0

    print(f"recover: {report['due_count']} overdue task(s), {report.get('progress_count', 0)} progress update(s)")
    for item in report['items']:
        print(f"- task: {item['task']}")
        print(f"  kind: {item['kind']}")
        print(f"  state/session: {item['state']} / {item['session']}")
        print(f"  reason: {item['reason']}")
        print(f"  goal: {item['goal']}")
        print(f"  note: {item['note']}")
        print(f"  suggested action: {item['recovery']['kind']}")
        print(f"  suggested session: {item['recovery']['suggested_session']}")
        print(f"  suggested sessionKey: {item['recovery'].get('suggested_session_key')}")
        print(f"  suggested message: {item['recovery']['suggested_message']}")
    return 1


def main():
    p = argparse.ArgumentParser(description='Build recovery suggestions for overdue long-run tasks')
    p.add_argument('--stale-minutes', type=int, default=10)
    p.add_argument('--json', action='store_true')
    args = p.parse_args()

    data = run_watchdog(args.stale_minutes)
    report = build_report(data)
    if args.json:
        print(json.dumps(report, ensure_ascii=False, indent=2))
        return 0 if report['due_count'] == 0 and report.get('progress_count', 0) == 0 else 1
    return print_human(report)


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/run_keepalive_once.py
content: #!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'
EMIT = ROOT / 'scripts' / 'emit_keepalive_resume.py'
TASK_STATE = ROOT / 'scripts' / 'task_state.py'
OPENCLAW_BIN = os.environ.get('OPENCLAW_BIN', 'openclaw')
AUTO_RESUME = os.environ.get('LONG_RUN_MODE_AUTO_RESUME', '').strip().lower() in {'1', 'true', 'yes', 'on'}
SESSIONS_FILE = Path(
    os.environ.get(
        'OPENCLAW_SESSIONS_FILE',
        str(Path.home() / '.openclaw' / 'agents' / 'main' / 'sessions' / 'sessions.json'),
    )
)
ACTIVE_STATES = {'analyzing', 'executing'}


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception as e:
        raise RuntimeError(f'failed to read state file: {e}')


def run_json(cmd, ok_codes=(0, 1)):
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in ok_codes:
        raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or f'command failed with code {proc.returncode}')
    try:
        data = json.loads(proc.stdout)
    except Exception as e:
        raise RuntimeError(f'failed to parse JSON: {e}')
    return proc.returncode, data


def run_plain(cmd, ok_codes=(0,)):
    proc = subprocess.run(cmd, text=True, capture_output=True)
    ok = proc.returncode in ok_codes
    return ok, proc


def load_sessions_index():
    if not SESSIONS_FILE.exists():
        return {}
    try:
        return json.loads(SESSIONS_FILE.read_text())
    except Exception:
        return {}


def resolve_session_id(item, event, sessions_index):
    candidates = [
        item.get('origin_session_key'),
        event.get('sessionKey'),
    ]
    for candidate in candidates:
        if not candidate:
            continue
        if candidate in sessions_index and isinstance(sessions_index[candidate], dict):
            sid = sessions_index[candidate].get('sessionId')
            if sid:
                return sid, candidate, 'sessions_index'
        if isinstance(candidate, str) and len(candidate) >= 32 and '-' in candidate:
            return candidate, candidate, 'direct'
    return None, None, 'missing'


def sha256_text(text):
    return hashlib.sha256(text.encode('utf-8')).hexdigest()


def mark_attempt(task, message_hash):
    run_plain([
        sys.executable, str(TASK_STATE), 'mark-progress-attempt',
        '--task', task,
        '--message-hash', message_hash,
        '--note', '保活续跑准备执行 openclaw agent',
    ], ok_codes=(0, 1))


def mark_sent(task, message_hash, note):
    run_plain([
        sys.executable, str(TASK_STATE), 'mark-progress-sent',
        '--task', task,
        '--message-hash', message_hash,
        '--note', note,
    ], ok_codes=(0, 1))


def mark_failed(task, message_hash, error, note):
    run_plain([
        sys.executable, str(TASK_STATE), 'mark-progress-failed',
        '--task', task,
        '--message-hash', message_hash,
        '--error', error[:500],
        '--note', note,
    ], ok_codes=(0, 1))


def mark_resume(task, reason, note):
    run_plain([
        sys.executable, str(TASK_STATE), 'mark-resume',
        '--task', task,
        '--reason', reason or 'idle_timeout',
        '--note', note,
    ], ok_codes=(0, 1))


def resume_via_agent(session_id, message):
    cmd = [
        OPENCLAW_BIN, 'agent',
        '--session-id', session_id,
        '--message', message,
        '--json',
    ]
    try:
        proc = subprocess.Popen(
            cmd,
            text=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
        )
        return True, proc
    except Exception as e:
        class FailedProc:
            returncode = 1
            stdout = ''
            stderr = str(e)
        return False, FailedProc()


def main():
    p = argparse.ArgumentParser(description='Run keepalive once for all active long-run tasks and auto-resume overdue sessions')
    p.add_argument('--idle-seconds', type=int, default=60)
    args = p.parse_args()

    data = load_state()
    sessions_index = load_sessions_index()
    dispatches = []
    scanned = 0
    resumed = 0
    failed = 0
    for task, item in data.get('tasks', {}).items():
        if item.get('state') not in ACTIVE_STATES:
            continue
        scanned += 1
        _, emitted = run_json([sys.executable, str(EMIT), '--task', task, '--idle-seconds', str(args.idle_seconds)])
        for event in emitted.get('events', []):
            message = event.get('message') or ''
            message_hash = sha256_text(message)
            dispatch = {
                'tool': 'openclaw_agent_resume',
                'sessionKey': event.get('sessionKey') or item.get('origin_session_key'),
                'origin_session_key': item.get('origin_session_key'),
                'sessionId': None,
                'resolvedFrom': None,
                'resolveMode': 'not-resolved',
                'message': message,
                'task': event.get('task'),
                'reason': event.get('reason'),
                'idle_for_seconds': event.get('idle_for_seconds'),
                'last_activity_source': event.get('last_activity_source'),
            }
            dispatches.append(dispatch)

            mark_attempt(task, message_hash)
            if not AUTO_RESUME:
                dispatch['status'] = 'planned'
                dispatch['note'] = 'auto resume disabled; set LONG_RUN_MODE_AUTO_RESUME=1 to execute openclaw agent'
                continue

            session_id, resolved_from, resolve_mode = resolve_session_id(item, event, sessions_index)
            dispatch['sessionId'] = session_id
            dispatch['resolvedFrom'] = resolved_from
            dispatch['resolveMode'] = resolve_mode

            if not session_id:
                failed += 1
                dispatch['status'] = 'failed'
                dispatch['error'] = 'missing session id in task state'
                mark_failed(task, message_hash, dispatch['error'], '保活续跑失败：缺少 session')
                continue

            ok, proc = resume_via_agent(session_id, message)
            if ok:
                resumed += 1
                dispatch['status'] = 'spawned'
                dispatch['spawn_pid'] = getattr(proc, 'pid', None)
                mark_sent(task, message_hash, '保活续跑已后台触发')
                mark_resume(task, event.get('reason'), '保活续跑已触发，继续沿上次锚点推进')
            else:
                failed += 1
                dispatch['status'] = 'failed'
                dispatch['exit_code'] = proc.returncode
                dispatch['error'] = (proc.stderr or proc.stdout or 'openclaw agent failed').strip()[:2000]
                mark_failed(task, message_hash, dispatch['error'], '保活续跑执行失败')

    out = {
        'scanned_active_tasks': scanned,
        'dispatch_count': len(dispatches),
        'resumed_count': resumed,
        'failed_count': failed,
        'dispatches': dispatches,
        'note': '默认只生成跟进计划；设置 LONG_RUN_MODE_AUTO_RESUME=1 后才会真正执行自动续跑。建议每 60s 运行一次。',
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 0 if failed == 0 else 1


if __name__ == '__main__':
    raise SystemExit(main())
mExit(main())
(main())
emExit(main())


path: scripts/run_watchdog_once.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
RECOVER = ROOT / 'scripts' / 'recover_due_tasks.py'
TASK_STATE = ROOT / 'scripts' / 'task_state.py'


def load_report(stale_minutes: int):
    cmd = [sys.executable, str(RECOVER), '--json', '--stale-minutes', str(stale_minutes)]
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'recover_due_tasks.py failed with code {proc.returncode}')
    return json.loads(proc.stdout)


def record_attempt(task: str, note: str | None = None):
    cmd = [sys.executable, str(TASK_STATE), 'mark-progress-attempt', '--task', task]
    if note:
        cmd.extend(['--note', note])
    subprocess.run(cmd, text=True, capture_output=True)


def record_success(task: str, note: str | None = None):
    cmd = [sys.executable, str(TASK_STATE), 'touch-progress', '--task', task]
    if note:
        cmd.extend(['--note', note])
    subprocess.run(cmd, text=True, capture_output=True)


def record_error(task: str, error: str, note: str | None = None):
    cmd = [sys.executable, str(TASK_STATE), 'mark-progress-attempt', '--task', task, '--error', error]
    if note:
        cmd.extend(['--note', note])
    subprocess.run(cmd, text=True, capture_output=True)


def main():
    p = argparse.ArgumentParser(description='Run long-run-mode watchdog once and print tool payloads + state side effects')
    p.add_argument('--stale-minutes', type=int, default=10)
    args = p.parse_args()

    report = load_report(args.stale_minutes)
    out = {
        'due_count': report.get('due_count', 0),
        'progress_count': report.get('progress_count', 0),
        'tool': 'sessions_send',
        'payloads': [],
    }

    for item in report.get('items', []):
        task = item.get('task')
        session = item.get('recovery', {}).get('suggested_session') or item.get('session_label') or item.get('session')
        session_key = item.get('recovery', {}).get('suggested_session_key') or item.get('origin_session_key')
        message = item.get('recovery', {}).get('suggested_message')
        kind = item.get('kind')
        note = item.get('note')
        if not task or not session or not session_key or not message:
            continue
        if kind == 'progress':
            record_attempt(task, note)
        out['payloads'].append({
            'sessionKey': session_key,
            'label': session,
            'task': task,
            'kind': kind,
            'message': message,
            'timeoutSeconds': 30,
            'onSuccess': {
                'script': 'task_state.py touch-progress' if kind == 'progress' else None,
                'task': task,
                'note': note,
            },
            'onError': {
                'script': 'task_state.py mark-progress-attempt' if kind == 'progress' else None,
                'task': task,
                'note': note,
            },
        })

    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 0 if out['due_count'] == 0 and out['progress_count'] == 0 else 1


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/task_state.py
content: #!/usr/bin/env python3
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
import sys

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'
VALID_STATES = {'analyzing', 'executing', 'waiting', 'blocked', 'handoff', 'done'}
VALID_DELIVERY_STATUS = {'sent', 'unknown', 'failed'}
DEFAULT_PROGRESS_EVERY = '3m'


def now_iso() -> str:
    return datetime.now(timezone.utc).astimezone().isoformat(timespec='seconds')


def normalize_state(data):
    if not isinstance(data, dict):
        data = {}
    tasks = data.get('tasks')
    if not isinstance(tasks, dict):
        tasks = {}
    for _, item in tasks.items():
        if not isinstance(item, dict):
            continue
        session_label = item.get('session_label') or item.get('session')
        if session_label:
            item['session_label'] = session_label
            item['session'] = session_label
    data['tasks'] = tasks
    locks = data.get('active_long_runs_by_session')
    if not isinstance(locks, dict):
        locks = {}
    legacy_task = data.get('active_long_run') or data.get('active_long_run_task')
    if legacy_task and isinstance(legacy_task, str):
        item = tasks.get(legacy_task)
        session_key = item.get('origin_session_key') or item.get('session') if isinstance(item, dict) else None
        if session_key and session_key not in locks:
            locks[session_key] = legacy_task
    data['active_long_runs_by_session'] = locks
    return data


def load_state():
    if not STATE_FILE.exists():
        return normalize_state({'tasks': {}})
    try:
        return normalize_state(json.loads(STATE_FILE.read_text()))
    except Exception:
        return normalize_state({'tasks': {}})


def save_state(data):
    STATE_FILE.write_text(json.dumps(normalize_state(data), ensure_ascii=False, indent=2) + '\n')


def apply_delivery_meta(item, *, delivery_status=None, delivery_id=None, message_hash=None, error_marker='__KEEP__'):
    if delivery_status is not None:
        item['last_progress_delivery_status'] = delivery_status
    if delivery_id is not None:
        item['last_progress_delivery_id'] = delivery_id
    if message_hash is not None:
        item['last_progress_message_hash'] = message_hash
    if error_marker != '__KEEP__':
        item['last_progress_send_error'] = error_marker


def lock_session(data, session_key: str, task_name: str, *, force: bool = False):
    locks = data.setdefault('active_long_runs_by_session', {})
    current = locks.get(session_key)
    if current and current != task_name and not force:
        return current
    locks[session_key] = task_name
    return None


def release_session_lock(data, session_key: str, task_name: str | None = None):
    locks = data.setdefault('active_long_runs_by_session', {})
    current = locks.get(session_key)
    if current is None:
        return False
    if task_name and current != task_name:
        return False
    del locks[session_key]
    return True


def cmd_set(args):
    if args.state not in VALID_STATES:
        print(f'invalid state: {args.state}', file=sys.stderr)
        return 2
    data = load_state()
    tasks = data.setdefault('tasks', {})
    previous = tasks.get(args.task, {})
    origin_session_key = args.origin_session_key or previous.get('origin_session_key')
    if args.state in {'analyzing', 'executing', 'waiting'} and not origin_session_key:
        print(json.dumps({
            'error': 'missing origin_session_key',
            'task': args.task,
            'state': args.state,
            'message': 'long-run start/update requires a real origin_session_key; state registration alone does not mean the task started in the target session',
        }, ensure_ascii=False, indent=2), file=sys.stderr)
        return 4
    if args.state in {'analyzing', 'executing', 'waiting'}:
        conflict = lock_session(data, origin_session_key, args.task, force=args.force_lock)
        if conflict:
            print(json.dumps({
                'error': 'session lock exists',
                'origin_session_key': origin_session_key,
                'current_task': conflict,
                'requested_task': args.task,
            }, ensure_ascii=False, indent=2), file=sys.stderr)
            return 3
    elif args.state in {'handoff', 'done', 'blocked'}:
        release_session_lock(data, origin_session_key, args.task)
    tasks[args.task] = {
        'task': args.task,
        'session': args.session,
        'origin_session_key': origin_session_key,
        'state': args.state,
        'goal': args.goal,
        'done_when': args.done_when,
        'blocked_when': args.blocked_when,
        'boundary': args.boundary,
        'next_check': args.next_check,
        'note': args.note,
        'progress_every': args.progress_every or previous.get('progress_every') or DEFAULT_PROGRESS_EVERY,
        'last_progress_at': previous.get('last_progress_at') or now_iso(),
        'last_progress_attempt_at': previous.get('last_progress_attempt_at'),
        'last_progress_sent_at': previous.get('last_progress_sent_at'),
        'last_progress_unknown_at': previous.get('last_progress_unknown_at'),
        'last_progress_delivery_status': previous.get('last_progress_delivery_status'),
        'last_progress_delivery_id': previous.get('last_progress_delivery_id'),
        'last_progress_message_hash': previous.get('last_progress_message_hash'),
        'last_progress_send_error': previous.get('last_progress_send_error'),
        'last_visible_reply_at': previous.get('last_visible_reply_at'),
        'last_tool_result_at': previous.get('last_tool_result_at'),
        'last_resume_at': previous.get('last_resume_at'),
        'last_resume_reason': previous.get('last_resume_reason'),
        'last_resume_count': previous.get('last_resume_count', 0),
        'updated_at': now_iso(),
    }
    save_state(data)
    print(json.dumps(tasks[args.task], ensure_ascii=False, indent=2))
    return 0


def cmd_list(args):
    data = load_state()
    tasks = data.get('tasks', {})
    if not tasks:
        print('no tasks')
        return 0
    for name, item in tasks.items():
        print(f"- {name} | {item.get('state')} | {item.get('session')} | next={item.get('next_check')} | updated={item.get('updated_at')}")
    return 0


def cmd_show(args):
    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_touch_progress(args):
    data = load_state()
    tasks = data.get('tasks', {})
    item = tasks.get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    item['last_progress_at'] = now_iso()
    item['last_progress_sent_at'] = item['last_progress_at']
    item['last_progress_send_error'] = None
    item['last_progress_delivery_status'] = 'sent'
    item['last_progress_unknown_at'] = None
    if args.note:
        item['note'] = args.note
    if args.delivery_id:
        item['last_progress_delivery_id'] = args.delivery_id
    if args.message_hash:
        item['last_progress_message_hash'] = args.message_hash
    item['updated_at'] = now_iso()
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_mark_progress_attempt(args):
    data = load_state()
    tasks = data.get('tasks', {})
    item = tasks.get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    item['last_progress_attempt_at'] = now_iso()
    if args.error is not None:
        item['last_progress_send_error'] = args.error
    if args.note:
        item['note'] = args.note
    if args.delivery_id:
        item['last_progress_delivery_id'] = args.delivery_id
    if args.message_hash:
        item['last_progress_message_hash'] = args.message_hash
    item['updated_at'] = now_iso()
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_mark_progress_sent(args):
    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    ts = now_iso()
    item['last_progress_attempt_at'] = ts
    item['last_progress_at'] = ts
    item['last_progress_sent_at'] = ts
    item['last_progress_unknown_at'] = None
    apply_delivery_meta(item, delivery_status='sent', delivery_id=args.delivery_id, message_hash=args.message_hash, error_marker=None)
    if args.note:
        item['note'] = args.note
    item['updated_at'] = ts
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_mark_progress_unknown(args):
    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    ts = now_iso()
    item['last_progress_attempt_at'] = ts
    item['last_progress_unknown_at'] = ts
    apply_delivery_meta(item, delivery_status='unknown', delivery_id=args.delivery_id, message_hash=args.message_hash, error_marker=args.error or 'delivery result unknown')
    if args.note:
        item['note'] = args.note
    item['updated_at'] = ts
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_mark_progress_failed(args):
    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    ts = now_iso()
    item['last_progress_attempt_at'] = ts
    apply_delivery_meta(item, delivery_status='failed', delivery_id=args.delivery_id, message_hash=args.message_hash, error_marker=args.error or 'delivery failed')
    if args.note:
        item['note'] = args.note
    item['updated_at'] = ts
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_mark_visible_reply(args):
    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    ts = now_iso()
    item['last_visible_reply_at'] = ts
    if args.note:
        item['note'] = args.note
    item['updated_at'] = ts
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_mark_tool_result(args):
    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    ts = now_iso()
    item['last_tool_result_at'] = ts
    if args.note:
        item['note'] = args.note
    item['updated_at'] = ts
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_mark_resume(args):
    data = load_state()
    item = data.get('tasks', {}).get(args.task)
    if not item:
        print(f'task not found: {args.task}', file=sys.stderr)
        return 1
    ts = now_iso()
    item['last_resume_at'] = ts
    item['last_resume_reason'] = args.reason or 'manual'
    item['last_resume_count'] = int(item.get('last_resume_count') or 0) + 1
    if args.note:
        item['note'] = args.note
    item['updated_at'] = ts
    save_state(data)
    print(json.dumps(item, ensure_ascii=False, indent=2))
    return 0


def cmd_acquire_lock(args):
    data = load_state()
    conflict = lock_session(data, args.session, args.task, force=args.force)
    if conflict:
        print(json.dumps({
            'ok': False,
            'error': 'session lock exists',
            'origin_session_key': args.session,
            'current_task': conflict,
            'requested_task': args.task,
        }, ensure_ascii=False, indent=2), file=sys.stderr)
        return 3
    save_state(data)
    print(json.dumps({
        'ok': True,
        'origin_session_key': args.session,
        'task': args.task,
    }, ensure_ascii=False, indent=2))
    return 0


def cmd_release_lock(args):
    data = load_state()
    released = release_session_lock(data, args.session, args.task)
    save_state(data)
    print(json.dumps({
        'ok': released,
        'origin_session_key': args.session,
        'task': args.task,
    }, ensure_ascii=False, indent=2))
    return 0 if released else 1


def cmd_locks(args):
    data = load_state()
    print(json.dumps(data.get('active_long_runs_by_session', {}), ensure_ascii=False, indent=2))
    return 0


def cmd_clear(args):
    data = load_state()
    tasks = data.get('tasks', {})
    if args.task:
        if args.task in tasks:
            item = tasks[args.task]
            session_key = item.get('origin_session_key') or item.get('session')
            if session_key:
                release_session_lock(data, session_key, args.task)
            del tasks[args.task]
            save_state(data)
            print(f'cleared: {args.task}')
        else:
            print(f'task not found: {args.task}', file=sys.stderr)
            return 1
    else:
        save_state({'tasks': {}, 'active_long_runs_by_session': {}})
        print('cleared all tasks')
    return 0


def add_delivery_args(parser):
    parser.add_argument('--delivery-id')
    parser.add_argument('--message-hash')


def build_parser():
    p = argparse.ArgumentParser(description='Manage long-run task state')
    sub = p.add_subparsers(dest='command', required=True)

    s = sub.add_parser('set')
    s.add_argument('--task', required=True)
    s.add_argument('--session', required=True)
    s.add_argument('--state', required=True)
    s.add_argument('--origin-session-key', dest='origin_session_key')
    s.add_argument('--goal', required=True)
    s.add_argument('--done-when', required=True, dest='done_when')
    s.add_argument('--blocked-when', required=True, dest='blocked_when')
    s.add_argument('--boundary', required=True)
    s.add_argument('--next-check', required=True, dest='next_check')
    s.add_argument('--note', required=True)
    s.add_argument('--progress-every', dest='progress_every')
    s.add_argument('--force-lock', action='store_true', dest='force_lock')
    s.set_defaults(func=cmd_set)

    t = sub.add_parser('touch-progress')
    t.add_argument('--task', required=True)
    t.add_argument('--note')
    add_delivery_args(t)
    t.set_defaults(func=cmd_touch_progress)

    m = sub.add_parser('mark-progress-attempt')
    m.add_argument('--task', required=True)
    m.add_argument('--error')
    m.add_argument('--note')
    add_delivery_args(m)
    m.set_defaults(func=cmd_mark_progress_attempt)

    ms = sub.add_parser('mark-progress-sent')
    ms.add_argument('--task', required=True)
    ms.add_argument('--note')
    add_delivery_args(ms)
    ms.set_defaults(func=cmd_mark_progress_sent)

    mu = sub.add_parser('mark-progress-unknown')
    mu.add_argument('--task', required=True)
    mu.add_argument('--error')
    mu.add_argument('--note')
    add_delivery_args(mu)
    mu.set_defaults(func=cmd_mark_progress_unknown)

    mf = sub.add_parser('mark-progress-failed')
    mf.add_argument('--task', required=True)
    mf.add_argument('--error')
    mf.add_argument('--note')
    add_delivery_args(mf)
    mf.set_defaults(func=cmd_mark_progress_failed)

    vr = sub.add_parser('mark-visible-reply')
    vr.add_argument('--task', required=True)
    vr.add_argument('--note')
    vr.set_defaults(func=cmd_mark_visible_reply)

    tr = sub.add_parser('mark-tool-result')
    tr.add_argument('--task', required=True)
    tr.add_argument('--note')
    tr.set_defaults(func=cmd_mark_tool_result)

    mr = sub.add_parser('mark-resume')
    mr.add_argument('--task', required=True)
    mr.add_argument('--reason')
    mr.add_argument('--note')
    mr.set_defaults(func=cmd_mark_resume)

    al = sub.add_parser('acquire-lock')
    al.add_argument('--session', required=True)
    al.add_argument('--task', required=True)
    al.add_argument('--force', action='store_true')
    al.set_defaults(func=cmd_acquire_lock)

    rl = sub.add_parser('release-lock')
    rl.add_argument('--session', required=True)
    rl.add_argument('--task')
    rl.set_defaults(func=cmd_release_lock)

    lk = sub.add_parser('locks')
    lk.set_defaults(func=cmd_locks)

    l = sub.add_parser('list')
    l.set_defaults(func=cmd_list)

    sh = sub.add_parser('show')
    sh.add_argument('--task', required=True)
    sh.set_defaults(func=cmd_show)

    c = sub.add_parser('clear')
    c.add_argument('--task')
    c.set_defaults(func=cmd_clear)

    return p


if __name__ == '__main__':
    parser = build_parser()
    args = parser.parse_args()
    raise SystemExit(args.func(args))


path: scripts/validate_task_start.py
content: #!/usr/bin/env python3
import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'
ACTIVE_STATES = {'analyzing', 'executing', 'waiting'}


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}, 'active_long_runs_by_session': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception as e:
        raise RuntimeError(f'failed to read state file: {e}')


def main():
    p = argparse.ArgumentParser(description='Validate that a long-run task was actually bound to the intended real session')
    p.add_argument('--task', required=True)
    p.add_argument('--expect-session-key')
    p.add_argument('--expect-state', action='append', dest='expect_states')
    args = p.parse_args()

    data = load_state()
    tasks = data.get('tasks', {}) or {}
    locks = data.get('active_long_runs_by_session', {}) or {}
    item = tasks.get(args.task)
    if not item:
        print(json.dumps({
            'ok': False,
            'error': 'task_not_found',
            'task': args.task,
            'message': '任务不存在，不能视为已启动。',
        }, ensure_ascii=False, indent=2))
        return 1

    state = item.get('state')
    origin_session_key = item.get('origin_session_key')
    expected_states = args.expect_states or sorted(ACTIVE_STATES)
    errors = []

    if state not in expected_states:
        errors.append(f'state_mismatch:{state}')
    if state in ACTIVE_STATES and not origin_session_key:
        errors.append('missing_origin_session_key')
    if args.expect_session_key and origin_session_key != args.expect_session_key:
        errors.append('origin_session_key_mismatch')
    if state in ACTIVE_STATES and origin_session_key:
        locked_task = locks.get(origin_session_key)
        if locked_task != args.task:
            errors.append('session_lock_mismatch')

    session_label = item.get('session_label') or item.get('session')
    out = {
        'ok': not errors,
        'task': args.task,
        'state': state,
        'session': session_label,
        'session_label': session_label,
        'origin_session_key': origin_session_key,
        'lock_owner': locks.get(origin_session_key) if origin_session_key else None,
        'expected_states': expected_states,
        'errors': errors,
        'message': '启动校验通过：任务已绑定到真实目标会话。' if not errors else '启动校验失败：不能宣称长任务已在目标会话启动成功。',
    }
    print(json.dumps(out, ensure_ascii=False, indent=2))
    return 0 if not errors else 2


if __name__ == '__main__':
    raise SystemExit(main())


path: scripts/watchdog.py
content: #!/usr/bin/env python3
import argparse
import json
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
STATE_FILE = ROOT / '.task-state.json'
STALE_MINUTES_DEFAULT = 10
DUE_STATES = {'analyzing', 'executing', 'waiting'}
PROGRESS_STATES = {'analyzing', 'executing'}
REL_RE = re.compile(r'^(\d+)([mh])$')


def load_state():
    if not STATE_FILE.exists():
        return {'tasks': {}}
    try:
        return json.loads(STATE_FILE.read_text())
    except Exception as e:
        print(f'failed to read state file: {e}', file=sys.stderr)
        return {'tasks': {}}


def parse_iso(value: str):
    if not value:
        return None
    try:
        return datetime.fromisoformat(value)
    except Exception:
        return None


def parse_relative(value: str, anchor: datetime):
    if not value:
        return None
    m = REL_RE.match(value.strip())
    if not m:
        return None
    count = int(m.group(1))
    unit = m.group(2)
    if unit == 'm':
        return anchor + timedelta(minutes=count)
    if unit == 'h':
        return anchor + timedelta(hours=count)
    return None


def progress_reason(item, now: datetime):
    state = item.get('state')
    if state not in PROGRESS_STATES:
        return None
    updated = parse_iso(item.get('updated_at', ''))
    if not updated:
        return None
    progress_every_raw = item.get('progress_every', '')
    last_progress = parse_iso(item.get('last_progress_at', '')) or updated
    expected_progress_at = parse_relative(progress_every_raw, last_progress)
    if expected_progress_at and now >= expected_progress_at:
        return f'progress_due ({progress_every_raw})'
    return None


def due_reason(item, now: datetime, stale_minutes: int):
    state = item.get('state')
    if state not in DUE_STATES:
        return None

    updated = parse_iso(item.get('updated_at', ''))
    if not updated:
        return 'missing updated_at'

    next_check_raw = item.get('next_check', '')
    next_check = parse_iso(next_check_raw) or parse_relative(next_check_raw, updated)
    if next_check and now >= next_check:
        return f'next_check_due ({next_check_raw})'

    if now - updated >= timedelta(minutes=stale_minutes):
        return f'stale_update ({stale_minutes}m)'

    return None


def summarize(item, reason, kind='recovery'):
    origin_session_key = item.get('origin_session_key')
    if not origin_session_key:
        raise RuntimeError(f"missing origin_session_key for task: {item.get('task')}")
    session_label = item.get('session_label') or item.get('session')
    return {
        'task': item.get('task'),
        'session': session_label,
        'session_label': session_label,
        'origin_session_key': origin_session_key,
        'state': item.get('state'),
        'reason': reason,
        'kind': kind,
        'goal': item.get('goal'),
        'boundary': item.get('boundary'),
        'next_check': item.get('next_check'),
        'progress_every': item.get('progress_every'),
        'last_progress_at': item.get('last_progress_at'),
        'updated_at': item.get('updated_at'),
        'note': item.get('note'),
    }


def cmd_check(args):
    data = load_state()
    tasks = data.get('tasks', {})
    now = datetime.now().astimezone()
    due = []
    progress = []
    ok = []

    for _, item in tasks.items():
        recovery_reason = due_reason(item, now, args.stale_minutes)
        if recovery_reason:
            due.append(summarize(item, recovery_reason, 'recovery'))
            continue
        progress_due_reason = progress_reason(item, now)
        if progress_due_reason:
            progress.append(summarize(item, progress_due_reason, 'progress'))
            continue
        ok.append(item.get('task'))

    if args.json:
        print(json.dumps({'due': due, 'progress': progress, 'ok': ok}, ensure_ascii=False, indent=2))
        return 0 if not due and not progress else 1

    if not due and not progress:
        print('watchdog: all tracked tasks are within expected window')
        return 0

    if due:
        print('watchdog: tasks needing follow-up')
        for item in due:
            print(f"- {item['task']} | {item['state']} | {item['session']} | {item['reason']}")
            print(f"  goal: {item['goal']}")
            print(f"  note: {item['note']}")
            print(f"  next: {item['next_check']} | updated: {item['updated_at']}")
    if progress:
        print('watchdog: tasks needing visible progress update')
        for item in progress:
            print(f"- {item['task']} | {item['state']} | {item['session']} | {item['reason']}")
            print(f"  goal: {item['goal']}")
            print(f"  note: {item['note']}")
            print(f"  progress_every: {item['progress_every']} | last_progress: {item['last_progress_at']}")
    return 1


def build_parser():
    p = argparse.ArgumentParser(description='Check long-run task state for overdue follow-up')
    sub = p.add_subparsers(dest='command', required=True)

    c = sub.add_parser('check')
    c.add_argument('--stale-minutes', type=int, default=STALE_MINUTES_DEFAULT)
    c.add_argument('--json', action='store_true')
    c.set_defaults(func=cmd_check)

    return p


if __name__ == '__main__':
    parser = build_parser()
    args = parser.parse_args()
    raise SystemExit(args.func(args))


path: scripts/watchdog_loop.sh
content: #!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3}"
INTERVAL_SECONDS="${INTERVAL_SECONDS:-60}"
LOG_FILE="${LOG_FILE:-${TMPDIR:-/tmp}/long-run-mode-watchdog.log}"

while true; do
  {
    printf '\n[%s] watchdog tick\n' "$(date '+%Y-%m-%d %H:%M:%S %z')"
    cd "$ROOT/.."
    "$PYTHON_BIN" "$ROOT/scripts/recover_due_tasks.py" --json || true
    LONG_RUN_MODE_AUTO_RESUME="${LONG_RUN_MODE_AUTO_RESUME:-}" OPENCLAW_BIN="${OPENCLAW_BIN:-openclaw}" OPENCLAW_SESSIONS_FILE="${OPENCLAW_SESSIONS_FILE:-}" "$PYTHON_BIN" "$ROOT/scripts/run_keepalive_once.py" --idle-seconds "$INTERVAL_SECONDS" || true
  } >> "$LOG_FILE" 2>&1
  sleep "$INTERVAL_SECONDS"
done


path: scripts/work_cycle_tick.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
FOREGROUND = ROOT / 'scripts' / 'foreground_tick.py'


def run_json(cmd):
    proc = subprocess.run(cmd, text=True, capture_output=True)
    if proc.returncode not in (0, 1):
        raise RuntimeError(proc.stderr.strip() or f'command failed with code {proc.returncode}')
    try:
        data = json.loads(proc.stdout)
    except Exception as e:
        raise RuntimeError(f'failed to parse JSON: {e}')
    return proc.returncode, data


def main():
    p = argparse.ArgumentParser(description='Work-cycle hook for long-run tasks: run after each meaningful step')
    p.add_argument('--task', required=True)
    p.add_argument('--step', default='', help='What was just done in this work cycle')
    args = p.parse_args()

    code, tick = run_json([sys.executable, str(FOREGROUND), '--task', args.task])
    session_key = tick.get('sessionKey') or tick.get('check', {}).get('origin_session_key')
    if not session_key:
        raise RuntimeError('missing origin_session_key; work-cycle delivery requires a real session key')

    out = {
        'task': args.task,
        'step': args.step,
        'continue': True,
        'report_now': bool(tick.get('due')),
        'must_send_now': bool(tick.get('due')),
        'delivery': tick.get('delivery', 'session'),
        'sessionKey': session_key,
        'session_label': tick.get('session_label') or tick.get('check', {}).get('session_label') or tick.get('check', {}).get('session'),
        'message': tick.get('message'),
        'reason': tick.get('check', {}).get('reason'),
        'instruction': None,
        'protocol': {
            'on_success': '必须立即执行 task_state.py touch-progress，然后才能继续下一轮。',
            'on_error': '必须立即执行 task_state.py mark-progress-attempt --error <失败原因>；若连续触发仍未送达，应转 blocked。'
        }
    }
    if out['report_now']:
        out['instruction'] = '这是前台心跳，必须立即发往该任务绑定的 sessionKey；发送成功后必须 touch-progress；发送失败后必须 mark-progress-attempt，再决定是否继续。'
    else:
        out['instruction'] = '本轮无需汇报，直接继续下一轮；下一轮结束后再次运行 work_cycle_tick.py。'

    print(json.dumps(out, ensure_ascii=False, indent=2))
    return code


if __name__ == '__main__':
    raise SystemExit(main())


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