@openchambery/web 1.19.13 → 1.19.15-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.
- package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
- package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
- package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.js} +1 -1
- package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
- package/dist/assets/Icon-D1BcR8Tz.js +1 -0
- package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
- package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
- package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
- package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
- package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.js} +1 -1
- package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
- package/dist/assets/SettingsView-b8LUjoad.js +25 -0
- package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
- package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
- package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
- package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.js} +1 -1
- package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
- package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.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-DhMGbF_B.js} +1 -1
- package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
- package/dist/assets/fileMentionTouchSelection-DKYkoypI.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-BjGouC_l.js} +2 -2
- package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-D4PyYgLZ.js} +3 -3
- package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
- package/dist/assets/insertionBoundaries-Bnnx03Wj.js +1 -0
- package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
- package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
- package/dist/assets/main-BRiQTNFX.js +169 -0
- package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
- package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
- package/dist/assets/miniChat-B0pw_mUs.js +2 -0
- package/dist/assets/mobile-BKHmxwXz.js +2 -0
- package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.js} +1 -1
- package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
- package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
- package/dist/assets/platform-DQ5_LNKO.js +2 -0
- package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-BvG8sdtC.js} +2 -2
- package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
- package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
- package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
- package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
- package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
- package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
- package/dist/assets/src-CFUi4riW.css +2 -0
- package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
- package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
- package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
- package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
- package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
- package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
- package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects-BhYuP4L2.js} +43 -44
- package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
- package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
- package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
- package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
- package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.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-C6OFKjeT.js} +2 -2
- package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
- 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 +120 -22
- package/server/lib/assistants/assign.js +117 -12
- package/server/lib/assistants/assign.test.js +79 -0
- package/server/lib/assistants/bubbles.js +1 -1
- package/server/lib/assistants/contact-store.js +520 -11
- package/server/lib/assistants/contact-store.test.js +362 -12
- package/server/lib/assistants/contact-tools.js +305 -54
- package/server/lib/assistants/contact-tools.test.js +239 -7
- package/server/lib/assistants/contracts.js +31 -0
- package/server/lib/assistants/harness.js +295 -132
- package/server/lib/assistants/harness.test.js +662 -10
- package/server/lib/assistants/pi-tools.js +2 -0
- package/server/lib/assistants/pi-tools.test.js +8 -0
- package/server/lib/assistants/routes.js +3 -0
- package/server/lib/assistants/service.js +861 -79
- package/server/lib/assistants/service.test.js +1234 -8
- package/server/lib/llm/DOCUMENTATION.md +10 -3
- package/server/lib/llm/completions.js +4 -0
- package/server/lib/llm/completions.test.js +3 -0
- package/server/lib/llm/generate.js +57 -11
- package/server/lib/llm/generate.test.js +92 -0
- package/server/lib/opencode/feature-routes-runtime.js +5 -0
- package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
- package/dist/assets/Icon-CkzzhaLr.js +0 -1
- package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
- package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
- package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
- package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
- package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
- package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
- package/dist/assets/main-BjeOhdcG.js +0 -169
- package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
- package/dist/assets/mobile-BVMNnPK2.js +0 -2
- package/dist/assets/platform-DzsGqlGW.js +0 -2
- package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
- package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
- package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
- package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
- package/dist/assets/sessionLookup-Imc995rW.js +0 -1
- package/dist/assets/src-AA-5nXsF.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
- package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
- package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
|
@@ -10,22 +10,30 @@ import { contactCardIdentity, parseContactCard, parseContactPart } from './cards
|
|
|
10
10
|
import {
|
|
11
11
|
CONTACT_PAGE_DEFAULT_LIMIT,
|
|
12
12
|
CONTACT_PAGE_MAX_LIMIT,
|
|
13
|
+
advanceContactReadWatermark,
|
|
13
14
|
bumpContactGeneration,
|
|
14
15
|
clearContactMemory as clearContactMemoryStore,
|
|
15
16
|
contactHistoryForLlm,
|
|
16
17
|
createActiveContactTurn,
|
|
17
18
|
deleteContactMessages,
|
|
19
|
+
deleteContactReadState,
|
|
20
|
+
deleteContactTurnAssistantOutputs,
|
|
18
21
|
ensureContactSchema,
|
|
19
22
|
contactPartsFingerprint,
|
|
20
23
|
getContactMessage,
|
|
24
|
+
getContactReadTip,
|
|
25
|
+
getContactReadWatermark,
|
|
26
|
+
getContactUnreadSnapshots,
|
|
21
27
|
getLatestContactMessagePreview,
|
|
22
28
|
getLatestContactMessagePreviews,
|
|
23
29
|
insertContactMessage,
|
|
24
30
|
listContactMessages,
|
|
25
31
|
listInFlightWatches,
|
|
26
32
|
listWatchesBySession,
|
|
33
|
+
migrateContactReadStateDefaultRead,
|
|
27
34
|
nextContactOrdinal,
|
|
28
35
|
projectActiveContactTurn,
|
|
36
|
+
resetContactReadWatermarkForGeneration,
|
|
29
37
|
updateSessionCardStatus,
|
|
30
38
|
upsertContactWatch,
|
|
31
39
|
} from './contact-store.js';
|
|
@@ -63,7 +71,10 @@ import {
|
|
|
63
71
|
ASSIGN_CODES,
|
|
64
72
|
assignSession,
|
|
65
73
|
attachmentScopeKey,
|
|
74
|
+
extractSessionDirectory,
|
|
75
|
+
extractSessionTitle,
|
|
66
76
|
hasAssignImageParts,
|
|
77
|
+
mapSessionToWatchStatus,
|
|
67
78
|
resolveAssignDirectory,
|
|
68
79
|
extractAssignSessionModel,
|
|
69
80
|
resolveAssignWorkerModel,
|
|
@@ -73,7 +84,7 @@ import { runContactTurn as defaultRunContactTurn } from './harness.js';
|
|
|
73
84
|
import { loadConnectedCatalog } from '../llm/catalog.js';
|
|
74
85
|
|
|
75
86
|
const require = createRequire(import.meta.url);
|
|
76
|
-
const SCHEMA_VERSION =
|
|
87
|
+
const SCHEMA_VERSION = 13;
|
|
77
88
|
const normalizeSessionDirectory = (value) => {
|
|
78
89
|
if (typeof value !== 'string') return null;
|
|
79
90
|
const trimmed = value.trim();
|
|
@@ -130,10 +141,11 @@ const promptAdmitted = (result) => !result?.error && (result?.response?.status =
|
|
|
130
141
|
export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
|
|
131
142
|
/** Bound last worker assistant text injected into assigned-session resume prompts. */
|
|
132
143
|
export const ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX = 2_000;
|
|
133
|
-
/** Stable resume turn/message id for assigned-session complete/error continuation. */
|
|
144
|
+
/** Stable resume turn/message id for assigned-session complete/error/cancelled continuation. */
|
|
134
145
|
export const assignedSessionResumeMessageID = (assistantID, sessionID, status, updatedAt) => (
|
|
135
146
|
`resume_${assistantID}_${sessionID}_${status}_${updatedAt}`
|
|
136
147
|
);
|
|
148
|
+
const assignedSessionResumeStatus = (status) => status === 'complete' || status === 'error' || status === 'cancelled';
|
|
137
149
|
const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
|
|
138
150
|
let timer = null;
|
|
139
151
|
try {
|
|
@@ -152,7 +164,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
|
|
|
152
164
|
}
|
|
153
165
|
};
|
|
154
166
|
|
|
155
|
-
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 } = {}) => {
|
|
167
|
+
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 } = {}) => {
|
|
156
168
|
if (!dbPath || !dataDir) return null;
|
|
157
169
|
const Database = require('better-sqlite3');
|
|
158
170
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
@@ -289,12 +301,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
289
301
|
}
|
|
290
302
|
activeContactTurnsByAssistant.delete(assistantID);
|
|
291
303
|
};
|
|
292
|
-
const output = (row, latestMessagePreview) => {
|
|
304
|
+
const output = (row, latestMessagePreview, unreadSnapshot) => {
|
|
293
305
|
const watches = listInFlightWatches(db, row.assistant_id);
|
|
294
306
|
const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
|
|
295
307
|
const preview = latestMessagePreview !== undefined
|
|
296
308
|
? latestMessagePreview
|
|
297
309
|
: getLatestContactMessagePreview(db, row.assistant_id);
|
|
310
|
+
const unread = unreadSnapshot !== undefined && unreadSnapshot !== null
|
|
311
|
+
? unreadSnapshot
|
|
312
|
+
: (getContactUnreadSnapshots(db, [row.assistant_id]).get(row.assistant_id) ?? {
|
|
313
|
+
unreadCount: 0,
|
|
314
|
+
readWatermark: getContactReadWatermark(db, row.assistant_id),
|
|
315
|
+
readTip: getContactReadTip(db, row.assistant_id),
|
|
316
|
+
});
|
|
298
317
|
return {
|
|
299
318
|
id: row.assistant_id,
|
|
300
319
|
revision: row.revision,
|
|
@@ -321,6 +340,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
321
340
|
// Contact-transcript list desc: authoritative last visible bubble (not
|
|
322
341
|
// defaultPrompt / updatedAt). null when the contact has no list-visible rows.
|
|
323
342
|
latestMessagePreview: preview ?? null,
|
|
343
|
+
// Shared multi-client unread: countable assistant/peer replies after watermark.
|
|
344
|
+
unreadCount: Number(unread.unreadCount) || 0,
|
|
345
|
+
// Persisted shared watermark (generation + keyset). Clients POST this tip safely.
|
|
346
|
+
readWatermark: unread.readWatermark,
|
|
347
|
+
// Highest readable tip for safe mark-read report (transcript head + generation).
|
|
348
|
+
readTip: unread.readTip,
|
|
324
349
|
createdAt: row.created_at,
|
|
325
350
|
updatedAt: row.updated_at,
|
|
326
351
|
tombstoneAt: row.tombstone_at,
|
|
@@ -386,6 +411,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
386
411
|
const legacy = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='assistant'").get();
|
|
387
412
|
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); } }
|
|
388
413
|
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); }
|
|
414
|
+
// v13: shared contact read watermark. Legacy transcripts default to fully read
|
|
415
|
+
// so existing installs do not flash unread on upgrade; later messages count.
|
|
416
|
+
migrateContactReadStateDefaultRead(db, { updatedAt: now() });
|
|
389
417
|
db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
|
|
390
418
|
};
|
|
391
419
|
migrate();
|
|
@@ -460,8 +488,43 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
460
488
|
const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
|
|
461
489
|
return nonEmptyString(sessionID) ? sessionID : '';
|
|
462
490
|
};
|
|
491
|
+
const isUserAbort = (error) => error?.name === 'MessageAbortedError';
|
|
492
|
+
const assignedResumes = new Map();
|
|
493
|
+
const contactControllers = new Map();
|
|
494
|
+
// Last UI locale seen from a client per assistant. Background notifications
|
|
495
|
+
// (assigned-session settle) have no request locale, so they recall this value.
|
|
496
|
+
const contactTurnLanguages = new Map();
|
|
497
|
+
const cancelAssignedResumes = ({ sessionID, assistantID, statuses } = {}) => {
|
|
498
|
+
for (const resume of assignedResumes.values()) {
|
|
499
|
+
if (sessionID && resume.sessionID !== sessionID) continue;
|
|
500
|
+
if (assistantID && resume.assistantID !== assistantID) continue;
|
|
501
|
+
if (statuses && !statuses.includes(resume.status)) continue;
|
|
502
|
+
resume.controller.abort();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
// Abort-request lifecycle only (stop_session). Concurrent same-session aborts share one promise.
|
|
506
|
+
// Observed SSE terminals are stashed until abort settles so resume=true cannot beat resume=false.
|
|
507
|
+
const sessionAbortInflight = new Map();
|
|
508
|
+
const abortTerminalRank = (status) => {
|
|
509
|
+
if (status === 'cancelled') return 3;
|
|
510
|
+
if (status === 'error') return 2;
|
|
511
|
+
if (status === 'complete') return 1;
|
|
512
|
+
return 0;
|
|
513
|
+
};
|
|
514
|
+
const stashSessionAbortTerminal = (entry, status, resume) => {
|
|
515
|
+
if (!entry || typeof status !== 'string' || !status) return;
|
|
516
|
+
const nextRank = abortTerminalRank(status);
|
|
517
|
+
if (nextRank <= 0) return;
|
|
518
|
+
const prev = entry.observed;
|
|
519
|
+
const prevRank = prev ? abortTerminalRank(prev.status) : -1;
|
|
520
|
+
// cancelled wins over complete/error; error wins over complete; equal rank keeps latest.
|
|
521
|
+
if (nextRank > prevRank || (nextRank === prevRank && (status === 'cancelled' || nextRank > 0))) {
|
|
522
|
+
entry.observed = { status, resume: resume !== false };
|
|
523
|
+
if (status === 'cancelled') entry.observed.resume = resume !== false;
|
|
524
|
+
}
|
|
525
|
+
};
|
|
463
526
|
const settleStatusFromEvent = (payload, properties) => {
|
|
464
|
-
if (payload.type === 'session.error') return 'error';
|
|
527
|
+
if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
|
|
465
528
|
if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
|
|
466
529
|
if (payload.type === 'session.idle') return 'complete';
|
|
467
530
|
if (payload.type === 'session.status') {
|
|
@@ -471,10 +534,20 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
471
534
|
}
|
|
472
535
|
return null;
|
|
473
536
|
};
|
|
474
|
-
// Assigned-session complete/error resumes the contact LLM (no canned settle bubble).
|
|
537
|
+
// Assigned-session complete/error/cancelled resumes the contact LLM (no canned settle bubble).
|
|
475
538
|
// Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
|
|
476
539
|
const assignedSessionResumeRef = { schedule: null };
|
|
477
|
-
const reportAssignedSession = (sessionID, status) => {
|
|
540
|
+
const reportAssignedSession = (sessionID, status, resume = true) => {
|
|
541
|
+
const inflight = sessionAbortInflight.get(sessionID);
|
|
542
|
+
if (inflight) {
|
|
543
|
+
// Defer watch terminal + continuation until stop_session abort settles.
|
|
544
|
+
stashSessionAbortTerminal(inflight, status, resume);
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
// Drop complete/error notifies for this worker. Do not abort a cancelled
|
|
548
|
+
// interrupt notify — duplicate cancelled reports (boot+timer, error+message.updated)
|
|
549
|
+
// must still reach the contact LLM.
|
|
550
|
+
if (status === 'cancelled') cancelAssignedResumes({ sessionID, statuses: ['complete', 'error'] });
|
|
478
551
|
const watches = listWatchesBySession(db, sessionID);
|
|
479
552
|
if (watches.length === 0) return false;
|
|
480
553
|
let changed = false;
|
|
@@ -482,7 +555,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
482
555
|
db.exec('BEGIN IMMEDIATE');
|
|
483
556
|
try {
|
|
484
557
|
for (const watch of watches) {
|
|
485
|
-
if (watch.status === status) continue;
|
|
558
|
+
if (watch.status === status || watch.status === 'cancelled') continue;
|
|
486
559
|
// session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
|
|
487
560
|
if (status === 'complete' && watch.status === 'error') continue;
|
|
488
561
|
const updatedAt = now();
|
|
@@ -493,10 +566,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
493
566
|
directory: watch.directory,
|
|
494
567
|
status,
|
|
495
568
|
updatedAt,
|
|
569
|
+
resumeAllowed: watch.resumeAllowed,
|
|
496
570
|
});
|
|
497
|
-
// complete/error: hand result back to contact LLM.
|
|
571
|
+
// complete/error/user-interrupt cancelled: hand result back to contact LLM.
|
|
572
|
+
// question: card only (worker waiting). stop_session passes resume=false.
|
|
498
573
|
// Never insert oc.settle.* canned transcript bubbles.
|
|
499
|
-
if (
|
|
574
|
+
if (resume && watch.resumeAllowed && assignedSessionResumeStatus(status)) {
|
|
500
575
|
resumes.push({
|
|
501
576
|
assistantID: watch.assistantID,
|
|
502
577
|
sessionID,
|
|
@@ -537,12 +612,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
537
612
|
if (isMissing(getResult)) return 'complete';
|
|
538
613
|
if (getResult?.error) return null;
|
|
539
614
|
const session = getResult?.data;
|
|
540
|
-
if (session?.error) return 'error';
|
|
615
|
+
if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
|
|
541
616
|
const statusType = sessionStatusType(session);
|
|
542
617
|
if (statusType === 'busy' || statusType === 'retry') return null;
|
|
543
618
|
if (isMissing(messagesResult)) return 'complete';
|
|
544
619
|
const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
|
|
545
|
-
if (assistant?.error) return 'error';
|
|
620
|
+
if (assistant?.error) return isUserAbort(assistant.error) ? 'cancelled' : 'error';
|
|
546
621
|
if (statusType === 'idle' || session?.time?.completed) return 'complete';
|
|
547
622
|
if (assistant?.time?.completed) return 'complete';
|
|
548
623
|
return null;
|
|
@@ -596,7 +671,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
596
671
|
if (payload.type === 'message.updated') {
|
|
597
672
|
const info = properties.info; const sessionID = info?.sessionID;
|
|
598
673
|
if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
|
|
599
|
-
const
|
|
674
|
+
const cancelled = info.role === 'assistant' && isUserAbort(info.error)
|
|
675
|
+
? reportAssignedSession(sessionID, 'cancelled') : false;
|
|
676
|
+
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;
|
|
600
677
|
}
|
|
601
678
|
if (payload.type === 'message.part.updated') {
|
|
602
679
|
const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
|
|
@@ -835,7 +912,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
835
912
|
if (parts.length === 0 && userText) parts.push({ type: 'text', text: userText });
|
|
836
913
|
return parts;
|
|
837
914
|
};
|
|
838
|
-
/** Admit the user row only — HTTP 202 returns here; assistant bubbles persist
|
|
915
|
+
/** Admit the user row only — HTTP 202 returns here; assistant bubbles persist as they publish. */
|
|
839
916
|
const admitContactUser = (assistantID, { userMessageID, userText, userParts, turnID }) => {
|
|
840
917
|
db.exec('BEGIN IMMEDIATE');
|
|
841
918
|
try {
|
|
@@ -882,13 +959,135 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
882
959
|
throw writeError;
|
|
883
960
|
}
|
|
884
961
|
};
|
|
885
|
-
|
|
886
|
-
const
|
|
962
|
+
const contactBubbleMessageID = (userMessageID, bubbleIndex) => `${userMessageID}:bubble:${bubbleIndex + 1}`;
|
|
963
|
+
const contactCardMessageID = (userMessageID, index) => `${userMessageID}:card:${index + 1}`;
|
|
964
|
+
|
|
965
|
+
const persistPublishedContactBubble = (assistantID, { userMessageID, turnID, bubbleIndex, text }) => {
|
|
966
|
+
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
967
|
+
if (!trimmed || trimmed.startsWith('oc.settle.')) return false;
|
|
968
|
+
const bubbleID = contactBubbleMessageID(userMessageID, bubbleIndex);
|
|
969
|
+
if (getContactMessage(db, bubbleID)) return false;
|
|
970
|
+
db.exec('BEGIN IMMEDIATE');
|
|
971
|
+
try {
|
|
972
|
+
if (getContactMessage(db, bubbleID)) {
|
|
973
|
+
db.exec('COMMIT');
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
insertContactMessage(db, {
|
|
977
|
+
messageID: bubbleID,
|
|
978
|
+
assistantID,
|
|
979
|
+
role: 'assistant',
|
|
980
|
+
turnID,
|
|
981
|
+
bubbleIndex,
|
|
982
|
+
createdAt: now(),
|
|
983
|
+
ordinal: nextContactOrdinal(db, assistantID),
|
|
984
|
+
status: 'complete',
|
|
985
|
+
parts: [{ type: 'text', text: trimmed }],
|
|
986
|
+
});
|
|
987
|
+
bump();
|
|
988
|
+
db.exec('COMMIT');
|
|
989
|
+
return true;
|
|
990
|
+
} catch (error) {
|
|
991
|
+
db.exec('ROLLBACK');
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const persistPublishedContactCard = (assistantID, { userMessageID, turnID, index, card: cardInput }) => {
|
|
997
|
+
const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
|
|
998
|
+
if (!card) return false;
|
|
999
|
+
const cardID = contactCardMessageID(userMessageID, index);
|
|
1000
|
+
if (getContactMessage(db, cardID)) return false;
|
|
1001
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1002
|
+
try {
|
|
1003
|
+
if (getContactMessage(db, cardID)) {
|
|
1004
|
+
db.exec('COMMIT');
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
insertContactMessage(db, {
|
|
1008
|
+
messageID: cardID,
|
|
1009
|
+
assistantID,
|
|
1010
|
+
role: 'assistant',
|
|
1011
|
+
turnID,
|
|
1012
|
+
bubbleIndex: 0,
|
|
1013
|
+
createdAt: now(),
|
|
1014
|
+
ordinal: nextContactOrdinal(db, assistantID),
|
|
1015
|
+
status: 'complete',
|
|
1016
|
+
parts: [card],
|
|
1017
|
+
});
|
|
1018
|
+
if (card.cardType === 'session' && card.sessionID) {
|
|
1019
|
+
upsertContactWatch(db, {
|
|
1020
|
+
assistantID,
|
|
1021
|
+
sessionID: card.sessionID,
|
|
1022
|
+
directory: card.directory,
|
|
1023
|
+
status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
|
|
1024
|
+
updatedAt: now(),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
bump();
|
|
1028
|
+
db.exec('COMMIT');
|
|
1029
|
+
return true;
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
db.exec('ROLLBACK');
|
|
1032
|
+
throw error;
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
const publishContactBubbleDelta = ({
|
|
1037
|
+
assistantID, turnID, userMessageID, spokenByIndex, bubbleIndex, delta, done, signal,
|
|
1038
|
+
}) => {
|
|
1039
|
+
if (signal?.aborted) return;
|
|
1040
|
+
const chunk = typeof delta === 'string' ? delta : '';
|
|
1041
|
+
if (chunk) spokenByIndex.set(bubbleIndex, `${spokenByIndex.get(bubbleIndex) || ''}${chunk}`);
|
|
1042
|
+
emitContactTurnEvent('openchamber:contact-bubble-delta', {
|
|
1043
|
+
assistantID,
|
|
1044
|
+
turnID,
|
|
1045
|
+
bubbleIndex,
|
|
1046
|
+
delta: chunk,
|
|
1047
|
+
done: Boolean(done),
|
|
1048
|
+
});
|
|
1049
|
+
if (!done) return;
|
|
1050
|
+
try {
|
|
1051
|
+
persistPublishedContactBubble(assistantID, {
|
|
1052
|
+
userMessageID,
|
|
1053
|
+
turnID,
|
|
1054
|
+
bubbleIndex,
|
|
1055
|
+
text: spokenByIndex.get(bubbleIndex) || '',
|
|
1056
|
+
});
|
|
1057
|
+
} catch {
|
|
1058
|
+
// Publishing a spoken bubble must not abort the live turn.
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const rememberPublishedContactCard = (assistantID, { userMessageID, turnID, assignedCards, card }) => {
|
|
1063
|
+
assignedCards.push(card);
|
|
1064
|
+
try {
|
|
1065
|
+
persistPublishedContactCard(assistantID, {
|
|
1066
|
+
userMessageID,
|
|
1067
|
+
turnID,
|
|
1068
|
+
index: assignedCards.length - 1,
|
|
1069
|
+
card,
|
|
1070
|
+
});
|
|
1071
|
+
} catch {
|
|
1072
|
+
// Publishing a card must not abort the live turn.
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
/** Persist remaining assistant bubbles/cards for an already-admitted user turn (no user row). */
|
|
1077
|
+
const persistContactAssistantReply = (assistantID, {
|
|
1078
|
+
userMessageID, bubbles, cards = [], turnID, reset = false,
|
|
1079
|
+
}) => {
|
|
887
1080
|
db.exec('BEGIN IMMEDIATE');
|
|
888
1081
|
try {
|
|
1082
|
+
// Successful new_conversation / clear_chat_history: drop this turn's pre-reset
|
|
1083
|
+
// spoken/card rows so the unique confirm can own bubble:1 (same id is not skipped).
|
|
1084
|
+
if (reset) {
|
|
1085
|
+
deleteContactTurnAssistantOutputs(db, assistantID, turnID);
|
|
1086
|
+
}
|
|
889
1087
|
let ordinal = nextContactOrdinal(db, assistantID);
|
|
890
1088
|
bubbles.forEach((text, index) => {
|
|
891
|
-
const bubbleID =
|
|
1089
|
+
const bubbleID = contactBubbleMessageID(userMessageID, index);
|
|
1090
|
+
if (getContactMessage(db, bubbleID)) return;
|
|
892
1091
|
insertContactMessage(db, {
|
|
893
1092
|
messageID: bubbleID,
|
|
894
1093
|
assistantID,
|
|
@@ -905,8 +1104,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
905
1104
|
cards.forEach((cardInput, index) => {
|
|
906
1105
|
const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
|
|
907
1106
|
if (!card) return;
|
|
1107
|
+
const cardID = contactCardMessageID(userMessageID, index);
|
|
1108
|
+
if (getContactMessage(db, cardID)) return;
|
|
908
1109
|
insertContactMessage(db, {
|
|
909
|
-
messageID:
|
|
1110
|
+
messageID: cardID,
|
|
910
1111
|
assistantID,
|
|
911
1112
|
role: 'assistant',
|
|
912
1113
|
turnID,
|
|
@@ -1005,6 +1206,23 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1005
1206
|
throw wrapped;
|
|
1006
1207
|
}
|
|
1007
1208
|
};
|
|
1209
|
+
const loadContactModelContext = async (signal) => {
|
|
1210
|
+
const [catalogResult, preferencesResult] = await Promise.allSettled([
|
|
1211
|
+
loadAssignCatalog(signal),
|
|
1212
|
+
awaitWithDeadline(Promise.resolve().then(() => readModelPreferences()), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error'),
|
|
1213
|
+
]);
|
|
1214
|
+
const catalog = catalogResult.status === 'fulfilled' ? catalogResult.value : null;
|
|
1215
|
+
const providerNames = new Map((catalog?.providers || []).map((provider) => [provider.id, provider.name]));
|
|
1216
|
+
const preferences = preferencesResult.status === 'fulfilled' ? preferencesResult.value : null;
|
|
1217
|
+
return {
|
|
1218
|
+
connectedModels: normalizeConnectedModels((catalog?.models || []).map((model) => ({ ...model, providerName: providerNames.get(model.providerID) }))),
|
|
1219
|
+
modelCatalogAvailable: catalog !== null,
|
|
1220
|
+
modelPreferences: preferences && typeof preferences === 'object' ? {
|
|
1221
|
+
favoriteModels: Array.isArray(preferences.favoriteModels) ? preferences.favoriteModels.slice(0, 64) : [],
|
|
1222
|
+
recentModels: Array.isArray(preferences.recentModels) ? preferences.recentModels.slice(0, 16) : [],
|
|
1223
|
+
} : null,
|
|
1224
|
+
};
|
|
1225
|
+
};
|
|
1008
1226
|
const loadRegisteredProjects = async () => {
|
|
1009
1227
|
const listed = await awaitWithDeadline(listProjects(), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error');
|
|
1010
1228
|
if (!Array.isArray(listed)) {
|
|
@@ -1097,6 +1315,231 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1097
1315
|
return null;
|
|
1098
1316
|
}
|
|
1099
1317
|
};
|
|
1318
|
+
/**
|
|
1319
|
+
* Find a session row in the OpenChamber session index (directory is index-authoritative).
|
|
1320
|
+
* Index failure throws — never silent empty.
|
|
1321
|
+
*/
|
|
1322
|
+
const findSessionInIndex = (sessionID) => {
|
|
1323
|
+
if (!sessionIndexService || typeof sessionIndexService.snapshot !== 'function') {
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
let snapshot;
|
|
1327
|
+
try {
|
|
1328
|
+
snapshot = sessionIndexService.snapshot();
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
throw new AssignError(
|
|
1331
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1332
|
+
typeof error?.message === 'string' && error.message.trim()
|
|
1333
|
+
? error.message.trim()
|
|
1334
|
+
: 'Session index snapshot failed.',
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
if (!snapshot || !Array.isArray(snapshot.directories)) {
|
|
1338
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index snapshot failed.');
|
|
1339
|
+
}
|
|
1340
|
+
for (const directory of snapshot.directories) {
|
|
1341
|
+
const dir = typeof directory?.directory === 'string' ? directory.directory : '';
|
|
1342
|
+
if (!dir) continue;
|
|
1343
|
+
for (const session of Array.isArray(directory.sessions) ? directory.sessions : []) {
|
|
1344
|
+
if (!session || typeof session.id !== 'string' || session.id !== sessionID) continue;
|
|
1345
|
+
return {
|
|
1346
|
+
sessionID,
|
|
1347
|
+
directory: dir,
|
|
1348
|
+
title: typeof session.title === 'string' ? session.title : null,
|
|
1349
|
+
updatedAt: typeof session.time?.updated === 'number'
|
|
1350
|
+
? session.time.updated
|
|
1351
|
+
: (typeof session.updatedAt === 'number' ? session.updatedAt : 0),
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return null;
|
|
1356
|
+
};
|
|
1357
|
+
/**
|
|
1358
|
+
* Resolve an existing OpenCode session for watch / stop / assign-reuse.
|
|
1359
|
+
* Authority order: session index directory → session.get metadata → registered project.
|
|
1360
|
+
* User-supplied directory/projectPath is a lookup hint only — never trusted as scope.
|
|
1361
|
+
* Failures stay failures (not empty success).
|
|
1362
|
+
*/
|
|
1363
|
+
const resolveReferencedSession = async ({
|
|
1364
|
+
sessionID: rawSessionID,
|
|
1365
|
+
directoryHint = null,
|
|
1366
|
+
titleHint = null,
|
|
1367
|
+
signal,
|
|
1368
|
+
includeMessages = true,
|
|
1369
|
+
} = {}) => {
|
|
1370
|
+
const sessionID = typeof rawSessionID === 'string' ? rawSessionID.trim() : '';
|
|
1371
|
+
if (!sessionID) {
|
|
1372
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'sessionID is required.');
|
|
1373
|
+
}
|
|
1374
|
+
const options = signal ? { signal } : undefined;
|
|
1375
|
+
const indexRow = findSessionInIndex(sessionID);
|
|
1376
|
+
const hint = typeof directoryHint === 'string' && directoryHint.trim()
|
|
1377
|
+
? directoryHint.trim()
|
|
1378
|
+
: null;
|
|
1379
|
+
// Prefer index directory; user path is last-resort get hint only.
|
|
1380
|
+
const getHints = [];
|
|
1381
|
+
if (indexRow?.directory) getHints.push(indexRow.directory);
|
|
1382
|
+
if (hint && !getHints.some((item) => directoryContained(item, hint) || directoryContained(hint, item))) {
|
|
1383
|
+
getHints.push(hint);
|
|
1384
|
+
}
|
|
1385
|
+
if (getHints.length === 0) getHints.push(null);
|
|
1386
|
+
|
|
1387
|
+
let getResult = null;
|
|
1388
|
+
let usedDirectoryHint = null;
|
|
1389
|
+
let lastError = null;
|
|
1390
|
+
for (const dir of getHints) {
|
|
1391
|
+
try {
|
|
1392
|
+
getResult = await client().session.get({
|
|
1393
|
+
sessionID,
|
|
1394
|
+
...(dir ? { directory: dir } : {}),
|
|
1395
|
+
}, options);
|
|
1396
|
+
usedDirectoryHint = dir;
|
|
1397
|
+
if (getResult && !getResult.error) break;
|
|
1398
|
+
if (isMissing(getResult)) {
|
|
1399
|
+
getResult = { missing: true, error: getResult?.error };
|
|
1400
|
+
break;
|
|
1401
|
+
}
|
|
1402
|
+
// Non-404 error with a directory may be wrong scope — try next hint.
|
|
1403
|
+
if (getResult?.error && dir) {
|
|
1404
|
+
lastError = getResult.error;
|
|
1405
|
+
getResult = null;
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
break;
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
lastError = error;
|
|
1411
|
+
getResult = null;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (!getResult) {
|
|
1415
|
+
throw new AssignError(
|
|
1416
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1417
|
+
typeof lastError?.message === 'string' && lastError.message.trim()
|
|
1418
|
+
? lastError.message.trim()
|
|
1419
|
+
: 'Failed to load that coding session.',
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
if (getResult.missing || isMissing(getResult)) {
|
|
1423
|
+
throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
|
|
1424
|
+
}
|
|
1425
|
+
if (getResult.error) {
|
|
1426
|
+
throw new AssignError(
|
|
1427
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1428
|
+
typeof getResult.error?.message === 'string' && getResult.error.message.trim()
|
|
1429
|
+
? getResult.error.message.trim()
|
|
1430
|
+
: 'Failed to load that coding session.',
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
const sessionPayload = getResult.data ?? getResult;
|
|
1434
|
+
const metaDirectory = extractSessionDirectory(sessionPayload);
|
|
1435
|
+
const directoryRaw = metaDirectory || indexRow?.directory || usedDirectoryHint;
|
|
1436
|
+
if (!directoryRaw) {
|
|
1437
|
+
throw new AssignError(
|
|
1438
|
+
ASSIGN_CODES.VALIDATION,
|
|
1439
|
+
'That session has no resolvable project directory.',
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
// Registered project scope = allowed roots (same gate as assign create).
|
|
1443
|
+
// Reject managed assistant-workspaces and paths outside Settings projects.
|
|
1444
|
+
let directory;
|
|
1445
|
+
try {
|
|
1446
|
+
directory = resolveAssignDirectory({
|
|
1447
|
+
projectPath: directoryRaw,
|
|
1448
|
+
directory: directoryRaw,
|
|
1449
|
+
branch: null,
|
|
1450
|
+
allowedRoots: getAllowedRoots(),
|
|
1451
|
+
managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
|
|
1452
|
+
worktrees: [],
|
|
1453
|
+
});
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
if (error instanceof AssignError) throw error;
|
|
1456
|
+
throw new AssignError(
|
|
1457
|
+
ASSIGN_CODES.WORKSPACE_FORBIDDEN,
|
|
1458
|
+
'That session is not under a registered project. Add the project in Settings first.',
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
let messagesResult = null;
|
|
1463
|
+
if (includeMessages && typeof client().session.messages === 'function') {
|
|
1464
|
+
try {
|
|
1465
|
+
messagesResult = await client().session.messages({
|
|
1466
|
+
sessionID,
|
|
1467
|
+
directory,
|
|
1468
|
+
limit: 100,
|
|
1469
|
+
}, options);
|
|
1470
|
+
if (messagesResult?.error && !isMissing(messagesResult)) {
|
|
1471
|
+
// Message fetch failure must not invent status; keep messages null.
|
|
1472
|
+
messagesResult = null;
|
|
1473
|
+
}
|
|
1474
|
+
} catch {
|
|
1475
|
+
messagesResult = null;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const messageRows = Array.isArray(messagesResult?.data)
|
|
1479
|
+
? messagesResult.data
|
|
1480
|
+
: (Array.isArray(messagesResult) ? messagesResult : null);
|
|
1481
|
+
const status = mapSessionToWatchStatus({
|
|
1482
|
+
session: sessionPayload,
|
|
1483
|
+
messages: messageRows,
|
|
1484
|
+
missing: false,
|
|
1485
|
+
});
|
|
1486
|
+
const title = (typeof titleHint === 'string' && titleHint.trim() ? titleHint.trim() : null)
|
|
1487
|
+
|| extractSessionTitle(sessionPayload)
|
|
1488
|
+
|| indexRow?.title
|
|
1489
|
+
|| sessionID;
|
|
1490
|
+
return {
|
|
1491
|
+
sessionID,
|
|
1492
|
+
directory,
|
|
1493
|
+
title,
|
|
1494
|
+
status,
|
|
1495
|
+
session: sessionPayload,
|
|
1496
|
+
messages: messageRows,
|
|
1497
|
+
};
|
|
1498
|
+
};
|
|
1499
|
+
const readSessionWork = async (params = {}) => {
|
|
1500
|
+
const limit = params.limit === undefined ? 20 : params.limit;
|
|
1501
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new AssignError(ASSIGN_CODES.VALIDATION, 'limit must be an integer from 1 to 50.');
|
|
1502
|
+
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.');
|
|
1503
|
+
const { signal } = params;
|
|
1504
|
+
signal?.throwIfAborted();
|
|
1505
|
+
const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
|
|
1506
|
+
const result = await client().session.messages({
|
|
1507
|
+
sessionID: resolved.sessionID, directory: resolved.directory, limit,
|
|
1508
|
+
...(params.before ? { before: params.before } : {}),
|
|
1509
|
+
}, signal ? { signal } : undefined);
|
|
1510
|
+
signal?.throwIfAborted();
|
|
1511
|
+
if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
|
|
1512
|
+
if (result?.error || !Array.isArray(result?.data)) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
|
|
1513
|
+
const rows = result.data;
|
|
1514
|
+
if (rows.some((entry) => (entry?.info?.sessionID && entry.info.sessionID !== resolved.sessionID))) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session message identity mismatch.');
|
|
1515
|
+
let remaining = 24000;
|
|
1516
|
+
let partial = rows.length > limit;
|
|
1517
|
+
const text = (value) => {
|
|
1518
|
+
if (typeof value !== 'string') return '';
|
|
1519
|
+
const kept = value.slice(0, Math.min(4000, remaining));
|
|
1520
|
+
remaining -= kept.length;
|
|
1521
|
+
if (kept.length < value.length) partial = true;
|
|
1522
|
+
return kept;
|
|
1523
|
+
};
|
|
1524
|
+
const messages = rows.slice(-limit).map((entry) => {
|
|
1525
|
+
const info = entry.info ?? entry;
|
|
1526
|
+
const parts = [];
|
|
1527
|
+
const sourceParts = Array.isArray(entry.parts) ? entry.parts : [];
|
|
1528
|
+
if (sourceParts.length > 20) partial = true;
|
|
1529
|
+
for (const part of sourceParts.slice(0, 20)) {
|
|
1530
|
+
if (part.type === 'text') parts.push({ type: 'text', text: text(part.text) });
|
|
1531
|
+
else if (part.type === 'file') { parts.push({ type: 'file', mime: part.mime, filename: text(part.filename) }); partial = true; }
|
|
1532
|
+
else if (part.type === 'tool') {
|
|
1533
|
+
parts.push({ type: 'tool', tool: text(part.tool), status: part.state?.status, output: text(part.state?.output), error: text(part.state?.error) });
|
|
1534
|
+
partial = true; // Inputs and binary details are intentionally omitted.
|
|
1535
|
+
} else if (part.type !== 'step-start' && part.type !== 'step-finish') partial = true;
|
|
1536
|
+
}
|
|
1537
|
+
return { messageID: info.id, role: info.role, parts };
|
|
1538
|
+
});
|
|
1539
|
+
const cursor = result.response?.headers?.get('x-next-cursor');
|
|
1540
|
+
const nextCursor = typeof cursor === 'string' && cursor ? cursor : null;
|
|
1541
|
+
return { sessionID: resolved.sessionID, directory: resolved.directory, title: resolved.title.slice(0, 500), messages, nextCursor, partial: partial || nextCursor !== null };
|
|
1542
|
+
};
|
|
1100
1543
|
const assignWork = async (row, params = {}) => {
|
|
1101
1544
|
const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
|
|
1102
1545
|
const signal = params.signal;
|
|
@@ -1110,6 +1553,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1110
1553
|
const reuseSessionID = typeof params.sessionID === 'string' && params.sessionID.trim()
|
|
1111
1554
|
? params.sessionID.trim()
|
|
1112
1555
|
: '';
|
|
1556
|
+
// Reuse: resolve directory/project from authoritative session metadata first.
|
|
1557
|
+
// User projectPath/directory are hints only and never override scope.
|
|
1558
|
+
let reuseScope = null;
|
|
1559
|
+
if (reuseSessionID) {
|
|
1560
|
+
const directoryHint = typeof params.directory === 'string' && params.directory.trim()
|
|
1561
|
+
? params.directory.trim()
|
|
1562
|
+
: (typeof params.projectPath === 'string' && params.projectPath.trim()
|
|
1563
|
+
? params.projectPath.trim()
|
|
1564
|
+
: null);
|
|
1565
|
+
reuseScope = await resolveReferencedSession({
|
|
1566
|
+
sessionID: reuseSessionID,
|
|
1567
|
+
directoryHint,
|
|
1568
|
+
titleHint: typeof params.title === 'string' ? params.title : null,
|
|
1569
|
+
signal,
|
|
1570
|
+
// Messages only needed when following session model without explicit selection.
|
|
1571
|
+
includeMessages: !explicitModel,
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1113
1574
|
// Reused session without explicit model may follow the session's last model (catalog-gated).
|
|
1114
1575
|
const followSessionModel = Boolean(reuseSessionID && !explicitModel);
|
|
1115
1576
|
const needsCatalog = explicitModel || hasAssignImageParts(fileParts) || followSessionModel;
|
|
@@ -1127,17 +1588,18 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1127
1588
|
catalog = await loadAssignCatalog(signal);
|
|
1128
1589
|
}
|
|
1129
1590
|
}
|
|
1130
|
-
if (followSessionModel && catalog) {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
:
|
|
1134
|
-
? params.projectPath.trim()
|
|
1135
|
-
: null);
|
|
1136
|
-
sessionModel = await lookupReusedSessionModel({
|
|
1137
|
-
sessionID: reuseSessionID,
|
|
1138
|
-
directory: directoryHint,
|
|
1139
|
-
signal,
|
|
1591
|
+
if (followSessionModel && catalog && reuseScope) {
|
|
1592
|
+
sessionModel = extractAssignSessionModel({
|
|
1593
|
+
session: reuseScope.session,
|
|
1594
|
+
messages: reuseScope.messages,
|
|
1140
1595
|
});
|
|
1596
|
+
if (!sessionModel) {
|
|
1597
|
+
sessionModel = await lookupReusedSessionModel({
|
|
1598
|
+
sessionID: reuseSessionID,
|
|
1599
|
+
directory: reuseScope.directory,
|
|
1600
|
+
signal,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1141
1603
|
}
|
|
1142
1604
|
const workerModel = resolveAssignWorkerModel({
|
|
1143
1605
|
providerID: params.providerID,
|
|
@@ -1158,6 +1620,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1158
1620
|
// Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
|
|
1159
1621
|
return assignSession({
|
|
1160
1622
|
...params,
|
|
1623
|
+
...(reuseScope ? {
|
|
1624
|
+
sessionID: reuseScope.sessionID,
|
|
1625
|
+
directory: reuseScope.directory,
|
|
1626
|
+
projectPath: reuseScope.directory,
|
|
1627
|
+
// Drop untrusted branch when reusing — directory is already authoritative.
|
|
1628
|
+
branch: undefined,
|
|
1629
|
+
title: typeof params.title === 'string' && params.title.trim()
|
|
1630
|
+
? params.title.trim()
|
|
1631
|
+
: reuseScope.title,
|
|
1632
|
+
} : {}),
|
|
1161
1633
|
fileParts,
|
|
1162
1634
|
...(params.variant !== undefined ? { variant: params.variant } : {}),
|
|
1163
1635
|
...(signal ? { signal } : {}),
|
|
@@ -1184,6 +1656,160 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1184
1656
|
messageID: params?.messageID || `msg_assign_${id()}`,
|
|
1185
1657
|
});
|
|
1186
1658
|
};
|
|
1659
|
+
/** Watch an existing session only — no prompt. Baseline status avoids false settle resume. */
|
|
1660
|
+
const watchSessionWork = async (_row, params = {}) => {
|
|
1661
|
+
const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
|
|
1662
|
+
if (!sessionID) {
|
|
1663
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'watch_session requires sessionID.');
|
|
1664
|
+
}
|
|
1665
|
+
const resolved = await resolveReferencedSession({
|
|
1666
|
+
sessionID,
|
|
1667
|
+
// directory/projectPath from the model are never trusted as scope authority.
|
|
1668
|
+
directoryHint: null,
|
|
1669
|
+
titleHint: typeof params.title === 'string' ? params.title : null,
|
|
1670
|
+
signal: params.signal,
|
|
1671
|
+
includeMessages: true,
|
|
1672
|
+
});
|
|
1673
|
+
return {
|
|
1674
|
+
sessionID: resolved.sessionID,
|
|
1675
|
+
directory: resolved.directory,
|
|
1676
|
+
title: resolved.title,
|
|
1677
|
+
status: resolved.status,
|
|
1678
|
+
watched: true,
|
|
1679
|
+
reused: true,
|
|
1680
|
+
};
|
|
1681
|
+
};
|
|
1682
|
+
/** Abort an existing coding session via real OpenCode session.abort. */
|
|
1683
|
+
const stopSessionWork = async (_row, params = {}) => {
|
|
1684
|
+
const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
|
|
1685
|
+
if (!sessionID) {
|
|
1686
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'stop_session requires sessionID.');
|
|
1687
|
+
}
|
|
1688
|
+
const resolved = await resolveReferencedSession({
|
|
1689
|
+
sessionID,
|
|
1690
|
+
directoryHint: null,
|
|
1691
|
+
signal: params.signal,
|
|
1692
|
+
// Abort does not need messages for baseline.
|
|
1693
|
+
includeMessages: false,
|
|
1694
|
+
});
|
|
1695
|
+
const signal = params.signal;
|
|
1696
|
+
signal?.throwIfAborted();
|
|
1697
|
+
if (typeof client().session.abort !== 'function') {
|
|
1698
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session abort is unavailable.');
|
|
1699
|
+
}
|
|
1700
|
+
const targetID = resolved.sessionID;
|
|
1701
|
+
// Share one abort lifecycle per worker session (register before await abort).
|
|
1702
|
+
const existing = sessionAbortInflight.get(targetID);
|
|
1703
|
+
if (existing?.promise) {
|
|
1704
|
+
const shared = await existing.promise;
|
|
1705
|
+
if (shared?.ok) {
|
|
1706
|
+
return {
|
|
1707
|
+
sessionID: targetID,
|
|
1708
|
+
directory: resolved.directory,
|
|
1709
|
+
title: resolved.title,
|
|
1710
|
+
aborted: true,
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
throw shared?.error || new AssignError(ASSIGN_CODES.UPSTREAM, 'Failed to abort that coding session.');
|
|
1714
|
+
}
|
|
1715
|
+
const entry = { sessionID: targetID, observed: null, promise: null };
|
|
1716
|
+
sessionAbortInflight.set(targetID, entry);
|
|
1717
|
+
const failAbort = (code, message) => new AssignError(
|
|
1718
|
+
code,
|
|
1719
|
+
typeof message === 'string' && message.trim() ? message.trim() : 'Failed to abort that coding session.',
|
|
1720
|
+
);
|
|
1721
|
+
entry.promise = (async () => {
|
|
1722
|
+
const sdkOptions = signal ? { signal } : undefined;
|
|
1723
|
+
let result;
|
|
1724
|
+
try {
|
|
1725
|
+
signal?.throwIfAborted();
|
|
1726
|
+
result = await client().session.abort({
|
|
1727
|
+
sessionID: targetID,
|
|
1728
|
+
directory: resolved.directory,
|
|
1729
|
+
}, sdkOptions);
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
const observed = entry.observed;
|
|
1732
|
+
if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
|
|
1733
|
+
if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
|
|
1734
|
+
const wrapped = error?.name === 'AbortError' || signal?.aborted
|
|
1735
|
+
? error
|
|
1736
|
+
: failAbort(
|
|
1737
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1738
|
+
typeof error?.message === 'string' ? error.message : '',
|
|
1739
|
+
);
|
|
1740
|
+
return { ok: false, error: wrapped };
|
|
1741
|
+
}
|
|
1742
|
+
if (isMissing(result)) {
|
|
1743
|
+
const observed = entry.observed;
|
|
1744
|
+
if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
|
|
1745
|
+
if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
|
|
1746
|
+
return { ok: false, error: failAbort(ASSIGN_CODES.NOT_FOUND, `No coding session ${targetID} was found.`) };
|
|
1747
|
+
}
|
|
1748
|
+
if (result?.error || result?.data === false) {
|
|
1749
|
+
const observed = entry.observed;
|
|
1750
|
+
if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
|
|
1751
|
+
if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
|
|
1752
|
+
return {
|
|
1753
|
+
ok: false,
|
|
1754
|
+
error: failAbort(
|
|
1755
|
+
ASSIGN_CODES.UPSTREAM,
|
|
1756
|
+
typeof result.error?.message === 'string' ? result.error.message : '',
|
|
1757
|
+
),
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
// Success (including silent abort with no SSE): release map first so reporter applies.
|
|
1761
|
+
if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
|
|
1762
|
+
cancelAssignedResumes({ sessionID: targetID });
|
|
1763
|
+
// Assistant already owns this user turn; do not schedule a second cancelled notify.
|
|
1764
|
+
reportAssignedSession(targetID, 'cancelled', false);
|
|
1765
|
+
return { ok: true };
|
|
1766
|
+
})();
|
|
1767
|
+
try {
|
|
1768
|
+
const outcome = await entry.promise;
|
|
1769
|
+
if (outcome?.ok) {
|
|
1770
|
+
return {
|
|
1771
|
+
sessionID: targetID,
|
|
1772
|
+
directory: resolved.directory,
|
|
1773
|
+
title: resolved.title,
|
|
1774
|
+
aborted: true,
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
throw outcome?.error || failAbort(ASSIGN_CODES.UPSTREAM, '');
|
|
1778
|
+
} finally {
|
|
1779
|
+
if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
// Resolve scope from the authoritative session, never model-supplied directories.
|
|
1783
|
+
const mutateSessionWork = async (operation, params = {}) => {
|
|
1784
|
+
const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
|
|
1785
|
+
if (!sessionID) throw new AssignError(ASSIGN_CODES.VALIDATION, `${operation} requires sessionID.`);
|
|
1786
|
+
if (operation === 'steer' && (typeof params.text !== 'string' || !params.text.trim())) {
|
|
1787
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, 'steer requires text.');
|
|
1788
|
+
}
|
|
1789
|
+
const resolved = await resolveReferencedSession({ sessionID, signal: params.signal, includeMessages: false });
|
|
1790
|
+
params.signal?.throwIfAborted();
|
|
1791
|
+
const session = client().session;
|
|
1792
|
+
const method = operation === 'steer' ? 'promptAsync' : operation === 'archive' ? 'update' : 'delete';
|
|
1793
|
+
if (typeof session[method] !== 'function') throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} is unavailable.`);
|
|
1794
|
+
const request = { sessionID, directory: resolved.directory };
|
|
1795
|
+
if (operation === 'steer') Object.assign(request, {
|
|
1796
|
+
messageID: `msg_steer_${id()}`, delivery: 'steer', parts: [{ type: 'text', text: params.text.trim() }],
|
|
1797
|
+
});
|
|
1798
|
+
if (operation === 'archive') request.time = { archived: now() };
|
|
1799
|
+
let result;
|
|
1800
|
+
try {
|
|
1801
|
+
result = await session[method](request, params.signal ? { signal: params.signal } : undefined);
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
|
|
1804
|
+
}
|
|
1805
|
+
if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
|
|
1806
|
+
if (result?.error || result?.data === false || (operation === 'steer' && !promptAdmitted(result))) {
|
|
1807
|
+
// Do not retry an ambiguous prompt submission: it may already be admitted upstream.
|
|
1808
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
|
|
1809
|
+
}
|
|
1810
|
+
if (operation !== 'steer') reportAssignedSession(sessionID, 'cancelled');
|
|
1811
|
+
return { sessionID, directory: resolved.directory, operation, ...(operation === 'steer' ? { messageID: request.messageID, admitted: true } : { [operation === 'archive' ? 'archived' : 'deleted']: true }) };
|
|
1812
|
+
};
|
|
1187
1813
|
const matchRegisteredProject = async (directory) => {
|
|
1188
1814
|
const projects = await listProjects();
|
|
1189
1815
|
const resolved = path.resolve(directory);
|
|
@@ -1457,6 +2083,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1457
2083
|
`sessionID: ${sessionID}`,
|
|
1458
2084
|
`status: ${status}`,
|
|
1459
2085
|
];
|
|
2086
|
+
if (status === 'cancelled') {
|
|
2087
|
+
lines.push(
|
|
2088
|
+
'reason: user_interrupted',
|
|
2089
|
+
'The user manually interrupted this assigned worker session (OpenCode session.abort / MessageAbortedError). This is not a model failure and not an assistant-initiated stop_session.',
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
1460
2092
|
if (title) lines.push(`cardTitle: ${title}`);
|
|
1461
2093
|
if (workerText) {
|
|
1462
2094
|
lines.push('lastWorkerAssistantText (bounded):');
|
|
@@ -1466,19 +2098,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1466
2098
|
}
|
|
1467
2099
|
lines.push(
|
|
1468
2100
|
'Instructions:',
|
|
1469
|
-
'- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed".',
|
|
1470
|
-
'-
|
|
1471
|
-
'-
|
|
2101
|
+
'- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed" / "会话已取消" / "Session cancelled".',
|
|
2102
|
+
'- This is a read-only result notification, never authorization to create, restart, continue, steer, or otherwise mutate any session or file.',
|
|
2103
|
+
'- Respect the latest user instructions in history, including stop/cancel. If work remains, report it and wait for an explicit user request.',
|
|
1472
2104
|
);
|
|
2105
|
+
if (status === 'cancelled') {
|
|
2106
|
+
lines.push(
|
|
2107
|
+
'- The user interrupted this worker themselves. In the user\'s language, acknowledge that interruption and decide the next spoken step (usually stop here and wait). Do not restart the worker unless history already asked you to.',
|
|
2108
|
+
);
|
|
2109
|
+
} else {
|
|
2110
|
+
lines.push(
|
|
2111
|
+
'- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
1473
2114
|
return lines.join('\n');
|
|
1474
2115
|
};
|
|
1475
2116
|
/**
|
|
1476
|
-
* After assigned worker complete/error: async contact-lane continuation.
|
|
2117
|
+
* After assigned worker complete/error/cancelled: async contact-lane continuation.
|
|
1477
2118
|
* No user transcript row — only internal runContactTurn userText + assistant bubbles/cards.
|
|
1478
2119
|
*/
|
|
1479
2120
|
const scheduleAssignedSessionResume = ({ assistantID, sessionID, status, directory, updatedAt }) => {
|
|
1480
2121
|
if (closed) return;
|
|
1481
|
-
if (status
|
|
2122
|
+
if (!assignedSessionResumeStatus(status)) return;
|
|
1482
2123
|
const row = assistant(assistantID);
|
|
1483
2124
|
if (!row || row.tombstone_at) return;
|
|
1484
2125
|
const messageID = assignedSessionResumeMessageID(assistantID, sessionID, status, updatedAt);
|
|
@@ -1489,6 +2130,15 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1489
2130
|
|| getContactMessage(db, `${messageID}:error`)
|
|
1490
2131
|
) return;
|
|
1491
2132
|
|
|
2133
|
+
if (assignedResumes.has(messageID)) return;
|
|
2134
|
+
const controller = new AbortController();
|
|
2135
|
+
assignedResumes.set(messageID, { assistantID, sessionID, status, controller });
|
|
2136
|
+
const finishCancelled = () => {
|
|
2137
|
+
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
2138
|
+
emitContactTurnEvent('openchamber:contact-turn-end', { assistantID, turnID, status: 'cancelled' });
|
|
2139
|
+
resolveContactTurnSettlement(messageID, { status: 'cancelled' });
|
|
2140
|
+
};
|
|
2141
|
+
|
|
1492
2142
|
rememberActiveContactTurn(assistantID, {
|
|
1493
2143
|
turnID,
|
|
1494
2144
|
messageID,
|
|
@@ -1505,6 +2155,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1505
2155
|
contactTurnSettlement(messageID);
|
|
1506
2156
|
|
|
1507
2157
|
void inContactTurnLane(assistantID, async () => {
|
|
2158
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1508
2159
|
if (closed) {
|
|
1509
2160
|
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
1510
2161
|
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
@@ -1532,13 +2183,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1532
2183
|
} catch {
|
|
1533
2184
|
registeredProjects = [];
|
|
1534
2185
|
}
|
|
1535
|
-
|
|
1536
|
-
try {
|
|
1537
|
-
const catalog = await loadAssignCatalog(undefined);
|
|
1538
|
-
connectedModels = normalizeConnectedModels(catalog?.models);
|
|
1539
|
-
} catch {
|
|
1540
|
-
connectedModels = [];
|
|
1541
|
-
}
|
|
2186
|
+
const modelContext = await loadContactModelContext(controller.signal);
|
|
1542
2187
|
|
|
1543
2188
|
let workerText = '';
|
|
1544
2189
|
try {
|
|
@@ -1560,6 +2205,13 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1560
2205
|
const rows = Array.isArray(messagesResult?.data)
|
|
1561
2206
|
? messagesResult.data
|
|
1562
2207
|
: (Array.isArray(messagesResult) ? messagesResult : []);
|
|
2208
|
+
// complete/error that discovers a user abort becomes cancelled + notify.
|
|
2209
|
+
// A cancelled resume must still run with reason: user_interrupted.
|
|
2210
|
+
if (status !== 'cancelled' && isUserAbort(lastAssistantInfo(rows)?.error)) {
|
|
2211
|
+
reportAssignedSession(sessionID, 'cancelled');
|
|
2212
|
+
finishCancelled();
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
1563
2215
|
workerText = extractLastWorkerAssistantText(rows);
|
|
1564
2216
|
}
|
|
1565
2217
|
}
|
|
@@ -1567,6 +2219,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1567
2219
|
workerText = '';
|
|
1568
2220
|
}
|
|
1569
2221
|
|
|
2222
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1570
2223
|
const title = findAssignedSessionCardTitle(assistantID, sessionID);
|
|
1571
2224
|
const userText = buildAssignedSessionResumeUserText({
|
|
1572
2225
|
sessionID,
|
|
@@ -1575,9 +2228,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1575
2228
|
workerText,
|
|
1576
2229
|
});
|
|
1577
2230
|
const history = contactHistoryForLlm(db, assistantID, {});
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
2231
|
+
const assignedCards = [];
|
|
2232
|
+
const spokenByIndex = new Map();
|
|
2233
|
+
let contactResetThisTurn = false;
|
|
2234
|
+
let contactHistoryClearedThisTurn = false;
|
|
2235
|
+
const markContactReset = (historyCleared = false) => {
|
|
2236
|
+
contactResetThisTurn = true;
|
|
2237
|
+
if (historyCleared) contactHistoryClearedThisTurn = true;
|
|
2238
|
+
spokenByIndex.clear();
|
|
2239
|
+
assignedCards.length = 0;
|
|
2240
|
+
};
|
|
1581
2241
|
|
|
1582
2242
|
try {
|
|
1583
2243
|
const executionHistory = await materializeContactHistory(
|
|
@@ -1589,23 +2249,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1589
2249
|
);
|
|
1590
2250
|
const tools = createContactTools({
|
|
1591
2251
|
assignWork: (params) => assignWork(live, params),
|
|
2252
|
+
watchSession: (params) => watchSessionWork(live, params),
|
|
2253
|
+
stopSession: (params) => stopSessionWork(live, params),
|
|
2254
|
+
steerSession: (params) => mutateSessionWork('steer', params),
|
|
2255
|
+
archiveSession: (params) => mutateSessionWork('archive', params),
|
|
2256
|
+
deleteSession: (params) => mutateSessionWork('delete', params),
|
|
1592
2257
|
createAssistant: (toolInput) => createAssistant(toolInput),
|
|
1593
2258
|
scheduleTask: (params) => scheduleWork(live, params),
|
|
1594
2259
|
deliverPeerMessage: (toolInput) => deliverPeerMessage(assistantID, toolInput),
|
|
1595
2260
|
clearContactMemory: () => {
|
|
1596
2261
|
const result = clearContactMemory(assistantID, {});
|
|
1597
|
-
|
|
2262
|
+
markContactReset(false);
|
|
1598
2263
|
return result;
|
|
1599
2264
|
},
|
|
1600
2265
|
resetContact: () => {
|
|
1601
2266
|
const result = resetContact(assistantID, {});
|
|
1602
|
-
|
|
1603
|
-
contactHistoryClearedThisTurn = true;
|
|
2267
|
+
markContactReset(true);
|
|
1604
2268
|
return result;
|
|
1605
2269
|
},
|
|
1606
2270
|
listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
|
|
1607
2271
|
listProjects: async () => loadRegisteredProjects(),
|
|
1608
2272
|
listSessions: (params) => listSessionsWork(params),
|
|
2273
|
+
readSession: (params) => readSessionWork(params),
|
|
1609
2274
|
readAssistantSettings: (input = {}) => {
|
|
1610
2275
|
const targetID = typeof input?.assistantID === 'string' && input.assistantID.trim()
|
|
1611
2276
|
? input.assistantID.trim()
|
|
@@ -1655,30 +2320,37 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1655
2320
|
throw lastError;
|
|
1656
2321
|
},
|
|
1657
2322
|
currentAssistant: assistantSnapshot,
|
|
1658
|
-
onCard: (card) =>
|
|
2323
|
+
onCard: (card) => {
|
|
2324
|
+
if (contactResetThisTurn) return;
|
|
2325
|
+
rememberPublishedContactCard(assistantID, {
|
|
2326
|
+
userMessageID: messageID, turnID, assignedCards, card,
|
|
2327
|
+
});
|
|
2328
|
+
},
|
|
1659
2329
|
turnFileParts: [],
|
|
1660
2330
|
turnAttachmentScope: attachmentScopeKey([]),
|
|
1661
2331
|
});
|
|
2332
|
+
controller.signal.throwIfAborted();
|
|
1662
2333
|
const generated = await runContactTurn({
|
|
2334
|
+
signal: controller.signal,
|
|
2335
|
+
readOnly: true,
|
|
1663
2336
|
assistant: assistantSnapshot,
|
|
1664
2337
|
history: executionHistory,
|
|
1665
2338
|
userText,
|
|
1666
2339
|
userParts: [{ type: 'text', text: userText }],
|
|
2340
|
+
language: contactTurnLanguages.get(assistantID) ?? '',
|
|
1667
2341
|
createChatCompletion,
|
|
1668
2342
|
tools,
|
|
1669
2343
|
projects: registeredProjects,
|
|
1670
|
-
|
|
2344
|
+
...modelContext,
|
|
1671
2345
|
globalEventHub,
|
|
1672
2346
|
onBubbleDelta: (bubbleIndex, delta, done) => {
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
turnID,
|
|
1676
|
-
bubbleIndex,
|
|
1677
|
-
delta: typeof delta === 'string' ? delta : '',
|
|
1678
|
-
done: Boolean(done),
|
|
2347
|
+
if (contactResetThisTurn) return;
|
|
2348
|
+
publishContactBubbleDelta({
|
|
2349
|
+
assistantID, turnID, userMessageID: messageID, spokenByIndex, bubbleIndex, delta, done, signal: controller.signal,
|
|
1679
2350
|
});
|
|
1680
2351
|
},
|
|
1681
2352
|
});
|
|
2353
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1682
2354
|
if (closed) {
|
|
1683
2355
|
settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
|
|
1684
2356
|
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
@@ -1734,6 +2406,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1734
2406
|
bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
|
|
1735
2407
|
cards,
|
|
1736
2408
|
turnID,
|
|
2409
|
+
reset: resetThisTurn,
|
|
1737
2410
|
});
|
|
1738
2411
|
settleActiveContactTurn(assistantID, turnID);
|
|
1739
2412
|
emitContactTurnEvent('openchamber:contact-turn-end', {
|
|
@@ -1751,6 +2424,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1751
2424
|
});
|
|
1752
2425
|
resolveContactTurnSettlement(messageID, { status: 'complete' });
|
|
1753
2426
|
} catch (error) {
|
|
2427
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1754
2428
|
const detail = typeof error?.message === 'string' && error.message.trim()
|
|
1755
2429
|
? error.message.trim()
|
|
1756
2430
|
: (error?.code || 'upstream_error');
|
|
@@ -1781,7 +2455,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1781
2455
|
});
|
|
1782
2456
|
resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
|
|
1783
2457
|
}
|
|
1784
|
-
});
|
|
2458
|
+
}).finally(() => { assignedResumes.delete(messageID); });
|
|
1785
2459
|
};
|
|
1786
2460
|
assignedSessionResumeRef.schedule = scheduleAssignedSessionResume;
|
|
1787
2461
|
/**
|
|
@@ -1795,7 +2469,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1795
2469
|
const messageID = string(input.messageID, 256, true);
|
|
1796
2470
|
const userText = extractUserText(input);
|
|
1797
2471
|
if (!userText) fail('validation_error');
|
|
2472
|
+
const language = typeof input.language === 'string' ? input.language.trim().slice(0, 64) : '';
|
|
1798
2473
|
const row = active(assistantID);
|
|
2474
|
+
if (language) contactTurnLanguages.set(row.assistant_id, language);
|
|
1799
2475
|
const userParts = await userContactParts(row.assistant_id, input, userText);
|
|
1800
2476
|
if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
|
|
1801
2477
|
let registeredProjects = [];
|
|
@@ -1825,12 +2501,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1825
2501
|
replayed: true,
|
|
1826
2502
|
};
|
|
1827
2503
|
}
|
|
2504
|
+
cancelAssignedResumes({ assistantID: row.assistant_id });
|
|
2505
|
+
// A new user turn supersedes pending background notifications as well as running ones.
|
|
2506
|
+
// Explicit assign/watch admission rearms only its own watch. Keep live card status intact.
|
|
2507
|
+
db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(row.assistant_id);
|
|
1828
2508
|
admitContactUser(row.assistant_id, {
|
|
1829
2509
|
userMessageID: messageID,
|
|
1830
2510
|
userText,
|
|
1831
2511
|
userParts,
|
|
1832
2512
|
turnID,
|
|
1833
2513
|
});
|
|
2514
|
+
const controller = new AbortController();
|
|
2515
|
+
contactControllers.set(messageID, { assistantID: row.assistant_id, controller });
|
|
2516
|
+
const finishCancelled = () => {
|
|
2517
|
+
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
2518
|
+
emitContactTurnEvent('openchamber:contact-turn-end', { assistantID: row.assistant_id, turnID, status: 'cancelled' });
|
|
2519
|
+
resolveContactTurnSettlement(messageID, { status: 'cancelled' });
|
|
2520
|
+
contactControllers.delete(messageID);
|
|
2521
|
+
};
|
|
1834
2522
|
rememberActiveContactTurn(row.assistant_id, {
|
|
1835
2523
|
turnID,
|
|
1836
2524
|
messageID,
|
|
@@ -1845,6 +2533,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1845
2533
|
contactTurnSettlement(messageID);
|
|
1846
2534
|
const kickTurn = () => {
|
|
1847
2535
|
void inContactTurnLane(row.assistant_id, async () => {
|
|
2536
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
1848
2537
|
if (closed) {
|
|
1849
2538
|
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
1850
2539
|
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
@@ -1865,17 +2554,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1865
2554
|
beforeOrdinal,
|
|
1866
2555
|
});
|
|
1867
2556
|
const assignedCards = [];
|
|
2557
|
+
const spokenByIndex = new Map();
|
|
1868
2558
|
let contactResetThisTurn = false;
|
|
1869
2559
|
let contactHistoryClearedThisTurn = false;
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
connectedModels = [];
|
|
1878
|
-
}
|
|
2560
|
+
const markContactReset = (historyCleared = false) => {
|
|
2561
|
+
contactResetThisTurn = true;
|
|
2562
|
+
if (historyCleared) contactHistoryClearedThisTurn = true;
|
|
2563
|
+
spokenByIndex.clear();
|
|
2564
|
+
assignedCards.length = 0;
|
|
2565
|
+
};
|
|
2566
|
+
const modelContext = await loadContactModelContext(controller.signal);
|
|
1879
2567
|
try {
|
|
1880
2568
|
// Materialize inside turn try so failures durable-error + settle working.
|
|
1881
2569
|
// DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
|
|
@@ -1900,6 +2588,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1900
2588
|
const turnAttachmentScope = attachmentScopeKey(turnFileParts);
|
|
1901
2589
|
const tools = createContactTools({
|
|
1902
2590
|
assignWork: (params) => assignWork(row, params),
|
|
2591
|
+
watchSession: (params) => watchSessionWork(row, params),
|
|
2592
|
+
stopSession: (params) => stopSessionWork(row, params),
|
|
2593
|
+
steerSession: (params) => mutateSessionWork('steer', params),
|
|
2594
|
+
archiveSession: (params) => mutateSessionWork('archive', params),
|
|
2595
|
+
deleteSession: (params) => mutateSessionWork('delete', params),
|
|
1903
2596
|
createAssistant: (toolInput) => createAssistant(toolInput),
|
|
1904
2597
|
scheduleTask: (params) => scheduleWork(row, params),
|
|
1905
2598
|
deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
|
|
@@ -1909,7 +2602,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1909
2602
|
const result = clearContactMemory(row.assistant_id, {
|
|
1910
2603
|
upToOrdinal: beforeOrdinal,
|
|
1911
2604
|
});
|
|
1912
|
-
|
|
2605
|
+
markContactReset(false);
|
|
1913
2606
|
return result;
|
|
1914
2607
|
},
|
|
1915
2608
|
resetContact: () => {
|
|
@@ -1918,13 +2611,13 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1918
2611
|
const result = resetContact(row.assistant_id, {
|
|
1919
2612
|
upToOrdinal: beforeOrdinal,
|
|
1920
2613
|
});
|
|
1921
|
-
|
|
1922
|
-
contactHistoryClearedThisTurn = true;
|
|
2614
|
+
markContactReset(true);
|
|
1923
2615
|
return result;
|
|
1924
2616
|
},
|
|
1925
2617
|
listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
|
|
1926
2618
|
listProjects: async () => loadRegisteredProjects(),
|
|
1927
2619
|
listSessions: (params) => listSessionsWork(params),
|
|
2620
|
+
readSession: (params) => readSessionWork(params),
|
|
1928
2621
|
// Live DB read — not the turn-start assistantSnapshot.
|
|
1929
2622
|
// Optional assistantID targets another live row; omitted → this contact.
|
|
1930
2623
|
readAssistantSettings: (input = {}) => {
|
|
@@ -1978,30 +2671,36 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1978
2671
|
throw lastError;
|
|
1979
2672
|
},
|
|
1980
2673
|
currentAssistant: assistantSnapshot,
|
|
1981
|
-
onCard: (card) =>
|
|
2674
|
+
onCard: (card) => {
|
|
2675
|
+
if (contactResetThisTurn) return;
|
|
2676
|
+
rememberPublishedContactCard(row.assistant_id, {
|
|
2677
|
+
userMessageID: messageID, turnID, assignedCards, card,
|
|
2678
|
+
});
|
|
2679
|
+
},
|
|
1982
2680
|
turnFileParts,
|
|
1983
2681
|
turnAttachmentScope,
|
|
1984
2682
|
});
|
|
2683
|
+
controller.signal.throwIfAborted();
|
|
1985
2684
|
const generated = await runContactTurn({
|
|
2685
|
+
signal: controller.signal,
|
|
1986
2686
|
assistant: assistantSnapshot,
|
|
1987
2687
|
history: executionHistory,
|
|
1988
2688
|
userText,
|
|
1989
2689
|
userParts: executionParts,
|
|
2690
|
+
language,
|
|
1990
2691
|
createChatCompletion,
|
|
1991
2692
|
tools,
|
|
1992
2693
|
projects: registeredProjects,
|
|
1993
|
-
|
|
2694
|
+
...modelContext,
|
|
1994
2695
|
globalEventHub,
|
|
1995
2696
|
onBubbleDelta: (bubbleIndex, delta, done) => {
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
turnID,
|
|
1999
|
-
bubbleIndex,
|
|
2000
|
-
delta: typeof delta === 'string' ? delta : '',
|
|
2001
|
-
done: Boolean(done),
|
|
2697
|
+
if (contactResetThisTurn) return;
|
|
2698
|
+
publishContactBubbleDelta({
|
|
2699
|
+
assistantID: row.assistant_id, turnID, userMessageID: messageID, spokenByIndex, bubbleIndex, delta, done, signal: controller.signal,
|
|
2002
2700
|
});
|
|
2003
2701
|
},
|
|
2004
2702
|
});
|
|
2703
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
2005
2704
|
if (closed) {
|
|
2006
2705
|
settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
|
|
2007
2706
|
resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
|
|
@@ -2070,6 +2769,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2070
2769
|
bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
|
|
2071
2770
|
cards,
|
|
2072
2771
|
turnID,
|
|
2772
|
+
reset: resetThisTurn,
|
|
2073
2773
|
});
|
|
2074
2774
|
// persistContactAssistantReply already bumps; clear activity without a second tip.
|
|
2075
2775
|
settleActiveContactTurn(row.assistant_id, turnID);
|
|
@@ -2088,6 +2788,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2088
2788
|
});
|
|
2089
2789
|
resolveContactTurnSettlement(messageID, { status: 'complete' });
|
|
2090
2790
|
} catch (error) {
|
|
2791
|
+
if (controller.signal.aborted) { finishCancelled(); return; }
|
|
2091
2792
|
const detail = typeof error?.message === 'string' && error.message.trim()
|
|
2092
2793
|
? error.message.trim()
|
|
2093
2794
|
: (error?.code || 'upstream_error');
|
|
@@ -2117,6 +2818,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2117
2818
|
body: detail,
|
|
2118
2819
|
});
|
|
2119
2820
|
resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
|
|
2821
|
+
} finally {
|
|
2822
|
+
contactControllers.delete(messageID);
|
|
2120
2823
|
}
|
|
2121
2824
|
});
|
|
2122
2825
|
};
|
|
@@ -2196,6 +2899,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2196
2899
|
deleteContactMessages(db, row.assistant_id, {
|
|
2197
2900
|
...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
|
|
2198
2901
|
});
|
|
2902
|
+
// Rebind read watermark to the new generation at empty cursor so post-wipe
|
|
2903
|
+
// confirm bubbles can surface as unread; stale generation mark-read fails closed.
|
|
2904
|
+
resetContactReadWatermarkForGeneration(db, row.assistant_id, generation, { updatedAt: now() });
|
|
2199
2905
|
// Active contact turns stay until settle — wipe must not drop the green
|
|
2200
2906
|
// dot while the clearing turn is still running.
|
|
2201
2907
|
bump();
|
|
@@ -2206,6 +2912,48 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2206
2912
|
throw error;
|
|
2207
2913
|
}
|
|
2208
2914
|
};
|
|
2915
|
+
/**
|
|
2916
|
+
* Advance the shared contact read watermark (multi-client).
|
|
2917
|
+
* Body: { generation, ordinal, messageID } — must match live generation;
|
|
2918
|
+
* only monotonic forward moves bump revision / assistants-changed.
|
|
2919
|
+
* Idempotent no-op when unchanged (no broadcast).
|
|
2920
|
+
*/
|
|
2921
|
+
const markContactRead = (assistantID, input = {}) => {
|
|
2922
|
+
const row = editable(assistantID);
|
|
2923
|
+
if (!plainObject(input)) fail('validation_error');
|
|
2924
|
+
db.exec('BEGIN IMMEDIATE');
|
|
2925
|
+
try {
|
|
2926
|
+
let advanced;
|
|
2927
|
+
try {
|
|
2928
|
+
advanced = advanceContactReadWatermark(db, row.assistant_id, {
|
|
2929
|
+
generation: input.generation,
|
|
2930
|
+
ordinal: input.ordinal,
|
|
2931
|
+
messageID: input.messageID,
|
|
2932
|
+
}, { updatedAt: now() });
|
|
2933
|
+
} catch (error) {
|
|
2934
|
+
if (error?.code === 'contact_generation_conflict' || error?.code === 'validation_error') {
|
|
2935
|
+
fail(error.code, error.message);
|
|
2936
|
+
}
|
|
2937
|
+
throw error;
|
|
2938
|
+
}
|
|
2939
|
+
let tipRevision = revision();
|
|
2940
|
+
if (advanced.changed) {
|
|
2941
|
+
tipRevision = bump();
|
|
2942
|
+
}
|
|
2943
|
+
db.exec('COMMIT');
|
|
2944
|
+
return {
|
|
2945
|
+
assistantID: row.assistant_id,
|
|
2946
|
+
changed: advanced.changed,
|
|
2947
|
+
unreadCount: advanced.unreadCount,
|
|
2948
|
+
readWatermark: advanced.readWatermark,
|
|
2949
|
+
readTip: advanced.readTip,
|
|
2950
|
+
revision: tipRevision,
|
|
2951
|
+
};
|
|
2952
|
+
} catch (error) {
|
|
2953
|
+
db.exec('ROLLBACK');
|
|
2954
|
+
throw error;
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2209
2957
|
const appendContactCard = (assistantID, input) => {
|
|
2210
2958
|
const row = active(assistantID);
|
|
2211
2959
|
const card = parseContactCard({ type: 'card', ...input, cardType: input?.cardType || 'session' });
|
|
@@ -2317,7 +3065,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2317
3065
|
};
|
|
2318
3066
|
return mode === 'stateless' ? inStatelessLane(deliveryTarget.assistantID, submit) : submit();
|
|
2319
3067
|
};
|
|
2320
|
-
const abort = async (assistantID, input) => {
|
|
3068
|
+
const abort = async (assistantID, input) => {
|
|
3069
|
+
const row = active(assistantID);
|
|
3070
|
+
if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation) fail('revision_conflict');
|
|
3071
|
+
let interrupted = false;
|
|
3072
|
+
for (const turn of contactControllers.values()) {
|
|
3073
|
+
if (turn.assistantID !== assistantID) continue;
|
|
3074
|
+
turn.controller.abort();
|
|
3075
|
+
interrupted = true;
|
|
3076
|
+
}
|
|
3077
|
+
for (const turn of assignedResumes.values()) {
|
|
3078
|
+
if (turn.assistantID !== assistantID) continue;
|
|
3079
|
+
turn.controller.abort();
|
|
3080
|
+
interrupted = true;
|
|
3081
|
+
}
|
|
3082
|
+
db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(assistantID);
|
|
3083
|
+
if (interrupted) return { binding: binding(row), aborted: true };
|
|
3084
|
+
if (!row.current_session_id) fail('revision_conflict');
|
|
3085
|
+
const result = await client().session.abort({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) });
|
|
3086
|
+
if (isMissing(result)) fail('not_found');
|
|
3087
|
+
if (result.error) fail('upstream_error');
|
|
3088
|
+
return { binding: binding(row), aborted: true };
|
|
3089
|
+
};
|
|
2321
3090
|
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)); };
|
|
2322
3091
|
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 }; };
|
|
2323
3092
|
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; } };
|
|
@@ -2384,12 +3153,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2384
3153
|
});
|
|
2385
3154
|
const snapshot = () => {
|
|
2386
3155
|
const rows = db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all();
|
|
3156
|
+
const assistantIDs = rows.map((row) => row.assistant_id);
|
|
2387
3157
|
// Per-assistant indexed LIMIT probes + one parts batch (catalog ≤100).
|
|
2388
|
-
const previews = getLatestContactMessagePreviews(db,
|
|
3158
|
+
const previews = getLatestContactMessagePreviews(db, assistantIDs);
|
|
3159
|
+
// Batch unread/watermark/tip — no per-assistant N+1 on snapshot.
|
|
3160
|
+
const unreadByID = getContactUnreadSnapshots(db, assistantIDs);
|
|
2389
3161
|
return {
|
|
2390
3162
|
revision: revision(),
|
|
2391
3163
|
enabled: enabled(),
|
|
2392
|
-
assistants: rows.map((row) => output(
|
|
3164
|
+
assistants: rows.map((row) => output(
|
|
3165
|
+
row,
|
|
3166
|
+
previews.get(row.assistant_id) ?? null,
|
|
3167
|
+
unreadByID.get(row.assistant_id) ?? null,
|
|
3168
|
+
)),
|
|
2393
3169
|
};
|
|
2394
3170
|
};
|
|
2395
3171
|
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) => {
|
|
@@ -2403,7 +3179,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2403
3179
|
db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
|
|
2404
3180
|
db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
|
|
2405
3181
|
deleteContactMessages(db, assistantID);
|
|
3182
|
+
deleteContactReadState(db, assistantID);
|
|
2406
3183
|
clearActiveContactTurns(assistantID);
|
|
3184
|
+
contactTurnLanguages.delete(assistantID);
|
|
2407
3185
|
bump();
|
|
2408
3186
|
db.exec('COMMIT');
|
|
2409
3187
|
return { assistantID, tombstoneAt: now() };
|
|
@@ -2411,11 +3189,15 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2411
3189
|
db.exec('ROLLBACK');
|
|
2412
3190
|
throw error;
|
|
2413
3191
|
}
|
|
2414
|
-
}, 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: () => {
|
|
3192
|
+
}, 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: () => {
|
|
3193
|
+
cancelAssignedResumes({});
|
|
3194
|
+
sessionAbortInflight.clear();
|
|
3195
|
+
for (const turn of contactControllers.values()) turn.controller.abort();
|
|
2415
3196
|
if (!closed) {
|
|
2416
3197
|
closed = true;
|
|
2417
3198
|
try { contactAttachmentMigration.controller.abort(); } catch { /* ignore */ }
|
|
2418
3199
|
clearActiveContactTurns();
|
|
3200
|
+
contactTurnLanguages.clear();
|
|
2419
3201
|
unsubscribeEvents?.();
|
|
2420
3202
|
clearIntervalFn(timer);
|
|
2421
3203
|
db.close();
|