@openchambery/web 1.19.13-beta.3 → 1.19.13-beta.5
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/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-B5MiX004.js} +1 -1
- package/dist/assets/AssistantView-B6kZZdri.js +2 -0
- package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-C40ituM2.js} +1 -1
- package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
- package/dist/assets/{Icon-CkzzhaLr.js → Icon-ZAKaNHiG.js} +1 -1
- package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-mcY597G0.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-YsTrEeUY.js} +6 -3
- package/dist/assets/MarkstreamRendererImpl-kNx_G9Sd.js +3 -0
- package/dist/assets/MobileDetailNavigation-Uwzh6TWj.js +1 -0
- package/dist/assets/MobileShareBridge-D3oSiSC6.js +4 -0
- package/dist/assets/MobileSurface-B-SWjAj7.js +2 -0
- package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-LgdUonfa.js} +1 -1
- package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-DHKq6y3W.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
- package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-iUiWTKbM.js} +8 -8
- package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-DCDEAC1n.js} +1 -1
- package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-CdZi_UXQ.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-fZq6UHvy.js} +1 -1
- package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-DyFotcOM.js} +1 -1
- package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
- package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.js} +1 -1
- package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
- package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
- package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
- package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
- package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
- package/dist/assets/{dialog-BwQ3rY_x.js → dialog-U4ML8hxt.js} +1 -1
- package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
- package/dist/assets/fileMentionTouchSelection-DSKcxgU_.js +4 -0
- package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
- package/dist/assets/{fr-D9taEN9L.js → fr-DPohCCUO.js} +1 -1
- package/dist/assets/{gitApi-D1mvv14J.js → gitApi-BUubBKri.js} +3 -3
- package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
- package/dist/assets/insertionBoundaries-DeGd-RP9.js +1 -0
- package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
- package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
- package/dist/assets/main-C6EluFKk.js +169 -0
- package/dist/assets/{main-phpzFgMN.js → main-iFGhn-zU.js} +2 -2
- package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
- package/dist/assets/miniChat-Bs_H98PB.js +2 -0
- package/dist/assets/mobile-HfLUO-bG.js +2 -0
- package/dist/assets/{multirun-CYuxJPBX.js → multirun-BABuRg1O.js} +1 -1
- package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
- package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
- package/dist/assets/platform-DQ5_LNKO.js +2 -0
- package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-WcxDg0DZ.js} +2 -2
- package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-Cp-dVXj5.js} +2 -2
- package/dist/assets/renderMobileApp-B6rb6wuc.js +28 -0
- package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-88DzHVMT.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CEbKea6x.js +147 -0
- package/dist/assets/sessionChangeSummary-C9071lsK.js +2 -0
- package/dist/assets/sessionLookup-CEw3tCz1.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-eL2whsf8.js} +1 -1
- package/dist/assets/src-B_DG0xy5.css +2 -0
- package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
- package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
- package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
- package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
- package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
- package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
- package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-Ca0mASq5.js} +43 -44
- package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-B3KJYYwp.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B4l9snxd.js} +1 -1
- package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
- package/dist/assets/useMobileNavigationStore-CU2UwmN3.js +413 -0
- package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-B9El9yjX.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-PPhFeDFI.js} +1 -1
- package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
- package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-92n_rGTt.js} +1 -1
- package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.js +1 -0
- package/server/lib/assistants/DOCUMENTATION.md +104 -18
- package/server/lib/assistants/assign.js +173 -13
- package/server/lib/assistants/assign.test.js +139 -0
- package/server/lib/assistants/bubbles.js +1 -1
- package/server/lib/assistants/contact-store.js +496 -11
- package/server/lib/assistants/contact-store.test.js +323 -7
- package/server/lib/assistants/contact-tools.js +514 -52
- package/server/lib/assistants/contact-tools.test.js +390 -7
- package/server/lib/assistants/contracts.js +31 -0
- package/server/lib/assistants/harness.js +224 -54
- package/server/lib/assistants/harness.test.js +372 -12
- package/server/lib/assistants/routes.js +3 -0
- package/server/lib/assistants/service.js +1079 -38
- package/server/lib/assistants/service.test.js +1235 -26
- package/server/lib/llm/DOCUMENTATION.md +2 -0
- package/server/lib/llm/completions.js +4 -0
- package/server/lib/llm/completions.test.js +3 -0
- package/server/lib/llm/generate.js +12 -5
- package/server/lib/llm/generate.test.js +40 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
- package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
- package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +0 -1
- package/dist/assets/MobileShareBridge-DtwVLkBL.js +0 -4
- package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
- package/dist/assets/insertionBoundaries-BBn8YW82.js +0 -4
- package/dist/assets/main-Cd7NwXx5.js +0 -169
- package/dist/assets/miniChat-D6UYe50H.js +0 -2
- package/dist/assets/mobile-w2Ed9I5O.js +0 -2
- package/dist/assets/platform-DzsGqlGW.js +0 -2
- package/dist/assets/renderMobileApp-BvIE86_O.js +0 -28
- package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +0 -147
- package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
- package/dist/assets/sessionLookup-CNRDupYq.js +0 -1
- package/dist/assets/src-AA-5nXsF.css +0 -2
- package/dist/assets/useMobileNavigationStore-DANrWekj.js +0 -413
|
@@ -10,23 +10,29 @@ import { contactCardIdentity, parseContactCard, parseContactPart } from './cards
|
|
|
10
10
|
import {
|
|
11
11
|
CONTACT_PAGE_DEFAULT_LIMIT,
|
|
12
12
|
CONTACT_PAGE_MAX_LIMIT,
|
|
13
|
-
|
|
13
|
+
advanceContactReadWatermark,
|
|
14
14
|
bumpContactGeneration,
|
|
15
15
|
clearContactMemory as clearContactMemoryStore,
|
|
16
16
|
contactHistoryForLlm,
|
|
17
17
|
createActiveContactTurn,
|
|
18
18
|
deleteContactMessages,
|
|
19
|
+
deleteContactReadState,
|
|
19
20
|
ensureContactSchema,
|
|
20
21
|
contactPartsFingerprint,
|
|
21
22
|
getContactMessage,
|
|
23
|
+
getContactReadTip,
|
|
24
|
+
getContactReadWatermark,
|
|
25
|
+
getContactUnreadSnapshots,
|
|
22
26
|
getLatestContactMessagePreview,
|
|
23
27
|
getLatestContactMessagePreviews,
|
|
24
28
|
insertContactMessage,
|
|
25
29
|
listContactMessages,
|
|
26
30
|
listInFlightWatches,
|
|
27
31
|
listWatchesBySession,
|
|
32
|
+
migrateContactReadStateDefaultRead,
|
|
28
33
|
nextContactOrdinal,
|
|
29
34
|
projectActiveContactTurn,
|
|
35
|
+
resetContactReadWatermarkForGeneration,
|
|
30
36
|
updateSessionCardStatus,
|
|
31
37
|
upsertContactWatch,
|
|
32
38
|
} from './contact-store.js';
|
|
@@ -64,8 +70,12 @@ import {
|
|
|
64
70
|
ASSIGN_CODES,
|
|
65
71
|
assignSession,
|
|
66
72
|
attachmentScopeKey,
|
|
73
|
+
extractSessionDirectory,
|
|
74
|
+
extractSessionTitle,
|
|
67
75
|
hasAssignImageParts,
|
|
76
|
+
mapSessionToWatchStatus,
|
|
68
77
|
resolveAssignDirectory,
|
|
78
|
+
extractAssignSessionModel,
|
|
69
79
|
resolveAssignWorkerModel,
|
|
70
80
|
sanitizeAssignFileParts,
|
|
71
81
|
} from './assign.js';
|
|
@@ -73,7 +83,7 @@ import { runContactTurn as defaultRunContactTurn } from './harness.js';
|
|
|
73
83
|
import { loadConnectedCatalog } from '../llm/catalog.js';
|
|
74
84
|
|
|
75
85
|
const require = createRequire(import.meta.url);
|
|
76
|
-
const SCHEMA_VERSION =
|
|
86
|
+
const SCHEMA_VERSION = 13;
|
|
77
87
|
const normalizeSessionDirectory = (value) => {
|
|
78
88
|
if (typeof value !== 'string') return null;
|
|
79
89
|
const trimmed = value.trim();
|
|
@@ -128,6 +138,12 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
128
138
|
const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
|
|
129
139
|
/** Bound catalog / project list waits so a contact lane cannot hang forever. */
|
|
130
140
|
export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
|
|
141
|
+
/** Bound last worker assistant text injected into assigned-session resume prompts. */
|
|
142
|
+
export const ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX = 2_000;
|
|
143
|
+
/** Stable resume turn/message id for assigned-session complete/error continuation. */
|
|
144
|
+
export const assignedSessionResumeMessageID = (assistantID, sessionID, status, updatedAt) => (
|
|
145
|
+
`resume_${assistantID}_${sessionID}_${status}_${updatedAt}`
|
|
146
|
+
);
|
|
131
147
|
const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
|
|
132
148
|
let timer = null;
|
|
133
149
|
try {
|
|
@@ -146,7 +162,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
|
|
|
146
162
|
}
|
|
147
163
|
};
|
|
148
164
|
|
|
149
|
-
export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
|
|
165
|
+
export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
|
|
150
166
|
if (!dbPath || !dataDir) return null;
|
|
151
167
|
const Database = require('better-sqlite3');
|
|
152
168
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
@@ -283,12 +299,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
283
299
|
}
|
|
284
300
|
activeContactTurnsByAssistant.delete(assistantID);
|
|
285
301
|
};
|
|
286
|
-
const output = (row, latestMessagePreview) => {
|
|
302
|
+
const output = (row, latestMessagePreview, unreadSnapshot) => {
|
|
287
303
|
const watches = listInFlightWatches(db, row.assistant_id);
|
|
288
304
|
const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
|
|
289
305
|
const preview = latestMessagePreview !== undefined
|
|
290
306
|
? latestMessagePreview
|
|
291
307
|
: getLatestContactMessagePreview(db, row.assistant_id);
|
|
308
|
+
const unread = unreadSnapshot !== undefined && unreadSnapshot !== null
|
|
309
|
+
? unreadSnapshot
|
|
310
|
+
: (getContactUnreadSnapshots(db, [row.assistant_id]).get(row.assistant_id) ?? {
|
|
311
|
+
unreadCount: 0,
|
|
312
|
+
readWatermark: getContactReadWatermark(db, row.assistant_id),
|
|
313
|
+
readTip: getContactReadTip(db, row.assistant_id),
|
|
314
|
+
});
|
|
292
315
|
return {
|
|
293
316
|
id: row.assistant_id,
|
|
294
317
|
revision: row.revision,
|
|
@@ -315,6 +338,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
315
338
|
// Contact-transcript list desc: authoritative last visible bubble (not
|
|
316
339
|
// defaultPrompt / updatedAt). null when the contact has no list-visible rows.
|
|
317
340
|
latestMessagePreview: preview ?? null,
|
|
341
|
+
// Shared multi-client unread: countable assistant/peer replies after watermark.
|
|
342
|
+
unreadCount: Number(unread.unreadCount) || 0,
|
|
343
|
+
// Persisted shared watermark (generation + keyset). Clients POST this tip safely.
|
|
344
|
+
readWatermark: unread.readWatermark,
|
|
345
|
+
// Highest readable tip for safe mark-read report (transcript head + generation).
|
|
346
|
+
readTip: unread.readTip,
|
|
318
347
|
createdAt: row.created_at,
|
|
319
348
|
updatedAt: row.updated_at,
|
|
320
349
|
tombstoneAt: row.tombstone_at,
|
|
@@ -380,6 +409,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
380
409
|
const legacy = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='assistant'").get();
|
|
381
410
|
if (legacy) { const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant')").all().map((column) => column.name)); for (const row of db.prepare('SELECT * FROM assistant').all()) { let sessionID = row.current_session_id ?? null; if (!sessionID && columns.has('inbox_topic_id') && row.inbox_topic_id) sessionID = db.prepare('SELECT session_id FROM assistant_topic WHERE topic_id=?').get(row.inbox_topic_id)?.session_id ?? null; if (!sessionID) sessionID = db.prepare("SELECT session_id FROM assistant_operation WHERE topic_id IN (SELECT topic_id FROM assistant_topic WHERE assistant_id=?) AND state='completed' AND session_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get(row.assistant_id)?.session_id ?? db.prepare('SELECT session_id FROM assistant_turn WHERE topic_id IN (SELECT topic_id FROM assistant_topic WHERE assistant_id=?) AND session_id IS NOT NULL ORDER BY created_at DESC LIMIT 1').get(row.assistant_id)?.session_id ?? null; const mode = columns.has('mode') && row.mode === 'stateless' ? 'stateless' : 'continuous'; db.prepare('INSERT OR IGNORE INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(row.assistant_id, row.revision, row.enabled, row.name, row.default_prompt, managedConfig(row.workspace_path, row.assistant_id), row.provider_id, row.model_id, row.agent, null, mode, sessionID, Math.max(0, row.session_generation ?? 0), row.created_at, row.updated_at, row.tombstone_at); } }
|
|
382
411
|
for (const row of db.prepare('SELECT assistant_id,workspace_path FROM assistant_v2 WHERE workspace_path IS NOT NULL').all()) { const workspacePath = managedConfig(row.workspace_path, row.assistant_id); if (workspacePath === null) db.prepare('UPDATE assistant_v2 SET workspace_path=NULL WHERE assistant_id=?').run(row.assistant_id); }
|
|
412
|
+
// v13: shared contact read watermark. Legacy transcripts default to fully read
|
|
413
|
+
// so existing installs do not flash unread on upgrade; later messages count.
|
|
414
|
+
migrateContactReadStateDefaultRead(db, { updatedAt: now() });
|
|
383
415
|
db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
|
|
384
416
|
};
|
|
385
417
|
migrate();
|
|
@@ -454,8 +486,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
454
486
|
const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
|
|
455
487
|
return nonEmptyString(sessionID) ? sessionID : '';
|
|
456
488
|
};
|
|
489
|
+
const isUserAbort = (error) => error?.name === 'MessageAbortedError';
|
|
490
|
+
const assignedResumes = new Map();
|
|
491
|
+
const contactControllers = new Map();
|
|
492
|
+
// Last UI locale seen from a client per assistant. Background notifications
|
|
493
|
+
// (assigned-session settle) have no request locale, so they recall this value.
|
|
494
|
+
const contactTurnLanguages = new Map();
|
|
495
|
+
const cancelAssignedResumes = ({ sessionID, assistantID }) => {
|
|
496
|
+
for (const resume of assignedResumes.values()) {
|
|
497
|
+
if (sessionID && resume.sessionID !== sessionID) continue;
|
|
498
|
+
if (assistantID && resume.assistantID !== assistantID) continue;
|
|
499
|
+
resume.controller.abort();
|
|
500
|
+
}
|
|
501
|
+
};
|
|
457
502
|
const settleStatusFromEvent = (payload, properties) => {
|
|
458
|
-
if (payload.type === 'session.error') return 'error';
|
|
503
|
+
if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
|
|
459
504
|
if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
|
|
460
505
|
if (payload.type === 'session.idle') return 'complete';
|
|
461
506
|
if (payload.type === 'session.status') {
|
|
@@ -465,37 +510,40 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
465
510
|
}
|
|
466
511
|
return null;
|
|
467
512
|
};
|
|
513
|
+
// Assigned-session complete/error resumes the contact LLM (no canned settle bubble).
|
|
514
|
+
// Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
|
|
515
|
+
const assignedSessionResumeRef = { schedule: null };
|
|
468
516
|
const reportAssignedSession = (sessionID, status) => {
|
|
517
|
+
if (status === 'cancelled') cancelAssignedResumes({ sessionID });
|
|
469
518
|
const watches = listWatchesBySession(db, sessionID);
|
|
470
519
|
if (watches.length === 0) return false;
|
|
471
520
|
let changed = false;
|
|
521
|
+
const resumes = [];
|
|
472
522
|
db.exec('BEGIN IMMEDIATE');
|
|
473
523
|
try {
|
|
474
524
|
for (const watch of watches) {
|
|
475
|
-
if (watch.status === status) continue;
|
|
525
|
+
if (watch.status === status || watch.status === 'cancelled') continue;
|
|
476
526
|
// session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
|
|
477
527
|
if (status === 'complete' && watch.status === 'error') continue;
|
|
528
|
+
const updatedAt = now();
|
|
478
529
|
updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
|
|
479
530
|
upsertContactWatch(db, {
|
|
480
531
|
assistantID: watch.assistantID,
|
|
481
532
|
sessionID,
|
|
482
533
|
directory: watch.directory,
|
|
483
534
|
status,
|
|
484
|
-
updatedAt
|
|
535
|
+
updatedAt,
|
|
536
|
+
resumeAllowed: watch.resumeAllowed,
|
|
485
537
|
});
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
messageID: settleID,
|
|
538
|
+
// complete/error: hand result back to contact LLM. question: card only (worker waiting).
|
|
539
|
+
// Never insert oc.settle.* canned transcript bubbles.
|
|
540
|
+
if (watch.resumeAllowed && (status === 'complete' || status === 'error')) {
|
|
541
|
+
resumes.push({
|
|
491
542
|
assistantID: watch.assistantID,
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
ordinal: nextContactOrdinal(db, watch.assistantID),
|
|
497
|
-
status: 'complete',
|
|
498
|
-
parts: [{ type: 'text', text: settleText }],
|
|
543
|
+
sessionID,
|
|
544
|
+
status,
|
|
545
|
+
directory: watch.directory,
|
|
546
|
+
updatedAt,
|
|
499
547
|
});
|
|
500
548
|
}
|
|
501
549
|
changed = true;
|
|
@@ -506,6 +554,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
506
554
|
db.exec('ROLLBACK');
|
|
507
555
|
throw error;
|
|
508
556
|
}
|
|
557
|
+
// processEvent stays sync; resume runs on the contact turn lane asynchronously.
|
|
558
|
+
if (typeof assignedSessionResumeRef.schedule === 'function') {
|
|
559
|
+
for (const resume of resumes) assignedSessionResumeRef.schedule(resume);
|
|
560
|
+
}
|
|
509
561
|
return changed;
|
|
510
562
|
};
|
|
511
563
|
const sessionStatusType = (session) => {
|
|
@@ -526,12 +578,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
526
578
|
if (isMissing(getResult)) return 'complete';
|
|
527
579
|
if (getResult?.error) return null;
|
|
528
580
|
const session = getResult?.data;
|
|
529
|
-
if (session?.error) return 'error';
|
|
581
|
+
if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
|
|
530
582
|
const statusType = sessionStatusType(session);
|
|
531
583
|
if (statusType === 'busy' || statusType === 'retry') return null;
|
|
532
584
|
if (isMissing(messagesResult)) return 'complete';
|
|
533
585
|
const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
|
|
534
|
-
if (assistant?.error) return 'error';
|
|
586
|
+
if (assistant?.error) return isUserAbort(assistant.error) ? 'cancelled' : 'error';
|
|
535
587
|
if (statusType === 'idle' || session?.time?.completed) return 'complete';
|
|
536
588
|
if (assistant?.time?.completed) return 'complete';
|
|
537
589
|
return null;
|
|
@@ -585,7 +637,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
585
637
|
if (payload.type === 'message.updated') {
|
|
586
638
|
const info = properties.info; const sessionID = info?.sessionID;
|
|
587
639
|
if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
|
|
588
|
-
const
|
|
640
|
+
const cancelled = info.role === 'assistant' && isUserAbort(info.error)
|
|
641
|
+
? reportAssignedSession(sessionID, 'cancelled') : false;
|
|
642
|
+
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { const current = assistant(assistantID)?.current_session_id === sessionID; mirrorMessage(assistantID, sessionID, info, undefined, current); if (!current) invalidateBackfill(assistantID, sessionID); } return cancelled || assistants.length > 0;
|
|
589
643
|
}
|
|
590
644
|
if (payload.type === 'message.part.updated') {
|
|
591
645
|
const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
|
|
@@ -994,6 +1048,23 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
994
1048
|
throw wrapped;
|
|
995
1049
|
}
|
|
996
1050
|
};
|
|
1051
|
+
const loadContactModelContext = async (signal) => {
|
|
1052
|
+
const [catalogResult, preferencesResult] = await Promise.allSettled([
|
|
1053
|
+
loadAssignCatalog(signal),
|
|
1054
|
+
awaitWithDeadline(Promise.resolve().then(() => readModelPreferences()), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error'),
|
|
1055
|
+
]);
|
|
1056
|
+
const catalog = catalogResult.status === 'fulfilled' ? catalogResult.value : null;
|
|
1057
|
+
const providerNames = new Map((catalog?.providers || []).map((provider) => [provider.id, provider.name]));
|
|
1058
|
+
const preferences = preferencesResult.status === 'fulfilled' ? preferencesResult.value : null;
|
|
1059
|
+
return {
|
|
1060
|
+
connectedModels: normalizeConnectedModels((catalog?.models || []).map((model) => ({ ...model, providerName: providerNames.get(model.providerID) }))),
|
|
1061
|
+
modelCatalogAvailable: catalog !== null,
|
|
1062
|
+
modelPreferences: preferences && typeof preferences === 'object' ? {
|
|
1063
|
+
favoriteModels: Array.isArray(preferences.favoriteModels) ? preferences.favoriteModels.slice(0, 64) : [],
|
|
1064
|
+
recentModels: Array.isArray(preferences.recentModels) ? preferences.recentModels.slice(0, 16) : [],
|
|
1065
|
+
} : null,
|
|
1066
|
+
};
|
|
1067
|
+
};
|
|
997
1068
|
const loadRegisteredProjects = async () => {
|
|
998
1069
|
const listed = await awaitWithDeadline(listProjects(), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error');
|
|
999
1070
|
if (!Array.isArray(listed)) {
|
|
@@ -1050,6 +1121,267 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1050
1121
|
return null;
|
|
1051
1122
|
}
|
|
1052
1123
|
};
|
|
1124
|
+
/**
|
|
1125
|
+
* Best-effort previous worker model for a reused sessionID.
|
|
1126
|
+
* session.get → session.model, else newest message info.model. Never throws / never fails assign.
|
|
1127
|
+
*/
|
|
1128
|
+
const lookupReusedSessionModel = async ({ sessionID, directory, signal } = {}) => {
|
|
1129
|
+
if (typeof sessionID !== 'string' || !sessionID.trim()) return null;
|
|
1130
|
+
const api = client();
|
|
1131
|
+
const options = signal ? { signal } : undefined;
|
|
1132
|
+
const dir = typeof directory === 'string' && directory.trim() ? directory.trim() : null;
|
|
1133
|
+
try {
|
|
1134
|
+
const got = await api.session.get({
|
|
1135
|
+
sessionID,
|
|
1136
|
+
...(dir ? { directory: dir } : {}),
|
|
1137
|
+
}, options);
|
|
1138
|
+
if (got?.error) return null;
|
|
1139
|
+
const sessionPayload = got?.data ?? got;
|
|
1140
|
+
let messages = null;
|
|
1141
|
+
const fromSession = extractAssignSessionModel({ session: sessionPayload });
|
|
1142
|
+
if (fromSession) return fromSession;
|
|
1143
|
+
try {
|
|
1144
|
+
if (typeof api.session.messages !== 'function') return null;
|
|
1145
|
+
const listed = await api.session.messages({
|
|
1146
|
+
sessionID,
|
|
1147
|
+
...(dir ? { directory: dir } : {}),
|
|
1148
|
+
limit: 40,
|
|
1149
|
+
}, options);
|
|
1150
|
+
if (listed?.error) return null;
|
|
1151
|
+
messages = listed?.data ?? listed;
|
|
1152
|
+
} catch {
|
|
1153
|
+
return null;
|
|
1154
|
+
}
|
|
1155
|
+
return extractAssignSessionModel({ session: sessionPayload, messages });
|
|
1156
|
+
} catch {
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
/**
|
|
1161
|
+
* Find a session row in the OpenChamber session index (directory is index-authoritative).
|
|
1162
|
+
* Index failure throws — never silent empty.
|
|
1163
|
+
*/
|
|
1164
|
+
const findSessionInIndex = (sessionID) => {
|
|
1165
|
+
if (!sessionIndexService || typeof sessionIndexService.snapshot !== 'function') {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
let snapshot;
|
|
1169
|
+
try {
|
|
1170
|
+
snapshot = sessionIndexService.snapshot();
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
throw new AssignError(
|
|
1173
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1174
|
+
typeof error?.message === 'string' && error.message.trim()
|
|
1175
|
+
? error.message.trim()
|
|
1176
|
+
: 'Session index snapshot failed.',
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
if (!snapshot || !Array.isArray(snapshot.directories)) {
|
|
1180
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index snapshot failed.');
|
|
1181
|
+
}
|
|
1182
|
+
for (const directory of snapshot.directories) {
|
|
1183
|
+
const dir = typeof directory?.directory === 'string' ? directory.directory : '';
|
|
1184
|
+
if (!dir) continue;
|
|
1185
|
+
for (const session of Array.isArray(directory.sessions) ? directory.sessions : []) {
|
|
1186
|
+
if (!session || typeof session.id !== 'string' || session.id !== sessionID) continue;
|
|
1187
|
+
return {
|
|
1188
|
+
sessionID,
|
|
1189
|
+
directory: dir,
|
|
1190
|
+
title: typeof session.title === 'string' ? session.title : null,
|
|
1191
|
+
updatedAt: typeof session.time?.updated === 'number'
|
|
1192
|
+
? session.time.updated
|
|
1193
|
+
: (typeof session.updatedAt === 'number' ? session.updatedAt : 0),
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return null;
|
|
1198
|
+
};
|
|
1199
|
+
/**
|
|
1200
|
+
* Resolve an existing OpenCode session for watch / stop / assign-reuse.
|
|
1201
|
+
* Authority order: session index directory → session.get metadata → registered project.
|
|
1202
|
+
* User-supplied directory/projectPath is a lookup hint only — never trusted as scope.
|
|
1203
|
+
* Failures stay failures (not empty success).
|
|
1204
|
+
*/
|
|
1205
|
+
const resolveReferencedSession = async ({
|
|
1206
|
+
sessionID: rawSessionID,
|
|
1207
|
+
directoryHint = null,
|
|
1208
|
+
titleHint = null,
|
|
1209
|
+
signal,
|
|
1210
|
+
includeMessages = true,
|
|
1211
|
+
} = {}) => {
|
|
1212
|
+
const sessionID = typeof rawSessionID === 'string' ? rawSessionID.trim() : '';
|
|
1213
|
+
if (!sessionID) {
|
|
1214
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'sessionID is required.');
|
|
1215
|
+
}
|
|
1216
|
+
const options = signal ? { signal } : undefined;
|
|
1217
|
+
const indexRow = findSessionInIndex(sessionID);
|
|
1218
|
+
const hint = typeof directoryHint === 'string' && directoryHint.trim()
|
|
1219
|
+
? directoryHint.trim()
|
|
1220
|
+
: null;
|
|
1221
|
+
// Prefer index directory; user path is last-resort get hint only.
|
|
1222
|
+
const getHints = [];
|
|
1223
|
+
if (indexRow?.directory) getHints.push(indexRow.directory);
|
|
1224
|
+
if (hint && !getHints.some((item) => directoryContained(item, hint) || directoryContained(hint, item))) {
|
|
1225
|
+
getHints.push(hint);
|
|
1226
|
+
}
|
|
1227
|
+
if (getHints.length === 0) getHints.push(null);
|
|
1228
|
+
|
|
1229
|
+
let getResult = null;
|
|
1230
|
+
let usedDirectoryHint = null;
|
|
1231
|
+
let lastError = null;
|
|
1232
|
+
for (const dir of getHints) {
|
|
1233
|
+
try {
|
|
1234
|
+
getResult = await client().session.get({
|
|
1235
|
+
sessionID,
|
|
1236
|
+
...(dir ? { directory: dir } : {}),
|
|
1237
|
+
}, options);
|
|
1238
|
+
usedDirectoryHint = dir;
|
|
1239
|
+
if (getResult && !getResult.error) break;
|
|
1240
|
+
if (isMissing(getResult)) {
|
|
1241
|
+
getResult = { missing: true, error: getResult?.error };
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
// Non-404 error with a directory may be wrong scope — try next hint.
|
|
1245
|
+
if (getResult?.error && dir) {
|
|
1246
|
+
lastError = getResult.error;
|
|
1247
|
+
getResult = null;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
break;
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
lastError = error;
|
|
1253
|
+
getResult = null;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if (!getResult) {
|
|
1257
|
+
throw new AssignError(
|
|
1258
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1259
|
+
typeof lastError?.message === 'string' && lastError.message.trim()
|
|
1260
|
+
? lastError.message.trim()
|
|
1261
|
+
: 'Failed to load that coding session.',
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
if (getResult.missing || isMissing(getResult)) {
|
|
1265
|
+
throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
|
|
1266
|
+
}
|
|
1267
|
+
if (getResult.error) {
|
|
1268
|
+
throw new AssignError(
|
|
1269
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1270
|
+
typeof getResult.error?.message === 'string' && getResult.error.message.trim()
|
|
1271
|
+
? getResult.error.message.trim()
|
|
1272
|
+
: 'Failed to load that coding session.',
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
const sessionPayload = getResult.data ?? getResult;
|
|
1276
|
+
const metaDirectory = extractSessionDirectory(sessionPayload);
|
|
1277
|
+
const directoryRaw = metaDirectory || indexRow?.directory || usedDirectoryHint;
|
|
1278
|
+
if (!directoryRaw) {
|
|
1279
|
+
throw new AssignError(
|
|
1280
|
+
ASSIGN_CODES.VALIDATION,
|
|
1281
|
+
'That session has no resolvable project directory.',
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
// Registered project scope = allowed roots (same gate as assign create).
|
|
1285
|
+
// Reject managed assistant-workspaces and paths outside Settings projects.
|
|
1286
|
+
let directory;
|
|
1287
|
+
try {
|
|
1288
|
+
directory = resolveAssignDirectory({
|
|
1289
|
+
projectPath: directoryRaw,
|
|
1290
|
+
directory: directoryRaw,
|
|
1291
|
+
branch: null,
|
|
1292
|
+
allowedRoots: getAllowedRoots(),
|
|
1293
|
+
managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
|
|
1294
|
+
worktrees: [],
|
|
1295
|
+
});
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
if (error instanceof AssignError) throw error;
|
|
1298
|
+
throw new AssignError(
|
|
1299
|
+
ASSIGN_CODES.WORKSPACE_FORBIDDEN,
|
|
1300
|
+
'That session is not under a registered project. Add the project in Settings first.',
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
let messagesResult = null;
|
|
1305
|
+
if (includeMessages && typeof client().session.messages === 'function') {
|
|
1306
|
+
try {
|
|
1307
|
+
messagesResult = await client().session.messages({
|
|
1308
|
+
sessionID,
|
|
1309
|
+
directory,
|
|
1310
|
+
limit: 100,
|
|
1311
|
+
}, options);
|
|
1312
|
+
if (messagesResult?.error && !isMissing(messagesResult)) {
|
|
1313
|
+
// Message fetch failure must not invent status; keep messages null.
|
|
1314
|
+
messagesResult = null;
|
|
1315
|
+
}
|
|
1316
|
+
} catch {
|
|
1317
|
+
messagesResult = null;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const messageRows = Array.isArray(messagesResult?.data)
|
|
1321
|
+
? messagesResult.data
|
|
1322
|
+
: (Array.isArray(messagesResult) ? messagesResult : null);
|
|
1323
|
+
const status = mapSessionToWatchStatus({
|
|
1324
|
+
session: sessionPayload,
|
|
1325
|
+
messages: messageRows,
|
|
1326
|
+
missing: false,
|
|
1327
|
+
});
|
|
1328
|
+
const title = (typeof titleHint === 'string' && titleHint.trim() ? titleHint.trim() : null)
|
|
1329
|
+
|| extractSessionTitle(sessionPayload)
|
|
1330
|
+
|| indexRow?.title
|
|
1331
|
+
|| sessionID;
|
|
1332
|
+
return {
|
|
1333
|
+
sessionID,
|
|
1334
|
+
directory,
|
|
1335
|
+
title,
|
|
1336
|
+
status,
|
|
1337
|
+
session: sessionPayload,
|
|
1338
|
+
messages: messageRows,
|
|
1339
|
+
};
|
|
1340
|
+
};
|
|
1341
|
+
const readSessionWork = async (params = {}) => {
|
|
1342
|
+
const limit = params.limit === undefined ? 20 : params.limit;
|
|
1343
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new AssignError(ASSIGN_CODES.VALIDATION, 'limit must be an integer from 1 to 50.');
|
|
1344
|
+
if (params.before !== undefined && (typeof params.before !== 'string' || !params.before || params.before.length > 2048)) throw new AssignError(ASSIGN_CODES.VALIDATION, 'before must be an opaque cursor.');
|
|
1345
|
+
const { signal } = params;
|
|
1346
|
+
signal?.throwIfAborted();
|
|
1347
|
+
const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
|
|
1348
|
+
const result = await client().session.messages({
|
|
1349
|
+
sessionID: resolved.sessionID, directory: resolved.directory, limit,
|
|
1350
|
+
...(params.before ? { before: params.before } : {}),
|
|
1351
|
+
}, signal ? { signal } : undefined);
|
|
1352
|
+
signal?.throwIfAborted();
|
|
1353
|
+
if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
|
|
1354
|
+
if (result?.error || !Array.isArray(result?.data)) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
|
|
1355
|
+
const rows = result.data;
|
|
1356
|
+
if (rows.some((entry) => (entry?.info?.sessionID && entry.info.sessionID !== resolved.sessionID))) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session message identity mismatch.');
|
|
1357
|
+
let remaining = 24000;
|
|
1358
|
+
let partial = rows.length > limit;
|
|
1359
|
+
const text = (value) => {
|
|
1360
|
+
if (typeof value !== 'string') return '';
|
|
1361
|
+
const kept = value.slice(0, Math.min(4000, remaining));
|
|
1362
|
+
remaining -= kept.length;
|
|
1363
|
+
if (kept.length < value.length) partial = true;
|
|
1364
|
+
return kept;
|
|
1365
|
+
};
|
|
1366
|
+
const messages = rows.slice(-limit).map((entry) => {
|
|
1367
|
+
const info = entry.info ?? entry;
|
|
1368
|
+
const parts = [];
|
|
1369
|
+
const sourceParts = Array.isArray(entry.parts) ? entry.parts : [];
|
|
1370
|
+
if (sourceParts.length > 20) partial = true;
|
|
1371
|
+
for (const part of sourceParts.slice(0, 20)) {
|
|
1372
|
+
if (part.type === 'text') parts.push({ type: 'text', text: text(part.text) });
|
|
1373
|
+
else if (part.type === 'file') { parts.push({ type: 'file', mime: part.mime, filename: text(part.filename) }); partial = true; }
|
|
1374
|
+
else if (part.type === 'tool') {
|
|
1375
|
+
parts.push({ type: 'tool', tool: text(part.tool), status: part.state?.status, output: text(part.state?.output), error: text(part.state?.error) });
|
|
1376
|
+
partial = true; // Inputs and binary details are intentionally omitted.
|
|
1377
|
+
} else if (part.type !== 'step-start' && part.type !== 'step-finish') partial = true;
|
|
1378
|
+
}
|
|
1379
|
+
return { messageID: info.id, role: info.role, parts };
|
|
1380
|
+
});
|
|
1381
|
+
const cursor = result.response?.headers?.get('x-next-cursor');
|
|
1382
|
+
const nextCursor = typeof cursor === 'string' && cursor ? cursor : null;
|
|
1383
|
+
return { sessionID: resolved.sessionID, directory: resolved.directory, title: resolved.title.slice(0, 500), messages, nextCursor, partial: partial || nextCursor !== null };
|
|
1384
|
+
};
|
|
1053
1385
|
const assignWork = async (row, params = {}) => {
|
|
1054
1386
|
const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
|
|
1055
1387
|
const signal = params.signal;
|
|
@@ -1060,16 +1392,63 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1060
1392
|
|| (typeof params.model === 'string' && params.model.trim())
|
|
1061
1393
|
|| (params.model && typeof params.model === 'object'),
|
|
1062
1394
|
);
|
|
1063
|
-
const
|
|
1395
|
+
const reuseSessionID = typeof params.sessionID === 'string' && params.sessionID.trim()
|
|
1396
|
+
? params.sessionID.trim()
|
|
1397
|
+
: '';
|
|
1398
|
+
// Reuse: resolve directory/project from authoritative session metadata first.
|
|
1399
|
+
// User projectPath/directory are hints only and never override scope.
|
|
1400
|
+
let reuseScope = null;
|
|
1401
|
+
if (reuseSessionID) {
|
|
1402
|
+
const directoryHint = typeof params.directory === 'string' && params.directory.trim()
|
|
1403
|
+
? params.directory.trim()
|
|
1404
|
+
: (typeof params.projectPath === 'string' && params.projectPath.trim()
|
|
1405
|
+
? params.projectPath.trim()
|
|
1406
|
+
: null);
|
|
1407
|
+
reuseScope = await resolveReferencedSession({
|
|
1408
|
+
sessionID: reuseSessionID,
|
|
1409
|
+
directoryHint,
|
|
1410
|
+
titleHint: typeof params.title === 'string' ? params.title : null,
|
|
1411
|
+
signal,
|
|
1412
|
+
// Messages only needed when following session model without explicit selection.
|
|
1413
|
+
includeMessages: !explicitModel,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
// Reused session without explicit model may follow the session's last model (catalog-gated).
|
|
1417
|
+
const followSessionModel = Boolean(reuseSessionID && !explicitModel);
|
|
1418
|
+
const needsCatalog = explicitModel || hasAssignImageParts(fileParts) || followSessionModel;
|
|
1064
1419
|
let catalog = null;
|
|
1420
|
+
let sessionModel = null;
|
|
1065
1421
|
if (needsCatalog) {
|
|
1066
|
-
|
|
1422
|
+
if (followSessionModel && !explicitModel && !hasAssignImageParts(fileParts)) {
|
|
1423
|
+
// Session-follow degrades on catalog failure; explicit/image stay fail-closed.
|
|
1424
|
+
try {
|
|
1425
|
+
catalog = await loadAssignCatalog(signal);
|
|
1426
|
+
} catch {
|
|
1427
|
+
catalog = null;
|
|
1428
|
+
}
|
|
1429
|
+
} else {
|
|
1430
|
+
catalog = await loadAssignCatalog(signal);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (followSessionModel && catalog && reuseScope) {
|
|
1434
|
+
sessionModel = extractAssignSessionModel({
|
|
1435
|
+
session: reuseScope.session,
|
|
1436
|
+
messages: reuseScope.messages,
|
|
1437
|
+
});
|
|
1438
|
+
if (!sessionModel) {
|
|
1439
|
+
sessionModel = await lookupReusedSessionModel({
|
|
1440
|
+
sessionID: reuseSessionID,
|
|
1441
|
+
directory: reuseScope.directory,
|
|
1442
|
+
signal,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1067
1445
|
}
|
|
1068
1446
|
const workerModel = resolveAssignWorkerModel({
|
|
1069
1447
|
providerID: params.providerID,
|
|
1070
1448
|
modelID: params.modelID,
|
|
1071
1449
|
model: params.model,
|
|
1072
1450
|
fallback: { providerID: row.provider_id, modelID: row.model_id },
|
|
1451
|
+
sessionModel,
|
|
1073
1452
|
catalog,
|
|
1074
1453
|
});
|
|
1075
1454
|
let acceptsImages = workerModel.acceptsImages;
|
|
@@ -1083,6 +1462,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1083
1462
|
// Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
|
|
1084
1463
|
return assignSession({
|
|
1085
1464
|
...params,
|
|
1465
|
+
...(reuseScope ? {
|
|
1466
|
+
sessionID: reuseScope.sessionID,
|
|
1467
|
+
directory: reuseScope.directory,
|
|
1468
|
+
projectPath: reuseScope.directory,
|
|
1469
|
+
// Drop untrusted branch when reusing — directory is already authoritative.
|
|
1470
|
+
branch: undefined,
|
|
1471
|
+
title: typeof params.title === 'string' && params.title.trim()
|
|
1472
|
+
? params.title.trim()
|
|
1473
|
+
: reuseScope.title,
|
|
1474
|
+
} : {}),
|
|
1086
1475
|
fileParts,
|
|
1087
1476
|
...(params.variant !== undefined ? { variant: params.variant } : {}),
|
|
1088
1477
|
...(signal ? { signal } : {}),
|
|
@@ -1109,6 +1498,112 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1109
1498
|
messageID: params?.messageID || `msg_assign_${id()}`,
|
|
1110
1499
|
});
|
|
1111
1500
|
};
|
|
1501
|
+
/** Watch an existing session only — no prompt. Baseline status avoids false settle resume. */
|
|
1502
|
+
const watchSessionWork = async (_row, params = {}) => {
|
|
1503
|
+
const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
|
|
1504
|
+
if (!sessionID) {
|
|
1505
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'watch_session requires sessionID.');
|
|
1506
|
+
}
|
|
1507
|
+
const resolved = await resolveReferencedSession({
|
|
1508
|
+
sessionID,
|
|
1509
|
+
// directory/projectPath from the model are never trusted as scope authority.
|
|
1510
|
+
directoryHint: null,
|
|
1511
|
+
titleHint: typeof params.title === 'string' ? params.title : null,
|
|
1512
|
+
signal: params.signal,
|
|
1513
|
+
includeMessages: true,
|
|
1514
|
+
});
|
|
1515
|
+
return {
|
|
1516
|
+
sessionID: resolved.sessionID,
|
|
1517
|
+
directory: resolved.directory,
|
|
1518
|
+
title: resolved.title,
|
|
1519
|
+
status: resolved.status,
|
|
1520
|
+
watched: true,
|
|
1521
|
+
reused: true,
|
|
1522
|
+
};
|
|
1523
|
+
};
|
|
1524
|
+
/** Abort an existing coding session via real OpenCode session.abort. */
|
|
1525
|
+
const stopSessionWork = async (_row, params = {}) => {
|
|
1526
|
+
const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
|
|
1527
|
+
if (!sessionID) {
|
|
1528
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'stop_session requires sessionID.');
|
|
1529
|
+
}
|
|
1530
|
+
const resolved = await resolveReferencedSession({
|
|
1531
|
+
sessionID,
|
|
1532
|
+
directoryHint: null,
|
|
1533
|
+
signal: params.signal,
|
|
1534
|
+
// Abort does not need messages for baseline.
|
|
1535
|
+
includeMessages: false,
|
|
1536
|
+
});
|
|
1537
|
+
const signal = params.signal;
|
|
1538
|
+
signal?.throwIfAborted();
|
|
1539
|
+
const sdkOptions = signal ? { signal } : undefined;
|
|
1540
|
+
if (typeof client().session.abort !== 'function') {
|
|
1541
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session abort is unavailable.');
|
|
1542
|
+
}
|
|
1543
|
+
let result;
|
|
1544
|
+
try {
|
|
1545
|
+
result = await client().session.abort({
|
|
1546
|
+
sessionID: resolved.sessionID,
|
|
1547
|
+
directory: resolved.directory,
|
|
1548
|
+
}, sdkOptions);
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
throw new AssignError(
|
|
1551
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1552
|
+
typeof error?.message === 'string' && error.message.trim()
|
|
1553
|
+
? error.message.trim()
|
|
1554
|
+
: 'Failed to abort that coding session.',
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
if (isMissing(result)) {
|
|
1558
|
+
throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${resolved.sessionID} was found.`);
|
|
1559
|
+
}
|
|
1560
|
+
if (result?.error || result?.data === false) {
|
|
1561
|
+
throw new AssignError(
|
|
1562
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1563
|
+
typeof result.error?.message === 'string' && result.error.message.trim()
|
|
1564
|
+
? result.error.message.trim()
|
|
1565
|
+
: 'Failed to abort that coding session.',
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
reportAssignedSession(resolved.sessionID, 'cancelled');
|
|
1569
|
+
return {
|
|
1570
|
+
sessionID: resolved.sessionID,
|
|
1571
|
+
directory: resolved.directory,
|
|
1572
|
+
title: resolved.title,
|
|
1573
|
+
aborted: true,
|
|
1574
|
+
};
|
|
1575
|
+
};
|
|
1576
|
+
// Resolve scope from the authoritative session, never model-supplied directories.
|
|
1577
|
+
const mutateSessionWork = async (operation, params = {}) => {
|
|
1578
|
+
const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
|
|
1579
|
+
if (!sessionID) throw new AssignError(ASSIGN_CODES.VALIDATION, `${operation} requires sessionID.`);
|
|
1580
|
+
if (operation === 'steer' && (typeof params.text !== 'string' || !params.text.trim())) {
|
|
1581
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'steer requires text.');
|
|
1582
|
+
}
|
|
1583
|
+
const resolved = await resolveReferencedSession({ sessionID, signal: params.signal, includeMessages: false });
|
|
1584
|
+
params.signal?.throwIfAborted();
|
|
1585
|
+
const session = client().session;
|
|
1586
|
+
const method = operation === 'steer' ? 'promptAsync' : operation === 'archive' ? 'update' : 'delete';
|
|
1587
|
+
if (typeof session[method] !== 'function') throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} is unavailable.`);
|
|
1588
|
+
const request = { sessionID, directory: resolved.directory };
|
|
1589
|
+
if (operation === 'steer') Object.assign(request, {
|
|
1590
|
+
messageID: `msg_steer_${id()}`, delivery: 'steer', parts: [{ type: 'text', text: params.text.trim() }],
|
|
1591
|
+
});
|
|
1592
|
+
if (operation === 'archive') request.time = { archived: now() };
|
|
1593
|
+
let result;
|
|
1594
|
+
try {
|
|
1595
|
+
result = await session[method](request, params.signal ? { signal: params.signal } : undefined);
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
|
|
1598
|
+
}
|
|
1599
|
+
if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
|
|
1600
|
+
if (result?.error || result?.data === false || (operation === 'steer' && !promptAdmitted(result))) {
|
|
1601
|
+
// Do not retry an ambiguous prompt submission: it may already be admitted upstream.
|
|
1602
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
|
|
1603
|
+
}
|
|
1604
|
+
if (operation !== 'steer') reportAssignedSession(sessionID, 'cancelled');
|
|
1605
|
+
return { sessionID, directory: resolved.directory, operation, ...(operation === 'steer' ? { messageID: request.messageID, admitted: true } : { [operation === 'archive' ? 'archived' : 'deleted']: true }) };
|
|
1606
|
+
};
|
|
1112
1607
|
const matchRegisteredProject = async (directory) => {
|
|
1113
1608
|
const projects = await listProjects();
|
|
1114
1609
|
const resolved = path.resolve(directory);
|
|
@@ -1338,6 +1833,401 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1338
1833
|
}),
|
|
1339
1834
|
};
|
|
1340
1835
|
};
|
|
1836
|
+
const findAssignedSessionCardTitle = (assistantID, sessionID) => {
|
|
1837
|
+
const rows = db.prepare(
|
|
1838
|
+
'SELECT p.part_json FROM assistant_contact_part p JOIN assistant_contact_message m ON m.message_id=p.message_id WHERE m.assistant_id=?',
|
|
1839
|
+
).all(assistantID);
|
|
1840
|
+
for (const row of rows) {
|
|
1841
|
+
let part;
|
|
1842
|
+
try { part = parseContactPart(parse(row.part_json)); } catch { continue; }
|
|
1843
|
+
if (part?.type === 'card' && part.cardType === 'session' && part.sessionID === sessionID) {
|
|
1844
|
+
return typeof part.title === 'string' && part.title.trim() ? part.title.trim() : null;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return null;
|
|
1848
|
+
};
|
|
1849
|
+
const extractLastWorkerAssistantText = (messages, maxChars = ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX) => {
|
|
1850
|
+
if (!Array.isArray(messages)) return '';
|
|
1851
|
+
const limit = Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX;
|
|
1852
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
1853
|
+
const entry = messages[index];
|
|
1854
|
+
const info = entry?.info ?? entry;
|
|
1855
|
+
if (info?.role !== 'assistant') continue;
|
|
1856
|
+
const parts = Array.isArray(entry?.parts)
|
|
1857
|
+
? entry.parts
|
|
1858
|
+
: (Array.isArray(info?.parts) ? info.parts : []);
|
|
1859
|
+
const chunks = [];
|
|
1860
|
+
for (const part of parts) {
|
|
1861
|
+
if (part?.type === 'text' && typeof part.text === 'string' && part.text.trim()) {
|
|
1862
|
+
chunks.push(part.text.trim());
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
if (chunks.length === 0 && typeof info?.content === 'string' && info.content.trim()) {
|
|
1866
|
+
chunks.push(info.content.trim());
|
|
1867
|
+
}
|
|
1868
|
+
if (chunks.length === 0) continue;
|
|
1869
|
+
const joined = chunks.join('\n');
|
|
1870
|
+
return joined.length > limit ? joined.slice(0, limit) : joined;
|
|
1871
|
+
}
|
|
1872
|
+
return '';
|
|
1873
|
+
};
|
|
1874
|
+
const buildAssignedSessionResumeUserText = ({ sessionID, status, title, workerText }) => {
|
|
1875
|
+
const lines = [
|
|
1876
|
+
'[Internal assigned-session resume — not a user message. Do not quote this block.]',
|
|
1877
|
+
`sessionID: ${sessionID}`,
|
|
1878
|
+
`status: ${status}`,
|
|
1879
|
+
];
|
|
1880
|
+
if (title) lines.push(`cardTitle: ${title}`);
|
|
1881
|
+
if (workerText) {
|
|
1882
|
+
lines.push('lastWorkerAssistantText (bounded):');
|
|
1883
|
+
lines.push(workerText);
|
|
1884
|
+
} else {
|
|
1885
|
+
lines.push('lastWorkerAssistantText: (unavailable)');
|
|
1886
|
+
}
|
|
1887
|
+
lines.push(
|
|
1888
|
+
'Instructions:',
|
|
1889
|
+
'- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed".',
|
|
1890
|
+
'- This is a read-only result notification, never authorization to create, restart, continue, steer, or otherwise mutate any session or file.',
|
|
1891
|
+
'- Respect the latest user instructions in history, including stop/cancel. If work remains, report it and wait for an explicit user request.',
|
|
1892
|
+
'- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
|
|
1893
|
+
);
|
|
1894
|
+
return lines.join('\n');
|
|
1895
|
+
};
|
|
1896
|
+
/**
|
|
1897
|
+
* After assigned worker complete/error: async contact-lane continuation.
|
|
1898
|
+
* No user transcript row — only internal runContactTurn userText + assistant bubbles/cards.
|
|
1899
|
+
*/
|
|
1900
|
+
const scheduleAssignedSessionResume = ({ assistantID, sessionID, status, directory, updatedAt }) => {
|
|
1901
|
+
if (closed) return;
|
|
1902
|
+
if (status !== 'complete' && status !== 'error') return;
|
|
1903
|
+
const row = assistant(assistantID);
|
|
1904
|
+
if (!row || row.tombstone_at) return;
|
|
1905
|
+
const messageID = assignedSessionResumeMessageID(assistantID, sessionID, status, updatedAt);
|
|
1906
|
+
const turnID = messageID;
|
|
1907
|
+
// Idempotent: same watch status already resumed (bubbles or durable error).
|
|
1908
|
+
if (
|
|
1909
|
+
getContactMessage(db, `${messageID}:bubble:1`)
|
|
1910
|
+
|| getContactMessage(db, `${messageID}:error`)
|
|
1911
|
+
) return;
|
|
1912
|
+
|
|
1913
|
+
if (assignedResumes.has(messageID)) return;
|
|
1914
|
+
const controller = new AbortController();
|
|
1915
|
+
assignedResumes.set(messageID, { assistantID, sessionID, controller });
|
|
1916
|
+
const finishCancelled = () => {
|
|
1917
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
1918
|
+
emitContactTurnEvent('openchamber:contact-turn-end', { assistantID, turnID, status: 'cancelled' });
|
|
1919
|
+
resolveContactTurnSettlement(messageID, { status: 'cancelled' });
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
rememberActiveContactTurn(assistantID, {
|
|
1923
|
+
turnID,
|
|
1924
|
+
messageID,
|
|
1925
|
+
status: 'queued',
|
|
1926
|
+
admittedAt: now(),
|
|
1927
|
+
});
|
|
1928
|
+
// Tip snapshot so list green dots / 3-dot row rehydrate without waiting for lane start.
|
|
1929
|
+
bump();
|
|
1930
|
+
emitContactTurnEvent('openchamber:contact-turn-start', {
|
|
1931
|
+
assistantID,
|
|
1932
|
+
turnID,
|
|
1933
|
+
messageID,
|
|
1934
|
+
});
|
|
1935
|
+
contactTurnSettlement(messageID);
|
|
1936
|
+
|
|
1937
|
+
void inContactTurnLane(assistantID, async () => {
|
|
1938
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1939
|
+
if (closed) {
|
|
1940
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
1941
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const live = assistant(assistantID);
|
|
1945
|
+
if (!live || live.tombstone_at) {
|
|
1946
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
1947
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: 'not_found' });
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
if (
|
|
1951
|
+
getContactMessage(db, `${messageID}:bubble:1`)
|
|
1952
|
+
|| getContactMessage(db, `${messageID}:error`)
|
|
1953
|
+
) {
|
|
1954
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
1955
|
+
resolveContactTurnSettlement(messageID, { status: 'complete', replayed: true });
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
markActiveContactTurnRunning(assistantID, turnID);
|
|
1959
|
+
const assistantSnapshot = output(live);
|
|
1960
|
+
let registeredProjects = [];
|
|
1961
|
+
try {
|
|
1962
|
+
registeredProjects = filterRegisteredProjects(await loadRegisteredProjects());
|
|
1963
|
+
} catch {
|
|
1964
|
+
registeredProjects = [];
|
|
1965
|
+
}
|
|
1966
|
+
const modelContext = await loadContactModelContext(controller.signal);
|
|
1967
|
+
|
|
1968
|
+
let workerText = '';
|
|
1969
|
+
try {
|
|
1970
|
+
const watchDirectory = nonEmptyString(directory)
|
|
1971
|
+
? (() => {
|
|
1972
|
+
try { return workspace(directory, assistantID); } catch { return null; }
|
|
1973
|
+
})()
|
|
1974
|
+
: null;
|
|
1975
|
+
const resolvedDirectory = watchDirectory || (() => {
|
|
1976
|
+
try { return effectiveWorkspace(live); } catch { return null; }
|
|
1977
|
+
})();
|
|
1978
|
+
if (resolvedDirectory) {
|
|
1979
|
+
const messagesResult = await client().session.messages({
|
|
1980
|
+
sessionID,
|
|
1981
|
+
directory: resolvedDirectory,
|
|
1982
|
+
limit: 100,
|
|
1983
|
+
});
|
|
1984
|
+
if (!messagesResult?.error) {
|
|
1985
|
+
const rows = Array.isArray(messagesResult?.data)
|
|
1986
|
+
? messagesResult.data
|
|
1987
|
+
: (Array.isArray(messagesResult) ? messagesResult : []);
|
|
1988
|
+
if (isUserAbort(lastAssistantInfo(rows)?.error)) {
|
|
1989
|
+
reportAssignedSession(sessionID, 'cancelled');
|
|
1990
|
+
finishCancelled();
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
workerText = extractLastWorkerAssistantText(rows);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
} catch {
|
|
1997
|
+
workerText = '';
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
2001
|
+
const title = findAssignedSessionCardTitle(assistantID, sessionID);
|
|
2002
|
+
const userText = buildAssignedSessionResumeUserText({
|
|
2003
|
+
sessionID,
|
|
2004
|
+
status,
|
|
2005
|
+
title,
|
|
2006
|
+
workerText,
|
|
2007
|
+
});
|
|
2008
|
+
const history = contactHistoryForLlm(db, assistantID, {});
|
|
2009
|
+
const assignedCards = [];
|
|
2010
|
+
let contactResetThisTurn = false;
|
|
2011
|
+
let contactHistoryClearedThisTurn = false;
|
|
2012
|
+
|
|
2013
|
+
try {
|
|
2014
|
+
const executionHistory = await materializeContactHistory(
|
|
2015
|
+
db,
|
|
2016
|
+
dataDir,
|
|
2017
|
+
assistantID,
|
|
2018
|
+
history,
|
|
2019
|
+
{ budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
|
|
2020
|
+
);
|
|
2021
|
+
const tools = createContactTools({
|
|
2022
|
+
assignWork: (params) => assignWork(live, params),
|
|
2023
|
+
watchSession: (params) => watchSessionWork(live, params),
|
|
2024
|
+
stopSession: (params) => stopSessionWork(live, params),
|
|
2025
|
+
steerSession: (params) => mutateSessionWork('steer', params),
|
|
2026
|
+
archiveSession: (params) => mutateSessionWork('archive', params),
|
|
2027
|
+
deleteSession: (params) => mutateSessionWork('delete', params),
|
|
2028
|
+
createAssistant: (toolInput) => createAssistant(toolInput),
|
|
2029
|
+
scheduleTask: (params) => scheduleWork(live, params),
|
|
2030
|
+
deliverPeerMessage: (toolInput) => deliverPeerMessage(assistantID, toolInput),
|
|
2031
|
+
clearContactMemory: () => {
|
|
2032
|
+
const result = clearContactMemory(assistantID, {});
|
|
2033
|
+
contactResetThisTurn = true;
|
|
2034
|
+
return result;
|
|
2035
|
+
},
|
|
2036
|
+
resetContact: () => {
|
|
2037
|
+
const result = resetContact(assistantID, {});
|
|
2038
|
+
contactResetThisTurn = true;
|
|
2039
|
+
contactHistoryClearedThisTurn = true;
|
|
2040
|
+
return result;
|
|
2041
|
+
},
|
|
2042
|
+
listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
|
|
2043
|
+
listProjects: async () => loadRegisteredProjects(),
|
|
2044
|
+
listSessions: (params) => listSessionsWork(params),
|
|
2045
|
+
readSession: (params) => readSessionWork(params),
|
|
2046
|
+
readAssistantSettings: (input = {}) => {
|
|
2047
|
+
const targetID = typeof input?.assistantID === 'string' && input.assistantID.trim()
|
|
2048
|
+
? input.assistantID.trim()
|
|
2049
|
+
: assistantID;
|
|
2050
|
+
return output(editable(targetID));
|
|
2051
|
+
},
|
|
2052
|
+
updateAssistantSettings: async (patch = {}) => {
|
|
2053
|
+
const normalizePrompt = (value) => {
|
|
2054
|
+
if (typeof value !== 'string') fail('validation_error');
|
|
2055
|
+
if (value.length > 200_000) fail('validation_error');
|
|
2056
|
+
return value.trim();
|
|
2057
|
+
};
|
|
2058
|
+
const targetID = typeof patch?.assistantID === 'string' && patch.assistantID.trim()
|
|
2059
|
+
? patch.assistantID.trim()
|
|
2060
|
+
: assistantID;
|
|
2061
|
+
let lastError = null;
|
|
2062
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2063
|
+
const current = output(editable(targetID));
|
|
2064
|
+
if (!Object.prototype.hasOwnProperty.call(patch, 'defaultPrompt')) {
|
|
2065
|
+
fail('validation_error');
|
|
2066
|
+
}
|
|
2067
|
+
const nextPrompt = normalizePrompt(patch.defaultPrompt);
|
|
2068
|
+
if (current.defaultPrompt === nextPrompt) {
|
|
2069
|
+
return {
|
|
2070
|
+
updated: false,
|
|
2071
|
+
unchanged: true,
|
|
2072
|
+
defaultPrompt: current.defaultPrompt,
|
|
2073
|
+
assistant: current,
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
await Promise.resolve();
|
|
2077
|
+
try {
|
|
2078
|
+
const updated = await updateAssistant(targetID, {
|
|
2079
|
+
expectedRevision: current.revision,
|
|
2080
|
+
defaultPrompt: nextPrompt,
|
|
2081
|
+
});
|
|
2082
|
+
return {
|
|
2083
|
+
updated: true,
|
|
2084
|
+
defaultPrompt: updated.defaultPrompt,
|
|
2085
|
+
assistant: updated,
|
|
2086
|
+
};
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
lastError = error;
|
|
2089
|
+
if (error?.code !== 'revision_conflict') throw error;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
throw lastError;
|
|
2093
|
+
},
|
|
2094
|
+
currentAssistant: assistantSnapshot,
|
|
2095
|
+
onCard: (card) => assignedCards.push(card),
|
|
2096
|
+
turnFileParts: [],
|
|
2097
|
+
turnAttachmentScope: attachmentScopeKey([]),
|
|
2098
|
+
});
|
|
2099
|
+
controller.signal.throwIfAborted();
|
|
2100
|
+
const generated = await runContactTurn({
|
|
2101
|
+
signal: controller.signal,
|
|
2102
|
+
readOnly: true,
|
|
2103
|
+
assistant: assistantSnapshot,
|
|
2104
|
+
history: executionHistory,
|
|
2105
|
+
userText,
|
|
2106
|
+
userParts: [{ type: 'text', text: userText }],
|
|
2107
|
+
language: contactTurnLanguages.get(assistantID) ?? '',
|
|
2108
|
+
createChatCompletion,
|
|
2109
|
+
tools,
|
|
2110
|
+
projects: registeredProjects,
|
|
2111
|
+
...modelContext,
|
|
2112
|
+
globalEventHub,
|
|
2113
|
+
onBubbleDelta: (bubbleIndex, delta, done) => {
|
|
2114
|
+
if (controller.signal.aborted) return;
|
|
2115
|
+
emitContactTurnEvent('openchamber:contact-bubble-delta', {
|
|
2116
|
+
assistantID,
|
|
2117
|
+
turnID,
|
|
2118
|
+
bubbleIndex,
|
|
2119
|
+
delta: typeof delta === 'string' ? delta : '',
|
|
2120
|
+
done: Boolean(done),
|
|
2121
|
+
});
|
|
2122
|
+
},
|
|
2123
|
+
});
|
|
2124
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
2125
|
+
if (closed) {
|
|
2126
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
2127
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
const resetThisTurn = contactResetThisTurn || generated?.reset === true;
|
|
2131
|
+
const historyClearedThisTurn = contactHistoryClearedThisTurn
|
|
2132
|
+
|| contactTurnClearedChatHistory(generated?.messages)
|
|
2133
|
+
|| generated?.historyCleared === true;
|
|
2134
|
+
const preferredConfirm = historyClearedThisTurn
|
|
2135
|
+
? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
|
|
2136
|
+
: NEW_CONVERSATION_CONFIRM_BUBBLE;
|
|
2137
|
+
const bubbles = resetThisTurn
|
|
2138
|
+
? confirmBubbleAfterContactReset(generated?.bubbles, preferredConfirm)
|
|
2139
|
+
: (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
|
|
2140
|
+
const cards = resetThisTurn
|
|
2141
|
+
? []
|
|
2142
|
+
: [
|
|
2143
|
+
...assignedCards,
|
|
2144
|
+
...(Array.isArray(generated?.cards) ? generated.cards : []),
|
|
2145
|
+
].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
|
|
2146
|
+
if (bubbles.length === 0 && cards.length === 0) {
|
|
2147
|
+
const detail = 'Assistant returned no text';
|
|
2148
|
+
try {
|
|
2149
|
+
persistContactTurnFailure(assistantID, {
|
|
2150
|
+
userMessageID: messageID,
|
|
2151
|
+
turnID,
|
|
2152
|
+
error: detail,
|
|
2153
|
+
});
|
|
2154
|
+
settleActiveContactTurn(assistantID, turnID);
|
|
2155
|
+
} catch {
|
|
2156
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
2157
|
+
}
|
|
2158
|
+
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
2159
|
+
assistantID,
|
|
2160
|
+
turnID,
|
|
2161
|
+
status: 'error',
|
|
2162
|
+
error: detail,
|
|
2163
|
+
});
|
|
2164
|
+
notifyContactTurn({
|
|
2165
|
+
assistantID,
|
|
2166
|
+
name: assistantSnapshot.name,
|
|
2167
|
+
turnID,
|
|
2168
|
+
status: 'error',
|
|
2169
|
+
body: detail,
|
|
2170
|
+
});
|
|
2171
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
// No user row for resume — only assistant bubbles/cards (userMessageID is id prefix).
|
|
2175
|
+
persistContactAssistantReply(assistantID, {
|
|
2176
|
+
userMessageID: messageID,
|
|
2177
|
+
bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
|
|
2178
|
+
cards,
|
|
2179
|
+
turnID,
|
|
2180
|
+
});
|
|
2181
|
+
settleActiveContactTurn(assistantID, turnID);
|
|
2182
|
+
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
2183
|
+
assistantID,
|
|
2184
|
+
turnID,
|
|
2185
|
+
status: 'complete',
|
|
2186
|
+
});
|
|
2187
|
+
const spoken = bubbles.filter((text) => typeof text === 'string' && text.trim() && !text.startsWith('oc.settle.'));
|
|
2188
|
+
notifyContactTurn({
|
|
2189
|
+
assistantID,
|
|
2190
|
+
name: assistantSnapshot.name,
|
|
2191
|
+
turnID,
|
|
2192
|
+
status: 'complete',
|
|
2193
|
+
body: spoken.join('\n') || cards[0]?.title || '',
|
|
2194
|
+
});
|
|
2195
|
+
resolveContactTurnSettlement(messageID, { status: 'complete' });
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
2198
|
+
const detail = typeof error?.message === 'string' && error.message.trim()
|
|
2199
|
+
? error.message.trim()
|
|
2200
|
+
: (error?.code || 'upstream_error');
|
|
2201
|
+
const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
|
|
2202
|
+
try {
|
|
2203
|
+
persistContactTurnFailure(assistantID, {
|
|
2204
|
+
userMessageID: messageID,
|
|
2205
|
+
turnID,
|
|
2206
|
+
error: detail,
|
|
2207
|
+
});
|
|
2208
|
+
settleActiveContactTurn(assistantID, turnID);
|
|
2209
|
+
} catch {
|
|
2210
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
2211
|
+
}
|
|
2212
|
+
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
2213
|
+
assistantID,
|
|
2214
|
+
turnID,
|
|
2215
|
+
status: 'error',
|
|
2216
|
+
error: detail,
|
|
2217
|
+
...(statusCode !== detail ? { code: statusCode } : {}),
|
|
2218
|
+
});
|
|
2219
|
+
notifyContactTurn({
|
|
2220
|
+
assistantID,
|
|
2221
|
+
name: assistantSnapshot.name,
|
|
2222
|
+
turnID,
|
|
2223
|
+
status: 'error',
|
|
2224
|
+
body: detail,
|
|
2225
|
+
});
|
|
2226
|
+
resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
|
|
2227
|
+
}
|
|
2228
|
+
}).finally(() => { assignedResumes.delete(messageID); });
|
|
2229
|
+
};
|
|
2230
|
+
assignedSessionResumeRef.schedule = scheduleAssignedSessionResume;
|
|
1341
2231
|
/**
|
|
1342
2232
|
* Contact composer send: validate → admit user (202) → async turn.
|
|
1343
2233
|
* Does not await the LLM. Live bubble tokens use openchamber:contact-bubble-delta
|
|
@@ -1349,7 +2239,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1349
2239
|
const messageID = string(input.messageID, 256, true);
|
|
1350
2240
|
const userText = extractUserText(input);
|
|
1351
2241
|
if (!userText) fail('validation_error');
|
|
2242
|
+
const language = typeof input.language === 'string' ? input.language.trim().slice(0, 64) : '';
|
|
1352
2243
|
const row = active(assistantID);
|
|
2244
|
+
if (language) contactTurnLanguages.set(row.assistant_id, language);
|
|
1353
2245
|
const userParts = await userContactParts(row.assistant_id, input, userText);
|
|
1354
2246
|
if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
|
|
1355
2247
|
let registeredProjects = [];
|
|
@@ -1379,12 +2271,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1379
2271
|
replayed: true,
|
|
1380
2272
|
};
|
|
1381
2273
|
}
|
|
2274
|
+
cancelAssignedResumes({ assistantID: row.assistant_id });
|
|
2275
|
+
// A new user turn supersedes pending background notifications as well as running ones.
|
|
2276
|
+
// Explicit assign/watch admission rearms only its own watch. Keep live card status intact.
|
|
2277
|
+
db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(row.assistant_id);
|
|
1382
2278
|
admitContactUser(row.assistant_id, {
|
|
1383
2279
|
userMessageID: messageID,
|
|
1384
2280
|
userText,
|
|
1385
2281
|
userParts,
|
|
1386
2282
|
turnID,
|
|
1387
2283
|
});
|
|
2284
|
+
const controller = new AbortController();
|
|
2285
|
+
contactControllers.set(messageID, { assistantID: row.assistant_id, controller });
|
|
2286
|
+
const finishCancelled = () => {
|
|
2287
|
+
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
2288
|
+
emitContactTurnEvent('openchamber:contact-turn-end', { assistantID: row.assistant_id, turnID, status: 'cancelled' });
|
|
2289
|
+
resolveContactTurnSettlement(messageID, { status: 'cancelled' });
|
|
2290
|
+
contactControllers.delete(messageID);
|
|
2291
|
+
};
|
|
1388
2292
|
rememberActiveContactTurn(row.assistant_id, {
|
|
1389
2293
|
turnID,
|
|
1390
2294
|
messageID,
|
|
@@ -1399,6 +2303,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1399
2303
|
contactTurnSettlement(messageID);
|
|
1400
2304
|
const kickTurn = () => {
|
|
1401
2305
|
void inContactTurnLane(row.assistant_id, async () => {
|
|
2306
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1402
2307
|
if (closed) {
|
|
1403
2308
|
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
1404
2309
|
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
@@ -1421,15 +2326,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1421
2326
|
const assignedCards = [];
|
|
1422
2327
|
let contactResetThisTurn = false;
|
|
1423
2328
|
let contactHistoryClearedThisTurn = false;
|
|
1424
|
-
|
|
1425
|
-
try {
|
|
1426
|
-
const catalog = await loadAssignCatalog(undefined);
|
|
1427
|
-
connectedModels = normalizeConnectedModels(catalog?.models);
|
|
1428
|
-
} catch {
|
|
1429
|
-
// Catalog failure must not block default-model contact turns.
|
|
1430
|
-
// Explicit worker model / image capability checks still fail closed inside assignWork.
|
|
1431
|
-
connectedModels = [];
|
|
1432
|
-
}
|
|
2329
|
+
const modelContext = await loadContactModelContext(controller.signal);
|
|
1433
2330
|
try {
|
|
1434
2331
|
// Materialize inside turn try so failures durable-error + settle working.
|
|
1435
2332
|
// DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
|
|
@@ -1454,6 +2351,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1454
2351
|
const turnAttachmentScope = attachmentScopeKey(turnFileParts);
|
|
1455
2352
|
const tools = createContactTools({
|
|
1456
2353
|
assignWork: (params) => assignWork(row, params),
|
|
2354
|
+
watchSession: (params) => watchSessionWork(row, params),
|
|
2355
|
+
stopSession: (params) => stopSessionWork(row, params),
|
|
2356
|
+
steerSession: (params) => mutateSessionWork('steer', params),
|
|
2357
|
+
archiveSession: (params) => mutateSessionWork('archive', params),
|
|
2358
|
+
deleteSession: (params) => mutateSessionWork('delete', params),
|
|
1457
2359
|
createAssistant: (toolInput) => createAssistant(toolInput),
|
|
1458
2360
|
scheduleTask: (params) => scheduleWork(row, params),
|
|
1459
2361
|
deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
|
|
@@ -1479,22 +2381,79 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1479
2381
|
listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
|
|
1480
2382
|
listProjects: async () => loadRegisteredProjects(),
|
|
1481
2383
|
listSessions: (params) => listSessionsWork(params),
|
|
2384
|
+
readSession: (params) => readSessionWork(params),
|
|
2385
|
+
// Live DB read — not the turn-start assistantSnapshot.
|
|
2386
|
+
// Optional assistantID targets another live row; omitted → this contact.
|
|
2387
|
+
readAssistantSettings: (input = {}) => {
|
|
2388
|
+
const assistantID = typeof input?.assistantID === 'string' && input.assistantID.trim()
|
|
2389
|
+
? input.assistantID.trim()
|
|
2390
|
+
: row.assistant_id;
|
|
2391
|
+
return output(editable(assistantID));
|
|
2392
|
+
},
|
|
2393
|
+
// Persist defaultPrompt via updateAssistant CAS; one revision_conflict retry.
|
|
2394
|
+
updateAssistantSettings: async (patch = {}) => {
|
|
2395
|
+
const normalizePrompt = (value) => {
|
|
2396
|
+
if (typeof value !== 'string') fail('validation_error');
|
|
2397
|
+
if (value.length > 200_000) fail('validation_error');
|
|
2398
|
+
return value.trim();
|
|
2399
|
+
};
|
|
2400
|
+
const assistantID = typeof patch?.assistantID === 'string' && patch.assistantID.trim()
|
|
2401
|
+
? patch.assistantID.trim()
|
|
2402
|
+
: row.assistant_id;
|
|
2403
|
+
let lastError = null;
|
|
2404
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2405
|
+
const current = output(editable(assistantID));
|
|
2406
|
+
if (!Object.prototype.hasOwnProperty.call(patch, 'defaultPrompt')) {
|
|
2407
|
+
fail('validation_error');
|
|
2408
|
+
}
|
|
2409
|
+
const nextPrompt = normalizePrompt(patch.defaultPrompt);
|
|
2410
|
+
if (current.defaultPrompt === nextPrompt) {
|
|
2411
|
+
return {
|
|
2412
|
+
updated: false,
|
|
2413
|
+
unchanged: true,
|
|
2414
|
+
defaultPrompt: current.defaultPrompt,
|
|
2415
|
+
assistant: current,
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
// Yield so a concurrent UI PATCH can land before CAS (and tests can inject).
|
|
2419
|
+
await Promise.resolve();
|
|
2420
|
+
try {
|
|
2421
|
+
const updated = await updateAssistant(assistantID, {
|
|
2422
|
+
expectedRevision: current.revision,
|
|
2423
|
+
defaultPrompt: nextPrompt,
|
|
2424
|
+
});
|
|
2425
|
+
return {
|
|
2426
|
+
updated: true,
|
|
2427
|
+
defaultPrompt: updated.defaultPrompt,
|
|
2428
|
+
assistant: updated,
|
|
2429
|
+
};
|
|
2430
|
+
} catch (error) {
|
|
2431
|
+
lastError = error;
|
|
2432
|
+
if (error?.code !== 'revision_conflict') throw error;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
throw lastError;
|
|
2436
|
+
},
|
|
1482
2437
|
currentAssistant: assistantSnapshot,
|
|
1483
2438
|
onCard: (card) => assignedCards.push(card),
|
|
1484
2439
|
turnFileParts,
|
|
1485
2440
|
turnAttachmentScope,
|
|
1486
2441
|
});
|
|
2442
|
+
controller.signal.throwIfAborted();
|
|
1487
2443
|
const generated = await runContactTurn({
|
|
2444
|
+
signal: controller.signal,
|
|
1488
2445
|
assistant: assistantSnapshot,
|
|
1489
2446
|
history: executionHistory,
|
|
1490
2447
|
userText,
|
|
1491
2448
|
userParts: executionParts,
|
|
2449
|
+
language,
|
|
1492
2450
|
createChatCompletion,
|
|
1493
2451
|
tools,
|
|
1494
2452
|
projects: registeredProjects,
|
|
1495
|
-
|
|
2453
|
+
...modelContext,
|
|
1496
2454
|
globalEventHub,
|
|
1497
2455
|
onBubbleDelta: (bubbleIndex, delta, done) => {
|
|
2456
|
+
if (controller.signal.aborted) return;
|
|
1498
2457
|
emitContactTurnEvent('openchamber:contact-bubble-delta', {
|
|
1499
2458
|
assistantID: row.assistant_id,
|
|
1500
2459
|
turnID,
|
|
@@ -1504,6 +2463,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1504
2463
|
});
|
|
1505
2464
|
},
|
|
1506
2465
|
});
|
|
2466
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1507
2467
|
if (closed) {
|
|
1508
2468
|
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
1509
2469
|
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
@@ -1590,6 +2550,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1590
2550
|
});
|
|
1591
2551
|
resolveContactTurnSettlement(messageID, { status: 'complete' });
|
|
1592
2552
|
} catch (error) {
|
|
2553
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1593
2554
|
const detail = typeof error?.message === 'string' && error.message.trim()
|
|
1594
2555
|
? error.message.trim()
|
|
1595
2556
|
: (error?.code || 'upstream_error');
|
|
@@ -1619,6 +2580,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1619
2580
|
body: detail,
|
|
1620
2581
|
});
|
|
1621
2582
|
resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
|
|
2583
|
+
} finally {
|
|
2584
|
+
contactControllers.delete(messageID);
|
|
1622
2585
|
}
|
|
1623
2586
|
});
|
|
1624
2587
|
};
|
|
@@ -1698,6 +2661,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1698
2661
|
deleteContactMessages(db, row.assistant_id, {
|
|
1699
2662
|
...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
|
|
1700
2663
|
});
|
|
2664
|
+
// Rebind read watermark to the new generation at empty cursor so post-wipe
|
|
2665
|
+
// confirm bubbles can surface as unread; stale generation mark-read fails closed.
|
|
2666
|
+
resetContactReadWatermarkForGeneration(db, row.assistant_id, generation, { updatedAt: now() });
|
|
1701
2667
|
// Active contact turns stay until settle — wipe must not drop the green
|
|
1702
2668
|
// dot while the clearing turn is still running.
|
|
1703
2669
|
bump();
|
|
@@ -1708,6 +2674,48 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1708
2674
|
throw error;
|
|
1709
2675
|
}
|
|
1710
2676
|
};
|
|
2677
|
+
/**
|
|
2678
|
+
* Advance the shared contact read watermark (multi-client).
|
|
2679
|
+
* Body: { generation, ordinal, messageID } — must match live generation;
|
|
2680
|
+
* only monotonic forward moves bump revision / assistants-changed.
|
|
2681
|
+
* Idempotent no-op when unchanged (no broadcast).
|
|
2682
|
+
*/
|
|
2683
|
+
const markContactRead = (assistantID, input = {}) => {
|
|
2684
|
+
const row = editable(assistantID);
|
|
2685
|
+
if (!plainObject(input)) fail('validation_error');
|
|
2686
|
+
db.exec('BEGIN IMMEDIATE');
|
|
2687
|
+
try {
|
|
2688
|
+
let advanced;
|
|
2689
|
+
try {
|
|
2690
|
+
advanced = advanceContactReadWatermark(db, row.assistant_id, {
|
|
2691
|
+
generation: input.generation,
|
|
2692
|
+
ordinal: input.ordinal,
|
|
2693
|
+
messageID: input.messageID,
|
|
2694
|
+
}, { updatedAt: now() });
|
|
2695
|
+
} catch (error) {
|
|
2696
|
+
if (error?.code === 'contact_generation_conflict' || error?.code === 'validation_error') {
|
|
2697
|
+
fail(error.code, error.message);
|
|
2698
|
+
}
|
|
2699
|
+
throw error;
|
|
2700
|
+
}
|
|
2701
|
+
let tipRevision = revision();
|
|
2702
|
+
if (advanced.changed) {
|
|
2703
|
+
tipRevision = bump();
|
|
2704
|
+
}
|
|
2705
|
+
db.exec('COMMIT');
|
|
2706
|
+
return {
|
|
2707
|
+
assistantID: row.assistant_id,
|
|
2708
|
+
changed: advanced.changed,
|
|
2709
|
+
unreadCount: advanced.unreadCount,
|
|
2710
|
+
readWatermark: advanced.readWatermark,
|
|
2711
|
+
readTip: advanced.readTip,
|
|
2712
|
+
revision: tipRevision,
|
|
2713
|
+
};
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
db.exec('ROLLBACK');
|
|
2716
|
+
throw error;
|
|
2717
|
+
}
|
|
2718
|
+
};
|
|
1711
2719
|
const appendContactCard = (assistantID, input) => {
|
|
1712
2720
|
const row = active(assistantID);
|
|
1713
2721
|
const card = parseContactCard({ type: 'card', ...input, cardType: input?.cardType || 'session' });
|
|
@@ -1819,7 +2827,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1819
2827
|
};
|
|
1820
2828
|
return mode === 'stateless' ? inStatelessLane(deliveryTarget.assistantID, submit) : submit();
|
|
1821
2829
|
};
|
|
1822
|
-
const abort = async (assistantID, input) => {
|
|
2830
|
+
const abort = async (assistantID, input) => {
|
|
2831
|
+
const row = active(assistantID);
|
|
2832
|
+
if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation) fail('revision_conflict');
|
|
2833
|
+
let interrupted = false;
|
|
2834
|
+
for (const turn of contactControllers.values()) {
|
|
2835
|
+
if (turn.assistantID !== assistantID) continue;
|
|
2836
|
+
turn.controller.abort();
|
|
2837
|
+
interrupted = true;
|
|
2838
|
+
}
|
|
2839
|
+
for (const turn of assignedResumes.values()) {
|
|
2840
|
+
if (turn.assistantID !== assistantID) continue;
|
|
2841
|
+
turn.controller.abort();
|
|
2842
|
+
interrupted = true;
|
|
2843
|
+
}
|
|
2844
|
+
db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(assistantID);
|
|
2845
|
+
if (interrupted) return { binding: binding(row), aborted: true };
|
|
2846
|
+
if (!row.current_session_id) fail('revision_conflict');
|
|
2847
|
+
const result = await client().session.abort({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) });
|
|
2848
|
+
if (isMissing(result)) fail('not_found');
|
|
2849
|
+
if (result.error) fail('upstream_error');
|
|
2850
|
+
return { binding: binding(row), aborted: true };
|
|
2851
|
+
};
|
|
1823
2852
|
const createNew = async (assistantID) => { for (let attempt = 0; attempt < 3; attempt++) { const row = active(assistantID); const created = await createSession(row); const won = replaceBinding(row, created); if (won) return binding(won); } return binding(active(assistantID)); };
|
|
1824
2853
|
const shareOperation = (operationID) => { const row = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); return row && { operationID: row.operation_id, assistantID: row.assistant_id, sessionID: row.session_id, messageID: row.message_id, state: row.state, phase: row.phase, attempt: row.attempt, leaseExpiresAt: row.lease_expires_at, errorCode: row.error_code }; };
|
|
1825
2854
|
const claim = (operationID, retry = false) => { db.exec('BEGIN IMMEDIATE'); try { const operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (!operation) { db.exec('COMMIT'); return null; } const at = now(); const eligible = operation.state === 'failed' ? retry : operation.state === 'running' && operation.lease_expires_at <= at && operation.phase === 'admitted'; if (!eligible || operation.attempt >= SHARE_MAX_ATTEMPTS) { db.exec('COMMIT'); return null; } const result = db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitting',attempt=attempt+1,lease_expires_at=?,error_code=NULL,updated_at=? WHERE operation_id=? AND state=? AND phase=? AND attempt<? AND (state='failed' OR lease_expires_at<=?)").run(at + SHARE_LEASE_MS, at, operationID, operation.state, operation.phase, SHARE_MAX_ATTEMPTS, at); const claimed = result.changes ? db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID) : null; db.exec('COMMIT'); return claimed; } catch (error) { db.exec('ROLLBACK'); throw error; } };
|
|
@@ -1886,12 +2915,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1886
2915
|
});
|
|
1887
2916
|
const snapshot = () => {
|
|
1888
2917
|
const rows = db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all();
|
|
2918
|
+
const assistantIDs = rows.map((row) => row.assistant_id);
|
|
1889
2919
|
// Per-assistant indexed LIMIT probes + one parts batch (catalog ≤100).
|
|
1890
|
-
const previews = getLatestContactMessagePreviews(db,
|
|
2920
|
+
const previews = getLatestContactMessagePreviews(db, assistantIDs);
|
|
2921
|
+
// Batch unread/watermark/tip — no per-assistant N+1 on snapshot.
|
|
2922
|
+
const unreadByID = getContactUnreadSnapshots(db, assistantIDs);
|
|
1891
2923
|
return {
|
|
1892
2924
|
revision: revision(),
|
|
1893
2925
|
enabled: enabled(),
|
|
1894
|
-
assistants: rows.map((row) => output(
|
|
2926
|
+
assistants: rows.map((row) => output(
|
|
2927
|
+
row,
|
|
2928
|
+
previews.get(row.assistant_id) ?? null,
|
|
2929
|
+
unreadByID.get(row.assistant_id) ?? null,
|
|
2930
|
+
)),
|
|
1895
2931
|
};
|
|
1896
2932
|
};
|
|
1897
2933
|
return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
|
|
@@ -1905,7 +2941,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1905
2941
|
db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
|
|
1906
2942
|
db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
|
|
1907
2943
|
deleteContactMessages(db, assistantID);
|
|
2944
|
+
deleteContactReadState(db, assistantID);
|
|
1908
2945
|
clearActiveContactTurns(assistantID);
|
|
2946
|
+
contactTurnLanguages.delete(assistantID);
|
|
1909
2947
|
bump();
|
|
1910
2948
|
db.exec('COMMIT');
|
|
1911
2949
|
return { assistantID, tombstoneAt: now() };
|
|
@@ -1913,11 +2951,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1913
2951
|
db.exec('ROLLBACK');
|
|
1914
2952
|
throw error;
|
|
1915
2953
|
}
|
|
1916
|
-
}, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
|
|
2954
|
+
}, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, markContactRead, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
|
|
2955
|
+
cancelAssignedResumes({});
|
|
2956
|
+
for (const turn of contactControllers.values()) turn.controller.abort();
|
|
1917
2957
|
if (!closed) {
|
|
1918
2958
|
closed = true;
|
|
1919
2959
|
try { contactAttachmentMigration.controller.abort(); } catch { /* ignore */ }
|
|
1920
2960
|
clearActiveContactTurns();
|
|
2961
|
+
contactTurnLanguages.clear();
|
|
1921
2962
|
unsubscribeEvents?.();
|
|
1922
2963
|
clearIntervalFn(timer);
|
|
1923
2964
|
db.close();
|