@vellumai/assistant 0.11.8 → 0.11.9-staging.2
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/ARCHITECTURE.md +2 -2
- package/Dockerfile +8 -48
- package/docker-entrypoint.sh +5 -1
- package/docker-kata-apt-env.sh +3 -0
- package/docker-kata-apt-shims.sh +127 -0
- package/docker-kata-apt-wrapper.sh +45 -0
- package/docker-kata-chroot-exec.sh +35 -0
- package/docker-kata-pip.sh +8 -2
- package/docs/architecture/memory.md +8 -4
- package/docs/guardian-request-flow.md +18 -14
- package/docs/trusted-contact-access.md +1 -7
- package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
- package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
- package/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/openapi.yaml +193 -4
- package/package.json +2 -2
- package/src/__tests__/access-request-card-view.test.ts +6 -5
- package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
- package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
- package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
- package/src/__tests__/attachments-store.test.ts +27 -0
- package/src/__tests__/attachments.test.ts +43 -0
- package/src/__tests__/canned-reply-release.test.ts +48 -5
- package/src/__tests__/channel-delivery-store.test.ts +129 -18
- package/src/__tests__/channel-reply-delivery.test.ts +553 -93
- package/src/__tests__/channel-retry-sweep.test.ts +199 -0
- package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
- package/src/__tests__/conversation-error.test.ts +15 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
- package/src/__tests__/conversation-pairing.test.ts +141 -1
- package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
- package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
- package/src/__tests__/conversation-sync-tags.test.ts +2 -37
- package/src/__tests__/credential-health-service.test.ts +98 -10
- package/src/__tests__/delete-propagation.test.ts +469 -0
- package/src/__tests__/dm-persistence.test.ts +16 -0
- package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
- package/src/__tests__/gemini-provider.test.ts +46 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
- package/src/__tests__/guardian-gateway-sim.ts +0 -23
- package/src/__tests__/guardian-question-mode.test.ts +108 -0
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
- package/src/__tests__/host-proxy-interface.test.ts +10 -0
- package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
- package/src/__tests__/injector-v3-suppression.test.ts +43 -9
- package/src/__tests__/list-messages-page-latest.test.ts +127 -0
- package/src/__tests__/list-messages-system-card.test.ts +103 -0
- package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
- package/src/__tests__/notification-decision-fallback.test.ts +199 -77
- package/src/__tests__/notification-decision-strategy.test.ts +198 -213
- package/src/__tests__/notification-discord-adapter.test.ts +25 -0
- package/src/__tests__/notification-slack-adapter.test.ts +133 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
- package/src/__tests__/openai-provider.test.ts +5 -5
- package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
- package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
- package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
- package/src/__tests__/pricing.test.ts +11 -0
- package/src/__tests__/process-message-display-content.test.ts +20 -9
- package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
- package/src/__tests__/provider-meta-persistence.test.ts +67 -0
- package/src/__tests__/reaction-persistence.test.ts +22 -198
- package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
- package/src/__tests__/skill-load-tool.test.ts +27 -0
- package/src/__tests__/skills.test.ts +34 -1
- package/src/__tests__/strip-memory-injections.test.ts +3 -4
- package/src/__tests__/terminal-tools.test.ts +9 -0
- package/src/__tests__/thread-backfill.test.ts +5 -3
- package/src/__tests__/unified-turn-context-location.test.ts +76 -0
- package/src/__tests__/voice-session-bridge.test.ts +18 -0
- package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
- package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
- package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
- package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
- package/src/agent/attachments.ts +13 -2
- package/src/agent/loop.ts +3 -19
- package/src/api/README.md +9 -5
- package/src/api/index.ts +9 -8
- package/src/api/package.json +1 -0
- package/src/api/responses/conversation-message.ts +8 -0
- package/src/api/responses/home.ts +7 -18
- package/src/api/surfaces.ts +114 -7
- package/src/approvals/AGENTS.md +1 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
- package/src/channels/__tests__/types.test.ts +22 -1
- package/src/channels/gateway-guardian-requests.ts +0 -22
- package/src/channels/types.ts +8 -6
- package/src/cli/__tests__/catalog-search-help.test.ts +18 -0
- package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
- package/src/cli/commands/channels/index.help.ts +19 -6
- package/src/cli/commands/channels/index.ts +6 -2
- package/src/cli/commands/db/__tests__/status.test.ts +22 -0
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +172 -1
- package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
- package/src/cli/commands/platform/connect.ts +14 -5
- package/src/cli/commands/plugins.help.ts +6 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
- package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
- package/src/cli/lib/bundled-marketplace.json +14 -0
- package/src/cli/lib/install-from-github.ts +9 -5
- package/src/cli/lib/install-from-platform.ts +12 -1
- package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
- package/src/config/assistant-initiated-threads-gate.ts +50 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -3
- package/src/config/bundled-skills/schedule/SKILL.md +25 -11
- package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
- package/src/config/call-site-defaults.ts +4 -1
- package/src/config/feature-flag-registry.json +21 -4
- package/src/config/schemas/memory-v3.ts +4 -3
- package/src/context/strip-injections.ts +17 -56
- package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
- package/src/conversations/message-consolidation.ts +4 -3
- package/src/credential-health/credential-health-service.ts +130 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
- package/src/daemon/conversation-error.ts +2 -0
- package/src/daemon/conversation-messaging.ts +195 -47
- package/src/daemon/conversation-process.ts +20 -5
- package/src/daemon/conversation-runtime-assembly.ts +48 -39
- package/src/daemon/conversation-store.ts +159 -11
- package/src/daemon/conversation-tool-setup.ts +9 -2
- package/src/daemon/conversation.ts +290 -19
- package/src/daemon/dictation-text-processing.ts +2 -7
- package/src/daemon/handlers/config-channels.ts +33 -3
- package/src/daemon/handlers/config-model.test.ts +1 -0
- package/src/daemon/handlers/shared.ts +9 -0
- package/src/daemon/port-oversized-content.test.ts +117 -0
- package/src/daemon/port-oversized-content.ts +110 -0
- package/src/daemon/process-message.ts +87 -16
- package/src/daemon/reaction-record.test.ts +23 -11
- package/src/daemon/reaction-record.ts +6 -9
- package/src/documents/document-store.ts +1 -5
- package/src/home/conversation-starter-validation.ts +1 -5
- package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
- package/src/live-voice/live-voice-photo.ts +544 -61
- package/src/live-voice/live-voice-session.ts +6 -2
- package/src/live-voice/protocol.ts +20 -1
- package/src/messaging/provider-message-metadata.ts +40 -1
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
- package/src/messaging/providers/channel-transport.ts +28 -0
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/slack/api.ts +11 -1
- package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
- package/src/messaging/providers/slack/message-metadata.ts +141 -1
- package/src/messaging/providers/slack/send.test.ts +58 -0
- package/src/messaging/providers/slack/send.ts +4 -4
- package/src/messaging/providers/slack/transport.ts +28 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
- package/src/messaging/providers/telegram-bot/send.ts +93 -0
- package/src/messaging/providers/telegram-bot/transport.ts +71 -0
- package/src/messaging/reaction-envelopes.test.ts +73 -0
- package/src/messaging/reaction-envelopes.ts +33 -5
- package/src/messaging/read-provider-metadata.ts +4 -3
- package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
- package/src/monitoring/recovery/db.ts +35 -0
- package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
- package/src/monitoring/recovery/run-recovery.ts +6 -2
- package/src/monitoring/recovery/stale-processing.ts +3 -20
- package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
- package/src/notifications/AGENTS.md +2 -2
- package/src/notifications/README.md +2 -2
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
- package/src/notifications/__tests__/broadcaster.test.ts +35 -4
- package/src/notifications/__tests__/notification-utils.test.ts +31 -0
- package/src/notifications/access-request-copy.ts +126 -114
- package/src/notifications/adapters/discord.ts +9 -5
- package/src/notifications/adapters/shared.ts +17 -4
- package/src/notifications/adapters/slack.ts +14 -4
- package/src/notifications/adapters/telegram.ts +8 -4
- package/src/notifications/approval-card-data.ts +5 -2
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/broadcaster.ts +57 -25
- package/src/notifications/conversation-pairing.ts +68 -2
- package/src/notifications/copy-composer.ts +13 -32
- package/src/notifications/decision-engine.ts +56 -237
- package/src/notifications/guardian-delivery-recorder.ts +5 -9
- package/src/notifications/guardian-feed-projection.ts +0 -15
- package/src/notifications/guardian-question-mode.ts +147 -6
- package/src/notifications/notification-utils.ts +117 -1
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/prompter.ts +1 -1
- package/src/persistence/conversation-crud.ts +93 -14
- package/src/persistence/conversation-queries.ts +144 -17
- package/src/persistence/conversation-types.ts +39 -5
- package/src/persistence/delivery-crud.ts +224 -28
- package/src/persistence/delivery-status.ts +21 -2
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
- package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
- package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
- package/src/persistence/schema/conversations.ts +71 -2
- package/src/persistence/schema-contract.test.ts +78 -0
- package/src/persistence/schema-contract.ts +96 -0
- package/src/persistence/steps.ts +4 -0
- package/src/platform/client.ts +55 -0
- package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
- package/src/plugins/defaults/injector-order.ts +2 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
- package/src/plugins/defaults/memory/injectors.ts +1 -1
- package/src/plugins/defaults/memory/memory-marker.ts +17 -7
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
- package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +17 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +22 -0
- package/src/plugins/defaults/memory/v3/injector.ts +14 -15
- package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
- package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
- package/src/plugins/defaults/memory/v3/types.ts +15 -4
- package/src/plugins/defaults/turn-context/injectors.ts +3 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
- package/src/plugins/mcp-servers.ts +14 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
- package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
- package/src/providers/anthropic/client.ts +21 -24
- package/src/providers/call-site-routing.ts +5 -4
- package/src/providers/connection-resolution.ts +29 -1
- package/src/providers/content-block-size.test.ts +82 -0
- package/src/providers/content-block-size.ts +99 -0
- package/src/providers/file-block-text.test.ts +64 -0
- package/src/providers/file-block-text.ts +28 -0
- package/src/providers/gemini/client.ts +15 -9
- package/src/providers/inference/auth.ts +6 -6
- package/src/providers/media-resolve.ts +14 -0
- package/src/providers/model-catalog.ts +47 -34
- package/src/providers/openai/chat-completions-provider.ts +9 -25
- package/src/providers/openai/responses-provider.ts +11 -18
- package/src/providers/registry.ts +10 -7
- package/src/providers/routing-identity.ts +2 -1
- package/src/providers/types.ts +1 -4
- package/src/providers/vellum-model-routing.ts +3 -2
- package/src/runtime/AGENTS.md +40 -15
- package/src/runtime/approval-message-composer.ts +1 -6
- package/src/runtime/assistant-event-hub.ts +2 -39
- package/src/runtime/channel-approval-types.ts +1 -5
- package/src/runtime/channel-reply-delivery.ts +182 -115
- package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
- package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
- package/src/runtime/channel-retry-sweep.ts +102 -1
- package/src/runtime/finalize-event-delivery.ts +4 -4
- package/src/runtime/guardian-action-message-composer.ts +1 -4
- package/src/runtime/guardian-reply-router.ts +4 -75
- package/src/runtime/http-router.ts +1 -5
- package/src/runtime/question-request-guardian-bridge.ts +2 -3
- package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
- package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
- package/src/runtime/routes/acp-routes.ts +1 -1
- package/src/runtime/routes/canned-reply-release.ts +19 -9
- package/src/runtime/routes/channel-route-shared.ts +0 -39
- package/src/runtime/routes/channel-verification-routes.ts +4 -1
- package/src/runtime/routes/conversation-list-routes.ts +44 -3
- package/src/runtime/routes/conversation-management-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +138 -59
- package/src/runtime/routes/diagnostics-routes.ts +111 -60
- package/src/runtime/routes/guardian-action-routes.ts +10 -14
- package/src/runtime/routes/guardian-approval-interception.ts +0 -5
- package/src/runtime/routes/inbound-message-handler.ts +170 -48
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +99 -10
- package/src/runtime/routes/sight-frame-routes.ts +136 -0
- package/src/runtime/sync/resource-sync-events.ts +14 -37
- package/src/runtime/sync/sync-publisher.test.ts +5 -4
- package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
- package/src/tools/client-os.ts +11 -1
- package/src/tools/host-filesystem/edit.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/transfer.ts +2 -2
- package/src/tools/host-filesystem/write.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +2 -2
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/terminal/safe-env.ts +4 -0
- package/src/tools/tool-input-schemas.ts +2 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +4 -3
- package/src/tools/watch/watch-retro-report.ts +205 -0
- package/src/watch/__tests__/watch-retro.test.ts +268 -40
- package/src/watch/watch-retro.ts +190 -77
- package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
- package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
- package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +4 -0
- package/docker-kata-pip-chroot.sh +0 -22
- package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
- package/src/api/events/conversation-list-invalidated.ts +0 -38
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
createMockProvider,
|
|
@@ -10,12 +12,18 @@ import { waitFor } from "../../__tests__/helpers/wait-for.js";
|
|
|
10
12
|
setConfig("memory", { enabled: false });
|
|
11
13
|
|
|
12
14
|
import type { AssistantEvent } from "../../api/index.js";
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
Conversation,
|
|
17
|
+
ProcessingClaimLostError,
|
|
18
|
+
} from "../../daemon/conversation.js";
|
|
14
19
|
import {
|
|
15
20
|
deleteConversation,
|
|
16
21
|
setConversation,
|
|
17
22
|
} from "../../daemon/conversation-registry.js";
|
|
18
23
|
import { applySightFrameRetention } from "../../daemon/conversation-runtime-assembly.js";
|
|
24
|
+
import { destroyActiveConversation } from "../../daemon/conversation-store.js";
|
|
25
|
+
import * as portOversized from "../../daemon/port-oversized-content.js";
|
|
26
|
+
import * as attachmentsStore from "../../persistence/attachments-store.js";
|
|
19
27
|
import {
|
|
20
28
|
getAttachmentById,
|
|
21
29
|
getAttachmentsForMessage,
|
|
@@ -25,23 +33,135 @@ import {
|
|
|
25
33
|
import {
|
|
26
34
|
addMessage,
|
|
27
35
|
createConversation,
|
|
36
|
+
deleteConversation as deleteConversationRows,
|
|
37
|
+
getConversation,
|
|
28
38
|
getMessages,
|
|
29
39
|
type MessageRow,
|
|
30
40
|
selectSightFrameCaptureTimes,
|
|
31
41
|
} from "../../persistence/conversation-crud.js";
|
|
32
42
|
import { sightFrameAttachmentIdsFromMetadata } from "../../persistence/conversation-types.js";
|
|
43
|
+
import * as dbConnection from "../../persistence/db-connection.js";
|
|
44
|
+
import { getDb } from "../../persistence/db-connection.js";
|
|
33
45
|
import { initializeDb } from "../../persistence/db-init.js";
|
|
46
|
+
import { attachments, conversations } from "../../persistence/schema/index.js";
|
|
47
|
+
import * as slowSyncLog from "../../persistence/slow-sync-log.js";
|
|
34
48
|
import { mediaBlockAttachmentId, type Message } from "../../providers/types.js";
|
|
35
49
|
import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
|
|
36
50
|
import {
|
|
51
|
+
_drainFrameReclaimRechecksForTests,
|
|
52
|
+
_nextFrameReclaimRecheckDelayForTests,
|
|
53
|
+
_pendingFrameReclaimCountForTests,
|
|
37
54
|
_setProcessingWaitMsForTests,
|
|
38
55
|
_standaloneImageQueueSizeForTests,
|
|
56
|
+
persistAmbientSightFrame,
|
|
39
57
|
persistLiveVoicePhoto,
|
|
40
|
-
persistLiveVoiceSightFrame,
|
|
41
58
|
} from "../live-voice-photo.js";
|
|
42
59
|
|
|
43
60
|
await initializeDb();
|
|
44
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Run something in the window the persist opens between materializing an
|
|
64
|
+
* attachment and inserting its row.
|
|
65
|
+
*
|
|
66
|
+
* `offloadOversizedText` is the last step the persist awaits before the
|
|
67
|
+
* insert, so a hook here lands inside that window without a timer to race.
|
|
68
|
+
* Armed per test and cleared as it fires; every other test leaves it null
|
|
69
|
+
* and gets the real function.
|
|
70
|
+
*/
|
|
71
|
+
let duringPersistBeforeInsert: (() => void) | null = null;
|
|
72
|
+
const realPortOversized = { ...portOversized };
|
|
73
|
+
mock.module("../../daemon/port-oversized-content.js", () => ({
|
|
74
|
+
...realPortOversized,
|
|
75
|
+
offloadOversizedText: (
|
|
76
|
+
...args: Parameters<typeof realPortOversized.offloadOversizedText>
|
|
77
|
+
) => {
|
|
78
|
+
const hook = duringPersistBeforeInsert;
|
|
79
|
+
duringPersistBeforeInsert = null;
|
|
80
|
+
hook?.();
|
|
81
|
+
return realPortOversized.offloadOversizedText(...args);
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fail the next message insert the way SQLite reports contention, running
|
|
87
|
+
* something first.
|
|
88
|
+
*
|
|
89
|
+
* `SQLITE_BUSY` is the class `withSqliteRetry` retries, so the attempt this
|
|
90
|
+
* refuses is followed by an awaited backoff and another attempt. The hook runs
|
|
91
|
+
* in place of the statement, which is a window no row was written in. Armed
|
|
92
|
+
* per test and cleared as it fires; every other test leaves it null and the
|
|
93
|
+
* insert runs for real the first time.
|
|
94
|
+
*/
|
|
95
|
+
let beforeFailingInsertAttempt: (() => void) | null = null;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Make every store read throw, which is what leaves a persist unable to say
|
|
99
|
+
* whether its row landed.
|
|
100
|
+
*/
|
|
101
|
+
let storeUnreadable = false;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Break the store the instant a row has committed rather than before it, which
|
|
105
|
+
* is the one shape where "could not read" and "the frame is in the transcript"
|
|
106
|
+
* are both true.
|
|
107
|
+
*/
|
|
108
|
+
let breakStoreAfterInsert = false;
|
|
109
|
+
|
|
110
|
+
const realSlowSyncLog = { ...slowSyncLog };
|
|
111
|
+
mock.module("../../persistence/slow-sync-log.js", () => ({
|
|
112
|
+
...realSlowSyncLog,
|
|
113
|
+
timeSyncSection: <T>(
|
|
114
|
+
label: string,
|
|
115
|
+
fn: () => T,
|
|
116
|
+
detail?: (result: T) => Record<string, unknown>,
|
|
117
|
+
): T => {
|
|
118
|
+
if (label === "messages:insert" && beforeFailingInsertAttempt) {
|
|
119
|
+
const hook = beforeFailingInsertAttempt;
|
|
120
|
+
beforeFailingInsertAttempt = null;
|
|
121
|
+
hook();
|
|
122
|
+
throw Object.assign(new Error("database is locked"), {
|
|
123
|
+
code: "SQLITE_BUSY",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const result = realSlowSyncLog.timeSyncSection(label, fn, detail);
|
|
127
|
+
if (label === "messages:insert" && breakStoreAfterInsert) {
|
|
128
|
+
breakStoreAfterInsert = false;
|
|
129
|
+
storeUnreadable = true;
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fail the next orphan delete the way contention does, which is the one thing
|
|
137
|
+
* standing between a dropped frame and its bytes.
|
|
138
|
+
*/
|
|
139
|
+
let failNextReclaims = 0;
|
|
140
|
+
const realAttachmentsStore = { ...attachmentsStore };
|
|
141
|
+
mock.module("../../persistence/attachments-store.js", () => ({
|
|
142
|
+
...realAttachmentsStore,
|
|
143
|
+
deleteOrphanAttachments: (ids: string[]) => {
|
|
144
|
+
if (failNextReclaims > 0) {
|
|
145
|
+
failNextReclaims -= 1;
|
|
146
|
+
throw Object.assign(new Error("database is locked"), {
|
|
147
|
+
code: "SQLITE_BUSY",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return realAttachmentsStore.deleteOrphanAttachments(ids);
|
|
151
|
+
},
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
const realDbConnection = { ...dbConnection };
|
|
155
|
+
mock.module("../../persistence/db-connection.js", () => ({
|
|
156
|
+
...realDbConnection,
|
|
157
|
+
getDb: () => {
|
|
158
|
+
if (storeUnreadable) {
|
|
159
|
+
throw new Error("database is not readable");
|
|
160
|
+
}
|
|
161
|
+
return realDbConnection.getDb();
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
|
|
45
165
|
const IMAGE_BASE64 =
|
|
46
166
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk";
|
|
47
167
|
|
|
@@ -92,12 +212,25 @@ function watchProcessing(conversation: Conversation): {
|
|
|
92
212
|
maxDepth: number;
|
|
93
213
|
} {
|
|
94
214
|
const seen = { calls: [] as boolean[], depth: 0, maxDepth: 0 };
|
|
95
|
-
const
|
|
96
|
-
conversation.setProcessing = (value: boolean): void => {
|
|
215
|
+
const record = (value: boolean): void => {
|
|
97
216
|
seen.calls.push(value);
|
|
98
217
|
seen.depth += value ? 1 : -1;
|
|
99
218
|
seen.maxDepth = Math.max(seen.maxDepth, seen.depth);
|
|
100
|
-
|
|
219
|
+
};
|
|
220
|
+
const originalSet = conversation.setProcessing.bind(conversation);
|
|
221
|
+
conversation.setProcessing = (value: boolean): void => {
|
|
222
|
+
record(value);
|
|
223
|
+
originalSet(value);
|
|
224
|
+
};
|
|
225
|
+
// Standalone images take the flag through the owned acquire, and release it
|
|
226
|
+
// through `releaseProcessing`, which clears via `setProcessing` above.
|
|
227
|
+
const originalAcquire = conversation.acquireProcessing.bind(conversation);
|
|
228
|
+
conversation.acquireProcessing = (): number | null => {
|
|
229
|
+
const owner = originalAcquire();
|
|
230
|
+
if (owner !== null) {
|
|
231
|
+
record(true);
|
|
232
|
+
}
|
|
233
|
+
return owner;
|
|
101
234
|
};
|
|
102
235
|
return seen;
|
|
103
236
|
}
|
|
@@ -112,6 +245,36 @@ function frameStored(attachmentId: string): boolean {
|
|
|
112
245
|
return getAttachmentById(attachmentId) !== null;
|
|
113
246
|
}
|
|
114
247
|
|
|
248
|
+
/** Collect the row announcements the hub carries for one conversation. */
|
|
249
|
+
function watchEchoes(conversationId: string) {
|
|
250
|
+
const published: AssistantEvent[] = [];
|
|
251
|
+
const subscription = assistantEventHub.subscribe({
|
|
252
|
+
type: "process",
|
|
253
|
+
filter: { conversationId },
|
|
254
|
+
callback: (event) => {
|
|
255
|
+
published.push(event.message);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
echoes: () => published.filter((e) => e.type === "user_message_echo"),
|
|
260
|
+
dispose: () => subscription.dispose(),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Every attachment row in the store, so a test can spot one made since. */
|
|
265
|
+
function allAttachmentIds(): string[] {
|
|
266
|
+
return getDb()
|
|
267
|
+
.select({ id: attachments.id })
|
|
268
|
+
.from(attachments)
|
|
269
|
+
.all()
|
|
270
|
+
.map((row) => row.id);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function conversationCount(): number {
|
|
274
|
+
return getDb().select({ id: conversations.id }).from(conversations).all()
|
|
275
|
+
.length;
|
|
276
|
+
}
|
|
277
|
+
|
|
115
278
|
/**
|
|
116
279
|
* Attachment ids the conversation's LIVE history is attributable by, which is
|
|
117
280
|
* the array a turn sends rather than the rows a reload would rebuild.
|
|
@@ -129,30 +292,27 @@ function liveImageIds(conversation: Conversation): Array<string | undefined> {
|
|
|
129
292
|
}
|
|
130
293
|
|
|
131
294
|
/**
|
|
132
|
-
* Let a turn try to start in the instant right after the persist
|
|
133
|
-
* flag
|
|
134
|
-
*
|
|
135
|
-
* and its take.
|
|
295
|
+
* Let a turn try to start in the instant right after the persist takes the
|
|
296
|
+
* flag. The turn models a correct acquirer: it claims only what it finds free,
|
|
297
|
+
* so it succeeds exactly when the persist left the flag takeable.
|
|
136
298
|
*/
|
|
137
299
|
function armTurnStartInTheGap(conversation: Conversation): {
|
|
138
300
|
claimed: () => boolean;
|
|
139
301
|
} {
|
|
140
302
|
let armed = true;
|
|
141
303
|
let claimed = false;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (armed && !busy) {
|
|
304
|
+
const acquire = conversation.acquireProcessing.bind(conversation);
|
|
305
|
+
conversation.acquireProcessing = (): number | null => {
|
|
306
|
+
const owner = acquire();
|
|
307
|
+
if (armed && owner !== null) {
|
|
147
308
|
armed = false;
|
|
148
309
|
queueMicrotask(() => {
|
|
149
|
-
if (
|
|
150
|
-
writeFlag(true);
|
|
310
|
+
if (acquire() !== null) {
|
|
151
311
|
claimed = true;
|
|
152
312
|
}
|
|
153
313
|
});
|
|
154
314
|
}
|
|
155
|
-
return
|
|
315
|
+
return owner;
|
|
156
316
|
};
|
|
157
317
|
return { claimed: () => claimed };
|
|
158
318
|
}
|
|
@@ -300,7 +460,7 @@ describe("persistLiveVoicePhoto", () => {
|
|
|
300
460
|
});
|
|
301
461
|
});
|
|
302
462
|
|
|
303
|
-
describe("
|
|
463
|
+
describe("persistAmbientSightFrame", () => {
|
|
304
464
|
test("tags the row with the attachment it carries", async () => {
|
|
305
465
|
const live = liveConversation("Live voice sight frame");
|
|
306
466
|
try {
|
|
@@ -310,7 +470,11 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
310
470
|
IMAGE_BASE64,
|
|
311
471
|
);
|
|
312
472
|
|
|
313
|
-
const result = await
|
|
473
|
+
const result = await persistAmbientSightFrame(
|
|
474
|
+
live.id,
|
|
475
|
+
attachment.id,
|
|
476
|
+
"voice",
|
|
477
|
+
);
|
|
314
478
|
expect(result.ok).toBe(true);
|
|
315
479
|
|
|
316
480
|
const [message] = getMessages(live.id);
|
|
@@ -340,7 +504,7 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
340
504
|
);
|
|
341
505
|
|
|
342
506
|
expect(
|
|
343
|
-
(await
|
|
507
|
+
(await persistAmbientSightFrame(live.id, attachment.id, "voice")).ok,
|
|
344
508
|
).toBe(true);
|
|
345
509
|
|
|
346
510
|
expect(metadataOf(getMessages(live.id)[0]).scripted).toBe(true);
|
|
@@ -349,6 +513,28 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
349
513
|
}
|
|
350
514
|
});
|
|
351
515
|
|
|
516
|
+
test("marks a keep taken on a call as a voice session turn", async () => {
|
|
517
|
+
// The mark says a reply to this row is spoken back over a session that is
|
|
518
|
+
// still open, which decides whether a finished reply raises a push. Only
|
|
519
|
+
// the voice surface may claim it, and it has to keep claiming it.
|
|
520
|
+
const live = liveConversation("Live voice sight frame voice mark");
|
|
521
|
+
try {
|
|
522
|
+
const attachment = await uploadAttachment(
|
|
523
|
+
"frame.png",
|
|
524
|
+
"image/png",
|
|
525
|
+
IMAGE_BASE64,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
expect(
|
|
529
|
+
(await persistAmbientSightFrame(live.id, attachment.id, "voice")).ok,
|
|
530
|
+
).toBe(true);
|
|
531
|
+
|
|
532
|
+
expect(metadataOf(getMessages(live.id)[0]).voiceSessionTurn).toBe(true);
|
|
533
|
+
} finally {
|
|
534
|
+
live.dispose();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
352
538
|
test("tags the id the attachment was cloned into, not the one it arrived as", async () => {
|
|
353
539
|
// An attachment already linked to another conversation is cloned into this
|
|
354
540
|
// one under a fresh id, and both the persisted block and the link carry the
|
|
@@ -358,9 +544,9 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
358
544
|
try {
|
|
359
545
|
const arrivedAs = await attachmentLinkedElsewhere(source.id);
|
|
360
546
|
|
|
361
|
-
expect(
|
|
362
|
-
|
|
363
|
-
);
|
|
547
|
+
expect(
|
|
548
|
+
(await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
|
|
549
|
+
).toBe(true);
|
|
364
550
|
|
|
365
551
|
const [row] = getMessages(live.id);
|
|
366
552
|
const stored = storedImageId(row);
|
|
@@ -388,9 +574,9 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
388
574
|
try {
|
|
389
575
|
const arrivedAs = await attachmentLinkedElsewhere(source.id);
|
|
390
576
|
|
|
391
|
-
expect(
|
|
392
|
-
|
|
393
|
-
);
|
|
577
|
+
expect(
|
|
578
|
+
(await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
|
|
579
|
+
).toBe(true);
|
|
394
580
|
|
|
395
581
|
const stored = storedImageId(getMessages(live.id)[0]);
|
|
396
582
|
expect(stored).toBeDefined();
|
|
@@ -410,14 +596,14 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
410
596
|
const live = liveConversation("Live voice live retention");
|
|
411
597
|
try {
|
|
412
598
|
const arrivedAs = await attachmentLinkedElsewhere(source.id);
|
|
413
|
-
expect(
|
|
414
|
-
|
|
415
|
-
);
|
|
599
|
+
expect(
|
|
600
|
+
(await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
|
|
601
|
+
).toBe(true);
|
|
416
602
|
for (const name of ["live-fresh-a.png", "live-fresh-b.png"]) {
|
|
417
603
|
const fresh = await uploadFrame(name);
|
|
418
|
-
expect(
|
|
419
|
-
|
|
420
|
-
);
|
|
604
|
+
expect(
|
|
605
|
+
(await persistAmbientSightFrame(live.id, fresh, "voice")).ok,
|
|
606
|
+
).toBe(true);
|
|
421
607
|
}
|
|
422
608
|
|
|
423
609
|
const live_ = live.activeConversation;
|
|
@@ -442,14 +628,14 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
442
628
|
const live = liveConversation("Live voice cloned retention");
|
|
443
629
|
try {
|
|
444
630
|
const arrivedAs = await attachmentLinkedElsewhere(source.id);
|
|
445
|
-
expect(
|
|
446
|
-
|
|
447
|
-
);
|
|
631
|
+
expect(
|
|
632
|
+
(await persistAmbientSightFrame(live.id, arrivedAs, "voice")).ok,
|
|
633
|
+
).toBe(true);
|
|
448
634
|
for (const name of ["later-a.png", "later-b.png"]) {
|
|
449
635
|
const fresh = await uploadAttachment(name, "image/png", IMAGE_BASE64);
|
|
450
|
-
expect(
|
|
451
|
-
|
|
452
|
-
);
|
|
636
|
+
expect(
|
|
637
|
+
(await persistAmbientSightFrame(live.id, fresh.id, "voice")).ok,
|
|
638
|
+
).toBe(true);
|
|
453
639
|
}
|
|
454
640
|
|
|
455
641
|
const assembled: Message[] = getMessages(live.id).map((row) => ({
|
|
@@ -471,7 +657,9 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
471
657
|
test("reports a frame whose attachment does not resolve", async () => {
|
|
472
658
|
const live = liveConversation("Live voice sight frame missing");
|
|
473
659
|
try {
|
|
474
|
-
expect(
|
|
660
|
+
expect(
|
|
661
|
+
await persistAmbientSightFrame(live.id, "att-missing", "voice"),
|
|
662
|
+
).toEqual({
|
|
475
663
|
ok: false,
|
|
476
664
|
});
|
|
477
665
|
expect(getMessages(live.id)).toHaveLength(0);
|
|
@@ -493,7 +681,7 @@ describe("persistLiveVoiceSightFrame", () => {
|
|
|
493
681
|
);
|
|
494
682
|
|
|
495
683
|
live.activeConversation.setProcessing(true);
|
|
496
|
-
const pending =
|
|
684
|
+
const pending = persistAmbientSightFrame(live.id, attachment.id, "voice");
|
|
497
685
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
498
686
|
expect(getMessages(live.id)).toHaveLength(0);
|
|
499
687
|
|
|
@@ -526,7 +714,7 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
526
714
|
const frame = await uploadFrame("ambient.png");
|
|
527
715
|
|
|
528
716
|
const photoWrite = persistLiveVoicePhoto(live.id, photo);
|
|
529
|
-
const frameWrite =
|
|
717
|
+
const frameWrite = persistAmbientSightFrame(live.id, frame, "voice");
|
|
530
718
|
|
|
531
719
|
expect(await photoWrite).toMatchObject({ ok: true });
|
|
532
720
|
expect(await frameWrite).toMatchObject({ ok: true });
|
|
@@ -553,11 +741,11 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
553
741
|
const second = await uploadFrame("second.png");
|
|
554
742
|
|
|
555
743
|
live.activeConversation.setProcessing(true);
|
|
556
|
-
const firstKeep =
|
|
744
|
+
const firstKeep = persistAmbientSightFrame(live.id, first, "voice");
|
|
557
745
|
// Long enough for the first keep to reach the idle wait, so the second
|
|
558
746
|
// queues behind a job that has already begun rather than replacing it.
|
|
559
747
|
await sleep(30);
|
|
560
|
-
const secondKeep =
|
|
748
|
+
const secondKeep = persistAmbientSightFrame(live.id, second, "voice");
|
|
561
749
|
await sleep(30);
|
|
562
750
|
live.activeConversation.setProcessing(false);
|
|
563
751
|
|
|
@@ -583,7 +771,7 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
583
771
|
|
|
584
772
|
// A turn is running, so the keep goes into the wait.
|
|
585
773
|
live.activeConversation.setProcessing(true);
|
|
586
|
-
const keep =
|
|
774
|
+
const keep = persistAmbientSightFrame(live.id, frame, "voice");
|
|
587
775
|
await sleep(150);
|
|
588
776
|
|
|
589
777
|
// From here, the next observation of a free flag lets a turn try to
|
|
@@ -603,6 +791,292 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
603
791
|
}
|
|
604
792
|
});
|
|
605
793
|
|
|
794
|
+
test("a frame does not write into or release a turn that claimed the flag", async () => {
|
|
795
|
+
// A turn claims the flag unconditionally, which is how every turn starts.
|
|
796
|
+
// The frame's write fence reads the claim as no longer its own and refuses
|
|
797
|
+
// the row, and its release is refused for the same reason, so the turn
|
|
798
|
+
// keeps a conversation nothing else wrote into.
|
|
799
|
+
const live = liveConversation("Live voice keep flag claimed away");
|
|
800
|
+
try {
|
|
801
|
+
const frame = await uploadFrame("claimed-away.png");
|
|
802
|
+
|
|
803
|
+
duringPersistBeforeInsert = () => {
|
|
804
|
+
live.activeConversation.setProcessing(true);
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
|
|
808
|
+
ok: false,
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
812
|
+
// The turn still holds it: the frame released only its own claim.
|
|
813
|
+
expect(live.activeConversation.isProcessing()).toBe(true);
|
|
814
|
+
} finally {
|
|
815
|
+
duringPersistBeforeInsert = null;
|
|
816
|
+
live.activeConversation.setProcessing(false);
|
|
817
|
+
live.dispose();
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("an acquire keeps the flag when its advisory write cannot land", async () => {
|
|
822
|
+
// The window a strict set opened: reverting the flag because an advisory
|
|
823
|
+
// write lost a race to contention publishes an idle conversation for as
|
|
824
|
+
// long as the caller's retry sleeps, and anything polling for idle takes
|
|
825
|
+
// it while that caller believes its own turn is starting.
|
|
826
|
+
const live = liveConversation("Live voice acquire mirror failure");
|
|
827
|
+
try {
|
|
828
|
+
storeUnreadable = true;
|
|
829
|
+
const owner = live.activeConversation.acquireProcessing();
|
|
830
|
+
storeUnreadable = false;
|
|
831
|
+
|
|
832
|
+
expect(owner).not.toBeNull();
|
|
833
|
+
expect(live.activeConversation.isProcessing()).toBe(true);
|
|
834
|
+
// Held, so nothing else can take it.
|
|
835
|
+
expect(live.activeConversation.acquireProcessing()).toBeNull();
|
|
836
|
+
|
|
837
|
+
// A claim that is not the live one releases nothing.
|
|
838
|
+
expect(live.activeConversation.releaseProcessing(owner! - 1)).toBe(false);
|
|
839
|
+
expect(live.activeConversation.isProcessing()).toBe(true);
|
|
840
|
+
|
|
841
|
+
expect(live.activeConversation.releaseProcessing(owner!)).toBe(true);
|
|
842
|
+
expect(live.activeConversation.isProcessing()).toBe(false);
|
|
843
|
+
} finally {
|
|
844
|
+
storeUnreadable = false;
|
|
845
|
+
live.dispose();
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("an acquire does not proceed until its processing marker lands", async () => {
|
|
850
|
+
// `processing_started_at` is what a reconnecting client and the
|
|
851
|
+
// out-of-process retrospective worker read to decide a turn is live, so a
|
|
852
|
+
// turn that writes rows while the column is null lets a client stop
|
|
853
|
+
// waiting mid-turn and lets the worker fork partial history.
|
|
854
|
+
const live = liveConversation("Live voice marker fence");
|
|
855
|
+
try {
|
|
856
|
+
const owner = live.activeConversation.acquireProcessing();
|
|
857
|
+
expect(owner).not.toBeNull();
|
|
858
|
+
|
|
859
|
+
// Held for the whole write, so no second acquirer can slip in behind a
|
|
860
|
+
// marker that is still landing.
|
|
861
|
+
expect(live.activeConversation.isProcessing()).toBe(true);
|
|
862
|
+
await live.activeConversation.ensureProcessingMarker(owner!);
|
|
863
|
+
|
|
864
|
+
expect(
|
|
865
|
+
getDb()
|
|
866
|
+
.select({ startedAt: conversations.processingStartedAt })
|
|
867
|
+
.from(conversations)
|
|
868
|
+
.where(eq(conversations.id, live.id))
|
|
869
|
+
.get()?.startedAt,
|
|
870
|
+
).not.toBeNull();
|
|
871
|
+
|
|
872
|
+
expect(live.activeConversation.releaseProcessing(owner!)).toBe(true);
|
|
873
|
+
} finally {
|
|
874
|
+
live.dispose();
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("a claim whose marker will not land is given back", async () => {
|
|
879
|
+
// The fence has to fail closed: a hold nobody can see is worse than an
|
|
880
|
+
// operation that reports it could not start.
|
|
881
|
+
const live = liveConversation("Live voice marker fence failure");
|
|
882
|
+
try {
|
|
883
|
+
storeUnreadable = true;
|
|
884
|
+
const owner = live.activeConversation.acquireProcessing();
|
|
885
|
+
expect(owner).not.toBeNull();
|
|
886
|
+
|
|
887
|
+
await expect(
|
|
888
|
+
live.activeConversation.ensureProcessingMarker(owner!),
|
|
889
|
+
).rejects.toThrow();
|
|
890
|
+
storeUnreadable = false;
|
|
891
|
+
|
|
892
|
+
// The caller gives the hold back, and the flag is free again.
|
|
893
|
+
expect(live.activeConversation.releaseProcessing(owner!)).toBe(true);
|
|
894
|
+
expect(live.activeConversation.isProcessing()).toBe(false);
|
|
895
|
+
} finally {
|
|
896
|
+
storeUnreadable = false;
|
|
897
|
+
live.dispose();
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("a deferred reclaim collects the clone, not just the id it was handed", async () => {
|
|
902
|
+
// A frame already linked to another conversation is CLONED into this one
|
|
903
|
+
// before the insert precondition runs. If the precondition then refuses
|
|
904
|
+
// and the persist's own clone cleanup hits contention, retrying under the
|
|
905
|
+
// caller's id reclaims nothing: that row is still linked where it came
|
|
906
|
+
// from, and the clone nobody names survives every pass.
|
|
907
|
+
const source = liveConversation("Live voice clone reclaim source");
|
|
908
|
+
const live = liveConversation("Live voice clone reclaim");
|
|
909
|
+
try {
|
|
910
|
+
const arrivedAs = await attachmentLinkedElsewhere(source.id);
|
|
911
|
+
const beforePersist = new Set(allAttachmentIds());
|
|
912
|
+
|
|
913
|
+
// Replaced mid-persist, so the precondition refuses after the clone was
|
|
914
|
+
// made, and the clone cleanup that follows fails once on contention.
|
|
915
|
+
duringPersistBeforeInsert = () => {
|
|
916
|
+
deleteConversationRows(live.id);
|
|
917
|
+
createConversation({ id: live.id, title: "Recreated" });
|
|
918
|
+
// The persist's own clone cleanup fails, and so does the reclaim that
|
|
919
|
+
// follows it, so the record reaches the deferred pass.
|
|
920
|
+
failNextReclaims = 2;
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
expect(
|
|
924
|
+
await persistAmbientSightFrame(live.id, arrivedAs, "voice"),
|
|
925
|
+
).toEqual({ ok: false });
|
|
926
|
+
|
|
927
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
928
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(1);
|
|
929
|
+
// The clone outlived the failed cleanup, so something still owes it.
|
|
930
|
+
const clones = allAttachmentIds().filter((id) => !beforePersist.has(id));
|
|
931
|
+
expect(clones).toHaveLength(1);
|
|
932
|
+
expect(frameStored(clones[0])).toBe(true);
|
|
933
|
+
|
|
934
|
+
_drainFrameReclaimRechecksForTests();
|
|
935
|
+
|
|
936
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(0);
|
|
937
|
+
// The clone is gone, and the original it was copied from is untouched
|
|
938
|
+
// and still linked to the message that owns it.
|
|
939
|
+
expect(frameStored(clones[0])).toBe(false);
|
|
940
|
+
expect(frameStored(arrivedAs)).toBe(true);
|
|
941
|
+
expect(
|
|
942
|
+
getAttachmentsForMessage(getMessages(source.id)[0].id),
|
|
943
|
+
).toHaveLength(1);
|
|
944
|
+
} finally {
|
|
945
|
+
duringPersistBeforeInsert = null;
|
|
946
|
+
failNextReclaims = 0;
|
|
947
|
+
live.dispose();
|
|
948
|
+
source.dispose();
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
test("a fence whose claim was cleared under it reports the loss", async () => {
|
|
953
|
+
// The caller does its work between the fence and its release, so a fence
|
|
954
|
+
// that answered "fine" for a hold Stop or a teardown already cleared would
|
|
955
|
+
// let that work run under a dead claim while a new turn acquires and
|
|
956
|
+
// writes alongside it.
|
|
957
|
+
const live = liveConversation("Live voice fence claim cleared");
|
|
958
|
+
try {
|
|
959
|
+
const owner = live.activeConversation.acquireProcessing();
|
|
960
|
+
expect(owner).not.toBeNull();
|
|
961
|
+
|
|
962
|
+
// The teardown shape: an unconditional clear while the marker is still
|
|
963
|
+
// the live claim's.
|
|
964
|
+
live.activeConversation.setProcessing(false);
|
|
965
|
+
|
|
966
|
+
await expect(
|
|
967
|
+
live.activeConversation.ensureProcessingMarker(owner!),
|
|
968
|
+
).rejects.toThrow(ProcessingClaimLostError);
|
|
969
|
+
} finally {
|
|
970
|
+
live.dispose();
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("a fence whose claim is taken away after the write reports the loss", async () => {
|
|
975
|
+
// The other half of the same window: the marker lands, and the hold is
|
|
976
|
+
// claimed away before the awaiter resumes. The fence answers for the
|
|
977
|
+
// present, not for the moment it started waiting.
|
|
978
|
+
const live = liveConversation("Live voice fence claim stolen");
|
|
979
|
+
try {
|
|
980
|
+
const owner = live.activeConversation.acquireProcessing();
|
|
981
|
+
expect(owner).not.toBeNull();
|
|
982
|
+
|
|
983
|
+
const fence = live.activeConversation.ensureProcessingMarker(owner!);
|
|
984
|
+
// A turn claiming the flag, which is how every turn starts.
|
|
985
|
+
live.activeConversation.setProcessing(true);
|
|
986
|
+
|
|
987
|
+
await expect(fence).rejects.toThrow(ProcessingClaimLostError);
|
|
988
|
+
} finally {
|
|
989
|
+
live.activeConversation.setProcessing(false);
|
|
990
|
+
live.dispose();
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("a fenced acquire reads a lost claim as a busy conversation", async () => {
|
|
995
|
+
// Callers owe one answer for "this conversation is someone else's",
|
|
996
|
+
// however it became so, and the fenced acquire is what collapses the two
|
|
997
|
+
// routes into it. The claim is given back on the way out.
|
|
998
|
+
const live = liveConversation("Live voice fenced acquire loss");
|
|
999
|
+
try {
|
|
1000
|
+
const marker = spyOn(
|
|
1001
|
+
live.activeConversation,
|
|
1002
|
+
"ensureProcessingMarker",
|
|
1003
|
+
).mockImplementation(async () => {
|
|
1004
|
+
throw new ProcessingClaimLostError(live.id);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
expect(
|
|
1008
|
+
await live.activeConversation.acquireProcessingFenced(),
|
|
1009
|
+
).toBeNull();
|
|
1010
|
+
// Not left held: the next acquire finds it free.
|
|
1011
|
+
expect(live.activeConversation.isProcessing()).toBe(false);
|
|
1012
|
+
|
|
1013
|
+
marker.mockRestore();
|
|
1014
|
+
expect(
|
|
1015
|
+
await live.activeConversation.acquireProcessingFenced(),
|
|
1016
|
+
).not.toBeNull();
|
|
1017
|
+
} finally {
|
|
1018
|
+
live.activeConversation.setProcessing(false);
|
|
1019
|
+
live.dispose();
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
test("a fenced acquire that cannot persist its marker leaves nothing held", async () => {
|
|
1024
|
+
// The P2 shape. A marker that will not land is a real failure, and the
|
|
1025
|
+
// claim has to be gone before the throw reaches the caller, or the
|
|
1026
|
+
// conversation is stuck processing with sends queuing behind a dead hold.
|
|
1027
|
+
const live = liveConversation("Live voice fenced acquire marker failure");
|
|
1028
|
+
try {
|
|
1029
|
+
const marker = spyOn(
|
|
1030
|
+
live.activeConversation,
|
|
1031
|
+
"ensureProcessingMarker",
|
|
1032
|
+
).mockImplementation(async () => {
|
|
1033
|
+
throw new Error("database is not readable");
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
await expect(
|
|
1037
|
+
live.activeConversation.acquireProcessingFenced(),
|
|
1038
|
+
).rejects.toThrow("database is not readable");
|
|
1039
|
+
|
|
1040
|
+
expect(live.activeConversation.isProcessing()).toBe(false);
|
|
1041
|
+
marker.mockRestore();
|
|
1042
|
+
} finally {
|
|
1043
|
+
live.activeConversation.setProcessing(false);
|
|
1044
|
+
live.dispose();
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("a keep does not write under a claim a Stop took away mid-write", async () => {
|
|
1049
|
+
// Stop on a standalone hold force-clears the flag, because no turn owns
|
|
1050
|
+
// the claim to signal, and the next request acquires. The incarnation is
|
|
1051
|
+
// unchanged, so without the ownership term the frame writes under a dead
|
|
1052
|
+
// claim alongside the turn that now holds the conversation, and the
|
|
1053
|
+
// refused release afterwards cannot undo the row.
|
|
1054
|
+
const live = liveConversation("Live voice keep stopped mid-write");
|
|
1055
|
+
try {
|
|
1056
|
+
const frame = await uploadFrame("stopped-mid-write.png");
|
|
1057
|
+
|
|
1058
|
+
duringPersistBeforeInsert = () => {
|
|
1059
|
+
// What `abortConversation` does for a hold with no live turn behind
|
|
1060
|
+
// it, followed by the next request taking the conversation.
|
|
1061
|
+
live.activeConversation.setProcessing(false);
|
|
1062
|
+
expect(live.activeConversation.acquireProcessing()).not.toBeNull();
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
|
|
1066
|
+
ok: false,
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1070
|
+
expect(frameStored(frame)).toBe(false);
|
|
1071
|
+
// The claim taken during the Stop is untouched by the frame's release.
|
|
1072
|
+
expect(live.activeConversation.isProcessing()).toBe(true);
|
|
1073
|
+
} finally {
|
|
1074
|
+
duringPersistBeforeInsert = null;
|
|
1075
|
+
live.activeConversation.setProcessing(false);
|
|
1076
|
+
live.dispose();
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
606
1080
|
test("a newer keep replaces one that is still waiting to start", async () => {
|
|
607
1081
|
// The chain is bounded this way: keeps arrive every few seconds while each
|
|
608
1082
|
// job can wait out a turn for far longer, so a queued keep that a newer
|
|
@@ -614,10 +1088,10 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
614
1088
|
const newest = await uploadFrame("newest.png");
|
|
615
1089
|
|
|
616
1090
|
live.activeConversation.setProcessing(true);
|
|
617
|
-
const runningKeep =
|
|
1091
|
+
const runningKeep = persistAmbientSightFrame(live.id, running, "voice");
|
|
618
1092
|
await sleep(30);
|
|
619
|
-
const staleKeep =
|
|
620
|
-
const newestKeep =
|
|
1093
|
+
const staleKeep = persistAmbientSightFrame(live.id, stale, "voice");
|
|
1094
|
+
const newestKeep = persistAmbientSightFrame(live.id, newest, "voice");
|
|
621
1095
|
await sleep(30);
|
|
622
1096
|
live.activeConversation.setProcessing(false);
|
|
623
1097
|
|
|
@@ -683,12 +1157,14 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
683
1157
|
|
|
684
1158
|
// A turn that outlasts the wait.
|
|
685
1159
|
live.activeConversation.setProcessing(true);
|
|
686
|
-
expect(await
|
|
1160
|
+
expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
|
|
687
1161
|
ok: false,
|
|
688
1162
|
});
|
|
689
1163
|
|
|
690
1164
|
expect(frameStored(frame)).toBe(false);
|
|
691
1165
|
expect(getMessages(live.id)).toHaveLength(0);
|
|
1166
|
+
// The delete took first try, so nothing is left waiting on the store.
|
|
1167
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(0);
|
|
692
1168
|
} finally {
|
|
693
1169
|
live.activeConversation.setProcessing(false);
|
|
694
1170
|
live.dispose();
|
|
@@ -696,6 +1172,46 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
696
1172
|
}
|
|
697
1173
|
});
|
|
698
1174
|
|
|
1175
|
+
test("a reclaim the store refuses is retried until it takes", async () => {
|
|
1176
|
+
// The refusal already told the client the daemon owns the upload, so a
|
|
1177
|
+
// delete that fails on the same contention everything else here waits out
|
|
1178
|
+
// cannot just be logged: the record is the only handle left on the bytes.
|
|
1179
|
+
const restoreWait = _setProcessingWaitMsForTests(120);
|
|
1180
|
+
const live = liveConversation("Live voice keep reclaim contention");
|
|
1181
|
+
try {
|
|
1182
|
+
const frame = await uploadFrame("reclaim-contention.png");
|
|
1183
|
+
|
|
1184
|
+
// A turn that outlasts the wait, so the keep is dropped and its upload
|
|
1185
|
+
// becomes the daemon's to collect.
|
|
1186
|
+
live.activeConversation.setProcessing(true);
|
|
1187
|
+
failNextReclaims = 1;
|
|
1188
|
+
expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
|
|
1189
|
+
ok: false,
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
expect(frameStored(frame)).toBe(true);
|
|
1193
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(1);
|
|
1194
|
+
|
|
1195
|
+
// The recheck's own delete can fail too, and that keeps the record
|
|
1196
|
+
// rather than spending it.
|
|
1197
|
+
failNextReclaims = 1;
|
|
1198
|
+
_drainFrameReclaimRechecksForTests();
|
|
1199
|
+
expect(frameStored(frame)).toBe(true);
|
|
1200
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(1);
|
|
1201
|
+
|
|
1202
|
+
_drainFrameReclaimRechecksForTests();
|
|
1203
|
+
|
|
1204
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(0);
|
|
1205
|
+
expect(frameStored(frame)).toBe(false);
|
|
1206
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1207
|
+
} finally {
|
|
1208
|
+
failNextReclaims = 0;
|
|
1209
|
+
live.activeConversation.setProcessing(false);
|
|
1210
|
+
live.dispose();
|
|
1211
|
+
_setProcessingWaitMsForTests(restoreWait);
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
|
|
699
1215
|
test("a photo whose wait runs out keeps its upload", async () => {
|
|
700
1216
|
// A photo is a deliberate upload the user watched themselves make, so a
|
|
701
1217
|
// failed persist is theirs to retry rather than the daemon's to delete.
|
|
@@ -718,12 +1234,356 @@ describe("standalone image persists are serialized per conversation", () => {
|
|
|
718
1234
|
}
|
|
719
1235
|
});
|
|
720
1236
|
|
|
1237
|
+
test("a deletion is final for a keep still queued behind another", async () => {
|
|
1238
|
+
// The persist reaches its conversation through `getOrCreateConversation`,
|
|
1239
|
+
// which writes the row back for an id it finds missing. A keep that was
|
|
1240
|
+
// queued when the delete landed would therefore bring the conversation
|
|
1241
|
+
// back holding nothing but camera frames, so the job re-reads before it
|
|
1242
|
+
// touches anything.
|
|
1243
|
+
const restoreWait = _setProcessingWaitMsForTests(150);
|
|
1244
|
+
const live = liveConversation("Live voice keep deleted mid-queue");
|
|
1245
|
+
const holder = await uploadFrame("voice-holder.png");
|
|
1246
|
+
const queued = await uploadFrame("voice-queued.png");
|
|
1247
|
+
try {
|
|
1248
|
+
live.activeConversation.setProcessing(true);
|
|
1249
|
+
const holderKeep = persistAmbientSightFrame(live.id, holder, "voice");
|
|
1250
|
+
await sleep(30);
|
|
1251
|
+
const queuedKeep = persistAmbientSightFrame(live.id, queued, "voice");
|
|
1252
|
+
|
|
1253
|
+
destroyActiveConversation(live.id, { keepSubagentRecords: true });
|
|
1254
|
+
deleteConversationRows(live.id);
|
|
1255
|
+
const countAfterDelete = conversationCount();
|
|
1256
|
+
|
|
1257
|
+
expect(await holderKeep).toEqual({ ok: false });
|
|
1258
|
+
expect(await queuedKeep).toEqual({ ok: false });
|
|
1259
|
+
|
|
1260
|
+
expect(getConversation(live.id)).toBeNull();
|
|
1261
|
+
expect(conversationCount()).toBe(countAfterDelete);
|
|
1262
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1263
|
+
expect(frameStored(queued)).toBe(false);
|
|
1264
|
+
} finally {
|
|
1265
|
+
_setProcessingWaitMsForTests(restoreWait);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
test("a keep does not land in a conversation recreated under its id", async () => {
|
|
1270
|
+
// The job holds its instance across the idle wait rather than re-reading,
|
|
1271
|
+
// so a delete alone is caught only by the messages foreign key. A recreate
|
|
1272
|
+
// under the same id restores that foreign key's target, and the frame
|
|
1273
|
+
// would land in a conversation made after the deletion.
|
|
1274
|
+
const live = liveConversation("Live voice keep recreated id");
|
|
1275
|
+
try {
|
|
1276
|
+
const frame = await uploadFrame("recreated.png");
|
|
1277
|
+
|
|
1278
|
+
// A turn the keep has to wait out, which is the window the delete and
|
|
1279
|
+
// the recreate land in.
|
|
1280
|
+
live.activeConversation.setProcessing(true);
|
|
1281
|
+
const keep = persistAmbientSightFrame(live.id, frame, "voice");
|
|
1282
|
+
await sleep(30);
|
|
1283
|
+
|
|
1284
|
+
// The same id, naming a conversation the keep was never taken in.
|
|
1285
|
+
deleteConversationRows(live.id);
|
|
1286
|
+
createConversation({ id: live.id, title: "Recreated" });
|
|
1287
|
+
live.activeConversation.setProcessing(false);
|
|
1288
|
+
|
|
1289
|
+
expect(await keep).toEqual({ ok: false });
|
|
1290
|
+
|
|
1291
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1292
|
+
expect(frameStored(frame)).toBe(false);
|
|
1293
|
+
} finally {
|
|
1294
|
+
live.dispose();
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("a keep waiting its turn in the chain does not land in a replacement", async () => {
|
|
1299
|
+
// The incarnation a frame is accepted for is read before it is queued, not
|
|
1300
|
+
// when its job starts. The chain can hold a keep behind another image for
|
|
1301
|
+
// as long as a turn runs, and a job that read the id when it began would
|
|
1302
|
+
// read the replacement and then check it against itself.
|
|
1303
|
+
const live = liveConversation("Live voice keep queued behind another");
|
|
1304
|
+
const holder = await uploadFrame("chain-holder.png");
|
|
1305
|
+
const queued = await uploadFrame("chain-queued.png");
|
|
1306
|
+
try {
|
|
1307
|
+
// A turn the first keep waits out, which is what keeps the second one
|
|
1308
|
+
// chained rather than started.
|
|
1309
|
+
live.activeConversation.setProcessing(true);
|
|
1310
|
+
const holderKeep = persistAmbientSightFrame(live.id, holder, "voice");
|
|
1311
|
+
await sleep(30);
|
|
1312
|
+
const queuedKeep = persistAmbientSightFrame(live.id, queued, "voice");
|
|
1313
|
+
|
|
1314
|
+
// The same id, naming a conversation neither frame was taken in.
|
|
1315
|
+
deleteConversationRows(live.id);
|
|
1316
|
+
createConversation({ id: live.id, title: "Recreated" });
|
|
1317
|
+
live.activeConversation.setProcessing(false);
|
|
1318
|
+
|
|
1319
|
+
expect(await holderKeep).toEqual({ ok: false });
|
|
1320
|
+
expect(await queuedKeep).toEqual({ ok: false });
|
|
1321
|
+
|
|
1322
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1323
|
+
expect(frameStored(holder)).toBe(false);
|
|
1324
|
+
expect(frameStored(queued)).toBe(false);
|
|
1325
|
+
} finally {
|
|
1326
|
+
live.dispose();
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
test("a keep is refused by a conversation recreated in the same millisecond", async () => {
|
|
1331
|
+
// `created_at` is the whole of an incarnation's identity, so two rows
|
|
1332
|
+
// stamped from one wall-clock millisecond would read as the same
|
|
1333
|
+
// conversation and every fence would pass. The stamp is issued
|
|
1334
|
+
// monotonically, which is what keeps the two apart.
|
|
1335
|
+
const live = liveConversation("Live voice same millisecond recreate");
|
|
1336
|
+
try {
|
|
1337
|
+
const frame = await uploadFrame("same-millisecond.png");
|
|
1338
|
+
const takenIn = getConversation(live.id)!.createdAt;
|
|
1339
|
+
|
|
1340
|
+
live.activeConversation.setProcessing(true);
|
|
1341
|
+
const keep = persistAmbientSightFrame(live.id, frame, "voice");
|
|
1342
|
+
await sleep(30);
|
|
1343
|
+
|
|
1344
|
+
const clock = spyOn(Date, "now").mockReturnValue(takenIn);
|
|
1345
|
+
try {
|
|
1346
|
+
deleteConversationRows(live.id);
|
|
1347
|
+
createConversation({ id: live.id, title: "Recreated" });
|
|
1348
|
+
} finally {
|
|
1349
|
+
clock.mockRestore();
|
|
1350
|
+
}
|
|
1351
|
+
expect(getConversation(live.id)!.createdAt).not.toBe(takenIn);
|
|
1352
|
+
|
|
1353
|
+
live.activeConversation.setProcessing(false);
|
|
1354
|
+
|
|
1355
|
+
expect(await keep).toEqual({ ok: false });
|
|
1356
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1357
|
+
expect(frameStored(frame)).toBe(false);
|
|
1358
|
+
} finally {
|
|
1359
|
+
live.dispose();
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test("a keep does not land in a conversation replaced during its write", async () => {
|
|
1364
|
+
// The check before the persist answers only for the moment it ran. The
|
|
1365
|
+
// persist then materializes the frame and builds its content, all awaited,
|
|
1366
|
+
// and a delete and recreate under the same id landing in any of those
|
|
1367
|
+
// windows restores the messages foreign key's target, so nothing else
|
|
1368
|
+
// stops the frame joining a conversation it was never taken in.
|
|
1369
|
+
const live = liveConversation("Live voice keep replaced mid-write");
|
|
1370
|
+
try {
|
|
1371
|
+
const frame = await uploadFrame("mid-write.png");
|
|
1372
|
+
|
|
1373
|
+
duringPersistBeforeInsert = () => {
|
|
1374
|
+
deleteConversationRows(live.id);
|
|
1375
|
+
createConversation({ id: live.id, title: "Recreated" });
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
|
|
1379
|
+
ok: false,
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1383
|
+
// Nothing else would ever collect it: the client's abandon-delete fires
|
|
1384
|
+
// on a refused send, and this send was accepted.
|
|
1385
|
+
expect(frameStored(frame)).toBe(false);
|
|
1386
|
+
} finally {
|
|
1387
|
+
duringPersistBeforeInsert = null;
|
|
1388
|
+
live.dispose();
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("a keep is refused by a replacement that lands between insert attempts", async () => {
|
|
1393
|
+
// Contention retries the insert after an awaited backoff, so an answer
|
|
1394
|
+
// given once for the call speaks only for the first attempt. A delete and
|
|
1395
|
+
// recreate inside that sleep leaves the retry writing into a row the frame
|
|
1396
|
+
// was never accepted for.
|
|
1397
|
+
const live = liveConversation("Live voice keep replaced between attempts");
|
|
1398
|
+
try {
|
|
1399
|
+
const frame = await uploadFrame("between-attempts.png");
|
|
1400
|
+
|
|
1401
|
+
beforeFailingInsertAttempt = () => {
|
|
1402
|
+
deleteConversationRows(live.id);
|
|
1403
|
+
createConversation({ id: live.id, title: "Recreated" });
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
expect(await persistAmbientSightFrame(live.id, frame, "voice")).toEqual({
|
|
1407
|
+
ok: false,
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1411
|
+
expect(frameStored(frame)).toBe(false);
|
|
1412
|
+
} finally {
|
|
1413
|
+
beforeFailingInsertAttempt = null;
|
|
1414
|
+
live.dispose();
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
test("a keep whose first insert attempt hit contention still lands", async () => {
|
|
1419
|
+
// The control on the check above: a retry the conversation outlived is the
|
|
1420
|
+
// ordinary path `withSqliteRetry` exists for, and the frame has to survive
|
|
1421
|
+
// it exactly as it always has.
|
|
1422
|
+
const live = liveConversation("Live voice keep retried insert");
|
|
1423
|
+
try {
|
|
1424
|
+
const frame = await uploadFrame("retried.png");
|
|
1425
|
+
|
|
1426
|
+
beforeFailingInsertAttempt = () => {};
|
|
1427
|
+
|
|
1428
|
+
const result = await persistAmbientSightFrame(live.id, frame, "voice");
|
|
1429
|
+
expect(result.ok).toBe(true);
|
|
1430
|
+
|
|
1431
|
+
const rows = getMessages(live.id);
|
|
1432
|
+
expect(rows).toHaveLength(1);
|
|
1433
|
+
expect(result.messageId).toBe(rows[0].id);
|
|
1434
|
+
expect(frameStored(frame)).toBe(true);
|
|
1435
|
+
} finally {
|
|
1436
|
+
beforeFailingInsertAttempt = null;
|
|
1437
|
+
live.dispose();
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
test("a frame refused under an unreadable store is collected once it answers", async () => {
|
|
1442
|
+
// `persisted: false` tells the client the daemon has dealt with the
|
|
1443
|
+
// upload, and nothing else ever collects an attachment no caller names:
|
|
1444
|
+
// collection is candidate-driven with no sweep. So a refusal the store
|
|
1445
|
+
// could not explain has to come back to the question rather than leave the
|
|
1446
|
+
// bytes for good.
|
|
1447
|
+
const live = liveConversation("Live voice keep unreadable store");
|
|
1448
|
+
const watch = watchEchoes(live.id);
|
|
1449
|
+
try {
|
|
1450
|
+
const frame = await uploadFrame("unreadable.png");
|
|
1451
|
+
|
|
1452
|
+
storeUnreadable = true;
|
|
1453
|
+
const result = await persistAmbientSightFrame(live.id, frame, "voice");
|
|
1454
|
+
storeUnreadable = false;
|
|
1455
|
+
|
|
1456
|
+
expect(result).toEqual({ ok: false });
|
|
1457
|
+
// Not reclaimed on the spot: "could not read" is not "no row".
|
|
1458
|
+
expect(frameStored(frame)).toBe(true);
|
|
1459
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(1);
|
|
1460
|
+
|
|
1461
|
+
_drainFrameReclaimRechecksForTests();
|
|
1462
|
+
|
|
1463
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(0);
|
|
1464
|
+
expect(frameStored(frame)).toBe(false);
|
|
1465
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
1466
|
+
// No row landed, so there is nothing to tell the client about and the
|
|
1467
|
+
// refusal it already has stands.
|
|
1468
|
+
expect(watch.echoes()).toHaveLength(0);
|
|
1469
|
+
} finally {
|
|
1470
|
+
storeUnreadable = false;
|
|
1471
|
+
watch.dispose();
|
|
1472
|
+
live.dispose();
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
test("a frame whose row landed is kept and announced when the store answers", async () => {
|
|
1477
|
+
// The other half of the same question. A persist can fail after its row
|
|
1478
|
+
// committed, and that row references the bytes through content the link
|
|
1479
|
+
// write never got to protect, so a recheck that finds the row must leave
|
|
1480
|
+
// the attachment alone. The client was told the frame was dropped, so
|
|
1481
|
+
// without the announce the row it never heard about waits for a reload.
|
|
1482
|
+
const live = liveConversation("Live voice keep row landed unreadable");
|
|
1483
|
+
const watch = watchEchoes(live.id);
|
|
1484
|
+
try {
|
|
1485
|
+
const frame = await uploadFrame("landed.png");
|
|
1486
|
+
|
|
1487
|
+
breakStoreAfterInsert = true;
|
|
1488
|
+
const result = await persistAmbientSightFrame(live.id, frame, "voice");
|
|
1489
|
+
storeUnreadable = false;
|
|
1490
|
+
|
|
1491
|
+
expect(result).toEqual({ ok: false });
|
|
1492
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(1);
|
|
1493
|
+
|
|
1494
|
+
_drainFrameReclaimRechecksForTests();
|
|
1495
|
+
|
|
1496
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(0);
|
|
1497
|
+
// The row is in the transcript, so its bytes are spoken for.
|
|
1498
|
+
const rows = getMessages(live.id);
|
|
1499
|
+
expect(rows).toHaveLength(1);
|
|
1500
|
+
expect(frameStored(frame)).toBe(true);
|
|
1501
|
+
|
|
1502
|
+
await waitFor(() => watch.echoes().length > 0, {
|
|
1503
|
+
message: "Timed out waiting for the deferred row announcement",
|
|
1504
|
+
});
|
|
1505
|
+
expect(watch.echoes()).toMatchObject([
|
|
1506
|
+
{
|
|
1507
|
+
type: "user_message_echo",
|
|
1508
|
+
text: "(camera frame)",
|
|
1509
|
+
conversationId: live.id,
|
|
1510
|
+
messageId: rows[0].id,
|
|
1511
|
+
},
|
|
1512
|
+
]);
|
|
1513
|
+
} finally {
|
|
1514
|
+
breakStoreAfterInsert = false;
|
|
1515
|
+
storeUnreadable = false;
|
|
1516
|
+
watch.dispose();
|
|
1517
|
+
live.dispose();
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
test("a frame outlasting the quick passes waits, then settles when the store returns", async () => {
|
|
1522
|
+
// Ten passes was once the whole budget, and dropping a record there loses
|
|
1523
|
+
// its upload for good: nothing else in the daemon collects an attachment
|
|
1524
|
+
// no caller names. The passes only get further apart.
|
|
1525
|
+
const live = liveConversation("Live voice keep long outage");
|
|
1526
|
+
const watch = watchEchoes(live.id);
|
|
1527
|
+
try {
|
|
1528
|
+
const landed = await uploadFrame("outage-landed.png");
|
|
1529
|
+
const dropped = await uploadFrame("outage-dropped.png");
|
|
1530
|
+
|
|
1531
|
+
// One frame whose row commits before the store goes, one that never
|
|
1532
|
+
// reaches an insert at all, so both branches wait out the same outage.
|
|
1533
|
+
breakStoreAfterInsert = true;
|
|
1534
|
+
expect(await persistAmbientSightFrame(live.id, landed, "voice")).toEqual({
|
|
1535
|
+
ok: false,
|
|
1536
|
+
});
|
|
1537
|
+
expect(await persistAmbientSightFrame(live.id, dropped, "voice")).toEqual(
|
|
1538
|
+
{ ok: false },
|
|
1539
|
+
);
|
|
1540
|
+
|
|
1541
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(2);
|
|
1542
|
+
expect(_nextFrameReclaimRecheckDelayForTests()).toBe(30_000);
|
|
1543
|
+
|
|
1544
|
+
// Well past the ten passes the records used to be given up at.
|
|
1545
|
+
for (let pass = 0; pass < 14; pass += 1) {
|
|
1546
|
+
_drainFrameReclaimRechecksForTests();
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(2);
|
|
1550
|
+
// Asking less often rather than not at all.
|
|
1551
|
+
expect(_nextFrameReclaimRecheckDelayForTests()).toBe(300_000);
|
|
1552
|
+
|
|
1553
|
+
// The store comes back long after the old cap.
|
|
1554
|
+
storeUnreadable = false;
|
|
1555
|
+
// Neither upload was given up while it was down.
|
|
1556
|
+
expect(frameStored(landed)).toBe(true);
|
|
1557
|
+
expect(frameStored(dropped)).toBe(true);
|
|
1558
|
+
|
|
1559
|
+
_drainFrameReclaimRechecksForTests();
|
|
1560
|
+
|
|
1561
|
+
expect(_pendingFrameReclaimCountForTests()).toBe(0);
|
|
1562
|
+
expect(frameStored(landed)).toBe(true);
|
|
1563
|
+
expect(frameStored(dropped)).toBe(false);
|
|
1564
|
+
|
|
1565
|
+
const rows = getMessages(live.id);
|
|
1566
|
+
expect(rows).toHaveLength(1);
|
|
1567
|
+
await waitFor(() => watch.echoes().length > 0, {
|
|
1568
|
+
message: "Timed out waiting for the post-outage row announcement",
|
|
1569
|
+
});
|
|
1570
|
+
expect(watch.echoes()).toMatchObject([
|
|
1571
|
+
{ type: "user_message_echo", messageId: rows[0].id },
|
|
1572
|
+
]);
|
|
1573
|
+
} finally {
|
|
1574
|
+
breakStoreAfterInsert = false;
|
|
1575
|
+
storeUnreadable = false;
|
|
1576
|
+
watch.dispose();
|
|
1577
|
+
live.dispose();
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
|
|
721
1581
|
test("the conversation's chain is dropped once it drains", async () => {
|
|
722
1582
|
const live = liveConversation("Live voice chain cleanup");
|
|
723
1583
|
try {
|
|
724
1584
|
const frame = await uploadFrame("cleanup.png");
|
|
725
1585
|
|
|
726
|
-
const pending =
|
|
1586
|
+
const pending = persistAmbientSightFrame(live.id, frame, "voice");
|
|
727
1587
|
expect(_standaloneImageQueueSizeForTests()).toBe(1);
|
|
728
1588
|
expect(await pending).toMatchObject({ ok: true });
|
|
729
1589
|
|