Skip to content

Commit 81af00c

Browse files
oratisclaude
andcommitted
feat(cron): a scheduled job can fire from a calendar or a file change
Until now the only trigger was a clock. "Run the release checklist when the release meeting starts" and "regenerate the client when the schema changes" are both scheduling, and neither is a time. `TriggerSource` is cron | ics | file. `schedule` still means cron and still works, so no existing job needs migrating — a store nobody has to rewrite cannot be rewritten wrongly. Every source is polled by the `scheduler run` that already exists. No daemon, no watcher process, no way for a trigger to fire while nothing is listening. The cost is minute granularity everywhere, which is the granularity anyone can actually observe: a second-resolution trigger would fire or not depending on how promptly launchd got around to it. ICS is standard calendar text and nothing else — no vendor SDK, no OAuth to a calendar service, no remote account. Every calendar worth integrating with exports `.ics`, and a file on disk is a boundary you can inspect, which a client library is not. The reader handles DTSTART (UTC, floating, all-day), folded SUMMARY lines, and RRULE FREQ=DAILY/WEEKLY with INTERVAL/BYDAY/UNTIL/COUNT. Everything else is *reported*, following the file-contract parser's rule: a silently ignored RRULE is a job that never fires, and that failure is indistinguishable from "nothing was scheduled". TZID is reported rather than honoured — there is no timezone database here, and applying the host's zone would make one file fire at different moments on different machines. All-day entries never fire. They name a day, not a moment, and picking one would be this module inventing a schedule the user did not write. A file trigger's first evaluation records a baseline instead of firing, or every one of them would go off the moment it was created on files nobody had touched. The scheduler stamps that baseline itself — without it the job could never acquire one and would stay silent forever while looking configured. A trigger decides when, never what may happen: scheduled runs still go through the unattended clamp. A calendar you do not control deciding when DeepCode runs is already worth thinking about; it must not also decide what it may do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d901879 commit 81af00c

11 files changed

Lines changed: 1104 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
existing `threadManagement` capability, with the local writer kept as the
3030
fallback for a sidecar too old to know the method.
3131

32+
- **Trigger sources for scheduled jobs** — a job can now fire from a calendar
33+
file or a file change, not only a clock. `{ "kind": "ics", "path": "team.ics",
34+
"match": "release" }` fires when a matching event starts;
35+
`{ "kind": "file", "paths": ["schema.json"] }` fires when a watched path
36+
changes. `schedule` still means cron and existing jobs need no migration.
37+
Everything is **polled** by the existing `scheduler run`, so there is no
38+
daemon and no way for a trigger to fire while nothing is listening. See
39+
[`docs/triggers.md`](docs/triggers.md).
40+
41+
Standard iCalendar text is the only calendar input — no vendor SDK, no OAuth
42+
to a calendar service. The reader handles `DTSTART`, folded `SUMMARY` lines and
43+
`RRULE FREQ=DAILY`/`WEEKLY` with `INTERVAL`/`BYDAY`/`UNTIL`/`COUNT`, and
44+
**reports** anything it cannot express rather than dropping it: a silently
45+
ignored `RRULE` is a job that never fires, and that failure is
46+
indistinguishable from "nothing was scheduled". All-day entries never fire —
47+
they name a day, not a moment. A trigger decides when, never what may happen:
48+
every scheduled run still goes through the unattended clamp.
49+
3250
### 🔒 Security
3351

