-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathfindings.json
More file actions
194 lines (194 loc) · 64.9 KB
/
Copy pathfindings.json
File metadata and controls
194 lines (194 loc) · 64.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
[
{
"title": "Image attachments are silently dropped (never reach the model) when the selected chat model is a local GGUF file",
"file": "backend/cortex_backend/api/routes.py:1329",
"failureScenario": "User selects a local GGUF model (Settings -> models.chat = \"gguf:tiny-model.gguf\"), attaches photo.png, and asks \"What is in this picture?\". POST /api/v1/generations returns 202 (no error, no warning). The prompt text tells the model `## ATTACHED IMAGES\\n- \"photo.png\"`, but `_strip_unsupported_fields` removes the `images` field before the llama-server request, so the model receives the *claim* that an image is attached and none of the pixels. It answers about an image it never saw. Nothing in the API response, the SSE stream, or the UI indicates the image was discarded.",
"evidence": "`_resolve_generation_attachments` gates only on an explicit False:\n```python\ncontains_image = any(item.kind == \"image\" for item in references)\nif contains_image and model is not None:\n vision = deps.models.model_supports_vision(model)\n if vision is False:\n raise ChatDomainError(...)\n```\nBut `CombinedModelCatalog.model_supports_vision` (services/model_catalog.py:90-96) returns **None**, not False, for every `gguf:` id. The frontend gate is the identical test (`frontend/src/features/chat/ChatPage.tsx:667`: `&& selectedModelSupportsVision === false`), so nothing blocks it there either \u2014 yet `llamacpp/chat_client.py:423` `_strip_unsupported_fields` drops the payload on the explicit assumption that it was already blocked: \"the frontend already disables image attachments for them -- drop any Ollama-shaped 'images' field defensively\".\n\nRan against a real app (build_app + TestClient), with a valid 24-byte GGUF stub in the configured models dir:\n```\ninstalled: ['gguf:tiny-model.gguf']\nvision flags: [('gguf:tiny-model.gguf', None, 'gguf')]\nmodel_supports_vision('gguf:tiny-model.gguf') -> None\nPOST /generations -> 202 {\"job_id\":\"5c17...\",\"status\":\"queued\",...}\nprompt has images field: True\nllama.cpp body has images field: False\nprompt mentions the image: True\n... ## ATTACHED IMAGES\n- \"photo.png\"\n```\nExisting tests cover only the Ollama `False` case (tests/test_chat_attachments.py:309) and the catalog value in isolation (tests/test_gguf_model_directory.py:178); nothing covers the end-to-end consequence.",
"severity": "high",
"fix": "backend/cortex_backend/api/routes.py:1329 \u2014 reject on anything that is not a confirmed True: `if vision is not True: raise ChatDomainError(...)`, or add an explicit `if vision is None and model.startswith(GGUF_PREFIX)` branch with a clear message (\"Local GGUF models cannot accept images yet\"). Mirror the same change at frontend/src/features/chat/ChatPage.tsx:667 (`selectedModelSupportsVision !== true`). Correct the now-false comments in services/model_catalog.py:91-95 and llamacpp/chat_client.py:424-428."
},
{
"title": "Duplicate code-execution submission fails the job with \"approval_required\" before its approval row is ever written",
"file": "backend/cortex_backend/execution/local_runtime.py:212",
"failureScenario": "Two concurrent POSTs to /api/v1/execution/code carrying the same request_id (a client retry, a double-submit, React StrictMode double-invoke). start_code() creates the job in one transaction and writes the approval row in a *second* one. The duplicate lands in that gap: create_job returns created=False for a job whose approval_state is still \"not_required\", line 212 sees a non-terminal status and calls _launch_code, and that second worker reaches line 538 (`if current.approval_state != \"approved\"`) and fails the job with error=\"approval_required\". Observed through the public API in 5 of 120 rounds: one POST returns 202 {status: \"queued\", approval_state: \"not_required\"}, the other returns 422, and GET /api/v1/execution/{id} then reports status=failed, error=approval_required, approval_state=not_required, can_cancel=false, with events ['queued','code.failed']. The user never gets an approval prompt, the proposed code never runs, and a dead task sits in the tray. The original caller's request_approval then raises ApprovalTransitionError(\"Terminal jobs cannot request approval\") and it reports approval_unavailable.",
"evidence": "local_runtime.py:206-214 \u2014 `if not created: ... if job.status not in TerminalExecutionStatus and job.status != \"cancelling\": self._launch_code(job.job_id)` runs with no check that the approval row exists yet; the creating call only writes it afterwards at line 216 `self.repository.request_approval(...)`. _run_code then does, at line 538, `if current.approval_state != \"approved\": self._finish_code_failure(job_id, cancel_event, \"approval_required\"); return` \u2014 and get_job() reports \"not_required\" when the execution_approvals row is simply absent (repository.py get_job: `COALESCE(..., 'not_required')`). Reproductions: coordinator-level (two threads on a Barrier calling LocalExecutionCoordinator.start_code with one request_id) 11/80 and 8/80 runs left the job failed with approval=None; HTTP-level (two threads POSTing /api/v1/execution/code) 5/120 rounds.",
"severity": "high",
"fix": "Do not relaunch a duplicate submission before its approval exists. At local_runtime.py:212 gate the relaunch on the approval being present, e.g. `if job.status not in TerminalExecutionStatus and job.status != \"cancelling\" and job.approval_state != \"not_required\": self._launch_code(job.job_id)` \u2014 the creating call launches the worker itself at line 237 once request_approval has committed. Belt-and-braces: at local_runtime.py:538 treat \"not_required\" on a code.exec.v1 job as \"the approval has not been created yet\" and keep waiting (bounded) rather than failing the job."
},
{
"title": "A Vulkan binary that fails verification blocks GGUF chat entirely \u2014 no CPU fallback, even when the verified CPU build is already cached",
"file": "backend/cortex_backend/llamacpp/server_manager.py:1024",
"failureScenario": "Default settings (`LlamaCppSettings.gpu_backend = \"auto\"`, settings.py:49) so `_backend_order` returns `[\"vulkan\", \"cpu\"]`. The Vulkan asset fails its pinned hash \u2014 a mis-pinned `directory_sha256`, a re-uploaded release asset, a corporate proxy rewriting the archive, or antivirus quarantining one `ggml-*.dll` inside `<runtime_dir>/b10311-vulkan/` \u2014 so `BinaryFetcher.ensure_binary(release, \"vulkan\")` raises `BinaryVerificationError`. That is a `LlamaCppError`, not a `ServerLaunchError`, so `_start`'s loop never catches it and the `\"cpu\"` iteration never runs. Every GGUF message fails forever with \"Downloaded llama.cpp archive failed checksum verification.\", even though the CPU build is present, verified and would have run. Only manually switching Settings \u2192 GPU backend to \"cpu\" recovers.",
"evidence": "Ran a manager with a fetcher where `is_cached(\"cpu\") is True` / `ensure_binary(\"cpu\")` succeeds and `ensure_binary(\"vulkan\")` raises `BinaryVerificationError`, gpu_backend=\"auto\":\n fetcher calls : [('is_cached','vulkan'), ('ensure_binary','vulkan')] <- cpu never touched\n launches : 0\n raised : BinaryVerificationError - Downloaded llama.cpp archive failed checksum verification.\nControl with gpu_backend=\"cpu\": ready at http://127.0.0.1:43125, state=ready.\nThe loop only catches one type:\n for backend in self._backend_order(...):\n try:\n handle = self._start_with_backend(model_path, num_ctx, backend, on_status, cancellation_event)\n except ServerLaunchError as exc:\n last_exc = exc; ...; continue\n`tests/test_llamacpp_server_manager.py` references `BinaryVerificationError` only for cancellation (lines 165, 180) \u2014 a genuine verification failure with a usable alternative backend is untested.",
"severity": "high",
"fix": "server_manager.py:1024 \u2014 widen the loop to `except (ServerLaunchError, BinaryVerificationError) as exc:` (and `OSError`, which `_extract`/`_remove_tree`/`os.replace` in binary_fetcher.py:253-258 can raise on a full disk or an AV-locked DLL), keeping `last_exc` so the final `raise last_exc` still reports the real cause. Cancellation is unaffected: `_start_with_backend` (lines 1127-1130, 1140-1143) already converts a cancelled `BinaryVerificationError` into a plain `LlamaCppError`, which the widened clause still will not catch. Guard `_mark_backend_bad` so a fetch failure is not recorded as \"vulkan can't run here\"."
},
{
"title": "Reopening Cortex within 60s of an unclean exit silently disables every local execution capability for the whole session",
"file": "backend/cortex_backend/execution/local_runtime.py:350",
"severity": "high",
"failureScenario": "Cortex is killed without a clean lifespan teardown (Task Manager, a crash, a Windows reboot, or the launcher's own `ServerSupervisor.stop(timeout=15)` giving up), so `release_supervisor_lease` never runs and the `execution_supervisor_leases` row survives with its full 60s TTL. The user reopens Cortex ~10 seconds later. `LocalExecutionCoordinator.startup_recover()` calls `claim_supervisor_lease` as its very first statement, outside any try/except; the surviving row is still live and owned by the dead process, so `LeaseConflict` propagates. `ExecutionLifecycle.start()` catches it, latches `_state = \"blocked\"`, and is never called again (the lifespan calls `start()` exactly once). For the rest of that session `/api/v1/system` reports execution_preview_available / code_execution_available / scratch_compute_available / image_transform_available all false, the background-task tray disappears from the sidebar, `POST /api/v1/execution/code` and `GET /api/v1/execution/tasks` return 404 \"Execution preview is unavailable.\", automatic compute is skipped, and any job that was in flight at the crash is left non-terminal because recovery never runs. The only user-visible explanation is one `WARNING ... Execution lifecycle start failed (LeaseConflict)` log line. Restarting again more than 60s after the crash restores everything.",
"evidence": "local_runtime.py:350 \u2014 the claim is made before the try block that would release/So a conflict escapes startup_recover entirely:\n self.repository.claim_supervisor_lease(\n lease_owner=self._supervisor_owner,\n ttl_seconds=self.supervisor_lease_seconds, # 60.0\n )\n self._supervisor_lease_active = True\n try: ...\nrepository.py:854 \u2014 a lease left by a dead process is indistinguishable from a live one:\n if row is not None and datetime.fromisoformat(row[\"lease_expires_at\"]) > now and row[\"lease_owner\"] != lease_owner:\n raise LeaseConflict(\"Execution recovery supervisor is already running.\")\nlifecycle.py:198-215 latches `self._state = \"blocked\"`, and api/app.py:129 calls `start()` once per process, so nothing retries.\n\nRan against a real app (app_factory.build_app on a temp data dir), run 1 started the lifecycle then dropped the app without `stop()`; run 2 built a fresh app on the same data dir:\n run 1: ready\n WARNING:cortex.execution.lifecycle:Execution lifecycle start failed (LeaseConflict).\n run 2 lifecycle: blocked\n system flags: {'execution_preview_available': False, 'code_execution_available': False, 'scratch_compute_available': False, 'image_transform_available': False}\n POST /api/v1/execution/code -> 404 {'detail': 'Execution preview is unavailable.'}\n GET /api/v1/execution/tasks -> 404 {'detail': 'Execution preview is unavailable.'}\nThe same crash also strands in-flight jobs: with the supervisor lease reclaimed by hand, a job whose lease is still live is skipped by `recover_expired_leases()` (it selects only `lease_expires_at <= now`), stays 'running', and the user's Cancel only moves it to 'cancelling' \u2014 `LocalExecutionCoordinator.cancel` falls through to `repository.request_cancel` with no in-process worker to finish it, and `cleanup_expired` will not delete a non-terminal job.",
"fix": "In `local_runtime.py:350`, a supervisor lease left behind by a previous process must be reclaimable rather than fatal: persist a process identity (pid + boot/start time) alongside `lease_owner` and let `claim_supervisor_lease` steal a lease whose recorded process is gone, or \u2014 since `startup_recover()` runs exactly once per process and the lease is renewed every 5s \u2014 treat a lease this process has never renewed as stale at startup. Additionally, in `lifecycle.py:198`, separate `LeaseConflict` from a genuine start failure so the lifecycle can retry (e.g. on the next `/api/v1/system` poll) instead of latching \"blocked\" for the session."
},
{
"title": "A failed post-generation reload strands the transcript on \"Loading conversation...\" with no way back",
"file": "frontend/src/features/chat/ChatPage.tsx:240",
"failureScenario": "Thread A is generating. The user opens /settings and comes back to /chat/A (or switches to another chat and back), so the route effect has an `api.chat(\"A\")` still in flight and the page is showing the loading spinner. The resumed stream then delivers `generation.completed`, which calls `reconcileChat(\"A\")`; that bumps the shared request-version for A (invalidating the pending route load) and its own `GET /chats/A` fails with a transient backend/network error. Result: the route load returns early because it is no longer the latest request, and reconcileChat's catch never touches `chatLoad`, so `chatLoad.loading` stays true forever. ChatPage renders `Loading conversation...` permanently \u2014 no transcript, no composer, and no Retry button (that only renders for `chatLoad.error`). The only escape is navigating to a different chat and back.",
"evidence": "`loadChat` and `reconcileChat` share one version map, and only `loadChat` owns the loading flag. loadChat:157 gives up silently once superseded: `if (viewThreadIdRef.current !== requestedThreadId || !isLatestRequest()) return;` \u2014 leaving the `setChatLoad({ threadId, loading: true, error: null })` it set at line 149 in place. reconcileChat always bumps that same version (line 224-225) and its failure path clears nothing:\n```js\n} catch {\n if (!isLatestRequest()) return;\n setGenerationError({ threadId: id, message: \"Generation finished, but the saved chat could not be reloaded.\" });\n}\n```\nThe render guard at line 682 is `if (chatLoad.threadId !== threadId || chatLoad.loading) return <div className=\"chat-empty-state\">\u2026Loading conversation...</div>` \u2014 no retry affordance.\n\nRAN: a ChatPage test driving exactly that order (route load for thread-a held pending \u2192 `generation.completed` \u2192 reconcile's `api.chat` rejected \u2192 the held route load then resolved). Document body afterwards: `\"Loading conversation...\"`. A control run identical except that the reconcile fetch resolves clears the spinner normally, so the version race plus the empty catch is the cause.",
"severity": "high",
"fix": "In reconcileChat's catch (ChatPage.tsx:239-242), also settle the load state that it invalidated, e.g. `if (viewThreadIdRef.current === id) setChatLoad({ threadId: id, loading: false, error: null });` alongside the existing `setGenerationError`, so the stale transcript is shown with the error banner instead of an eternal spinner. Better still, give loadChat and reconcileChat separate version counters so a completion reload never invalidates an in-flight route load."
},
{
"title": "downloadExecutionArtifact clears the session on a stale 401, wiping a freshly re-exchanged valid session",
"file": "C:/Users/Admin/source/repos/Chat_LLM/frontend/src/api/client.ts:503",
"failureScenario": "The bearer session expires while two requests are in flight (e.g. the task tray polls while the user clicks \"download result\" on a finished image/code run). The first 401 calls clearSession() -> App.handleSessionExpired -> setSessionReady(false) + reconnect() -> api.rebootstrap(handoffSecret) installs a brand new, valid session token. The artifact download, still carrying the old token, then answers 401 and calls clearSession() unconditionally -- destroying the session that was just successfully re-established and firing sessionExpired again. The user is bounced back to the connecting/Onboarding screen, a second handoff+exchange round trip is burned, and every request issued in between goes out with no Authorization header. Reproduced deterministically: with a gated fetcher, exchange boot-1 -> start downloadExecutionArtifact('a1') -> exchange boot-2 (fresh valid token) -> resolve the download with 401 gives `hasSession: false, sessionExpired events: 1`, while the identical sequence through request()/api.system() gives `hasSession: true, expiredEvents: 0`.",
"evidence": "client.ts:498-505 is the only one of the three `this.fetcher(` call sites without the staleness guard:\n\n async downloadExecutionArtifact(artifactId: string): Promise<Response> {\n const response = await this.fetcher(\n `${this.baseUrl}/execution/artifacts/${encodeURIComponent(artifactId)}`,\n { headers: this.authHeaders() },\n );\n if (response.status === 401) this.clearSession();\n\nCompare request() (client.ts:586-592):\n const sessionAtRequest = authenticated ? this.sessionToken : null;\n ...\n if (response.status === 401 && authenticated && this.sessionToken === sessionAtRequest) {\n this.clearSession();\n }\nand streamEvents() (client.ts:324-335), which both capture `sessionAtRequest` before the fetch precisely to ignore a 401 that belongs to a token already replaced. App.tsx:126-131 shows the consequence: clearSession() -> handleSessionExpired -> setSessionReady(false), which unmounts the workspace. Not covered by src/api/client.test.ts (no test references downloadExecutionArtifact or the stale-401 path).",
"severity": "medium",
"fix": "In client.ts:498-505, capture the token before the fetch and reuse the same guard as request(): `const sessionAtRequest = this.sessionToken;` before `await this.fetcher(...)`, then `if (response.status === 401 && this.sessionToken === sessionAtRequest) this.clearSession();`."
},
{
"title": "A damaged execution.sqlite is fatal at the composition root, so disposable job state can make the whole app unlaunchable and intact chat history unreachable",
"file": "app_factory.py:69",
"severity": "medium",
"failureScenario": "execution.sqlite holds only transient bookkeeping (jobs, events, leases, artifact rows) and is written on every job, event and lease renewal, so it is the store most exposed to an unclean shutdown. Unlike the chat store and the settings store it has no backup, no verified-copy rotation and no corrupt-primary recovery. ExecutionRepository is also the very first dependency built in build_app(), before DatabaseManager. Zeroing bytes 100..4096 of execution.sqlite (a single torn page) makes the next launch raise ExecutionRepositoryError out of build_app(); main.py:427 does not guard the call, so Cortex will not start at all. The user's chats and settings are perfectly intact but unreachable, and there is no in-app way out -- they have to find and delete a file in %APPDATA% by hand.",
"evidence": "app_factory.py:68-71 builds it first and unguarded:\n\n execution_repository = ExecutionRepository(\n paths.execution_database,\n paths.execution_artifacts,\n )\n\nexecution/repository.py:113-116 -> _ensure_schema() -> _ensure_schema_locked() -> connect(), and connect() (line 137) turns any sqlite3.Error into ExecutionRepositoryError. There is no _prepare_primary/_create_backup/_database_is_valid in execution/repository.py at all (`grep -n \"def \" execution/repository.py` has no backup or recovery method), while both other stores have two-generation verified backups.\n\nReproduced against a real app:\n app = app_factory.build_app(data_dir=tmp, serve_frontend=False, handoff_secret=\"p\") # ok, chat 'Keepme' created\n data = bytearray(p.read_bytes()); data[100:4096] = b\"\\x00\" * (4096-100); p.write_bytes(bytes(data))\n app_factory.build_app(data_dir=tmp, ...)\n->\n sqlite3.DatabaseError: database disk image is malformed\n ...\n File \"app_factory.py\", line 69, in build_app\n File \"execution/repository.py\", line 157, in _ensure_schema\n cortex_backend.execution.repository.ExecutionRepositoryError: SQLite execution operation failed.\n RESTART FAILED\n\nNo test covers this: `grep -rn \"malformed\\|corrupt\" tests/test_execution_store.py tests/test_execution_cleanup.py` returns only test_recovery_supervisor_fails_closed_on_malformed_payload (a JSON payload, not the file).",
"fix": "Because execution.sqlite carries no user-authored data, the cheapest correct behaviour is to rebuild rather than restore. In ExecutionRepository._ensure_schema (execution/repository.py:147), validate the file first with a read-only `PRAGMA integrity_check` (the same helper shape as storage.py:148 _database_is_valid); when it fails, rename the file to `<db>.corrupt-<uuid4().hex>` (and remove the `-wal`/`-shm` sidecars) and let _ensure_schema_locked recreate an empty schema, logging the loss of in-flight job state. Alternatively wrap the ExecutionRepository construction at app_factory.py:69 in the same recovery so a broken execution store can never block access to chats."
},
{
"title": "Every backup rotation orphans the temporary copy's -shm/-wal sidecars, so the data directory grows without bound (~62 KB per settings save)",
"file": "backend/cortex_backend/repositories/storage.py:183",
"severity": "medium",
"failureScenario": "_atomic_copy_database() copies the database to a mkstemp temp name and then validates it with _database_is_valid(), which opens the temp file; because these databases are in WAL mode that open creates `<temp>-shm` (32 KB) and `<temp>-wal`. os.replace() then moves only the main temp file to its destination, so both sidecars are orphaned under a name nothing will ever look at again. SQLiteSettingsRepository.save() calls _create_backup() on *every* save (two copies per call), so ordinary settings use leaks ~4 files / ~62 KB each time; DatabaseManager.__init__ leaks 4 more per launch. Nothing ever deletes them. A user who changes settings a few hundred times ends up with thousands of dot-prefixed junk files and tens of MB of dead .tmp-shm in %APPDATA%\\ChatLLM\\ChatLLM-Assistant, which also slows every later directory scan of that folder.",
"evidence": "storage.py::_atomic_copy_database, 170-195 (and the identical sqlite_settings.py:270-295):\n\n fd, temporary_path = tempfile.mkstemp(prefix=f\".{os.path.basename(destination)}.\", suffix=\".tmp\", dir=...)\n os.close(fd)\n shutil.copy2(source, temporary_path)\n if not cls._database_is_valid(temporary_path): # <- opens the WAL db, creates <temp>-shm/-wal\n raise OSError(...)\n os.replace(temporary_path, destination) # <- moves only the main file\n\nMeasured against a real app (build_app + TestClient, 5 PUT /api/v1/settings):\n strays before: 2\n strays after 5 settings saves: 20\n data dir files: 29\nwith 9 x `.cortex_settings.sqlite.bak*.tmp-shm` at 32768 bytes each plus their 0-byte `.tmp-wal` partners -- ~295 KB of dead files from five settings changes.\n\nRepeated launches leak too: 6 DatabaseManager constructions against one path produced strays = 2, 6, 10, 14, 18, 22 (four per launch, linear and unbounded).",
"fix": "In both _atomic_copy_database implementations (storage.py:170 and sqlite_settings.py:270), delete `Path(f\"{temporary_path}-wal\")` and `Path(f\"{temporary_path}-shm\")` (unlink with missing_ok=True) in the `finally` block right after `os.replace(temporary_path, destination)` succeeds, and also on the error path where the temp file is removed. Simplest robust variant: extract the existing sqlite_settings._discard_sidecars() into a module helper taking a base path and call it for the temp name after every copy. A regression test asserting no `*.tmp-shm` remains after two DatabaseManager constructions / two settings saves would pin it."
},
{
"title": "Live generation SSE silently drops a contiguous run of content/thinking deltas: JobRegistry evicts events an already-connected stream has not read yet",
"file": "backend/cortex_backend/api/jobs.py:802",
"failureScenario": "The model returns an answer longer than about 20,300 characters (or a shorter answer whose reasoning trace plus answer exceed that combined). `runner()` in routes.py publishes the whole response as `len(text)/80` progress events in one tight loop with no awaits, while the SSE consumer is parked in `await asyncio.sleep(0.025)`. `JobRegistry._append_event` caps retained events at `DEFAULT_MAX_EVENT_COUNT = 256` and evicts from the front, so by the time the consumer wakes and takes its next snapshot the earliest deltas are already gone -- on a connection that never dropped and never reconnected. The browser receives a sequence jump (`useGenerationStream.ts:232` only filters `event_id <= cursor`; nothing checks for a gap), so the assistant bubble streams in starting mid-answer. A 40,000-character answer delivers only 20,320 characters live; the missing half reappears only when `generation.completed` fires and `reconcileChat` reloads the persisted message, so the user watches half an answer stream in and then sees it snap to the full text. With a reasoning model the reasoning panel is hit first: 12,000 chars of thoughts + 12,000 chars of answer streams 8,320 of the 12,000 thought characters.",
"evidence": "backend/cortex_backend/api/jobs.py:800-812 evicts with no regard for open readers:\n\n record.events.append(event)\n record.event_bytes += retained_bytes\n while (\n len(record.events) > self._max_event_count\n or record.event_bytes > self._max_event_bytes\n ):\n if len(record.events) == 1:\n break\n evicted = record.events.pop(0)\n\nEnd-to-end over HTTP (create_app + demo deps, one continuously open GET /api/v1/generations/{job}/events, reassembling `generation.content_delta` deltas and comparing against GET /api/v1/chats/{thread}):\n\n answer 19000 chars -> streamed 19000 chars, gaps [], lost 0\n answer 20200 chars -> streamed 20200 chars, gaps [], lost 0\n answer 21000 chars -> streamed 20280 chars, gaps [(4, 14)], lost 720\n answer 40000 chars -> streamed 20320 chars, gaps [(4, 251)], lost 19680\n\nand with thoughts sharing the same budget:\n\n thoughts streamed 8320 of 12000; answer streamed 12000 of 12000\n gaps: [(4, 51)]\n\nThe events() docstring at jobs.py:544-551 anticipates loss only for a *reconnect* with a stale cursor (\"The cursor is a monotonic lower bound, not a durable replay promise\"); here the stream is continuously connected and loses events it was never given the chance to read.",
"severity": "medium",
"fix": "Do not evict below the position of any live reader. Concretely, in backend/cortex_backend/api/jobs.py add a per-record low-water mark (e.g. `record.min_live_cursor`, the minimum cursor across the `events()` generators currently attached to that record, registered on entry and released in a `finally`) and stop the eviction loop at `record.events[0].sequence <= min_live_cursor`. A smaller mitigation that removes the common case is to raise `DEFAULT_MAX_EVENT_COUNT` (jobs.py:37) well above the worst-case chunk count and let `DEFAULT_MAX_EVENT_BYTES` be the only real bound, and/or raise the chunk size in `_chunks` (backend/cortex_backend/api/routes.py:800) so a long answer produces far fewer than 256 events."
},
{
"title": "ImageTransformPlan bounds resize width and height separately but never their product, so one API request allocates ~2.2 GB before the pixel limit is checked",
"file": "backend/cortex_backend/execution/recipes.py:155",
"failureScenario": "POST /api/v1/execution/recipe/image with a plan whose single step is `{\"op\":\"resize\",\"width\":16384,\"height\":16384}` against any staged 8x8 PNG is accepted (202). The spawned worker peaks at 2.19 GB RSS for ~8 seconds and only then fails with `worker_provider_failed`. On a desktop with modest RAM this thrashes or OOMs the machine; the request is cheap to repeat (a few hundred bytes of JSON) and each repeat pays the same spike.",
"evidence": "`ResizeStep` bounds each dimension independently and nothing bounds the product:\n```python\nclass ResizeStep(_StrictModel):\n op: Literal[\"resize\"]\n width: Annotated[int, Field(strict=True, ge=1, le=MAX_IMAGE_DIMENSION)] # 16384\n height: Annotated[int, Field(strict=True, ge=1, le=MAX_IMAGE_DIMENSION)] # 16384\n```\n16384*16384 = 268 Mpx, four times `MAX_PIXELS` (64 Mpx). The pixel bound is only applied *after* the allocation, in execution/recipe_provider.py:287-291 -> `_image_dimensions(current, limits)` at the end of the loop body:\n```python\nelif step.op == \"resize\":\n current = _replace_image(\n current,\n current.resize((step.width, step.height), resample=Image.Resampling.LANCZOS),\n )\n...\n_image_dimensions(current, limits) # raises resource_limit -- too late\n```\nDirect provider call: `PLAN ACCEPTED: (ResizeStep(op='resize', width=16384, height=16384),)` / `RAISED RecipeProviderError resource_limit` / `elapsed 3.61s peak_rss=2177.8 MB`.\nEnd-to-end through the API: `recipe: 202 ...` then `final status: failed child peak rss = 2190.4 MB elapsed 8.4s`, `error: worker_provider_failed`. The recipe worker (execution/local_recipe_attempt.py) has no memory cap \u2014 unlike the code worker, it is never placed in a `_WindowsProcessJob`. tests/test_recipe_provider.py:142 only exercises the pixel limit via artificially tiny `RecipeProviderLimits` on a grayscale step, so this path is untested.",
"severity": "medium",
"fix": "backend/cortex_backend/execution/recipes.py:155 \u2014 add a `@model_validator(mode=\"after\")` on `ResizeStep` (and the same on `CropStep`) rejecting `width * height > MAX_PIXELS`, so the plan is refused at parse time. Defensively, also check the target size before calling `resize` in execution/recipe_provider.py:287 rather than only after."
},
{
"title": "Adding a memory when 100 already exist returns 500, and PUT /memories has no per-item length bound (its POST sibling does)",
"file": "backend/cortex_backend/api/routers/settings.py:101",
"failureScenario": "A user who has accumulated the maximum 100 permanent memories adds one more: POST /api/v1/memories {\"memo\":\"one more memory\"} -> 500 {\"detail\":\"Could not save memory.\"}. The store being full is an ordinary, expected state, but it is reported as a server fault with no indication of the real cause, and the memory is silently not saved. Separately, PUT /api/v1/memories {\"memos\":[\"a\"*501]} -> 500, while the sibling POST /api/v1/memories {\"memo\":\"a\"*501} correctly returns 422 with a field-level message.",
"evidence": "`PermanentMemoryManager.normalize_memos` raises bare `ValueError`s for both domain limits (repositories/storage.py:1127, 1132):\n```python\nif len(memo) > cls.MAX_MEMO_LENGTH:\n raise ValueError(f\"memory entries may not exceed {cls.MAX_MEMO_LENGTH} characters\")\n...\nif len(normalized) >= cls.MAX_MEMOS:\n raise ValueError(f\"no more than {cls.MAX_MEMOS} memories may be stored\")\n```\nBoth routes funnel every exception into `_raise_repository_error`, which is hard-coded to 500 (api/routes.py:1120-1127):\n```python\nexcept Exception as exc:\n _raise_repository_error(\"save memory\", exc)\n```\nThe request schemas are asymmetric: `AddMemoryRequest.memo: str = Field(min_length=1, max_length=500)` (api/schemas.py:276) versus `ReplaceMemoryRequest.memos: list[str] = Field(max_length=100)` (api/schemas.py:280) \u2014 the list length is bounded, the item length is not.\n\nRan against a real app:\n```\nseed 100: 200 100\nadd 101st: 500 {\"detail\":\"Could not save memory.\"}\nreplace with 501-char memo: 500 {\"detail\":\"Could not replace memories.\"}\nadd 501-char memo: 422 {\"detail\":[{\"type\":\"string_too_long\",...}]}\nreplace with non-string: 422 {\"detail\":[{\"type\":\"string_type\",...}]}\n```",
"severity": "medium",
"fix": "Two parts. (1) api/schemas.py:280 \u2014 give the item the same bound as its sibling: `memos: list[Annotated[str, StringConstraints(min_length=1, max_length=500)]] = Field(max_length=100)`, so an oversized entry is a 422 like POST. (2) api/routers/settings.py:95-113 \u2014 catch the domain limit explicitly before the generic handler, e.g. add a `MemoryLimitError(ValueError)` raised by `normalize_memos` (repositories/storage.py:1127, 1132) and map it to `HTTPException(409, \"Permanent memory is full; remove an entry first.\")` in both `add_memory` and `replace_memories`, leaving `_raise_repository_error` for genuine persistence faults."
},
{
"title": "Execution SSE stream can close without ever delivering the job's terminal event",
"file": "backend/cortex_backend/api/routes.py:1546",
"failureScenario": "A client streams GET /api/v1/execution/{job_id}/events while the job is running. _poll_execution_stream reads new events and the job row as two separate SQLite connections. If the coordinator commits the terminal transition between them, the poll returns an empty event batch plus a terminal job, and the router at execution.py:545 returns immediately \u2014 the execution.completed / execution.failed / execution.cancelled frame (and any events after the batch) is never sent. The stream just ends mid-job, so a consumer of CortexApi.streamExecution shows the task as still running until it falls back to polling. Measured with a real worker thread and no patching: 4 of 120 streams (~3%) closed having delivered zero events while the store held ['queued','completed'].",
"evidence": "routes.py:1546-1547:\n events = repository.events(job_id, after_sequence=after_sequence)\n return events, repository.get_job(job_id, owner=owner)\nThe docstring above it claims these are \"an event batch and a job status read taken at the same point in time\", but repository.connect() opens a fresh connection per call, so they are two independent transactions. Consumer, execution.py:536-546: `events, current = await asyncio.to_thread(_poll_execution_stream, ...)` then `if current is None or current.status in TerminalExecutionStatus: return`. Deterministic confirmation: hooking ExecutionRepository.events to commit `transition(status=\"succeeded\", event=\"completed\")` right after it returns makes the endpoint respond 200 with an empty body while repository.events(job) holds [(1,'queued'),(2,'completed')]. This also breaks the contract tests/test_execution_api.py:118 asserts (`events[-1][\"event\"] == \"execution.completed\"`).",
"severity": "medium",
"fix": "Read the job before the events so an observed terminal status is never newer than the batch: in routes.py:1538-1547 make it `job = repository.get_job(job_id, owner=owner); return repository.events(job_id, after_sequence=after_sequence), job`. (Equivalently, in execution.py:545, before returning on a terminal `current`, do one final events() read and yield whatever it returns.)"
},
{
"title": "When a non-ServerLaunchError escapes _start, the runtime is left in `downloading_binary` with `last_error=None`, so the UI shows \"Downloading runtime\u2026\" forever and never reports the failure",
"file": "backend/cortex_backend/llamacpp/server_manager.py:1048",
"failureScenario": "Same trigger as above (or any `OSError` from the fetcher \u2014 disk full during `_extract`, an AV-locked DLL during `_remove_tree`). `_start_with_backend` sets `self._state = \"downloading_binary\"` at line 1118 and then raises before anything resets it; `_start` only publishes `state=\"failed\"` / `last_error` at lines 1052-1054, which is reached solely when the `for` loop is *exhausted*, not when an exception escapes it. `ensure_ready`'s handler (line 592) only records launch failures and re-raises. So `/api/v1/system` keeps reporting `llamacpp.state=\"downloading_binary\", last_error=null` indefinitely: `MessageComposer.tsx:34` renders the badge as \"Downloading runtime\u2026\" with an empty tooltip (`last_error ?? last_restart_reason ?? undefined`), and `runtimeAvailability.ts:36` only treats `state === \"failed\"` as unavailable, so Send stays enabled and every message fails while the UI insists a download is in progress. Nothing repairs the state until the next `ensure_ready`.",
"evidence": "Same harness as the previous finding:\n status.state : downloading_binary\n status.error : None\n status.backend: None\n(and again identically on a second attempt).\nA fetcher raising a plain `OSError(\"disk gone\")` from `ensure_binary` gives the same result \u2014 the exception is not a `LlamaCppError` at all, so it bypasses `ensure_ready`'s `except LlamaCppError` entirely:\n raised: OSError disk gone\n state: downloading_binary | last_error: None\nThe only path that publishes a terminal state is after the loop:\n with self._state_lock:\n self._state = \"failed\"\n self._last_error = message\n raise last_exc or LlamaCppError(message)",
"severity": "medium",
"fix": "server_manager.py:1010-1055 \u2014 wrap `_start`'s body so *any* exit without a ready handle publishes the terminal state, e.g. `except Exception: with self._state_lock: if self._state != \"ready\": self._state = \"failed\"; self._last_error = message; raise`. (The already-asserted `\"starting\"`-after-timeout case at tests/test_llamacpp_server_manager.py:419 is the same shape and would be fixed by the same change.) Optionally also relax `frontend/src/app/runtimeAvailability.ts:36` to gate on a stale non-ready state, but the backend is where the lie originates."
},
{
"title": "The warm-server health re-verification uses a 1.0s HTTP timeout instead of the declared 2.0s, so a briefly slow but live llama-server is torn down and the model fully reloaded",
"file": "backend/cortex_backend/llamacpp/server_manager.py:1288",
"failureScenario": "A GGUF model is loaded and healthy. More than `_HEALTH_STATUS_CACHE_SECONDS` (5s) since the last probe, the user sends a message while the machine is paging (a large model partly swapped out, another app taking memory) so llama-server's `/health` needs ~1.5s. `_probe_health` calls httpx with `timeout=1.0`, so all three `_HEALTH_RETRY_ATTEMPTS` time out while `process.poll()` keeps returning `None`. `_reuse_verdict` returns `failure=True, reason=\"the runtime stopped responding to health checks (3 attempts)\"`, the live process is terminated and the multi-gigabyte model is reloaded from disk \u2014 minutes of work \u2014 for a server that was never actually dead. `_HEALTH_RETRY_TIMEOUT_SECONDS = 2.0` exists precisely to prevent this (\"a single slow /health response must never be a death sentence\u2026 waiting a few extra seconds to be sure costs nothing\") but is never referenced anywhere in the codebase.",
"evidence": "`grep -rn \"_HEALTH_RETRY_TIMEOUT_SECONDS\" backend/ tests/` -> only its own definition at server_manager.py:58. Both probes hardcode the value: `self._http.get(f\"{base_url}/health\", timeout=1.0)` (line 1288) and the `/props` call `timeout=1.0` (line 1302).\nRan a warm manager whose live process answers /health in 1.5s for a 5s window (poll() always None, so it is demonstrably alive):\n as shipped (1.0s budget) launches=2 warm process killed=True reason=the runtime stopped responding to health checks\n with the declared 2.0s budget launches=1 warm process killed=False reason=None",
"severity": "medium",
"fix": "server_manager.py:1286 \u2014 give `_probe_health` a `timeout: float = 1.0` keyword and pass it to both `self._http.get` calls (lines 1288, 1302); then call it from `_probe_health_with_retries` (line 822) with `timeout=_HEALTH_RETRY_TIMEOUT_SECONDS`, leaving the startup poll in `_start_with_backend` (line 1236) at the tighter 1.0s it wants."
},
{
"title": "_validate_gguf_file accepts a GGUF truncated inside its tensor data, so a partial download lands in the models folder as a usable model",
"file": "backend/cortex_backend/llamacpp/download.py:411",
"failureScenario": "The tensor loop only checks each tensor's *start* offset (`data_offset + tensor_offset >= size`); nothing checks that the file is long enough to hold the tensor bytes. A response framed without `Content-Length` (an HTTP/1.0 mirror, a proxy that drops the header, connection-close framing) makes `total is None`, so the `completed != total` guard at line 281 is skipped too, and a body cut off just past `data_offset` passes every check. `download_gguf` then hard-links it into the GGUF folder, `is_valid_gguf_file` (magic only) lists it in the model picker, and the first message launches llama-server against it. The child exits at load, so `_start` raises `ServerLaunchError` three times and `_guard_against_crash_loop` tells the user \"It likely does not fit in available memory. Choose a smaller model or quantization, or lower the context window in Settings\" \u2014 actively wrong advice for a truncated file, with no hint to re-download.",
"evidence": "Built a minimal valid GGUF (v3, 1 metadata kv, 1 tensor at offset 0, 4096 bytes of tensor data = 4224 bytes total) and truncated it:\n full file validates: OK\n truncated to 224 bytes (5.3% of the file): ACCEPTED as a valid GGUF | is_valid_gguf_file -> True\n truncated to 129 bytes (3.1% of the file): ACCEPTED as a valid GGUF | is_valid_gguf_file -> True\nEnd-to-end through `download_gguf` with an httpx MockTransport returning the truncated body and no Content-Length:\n saved: m.gguf 224 bytes (expected 4224) -> corrupt file accepted\n appears in the model picker: True\nThe check that should have caught it:\n for tensor_offset in tensor_offsets:\n if tensor_offset % alignment or data_offset + tensor_offset >= size:\n raise GGUFDownloadError(\"The downloaded file has an invalid GGUF tensor offset.\")",
"severity": "medium",
"fix": "download.py:405-413 \u2014 carry each tensor's dimensions (already read at line 399, currently discarded) and require `data_offset + tensor_offset + ceil(elements * bytes_per_element)` to fit inside `size`, tracking the maximum end offset. If deriving per-type sizes is unwanted, close the demonstrated hole at download.py:240-246 instead by rejecting a response with no `Content-Length` (`total is None`), since that header is currently the only real truncation guard and `_validate_gguf_file` cannot substitute for it."
},
{
"title": "Any message carrying an attachment crashes an engine that matches the declared GenerationEngine protocol (host_observations is passed but not declared)",
"file": "backend/cortex_backend/services/generation.py:278",
"severity": "medium",
"failureScenario": "Send a chat message with any attachment through a backend whose generation engine implements the `GenerationEngine` protocol as declared \u2014 which is the case for the shipped `FakeGenerationEngine` behind `scripts/e2e_backend.py` and `cortex_backend.testing.build_demo_dependencies()`. `GenerationService.generate` splats `observation_kwargs` (always `{\"host_observations\": ...}` since commit 2643886e removed the runtime probes) into `engine.fit_attachments_to_context(...)`, but that one method is the only one in the protocol that was not given a `host_observations` parameter. The call raises `TypeError: FakeGenerationEngine.fit_attachments_to_context() got an unexpected keyword argument 'host_observations'`. The user's turn is durably admitted, then the job fails: the transcript keeps the user message with no reply and the UI shows \"Job failed. Please try again.\" mypy cannot see it because `**observation_kwargs` is `dict[str, Any]`, and the browser specs miss it because composer.spec.ts stubs `/api/v1/attachments` and the generation POST with `page.route`, so the attachment path never reaches the e2e backend.",
"evidence": "services/generation.py:267-279 always passes the kwarg:\n if snapshot.attachments:\n reserved_attachments = engine.fit_attachments_to_context(\n snapshot.attachments,\n ...\n bypass_system_prompt=snapshot.bypass_system_prompt,\n **observation_kwargs, # {\"host_observations\": snapshot.host_observations}\n )\nservices/generation.py:137-149 \u2014 the protocol it was refactored to trust ends at `bypass_system_prompt`; there is no `host_observations`. `FakeGenerationEngine.fit_attachments_to_context` (testing/fake_ollama.py:160-181) matches that declaration; only the production `SynthesisAgent` (services/llm.py:601) happens to accept `host_observations: str | None = None`.\n\nDriven end to end through create_app(build_demo_dependencies()):\n stage attachment: 201 {...'attachment_id': 'd472...', 'kind': 'document'...}\n POST generation: 202 {'job_id': '9147...', 'status': 'queued', 'user_message_id': 'm-1'}\n ERROR:root:Cortex generation job failed (TypeError).\n FINAL: {\"status\": \"failed\", \"error\": \"Job failed. Please try again.\", \"result\": null}\n events: generation.queued, generation.started, generation.status, generation.status, generation.failed\nDirect call confirms the cause:\n TypeError: FakeGenerationEngine.fit_attachments_to_context() got an unexpected keyword argument 'host_observations'",
"fix": "Add `host_observations: Sequence[Any] = ()` to the `GenerationEngine.fit_attachments_to_context` signature at services/generation.py:137-149 (the sibling `fit_memories_to_context`, `fit_history_to_context` and `fit_history` all already declare it), and add the matching parameter to `FakeGenerationEngine.fit_attachments_to_context` at testing/fake_ollama.py:160. While there, the protocol types it as `Sequence[Any]` everywhere but `GenerationSnapshot.host_observations` and `SynthesisAgent` both use `str | None`; aligning them would let mypy catch this class of mismatch instead of it hiding behind `**kwargs`."
},
{
"title": "Stop cannot interrupt an Ollama turn until the model emits its next chunk, because OllamaChatClient has no watcher to close the response",
"file": "backend/cortex_backend/services/chat_client.py:108",
"severity": "medium",
"failureScenario": "With the default Ollama backend, press Stop while the model is in its prompt-evaluation / cold-load phase \u2014 the phase where Ollama has sent no chunks yet, which for a long context or a model not resident in VRAM lasts tens of seconds. `OllamaChatClient.chat` only tests `cancellation_event.is_set()` at the top of its `for chunk in chunks:` body, so it stays blocked inside the generator until a chunk actually arrives; nothing closes the response from outside. Until then the job stays non-terminal, the composer stays disabled showing \"Stopping response...\", and because JobRegistry allows one active generation, every new message is refused with 409 \"A generation job is already active.\" If the model never produces a chunk, this lasts until the 600s httpx read timeout configured in app_factory.py. LlamaCppChatClient does not have this problem: it starts a `llama-chat-cancel-watch` thread whose only job is to call `response.close()` when cancellation fires, precisely because (its own docstring) \"waiting for the next chunk to arrive would make Stop as slow as the model.\" The comment at chat_client.py:96-102 claims this path uses \"the same mechanism LlamaCppChatClient uses\", but breaking out of a loop cannot run until the loop resumes.",
"evidence": "services/chat_client.py:103-123 \u2014 cancellation is only observed between delivered chunks, and `close()` runs only after the loop has already exited:\n chunks = self._client.chat(model=model, messages=messages, options=options, stream=True)\n try:\n for chunk in chunks:\n if cancellation_event.is_set():\n break\n ...\n finally:\n close = getattr(chunks, \"close\", None)\n if callable(close):\n close()\nCompare llamacpp/chat_client.py:239-252, which does have the watcher.\n\nMeasured with a stand-in Ollama client whose generator sleeps 3s before its first chunk (a stalled prompt-eval), with the cancel event already set before the call:\n OllamaChatClient.chat returned after 3.00s with cancel already set\n result: {'message': {'content': '', 'thinking': None}}\nThe call is blocked for the entire stall even though cancellation was requested before it started.",
"fix": "Mirror the llama.cpp client in `OllamaChatClient.chat` (services/chat_client.py:94-123): before the loop, return the empty-response shape immediately if `cancellation_event.is_set()` (as LlamaCppChatClient.chat does at llamacpp/chat_client.py:133-140), and start a small daemon watcher that waits on `cancellation_event` and calls `chunks.close()` (plus a `finished` Event set on every exit path so the watcher retires after a normal completion), so a Stop unblocks the in-flight read instead of waiting for the model."
},
{
"title": "After a failed completion reload, the cached chat revision stays one behind and every further message in that thread is rejected",
"file": "frontend/src/features/chat/ChatPage.tsx:356",
"failureScenario": "User sends a message in thread A. `api.generate` is accepted, so ChatPage optimistically writes `revision: (current.revision ?? 0) + 1` (matching the server after the user turn is persisted). The generation completes \u2014 the backend now also persisted the assistant turn, so `chat_revision` (message count) is old+2 \u2014 but `reconcileChat`'s `GET /chats/A` fails, so the client keeps `revision = old+1` and messages without the assistant reply. The user dismisses the error and types another message: ChatPage sends `base_revision: old+1`, the backend compares it against old+2 and refuses with \"This chat changed. Reload it before generating again.\" The thread is now un-sendable, and neither of the two offered actions recovers it \u2014 \"Retry last message\" sees the optimistic user turn as the last message and calls `regenerate`, which the backend refuses with \"Only the final message can be regenerated\" (routes.py:384-388), and there is no reload/refresh control on the transcript at all.",
"evidence": "`const baseRevision = admissionOverride ? admissionOverride.baseRevision : currentChat?.revision ?? 0;` (ChatPage.tsx:356) reads the optimistic revision written at ChatPage.tsx:390-392, and the only thing that ever replaces it with server truth is `reconcileChat`, whose catch (line 239-242) sets an error message and nothing else. Backend: `chat_revision` = `len(chat.get(\"messages\", ()))` (services/chat.py:16) and `if payload.base_revision is not None and current_revision != payload.base_revision: raise ChatDomainError(\"This chat changed. Reload it before generating again.\")` (api/routes.py:288-293).\n\nRAN: a ChatPage test where the first `api.chat` succeeds (empty chat, revision 0), the message is accepted, `generation.completed` fires and the reload rejects. First request: `base_revision: 0`. Second message after the failure: `base_revision: 1` \u2014 while the server holds two messages, i.e. revision 2.",
"severity": "medium",
"fix": "Make the failure path recoverable rather than silently stale: in reconcileChat's catch, mark the chat state as unreliable and drop the cached revision (or retry the fetch), and surface a \"Reload conversation\" action next to the \"Generation finished, but the saved chat could not be reloaded.\" banner in ChatPage.tsx:241 that calls `loadChat()` \u2014 today the banner only offers \"Retry last message\", which replays the same stale state."
},
{
"title": "Memory rows the server normalizes away stay on screen as if they were saved",
"file": "frontend/src/features/settings/MemoryPanel.tsx:14",
"failureScenario": "Settings \u2192 Memory with stored memos [\"Likes tea\", \"Works at Acme\"]. The user edits row 2 to \"likes tea\" (a case-variant of row 1) \u2014 or just clears the row's text \u2014 and clicks \"Save changes\". The backend normalizes on write (strips, drops empties, drops case-insensitive duplicates), so it stores [\"Likes tea\"] and returns that list; App shows the toast \"Memory changes saved.\" But MemoryPanel seeds `draft` once at mount and never re-derives it from the `memos` prop, so the panel still renders two rows including the one that was discarded. The user believes the edit was saved; it is gone on the next launch. The same missing sync means any unsaved row edits are also dropped without warning when the user clicks another settings tab, because `MemoryPanel` is unmounted by SettingsPanel's `section === \"memory\"` guard.",
"evidence": "`const [draft, setDraft] = useState(() => memos.map((value, id) => ({ id, value })));` \u2014 a lazy initialiser with no `useEffect` on `memos` anywhere in the file; `draft` is only ever mutated by local handlers. Backend normalization: `InMemoryMemoryRepository._normalize` skips `if not value: continue` and `if key in seen: continue` (repositories/memories.py:64-75), and the legacy manager routes `update_memos` through `self.normalize_memos(memos)` (repositories/storage.py:1264).\n\nRAN: a MemoryPanel test whose `onReplace` mirrors that normalization. After saving, the stored list is `[\"Likes tea\"]` while the rendered inputs are `['', 'Likes tea', 'likes tea']` \u2014 the phantom \"Memory 2\" row is still in the document.",
"severity": "medium",
"fix": "Re-derive the draft when the authoritative list changes: keep the last-seen `memos` in a ref and, in an effect on `memos`, reset the row state to the server's list (or merge unsaved edits against it the way SettingsPanel's `mergeChangedValues` does for settings). At minimum, reset `draft` from the `onReplace` response so the panel stops showing rows the server rejected."
},
{
"title": "Malformed bracketed Host header reaches urlsplit() and raises ValueError -> unauthenticated HTTP 500 instead of 400",
"file": "C:/Users/Admin/source/repos/Chat_LLM/backend/cortex_backend/api/security.py:181",
"failureScenario": "`curl -H 'Host: [::1' http://127.0.0.1:<port>/api/v1/health` (or `[`, `[:evil.com`, `[::1]evil.com`) returns 500 Internal Server Error with an uncaught ValueError traceback in the server log, instead of the intended 400 \"invalid local host\". It reproduces unauthenticated on every route, including /api/v1/session/exchange and /api/v1/health, because validate_request_context runs before any credential check. Verified against a real app: Host '[::1' -> 500, '[' -> 500, '[:evil.com' -> 500, '[::1]evil.com' -> 500, while the well-formed '[]' -> 400. Reachability comes from app.py deliberately widening the middleware allowlist: TrustedHostMiddleware compares `host.split(':')[0]`, which is the literal '[' for every one of those headers, and app.py:242-248 appends '[' to middleware_allowed_hosts, so the middleware passes them through. The same widening also means those non-canonical Host values are accepted outright by routes that never call validate_request_context -- the static frontend handler at app.py:275-281 when serve_frontend is on.",
"evidence": "security.py:179-186:\n def validate_request_context(self, request: Request) -> None:\n raw_host = request.headers.get(\"host\") or \"\"\n host = (urlsplit(f\"//{raw_host}\").hostname or \"\").lower()\n if host not in self._allowed_hosts:\n raise HTTPException(status_code=..HTTP_400_BAD_REQUEST, detail=\"invalid local host\")\n\nurlsplit('//[::1') raises ValueError('Invalid IPv6 URL') -- it is not caught anywhere in the call chain, so it escapes require()/the route and becomes a 500. Confirmed with python: urlsplit for '[::1', '[', '[:evil.com', '[::1]evil.com' all raise ValueError, and each has `raw.split(':')[0] == '['`, the exact token app.py:244 adds to the middleware allowlist. Run against a built app with TestClient(raise_server_exceptions=False): all four return 500 on GET /api/v1/health.",
"severity": "low",
"fix": "Wrap the parse at security.py:181 so a malformed authority fails closed as a 400 rather than a 500, e.g. `try: host = (urlsplit(f'//{raw_host}').hostname or '').lower() except ValueError: host = ''` (the existing `host not in self._allowed_hosts` check then raises the 400). Additionally, replace the '[' entry appended at app.py:244 with a host-parsing check that matches SessionManager's, so TrustedHostMiddleware stops accepting every Host header beginning with '[' for the static frontend routes."
},
{
"title": "Artifact retention deletes the files but never the per-job directory, so every attachment and execution leaves an empty directory forever",
"file": "backend/cortex_backend/execution/repository.py:1442",
"severity": "low",
"failureScenario": "publish_artifact() creates one directory per job (`artifact_root / job_id`, job_id is a fresh uuid4 for every attachment stage and every code execution). cleanup_expired() quarantines and unlinks the artifact file, deletes the execution_artifacts row and finally deletes the terminal execution_jobs row -- but never rmdir()s the now-empty job directory, and nothing else sweeps artifact_root. Every file a user attaches to a chat therefore leaves a permanently empty directory in %APPDATA%\\ChatLLM\\ChatLLM-Assistant\\execution_artifacts. After the job row is purged there is no longer any record tying the directory to anything, so it can never be reclaimed; heavy attachment use accumulates tens of thousands of empty directories that slow every enumeration of that folder and are invisible to the app.",
"evidence": "Ran a full retention cycle against a real ExecutionRepository (3 jobs, one artifact each, retention_seconds=1, cleanup at now+10s, terminal_job_retention_seconds=0):\n\n before: ['artifacts\\\\.quarantine', 'artifacts\\\\job0', 'artifacts\\\\job0\\\\562d...-out0.txt', 'artifacts\\\\job1', ..., 'artifacts\\\\job2\\\\2fec...-out2.txt']\n pass1: ExecutionCleanupResult(artifacts=3, jobs=3, events=6)\n after1: ['artifacts\\\\.quarantine', 'artifacts\\\\job0', 'artifacts\\\\job1', 'artifacts\\\\job2']\n pass2: ExecutionCleanupResult(artifacts=0, jobs=0, events=0)\n after2: ['artifacts\\\\.quarantine', 'artifacts\\\\job0', 'artifacts\\\\job1', 'artifacts\\\\job2']\n jobs left: 0 | artifact rows: 0 | tombstones: 0 | events: 0\n\nThe three job directories survive with every row that referenced them gone. _resume_artifact_cleanup's finalize step only removes the quarantine copy:\n\n if state == \"finalized\":\n try:\n quarantine.unlink(missing_ok=True)\n ...\n \"DELETE FROM execution_artifact_cleanup WHERE artifact_id = ?\",",
"fix": "In _resume_artifact_cleanup (execution/repository.py:1393), after the quarantine copy is unlinked and the tombstone row is deleted, attempt `path.parent.rmdir()` inside a `try/except OSError: pass` -- guarded by the same `_validated_cleanup_path`-style check that the parent is a direct child of self.artifact_root and is not the root or the quarantine root. rmdir fails harmlessly while other artifacts for that job remain, so no extra bookkeeping is needed."
},
{
"title": "Last-Event-ID above 2**63-1 crashes the execution SSE stream with an unhandled OverflowError",
"file": "backend/cortex_backend/api/routes.py:1527",
"failureScenario": "GET /api/v1/execution/{job_id}/events with header `Last-Event-ID: 9223372036854775808` passes validation, then the value is bound straight into a SQLite query. sqlite3 raises `OverflowError: Python int too large to convert to SQLite INTEGER` inside the StreamingResponse generator \u2014 after `http.response.start` has already been sent. The client gets a 200 with an aborted, never-terminated chunked body, the server logs an ASGI traceback, and the EventSource reconnect loop can re-send the same bad id indefinitely. The sibling in-memory stream (/api/v1/generations/{id}/events, which uses `bisect_right` over Python ints) handles the same header fine, so the bound is enforced in one path and not the other.",
"evidence": "`_last_event_cursor` validates only the lower end:\n```python\ndef _last_event_cursor(request: Request, value: str | None = None) -> int:\n raw = request.headers.get(\"last-event-id\", \"0\") if value is None else value\n try:\n cursor = int(raw or \"0\")\n except ValueError as exc:\n raise HTTPException(status_code=400, ...) from exc\n if cursor < 0:\n raise HTTPException(status_code=400, ...)\n return cursor\n```\n`ExecutionRepository.events` mirrors that same one-sided check (execution/repository.py:938 `if after_sequence < 0`) and then binds the value: `connection.execute(\"... WHERE job_id = ? AND sequence > ? ...\", (job_id, after_sequence))`.\n\nRan against a real app (job made terminal first so the stream closes immediately):\n```\nLEID='0' -> 200 'id: 1\\nevent: execution.queued\\ndata: {...}'\nLEID='9223372036854775807' -> 200 ''\nLEID='9223372036854775808' -> EXC OverflowError: Python int too large to convert to SQLite INTEGER\nLEID='9999999999999999999999999' -> EXC OverflowError: Python int too large to convert to SQLite INTEGER\nrepo.events after_sequence= 9223372036854775808 -> EXC OverflowError ...\n```\ntests/test_api_contract.py:599 covers only the non-numeric (\"bad\") case.",
"severity": "low",
"fix": "backend/cortex_backend/api/routes.py:1527 \u2014 clamp the parsed cursor to the SQLite integer range, e.g. `if not 0 <= cursor <= 2**63 - 1: raise HTTPException(400, \"Last-Event-ID is out of range.\")` (and apply the same clamp in `_event_cursor` at api/routes.py:805 for symmetry). Defensively, tighten the guard in execution/repository.py:938 to `if not 0 <= after_sequence <= 2**63 - 1: raise ValueError(...)` so the store never hands a bignum to sqlite3."
},
{
"title": "Recipe and scratch workers overwrite a committed cancellation with \"succeeded\" (no expected_status guard)",
"file": "backend/cortex_backend/execution/recipe_coordinator.py:538",
"failureScenario": "A user presses Stop on an image recipe (or a scratch computation) in the window between the worker's last cancellation check and its terminal write. cancel() commits status=\"cancelling\" and the API returns that to the caller; the worker's terminal transition then carries no expected_status, so it overwrites \"cancelling\" with \"succeeded\". The durable event log ends up ['queued','started','progress','cancelling','completed'] with final status succeeded and the output artifact published and retained \u2014 the Stop the user was told was accepted is silently discarded. The equivalent write in _run_code is explicitly guarded, so the two profiles disagree about who wins.",
"evidence": "recipe_coordinator.py:533-540 \u2014 the cancellation check `if cancel_event.is_set() or (current is not None and current.status == \"cancelling\")` is followed by `self.repository.transition(job_id, status=\"succeeded\", event=\"completed\", ...)` with no expected_status; same shape at local_runtime.py:812-822 for scratch. Contrast local_runtime.py:594-604, where the code.exec worker passes `expected_status=\"running\"` and converts ExecutionTransitionConflict into a cancellation. Demonstrated by letting the user's cancel land in that window (coordinator.cancel invoked from a hook on the succeeded transition): events = [(1,'queued'),(2,'started'),(3,'progress'),(4,'cancelling'),(5,'completed')], final status 'succeeded', error None, artifact published. The natural window is sub-millisecond (0/200 randomized cancels hit it), so this bites a user who stops a job at the moment it finishes.",
"severity": "low",
"fix": "Pass `expected_status=\"running\"` to the terminal transition at recipe_coordinator.py:536 and local_runtime.py:814, and handle ExecutionTransitionConflict by re-reading the job and finishing it as cancelled (deleting the published artifact in the recipe case) \u2014 the pattern already used at local_runtime.py:594-609."
},
{
"title": "A failed chat move rolls the sidebar back over a later move that already succeeded",
"file": "frontend/src/app/App.tsx:532",
"failureScenario": "User moves chat \"Alpha\" into group \"One\" (PATCH in flight), then immediately reopens the row menu and moves it into \"Two\" (second PATCH in flight). The second request succeeds on the server; the first then fails (5xx, or the group was removed). Its unconditional rollback writes the group it captured *before* the first move \u2014 null \u2014 so the sidebar shows Alpha as ungrouped while the server has it filed under \"Two\". The divergence persists until the workspace is reloaded, and the toast (\"Could not move chat.\") gives no hint that the visible filing is now wrong.",
"evidence": "```js\nconst moveChat = (threadId, groupId) => {\n const previous = useChatStore.getState().chats.find((chat) => chat.id === threadId)?.group_id ?? null;\n setChatGroup(threadId, groupId);\n void api.moveChatToGroup(threadId, groupId).catch((error) => {\n setChatGroup(threadId, previous); // unconditional\n notify(apiMessage(error, \"Could not move chat.\"), \"error\");\n });\n};\n```\nThe sibling `toggleGroup` (App.tsx:512-522) does guard its rollback \u2014 `group.id === groupId && group.collapsed === collapsed ? \u2026 : group` \u2014 with a comment explaining exactly this hazard; `moveChat` never got the same treatment.\n\nRAN: an App-level test with two groups and both PATCH /chats/c1/group responses held open. After the second is fulfilled (200, group_id g2) and the first is then failed (500), `useChatStore.getState().chats[0].group_id` is `null` instead of `\"g2\"`.",
"severity": "low",
"fix": "Mirror toggleGroup's guard at App.tsx:532: only roll back when the row still holds this call's optimistic value, e.g. `setChats((current) => current.map((chat) => chat.id === threadId && chat.group_id === groupId ? { ...chat, group_id: previous } : chat));`"
}
]