mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-20 12:13:38 +08:00
refactor: streamline the course to 17 lessons
This commit is contained in:
153
s12_cron_scheduler/README.ja.md
Normal file
153
s12_cron_scheduler/README.ja.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# s12: Cron Scheduler — 時刻に合わせて作業を開始する
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s10 → s11 → `s12` → [s13](../s13_agent_teams/) → ... → s17
|
||||
|
||||
---
|
||||
|
||||
## 課題
|
||||
|
||||
S11 が扱うのは、コマンド開始後の実行方法である。時間のかかる Bash コマンドはバックグラウンドで実行できるが、将来の作業をいつ開始するかは記録せず、現在時刻を継続的に確認するコンポーネントもない。
|
||||
|
||||
「毎朝 9 時にテストを実行する」「30 分ごとに CI の状態を確認する」といった依頼を現在の Agent Loop だけで扱う場合、ユーザーは時刻が来るたびに prompt を送り直す必要がある。Harness は実行時刻を保存し、時刻が来たら対応する prompt を待機キューへ入れ、Agent がアイドルの時に Agent Loop へ渡す必要がある。
|
||||
|
||||
---
|
||||
|
||||
## 解決方法
|
||||
|
||||

|
||||
|
||||
Agent が次のジョブを登録したとする。
|
||||
|
||||
```text
|
||||
cron: 0 9 * * *
|
||||
prompt: run tests
|
||||
```
|
||||
|
||||
ローカル時刻の 09:00 に scheduler thread がジョブを検出し、`[Scheduled] run tests` を `cron_queue` に入れる。queue processor は Agent がアイドルになるまで待ち、Agent Loop の 1 ターンを開始する。モデルはその後 Bash を呼び出してテストを実行できる。
|
||||
|
||||
S12 のコードは S04 の 5 つの基本ツールと Hooks を残し、`schedule_cron`、`list_crons`、`cancel_cron` を追加する。ここで渡すのは新しい作業を開始する prompt であり、実行中のコマンド結果ではないため、S11 の background command は含めない。
|
||||
|
||||
---
|
||||
|
||||
## 仕組み
|
||||
|
||||
### CronJob が保存する内容
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str
|
||||
prompt: str
|
||||
recurring: bool
|
||||
durable: bool
|
||||
pending_delivery: bool = False
|
||||
last_fired: str | None = None
|
||||
```
|
||||
|
||||
`cron` は発火時刻を決め、`prompt` は Agent に渡す作業を表す。`pending_delivery` は期限に達したがモデルに受け取られていないジョブを示し、`last_fired` は同じ分での重複投入を防ぐ。
|
||||
|
||||
### 5 フィールドの cron 式
|
||||
|
||||
```text
|
||||
分 時 日 月 曜日
|
||||
* * * * * 毎分
|
||||
0 9 * * * 毎日 09:00
|
||||
*/5 * * * * 5 分ごと
|
||||
0 9 * * 1-5 平日 09:00
|
||||
```
|
||||
|
||||
この章では `*`、`*/N`、`N`、`N-M`、`N,M,...` を扱う。`schedule_job()` は保存前に `validate_cron()` を呼び、フィールド数や値の範囲が正しくない式を拒否する。
|
||||
|
||||
### 期限に達したらキューへ入れる
|
||||
|
||||
scheduler thread は 1 秒ごとにローカル時刻を読む。式が一致し、現在の分にまだ発火していない場合、`_enqueue_due_job()` は `pending_delivery` と `last_fired` を保存してからメモリ上のキューへ追加する。
|
||||
|
||||
```python
|
||||
def poll_due_jobs(moment: datetime):
|
||||
minute_marker = moment.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
if job.pending_delivery or job.last_fired == minute_marker:
|
||||
continue
|
||||
if cron_matches(job.cron, moment):
|
||||
_enqueue_due_job(job, minute_marker)
|
||||
```
|
||||
|
||||
永続化に失敗すると、`_enqueue_due_job()` は元の状態へ戻し、メモリにしか存在しない配信を queue processor に渡さない。
|
||||
|
||||
### Agent がアイドルになってから配信する
|
||||
|
||||
`queue_processor_loop()` は時刻を確認しない。キューだけを確認し、`agent_lock` によってユーザーのターンと定時ターンが同時に session を変更するのを防ぐ。
|
||||
|
||||
```python
|
||||
def queue_processor_loop(stop_event=RUNTIME_STOP):
|
||||
while not stop_event.wait(0.2):
|
||||
if not has_cron_queue() or not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if has_cron_queue():
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
```
|
||||
|
||||
Agent Loop は期限に達したジョブをキューから取り出し、それぞれを新しい user message として追加する。
|
||||
|
||||
```python
|
||||
fired = consume_cron_queue()
|
||||
for job in fired:
|
||||
messages.append({"role": "user", "content": f"[Scheduled] {job.prompt}"})
|
||||
```
|
||||
|
||||
モデル呼び出しに失敗すると、これらの message を現在の session から削除し、ジョブをキューへ戻す。モデルが受け取った後、一回限りのジョブは削除し、定期ジョブは `pending_delivery` を解除して次の一致を待つ。
|
||||
|
||||
### 永続化の境界
|
||||
|
||||
| モード | 保存先 | プロセス再起動後 |
|
||||
|---|---|---|
|
||||
| `durable=True` | `.scheduled_tasks.json` | 再読み込み |
|
||||
| `durable=False` | メモリ | 消失 |
|
||||
|
||||
`.scheduled_tasks.json` は一時ファイルと `os.replace()` で更新する。ファイルが壊れている場合、起動時にエラーを表示し、黙って無視しない。
|
||||
|
||||
配信保証は at-least-once である。モデルが prompt を受け取った後、確認状態をディスクへ書く前にプロセスが終了すると、再起動後に同じジョブを再配信する場合がある。
|
||||
|
||||
### 実行境界
|
||||
|
||||
- scheduler は Agent プロセスのローカル時刻を使う。
|
||||
- Agent プロセスが終了すると scheduler thread も停止する。`durable` が保持するのはジョブ定義だけである。
|
||||
- 再起動時にジョブを復元するが、停止中に過ぎた実行時刻は補わない。
|
||||
- 定時ターンは queue processor thread で動く。対話的な許可が必要な tool call は拒否し、main terminal から同時に入力を読まない。
|
||||
- scheduler と queue processor の thread は CLI 実行時だけ開始する。`code.py` の import では background thread を起動しない。
|
||||
|
||||
Agent が閉じている間も実行する必要がある場合は、crontab、systemd timer、外部 scheduler を使う。
|
||||
|
||||
---
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s12_cron_scheduler/code.py
|
||||
```
|
||||
|
||||
次の prompt を順に入力できる。
|
||||
|
||||
1. `Schedule "run date" every 2 minutes and keep it after restart.`
|
||||
2. `List all cron jobs.`
|
||||
3. `Cancel the cron job you just created.`
|
||||
|
||||
`.scheduled_tasks.json` の内容と、期限に達した後の `[Scheduled] run date` message を確認する。分単位のジョブを試す間は Agent プロセスを起動したままにする。
|
||||
|
||||
---
|
||||
|
||||
## 次の章
|
||||
|
||||
スケジューラは指定した時刻に Agent Loop の 1 ターンを開始できるが、そのターンを処理するのは一つの Agent である。複数のモジュールを同時に調査、変更し、結果をまとめるタスクでは、Harness が複数の Agent へ作業を割り当て、それぞれの実行結果を集める必要がある。
|
||||
|
||||
s13 Agent Teams → Lead がタスクを割り当て、teammate が個別に実行し、inbox を通じて結果を返す。
|
||||
|
||||
<!-- translation-sync: zh@v9, en@v9, ja@v9 -->
|
||||
153
s12_cron_scheduler/README.md
Normal file
153
s12_cron_scheduler/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# s12: Cron Scheduler — Start Work on a Schedule
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s10 → s11 → `s12` → [s13](../s13_agent_teams/) → ... → s17
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
S11 changes how a command runs after it starts: a long Bash command can run in the background. It does not record when future work should start, and no component keeps checking the current time.
|
||||
|
||||
For requests such as "run tests every morning at 9am" or "check CI status every 30 minutes," the user would still have to submit the prompt again at each scheduled time. The Harness needs to store the schedule, put the corresponding prompt into a pending queue when it becomes due, and deliver it to the Agent Loop when the Agent is idle.
|
||||
|
||||
---
|
||||
|
||||
## The Solution
|
||||
|
||||