3452
- **A sub-agent did not inherit the file contract.** The `Task` delegation

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Mac 客户端(v1 即将发布):拖入 Applications → 首启完成 onboar
5656
| [docs/file-contract.md](docs/file-contract.md) | 路径维度权限契约(`deepcode contract`|
5757
| [docs/change-ledger.md](docs/change-ledger.md) | 变更账本与回滚(`deepcode ledger`|
5858
| [docs/combo.md](docs/combo.md) | `/combo` —— 把做完的 thread 蒸馏成 skill |
59+
| [docs/triggers.md](docs/triggers.md) | 定时任务触发源:cron / ICS 日历 / 文件变更 |
5960
| [docs/DEMO_SCRIPT.md](docs/DEMO_SCRIPT.md) | 5 分钟 launch 视频逐段录制脚本 |
6061

6162
### 设计文档

apps/cli/src/scheduler.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
// so it survives `nvm`/path quirks) and best-effort `launchctl load`s it.
1010

1111
import {
12-
dueJobs,
12+
dueJobsWithTriggers,
13+
resolveTrigger,
1314
installPlist,
1415
launchdPlistPath,
1516
listCronJobs,
@@ -50,17 +51,38 @@ export async function runSchedulerRun(deps: SchedulerDeps = {}): Promise<{ ran:
5051
const home = deps.home ?? homedir();
5152
const out = deps.output ?? process.stdout;
5253
const store = await loadCronStore(home);
53-
const due = dueJobs(store.jobs, now);
54+
const due = await dueJobsWithTriggers(store.jobs, now);
5455
const ran: string[] = [];
55-
if (due.length === 0) return { ran };
56+
57+
// A file trigger compares mtimes against `lastRunAt`, so a job that has never
58+
// run has nothing to compare against and deliberately does not fire. Stamp
59+
// the baseline here — otherwise it has no way to ever acquire one, and the
60+
// job stays silent forever while looking configured.
61+
let stamped = false;
62+
for (const job of store.jobs) {
63+
if (!job.enabled || job.lastRunAt) continue;
64+
if (resolveTrigger(job).kind !== 'file') continue;
65+
job.lastRunAt = now.toISOString();
66+
stamped = true;
67+
out.write(`[scheduler] ${job.id}: watching from ${job.lastRunAt}\n`);
68+
}
69+
70+
if (due.length === 0) {
71+
if (stamped) await saveCronStore(store, home);
72+
return { ran };
73+
}
5674

5775
out.write(`[scheduler] ${now.toISOString()}${due.length} job(s) due\n`);
58-
for (const job of due) {
76+
for (const { job, verdict } of due) {
77+
// Say what the calendar could not express, next to the job it belongs to.
78+
for (const note of verdict.diagnostics ?? []) {
79+
out.write(`[scheduler] ${job.id}: ${note}\n`);
80+
}
5981
try {
6082
await (deps.runJob ?? ((j) => defaultRunJob(j, home)))(job);
6183
job.lastRunAt = now.toISOString();
6284
ran.push(job.id);
63-
out.write(`[scheduler] ran ${job.id}\n`);
85+
out.write(`[scheduler] ran ${job.id}${verdict.reason ? ` — ${verdict.reason}` : ''}\n`);
6486
} catch (err) {
6587
out.write(`[scheduler] job ${job.id} failed: ${(err as Error).message}\n`);
6688
}

docs/FLOATBOAT_ADOPTION_PLAN.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -500,15 +500,15 @@ File Contract 接入(PR 2)是唯一需要谨慎评审的一步。
500500

501501
写下与计划不符的地方,比宣称"照计划完成"有用。
502502

503-
|| 计划 | 实际 |
504-
| ---------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
505-
| PR 0 的问题陈述 | 称无人值守可能"静默放行" | **计划写错了**`ask` 路径本来就 fail-closed(`runHeadless``approval: async () => false`)。真正缺的是"停下来"的能力和可见性,PR #237 按事实重写了范围 |
506-
| 权限档位的钳制 | 放在 PR 0 | 推迟到 PR 7。没有 opt-in 的钳制只是破坏,等 `TriggerProfile` 落地才安全 |
507-
| 契约 `deny``bypassPermissions` | 计划未明确 | 实施时决定 **`deny` 不可被 `bypassPermissions` 豁免**。它是关于路径的常驻声明,不是逐次提示;否则契约最强的一句话也最容易被关掉 |
508-
| 四客户端一致性测试 | 计划要求 4 个客户端逐字段相等 | 实际只有 CLI 与 app-server **独立解析**策略;VS Code / LSP 是协议瘦客户端,逐字节消费 server 的答复,构造上即相等。测试断言前两者,并在文档里说明后两者的理由 —— 不宣称验证了 4 条独立路径 |
509-
| Grep/Glob 结果过滤 | 列为 PR 1 的已知缺口 | 仍未做。契约对 Grep/Glob 只裁决搜索根,命中结果里混入 deny 路径的内容需要工具输出层二次过滤 |
510-
| 制品 `provenance` | 列在 PR 8(P2) | 未做 |
511-
| 触发源抽象(ICS / watch) | 列在 PR 8(P2) | 未做。`cron` 仍只有时间源 |
503+
|| 计划 | 实际 |
504+
| ---------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
505+
| PR 0 的问题陈述 | 称无人值守可能"静默放行" | **计划写错了**`ask` 路径本来就 fail-closed(`runHeadless``approval: async () => false`)。真正缺的是"停下来"的能力和可见性,PR #237 按事实重写了范围 |
506+
| 权限档位的钳制 | 放在 PR 0 | 推迟到 PR 7。没有 opt-in 的钳制只是破坏,等 `TriggerProfile` 落地才安全 |
507+
| 契约 `deny``bypassPermissions` | 计划未明确 | 实施时决定 **`deny` 不可被 `bypassPermissions` 豁免**。它是关于路径的常驻声明,不是逐次提示;否则契约最强的一句话也最容易被关掉 |
508+
| 四客户端一致性测试 | 计划要求 4 个客户端逐字段相等 | 实际只有 CLI 与 app-server **独立解析**策略;VS Code / LSP 是协议瘦客户端,逐字节消费 server 的答复,构造上即相等。测试断言前两者,并在文档里说明后两者的理由 —— 不宣称验证了 4 条独立路径 |
509+
| Grep/Glob 结果过滤 | 列为 PR 1 的已知缺口 | 仍未做。契约对 Grep/Glob 只裁决搜索根,命中结果里混入 deny 路径的内容需要工具输出层二次过滤 |
510+
| 制品 `provenance` | 列在 PR 8(P2) | 未做 |
511+
| 触发源抽象(ICS / watch) | 列在 PR 8(P2) | 已做。`TriggerSource` = cron / ics / file,全部走既有 `scheduler run` 轮询(无 daemon)。ICS 只接受标准文本、不内置任何日历厂商 SDK;解析器对 `RRULE` 未覆盖的部分**报告而不丢弃**——静默忽略的 RRULE 就是一个永不触发的任务,且和"根本没配"长得一模一样。见 [`triggers.md`](triggers.md) |
512512

513513
**未解假设的最终结论**(§7 提的四个):
514514

docs/triggers.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Trigger sources
2+
3+
A scheduled job runs when its **trigger** fires. Until 0.4 the only trigger was a
4+
clock, so "every weekday at 09:00" was expressible and "when the release meeting
5+
starts" or "when the schema changes" were not — both of which are scheduling, and
6+
neither of which is a time.
7+
8+
```jsonc
9+
// ~/.deepcode/cron.json
10+
{
11+
"jobs": [
12+
{ "id": "nightly", "schedule": "0 3 * * *", "prompt": "", "cwd": "/repo" },
13+
14+
{
15+
"id": "release-prep",
16+
"trigger": { "kind": "ics", "path": "team.ics", "match": "release" },
17+
"prompt": "Run the release checklist",
18+
"cwd": "/repo",
19+
},
20+
21+
{
22+
"id": "regen",
23+
"trigger": { "kind": "file", "paths": ["schema.json"] },
24+
"prompt": "Regenerate the client from schema.json",
25+
"cwd": "/repo",
26+
},
27+
],
28+
}
29+
```
30+
31+
`schedule` still works and still means cron. A job written before triggers
32+
existed needs no migration — a store nobody has to rewrite cannot be rewritten
33+
wrongly.
34+
35+
## Everything is polled
36+
37+
`deepcode scheduler run` already wakes on a timer and asks what is due. Every
38+
trigger answers that same question, so there is no daemon, no watcher process,
39+
and no way for a trigger to fire while nothing is listening.
40+
41+
The cost is **minute granularity** for all of them, which is the granularity you
42+
can observe anyway: a trigger resolvable to the second would fire or not
43+
depending on how promptly launchd got around to it.
44+
45+
## `cron`
46+
47+
```jsonc
48+
{ "kind": "cron", "schedule": "0 9 * * 1-5" }
49+
```
50+
51+
Five fields: minute, hour, day-of-month, month, day-of-week.
52+
53+
## `ics` — a calendar file
54+
55+
```jsonc
56+
{ "kind": "ics", "path": "team.ics", "match": "release" }
57+
```
58+
59+
Fires in the minute a matching event **starts**. `match` is a case-insensitive
60+
substring of the event summary; without it, every event in the file fires the
61+
job.
62+
63+
**Standard iCalendar text is the only calendar input.** No vendor SDK, no OAuth
64+
to anybody's calendar service, no remote account polling. Every calendar worth
65+
integrating with exports `.ics`, and a file on disk is a boundary you can
66+
inspect — which a vendor client library is not. Point the path at an export, a
67+
synced file, or something your own tooling writes.
68+
69+
### What the reader supports
70+
71+
| Construct | Behaviour |
72+
| ------------------------------ | ---------------------------------------------------- |
73+
| `DTSTART` UTC (`…Z`) | Used as written |
74+
| `DTSTART` floating (no zone) | Read as UTC |
75+
| `DTSTART;VALUE=DATE` (all-day) | **Never fires** — see below |
76+
| `SUMMARY`, incl. folded lines | Used for `match` |
77+
| `RRULE FREQ=DAILY` / `=WEEKLY` | Expanded, with `INTERVAL`, `BYDAY`, `UNTIL`, `COUNT` |
78+
| Anything else | **Reported**, never silently dropped |
79+
80+
Unsupported constructs are logged next to the job that hit them. A silently
81+
ignored `RRULE` is a job that never fires, and that failure looks exactly like
82+
"nothing was scheduled" — which is the one thing it must not be mistaken for.
83+
84+
`TZID` is reported rather than honoured. DeepCode carries no timezone database,
85+
and quietly applying the host's zone would make the same file fire at different
86+
moments on different machines.
87+
88+
**All-day entries never fire.** They name a day, not a moment; choosing one
89+
(midnight? 09:00?) would be DeepCode inventing a schedule you did not write. Use
90+
a `cron` trigger if you want a time.
91+
92+
## `file` — something changed
93+
94+
```jsonc
95+
{ "kind": "file", "paths": ["schema.json", "proto/"] }
96+
```
97+
98+
Fires when any listed path's modification time is newer than the job's last run.
99+
Relative paths resolve against the job's `cwd`.
100+
101+
Two behaviours worth knowing:
102+
103+
- **The first evaluation never fires.** It records a baseline instead. Otherwise
104+
every file trigger would fire the moment it was created, on files nobody had
105+
touched since anyone cared.
106+
- **A missing path is not a change.** A watched file may simply not have been
107+
generated yet, and reporting that every minute would bury the messages that
108+
matter.
109+
110+
## Permissions are unchanged
111+
112+
A trigger decides _when_, never _what may happen_. Every scheduled run still goes
113+
through the [trigger profile](FLOATBOAT_ADOPTION_PLAN.md) clamp: a permissive
114+
`permissions.defaultMode` inherited from interactive settings is reduced to
115+
`default` unless the job sets `profile.mode` explicitly, and a call needing
116+
approval is refused because nobody is present to give it.
117+
118+
A calendar you do not control deciding _when_ DeepCode runs is already worth
119+
thinking about. It must never also decide what it may do.

0 commit comments

Comments
 (0)