@xfey/tutti 0.1.127 → 0.1.128

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 (65) hide show
  1. package/dist/approvals/decision-command.d.ts +2 -2
  2. package/dist/chat-assistant/diagnostics.d.ts +7 -2
  3. package/dist/chat-assistant/diagnostics.js +5 -2
  4. package/dist/chat-assistant/failures.d.ts +27 -0
  5. package/dist/chat-assistant/failures.js +102 -0
  6. package/dist/chat-assistant/index.d.ts +14 -2
  7. package/dist/chat-assistant/index.js +226 -48
  8. package/dist/chat-assistant/requests.d.ts +30 -0
  9. package/dist/chat-assistant/requests.js +58 -0
  10. package/dist/collaboration-ingestion/clarification.js +3 -1
  11. package/dist/collaboration-ingestion/main-chat.js +1 -0
  12. package/dist/collaboration-ingestion/types.d.ts +2 -0
  13. package/dist/collaboration-state/clarification-snapshot.d.ts +5 -5
  14. package/dist/collaboration-state/task-compile-context.d.ts +1 -1
  15. package/dist/collaboration-state/task-compile-context.js +11 -1
  16. package/dist/collaboration-state/task-compile-requests.d.ts +6 -0
  17. package/dist/collaboration-state/task-compile-requests.js +16 -0
  18. package/dist/control-plane/task-compile-continuation.js +2 -0
  19. package/dist/control-plane/task-compile-request-status.d.ts +6 -0
  20. package/dist/control-plane/task-compile-request-status.js +70 -0
  21. package/dist/control-plane/task-compile-start.js +40 -2
  22. package/dist/providers/failure-classification.d.ts +23 -0
  23. package/dist/providers/failure-classification.js +43 -0
  24. package/dist/reference-access/read-evidence.d.ts +2 -0
  25. package/dist/reference-access/read-evidence.js +5 -0
  26. package/dist/reference-access/service.d.ts +2 -0
  27. package/dist/reference-access/service.js +2 -0
  28. package/dist/runtime-failures.js +15 -1
  29. package/dist/server-shell/cli/external-reference-runtime.js +6 -3
  30. package/dist/server-shell/cli/host-server-runtime.js +4 -1
  31. package/dist/server-shell/cli/relay-registration.js +1 -0
  32. package/dist/server-shell/http/routes/project-api/messages-routes.js +22 -1
  33. package/dist/server-shell/http/routes/project-api/openapi-collaboration-routes.d.ts +140 -5
  34. package/dist/server-shell/http/routes/project-api/openapi-collaboration-routes.js +29 -2
  35. package/dist/server-shell/http/routes/project-api/openapi-skills-routes.d.ts +2 -0
  36. package/dist/server-shell/http/routes/project-api/openapi-workspace-routes.d.ts +6 -0
  37. package/dist/server-shell/http/routes/project-api/openapi.d.ts +148 -5
  38. package/dist/server-shell/http/routes/project-api/schemas.d.ts +6 -0
  39. package/dist/server-shell/http/routes/project-api/schemas.js +1 -0
  40. package/dist/server-shell/http/routes/project-api/types.d.ts +1 -0
  41. package/dist/server-shell/http/routes/project-api/workflow-routes.js +39 -3
  42. package/dist/server-shell/http/routes/project-api/workspace-projections.js +1 -0
  43. package/dist/server-shell/http/validation.js +8 -2
  44. package/dist/server-shell/local-console/managed-project-service.d.ts +1 -1
  45. package/dist/server-shell/managed-access/transport.d.ts +1 -1
  46. package/migrations/0024_chat_requests.sql +19 -0
  47. package/migrations/0025_task_compile_requests.sql +50 -0
  48. package/migrations/README.md +3 -1
  49. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +1 -0
  50. package/node_modules/@tutti/relay-client/dist/host-control.js +1 -0
  51. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -0
  52. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -0
  53. package/node_modules/@tutti/shared/dist/schemas/api/operation-status.d.ts +174 -0
  54. package/node_modules/@tutti/shared/dist/schemas/api/operation-status.js +75 -0
  55. package/node_modules/@tutti/shared/dist/schemas/api/primitives.d.ts +1 -0
  56. package/node_modules/@tutti/shared/dist/schemas/api/primitives.js +1 -0
  57. package/node_modules/@tutti/shared/dist/schemas/api/workspace-commands.d.ts +1 -0
  58. package/node_modules/@tutti/shared/dist/schemas/api/workspace-commands.js +2 -1
  59. package/package.json +1 -1
  60. package/web/assets/{homepage-motion-scene-DyEmWwvt.js → homepage-motion-scene-BcMt0AVW.js} +1 -1
  61. package/web/assets/index-2eX37Jx6.js +70 -0
  62. package/web/assets/index-BJvKuUiT.css +1 -0
  63. package/web/index.html +2 -2
  64. package/web/assets/index-By3idT4U.js +0 -70
  65. package/web/assets/index-DSBjOZmJ.css +0 -1
