@gotcos/glasses-server 6.80.0 → 6.82.0

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,27 @@
1
+ ## 6.82.0 - Routines: any COS skill on a schedule, each with its own cap, off switch and breaker
2
+
3
+ Miles, 2026-10-20 (plan Decision Q4): "Any COS skill on a schedule. Pick a skill, a time and days; each routine has its own cap and off switch." Until now the server had one routine, the morning brief.
4
+
5
+ - **Client matrix.** Server only in this release. Companion 6.10.650 lists, adds, edits, pauses and runs routines from the phone; older companions and COS Control keep using `/api/morning-brief`, which is unchanged. Nothing runs that a user did not create: a fresh server has the morning brief and no skill routines.
6
+ - **The morning brief is routine #1, unchanged.** Its config file, scheduler, prompt, scheduled submission, `/api/morning-brief` envelope, a rejected PUT and Run now while it runs are pinned by `morning-brief-golden.test.ts`, recorded on the unmodified 6.81.0 base (6b2e70b) before any routine code existed and passing byte for byte after. The only change it sees: its run is counted in the token audit as `routine:morning-brief` instead of `full_query`.
7
+ - **`/api/routines`.** `GET` lists the brief first, then skill routines, each with schedule, next run, gate (why it will not run), breaker state, today's runs against its cap, and its last run. `GET /routines/skills` lists this workspace's skills (`.agents/skills`, `.claude/skills`; labels only, never paths). `POST` creates; `PATCH` (or `PUT`) edits, and `resetBreaker: true` closes an open breaker; `POST /:id/pause` and `/:id/resume`; `DELETE` (the brief can only be turned off: 409 `cannot_delete_brief`); `POST /:id/run` is Run now (202; 409 running, off or skill missing; 423 breaker open, with `openUntil`; 429 capped); `GET /:id/runs` and `/routines/runs` are history. A brief edit through `/api/routines/morning-brief` takes only the brief's own fields and refuses any other (400 `not_a_brief_field`).
8
+ - **A skill routine.** A workspace skill, an optional extra instruction, a time and days in a zone (default the Mac's, weekdays at 08:00), runs a day (default 2, at most 10), a model (default `sonnet`, never left to the user default, never a key lane), and its breaker (default 3 failures, 6 hours). Stored in `data/routines/routines.json`, runs in `data/routines/runs.json` (0600, atomic; a damaged file keeps its good routines and drops the rest). It runs as an ordinary durable job with `origin: { kind: 'routine', id }`, so it survives the phone sleeping and lands in the inbox with a message number. Its prompt says nobody is watching: read and summarise freely, write only the skill's own files, never send, publish, deploy, push, delete or spend; if a source is missing, say so in one line.
9
+ - **How it stops.** Its own switch, and `COS_ROUTINES=0` turns every skill routine off (the brief keeps its own switch). Its daily cap counts scheduled and Run now runs together; a failed submit never ran and is not counted. Every skill routine together is capped at 40 runs a day. The breaker opens after N consecutive failed runs (a failed or interrupted job, or a failed submit) and nothing fires until the cooldown ends; then one trial run is allowed, a success closes it and a failure opens it again. A canceled run counts neither way, and each finished run is counted once however many ticks read it. One scheduled slot per selected day, three submit attempts two minutes apart, crash-resumed by a fixed client id. Inert while Background jobs are off or maintenance is closed, as the brief.
10
+ - **Token audit by caller.** A routine's run is logged as `routine:<id>` (Claude, Codex, Cursor and direct bridges; the runtime passes `auditCaller` only for routine origins, so every other caller is byte for byte what it was).
11
+ - **Checks.** Canary C7 (`server/scripts/routines-canary.ts`, not packaged): on a scratch data dir, a scratch HOME and workspace, an ephemeral port, and a stub `claude` on PATH, two routines fired at their slot on the production 30 s tick; the one whose skill fails exited 1, the breaker counted it, a second failure opened it, Run now answered 423 and the next tick skipped it, the other routine stayed ready, and the token audit row read `routine:<id>` on `sonnet`. Mutation gate `server/scripts/routines-mutation-gate.mjs` (not packaged): green baseline (55 tests in 6 files), 42 of 42 mutants killed, each by a named test. Full suite 8,503 passed, 0 failed, 3 skipped (562 files); `tsc --noEmit` clean. The key-lanes census classifies the three new model sites as Reject.
12
+
13
+ ## 6.81.0 - The Mac saves a stopped meeting on its own once it holds every chunk
14
+
15
+ Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the meetings". Both G2 meetings that day saved only once the phone came back on screen: the phone's Stop needs IndexedDB, and iOS suspends IndexedDB while the companion is hidden (11:19 qbbgyq: Stop 12:43, `stop_blocked`, "Load failed", saved 12:47 by a new WebView; 14:07 mip9qb: Stop 15:13:50, saved 15:14:37 only after the phone was visible). The full map is `docs/meeting-autonomous-save.md`.
16
+
17
+ - **Client matrix.** Server only. Nothing changes until the companion sends the new Stop (a companion release, not in this one). Older companions never call the new route and save exactly as before.
18
+ - **The phone's Stop, durable.** `POST /api/meeting/sessions/:id/stop` `{finalChunkIndex, serverInstanceId}`: one small fetch a hidden phone can still send. The server pin is required. It is written to `data/meeting-stop-intents.json` (0600, atomic) before the 202, so a restart keeps it. A repeat is idempotent; a higher index raises it, a lower one never lowers it. A Stop behind what the Mac already holds is refused 409 `final_index_behind_server`. It never closes the session and never refuses a chunk.
19
+ - **The Mac saves only a complete capture.** Once every chunk 0..finalChunkIndex has arrived and finished ASR, and 45 s have passed since the Stop and since the last chunk (so a phone on screen saves first), the Mac runs the same save the phone's `POST /meeting/save` runs: one lock, one idempotent receipt. A gap means audio exists only on the phone, so the Mac waits and the late chunks land in the SAME live session. There is no timeout that saves with a gap: a gap that never fills is left to the existing 4 h stranded sweep, unchanged. A chunk past the Stop revokes it (the meeting is still recording). Checked every 15 s while a Stop waits, only while admissions are open and session recovery has run.
20
+ - **The phone coming back.** Status answers `saved` with the receipt; `POST /meeting/save` replays it; and a chunk the phone re-sends because its answer was lost while hidden is answered from the saved sidecar instead of 410, so the phone no longer reads that state as a collision. This replay is only for meetings the Mac saved itself; every other closed session still answers 410.
21
+ - **Telemetry.** `[meeting/auto-save]` lines and `data/meeting-autonomous-save.jsonl`: `stop_received`, `waiting` (once per change, with missing count and sample), `finalized` (chunks, canonical, gaps, waited ms), `phone_saved` (or `client_saved` for an unpinned save such as the stranded sweep's), `phone_confirmed` (how and how long after), `late_replay`, `late_chunk_refused`, `revoked`, `stood_down`, `finalize_failed`. The status route carries `stopIntent` additively. `recording_stop` carries `savedBy: "server"` for the Mac's own saves.
22
+ - **Unchanged.** Transcription engines, the G2 matching thresholds, the restart and maintenance gate semantics, the stranded sweeper, and the save route's answers (its body moved into one shared `performSave`; the mutation gate anchor `cx-6533-save-nothing-to-save` follows it).
23
+ - **Checks.** Full suite 8,462 passed, 0 failed, 3 skipped (557 files); `tsc --noEmit` clean. `server/scripts/autonomous-save-mutation-gate.mjs` (not packaged): green baseline (63 tests in 4 files), 42 of 42 mutants killed, each by a named test. The moved `cx-6533-save-nothing-to-save` anchor re-runs killed. Journeys: phone hidden after Stop (the Mac saves alone), phone back late with missing chunks (merged into one meeting), phone and Mac racing (one meeting), against the real chunk and save routes. Needs the companion release to send the Stop before it does anything on a real phone.
24
+
1
25
  ## 6.80.0 - Answer with your own key (opt in, off by default)
2
26
 
3
27
  You can now give COS your own provider key (OpenRouter, Anthropic, OpenAI, or a compatible endpoint) and pick a key profile for a thread, so that thread answers on the key instead of your Claude or ChatGPT plan. **This is opt in. Without a key, nothing changes:** with `COS_KEY_LANES` unset or `0`, or with lanes on but no key profile saved, the server answers, spawns and writes exactly as 6.79.1 did. Turning it on and saving keys happens in COS Control on this Mac.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.80.0",
3
+ "version": "6.82.0",
4
4
  "description": "COS Glasses: 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": {
@@ -54,6 +54,9 @@
54
54
  "!server/scripts/work-search-mutation-gate.mjs",
55
55
  "!server/scripts/fact-check-mutation-gate.mjs",
56
56
  "!server/scripts/stale-session-mutation-gate.mjs",
57
+ "!server/scripts/autonomous-save-mutation-gate.mjs",
58
+ "!server/scripts/routines-mutation-gate.mjs",
59
+ "!server/scripts/routines-canary.ts",
57
60
  "!server/scripts/stt-mutation-gate.mjs",
58
61
  "!server/scripts/dictation-stt-mutation-gate.mjs",
59
62
  "!server/scripts/pairing-mutation-gate.mjs",
package/server/index.ts CHANGED
@@ -74,7 +74,7 @@ import { nativeHead, realNativeHeadDeps, registerClaudeTranscriptHint } from './
74
74
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
75
75
  import { displayRouter } from './routes/display.js'
76
76
  import { transcribeStreamRouter, getActiveTranscriptionSessionCount } from './routes/transcribe-stream.js'
77
- import { meetingRouter, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
77
+ import { meetingRouter, resumeAutonomousMeetingSaves, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
78
78
  import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
79
79
  import { meetingsRouter } from './routes/meetings.js'
80
80
  import { openaiCompatRouter } from './routes/openai-compat.js'
@@ -170,6 +170,8 @@ import { listBoard } from './lib/task-store.js'
170
170
  import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
171
171
  import { createMorningBriefRouter } from './routes/morning-brief.js'
172
172
  import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
173
+ import { createRoutinesRouter } from './routes/routines.js'
174
+ import { getRoutineRegistry, routineSkillCatalog, startRoutineScheduler, stopRoutineScheduler } from './lib/routines-runtime.js'
173
175
  import {
174
176
  initQueryJobRuntime,
175
177
  preparePublicDurableQueryAdmission,
@@ -838,6 +840,8 @@ app.use('/api', queryRouter)
838
840
  // The scheduled start-of-day brief: settings, status, run-now. Same auth as
839
841
  // every other settings route; the brief itself is an ordinary durable job.
840
842
  app.use('/api', createMorningBriefRouter(getMorningBriefScheduler))
843
+ // 6.82.0: every routine (the brief first, then skill routines), each with its own cap, switch and breaker.
844
+ app.use('/api', createRoutinesRouter(getRoutineRegistry, routineSkillCatalog))
841
845
  app.use('/api', providerProofRouter)
842
846
  app.use('/api', transcribeRouter)
843
847
  // Ported from cos-glasses-app in 6.24.0. The companion's Sessions tab has been
@@ -1257,6 +1261,7 @@ async function gracefulShutdown(): Promise<void> {
1257
1261
  forceExit.unref?.()
1258
1262
  await meetingAskRuntime.shutdown()
1259
1263
  stopMorningBriefScheduler()
1264
+ stopRoutineScheduler()
1260
1265
  await workReviewRuntime?.close()
1261
1266
  workIntakeStore?.close()
1262
1267
  workHandoffRequestStore?.close()
@@ -1473,6 +1478,8 @@ listenRequiredServers(listeners).then(() => {
1473
1478
  // Resume already-saved meetings whose post-response HQ/operations work
1474
1479
  // was interrupted by a prior server update or process exit.
1475
1480
  resumeMeetingFinalizationJobs()
1481
+ // 6.81.0: Stops the phone sent before the last exit; the server saves them once complete.
1482
+ resumeAutonomousMeetingSaves()
1476
1483
  // 6.77.0: transcript cleanup data whose meeting is gone, and screen text past retention.
1477
1484
  scheduleTranscriptCleanupSweep()
1478
1485
 
@@ -1532,6 +1539,8 @@ listenRequiredServers(listeners).then(() => {
1532
1539
  // that store is ready. Its own tick checks the durable-jobs switch and the
1533
1540
  // maintenance gate, so starting it here is safe when either is off.
1534
1541
  startMorningBriefScheduler()
1542
+ // 6.82.0: skill routines ride the same coordinator and the same gates.
1543
+ startRoutineScheduler()
1535
1544
  void workReviewRuntime?.start().catch(error => console.error('[work-reviews] reconciliation unavailable:', error))
1536
1545
  }).catch(error => {
1537
1546
  // The store remains degraded and rejects admission. Legacy /api/query is
@@ -436,6 +436,11 @@ export interface CallOptions {
436
436
  dispatch?: { restricted: true; tools: readonly string[] }
437
437
  /** 6.58.2: `claude --name` for a session this COS session starts fresh (QueryJobRequest.sessionName). */
438
438
  sessionName?: string
439
+ /**
440
+ * 6.82.0: the token-audit `caller` for this run, so a server-started job is counted by who started
441
+ * it (`routine:morning-brief`, `routine:routine-<id>`). Absent, every bridge writes the caller it always did.
442
+ */
443
+ auditCaller?: string
439
444
  /**
440
445
  * 6.78.0 Messages steer: the job's steer port. Present only when the coordinator decided the
441
446
  * run is steerable (lib/claude-steer.ts). Absent, every argument and every stdin byte is the
@@ -927,7 +932,7 @@ export async function callClaudeStreaming(
927
932
  inputChars: inputEstimate,
928
933
  outputChars: text.length,
929
934
  durationMs: totalMs,
930
- caller: options?.lightweight ? 'voice_query' : 'full_query',
935
+ caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
931
936
  ...keyAuditFields,
932
937
  ...keyUsdFields,
933
938
  })
@@ -817,7 +817,7 @@ export async function callCodexStreaming(
817
817
  inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
818
818
  outputChars: text.length,
819
819
  durationMs: totalMs,
820
- caller: options?.lightweight ? 'voice_query' : 'full_query',
820
+ caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
821
821
  ...keyAuditFields,
822
822
  ...keyUsdFields,
823
823
  })
@@ -528,7 +528,7 @@ export async function callCursorStreaming(
528
528
  inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
529
529
  outputChars: text.length,
530
530
  durationMs: totalMs,
531
- caller: options?.lightweight ? 'voice_query' : 'full_query',
531
+ caller: options?.auditCaller ?? (options?.lightweight ? 'voice_query' : 'full_query'),
532
532
  turnId: options?.turnId,
533
533
  runId: run.runId,
534
534
  sessionId: sid,
@@ -564,7 +564,7 @@ export async function callDirectStreaming(
564
564
  inputChars: messages.reduce((n, m) => n + (typeof m.content === 'string' ? m.content.length : 0), 0),
565
565
  outputChars: text.length,
566
566
  durationMs: Date.now() - startTime,
567
- caller: 'direct_query',
567
+ caller: options?.auditCaller ?? 'direct_query',
568
568
  ...(options?.turnId ? { turnId: options.turnId } : {}),
569
569
  runId: run.runId,
570
570
  sessionId: sid,
@@ -0,0 +1,267 @@
1
+ // The server finishes a stopped G2 meeting on its own (6.81.0).
2
+ //
3
+ // Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the
4
+ // meetings". Once the phone has told the server its meeting stopped (lib/meeting-stop-
5
+ // intents.ts) and the server holds every chunk it captured, nothing is left that needs the
6
+ // phone, so the server runs the same save the phone would have run. The phone, when it
7
+ // comes back on screen, finds a saved meeting and adopts the receipt.
8
+ //
9
+ // THE ONE RULE: NEVER SAVE A CAPTURE THAT IS NOT COMPLETE. The server saves on its own only
10
+ // when its ledger holds every index 0..N (N = the phone's last captured index) and every one
11
+ // of them has finished ASR. A gap means audio exists only on the phone, so the server waits
12
+ // and the session stays live: the phone's late upload lands in the SAME session (the merge)
13
+ // and the next check saves. There is no shorter timeout that saves with the gap, because a
14
+ // save closes the session, and a chunk that arrives after the close is refused (410) and the
15
+ // phone reads "server closed, local audio pending" as a collision it cannot resolve: the
16
+ // meeting splits or the tail is lost. A gap that never fills is handed to the existing
17
+ // stranded sweeper at its existing 4 h horizon, exactly as before this release. Waiting
18
+ // costs only time; saving early costs audio.
19
+ //
20
+ // Why a settle delay as well: a phone that is on screen saves within a few seconds of Stop.
21
+ // The server stands back for AUTONOMOUS_FINALIZE_SETTLE_MS after the Stop and after the last
22
+ // chunk arrived, so a healthy phone's own save is the normal path and the server's is the
23
+ // fallback. Both racing is safe anyway (one save lock, an idempotent save route), but not
24
+ // racing is quieter.
25
+
26
+ import type { StopIntent } from './meeting-stop-intents.js'
27
+
28
+ /** Quiet time after the Stop and after the last chunk before the server saves on its own. */
29
+ export const AUTONOMOUS_FINALIZE_SETTLE_MS = 45_000
30
+ /** How often a waiting Stop is checked again. Cheap: an in-memory ledger read. */
31
+ export const AUTONOMOUS_FINALIZE_POLL_MS = 15_000
32
+ /** Missing indices kept in a log line. */
33
+ const MISSING_SAMPLE = 8
34
+
35
+ export interface SessionLedger {
36
+ state: 'active' | 'closed' | 'missing'
37
+ received: readonly number[]
38
+ asrCompleted: readonly number[]
39
+ maxChunkIndex: number
40
+ /** ms since epoch of the last chunk arrival, or null. */
41
+ lastActivityAt: number | null
42
+ }
43
+
44
+ export type AutonomousWaitReason = 'settling' | 'missing_chunks' | 'asr_pending' | 'session_unknown' | 'saving' | 'not_ready'
45
+ export type AutonomousStandDownReason = 'already_saved' | 'closed_unsaved' | 'nothing_captured' | 'server_instance_mismatch'
46
+
47
+ export type AutonomousDecision =
48
+ | { action: 'finalize'; chunks: number }
49
+ | { action: 'wait'; reason: AutonomousWaitReason; missing: number[]; missingCount: number; retryInMs: number }
50
+ | { action: 'revoke'; reason: 'chunk_beyond_final'; maxChunkIndex: number }
51
+ | { action: 'stand_down'; reason: AutonomousStandDownReason }
52
+
53
+ function missingFrom(finalIndex: number, have: readonly number[]): { sample: number[]; count: number } {
54
+ const present = new Set(have)
55
+ const sample: number[] = []
56
+ let count = 0
57
+ for (let index = 0; index <= finalIndex; index++) {
58
+ if (present.has(index)) continue
59
+ count++
60
+ if (sample.length < MISSING_SAMPLE) sample.push(index)
61
+ }
62
+ return { sample, count }
63
+ }
64
+
65
+ /**
66
+ * What to do with a waiting Stop right now. Pure: every input is passed in.
67
+ *
68
+ * Order matters and each step is pinned by a test: a saved meeting is never saved again, a
69
+ * chunk past the Stop means the meeting is still recording (revoke, never save), and only a
70
+ * complete, fully transcribed, quiet capture is saved.
71
+ */
72
+ export function decideAutonomousFinalize(input: {
73
+ intent: Pick<StopIntent, 'finalChunkIndex' | 'receivedAt'>
74
+ ledger: SessionLedger
75
+ now: number
76
+ alreadySaved: boolean
77
+ saving: boolean
78
+ }): AutonomousDecision {
79
+ const { intent, ledger, now } = input
80
+ if (input.alreadySaved) return { action: 'stand_down', reason: 'already_saved' }
81
+ if (input.saving) return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
82
+ // A session closed WITHOUT a save receipt (expired, quarantined) is the stranded and
83
+ // quarantine paths' to finish. Saving here would race them.
84
+ if (ledger.state === 'closed') return { action: 'stand_down', reason: 'closed_unsaved' }
85
+ if (intent.finalChunkIndex < 0) return { action: 'stand_down', reason: 'nothing_captured' }
86
+ // The server holds audio the phone says it never captured: the phone resumed after this
87
+ // Stop, or the Stop is stale. Either way the meeting may still be recording.
88
+ if (ledger.maxChunkIndex > intent.finalChunkIndex) {
89
+ return { action: 'revoke', reason: 'chunk_beyond_final', maxChunkIndex: ledger.maxChunkIndex }
90
+ }
91
+ // An offline capture: the Stop arrived before any chunk did. Wait for the upload.
92
+ if (ledger.state === 'missing') {
93
+ return { action: 'wait', reason: 'session_unknown', missing: [], missingCount: intent.finalChunkIndex + 1, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
94
+ }
95
+ const absent = missingFrom(intent.finalChunkIndex, ledger.received)
96
+ if (absent.count > 0) {
97
+ return { action: 'wait', reason: 'missing_chunks', missing: absent.sample, missingCount: absent.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
98
+ }
99
+ const pending = missingFrom(intent.finalChunkIndex, ledger.asrCompleted)
100
+ if (pending.count > 0) {
101
+ return { action: 'wait', reason: 'asr_pending', missing: pending.sample, missingCount: pending.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
102
+ }
103
+ const quietSince = Math.max(intent.receivedAt, ledger.lastActivityAt ?? 0)
104
+ const settleLeft = quietSince + AUTONOMOUS_FINALIZE_SETTLE_MS - now
105
+ if (settleLeft > 0) {
106
+ return { action: 'wait', reason: 'settling', missing: [], missingCount: 0, retryInMs: Math.min(settleLeft, AUTONOMOUS_FINALIZE_POLL_MS) }
107
+ }
108
+ return { action: 'finalize', chunks: intent.finalChunkIndex + 1 }
109
+ }
110
+
111
+ /** Expand `[start, end]` ranges (the status route's shape) into indices. */
112
+ export function indicesFromRanges(ranges: ReadonlyArray<readonly [number, number]>): number[] {
113
+ const out: number[] = []
114
+ for (const [start, end] of ranges) for (let index = start; index <= end; index++) out.push(index)
115
+ return out
116
+ }
117
+
118
+ export type AutonomousSaveOutcome =
119
+ | { ok: true; filename: string; sidecarPath: string; chunkCount: number; canonicalCount: number; missingIndices: number[] }
120
+ | { ok: false; status: number; reason: string }
121
+
122
+ export interface AutonomousFinalizerDeps {
123
+ waiting(): StopIntent[]
124
+ get(sessionId: string): StopIntent | null
125
+ update(sessionId: string, patch: Partial<StopIntent>): void
126
+ ledger(sessionId: string): SessionLedger
127
+ alreadySaved(sessionId: string): boolean
128
+ saving(sessionId: string): boolean
129
+ save(intent: StopIntent): Promise<AutonomousSaveOutcome>
130
+ /** Maintenance admissions open AND durable session recovery has run. */
131
+ ready(): boolean
132
+ now(): number
133
+ /** Returns a cancel function. Production timers are unref'd so they never hold the process. */
134
+ schedule(fn: () => void, ms: number): () => void
135
+ log(event: string, fields: Record<string, unknown>): void
136
+ }
137
+
138
+ export interface AutonomousFinalizer {
139
+ /** Check one session now (a Stop arrived, a boot). Never throws. */
140
+ check(sessionId: string, trigger: string): Promise<AutonomousDecision | null>
141
+ /** Check every waiting Stop, then re-arm while any remain. */
142
+ tick(trigger?: string): Promise<void>
143
+ /** Make sure a tick is scheduled. */
144
+ arm(ms?: number): void
145
+ }
146
+
147
+ export function createAutonomousFinalizer(deps: AutonomousFinalizerDeps): AutonomousFinalizer {
148
+ const inFlight = new Set<string>()
149
+ let cancel: (() => void) | null = null
150
+ let armedFor = Infinity
151
+
152
+ const arm = (ms = AUTONOMOUS_FINALIZE_POLL_MS) => {
153
+ const due = deps.now() + Math.max(0, ms)
154
+ if (cancel && armedFor <= due) return
155
+ cancel?.()
156
+ armedFor = due
157
+ cancel = deps.schedule(() => {
158
+ cancel = null
159
+ armedFor = Infinity
160
+ void finalizer.tick('poll')
161
+ }, Math.max(0, ms))
162
+ }
163
+
164
+ const check = async (sessionId: string, trigger: string): Promise<AutonomousDecision | null> => {
165
+ if (inFlight.has(sessionId)) return null
166
+ const intent = deps.get(sessionId)
167
+ if (!intent || intent.state !== 'waiting') return null
168
+ // A boot behind a closed maintenance gate has not adopted the persisted sessions yet,
169
+ // so "the server holds no chunks" is not evidence of anything. Wait, never decide.
170
+ if (!deps.ready()) {
171
+ return { action: 'wait', reason: 'not_ready', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
172
+ }
173
+ inFlight.add(sessionId)
174
+ try {
175
+ const now = deps.now()
176
+ const decision = decideAutonomousFinalize({
177
+ intent,
178
+ ledger: deps.ledger(sessionId),
179
+ now,
180
+ alreadySaved: deps.alreadySaved(sessionId),
181
+ saving: deps.saving(sessionId),
182
+ })
183
+ if (decision.action === 'stand_down') {
184
+ // A save the phone (or anyone) already made is the phone's save, not ours.
185
+ deps.update(sessionId, decision.reason === 'already_saved'
186
+ ? { state: 'saved', savedBy: intent.savedBy ?? 'phone', savedAt: intent.savedAt ?? now }
187
+ : { state: 'stood_down', reason: decision.reason })
188
+ deps.log('stood_down', { sessionId, reason: decision.reason, trigger, waitedMs: now - intent.receivedAt })
189
+ return decision
190
+ }
191
+ if (decision.action === 'revoke') {
192
+ deps.update(sessionId, { state: 'revoked', reason: decision.reason })
193
+ deps.log('revoked', { sessionId, reason: decision.reason, finalChunkIndex: intent.finalChunkIndex, maxChunkIndex: decision.maxChunkIndex, trigger })
194
+ return decision
195
+ }
196
+ if (decision.action === 'wait') {
197
+ const last = intent.lastWait
198
+ // One line per change of state, not one per poll.
199
+ if (!last || last.reason !== decision.reason || last.missingCount !== decision.missingCount) {
200
+ deps.update(sessionId, { lastWait: { reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, at: now } })
201
+ deps.log('waiting', { sessionId, reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, finalChunkIndex: intent.finalChunkIndex, trigger })
202
+ }
203
+ return decision
204
+ }
205
+ const outcome = await deps.save(intent)
206
+ const at = deps.now()
207
+ if (outcome.ok) {
208
+ deps.update(sessionId, {
209
+ state: 'saved', savedBy: 'server', savedAt: at,
210
+ filename: outcome.filename, sidecarPath: outcome.sidecarPath,
211
+ chunkCount: outcome.chunkCount, canonicalCount: outcome.canonicalCount, lastWait: null,
212
+ })
213
+ deps.log('finalized', {
214
+ sessionId, reason: 'stop_received_complete', trigger,
215
+ finalChunkIndex: intent.finalChunkIndex, chunks: outcome.chunkCount, canonical: outcome.canonicalCount,
216
+ gaps: outcome.missingIndices.length, filename: outcome.filename,
217
+ waitedMs: at - intent.receivedAt, phoneConfirmed: false,
218
+ })
219
+ return decision
220
+ }
221
+ // The phone saved between the decision and the lock: its save, not ours.
222
+ if (outcome.reason === 'already_saved') {
223
+ deps.update(sessionId, { state: 'saved', savedBy: 'phone', savedAt: at, lastWait: null })
224
+ deps.log('stood_down', { sessionId, reason: 'already_saved', trigger, waitedMs: at - intent.receivedAt })
225
+ return { action: 'stand_down', reason: 'already_saved' }
226
+ }
227
+ // The phone pinned another server instance: this Mac is not where it meant to save.
228
+ if (outcome.reason === 'server_instance_mismatch') {
229
+ deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
230
+ deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
231
+ return { action: 'stand_down', reason: 'server_instance_mismatch' }
232
+ }
233
+ // The phone's own save holds the lock: it wins, and the next check reads its receipt.
234
+ if (outcome.status === 409 && outcome.reason === 'save_in_progress') {
235
+ return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
236
+ }
237
+ // Nothing to save (every chunk was silence): the phone's own Stop says so on the lens.
238
+ if (outcome.status === 404) {
239
+ deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
240
+ deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
241
+ return { action: 'stand_down', reason: 'nothing_captured' }
242
+ }
243
+ deps.log('finalize_failed', { sessionId, status: outcome.status, reason: outcome.reason, trigger })
244
+ return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
245
+ } catch (error) {
246
+ deps.log('finalize_failed', { sessionId, status: 500, reason: error instanceof Error ? error.message.slice(0, 200) : 'error', trigger })
247
+ return null
248
+ } finally {
249
+ inFlight.delete(sessionId)
250
+ }
251
+ }
252
+
253
+ const finalizer: AutonomousFinalizer = {
254
+ check,
255
+ async tick(trigger = 'poll') {
256
+ let soonest = Infinity
257
+ for (const intent of deps.waiting()) {
258
+ const decision = await check(intent.sessionId, trigger)
259
+ if (decision?.action === 'wait') soonest = Math.min(soonest, decision.retryInMs)
260
+ else if (decision === null && deps.get(intent.sessionId)?.state === 'waiting') soonest = Math.min(soonest, AUTONOMOUS_FINALIZE_POLL_MS)
261
+ }
262
+ if (Number.isFinite(soonest)) arm(soonest)
263
+ },
264
+ arm,
265
+ }
266
+ return finalizer
267
+ }