|
||||
|
||||
Suppose the Agent registers this job:
|
||||
|
||||
```text
|
||||
cron: 0 9 * * *
|
||||
prompt: run tests
|
||||
```
|
||||
|
||||
At 09:00 local time, the scheduler thread matches the job and puts `[Scheduled] run tests` into `cron_queue`. The queue processor waits until the Agent is idle, then starts an Agent Loop turn. The model can then call Bash to run the tests.
|
||||
|
||||
The S12 code keeps the five base tools and Hooks from S04, then adds `schedule_cron`, `list_crons`, and `cancel_cron`. It does not include S11 background commands because this chapter delivers a prompt to start work, not the result of a command that is already running.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### What CronJob stores
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str
|
||||
prompt: str
|
||||
recurring: bool
|
||||
durable: bool
|
||||
pending_delivery: bool = False
|
||||
last_fired: str | None = None
|
||||
```
|
||||
|
||||
`cron` controls when the job becomes due. `prompt` is the task sent to the Agent. `pending_delivery` marks a due job that the model has not accepted, while `last_fired` prevents another enqueue in the same minute.
|
||||
|
||||
### Five-field cron expressions
|
||||
|
||||
```text
|
||||
minute hour day month weekday
|
||||
* * * * * every minute
|
||||
0 9 * * * every day at 09:00
|
||||
*/5 * * * * every 5 minutes
|
||||
0 9 * * 1-5 weekdays at 09:00
|
||||
```
|
||||
|
||||
This chapter supports `*`, `*/N`, `N`, `N-M`, and `N,M,...`. Before saving a job, `schedule_job()` calls `validate_cron()` and rejects expressions with the wrong number of fields or out-of-range values.
|
||||
|
||||
### Enqueue when due
|
||||
|
||||
The scheduler thread reads local time once per second. When an expression matches and the job has not fired in the current minute, `_enqueue_due_job()` saves `pending_delivery` and `last_fired` before adding the job to the in-memory queue:
|
||||
|
||||
```python
|
||||
def poll_due_jobs(moment: datetime):
|
||||
minute_marker = moment.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
if job.pending_delivery or job.last_fired == minute_marker:
|
||||
continue
|
||||
if cron_matches(job.cron, moment):
|
||||
_enqueue_due_job(job, minute_marker)
|
||||
```
|
||||
|
||||
If persistence fails, `_enqueue_due_job()` restores the previous state and does not expose a memory-only delivery to the queue processor.
|
||||
|
||||
### Deliver when the Agent is idle
|
||||
|
||||
`queue_processor_loop()` does not check the time. It checks the queue, and `agent_lock` prevents a scheduled turn from changing the session while a user turn is running:
|
||||
|
||||
```python
|
||||
def queue_processor_loop(stop_event=RUNTIME_STOP):
|
||||
while not stop_event.wait(0.2):
|
||||
if not has_cron_queue() or not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if has_cron_queue():
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
```
|
||||
|
||||
The Agent Loop takes due jobs from the queue and appends each one as a new user message:
|
||||
|
||||
```python
|
||||
fired = consume_cron_queue()
|
||||
for job in fired:
|
||||
messages.append({"role": "user", "content": f"[Scheduled] {job.prompt}"})
|
||||
```
|
||||
|
||||
If the model call fails, those messages are removed from the current session and the jobs return to the queue. Once the model accepts the call, one-shot jobs are removed and recurring jobs clear `pending_delivery` until the next match.
|
||||
|
||||
### Persistence boundary
|
||||
|
||||
| Mode | Stored in | After a process restart |
|
||||
|---|---|---|
|
||||
| `durable=True` | `.scheduled_tasks.json` | Loaded again |
|
||||
| `durable=False` | Memory | Gone |
|
||||
|
||||
The code updates `.scheduled_tasks.json` through a temporary file and `os.replace()`. If the file is corrupt, startup reports the error instead of ignoring it.
|
||||
|
||||
Delivery is at least once. If the process exits after the model accepts a prompt but before the acknowledgement reaches disk, the same job may be delivered again after restart.
|
||||
|
||||
### Runtime boundary
|
||||
|
||||
- The scheduler uses the Agent process's local time.
|
||||
- The scheduler stops when the Agent process exits. `durable` preserves the job definition only.
|
||||
- Restart loads saved jobs but does not replay schedule times missed while the process was down.
|
||||
- Scheduled turns run in the queue processor thread. A tool call that needs interactive approval is denied instead of competing with the main terminal for input.
|
||||
- Scheduler and queue processor threads start only in the CLI. Importing `code.py` starts no background thread.
|
||||
|
||||
Use crontab, a systemd timer, or an external scheduler when jobs must run while the Agent is closed.
|
||||
|
||||
---
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s12_cron_scheduler/code.py
|
||||
```
|
||||
|
||||
Enter these prompts in order:
|
||||
|
||||
1. `Schedule "run date" every 2 minutes and keep it after restart.`
|
||||
2. `List all cron jobs.`
|
||||
3. `Cancel the cron job you just created.`
|
||||
|
||||
You can inspect `.scheduled_tasks.json` and watch for the `[Scheduled] run date` message when the job becomes due. Keep the Agent process running while testing a minute-level schedule.
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
The scheduler can start an Agent Loop turn at a specified time, but one Agent still handles that turn. When a task requires parallel investigation, changes across multiple modules, and a combined result, the Harness also needs to assign work to multiple Agents and collect what each one produces.
|
||||
|
||||
s13 Agent Teams → A Lead assigns tasks, teammates run independently, and results return through inboxes.
|
||||
|
||||
<!-- translation-sync: zh@v9, en@v9, ja@v9 -->
|
||||
153
s12_cron_scheduler/README.zh.md
Normal file
153
s12_cron_scheduler/README.zh.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# s12: Cron Scheduler — 按时间启动任务
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s10 → s11 → `s12` → [s13](../s13_agent_teams/) → ... → s17
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
S11 解决的是命令开始后的执行方式:耗时的 Bash 命令可以在后台运行。但它不会记录某项工作应该在什么时间开始,也没有组件持续检查当前时间。
|
||||
|
||||
对于“每天早上 9 点跑测试”或“每 30 分钟检查 CI 状态”这样的请求,如果只依靠当前的 Agent Loop,用户仍要在每次到点后重新发送 prompt。Harness 需要保存执行时间,到点后把对应的 prompt 加入待执行队列,再在 Agent 空闲时交给 Agent Loop。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||

