@gotcos/glasses-server 6.40.1 → 6.40.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/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 6.40.2
2
+
3
+ A finished answer could leave the stream open forever.
4
+
5
+ An Ollama turn with tools streamed its whole answer, persisted it, and then
6
+ never sent the terminal `done` event. On the glasses the turn counted past
7
+ 1,100 seconds while the reply had actually completed in 166 and was already
8
+ saved in Messages. Double-tap to cancel did nothing, because there was no live
9
+ job to cancel.
10
+
11
+ The cause is one line in the query route, and it predates the tool loop. The
12
+ SSE callbacks are declared inside `const sid = await callModelStreaming(...)`,
13
+ so while that call is running `sid` is in its temporal dead zone. `onStart`
14
+ never noticed because its own `sid` PARAMETER shadows the outer binding.
15
+ `onDone` has no such parameter: it read the outer `sid`, threw
16
+ ReferenceError, and — critically — threw AFTER setting `done = true` and
17
+ BEFORE writing the terminal event, so the failure could not even fall through
18
+ to the error path. The socket stayed open with the work already committed.
19
+
20
+ Only Ollama hit it. The child-process bridges resolve outside that window, so
21
+ Claude and Codex kept working; the in-process Ollama loop awaits its own
22
+ finalize before returning, which lands the callback squarely inside the dead
23
+ zone. Both readers now use the session id captured from `onStart`.
24
+
25
+ The existing SSE contract test could never have caught this: it fires
26
+ callbacks on a macrotask after the router resolves, with a comment asserting
27
+ that bridges "never" call back synchronously. That assumption is now marked
28
+ as covering the child-process bridges only, and a new test pins the
29
+ in-process ordering. Reverting the fix makes all three of its cases hang,
30
+ which is the production symptom exactly.
31
+
1
32
  ## 6.40.1
2
33
 
3
34
  The local model can read your meetings and memories.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.40.1",
3
+ "version": "6.40.2",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,6 +114,22 @@ queryRouter.post('/query', async (req, res) => {
114
114
  res.write(': keepalive\n\n')
115
115
 
116
116
  let done = false
117
+ // The session id AS REPORTED BY THE BRIDGE.
118
+ //
119
+ // `const sid = await callModelStreaming(...)` below is not assigned until the
120
+ // whole call resolves, but these callbacks fire DURING that await — so any
121
+ // callback that reads `sid` reads a temporal-dead-zone binding and throws
122
+ // ReferenceError. `onStart` never noticed because its own `sid` PARAMETER
123
+ // shadows the outer const; `onDone` has no such parameter and did throw,
124
+ // after setting `done = true` and before writing the terminal event, which
125
+ // left the SSE socket open forever with the answer already persisted.
126
+ //
127
+ // Measured 2026-08-26: an Ollama tool turn streamed its full answer and never
128
+ // sent `done`; the glasses counted past 1,100 seconds on a turn that had
129
+ // finished in 166. Claude survived only because its bridge resolves outside
130
+ // the window — the same line was one scheduling change away from hanging
131
+ // every provider.
132
+ let streamSessionId: string | undefined = typeof sessionId === 'string' ? sessionId : undefined
117
133
  const abortController = new AbortController()
118
134
  res.on('close', () => {
119
135
  if (!done) abortController.abort()
@@ -123,6 +139,7 @@ queryRouter.post('/query', async (req, res) => {
123
139
  try {
124
140
  const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
125
141
  onStart: (model, sid, cliSessionId, metadata) => {
142
+ streamSessionId = sid
126
143
  if (!done) {
127
144
  const payload = { model, sessionId: sid, cliSessionId, ...metadata }
128
145
  res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
@@ -161,7 +178,7 @@ queryRouter.post('/query', async (req, res) => {
161
178
  // expiry or make maintenance proof outrun the pending write.
162
179
  if (resolvedAttachments.ids.length > 0) {
163
180
  await getMediaStore().associate(resolvedAttachments.ids, {
164
- sessionId: sid,
181
+ sessionId: streamSessionId ?? '',
165
182
  ...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
166
183
  messageEra: activeMessageEra,
167
184
  }).catch((err) => console.error('[query] attachment association failed:', err))
@@ -171,7 +188,7 @@ queryRouter.post('/query', async (req, res) => {
171
188
  const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
172
189
  const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
173
190
  const payload = {
174
- text: fullText, sessionId: sid, model, cliSessionId, ...runMetadata,
191
+ text: fullText, sessionId: streamSessionId, model, cliSessionId, ...runMetadata,
175
192
  ...(attachments.length > 0 ? { attachments } : {}),
176
193
  }
177
194
  res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)