@sleep2agi/commhub-server 0.9.0-preview.65 → 0.9.0-preview.67

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.
@@ -45,7 +45,7 @@ const GOLDEN_RESPONSE_KEYS = {
45
45
  "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
46
46
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
47
47
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
48
- "external_schedules", "rules_file_capable", "skills_capable", "files_capable", "runtime", "host", "process_telemetry",
48
+ "external_schedules", "rules_file_capable", "skills_capable", "files_capable", "logs_capable", "runtime", "host", "process_telemetry",
49
49
  ],
50
50
  task: [
51
51
  "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
@@ -20,6 +20,7 @@ export const SESSION_REST_COLUMNS = [
20
20
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
21
21
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
22
22
  "external_schedules", "rules_file_capable", "skills_capable", "files_capable",
23
+ "logs_capable",
23
24
  ] as const;
24
25
 
25
26
  export const AUDIT_LOG_REST_COLUMNS = [
@@ -0,0 +1,130 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { createSSEStream } from "./push.js";
4
+ import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
5
+
6
+ // A claude-code node's channel server reports status without node_id, so its
7
+ // sessions row has node_id NULL. The scheduler used to look the session up by
8
+ // node_id only, judged every occurrence "queued" and never rang the doorbell:
9
+ // the task sat in the inbox until an unrelated push drained it (20–36 min in
10
+ // production).
11
+
12
+ const NET = "net_sched_doorbell_nodeless";
13
+ const NODE = "n_sched_doorbell_cc";
14
+ const ALIAS = "sched-doorbell-cc";
15
+
16
+ function cleanup(): void {
17
+ for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes"]) {
18
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
19
+ }
20
+ }
21
+
22
+ function seedNode(): void {
23
+ db.run(
24
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
25
+ VALUES (?1, ?2, ?2, 'claude-code-cli', ?3, 'active')`,
26
+ [NODE, ALIAS, NET],
27
+ );
28
+ }
29
+
30
+ function seedSession(status: string, nodeId: string | null): void {
31
+ db.run(
32
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, agent, updated_at, last_seen_at)
33
+ VALUES (?1, ?2, ?3, ?4, ?5, 'claude-code', datetime('now'), datetime('now'))`,
34
+ [`cc-${crypto.randomUUID()}`, ALIAS, status, nodeId, NET],
35
+ );
36
+ }
37
+
38
+ function seedSchedule(): any {
39
+ const scheduleId = `sched_${crypto.randomUUID()}`;
40
+ db.run(
41
+ `INSERT INTO scheduled_tasks
42
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
43
+ schedule_type, schedule_json, timezone, status, next_run_at)
44
+ VALUES (?1, ?2, 'doorbell', ?3, ?4, 'scheduled doorbell task', 'normal', 'interval',
45
+ '{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
46
+ [scheduleId, NET, NODE, ALIAS],
47
+ );
48
+ return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
49
+ }
50
+
51
+ /** Subscribe like the channel server does and collect decoded SSE frames. */
52
+ function subscribe(): { frames: any[]; close: () => Promise<void> } {
53
+ const res = createSSEStream(ALIAS, NET);
54
+ const reader = res.body!.getReader();
55
+ const decoder = new TextDecoder();
56
+ const frames: any[] = [];
57
+ let buffer = "";
58
+ let open = true;
59
+ (async () => {
60
+ while (open) {
61
+ const { done, value } = await reader.read();
62
+ if (done) break;
63
+ buffer += decoder.decode(value, { stream: true });
64
+ const blocks = buffer.split("\n\n");
65
+ buffer = blocks.pop() || "";
66
+ for (const block of blocks) {
67
+ const line = block.split("\n").find((l) => l.startsWith("data: "));
68
+ if (line) frames.push(JSON.parse(line.slice(6)));
69
+ }
70
+ }
71
+ })().catch(() => {});
72
+ return {
73
+ frames,
74
+ close: async () => {
75
+ open = false;
76
+ await reader.cancel().catch(() => {});
77
+ },
78
+ };
79
+ }
80
+
81
+ async function settle(): Promise<void> {
82
+ await new Promise((resolve) => setTimeout(resolve, 20));
83
+ }
84
+
85
+ beforeEach(() => {
86
+ cleanup();
87
+ seedNode();
88
+ });
89
+
90
+ afterAll(cleanup);
91
+
92
+ describe("scheduler doorbell for sessions reported without node_id", () => {
93
+ test("idle node_id-less session is delivered and its subscriber receives new_task", async () => {
94
+ seedSession("idle", null);
95
+ const sub = subscribe();
96
+ try {
97
+ await settle();
98
+ const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
99
+ await settle();
100
+ expect(result.status).toBe("delivered");
101
+ expect(db.get<{ status: string }>("SELECT status FROM scheduled_task_runs WHERE run_id = ?1", result.runId)?.status).toBe("delivered");
102
+ const wake = sub.frames.filter((f) => f.type === "new_task");
103
+ expect(wake).toHaveLength(1);
104
+ expect(wake[0].from).toBe("scheduler");
105
+ expect(wake[0].inbox_count).toBe(1);
106
+ } finally {
107
+ await sub.close();
108
+ }
109
+ });
110
+
111
+ test("a live subscriber counts even when the session row says offline", async () => {
112
+ seedSession("offline", null);
113
+ const sub = subscribe();
114
+ try {
115
+ await settle();
116
+ const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
117
+ await settle();
118
+ expect(result.status).toBe("delivered");
119
+ expect(sub.frames.filter((f) => f.type === "new_task")).toHaveLength(1);
120
+ } finally {
121
+ await sub.close();
122
+ }
123
+ });
124
+
125
+ test("offline session with nobody listening stays queued", () => {
126
+ seedSession("offline", null);
127
+ const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
128
+ expect(result.status).toBe("queued");
129
+ });
130
+ });
@@ -0,0 +1,120 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
4
+
5
+ // A skip used to record only error_code=previous_run_active. The owner saw
6
+ // dozens of identical 「已跳过」 rows while the node sat idle, because the
7
+ // open task had never reached it. The skip record now names the blocking task
8
+ // and whether the node had picked it up.
9
+
10
+ const NET = "net_sched_skip_blocking";
11
+ const NODE = "n_sched_skip_blocking";
12
+ const ALIAS = "sched-skip-blocking";
13
+
14
+ function cleanup(): void {
15
+ for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes"]) {
16
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
17
+ }
18
+ }
19
+
20
+ function seedSchedule(): any {
21
+ const scheduleId = `sched_${crypto.randomUUID()}`;
22
+ db.run(
23
+ `INSERT INTO scheduled_tasks
24
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
25
+ schedule_type, schedule_json, timezone, status, next_run_at)
26
+ VALUES (?1, ?2, 'skip detail', ?3, ?4, 'scheduled task', 'normal', 'interval',
27
+ '{"type":"interval","every_seconds":60}', 'UTC', 'active', datetime('now', '+1 minute'))`,
28
+ [scheduleId, NET, NODE, ALIAS],
29
+ );
30
+ return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
31
+ }
32
+
33
+ function run(runId: string): any {
34
+ return db.get(
35
+ "SELECT status, error_code, error_message, blocked_by_task_id, blocked_by_state FROM scheduled_task_runs WHERE run_id = ?1",
36
+ runId,
37
+ );
38
+ }
39
+
40
+ beforeEach(() => {
41
+ cleanup();
42
+ db.run(
43
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
44
+ VALUES (?1, ?2, ?2, 'codex-sdk', ?3, 'active')`,
45
+ [NODE, ALIAS, NET],
46
+ );
47
+ db.run(
48
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
49
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
50
+ [`r_${crypto.randomUUID()}`, ALIAS, NODE, NET],
51
+ );
52
+ });
53
+
54
+ afterAll(cleanup);
55
+
56
+ describe("skipped run names the task that blocked it", () => {
57
+ test("open task still unacked in the inbox → not_received", () => {
58
+ const schedule = seedSchedule();
59
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T08:00:00.000Z", false);
60
+ expect(first.status).toBe("delivered");
61
+
62
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T08:01:00.000Z", false);
63
+ expect(second.status).toBe("skipped");
64
+ const row = run(second.runId);
65
+ expect(row.error_code).toBe("previous_run_active");
66
+ expect(row.blocked_by_task_id).toBe(first.taskId);
67
+ expect(row.blocked_by_state).toBe("not_received");
68
+ expect(row.error_message).toContain(first.taskId!);
69
+ expect(row.error_message).toContain("not been received");
70
+ });
71
+
72
+ test("open task whose inbox row the node acked → in_progress", () => {
73
+ const schedule = seedSchedule();
74
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T09:00:00.000Z", false);
75
+ db.run("UPDATE inbox SET acked = 1 WHERE task_id = ?1", [first.taskId]);
76
+
77
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T09:01:00.000Z", false);
78
+ expect(second.status).toBe("skipped");
79
+ const row = run(second.runId);
80
+ expect(row.blocked_by_task_id).toBe(first.taskId);
81
+ expect(row.blocked_by_state).toBe("in_progress");
82
+ expect(row.error_message).toContain("still in progress");
83
+ });
84
+
85
+ test("task status running counts as received even without an acked inbox row", () => {
86
+ const schedule = seedSchedule();
87
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T10:00:00.000Z", false);
88
+ db.run("UPDATE tasks SET status = 'running' WHERE task_id = ?1", [first.taskId]);
89
+
90
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T10:01:00.000Z", false);
91
+ expect(run(second.runId).blocked_by_state).toBe("in_progress");
92
+ });
93
+
94
+ test("GET /runs exposes the blocking task and state", async () => {
95
+ const schedule = seedSchedule();
96
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T12:00:00.000Z", false);
97
+ const second = dispatchScheduledOccurrence(schedule, "2026-09-29T12:01:00.000Z", false);
98
+ const url = new URL(`http://hub.test/api/scheduled-tasks/${schedule.schedule_id}/runs`);
99
+ const res = await handleScheduledTaskRequest({
100
+ req: new Request(url, { method: "GET" }),
101
+ url,
102
+ auth: { userId: "u_skip_detail", networkId: NET, username: "skip-detail" },
103
+ isAdmin: false,
104
+ isNodeToken: false,
105
+ scope: { networkId: NET, networkIds: [NET] },
106
+ });
107
+ const body = await res!.json() as any;
108
+ const skipped = body.runs.find((r: any) => r.run_id === second.runId);
109
+ expect(skipped.blocked_by_task_id).toBe(first.taskId);
110
+ expect(skipped.blocked_by_state).toBe("not_received");
111
+ });
112
+
113
+ test("a delivered run carries no blocking fields", () => {
114
+ const schedule = seedSchedule();
115
+ const first = dispatchScheduledOccurrence(schedule, "2026-09-29T11:00:00.000Z", false);
116
+ const row = run(first.runId);
117
+ expect(row.blocked_by_task_id).toBeNull();
118
+ expect(row.blocked_by_state).toBeNull();
119
+ });
120
+ });
@@ -431,6 +431,96 @@ describe("Hub scheduled task API and dispatcher", () => {
431
431
  expect(["Concurrent editor A", "Concurrent editor B"]).toContain(latest.name);
432
432
  });
