@gotcos/glasses-server 6.43.3 → 6.44.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 +50 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +2 -0
- package/server/lib/archive.ts +7 -3
- package/server/lib/claude-bridge.ts +30 -12
- package/server/lib/conversation.ts +27 -0
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/model-router.ts +4 -0
- package/server/lib/morning-brief-config.ts +22 -1
- package/server/lib/morning-brief-prompt.ts +16 -2
- package/server/lib/morning-brief-runtime.ts +32 -1
- package/server/lib/morning-brief-schedule.ts +19 -2
- package/server/lib/morning-brief-scheduler.ts +111 -9
- package/server/lib/python-bridge.ts +18 -4
- package/server/lib/query-job-coordinator.ts +8 -2
- package/server/lib/query-job-runtime.ts +22 -2
- package/server/lib/query-job-store.ts +33 -3
- package/server/lib/query-job-types.ts +147 -2
- package/server/lib/task-dispatcher.ts +642 -0
- package/server/lib/task-store.ts +624 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/message-ref.ts +2 -1
- package/server/routes/query-jobs.ts +1 -1
- package/server/routes/sessions.ts +10 -1
- package/server/routes/tasks.ts +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
## 6.44.0
|
|
2
|
+
|
|
3
|
+
Task store: one COS `tasks.md` contract with a durable lock, columns, and a run ledger.
|
|
4
|
+
|
|
5
|
+
- Bridge subcommands `task-rows`, `task-capture`, `task-set-run-at`, `task-set-marker`, `task-move`, and `task-check` are first-class. Standalone installs without a COS pipeline return `cos_pipeline_not_configured` instead of an empty object.
|
|
6
|
+
- `GET /api/tasks` and `/api/tasks/runs` project the board and the ledger. Capture, check, move, and schedule go through the same writer lock the Python side uses.
|
|
7
|
+
- Morning-brief config gains `taskCatchUpMinutes` (default 180). Task due/missed math uses `taskInstant`, which steps a spring-forward gap by 60 minutes instead of pretending `zonedInstant` already did that.
|
|
8
|
+
- Health advertises `capabilities.tasks.gate` so a new client can tell an old server from a new one.
|
|
9
|
+
- `POST /api/tasks/:id/run` is Run now: a restricted Claude job (`Read` / `Grep` / `Glob`) on the same durable path as the morning brief. The same task already live → 409 `task_running`. A second different task while the slot is reserved → 503 `dispatch_slots_busy`.
|
|
10
|
+
- `GET /api/tasks` returns `{ tasks, workBadge, gate }`. A client that still expects a domain map must adapt.
|
|
11
|
+
|
|
12
|
+
## 6.43.4
|
|
13
|
+
|
|
14
|
+
Every message now says who started it.
|
|
15
|
+
|
|
16
|
+
- **Server-started jobs carry an origin label.** A durable job request may
|
|
17
|
+
carry `origin: { kind: 'routine' | 'task', id }`; the scheduled morning
|
|
18
|
+
brief sends `{ kind: 'routine', id: 'morning-brief' }`. The label lands on
|
|
19
|
+
both halves of the projected exchange (`origin`, `originId`), on the display
|
|
20
|
+
bus (`start`, `done`, `error`, flattened to the same two keys), and on the
|
|
21
|
+
today/all-messages and day-archive views the phone and COS Control read.
|
|
22
|
+
A prompt typed on the phone carries nothing; the phone's own `G2` stamp
|
|
23
|
+
never reaches the server. Nothing is inferred from the label's absence.
|
|
24
|
+
- **The request fingerprint is pinned to the sixteen identity keys it always
|
|
25
|
+
had.** `origin` (and any future provenance or enforcement key) is outside
|
|
26
|
+
it, so a build that does not know the key still hydrates a journal written
|
|
27
|
+
by one that does, and a rollback from this version to 6.43.3 keeps every
|
|
28
|
+
retained job. Stored fingerprints are byte-identical before and after.
|
|
29
|
+
- **Admission is strict only about a known kind with a bad id (400
|
|
30
|
+
`invalid_origin`).** An unknown kind, and the phone's bare-string stamp, are
|
|
31
|
+
dropped and counted on both paths, so a newer client's label degrades to
|
|
32
|
+
unlabeled and never refuses the job; hydration is lenient throughout. Two new
|
|
33
|
+
store counters on `/api/health`: `originDropped` (once per job created, once
|
|
34
|
+
per journal record hydrated) and `fingerprintMismatches` — the second is a
|
|
35
|
+
job that did NOT hydrate, and the first mismatch logs at error level. No
|
|
36
|
+
client reads either yet; `/api/health` is where a human sees them.
|
|
37
|
+
- **The label is validated at every boundary it crosses.** The conversation
|
|
38
|
+
store drops an unknown kind (and an orphan id) when it loads from disk, and
|
|
39
|
+
the bus stamp is spread after provider metadata so no provider key can
|
|
40
|
+
overwrite it. A real 6.43.3-written journal is committed as a fixture and
|
|
41
|
+
hydrated by the suite, in addition to the in-process hash oracle.
|
|
42
|
+
- **The scheduler adopts an admitted brief on every scheduled fire**, not only
|
|
43
|
+
on the crash-resume branch, and replaces the last of the day's scheduled
|
|
44
|
+
ledger rows instead of pushing another; "Run now" and the scheduled tick
|
|
45
|
+
are serialised, so two overlapping admissions can no longer interleave. A
|
|
46
|
+
manual and a scheduled brief on the same day remain two briefs, by design.
|
|
47
|
+
- Briefs that ran on 6.43.0–6.43.3 stay unlabeled; badges start with runs
|
|
48
|
+
from this version. A routine's reply is unlabeled while it is still running
|
|
49
|
+
and gains the label when it lands.
|
|
50
|
+
|
|
1
51
|
## 6.43.3
|
|
2
52
|
|
|
3
53
|
Managed-server updates no longer hinge on one vendor's meter.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.44.0",
|
|
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": {
|
package/server/index.ts
CHANGED
|
@@ -59,6 +59,7 @@ import { promptEditRouter } from './routes/prompt-edit.js'
|
|
|
59
59
|
import { bookmarksRouter } from './routes/bookmarks.js'
|
|
60
60
|
import { welcomeContextRouter } from './routes/welcome-context.js'
|
|
61
61
|
import { liveCuesRouter } from './routes/live-cues.js'
|
|
62
|
+
import { tasksRouter } from './routes/tasks.js'
|
|
62
63
|
import { memoryRouter } from './routes/memory.js'
|
|
63
64
|
import { threadsRouter } from './routes/threads.js'
|
|
64
65
|
import { shutdownLiveCues } from './lib/live-cues-engine.js'
|
|
@@ -689,6 +690,7 @@ app.use('/api', promptEditRouter)
|
|
|
689
690
|
app.use('/api', bookmarksRouter)
|
|
690
691
|
app.use('/api', welcomeContextRouter)
|
|
691
692
|
app.use('/api', liveCuesRouter)
|
|
693
|
+
app.use('/api', tasksRouter)
|
|
692
694
|
|
|
693
695
|
// OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
|
|
694
696
|
// Mounted at root — routes are /v1/chat/completions and /v1/models
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"schemaVersion":1,"recordId":"02b09947-ed7d-47ef-99c5-4985af3ec924","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"43b397bd-dd67-4312-9a5c-4c5d4b246f1d","clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"turnId":"a456fd1d-1e82-4b2d-8a20-5d34681201fd","requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"query":"written by 6.43.3","sessionId":"session-6-43-3","model":"opus","effort":"high","cursorExecutionMode":"ask","messageEra":"era1","globalMsgNum":78,"reference":{"query":"earlier q","response":"earlier a"},"handoffCode":"ABCD","handoffLatest":true,"clientQueueItemId":"q1","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95"}}
|
|
2
|
+
{"schemaVersion":1,"recordId":"a1145a40-2ced-4da3-9cf7-76d84cffc946","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"64c5b502-13f2-4871-b6c4-007b91be2414","clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"turnId":"843e633f-0f92-4fe3-b2a1-9dc683715791","requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"query":"minimal by 6.43.3","sessionId":"session-6-43-3-min","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638"}}
|
package/server/lib/archive.ts
CHANGED
|
@@ -472,7 +472,8 @@ export function getArchiveChats(date: string): ArchiveChatSummary[] {
|
|
|
472
472
|
}
|
|
473
473
|
|
|
474
474
|
/** Get paired Q&A messages for a specific chat within a day. Request refs on
|
|
475
|
-
* the user turn and model-output refs on the assistant turn surface together.
|
|
475
|
+
* the user turn and model-output refs on the assistant turn surface together.
|
|
476
|
+
* Deliberately no origin label here (6.43.4): the day view carries it. */
|
|
476
477
|
export function getArchiveChatMessages(
|
|
477
478
|
date: string,
|
|
478
479
|
chatIndex: number,
|
|
@@ -507,11 +508,11 @@ export function getArchiveChatMessages(
|
|
|
507
508
|
* (sessionId, timestamp) instead of bare timestamp (collision-prone). */
|
|
508
509
|
export function getArchiveDayMessages(
|
|
509
510
|
date: string,
|
|
510
|
-
): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> {
|
|
511
|
+
): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> {
|
|
511
512
|
const archive = loadArchive(date)
|
|
512
513
|
if (!archive) return []
|
|
513
514
|
|
|
514
|
-
const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; attachments?: MediaAttachmentRef[] }> = []
|
|
515
|
+
const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; globalMsgNum?: number; messageEra?: string; modelPreference?: string; origin?: 'routine' | 'task'; originId?: string; attachments?: MediaAttachmentRef[] }> = []
|
|
515
516
|
for (const chat of archive.chats) {
|
|
516
517
|
for (let i = 0; i < chat.exchanges.length; i++) {
|
|
517
518
|
const ex = chat.exchanges[i]
|
|
@@ -522,6 +523,8 @@ export function getArchiveDayMessages(
|
|
|
522
523
|
const globalMsgNum = ex.globalMsgNum ?? next.globalMsgNum
|
|
523
524
|
const messageEra = ex.messageEra ?? next.messageEra
|
|
524
525
|
const modelPreference = resolveExchangePairModel(ex, next, null)
|
|
526
|
+
// Same one-half rule as the live view (routes/sessions.ts).
|
|
527
|
+
const stamped = ex.origin ? ex : next
|
|
525
528
|
messages.push({
|
|
526
529
|
query: ex.content,
|
|
527
530
|
text: next.content,
|
|
@@ -531,6 +534,7 @@ export function getArchiveDayMessages(
|
|
|
531
534
|
...(globalMsgNum != null ? { no: globalMsgNum, globalMsgNum } : {}),
|
|
532
535
|
...(messageEra ? { messageEra } : {}),
|
|
533
536
|
...(modelPreference ? { modelPreference } : {}),
|
|
537
|
+
...(stamped.origin ? { origin: stamped.origin, ...(stamped.originId ? { originId: stamped.originId } : {}) } : {}),
|
|
534
538
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
535
539
|
})
|
|
536
540
|
i++
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
buildClaudeToolList,
|
|
55
55
|
claudeMcpConfigArgs,
|
|
56
56
|
claudeToolCapabilityPrompt,
|
|
57
|
+
readOnlyCapabilityPrompt,
|
|
57
58
|
} from './claude-tool-access.js'
|
|
58
59
|
import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
59
60
|
import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
|
|
@@ -388,6 +389,8 @@ export interface CallOptions {
|
|
|
388
389
|
cursorExecutionMode?: import('../../shared/model-preference.js').CursorExecutionMode
|
|
389
390
|
/** Durable coordinator already owns the per-session provider lease. */
|
|
390
391
|
sessionLockHeld?: boolean
|
|
392
|
+
/** Read-only scheduled-task dispatch. Same shape as QueryJobRequest.dispatch. */
|
|
393
|
+
dispatch?: { restricted: true; tools: readonly string[] }
|
|
391
394
|
}
|
|
392
395
|
|
|
393
396
|
export async function callClaudeStreaming(
|
|
@@ -418,7 +421,7 @@ export async function callClaudeStreaming(
|
|
|
418
421
|
// Notify client immediately — model is known before any async work
|
|
419
422
|
// Pass existing CLI session ID if resuming (new sessions get it after first result)
|
|
420
423
|
const resolvedCliKey = cliSessionKey(sid, resolvedModel)
|
|
421
|
-
let existingCliSession = cliSessionMap.get(resolvedCliKey)
|
|
424
|
+
let existingCliSession = options?.dispatch ? undefined : cliSessionMap.get(resolvedCliKey)
|
|
422
425
|
callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
|
|
423
426
|
clientJobId: options?.clientJobId,
|
|
424
427
|
generation: options?.generation,
|
|
@@ -461,14 +464,18 @@ export async function callClaudeStreaming(
|
|
|
461
464
|
const allowedToolList = buildClaudeToolList({
|
|
462
465
|
publisherTool: outputImagePublisher?.claudeAllowedTool,
|
|
463
466
|
})
|
|
464
|
-
let mcpConfigArgs: string[]
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
467
|
+
let mcpConfigArgs: string[] = []
|
|
468
|
+
if (!options?.dispatch) {
|
|
469
|
+
try {
|
|
470
|
+
mcpConfigArgs = claudeMcpConfigArgs()
|
|
471
|
+
} catch (err) {
|
|
472
|
+
outputImagePublisher?.cleanup()
|
|
473
|
+
throw err
|
|
474
|
+
}
|
|
470
475
|
}
|
|
471
|
-
systemPrompt =
|
|
476
|
+
systemPrompt = options?.dispatch
|
|
477
|
+
? `${systemPrompt}\n\n${readOnlyCapabilityPrompt('scheduled task dispatch', 'Read, Grep and Glob only; no writes, no shell, no web', 'the task owner on the Mac')}`
|
|
478
|
+
: `${systemPrompt}\n\n${claudeToolCapabilityPrompt(allowedToolList)}`
|
|
472
479
|
if (outputImagePublisher) systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
|
|
473
480
|
|
|
474
481
|
// Phase: thinking (waiting for Claude to start)
|
|
@@ -519,7 +526,8 @@ export async function callClaudeStreaming(
|
|
|
519
526
|
|
|
520
527
|
// Check if we have a prior CLI session for this COS session.
|
|
521
528
|
// If not, use the pre-warmed session (eliminates 2-15s cold start on first query).
|
|
522
|
-
|
|
529
|
+
// Restricted dispatch never steals the pre-warmed unrestricted session.
|
|
530
|
+
if (!options?.dispatch && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
|
|
523
531
|
// Only the default model consumes the pre-warmed session.
|
|
524
532
|
// Hey Even (Haiku) cold-starts its own session to avoid model contamination.
|
|
525
533
|
existingCliSession = preWarmedCliSessionId
|
|
@@ -540,11 +548,21 @@ export async function callClaudeStreaming(
|
|
|
540
548
|
'--output-format', 'stream-json',
|
|
541
549
|
'--verbose', // Required: stream-json requires --verbose
|
|
542
550
|
'--system-prompt', systemPrompt,
|
|
543
|
-
...mcpConfigArgs,
|
|
551
|
+
...(options?.dispatch ? [] : mcpConfigArgs),
|
|
544
552
|
]
|
|
545
553
|
|
|
546
554
|
// Full and lightweight paths share the same explicit MCP selector contract.
|
|
547
|
-
if (options?.
|
|
555
|
+
if (options?.dispatch) {
|
|
556
|
+
args.push(
|
|
557
|
+
'--restricted',
|
|
558
|
+
'--tools', options.dispatch.tools.join(','),
|
|
559
|
+
'--permission-mode', 'dontAsk',
|
|
560
|
+
'--strict-mcp-config',
|
|
561
|
+
'--mcp-config', '{"mcpServers":{}}',
|
|
562
|
+
'--no-session-persistence',
|
|
563
|
+
'--include-partial-messages',
|
|
564
|
+
)
|
|
565
|
+
} else if (options?.lightweight) {
|
|
548
566
|
args.push(...claudePermissionArgs(getClaudeTrustMode(), tools))
|
|
549
567
|
} else {
|
|
550
568
|
args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
|
|
@@ -929,7 +947,7 @@ export async function callClaudeStreaming(
|
|
|
929
947
|
continue
|
|
930
948
|
}
|
|
931
949
|
// Capture CLI session ID for future --resume (avoids cold start on next query)
|
|
932
|
-
if (event.session_id) {
|
|
950
|
+
if (event.session_id && !options?.dispatch) {
|
|
933
951
|
cliSessionMap.set(resolvedCliKey, event.session_id)
|
|
934
952
|
scheduleCliSessionSave()
|
|
935
953
|
updateClaudeRun(run.runId, { cliSessionId: event.session_id })
|
|
@@ -37,6 +37,20 @@ export interface Exchange {
|
|
|
37
37
|
* photos on the user exchange, model-published images on the assistant
|
|
38
38
|
* exchange. Bytes, filesystem paths, and capabilities never persist here. */
|
|
39
39
|
attachments?: MediaAttachmentRef[]
|
|
40
|
+
/** Who started the job that produced this pair, when it was not the person
|
|
41
|
+
* on the phone or the glasses: `routine` (the scheduler) or `task` (a
|
|
42
|
+
* dispatched task). Absent on every human prompt. The phone's own `g2` stamp
|
|
43
|
+
* never reaches the server; this is the server's half of the label. */
|
|
44
|
+
origin?: 'routine' | 'task'
|
|
45
|
+
/** `morning-brief`, or a task's 12-hex id. */
|
|
46
|
+
originId?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Structural twin of `QueryJobOrigin` so the conversation store takes no
|
|
50
|
+
* import from the job module. */
|
|
51
|
+
export interface ExchangeOriginStamp {
|
|
52
|
+
kind: 'routine' | 'task'
|
|
53
|
+
id: string
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
export interface ExchangeJobProvenance {
|
|
@@ -159,6 +173,14 @@ function loadFromDisk(): void {
|
|
|
159
173
|
if (refs.length > 0) exchange.attachments = refs
|
|
160
174
|
else delete exchange.attachments
|
|
161
175
|
}
|
|
176
|
+
// Same boundary for the origin label (6.43.4): only the two kinds this
|
|
177
|
+
// build renders survive a load, and an id never survives without its kind.
|
|
178
|
+
if (exchange.origin !== 'routine' && exchange.origin !== 'task') {
|
|
179
|
+
delete exchange.origin
|
|
180
|
+
delete exchange.originId
|
|
181
|
+
} else if (typeof exchange.originId !== 'string' || !exchange.originId) {
|
|
182
|
+
delete exchange.originId
|
|
183
|
+
}
|
|
162
184
|
}
|
|
163
185
|
sessions.set(id, session)
|
|
164
186
|
loaded++
|
|
@@ -528,6 +550,7 @@ export function reconcileExchangeByJobIdentity(
|
|
|
528
550
|
attachments?: MediaAttachmentRef[],
|
|
529
551
|
messageEra?: string,
|
|
530
552
|
modelPreference?: ModelPreference | null,
|
|
553
|
+
origin?: ExchangeOriginStamp,
|
|
531
554
|
): ReconciledExchange {
|
|
532
555
|
if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
|
|
533
556
|
throw new Error('conversation: invalid durable job identity')
|
|
@@ -572,6 +595,7 @@ export function reconcileExchangeByJobIdentity(
|
|
|
572
595
|
generation: identity.generation,
|
|
573
596
|
...(stampedModel ? { modelPreference: stampedModel } : {}),
|
|
574
597
|
...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
|
|
598
|
+
...(origin ? { origin: origin.kind, originId: origin.id } : {}),
|
|
575
599
|
}
|
|
576
600
|
session.exchanges.push(exchange)
|
|
577
601
|
} else {
|
|
@@ -580,6 +604,9 @@ export function reconcileExchangeByJobIdentity(
|
|
|
580
604
|
exchange.globalMsgNum = globalMsgNum
|
|
581
605
|
exchange.messageEra = projectedMessageEra
|
|
582
606
|
if (stampedModel) exchange.modelPreference = stampedModel
|
|
607
|
+
// Guarded like the model: the cursor and ollama bridges reconcile the user
|
|
608
|
+
// half mid-run WITHOUT an origin, and must not erase the runtime's stamp.
|
|
609
|
+
if (origin) { exchange.origin = origin.kind; exchange.originId = origin.id }
|
|
583
610
|
if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
|
|
584
611
|
else delete exchange.attachments
|
|
585
612
|
|
|
@@ -54,6 +54,12 @@ export type MaintenanceWorkKind =
|
|
|
54
54
|
// budget must stay under COS Control's 90s drain timeout (main.swift:1853)
|
|
55
55
|
// or every drain that catches a cue in flight hard-fails to Repair.
|
|
56
56
|
| 'live_cue_pipeline'
|
|
57
|
+
// Scheduled-task dispatch and reconcile (6.44.0). Held for
|
|
58
|
+
// TASK_LEASE_CEILING_MS so an Update Server drain cannot unload the
|
|
59
|
+
// server while a restricted Claude submit or marker write is live.
|
|
60
|
+
// Two `task_dispatch` leases (dispatch + reconcile) are expected —
|
|
61
|
+
// acquire() is a concurrent Map, not a per-kind mutex.
|
|
62
|
+
| 'task_dispatch'
|
|
57
63
|
// Miles-triggered recovery of a quarantined unsaved capture (6.19.0):
|
|
58
64
|
// batch-transcribes retained WAVs into a durable scribe. Held for the whole
|
|
59
65
|
// background run so an Update Server drain waits for it like any batch.
|
|
@@ -105,6 +105,10 @@ export async function callModelStreaming(
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
try {
|
|
108
|
+
if (options?.dispatch && !isClaudeModel(resolvedModel)) {
|
|
109
|
+
await lockedCallbacks.onError('dispatch_requires_claude')
|
|
110
|
+
return sid
|
|
111
|
+
}
|
|
108
112
|
// Cursor slots fail closed — never fall through to Claude/Codex.
|
|
109
113
|
if (isCursorModel(resolvedModel)) {
|
|
110
114
|
await getCursorModelCatalog()
|
|
@@ -29,6 +29,12 @@ import {
|
|
|
29
29
|
export const MORNING_BRIEF_PROTOCOL_VERSION = 1 as const
|
|
30
30
|
export const MORNING_BRIEF_CONFIG_VERSION = 1 as const
|
|
31
31
|
|
|
32
|
+
export const TASK_CATCH_UP_LIMITS = Object.freeze({
|
|
33
|
+
defaultMinutes: 180,
|
|
34
|
+
minMinutes: 30,
|
|
35
|
+
maxMinutes: 720,
|
|
36
|
+
})
|
|
37
|
+
|
|
32
38
|
export const MORNING_BRIEF_LIMITS = Object.freeze({
|
|
33
39
|
/** How late after the slot a missed fire may still happen (Mac was asleep). */
|
|
34
40
|
maxCatchUpMinutes: 12 * 60,
|
|
@@ -91,6 +97,8 @@ export interface MorningBriefConfig {
|
|
|
91
97
|
/** 0 = Sunday … 6 = Saturday. */
|
|
92
98
|
days: number[]
|
|
93
99
|
catchUpMinutes: number
|
|
100
|
+
/** How late a task runAt may still count as due (not missed). */
|
|
101
|
+
taskCatchUpMinutes: number
|
|
94
102
|
model?: ModelPreference
|
|
95
103
|
effort?: EffortPreference
|
|
96
104
|
/** Ordered: section order in the brief. */
|
|
@@ -271,6 +279,7 @@ export function defaultMorningBriefConfig(now = new Date()): MorningBriefConfig
|
|
|
271
279
|
timezone: serverTimezone(),
|
|
272
280
|
days: [1, 2, 3, 4, 5],
|
|
273
281
|
catchUpMinutes: MORNING_BRIEF_LIMITS.defaultCatchUpMinutes,
|
|
282
|
+
taskCatchUpMinutes: TASK_CATCH_UP_LIMITS.defaultMinutes,
|
|
274
283
|
sources: defaultSources(),
|
|
275
284
|
closingInstruction: '',
|
|
276
285
|
updatedAt: now.toISOString(),
|
|
@@ -389,6 +398,13 @@ export function applyMorningBriefPatch(current: MorningBriefConfig, raw: unknown
|
|
|
389
398
|
}
|
|
390
399
|
next.catchUpMinutes = minutes
|
|
391
400
|
}
|
|
401
|
+
if ('taskCatchUpMinutes' in patch) {
|
|
402
|
+
const minutes = Number(patch.taskCatchUpMinutes)
|
|
403
|
+
if (!Number.isSafeInteger(minutes) || minutes < TASK_CATCH_UP_LIMITS.minMinutes || minutes > TASK_CATCH_UP_LIMITS.maxMinutes) {
|
|
404
|
+
throw new MorningBriefConfigError('invalid_task_catch_up', `taskCatchUpMinutes must be ${TASK_CATCH_UP_LIMITS.minMinutes} to ${TASK_CATCH_UP_LIMITS.maxMinutes}.`)
|
|
405
|
+
}
|
|
406
|
+
next.taskCatchUpMinutes = minutes
|
|
407
|
+
}
|
|
392
408
|
if ('model' in patch) {
|
|
393
409
|
if (patch.model == null || patch.model === '') {
|
|
394
410
|
delete next.model
|
|
@@ -429,6 +445,7 @@ export function coerceMorningBriefConfig(raw: unknown, now = new Date()): Mornin
|
|
|
429
445
|
const input = raw as Record<string, unknown>
|
|
430
446
|
const time = typeof input.time === 'string' && TIME_RE.test(input.time.trim()) ? input.time.trim() : base.time
|
|
431
447
|
const catchUp = Number(input.catchUpMinutes)
|
|
448
|
+
const taskCatchUp = Number(input.taskCatchUpMinutes)
|
|
432
449
|
const model = normalizeModelPreference(input.model)
|
|
433
450
|
const effort = normalizeEffortPreference(input.effort)
|
|
434
451
|
let sources: MorningBriefSource[]
|
|
@@ -441,6 +458,10 @@ export function coerceMorningBriefConfig(raw: unknown, now = new Date()): Mornin
|
|
|
441
458
|
days: normalizeDays(input.days, base.days),
|
|
442
459
|
catchUpMinutes: Number.isSafeInteger(catchUp) && catchUp >= 0 && catchUp <= MORNING_BRIEF_LIMITS.maxCatchUpMinutes
|
|
443
460
|
? catchUp : base.catchUpMinutes,
|
|
461
|
+
taskCatchUpMinutes: Number.isSafeInteger(taskCatchUp)
|
|
462
|
+
&& taskCatchUp >= TASK_CATCH_UP_LIMITS.minMinutes
|
|
463
|
+
&& taskCatchUp <= TASK_CATCH_UP_LIMITS.maxMinutes
|
|
464
|
+
? taskCatchUp : base.taskCatchUpMinutes,
|
|
444
465
|
...(model ? { model } : {}),
|
|
445
466
|
...(effort ? { effort } : {}),
|
|
446
467
|
sources,
|
|
@@ -463,7 +484,7 @@ export function morningBriefPaths(root?: string): MorningBriefStorePaths {
|
|
|
463
484
|
return { config: `${base}/config.json`, runs: `${base}/runs.json` }
|
|
464
485
|
}
|
|
465
486
|
|
|
466
|
-
function ensurePrivateDir(file: string): void {
|
|
487
|
+
export function ensurePrivateDir(file: string): void {
|
|
467
488
|
const dir = dirname(file)
|
|
468
489
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
469
490
|
try { chmodSync(dir, 0o700) } catch { /* best effort */ }
|
|
@@ -30,6 +30,8 @@ export interface MorningBriefPromptInput {
|
|
|
30
30
|
day: string
|
|
31
31
|
ownerName: string
|
|
32
32
|
trigger: 'scheduled' | 'manual'
|
|
33
|
+
/** Optional TASKS digest. Kept only when the full prompt still fits. */
|
|
34
|
+
taskDigest?: string
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
@@ -200,7 +202,7 @@ const KNOWN_IDS = new Set<MorningBriefSourceId>(MORNING_BRIEF_SOURCES.map(spec =
|
|
|
200
202
|
|
|
201
203
|
/** Compose the brief prompt. Enabled sources become numbered sections in the
|
|
202
204
|
* user's order; a section whose source has nothing to say is skipped. */
|
|
203
|
-
|
|
205
|
+
function assembleMorningBriefPrompt(input: MorningBriefPromptInput, includeDigest: boolean): string {
|
|
204
206
|
const { config, day, ownerName, trigger } = input
|
|
205
207
|
const slotMinutes = parseTime(config.time)
|
|
206
208
|
const slot = `${String(Math.floor(slotMinutes / 60)).padStart(2, '0')}:${String(slotMinutes % 60).padStart(2, '0')}`
|
|
@@ -244,6 +246,11 @@ export function composeMorningBriefPrompt(input: MorningBriefPromptInput): strin
|
|
|
244
246
|
lines.push('')
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
if (includeDigest && input.taskDigest) {
|
|
250
|
+
lines.push(input.taskDigest)
|
|
251
|
+
lines.push('')
|
|
252
|
+
}
|
|
253
|
+
|
|
247
254
|
lines.push(
|
|
248
255
|
'Format for the glasses: plain text only. No markdown headings, tables, or bullet symbols; a section is its label on one line followed by short lines. ' +
|
|
249
256
|
'Keep every line under 60 characters where you can, because the lens is 576 pixels wide and wraps silently. Keep the whole brief under about 60 lines; ' +
|
|
@@ -256,7 +263,14 @@ export function composeMorningBriefPrompt(input: MorningBriefPromptInput): strin
|
|
|
256
263
|
)
|
|
257
264
|
lines.push('Do not say "here is" or "I found". Do not add a preamble or a sign-off.')
|
|
258
265
|
|
|
259
|
-
|
|
266
|
+
return lines.join('\n')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function composeMorningBriefPrompt(input: MorningBriefPromptInput): string {
|
|
270
|
+
const withDigest = assembleMorningBriefPrompt(input, true)
|
|
271
|
+
const prompt = input.taskDigest && withDigest.length <= MORNING_BRIEF_PROMPT_MAX_CHARS
|
|
272
|
+
? withDigest
|
|
273
|
+
: assembleMorningBriefPrompt(input, false)
|
|
260
274
|
return prompt.length > MORNING_BRIEF_PROMPT_MAX_CHARS
|
|
261
275
|
? `${prompt.slice(0, MORNING_BRIEF_PROMPT_MAX_CHARS - 1)}…`
|
|
262
276
|
: prompt
|
|
@@ -13,9 +13,16 @@ import { maxGlobalMsgNumInDir } from '../routes/message-ref.js'
|
|
|
13
13
|
import { dataPath } from './data-dir.js'
|
|
14
14
|
import { getOwnerName } from './profile.js'
|
|
15
15
|
import { durableQueryJobsEnabled } from './query-job-feature.js'
|
|
16
|
-
import { queryJobCoordinator } from './query-job-runtime.js'
|
|
16
|
+
import { projectPublicConversationTerminal, queryJobCoordinator } from './query-job-runtime.js'
|
|
17
17
|
import { maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
|
|
18
18
|
import { maxReservedGlobalMsgNum, registerMessageReservationSource } from './message-reservations.js'
|
|
19
|
+
import {
|
|
20
|
+
bindTaskDispatcher,
|
|
21
|
+
dispatchDueTasks,
|
|
22
|
+
reconcileDispatch,
|
|
23
|
+
reservationsForEra,
|
|
24
|
+
} from './task-dispatcher.js'
|
|
25
|
+
import { composeTaskDigest, listBoard } from './task-store.js'
|
|
19
26
|
import { morningBriefPaths } from './morning-brief-config.js'
|
|
20
27
|
import { MorningBriefScheduler } from './morning-brief-scheduler.js'
|
|
21
28
|
import {
|
|
@@ -162,9 +169,25 @@ export function probeSkill(rawName: string, workDir = resolveProviderWorkDir({ s
|
|
|
162
169
|
|
|
163
170
|
let scheduler: MorningBriefScheduler | null = null
|
|
164
171
|
let unregisterReservations: (() => void) | null = null
|
|
172
|
+
let unregisterTaskReservations: (() => void) | null = null
|
|
165
173
|
|
|
166
174
|
export function getMorningBriefScheduler(): MorningBriefScheduler {
|
|
167
175
|
if (!scheduler) {
|
|
176
|
+
bindTaskDispatcher({
|
|
177
|
+
submit: raw => queryJobCoordinator.submit(raw),
|
|
178
|
+
findByClientGeneration: (clientJobId, generation) => queryJobCoordinator.getByClientGeneration(clientJobId, generation),
|
|
179
|
+
getSnapshot: jobId => queryJobCoordinator.getSnapshot(jobId),
|
|
180
|
+
getExecution: jobId => queryJobCoordinator.store.getExecution(jobId),
|
|
181
|
+
cancel: (jobId, generation) => queryJobCoordinator.cancel(jobId, generation),
|
|
182
|
+
complete: (jobId, input) => queryJobCoordinator.store.complete(jobId, input),
|
|
183
|
+
createSession,
|
|
184
|
+
currentMessageEra,
|
|
185
|
+
currentMessageMax,
|
|
186
|
+
durableJobsEnabled: durableQueryJobsEnabled,
|
|
187
|
+
admissionsOpen: maintenanceAdmissionsOpen,
|
|
188
|
+
projectTerminal: projectPublicConversationTerminal,
|
|
189
|
+
finishIfActive: jobId => queryJobCoordinator.finishIfActive(jobId),
|
|
190
|
+
})
|
|
168
191
|
const instance = new MorningBriefScheduler({
|
|
169
192
|
paths: morningBriefPaths(),
|
|
170
193
|
submit: raw => queryJobCoordinator.submit(raw),
|
|
@@ -184,9 +207,15 @@ export function getMorningBriefScheduler(): MorningBriefScheduler {
|
|
|
184
207
|
reflection: probeReflection,
|
|
185
208
|
skill: name => probeSkill(name),
|
|
186
209
|
}),
|
|
210
|
+
dispatchDueTasks,
|
|
211
|
+
reconcileDispatch,
|
|
212
|
+
taskDigest: async () => {
|
|
213
|
+
try { return composeTaskDigest(await listBoard()) } catch { return '' }
|
|
214
|
+
},
|
|
187
215
|
})
|
|
188
216
|
// The ledger row exists before the job does; its number must count.
|
|
189
217
|
unregisterReservations = registerMessageReservationSource(() => instance.liveReservations(currentMessageEra()))
|
|
218
|
+
unregisterTaskReservations = registerMessageReservationSource(() => reservationsForEra(currentMessageEra()))
|
|
190
219
|
scheduler = instance
|
|
191
220
|
}
|
|
192
221
|
return scheduler
|
|
@@ -200,4 +229,6 @@ export function stopMorningBriefScheduler(): void {
|
|
|
200
229
|
scheduler?.stop()
|
|
201
230
|
unregisterReservations?.()
|
|
202
231
|
unregisterReservations = null
|
|
232
|
+
unregisterTaskReservations?.()
|
|
233
|
+
unregisterTaskReservations = null
|
|
203
234
|
}
|
|
@@ -74,8 +74,10 @@ export function shiftDay(day: string, delta: number): string {
|
|
|
74
74
|
* The instant at which `day` reaches `minutes` past midnight in `timezone`.
|
|
75
75
|
* Two-pass offset correction: read the zone's wall clock at a UTC guess, apply
|
|
76
76
|
* the difference, and re-check once so a DST transition between the guess and
|
|
77
|
-
* the target lands on the right side.
|
|
78
|
-
*
|
|
77
|
+
* the target lands on the right side. A non-existent local time local-clocks
|
|
78
|
+
* ~60 min early on the old offset (02:30 CDT gap → 01:30; 02:00 → 01:00);
|
|
79
|
+
* 03:00 lands on 03:00 CDT. `taskInstant` adds 60 min when earlier:
|
|
80
|
+
* 02:00→03:00, 02:30→03:30.
|
|
79
81
|
*/
|
|
80
82
|
export function zonedInstant(day: string, minutes: number, timezone: string): number {
|
|
81
83
|
const [y, m, d] = day.split('-').map(Number)
|
|
@@ -90,6 +92,21 @@ export function zonedInstant(day: string, minutes: number, timezone: string): nu
|
|
|
90
92
|
return guess
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/** Instant for a task runAt. If the rendered local (day, minutes) is earlier
|
|
96
|
+
* than requested (spring-forward gap), add 60 minutes up to three times.
|
|
97
|
+
* Fall back to the first zonedInstant. */
|
|
98
|
+
export function taskInstant(day: string, minutes: number, timezone: string): number {
|
|
99
|
+
const first = zonedInstant(day, minutes, timezone)
|
|
100
|
+
let instant = first
|
|
101
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
102
|
+
const clock = localClock(instant, timezone)
|
|
103
|
+
const earlier = clock.day < day || (clock.day === day && clock.minutes < minutes)
|
|
104
|
+
if (!earlier) return instant
|
|
105
|
+
instant += 60 * 60_000
|
|
106
|
+
}
|
|
107
|
+
return first
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
function daysBetween(fromDay: string, toDay: string): number {
|
|
94
111
|
const [fy, fm, fd] = fromDay.split('-').map(Number)
|
|
95
112
|
const [ty, tm, td] = toDay.split('-').map(Number)
|