@yeaft/webchat-agent 1.0.567 → 1.0.569

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 (42) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +229 -120
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +2 -2
  5. package/local-runtime/web/style.bundle.css +1 -1
  6. package/local-runtime/web/style.bundle.css.gz +0 -0
  7. package/package.json +1 -1
  8. package/yeaft/engine.js +54 -31
  9. package/yeaft/sub-agent/execution-control.js +11 -0
  10. package/yeaft/sub-agent/notifications.js +19 -3
  11. package/yeaft/sub-agent/outcome.js +39 -0
  12. package/yeaft/sub-agent/runner.js +54 -3
  13. package/yeaft/sub-agent/status.js +3 -3
  14. package/yeaft/tasks/manager.js +80 -8
  15. package/yeaft/tool-folding/index.js +17 -9
  16. package/yeaft/tool-folding/t1-reflector.js +34 -13
  17. package/yeaft/tool-folding/t2-reflector.js +34 -13
  18. package/yeaft/tools/activation.js +2 -0
  19. package/yeaft/tools/agent.js +4 -1
  20. package/yeaft/tools/bash.js +30 -5
  21. package/yeaft/tools/cancel-task.js +4 -1
  22. package/yeaft/tools/close-agent.js +7 -2
  23. package/yeaft/tools/enter-worktree.js +6 -2
  24. package/yeaft/tools/git-read.js +54 -12
  25. package/yeaft/tools/index.js +2 -0
  26. package/yeaft/tools/list-agents.js +3 -0
  27. package/yeaft/tools/list-tasks.js +9 -2
  28. package/yeaft/tools/read-task-log.js +5 -2
  29. package/yeaft/tools/registry.js +29 -0
  30. package/yeaft/tools/update-agent.js +14 -5
  31. package/yeaft/tools/wait-agent.js +16 -0
  32. package/yeaft/tools/wait-task.js +73 -0
  33. package/yeaft/work-center/attachments.js +28 -0
  34. package/yeaft/work-center/bridge.js +3 -2
  35. package/yeaft/work-center/controller.js +3 -3
  36. package/yeaft/work-center/durable-model.js +18 -10
  37. package/yeaft/work-center/projection.js +27 -3
  38. package/yeaft/work-center/recurrence.js +103 -0
  39. package/yeaft/work-center/resource-control.js +2 -4
  40. package/yeaft/work-center/service.js +30 -3
  41. package/yeaft/work-center/store.js +137 -24
  42. package/yeaft/work-center/transaction.js +21 -0
@@ -242,6 +242,35 @@ export function truncateToolResultIfNeeded(output, { toolName, language } = {})
242
242
  const originalBytes = Buffer.byteLength(text, 'utf8');
243
243
  if (originalBytes <= TOOL_RESULT_MAX_BYTES) return text;
244
244
 
245
+ // Preserve the error contract in the model projection; raw tool output
246
+ // remains available to tracing/persistence before this budget is applied.
247
+ if (toolName === 'Bash') {
248
+ const failure = parseToolErrorOutput(text);
249
+ if (failure && failure.code?.startsWith('bash_')) {
250
+ const projected = { ...failure, truncated: true, originalBytes };
251
+ for (const [key, value] of Object.entries(projected)) {
252
+ if (key !== 'output' && typeof value === 'string') projected[key] = truncateUtf8(value, 1024);
253
+ }
254
+ const source = typeof failure.output === 'string' ? failure.output : '';
255
+ const buffer = Buffer.from(source, 'utf8');
256
+ const sample = bytes => {
257
+ const half = Math.floor(bytes / 2);
258
+ let start = Math.max(0, buffer.length - half);
259
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
260
+ return truncateUtf8(source, half) + '\n[Middle omitted by tool result budget]\n' + buffer.subarray(start).toString('utf8');
261
+ };
262
+ let low = 0, high = Math.min(buffer.length, TOOL_RESULT_MAX_BYTES);
263
+ while (low < high) {
264
+ const mid = Math.ceil((low + high) / 2);
265
+ projected.output = sample(mid);
266
+ if (Buffer.byteLength(JSON.stringify(projected), 'utf8') <= TOOL_RESULT_MAX_BYTES) low = mid;
267
+ else high = mid - 1;
268
+ }
269
+ projected.output = sample(low);
270
+ return JSON.stringify(projected);
271
+ }
272
+ }
273
+
245
274
  const markerFor = name => normalizeLanguage(language) === 'zh'
246
275
  ? `\n\n[已截断:${name} 返回 ${formatSize(originalBytes)},上限为 ${formatSize(TOOL_RESULT_MAX_BYTES)};原因:单个 tool result 超过 ${formatSize(TOOL_RESULT_MAX_BYTES)},模型消息历史不会看到剩余内容]`
247
276
  : `\n\n[truncated: ${name} returned ${formatSize(originalBytes)}, capped at ${formatSize(TOOL_RESULT_MAX_BYTES)}; reason: single tool result exceeded ${formatSize(TOOL_RESULT_MAX_BYTES)}, the model message history will not see the rest]`;