433
433
 
434
+ // Owner report 2026-09-29: editing a 2-minute schedule failed with
435
+ // revision_conflict whenever a run landed while the dialog was open, and the
436
+ // client dropped the draft. Runs are bookkeeping, not edits.
437
+ test("a scheduler run between GET and PATCH does not conflict with the edit", async () => {
438
+ const created = await api(ownerToken, "/api/scheduled-tasks", {
439
+ method: "POST",
440
+ body: JSON.stringify({ network_id: networkId, name: "Run-during-edit", target_node_id: nodeId, task: "before edit", timezone: "Asia/Shanghai", schedule: { type: "interval", every_seconds: 120 } }),
441
+ });
442
+ expect(created.status).toBe(201);
443
+ const id = created.body.schedule.schedule_id;
444
+ const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
445
+ const opened = (await api(ownerToken, path)).body.schedule;
446
+
447
+ const firedAt = new Date(Date.now() - 1_000).toISOString();
448
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
449
+ runDueScheduledTasks();
450
+ const afterRun = (await api(ownerToken, path)).body.schedule;
451
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1", id)!.n).toBe(1);
452
+ expect(afterRun.last_run_at).toBe(firedAt);
453
+ expect(new Date(afterRun.next_run_at).getTime()).toBeGreaterThan(Date.now());
454
+ expect(afterRun.revision).toBe(opened.revision);
455
+
456
+ const saved = await api(ownerToken, path, {
457
+ method: "PATCH",
458
+ body: JSON.stringify({ revision: opened.revision, task: "edited while it ran", name: opened.name, schedule: opened.schedule, timezone: opened.timezone }),
459
+ });
460
+ expect(saved.status).toBe(200);
461
+ expect(saved.body.schedule.task_content).toBe("edited while it ran");
462
+ expect(saved.body.schedule.revision).toBe(opened.revision + 1);
463
+ // Cadence edit-preservation must keep the scheduler's advanced slot, not
464
+ // resurrect the occurrence that already fired.
465
+ expect(saved.body.schedule.next_run_at).toBe(afterRun.next_run_at);
466
+
467
+ // A second device still holding the pre-edit revision is a true conflict.
468
+ const stale = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "other device" }) });
469
+ expect(stale.status).toBe(409);
470
+ expect(stale.body.error).toBe("revision_conflict");
471
+ await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
472
+ });
473
+
474
+ test("a run that lands while the PATCH body is in flight keeps the advanced next_run_at", async () => {
475
+ const created = await api(ownerToken, "/api/scheduled-tasks", {
476
+ method: "POST",
477
+ body: JSON.stringify({ network_id: networkId, name: "Run-during-body", target_node_id: nodeId, task: "before", timezone: "Asia/Shanghai", schedule: { type: "interval", every_seconds: 120 } }),
478
+ });
479
+ const id = created.body.schedule.schedule_id;
480
+ const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
481
+ const firedAt = new Date(Date.now() - 1_000).toISOString();
482
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
483
+ const opened = (await api(ownerToken, path)).body.schedule;
484
+
485
+ // The handler reads the row before awaiting the body. Hold the body open,
486
+ // run the scheduler, then release it — the PATCH now carries a stale
487
+ // row.next_run_at (the slot that just fired).
488
+ let release!: () => void;
489
+ const gate = new Promise<void>((r) => { release = r; });
490
+ const payload = new TextEncoder().encode(JSON.stringify({ revision: opened.revision, task: "edited mid-run" }));
491
+ const body = new ReadableStream({ async pull(c) { await gate; c.enqueue(payload); c.close(); } });
492
+ const pending = fetch(`${base}${path}`, { method: "PATCH", body, duplex: "half", headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" } } as any);
493
+ await new Promise((r) => setTimeout(r, 100));
494
+ runDueScheduledTasks();
495
+ const advanced = db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", id)!.next_run_at;
496
+ expect(advanced).not.toBe(firedAt);
497
+ release();
498
+ const res = await pending;
499
+ const saved = await res.json() as any;
500
+ expect(res.status).toBe(200);
501
+ expect(saved.schedule.task_content).toBe("edited mid-run");
502
+ expect(saved.schedule.next_run_at).toBe(advanced);
503
+ await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
504
+ });
505
+
506
+ test("one-shot auto-complete is a user-visible status change and still bumps revision", async () => {
507
+ const created = await api(ownerToken, "/api/scheduled-tasks", {
508
+ method: "POST",
509
+ body: JSON.stringify({ network_id: networkId, name: "One-shot", target_node_id: nodeId, task: "once", timezone: "Asia/Shanghai", schedule: { type: "once", run_at: new Date(Date.now() + 3_600_000).toISOString() } }),
510
+ });
511
+ const id = created.body.schedule.schedule_id;
512
+ const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
513
+ const opened = (await api(ownerToken, path)).body.schedule;
514
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1_000).toISOString(), id]);
515
+ runDueScheduledTasks();
516
+ const done = (await api(ownerToken, path)).body.schedule;
517
+ expect(done.status).toBe("completed");
518
+ expect(done.revision).toBe(opened.revision + 1);
519
+ const edit = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "too late" }) });
520
+ expect(edit.status).toBe(409);
521
+ expect(edit.body.error).toBe("schedule_completed");
522
+ });
523
+
434
524
  test("optimistic revision, pause/resume, run-now and cancel preserve history", async () => {
435
525
  const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`);
436
526
  revision = latest.body.schedule.revision;
@@ -1,7 +1,7 @@
1
1
  import { db, logTaskEvent } from "./db.js";
2
2
  import { assertNodeActive } from "./lifecycle-guard.js";
3
3
  import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
4
- import { pushEvent, pushNetworkObserverEvent } from "./push.js";
4
+ import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
5
5
  import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
6
6
 
7
7
  export type ScheduleSpec =
@@ -219,8 +219,15 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
219
219
 
220
220
  if (row.overlap_policy === "skip") {
221
221
  const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
222
- const open = db.get<{ task_id: string }>(
223
- `SELECT r.task_id FROM scheduled_task_runs r
222
+ // received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
223
+ // 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
224
+ // 后者才是真的上一次还没做完。
225
+ const open = db.get<{ task_id: string; received: number }>(
226
+ `SELECT r.task_id,
227
+ CASE WHEN t.status IN ('acked', 'running')
228
+ OR EXISTS (SELECT 1 FROM inbox i WHERE (i.task_id = r.task_id OR i.id = r.task_id) AND i.acked = 1)
229
+ THEN 1 ELSE 0 END AS received
230
+ FROM scheduled_task_runs r
224
231
  JOIN tasks t ON t.task_id = r.task_id
225
232
  WHERE r.schedule_id = ?1 AND t.status IN (${placeholders})
226
233
  ORDER BY r.created_at DESC LIMIT 1`,
@@ -228,9 +235,18 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
228
235
  );
229
236
  if (open) {
230
237
  finalStatus = "skipped";
238
+ const blockedState = open.received ? "in_progress" : "not_received";
231
239
  db.run(
232
- "UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'previous_run_active', completed_at = datetime('now') WHERE run_id = ?1",
233
- [runId],
240
+ `UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'previous_run_active', error_message = ?2,
241
+ blocked_by_task_id = ?3, blocked_by_state = ?4, completed_at = datetime('now') WHERE run_id = ?1`,
242
+ [
243
+ runId,
244
+ blockedState === "not_received"
245
+ ? `previous run task ${open.task_id} has not been received by the node yet`
246
+ : `previous run task ${open.task_id} is still in progress on the node`,
247
+ open.task_id,
248
+ blockedState,
249
+ ],
234
250
  );
235
251
  if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
236
252
  return;
@@ -262,11 +278,19 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
262
278
  }
263
279
 
264
280
  const taskId = crypto.randomUUID();
281
+ // claude-code 裸节点的 node-server 上报 report_status 不带 node_id,它的 sessions 行
282
+ // node_id 永远是 NULL —— 只按 node_id 找会话,这类节点每一次都判成 queued、门铃不响,
283
+ // 任务在 inbox 里等到别的消息顺带捎进去(生产上 20–36 分钟)。按 node_id 找不到时退回
284
+ // 同网同别名、node_id 为空的会话;SSE 订阅者注册表是「有没有人在听」的实测,也算数。
265
285
  const session = db.get<{ status: string | null }>(
266
286
  "SELECT status FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
267
287
  row.target_node_id, row.network_id,
288
+ ) ?? db.get<{ status: string | null }>(
289
+ "SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
290
+ node.alias, row.network_id,
268
291
  );
269
- const deliveryState: "delivered" | "queued" = session && session.status !== "offline" ? "delivered" : "queued";
292
+ const deliveryState: "delivered" | "queued" =
293
+ (session && session.status !== "offline") || hasSubscribers(node.alias, row.network_id) ? "delivered" : "queued";
270
294
  const metaJson = JSON.stringify({
271
295
  scheduled_task_id: row.schedule_id,
272
296
  scheduled_run_id: runId,
@@ -310,6 +334,12 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
310
334
  return { runId, taskId: createdTaskId, status: finalStatus, event };
311
335
  }
312
336
 
337
+ // `revision` is the optimistic-concurrency token for *user edits*. Run
338
+ // bookkeeping (next_run_at/last_run_at) must not bump it: a 2-minute schedule
339
+ // would otherwise turn every edit that spans a run into a 409 and the client
340
+ // would drop the user's draft. Terminal completion still bumps, because it is
341
+ // a status change the user sees and it is what stops an in-flight PATCH (whose
342
+ // UPDATE is guarded by `AND revision = ?`) from reviving a finished one-shot.
313
343
  function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), recordLastRun = true): void {
314
344
  const spec = JSON.parse(row.schedule_json) as ScheduleSpec;
315
345
  const next = nextOccurrence(spec, row.timezone, after);
@@ -321,7 +351,7 @@ function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), re
321
351
  );
