@xfey/tutti 0.1.127 → 0.1.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/approvals/decision-command.d.ts +2 -2
  2. package/dist/chat-assistant/diagnostics.d.ts +17 -3
  3. package/dist/chat-assistant/diagnostics.js +5 -2
  4. package/dist/chat-assistant/failure-messages.d.ts +4 -0
  5. package/dist/chat-assistant/failure-messages.js +57 -0
  6. package/dist/chat-assistant/failures.d.ts +27 -0
  7. package/dist/chat-assistant/failures.js +46 -0
  8. package/dist/chat-assistant/index.d.ts +18 -4
  9. package/dist/chat-assistant/index.js +254 -51
  10. package/dist/chat-assistant/language-context.d.ts +2 -0
  11. package/dist/chat-assistant/language-context.js +17 -14
  12. package/dist/chat-assistant/requests.d.ts +34 -0
  13. package/dist/chat-assistant/requests.js +64 -0
  14. package/dist/collaboration-ingestion/clarification.js +3 -1
  15. package/dist/collaboration-ingestion/main-chat.js +1 -0
  16. package/dist/collaboration-ingestion/types.d.ts +2 -0
  17. package/dist/collaboration-state/clarification-snapshot.d.ts +5 -5
  18. package/dist/collaboration-state/context-sync-attempts.d.ts +9 -0
  19. package/dist/collaboration-state/context-sync-attempts.js +13 -0
  20. package/dist/collaboration-state/task-compile-context.d.ts +1 -1
  21. package/dist/collaboration-state/task-compile-context.js +11 -1
  22. package/dist/collaboration-state/task-compile-requests.d.ts +6 -0
  23. package/dist/collaboration-state/task-compile-requests.js +16 -0
  24. package/dist/communication-language/notice-locale.d.ts +4 -0
  25. package/dist/communication-language/notice-locale.js +14 -0
  26. package/dist/control-plane/context-sync-completion.d.ts +7 -0
  27. package/dist/control-plane/context-sync-completion.js +27 -0
  28. package/dist/control-plane/context-sync.d.ts +1 -0
  29. package/dist/control-plane/context-sync.js +23 -0
  30. package/dist/control-plane/index.d.ts +13 -4
  31. package/dist/control-plane/index.js +30 -23
  32. package/dist/control-plane/task-compile-continuation.js +2 -0
  33. package/dist/control-plane/task-compile-request-status.d.ts +6 -0
  34. package/dist/control-plane/task-compile-request-status.js +70 -0
  35. package/dist/control-plane/task-compile-start.js +40 -2
  36. package/dist/control-plane/types.d.ts +2 -0
  37. package/dist/providers/failure-classification.d.ts +23 -0
  38. package/dist/providers/failure-classification.js +43 -0
  39. package/dist/providers/invocation-progress.d.ts +6 -0
  40. package/dist/providers/invocation-progress.js +24 -0
  41. package/dist/providers/openai/app-server/read-only-procedure.js +1 -0
  42. package/dist/providers/openai/chat-assistant.js +2 -1
  43. package/dist/reference-access/read-evidence.d.ts +2 -0
  44. package/dist/reference-access/read-evidence.js +5 -0
  45. package/dist/reference-access/service.d.ts +2 -0
  46. package/dist/reference-access/service.js +2 -0
  47. package/dist/runtime-failures.js +15 -1
  48. package/dist/server-shell/cli/external-reference-runtime.js +6 -3
  49. package/dist/server-shell/cli/host-server-runtime.js +8 -2
  50. package/dist/server-shell/cli/relay-registration.js +1 -0
  51. package/dist/server-shell/http/routes/project-api/messages-routes.js +22 -1
  52. package/dist/server-shell/http/routes/project-api/openapi-collaboration-routes.d.ts +140 -5
  53. package/dist/server-shell/http/routes/project-api/openapi-collaboration-routes.js +29 -2
  54. package/dist/server-shell/http/routes/project-api/openapi-skills-routes.d.ts +2 -0
  55. package/dist/server-shell/http/routes/project-api/openapi-workspace-routes.d.ts +6 -0
  56. package/dist/server-shell/http/routes/project-api/openapi.d.ts +148 -5
  57. package/dist/server-shell/http/routes/project-api/schemas.d.ts +6 -0
  58. package/dist/server-shell/http/routes/project-api/schemas.js +1 -0
  59. package/dist/server-shell/http/routes/project-api/types.d.ts +1 -0
  60. package/dist/server-shell/http/routes/project-api/workflow-routes.js +39 -3
  61. package/dist/server-shell/http/routes/project-api/workspace-projections.js +1 -0
  62. package/dist/server-shell/http/validation.js +8 -2
  63. package/dist/server-shell/local-console/managed-project-service.d.ts +1 -1
  64. package/dist/server-shell/managed-access/transport.d.ts +1 -1
  65. package/migrations/0024_chat_requests.sql +19 -0
  66. package/migrations/0025_task_compile_requests.sql +50 -0
  67. package/migrations/0026_context_sync_and_chat_locale.sql +12 -0
  68. package/migrations/README.md +3 -1
  69. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +1 -0
  70. package/node_modules/@tutti/relay-client/dist/host-control.js +1 -0
  71. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -0
  72. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -0
  73. package/node_modules/@tutti/shared/dist/schemas/api/operation-status.d.ts +174 -0
  74. package/node_modules/@tutti/shared/dist/schemas/api/operation-status.js +75 -0
  75. package/node_modules/@tutti/shared/dist/schemas/api/primitives.d.ts +1 -0
  76. package/node_modules/@tutti/shared/dist/schemas/api/primitives.js +1 -0
  77. package/node_modules/@tutti/shared/dist/schemas/api/workspace-commands.d.ts +1 -0
  78. package/node_modules/@tutti/shared/dist/schemas/api/workspace-commands.js +2 -1
  79. package/package.json +1 -1
  80. package/web/assets/{homepage-motion-scene-DyEmWwvt.js → homepage-motion-scene-BHhw4CfM.js} +1 -1
  81. package/web/assets/index-5COc1Yku.js +70 -0
  82. package/web/assets/index-BJvKuUiT.css +1 -0
  83. package/web/index.html +2 -2
  84. package/web/assets/index-By3idT4U.js +0 -70
  85. package/web/assets/index-DSBjOZmJ.css +0 -1