@@ -25,8 +25,8 @@ export declare function executeApprovalDecisionCommand(input: {
25
25
  state: "pending";
26
26
  request: {
27
27
  expires_at?: string;
28
- task_id?: string;
29
28
  activity_ref?: string;
29
+ task_id?: string;
30
30
  reason?: string;
31
31
  file_change?: {
32
32
  paths?: string[];
@@ -69,8 +69,8 @@ export declare function executeApprovalDecisionCommand(input: {
69
69
  state: "resolved";
70
70
  request: {
71
71
  expires_at?: string;
72
- task_id?: string;
73
72
  activity_ref?: string;
73
+ task_id?: string;
74
74
  reason?: string;
75
75
  file_change?: {
76
76
  paths?: string[];
@@ -1,5 +1,6 @@
1
1
  import type { ProviderInvocationProgress } from "../providers/invocation-progress.js";
2
2
  import type { languageDiagnosticFields } from "../communication-language/index.js";
3
+ import type { ChatFailureCode } from "./failures.js";
3
4
  export type ChatAssistantLogger = {
4
5
  info: (fields: Record<string, unknown>, message: string) => void;
5
6
  warn: (fields: Record<string, unknown>, message: string) => void;
@@ -7,8 +8,10 @@ export type ChatAssistantLogger = {
7
8
  type ChatAssistantProgress = ProviderInvocationProgress | ({
8
9
  stage: "language_context";
9
10
  } & ReturnType<typeof languageDiagnosticFields>) | {
10
- stage: "queued" | "inspection_wait" | "model_requested" | "reply_persisted" | "failed" | "skipped";
11
- reason_code?: "queue_full" | "stopping" | "context_unavailable" | "answer_failed" | "reply_failed";
11
+ stage: "queued" | "inspection_wait" | "model_requested" | "reply_persisted" | "notification_failed" | "failed" | "skipped";
12
+ reason_code?: "queue_full" | "stopping" | "context_unavailable" | "answer_failed" | "reply_failed" | "request_changed";
13
+ error_code?: ChatFailureCode;
14
+ diagnostic_id?: string;
12
15
  reference_source_count?: number;
13
16
  };
14
17
  export declare function createChatAssistantDiagnostics(options: {
@@ -16,6 +19,8 @@ export declare function createChatAssistantDiagnostics(options: {
16
19
  projectId: string;
17
20
  triggerMessageId: string;
18
21
  scope: string;
22
+ requestRef?: string;
23
+ attemptRef?: string;
19
24
  }): (event: ChatAssistantProgress) => void;
20
25
  export {};
21
26
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -1,18 +1,21 @@
1
1
  import { createEventRef } from "@tutti/shared/ids";
2
2
  export function createChatAssistantDiagnostics(options) {
3
- const requestRef = createEventRef();
3
+ const requestRef = options.requestRef ?? createEventRef();
4
4
  const started = performance.now();
5
5
  return (event) => {
6
6
  try {
7
7
  const fields = {
8
8
  project_id: options.projectId,
9
9
  chat_request_ref: requestRef,
10
+ ...(options.attemptRef === undefined ? {} : { attempt_ref: options.attemptRef }),
10
11
  trigger_message_ref: options.triggerMessageId,
11
12
  scope: options.scope,
12
13
  elapsed_ms: Math.round(performance.now() - started),
13
14
  ...event,
14
15
  };
15
- if (event.stage === "failed" || event.stage === "provider_failed") {
16
+ if (event.stage === "failed" ||
17
+ event.stage === "provider_failed" ||
18
+ event.stage === "notification_failed") {
16
19
  options.logger?.warn(fields, "Chat assistant progress");
17
20
  }
18
21
  else {
@@ -0,0 +1,27 @@
1
+ import type { OperationFailure } from "@tutti/shared/schemas/api";
2
+ declare const failures: {
3
+ readonly reference_authorization_required: readonly ["access", "none", "Authorize document access using the group card, then send your question again. Authorization does not restart this answer."];
4
+ readonly chat_queue_full: readonly ["capacity", "resend_request", "Tutti is busy answering other questions. Try again shortly."];
5
+ readonly chat_context_unavailable: readonly ["interrupted", "none", "This question's conversation is no longer available."];
6
+ readonly host_restarted: readonly ["interrupted", "resend_request", "The Host restarted before this answer was saved. Send your question again."];
7
+ readonly provider_not_configured: readonly ["configuration", "check_provider", "Ask the project owner to configure model access, then send your question again."];
8
+ readonly provider_auth_invalid: readonly ["access", "check_provider", "Model access could not be verified. Ask the project owner to check it."];
9
+ readonly provider_quota_or_billing_required: readonly ["access", "check_provider", "Model credit is unavailable. Ask the project owner to check the account."];
10
+ readonly provider_rate_limited: readonly ["capacity", "resend_request", "The model service is busy. Wait a moment, then send your question again."];
11
+ readonly provider_network_error: readonly ["dependency", "resend_request", "The model service could not be reached. Try again when the connection is available."];
12
+ readonly provider_model_unavailable: readonly ["configuration", "check_provider", "The selected model is unavailable. Ask the project owner to check model access."];
13
+ readonly provider_timeout: readonly ["dependency", "resend_request", "The model did not finish answering in time. You can send your question again."];
14
+ readonly provider_output_invalid: readonly ["dependency", "resend_request", "The model did not return a usable answer. You can send your question again."];
15
+ readonly provider_lifecycle_failed: readonly ["internal", "restart_host", "The model process could not close safely. Restart the Host before trying again."];
16
+ readonly chat_reply_persistence_failed: readonly ["internal", "contact_support", "The answer could not be saved. Check the Host diagnostics before trying again."];
17
+ readonly internal_error: readonly ["internal", "contact_support", "Tutti could not complete this read-only answer. Share the diagnostic ID if this persists."];
18
+ };
19
+ export type ChatFailureCode = keyof typeof failures;
20
+ type ChatFailure = OperationFailure & {
21
+ code: ChatFailureCode;
22
+ };
23
+ export declare function chatFailure(code: ChatFailureCode, diagnosticId?: string): ChatFailure;
24
+ export declare function chatFailureMessage(failure: OperationFailure): string;
25
+ export declare function classifyChatFailure(error: unknown, diagnosticId: string): ChatFailure;
26
+ export {};
27
+ //# sourceMappingURL=failures.d.ts.map
@@ -0,0 +1,102 @@
1
+ import { createEventRef } from "@tutti/shared/ids";
2
+ import { providerFailureCode } from "../providers/failure-classification.js";
3
+ const failures = {
4
+ reference_authorization_required: [
5
+ "access",
6
+ "none",
7
+ "Authorize document access using the group card, then send your question again. Authorization does not restart this answer.",
8
+ ],
9
+ chat_queue_full: [
10
+ "capacity",
11
+ "resend_request",
12
+ "Tutti is busy answering other questions. Try again shortly.",
13
+ ],
14
+ chat_context_unavailable: [
15
+ "interrupted",
16
+ "none",
17
+ "This question's conversation is no longer available.",
18
+ ],
19
+ host_restarted: [
20
+ "interrupted",
21
+ "resend_request",
22
+ "The Host restarted before this answer was saved. Send your question again.",
23
+ ],
24
+ provider_not_configured: [
25
+ "configuration",
26
+ "check_provider",
27
+ "Ask the project owner to configure model access, then send your question again.",
28
+ ],
29
+ provider_auth_invalid: [
30
+ "access",
31
+ "check_provider",
32
+ "Model access could not be verified. Ask the project owner to check it.",
33
+ ],
34
+ provider_quota_or_billing_required: [
35
+ "access",
36
+ "check_provider",
37
+ "Model credit is unavailable. Ask the project owner to check the account.",
38
+ ],
39
+ provider_rate_limited: [
40
+ "capacity",
41
+ "resend_request",
42
+ "The model service is busy. Wait a moment, then send your question again.",
43
+ ],
44
+ provider_network_error: [
45
+ "dependency",
46
+ "resend_request",
47
+ "The model service could not be reached. Try again when the connection is available.",
48
+ ],
49
+ provider_model_unavailable: [
50
+ "configuration",
51
+ "check_provider",
52
+ "The selected model is unavailable. Ask the project owner to check model access.",
53
+ ],
54
+ provider_timeout: [
55
+ "dependency",
56
+ "resend_request",
57
+ "The model did not finish answering in time. You can send your question again.",
58
+ ],
59
+ provider_output_invalid: [
60
+ "dependency",
61
+ "resend_request",
62
+ "The model did not return a usable answer. You can send your question again.",
63
+ ],
64
+ provider_lifecycle_failed: [
65
+ "internal",
66
+ "restart_host",
67
+ "The model process could not close safely. Restart the Host before trying again.",
68
+ ],
69
+ chat_reply_persistence_failed: [
70
+ "internal",
71
+ "contact_support",
72
+ "The answer could not be saved. Check the Host diagnostics before trying again.",
73
+ ],
74
+ internal_error: [
75
+ "internal",
76
+ "contact_support",
77
+ "Tutti could not complete this read-only answer. Share the diagnostic ID if this persists.",
78
+ ],
79
+ };
80
+ export function chatFailure(code, diagnosticId = createEventRef()) {
81
+ const [category, recovery] = failures[code];
82
+ return { code, category, recovery, diagnostic_id: diagnosticId };
83
+ }
84
+ export function chatFailureMessage(failure) {
85
+ const entry = Object.hasOwn(failures, failure.code)
86
+ ? failures[failure.code]
87
+ : failures.internal_error;
88
+ return `${entry[2]} Diagnostic ID: ${failure.diagnostic_id}`;
89
+ }
90
+ // Allowlisted error facts only; neither error.message nor diagnostic callbacks
91
+ // establish business truth. Unknown failures remain non-automatically-retryable.
92
+ export function classifyChatFailure(error, diagnosticId) {
93
+ const value = typeof error === "object" && error !== null
94
+ ? error
95
+ : {};
96
+ const code = typeof value.code === "string" ? value.code : value.reason;
97
+ const mapped = typeof code === "string" && Object.hasOwn(failures, code)
98
+ ? code
99
+ : (providerFailureCode(error) ?? "internal_error");
100
+ return chatFailure(mapped, diagnosticId);
101
+ }
102
+ //# sourceMappingURL=failures.js.map
@@ -1,4 +1,4 @@
1
- import type { ProjectId } from "@tutti/shared/ids";
1
+ import { type ProjectId } from "@tutti/shared/ids";
2
2
  import type { MessageProjection } from "@tutti/shared/schemas/api";
3
3
  import { type ChatAssistantLogger } from "./diagnostics.js";
4
4
  import type { ProviderInvocationProgressObserver } from "../providers/invocation-progress.js";
@@ -33,6 +33,7 @@ export type ReadOnlyChatAssistantEnqueueResult = {
33
33
  triggered: boolean;
34
34
  };
35
35
  export type ReadOnlyChatAssistantHandle = {
36
+ register: (tx: SqliteDatabase, message: MessageProjection) => void;
36
37
  enqueue: (input: ReadOnlyChatAssistantTriggerInput) => ReadOnlyChatAssistantEnqueueResult;
37
38
  waitForIdle: () => Promise<void>;
38
39
  stopAccepting: () => void;
@@ -62,16 +63,26 @@ export declare function isChatAssistantOutput(value: unknown): value is ChatAssi
62
63
  export declare function containsTuttiMention(value: string): boolean;
63
64
  export declare class ReadOnlyChatAssistant implements ReadOnlyChatAssistantHandle {
64
65
  private readonly options;
65
- private readonly pending;
66
66
  private readonly idleWaiters;
67
67
  private active;
68
68
  private accepting;
69
69
  private readonly maxQueuedMentions;
70
+ private recoveryTimer;
71
+ private started;
72
+ private activeAttemptRef;
73
+ private storageFailureReported;
70
74
  constructor(options: ReadOnlyChatAssistantOptions);
75
+ /** Call once after Host recovery/transport wiring. No old messages are backfilled. */
76
+ start(): void;
77
+ register(tx: SqliteDatabase, message: MessageProjection): void;
78
+ private now;
79
+ private report;
71
80
  enqueue(input: ReadOnlyChatAssistantTriggerInput): ReadOnlyChatAssistantEnqueueResult;
72
81
  waitForIdle(): Promise<void>;
73
82
  stopAccepting(): void;
74
83
  private pump;
84
+ private reportStorageFailure;
85
+ private pumpNext;
75
86
  private resolveIdleIfNeeded;
76
87
  private buildMainChatContext;
77
88
  private buildClarificationRoundContext;
@@ -80,6 +91,7 @@ export declare class ReadOnlyChatAssistant implements ReadOnlyChatAssistantHandl
80
91
  private appendMainChatAssistantMessage;
81
92
  private appendClarificationRoundAssistantMessage;
82
93
  private appendAssistantMessageForTrigger;
94
+ private recordReply;
83
95
  private respond;
84
96
  }
85
97
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,5 @@
1
+ import { linkChatRequestInvocation } from "./requests.js";
2
+ import { createEventRef, ID_PREFIXES, isPrefixedId, } from "@tutti/shared/ids";
1
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";
@@ -7,6 +9,8 @@ import { withHostStoreTransaction, } from "../store/index.js";
7
9
  import { messageTextForModel, messageTextForTuttiMention, } from "../collaboration-state/message-context.js";
8
10
  import { externalReferenceMessageContext } from "../reference-access/message-context.js";
9
11
  import { readExternalReferenceReadSources } from "../reference-access/read-evidence.js";
12
+ import { chatFailure, chatFailureMessage, classifyChatFailure } from "./failures.js";
13
+ import { countQueuedChatRequests, insertChatRequest, listRunningChatRequests, nextQueuedChatRequest, readChatRequest, updateChatRequest, } from "./requests.js";
10
14
  const CHAT_ASSISTANT_CONTEXT_MESSAGE_LIMIT = 3;
11
15
  const CHAT_ASSISTANT_ROUND_MESSAGE_LIMIT = 50;
12
16
  const CHAT_ASSISTANT_ROUND_MESSAGE_MAX_CHARS = 2_000;
@@ -45,9 +49,6 @@ function normalizeAnswer(answer) {
45
49
  }
46
50
  return `${trimmed.slice(0, CHAT_ASSISTANT_MAX_ANSWER_CHARS - 3)}...`;
47
51
  }
48
- function assistantFailureMessage() {
49
- return "Tutti could not complete this read-only answer. Try again later, or add more context in chat.";
50
- }
51
52
  function messageToContext(message) {
52
53
  const authorDisplayName = message.author.display_name?.trim();
53
54
  return {
@@ -105,46 +106,112 @@ function roundMessagesToContext(messages) {
105
106
  }
106
107
  export class ReadOnlyChatAssistant {
107
108
  options;
108
- pending = [];
109
109
  idleWaiters = new Set();
110
110
  active = null;
111
111
  accepting = true;
112
112
  maxQueuedMentions;
113
+ recoveryTimer;
114
+ started = false;
115
+ activeAttemptRef;
116
+ storageFailureReported = false;
113
117
  constructor(options) {
114
118
  this.options = options;
115
- this.maxQueuedMentions = options.maxQueuedMentions ?? 8;
119
+ this.maxQueuedMentions = Math.max(1, options.maxQueuedMentions ?? 8);
116
120
  }
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 };
121
+ /** Call once after Host recovery/transport wiring. No old messages are backfilled. */
122
+ start() {
123
+ if (this.started || !this.accepting)
124
+ return;
125
+ this.started = true;
126
+ for (const row of listRunningChatRequests(this.options.store.db)) {
127
+ if (row.attempt_ref === this.activeAttemptRef)
128
+ continue;
129
+ if (row.phase === "workspace_wait") {
130
+ updateChatRequest(this.options.store.db, row, { status: "queued", phase: "queue" }, this.now());
131
+ continue;
132
+ }
133
+ const failure = chatFailure("host_restarted");
134
+ const recovered = updateChatRequest(this.options.store.db, row, {
135
+ status: "failed",
136
+ phase: "settled",
137
+ failure,
138
+ }, this.now());
139
+ const message = readMessageProjectionById(this.options.store.db, row.trigger_message_id);
140
+ if (recovered && message) {
141
+ this.report(message, recovered)({ stage: "failed", error_code: "host_restarted", diagnostic_id: failure.diagnostic_id });
142
+ try {
143
+ this.appendAssistantMessageForTrigger(message, chatFailureMessage(failure), recovered, failure);
144
+ }
145
+ catch {
146
+ this.report(message, recovered)({ stage: "failed", reason_code: "reply_failed" });
147
+ }
148
+ }
127
149
  }
128
- const report = createChatAssistantDiagnostics({
150
+ // A lost post-commit wake must not strand a durable queued request.
151
+ this.recoveryTimer = setInterval(() => this.pump(), 1_000);
152
+ this.recoveryTimer.unref?.();
153
+ this.pump();
154
+ }
155
+ register(tx, message) {
156
+ if (!this.accepting ||
157
+ (message.author.kind !== "human" &&
158
+ !(message.author.kind === "system" && message.refs?.development_operator === true)) ||
159
+ !containsTuttiMention(messageTextForTuttiMention(message)) ||
160
+ readChatRequest(tx, message.id) !== null)
161
+ return;
162
+ insertChatRequest(tx, message.id, this.now(), countQueuedChatRequests(tx) >= this.maxQueuedMentions
163
+ ? chatFailure("chat_queue_full")
164
+ : undefined);
165
+ }
166
+ now() {
167
+ return (this.options.now?.() ?? new Date()).toISOString();
168
+ }
169
+ report(message, request) {
170
+ return createChatAssistantDiagnostics({
129
171
  ...(this.options.logger === undefined ? {} : { logger: this.options.logger }),
130
172
  projectId: this.options.project.project_id,
131
- triggerMessageId: input.message.id,
132
- scope: input.message.scope.kind,
173
+ triggerMessageId: message.id,
174
+ scope: message.scope.kind,
175
+ requestRef: message.id,
176
+ ...(request.attempt_ref === null ? {} : { attemptRef: request.attempt_ref }),
133
177
  });
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",
178
+ }
179
+ enqueue(input) {
180
+ if (!this.accepting)
181
+ return { triggered: false };
182
+ withHostStoreTransaction(this.options.store.db, (tx) => this.register(tx, input.message));
183
+ const request = readChatRequest(this.options.store.db, input.message.id);
184
+ if (request === null)
185
+ return { triggered: false };
186
+ if (request.status === "failed" &&
187
+ request.response_message_id === null &&
188
+ request.failure_json !== null) {
189
+ const report = this.report(input.message, request);
190
+ const failure = JSON.parse(request.failure_json);
191
+ report({
192
+ stage: "failed",
193
+ reason_code: failure.code === "chat_queue_full" ? "queue_full" : "reply_failed",
194
+ diagnostic_id: failure.diagnostic_id,
138
195
  });
196
+ try {
197
+ this.appendAssistantMessageForTrigger(input.message, chatFailureMessage(failure), request, failure);
198
+ }
199
+ catch {
200
+ // The original message and failed request are already accepted.
201
+ report({
202
+ stage: "notification_failed",
203
+ reason_code: "reply_failed",
204
+ diagnostic_id: failure.diagnostic_id,
205
+ });
206
+ }
139
207
  return { triggered: true };
140
208
  }
141
- report({ stage: "queued" });
142
- this.pending.push({ message: input.message, report });
143
209
  this.pump();
144
210
  return { triggered: true };
145
211
  }
146
212
  async waitForIdle() {
147
- if (this.active === null && this.pending.length === 0) {
213
+ if (this.active === null &&
214
+ (!this.accepting || nextQueuedChatRequest(this.options.store.db) === null)) {
148
215
  return;
149
216
  }
150
217
  await new Promise((resolve) => this.idleWaiters.add(resolve));
@@ -154,36 +221,102 @@ export class ReadOnlyChatAssistant {
154
221
  return;
155
222
  }
156
223
  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
- }
224
+ if (this.recoveryTimer !== undefined)
225
+ clearInterval(this.recoveryTimer);
163
226
  this.resolveIdleIfNeeded();
164
227
  }
165
228
  pump() {
166
- if (this.active !== null) {
229
+ try {
230
+ this.pumpNext();
231
+ this.storageFailureReported = false;
232
+ }
233
+ catch {
234
+ this.reportStorageFailure();
235
+ }
236
+ }
237
+ reportStorageFailure() {
238
+ if (this.storageFailureReported)
239
+ return;
240
+ this.storageFailureReported = true;
241
+ try {
242
+ this.options.logger?.warn({
243
+ project_id: this.options.project.project_id,
244
+ stage: "queue_storage_failed",
245
+ diagnostic_id: createEventRef(),
246
+ }, "Chat request queue storage unavailable");
247
+ }
248
+ catch {
249
+ /* Diagnostics cannot replace durable recovery. */
250
+ }
251
+ }
252
+ pumpNext() {
253
+ if (this.active !== null)
254
+ return;
255
+ if (!this.accepting) {
256
+ this.resolveIdleIfNeeded();
167
257
  return;
168
258
  }
169
- const pending = this.pending.shift();
170
- if (pending === undefined) {
259
+ const pending = nextQueuedChatRequest(this.options.store.db);
260
+ if (pending === null) {
171
261
  this.resolveIdleIfNeeded();
172
262
  return;
173
263
  }
174
- const task = this.respond(pending.message, pending.report).catch(() => {
175
- pending.report({ stage: "failed", reason_code: "reply_failed" });
264
+ const request = updateChatRequest(this.options.store.db, pending, {
265
+ status: "running",
266
+ phase: "workspace_wait",
267
+ attemptRef: createEventRef(),
268
+ }, this.now());
269
+ if (request === null)
270
+ return;
271
+ this.activeAttemptRef = request.attempt_ref;
272
+ const message = readMessageProjectionById(this.options.store.db, request.trigger_message_id);
273
+ if (message === null)
274
+ throw new Error("chat_request_source_missing");
275
+ const report = this.report(message, request);
276
+ report({ stage: "queued" });
277
+ const task = this.respond(message, request, report).catch(() => {
278
+ // Do not overwrite a committed success when only notification delivery failed.
279
+ try {
280
+ const current = readChatRequest(this.options.store.db, message.id);
281
+ if (current?.status === "running" && current.attempt_ref === request.attempt_ref) {
282
+ const failure = chatFailure("chat_reply_persistence_failed", request.attempt_ref);
283
+ updateChatRequest(this.options.store.db, current, {
284
+ status: "failed",
285
+ phase: "settled",
286
+ failure,
287
+ }, this.now());
288
+ report({
289
+ stage: "failed",
290
+ reason_code: "reply_failed",
291
+ error_code: "chat_reply_persistence_failed",
292
+ diagnostic_id: failure.diagnostic_id,
293
+ });
294
+ }
295
+ else if (current !== null &&
296
+ current.response_message_id !== null &&
297
+ current.attempt_ref === request.attempt_ref) {
298
+ report({ stage: "notification_failed", reason_code: "reply_failed" });
299
+ }
300
+ else {
301
+ report({ stage: "skipped", reason_code: "request_changed" });
302
+ }
303
+ }
304
+ catch {
305
+ this.reportStorageFailure();
306
+ }
176
307
  });
177
308
  this.active = task;
178
309
  void task.finally(() => {
179
310
  if (this.active === task) {
180
311
  this.active = null;
312
+ this.activeAttemptRef = undefined;
181
313
  this.pump();
182
314
  }
183
315
  });
184
316
  }
185
317
  resolveIdleIfNeeded() {
186
- if (this.active !== null || this.pending.length !== 0) {
318
+ if (this.active !== null ||
319
+ (this.accepting && nextQueuedChatRequest(this.options.store.db) !== null)) {
187
320
  return;
188
321
  }
189
322
  for (const resolve of this.idleWaiters) {
@@ -263,7 +396,7 @@ export class ReadOnlyChatAssistant {
263
396
  },
264
397
  }, language);
265
398
  }
266
- appendMainChatAssistantMessage(triggerMessage, body, refs) {
399
+ appendMainChatAssistantMessage(triggerMessage, body, request, failure, refs) {
267
400
  let afterCommit;
268
401
  const message = withHostStoreTransaction(this.options.store.db, (tx) => {
269
402
  const responseMessage = createMainChatAgentMessage(tx, {
@@ -279,6 +412,7 @@ export class ReadOnlyChatAssistant {
279
412
  if (typeof callback === "function") {
280
413
  afterCommit = callback;
281
414
  }
415
+ this.recordReply(tx, request, responseMessage.id, failure);
282
416
  return responseMessage;
283
417
  });
284
418
  afterCommit?.();
@@ -310,6 +444,7 @@ export class ReadOnlyChatAssistant {
310
444
  });
311
445
  if (typeof callback === "function")
312
446
  afterCommit = callback;
447
+ this.recordReply(tx, input.request, responseMessage.id, input.failure);
313
448
  return responseMessage;
314
449
  });
315
450
  afterCommit?.();
@@ -332,36 +467,75 @@ export class ReadOnlyChatAssistant {
332
467
  invalidates,
333
468
  });
334
469
  }
335
- appendAssistantMessageForTrigger(triggerMessage, body, refs) {
470
+ appendAssistantMessageForTrigger(triggerMessage, body, request, failure, refs) {
471
+ if (failure !== undefined)
472
+ refs = { ...refs, scratchpad_source: "exclude" };
336
473
  if (triggerMessage.scope.kind === "clarification_round") {
337
474
  this.appendClarificationRoundAssistantMessage({
338
475
  triggerMessage,
339
476
  roundId: triggerMessage.scope.clarification_round_ref,
340
477
  body,
478
+ request,
479
+ ...(failure === undefined ? {} : { failure }),
341
480
  ...(refs === undefined ? {} : { refs }),
342
481
  });
343
482
  return;
344
483
  }
345
- this.appendMainChatAssistantMessage(triggerMessage, body, refs);
484
+ this.appendMainChatAssistantMessage(triggerMessage, body, request, failure, refs);
485
+ }
486
+ recordReply(tx, request, messageId, failure) {
487
+ if (!updateChatRequest(tx, request, {
488
+ status: failure ? "failed" : "succeeded",
489
+ phase: "settled",
490
+ responseMessageId: messageId,
491
+ ...(failure === undefined ? {} : { failure }),
492
+ }, this.now()))
493
+ throw new Error("chat_request_changed");
346
494
  }
347
- async respond(triggerMessage, report) {
495
+ async respond(triggerMessage, request, report) {
348
496
  let answer;
349
497
  let invocationRef;
350
498
  let readSources = [];
351
499
  try {
352
500
  report({ stage: "inspection_wait" });
353
501
  await this.options.waitForInspectionAvailability?.();
502
+ if (!this.accepting) {
503
+ updateChatRequest(this.options.store.db, request, { status: "queued", phase: "queue" }, this.now());
504
+ return;
505
+ }
354
506
  const prompt = this.renderPrompt(triggerMessage, report);
355
507
  if (prompt === null) {
356
508
  report({ stage: "skipped", reason_code: "context_unavailable" });
509
+ updateChatRequest(this.options.store.db, request, {
510
+ status: "failed",
511
+ phase: "settled",
512
+ failure: chatFailure("chat_context_unavailable", request.attempt_ref),
513
+ }, this.now());
357
514
  return;
358
515
  }
516
+ const executing = updateChatRequest(this.options.store.db, request, {
517
+ status: "running",
518
+ phase: "answer",
519
+ }, this.now());
520
+ if (executing === null)
521
+ return;
522
+ request = executing;
359
523
  report({ stage: "model_requested" });
360
524
  const output = await this.options.model.answer({
361
525
  prompt,
362
526
  onProgress: (event) => {
363
- if (event.stage === "context_ready")
364
- invocationRef = event.invocation_ref;
527
+ if (event.stage === "context_ready") {
528
+ if (typeof event.invocation_ref === "string" &&
529
+ isPrefixedId(event.invocation_ref, ID_PREFIXES.event)) {
530
+ invocationRef = event.invocation_ref;
531
+ try {
532
+ linkChatRequestInvocation(this.options.store.db, request, invocationRef);
533
+ }
534
+ catch {
535
+ report({ stage: "notification_failed" });
536
+ }
537
+ }
538
+ }
365
539
  report(event);
366
540
  },
367
541
  });
@@ -370,16 +544,20 @@ export class ReadOnlyChatAssistant {
370
544
  readSources = readExternalReferenceReadSources(this.options.store.db, invocationRef);
371
545
  }
372
546
  }
373
- catch {
374
- report({ stage: "failed", reason_code: "answer_failed" });
375
- this.appendAssistantMessageForTrigger(triggerMessage, assistantFailureMessage(), {
376
- scratchpad_source: "exclude",
547
+ catch (error) {
548
+ const failure = classifyChatFailure(error, request.attempt_ref);
549
+ report({
550
+ stage: "failed",
551
+ reason_code: "answer_failed",
552
+ error_code: failure.code,
553
+ diagnostic_id: failure.diagnostic_id,
377
554
  });
555
+ this.appendAssistantMessageForTrigger(triggerMessage, chatFailureMessage(failure), request, failure);
378
556
  report({ stage: "reply_persisted" });
379
557
  return;
380
558
  }
381
559
  // 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 });
560
+ this.appendAssistantMessageForTrigger(triggerMessage, answer, request, undefined, readSources.length === 0 ? undefined : { external_reference_reads: readSources });
383
561
  report({ stage: "reply_persisted", reference_source_count: readSources.length });
384
562
  }
385
563
  }