@yeaft/webchat-agent 1.0.649 → 1.0.650

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.649",
3
+ "version": "1.0.650",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,74 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { initialOccurrence, nextOccurrence, normalizeRecurrence, validateScheduleTimestamp } from '../work-center/recurrence.js';
3
+
4
+ const FIELDS = ['prompt', 'enabled', 'notify', 'trigger'];
5
+
6
+ function canonical(value) {
7
+ if (Array.isArray(value)) return value.map(canonical);
8
+ if (value && typeof value === 'object') {
9
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
10
+ }
11
+ return value;
12
+ }
13
+
14
+ function configKey(task) {
15
+ return JSON.stringify(canonical({ prompt: task.prompt, enabled: task.enabled,
16
+ notify: task.notify !== false, trigger: task.trigger }));
17
+ }
18
+
19
+ /** Legacy definitions get a stable config-only revision without a read-time disk write. */
20
+ export function scheduledTaskRevision(task) {
21
+ if (typeof task._revision === 'string' && task._revision) return task._revision;
22
+ return `legacy:${createHash('sha256').update(configKey(task)).digest('hex')}`;
23
+ }
24
+
25
+ /** Validate external task definitions only; runtime fields are never writable over the wire. */
26
+ export function normalizeScheduledTask(value, previous = null) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('task must be an object');
28
+ if (Object.keys(value).some(key => !FIELDS.includes(key))) throw new Error('Unknown or read-only task field');
29
+ const input = previous ? { ...publicScheduledTask(previous), ...value } : value;
30
+ if (typeof input.prompt !== 'string' || !input.prompt.trim() || input.prompt.length > 65536) {
31
+ throw new Error('task.prompt must be non-empty text of at most 65536 characters');
32
+ }
33
+ if (typeof input.enabled !== 'boolean') throw new Error('task.enabled must be a boolean');
34
+ if (input.notify !== undefined && typeof input.notify !== 'boolean') throw new Error('task.notify must be a boolean');
35
+ const trigger = input.trigger;
36
+ if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) throw new Error('Invalid task.trigger');
37
+ let normalizedTrigger;
38
+ if (trigger.type === 'manual') {
39
+ if (Object.keys(trigger).some(key => key !== 'type')) throw new Error('Unknown manual trigger field');
40
+ normalizedTrigger = { type: 'manual' };
41
+ } else if (trigger.type === 'schedule') {
42
+ if (Object.keys(trigger).some(key => !['type', 'scheduledFor', 'recurrence'].includes(key))) throw new Error('Unknown schedule trigger field');
43
+ normalizedTrigger = { type: 'schedule', scheduledFor: validateScheduleTimestamp(trigger.scheduledFor),
44
+ recurrence: normalizeRecurrence(trigger.recurrence) };
45
+ } else throw new Error('Invalid task.trigger type');
46
+ const next = { prompt: input.prompt.trim(), enabled: input.enabled, notify: input.notify ?? true,
47
+ trigger: normalizedTrigger, nextRunAt: null, lastRun: previous?.lastRun ?? null };
48
+ const sameTrigger = previous && JSON.stringify(previous.trigger) === JSON.stringify(next.trigger);
49
+ if (sameTrigger) {
50
+ next._scheduledRuns = previous._scheduledRuns || 0;
51
+ next.nextRunAt = previous.nextRunAt;
52
+ } else {
53
+ next.nextRunAt = next.trigger.type === 'schedule'
54
+ ? initialOccurrence(next.trigger.recurrence, next.trigger.scheduledFor) : null;
55
+ }
56
+ if (next.trigger.recurrence?.endsAt != null && next.nextRunAt > next.trigger.recurrence.endsAt) next.nextRunAt = null;
57
+ next._revision = previous && configKey(previous) === configKey(next) ? scheduledTaskRevision(previous) : randomUUID();
58
+ return next;
59
+ }
60
+
61
+ /** Explicit, idempotent projection keeps durable scheduler bookkeeping out of Session snapshots. */
62
+ export function publicScheduledTask(task) {
63
+ return { prompt: task.prompt, enabled: task.enabled, notify: task.notify !== false,
64
+ trigger: structuredClone(task.trigger), nextRunAt: task.nextRunAt ?? null,
65
+ lastRun: task.lastRun ? structuredClone(task.lastRun) : null,
66
+ revision: typeof task.revision === 'string' && !task._revision ? task.revision : scheduledTaskRevision(task) };
67
+ }
68
+
69
+ export function advanceScheduledTask(task, after) {
70
+ const recurrence = task.trigger.recurrence;
71
+ if (!recurrence || (recurrence.maxRuns != null && (task._scheduledRuns || 0) >= recurrence.maxRuns)) return null;
72
+ const next = nextOccurrence(recurrence, after);
73
+ return next != null && (recurrence.endsAt == null || next <= recurrence.endsAt) ? next : null;
74
+ }
@@ -0,0 +1,257 @@
1
+ import { lstatSync, readdirSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { validateSessionId } from './ids.js';
5
+ import { loadSessionMeta, SESSION_META_FILE } from './session-store.js';
6
+ import { writeAtomic } from '../storage/atomic.js';
7
+ import { advanceScheduledTask, normalizeScheduledTask, publicScheduledTask } from './scheduled-task-definition.js';
8
+
9
+ // Internal-only ingress fence. Browser JSON cannot supply or bypass this hook.
10
+ export const SCHEDULED_TASK_DISPATCH_GUARD = Symbol('scheduled-task-dispatch-guard');
11
+ const POLL_MS = 1000;
12
+
13
+ /** Instance-owned scheduler. All metadata transitions are synchronous, fresh-read,
14
+ * atomic replacements; no openSession handle can create a deleted directory.
15
+ * dispatch only accepts/routes input. isActive owns the full Session lifecycle,
16
+ * including fanout, route-forward, pending classifications and model task reentry.
17
+ */
18
+ export class ScheduledTaskScheduler {
19
+ constructor({ yeaftDir, dispatch, isActive, canDispatch = () => true, onChange = () => {}, notify = () => {}, now = Date.now,
20
+ warn = error => console.warn('[ScheduledTasks]', error?.message || error) }) {
21
+ this.yeaftDir = resolve(yeaftDir);
22
+ this.dispatch = dispatch;
23
+ this.isActive = isActive;
24
+ this.canDispatch = canDispatch;
25
+ this.waitingForContext = new Set();
26
+ this.onChange = onChange;
27
+ this.notify = notify;
28
+ this.now = now;
29
+ this.warn = warn;
30
+ this.runs = new Map();
31
+ this.retryAfter = new Map();
32
+ this.timer = null;
33
+ }
34
+
35
+ read(sessionId) {
36
+ if (!validateSessionId(sessionId).ok) throw new Error('Invalid Session id');
37
+ const root = join(this.yeaftDir, 'sessions');
38
+ const dir = join(root, sessionId);
39
+ const path = join(dir, SESSION_META_FILE);
40
+ try {
41
+ const rootStat = lstatSync(root), stat = lstatSync(dir), fileStat = lstatSync(path);
42
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || !stat.isDirectory()
43
+ || stat.isSymbolicLink() || !fileStat.isFile() || fileStat.isSymbolicLink()) throw new Error('Invalid Session path');
44
+ const meta = loadSessionMeta(dir);
45
+ if (!meta || meta.id !== sessionId) throw new Error('Session not found');
46
+ return { meta, path, identity: `${stat.dev}:${stat.ino}:${meta.createdAt}` };
47
+ } catch { throw new Error('Session not found or unsafe Session path'); }
48
+ }
49
+
50
+ ids() {
51
+ const root = join(this.yeaftDir, 'sessions');
52
+ try {
53
+ const stat = lstatSync(root);
54
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return [];
55
+ return readdirSync(root).filter(id => validateSessionId(id).ok);
56
+ } catch (error) {
57
+ if (error.code !== 'ENOENT') this.warn(error);
58
+ return [];
59
+ }
60
+ }
61
+
62
+ save(row) {
63
+ // Recheck identity rather than creating directories if deletion/recreation won.
64
+ if (this.read(row.meta.id).identity !== row.identity) throw new Error('Session changed');
65
+ row.meta.metadataUpdatedAt = new Date(this.now()).toISOString();
66
+ writeAtomic(row.path, `${JSON.stringify(row.meta, null, 2)}\n`);
67
+ }
68
+
69
+ changed() {
70
+ try { this.onChange(); } catch (error) { this.warn(error); }
71
+ }
72
+
73
+ snapshot(meta) {
74
+ return { ...meta, ...(meta.task ? { task: publicScheduledTask(meta.task) } : {}) };
75
+ }
76
+
77
+ /** Compare the caller's config revision against fresh metadata, never runtime state. */
78
+ update(sessionId, patch, expectedRevision) {
79
+ const row = this.read(sessionId);
80
+ if (!row.meta.task) throw new Error('Session has no task');
81
+ if (typeof expectedRevision !== 'string' || expectedRevision !== row.meta.task._revision) {
82
+ throw Object.assign(new Error('Task changed; refresh before editing'), { code: 'task_conflict' });
83
+ }
84
+ row.meta.task = normalizeScheduledTask(patch, row.meta.task);
85
+ this.save(row);
86
+ this.changed();
87
+ return this.snapshot(row.meta);
88
+ }
89
+
90
+ start() {
91
+ if (this.timer) return this;
92
+ for (const id of this.ids()) {
93
+ try { this.recover(this.read(id), true); } catch (error) { this.backoff(id, error); }
94
+ }
95
+ this.timer = setInterval(() => { this.tick().catch(error => this.warn(error)); }, POLL_MS);
96
+ this.timer.unref?.();
97
+ return this;
98
+ }
99
+
100
+ recover(row, skipMissed = false) {
101
+ const task = row.meta.task;
102
+ if (!task) return;
103
+ let dirty = false, interrupted = false;
104
+ // Never replay a persisted running attempt: effects may already have occurred.
105
+ if (task.lastRun?.status === 'running') {
106
+ // A newer edited trigger or a manual run must not consume a future one-shot.
107
+ if (task.lastRun.trigger === 'schedule' && task.trigger.type === 'schedule'
108
+ && !task.trigger.recurrence && task.nextRunAt != null && task.nextRunAt <= task.lastRun.startedAt) task.nextRunAt = null;
109
+ task.lastRun = { ...task.lastRun, status: 'interrupted', finishedAt: this.now(), error: 'Agent restarted during execution' };
110
+ interrupted = true;
111
+ dirty = true;
112
+ }
113
+ // Recurring downtime is skipped, including an occurrence exactly at boot.
114
+ if (skipMissed && this.canDispatch(row.meta.id) && task.trigger.type === 'schedule' && task.trigger.recurrence
115
+ && task.nextRunAt != null && task.nextRunAt <= this.now()) {
116
+ task.nextRunAt = advanceScheduledTask(task, this.now());
117
+ dirty = true;
118
+ }
119
+ if (dirty) { this.save(row); this.changed(); }
120
+ if (interrupted && task.notify !== false) {
121
+ try { this.notify({ type: 'scheduled_task_notification', sessionId: row.meta.id, runId: task.lastRun.id,
122
+ status: 'interrupted', taskName: row.meta.name, error: task.lastRun.error }); } catch (error) { this.warn(error); }
123
+ }
124
+ }
125
+
126
+ stop() {
127
+ clearInterval(this.timer);
128
+ this.timer = null;
129
+ this.runs.clear();
130
+ this.waitingForContext.clear();
131
+ }
132
+
133
+ backoff(id, error) {
134
+ this.retryAfter.set(id, this.now() + 30_000);
135
+ this.warn(error);
136
+ }
137
+
138
+ assertReady(sessionId) {
139
+ if (!this.canDispatch(sessionId)) {
140
+ throw Object.assign(new Error('Waiting for authoritative Project context from Server; retry after synchronization'),
141
+ { code: 'project_context_unavailable' });
142
+ }
143
+ }
144
+
145
+ run(sessionId, trigger = 'manual') {
146
+ const row = this.read(sessionId), task = row.meta.task;
147
+ if (!task) throw new Error('Session has no task');
148
+ this.assertReady(sessionId);
149
+ if (this.runs.has(sessionId) || task.lastRun?.status === 'running' || this.isActive(sessionId)) throw new Error('Session is busy or task is already running');
150
+ if (trigger === 'schedule' && (!task.enabled || task.trigger.type !== 'schedule'
151
+ || task.nextRunAt == null || task.nextRunAt > this.now())) throw new Error('Task is no longer due');
152
+ // Validate persisted definitions without trusting externally edited runtime fields.
153
+ normalizeScheduledTask({ prompt: task.prompt, enabled: task.enabled, notify: task.notify, trigger: task.trigger });
154
+ const run = { id: randomUUID(), status: 'running', trigger, startedAt: this.now(), finishedAt: null };
155
+ task.lastRun = run;
156
+ if (trigger === 'schedule') {
157
+ task._scheduledRuns = (task._scheduledRuns || 0) + 1;
158
+ task.nextRunAt = advanceScheduledTask(task, this.now());
159
+ }
160
+ this.save(row); // A durable claim must precede any execution side effect.
161
+ const active = { id: run.id, identity: row.identity, revision: task._revision, routed: false, terminal: false, error: null, interrupted: false };
162
+ this.runs.set(sessionId, active);
163
+ const guard = () => {
164
+ this.assertReady(sessionId);
165
+ const fresh = this.read(sessionId);
166
+ if (fresh.identity !== active.identity || fresh.meta.task?.lastRun?.id !== active.id
167
+ || fresh.meta.task?._revision !== active.revision) throw new Error('Task changed before dispatch');
168
+ };
169
+ try {
170
+ guard();
171
+ const result = this.dispatch({ type: 'yeaft_session_send', sessionId, id: run.id, text: task.prompt,
172
+ [SCHEDULED_TASK_DISPATCH_GUARD]: guard });
173
+ Promise.resolve(result).then(() => { active.routed = true; }, error => {
174
+ active.error = error?.message || String(error); active.routed = true;
175
+ });
176
+ } catch (error) {
177
+ active.error = error?.message || String(error); active.routed = true;
178
+ }
179
+ this.changed();
180
+ return this.snapshot(row.meta);
181
+ }
182
+
183
+ observe(sessionId, event) {
184
+ const active = this.runs.get(sessionId);
185
+ if (!active) return;
186
+ if (event.type === 'error') active.error = event.message || event.error?.message || String(event.error || 'Execution failed');
187
+ if (event.type === 'vp_turn_end') {
188
+ active.terminal = true;
189
+ if (event.reason === 'errored') active.error = event.detail?.message || active.error || 'Execution failed';
190
+ if (event.reason === 'aborted') active.interrupted = true;
191
+ }
192
+ }
193
+
194
+ finish(id, active) {
195
+ let row;
196
+ try { row = this.read(id); } catch { this.runs.delete(id); return; }
197
+ const task = row.meta.task;
198
+ if (row.identity !== active.identity || task?.lastRun?.id !== active.id) { this.runs.delete(id); return; }
199
+ const status = active.error ? 'failed' : active.interrupted ? 'interrupted' : active.terminal ? 'completed' : 'failed';
200
+ const error = active.error || (status === 'failed' ? 'Session produced no terminal execution' : null);
201
+ task.lastRun = { ...task.lastRun, status, finishedAt: this.now(), ...(error ? { error } : {}) };
202
+ // Completion never consumes or moves a schedule, especially for manual runs.
203
+ // Missed recurring occurrences belong to tick's explicit busy-Session policy.
204
+ this.save(row);
205
+ this.runs.delete(id);
206
+ this.changed();
207
+ if (task.notify !== false) {
208
+ try { this.notify({ type: 'scheduled_task_notification', sessionId: id, runId: active.id, status,
209
+ taskName: row.meta.name, ...(error ? { error } : {}) }); } catch (error) { this.warn(error); }
210
+ }
211
+ }
212
+
213
+ async tick() {
214
+ // Inspect due occurrences before retiring tracked runs: an occurrence elapsed
215
+ // during a manual run is busy/missed, even if its runtime just became idle.
216
+ // Manual run/finish themselves never change nextRunAt or _scheduledRuns.
217
+ for (const id of this.ids()) {
218
+ if ((this.retryAfter.get(id) || 0) > this.now()) continue;
219
+ try {
220
+ const row = this.read(id), task = row.meta.task;
221
+ const tracked = this.runs.get(id);
222
+ if (tracked && (row.identity !== tracked.identity || task?.lastRun?.id !== tracked.id)) this.runs.delete(id);
223
+ // Retry boot recovery after a transient atomic-write failure.
224
+ if (task?.lastRun?.status === 'running' && !this.runs.has(id)) { this.recover(row, true); continue; }
225
+ if (task?.enabled && task.trigger.type === 'schedule' && task.nextRunAt != null && task.nextRunAt <= this.now()) {
226
+ if (!this.canDispatch(id)) {
227
+ if (!this.waitingForContext.has(id)) {
228
+ this.waitingForContext.add(id);
229
+ this.warn(new Error(`Session ${id}: waiting for authoritative Project context from Server`));
230
+ }
231
+ continue; // Unknown is not 'no Project': do not claim, fail or consume the occurrence.
232
+ }
233
+ this.waitingForContext.delete(id);
234
+ const busy = this.runs.has(id) || this.isActive(id);
235
+ // One minute of grace tolerates timer jitter. Older recurring
236
+ // occurrences (or ones blocked by an active Session) are missed, not queued.
237
+ if (task.trigger.recurrence && (busy || this.now() - task.nextRunAt > 60_000)) {
238
+ task.nextRunAt = advanceScheduledTask(task, this.now());
239
+ this.save(row);
240
+ this.changed();
241
+ } else if (!busy) this.run(id, 'schedule');
242
+ }
243
+ } catch (error) { this.backoff(id, error); }
244
+ }
245
+ for (const [id, active] of this.runs) {
246
+ if ((this.retryAfter.get(id) || 0) > this.now()) continue;
247
+ try {
248
+ // Drop removed/archived/replaced Sessions even if an old engine stays alive.
249
+ const fresh = this.read(id);
250
+ if (fresh.identity !== active.identity || fresh.meta.task?.lastRun?.id !== active.id) { this.runs.delete(id); continue; }
251
+ if (active.routed && !this.isActive(id)) this.finish(id, active);
252
+ } catch (error) {
253
+ try { this.read(id); this.backoff(id, error); } catch { this.runs.delete(id); }
254
+ }
255
+ }
256
+ }
257
+ }
@@ -52,6 +52,7 @@ import { isAbsolute, join, resolve } from 'path';
52
52
  import {
53
53
  openSession, createSession, loadSessionMeta,
54
54
  } from './session-store.js';
