@sleep2agi/commhub-server 0.9.0-preview.139 → 0.9.0-preview.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/agent-department-http.test.ts +148 -0
  3. package/src/chain-attempt-dedup.test.ts +447 -0
  4. package/src/create-node-tool-schema.test.ts +1 -0
  5. package/src/create-node-validate.test.ts +6 -0
  6. package/src/create-node-validate.ts +7 -2
  7. package/src/db.ts +190 -21
  8. package/src/departments.ts +16 -0
  9. package/src/env-transport-gate-http.test.ts +146 -0
  10. package/src/env-transport-gate.ts +74 -0
  11. package/src/env-transport-policy.ts +21 -0
  12. package/src/host-supervisor-identity-http.test.ts +20 -4
  13. package/src/hub-catalog-read-http.test.ts +187 -0
  14. package/src/node-secret-rest-http.test.ts +190 -0
  15. package/src/observer-avatar-http.test.ts +13 -2
  16. package/src/report-status-resume-id-handover.test.ts +34 -0
  17. package/src/report-status-secret-keys.test.ts +140 -0
  18. package/src/requirements-http.test.ts +1 -1
  19. package/src/requirements-people.ts +8 -4
  20. package/src/rest-projections.ts +2 -0
  21. package/src/schedule-agent.ts +1 -1
  22. package/src/scheduled-backoff-http.test.ts +193 -0
  23. package/src/scheduled-backoff.ts +64 -0
  24. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  25. package/src/scheduled-outcome-guidance.ts +9 -0
  26. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  27. package/src/scheduled-requirement-wake.ts +125 -0
  28. package/src/scheduled-tasks.ts +78 -23
  29. package/src/scheduled-work-outcome-http.test.ts +154 -0
  30. package/src/scheduled-work-outcome.ts +50 -0
  31. package/src/scoped-secrets.ts +15 -0
  32. package/src/server.ts +162 -3
  33. package/src/tools.ts +99 -16
