@openchambery/web 1.19.15 → 1.19.17-beta.1

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 (54) hide show
  1. package/dist/assets/{AssistantView-xdL1EvpR.js → AssistantView-WtjtyUyg.js} +1 -1
  2. package/dist/assets/{DiagramView-DPgkYU-_.js → DiagramView-hIHOl44C.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-Ym-7pIpr.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-BB1Q1RWk.js → MarkdownRendererImpl-CDnlh1Dp.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BjQrUIcq.js → MarkstreamRendererImpl-4acJU3sf.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-Cyzs6cuR.js → MobileDetailNavigation-6fa0eI33.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-A-Z6jMUn.js → MobileShareBridge-BkJMD_0e.js} +1 -1
  8. package/dist/assets/MobileSurface-D_6LtVFN.js +2 -0
  9. package/dist/assets/{MultiRunWindow-Cg5x-kJV.js → MultiRunWindow-2cqeffjI.js} +1 -1
  10. package/dist/assets/{SettingsView-CYrTHDdp.js → SettingsView-C5sFMDDr.js} +1 -1
  11. package/dist/assets/{SettingsWindow-CF-0Zgxr.js → SettingsWindow-Bdv6Aksi.js} +1 -1
  12. package/dist/assets/{TerminalView-CShou18D.js → TerminalView-CMkOh0DS.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-BvmxlNOa.js → ToolOutputDialog-CnJIHSL_.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-iqSTUpPj.js → appThemeRegistry-y_GyQ3ct.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-CddGH2aH.js → fileMentionTouchSelection-DpvBv5_N.js} +1 -1
  16. package/dist/assets/{gitApi-BwuaHHRQ.js → gitApi-m5nvxxzw.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-og6NBgdn.js → insertionBoundaries-BPmqh_Tr.js} +1 -1
  18. package/dist/assets/{main-VD-QBM47.js → main-B21k2oEx.js} +2 -2
  19. package/dist/assets/{main-31g3JiQx.js → main-D_QwFeXJ.js} +4 -4
  20. package/dist/assets/{miniChat-DwCMTamY.js → miniChat-Bp8luloI.js} +2 -2
  21. package/dist/assets/{mobile-C7q1yowe.js → mobile-BfgTp3XO.js} +2 -2
  22. package/dist/assets/{multirun-DcN5T0lN.js → multirun-DVHbCRKI.js} +1 -1
  23. package/dist/assets/{projectMeta-moNJYCK4.js → projectMeta-DRIkQQ-o.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-DVkgKA6A.js → renderElectronMiniChatApp-DQM5xlcU.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BgEXZhCO.js → renderMobileApp-CkvkhgAh.js} +4 -4
  26. package/dist/assets/{runtimeConfig-B49cxtgI.js → runtimeConfig-kj6kuVyP.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-9K_Dj1qR.js → runtimeEndpointReset-CgaiwIrw.js} +1 -1
  28. package/dist/assets/{sessionLookup-m3h8c4xR.js → sessionLookup-D-SPo12C.js} +1 -1
  29. package/dist/assets/{useAppFontEffects-DRFi4TCE.js → useAppFontEffects-Cx1Q-PF_.js} +5 -5
  30. package/dist/assets/{useDesktopWindowControlsLayout-BdNR3zMN.js → useDesktopWindowControlsLayout-Cju9H0m2.js} +1 -1
  31. package/dist/assets/{useEffectiveDirectory-COoPja_2.js → useEffectiveDirectory-8QrZ__Q1.js} +1 -1
  32. package/dist/assets/{useMobileNavigationStore-Cn0-ozUB.js → useMobileNavigationStore-BZYNB0CE.js} +1 -1
  33. package/dist/assets/{useSessionAutoCleanup-XwgyzsEd.js → useSessionAutoCleanup-CKKtjnuX.js} +1 -1
  34. package/dist/assets/{useWorkerHighlightedLines-GgcqEoKY.js → useWorkerHighlightedLines-DzP14_K3.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/mini-chat.html +2 -2
  37. package/dist/mobile.html +2 -2
  38. package/package.json +1 -1
  39. package/server/lib/assistants/DOCUMENTATION.md +11 -1
  40. package/server/lib/assistants/contact-store.js +18 -4
  41. package/server/lib/assistants/contact-tools.js +44 -0
  42. package/server/lib/assistants/contact-tools.test.js +2 -0
  43. package/server/lib/assistants/harness.js +2 -2
  44. package/server/lib/assistants/memory.http.test.js +204 -0
  45. package/server/lib/assistants/memory.js +124 -0
  46. package/server/lib/assistants/memory.test.js +159 -0
  47. package/server/lib/assistants/service.js +9 -0
  48. package/server/lib/scheduled-tasks/DOCUMENTATION.md +10 -0
  49. package/server/lib/scheduled-tasks/run-history-store.js +32 -6
  50. package/server/lib/scheduled-tasks/run-history-store.test.js +74 -0
  51. package/server/lib/scheduled-tasks/runtime.js +21 -5
  52. package/server/lib/scheduled-tasks/runtime.test.js +71 -0
  53. package/dist/assets/MarkdownRenderer-CP7UVZdr.js +0 -2
  54. package/dist/assets/MobileSurface-DjDa20gx.js +0 -2
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getContactAssistantContextBoundary, getContactContextBoundary, getContactGeneration } from './contact-store.js';
3
+
4
+ const SCAN_LIMIT = 200;
5
+ const fail = (code) => { throw Object.assign(new Error(code), { code }); };
6
+ const paramsObject = (value, keys) => {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !keys.includes(key))) fail('validation_error');
8
+ };
9
+ const integer = (value, fallback, min, max) => {
10
+ if (value === undefined) return fallback;
11
+ if (!Number.isSafeInteger(value) || value < min || value > max) fail('validation_error');
12
+ return value;
13
+ };
14
+ const time = (value) => {
15
+ if (value === undefined) return null;
16
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/u.test(value)) fail('validation_error');
17
+ const result = Date.parse(value);
18
+ if (!Number.isFinite(result)) fail('validation_error');
19
+ return result;
20
+ };
21
+
22
+ // Project only authored text, ordered exactly as the contact transcript.
23
+ // Attachment bodies, URLs, peer DMs, diagnostics and tool payloads never leave this reader.
24
+ const textRows = (count) => `
25
+ WITH text_parts AS (
26
+ SELECT message_id, json_extract(part_json, '$.text') AS text
27
+ FROM assistant_contact_part
28
+ WHERE message_id IN (${Array(count).fill('?').join(',')})
29
+ AND json_extract(part_json, '$.type') = 'text'
30
+ AND substr(json_extract(part_json, '$.text'), 1, 10) <> 'oc.settle.'
31
+ ORDER BY message_id, ordinal, part_id
32
+ ), texts AS (
33
+ SELECT message_id, group_concat(text, '') AS text FROM text_parts GROUP BY message_id
34
+ )`;
35
+
36
+ /** One contact turn's read-only view, fenced again against live memory state on every call. */
37
+ export function createContactMemoryReader(db, assistantID, { beforeOrdinal = null, assertAvailable = () => {} } = {}) {
38
+ const boundary = getContactContextBoundary(db, assistantID);
39
+ const assistantBoundary = getContactAssistantContextBoundary(db, assistantID);
40
+ const generation = getContactGeneration(db, assistantID);
41
+ const ceiling = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) AS value FROM assistant_contact_message WHERE assistant_id=?').get(assistantID).value);
42
+ const eligible = (row) => row.role !== 'peer' && ['user', 'assistant'].includes(row.role)
43
+ && row.status === 'complete' && !row.from_assistant_id
44
+ && (row.role !== 'assistant' || row.ordinal > assistantBoundary)
45
+ && (row.role !== 'user' || beforeOrdinal === null || row.ordinal < beforeOrdinal);
46
+ const check = () => {
47
+ assertAvailable();
48
+ if (getContactGeneration(db, assistantID) !== generation || getContactContextBoundary(db, assistantID) !== boundary
49
+ || getContactAssistantContextBoundary(db, assistantID) !== assistantBoundary) fail('memory_scope_changed');
50
+ };
51
+ return {
52
+ search(params = {}) {
53
+ check();
54
+ paramsObject(params, ['query', 'from', 'to', 'limit', 'cursor']);
55
+ const query = params.query === undefined ? '' : params.query;
56
+ if (typeof query !== 'string' || query.length > 256) fail('validation_error');
57
+ const from = time(params.from);
58
+ const to = time(params.to);
59
+ if (from !== null && to !== null && from > to) fail('validation_error');
60
+ const limit = integer(params.limit, 10, 1, 20);
61
+ const scope = createHash('sha256').update(JSON.stringify([assistantID, boundary, assistantBoundary, generation, ceiling, beforeOrdinal, query, from, to])).digest('hex');
62
+ let cursor = null;
63
+ if (params.cursor !== undefined) {
64
+ if (typeof params.cursor !== 'string' || params.cursor.length > 2048 || !/^[A-Za-z0-9_-]+$/u.test(params.cursor)) fail('invalid_memory_cursor');
65
+ try { cursor = JSON.parse(Buffer.from(params.cursor, 'base64url').toString('utf8')); } catch { fail('invalid_memory_cursor'); }
66
+ if (cursor?.v !== 1 || cursor.scope !== scope || !Number.isSafeInteger(cursor.ordinal)
67
+ || cursor.ordinal <= boundary || cursor.ordinal > ceiling || typeof cursor.messageID !== 'string'
68
+ || !cursor.messageID || cursor.messageID.length > 512) fail('invalid_memory_cursor');
69
+ }
70
+ const rows = db.prepare(`SELECT message_id, role, created_at, ordinal, status, from_assistant_id
71
+ FROM assistant_contact_message WHERE assistant_id=? AND ordinal>? AND ordinal<=?
72
+ ${cursor ? 'AND (ordinal, message_id) < (?, ?)' : ''}
73
+ ORDER BY ordinal DESC, message_id DESC LIMIT ?`).all(
74
+ assistantID, boundary, ceiling, ...(cursor ? [cursor.ordinal, cursor.messageID] : []), SCAN_LIMIT + 1,
75
+ );
76
+ const candidates = rows.slice(0, SCAN_LIMIT);
77
+ const ids = candidates.filter((row) => eligible(row)
78
+ && (from === null || row.created_at >= from) && (to === null || row.created_at <= to)).map((row) => row.message_id);
79
+ const snippets = ids.length === 0 ? [] : db.prepare(`${textRows(ids.length)}
80
+ SELECT message_id, substr(text, max(instr(lower(text), lower(?)) - 80, 1), 400) AS snippet,
81
+ max(instr(lower(text), lower(?)) - 81, 0) AS snippet_offset, length(text) AS total_chars
82
+ FROM texts WHERE length(trim(text)) > 0 AND (? = '' OR instr(lower(text), lower(?)) > 0)
83
+ `).all(...ids, query, query, query, query);
84
+ const byID = new Map(snippets.map((row) => [row.message_id, row]));
85
+ const matches = [];
86
+ let scanned = 0;
87
+ for (const row of candidates) {
88
+ scanned += 1;
89
+ const snippet = byID.get(row.message_id);
90
+ if (snippet) matches.push({
91
+ messageID: row.message_id, role: row.role, createdAt: row.created_at,
92
+ snippet: snippet.snippet, offset: snippet.snippet_offset, totalChars: snippet.total_chars,
93
+ });
94
+ if (matches.length === limit) break;
95
+ }
96
+ const last = candidates[scanned - 1];
97
+ const hasMore = scanned < rows.length;
98
+ const nextCursor = hasMore && last ? Buffer.from(JSON.stringify({
99
+ v: 1, scope, ordinal: last.ordinal, messageID: last.message_id,
100
+ })).toString('base64url') : null;
101
+ return { matches, nextCursor, complete: nextCursor === null, scanned };
102
+ },
103
+ read(params = {}) {
104
+ check();
105
+ paramsObject(params, ['messageID', 'offset', 'maxChars']);
106
+ if (typeof params.messageID !== 'string' || !params.messageID || params.messageID.length > 512) fail('validation_error');
107
+ const offset = integer(params.offset, 0, 0, Number.MAX_SAFE_INTEGER);
108
+ const maxChars = integer(params.maxChars, 4000, 1, 8000);
109
+ const row = db.prepare(`SELECT message_id, role, created_at, ordinal, status, from_assistant_id
110
+ FROM assistant_contact_message WHERE assistant_id=? AND message_id=? AND ordinal>? AND ordinal<=?`).get(
111
+ assistantID, params.messageID, boundary, ceiling,
112
+ );
113
+ if (!row || !eligible(row)) fail('memory_not_found');
114
+ const content = db.prepare(`${textRows(1)} SELECT substr(text, ?, ?) AS text, length(text) AS total_chars FROM texts`).get(row.message_id, offset + 1, maxChars);
115
+ if (!content || !content.total_chars) fail('memory_not_found');
116
+ if (offset > content.total_chars) fail('validation_error');
117
+ const nextOffset = offset + maxChars < content.total_chars ? offset + maxChars : null;
118
+ return {
119
+ messageID: row.message_id, role: row.role, createdAt: row.created_at,
120
+ text: content.text, offset, totalChars: content.total_chars, nextOffset, complete: nextOffset === null,
121
+ };
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,159 @@
1
+ import { createRequire } from 'node:module';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { createContactMemoryReader } from './memory.js';
4
+ import { bumpContactGeneration, clearContactMemory, contactHistoryForLlm, deleteContactMessages, ensureContactSchema, insertContactMessage } from './contact-store.js';
5
+
6
+ const Database = createRequire(import.meta.url)('better-sqlite3');
7
+ const databases = [];
8
+ afterEach(() => { while (databases.length) databases.pop().close(); });
9
+ const fixture = () => {
10
+ const db = new Database(':memory:');
11
+ databases.push(db);
12
+ ensureContactSchema(db);
13
+ let ordinal = 0;
14
+ const insert = (messageID, text, { assistantID = 'a', role = 'user', turnID = messageID, ...extra } = {}) => {
15
+ insertContactMessage(db, {
16
+ messageID, assistantID, role, turnID, ordinal: ++ordinal, createdAt: 1_700_000_000_000 + ordinal,
17
+ parts: [{ type: 'text', text }], ...extra,
18
+ });
19
+ return ordinal;
20
+ };
21
+ return { db, insert };
22
+ };
23
+
24
+ describe('contact memory boundaries', () => {
25
+ it('forgets a preceding turn late reply while preserving queued users after clear-memory admission', () => {
26
+ const { db, insert } = fixture();
27
+ insert('prior', 'Old decision');
28
+ const clearing = insert('clearing', 'Clear memory');
29
+ insert('queued', 'New topic');
30
+ insert('late-prior', 'Old decision details', { role: 'assistant', turnID: 'prior' });
31
+ clearContactMemory(db, 'a', { upToOrdinal: clearing });
32
+ insert('reset-confirm', 'Ready for a new topic', { role: 'assistant', turnID: 'clearing' });
33
+ const reader = createContactMemoryReader(db, 'a');
34
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['reset-confirm', 'queued']);
35
+ expect(() => reader.read({ messageID: 'late-prior' })).toThrow('memory_not_found');
36
+ expect(contactHistoryForLlm(db, 'a').map((m) => m.content)).toEqual(['New topic', 'Ready for a new topic']);
37
+ });
38
+
39
+ it('scopes exact reads, search and cursors to one assistant and rejects target overrides', () => {
40
+ const { db, insert } = fixture();
41
+ insert('a1', 'needle own'); insert('a2', 'needle own later');
42
+ insert('b1', 'needle other', { assistantID: 'b' });
43
+ const a = createContactMemoryReader(db, 'a');
44
+ const b = createContactMemoryReader(db, 'b');
45
+ expect(a.search({ query: 'needle' }).matches.map((m) => m.messageID)).toEqual(['a2', 'a1']);
46
+ expect(() => a.read({ messageID: 'b1' })).toThrow('memory_not_found');
47
+ expect(() => a.search({ assistantID: 'b' })).toThrow('validation_error');
48
+ expect(() => b.search({ query: 'needle', cursor: a.search({ query: 'needle', limit: 1 }).nextCursor })).toThrow('invalid_memory_cursor');
49
+ });
50
+
51
+ it('bounds scans and exposes empty partial pages until an older match is reached', () => {
52
+ const { db, insert } = fixture();
53
+ insert('old', 'rare decision');
54
+ for (let i = 0; i < 410; i++) insert(`filler-${i}`, 'ordinary');
55
+ const reader = createContactMemoryReader(db, 'a');
56
+ let page = reader.search({ query: 'rare' });
57
+ expect(page).toMatchObject({ matches: [], complete: false, scanned: 200 });
58
+ page = reader.search({ query: 'rare', cursor: page.nextCursor });
59
+ expect(page).toMatchObject({ matches: [], complete: false, scanned: 200 });
60
+ page = reader.search({ query: 'rare', cursor: page.nextCursor });
61
+ expect(page.matches.map((m) => m.messageID)).toEqual(['old']);
62
+ expect(page.complete).toBe(true);
63
+ expect(page.scanned).toBe(11);
64
+ });
65
+
66
+ it('paginates matching rows without gaps and returns literal, dated snippets', () => {
67
+ const { db, insert } = fixture();
68
+ for (let i = 0; i < 7; i++) insert(`match-${i}`, `100%_literal ${i}`);
69
+ const reader = createContactMemoryReader(db, 'a');
70
+ const ids = [];
71
+ let cursor;
72
+ do {
73
+ const page = reader.search({ query: '%_', limit: 2, ...(cursor ? { cursor } : {}) });
74
+ ids.push(...page.matches.map((m) => m.messageID));
75
+ cursor = page.nextCursor;
76
+ } while (cursor);
77
+ expect(ids).toEqual(['match-6', 'match-5', 'match-4', 'match-3', 'match-2', 'match-1', 'match-0']);
78
+ const page = reader.search({ from: new Date(1_700_000_000_003).toISOString(), to: new Date(1_700_000_000_004).toISOString() });
79
+ expect(page.matches.map((m) => m.messageID)).toEqual(['match-3', 'match-2']);
80
+ });
81
+
82
+ it('reads long Unicode messages by character offsets, including a match beyond the first chunk', () => {
83
+ const { db, insert } = fixture();
84
+ const text = '你好🙂'.repeat(2000) + 'rare ending';
85
+ insert('long', text);
86
+ const reader = createContactMemoryReader(db, 'a');
87
+ const found = reader.search({ query: 'rare' }).matches[0];
88
+ expect(found.snippet).toContain('rare ending');
89
+ expect(found.offset).toBeGreaterThan(4000);
90
+ let offset = 0;
91
+ let recovered = '';
92
+ do {
93
+ const page = reader.read({ messageID: 'long', offset, maxChars: 997 });
94
+ recovered += page.text;
95
+ offset = page.nextOffset;
96
+ } while (offset !== null);
97
+ expect(recovered).toBe(text);
98
+ expect(() => reader.read({ messageID: 'long', offset: 99999 })).toThrow('validation_error');
99
+ });
100
+
101
+ it('invalidates captured readers and cursors on clear and wipe, retaining visible history only for clear', () => {
102
+ const { db, insert } = fixture();
103
+ insert('old-1', 'old'); insert('old-2', 'old');
104
+ const reader = createContactMemoryReader(db, 'a');
105
+ const cursor = reader.search({ limit: 1 }).nextCursor;
106
+ clearContactMemory(db, 'a');
107
+ expect(() => reader.search({ cursor })).toThrow('memory_scope_changed');
108
+ expect(() => reader.read({ messageID: 'old-1' })).toThrow('memory_scope_changed');
109
+ const fresh = createContactMemoryReader(db, 'a');
110
+ expect(fresh.search().matches).toEqual([]);
111
+ expect(() => fresh.read({ messageID: 'old-1' })).toThrow('memory_not_found');
112
+ expect(() => fresh.search({ cursor })).toThrow('invalid_memory_cursor');
113
+ deleteContactMessages(db, 'a'); bumpContactGeneration(db, 'a');
114
+ expect(() => fresh.search()).toThrow('memory_scope_changed');
115
+ expect(createContactMemoryReader(db, 'a').search().matches).toEqual([]);
116
+ });
117
+
118
+ it('excludes peers, errors, internal markers, attachment bodies and later queued admissions', () => {
119
+ const { db, insert } = fixture();
120
+ insert('visible', 'visible');
121
+ const current = insert('current', 'current question');
122
+ insert('queued', 'queued secret');
123
+ insert('error', 'error secret', { role: 'assistant', status: 'error' });
124
+ insert('peer', 'peer secret', { role: 'peer', fromAssistantID: 'b' });
125
+ insert('internal', 'oc.settle.complete', { role: 'assistant' });
126
+ insert('file', '', { parts: [{ type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,c2VjcmV0', filename: 'private.txt' }] });
127
+ const reader = createContactMemoryReader(db, 'a', { beforeOrdinal: current });
128
+ insert('future', 'future secret');
129
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['visible']);
130
+ for (const id of ['current', 'queued', 'error', 'peer', 'internal', 'file', 'future']) expect(() => reader.read({ messageID: id })).toThrow('memory_not_found');
131
+ });
132
+
133
+ it('rejects malformed arguments and changed-filter cursors while database failures remain errors', () => {
134
+ const { db, insert } = fixture();
135
+ insert('one', 'needle'); insert('two', 'needle');
136
+ const reader = createContactMemoryReader(db, 'a');
137
+ for (const params of [{ limit: 21 }, { query: 1 }, { from: 'yesterday' }, { cursor: '{}' }, { cursor: 'bnVsbA' }]) expect(() => reader.search(params)).toThrow();
138
+ const cursor = reader.search({ query: 'needle', limit: 1 }).nextCursor;
139
+ expect(() => reader.search({ query: 'different', cursor })).toThrow('invalid_memory_cursor');
140
+ db.exec('DROP TABLE assistant_contact_part');
141
+ expect(() => reader.search()).toThrow();
142
+ expect(() => reader.read({ messageID: 'one' })).toThrow();
143
+ });
144
+
145
+ it('migrates existing boundaries and rolls both watermarks back on failed transactions', () => {
146
+ const { db, insert } = fixture();
147
+ insert('old', 'old text');
148
+ db.exec('ALTER TABLE assistant_contact_context_boundary DROP COLUMN assistant_after_ordinal');
149
+ db.prepare('INSERT INTO assistant_contact_context_boundary VALUES (?,?,?)').run('a', 1, 1);
150
+ ensureContactSchema(db);
151
+ insert('new', 'new text');
152
+ const reader = createContactMemoryReader(db, 'a');
153
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['new']);
154
+ db.exec('BEGIN');
155
+ clearContactMemory(db, 'a');
156
+ db.exec('ROLLBACK');
157
+ expect(reader.search().matches.map((m) => m.messageID)).toEqual(['new']);
158
+ });
159
+ });
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { createOpencodeClient } from '@opencode-ai/sdk/v2';
6
6
  import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
7
7
  import { reduceBackfillState } from './history-state.js';
8
+ import { createContactMemoryReader } from './memory.js';
8
9
  import { getWorktrees as defaultListWorktrees } from '../git/service.js';
9
10
  import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
10
11
  import {
@@ -2240,7 +2241,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2240
2241
  history,
2241
2242
  { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
2242
2243
  );
2244
+ const memory = createContactMemoryReader(db, assistantID, { assertAvailable: () => editable(assistantID) });
2243
2245
  const tools = createContactTools({
2246
+ searchMemory: memory.search,
2247
+ readMemory: memory.read,
2244
2248
  assignWork: (params) => assignWork(live, params),
2245
2249
  watchSession: (params) => watchSessionWork(live, params),
2246
2250
  stopSession: (params) => stopSessionWork(live, params),
@@ -2570,7 +2574,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2570
2574
  (Array.isArray(executionParts) ? executionParts : []).filter((part) => part?.type === 'file'),
2571
2575
  );
2572
2576
  const turnAttachmentScope = attachmentScopeKey(turnFileParts);
2577
+ const memory = createContactMemoryReader(db, row.assistant_id, {
2578
+ beforeOrdinal, assertAvailable: () => editable(row.assistant_id),
2579
+ });
2573
2580
  const tools = createContactTools({
2581
+ searchMemory: memory.search,
2582
+ readMemory: memory.read,
2574
2583
  assignWork: (params) => assignWork(row, params),
2575
2584
  watchSession: (params) => watchSessionWork(row, params),
2576
2585
  stopSession: (params) => stopSessionWork(row, params),
@@ -25,6 +25,8 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
25
25
  - Independent SQLite file `scheduled-task-runs.sqlite` (WAL)
26
26
  - Keyset-paginated list of run records
27
27
  - On open, converges leftover `running` rows to `error` with an interrupted message
28
+ - Scheduled runs claim `(project_id, task_id, slot_at)` so multiple OpenChamber
29
+ processes sharing a data dir cannot occupy the same fire slot
28
30
 
29
31
  - `packages/web/server/lib/scheduled-tasks/routes.js`
30
32
  - Global scheduled task list endpoint with per-project partial-result handling
@@ -101,6 +103,11 @@ Persisted columns:
101
103
  | `started_at` | Epoch ms |
102
104
  | `finished_at` | Nullable epoch ms |
103
105
  | `duration_ms` | Nullable |
106
+ | `slot_at` | Scheduled occupancy key (the armed `nextRunAt`). Null for manual runs. List DTO omits this column. |
107
+
108
+ Partial unique index `scheduled_task_run_scheduled_slot` on
109
+ `(project_id, task_id, slot_at)` where `trigger = 'scheduled' AND slot_at IS NOT NULL`.
110
+ A second `startRun` for the same slot throws `SCHEDULED_SLOT_CLAIMED`.
104
111
 
105
112
  Indexes support `started_at DESC, run_id DESC` listing with project/task filters.
106
113
 
@@ -168,6 +175,9 @@ failure preserves tasks from completed projects and adds that project ID to
168
175
  Every actual run (timer or manual):
169
176
 
170
177
  1. Allocate a unique `runID` and persist `status=running` in the history store.
178
+ Timer fires pass the armed `nextRunAt` as `slotAt`. A duplicate scheduled
179
+ slot claim skips the run: no session, no task-state write, no notification.
180
+ The losing scheduler arms the next occurrence and returns `{ skipped: true }`.
171
181
  2. Persist task state `lastStatus=running`.
172
182
  3. Create a per-run `AbortController` and race `runTaskWithWatchdog` against
173
183
  `maxRunDurationMs` (default 2 hours). Only this watchdog timeout aborts
@@ -8,6 +8,8 @@ const require = createRequire(import.meta.url);
8
8
  const DEFAULT_LIMIT = 20;
9
9
  const MAX_LIMIT = 100;
10
10
  const INTERRUPTED_ERROR = 'Run interrupted by process restart';
11
+ export const SCHEDULED_SLOT_CLAIMED_CODE = 'SCHEDULED_SLOT_CLAIMED';
12
+ const SQLITE_UNIQUE_CODES = new Set(['SQLITE_CONSTRAINT', 'SQLITE_CONSTRAINT_UNIQUE']);
11
13
 
12
14
  const asNonEmptyString = (value) => {
13
15
  if (typeof value !== 'string') {
@@ -105,6 +107,18 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
105
107
  ON scheduled_task_run (project_id, task_id, started_at DESC, run_id DESC);
106
108
  `);
107
109
 
110
+ const hasSlotAt = db.prepare(`
111
+ SELECT 1 AS ok FROM pragma_table_info('scheduled_task_run') WHERE name = 'slot_at'
112
+ `).get();
113
+ if (!hasSlotAt) {
114
+ db.exec('ALTER TABLE scheduled_task_run ADD COLUMN slot_at INTEGER');
115
+ }
116
+ db.exec(`
117
+ CREATE UNIQUE INDEX IF NOT EXISTS scheduled_task_run_scheduled_slot
118
+ ON scheduled_task_run (project_id, task_id, slot_at)
119
+ WHERE trigger = 'scheduled' AND slot_at IS NOT NULL
120
+ `);
121
+
108
122
  const now = () => Math.trunc(clock());
109
123
 
110
124
  const convergeInterruptedRuns = () => {
@@ -137,17 +151,29 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
137
151
  const trigger = record.trigger === 'manual' ? 'manual' : 'scheduled';
138
152
  const directory = asNonEmptyString(record.directory);
139
153
  const startedAt = asFiniteInteger(record.startedAt) ?? now();
154
+ const slotAt = trigger === 'scheduled'
155
+ ? asFiniteInteger(record.slotAt ?? record.slot_at)
156
+ : null;
140
157
 
141
158
  if (!projectId || !taskId) {
142
159
  throw new Error('projectId and taskId are required');
143
160
  }
144
161
 
145
- db.prepare(`
146
- INSERT INTO scheduled_task_run (
147
- run_id, project_id, task_id, task_name, trigger, status,
148
- session_id, directory, error, started_at, finished_at, duration_ms
149
- ) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL)
150
- `).run(id, projectId, taskId, taskName, trigger, directory, startedAt);
162
+ try {
163
+ db.prepare(`
164
+ INSERT INTO scheduled_task_run (
165
+ run_id, project_id, task_id, task_name, trigger, status,
166
+ session_id, directory, error, started_at, finished_at, duration_ms, slot_at
167
+ ) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?)
168
+ `).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt);
169
+ } catch (error) {
170
+ if (slotAt !== null && SQLITE_UNIQUE_CODES.has(error?.code)) {
171
+ const claimed = new Error('scheduled slot already claimed');
172
+ claimed.code = SCHEDULED_SLOT_CLAIMED_CODE;
173
+ throw claimed;
174
+ }
175
+ throw error;
176
+ }
151
177
 
152
178
  return rowToDto(db.prepare('SELECT * FROM scheduled_task_run WHERE run_id = ?').get(id));
153
179
  };
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { afterEach, describe, expect, it } from 'vitest';
5
6
  import { createScheduledTaskRunHistoryStore } from './run-history-store.js';
6
7
 
8
+ const require = createRequire(import.meta.url);
9
+
7
10
  const directories = [];
8
11
 
9
12
  const createTempDbPath = () => {
@@ -26,6 +29,7 @@ const startSample = (store, overrides = {}) => store.startRun({
26
29
  trigger: overrides.trigger ?? 'scheduled',
27
30
  directory: overrides.directory ?? '/tmp/project-a',
28
31
  startedAt: overrides.startedAt,
32
+ slotAt: overrides.slotAt,
29
33
  });
30
34
 
31
35
  describe('scheduled task run history store', () => {
@@ -160,4 +164,74 @@ describe('scheduled task run history store', () => {
160
164
  expect(() => store.listRuns({ limit: -3 })).toThrow(/limit/i);
161
165
  store.close();
162
166
  });
167
+
168
+ it('lets only one scheduled startRun occupy a slot, including across store handles', () => {
169
+ const dbPath = createTempDbPath();
170
+ const first = createScheduledTaskRunHistoryStore({ dbPath });
171
+ startSample(first, { id: 'run-a', slotAt: 1_700_000_000_000 });
172
+
173
+ expect(() => startSample(first, { id: 'run-b', slotAt: 1_700_000_000_000 })).toThrow(
174
+ /already claimed/,
175
+ );
176
+
177
+ const second = createScheduledTaskRunHistoryStore({ dbPath });
178
+ expect(() => startSample(second, { id: 'run-c', slotAt: 1_700_000_000_000 })).toThrow(
179
+ /already claimed/,
180
+ );
181
+
182
+ startSample(first, { id: 'run-next-slot', slotAt: 1_700_000_000_000 + 60_000 });
183
+ startSample(first, {
184
+ id: 'run-manual',
185
+ trigger: 'manual',
186
+ slotAt: 1_700_000_000_000,
187
+ });
188
+
189
+ expect(first.listRuns({}).runs.map((run) => run.id).sort()).toEqual([
190
+ 'run-a',
191
+ 'run-manual',
192
+ 'run-next-slot',
193
+ ].sort());
194
+
195
+ first.close();
196
+ second.close();
197
+ });
198
+
199
+ it('adds slot occupancy to databases created before slot_at existed', () => {
200
+ const dbPath = createTempDbPath();
201
+ const Database = require('better-sqlite3');
202
+ const legacy = new Database(dbPath);
203
+ legacy.exec(`
204
+ CREATE TABLE scheduled_task_run (
205
+ run_id TEXT PRIMARY KEY NOT NULL,
206
+ project_id TEXT NOT NULL,
207
+ task_id TEXT NOT NULL,
208
+ task_name TEXT NOT NULL,
209
+ trigger TEXT NOT NULL,
210
+ status TEXT NOT NULL,
211
+ session_id TEXT,
212
+ directory TEXT,
213
+ error TEXT,
214
+ started_at INTEGER NOT NULL,
215
+ finished_at INTEGER,
216
+ duration_ms INTEGER
217
+ );
218
+ `);
219
+ legacy.prepare(`
220
+ INSERT INTO scheduled_task_run (
221
+ run_id, project_id, task_id, task_name, trigger, status,
222
+ session_id, directory, error, started_at, finished_at, duration_ms
223
+ ) VALUES ('legacy-run', 'project-a', 'task-1', 'Morning Sync', 'scheduled',
224
+ 'success', NULL, '/tmp/project-a', NULL, 50, 60, 10)
225
+ `).run();
226
+ legacy.close();
227
+
228
+ const store = createScheduledTaskRunHistoryStore({ dbPath });
229
+ expect(store.listRuns({}).runs).toHaveLength(1);
230
+ startSample(store, { id: 'run-a', slotAt: 1_700_000_000_000 });
231
+ expect(() => startSample(store, { id: 'run-b', slotAt: 1_700_000_000_000 })).toThrow(
232
+ /already claimed/,
233
+ );
234
+ expect(store.listRuns({}).runs.map((run) => run.id)).toEqual(['run-a', 'legacy-run']);
235
+ store.close();
236
+ });
163
237
  });
@@ -3,6 +3,7 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
3
3
  import { DateTime } from 'luxon';
4
4
  import parser from 'cron-parser';
5
5
  import { expandSnippets } from '../opencode/snippets.js';
6
+ import { SCHEDULED_SLOT_CLAIMED_CODE } from './run-history-store.js';
6
7
 
7
8
  const DEFAULT_GLOBAL_CONCURRENCY = 4;
8
9
  const DEFAULT_PROJECT_CONCURRENCY = 2;
@@ -535,7 +536,7 @@ export const createScheduledTasksRuntime = (deps) => {
535
536
  if (!task || !task.enabled) {
536
537
  return;
537
538
  }
538
- queueTaskRun(projectID, taskID, 'scheduled');
539
+ queueTaskRun(projectID, taskID, 'scheduled', nextRunAt);
539
540
  pumpQueue();
540
541
  }, boundedDelay);
541
542
 
@@ -669,13 +670,13 @@ export const createScheduledTasksRuntime = (deps) => {
669
670
  }
670
671
  };
671
672
 
672
- const queueTaskRun = (projectID, taskID, reason) => {
673
+ const queueTaskRun = (projectID, taskID, reason, slotAt) => {
673
674
  const taskKey = buildTaskKey(projectID, taskID);
674
675
  if (queuedTaskKeys.has(taskKey) || runningTaskKeys.has(taskKey)) {
675
676
  return;
676
677
  }
677
678
  queuedTaskKeys.add(taskKey);
678
- queue.push({ projectID, taskID, reason });
679
+ queue.push({ projectID, taskID, reason, slotAt });
679
680
  };
680
681
 
681
682
  const canRunTask = (projectID) => {
@@ -1231,7 +1232,7 @@ export const createScheduledTasksRuntime = (deps) => {
1231
1232
  }
1232
1233
  };
1233
1234
 
1234
- const runTask = async (projectID, taskID, reason) => {
1235
+ const runTask = async (projectID, taskID, reason, slotAt) => {
1235
1236
  const taskMap = tasksByProject.get(projectID);
1236
1237
  const task = taskMap?.get(taskID);
1237
1238
  if (!task || !task.enabled) {
@@ -1264,6 +1265,9 @@ export const createScheduledTasksRuntime = (deps) => {
1264
1265
  if (runHistoryStore && typeof runHistoryStore.startRun === 'function') {
1265
1266
  try {
1266
1267
  runID = crypto.randomUUID();
1268
+ const resolvedSlotAt = reason === 'scheduled' && Number.isFinite(slotAt)
1269
+ ? Math.trunc(slotAt)
1270
+ : undefined;
1267
1271
  runHistoryStore.startRun({
1268
1272
  id: runID,
1269
1273
  projectId: projectID,
@@ -1272,8 +1276,20 @@ export const createScheduledTasksRuntime = (deps) => {
1272
1276
  trigger: reason === 'manual' ? 'manual' : 'scheduled',
1273
1277
  directory: projectPath,
1274
1278
  startedAt: runStartedAt,
1279
+ ...(resolvedSlotAt !== undefined ? { slotAt: resolvedSlotAt } : {}),
1275
1280
  });
1276
1281
  } catch (error) {
1282
+ if (error?.code === SCHEDULED_SLOT_CLAIMED_CODE) {
1283
+ const nextRunAt = computeNextRunAt(task, Date.now());
1284
+ if (task.enabled && Number.isFinite(nextRunAt)) {
1285
+ scheduleTask(projectID, taskID, nextRunAt);
1286
+ }
1287
+ logger.info?.('[ScheduledTasks] skipped duplicate scheduled slot', {
1288
+ projectID,
1289
+ taskID,
1290
+ });
1291
+ return { ok: false, skipped: true };
1292
+ }
1277
1293
  errorMessage = safeErrorMessage(error);
1278
1294
  runID = null;
1279
1295
  logger.warn?.('[ScheduledTasks] failed to start run history', {
@@ -1547,7 +1563,7 @@ export const createScheduledTasksRuntime = (deps) => {
1547
1563
  queuedTaskKeys.delete(taskKey);
1548
1564
  consumed = true;
1549
1565
 
1550
- void runTask(item.projectID, item.taskID, item.reason).finally(() => {
1566
+ void runTask(item.projectID, item.taskID, item.reason, item.slotAt).finally(() => {
1551
1567
  pumpQueue();
1552
1568
  });
1553
1569
  }