mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-06-20 20:23:36 +08:00
* feat: s01-s14 docs quality overhaul — tool pipeline, single-agent, knowledge & resilience Rewrite code.py and README (zh/en/ja) for s01-s14, each chapter building incrementally on the previous. Key fixes across chapters: - s01-s04: agent loop, tool dispatch, permission pipeline, hooks - s05-s08: todo write, subagent, skill loading, context compact - s09-s11: memory system, system prompt assembly, error recovery - s12-s14: task graph, background tasks, cron scheduler All chapters CC source-verified. Code inherits fixes forward (PROMPT_SECTIONS, json.dumps cache, real-state context, can_start dep protection, etc.). * feat: s15-s19 docs quality overhaul — multi-agent platform: teams, protocols, autonomy, worktree, MCP tools Rewrite code.py and README (zh/en/ja) for s15-s19, the multi-agent platform chapters. Each chapter inherits all previous fixes and adds one mechanism: - s15: agent teams (TeamCreate, teammate threads, shared task list) - s16: team protocols (plan approval, shutdown handshake, consume_inbox) - s17: autonomous agents (idle polling, auto-claim, consume_lead_inbox) - s18: worktree isolation (git worktree, bind_task, cwd switching, safety) - s19: MCP tools (MCPClient, normalize_mcp_name, assemble_tool_pool, no cache) All appendix source code references verified against CC source. Config priority corrected: claude.ai < plugin < user < project < local. * fix: 5 regressions across s05-s19 — glob safety, todo validation, memory extraction, protocol types, dep crash - s05-s09: glob results now filter with is_relative_to(WORKDIR) (inherited from s02) - s06-s08: todo_write validates content/status required fields (inherited from s05) - s09: extract_memories uses pre-compression snapshot instead of compacted messages - s16: submit_plan docstring clarifies protocol-only (not code-level gate) - s17-s19: match_response restores type mismatch validation (from s16) - s17-s19: claim_task deps list handles missing dep files without crashing * fix: s12 Todo V2 logic reversal, s14/s15 cron range validation, s18/s19 worktree name validation - s12 README (zh/en/ja): fix Todo V2 direction — interactive defaults to Task, non-interactive/SDK defaults to TodoWrite. Fix env var name to CLAUDE_CODE_ENABLE_TASKS (not TODO_V2). - s14/s15: add _validate_cron_field with per-field range checks (minute 0-59, hour 0-23, dom 1-31, month 1-12, dow 0-6), step > 0, range lo <= hi. Replace old try/except validation that only caught exceptions. - s18/s19: add validate_worktree_name() to remove_worktree and keep_worktree, not just create_worktree. * fix: align s16-s19 teaching tool consistency * fix pr265 chapter diagrams * Add comprehensive s20 harness chapter * Fix chapter smoke test regressions * Clarify README tutorial track transition --------- Co-authored-by: Haoran <bill-billion@outlook.com>
This commit is contained in:
305
s14_cron_scheduler/README.en.md
Normal file
305
s14_cron_scheduler/README.en.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# s14: Cron Scheduler — Producing Work on a Schedule
|
||||
|
||||
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20
|
||||
> *"Produce work on a schedule, decouple scheduling from execution"* — Cron scheduling, durable or session-level.
|
||||
>
|
||||
> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
An alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00 — you could be sleeping, showering, cooking, it rings regardless.
|
||||
|
||||
s13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. "Run tests every morning at 9am", "Check CI status every 30 minutes" — these recurring tasks shouldn't need a human to push them each time.
|
||||
|
||||
---
|
||||
|
||||
## The Solution
|
||||
|
||||