55
+ import { normalizeScheduledTask, publicScheduledTask } from './scheduled-task-definition.js';
55
56
  import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './roster.js';
56
57
  import { seedDefaultSession, DEFAULT_SESSION_ID } from './seed-default.js';
57
58
  import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
@@ -511,6 +512,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
511
512
  */
512
513
  export function createSessionFromSpec(yeaftDir, spec, options = {}) {
513
514
  const input = spec || {};
515
+ // Task validation precedes discovery repair and every filesystem mutation.
516
+ if (input.task !== undefined) normalizeScheduledTask(input.task);
514
517
  const normalizedWorkDir = normalizeWorkDir(input.workDir);
515
518
  const workspaceKey = canonicalWorkspaceKey(normalizedWorkDir);
516
519
  ensureSessionManifestReady(yeaftDir);
@@ -553,6 +556,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
553
556
 
554
557
  const handle = createSession(root, {
555
558
  id, name, roster, defaultVpId, workDir: normalizedWorkDir, workspaceKey,
559
+ ...(input.task !== undefined ? { task: input.task } : {}),
556
560
  });
557
561
  const meta = handle.getMeta();
558
562
  handle.close();
@@ -998,6 +1002,7 @@ export function snapshotSessions(yeaftDir) {
998
1002
  // render the effective model without re-querying.
999
1003
  for (const meta of byId.values()) {
1000
1004
  meta.config = loadSessionConfig(yeaftDir, meta.id);
1005
+ if (meta.task) meta.task = publicScheduledTask(meta.task);
1001
1006
  }
1002
1007
  return Array.from(byId.values()).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
1003
1008
  }
@@ -30,6 +30,8 @@ import { join } from 'path';
30
30
  import { writeAtomic, openLog } from '../storage/index.js';
31
31
  import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from './ids.js';
32
32
 
33
+ import { normalizeScheduledTask, scheduledTaskRevision } from './scheduled-task-definition.js';
34
+
33
35
  export const SESSION_META_FILE = 'session.json';
34
36
  export const LEGACY_GROUP_META_FILE = 'group.json';
35
37
  const MESSAGES_DIR = 'messages';
@@ -48,12 +50,6 @@ export function openSession(sessionsRoot, sessionId) {
48
50
  const dir = join(sessionsRoot, sessionId);
49
51
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
50
52
 
51
- let meta = loadSessionMeta(dir);
52
- if (!meta) {
53
- // Fresh session — caller must call createSession() next; we return a handle
54
- // with meta=null so createSession() can write the initial file.
55
- }
56
-
57
53
  const messagesDir = join(dir, MESSAGES_DIR);
58
54
  if (!existsSync(messagesDir)) mkdirSync(messagesDir, { recursive: true });
59
55
  const log = openLog(messagesDir);
@@ -61,12 +57,21 @@ export function openSession(sessionsRoot, sessionId) {
61
57
  return {
62
58
  dir,
63
59
  id: sessionId,
64
- /** Return current meta (reads fresh from memory after last save). */
65
- getMeta() { return meta ? structuredClone(meta) : null; },
66
- /** Overwrite session.json atomically. */
60
+ /** Read fresh: coordinators may retain this handle across external CRUD. */
61
+ getMeta() { return loadSessionMeta(dir); },
62
+ /**
63
+ * Save Session metadata atomically. After creation, the scheduler exclusively
64
+ * owns task definitions/runtime. Preserve its fresh on-disk value even when
65
+ * a caller retained an older metadata snapshot across a run or task edit.
66
+ */
67
67
  saveMeta(next) {
68
68
  validateMeta(next);
69
- meta = next;
69
+ const current = loadSessionMeta(dir);
70
+ const meta = structuredClone(next);
71
+ if (current) {
72
+ if (Object.hasOwn(current, 'task')) meta.task = current.task;
73
+ else delete meta.task;
74
+ }
70
75
  writeAtomic(join(dir, SESSION_META_FILE), JSON.stringify(meta, null, 2));
71
76
  },
72
77
  /**
@@ -121,6 +126,7 @@ export function openSession(sessionsRoot, sessionId) {
121
126
  */
122
127
  export function createSession(sessionsRoot, spec) {
123
128
  if (!spec || !spec.id) throw new Error('createSession: spec.id required');
129
+ const task = spec.task === undefined ? undefined : normalizeScheduledTask(spec.task);
124
130
  const h = openSession(sessionsRoot, spec.id);
125
131
  if (h.getMeta()) {
126
132
  throw new Error(`session ${spec.id} already exists`);
@@ -138,6 +144,7 @@ export function createSession(sessionsRoot, spec) {
138
144
  }
139
145
  const meta = {
140
146
  id: spec.id,
147
+ ...(task ? { task } : {}),
141
148
  name: spec.name || spec.id,
142
149
  roster,
143
150
  defaultVpId: spec.defaultVpId || null,
@@ -168,6 +175,7 @@ export function loadSessionMeta(dir) {
168
175
  if (typeof parsed.announcement !== 'string') parsed.announcement = '';
169
176
  if (typeof parsed.workDir !== 'string') parsed.workDir = '';
170
177
  if (typeof parsed.workspaceKey !== 'string') parsed.workspaceKey = '';
178
+ if (parsed.task) parsed.task._revision = scheduledTaskRevision(parsed.task);
171
179
  return parsed;
172
180
  } catch {
173
181
  return null;
@@ -15,7 +15,7 @@
15
15
  * style strips on incoming/outgoing message bodies.
16
16
  */
17
17
 
18
- import { delimiter, join } from 'node:path';
18
+ import { delimiter, join, resolve } from 'node:path';
19
19
  import { COLLAB_TOOL_POLICY } from './tools/registry.js';
20
20
  import { createFullRegistry } from './tools/index.js';
21
21
  import { existsSync, lstatSync } from 'node:fs';
@@ -102,6 +102,8 @@ import {
102
102
  } from './sessions/session-crud.js';
103
103
  import { openSession, loadSessionMeta, SESSION_META_FILE } from './sessions/session-store.js';
104
104
  import { validateSessionId } from './sessions/ids.js';
105
+ import { publicScheduledTask } from './sessions/scheduled-task-definition.js';
106
+ import { ScheduledTaskScheduler, SCHEDULED_TASK_DISPATCH_GUARD } from './sessions/scheduled-task-scheduler.js';
105
107
  import { loadSessionConfig, normalizeSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
106
108
  import { updateSessionConfig } from './sessions/session-crud.js';
107
109
  import { createCoordinator } from './sessions/coordinator.js';
@@ -2693,6 +2695,7 @@ function resolveGroupDefaultVpId(sessionId) {
2693
2695
  }
2694
2696
 
2695
2697
  function sendSessionOutputFrame(data, { sessionId, chatId, vpId, turnId, threadId, perfTraceId } = {}) {
2698
+ scheduledTaskScheduler?.observe(sessionId, data);
2696
2699
  const resolvedVpId = vpId || (sessionId ? resolveGroupDefaultVpId(sessionId) : null);
2697
2700
  sendToServer({
2698
2701
  type: 'yeaft_output',
@@ -3156,6 +3159,7 @@ function mergedStatusForProjectRuntime(runtime, ownerSession = session) {
3156
3159
 
3157
3160
  /** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
3158
3161
  function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, requestId, requestClientId, perfTraceId, conversationId = yeaftConversationId } = {}) {
3162
+ scheduledTaskScheduler?.observe(sessionId || event?.sessionId, event);
3159
3163
  sendToServer({
3160
3164
  type: 'yeaft_output',
3161
3165
  conversationId,
@@ -3498,11 +3502,12 @@ async function sharedProjectContext(yeaftDir, sessionId, options = {}) {
3498
3502
  return context;
3499
3503
  }
3500
3504
 
3501
- function sendSessionCrudResult(payload) {
3505
+ function sendSessionCrudResult(payload, envelope) {
3502
3506
  const next = payload && payload.ok && Array.isArray(payload.sessions)
3503
3507
  ? { ...payload, sessions: decorateSessionsWithRuntimeState(payload.sessions) }
3504
3508
  : payload;
3505
- sendSessionEvent({ type: 'session_crud_result', ...next });
3509
+ const projected = next?.session?.task ? { ...next, session: { ...next.session, task: publicScheduledTask(next.session.task) } } : next;
3510
+ sendSessionEvent({ type: 'session_crud_result', ...projected }, envelope);
3506
3511
  }
3507
3512
 
3508
3513
  function isMcpServerEnabled(name, plugins = session?.config?.plugins) {
@@ -3708,6 +3713,72 @@ function sendSessionSnapshotBroadcast() {
3708
3713
  */
3709
3714
  export { sendSessionSnapshotBroadcast as broadcastYeaftSessionSnapshotEager };
3710
3715
 
3716
+ let scheduledTaskScheduler = null;
3717
+
3718
+ /** Server authority is connection-scoped; cached context must not survive reconnect. */
3719
+ export function invalidateYeaftProjectContexts() {
3720
+ projectContextBySession.clear();
3721
+ }
3722
+
3723
+ function scheduledSessionReady(sessionId) {
3724
+ return ctx.ws?.readyState === 1 && projectContextBySession.has(sessionId);
3725
+ }
3726
+
3727
+ /** Start once per Agent data root, not per browser subscription or reconnect. */
3728
+ export function startYeaftScheduledTasks() {
3729
+ const yeaftDir = ctx.CONFIG?.yeaftDir;
3730
+ if (!yeaftDir) return null;
3731
+ if (scheduledTaskScheduler?.yeaftDir !== resolve(yeaftDir)) {
3732
+ scheduledTaskScheduler?.stop();
3733
+ scheduledTaskScheduler = new ScheduledTaskScheduler({
3734
+ yeaftDir,
3735
+ canDispatch: scheduledSessionReady,
3736
+ dispatch: msg => {
3737
+ const guard = msg[SCHEDULED_TASK_DISPATCH_GUARD];
3738
+ const input = { ...msg, projectContext: projectContextBySession.get(msg.sessionId) };
3739
+ input[SCHEDULED_TASK_DISPATCH_GUARD] = () => {
3740
+ guard(); // Includes readiness plus the persisted claim/config/identity checks.
3741
+ // Runtime boot may have spanned a Project edit or reconnect. Use the
3742
+ // latest authority at ingress, never the pre-boot context or legacy cache.
3743
+ input.projectContext = projectContextBySession.get(msg.sessionId);
3744
+ };
3745
+ return handleYeaftSessionSend(input);
3746
+ },
3747
+ isActive: sessionHasActiveWork,
3748
+ onChange: sendSessionSnapshotBroadcast,
3749
+ notify: event => sendSessionEvent(event, { sessionId: event.sessionId }),
3750
+ });
3751
+ }
3752
+ return scheduledTaskScheduler.start();
3753
+ }
3754
+
3755
+ export function stopYeaftScheduledTasks() {
3756
+ scheduledTaskScheduler?.stop();
3757
+ scheduledTaskScheduler = null;
3758
+ }
3759
+
3760
+ export function handleYeaftScheduledTask(msg = {}) {
3761
+ const op = msg.op === 'update' ? 'update_task' : 'run_task';
3762
+ const requestId = msg.requestId || null;
3763
+ const respond = payload => sendSessionEvent({ type: 'session_crud_result', op, requestId, ...payload },
3764
+ { requestId, requestClientId: msg._requestClientId });
3765
+ try {
3766
+ if (!['update', 'run'].includes(msg.op)) throw new Error('Invalid scheduled task operation');
3767
+ if (msg.op === 'run' && msg.task !== undefined) throw new Error('task is only accepted for update');
3768
+ if (!validateSessionId(msg.sessionId).ok) throw new Error('Invalid Session id');
3769
+ if (msg.op === 'run' && Object.hasOwn(msg, 'projectContext')) {
3770
+ handleYeaftProjectContextSync({ contexts: [{ sessionId: msg.sessionId, projectContext: msg.projectContext }] });
3771
+ }
3772
+ const scheduler = startYeaftScheduledTasks();
3773
+ if (!scheduler) throw new Error('No Yeaft directory configured');
3774
+ const result = msg.op === 'update' ? scheduler.update(msg.sessionId, msg.task, msg.expectedRevision) : scheduler.run(msg.sessionId);
3775
+ result.config = loadSessionConfig(scheduler.yeaftDir, msg.sessionId);
3776
+ respond({ ok: true, session: result });
3777
+ } catch (error) {
3778
+ respond({ ok: false, error: { code: error?.code || 'scheduled_task_error', sessionId: msg.sessionId, message: error?.message || String(error) } });
3779
+ }
3780
+ }
3781
+
3711
3782
  function sendSessionRosterChanged(session) {
3712
3783
  if (!session) return;
3713
3784
  const payload = {
@@ -3749,6 +3820,7 @@ export function handleYeaftProjectContextSync(msg) {
3749
3820
  const sessionId = typeof row?.sessionId === 'string' ? row.sessionId.trim() : '';
3750
3821
  if (!sessionId) continue;
3751
3822
  const projectContext = normalizeProjectContext(row.projectContext, sessionId);
3823
+ if (!projectContext && row.projectContext !== null) continue;
3752
3824
  projectContextBySession.set(sessionId, projectContext || {
3753
3825
  projectId: null,
3754
3826
  projectName: null,
@@ -3801,15 +3873,18 @@ export function handleYeaftProjectMutation(msg) {
3801
3873
  export function handleYeaftCreateSession(msg) {
3802
3874
  const requestId = msg && msg.requestId;
3803
3875
  const payload = (msg && msg.payload) || {};
3876
+ // Server authenticates/overwrites the requester identity. Task create results
3877
+ // (including validation failures) must be routed back to that browser only.
3878
+ const envelope = payload.task !== undefined ? { requestId, requestClientId: msg?._requestClientId } : undefined;
3804
3879
  try {
3805
3880
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3806
3881
  const group = createSessionFromSpec(yeaftDir, payload, configuredVpPaths());
3807
3882
  recordAgentSessionCreated();
3808
3883
  group.config = loadSessionConfig(yeaftDir, group.id);
3809
- sendSessionCrudResult({ op: 'create', requestId, ok: true, session: group });
3884
+ sendSessionCrudResult({ op: 'create', requestId, ok: true, session: group }, envelope);
3810
3885
  sendSessionSnapshotBroadcast();
3811
3886
  } catch (err) {
3812
- sendSessionCrudResult({ op: 'create', requestId, ok: false, error: sessionErrorPayload(err) });
3887
+ sendSessionCrudResult({ op: 'create', requestId, ok: false, error: sessionErrorPayload(err) }, envelope);
3813
3888
  }
3814
3889
  }
3815
3890
 
@@ -5028,6 +5103,23 @@ async function runYeaftSessionSend(msg) {
5028
5103
  const sessionId = (typeof msg.sessionId === 'string' && msg.sessionId.trim())
5029
5104
  ? msg.sessionId.trim()
5030
5105
  : 'grp_default';
5106
+ // Cache authority before the first await: an accepted send from the previous
5107
+ // transport must not repopulate stale context after disconnect invalidation.
5108
+ const hasInboundProjectContext = Object.prototype.hasOwnProperty.call(msg, 'projectContext');
5109
+ const inboundProjectContext = normalizeProjectContext(msg.projectContext, sessionId);
5110
+ if (hasInboundProjectContext) {
5111
+ projectContextBySession.set(sessionId, inboundProjectContext || {
5112
+ projectId: null,
5113
+ projectName: null,
5114
+ projectInstruction: '',
5115
+ sessionIds: [],
5116
+ });
5117
+ } else {
5118
+ // An old Server does not know this field. Drop any context cached from a
5119
+ // newer Server before falling back to this Agent's legacy projects.json.
5120
+ projectContextBySession.delete(sessionId);
5121
+ }
5122
+
5031
5123
  const perfTraceId = typeof msg.perfTraceId === 'string' && msg.perfTraceId.trim()
5032
5124
  ? msg.perfTraceId.trim()
5033
5125
  : null;
@@ -5128,6 +5220,9 @@ async function dispatchAcceptedSessionSend({
5128
5220
  }) {
5129
5221
  const ensureSessionStart = perfNowMs();
5130
5222
  await ensureSessionLoaded({ sessionMeta: sessionMetaForRuntime, perfTraceId });
5223
+ // Runtime boot may await I/O. Re-read the claim immediately before ingress
5224
+ // so edits, disable, archive or deletion during boot cannot execute stale work.
5225
+ msg[SCHEDULED_TASK_DISPATCH_GUARD]?.();
5131
5226
  if (!sessionHandle) {
5132
5227
  try {
5133
5228
  const dir = join(sessionRoot, sessionId);
@@ -5244,21 +5339,6 @@ async function dispatchAcceptedSessionSend({
5244
5339
  },
5245
5340
  });
5246
5341
 
5247
- const hasInboundProjectContext = Object.prototype.hasOwnProperty.call(msg, 'projectContext');
5248
- const inboundProjectContext = normalizeProjectContext(msg.projectContext, sessionId);
5249
- if (hasInboundProjectContext) {
5250
- projectContextBySession.set(sessionId, inboundProjectContext || {
5251
- projectId: null,
5252
- projectName: null,
5253
- projectInstruction: '',
5254
- sessionIds: [],
5255
- });
5256
- } else {
5257
- // An old Server does not know this field. Drop any context cached from a
5258
- // newer Server before falling back to this Agent's legacy projects.json.
5259
- projectContextBySession.delete(sessionId);
5260
- }
5261
-
5262
5342
  // Ingest user text. The coordinator persists, applies mention/fanout
5263
5343
  // rules, and calls deliver() (== enqueueForVp) for each chosen VP —
5264
5344
  // which both (a) emits vp_typing_start and (b) ensures a driver runs.