@@ -1,12 +1,17 @@
1
- import { createChatAssistantDiagnostics } from "./diagnostics.js";
1
+ import { linkChatRequestInvocation } from "./requests.js";
2
+ import { createEventRef, ID_PREFIXES, isPrefixedId, } from "@tutti/shared/ids";
3
+ import { createChatAssistantDiagnostics, } from "./diagnostics.js";
2
4
  import { createClarificationRoundAgentMessage, createMainChatAgentMessage, readClarificationRoundMessagesWindow, readClarificationRoundProjection, readMainChatMessageWindow, readMainChatMessagesBeforeCursor, readMessageProjectionById, } from "../collaboration-state/index.js";
3
5
  import { renderLanguageAwarePrompt } from "../communication-language/prompt.js";
4
6
  import { languageDiagnosticFields } from "../communication-language/index.js";
5
- import { chatLanguageContext } from "./language-context.js";
7
+ import { chatLanguageContext, chatLanguagePreference } from "./language-context.js";
8
+ import { noticeLocale } from "../communication-language/notice-locale.js";
6
9
  import { withHostStoreTransaction, } from "../store/index.js";
7
10
  import { messageTextForModel, messageTextForTuttiMention, } from "../collaboration-state/message-context.js";
8
11
  import { externalReferenceMessageContext } from "../reference-access/message-context.js";
9
12
  import { readExternalReferenceReadSources } from "../reference-access/read-evidence.js";
13
+ import { chatFailure, chatFailureMessage, classifyChatFailure } from "./failures.js";
14
+ import { countQueuedChatRequests, insertChatRequest, listRunningChatRequests, nextQueuedChatRequest, readChatRequest, updateChatRequest, } from "./requests.js";
10
15
  const CHAT_ASSISTANT_CONTEXT_MESSAGE_LIMIT = 3;
11
16
  const CHAT_ASSISTANT_ROUND_MESSAGE_LIMIT = 50;
12
17
  const CHAT_ASSISTANT_ROUND_MESSAGE_MAX_CHARS = 2_000;
@@ -45,9 +50,6 @@ function normalizeAnswer(answer) {
45
50
  }
46
51
  return `${trimmed.slice(0, CHAT_ASSISTANT_MAX_ANSWER_CHARS - 3)}...`;
47
52
  }