|
||||
|
||||
Teaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle.
|
||||
|
||||
Manual vs Scheduled:
|
||||
|
||||
| | Manual (s13) | Scheduled (s14) |
|
||||
|---|---|---|
|
||||
| Triggered by | User input | Scheduler thread |
|
||||
| Trigger timing | Anytime | Specified by cron expression |
|
||||
| Human involvement | Yes | No (scheduler auto-enqueues, idle agent auto-delivers) |
|
||||
| Persistence | — | Durable survives restart |
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Four-Layer Model
|
||||
|
||||
Cron scheduling has four layers:
|
||||
|
||||
1. **Scheduler**: daemon thread, polls every second, checks if it's time
|
||||
2. **Queue**: `cron_queue`, scheduler writes fired jobs
|
||||
3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn
|
||||
4. **Consumer**: agent_loop consumes queue and injects into messages
|
||||
|
||||
The teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real CC's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes.
|
||||
|
||||
### CronJob: Data Structure
|
||||
|
||||
Each cron task is a `CronJob` object:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str # "0 9 * * *" (5-field cron expression)
|
||||
prompt: str # Message injected to the agent when fired
|
||||
recurring: bool # True=recurring, False=one-shot
|
||||
durable: bool # True=write to disk, survives sessions
|
||||
```
|
||||
|
||||
Cron expression, 5 fields, used by Unix for 50 years:
|
||||
|
||||
```
|
||||
min hour dom month dow
|
||||
* * * * * Every minute
|
||||
0 9 * * * Every day at 9:00
|
||||
*/5 * * * * Every 5 minutes
|
||||
0 9 * * 1-5 Weekdays at 9:00
|
||||
```
|
||||
|
||||
Supports `*`, `*/N`, `N`, `N-M`, `N,M,...`.
|
||||
|
||||
### cron_matches: 5-Field Matching
|
||||
|
||||
Standard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained:
|
||||
|
||||
```python
|
||||
def cron_matches(cron_expr: str, dt: datetime) -> bool:
|
||||
fields = cron_expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return False
|
||||
minute, hour, dom, month, dow = fields
|
||||
dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0
|
||||
|
||||
m = _cron_field_matches(minute, dt.minute)
|
||||
h = _cron_field_matches(hour, dt.hour)
|
||||
dom_ok = _cron_field_matches(dom, dt.day)
|
||||
month_ok = _cron_field_matches(month, dt.month)
|
||||
dow_ok = _cron_field_matches(dow, dow_val)
|
||||
|
||||
if not (m and h and month_ok):
|
||||
return False
|
||||
# DOM and DOW: both constrained → either matching is enough (OR)
|
||||
dom_unconstrained = dom == "*"
|
||||
dow_unconstrained = dow == "*"
|
||||
if dom_unconstrained and dow_unconstrained:
|
||||
return True
|
||||
if dom_unconstrained:
|
||||
return dow_ok
|
||||
if dow_unconstrained:
|
||||
return dom_ok
|
||||
return dom_ok or dow_ok
|
||||
```
|
||||
|
||||
### Independent Scheduler Thread: 1-Second Polling
|
||||
|
||||
The scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread:
|
||||
|
||||
```python
|
||||
def cron_scheduler_loop():
|
||||
while True:
|
||||
time.sleep(1)
|
||||
now = datetime.now()
|
||||
minute_marker = now.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
try:
|
||||
if cron_matches(job.cron, now):
|
||||
if _last_fired.get(job.id) != minute_marker:
|
||||
cron_queue.append(job)
|
||||
_last_fired[job.id] = minute_marker
|
||||
if not job.recurring:
|
||||
scheduled_jobs.pop(job.id, None)
|
||||
if job.durable:
|
||||
save_durable_jobs()
|
||||
except Exception as e:
|
||||
print(f"[cron error] {job.id}: {e}")
|
||||
```
|
||||
|
||||
Key design:
|
||||
- **Independent of agent_loop**: scheduler checks time in background even when agent_loop isn't running
|
||||
- **Date-aware minute_marker**: uses `"YYYY-MM-DD HH:MM"` to prevent same-minute double-fire while not skipping on the next day
|
||||
- **Per-job try/except**: one bad job doesn't crash the scheduler thread
|
||||
- **One-shot jobs**: auto-removed from scheduled_jobs after firing
|
||||
|
||||
### Queue Processor + agent_loop: Delivery
|
||||
|
||||
The queue processor does not check time. It only starts a turn when queued work exists and the agent is idle:
|
||||
|
||||
```python
|
||||
def queue_processor_loop():
|
||||
while True:
|
||||
time.sleep(0.2)
|
||||
if not has_cron_queue():
|
||||
continue
|
||||
if not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if has_cron_queue():
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
```
|
||||
|
||||
agent_loop also doesn't check time. It only takes fired tasks from `cron_queue` and injects them into messages:
|
||||
|
||||
```python
|
||||
fired = consume_cron_queue()
|
||||
for job in fired:
|
||||
messages.append({"role": "user",
|
||||
"content": f"[Scheduled] {job.prompt}"})
|
||||
```
|
||||
|
||||
Producer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via `cron_queue`, `cron_lock`, and `agent_lock`.
|
||||
|
||||
### Validation: Prevent Bad Cron from Killing the Scheduler
|
||||
|
||||
`schedule_job` validates the cron expression before registering, returning an error for invalid input:
|
||||
|
||||
```python
|
||||
def schedule_job(cron, prompt, recurring=True, durable=True):
|
||||
err = validate_cron(cron)
|
||||
if err:
|
||||
return err
|
||||
# ... register job
|
||||
```
|
||||
|
||||
Loading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup.
|
||||
|
||||
### Durable vs Session-only
|
||||
|
||||
- **Durable**: Task definition written to `.scheduled_tasks.json`. Loaded on agent restart.
|
||||
- **Session-only**: In-memory only. Gone when the agent closes.
|
||||
|
||||
> **Important caveat**: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts — next time the agent starts, the scheduler discovers "it should fire" and fires. If you need "run even when the app is closed", use system crontab or systemd timer.
|
||||
|
||||
### Putting It Together
|
||||
|
||||
```
|
||||
1. On startup:
|
||||
load_durable_jobs() → restore durable tasks from .scheduled_tasks.json
|
||||
Thread(cron_scheduler_loop, daemon=True).start() → scheduler begins polling
|
||||
Thread(queue_processor_loop, daemon=True).start() → processor waits to deliver
|
||||
|
||||
2. Register a task:
|
||||
schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True)
|
||||
→ CronJob written to scheduled_jobs + .scheduled_tasks.json
|
||||
|
||||
3. Every 2 minutes:
|
||||
Scheduler checks → cron_matches returns True → cron_queue.append(job)
|
||||
→ queue processor sees idle agent → agent_loop consume_cron_queue
|
||||
→ injects "[Scheduled] run date"
|
||||
→ LLM receives message, runs date command
|
||||
|
||||
4. Process shutdown:
|
||||
Scheduler thread stops (daemon=True)
|
||||
.scheduled_tasks.json stays on disk
|
||||
Next startup → load_durable_jobs → tasks restored
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changes from s13
|
||||
|
||||
| Component | Before (s13) | After (s14) |
|
||||
|-----------|-------------|-------------|
|
||||
| Trigger method | User manual trigger | Scheduler thread auto-enqueues |
|
||||
| New types | — | CronJob dataclass (id, cron, prompt, recurring, durable) |
|
||||
| New functions | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
|
||||
| New storage | — | .scheduled_tasks.json (durable) + memory (session-only) |
|
||||
| Threads | Background execution thread | + Scheduler thread (daemon, 1s polling) + queue processor thread |
|
||||
| Queue | background_results | + cron_queue (scheduler writes, queue processor delivers, agent_loop consumes) |
|
||||
| Tools | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
|
||||
|
||||
---
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s14_cron_scheduler/code.py
|
||||
```
|
||||
|
||||
Try these prompts:
|
||||
|
||||
1. `Schedule a task to print the current date every 2 minutes`
|
||||
2. `List all cron jobs`
|
||||
3. `Create a one-shot reminder in 1 minute to check the build status`
|
||||
4. `Cancel the recurring job and verify with list_crons`
|
||||
|
||||
What to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see `[queue processor]` and automatic execution? Is the durable job written to `.scheduled_tasks.json`?
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
One agent can do a lot now: plan, compress, background, schedule. But some tasks are too big for one agent.
|
||||
|
||||
"Refactor the entire backend" — overhaul auth, database layer, API routes, and tests. One agent's attention is limited. This needs a team.
|
||||
|
||||
s15 Agent Teams → One agent isn't enough, form a team. Persistent teammates + async inboxes.
|
||||
|
||||
<details>
|
||||
<summary>Deep Dive into CC Source</summary>
|
||||
|
||||
> The following is a complete analysis based on CC source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines).
|
||||
|
||||
### 1. Three Cron Tools
|
||||
|
||||
CC exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override.
|
||||
|
||||
### 2. Storage: `.claude/scheduled_tasks.json`
|
||||
|
||||
```json
|
||||
{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
|
||||
```
|
||||
|
||||
Durable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project.
|
||||
|
||||
### 3. Scheduler: 1-Second Polling
|
||||
|
||||
`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes.
|
||||
|
||||
### 4. Cron Expression: Standard 5 Fields
|
||||
|
||||
Minute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained.
|
||||
|
||||
### 5. Jitter (Thundering Herd Prevention)
|
||||
|
||||
- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID
|
||||
- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30`
|
||||
- Jitter config adjustable via GrowthBook, refreshed every 60 seconds
|
||||
|
||||
### 6. Auto-Expiration
|
||||
|
||||
Recurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete.
|
||||
|
||||
### 7. Job Limit
|
||||
|
||||
`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: "Too many scheduled jobs (max 50). Cancel one first."
|
||||
|
||||
### 8. Trigger Injection
|
||||
|
||||
After firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight.
|
||||
|
||||
### 9. Queue Processor: Automatic Delivery
|
||||
|
||||
Real CC auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.
|
||||
|
||||
</details>
|
||||
|
||||
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
|
||||
305
s14_cron_scheduler/README.ja.md
Normal file
305
s14_cron_scheduler/README.ja.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# s14: Cron Scheduler — スケジュールに従って作業を生産
|
||||
|
||||
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20
|
||||
> *"スケジュールに従って作業を生産、スケジューリングと実行を分離"* — cron スケジューリング、永続またはセッションレベル。
|
||||
>
|
||||
> **Harness 層**: スケジューリング — 独立スレッドが時刻を判定、キューがトリガーを配信。
|
||||
|
||||
---
|
||||
|
||||
## 課題
|
||||
|
||||
目覚まし時計はあなたが見ていないと鳴らないわけではない。7:00 にセットすれば、7:00 に鳴る。寝ていても、シャワーを浴びていても、料理をしていても、鳴る。
|
||||
|
||||
s13 で Agent は遅い操作をバックグラウンドで実行できるようになった。しかし、すべての操作は手動でトリガーされる。一言言えば、Agent が動く。「毎朝 9 時にテストを実行」「30 分ごとに CI ステータスを確認」、これらの定期的なタスクに人が毎回押す必要はないはずだ。
|
||||
|
||||
---
|
||||
|
||||
## ソリューション
|
||||
|
||||

|
||||
|
||||
教学版は S13 の簡易タスクシステム、バックグラウンド実行、プロンプト組み立てを踏襲。スケジューラに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:独立した cron スケジューラスレッド、1 秒ごとにポーリング、時間が来たらタスクを `cron_queue` に投入し、queue processor が Agent のアイドル時に自動配信。
|
||||
|
||||
手動 vs スケジュール:
|
||||
|
||||
| | 手動 (s13) | スケジュール (s14) |
|
||||
|---|---|---|
|
||||
| トリガー | ユーザー入力 | スケジューラスレッド |
|
||||
| トリガー時刻 | いつでも | cron 式で指定 |
|
||||
| 人の関与 | あり | なし(スケジューラが自動キュー投入、アイドル時に自動配信) |
|
||||
| 永続性 | — | durable は再起動後も保持 |
|
||||
|
||||
---
|
||||
|
||||
## 仕組み
|
||||
|
||||
### 4 層モデル
|
||||
|
||||
cron スケジューリングは 4 層に分かれる:
|
||||
|
||||
1. **Scheduler**:daemon スレッド、1 秒ごとにポーリング、時刻が来たか判定
|
||||
2. **Queue**:`cron_queue`、スケジューラが発火済みタスクを書き込み
|
||||
3. **Queue Processor**:キューが空でなく Agent がアイドルなら、一回の agent_loop を開始
|
||||
4. **Consumer**:agent_loop がキューから消費、messages に注入
|
||||
|
||||
教学版は最小の queue processor を実装する。`agent_lock` で Agent がアイドルかを判定し、キューに入った cron 作業を自動配信する。実際の CC の `useQueueProcessor.ts` はさらに UI ブロック、キュープライオリティ、メッセージモードを扱う。
|
||||
|
||||
### CronJob: データ構造
|
||||
|
||||
各 cron タスクは `CronJob` オブジェクト:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str # "0 9 * * *"(5 フィールド cron 式)
|
||||
prompt: str # 発火時に Agent に注入するメッセージ
|
||||
recurring: bool # True=定期的、False=一回限り
|
||||
durable: bool # True=ディスク書き込み、セッション横断
|
||||
```
|
||||
|
||||
cron 式、5 フィールド、Unix で 50 年使われている:
|
||||
|
||||
```
|
||||
分 時 日 月 曜日
|
||||
* * * * * 毎分
|
||||
0 9 * * * 毎日 9:00
|
||||
*/5 * * * * 5 分ごと
|
||||
0 9 * * 1-5 平日 9:00
|
||||
```
|
||||
|
||||
`*`、`*/N`、`N`、`N-M`、`N,M,...` をサポート。
|
||||
|
||||
### cron_matches: 5 フィールドマッチング
|
||||
|
||||
標準 cron セマンティクス:分、時、月はすべてマッチ必須。日(DOM)と曜日(DOW)が両方制約されている場合は、いずれかのマッチで十分(OR):
|
||||
|
||||
```python
|
||||
def cron_matches(cron_expr: str, dt: datetime) -> bool:
|
||||
fields = cron_expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return False
|
||||
minute, hour, dom, month, dow = fields
|
||||
dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0
|
||||
|
||||
m = _cron_field_matches(minute, dt.minute)
|
||||
h = _cron_field_matches(hour, dt.hour)
|
||||
dom_ok = _cron_field_matches(dom, dt.day)
|
||||
month_ok = _cron_field_matches(month, dt.month)
|
||||
dow_ok = _cron_field_matches(dow, dow_val)
|
||||
|
||||
if not (m and h and month_ok):
|
||||
return False
|
||||
# DOM and DOW: both constrained → either matching is enough (OR)
|
||||
dom_unconstrained = dom == "*"
|
||||
dow_unconstrained = dow == "*"
|
||||
if dom_unconstrained and dow_unconstrained:
|
||||
return True
|
||||
if dom_unconstrained:
|
||||
return dow_ok
|
||||
if dow_unconstrained:
|
||||
return dom_ok
|
||||
return dom_ok or dow_ok
|
||||
```
|
||||
|
||||
### 独立スケジューラスレッド:1 秒ポーリング
|
||||
|
||||
スケジューラは独立した daemon スレッドで動作、agent_loop が実行中かどうかに依存しない。個々のジョブエラーはスレッド全体を殺さない:
|
||||
|
||||
```python
|
||||
def cron_scheduler_loop():
|
||||
while True:
|
||||
time.sleep(1)
|
||||
now = datetime.now()
|
||||
minute_marker = now.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
try:
|
||||
if cron_matches(job.cron, now):
|
||||
if _last_fired.get(job.id) != minute_marker:
|
||||
cron_queue.append(job)
|
||||
_last_fired[job.id] = minute_marker
|
||||
if not job.recurring:
|
||||
scheduled_jobs.pop(job.id, None)
|
||||
if job.durable:
|
||||
save_durable_jobs()
|
||||
except Exception as e:
|
||||
print(f"[cron error] {job.id}: {e}")
|
||||
```
|
||||
|
||||
重要な設計:
|
||||
- **agent_loop から独立**:agent_loop が動いていなくても、スケジューラはバックグラウンドで時刻をチェック
|
||||
- **日付認識 minute_marker**:`"YYYY-MM-DD HH:MM"` を使用、同じ分の重複発火を防ぎつつ翌日のスキップも防止
|
||||
- **ジョブ単位の try/except**:一つの悪いジョブがスケジューラスレッド全体をクラッシュさせない
|
||||
- **一回限りジョブ**:発火後、scheduled_jobs から自動削除
|
||||
|
||||
### Queue Processor + agent_loop: 配信側
|
||||
|
||||
queue processor は時刻をチェックしない。キューに作業があり、Agent がアイドルの時だけ一回の実行を開始する:
|
||||
|
||||
```python
|
||||
def queue_processor_loop():
|
||||
while True:
|
||||
time.sleep(0.2)
|
||||
if not has_cron_queue():
|
||||
continue
|
||||
if not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if has_cron_queue():
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
```
|
||||
|
||||
agent_loop も時刻をチェックしない。`cron_queue` から発火済みタスクを取り出し、messages に注入するだけ:
|
||||
|
||||
```python
|
||||
fired = consume_cron_queue()
|
||||
for job in fired:
|
||||
messages.append({"role": "user",
|
||||
"content": f"[Scheduled] {job.prompt}"})
|
||||
```
|
||||
|
||||
生産者(スケジューラスレッド)、配信者(queue processor)、消費者(agent_loop)は `cron_queue`、`cron_lock`、`agent_lock` で分離されている。
|
||||
|
||||
### バリデーション:不正 cron がスケジューラを殺すのを防止
|
||||
|
||||
`schedule_job` は登録前に cron 式をバリデーションし、不正な場合はエラーを返す:
|
||||
|
||||
```python
|
||||
def schedule_job(cron, prompt, recurring=True, durable=True):
|
||||
err = validate_cron(cron)
|
||||
if err:
|
||||
return err
|
||||
# ... ジョブ登録
|
||||
```
|
||||
|
||||
ディスクから durable ジョブを読み込む際も不正な式をスキップし、一つの悪いタスクが起動を妨げない。
|
||||
|
||||
### Durable vs Session-only
|
||||
|
||||
- **Durable**:タスク定義を `.scheduled_tasks.json` に書き込み。Agent 再起動後にファイルから復元。
|
||||
- **Session-only**:メモリ内のみ。Agent 終了で消失。
|
||||
|
||||
> **重要な前提**:cron スケジューラは Agent プロセス内で実行される必要がある。プロセスが終了するとスケジューラも停止。Durable はタスク定義が再起動後も保持されることを意味するだけで、次回 Agent 起動時にスケジューラが「発火すべき」と判定して初めて発火する。「アプリケーションが閉じていても定期的に実行」が必要な場合は、システム crontab または systemd timer を使用。
|
||||
|
||||
### 組み合わせて実行
|
||||
|
||||
```
|
||||
1. 起動時:
|
||||
load_durable_jobs() → .scheduled_tasks.json から永続タスクを復元
|
||||
Thread(cron_scheduler_loop, daemon=True).start() → スケジューラスレッドがポーリング開始
|
||||
Thread(queue_processor_loop, daemon=True).start() → processor が配信待機
|
||||
|
||||
2. タスク登録:
|
||||
schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True)
|
||||
→ CronJob を scheduled_jobs + .scheduled_tasks.json に書き込み
|
||||
|
||||
3. 2 分ごと:
|
||||
スケジューラチェック → cron_matches が True → cron_queue.append(job)
|
||||
→ queue processor がアイドル状態を検知 → agent_loop consume_cron_queue
|
||||
→ "[Scheduled] run date" を注入
|
||||
→ LLM がメッセージを受信、date コマンドを実行
|
||||
|
||||
4. プロセス終了:
|
||||
スケジューラスレッドも停止(daemon=True)
|
||||
.scheduled_tasks.json はディスクに残存
|
||||
次回起動 → load_durable_jobs → タスク復元
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## s13 からの変更
|
||||
|
||||
| コンポーネント | 変更前 (s13) | 変更後 (s14) |
|
||||
|--------------|------------|------------|
|
||||
| トリガー方式 | ユーザー手動トリガー | スケジューラスレッドが自動キュー投入 |
|
||||
| 新規型 | — | CronJob データクラス (id, cron, prompt, recurring, durable) |
|
||||
| 新規関数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
|
||||
| 新規ストレージ | — | .scheduled_tasks.json (durable) + メモリ (session-only) |
|
||||
| スレッド | バックグラウンド実行スレッド | + スケジューラスレッド (daemon, 1s ポーリング) + queue processor スレッド |
|
||||
| キュー | background_results | + cron_queue(スケジューラ書き込み、queue processor 配信、agent_loop 消費) |
|
||||
| ツール | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
|
||||
|
||||
---
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s14_cron_scheduler/code.py
|
||||
```
|
||||
|
||||
以下のプロンプトを試してください:
|
||||
|
||||
1. `Schedule a task to print the current date every 2 minutes`
|
||||
2. `List all cron jobs`
|
||||
3. `Create a one-shot reminder in 1 minute to check the build status`
|
||||
4. `Cancel the recurring job and verify with list_crons`
|
||||
|
||||
観察ポイント:スケジューラスレッドが独立して動いているか?cron タスクが正しい時刻に発火しているか?新しい prompt を入力しなくても `[queue processor]` が出て自動実行されるか?durable ジョブが `.scheduled_tasks.json` に書き込まれているか?
|
||||
|
||||
---
|
||||
|
||||
## 次の章
|
||||
|
||||
一つの Agent でできることは増えた。計画、圧縮、バックグラウンド、スケジューリング。しかし、一部のタスクは一つの Agent では大きすぎる。
|
||||
|
||||
「バックエンド全体をリファクタリング」、認証モジュール、データベース層、API ルート、テストを全面的に刷新。一つの Agent の注意力には限界がある。これにはチームが必要だ。
|
||||
|
||||
s15 Agent Teams → 一人の Agent では足りない、チームを組もう。永続的なチームメイト + 非同期受信箱。
|
||||
|
||||
<details>
|
||||
<summary>CC ソースコード深掘り</summary>
|
||||
|
||||
> 以下は CC ソースコード `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)の完全分析に基づく。
|
||||
|
||||
### 一、3 つの Cron ツール
|
||||
|
||||
CC はモデルに 3 つの cron ツールを公開:`CronCreate`、`CronDelete`、`CronList`。すべてコンパイル時ゲート `feature('AGENT_TRIGGERS')` とランタイム GrowthBook フラグ `tengu_kairos_cron` で制御。`CLAUDE_CODE_DISABLE_CRON` 環境変数でローカル上書きも可能。
|
||||
|
||||
### 二、ストレージ:`.claude/scheduled_tasks.json`
|
||||
|
||||
```json
|
||||
{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
|
||||
```
|
||||
|
||||
durable タスクはディスクに書き込み。session-only タスクは `STATE.sessionCronTasks` メモリ配列に格納(プロセス再起動で消失)。`.scheduled_tasks.lock` ファイルで同じプロジェクトの複数セッション間の重複発火を防止。
|
||||
|
||||
### 三、スケジューラ:1 秒ポーリング
|
||||
|
||||
`cronScheduler.ts` は毎秒チェック(`CHECK_INTERVAL_MS = 1000`)。ロックを保持しているセッションがファイルタスクをトリガー。すべてのセッションが session-only タスクをトリガー。`chokidar` ファイルウォッチャーが `scheduled_tasks.json` の変更を監視。
|
||||
|
||||
### 四、cron 式:標準 5 フィールド
|
||||
|
||||
分 時 日 月 曜日。`*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...` をサポート。`L`、`W`、`?` は非サポート。すべての時間はローカルタイムゾーンで解釈。day-of-month と day-of-week が両方制約されている場合は OR セマンティクス。
|
||||
|
||||
### 五、ジッター(サンダリングハード防止)
|
||||
|
||||
- 定期タスク:トリガー遅延は期間の最大 10%(上限 15 分)、タスク ID ベースの決定的ハッシュ
|
||||
- 一回限りタスク:発火時刻が `:00` または `:30` の場合、最大 90 秒早く発火
|
||||
- ジッター設定は GrowthBook でリアルタイム調整可能、60 秒ごとにリフレッシュ
|
||||
|
||||
### 六、自動期限切れ
|
||||
|
||||
定期タスクは 7 日後に自動期限切れ(設定可能、上限 30 日)。期限切れ前に最後の一回を発火、その後自動削除。
|
||||
|
||||
### 七、ジョブ数上限
|
||||
|
||||
`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超過時はエラーを返す:"Too many scheduled jobs (max 50). Cancel one first."
|
||||
|
||||
### 八、トリガー注入
|
||||
|
||||
発火後、`enqueuePendingNotification()` で `priority: 'later'` としてコマンドキューにエンキュー。`workload: WORKLOAD_CRON` タグ付き、API は容量が逼迫している時に cron 発信リクエストを低い QoS で処理。
|
||||
|
||||
### 九、Queue Processor:自動配信
|
||||
|
||||
実際の CC は `useQueueProcessor.ts:48-60` により、アクティブな query がなく、UI がブロックされておらず、キューが空でない場合に自動的に処理をトリガーする。`queueProcessor.ts:52-87` がキュープライオリティに従ってコマンドを `handlePromptSubmit()` にディスパッチ。教学版は `queue_processor_loop` で核心動作を保つ:キューに作業があり Agent がアイドルなら、自動的に一回の agent_loop を開始する。
|
||||
|
||||
</details>
|
||||
|
||||
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
|
||||
305
s14_cron_scheduler/README.md
Normal file
305
s14_cron_scheduler/README.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# s14: Cron Scheduler — 按时间表生产工作
|
||||
|
||||
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20
|
||||
> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。
|
||||
>
|
||||
> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
闹钟不需要你盯着它才会响。你设好 7:00,到点它自己响,你在睡觉、在洗澡、在做饭,它都照响不误。
|
||||
|
||||
s13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发的。你说一句,Agent 动一下。"每天早上 9 点跑测试"、"每 30 分钟检查 CI 状态",这些周期性任务不该需要人每次来推。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||

|
||||
|
||||
教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增:独立的 cron 调度线程,每秒检查一次,时间到了把任务塞进 `cron_queue`;再由 queue processor 在 Agent 空闲时自动交付。
|
||||
|
||||
手动 vs 定时:
|
||||
|
||||
| | 手动触发 (s13) | 定时触发 (s14) |
|
||||
|---|---|---|
|
||||
| 触发者 | 用户输入 | 调度线程 |
|
||||
| 触发时机 | 随时 | cron 表达式指定 |
|
||||
| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) |
|
||||
| 持久性 | — | durable 跨重启 |
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
### 四层模型
|
||||
|
||||
Cron 调度分四层:
|
||||
|
||||
1. **Scheduler**:daemon 线程,每秒轮询,判断时间到了没有
|
||||
2. **Queue**:`cron_queue`,调度线程写入已触发任务
|
||||
3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop
|
||||
4. **Consumer**:agent_loop 从队列消费,注入到 messages
|
||||
|
||||
教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 CC 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。
|
||||
|
||||
### CronJob: 数据结构
|
||||
|
||||
每个 cron 任务是一个 `CronJob` 对象:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str # "0 9 * * *" (五段式 cron 表达式)
|
||||
prompt: str # 触发时注入给 Agent 的消息
|
||||
recurring: bool # True=周期性,False=一次性
|
||||
durable: bool # True=写磁盘,跨会话保留
|
||||
```
|
||||
|
||||
Cron 表达式,五段式,Unix 用了 50 年:
|
||||
|
||||
```
|
||||
分钟 小时 日 月 星期
|
||||
* * * * * 每分钟
|
||||
0 9 * * * 每天早上 9:00
|
||||
*/5 * * * * 每 5 分钟
|
||||
0 9 * * 1-5 工作日早上 9:00
|
||||
```
|
||||
|
||||
支持 `*`、`*/N`、`N`、`N-M`、`N,M,...`。
|
||||
|
||||
### cron_matches: 五段式匹配
|
||||
|
||||
标准 cron 语义:分钟、小时、月必须全部匹配;日(DOM)和星期(DOW)同时被约束时任一匹配即可(OR):
|
||||
|
||||
```python
|
||||
def cron_matches(cron_expr: str, dt: datetime) -> bool:
|
||||
fields = cron_expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return False
|
||||
minute, hour, dom, month, dow = fields
|
||||
dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0
|
||||
|
||||
m = _cron_field_matches(minute, dt.minute)
|
||||
h = _cron_field_matches(hour, dt.hour)
|
||||
dom_ok = _cron_field_matches(dom, dt.day)
|
||||
month_ok = _cron_field_matches(month, dt.month)
|
||||
dow_ok = _cron_field_matches(dow, dow_val)
|
||||
|
||||
if not (m and h and month_ok):
|
||||
return False
|
||||
# DOM and DOW: both constrained → either matching is enough (OR)
|
||||
dom_unconstrained = dom == "*"
|
||||
dow_unconstrained = dow == "*"
|
||||
if dom_unconstrained and dow_unconstrained:
|
||||
return True
|
||||
if dom_unconstrained:
|
||||
return dow_ok
|
||||
if dow_unconstrained:
|
||||
return dom_ok
|
||||
return dom_ok or dow_ok
|
||||
```
|
||||
|
||||
### 独立调度线程: 每秒轮询
|
||||
|
||||
调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程:
|
||||
|
||||
```python
|
||||
def cron_scheduler_loop():
|
||||
while True:
|
||||
time.sleep(1)
|
||||
now = datetime.now()
|
||||
minute_marker = now.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
try:
|
||||
if cron_matches(job.cron, now):
|
||||
if _last_fired.get(job.id) != minute_marker:
|
||||
cron_queue.append(job)
|
||||
_last_fired[job.id] = minute_marker
|
||||
if not job.recurring:
|
||||
scheduled_jobs.pop(job.id, None)
|
||||
if job.durable:
|
||||
save_durable_jobs()
|
||||
except Exception as e:
|
||||
print(f"[cron error] {job.id}: {e}")
|
||||
```
|
||||
|
||||
关键设计:
|
||||
- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间
|
||||
- **date-aware minute_marker**:用 `"YYYY-MM-DD HH:MM"` 防止同一分钟重复触发,同时不会在第二天跳过
|
||||
- **单 job try/except**:一个坏 job 不会拖垮整个调度线程
|
||||
- **一次性任务**:触发后自动从 scheduled_jobs 里删除
|
||||
|
||||
### Queue Processor + agent_loop: 交付端
|
||||
|
||||
queue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行:
|
||||
|
||||
```python
|
||||
def queue_processor_loop():
|
||||
while True:
|
||||
time.sleep(0.2)
|
||||
if not has_cron_queue():
|
||||
continue
|
||||
if not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if has_cron_queue():
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
```
|
||||
|
||||
agent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里:
|
||||
|
||||
```python
|
||||
fired = consume_cron_queue()
|
||||
for job in fired:
|
||||
messages.append({"role": "user",
|
||||
"content": f"[Scheduled] {job.prompt}"})
|
||||
```
|
||||
|
||||
生产者(调度线程)、交付者(queue processor)和消费者(agent_loop)通过 `cron_queue`、`cron_lock`、`agent_lock` 解耦。
|
||||
|
||||
### 校验:防止坏 cron 杀掉调度器
|
||||
|
||||
`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误:
|
||||
|
||||
```python
|
||||
def schedule_job(cron, prompt, recurring=True, durable=True):
|
||||
err = validate_cron(cron)
|
||||
if err:
|
||||
return err
|
||||
# ... register job
|
||||
```
|
||||
|
||||
从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。
|
||||
|
||||
### Durable vs Session-only
|
||||
|
||||
- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。
|
||||
- **Session-only**:只在内存里。Agent 关闭就没了。
|
||||
|
||||
> **重要前提**:cron 调度器必须在 Agent 进程内跑。进程关闭,调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现"该触发了"并触发。如果需要"即使应用关闭也能定时跑",请用系统 crontab 或 systemd timer。
|
||||
|
||||
### 合起来跑
|
||||
|
||||
```
|
||||
1. 启动时:
|
||||
load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务
|
||||
Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询
|
||||
Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付
|
||||
|
||||
2. 注册任务:
|
||||
schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True)
|
||||
→ CronJob 写入 scheduled_jobs + .scheduled_tasks.json
|
||||
|
||||
3. 每 2 分钟:
|
||||
调度线程检查 → cron_matches 返回 True → cron_queue.append(job)
|
||||
→ queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue
|
||||
→ 注入 "[Scheduled] run date"
|
||||
→ LLM 收到消息,执行 date 命令
|
||||
|
||||
4. 关闭进程:
|
||||
调度线程跟着停(daemon=True)
|
||||
.scheduled_tasks.json 还在磁盘上
|
||||
下次启动 → load_durable_jobs → 任务恢复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相对 s13 的变更
|
||||
|
||||
| 组件 | 之前 (s13) | 之后 (s14) |
|
||||
|------|-----------|-----------|
|
||||
| 触发方式 | 用户手动触发 | 调度线程自动入队 |
|
||||
| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) |
|
||||
| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |
|
||||
| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) |
|
||||
| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 |
|
||||
| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) |
|
||||
| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |
|
||||
|
||||
---
|
||||
|
||||
## 试一下
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s14_cron_scheduler/code.py
|
||||
```
|
||||
|
||||
试试这些 prompt:
|
||||
|
||||
1. `Schedule a task to print the current date every 2 minutes`
|
||||
2. `List all cron jobs`
|
||||
3. `Create a one-shot reminder in 1 minute to check the build status`
|
||||
4. `Cancel the recurring job and verify with list_crons`
|
||||
|
||||
观察重点:调度线程是否在独立运行?cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行?durable job 是否写入了 `.scheduled_tasks.json`?
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
一个 Agent 能做很多事了,能计划、能压缩、能后台、能定时。但有些任务太大了,不是一个 Agent 能搞定的。
|
||||
|
||||
"重构整个后端",把认证模块、数据库层、API 路由、测试全部翻新。一个 Agent 的注意力是有限的,这需要一个团队。
|
||||
|
||||
s15 Agent Teams → 一个 Agent 不够,组队吧。持久队友 + 异步收件箱。
|
||||
|
||||
<details>
|
||||
<summary>深入 CC 源码</summary>
|
||||
|
||||
> 以下基于 CC 源码 `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)的完整分析。
|
||||
|
||||
### 一、三个 Cron 工具
|
||||
|
||||
CC 暴露了三个 cron 工具给模型:`CronCreate`、`CronDelete`、`CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。
|
||||
|
||||
### 二、存储:`.claude/scheduled_tasks.json`
|
||||
|
||||
```json
|
||||
{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
|
||||
```
|
||||
|
||||
Durable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。
|
||||
|
||||
### 三、调度器:1 秒轮询
|
||||
|
||||
`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。
|
||||
|
||||
### 四、Cron 表达式:标准 5 字段
|
||||
|
||||
分钟 小时 日 月 星期。支持 `*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...`。不支持 `L`、`W`、`?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。
|
||||
|
||||
### 五、抖动(防惊群效应)
|
||||
|
||||
- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希
|
||||
- 一次性任务:当触发时间落在 `:00` 或 `:30` 时,最多提前 90 秒触发
|
||||
- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次
|
||||
|
||||
### 六、自动过期
|
||||
|
||||
重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。
|
||||
|
||||
### 七、作业数上限
|
||||
|
||||
`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超限时返回错误:"Too many scheduled jobs (max 50). Cancel one first."
|
||||
|
||||
### 八、触发注入
|
||||
|
||||
触发后通过 `enqueuePendingNotification()` 以 `priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。
|
||||
|
||||
### 九、Queue Processor:自动交付
|
||||
|
||||
真实 CC 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。
|
||||
|
||||
</details>
|
||||
|
||||
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
|
||||
805
s14_cron_scheduler/code.py
Normal file
805
s14_cron_scheduler/code.py
Normal file
@@ -0,0 +1,805 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
s14: Cron Scheduler — independent daemon thread + queue processor.
|
||||
|
||||
Run: python s14_cron_scheduler/code.py
|
||||
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
|
||||
|
||||
Changes from s13:
|
||||
- CronJob dataclass (id, cron, prompt, recurring, durable)
|
||||
- cron_matches: 5-field cron expression matching with DOM/DOW OR semantics
|
||||
- schedule_job / cancel_job: register/remove cron jobs (with validation)
|
||||
- cron_scheduler_loop: independent daemon thread, polls every 1s
|
||||
- cron_queue: thread-safe queue, scheduler writes, queue processor delivers
|
||||
- queue_processor_loop: auto-runs agent_loop when cron_queue has work
|
||||
- Durable storage: .scheduled_tasks.json (survives restart)
|
||||
- 3 new tools: schedule_cron, list_crons, cancel_cron
|
||||
|
||||
Four layers:
|
||||
1. Scheduler: daemon thread checks time → fires matching jobs
|
||||
2. Queue: cron_queue decouples scheduler from agent loop
|
||||
3. Queue processor: wakes the agent when queued work exists and it is idle
|
||||
4. Consumer: agent_loop consumes queued jobs and injects them into messages
|
||||
"""
|
||||
|
||||
import os, subprocess, json, time, random, threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
try:
|
||||
import readline
|
||||
readline.parse_and_bind('set bind-tty-special-chars 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()
|
||||
MEMORY_DIR = WORKDIR / ".memory"
|
||||
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
|
||||
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
|
||||
MODEL = os.environ["MODEL_ID"]
|
||||
|
||||
# ── Task System (from s12, synced) ──
|
||||
|
||||
TASKS_DIR = WORKDIR / ".tasks"
|
||||
TASKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
id: str
|
||||
subject: str
|
||||
description: str
|
||||
status: str # pending | in_progress | completed
|
||||
owner: str | None
|
||||
blockedBy: list[str]
|
||||
|
||||
|
||||
def _task_path(task_id: str) -> Path:
|
||||
return TASKS_DIR / f"{task_id}.json"
|
||||
|
||||
|
||||
def create_task(subject: str, description: str = "",
|
||||
blockedBy: list[str] | None = None) -> Task:
|
||||
task = Task(
|
||||
id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}",
|
||||
subject=subject, description=description,
|
||||
status="pending", owner=None,
|
||||
blockedBy=blockedBy or [],
|
||||
)
|
||||
save_task(task)
|
||||
return task
|
||||
|
||||
|
||||
def save_task(task: Task):
|
||||
_task_path(task.id).write_text(json.dumps(asdict(task), indent=2))
|
||||
|
||||
|
||||
def load_task(task_id: str) -> Task:
|
||||
return Task(**json.loads(_task_path(task_id).read_text()))
|
||||
|
||||
|
||||
def list_tasks() -> list[Task]:
|
||||
return [Task(**json.loads(p.read_text()))
|
||||
for p in sorted(TASKS_DIR.glob("task_*.json"))]
|
||||
|
||||
|
||||
def get_task(task_id: str) -> str:
|
||||
"""Return full task details as JSON."""
|
||||
task = load_task(task_id)
|
||||
return json.dumps(asdict(task), indent=2)
|
||||
|
||||
|
||||
def can_start(task_id: str) -> bool:
|
||||
"""Check if all blockedBy dependencies are completed.
|
||||
Missing dependencies are treated as blocked."""
|
||||
task = load_task(task_id)
|
||||
for dep_id in task.blockedBy:
|
||||
if not _task_path(dep_id).exists():
|
||||
return False
|
||||
if load_task(dep_id).status != "completed":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def claim_task(task_id: str, owner: str = "agent") -> str:
|
||||
task = load_task(task_id)
|
||||
if task.status != "pending":
|
||||
return f"Task {task_id} is {task.status}, cannot claim"
|
||||
if not can_start(task_id):
|
||||
deps = [d for d in task.blockedBy
|
||||
if not _task_path(d).exists() or load_task(d).status != "completed"]
|
||||
return f"Blocked by: {deps}"
|
||||
task.owner = owner
|
||||
task.status = "in_progress"
|
||||
save_task(task)
|
||||
print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m")
|
||||
return f"Claimed {task.id} ({task.subject})"
|
||||
|
||||
|
||||
def complete_task(task_id: str) -> str:
|
||||
task = load_task(task_id)
|
||||
if task.status != "in_progress":
|
||||
return f"Task {task_id} is {task.status}, cannot complete"
|
||||
task.status = "completed"
|
||||
save_task(task)
|
||||
unblocked = [t.subject for t in list_tasks()
|
||||
if t.status == "pending" and t.blockedBy and can_start(t.id)]
|
||||
print(f" \033[32m[complete] {task.subject} ✓\033[0m")
|
||||
msg = f"Completed {task.id} ({task.subject})"
|
||||
if unblocked:
|
||||
msg += f"\nUnblocked: {', '.join(unblocked)}"
|
||||
print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m")
|
||||
return msg
|
||||
|
||||
|
||||
# ── Prompt Assembly (from s10, synced) ──
|
||||
|
||||
PROMPT_SECTIONS = {
|
||||
"identity": "You are a coding agent. Act, don't explain.",
|
||||
"tools": "Available tools: bash, read_file, write_file, "
|
||||
"create_task, list_tasks, get_task, claim_task, complete_task, "
|
||||
"schedule_cron, list_crons, cancel_cron.",
|
||||
"workspace": f"Working directory: {WORKDIR}",
|
||||
"memory": "Relevant memories are injected below when available.",
|
||||
}
|
||||
|
||||
|
||||
def assemble_system_prompt(context: dict) -> str:
|
||||
sections = [PROMPT_SECTIONS["identity"],
|
||||
PROMPT_SECTIONS["tools"],
|
||||
PROMPT_SECTIONS["workspace"]]
|
||||
memories = context.get("memories", "")
|
||||
if memories:
|
||||
sections.append(f"Relevant memories:\n{memories}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
_last_context_key, _last_prompt = None, None
|
||||
|
||||
|
||||
def get_system_prompt(context: dict) -> str:
|
||||
global _last_context_key, _last_prompt
|
||||
key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
|
||||
if key == _last_context_key and _last_prompt:
|
||||
return _last_prompt
|
||||
_last_context_key = key
|
||||
_last_prompt = assemble_system_prompt(context)
|
||||
return _last_prompt
|
||||
|
||||
|
||||
# ── Tools ──
|
||||
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
|
||||
|
||||
def run_bash(command: str, run_in_background: bool = False) -> str:
|
||||
# run_in_background is handled by agent_loop dispatch, not here
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int | None = None) -> str:
|
||||
try:
|
||||
lines = safe_path(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 e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# Task tools
|
||||
|
||||
def run_create_task(subject: str, description: str = "",
|
||||
blockedBy: list[str] | None = None) -> str:
|
||||
task = create_task(subject, description, blockedBy)
|
||||
deps = f" (blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
|
||||
print(f" \033[34m[create] {task.subject}{deps}\033[0m")
|
||||
return f"Created {task.id}: {task.subject}{deps}"
|
||||
|
||||
|
||||
def run_list_tasks() -> str:
|
||||
tasks = list_tasks()
|
||||
if not tasks:
|
||||
return "No tasks. Use create_task to add some."
|
||||
lines = []
|
||||
for t in tasks:
|
||||
icon = {"pending": "○", "in_progress": "●",
|
||||
"completed": "✓"}.get(t.status, "?")
|
||||
deps = f" (blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
|
||||
owner = f" [{t.owner}]" if t.owner else ""
|
||||
lines.append(f" {icon} {t.id}: {t.subject} "
|
||||
f"[{t.status}]{owner}{deps}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_get_task(task_id: str) -> str:
|
||||
try:
|
||||
return get_task(task_id)
|
||||
except FileNotFoundError:
|
||||
return f"Error: Task {task_id} not found"
|
||||
|
||||
|
||||
def run_claim_task(task_id: str) -> str:
|
||||
return claim_task(task_id, owner="agent")
|
||||
|
||||
|
||||
def run_complete_task(task_id: str) -> str:
|
||||
return complete_task(task_id)
|
||||
|
||||
|
||||
# ── Background Tasks (from s13, synced) ──
|
||||
|
||||
_bg_counter = 0
|
||||
background_tasks: dict[str, dict] = {}
|
||||
background_results: dict[str, str] = {}
|
||||
background_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
|
||||
"""Fallback heuristic: commands likely to take > 30s."""
|
||||
if tool_name != "bash":
|
||||
return False
|
||||
cmd = tool_input.get("command", "").lower()
|
||||
slow_keywords = ["install", "build", "test", "deploy", "compile",
|
||||
"docker build", "pip install", "npm install",
|
||||
"cargo build", "pytest", "make"]
|
||||
return any(kw in cmd for kw in slow_keywords)
|
||||
|
||||
|
||||
def should_run_background(tool_name: str, tool_input: dict) -> bool:
|
||||
"""Model explicit request takes priority; fallback to heuristic."""
|
||||
if tool_input.get("run_in_background"):
|
||||
return True
|
||||
return is_slow_operation(tool_name, tool_input)
|
||||
|
||||
|
||||
def execute_tool(block) -> str:
|
||||
"""Execute a tool call block, return output."""
|
||||
handler = {
|
||||
"bash": run_bash, "read_file": run_read, "write_file": run_write,
|
||||
"create_task": run_create_task, "list_tasks": run_list_tasks,
|
||||
"get_task": run_get_task, "claim_task": run_claim_task,
|
||||
"complete_task": run_complete_task,
|
||||
"schedule_cron": run_schedule_cron, "list_crons": run_list_crons,
|
||||
"cancel_cron": run_cancel_cron,
|
||||
}.get(block.name)
|
||||
if handler:
|
||||
return handler(**block.input)
|
||||
return f"Unknown tool: {block.name}"
|
||||
|
||||
|
||||
def start_background_task(block) -> str:
|
||||
"""Run tool in a daemon thread. Returns background task ID."""
|
||||
global _bg_counter
|
||||
_bg_counter += 1
|
||||
bg_id = f"bg_{_bg_counter:04d}"
|
||||
cmd = block.input.get("command", block.name)
|
||||
|
||||
def worker():
|
||||
result = execute_tool(block)
|
||||
with background_lock:
|
||||
background_tasks[bg_id]["status"] = "completed"
|
||||
background_results[bg_id] = result
|
||||
|
||||
with background_lock:
|
||||
background_tasks[bg_id] = {
|
||||
"tool_use_id": block.id,
|
||||
"command": cmd,
|
||||
"status": "running",
|
||||
}
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m")
|
||||
return bg_id
|
||||
|
||||
|
||||
def collect_background_results() -> list[str]:
|
||||
"""Collect completed background results as task_notification messages."""
|
||||
with background_lock:
|
||||
ready_ids = [bid for bid, task in background_tasks.items()
|
||||
if task["status"] == "completed"]
|
||||
notifications = []
|
||||
for bg_id in ready_ids:
|
||||
with background_lock:
|
||||
task = background_tasks.pop(bg_id)
|
||||
output = background_results.pop(bg_id, "")
|
||||
summary = output[:200] if len(output) > 200 else output
|
||||
notifications.append(
|
||||
f"<task_notification>\n"
|
||||
f" <task_id>{bg_id}</task_id>\n"
|
||||
f" <status>completed</status>\n"
|
||||
f" <command>{task['command']}</command>\n"
|
||||
f" <summary>{summary}</summary>\n"
|
||||
f"</task_notification>")
|
||||
print(f" \033[32m[background done] {bg_id}: "
|
||||
f"{task['command'][:40]} ({len(output)} chars)\033[0m")
|
||||
return notifications
|
||||
|
||||
|
||||
# ── Cron Scheduler (s14 new) ──
|
||||
|
||||
DURABLE_PATH = WORKDIR / ".scheduled_tasks.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CronJob:
|
||||
id: str
|
||||
cron: str # "0 9 * * *"
|
||||
prompt: str # message to inject when fired
|
||||
recurring: bool # True = recurring, False = one-shot
|
||||
durable: bool # True = persist to disk
|
||||
|
||||
|
||||
scheduled_jobs: dict[str, CronJob] = {}
|
||||
cron_queue: list[CronJob] = []
|
||||
cron_lock = threading.Lock()
|
||||
agent_lock = threading.Lock()
|
||||
_last_fired: dict[str, str] = {} # job_id → "YYYY-MM-DD HH:MM"
|
||||
|
||||
|
||||
def _cron_field_matches(field: str, value: int) -> bool:
|
||||
"""Match a single cron field against a value."""
|
||||
if field == "*":
|
||||
return True
|
||||
if field.startswith("*/"):
|
||||
step = int(field[2:])
|
||||
return step > 0 and value % step == 0
|
||||
if "," in field:
|
||||
return any(_cron_field_matches(f.strip(), value)
|
||||
for f in field.split(","))
|
||||
if "-" in field:
|
||||
lo, hi = field.split("-", 1)
|
||||
return int(lo) <= value <= int(hi)
|
||||
return value == int(field)
|
||||
|
||||
|
||||
def cron_matches(cron_expr: str, dt: datetime) -> bool:
|
||||
"""Check if a 5-field cron expression matches the given datetime.
|
||||
Standard cron semantics: DOM and DOW use OR when both are constrained."""
|
||||
fields = cron_expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return False
|
||||
minute, hour, dom, month, dow = fields
|
||||
dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0
|
||||
|
||||
m = _cron_field_matches(minute, dt.minute)
|
||||
h = _cron_field_matches(hour, dt.hour)
|
||||
dom_ok = _cron_field_matches(dom, dt.day)
|
||||
month_ok = _cron_field_matches(month, dt.month)
|
||||
dow_ok = _cron_field_matches(dow, dow_val)
|
||||
|
||||
# Minute, hour, month must all match
|
||||
if not (m and h and month_ok):
|
||||
return False
|
||||
# DOM and DOW: if both constrained, either matching is enough (OR)
|
||||
dom_unconstrained = dom == "*"
|
||||
dow_unconstrained = dow == "*"
|
||||
if dom_unconstrained and dow_unconstrained:
|
||||
return True
|
||||
if dom_unconstrained:
|
||||
return dow_ok
|
||||
if dow_unconstrained:
|
||||
return dom_ok
|
||||
return dom_ok or dow_ok
|
||||
|
||||
|
||||
def _validate_cron_field(field: str, lo: int, hi: int) -> str | None:
|
||||
"""Validate a single cron field value is within [lo, hi]."""
|
||||
if field == "*":
|
||||
return None
|
||||
if field.startswith("*/"):
|
||||
step_str = field[2:]
|
||||
if not step_str.isdigit():
|
||||
return f"Invalid step: {field}"
|
||||
step = int(step_str)
|
||||
if step <= 0:
|
||||
return f"Step must be > 0: {field}"
|
||||
return None
|
||||
if "," in field:
|
||||
for part in field.split(","):
|
||||
err = _validate_cron_field(part.strip(), lo, hi)
|
||||
if err: return err
|
||||
return None
|
||||
if "-" in field:
|
||||
parts = field.split("-", 1)
|
||||
if not parts[0].isdigit() or not parts[1].isdigit():
|
||||
return f"Invalid range: {field}"
|
||||
a, b = int(parts[0]), int(parts[1])
|
||||
if a < lo or a > hi or b < lo or b > hi:
|
||||
return f"Range {field} out of bounds [{lo}-{hi}]"
|
||||
if a > b:
|
||||
return f"Range start > end: {field}"
|
||||
return None
|
||||
if not field.isdigit():
|
||||
return f"Invalid field: {field}"
|
||||
val = int(field)
|
||||
if val < lo or val > hi:
|
||||
return f"Value {val} out of bounds [{lo}-{hi}]"
|
||||
return None
|
||||
|
||||
|
||||
def validate_cron(cron_expr: str) -> str | None:
|
||||
"""Validate a cron expression. Returns error message or None."""
|
||||
fields = cron_expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return f"Expected 5 fields, got {len(fields)}"
|
||||
bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]
|
||||
names = ["minute", "hour", "day-of-month", "month", "day-of-week"]
|
||||
for i, (field, (lo, hi), name) in enumerate(zip(fields, bounds, names)):
|
||||
err = _validate_cron_field(field, lo, hi)
|
||||
if err:
|
||||
return f"{name}: {err}"
|
||||
return None
|
||||
|
||||
|
||||
def save_durable_jobs():
|
||||
"""Persist durable jobs to .scheduled_tasks.json."""
|
||||
durable = [asdict(j) for j in scheduled_jobs.values() if j.durable]
|
||||
DURABLE_PATH.write_text(json.dumps(durable, indent=2))
|
||||
|
||||
|
||||
def load_durable_jobs():
|
||||
"""Load durable jobs from disk on startup."""
|
||||
if not DURABLE_PATH.exists():
|
||||
return
|
||||
try:
|
||||
jobs = json.loads(DURABLE_PATH.read_text())
|
||||
for j in jobs:
|
||||
job = CronJob(**j)
|
||||
err = validate_cron(job.cron)
|
||||
if err:
|
||||
print(f" \033[31m[cron] skipping invalid job {job.id}: {err}\033[0m")
|
||||
continue
|
||||
scheduled_jobs[job.id] = job
|
||||
valid = [j for j in jobs if j["id"] in scheduled_jobs]
|
||||
if valid:
|
||||
print(f" \033[35m[cron] loaded {len(valid)} durable job(s)\033[0m")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def schedule_job(cron: str, prompt: str, recurring: bool = True,
|
||||
durable: bool = True) -> CronJob | str:
|
||||
"""Register a new cron job. Returns CronJob or error string."""
|
||||
err = validate_cron(cron)
|
||||
if err:
|
||||
return err
|
||||
job = CronJob(
|
||||
id=f"cron_{random.randint(0, 999999):06d}",
|
||||
cron=cron, prompt=prompt,
|
||||
recurring=recurring, durable=durable,
|
||||
)
|
||||
with cron_lock:
|
||||
scheduled_jobs[job.id] = job
|
||||
if durable:
|
||||
save_durable_jobs()
|
||||
print(f" \033[35m[cron register] {job.id} '{cron}' → {prompt[:40]}\033[0m")
|
||||
return job
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> str:
|
||||
"""Cancel a cron job."""
|
||||
with cron_lock:
|
||||
job = scheduled_jobs.pop(job_id, None)
|
||||
if not job:
|
||||
return f"Job {job_id} not found"
|
||||
if job.durable:
|
||||
save_durable_jobs()
|
||||
print(f" \033[31m[cron cancel] {job_id}\033[0m")
|
||||
return f"Cancelled {job_id}"
|
||||
|
||||
|
||||
def cron_scheduler_loop():
|
||||
"""Independent daemon thread: poll every 1s, fire matching jobs.
|
||||
Individual job errors are caught to prevent one bad job from
|
||||
killing the entire scheduler thread."""
|
||||
while True:
|
||||
time.sleep(1)
|
||||
now = datetime.now()
|
||||
# Date-aware marker prevents daily jobs from skipping on day 2+
|
||||
minute_marker = now.strftime("%Y-%m-%d %H:%M")
|
||||
with cron_lock:
|
||||
for job in list(scheduled_jobs.values()):
|
||||
try:
|
||||
if cron_matches(job.cron, now):
|
||||
if _last_fired.get(job.id) != minute_marker:
|
||||
cron_queue.append(job)
|
||||
_last_fired[job.id] = minute_marker
|
||||
print(f" \033[35m[cron fire] {job.id} → "
|
||||
f"{job.prompt[:40]}\033[0m")
|
||||
if not job.recurring:
|
||||
scheduled_jobs.pop(job.id, None)
|
||||
if job.durable:
|
||||
save_durable_jobs()
|
||||
except Exception as e:
|
||||
print(f" \033[31m[cron error] {job.id}: {e}\033[0m")
|
||||
|
||||
|
||||
def consume_cron_queue() -> list[CronJob]:
|
||||
"""Consume fired jobs from cron_queue (called by agent_loop)."""
|
||||
with cron_lock:
|
||||
fired = list(cron_queue)
|
||||
cron_queue.clear()
|
||||
return fired
|
||||
|
||||
|
||||
def has_cron_queue() -> bool:
|
||||
"""Return whether fired cron jobs are waiting to be delivered."""
|
||||
with cron_lock:
|
||||
return bool(cron_queue)
|
||||
|
||||
|
||||
# Load durable jobs on startup, then start scheduler thread
|
||||
load_durable_jobs()
|
||||
threading.Thread(target=cron_scheduler_loop, daemon=True).start()
|
||||
print(" \033[35m[cron] scheduler thread started\033[0m")
|
||||
|
||||
|
||||
# ── Cron Tools ──
|
||||
|
||||
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. Use schedule_cron to add one."
|
||||
lines = []
|
||||
for j in jobs:
|
||||
tag = "recurring" if j.recurring else "one-shot"
|
||||
dur = "durable" if j.durable else "session"
|
||||
lines.append(f" {j.id}: '{j.cron}' → {j.prompt[:40]} "
|
||||
f"[{tag}, {dur}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_cancel_cron(job_id: str) -> str:
|
||||
return cancel_job(job_id)
|
||||
|
||||
|
||||
# ── Tool Definitions ──
|
||||
|
||||
TOOLS = [
|
||||
{"name": "bash", "description": "Run a shell command.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string"},
|
||||
"run_in_background": {"type": "boolean"}},
|
||||
"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": "create_task",
|
||||
"description": "Create a new task with optional blockedBy dependencies.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {
|
||||
"subject": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"blockedBy": {"type": "array",
|
||||
"items": {"type": "string"}}},
|
||||
"required": ["subject"]}},
|
||||
{"name": "list_tasks",
|
||||
"description": "List all tasks with status, owner, and dependencies.",
|
||||
"input_schema": {"type": "object", "properties": {},
|
||||
"required": []}},
|
||||
{"name": "get_task",
|
||||
"description": "Get full details of a specific task by ID.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
{"name": "claim_task",
|
||||
"description": "Claim a pending task. Sets owner, changes status to in_progress.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
{"name": "complete_task",
|
||||
"description": "Complete an in-progress task. Reports unblocked downstream tasks.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
{"name": "schedule_cron",
|
||||
"description": "Schedule a cron job. cron is 5-field: min hour dom month dow.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {
|
||||
"cron": {"type": "string",
|
||||
"description": "5-field cron expression"},
|
||||
"prompt": {"type": "string",
|
||||
"description": "Message to inject when fired"},
|
||||
"recurring": {"type": "boolean",
|
||||
"description": "True=recurring, False=one-shot"},
|
||||
"durable": {"type": "boolean",
|
||||
"description": "True=persist to disk"}},
|
||||
"required": ["cron", "prompt"]}},
|
||||
{"name": "list_crons",
|
||||
"description": "List all registered 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"]}},
|
||||
]
|
||||
|
||||
|
||||
# ── Context ──
|
||||
|
||||
def update_context(context: dict, messages: list) -> dict:
|
||||
"""Derive context from real state."""
|
||||
memories = ""
|
||||
if MEMORY_INDEX.exists():
|
||||
content = MEMORY_INDEX.read_text().strip()
|
||||
if content:
|
||||
memories = content
|
||||
return {
|
||||
"enabled_tools": [t["name"] for t in TOOLS],
|
||||
"workspace": str(WORKDIR),
|
||||
"memories": memories,
|
||||
}
|
||||
|
||||
|
||||
# ── Agent Loop (simplified, focused on cron scheduler) ──
|
||||
# Teaching code keeps a basic agent loop. S11's full error recovery is omitted.
|
||||
# cron_scheduler_loop produces work; queue_processor_loop wakes this loop when
|
||||
# queued work exists and no other agent turn is running.
|
||||
|
||||
def agent_loop(messages: list, context: dict) -> dict:
|
||||
system = get_system_prompt(context)
|
||||
while True:
|
||||
# Layer 4: consume fired cron jobs → inject as messages
|
||||
fired = consume_cron_queue()
|
||||
for job in fired:
|
||||
messages.append({"role": "user",
|
||||
"content": f"[Scheduled] {job.prompt}"})
|
||||
print(f" \033[35m[inject cron] {job.prompt[:50]}\033[0m")
|
||||
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=system, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000)
|
||||
except Exception as e:
|
||||
messages.append({"role": "assistant", "content": [
|
||||
{"type": "text",
|
||||
"text": f"[Error] {type(e).__name__}: {e}"}]})
|
||||
return context
|
||||
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return context
|
||||
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type != "tool_use":
|
||||
continue
|
||||
print(f"\033[36m> {block.name}\033[0m")
|
||||
|
||||
if should_run_background(block.name, block.input):
|
||||
bg_id = start_background_task(block)
|
||||
results.append({"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": f"[Background task {bg_id} started] "
|
||||
f"Result will be available when complete."})
|
||||
else:
|
||||
output = execute_tool(block)
|
||||
print(str(output)[:300])
|
||||
results.append({"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output})
|
||||
|
||||
# Merge background notifications + tool results into one user message
|
||||
user_content = []
|
||||
bg_notifications = collect_background_results()
|
||||
if bg_notifications:
|
||||
for notif in bg_notifications:
|
||||
user_content.append({"type": "text", "text": notif})
|
||||
user_content.extend(results)
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
context = update_context(context, messages)
|
||||
system = get_system_prompt(context)
|
||||
|
||||
|
||||
session_history: list = []
|
||||
session_context = update_context({}, [])
|
||||
|
||||
|
||||
def print_latest_assistant_text(messages: list):
|
||||
"""Print text blocks from the latest assistant message."""
|
||||
if not messages:
|
||||
return
|
||||
msg = messages[-1]
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
return
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
print(content)
|
||||
return
|
||||
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", ""))
|
||||
|
||||
|
||||
def run_agent_turn_locked(user_query: str | None = None):
|
||||
"""Run one agent turn. Caller must hold agent_lock."""
|
||||
global session_context
|
||||
if user_query is not None:
|
||||
session_history.append({"role": "user", "content": user_query})
|
||||
session_context = agent_loop(session_history, session_context)
|
||||
session_context = update_context(session_context, session_history)
|
||||
print_latest_assistant_text(session_history)
|
||||
print()
|
||||
|
||||
|
||||
def queue_processor_loop():
|
||||
"""Auto-deliver fired cron jobs when the agent is idle."""
|
||||
global session_context
|
||||
while True:
|
||||
time.sleep(0.2)
|
||||
if not has_cron_queue():
|
||||
continue
|
||||
if not agent_lock.acquire(blocking=False):
|
||||
continue
|
||||
try:
|
||||
if not has_cron_queue():
|
||||
continue
|
||||
print("\n \033[35m[queue processor] delivering scheduled work\033[0m")
|
||||
run_agent_turn_locked()
|
||||
finally:
|
||||
agent_lock.release()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("s14: cron scheduler")
|
||||
print("Enter a question, press Enter to send. Type q to quit.\n")
|
||||
threading.Thread(target=queue_processor_loop, daemon=True).start()
|
||||
print(" \033[35m[queue processor] started\033[0m")
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms14 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
with agent_lock:
|
||||
run_agent_turn_locked(query)
|
||||
125
s14_cron_scheduler/images/cron-scheduler-overview.en.svg
Normal file
125
s14_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">s10-s13 retained</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">s14 new</text>
|
||||
|
||||
<!-- ===== Row 1: Full Agent Loop Chain ===== -->
|
||||
|
||||
<!-- consume_cron_queue (s14 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">★ s14 injection</text>
|
||||
|
||||
<line x1="125" y1="124" x2="143" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- messages (s10) -->
|
||||
<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)"/>
|
||||
|
||||
<!-- prompt + cache (s10) -->
|
||||
<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">prompt + cache</text>
|
||||
<text x="304" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">assemble_system_prompt</text>
|
||||
<text x="304" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">(s10)</text>
|
||||
|
||||
<line x1="362" y1="124" x2="380" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- LLM call (s11 retry) -->
|
||||
<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 (try/except)</text>
|
||||
<text x="433" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">with_retry</text>
|
||||
<text x="433" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">(s11)</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">fast → sync (bash, read, write)</text>
|
||||
<text x="519" y="134" fill="#ea580c" font-size="8">slow → background thread (s13)</text>
|
||||
<text x="519" y="146" fill="#4f46e5" font-size="8" font-weight="600">cron → schedule_cron, list, cancel (s14)</text>
|
||||
<text x="519" y="156" fill="#2563eb" font-size="8">task → create, list, claim, complete (s12)</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">time.sleep(1) → cron_matches(job.cron, now)</text>
|
||||
<text x="48" y="258" fill="#4f46e5" font-size="9">match → cron_queue.append(job)</text>
|
||||
<text x="48" y="272" fill="#6b7280" font-size="8">minute_marker prevents double-fire per minute</text>
|
||||
<text x="48" y="286" fill="#6b7280" font-size="8">one-shot jobs auto-delete after firing</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 · loop reads</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">next agent_loop consumes</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 dataclass:</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
s14_cron_scheduler/images/cron-scheduler-overview.ja.svg
Normal file
125
s14_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">s10-s13 維持</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">s14 新規</text>
|
||||
|
||||
<!-- ===== Row 1: Full Agent Loop Chain ===== -->
|
||||
|
||||
<!-- consume_cron_queue (s14 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">★ s14 注入点</text>
|
||||
|
||||
<line x1="125" y1="124" x2="143" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- messages (s10) -->
|
||||
<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)"/>
|
||||
|
||||
<!-- prompt + cache (s10) -->
|
||||
<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">prompt + cache</text>
|
||||
<text x="304" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">assemble_system_prompt</text>
|
||||
<text x="304" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">(s10)</text>
|
||||
|
||||
<line x1="362" y1="124" x2="380" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- LLM call (s11 retry) -->
|
||||
<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 (try/except)</text>
|
||||
<text x="433" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">with_retry</text>
|
||||
<text x="433" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">(s11)</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">fast → sync (bash, read, write)</text>
|
||||
<text x="519" y="134" fill="#ea580c" font-size="8">slow → background thread (s13)</text>
|
||||
<text x="519" y="146" fill="#4f46e5" font-size="8" font-weight="600">cron → schedule_cron, list, cancel (s14)</text>
|
||||
<text x="519" y="156" fill="#2563eb" font-size="8">task → create, list, claim, complete (s12)</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">time.sleep(1) → cron_matches(job.cron, now)</text>
|
||||
<text x="48" y="258" fill="#4f46e5" font-size="9">マッチ → cron_queue.append(job)</text>
|
||||
<text x="48" y="272" fill="#6b7280" font-size="8">minute_marker で同一分の重複発火を防止</text>
|
||||
<text x="48" y="286" fill="#6b7280" font-size="8">一度きりのタスクは発火後自動削除</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 · スケジューラ書込 · loop 読込</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_loop が消費</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 dataclass:</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
s14_cron_scheduler/images/cron-scheduler-overview.svg
Normal file
125
s14_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">s10-s13 保留</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">s14 新增</text>
|
||||
|
||||
<!-- ===== Row 1: Full Agent Loop Chain ===== -->
|
||||
|
||||
<!-- consume_cron_queue (s14 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">★ s14 注入点</text>
|
||||
|
||||
<line x1="125" y1="124" x2="143" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- messages (s10) -->
|
||||
<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)"/>
|
||||
|
||||
<!-- prompt + cache (s10) -->
|
||||
<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">prompt + cache</text>
|
||||
<text x="304" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">assemble_system_prompt</text>
|
||||
<text x="304" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">(s10)</text>
|
||||
|
||||
<line x1="362" y1="124" x2="380" y2="124" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- LLM call (s11 retry) -->
|
||||
<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 (try/except)</text>
|
||||
<text x="433" y="130" fill="#94a3b8" font-size="8" text-anchor="middle">with_retry</text>
|
||||
<text x="433" y="142" fill="#94a3b8" font-size="8" text-anchor="middle">(s11)</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">fast → sync (bash, read, write)</text>
|
||||
<text x="519" y="134" fill="#ea580c" font-size="8">slow → background thread (s13)</text>
|
||||
<text x="519" y="146" fill="#4f46e5" font-size="8" font-weight="600">cron → schedule_cron, list, cancel (s14)</text>
|
||||
<text x="519" y="156" fill="#2563eb" font-size="8">task → create, list, claim, complete (s12)</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">time.sleep(1) → cron_matches(job.cron, now)</text>
|
||||
<text x="48" y="258" fill="#4f46e5" font-size="9">匹配 → cron_queue.append(job)</text>
|
||||
<text x="48" y="272" fill="#6b7280" font-size="8">minute_marker 防同分钟重复触发</text>
|
||||
<text x="48" y="286" fill="#6b7280" font-size="8">一次性任务触发后自动删除</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 保护 · 调度线程写 · agent_loop 读</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_loop 消费</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 dataclass:</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