@@ -0,0 +1,264 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createNetworkTokenForNode, register } from "./auth.js";
3
+ import { db, syncScheduledRunForTask } from "./db.js";
4
+ import { consumeRequirementWake, pollRequirementWakes } from "./scheduled-requirement-wake.js";
5
+ import { refreshScheduledBackoff } from "./scheduled-backoff.js";
6
+ import { getScheduleRow, dispatchScheduledOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
7
+
8
+ if (!process.env.COMMHUB_DB) throw new Error("test1279 requires an explicit disposable SQLite COMMHUB_DB");
9
+ let server: any, base = "", network = "", ownerToken = "", ownerId = "";
10
+ const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
11
+ async function rest(token: string, method: string, path: string, body?: unknown) {
12
+ const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
13
+ "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
14
+ return { status: r.status, body: await r.json() as any };
15
+ }
16
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
17
+ const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
18
+ "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
19
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
20
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
21
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
22
+ try { return JSON.parse(envelope.result.content[0].text); }
23
+ catch { return { ok: false, transportError: true }; }
24
+ }
25
+ const readRun = (taskId: string) => db.get<any>(
26
+ "SELECT run_id, schedule_id, status, work_outcome, work_outcome_node_id, schedule_revision FROM scheduled_task_runs WHERE task_id = ?1 AND network_id = ?2", taskId, network)!;
27
+ const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
28
+ const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
29
+ async function fire(enabled = true, interval = 120) {
30
+ const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
31
+ name: `test1279-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "fixture1278 work",
32
+ schedule: { type: "interval", every_seconds: interval, ...(enabled ? { requirement_wake: { tag: "ANet" } } : {}) }, timezone: "UTC", no_change_backoff: enabled });
33
+ expect(created.status).toBe(201);
34
+ const scheduleId = created.body.schedule.schedule_id;
35
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
36
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
37
+ await runDueScheduledTasks();
38
+ const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
39
+ expect(run.task_id).toBeTruthy();
40
+ return { taskId: run.task_id as string, scheduleId };
41
+ }
42
+ const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
43
+ mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
44
+ const cadence = (id: string) => db.get<any>("SELECT next_run_at, last_run_at, revision, schedule_json, target_node_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
45
+
46
+ beforeAll(async () => {
47
+ const owner = register(`work_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
48
+ network = owner.network_id!; ownerId = owner.user!.user_id;
49
+ for (const key of ["a", "b"]) {
50
+ const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
51
+ const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
52
+ nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
53
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
54
+ }
55
+ const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
56
+ base = `http://127.0.0.1:${server.port}`;
57
+ }, 30_000);
58
+ afterAll(() => server?.stop?.(true));
59
+
60
+
61
+ const state = (id: string) => db.get<any>("SELECT no_change_backoff, backoff_streak, backoff_effective_seconds, backoff_last_run_id, next_run_at, revision, schedule_json, status, requirement_wake_json, requirement_wake_cursor, requirement_wake_last_event_id FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
62
+ let occurrence = 0;
63
+ async function nextRun(id: string) {
64
+ // Isolated fixture accelerates the clock, without touching live schedules.
65
+ const date = new Date(Date.now() + (++occurrence) * 1000).toISOString();
66
+ const out = dispatchScheduledOccurrence(getScheduleRow(id)!, date, true);
67
+ expect(out.taskId).toBeTruthy(); return out.taskId!;
68
+ }
69
+ async function empties(id: string, n: number) {
70
+ for (let i = 0; i < n; i++) expect((await reply(await nextRun(id), { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
71
+ }
72
+ async function atThree() {
73
+ const x = await fire(); expect((await reply(x.taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
74
+ await empties(x.scheduleId, 2); return x;
75
+ }
76
+
77
+ async function card(body: Record<string, unknown> = {}, token = ownerToken) {
78
+ const c = await rest(token, "POST", "/api/requirements", { network_id: network, name: "domain1279", tags: ["ANet", "知识库"], ...body });
79
+ expect(c.status).toBe(201); return c.body.requirement;
80
+ }
81
+ async function column(id: string, status: string, token = ownerToken) {
82
+ const c = await rest(token, "PATCH", `/api/requirements/${id}`, { column: status }); expect(c.status).toBe(200);
83
+ }
84
+ function poll(id: string) { return consumeRequirementWake(id, network); }
85
+ describe("#1279 authoritative scoped requirement-event wake over HTTP/MCP", () => {
86
+ test("new matching work restores base with a durable cursor and fences old completion", async () => {
87
+ const { scheduleId } = await atThree(), before = state(scheduleId), old = await nextRun(scheduleId);
88
+ const c = await card(); expect(poll(scheduleId)).toBe(true);
89
+ const after = state(scheduleId); expect(after).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: null, revision: before.revision + 1, schedule_json: before.schedule_json });
90
+ expect(after.requirement_wake_cursor).toBeGreaterThan(before.requirement_wake_cursor);
91
+ expect(after.requirement_wake_last_event_id).toBeTruthy();
92
+ expect(Date.parse(after.next_run_at) - Date.now()).toBeLessThanOrEqual(120_000);
93
+ expect((await reply(old, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
94
+ expect(state(scheduleId).backoff_streak).toBe(0); expect(state(scheduleId).revision).toBe(after.revision);
95
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId)).toEqual(after);
96
+ const wire = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}`);
97
+ expect(wire.body.schedule.schedule.requirement_wake).toEqual({ tag: "ANet", project_id: null });
98
+ expect(JSON.stringify(wire.body.schedule)).not.toContain(ownerId);
99
+ expect(c.id).toBeTruthy();
100
+ });
101
+ test("a matching event is consumed within normal reply before the old empty signal can delay cadence", async () => {
102
+ const { scheduleId } = await atThree(), task = await nextRun(scheduleId), revision = state(scheduleId).revision;
103
+ await card(); expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
104
+ expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: null, revision: revision + 1 });
105
+ });
106
+ test("existing scheduler tick scans delayed plans even before their next due", async () => {
107
+ const { scheduleId } = await atThree(), before = state(scheduleId); await card();
108
+ runDueScheduledTasks(); expect(state(scheduleId).revision).toBe(before.revision + 1);
109
+ expect(state(scheduleId).backoff_streak).toBe(0);
110
+ });
111
+ test("old events and duplicate consumption do not replay after enable or process reopen", async () => {
112
+ await card(); const { scheduleId } = await atThree(), before = state(scheduleId); expect(poll(scheduleId)).toBe(false);
113
+ const child = Bun.spawn([process.execPath, "-e", `import {consumeRequirementWake} from './server/src/scheduled-requirement-wake.ts'; console.log(JSON.stringify({wake:consumeRequirementWake(${JSON.stringify(scheduleId)},${JSON.stringify(network)})}));`], { cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: process.env });
114
+ const output = await new Response(child.stdout).text(); expect(await child.exited).toBe(0);
115
+ expect(JSON.parse(output.trim().split("\n").at(-1)!)).toEqual({ wake: false }); expect(state(scheduleId)).toEqual(before);
116
+ });
117
+ test("status transitions wake while comments/checklists/no-op edits do not", async () => {
118
+ const c = await card({ checklist: [{ id: "one", text: "unit", done: false }] });
119
+ const { scheduleId } = await atThree(), before = state(scheduleId);
120
+ expect((await mcp(ownerToken, "requirements_comment", { id: c.id, network_id: network, text: "routine checkpoint" })).ok).toBe(true);
121
+ expect((await mcp(ownerToken, "requirements_checklist_toggle", { id: c.id, network_id: network, item_id: "one", done: true })).ok).toBe(true);
122
+ await column(c.id, "pool"); expect(poll(scheduleId)).toBe(false);
123
+ expect(state(scheduleId).revision).toBe(before.revision); expect(state(scheduleId).backoff_streak).toBe(3);
124
+ await column(c.id, "doing"); expect(poll(scheduleId)).toBe(true);
125
+ });
126
+ test("self node writes and iteration archives do not wake; knowledge-tagged real work can wake", async () => {
127
+ const { scheduleId } = await atThree(), before = state(scheduleId);
128
+ expect((await mcp(nodes.a.token, "requirements_create", { name: "self source", tags: ["ANet"], network_id: network })).ok).toBe(true);
129
+ await card({ external_ref: "iteration:fixture1279" }); expect(poll(scheduleId)).toBe(false);
130
+ expect(state(scheduleId).revision).toBe(before.revision);
131
+ await card(); expect(poll(scheduleId)).toBe(true);
132
+ });
133
+ test("other-node real work wakes and unrelated tags or networks do not", async () => {
134
+ const { scheduleId } = await atThree(), before = state(scheduleId);
135
+ await card({ tags: ["unrelated"] }); expect(poll(scheduleId)).toBe(false);
136
+ const foreign = register(`foreign1279_${crypto.randomUUID()}`, "Fixture1279Only!x");
137
+ expect((await rest(foreign.token!, "POST", "/api/requirements", { network_id: foreign.network_id!, name: "foreign", tags: ["ANet"] })).status).toBe(201);
138
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
139
+ expect((await mcp(nodes.b.token, "requirements_create", { name: "other real work", tags: ["ANet"], network_id: network })).ok).toBe(true);
140
+ expect(poll(scheduleId)).toBe(true);
141
+ });
142
+ test("creator MCP config roundtrip retains watch through batch/metadata edits; another node cannot edit it", async () => {
143
+ const created = await mcp(nodes.a.token, "schedule_create", { name: "agent wake1279", task: "fixture", no_change_backoff: true, schedule: { type: "interval", every_seconds: 120, requirement_wake: { tag: "ANet" } } });
144
+ expect(created.ok).toBe(true); const id = created.schedule.schedule_id;
145
+ expect(created.schedule.schedule.requirement_wake.tag).toBe("ANet");
146
+ expect((await mcp(nodes.b.token, "schedule_update", { schedule_id: id, schedule: { type: "interval", every_seconds: 180, requirement_wake: null } })).ok).toBe(false);
147
+ expect((await mcp(nodes.a.token, "schedule_batch_interval", { schedule_ids: [id], every_seconds: 180 })).ok).toBe(true);
148
+ expect(state(id).requirement_wake_json).toBeTruthy();
149
+ expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: id, name: "metadata" })).ok).toBe(true);
150
+ expect(state(id).requirement_wake_json).toBeTruthy();
151
+ expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: id, no_change_backoff: false })).ok).toBe(true);
152
+ expect(state(id).requirement_wake_json).toBeNull();
153
+ });
154
+ test("paused/cancelled plans do not wake; resume rebases the cursor rather than replaying hidden time", async () => {
155
+ const { scheduleId } = await atThree();
156
+ expect((await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision: state(scheduleId).revision, status: "paused" })).status).toBe(200);
157
+ const before = state(scheduleId); await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId)).toEqual(before);
158
+ expect((await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision: state(scheduleId).revision, status: "active" })).status).toBe(200);
159
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).backoff_streak).toBe(0);
160
+ expect((await rest(ownerToken, "DELETE", `/api/scheduled-tasks/${scheduleId}`)).status).toBe(200);
161
+ const canceled = state(scheduleId); await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId)).toEqual(canceled);
162
+ });
163
+ test("current restricted target visibility is enforced even in permission log mode, grants never replay skipped history", async () => {
164
+ const { scheduleId } = await atThree(), before = state(scheduleId);
165
+ db.run("UPDATE nodes SET permission_mode = 'restricted' WHERE node_id = ?1", nodes.a.id);
166
+ try { await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision); }
167
+ finally { db.run("UPDATE nodes SET permission_mode = 'normal' WHERE node_id = ?1", nodes.a.id); }
168
+ expect(poll(scheduleId)).toBe(false);
169
+ await card(); expect(poll(scheduleId)).toBe(true);
170
+ });
171
+ test("creator membership loss, read-only target and owner replacement suppress wake and consume without replay", async () => {
172
+ for (const change of ["membership", "readonly", "owner"]) {
173
+ const { scheduleId } = await atThree(), before = state(scheduleId);
174
+ if (change === "membership") db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [network, ownerId]);
175
+ if (change === "readonly") db.run("UPDATE nodes SET permission_mode = 'readonly' WHERE node_id = ?1", nodes.a.id);
176
+ if (change === "owner") db.run("UPDATE nodes SET owner_user_id = NULL WHERE node_id = ?1", nodes.a.id);
177
+ // Fixture-only trusted event insertion after revocation; public mutation
178
+ // itself may now refuse, and must not be weakened for this scenario.
179
+ const c = db.get<any>("SELECT requirement_id,seq,title FROM requirements WHERE network_id = ?1 LIMIT 1", network)!;
180
+ db.run("INSERT INTO requirement_events(network_id,requirement_id,seq,title,actor_json,kind,field,old_json,new_json,created_at) VALUES(?1,?2,?3,?4,?5,'changed','column','\"pool\"','\"doing\"',datetime('now'))", [network,c.requirement_id,c.seq,c.title,JSON.stringify({kind:"user",id:ownerId})]);
181
+ try { expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision); }
182
+ finally {
183
+ if (change === "membership") db.run("INSERT INTO network_members(network_id,user_id,role,task_access) VALUES(?1,?2,'owner','all')", [network,ownerId]);
184
+ if (change === "readonly") db.run("UPDATE nodes SET permission_mode = 'normal' WHERE node_id = ?1", nodes.a.id);
185
+ if (change === "owner") db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [ownerId,nodes.a.id]);
186
+ }
187
+ expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
188
+ }
189
+ });
190
+ test("cursor write fault rolls back wake and the normal reply together; retry consumes exactly once", async () => {
191
+ const { scheduleId } = await atThree(), task = await nextRun(scheduleId); await card(); const before = state(scheduleId);
192
+ db.exec(`CREATE TRIGGER test1279_fault BEFORE UPDATE OF requirement_wake_cursor ON scheduled_tasks WHEN NEW.schedule_id='${scheduleId}' BEGIN SELECT RAISE(ABORT,'test1279_fault'); END`);
193
+ try { expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).not.toBe(true); }
194
+ finally { db.exec("DROP TRIGGER test1279_fault"); }
195
+ expect(state(scheduleId)).toEqual(before); expect(readTask(task).status).toBe("delivered");
196
+ expect(readRun(task).work_outcome).toBeNull(); expect(countReplies(task)).toBe(0);
197
+ expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
198
+ expect(state(scheduleId).revision).toBe(before.revision + 1); expect(poll(scheduleId)).toBe(false);
199
+ });
200
+ test("invalid/empty/forged authority filters refuse without creating a plan; explicit null disables", async () => {
201
+ for (const policy of [true, {}, {tag:""}, {tag:"ANet", creator_user_id:ownerId}]) {
202
+ const out = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id:network, name:"invalid1279",target_node_id:nodes.a.id,task:"fixture",no_change_backoff:true,schedule:{type:"interval",every_seconds:120,requirement_wake:policy} }); expect(out.status).toBe(400);
203
+ }
204
+ const {scheduleId}=await atThree();
205
+ expect((await rest(ownerToken,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:null}})).status).toBe(200);
206
+ await card();expect(poll(scheduleId)).toBe(false);expect(state(scheduleId).requirement_wake_json).toBeNull();
207
+ });
208
+ test("project filter and current target project grants limit wake, revoked grants never replay", async () => {
209
+ const p1 = await rest(ownerToken,"POST","/api/requirements/projects",{network_id:network,name:"project1279"}); expect(p1.status).toBe(201); const project=p1.body.project.id;
210
+ const {scheduleId}=await atThree();
211
+ const scoped=register(`scoped1279_${crypto.randomUUID()}`,"Fixture1279Only!x"), uid=scoped.user!.user_id;
212
+ db.run("INSERT INTO network_members(network_id,user_id,role,task_access) VALUES(?1,?2,'member','scoped')",[network,uid]);
213
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2",[uid,nodes.a.id]);
214
+ try {
215
+ const edit=await rest(ownerToken,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:{project_id:project,tag:"ANet"}}}); expect(edit.status).toBe(200);
216
+ const base=state(scheduleId);
217
+ await card({project_id:project}); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(base.revision);
218
+ expect((await rest(ownerToken,"PUT",`/api/networks/${network}/members/${uid}/task-grants`,{task_access:"scoped",project_grants:[{project_id:project}]})).status).toBe(200);
219
+ expect(poll(scheduleId)).toBe(false); // no replay after gaining access
220
+ await card(); expect(poll(scheduleId)).toBe(false); // tag alone is insufficient
221
+ await card({project_id:project}); expect(poll(scheduleId)).toBe(true);
222
+ expect((await rest(ownerToken,"PUT",`/api/networks/${network}/members/${uid}/task-grants`,{task_access:"scoped",project_grants:[]})).status).toBe(200);
223
+ const before=state(scheduleId); await card({project_id:project}); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
224
+ } finally { db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2",[ownerId,nodes.a.id]); }
225
+ });
226
+ test("a scoped editor cannot borrow the original creator's event visibility", async () => {
227
+ const {scheduleId}=await atThree();
228
+ const scoped=register(`editor1279_${crypto.randomUUID()}`,"Fixture1279Only!x"), uid=scoped.user!.user_id;
229
+ db.run("INSERT INTO network_members(network_id,user_id,role,task_access) VALUES(?1,?2,'member','scoped')",[network,uid]);
230
+ const edit=await rest(scoped.token!,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:{tag:"ANet"}}}); expect(edit.status).toBe(200);
231
+ const before=state(scheduleId); await card(); expect(poll(scheduleId)).toBe(false); expect(state(scheduleId).revision).toBe(before.revision);
232
+ await card({participants:[{kind:"user",id:uid}]}); expect(poll(scheduleId)).toBe(true);
233
+ const project=await rest(ownerToken,"POST","/api/requirements/projects",{network_id:network,name:"hidden1279"}); expect(project.status).toBe(201);
234
+ expect((await rest(scoped.token!,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,schedule:{type:"interval",every_seconds:120,requirement_wake:{project_id:project.body.project.id}}})).status).toBe(400);
235
+ });
236
+ test("manual wake rebases pending events so a stale event cannot reset again",async()=>{
237
+ const {scheduleId}=await atThree();await card();const before=state(scheduleId);
238
+ const out=await rest(ownerToken,"POST",`/api/scheduled-tasks/${scheduleId}/run-now`);expect(out.status).toBe(202);
239
+ expect(state(scheduleId).revision).toBe(before.revision+1);expect(poll(scheduleId)).toBe(false);
240
+ });
241
+ test("bounded cursor pages do not lose a later matching event",async()=>{
242
+ const c=await card(),{scheduleId}=await atThree(),revision=state(scheduleId).revision;
243
+ db.transaction(()=>{for(let i=0;i<270;i++)db.run("INSERT INTO requirement_events(network_id,requirement_id,actor_json,kind,new_json,created_at) VALUES(?1,?2,?3,'comment','{}',datetime('now'))",[network,c.id,JSON.stringify({kind:"user",id:ownerId})]);});
244
+ await card();expect(poll(scheduleId)).toBe(false);expect(state(scheduleId).revision).toBe(revision);
245
+ expect(poll(scheduleId)).toBe(true);expect(state(scheduleId).revision).toBe(revision+1);expect(poll(scheduleId)).toBe(false);
246
+ });
247
+ test("two independent SQLite processes consume the event once and persist one generation",async()=>{
248
+ const {scheduleId}=await atThree();await card();const before=state(scheduleId), gate=`/tmp/wake1279-${crypto.randomUUID()}`;
249
+ const code=`import {consumeRequirementWake} from './server/src/scheduled-requirement-wake.ts'; import {existsSync} from 'node:fs'; console.log('READY'); while(!existsSync(${JSON.stringify(gate)}))await Bun.sleep(10); for(let i=0;;i++){try{console.log(JSON.stringify({wake:consumeRequirementWake(${JSON.stringify(scheduleId)},${JSON.stringify(network)})})); break;}catch(e){if(i>=4||!String(e).includes('locked'))throw e;await Bun.sleep(20);}}`;
250
+ const workers=[] as any[];
251
+ // Initialize each adapter before releasing both against the same DB;
252
+ // schema startup is not the race under qualification.
253
+ for(let i=0;i<2;i++){
254
+ const child=Bun.spawn([process.execPath,"-e",code],{cwd:process.cwd(),stdout:"pipe",stderr:"pipe",env:process.env});
255
+ const reader=child.stdout.getReader();let start="";
256
+ for(;;){const part=await reader.read();if(part.done)throw new Error('worker exited before ready');start+=new TextDecoder().decode(part.value);if(start.includes('READY'))break;}
257
+ workers.push({child,reader,start});
258
+ }
259
+ await Bun.write(gate,"go");const results=[];
260
+ for(const w of workers){let output=w.start;for(;;){const part=await w.reader.read();if(part.done)break;output+=new TextDecoder().decode(part.value);}expect(await w.child.exited).toBe(0);results.push(JSON.parse(output.trim().split("\n").at(-1)!).wake);}
261
+ expect(results.sort()).toEqual([false,true]);expect(state(scheduleId).revision).toBe(before.revision+1);expect(poll(scheduleId)).toBe(false);
262
+ });
263
+
264
+ });
@@ -0,0 +1,125 @@
1
+ import { db } from "./db.js";
2
+ import { ensureRequirementEvents } from "./requirement-events.js";
3
+ import { canSeeTask, canUseProject, type TaskCaller } from "./task-access.js";
4
+ import { setRequirementWakeHook } from "./scheduled-backoff.js";
5
+ import { parseHubTimestamp } from "./hub-timestamp.js";
6
+
7
+ ensureRequirementEvents(db);
8
+ export type RequirementWake = { project_id: string | null; tag: string | null };
9
+ type StoredWake = RequirementWake & { creator_user_id: string; target_owner_user_id: string; configured_by_user_id: string };
10
+ type Plan = { schedule_id?: string; network_id: string; created_by: string | null;
11
+ created_by_node_id: string | null; target_node_id: string };
12
+
13
+ /** No implicit broad access when identity/membership is gone. Read current
14
+ * node mode and owner, rather than a token alias or a cached permission flag. */
15
+ function principal(userId: string | null, network: string, nodeId?: string | null): TaskCaller {
16
+ let only = false;
17
+ if (nodeId) {
18
+ const node = db.get<any>("SELECT owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, network);
19
+ if (!node || node.permission_mode === "readonly") return null;
20
+ userId = node.owner_user_id; only = node.permission_mode === "restricted";
21
+ }
22
+ if (!userId || !db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id = m.user_id WHERE m.user_id = ?1 AND m.network_id = ?2 AND m.role != 'viewer'", userId, network)) return null;
23
+ return { userId, ...(nodeId ? { node: { id: nodeId, only } } : {}) };
24
+ }
25
+ function audience(row: Plan) {
26
+ return { creator: principal(row.created_by, row.network_id, row.created_by_node_id),
27
+ target: principal(null, row.network_id, row.target_node_id) };
28
+ }
29
+ export function parseRequirementWake(raw: unknown, fallback: string | null = null): RequirementWake | null {
30
+ if (raw === undefined) return fallback ? publicRequirementWake(fallback) : null;
31
+ if (raw === null) return null;
32
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid_requirement_wake");
33
+ const p = raw as Record<string, unknown>;
34
+ if (Object.keys(p).some(k => k !== "project_id" && k !== "tag")) throw new Error("invalid_requirement_wake");
35
+ const project = p.project_id ?? null, tag = p.tag ?? null;
36
+ if (project !== null && (typeof project !== "string" || !project || project.length > 200)
37
+ || tag !== null && (typeof tag !== "string" || !tag.trim() || tag.length > 20)
38
+ || project === null && tag === null) throw new Error("invalid_requirement_wake");
39
+ return { project_id: project as string | null, tag: tag as string | null };
40
+ }
41
+ export function publicRequirementWake(json: string): RequirementWake {
42
+ const p = JSON.parse(json); return { project_id: p.project_id, tag: p.tag };
43
+ }
44
+ export function requirementWakeHead(network: string): number {
45
+ return Number(db.get<any>("SELECT COALESCE(MAX(id), 0) AS id FROM requirement_events WHERE network_id = ?1", network)!.id);
46
+ }
47
+ /** Called by the existing authorized creator/editor paths. No production
48
+ * plans are opted in by migration. The config records the creator authority. */
49
+ export function bindRequirementWake(row: Plan, policy: RequirementWake | null, enabled: boolean, type: string, configuratorUserId: string | null): string | null {
50
+ if (!policy) return null;
51
+ if (!enabled || type !== "interval") throw new Error("requirement_wake_requires_backoff_interval");
52
+ const a = audience(row), configurator = principal(configuratorUserId, row.network_id);
53
+ if (!a.creator || !a.target || !configurator) throw new Error("requirement_wake_identity_unresolved");
54
+ if (policy.project_id && (!db.get("SELECT 1 FROM requirement_projects WHERE project_id = ?1 AND network_id = ?2", policy.project_id, row.network_id)
55
+ || !canUseProject(a.creator, row.network_id, policy.project_id) || !canUseProject(configurator, row.network_id, policy.project_id))) throw new Error("requirement_wake_scope_not_visible");
56
+ return JSON.stringify({ ...policy, creator_user_id: a.creator.userId, target_owner_user_id: a.target.userId, configured_by_user_id: configurator.userId } satisfies StoredWake);
57
+ }
58
+
59
+ /** One cursor and generation update, atomic with the calling reply/dispatch
60
+ * transaction. Any write fault propagates; no consume-then-forget window. */
61
+ export function consumeRequirementWake(id: string, network: string, now = new Date()): boolean {
62
+ if (db.transactionalFeaturesRefusal !== null) return false;
63
+ return db.transaction(() => {
64
+ const row = db.get<any>(`SELECT schedule_id, network_id, created_by, created_by_node_id, target_node_id,
65
+ status, revision, schedule_json, no_change_backoff, requirement_wake_json, requirement_wake_cursor,
66
+ next_run_at FROM scheduled_tasks WHERE schedule_id = ?1 AND network_id = ?2`, id, network);
67
+ if (!row || row.status !== "active" || row.no_change_backoff !== 1 || !row.requirement_wake_json) return false;
68
+ const spec = JSON.parse(row.schedule_json); if (spec.type !== "interval") return false;
69
+ const policy: StoredWake = JSON.parse(row.requirement_wake_json), a = audience(row);
70
+ const configurator = principal(policy.configured_by_user_id, network);
71
+ const allowed = configurator && a.creator && a.target && a.creator.userId === policy.creator_user_id
72
+ && a.target.userId === policy.target_owner_user_id;
73
+ // Cursor is advanced even for invisible/irrelevant events, so a later
74
+ // permission grant cannot replay history hidden at the consumption time.
75
+ const events = db.all<any>(`SELECT id, requirement_id, actor_json, kind, field, old_json, new_json
76
+ FROM requirement_events WHERE network_id = ?1 AND id > ?2 ORDER BY id LIMIT 256`, network, row.requirement_wake_cursor);
77
+ if (!events.length) return false;
78
+ let matching: number | null = null;
79
+ for (const event of events) {
80
+ if (!allowed || !(event.kind === "created" || event.kind === "changed" && event.field === "column")) continue;
81
+ let actor: any; try { actor = JSON.parse(event.actor_json); } catch { continue; }
82
+ if (!actor || !["user", "node"].includes(actor.kind) || typeof actor.id !== "string") continue;
83
+ if (actor.kind === "node" && [row.target_node_id, row.created_by_node_id].includes(actor.id)) continue;
84
+ if (event.kind === "changed") {
85
+ const states = ["pool", "doing", "done", "abandoned"];
86
+ let before: unknown, after: unknown;
87
+ try { before = JSON.parse(event.old_json); after = JSON.parse(event.new_json); } catch { continue; }
88
+ if (!states.includes(before as string) || !states.includes(after as string) || before === after) continue;
89
+ }
90
+ const card = db.get<any>(`SELECT network_id, project_id, tags_json, external_ref, archived, owner_json,
91
+ participants_json, created_by, created_by_json, agent_owner_json, parent_id
92
+ FROM requirements WHERE requirement_id = ?1 AND network_id = ?2`, event.requirement_id, network);
93
+ if (!card || card.archived || card.external_ref?.startsWith("iteration:")) continue;
94
+ if (policy.project_id && card.project_id !== policy.project_id) continue;
95
+ if (policy.tag) { let tags: unknown; try { tags = JSON.parse(card.tags_json); } catch { continue; }
96
+ if (!Array.isArray(tags) || !tags.includes(policy.tag)) continue; }
97
+ if (!canSeeTask(a.creator!, card) || !canSeeTask(a.target!, card) || !canSeeTask(configurator!, card)) continue;
98
+ matching = Number(event.id);
99
+ }
100
+ const cursor = Number(events.at(-1)!.id);
101
+ const due = parseHubTimestamp(row.next_run_at);
102
+ if (matching && row.next_run_at && due === null) throw new Error("requirement_wake_invalid_due_time");
103
+ const next = matching && row.next_run_at
104
+ ? new Date(Math.min(due!, now.getTime() + spec.every_seconds * 1000)).toISOString() : row.next_run_at;
105
+ const result = db.run(`UPDATE scheduled_tasks SET requirement_wake_cursor = ?1,
106
+ requirement_wake_last_event_id = CASE WHEN ?2 IS NOT NULL THEN ?2 ELSE requirement_wake_last_event_id END,
107
+ backoff_streak = CASE WHEN ?2 IS NOT NULL THEN 0 ELSE backoff_streak END,
108
+ backoff_effective_seconds = CASE WHEN ?2 IS NOT NULL THEN NULL ELSE backoff_effective_seconds END,
109
+ backoff_last_run_id = CASE WHEN ?2 IS NOT NULL THEN NULL ELSE backoff_last_run_id END,
110
+ revision = revision + CASE WHEN ?2 IS NOT NULL THEN 1 ELSE 0 END, next_run_at = ?3,
111
+ updated_at = CASE WHEN ?2 IS NOT NULL THEN datetime('now') ELSE updated_at END
112
+ WHERE schedule_id = ?4 AND network_id = ?5 AND revision = ?6 AND requirement_wake_cursor = ?7
113
+ AND status = 'active' AND no_change_backoff = 1`, [cursor, matching, next, id, network, row.revision, row.requirement_wake_cursor]);
114
+ if (result.changes !== 1) throw new Error("requirement_wake_atomic_write_failed");
115
+ return matching !== null;
116
+ });
117
+ }
118
+ /** Existing scheduler tick also scans opt-in plans whose next due is delayed. */
119
+ export function pollRequirementWakes(now = new Date()): void {
120
+ if (db.transactionalFeaturesRefusal !== null) return;
121
+ const rows = db.all<any>(`SELECT schedule_id, network_id FROM scheduled_tasks
122
+ WHERE status = 'active' AND no_change_backoff = 1 AND requirement_wake_json IS NOT NULL`);
123
+ for (const row of rows) consumeRequirementWake(row.schedule_id, row.network_id, now);
124
+ }
125
+ setRequirementWakeHook(consumeRequirementWake);
@@ -1,3 +1,6 @@
1
+ import { bindRequirementWake, parseRequirementWake, publicRequirementWake, requirementWakeHead, pollRequirementWakes, type RequirementWake } from "./scheduled-requirement-wake.js";
2
+ import { scheduledOutcomeGuidance } from "./scheduled-outcome-guidance.js";
3
+ import { parseBackoffPolicy, refreshScheduledBackoff } from "./scheduled-backoff.js";
1
4
  import { db, logTaskEvent } from "./db.js";
2
5
  import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
3
6
  import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
@@ -38,6 +41,13 @@ export type ScheduledRow = {
38
41
  status: "active" | "paused" | "completed" | "cancelled";
39
42
  next_run_at: string | null;
40
43
  last_run_at: string | null;
44
+ no_change_backoff: number;
45
+ backoff_streak: number;
46
+ backoff_effective_seconds: number | null;
47
+ backoff_last_run_id: string | null;
48
+ requirement_wake_json: string | null;
49
+ requirement_wake_cursor: number;
50
+ requirement_wake_last_event_id: number | null;
41
51
  revision: number;
42
52
  created_at: string;
43
53
  updated_at: string;
@@ -184,8 +194,15 @@ export function nextOccurrence(spec: ScheduleSpec, timezone: string, after: Date
184
194
  function decodeRow(row: ScheduledRow): Record<string, unknown> {
185
195
  let schedule: unknown = null;
186
196
  try { schedule = JSON.parse(row.schedule_json); } catch {}
187
- const { created_by: _createdBy, created_by_node_id: _createdByNode, schedule_json: _scheduleJson, ...publicRow } = row;
188
- return { ...publicRow, schedule };
197
+ const { created_by: _createdBy, created_by_node_id: _createdByNode, schedule_json: _scheduleJson,
198
+ no_change_backoff, backoff_streak, backoff_effective_seconds, backoff_last_run_id,
199
+ requirement_wake_json, requirement_wake_cursor, requirement_wake_last_event_id, ...publicRow } = row;
200
+ if (requirement_wake_json && schedule && typeof schedule === "object")
201
+ schedule = { ...schedule, requirement_wake: publicRequirementWake(requirement_wake_json) };
202
+ return { ...publicRow, schedule, ...(requirement_wake_json ? { requirement_wake_state:
203
+ { cursor: requirement_wake_cursor, last_event_id: requirement_wake_last_event_id } } : {}), ...(no_change_backoff === 1 ? { no_change_backoff: true,
204
+ backoff: { streak: backoff_streak, effective_seconds: backoff_effective_seconds
205
+ ?? (schedule as { every_seconds?: number } | null)?.every_seconds, last_run_id: backoff_last_run_id } } : {}) };
189
206
  }
190
207
 
191
208
  /** MCP (agent) shape: REST shape + which node created it (null = a person / old row). Never the user id. */
@@ -202,7 +219,7 @@ export class ScheduleInputError extends Error {
202
219
  }
203
220
 
204
221
  export type ValidatedScheduleCreate = {
205
- name: string; content: string; priority: string; misfirePolicy: MisfirePolicy;
222
+ name: string; content: string; priority: string; misfirePolicy: MisfirePolicy; backoff: boolean; wake: RequirementWake | null;
206
223
  target: { node_id: string; alias: string }; spec: ScheduleSpec; timezone: string; next: Date;
207
224
  };
208
225
 
@@ -219,16 +236,20 @@ export function validateScheduleCreate(networkId: string, body: Record<string, u
219
236
  const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
220
237
  const next = nextOccurrence(spec, timezone, new Date());
221
238
  if (!next) throw new Error("schedule_has_no_future_occurrence");
222
- return { name, content, priority, misfirePolicy, target, spec, timezone, next };
239
+ return { name, content, priority, misfirePolicy, target, spec, timezone, next,
240
+ backoff: parseBackoffPolicy(body.no_change_backoff, spec.type),
241
+ wake: parseRequirementWake((body.schedule as Record<string, unknown>).requirement_wake) };
223
242
  }
224
243
 
225
244
  export function insertSchedule(networkId: string, createdBy: string | null, createdByNodeId: string | null, v: ValidatedScheduleCreate): ScheduledRow {
226
245
  const scheduleId = `sched_${crypto.randomUUID()}`;
246
+ const wakeJson = bindRequirementWake({ network_id: networkId, created_by: createdBy,
247
+ created_by_node_id: createdByNodeId, target_node_id: v.target.node_id }, v.wake, v.backoff, v.spec.type, createdBy);
227
248
  db.run(
228
249
  `INSERT INTO scheduled_tasks
229
- (schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at)
230
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'skip', ?13, ?14)`,
231
- [scheduleId, networkId, createdBy, createdByNodeId, v.name, v.target.node_id, v.target.alias, v.content, v.priority, v.spec.type, JSON.stringify(v.spec), v.timezone, v.misfirePolicy, iso(v.next)],
250
+ (schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at, no_change_backoff, requirement_wake_json, requirement_wake_cursor)
251
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'skip', ?13, ?14, ?15, ?16, ?17)`,
252
+ [scheduleId, networkId, createdBy, createdByNodeId, v.name, v.target.node_id, v.target.alias, v.content, v.priority, v.spec.type, JSON.stringify(v.spec), v.timezone, v.misfirePolicy, iso(v.next), v.backoff ? 1 : 0, wakeJson, wakeJson ? requirementWakeHead(networkId) : 0],
232
253
  );
233
254
  return getScheduleRow(scheduleId)!;
234
255
  }
@@ -236,7 +257,7 @@ export function insertSchedule(networkId: string, createdBy: string | null, crea
236
257
  /** Idempotent cancel (shared by REST DELETE / POST cancel and MCP schedule_cancel). */
237
258
  export function cancelSchedule(row: ScheduledRow): void {
238
259
  if (row.status === "cancelled") return;
239
- db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
260
+ db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, backoff_streak = 0, backoff_effective_seconds = NULL, backoff_last_run_id = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
240
261
  }
241
262
 
242
263
  export function scheduleRuns(row: ScheduledRow, limit: number): Record<string, unknown> {
@@ -244,7 +265,7 @@ export function scheduleRuns(row: ScheduledRow, limit: number): Record<string, u
244
265
  // #523 — a task that ended `failed` leaves run.error_message empty; its reason is the task's reply text.
245
266
  `SELECT r.run_id, r.schedule_id, r.scheduled_for, r.task_id, r.status, r.error_code,
246
267
  CASE WHEN r.error_message IS NULL AND r.status = 'failed' THEN SUBSTR(t.result, 1, 500) ELSE r.error_message END AS error_message,
247
- r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at
268
+ r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at, r.work_outcome, r.schedule_revision, r.work_outcome_node_id
248
269
  FROM scheduled_task_runs r
249
270
  LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
250
271
  WHERE r.schedule_id = ?1 AND r.network_id = ?2 ORDER BY r.created_at DESC, r.scheduled_for DESC LIMIT ?3`,
@@ -254,7 +275,7 @@ export function scheduleRuns(row: ScheduledRow, limit: number): Record<string, u
254
275
  }
255
276
 
256
277
  /** Shared PATCH semantics (REST PATCH and MCP schedule_update). Throws ScheduleInputError. */
257
- export function patchSchedule(row: ScheduledRow, body: Record<string, unknown>): ScheduledRow {
278
+ export function patchSchedule(row: ScheduledRow, body: Record<string, unknown>, actorUserId: string | null = null): ScheduledRow {
258
279
  // Cancellation/completion are terminal states. Editing must never become
259
280
  // an implicit resurrection path (including by supplying status=active).
260
281
  if (row.status === "cancelled") throw new ScheduleInputError("schedule_cancelled", 409);
@@ -281,24 +302,32 @@ export function patchSchedule(row: ScheduledRow, body: Record<string, unknown>):
281
302
  // with no next occurrence. The recompute uses the same DST-safe helper
282
303
  // as creation and dispatch advancement.
283
304
  const scheduleJson = JSON.stringify(parsed.spec);
305
+ const backoff = parseBackoffPolicy(body.no_change_backoff, parsed.spec.type, row.no_change_backoff === 1);
306
+ if (backoff && row.overlap_policy !== "skip") throw new Error("no_change_backoff_requires_skip_overlap");
307
+ const rawWake = (body.schedule as Record<string, unknown> | undefined)?.requirement_wake;
308
+ const wake = parseRequirementWake(rawWake, backoff ? row.requirement_wake_json : null);
309
+ const wakeJson = bindRequirementWake({ ...row, target_node_id: target.node_id }, wake, backoff, parsed.spec.type, actorUserId);
310
+ // Every edit creates a new reporting generation. Reset opted-in cadence
311
+ // too, so the visible state never describes an older target/revision.
284
312
  const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
313
+ const backoffGenerationReset = row.no_change_backoff === 1 || backoff;
285
314
  const resumed = row.status !== "active" && requestedStatus === "active";
286
315
  const next = requestedStatus !== "active"
287
316
  ? null
288
- : schedulingChanged || resumed || !row.next_run_at
317
+ : schedulingChanged || backoffGenerationReset || resumed || !row.next_run_at
289
318
  ? nextOccurrence(parsed.spec, parsed.timezone, new Date())
290
319
  : new Date(row.next_run_at);
291
320
  if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
292
321
  // Runs no longer bump revision, so the scheduler may have advanced
293
322
  // next_run_at after `row` was read. When cadence is preserved, keep the
294
323
  // stored value instead of writing back the already-fired occurrence.
295
- const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
324
+ const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !backoffGenerationReset && !resumed && !!row.next_run_at ? 1 : 0;
296
325
  const updated = db.run(
297
326
  `UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
298
327
  priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
299
328
  next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
300
- misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
301
- [name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision, keepStoredNext],
329
+ misfire_policy = ?11, no_change_backoff = ?15, requirement_wake_json = ?16, requirement_wake_cursor = ?17, requirement_wake_last_event_id = NULL, backoff_streak = 0, backoff_effective_seconds = NULL, backoff_last_run_id = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
330
+ [name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision, keepStoredNext, backoff ? 1 : 0, wakeJson, wakeJson ? requirementWakeHead(row.network_id) : 0],
302
331
  );
303
332
  if (updated.changes !== 1) throw new ScheduleInputError("revision_conflict", 409);
304
333
  return getScheduleRow(row.schedule_id)!;
@@ -373,11 +402,29 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
373
402
  // UNIQUE(schedule_id, scheduled_for) is the cross-process claim. If two
374
403
  // Hub processes race, one INSERT wins and the other transaction aborts.
375
404
  db.run(
376
- `INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, status)
377
- VALUES (?1, ?2, ?3, ?4, 'claiming')`,
378
- [runId, row.schedule_id, row.network_id, scheduledFor],
405
+ `INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, status, schedule_revision)
406
+ VALUES (?1, ?2, ?3, ?4, 'claiming', ?5)`,
407
+ [runId, row.schedule_id, row.network_id, scheduledFor, row.revision],
379
408
  );
380
409
 
410
+ const current = getScheduleRow(row.schedule_id);
411
+ if (!current || current.revision !== row.revision || current.status === "cancelled"
412
+ || (advanceSchedule && current.status !== "active")) throw new Error("schedule_changed");
413
+ row = current;
414
+ // Manual dispatch is an explicit wake. Its new generation fences all
415
+ // already-running reports, while a paused schedule remains paused.
416
+ if (!advanceSchedule && row.no_change_backoff === 1) {
417
+ const base = JSON.parse(row.schedule_json).every_seconds;
418
+ db.run(`UPDATE scheduled_tasks SET revision = revision + 1, backoff_streak = 0,
419
+ backoff_effective_seconds = NULL, backoff_last_run_id = NULL,
420
+ requirement_wake_cursor = ?4, requirement_wake_last_event_id = NULL,
421
+ next_run_at = CASE WHEN status = 'active' THEN ?1 ELSE next_run_at END,
422
+ updated_at = datetime('now') WHERE schedule_id = ?2 AND revision = ?3`,
423
+ [new Date(advanceAfter.getTime() + base * 1000).toISOString(), row.schedule_id, row.revision, requirementWakeHead(row.network_id)]);
424
+ row = getScheduleRow(row.schedule_id)!;
425
+ }
426
+ db.run("UPDATE scheduled_task_runs SET schedule_revision = ?1 WHERE run_id = ?2", [row.revision, runId]);
427
+
381
428
  // Board #757 — do not create a task that will merely expire while its
382
429
  // exact target session is offline. This branch deliberately precedes the
383
430
  // overlap check: target_offline is the actionable reason for this round.
@@ -537,9 +584,12 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
537
584
  auth_origin: "hub_scheduler",
538
585
  ...(row.created_by_node_id ? { scheduled_by_node_id: row.created_by_node_id, scheduled_by_alias: creatorNode?.alias ?? null } : {}),
539
586
  });
540
- const taskContent = row.created_by_node_id
587
+ const originalContent = row.created_by_node_id
541
588
  ? `${agentSchedulePrefix(creatorNode?.alias ?? null, row.created_by_node_id)}${row.task_content}`
542
589
  : row.task_content;
590
+ const taskContent = originalContent + (row.no_change_backoff === 1
591
+ && row.schedule_type === "interval" && row.overlap_policy === "skip"
592
+ && db.transactionalFeaturesRefusal === null ? scheduledOutcomeGuidance(taskId) : "");
543
593
  db.run(
544
594
  `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
545
595
  VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, 'scheduler', 'reply', ?6, ?7)`,
@@ -589,8 +639,12 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
589
639
  // a status change the user sees and it is what stops an in-flight PATCH (whose
590
640
  // UPDATE is guarded by `AND revision = ?`) from reviving a finished one-shot.
591
641
  function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), recordLastRun = true): void {
642
+ refreshScheduledBackoff(row.schedule_id, row.network_id, after);
643
+ const stored = getScheduleRow(row.schedule_id)!;
592
644
  const spec = JSON.parse(row.schedule_json) as ScheduleSpec;
593
- const next = nextOccurrence(spec, row.timezone, after);
645
+ const effective = spec.type === "interval" && stored.no_change_backoff === 1
646
+ ? { ...spec, every_seconds: Math.max(spec.every_seconds, stored.backoff_effective_seconds ?? spec.every_seconds) } : spec;
647
+ const next = nextOccurrence(effective, row.timezone, after);
594
648
  const lastRunAt = recordLastRun ? scheduledFor : row.last_run_at;
595
649
  if (spec.type === "once" || !next) {
596
650
  db.run(
@@ -615,9 +669,9 @@ function skipScheduledMisfire(row: ScheduledRow, scheduledFor: string, now: Date
615
669
  db.transaction(() => {
616
670
  db.run(
617
671
  `INSERT INTO scheduled_task_runs
618
- (run_id, schedule_id, network_id, scheduled_for, status, error_code, error_message, completed_at)
619
- VALUES (?1, ?2, ?3, ?4, 'skipped', 'misfire_skipped', 'The occurrence was overdue and the schedule is configured to skip missed runs', datetime('now'))`,
620
- [runId, row.schedule_id, row.network_id, scheduledFor],
672
+ (run_id, schedule_id, network_id, scheduled_for, status, error_code, error_message, completed_at, schedule_revision)
673
+ VALUES (?1, ?2, ?3, ?4, 'skipped', 'misfire_skipped', 'The occurrence was overdue and the schedule is configured to skip missed runs', datetime('now'), ?5)`,
674
+ [runId, row.schedule_id, row.network_id, scheduledFor, row.revision],
621
675
  );
622
676
  // A skipped occurrence is auditable but is not an execution: preserve
623
677
  // last_run_at while moving the schedule directly to its next future slot.
@@ -626,6 +680,7 @@ function skipScheduledMisfire(row: ScheduledRow, scheduledFor: string, now: Date
626
680
  }
627
681
 
628
682
  export function runDueScheduledTasks(now = new Date(), limit = 100): { processed: number; failed: number } {
683
+ pollRequirementWakes(now);
629
684
  const due = db.all<ScheduledRow>(
630
685
  `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?1 ORDER BY next_run_at LIMIT ?2`,
631
686
  iso(now), limit,
@@ -754,7 +809,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
754
809
  let body: Record<string, unknown>;
755
810
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
756
811
  try {
757
- return Response.json({ ok: true, schedule: decodeRow(patchSchedule(row, body)) });
812
+ return Response.json({ ok: true, schedule: decodeRow(patchSchedule(row, body, ctx.auth?.userId ?? null)) });
758
813
  } catch (e: any) {
759
814
  if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
760
815
  throw e;