322
352
  } else {
323
353
  db.run(
324
- "UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?3",
354
+ "UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3",
325
355
  [iso(next), lastRunAt, row.schedule_id],
326
356
  );
327
357
  }
@@ -461,7 +491,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
461
491
  if (sub === "runs" && req.method === "GET") {
462
492
  const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
463
493
  const runs = db.all(
464
- "SELECT run_id, schedule_id, scheduled_for, task_id, status, error_code, error_message, created_at, completed_at FROM scheduled_task_runs WHERE schedule_id = ?1 AND network_id = ?2 ORDER BY created_at DESC LIMIT ?3",
494
+ "SELECT run_id, schedule_id, scheduled_for, task_id, status, error_code, error_message, blocked_by_task_id, blocked_by_state, created_at, completed_at FROM scheduled_task_runs WHERE schedule_id = ?1 AND network_id = ?2 ORDER BY created_at DESC LIMIT ?3",
465
495
  row.schedule_id, row.network_id, limit,
466
496
  );
467
497
  return Response.json({ ok: true, runs });
@@ -532,11 +562,16 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
532
562
  ? nextOccurrence(parsed.spec, parsed.timezone, new Date())
533
563
  : new Date(row.next_run_at);
534
564
  if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
565
+ // Runs no longer bump revision, so the scheduler may have advanced
566
+ // next_run_at after `row` was read. When cadence is preserved, keep the
567
+ // stored value instead of writing back the already-fired occurrence.
568
+ const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
535
569
  const updated = db.run(
536
570
  `UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
537
- priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9, next_run_at = ?10,
571
+ priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
572
+ next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
538
573
  misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
539
- [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],
574
+ [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],
540
575
  );
541
576
  if (updated.changes !== 1) return jsonError("revision_conflict", 409);
542
577
  return Response.json({ ok: true, schedule: decodeRow(db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, row.schedule_id)!) });
package/src/server.ts CHANGED
@@ -1707,6 +1707,7 @@ return Bun.serve({
1707
1707
  rules_file_capable: s.rules_file_capable === 1,
1708
1708
  skills_capable: s.skills_capable === 1,
1709
1709
  files_capable: s.files_capable === 1,
1710
+ logs_capable: s.logs_capable === 1,
1710
1711
  model: s.model ?? null,
1711
1712
  runtime: normalizeRuntime(s.agent),
1712
1713
  host: {