@@ -9,8 +9,8 @@ import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
9
9
  export default defineTool({
10
10
  name: 'UpdateAgent',
11
11
  description: {
12
- en: 'Adjust a live child in place after inspecting its progress: absolute lifetime time/tool/LLM budgets and explicit extra tool grants. Does not queue a prompt, reset usage, or revive a terminal/reporting child. Give evidence and the remaining task in reason; do not extend stalled or repeating work blindly. Bash permits arbitrary shell and writes, not a read-only sandbox; grant only necessary parent tools and isolate writable workspaces. Already dispatched work is not undone by revocation.',
13
- zh: '检查进展后原地调整活跃子 Agent:累计时间/工具/LLM 上限及额外工具授权。不排队提示、不清零用量、不复活终止或收尾中的任务。reason 说明已有证据和剩余工作,勿盲目给停滞/重复任务扩额。Bash 可执行任意 Shell 和写入,并非只读沙箱;只授予必要的父级工具,写任务隔离 workspace。撤销不撤回已执行操作。',
12
+ en: 'Adjust a live child in place after inspecting its progress: absolute lifetime budgets, extra tool grants, or request a cooperative evidence-only final report. Finalization is control, not a prompt containing reason. Does not reset usage or revive a terminal/reporting child. Already dispatched work is not undone.',
13
+ zh: '检查进展后原地调整活跃子 Agent:累计预算、额外工具授权,或请求基于现有证据协作收尾。收尾是控制信号,不会把 reason 冒充提示词。不清零用量、不复活终止/报告中的任务,也不撤回已派发操作。',
14
14
  },
15
15
  parameters: {
16
16
  type: 'object',
@@ -32,6 +32,10 @@ export default defineTool({
32
32
  type: 'array', items: { type: 'string' }, maxItems: 32,
33
33
  description: { en: 'Replace extra persona grants with these canonical parent tool names (e.g. Bash, FileEdit); [] revokes extras, omission leaves grants unchanged', zh: '替换 persona 额外授权(如 Bash、FileEdit);[] 撤销额外授权,省略则不变' },
34
34
  },
35
+ request_finalize: {
36
+ type: 'boolean',
37
+ description: { en: 'Request one tool-free final report from existing evidence, then end the child lifecycle normally', zh: '请求仅基于现有证据生成一次无工具最终报告,然后正常结束子任务生命周期' },
38
+ },
35
39
  },
36
40
  required: ['agent_id', 'reason'],
37
41
  },
@@ -42,10 +46,10 @@ export default defineTool({
42
46
  const fail = error => JSON.stringify({ error, next_steps: 'Inspect the current agent state and correct the request; do not respawn or repeat blindly.' });
43
47
  const agent = getAgentRegistry().get(input.agent_id);
44
48
  if (!agent || !agentBelongsToCaller(agent, ctx)) return fail(`Agent not found: ${input.agent_id}`);
45
- if (isTerminalAgentStatus(agent.status) || agent.budgetReportStarted || agent.budgetStopReason
49
+ if (isTerminalAgentStatus(agent.status) || agent.budgetReportStarted || agent.finalizationRequested || agent.budgetStopReason
46
50
  || agent.abortController?.signal.aborted) return fail('Agent is terminal, stopping or already reporting; it cannot be extended');
47
51
  if (typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 2000) return fail('reason must contain 1..2000 characters of evidence and remaining work');
48
- if (input.budget === undefined && input.allow_tools === undefined) return fail('budget or allow_tools is required');
52
+ if (input.budget === undefined && input.allow_tools === undefined && input.request_finalize !== true) return fail('budget, allow_tools, or request_finalize=true is required');
49
53
  if (input.budget !== undefined) {
50
54
  const error = validateBudget(input.budget);
51
55
  if (error) return fail(error);
@@ -58,6 +62,7 @@ export default defineTool({
58
62
  // All validation precedes mutation. Original usage/deadline origin are retained.
59
63
  agent.budget = { ...agent.budget, ...input.budget };
60
64
  if (grants) agent.allowTools = grants.tools;
65
+ if (input.request_finalize === true) agent.finalizationRequested = true;
61
66
  agent.controlRevision = (agent.controlRevision || 0) + 1;
62
67
  if (agent.execution && (agent.budget.max_tool_calls === undefined
63
68
  || agent.execution.toolCalls < agent.budget.max_tool_calls * 0.75)) agent.execution.warning = null;
@@ -68,6 +73,7 @@ export default defineTool({
68
73
  agent.refreshToolPolicy?.();
69
74
  agent.rearmWallTimeWatchdog?.();
70
75
  const event = { type: 'sub_agent_control_updated', at: Date.now(), reason: input.reason.trim(),
76
+ requestFinalize: input.request_finalize === true,
71
77
  previousBudget, budget: { ...agent.budget }, previousTools, allowTools: [...(agent.allowTools || [])] };
72
78
  agent.diagnostics ||= [];
73
79
  agent.diagnostics.push(event);
@@ -75,6 +81,9 @@ export default defineTool({
75
81
  try { agent.outputLog?.write(event); } catch { /* diagnostics must not fail the applied update */ }
76
82
  return JSON.stringify({ success: true, agentId: agent.id, status: agent.status,
77
83
  budget: agent.budget, allow_tools: agent.allowTools || [], liveness: diagnoseAgentLiveness(agent),
78
- next_steps: 'Adjustment applied without restarting work. Continue the parent task; use PromptAgent only if new guidance is needed, then collect its reply. Revocation does not cancel already dispatched work.' });
84
+ finalizationRequested: agent.finalizationRequested === true,
85
+ next_steps: input.request_finalize === true
86
+ ? 'Cooperative wrap-up requested without turning reason into a child prompt. Use WaitAgent to collect the evidence-only final report; already dispatched work may finish first.'
87
+ : 'Adjustment applied without restarting work. Continue the parent task; use PromptAgent only if new guidance is needed, then collect its reply. Revocation does not cancel already dispatched work.' });
79
88
  },
80
89
  });
@@ -35,6 +35,7 @@ import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
35
35
  import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
36
36
  import { diagnoseAgentLiveness } from '../sub-agent/liveness.js';
37
37
  import { consumeNotificationForAgent } from '../sub-agent/notifications.js';
38
+ import { describeAgentLifecycle, describeAgentOutcome } from '../sub-agent/outcome.js';
38
39
 
39
40
  /**
40
41
  * Build the status-specific next-step guidance the LLM reads after a wait.
@@ -55,6 +56,9 @@ function nextStepsFor(status, opts = {}) {
55
56
  'as an ordinary successful completion.'
56
57
  );
57
58
  }
59
+ if (opts.incomplete) {
60
+ return 'Sub-agent lifecycle ended with incomplete evidence. Inspect outcome and final_report; do not present partial verdict text as a completed review.';
61
+ }
58
62
  if (opts.timedOut && opts.stale) {
59
63
  return (
60
64
  'No observable event arrived within the diagnostic threshold. This does ' +
@@ -157,12 +161,15 @@ function buildEnvelope(agent, { timedOut = false } = {}) {
157
161
  next_steps: nextStepsFor(status, {
158
162
  timedOut,
159
163
  budgetExceeded: !!budgetResult,
164
+ incomplete: isTerminalAgentStatus(status) && !describeAgentOutcome(agent).complete,
160
165
  stale: liveness.stale,
161
166
  mustCollectReply: mustCollectReply && !liveness.stale,
162
167
  }),
163
168
  agentId: agent.id,
164
169
  name: agent.name,
165
170
  status,
171
+ lifecycle: describeAgentLifecycle(agent),
172
+ outcome: describeAgentOutcome(agent),
166
173
  error: agent.error || null,
167
174
  outputFile: agent.outputFile || null,
168
175
  liveness,
@@ -185,8 +192,17 @@ function buildEnvelope(agent, { timedOut = false } = {}) {
185
192
  env.budget_status = budgetResult.status;
186
193
  env.budget_reason = budgetResult.reason || null;
187
194
  env.partial_output = budgetResult.partial_output || '';
195
+ env.incomplete = true;
196
+ env.truncated = Boolean(budgetResult.truncated || budgetResult.final_report?.truncated);
197
+ env.final_report = budgetResult.final_report || null;
188
198
  env.budget_usage = budgetResult.usage || null;
189
199
  }
200
+ if (agent.finalizationRequested) {
201
+ env.incomplete = true;
202
+ env.final_report = agent.finalReport || null;
203
+ env.truncated = Boolean(agent.finalReport?.truncated);
204
+ env.partial_output = resultText;
205
+ }
190
206
  env.result = resultText;
191
207
  return env;
192
208
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * wait-task.js — Bounded wait for one Session background task.
3
+ */
4
+
5
+ import { defineTool } from './types.js';
6
+ import { isTerminalTaskStatus } from '../tasks/store.js';
7
+
8
+ const DEFAULT_TIMEOUT_MS = 120_000;
9
+ const MAX_TIMEOUT_MS = 600_000;
10
+
11
+ export function taskWaitResult(result) {
12
+ if (!result?.ok) return { error: result?.error || 'Unable to wait for task' };
13
+ const task = result.task;
14
+ if (!task) return { error: 'Task state unavailable' };
15
+ const log = task.log && typeof task.log === 'object' ? task.log : {};
16
+ const taskResult = task.result && typeof task.result === 'object' ? task.result : {};
17
+ return {
18
+ taskId: task.id,
19
+ status: task.status,
20
+ terminal: isTerminalTaskStatus(task.status),
21
+ timedOut: result.timedOut === true,
22
+ exitCode: Number.isInteger(taskResult.exitCode) ? taskResult.exitCode : null,
23
+ signal: taskResult.signal || null,
24
+ resultDelivery: task.resultDelivery,
25
+ resultConsumed: false,
26
+ ...(task.runtime?.subAgentId ? { agentId: task.runtime.subAgentId } : {}),
27
+ ...(task.runtime?.cancelRequestedAt ? { cancelPending: !isTerminalTaskStatus(task.status) } : {}),
28
+ next_steps: 'Status only; no output was consumed. ReadTaskLog with your last read offset (or tail) for evidence; use WaitAgent for a child result. Do not use log.endOffset as an already-read cursor.',
29
+ error: taskResult.error || null,
30
+ log: {
31
+ ...(log.path ? { path: log.path } : {}),
32
+ bytes: Number.isFinite(log.bytes) ? log.bytes : 0,
33
+ endOffset: Number.isFinite(log.bytes) ? log.bytes : 0,
34
+ },
35
+ };
36
+ }
37
+
38
+ export default defineTool({
39
+ name: 'WaitTask',
40
+ description: {
41
+ en: 'Wait for one background task by taskId, up to a bounded timeout. Returns terminal status and a log cursor/reference, never the whole log. This does not cancel or retry the task.',
42
+ zh: '按 taskId 有界等待一个后台任务。仅返回终态和日志游标/引用,不返回完整日志;不会取消或重试任务。',
43
+ },
44
+ parameters: {
45
+ type: 'object',
46
+ properties: {
47
+ taskId: { type: 'string', description: { en: 'Task id', zh: '任务 ID' } },
48
+ sessionId: { type: 'string', description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' } },
49
+ timeout_ms: { type: 'number', description: { en: `Wait timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS})`, zh: `等待超时毫秒数(默认 ${DEFAULT_TIMEOUT_MS},最大 ${MAX_TIMEOUT_MS})` } },
50
+ },
51
+ required: ['taskId'],
52
+ },
53
+ timeoutMs: 0,
54
+ isConcurrencySafe: () => true,
55
+ isReadOnly: () => true,
56
+ cacheWithinQuery: false,
57
+ duplicateCallPolicy: () => 'allow',
58
+ async execute(input = {}, ctx = {}) {
59
+ if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
60
+ if (ctx.sessionId && input.sessionId && input.sessionId !== ctx.sessionId) {
61
+ return JSON.stringify({ error: 'Task access is limited to the current Session', errorEffect: 'none' });
62
+ }
63
+ if (!input.taskId) return JSON.stringify({ error: 'taskId is required' });
64
+ const sessionId = input.sessionId || ctx.sessionId || 'default';
65
+ const timeoutMs = Math.min(Math.max(Number.isFinite(input.timeout_ms) ? input.timeout_ms : DEFAULT_TIMEOUT_MS, 0), MAX_TIMEOUT_MS);
66
+ const result = await ctx.taskManager.waitForTask(sessionId, input.taskId, {
67
+ timeoutMs,
68
+ signal: ctx.signal || null,
69
+ ownerVpId: ctx.currentVpId || null,
70
+ });
71
+ return JSON.stringify(taskWaitResult(result), null, 2);
72
+ },
73
+ });
@@ -392,6 +392,34 @@ function resolveAttachmentPath(root, workItemId, attachment) {
392
392
  return { filePath: actualPath, size: stat.size, itemDirectory: itemRoot };
393
393
  }
394
394
 
395
+ /** Copy verified bytes into a new owner directory; never share source paths. */
396
+ export function cloneWorkItemAttachments(workItem, workItemId, options = {}) {
397
+ if (!workItem.attachments?.length) return [];
398
+ const state = openAttachmentDirectory(options.root, workItem.id);
399
+ try {
400
+ const files = workItem.attachments.map(attachment => {
401
+ const storageName = attachment.storageName;
402
+ if (typeof storageName !== 'string' || !/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) {
403
+ throw new Error('WorkItem attachment metadata is invalid');
404
+ }
405
+ assertDescriptorMatchesPath(state.rootDescriptor, state.attachmentRoot, 'WorkItem attachment root');
406
+ assertDescriptorMatchesPath(state.itemDescriptor, state.itemDirectory, 'WorkItem attachment owner directory');
407
+ const fd = openSync(`/proc/self/fd/${state.itemDescriptor}/${storageName}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
408
+ try {
409
+ const stat = fstatSync(fd);
410
+ if (!stat.isFile()) throw new Error('WorkItem attachment is not a regular file');
411
+ assertWorkItemAttachmentSize(stat.size);
412
+ const buffer = readFileSync(fd);
413
+ if (buffer.length !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
414
+ throw new Error('WorkItem attachment changed after creation');
415
+ }
416
+ return { name: attachment.name, mimeType: attachment.mimeType, data: buffer.toString('base64') };
417
+ } finally { closeSync(fd); }
418
+ });
419
+ return persistWorkItemAttachments(files, { root: options.root, workItemId });
420
+ } finally { closeDirectoryState(state); }
421
+ }
422
+
395
423
  export function readWorkItemAttachment(workItem, attachmentId, options = {}) {
396
424
  const attachment = Array.isArray(workItem?.attachments)
397
425
  ? workItem.attachments.find(item => item?.id === attachmentId)
@@ -21,7 +21,7 @@ let serviceFactory = null;
21
21
  let featureEnabled = false;
22
22
 
23
23
  const BROWSER_DETAIL_OPS = new Set([
24
- 'get', 'create', 'update', 'start', 'cancel', 'resume', 'extend_budget', 'post_work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
24
+ 'get', 'create', 'update', 'update_schedule', 'start', 'cancel', 'resume', 'extend_budget', 'post_work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
25
25
  ]);
26
26
  const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
27
27
  // `files` is an internal server-to-Agent field. The browser relay rejects any
@@ -29,7 +29,7 @@ const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_req
29
29
  const BROWSER_FILE_FIELDS = Object.freeze({
30
30
  create: [
31
31
  'title', 'titleSource', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'deliveryTarget', 'deliveryInstructions',
32
- 'reuseMemory', 'files', 'start',
32
+ 'reuseMemory', 'files', 'start', 'scheduledFor', 'scheduleEnabled', 'recurrence',
33
33
  ],
34
34
  post_work_item_message: [
35
35
  'id', 'clientMessageId', 'text', 'target', 'revision', 'planRevision', 'ledgerRevision',
@@ -96,6 +96,7 @@ async function getSettingsRuntime() {
96
96
  defaultWorkDir: ctx.CONFIG?.workDir || process.cwd(),
97
97
  workItemAttachments: Array.isArray(ctx.agentCapabilities)
98
98
  && ctx.agentCapabilities.includes('work_item_attachments'),
99
+ recurringSchedules: true,
99
100
  defaultStageInstructions: defaultWorkCenterStageInstructions(),
100
101
  };
101
102
  }
@@ -164,8 +164,8 @@ export class WorkflowController {
164
164
  return detail;
165
165
  }
166
166
 
167
- startScheduled(id, scheduledAt) {
168
- return this.store.startWorkItemAtomic(id, workItem => {
167
+ startScheduled(id, scheduledAt, cloneAttachments = null) {
168
+ return this.store.dispatchScheduledWorkItem(id, scheduledAt, workItem => {
169
169
  const action = initialActionFor(workItem);
170
170
  if (workItem.reuseMemory === false) return action;
171
171
  const context = this.store.getReusableContext(workItem.workDir, workItem.id);
@@ -174,7 +174,7 @@ export class WorkflowController {
174
174
  context,
175
175
  instruction: actionInstruction(action, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
176
176
  };
177
- }, { scheduledAt });
177
+ }, cloneAttachments);
178
178
  }
179
179
 
180
180
  update(id, patch) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { withTransaction } from './transaction.js';
2
3
 
3
- export const WORK_CENTER_SCHEMA_VERSION = 41;
4
+ export const WORK_CENTER_SCHEMA_VERSION = 42;
4
5
 
5
6
  const MIGRATIONS = [
6
7
  ['23-conversation-stream', migrateConversationStream],
@@ -22,6 +23,7 @@ const MIGRATIONS = [
22
23
  ['39-action-creation-source', migrateActionCreationSource],
23
24
  ['40-work-item-schedules', migrateWorkItemSchedules],
24
25
  ['41-delivery-instructions', migrateDeliveryInstructions],
26
+ ['42-recurring-schedules', migrateRecurringSchedules],
25
27
  ];
26
28
 
27
29
  const MIGRATION_ALIASES = new Map([
@@ -83,15 +85,7 @@ function runMigration(db, now, name, migration) {
83
85
  db.prepare(`INSERT INTO schema_migrations(name, checksum, applied_at)
84
86
  VALUES (?, ?, ?)`).run(name, checksum, now);
85
87
  };
86
- if (db.isTransaction) return apply();
87
- db.exec('BEGIN IMMEDIATE');
88
- try {
89
- apply();
90
- db.exec('COMMIT');
91
- } catch (error) {
92
- try { db.exec('ROLLBACK'); } catch {}
93
- throw error;
94
- }
88
+ return withTransaction(db, apply);
95
89
  }
96
90
 
97
91
  export function migrateDurableWorkCenterModel(db, now = Date.now(), sourceSchemaVersion = 22) {
@@ -568,6 +562,20 @@ function migrateActionClosureAndOutputs(db) {
568
562
  `);
569
563
  }
570
564
 
565
+ function migrateRecurringSchedules(db) {
566
+ for (const [column, definition] of [
567
+ ['schedule_recurrence', 'TEXT'],
568
+ ['schedule_run_count', 'INTEGER NOT NULL DEFAULT 0'],
569
+ ['schedule_last_work_item_id', 'TEXT'],
570
+ ['source_schedule_id', 'TEXT'],
571
+ ['scheduled_occurrence_at', 'INTEGER'],
572
+ ]) {
573
+ if (!hasColumn(db, 'work_items', column)) db.exec(`ALTER TABLE work_items ADD COLUMN ${column} ${definition}`);
574
+ }
575
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_schedule_occurrence
576
+ ON work_items(source_schedule_id, scheduled_occurrence_at) WHERE source_schedule_id IS NOT NULL`);
577
+ }
578
+
571
579
  function migrateWorkItemSchedules(db) {
572
580
  for (const [column, definition] of [
573
581
  ['schedule_status', 'TEXT'],
@@ -27,6 +27,24 @@ const MAX_HISTORICAL_BRIEF_CHARS = 256;
27
27
  const MAX_CURRENT_BRIEF_BYTES = 8 * 1024;
28
28
  export const MAX_WORK_ITEM_BROWSER_DTO_BYTES = 512 * 1024;
29
29
 
30
+ function projectSchedule(schedule) {
31
+ if (!schedule) return null;
32
+ return {
33
+ status: schedule.status,
34
+ scheduledFor: schedule.scheduledFor ?? null,
35
+ triggeredAt: schedule.triggeredAt ?? null,
36
+ recurrence: schedule.recurrence || null,
37
+ runCount: count(schedule.runCount),
38
+ lastWorkItemId: schedule.lastWorkItemId || null,
39
+ // Do not forward caller-provided diagnostic text, stack, paths or metadata.
40
+ lastError: schedule.lastError ? {
41
+ code: 'schedule_dispatch_failed',
42
+ message: 'Scheduled execution could not start. The plan will retry automatically; check its configuration and attachments.',
43
+ at: count(schedule.lastError.at),
44
+ } : null,
45
+ };
46
+ }
47
+
30
48
  function jsonByteLength(value) {
31
49
  return Buffer.byteLength(JSON.stringify(value), 'utf8');
32
50
  }
@@ -1094,7 +1112,9 @@ export function projectWorkItemDetail(detail, options = {}) {
1094
1112
  executionControl: detail.executionControl,
1095
1113
  executionStats: combinedExecutionStats(detail),
1096
1114
  reuseMemory: detail.reuseMemory !== false,
1097
- schedule: detail.schedule || null,
1115
+ schedule: projectSchedule(detail.schedule),
1116
+ sourceScheduleId: detail.sourceScheduleId || null,
1117
+ scheduledOccurrenceAt: detail.scheduledOccurrenceAt ?? null,
1098
1118
  deliveryTarget: ['response', 'workspace_files', 'pull_request', 'merge'].includes(detail.deliveryTarget)
1099
1119
  ? detail.deliveryTarget : null,
1100
1120
  deliveryInstructions: truncateUtf8(detail.deliveryInstructions || '', 2 * 1024),
@@ -1206,7 +1226,9 @@ export function projectWorkItemSummary(detail) {
1206
1226
  executionStats: combinedExecutionStats(detail),
1207
1227
  executionControl: detail.executionControl,
1208
1228
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
1209
- schedule: detail.schedule || null,
1229
+ schedule: projectSchedule(detail.schedule),
1230
+ sourceScheduleId: detail.sourceScheduleId || null,
1231
+ scheduledOccurrenceAt: detail.scheduledOccurrenceAt ?? null,
1210
1232
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
1211
1233
  attachmentCount: Array.isArray(detail.attachments) ? detail.attachments.length : 0,
1212
1234
  createdAt: detail.createdAt,
@@ -1247,7 +1269,9 @@ export function projectWorkItemSummary(detail) {
1247
1269
  currentAction: projectCurrentActionSummary(action, projectedAction),
1248
1270
  actionStats: projectActionStats(detail, null),
1249
1271
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
1250
- schedule: detail.schedule || null,
1272
+ schedule: projectSchedule(detail.schedule),
1273
+ sourceScheduleId: detail.sourceScheduleId || null,
1274
+ scheduledOccurrenceAt: detail.scheduledOccurrenceAt ?? null,
1251
1275
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
1252
1276
  attachmentCount: Array.isArray(detail.attachments) ? detail.attachments.length : 0,
1253
1277
  createdAt: detail.createdAt,
@@ -0,0 +1,103 @@
1
+ // Calendar computation is pure and independent of dispatch / Agent local timezone.
2
+ // Keep arithmetic and Intl in a bounded, supported date range (1970–2099).
3
+ export const MAX_SCHEDULE_TIMESTAMP = Date.UTC(2100, 0, 1) - 1;
4
+ const DAY = 86_400_000;
5
+
6
+ export function validateScheduleTimestamp(value, name = 'scheduledFor') {
7
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_SCHEDULE_TIMESTAMP) {
8
+ throw new Error(`${name} must be epoch milliseconds between 1970 and 2099`);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ export function normalizeRecurrence(value) {
14
+ if (value == null) return null;
15
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid recurrence');
16
+ const allowed = ['frequency', 'timeZone', 'time', 'weekdays', 'dayOfMonth', 'endsAt', 'maxRuns'];
17
+ if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unknown recurrence field');
18
+ if (!['daily', 'weekdays', 'weekly', 'monthly'].includes(value.frequency)) throw new Error('Invalid recurrence frequency');
19
+ if (typeof value.timeZone !== 'string' || value.timeZone.length > 100 || /^[+-]/.test(value.timeZone)) throw new Error('Invalid recurrence timeZone');
20
+ try { new Intl.DateTimeFormat('en', { timeZone: value.timeZone }); } catch { throw new Error('Invalid recurrence timeZone'); }
21
+ if (typeof value.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(value.time)) throw new Error('Invalid recurrence time');
22
+ if (value.weekdays !== undefined && (!Array.isArray(value.weekdays) || value.weekdays.length > 7
23
+ || value.weekdays.some(day => !Number.isInteger(day) || day < 0 || day > 6)
24
+ || new Set(value.weekdays).size !== value.weekdays.length)) throw new Error('Invalid recurrence weekdays');
25
+ if (value.frequency === 'weekly' && !value.weekdays?.length) throw new Error('Weekly recurrence requires weekdays');
26
+ if (value.dayOfMonth !== undefined && (!Number.isInteger(value.dayOfMonth) || value.dayOfMonth < 1 || value.dayOfMonth > 31)) throw new Error('Invalid recurrence dayOfMonth');
27
+ if (value.frequency === 'monthly' && value.dayOfMonth === undefined) throw new Error('Monthly recurrence requires dayOfMonth');
28
+ if (value.endsAt != null) validateScheduleTimestamp(value.endsAt, 'endsAt');
29
+ if (value.maxRuns != null && (!Number.isInteger(value.maxRuns) || value.maxRuns < 1 || value.maxRuns > 1000)) throw new Error('Invalid recurrence maxRuns');
30
+ return { frequency: value.frequency, timeZone: value.timeZone, time: value.time,
31
+ ...(value.weekdays !== undefined ? { weekdays: [...value.weekdays].sort() } : {}),
32
+ ...(value.dayOfMonth !== undefined ? { dayOfMonth: value.dayOfMonth } : {}),
33
+ endsAt: value.endsAt ?? null, maxRuns: value.maxRuns ?? null };
34
+ }
35
+
36
+ function calendar(recurrence) {
37
+ const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: recurrence.timeZone,
38
+ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
39
+ const parts = timestamp => Object.fromEntries(formatter.formatToParts(timestamp)
40
+ .filter(part => part.type !== 'literal').map(part => [part.type, Number(part.value)]));
41
+ const localEpoch = timestamp => {
42
+ const p = parts(timestamp);
43
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
44
+ };
45
+ return { parts, localEpoch };
46
+ }
47
+
48
+ // Resolve a wall time by trying nearby UTC offsets. Gaps have no matching instant;
49
+ // folds select the earlier instant, so a local date can never execute twice.
50
+ function wallInstant(date, recurrence, localEpoch) {
51
+ const [hour, minute] = recurrence.time.split(':').map(Number);
52
+ const wall = date + hour * 3_600_000 + minute * 60_000;
53
+ const candidates = new Set();
54
+ for (let hours = -36; hours <= 36; hours += 6) {
55
+ const probe = wall + hours * 3_600_000;
56
+ const candidate = wall - (localEpoch(probe) - probe);
57
+ if (localEpoch(candidate) === wall) candidates.add(candidate);
58
+ }
59
+ return candidates.size ? Math.min(...candidates) : null;
60
+ }
61
+
62
+ function matchesDate(date, recurrence) {
63
+ const d = new Date(date);
64
+ const weekday = d.getUTCDay();
65
+ if (recurrence.frequency === 'weekdays') return weekday > 0 && weekday < 6;
66
+ if (recurrence.frequency === 'weekly') return recurrence.weekdays.includes(weekday);
67
+ if (recurrence.frequency === 'monthly') {
68
+ const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
69
+ return d.getUTCDate() === Math.min(recurrence.dayOfMonth, last);
70
+ }
71
+ return true;
72
+ }
73
+
74
+ function findOccurrence(recurrence, timestamp, direction) {
75
+ validateScheduleTimestamp(timestamp);
76
+ const { parts, localEpoch } = calendar(recurrence);
77
+ const p = parts(timestamp);
78
+ const date = Date.UTC(p.year, p.month - 1, p.day);
79
+ // Monthly schedules need at most 62 days even around a skipped civil date.
80
+ // A hard bound also protects dispatch from malformed persisted data.
81
+ for (let day = 0; day < 370; day++) {
82
+ const candidateDate = date + day * direction * DAY;
83
+ if (!matchesDate(candidateDate, recurrence)) continue;
84
+ const candidate = wallInstant(candidateDate, recurrence, localEpoch);
85
+ if (candidate == null || candidate < 0 || candidate > MAX_SCHEDULE_TIMESTAMP) continue;
86
+ if (direction > 0 ? candidate > timestamp : candidate <= timestamp) return candidate;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ export function nextOccurrence(recurrence, after) {
92
+ return findOccurrence(recurrence, after, 1);
93
+ }
94
+
95
+ export function latestOccurrence(recurrence, at) {
96
+ return findOccurrence(recurrence, at, -1);
97
+ }
98
+
99
+ export function initialOccurrence(recurrence, scheduledFor) {
100
+ validateScheduleTimestamp(scheduledFor);
101
+ if (!recurrence || latestOccurrence(recurrence, scheduledFor) === scheduledFor) return scheduledFor;
102
+ return nextOccurrence(recurrence, scheduledFor);
103
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { withTransaction } from './transaction.js';
2
3
  import { LLMAdapter } from '../llm/adapter.js';
3
4
  import { normalizeTokenUsage } from '../llm/usage-accounting.js';
4
5
 
@@ -88,10 +89,7 @@ export class WorkCenterResourceControl {
88
89
  }
89
90
 
90
91
  atomic(fn) {
91
- if (this.db.isTransaction) return fn();
92
- this.db.exec('BEGIN IMMEDIATE');
93
- try { const result = fn(); this.db.exec('COMMIT'); return result; }
94
- catch (error) { this.db.exec('ROLLBACK'); throw error; }
92
+ return withTransaction(this.db, fn);
95
93
  }
96
94
 
97
95
  ensure(id) {
@@ -1,3 +1,4 @@
1
+ import { normalizeRecurrence, validateScheduleTimestamp } from './recurrence.js';
1
2
  import { realpathSync, statSync } from 'node:fs';
2
3
  import { join, resolve } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
@@ -6,6 +7,7 @@ import { WorkflowController } from './controller.js';
6
7
  import { WorkItemWatcher } from './watcher.js';
7
8
  import {
8
9
  appendWorkItemAttachments,
10
+ cloneWorkItemAttachments,
9
11
  persistWorkItemAttachments,
10
12
  readWorkItemAttachment,
11
13
  removeWorkItemAttachmentFiles,
@@ -114,6 +116,7 @@ export class WorkCenterService {
114
116
  ...(await this.runtimeInfoProvider()),
115
117
  defaultStageInstructions: defaultWorkCenterStageInstructions(),
116
118
  workItemTypes: listWorkItemTypeTemplates(settings),
119
+ recurringSchedules: true,
117
120
  };
118
121
  };
119
122
  this.ownerBootId = options.ownerBootId || randomUUID();
@@ -255,10 +258,14 @@ export class WorkCenterService {
255
258
  workItemId,
256
259
  });
257
260
  const scheduledFor = payload.scheduledFor == null || payload.scheduledFor === ''
258
- ? null : Number(payload.scheduledFor);
261
+ ? null : payload.scheduledFor;
259
262
  if (scheduledFor != null && (!Number.isSafeInteger(scheduledFor) || scheduledFor <= this.now())) {
260
263
  throw new Error('scheduledFor must be in the future');
261
264
  }
265
+ if (scheduledFor != null) validateScheduleTimestamp(scheduledFor);
266
+ if (payload.scheduleEnabled !== undefined && typeof payload.scheduleEnabled !== 'boolean') throw new Error('scheduleEnabled must be a boolean');
267
+ const recurrence = normalizeRecurrence(payload.recurrence);
268
+ if (recurrence && scheduledFor == null) throw new Error('recurrence requires scheduledFor');
262
269
  const shouldStart = scheduledFor == null
263
270
  && (payload.start === undefined ? settings.startImmediately : payload.start !== false);
264
271
  const goal = requiredString(payload.goal, 'goal');
@@ -308,6 +315,7 @@ export class WorkCenterService {
308
315
  schedule: scheduledFor == null ? null : {
309
316
  status: payload.scheduleEnabled === false ? 'paused' : 'scheduled',
310
317
  scheduledFor,
318
+ recurrence,
311
319
  },
312
320
  start: false,
313
321
  });
@@ -745,8 +753,27 @@ export class WorkCenterService {
745
753
  #scanSchedules() {
746
754
  const now = this.now();
747
755
  for (const id of this.store.listDueScheduledWorkItemIds(now)) {
748
- const detail = this.controller.startScheduled(id, now);
749
- if (detail) this.#emit({ type: 'work_item.schedule_triggered', workItem: detail });
756
+ const createdAttachmentOwners = [];
757
+ try {
758
+ const before = this.store.getWorkItem(id);
759
+ const detail = this.controller.startScheduled(id, now, (source, occurrenceId) => {
760
+ const attachments = cloneWorkItemAttachments(source, occurrenceId, { root: this.attachmentRoot });
761
+ createdAttachmentOwners.push(occurrenceId);
762
+ return attachments;
763
+ });
764
+ const source = this.store.getWorkItemDetail(id);
765
+ if (detail) this.#emit({ type: 'work_item.schedule_triggered', workItem: source });
766
+ else if (source && source.revision !== before?.revision) {
767
+ this.#emit({ type: 'work_item.schedule_advanced', workItem: source });
768
+ }
769
+ if (detail && detail.id !== id) this.#emit({ type: 'work_item.created', workItem: detail });
770
+ } catch {
771
+ for (const owner of createdAttachmentOwners) {
772
+ if (!this.store.getWorkItem(owner)) removeWorkItemAttachments(this.attachmentRoot, owner);
773
+ }
774
+ const workItem = this.store.recordScheduleFailure(id);
775
+ if (workItem) this.#emit({ type: 'work_item.schedule_failed', workItem });
776
+ }
750
777
  }
751
778
  }
752
779