@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.
- package/package.json +1 -1
- package/src/agent-department-http.test.ts +148 -0
- package/src/chain-attempt-dedup.test.ts +447 -0
- package/src/create-node-tool-schema.test.ts +1 -0
- package/src/create-node-validate.test.ts +6 -0
- package/src/create-node-validate.ts +7 -2
- package/src/db.ts +190 -21
- package/src/departments.ts +16 -0
- package/src/env-transport-gate-http.test.ts +146 -0
- package/src/env-transport-gate.ts +74 -0
- package/src/env-transport-policy.ts +21 -0
- package/src/host-supervisor-identity-http.test.ts +20 -4
- package/src/hub-catalog-read-http.test.ts +187 -0
- package/src/node-secret-rest-http.test.ts +190 -0
- package/src/observer-avatar-http.test.ts +13 -2
- package/src/report-status-resume-id-handover.test.ts +34 -0
- package/src/report-status-secret-keys.test.ts +140 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-people.ts +8 -4
- package/src/rest-projections.ts +2 -0
- package/src/schedule-agent.ts +1 -1
- package/src/scheduled-backoff-http.test.ts +193 -0
- package/src/scheduled-backoff.ts +64 -0
- package/src/scheduled-outcome-guidance-http.test.ts +139 -0
- package/src/scheduled-outcome-guidance.ts +9 -0
- package/src/scheduled-requirement-wake-http.test.ts +264 -0
- package/src/scheduled-requirement-wake.ts +125 -0
- package/src/scheduled-tasks.ts +78 -23
- package/src/scheduled-work-outcome-http.test.ts +154 -0
- package/src/scheduled-work-outcome.ts +50 -0
- package/src/scoped-secrets.ts +15 -0
- package/src/server.ts +162 -3
- 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);
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -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,
|
|
188
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|