48
- function assistantFailureMessage() {
49
- return "Tutti could not complete this read-only answer. Try again later, or add more context in chat.";
50
- }
51
53
  function messageToContext(message) {
52
54
  const authorDisplayName = message.author.display_name?.trim();
53
55
  return {
@@ -105,46 +107,113 @@ function roundMessagesToContext(messages) {
105
107
  }
106
108
  export class ReadOnlyChatAssistant {
107
109
  options;
108
- pending = [];
109
110
  idleWaiters = new Set();
110
111
  active = null;
111
112
  accepting = true;
112
113
  maxQueuedMentions;
114
+ recoveryTimer;
115
+ started = false;
116
+ activeAttemptRef;
117
+ storageFailureReported = false;
113
118
  constructor(options) {
114
119
  this.options = options;
115
- this.maxQueuedMentions = options.maxQueuedMentions ?? 8;
120
+ this.maxQueuedMentions = Math.max(1, options.maxQueuedMentions ?? 8);
116
121
  }
117
- enqueue(input) {
118
- if (input.message.author.kind !== "human" &&
119
- !(input.message.author.kind === "system" && input.message.refs?.development_operator === true)) {
120
- return { triggered: false };
121
- }
122
- if (!containsTuttiMention(messageTextForTuttiMention(input.message))) {
123
- return { triggered: false };
124
- }
125
- if (!this.accepting) {
126
- return { triggered: false };
122
+ /** Call once after Host recovery/transport wiring. No old messages are backfilled. */
123
+ start() {
124
+ if (this.started || !this.accepting)
125
+ return;
126
+ this.started = true;
127
+ for (const row of listRunningChatRequests(this.options.store.db)) {
128
+ if (row.attempt_ref === this.activeAttemptRef)
129
+ continue;
130
+ if (row.phase === "workspace_wait") {
131
+ updateChatRequest(this.options.store.db, row, { status: "queued", phase: "queue" }, this.now());
132
+ continue;
133
+ }
134
+ const failure = chatFailure("host_restarted");
135
+ const recovered = updateChatRequest(this.options.store.db, row, {
136
+ status: "failed",
137
+ phase: "settled",
138
+ failure,
139
+ }, this.now());
140
+ const message = readMessageProjectionById(this.options.store.db, row.trigger_message_id);
141
+ if (recovered && message) {
142
+ this.report(message, recovered)({ stage: "failed", error_code: "host_restarted", diagnostic_id: failure.diagnostic_id });
143
+ try {
144
+ this.appendAssistantMessageForTrigger(message, chatFailureMessage(failure, recovered.notice_locale), recovered, failure);
145
+ }
146
+ catch {
147
+ this.report(message, recovered)({ stage: "failed", reason_code: "reply_failed" });
148
+ }
149
+ }
127
150
  }
128
- const report = createChatAssistantDiagnostics({
151
+ // A lost post-commit wake must not strand a durable queued request.
152
+ this.recoveryTimer = setInterval(() => this.pump(), 1_000);
153
+ this.recoveryTimer.unref?.();
154
+ this.notifyRequestsChanged();
155
+ this.pump();
156
+ }
157
+ register(tx, message) {
158
+ if (!this.accepting ||
159
+ (message.author.kind !== "human" &&
160
+ !(message.author.kind === "system" && message.refs?.development_operator === true)) ||
161
+ !containsTuttiMention(messageTextForTuttiMention(message)) ||
162
+ readChatRequest(tx, message.id) !== null)
163
+ return;
164
+ insertChatRequest(tx, message.id, this.now(), countQueuedChatRequests(tx) >= this.maxQueuedMentions
165
+ ? chatFailure("chat_queue_full")
166
+ : undefined, noticeLocale(chatLanguagePreference(tx, message)?.locale));
167
+ }
168
+ now() {
169
+ return (this.options.now?.() ?? new Date()).toISOString();
170
+ }
171
+ report(message, request) {
172
+ return createChatAssistantDiagnostics({
129
173
  ...(this.options.logger === undefined ? {} : { logger: this.options.logger }),
130
174
  projectId: this.options.project.project_id,
131
- triggerMessageId: input.message.id,
132
- scope: input.message.scope.kind,
175
+ triggerMessageId: message.id,
176
+ scope: message.scope.kind,
177
+ requestRef: message.id,
178
+ ...(request.attempt_ref === null ? {} : { attemptRef: request.attempt_ref }),
133
179
  });
134
- if (this.pending.length >= this.maxQueuedMentions) {
135
- report({ stage: "failed", reason_code: "queue_full" });
136
- this.appendAssistantMessageForTrigger(input.message, assistantFailureMessage(), {
137
- scratchpad_source: "exclude",
180
+ }
181
+ enqueue(input) {
182
+ if (!this.accepting)
183
+ return { triggered: false };
184
+ withHostStoreTransaction(this.options.store.db, (tx) => this.register(tx, input.message));
185
+ const request = readChatRequest(this.options.store.db, input.message.id);
186
+ if (request === null)
187
+ return { triggered: false };
188
+ if (request.status === "failed" &&
189
+ request.response_message_id === null &&
190
+ request.failure_json !== null) {
191
+ const report = this.report(input.message, request);
192
+ const failure = JSON.parse(request.failure_json);
193
+ report({
194
+ stage: "failed",
195
+ reason_code: failure.code === "chat_queue_full" ? "queue_full" : "reply_failed",
196
+ diagnostic_id: failure.diagnostic_id,
138
197
  });
198
+ try {
199
+ this.appendAssistantMessageForTrigger(input.message, chatFailureMessage(failure, request.notice_locale), request, failure);
200
+ }
201
+ catch {
202
+ // The original message and failed request are already accepted.
203
+ report({
204
+ stage: "notification_failed",
205
+ reason_code: "reply_failed",
206
+ diagnostic_id: failure.diagnostic_id,
207
+ });
208
+ }
139
209
  return { triggered: true };
140
210
  }
141
- report({ stage: "queued" });
142
- this.pending.push({ message: input.message, report });
143
211
  this.pump();
144
212
  return { triggered: true };
145
213
  }
146
214
  async waitForIdle() {
147
- if (this.active === null && this.pending.length === 0) {
215
+ if (this.active === null &&
216
+ (!this.accepting || nextQueuedChatRequest(this.options.store.db) === null)) {
148
217
  return;
149
218
  }
150
219
  await new Promise((resolve) => this.idleWaiters.add(resolve));
@@ -154,36 +223,125 @@ export class ReadOnlyChatAssistant {
154
223
  return;
155
224
  }
156
225
  this.accepting = false;
157
- for (const { message, report } of this.pending.splice(0)) {
158
- report({ stage: "skipped", reason_code: "stopping" });
159
- this.appendAssistantMessageForTrigger(message, assistantFailureMessage(), {
160
- scratchpad_source: "exclude",
161
- });
162
- }
226
+ if (this.recoveryTimer !== undefined)
227
+ clearInterval(this.recoveryTimer);
163
228
  this.resolveIdleIfNeeded();
164
229
  }
165
230
  pump() {
166
- if (this.active !== null) {
231
+ try {
232
+ this.pumpNext();
233
+ this.storageFailureReported = false;
234
+ }
235
+ catch {
236
+ this.reportStorageFailure();
237
+ }
238
+ }
239
+ reportStorageFailure() {
240
+ if (this.storageFailureReported)
241
+ return;
242
+ this.storageFailureReported = true;
243
+ try {
244
+ this.options.logger?.warn({
245
+ project_id: this.options.project.project_id,
246
+ stage: "queue_storage_failed",
247
+ diagnostic_id: createEventRef(),
248
+ }, "Chat request queue storage unavailable");
249
+ }
250
+ catch {
251
+ /* Diagnostics cannot replace durable recovery. */
252
+ }
253
+ }
254
+ pumpNext() {
255
+ if (this.active !== null)
256
+ return;
257
+ if (!this.accepting) {
258
+ this.resolveIdleIfNeeded();
167
259
  return;
168
260
  }
169
- const pending = this.pending.shift();
170
- if (pending === undefined) {
261
+ const pending = nextQueuedChatRequest(this.options.store.db);
262
+ if (pending === null) {
171
263
  this.resolveIdleIfNeeded();
172
264
  return;
173
265
  }
174
- const task = this.respond(pending.message, pending.report).catch(() => {
175
- pending.report({ stage: "failed", reason_code: "reply_failed" });
266
+ const request = updateChatRequest(this.options.store.db, pending, {
267
+ status: "running",
268
+ phase: "workspace_wait",
269
+ attemptRef: createEventRef(),
270
+ }, this.now());
271
+ if (request === null)
272
+ return;
273
+ this.notifyRequestsChanged();
274
+ this.activeAttemptRef = request.attempt_ref;
275
+ const message = readMessageProjectionById(this.options.store.db, request.trigger_message_id);
276
+ if (message === null)
277
+ throw new Error("chat_request_source_missing");
278
+ const report = this.report(message, request);
279
+ report({
280
+ stage: "queued",
281
+ queue_wait_ms: Math.max(0, Date.parse(this.now()) - Date.parse(request.created_at)),
282
+ });
283
+ const task = this.respond(message, request, report).catch(() => {
284
+ // Do not overwrite a committed success when only notification delivery failed.
285
+ try {
286
+ const current = readChatRequest(this.options.store.db, message.id);
287
+ if (current?.status === "running" && current.attempt_ref === request.attempt_ref) {
288
+ const failure = chatFailure("chat_reply_persistence_failed", request.attempt_ref);
289
+ updateChatRequest(this.options.store.db, current, {
290
+ status: "failed",
291
+ phase: "settled",
292
+ failure,
293
+ }, this.now());
294
+ report({
295
+ stage: "failed",
296
+ reason_code: "reply_failed",
297
+ error_code: "chat_reply_persistence_failed",
298
+ diagnostic_id: failure.diagnostic_id,
299
+ });
300
+ }
301
+ else if (current !== null &&
302
+ current.response_message_id !== null &&
303
+ current.attempt_ref === request.attempt_ref) {
304
+ report({ stage: "notification_failed", reason_code: "reply_failed" });
305
+ }
306
+ else {
307
+ report({ stage: "skipped", reason_code: "request_changed" });
308
+ }
309
+ }
310
+ catch {
311
+ this.reportStorageFailure();
312
+ }
176
313
  });
177
314
  this.active = task;
178
315
  void task.finally(() => {
179
316
  if (this.active === task) {
180
317
  this.active = null;
318
+ this.activeAttemptRef = undefined;
319
+ this.notifyRequestsChanged();
181
320
  this.pump();
182
321
  }
183
322
  });
184
323
  }
324
+ notifyRequestsChanged() {
325
+ try {
326
+ this.options.onRequestsChanged?.();
327
+ }
328
+ catch {
329
+ // Maintenance wake failure cannot change a committed Chat request/reply.
330
+ try {
331
+ this.options.logger?.warn({
332
+ project_id: this.options.project.project_id,
333
+ stage: "maintenance_notification_failed",
334
+ diagnostic_id: createEventRef(),
335
+ }, "Chat maintenance notification failed");
336
+ }
337
+ catch {
338
+ /* Diagnostics cannot replace durable recovery. */
339
+ }
340
+ }
341
+ }
185
342
  resolveIdleIfNeeded() {
186
- if (this.active !== null || this.pending.length !== 0) {
343
+ if (this.active !== null ||
344
+ (this.accepting && nextQueuedChatRequest(this.options.store.db) !== null)) {
187
345
  return;
188
346
  }
189
347
  for (const resolve of this.idleWaiters) {
@@ -263,7 +421,7 @@ export class ReadOnlyChatAssistant {
263
421
  },
264
422
  }, language);
265
423
  }
266
- appendMainChatAssistantMessage(triggerMessage, body, refs) {
424
+ appendMainChatAssistantMessage(triggerMessage, body, request, failure, refs) {
267
425
  let afterCommit;
268
426
  const message = withHostStoreTransaction(this.options.store.db, (tx) => {
269
427
  const responseMessage = createMainChatAgentMessage(tx, {
@@ -279,6 +437,7 @@ export class ReadOnlyChatAssistant {
279
437
  if (typeof callback === "function") {
280
438
  afterCommit = callback;
281
439
  }
440
+ this.recordReply(tx, request, responseMessage.id, failure);
282
441
  return responseMessage;
283
442
  });
284
443
  afterCommit?.();
@@ -310,6 +469,7 @@ export class ReadOnlyChatAssistant {
310
469
  });
311
470
  if (typeof callback === "function")
312
471
  afterCommit = callback;
472
+ this.recordReply(tx, input.request, responseMessage.id, input.failure);
313
473
  return responseMessage;
314
474
  });
315
475
  afterCommit?.();
@@ -332,36 +492,75 @@ export class ReadOnlyChatAssistant {
332
492
  invalidates,
333
493
  });
334
494
  }
335
- appendAssistantMessageForTrigger(triggerMessage, body, refs) {
495
+ appendAssistantMessageForTrigger(triggerMessage, body, request, failure, refs) {
496
+ if (failure !== undefined)
497
+ refs = { ...refs, scratchpad_source: "exclude" };
336
498
  if (triggerMessage.scope.kind === "clarification_round") {
337
499
  this.appendClarificationRoundAssistantMessage({
338
500
  triggerMessage,
339
501
  roundId: triggerMessage.scope.clarification_round_ref,
340
502
  body,
503
+ request,
504
+ ...(failure === undefined ? {} : { failure }),
341
505
  ...(refs === undefined ? {} : { refs }),
342
506
  });
343
507
  return;
344
508
  }
345
- this.appendMainChatAssistantMessage(triggerMessage, body, refs);
509
+ this.appendMainChatAssistantMessage(triggerMessage, body, request, failure, refs);
510
+ }
511
+ recordReply(tx, request, messageId, failure) {
512
+ if (!updateChatRequest(tx, request, {
513
+ status: failure ? "failed" : "succeeded",
514
+ phase: "settled",
515
+ responseMessageId: messageId,
516
+ ...(failure === undefined ? {} : { failure }),
517
+ }, this.now()))
518
+ throw new Error("chat_request_changed");
346
519
  }
347
- async respond(triggerMessage, report) {
520
+ async respond(triggerMessage, request, report) {
348
521
  let answer;
349
522
  let invocationRef;
350
523
  let readSources = [];
351
524
  try {
352
525
  report({ stage: "inspection_wait" });
353
- await this.options.waitForInspectionAvailability?.();
526
+ await this.options.waitForInspectionAvailability?.((blocker) => report({ stage: "inspection_blocked", ...blocker }));
527
+ if (!this.accepting) {
528
+ updateChatRequest(this.options.store.db, request, { status: "queued", phase: "queue" }, this.now());
529
+ return;
530
+ }
354
531
  const prompt = this.renderPrompt(triggerMessage, report);
355
532
  if (prompt === null) {
356
533
  report({ stage: "skipped", reason_code: "context_unavailable" });
534
+ updateChatRequest(this.options.store.db, request, {
535
+ status: "failed",
536
+ phase: "settled",
537
+ failure: chatFailure("chat_context_unavailable", request.attempt_ref),
538
+ }, this.now());
357
539
  return;
358
540
  }
541
+ const executing = updateChatRequest(this.options.store.db, request, {
542
+ status: "running",
543
+ phase: "answer",
544
+ }, this.now());
545
+ if (executing === null)
546
+ return;
547
+ request = executing;
359
548
  report({ stage: "model_requested" });
360
549
  const output = await this.options.model.answer({
361
550
  prompt,
362
551
  onProgress: (event) => {
363
- if (event.stage === "context_ready")
364
- invocationRef = event.invocation_ref;
552
+ if (event.stage === "context_ready") {
553
+ if (typeof event.invocation_ref === "string" &&
554
+ isPrefixedId(event.invocation_ref, ID_PREFIXES.event)) {
555
+ invocationRef = event.invocation_ref;
556
+ try {
557
+ linkChatRequestInvocation(this.options.store.db, request, invocationRef);
558
+ }
559
+ catch {
560
+ report({ stage: "notification_failed" });
561
+ }
562
+ }
563
+ }
365
564
  report(event);
366
565
  },
367
566
  });
@@ -370,16 +569,20 @@ export class ReadOnlyChatAssistant {
370
569
  readSources = readExternalReferenceReadSources(this.options.store.db, invocationRef);
371
570
  }
372
571
  }
373
- catch {
374
- report({ stage: "failed", reason_code: "answer_failed" });
375
- this.appendAssistantMessageForTrigger(triggerMessage, assistantFailureMessage(), {
376
- scratchpad_source: "exclude",
572
+ catch (error) {
573
+ const failure = classifyChatFailure(error, request.attempt_ref);
574
+ report({
575
+ stage: "failed",
576
+ reason_code: "answer_failed",
577
+ error_code: failure.code,
578
+ diagnostic_id: failure.diagnostic_id,
377
579
  });
580
+ this.appendAssistantMessageForTrigger(triggerMessage, chatFailureMessage(failure, request.notice_locale), request, failure);
378
581
  report({ stage: "reply_persisted" });
379
582
  return;
380
583
  }
381
584
  // Persistence/outbound-hook failure must not retry by creating a second reply.
382
- this.appendAssistantMessageForTrigger(triggerMessage, answer, readSources.length === 0 ? undefined : { external_reference_reads: readSources });
585
+ this.appendAssistantMessageForTrigger(triggerMessage, answer, request, undefined, readSources.length === 0 ? undefined : { external_reference_reads: readSources });
383
586
  report({ stage: "reply_persisted", reference_source_count: readSources.length });
384
587
  }
385
588
  }
@@ -1,5 +1,7 @@
1
1
  import type { MessageProjection } from "@tutti/shared/schemas/api";
2
2
  import type { SqliteDatabase } from "../store/index.js";
3
+ /** Shared scope boundary for model inheritance and pre-call static notices. */
4
+ export declare function chatLanguagePreference(db: SqliteDatabase, trigger: MessageProjection): import("../collaboration-state/communication-language.js").StoredCommunicationLanguage | null;
3
5
  /** Same compact scope as Q&A, never boundary-after main chat for a round. */
4
6
  export declare function chatLanguageContext(db: SqliteDatabase, trigger: MessageProjection, limits: {
5
7
  mainChat: number;
@@ -1,9 +1,25 @@
1
1
  import { readActiveClarificationProjection, readClarificationRoundMessagesWindow, readMainChatMessageWindow, readTaskProjection, } from "../collaboration-state/index.js";
2
2
  import { readCommunicationLanguage } from "../collaboration-state/communication-language.js";
3
3
  import { createLanguageContext, messageLanguageEvidence } from "../communication-language/index.js";
4
+ /** Shared scope boundary for model inheritance and pre-call static notices. */
5
+ export function chatLanguagePreference(db, trigger) {
6
+ if (trigger.scope.kind === "main_chat")
7
+ return readCommunicationLanguage(db);
8
+ const active = readActiveClarificationProjection(db);
9
+ // Never borrow a newer project-wide preference for a task/compile round.
10
+ if (active.state !== "active" || active.round.id !== trigger.scope.clarification_round_ref)
11
+ return null;
12
+ const owner = active.thread.owner;
13
+ const task = owner.kind === "task" ? readTaskProjection(db, owner.task_id) : null;
14
+ const workflowRef = owner.kind === "task_compile" ? owner.workflow_ref : task?.created_from_workflow_ref;
15
+ return ((task === null ? null : readCommunicationLanguage(db, { kind: "task", ref: task.id })) ??
16
+ (workflowRef === undefined
17
+ ? null
18
+ : readCommunicationLanguage(db, { kind: "workflow", ref: workflowRef })));
19
+ }
4
20
  /** Same compact scope as Q&A, never boundary-after main chat for a round. */
5
21
  export function chatLanguageContext(db, trigger, limits) {
6
- let inherited = readCommunicationLanguage(db);
22
+ const inherited = chatLanguagePreference(db, trigger);
7
23
  let messages;
8
24
  if (trigger.scope.kind === "main_chat") {
9
25
  messages = readMainChatMessageWindow(db, { limit: limits.mainChat }).items;
@@ -14,19 +30,6 @@ export function chatLanguageContext(db, trigger, limits) {
14
30
  anchor: "start",
15
31
  limit: limits.round,
16
32
  })?.items ?? [];
17
- const active = readActiveClarificationProjection(db);
18
- // A task/compile round must not inherit a newer project-wide language choice.
19
- inherited = null;
20
- if (active.state === "active" && active.round.id === trigger.scope.clarification_round_ref) {
21
- const owner = active.thread.owner;
22
- const task = owner.kind === "task" ? readTaskProjection(db, owner.task_id) : null;
23
- const workflowRef = owner.kind === "task_compile" ? owner.workflow_ref : task?.created_from_workflow_ref;
24
- inherited =
25
- (task === null ? null : readCommunicationLanguage(db, { kind: "task", ref: task.id })) ??
26
- (workflowRef === undefined
27
- ? null
28
- : readCommunicationLanguage(db, { kind: "workflow", ref: workflowRef }));
29
- }
30
33
  }
31
34
  return createLanguageContext({
32
35
  scope: "reply",
@@ -0,0 +1,34 @@
1
+ import { type ChatRequestStatus, type OperationFailure } from "@tutti/shared/schemas/api";
2
+ import type { SqliteDatabase } from "../store/index.js";
3
+ import type { NoticeLocale } from "../communication-language/notice-locale.js";
4
+ export type ChatRequestRecord = {
5
+ trigger_message_id: string;
6
+ status: "queued" | "running" | "succeeded" | "failed";
7
+ phase: "queue" | "workspace_wait" | "answer" | "settled";
8
+ revision: number;
9
+ attempt_ref: string | null;
10
+ response_message_id: string | null;
11
+ failure_json: string | null;
12
+ created_at: string;
13
+ updated_at: string;
14
+ notice_locale: NoticeLocale;
15
+ };
16
+ export declare function readChatRequest(db: SqliteDatabase, messageId: string): ChatRequestRecord | null;
17
+ export declare function chatRequestStatus(row: ChatRequestRecord): ChatRequestStatus;
18
+ export declare function insertChatRequest(db: SqliteDatabase, messageId: string, now: string, failure?: OperationFailure, noticeLocale?: NoticeLocale): void;
19
+ /** A narrow read-only signal for deferring automatic maintenance, not Chat lifecycle ownership. */
20
+ export declare function hasPendingChatRequests(db: SqliteDatabase): boolean;
21
+ export declare function countQueuedChatRequests(db: SqliteDatabase): number;
22
+ export declare function nextQueuedChatRequest(db: SqliteDatabase): ChatRequestRecord | null;
23
+ /** CAS/fencing only. Lifecycle policy belongs to Chat Assistant, not the repository. */
24
+ export declare function updateChatRequest(db: SqliteDatabase, row: ChatRequestRecord, next: {
25
+ status: ChatRequestRecord["status"];
26
+ phase: ChatRequestRecord["phase"];
27
+ attemptRef?: string;
28
+ responseMessageId?: string;
29
+ failure?: OperationFailure;
30
+ }, now: string): ChatRequestRecord | null;
31
+ export declare function listRunningChatRequests(db: SqliteDatabase): ChatRequestRecord[];
32
+ /** Correlation only; never changes lifecycle state based on a progress callback. */
33
+ export declare function linkChatRequestInvocation(db: SqliteDatabase, row: ChatRequestRecord, invocationRef: string): void;
34
+ //# sourceMappingURL=requests.d.ts.map
@@ -0,0 +1,64 @@
1
+ import { Value } from "@sinclair/typebox/value";
2
+ import { ChatRequestStatusSchema, } from "@tutti/shared/schemas/api";
3
+ export function readChatRequest(db, messageId) {
4
+ return (db
5
+ .prepare("SELECT * FROM chat_requests WHERE trigger_message_id = ?")
6
+ .get(messageId) ?? null);
7
+ }
8
+ export function chatRequestStatus(row) {
9
+ const value = {
10
+ operation: { kind: "chat_answer", ref: row.trigger_message_id },
11
+ status: row.status,
12
+ phase: row.phase,
13
+ revision: row.revision,
14
+ created_at: row.created_at,
15
+ updated_at: row.updated_at,
16
+ ...(row.response_message_id === null ? {} : { response_message_id: row.response_message_id }),
17
+ ...(row.failure_json === null ? {} : { failure: JSON.parse(row.failure_json) }),
18
+ };
19
+ if (!Value.Check(ChatRequestStatusSchema, value))
20
+ throw new Error("chat_request_invalid");
21
+ return value;
22
+ }
23
+ export function insertChatRequest(db, messageId, now, failure, noticeLocale = "en") {
24
+ db.prepare(`INSERT INTO chat_requests
25
+ (trigger_message_id, status, phase, failure_json, created_at, updated_at, notice_locale)
26
+ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(trigger_message_id) DO NOTHING`).run(messageId, failure ? "failed" : "queued", failure ? "settled" : "queue", failure ? JSON.stringify(failure) : null, now, now, noticeLocale);
27
+ }
28
+ /** A narrow read-only signal for deferring automatic maintenance, not Chat lifecycle ownership. */
29
+ export function hasPendingChatRequests(db) {
30
+ return (db
31
+ .prepare("SELECT 1 AS present FROM chat_requests WHERE status IN ('queued', 'running') LIMIT 1")
32
+ .get() !== undefined);
33
+ }
34
+ export function countQueuedChatRequests(db) {
35
+ return db
36
+ .prepare("SELECT count(*) AS count FROM chat_requests WHERE status = 'queued'")
37
+ .get().count;
38
+ }
39
+ export function nextQueuedChatRequest(db) {
40
+ return (db
41
+ .prepare("SELECT * FROM chat_requests WHERE status = 'queued' ORDER BY enqueue_order LIMIT 1")
42
+ .get() ?? null);
43
+ }
44
+ /** CAS/fencing only. Lifecycle policy belongs to Chat Assistant, not the repository. */
45
+ export function updateChatRequest(db, row, next, now) {
46
+ const changed = db
47
+ .prepare(`UPDATE chat_requests SET status = ?, phase = ?,
48
+ attempt_ref = ?, response_message_id = ?, failure_json = ?, updated_at = ?, revision = revision + 1
49
+ WHERE trigger_message_id = ? AND revision = ?`)
50
+ .run(next.status, next.phase, next.attemptRef ?? row.attempt_ref, next.responseMessageId ?? null, next.failure ? JSON.stringify(next.failure) : null, now, row.trigger_message_id, row.revision).changes;
51
+ return changed === 0 ? null : readChatRequest(db, row.trigger_message_id);
52
+ }
53
+ export function listRunningChatRequests(db) {
54
+ return db
55
+ .prepare("SELECT * FROM chat_requests WHERE status = 'running' ORDER BY created_at, trigger_message_id")
56
+ .all();
57
+ }
58
+ /** Correlation only; never changes lifecycle state based on a progress callback. */
59
+ export function linkChatRequestInvocation(db, row, invocationRef) {
60
+ db.prepare(`INSERT OR IGNORE INTO chat_request_invocations(message_id, attempt_ref, invocation_ref)
61
+ SELECT trigger_message_id, attempt_ref, ? FROM chat_requests
62
+ WHERE trigger_message_id = ? AND attempt_ref = ? AND status = 'running'`).run(invocationRef, row.trigger_message_id, row.attempt_ref);
63
+ }
64
+ //# sourceMappingURL=requests.js.map
@@ -21,8 +21,10 @@ export function ingestClarificationMessage(options) {
21
21
  ...(options.now === undefined ? {} : { now: options.now }),
22
22
  execute: (tx) => {
23
23
  const result = options.controlPlane.sendClarificationRoundMessage(options.roundId, options.payload, options.author);
24
- if (result.result?.message !== undefined)
24
+ if (result.result?.message !== undefined) {
25
25
  options.afterCreate?.(tx, result.result.message);
26
+ options.chatAssistant?.register(tx, result.result.message);
27
+ }
26
28
  return result;
27
29
  },
28
30
  });
@@ -39,6 +39,7 @@ export function ingestMainChatMessage(options) {
39
39
  ...(options.now === undefined ? {} : { now: options.now }),
40
40
  });
41
41
  options.afterCreate?.(tx, message);
42
+ options.chatAssistant?.register(tx, message);
42
43
  return {
43
44
  disposition: { kind: "created", message_id: message.id },
44
45
  result: { message },
@@ -1,5 +1,6 @@
1
1
  import type { AccountAvatarProjection } from "@tutti/shared/domain";
2
2
  import type { MessageProjection } from "@tutti/shared/schemas/api";
3
+ import type { SqliteDatabase } from "../store/index.js";
3
4
  export type TrustedCollaborationAuthor = {
4
5
  kind?: "human" | "development_operator";
5
6
  account_ref: string;
@@ -10,6 +11,7 @@ export type ScratchpadSourceNotifier = {
10
11
  notify: (message: MessageProjection) => void;
11
12
  };
12
13
  export type ChatAssistantEnqueuer = {
14
+ register: (tx: SqliteDatabase, message: MessageProjection) => void;
13
15
  enqueue: (input: {
14
16
  message: MessageProjection;
15
17
  }) => unknown;