|
||||
|
||||
假设 Agent 注册了下面这项任务:
|
||||
|
||||
```text
|
||||
cron: 0 9 * * *
|
||||
prompt: run tests
|
||||
```
|
||||
|
||||
调度线程在本地时间 09:00 匹配到这项任务,把 `[Scheduled] run tests` 放进 `cron_queue`。队列处理线程等到 Agent 空闲后启动一轮 Agent Loop,模型随后可以调用 Bash 执行测试。
|
||||
|
||||
S12 的代码保留 S04 的五个基础工具和 Hooks,再增加 `schedule_cron`、`list_crons`、`cancel_cron`。它不包含 S11 的后台命令,因为这里传递的是一条待执行的 prompt,而不是某个后台命令的执行结果。
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
### CronJob 保存什么
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str
|
||||
prompt: str
|
||||
recurring: bool
|
||||
durable: bool
|
||||
pending_delivery: bool = False
|
||||
last_fired: str | None = None
|
||||
```
|
||||
|
||||
`cron` 决定何时触发,`prompt` 是触发后交给 Agent 的任务。`pending_delivery` 表示任务已经到期但尚未被模型接收,`last_fired` 防止同一分钟重复入队。
|
||||
|
||||
### 五段式 Cron 表达式
|
||||
|
||||
```text
|
||||
分钟 小时 日 月 星期
|
||||
* * * * * 每分钟
|
||||
0 9 * * * 每天 09:00
|
||||
*/5 * * * * 每 5 分钟
|
||||
0 9 * * 1-5 工作日 09:00
|
||||
```
|
||||
|
||||
本章支持 `*`、`*/N`、`N`、`N-M` 和 `N,M,...`。`schedule_job()` 会在保存任务前调用 `validate_cron()`,拒绝字段数量或取值范围不正确的表达式。
|
||||
|
||||
### 到期后先入队
|
||||
|
||||
调度线程每秒读取一次本地时间。表达式匹配且任务在当前分钟尚未触发时,`_enqueue_due_job()` 先保存 `pending_delivery` 和 `last_fired`,再把任务放进内存队列:
|
||||
|
||||
```python
|
||||
def poll_due_jobs(moment: datetime):
|
||||
minute_marker = moment.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
if job.pending_delivery or job.last_fired == minute_marker:
|
||||
continue
|
||||
if cron_matches(job.cron, moment):
|
||||
_enqueue_due_job(job, minute_marker)
|
||||
```
|
||||
|
||||
持久化失败时,`_enqueue_due_job()` 会恢复原来的状态,不会把只存在于内存中的任务暴露给队列处理线程。
|
||||
|
||||
### Agent 空闲后再交付
|
||||
|
||||
`queue_processor_loop()` 不负责判断时间。它只检查队列,并用 `agent_lock` 避免定时任务与用户正在进行的回合同时修改会话:
|
||||
|
||||
```python
|
||||
def queue_processor_loop(stop_event=RUNTIME_STOP):
|
||||
while not stop_event.wait(0.2):
|
||||
if not has_cron_queue() or not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if has_cron_queue():
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
```
|
||||
|
||||
Agent Loop 从队列取出到期任务,并把它们作为新的用户消息追加:
|
||||
|
||||
```python
|
||||
fired = consume_cron_queue()
|
||||
for job in fired:
|
||||
messages.append({"role": "user", "content": f"[Scheduled] {job.prompt}"})
|
||||
```
|
||||
|
||||
模型调用失败时,这些消息会从当前会话中移除,任务重新放回队列。模型成功接收后,一次性任务会被删除,周期任务则清除 `pending_delivery`,等待下一次匹配。
|
||||
|
||||
### 持久化边界
|
||||
|
||||
| 模式 | 保存位置 | 进程重启后 |
|
||||
|---|---|---|
|
||||
| `durable=True` | `.scheduled_tasks.json` | 重新加载 |
|
||||
| `durable=False` | 内存 | 消失 |
|
||||
|
||||
`.scheduled_tasks.json` 使用临时文件和 `os.replace()` 更新。文件损坏时,启动日志会报告错误,不会静默忽略。
|
||||
|
||||
这里采用至少一次交付:进程若在模型接收 prompt 后、确认状态写回前退出,同一任务可能在重启后再次交付。
|
||||
|
||||
### 运行边界
|
||||
|
||||
- 调度器使用 Agent 进程的本地时间。
|
||||
- Agent 进程关闭后,调度线程也会停止;`durable` 只保留任务定义。
|
||||
- 重启时只恢复任务,不补跑停机期间错过的时间点。
|
||||
- 定时回合运行在队列处理线程中。需要交互确认的工具调用会被拒绝,不会与主终端同时读取输入。
|
||||
- 调度线程和队列处理线程只在运行 CLI 时启动,导入 `code.py` 不会启动后台线程。
|
||||
|
||||
需要在 Agent 关闭时仍按时执行任务,应使用系统的 crontab、systemd timer 或其他外部调度服务。
|
||||
|
||||
---
|
||||
|
||||
## 试一下
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s12_cron_scheduler/code.py
|
||||
```
|
||||
|
||||
可以依次输入:
|
||||
|
||||
1. `Schedule "run date" every 2 minutes and keep it after restart.`
|
||||
2. `List all cron jobs.`
|
||||
3. `Cancel the cron job you just created.`
|
||||
|
||||
运行时可以查看 `.scheduled_tasks.json`,并观察到期后出现的 `[Scheduled] run date` 消息。测试一分钟级任务时,Agent 进程需要保持运行。
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
调度器可以在指定时间启动一轮 Agent Loop,但这一轮仍由一个 Agent 处理。面对需要同时调查多个模块、并行修改并汇总结果的任务,Harness 还需要把工作分给多个 Agent,并收集各自的执行结果。
|
||||
|
||||
s13 Agent Teams → Lead 分配任务,队友独立执行,再通过收件箱返回结果。
|
||||
|
||||
<!-- translation-sync: zh@v9, en@v9, ja@v9 -->
|
||||
768
s12_cron_scheduler/code.py
Normal file
768
s12_cron_scheduler/code.py
Normal file
@@ -0,0 +1,768 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
s12_cron_scheduler.py - Cron Scheduler
|
||||
|
||||
+--------------------------+ 09:00 +-----------------------+
|
||||
| 0 9 * * * | --------> | [Scheduled] run tests |
|
||||
| prompt: "run tests" | +-----------+-----------+
|
||||
+--------------------------+ |
|
||||
scheduled_jobs cron_queue | agent idle
|
||||
v
|
||||
+-------------+
|
||||
| Agent Loop |
|
||||
+-------------+
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import readline
|
||||
|
||||
readline.parse_and_bind("set bind-tty-special-chars off")
|
||||
readline.parse_and_bind("set input-meta on")
|
||||
readline.parse_and_bind("set output-meta on")
|
||||
readline.parse_and_bind("set convert-meta off")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from anthropic import Anthropic
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
if os.getenv("ANTHROPIC_BASE_URL"):
|
||||
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
||||
|
||||
WORKDIR = Path.cwd()
|
||||
DURABLE_PATH = WORKDIR / ".scheduled_tasks.json"
|
||||
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
|
||||
MODEL = os.environ["MODEL_ID"]
|
||||
|
||||
SYSTEM = (
|
||||
f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. "
|
||||
"Use schedule_cron for work that should start at a future local time."
|
||||
)
|
||||
|
||||
|
||||
# -- From s04: tool implementations --
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=WORKDIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
if result.returncode != 0:
|
||||
return f"Error: command exited with status {result.returncode}\n{output}"
|
||||
return output[:50000] if output else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int | None = None) -> str:
|
||||
try:
|
||||
file_path = (WORKDIR / path).resolve()
|
||||
lines = file_path.read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
|
||||
return "\n".join(lines)
|
||||
except Exception as error:
|
||||
return f"Error: {error}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
file_path = (WORKDIR / path).resolve()
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as error:
|
||||
return f"Error: {error}"
|
||||
|
||||
|
||||
def run_edit(path: str, old_text: str, new_text: str) -> str:
|
||||
try:
|
||||
file_path = (WORKDIR / path).resolve()
|
||||
text = file_path.read_text()
|
||||
if old_text not in text:
|
||||
return f"Error: text not found in {path}"
|
||||
file_path.write_text(text.replace(old_text, new_text, 1))
|
||||
return f"Edited {path}"
|
||||
except Exception as error:
|
||||
return f"Error: {error}"
|
||||
|
||||
|
||||
def run_glob(pattern: str) -> str:
|
||||
try:
|
||||
matches = [
|
||||
match
|
||||
for match in glob.glob(pattern, root_dir=WORKDIR)
|
||||
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
|
||||
]
|
||||
return "\n".join(matches) if matches else "(no matches)"
|
||||
except Exception as error:
|
||||
return f"Error: {error}"
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{"name": "bash", "description": "Run a shell command.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"command": {"type": "string"}},
|
||||
"required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file contents.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"path": {"type": "string"},
|
||||
"limit": {"type": "integer"}},
|
||||
"required": ["path"]}},
|
||||
{"name": "write_file", "description": "Write content to a file.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"path": {"type": "string"},
|
||||
"content": {"type": "string"}},
|
||||
"required": ["path", "content"]}},
|
||||
{"name": "edit_file", "description": "Replace exact text in a file once.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"path": {"type": "string"},
|
||||
"old_text": {"type": "string"},
|
||||
"new_text": {"type": "string"}},
|
||||
"required": ["path", "old_text", "new_text"]}},
|
||||
{"name": "glob", "description": "Find files matching a glob pattern.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"pattern": {"type": "string"}},
|
||||
"required": ["pattern"]}},
|
||||
]
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": run_bash,
|
||||
"read_file": run_read,
|
||||
"write_file": run_write,
|
||||
"edit_file": run_edit,
|
||||
"glob": run_glob,
|
||||
}
|
||||
|
||||
|
||||
# -- From s04: hooks and permission checks --
|
||||
|
||||
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
|
||||
|
||||
|
||||
def register_hook(event: str, callback):
|
||||
HOOKS[event].append(callback)
|
||||
|
||||
|
||||
def trigger_hooks(event: str, *args):
|
||||
for callback in HOOKS[event]:
|
||||
result = callback(*args)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
|
||||
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
|
||||
|
||||
|
||||
def request_permission(block, reason: str) -> str | None:
|
||||
if threading.current_thread() is not threading.main_thread():
|
||||
return "Permission denied: scheduled turns cannot request interactive approval"
|
||||
|
||||
print(f"\n\033[33m[permission] {reason}\033[0m")
|
||||
print(f" Tool: {block.name}({block.input})")
|
||||
choice = input(" Allow? [y/N] ").strip().lower()
|
||||
if choice not in ("y", "yes"):
|
||||
return "Permission denied by user"
|
||||
return None
|
||||
|
||||
|
||||
def permission_hook(block):
|
||||
if block.name == "bash":
|
||||
command = block.input.get("command", "")
|
||||
for pattern in DENY_LIST:
|
||||
if pattern in command:
|
||||
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
|
||||
return "Permission denied by deny list"
|
||||
if any(keyword in command for keyword in DESTRUCTIVE):
|
||||
return request_permission(block, "Potentially destructive command")
|
||||
|
||||
if block.name in ("read_file", "write_file", "edit_file"):
|
||||
path = block.input.get("path", "")
|
||||
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
|
||||
return request_permission(block, "Access outside workspace")
|
||||
return None
|
||||
|
||||
|
||||
def log_hook(block):
|
||||
preview = str(list(block.input.values())[:2])[:60]
|
||||
print(f"\033[90m[HOOK] {block.name}({preview})\033[0m")
|
||||
return None
|
||||
|
||||
|
||||
def large_output_hook(block, output):
|
||||
if len(str(output)) > 100000:
|
||||
print(
|
||||
f"\033[33m[HOOK] Large output from {block.name}: "
|
||||
f"{len(str(output))} chars\033[0m"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def context_inject_hook(query: str):
|
||||
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
|
||||
return None
|
||||
|
||||
|
||||
def summary_hook(messages: list):
|
||||
tool_count = sum(
|
||||
1
|
||||
for message in messages
|
||||
for block in (
|
||||
message.get("content")
|
||||
if isinstance(message.get("content"), list)
|
||||
else []
|
||||
)
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
)
|
||||
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
|
||||
return None
|
||||
|
||||
|
||||
register_hook("UserPromptSubmit", context_inject_hook)
|
||||
register_hook("PreToolUse", permission_hook)
|
||||
register_hook("PreToolUse", log_hook)
|
||||
register_hook("PostToolUse", large_output_hook)
|
||||
register_hook("Stop", summary_hook)
|
||||
|
||||
|
||||
# -- New in s12: cron jobs --
|
||||
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str
|
||||
prompt: str
|
||||
recurring: bool
|
||||
durable: bool
|
||||
pending_delivery: bool = False
|
||||
last_fired: str | None = None
|
||||
|
||||
|
||||
scheduled_jobs: dict[str, CronJob] = {}
|
||||
cron_queue: list[CronJob] = []
|
||||
cron_lock = threading.RLock()
|
||||
|
||||
|
||||
def _cron_field_matches(field: str, value: int) -> bool:
|
||||
if field == "*":
|
||||
return True
|
||||
if field.startswith("*/"):
|
||||
return value % int(field[2:]) == 0
|
||||
if "," in field:
|
||||
return any(_cron_field_matches(part.strip(), value)
|
||||
for part in field.split(","))
|
||||
if "-" in field:
|
||||
start, end = field.split("-", 1)
|
||||
return int(start) <= value <= int(end)
|
||||
return value == int(field)
|
||||
|
||||
|
||||
def cron_matches(cron_expr: str, moment: datetime) -> bool:
|
||||
fields = cron_expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return False
|
||||
|
||||
minute, hour, day, month, weekday = fields
|
||||
cron_weekday = (moment.weekday() + 1) % 7
|
||||
if not (
|
||||
_cron_field_matches(minute, moment.minute)
|
||||
and _cron_field_matches(hour, moment.hour)
|
||||
and _cron_field_matches(month, moment.month)
|
||||
):
|
||||
return False
|
||||
|
||||
day_matches = _cron_field_matches(day, moment.day)
|
||||
weekday_matches = _cron_field_matches(weekday, cron_weekday)
|
||||
if day == "*" and weekday == "*":
|
||||
return True
|
||||
if day == "*":
|
||||
return weekday_matches
|
||||
if weekday == "*":
|
||||
return day_matches
|
||||
return day_matches or weekday_matches
|
||||
|
||||
|
||||
def _validate_cron_field(field: str, minimum: int, maximum: int) -> str | None:
|
||||
if field == "*":
|
||||
return None
|
||||
if field.startswith("*/"):
|
||||
step = field[2:]
|
||||
if not step.isdigit() or int(step) <= 0:
|
||||
return f"Invalid step: {field}"
|
||||
return None
|
||||
if "," in field:
|
||||
for part in field.split(","):
|
||||
error = _validate_cron_field(part.strip(), minimum, maximum)
|
||||
if error:
|
||||
return error
|
||||
return None
|
||||
if "-" in field:
|
||||
start, end = field.split("-", 1)
|
||||
if not start.isdigit() or not end.isdigit():
|
||||
return f"Invalid range: {field}"
|
||||
start_value, end_value = int(start), int(end)
|
||||
if start_value > end_value:
|
||||
return f"Range start is greater than end: {field}"
|
||||
if start_value < minimum or end_value > maximum:
|
||||
return f"Range {field} is outside [{minimum}-{maximum}]"
|
||||
return None
|
||||
if not field.isdigit():
|
||||
return f"Invalid field: {field}"
|
||||
value = int(field)
|
||||
if value < minimum or value > maximum:
|
||||
return f"Value {value} is outside [{minimum}-{maximum}]"
|
||||
return None
|
||||
|
||||
|
||||
def validate_cron(cron_expr: str) -> str | None:
|
||||
fields = cron_expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return f"Expected 5 fields, got {len(fields)}"
|
||||
|
||||
field_rules = [
|
||||
("minute", 0, 59),
|
||||
("hour", 0, 23),
|
||||
("day-of-month", 1, 31),
|
||||
("month", 1, 12),
|
||||
("day-of-week", 0, 6),
|
||||
]
|
||||
for field, (name, minimum, maximum) in zip(fields, field_rules):
|
||||
error = _validate_cron_field(field, minimum, maximum)
|
||||
if error:
|
||||
return f"{name}: {error}"
|
||||
return None
|
||||
|
||||
|
||||
def save_durable_jobs():
|
||||
with cron_lock:
|
||||
payload = [
|
||||
asdict(job)
|
||||
for job in scheduled_jobs.values()
|
||||
if job.durable
|
||||
]
|
||||
temporary = DURABLE_PATH.with_name(
|
||||
f"{DURABLE_PATH.name}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
try:
|
||||
temporary.write_text(json.dumps(payload, indent=2))
|
||||
os.replace(temporary, DURABLE_PATH)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def load_durable_jobs():
|
||||
if not DURABLE_PATH.exists():
|
||||
return
|
||||
try:
|
||||
payload = json.loads(DURABLE_PATH.read_text())
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("expected a JSON list")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f" [cron] could not load {DURABLE_PATH.name}: {error}")
|
||||
return
|
||||
|
||||
loaded = 0
|
||||
with cron_lock:
|
||||
for item in payload:
|
||||
try:
|
||||
job = CronJob(**item)
|
||||
error = validate_cron(job.cron)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
if not job.id.startswith("cron_"):
|
||||
raise ValueError("invalid job ID")
|
||||
if not job.prompt.strip():
|
||||
raise ValueError("prompt cannot be empty")
|
||||
except (TypeError, ValueError) as error:
|
||||
print(f" [cron] skipped invalid saved job: {error}")
|
||||
continue
|
||||
scheduled_jobs[job.id] = job
|
||||
if job.pending_delivery:
|
||||
cron_queue.append(job)
|
||||
loaded += 1
|
||||
if loaded:
|
||||
print(f" [cron] loaded {loaded} durable job(s)")
|
||||
|
||||
|
||||
def new_cron_id() -> str:
|
||||
for _ in range(100):
|
||||
job_id = f"cron_{secrets.token_hex(4)}"
|
||||
if job_id not in scheduled_jobs:
|
||||
return job_id
|
||||
raise RuntimeError("Could not allocate a cron job ID")
|
||||
|
||||
|
||||
def schedule_job(cron: str, prompt: str, recurring: bool = True,
|
||||
durable: bool = True) -> CronJob | str:
|
||||
error = validate_cron(cron)
|
||||
if error:
|
||||
return error
|
||||
if not prompt.strip():
|
||||
return "Prompt cannot be empty"
|
||||
|
||||
with cron_lock:
|
||||
job = CronJob(
|
||||
id=new_cron_id(),
|
||||
cron=cron,
|
||||
prompt=prompt,
|
||||
recurring=recurring,
|
||||
durable=durable,
|
||||
)
|
||||
scheduled_jobs[job.id] = job
|
||||
try:
|
||||
if durable:
|
||||
save_durable_jobs()
|
||||
except Exception:
|
||||
scheduled_jobs.pop(job.id, None)
|
||||
raise
|
||||
print(f" [cron] scheduled {job.id}: {cron} -> {prompt[:60]}")
|
||||
return job
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> str:
|
||||
with cron_lock:
|
||||
job = scheduled_jobs.get(job_id)
|
||||
if job is None:
|
||||
return f"Job {job_id} not found"
|
||||
|
||||
previous_queue = list(cron_queue)
|
||||
scheduled_jobs.pop(job_id)
|
||||
cron_queue[:] = [queued for queued in cron_queue if queued.id != job_id]
|
||||
try:
|
||||
if job.durable:
|
||||
save_durable_jobs()
|
||||
except Exception:
|
||||
scheduled_jobs[job_id] = job
|
||||
cron_queue[:] = previous_queue
|
||||
raise
|
||||
print(f" [cron] cancelled {job_id}")
|
||||
return f"Cancelled {job_id}"
|
||||
|
||||
|
||||
def _enqueue_due_job(job: CronJob, minute_marker: str | None = None):
|
||||
old_pending = job.pending_delivery
|
||||
old_last_fired = job.last_fired
|
||||
job.pending_delivery = True
|
||||
if minute_marker is not None:
|
||||
job.last_fired = minute_marker
|
||||
try:
|
||||
if job.durable:
|
||||
save_durable_jobs()
|
||||
except Exception:
|
||||
job.pending_delivery = old_pending
|
||||
job.last_fired = old_last_fired
|
||||
raise
|
||||
cron_queue.append(job)
|
||||
|
||||
|
||||
def poll_due_jobs(moment: datetime):
|
||||
minute_marker = moment.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
try:
|
||||
if job.pending_delivery or job.last_fired == minute_marker:
|
||||
continue
|
||||
if cron_matches(job.cron, moment):
|
||||
_enqueue_due_job(job, minute_marker)
|
||||
print(f" [cron] due {job.id}: {job.prompt[:60]}")
|
||||
except Exception as error:
|
||||
print(f" [cron] could not enqueue {job.id}: {error}")
|
||||
|
||||
|
||||
def consume_cron_queue() -> list[CronJob]:
|
||||
with cron_lock:
|
||||
jobs = list(cron_queue)
|
||||
cron_queue.clear()
|
||||
return jobs
|
||||
|
||||
|
||||
def acknowledge_cron_jobs(jobs: list[CronJob]):
|
||||
changed: list[tuple[CronJob, bool]] = []
|
||||
removed: list[CronJob] = []
|
||||
with cron_lock:
|
||||
for delivered in jobs:
|
||||
current = scheduled_jobs.get(delivered.id)
|
||||
if current is None:
|
||||
continue
|
||||
changed.append((current, current.pending_delivery))
|
||||
if current.recurring:
|
||||
current.pending_delivery = False
|
||||
else:
|
||||
removed.append(current)
|
||||
scheduled_jobs.pop(current.id)
|
||||
|
||||
try:
|
||||
if any(job.durable for job, _ in changed):
|
||||
save_durable_jobs()
|
||||
except Exception:
|
||||
for job in removed:
|
||||
scheduled_jobs[job.id] = job
|
||||
for job, pending in changed:
|
||||
job.pending_delivery = pending
|
||||
queued_ids = {job.id for job in cron_queue}
|
||||
for job, _ in changed:
|
||||
if job.id not in queued_ids:
|
||||
cron_queue.append(job)
|
||||
raise
|
||||
|
||||
|
||||
def restore_cron_jobs(jobs: list[CronJob]):
|
||||
with cron_lock:
|
||||
queued_ids = {job.id for job in cron_queue}
|
||||
for delivered in jobs:
|
||||
current = scheduled_jobs.get(delivered.id)
|
||||
if current is None:
|
||||
continue
|
||||
current.pending_delivery = True
|
||||
if current.id not in queued_ids:
|
||||
cron_queue.append(current)
|
||||
queued_ids.add(current.id)
|
||||
|
||||
|
||||
def has_cron_queue() -> bool:
|
||||
with cron_lock:
|
||||
return bool(cron_queue)
|
||||
|
||||
|
||||
def run_schedule_cron(cron: str, prompt: str, recurring: bool = True,
|
||||
durable: bool = True) -> str:
|
||||
result = schedule_job(cron, prompt, recurring, durable)
|
||||
if isinstance(result, str):
|
||||
return f"Error: {result}"
|
||||
return f"Scheduled {result.id}: {cron} -> {prompt}"
|
||||
|
||||
|
||||
def run_list_crons() -> str:
|
||||
with cron_lock:
|
||||
jobs = list(scheduled_jobs.values())
|
||||
if not jobs:
|
||||
return "No cron jobs."
|
||||
|
||||
lines = []
|
||||
for job in jobs:
|
||||
frequency = "recurring" if job.recurring else "one-shot"
|
||||
storage = "durable" if job.durable else "session"
|
||||
lines.append(
|
||||
f"{job.id}: {job.cron} -> {job.prompt[:60]} "
|
||||
f"[{frequency}, {storage}]"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_cancel_cron(job_id: str) -> str:
|
||||
return cancel_job(job_id)
|
||||
|
||||
|
||||
TOOLS.extend([
|
||||
{"name": "schedule_cron",
|
||||
"description": "Schedule a prompt with a 5-field cron expression.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {
|
||||
"cron": {"type": "string"},
|
||||
"prompt": {"type": "string"},
|
||||
"recurring": {"type": "boolean"},
|
||||
"durable": {"type": "boolean"}},
|
||||
"required": ["cron", "prompt"]}},
|
||||
{"name": "list_crons", "description": "List scheduled cron jobs.",
|
||||
"input_schema": {"type": "object", "properties": {}, "required": []}},
|
||||
{"name": "cancel_cron", "description": "Cancel a cron job by ID.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"job_id": {"type": "string"}},
|
||||
"required": ["job_id"]}},
|
||||
])
|
||||
|
||||
TOOL_HANDLERS.update({
|
||||
"schedule_cron": run_schedule_cron,
|
||||
"list_crons": run_list_crons,
|
||||
"cancel_cron": run_cancel_cron,
|
||||
})
|
||||
|
||||
|
||||
def execute_tool(block) -> str:
|
||||
blocked = trigger_hooks("PreToolUse", block)
|
||||
if blocked is not None:
|
||||
return str(blocked)
|
||||
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
try:
|
||||
output = handler(**block.input) if handler else f"Unknown: {block.name}"
|
||||
except Exception as error:
|
||||
output = f"Error: {error}"
|
||||
trigger_hooks("PostToolUse", block, output)
|
||||
return str(output)
|
||||
|
||||
|
||||
# -- Scheduler and agent loop --
|
||||
|
||||
RUNTIME_STOP = threading.Event()
|
||||
runtime_threads: list[threading.Thread] = []
|
||||
runtime_started = False
|
||||
runtime_lock = threading.Lock()
|
||||
agent_lock = threading.Lock()
|
||||
session_history: list = []
|
||||
|
||||
|
||||
def cron_scheduler_loop(stop_event: threading.Event = RUNTIME_STOP):
|
||||
while not stop_event.wait(1.0):
|
||||
poll_due_jobs(datetime.now())
|
||||
|
||||
|
||||
def agent_loop(messages: list, context: dict | None = None):
|
||||
fired = consume_cron_queue()
|
||||
scheduled_start = len(messages)
|
||||
for job in fired:
|
||||
messages.append({"role": "user", "content": f"[Scheduled] {job.prompt}"})
|
||||
print(f" [cron] delivered {job.id}: {job.prompt[:60]}")
|
||||
|
||||
waiting_for_ack = list(fired)
|
||||
while True:
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
system=SYSTEM,
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
max_tokens=8000,
|
||||
)
|
||||
except Exception as error:
|
||||
if waiting_for_ack:
|
||||
del messages[scheduled_start:]
|
||||
restore_cron_jobs(waiting_for_ack)
|
||||
print(f" [error] {type(error).__name__}: {error}")
|
||||
return context
|
||||
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if waiting_for_ack:
|
||||
try:
|
||||
acknowledge_cron_jobs(waiting_for_ack)
|
||||
except Exception as error:
|
||||
print(f" [cron] acknowledgement failed: {error}")
|
||||
waiting_for_ack = []
|
||||
|
||||
if response.stop_reason != "tool_use":
|
||||
force = trigger_hooks("Stop", messages)
|
||||
if force:
|
||||
messages.append({"role": "user", "content": force})
|
||||
continue
|
||||
return context
|
||||
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type != "tool_use":
|
||||
continue
|
||||
output = execute_tool(block)
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output,
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
|
||||
def print_latest_assistant_text(messages: list):
|
||||
for message in reversed(messages):
|
||||
if message.get("role") != "assistant":
|
||||
continue
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str):
|
||||
print(content)
|
||||
else:
|
||||
for block in content:
|
||||
if getattr(block, "type", None) == "text":
|
||||
print(block.text)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
print(block.get("text", ""))
|
||||
return
|
||||
|
||||
|
||||
def run_agent_turn_locked(user_query: str | None = None):
|
||||
if user_query is not None:
|
||||
trigger_hooks("UserPromptSubmit", user_query)
|
||||
session_history.append({"role": "user", "content": user_query})
|
||||
agent_loop(session_history)
|
||||
print_latest_assistant_text(session_history)
|
||||
print()
|
||||
|
||||
|
||||
def queue_processor_loop(stop_event: threading.Event = RUNTIME_STOP):
|
||||
while not stop_event.wait(0.2):
|
||||
if not has_cron_queue() or not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if has_cron_queue():
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
|
||||
|
||||
def start_runtime_threads():
|
||||
global runtime_started
|
||||
with runtime_lock:
|
||||
if runtime_started:
|
||||
return
|
||||
load_durable_jobs()
|
||||
RUNTIME_STOP.clear()
|
||||
runtime_threads.extend([
|
||||
threading.Thread(
|
||||
target=cron_scheduler_loop,
|
||||
name="cron-scheduler",
|
||||
daemon=True,
|
||||
),
|
||||
threading.Thread(
|
||||
target=queue_processor_loop,
|
||||
name="cron-queue-processor",
|
||||
daemon=True,
|
||||
),
|
||||
])
|
||||
for thread in runtime_threads:
|
||||
thread.start()
|
||||
runtime_started = True
|
||||
|
||||
|
||||
def stop_runtime_threads():
|
||||
global runtime_started
|
||||
with runtime_lock:
|
||||
if not runtime_started:
|
||||
return
|
||||
RUNTIME_STOP.set()
|
||||
for thread in runtime_threads:
|
||||
thread.join(timeout=1)
|
||||
runtime_threads.clear()
|
||||
runtime_started = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("s12: Cron Scheduler - run prompts on a local schedule")
|
||||
print("Enter a question, press Enter to send. Type q to quit.\n")
|
||||
start_runtime_threads()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms12 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
with agent_lock:
|
||||
run_agent_turn_locked(query)
|
||||
finally:
|
||||
stop_runtime_threads()
|
||||
125
s12_cron_scheduler/images/cron-scheduler-overview.en.svg
Normal file
125
s12_cron_scheduler/images/cron-scheduler-overview.en.svg
Normal file
@@ -0,0 +1,125 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 480" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#4f46e5"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
|
||||
</marker>
|
||||
<marker id="arrow-indigo" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4f46e5"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="760" height="480" fill="#fafbfc" rx="8"/>
|
||||
|
||||
<!-- Title -->
|
||||
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
|
||||
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
|
||||
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Cron Scheduler — Independent scheduler thread + cron_queue injection point</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f8fafc" stroke="#94a3b8" stroke-width="1"/>
|
||||
<text x="58" y="66" fill="#64748b" font-size="10" font-weight="600">S04 tools + hooks</text>
|
||||
<rect x="160" y="56" width="12" height="10" rx="2" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="178" y="66" fill="#4f46e5" font-size="10" font-weight="600">S12 new</text>
|
||||
|
||||
<!-- ===== Row 1: Full Agent Loop Chain ===== -->
|
||||
|
||||
<!-- consume_cron_queue (S12 new, indigo) -->
|
||||
<rect x="30" y="100" width="95" height="48" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="77" y="120" fill="#312e81" font-size="9" font-weight="700" text-anchor="middle">consume</text>
|
||||
<text x="77" y="134" fill="#312e81" font-size="9" font-weight="700" text-anchor="middle">cron_queue</text>
|
||||
<text x="77" y="144" fill="#94a3b8" font-size="7" text-anchor="middle">★ S12 injection</text>
|
||||
|
||||
<line x1="125" y1="124" x2="143" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- messages base instructions -->
|
||||
<rect x="146" y="100" width="80" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="186" y="128" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
|
||||
|
||||
<line x1="226" y1="124" x2="244" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- system prompt base instructions -->
|
||||
<rect x="247" y="94" width="115" height="60" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="304" y="116" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">system prompt</text>
|
||||
<text x="304" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">SYSTEM</text>
|
||||
<text x="304" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">base instructions</text>
|
||||
|
||||
<line x1="362" y1="124" x2="380" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- LLM call -->
|
||||
<rect x="383" y="94" width="100" height="60" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="433" y="116" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM call</text>
|
||||
<text x="433" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">client.messages.create</text>
|
||||
<text x="433" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">model request</text>
|
||||
|
||||
<line x1="483" y1="124" x2="501" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- TOOL DISPATCH (expanded) -->
|
||||
<rect x="504" y="88" width="220" height="72" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="614" y="106" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH</text>
|
||||
<text x="519" y="122" fill="#2563eb" font-size="8">base → bash, read, write, edit, glob</text>
|
||||
<text x="519" y="134" fill="#ea580c" font-size="8">PreToolUse → permission + log</text>
|
||||
<text x="519" y="146" fill="#4f46e5" font-size="8" font-weight="600">cron → schedule_cron, list, cancel (S12)</text>
|
||||
<text x="519" y="156" fill="#2563eb" font-size="8">PostToolUse → output check</text>
|
||||
|
||||
<!-- Loop back arrow -->
|
||||
<path d="M 724 124 L 748 124 L 748 170 L 77 170 L 77 148" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
|
||||
<text x="400" y="183" fill="#94a3b8" font-size="9" text-anchor="middle">loop back: tool_results → next turn</text>
|
||||
|
||||
<!-- ===== Row 2: Cron Scheduler Thread (indigo) ===== -->
|
||||
<rect x="30" y="206" width="250" height="90" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="155" y="226" fill="#312e81" font-size="11" font-weight="700" text-anchor="middle">cron_scheduler_loop (daemon thread)</text>
|
||||
<text x="48" y="244" fill="#4f46e5" font-size="9">wait(1s) → poll_due_jobs(datetime.now())</text>
|
||||
<text x="48" y="258" fill="#4f46e5" font-size="9">match → persist state → enqueue job</text>
|
||||
<text x="48" y="272" fill="#6b7280" font-size="8">last_fired prevents duplicate enqueue per minute</text>
|
||||
<text x="48" y="286" fill="#6b7280" font-size="8">one-shot is removed after the model accepts the prompt</text>
|
||||
|
||||
<!-- Arrow: scheduler → cron_queue -->
|
||||
<path d="M 155 296 L 155 330" fill="none" stroke="#4f46e5" stroke-width="1.5" marker-end="url(#arrow-indigo)"/>
|
||||
|
||||
<!-- cron_queue (indigo) -->
|
||||
<rect x="60" y="333" width="200" height="38" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="160" y="352" fill="#312e81" font-size="10" font-weight="700" text-anchor="middle">cron_queue</text>
|
||||
<text x="160" y="364" fill="#4f46e5" font-size="8" text-anchor="middle">cron_lock · scheduler writes · processor delivers</text>
|
||||
|
||||
<!-- Arrow: cron_queue → consume_cron_queue (connects to top row) -->
|
||||
<path d="M 77 333 L 77 318 L 18 318 L 18 124 L 30 124" fill="none" stroke="#4f46e5" stroke-width="2" marker-end="url(#arrow-indigo)"/>
|
||||
<text x="95" y="313" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">idle Agent receives it</text>
|
||||
|
||||
<!-- ===== Row 2 Right: CronJob + Storage ===== -->
|
||||
<rect x="320" y="206" width="410" height="90" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="525" y="226" fill="#312e81" font-size="11" font-weight="700" text-anchor="middle">CronJob + Persistence</text>
|
||||
<text x="338" y="244" fill="#4f46e5" font-size="9" font-weight="600">CronJob core fields:</text>
|
||||
<text x="455" y="244" fill="#6b7280" font-size="8">id, cron, prompt, recurring, durable</text>
|
||||
<text x="338" y="260" fill="#4f46e5" font-size="9" font-weight="600">Durable → .scheduled_tasks.json</text>
|
||||
<text x="560" y="260" fill="#6b7280" font-size="8">restored via load_durable_jobs after restart</text>
|
||||
<text x="338" y="276" fill="#4f46e5" font-size="9" font-weight="600">Session-only → memory only</text>
|
||||
<text x="530" y="276" fill="#6b7280" font-size="8">lost when process exits</text>
|
||||
<text x="338" y="292" fill="#ef4444" font-size="9" font-weight="600">⚠ Process exit = scheduler stops (not OS-level crontab)</text>
|
||||
|
||||
<!-- ===== Row 3: 5-field cron reference ===== -->
|
||||
<rect x="30" y="390" width="700" height="76" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<text x="60" y="412" fill="#1e3a5f" font-size="11" font-weight="600">5-field Cron Expression</text>
|
||||
<rect x="60" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="86" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="118" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="144" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="176" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="202" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="234" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="260" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="292" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="318" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<text x="60" y="455" fill="#94a3b8" font-size="8">min</text>
|
||||
<text x="130" y="455" fill="#94a3b8" font-size="8">hour</text>
|
||||
<text x="194" y="455" fill="#94a3b8" font-size="8">day</text>
|
||||
<text x="254" y="455" fill="#94a3b8" font-size="8">month</text>
|
||||
<text x="310" y="455" fill="#94a3b8" font-size="8">dow</text>
|
||||
|
||||
<text x="380" y="434" fill="#475569" font-size="9">*/5 * * * * → every 5 minutes</text>
|
||||
<text x="380" y="450" fill="#475569" font-size="9">0 9 * * 1-5 → weekdays 9:00</text>
|
||||
<text x="560" y="434" fill="#475569" font-size="9">0 9 * * * → daily 9:00</text>
|
||||
<text x="560" y="450" fill="#475569" font-size="9">Supports: *, */N, N, N-M, N,M,...</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
125
s12_cron_scheduler/images/cron-scheduler-overview.ja.svg
Normal file
125
s12_cron_scheduler/images/cron-scheduler-overview.ja.svg
Normal file
@@ -0,0 +1,125 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 480" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#4f46e5"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
|
||||
</marker>
|
||||
<marker id="arrow-indigo" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4f46e5"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="760" height="480" fill="#fafbfc" rx="8"/>
|
||||
|
||||
<!-- Title -->
|
||||
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
|
||||
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
|
||||
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Cron Scheduler — 独立スケジューラスレッド + cron_queue 注入ポイント</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f8fafc" stroke="#94a3b8" stroke-width="1"/>
|
||||
<text x="58" y="66" fill="#64748b" font-size="10" font-weight="600">S04 tools + hooks</text>
|
||||
<rect x="160" y="56" width="12" height="10" rx="2" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="178" y="66" fill="#4f46e5" font-size="10" font-weight="600">S12 新規</text>
|
||||
|
||||
<!-- ===== Row 1: Full Agent Loop Chain ===== -->
|
||||
|
||||
<!-- consume_cron_queue (S12 new, indigo) -->
|
||||
<rect x="30" y="100" width="95" height="48" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="77" y="120" fill="#312e81" font-size="9" font-weight="700" text-anchor="middle">consume</text>
|
||||
<text x="77" y="134" fill="#312e81" font-size="9" font-weight="700" text-anchor="middle">cron_queue</text>
|
||||
<text x="77" y="144" fill="#94a3b8" font-size="7" text-anchor="middle">★ S12 注入点</text>
|
||||
|
||||
<line x1="125" y1="124" x2="143" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- messages base instructions -->
|
||||
<rect x="146" y="100" width="80" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="186" y="128" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
|
||||
|
||||
<line x1="226" y1="124" x2="244" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- system prompt base instructions -->
|
||||
<rect x="247" y="94" width="115" height="60" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="304" y="116" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">system prompt</text>
|
||||
<text x="304" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">SYSTEM</text>
|
||||
<text x="304" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">base instructions</text>
|
||||
|
||||
<line x1="362" y1="124" x2="380" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- LLM call -->
|
||||
<rect x="383" y="94" width="100" height="60" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="433" y="116" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM call</text>
|
||||
<text x="433" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">client.messages.create</text>
|
||||
<text x="433" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">model request</text>
|
||||
|
||||
<line x1="483" y1="124" x2="501" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- TOOL DISPATCH (expanded) -->
|
||||
<rect x="504" y="88" width="220" height="72" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="614" y="106" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH</text>
|
||||
<text x="519" y="122" fill="#2563eb" font-size="8">base → bash, read, write, edit, glob</text>
|
||||
<text x="519" y="134" fill="#ea580c" font-size="8">PreToolUse → permission + log</text>
|
||||
<text x="519" y="146" fill="#4f46e5" font-size="8" font-weight="600">cron → schedule_cron, list, cancel (S12)</text>
|
||||
<text x="519" y="156" fill="#2563eb" font-size="8">PostToolUse → output check</text>
|
||||
|
||||
<!-- Loop back arrow -->
|
||||
<path d="M 724 124 L 748 124 L 748 170 L 77 170 L 77 148" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
|
||||
<text x="400" y="183" fill="#94a3b8" font-size="9" text-anchor="middle">loop back: tool_results → next turn</text>
|
||||
|
||||
<!-- ===== Row 2: Cron Scheduler Thread (indigo) ===== -->
|
||||
<rect x="30" y="206" width="250" height="90" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="155" y="226" fill="#312e81" font-size="11" font-weight="700" text-anchor="middle">cron_scheduler_loop (daemon スレッド)</text>
|
||||
<text x="48" y="244" fill="#4f46e5" font-size="9">wait(1s) → poll_due_jobs(datetime.now())</text>
|
||||
<text x="48" y="258" fill="#4f46e5" font-size="9">マッチ → 状態を保存 → queue へ追加</text>
|
||||
<text x="48" y="272" fill="#6b7280" font-size="8">last_fired で同一分の重複投入を防止</text>
|
||||
<text x="48" y="286" fill="#6b7280" font-size="8">model が prompt を受け取った後に削除</text>
|
||||
|
||||
<!-- Arrow: scheduler → cron_queue -->
|
||||
<path d="M 155 296 L 155 330" fill="none" stroke="#4f46e5" stroke-width="1.5" marker-end="url(#arrow-indigo)"/>
|
||||
|
||||
<!-- cron_queue (indigo) -->
|
||||
<rect x="60" y="333" width="200" height="38" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="160" y="352" fill="#312e81" font-size="10" font-weight="700" text-anchor="middle">cron_queue</text>
|
||||
<text x="160" y="364" fill="#4f46e5" font-size="8" text-anchor="middle">cron_lock · scheduler 書込 · processor 配信</text>
|
||||
|
||||
<!-- Arrow: cron_queue → consume_cron_queue (connects to top row) -->
|
||||
<path d="M 77 333 L 77 318 L 18 318 L 18 124 L 30 124" fill="none" stroke="#4f46e5" stroke-width="2" marker-end="url(#arrow-indigo)"/>
|
||||
<text x="80" y="313" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">Agent idle 時に配信</text>
|
||||
|
||||
<!-- ===== Row 2 Right: CronJob + Storage ===== -->
|
||||
<rect x="320" y="206" width="410" height="90" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="525" y="226" fill="#312e81" font-size="11" font-weight="700" text-anchor="middle">CronJob + 永続化</text>
|
||||
<text x="338" y="244" fill="#4f46e5" font-size="9" font-weight="600">CronJob core fields:</text>
|
||||
<text x="455" y="244" fill="#6b7280" font-size="8">id, cron, prompt, recurring, durable</text>
|
||||
<text x="338" y="260" fill="#4f46e5" font-size="9" font-weight="600">Durable → .scheduled_tasks.json</text>
|
||||
<text x="560" y="260" fill="#6b7280" font-size="8">再起動後 load_durable_jobs で復元</text>
|
||||
<text x="338" y="276" fill="#4f46e5" font-size="9" font-weight="600">Session-only → メモリのみ</text>
|
||||
<text x="530" y="276" fill="#6b7280" font-size="8">プロセス終了で消失</text>
|
||||
<text x="338" y="292" fill="#ef4444" font-size="9" font-weight="600">⚠ プロセス終了 = スケジューラ停止(OS レベルの crontab ではない)</text>
|
||||
|
||||
<!-- ===== Row 3: 5-field cron reference ===== -->
|
||||
<rect x="30" y="390" width="700" height="76" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<text x="60" y="412" fill="#1e3a5f" font-size="11" font-weight="600">5 フィールド Cron 式</text>
|
||||
<rect x="60" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="86" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="118" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="144" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="176" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="202" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="234" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="260" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="292" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="318" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<text x="60" y="455" fill="#94a3b8" font-size="8">分</text>
|
||||
<text x="130" y="455" fill="#94a3b8" font-size="8">時</text>
|
||||
<text x="194" y="455" fill="#94a3b8" font-size="8">日</text>
|
||||
<text x="254" y="455" fill="#94a3b8" font-size="8">月</text>
|
||||
<text x="310" y="455" fill="#94a3b8" font-size="8">曜日</text>
|
||||
|
||||
<text x="380" y="434" fill="#475569" font-size="9">*/5 * * * * → 5 分ごと</text>
|
||||
<text x="380" y="450" fill="#475569" font-size="9">0 9 * * 1-5 → 平日 9:00</text>
|
||||
<text x="560" y="434" fill="#475569" font-size="9">0 9 * * * → 毎日 9:00</text>
|
||||
<text x="560" y="450" fill="#475569" font-size="9">対応: *, */N, N, N-M, N,M,...</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
125
s12_cron_scheduler/images/cron-scheduler-overview.svg
Normal file
125
s12_cron_scheduler/images/cron-scheduler-overview.svg
Normal file
@@ -0,0 +1,125 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 480" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#4f46e5"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
|
||||
</marker>
|
||||
<marker id="arrow-indigo" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4f46e5"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="760" height="480" fill="#fafbfc" rx="8"/>
|
||||
|
||||
<!-- Title -->
|
||||
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
|
||||
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
|
||||
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Cron Scheduler — 独立调度线程 + cron_queue 注入点</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f8fafc" stroke="#94a3b8" stroke-width="1"/>
|
||||
<text x="58" y="66" fill="#64748b" font-size="10" font-weight="600">S04 工具与 Hooks</text>
|
||||
<rect x="160" y="56" width="12" height="10" rx="2" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="178" y="66" fill="#4f46e5" font-size="10" font-weight="600">S12 新增</text>
|
||||
|
||||
<!-- ===== Row 1: Full Agent Loop Chain ===== -->
|
||||
|
||||
<!-- consume_cron_queue (S12 new, indigo) -->
|
||||
<rect x="30" y="100" width="95" height="48" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="77" y="120" fill="#312e81" font-size="9" font-weight="700" text-anchor="middle">consume</text>
|
||||
<text x="77" y="134" fill="#312e81" font-size="9" font-weight="700" text-anchor="middle">cron_queue</text>
|
||||
<text x="77" y="144" fill="#94a3b8" font-size="7" text-anchor="middle">★ S12 注入点</text>
|
||||
|
||||
<line x1="125" y1="124" x2="143" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- messages 基础指令 -->
|
||||
<rect x="146" y="100" width="80" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="186" y="128" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
|
||||
|
||||
<line x1="226" y1="124" x2="244" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- system prompt 基础指令 -->
|
||||
<rect x="247" y="94" width="115" height="60" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="304" y="116" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">system prompt</text>
|
||||
<text x="304" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">SYSTEM</text>
|
||||
<text x="304" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">基础指令</text>
|
||||
|
||||
<line x1="362" y1="124" x2="380" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- LLM call -->
|
||||
<rect x="383" y="94" width="100" height="60" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="433" y="116" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM call</text>
|
||||
<text x="433" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">client.messages.create</text>
|
||||
<text x="433" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">model request</text>
|
||||
|
||||
<line x1="483" y1="124" x2="501" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- TOOL DISPATCH (expanded) -->
|
||||
<rect x="504" y="88" width="220" height="72" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.2"/>
|
||||
<text x="614" y="106" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH</text>
|
||||
<text x="519" y="122" fill="#2563eb" font-size="8">基础工具 → bash, read, write, edit, glob</text>
|
||||
<text x="519" y="134" fill="#ea580c" font-size="8">PreToolUse → permission + log</text>
|
||||
<text x="519" y="146" fill="#4f46e5" font-size="8" font-weight="600">cron → schedule_cron, list, cancel (S12)</text>
|
||||
<text x="519" y="156" fill="#2563eb" font-size="8">PostToolUse → output check</text>
|
||||
|
||||
<!-- Loop back arrow -->
|
||||
<path d="M 724 124 L 748 124 L 748 170 L 77 170 L 77 148" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
|
||||
<text x="400" y="183" fill="#94a3b8" font-size="9" text-anchor="middle">loop back: tool_results → next turn</text>
|
||||
|
||||
<!-- ===== Row 2: Cron Scheduler Thread (indigo) ===== -->
|
||||
<rect x="30" y="206" width="250" height="90" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="155" y="226" fill="#312e81" font-size="11" font-weight="700" text-anchor="middle">cron_scheduler_loop(独立 daemon 线程)</text>
|
||||
<text x="48" y="244" fill="#4f46e5" font-size="9">wait(1s) → poll_due_jobs(datetime.now())</text>
|
||||
<text x="48" y="258" fill="#4f46e5" font-size="9">匹配 → 持久化状态 → 加入队列</text>
|
||||
<text x="48" y="272" fill="#6b7280" font-size="8">last_fired 防止同一分钟重复入队</text>
|
||||
<text x="48" y="286" fill="#6b7280" font-size="8">模型接收 prompt 后删除一次性任务</text>
|
||||
|
||||
<!-- Arrow: scheduler → cron_queue -->
|
||||
<path d="M 155 296 L 155 330" fill="none" stroke="#4f46e5" stroke-width="1.5" marker-end="url(#arrow-indigo)"/>
|
||||
|
||||
<!-- cron_queue (indigo) -->
|
||||
<rect x="60" y="333" width="200" height="38" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="160" y="352" fill="#312e81" font-size="10" font-weight="700" text-anchor="middle">cron_queue</text>
|
||||
<text x="160" y="364" fill="#4f46e5" font-size="8" text-anchor="middle">cron_lock 保护 · scheduler 写 · processor 交付</text>
|
||||
|
||||
<!-- Arrow: cron_queue → consume_cron_queue (connects to top row) -->
|
||||
<path d="M 77 333 L 77 318 L 18 318 L 18 124 L 30 124" fill="none" stroke="#4f46e5" stroke-width="2" marker-end="url(#arrow-indigo)"/>
|
||||
<text x="72" y="313" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">Agent 空闲后交付</text>
|
||||
|
||||
<!-- ===== Row 2 Right: CronJob + Storage ===== -->
|
||||
<rect x="320" y="206" width="410" height="90" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="2"/>
|
||||
<text x="525" y="226" fill="#312e81" font-size="11" font-weight="700" text-anchor="middle">CronJob + 持久化</text>
|
||||
<text x="338" y="244" fill="#4f46e5" font-size="9" font-weight="600">CronJob 核心字段:</text>
|
||||
<text x="455" y="244" fill="#6b7280" font-size="8">id, cron, prompt, recurring, durable</text>
|
||||
<text x="338" y="260" fill="#4f46e5" font-size="9" font-weight="600">Durable → .scheduled_tasks.json</text>
|
||||
<text x="560" y="260" fill="#6b7280" font-size="8">重启后 load_durable_jobs 恢复</text>
|
||||
<text x="338" y="276" fill="#4f46e5" font-size="9" font-weight="600">Session-only → 内存 only</text>
|
||||
<text x="530" y="276" fill="#6b7280" font-size="8">进程关闭即丢</text>
|
||||
<text x="338" y="292" fill="#ef4444" font-size="9" font-weight="600">⚠ 进程关闭 = 调度停止(不是 OS 级 crontab)</text>
|
||||
|
||||
<!-- ===== Row 3: 5-field cron reference ===== -->
|
||||
<rect x="30" y="390" width="700" height="76" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<text x="60" y="412" fill="#1e3a5f" font-size="11" font-weight="600">五段式 Cron 表达式</text>
|
||||
<rect x="60" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="86" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="118" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="144" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="176" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="202" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="234" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="260" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<rect x="292" y="422" width="52" height="16" rx="3" fill="#eef2ff" stroke="#4f46e5" stroke-width="1"/>
|
||||
<text x="318" y="434" fill="#4f46e5" font-size="9" text-anchor="middle">*</text>
|
||||
<text x="60" y="455" fill="#94a3b8" font-size="8">分钟</text>
|
||||
<text x="130" y="455" fill="#94a3b8" font-size="8">小时</text>
|
||||
<text x="194" y="455" fill="#94a3b8" font-size="8">日</text>
|
||||
<text x="254" y="455" fill="#94a3b8" font-size="8">月</text>
|
||||
<text x="310" y="455" fill="#94a3b8" font-size="8">星期</text>
|
||||
|
||||
<text x="380" y="434" fill="#475569" font-size="9">*/5 * * * * → 每 5 分钟</text>
|
||||
<text x="380" y="450" fill="#475569" font-size="9">0 9 * * 1-5 → 工作日 9:00</text>
|
||||
<text x="560" y="434" fill="#475569" font-size="9">0 9 * * * → 每天 9:00</text>
|
||||
<text x="560" y="450" fill="#475569" font-size="9">支持: *, */N, N, N-M, N,M,...</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
Reference in New Issue
Block a user