@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,193 @@
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 { refreshScheduledBackoff } from "./scheduled-backoff.js";
5
+ import { getScheduleRow, dispatchScheduledOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
6
+
7
+ if (!process.env.COMMHUB_DB) throw new Error("test1278 requires an explicit disposable SQLite COMMHUB_DB");
8
+ let server: any, base = "", network = "", ownerToken = "", ownerId = "";
9
+ const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
10
+ async function rest(token: string, method: string, path: string, body?: unknown) {
11
+ const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
12
+ "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
13
+ return { status: r.status, body: await r.json() as any };
14
+ }
15
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
16
+ const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
17
+ "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
18
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
19
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
20
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
21
+ try { return JSON.parse(envelope.result.content[0].text); }
22
+ catch { return { ok: false, transportError: true }; }
23
+ }
24
+ const readRun = (taskId: string) => db.get<any>(
25
+ "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)!;
26
+ const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
27
+ const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
28
+ async function fire(enabled = true, interval = 120) {
29
+ const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
30
+ name: `test1278-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "fixture1278 work",
31
+ schedule: { type: "interval", every_seconds: interval }, timezone: "UTC", no_change_backoff: enabled });
32
+ expect(created.status).toBe(201);
33
+ const scheduleId = created.body.schedule.schedule_id;
34
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
35
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
36
+ await runDueScheduledTasks();
37
+ const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
38
+ expect(run.task_id).toBeTruthy();
39
+ return { taskId: run.task_id as string, scheduleId };
40
+ }
41
+ const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
42
+ mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
43
+ 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)!;
44
+
45
+ beforeAll(async () => {
46
+ const owner = register(`work_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
47
+ network = owner.network_id!; ownerId = owner.user!.user_id;
48
+ for (const key of ["a", "b"]) {
49
+ const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
50
+ const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
51
+ nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
52
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
53
+ }
54
+ const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
55
+ base = `http://127.0.0.1:${server.port}`;
56
+ }, 30_000);
57
+ afterAll(() => server?.stop?.(true));
58
+
59
+
60
+ 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 FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
61
+ let occurrence = 0;
62
+ async function nextRun(id: string) {
63
+ // Isolated fixture accelerates the clock, without touching live schedules.
64
+ const date = new Date(Date.now() + (++occurrence) * 1000).toISOString();
65
+ const out = dispatchScheduledOccurrence(getScheduleRow(id)!, date, true);
66
+ expect(out.taskId).toBeTruthy(); return out.taskId!;
67
+ }
68
+ async function empties(id: string, n: number) {
69
+ for (let i = 0; i < n; i++) expect((await reply(await nextRun(id), { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
70
+ }
71
+ async function atThree() {
72
+ const x = await fire(); expect((await reply(x.taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
73
+ await empties(x.scheduleId, 2); return x;
74
+ }
75
+ describe("#1278 opt-in durable no-change backoff through real HTTP/MCP", () => {
76
+ test("3 and 6 exact empty successes change effective cadence, preserving base and revision", async () => {
77
+ const { taskId, scheduleId } = await fire(), initial = state(scheduleId);
78
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
79
+ await empties(scheduleId, 1); expect(state(scheduleId).backoff_effective_seconds).toBe(120);
80
+ await empties(scheduleId, 1); const three = state(scheduleId);
81
+ expect(three).toMatchObject({ backoff_streak: 3, backoff_effective_seconds: 600, revision: initial.revision, schedule_json: initial.schedule_json });
82
+ expect(Date.parse(three.next_run_at) - Date.now()).toBeGreaterThan(590_000);
83
+ await empties(scheduleId, 3); expect(state(scheduleId)).toMatchObject({ backoff_streak: 6, backoff_effective_seconds: 1800 });
84
+ const wire = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}`);
85
+ expect(wire.body.schedule).toMatchObject({ no_change_backoff: true, schedule: { every_seconds: 120 }, backoff: { streak: 6, effective_seconds: 1800 } });
86
+ expect(wire.body.schedule.backoff_last_run_id).toBeUndefined();
87
+ });
88
+ test("changed restores base and nearer next occurrence; no natural-language inference", async () => {
89
+ const { scheduleId } = await atThree();
90
+ expect((await reply(await nextRun(scheduleId), { scheduled_work_outcome: "changed" })).ok).toBe(true);
91
+ expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: 120 });
92
+ expect(Date.parse(state(scheduleId).next_run_at) - Date.now()).toBeLessThanOrEqual(120_000);
93
+ });
94
+ test("unknown success and ordinary failure break the sequence", async () => {
95
+ for (const status of ["replied", "failed"]) {
96
+ const { scheduleId } = await atThree();
97
+ expect((await reply(await nextRun(scheduleId), undefined, status)).ok).toBe(true);
98
+ expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: 120 });
99
+ await empties(scheduleId, 2); expect(state(scheduleId).backoff_effective_seconds).toBe(120);
100
+ }
101
+ });
102
+ test("legacy disabled schedules preserve exact cadence and wire shape", async () => {
103
+ const { taskId, scheduleId } = await fire(false), before = state(scheduleId);
104
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
105
+ expect(state(scheduleId)).toEqual(before);
106
+ const wire = await rest(ownerToken, "GET", `/api/scheduled-tasks/${scheduleId}`);
107
+ expect(wire.body.schedule.no_change_backoff).toBeUndefined(); expect(wire.body.schedule.backoff).toBeUndefined();
108
+ });
109
+ test("a slower base interval is never accelerated", async () => {
110
+ const { taskId, scheduleId } = await fire(true, 3600);
111
+ await reply(taskId, { scheduled_work_outcome: "no_changes" }); await empties(scheduleId, 5);
112
+ expect(state(scheduleId)).toMatchObject({ backoff_streak: 6, backoff_effective_seconds: 3600 });
113
+ });
114
+ test("duplicate callbacks and another DB process cannot increment twice", async () => {
115
+ const { scheduleId } = await atThree(), before = state(scheduleId);
116
+ const latest = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE run_id = ?1", before.backoff_last_run_id)!;
117
+ expect((await reply(latest.task_id, { scheduled_work_outcome: "no_changes" })).ok).toBe(false);
118
+ syncScheduledRunForTask(latest.task_id, network); expect(state(scheduleId)).toEqual(before);
119
+ const child = Bun.spawn([process.execPath, "-e", `import {db} from './server/src/db.ts'; import {refreshScheduledBackoff} from './server/src/scheduled-backoff.ts'; db.transaction(()=>refreshScheduledBackoff(${JSON.stringify(scheduleId)},${JSON.stringify(network)})); console.log(JSON.stringify(db.get('SELECT backoff_streak,backoff_effective_seconds FROM scheduled_tasks WHERE schedule_id=?1',${JSON.stringify(scheduleId)})));`], { cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: process.env });
120
+ const output = await new Response(child.stdout).text(); expect(await child.exited).toBe(0);
121
+ expect(JSON.parse(output.trim().split("\n").at(-1)!)).toEqual({ backoff_streak: 3, backoff_effective_seconds: 600 });
122
+ expect(state(scheduleId)).toEqual(before);
123
+ });
124
+ test("old generation completion after edit cannot rearm", async () => {
125
+ const { scheduleId } = await atThree(), old = await nextRun(scheduleId), revision = state(scheduleId).revision;
126
+ const edit = await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision, name: "fresh generation" }); expect(edit.status).toBe(200);
127
+ const before = state(scheduleId); expect(before.backoff_streak).toBe(0);
128
+ expect((await reply(old, { scheduled_work_outcome: "no_changes" })).ok).toBe(true); expect(state(scheduleId)).toEqual(before);
129
+ });
130
+ test("manual wake resets durable state and fences an in-flight old generation", async () => {
131
+ const { scheduleId } = await atThree(), old = await nextRun(scheduleId), revision = state(scheduleId).revision;
132
+ // Existing overlap skip remains effective. Wake still resets cadence.
133
+ const wake = await rest(ownerToken, "POST", `/api/scheduled-tasks/${scheduleId}/run-now`); expect(wake.status).toBe(202);
134
+ expect(state(scheduleId)).toMatchObject({ backoff_streak: 0, backoff_effective_seconds: null, revision: revision + 1 });
135
+ const before = state(scheduleId); await reply(old, { scheduled_work_outcome: "no_changes" }); expect(state(scheduleId)).toEqual(before);
136
+ });
137
+ test("pause/cancel never resurrect cadence; resumed policy starts a new generation", async () => {
138
+ const { scheduleId } = await atThree(), old = await nextRun(scheduleId);
139
+ expect((await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${scheduleId}`, { revision: state(scheduleId).revision, status: "paused" })).status).toBe(200);
140
+ await reply(old, { scheduled_work_outcome: "changed" }); expect(state(scheduleId)).toMatchObject({ status: "paused", next_run_at: null, backoff_streak: 0 });
141
+ expect((await rest(ownerToken, "POST", `/api/scheduled-tasks/${scheduleId}/run-now`)).status).toBe(202);
142
+ expect(state(scheduleId).next_run_at).toBeNull();
143
+ expect((await rest(ownerToken, "DELETE", `/api/scheduled-tasks/${scheduleId}`)).status).toBe(200);
144
+ expect((await rest(ownerToken, "POST", `/api/scheduled-tasks/${scheduleId}/run-now`)).status).toBe(409);
145
+ expect(state(scheduleId)).toMatchObject({ status: "cancelled", next_run_at: null });
146
+ });
147
+ test("retry success is excluded from empty streaks and failure breaks prior successes", async () => {
148
+ const { scheduleId } = await atThree(), task = await nextRun(scheduleId);
149
+ await reply(task, undefined, "failed"); expect(state(scheduleId).backoff_streak).toBe(0);
150
+ expect((await mcp(ownerToken, "retry_task", { task_id: task, network_id: network })).ok).toBe(true);
151
+ expect((await reply(task, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
152
+ expect(state(scheduleId).backoff_streak).toBe(0); await empties(scheduleId, 2);
153
+ expect(state(scheduleId).backoff_streak).toBe(2);
154
+ });
155
+ test("overlap skip breaks the sequence and an older completion cannot bypass the skip", async () => {
156
+ const { scheduleId } = await atThree(), active = await nextRun(scheduleId);
157
+ const skipped = dispatchScheduledOccurrence(getScheduleRow(scheduleId)!, new Date(Date.now() + (++occurrence) * 1000).toISOString(), true);
158
+ expect(skipped.status).toBe("skipped"); expect(state(scheduleId).backoff_streak).toBe(0);
159
+ await reply(active, { scheduled_work_outcome: "no_changes" }); expect(state(scheduleId).backoff_streak).toBe(0);
160
+ });
161
+ test("current reporter target mismatch cannot count as empty", async () => {
162
+ const { scheduleId } = await atThree(), task = await nextRun(scheduleId);
163
+ // Fixture-only reassignment emulates an authorized assignment change.
164
+ db.run("UPDATE tasks SET to_node_id = ?1, to_name = ?2 WHERE task_id = ?3", [nodes.b.id, nodes.b.alias, task]);
165
+ expect((await reply(task, { scheduled_work_outcome: "no_changes" }, "replied", nodes.b.token)).ok).toBe(true);
166
+ expect(state(scheduleId).backoff_streak).toBe(0);
167
+ });
168
+ test("injected cadence-write fault rolls back task, inbox, outcome, and state together", async () => {
169
+ const { taskId, scheduleId } = await fire(), before = state(scheduleId);
170
+ db.exec(`CREATE TRIGGER test1278_fault BEFORE UPDATE OF backoff_streak ON scheduled_tasks WHEN NEW.schedule_id='${scheduleId}' BEGIN SELECT RAISE(ABORT,'test1278_fault'); END`);
171
+ try { expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).not.toBe(true); }
172
+ finally { db.exec("DROP TRIGGER test1278_fault"); }
173
+ expect(state(scheduleId)).toEqual(before); expect(readTask(taskId).status).toBe("delivered");
174
+ expect(readRun(taskId).work_outcome).toBeNull(); expect(countReplies(taskId)).toBe(0);
175
+ expect((await reply(taskId, { scheduled_work_outcome: "no_changes" })).ok).toBe(true);
176
+ });
177
+ test("REST rejects malformed and non-interval policy; MCP respects creator ownership", async () => {
178
+ for (const policy of ["true", 1, null]) {
179
+ const bad = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network, name: "invalid1278", target_node_id: nodes.a.id, task: "fixture", schedule: { type: "interval", every_seconds: 120 }, no_change_backoff: policy }); expect(bad.status).toBe(400);
180
+ }
181
+ const bad = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network, name: "once1278", target_node_id: nodes.a.id, task: "fixture", schedule: { type: "once", run_at: new Date(Date.now()+86400000).toISOString() }, no_change_backoff: true }); expect(bad.status).toBe(400);
182
+ const created = await mcp(nodes.a.token, "schedule_create", { name: "agent1278", task: "fixture", schedule: { type: "interval", every_seconds: 120 }, no_change_backoff: true }); expect(created.ok).toBe(true);
183
+ expect(created.schedule.no_change_backoff).toBe(true);
184
+ const id = created.schedule.schedule_id;
185
+ expect((await mcp(nodes.b.token, "schedule_update", { schedule_id: id, no_change_backoff: false })).ok).toBe(false);
186
+ expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: id, no_change_backoff: false })).ok).toBe(true);
187
+ expect(state(id).no_change_backoff).toBe(0);
188
+ const human = await fire(); expect((await mcp(nodes.a.token, "schedule_update", { schedule_id: human.scheduleId, no_change_backoff: false })).ok).toBe(false);
189
+ db.run("UPDATE scheduled_tasks SET overlap_policy = 'allow' WHERE schedule_id = ?1", human.scheduleId);
190
+ const allow = await rest(ownerToken, "PATCH", `/api/scheduled-tasks/${human.scheduleId}`, { revision: state(human.scheduleId).revision, no_change_backoff: true });
191
+ expect(allow.status).toBe(400); expect(allow.body.error).toBe("no_change_backoff_requires_skip_overlap");
192
+ });
193
+ });
@@ -0,0 +1,64 @@
1
+ import { db } from "./db.js";
2
+
3
+ type WakeHook = (id: string, network: string, now: Date) => boolean;
4
+ let requirementWakeHook: WakeHook | null = null;
5
+ export function setRequirementWakeHook(hook: WakeHook): void { requirementWakeHook = hook; }
6
+
7
+ export function parseBackoffPolicy(raw: unknown, type: string, fallback = false): boolean {
8
+ if (raw !== undefined && typeof raw !== "boolean") throw new Error("invalid_no_change_backoff");
9
+ const enabled = raw === undefined ? fallback : raw as boolean;
10
+ if (enabled && type !== "interval") throw new Error("no_change_backoff_requires_interval");
11
+ return enabled;
12
+ }
13
+
14
+ /** SQLite only. Called in the reply/dispatch transaction; no catch-and-continue
15
+ * on state-write failure. Deriving a capped streak from authoritative runs
16
+ * makes duplicate or out-of-order callbacks unable to increment it twice. */
17
+ export function refreshScheduledBackoff(scheduleId: string, networkId: string, now = new Date()): void {
18
+ if (db.transactionalFeaturesRefusal !== null) return;
19
+ if (requirementWakeHook?.(scheduleId, networkId, now)) return;
20
+ const row = db.get<any>(`SELECT status, revision, target_node_id, schedule_json,
21
+ no_change_backoff, overlap_policy, requirement_wake_json, backoff_streak, backoff_effective_seconds,
22
+ backoff_last_run_id, next_run_at FROM scheduled_tasks
23
+ WHERE schedule_id = ?1 AND network_id = ?2`, scheduleId, networkId);
24
+ if (!row || row.status !== "active" || row.no_change_backoff !== 1 || row.overlap_policy !== "skip") return;
25
+ // A DB-only process with no watcher loaded may not bypass its fence.
26
+ if (row.requirement_wake_json && !requirementWakeHook) return;
27
+ const spec = JSON.parse(row.schedule_json);
28
+ if (spec.type !== "interval") return;
29
+ // Include failures/skips/unknowns: excluding them would turn separated
30
+ // successes into a fictitious consecutive empty sequence. Six is a cap.
31
+ const runs = db.all<any>(`SELECT r.run_id, r.status, r.work_outcome,
32
+ r.schedule_revision, r.work_outcome_node_id, r.backoff_excluded,
33
+ t.status AS task_status, t.to_node_id, t.from_name
34
+ FROM scheduled_task_runs r LEFT JOIN tasks t
35
+ ON t.task_id = r.task_id AND t.network_id = r.network_id
36
+ WHERE r.schedule_id = ?1 AND r.network_id = ?2
37
+ ORDER BY r.scheduled_for DESC, r.created_at DESC, r.run_id DESC LIMIT 6`, scheduleId, networkId);
38
+ const latest = runs[0];
39
+ if (!latest || latest.schedule_revision !== row.revision
40
+ || latest.run_id === row.backoff_last_run_id
41
+ || ["claiming", "queued", "delivered"].includes(latest.status)) return;
42
+ let streak = 0;
43
+ for (const run of runs) {
44
+ if (run.schedule_revision !== row.revision || run.status !== "replied"
45
+ || run.task_status !== "replied" || run.work_outcome !== "no_changes"
46
+ || run.work_outcome_node_id !== row.target_node_id || run.to_node_id !== row.target_node_id
47
+ || run.from_name !== "scheduler" || run.backoff_excluded !== 0) break;
48
+ streak++;
49
+ }
50
+ const effective = Math.max(spec.every_seconds, streak >= 6 ? 1800 : streak >= 3 ? 600 : 0);
51
+ const previous = row.backoff_effective_seconds ?? spec.every_seconds;
52
+ let next = row.next_run_at;
53
+ if (effective !== previous && next) {
54
+ const candidate = now.getTime() + effective * 1000;
55
+ next = new Date(effective > previous ? Math.max(Date.parse(next), candidate)
56
+ : Math.min(Date.parse(next), candidate)).toISOString();
57
+ }
58
+ const result = db.run(`UPDATE scheduled_tasks SET backoff_streak = ?1,
59
+ backoff_effective_seconds = ?2, backoff_last_run_id = ?3, next_run_at = ?4,
60
+ updated_at = datetime('now') WHERE schedule_id = ?5 AND network_id = ?6
61
+ AND revision = ?7 AND status = 'active' AND no_change_backoff = 1`,
62
+ [streak, effective, latest.run_id, next, scheduleId, networkId, row.revision]);
63
+ if (result.changes !== 1) throw new Error("scheduled_backoff_atomic_write_failed");
64
+ }
@@ -0,0 +1,139 @@
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 { refreshScheduledBackoff } from "./scheduled-backoff.js";
5
+ import { getScheduleRow, dispatchScheduledOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
6
+
7
+ if (!process.env.COMMHUB_DB) throw new Error("test1284 requires an explicit disposable SQLite COMMHUB_DB");
8
+ let server: any, base = "", network = "", ownerToken = "", ownerId = "";
9
+ const stamp = Date.now(), nodes: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
10
+ async function rest(token: string, method: string, path: string, body?: unknown) {
11
+ const r = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`,
12
+ "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
13
+ return { status: r.status, body: await r.json() as any };
14
+ }
15
+ async function mcp(token: string, name: string, args: Record<string, unknown>) {
16
+ const r = await fetch(base + "/mcp", { method: "POST", headers: { Authorization: `Bearer ${token}`,
17
+ "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
18
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
19
+ const raw = await r.text(), line = raw.split("\n").find(x => x.startsWith("data: "));
20
+ const envelope = JSON.parse(line ? line.slice(6) : raw);
21
+ try { return JSON.parse(envelope.result.content[0].text); }
22
+ catch { return { ok: false, transportError: true }; }
23
+ }
24
+ const readRun = (taskId: string) => db.get<any>(
25
+ "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)!;
26
+ const readTask = (taskId: string) => db.get<any>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId)!;
27
+ const countReplies = (taskId: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE in_reply_to = ?1 AND type = 'reply'", taskId)!.n;
28
+ async function fire(enabled = true, interval = 120) {
29
+ const created = await rest(ownerToken, "POST", "/api/scheduled-tasks", { network_id: network,
30
+ name: `test1284-${crypto.randomUUID()}`, target_node_id: nodes.a.id, task: "核对实际需求;Owner 原文包含 no_changes 字样。",
31
+ schedule: { type: "interval", every_seconds: interval }, timezone: "UTC", no_change_backoff: enabled });
32
+ expect(created.status).toBe(201);
33
+ const scheduleId = created.body.schedule.schedule_id;
34
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 86400_000).toISOString(), scheduleId]);
35
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1000).toISOString(), scheduleId]);
36
+ await runDueScheduledTasks();
37
+ const run = db.get<any>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!;
38
+ expect(run.task_id).toBeTruthy();
39
+ return { taskId: run.task_id as string, scheduleId };
40
+ }
41
+ const reply = (taskId: string, meta?: unknown, status = "replied", token = nodes.a.token, extra = {}) =>
42
+ mcp(token, "send_reply", { in_reply_to: taskId, text: "本轮无新增", status, ...(meta === undefined ? {} : { meta }), ...extra });
43
+ 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)!;
44
+
45
+ beforeAll(async () => {
46
+ const owner = register(`guidance_owner_${stamp}`, "Fixture1274Only!x"); ownerToken = owner.token!;
47
+ network = owner.network_id!; ownerId = owner.user!.user_id;
48
+ for (const key of ["a", "b"]) {
49
+ const id = `n_work_${key}_${stamp}`, alias = `work-${key}-${stamp}`;
50
+ const token = createNetworkTokenForNode(ownerId, network, alias, id); expect(token.ok).toBe(true);
51
+ nodes[key] = { id, alias, token: token.token!, tokenId: token.token_id! };
52
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
53
+ }
54
+ const mod = await import("./server.js"); server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
55
+ base = `http://127.0.0.1:${server.port}`;
56
+ }, 30_000);
57
+ afterAll(() => server?.stop?.(true));
58
+
59
+
60
+ 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 FROM scheduled_tasks WHERE schedule_id = ?1", id)!;
61
+ let occurrence = 0;
62
+ async function nextRun(id: string) {
63
+ // Isolated fixture accelerates the clock, without touching live schedules.
64
+ const date = new Date(Date.now() + (++occurrence) * 1000).toISOString();
65
+ const out = dispatchScheduledOccurrence(getScheduleRow(id)!, date, true);
66
+ expect(out.taskId).toBeTruthy(); return out.taskId!;
67
+ }
68
+
69
+ const marker = "[Agent Network / scheduled work outcome]";
70
+ const original = "核对实际需求;Owner 原文包含 no_changes 字样。";
71
+ async function received(taskId: string) {
72
+ const out = await mcp(nodes.a.token,"get_task",{task_id:taskId});
73
+ expect(out.ok).toBe(true); expect(out.task.task_id).toBe(taskId); return out.task;
74
+ }
75
+ async function syntheticProducer(taskId: string, verified: "changed" | "no_changes") {
76
+ const task = await received(taskId);
77
+ expect(task.content).toContain(marker);
78
+ expect(task.content).toContain(`in_reply_to="${taskId}"`);
79
+ // This fixture chooses a known synthetic observation. It is not an LLM
80
+ // classifier and never parses reply prose to manufacture an outcome.
81
+ return reply(taskId,{scheduled_work_outcome:verified});
82
+ }
83
+ describe("#1284 opted-in dispatch guidance and explicit synthetic producer",()=>{
84
+ test("disabled schedules retain exact Owner task bytes in both task and inbox",async()=>{
85
+ const {taskId,scheduleId}=await fire(false), task=await received(taskId);
86
+ expect(task.content).toBe(original);
87
+ expect(db.get<any>("SELECT content FROM inbox WHERE task_id=?1",taskId).content).toBe(original);
88
+ expect(getScheduleRow(scheduleId)!.task_content).toBe(original);
89
+ });
90
+ test("human opt-in dispatch exposes exact task binding but does not annotate automatically",async()=>{
91
+ const {taskId,scheduleId}=await fire(),task=await received(taskId);
92
+ expect(task.content.startsWith(original)).toBe(true);
93
+ expect(task.content).toContain(`in_reply_to="${taskId}"`);
94
+ expect(task.content).toContain('"scheduled_work_outcome":"changed"');
95
+ expect(task.content).toContain('"scheduled_work_outcome":"no_changes"');
96
+ expect(task.content).toContain("未核实、失败、超时、过期、跳过");
97
+ expect(task.content).toContain("保留未知");
98
+ expect(db.get<any>("SELECT content FROM inbox WHERE task_id=?1",taskId).content).toBe(task.content);
99
+ expect(getScheduleRow(scheduleId)!.task_content).toBe(original);
100
+ expect(readRun(taskId).work_outcome).toBeNull(); expect(countReplies(taskId)).toBe(0);
101
+ });
102
+ test("three read-guidance explicit empty reports back off, a changed report restores base",async()=>{
103
+ const {taskId,scheduleId}=await fire();expect((await syntheticProducer(taskId,"no_changes")).ok).toBe(true);
104
+ for(let i=0;i<2;i++)expect((await syntheticProducer(await nextRun(scheduleId),"no_changes")).ok).toBe(true);
105
+ expect(state(scheduleId)).toMatchObject({backoff_streak:3,backoff_effective_seconds:600});
106
+ const changed=await nextRun(scheduleId);expect((await syntheticProducer(changed,"changed")).ok).toBe(true);
107
+ expect(readRun(changed).work_outcome).toBe("changed");
108
+ expect(state(scheduleId)).toMatchObject({backoff_streak:0,backoff_effective_seconds:120});
109
+ });
110
+ test("normal successful prose-only reply stays unknown despite prompt and no_changes words",async()=>{
111
+ const {taskId,scheduleId}=await fire();await received(taskId);
112
+ expect((await reply(taskId)).ok).toBe(true);expect(readRun(taskId).work_outcome).toBeNull();expect(state(scheduleId).backoff_streak).toBe(0);
113
+ });
114
+ test("guidance cannot turn a failed result into successful empty",async()=>{
115
+ const {taskId,scheduleId}=await fire();await received(taskId);
116
+ expect((await reply(taskId,{scheduled_work_outcome:"no_changes"},"failed")).ok).not.toBe(true);
117
+ expect(readTask(taskId).status).toBe("delivered");expect(countReplies(taskId)).toBe(0);
118
+ expect((await reply(taskId,undefined,"failed")).ok).toBe(true);expect(readRun(taskId).work_outcome).toBeNull();expect(state(scheduleId).backoff_streak).toBe(0);
119
+ });
120
+ test("revalidated persisted opt-in wins over a stale caller row",async()=>{
121
+ const {scheduleId,taskId}=await fire();expect((await reply(taskId)).ok).toBe(true);
122
+ const edit=await rest(ownerToken,"PATCH",`/api/scheduled-tasks/${scheduleId}`,{revision:state(scheduleId).revision,no_change_backoff:false});expect(edit.status).toBe(200);
123
+ const row={...getScheduleRow(scheduleId)!,no_change_backoff:1};
124
+ const out=dispatchScheduledOccurrence(row,new Date(Date.now()+(++occurrence)*1000).toISOString(),true);expect(out.taskId).toBeTruthy();
125
+ expect((await received(out.taskId!)).content).toBe(original);
126
+ });
127
+ test("agent-created schedules keep creator provenance ahead of Owner content and guidance",async()=>{
128
+ const made=await mcp(nodes.b.token,"schedule_create",{name:"agent guidance1284",task:original,target_node_id:nodes.a.id,no_change_backoff:true,schedule:{type:"interval",every_seconds:120}});expect(made.ok).toBe(true);
129
+ const id=made.schedule.schedule_id,taskId=await nextRun(id),task=await received(taskId);
130
+ expect(task.content).toContain(nodes.b.alias);expect(task.content.indexOf(nodes.b.alias)).toBeLessThan(task.content.indexOf(original));expect(task.content.indexOf(original)).toBeLessThan(task.content.indexOf(marker));
131
+ expect((await syntheticProducer(taskId,"changed")).ok).toBe(true);expect(readRun(taskId).work_outcome).toBe("changed");
132
+ });
133
+ test("another target cannot reuse the advertised binding; duplicate normal reply never counts twice",async()=>{
134
+ const {taskId,scheduleId}=await fire();await received(taskId);
135
+ expect((await reply(taskId,{scheduled_work_outcome:"no_changes"},"replied",nodes.b.token)).ok).not.toBe(true);
136
+ expect(readRun(taskId).work_outcome).toBeNull();expect((await syntheticProducer(taskId,"no_changes")).ok).toBe(true);
137
+ const before=state(scheduleId);expect((await reply(taskId,{scheduled_work_outcome:"no_changes"})).ok).not.toBe(true);expect(state(scheduleId)).toEqual(before);expect(countReplies(taskId)).toBe(1);
138
+ });
139
+ });
@@ -0,0 +1,9 @@
1
+ /** Dispatch guidance is not a classifier. Only an authenticated, successful
2
+ * producer's explicit reply may record an outcome; omissions stay unknown. */
3
+ export function scheduledOutcomeGuidance(taskId: string): string {
4
+ return `\n\n[Agent Network / scheduled work outcome]\n` +
5
+ `此计划已显式启用空转退避。完成本轮实际工作并核验范围后,正常 send_reply 回原任务时使用 in_reply_to="${taskId}"。保留工具要求的原收件人、认证身份及 thread/turn 绑定。\n` +
6
+ `仅当本轮正常成功且确认有实质变更,添加 meta: {"scheduled_work_outcome":"changed"};仅当正常成功且完成范围检查、确认没有实质变更,添加 meta: {"scheduled_work_outcome":"no_changes"}。\n` +
7
+ `未核实、失败、超时、过期、跳过或仅在等待输入/权限时,不自报 no_changes;不要从回复措辞或没有工具调用推断结果。缺少支持该字段的正常回复工具时保留未知,由原回复链路收口。\n` +
8
+ `此字段是本节点自报,不是独立完成证明。不要用 send_task/send_message 或额外消息补记结果,不要为了退避伪造成功或改动计划频率。`;
9
+ }