@gotcos/glasses-server 6.43.4 → 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 +11 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/claude-bridge.ts +30 -12
- 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 +49 -1
- package/server/lib/python-bridge.ts +18 -4
- package/server/lib/query-job-coordinator.ts +8 -2
- package/server/lib/query-job-runtime.ts +9 -2
- package/server/lib/query-job-store.ts +5 -0
- package/server/lib/query-job-types.ts +31 -1
- package/server/lib/task-dispatcher.ts +642 -0
- package/server/lib/task-store.ts +624 -0
- package/server/routes/health.ts +8 -1
- package/server/routes/query-jobs.ts +1 -1
- package/server/routes/tasks.ts +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
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
|
+
|
|
1
12
|
## 6.43.4
|
|
2
13
|
|
|
3
14
|
Every message now says who started it.
|
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
|
|
@@ -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 })
|
|
@@ -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)
|
|
@@ -70,6 +70,10 @@ export interface MorningBriefSchedulerDeps {
|
|
|
70
70
|
now?: () => number
|
|
71
71
|
tickMs?: number
|
|
72
72
|
log?: (line: string) => void
|
|
73
|
+
dispatchDueTasks?: () => Promise<{ fired: number; reason?: string }>
|
|
74
|
+
reconcileDispatch?: () => Promise<{ fired: number; reason?: string }>
|
|
75
|
+
onDispatch?: (result: { fired: number; reason?: string }) => void
|
|
76
|
+
taskDigest?: (day: string) => string | Promise<string>
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export class MorningBriefRunError extends Error {
|
|
@@ -134,6 +138,10 @@ export class MorningBriefScheduler {
|
|
|
134
138
|
/** One chain for tick() AND runNow(): the in-progress read in runNow and the
|
|
135
139
|
* ledger write in fire() must never interleave with a scheduled fire. */
|
|
136
140
|
private serial: Promise<unknown> = Promise.resolve()
|
|
141
|
+
private serialTasks: Promise<unknown> = Promise.resolve()
|
|
142
|
+
private serialTasksDepth = 0
|
|
143
|
+
private dispatchInFlight: Promise<unknown> | null = null
|
|
144
|
+
private reconcileInFlight: Promise<unknown> | null = null
|
|
137
145
|
private readonly now: () => number
|
|
138
146
|
private readonly log: (line: string) => void
|
|
139
147
|
readonly quarantinedConfig?: string
|
|
@@ -190,6 +198,23 @@ export class MorningBriefScheduler {
|
|
|
190
198
|
|
|
191
199
|
/** One scheduler pass. Serialised: a slow submission never overlaps the next tick. */
|
|
192
200
|
tick(): Promise<TickResult> {
|
|
201
|
+
if (this.deps.dispatchDueTasks && !this.dispatchInFlight) {
|
|
202
|
+
const mine = this.serializeTaskWork(() => this.deps.dispatchDueTasks!())
|
|
203
|
+
this.dispatchInFlight = mine
|
|
204
|
+
void mine.then(
|
|
205
|
+
result => this.deps.onDispatch?.(result),
|
|
206
|
+
() => undefined,
|
|
207
|
+
).finally(() => {
|
|
208
|
+
if (this.dispatchInFlight === mine) this.dispatchInFlight = null
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
if (this.deps.reconcileDispatch && !this.reconcileInFlight) {
|
|
212
|
+
const mine = this.serializeTaskWork(() => this.deps.reconcileDispatch!())
|
|
213
|
+
this.reconcileInFlight = mine
|
|
214
|
+
void mine.finally(() => {
|
|
215
|
+
if (this.reconcileInFlight === mine) this.reconcileInFlight = null
|
|
216
|
+
})
|
|
217
|
+
}
|
|
193
218
|
if (this.tickInFlight) return this.tickInFlight
|
|
194
219
|
this.tickInFlight = this.serialize(() => this.runTick()).finally(() => { this.tickInFlight = null })
|
|
195
220
|
return this.tickInFlight
|
|
@@ -201,6 +226,22 @@ export class MorningBriefScheduler {
|
|
|
201
226
|
return next
|
|
202
227
|
}
|
|
203
228
|
|
|
229
|
+
private serializeTaskWork<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
230
|
+
if (this.serialTasksDepth > 0) {
|
|
231
|
+
return Promise.reject(new Error('nested serializeTaskWork'))
|
|
232
|
+
}
|
|
233
|
+
const run = this.serialTasks.then(async () => {
|
|
234
|
+
this.serialTasksDepth += 1
|
|
235
|
+
try {
|
|
236
|
+
return await fn()
|
|
237
|
+
} finally {
|
|
238
|
+
this.serialTasksDepth -= 1
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
this.serialTasks = run.then(() => undefined, () => undefined)
|
|
242
|
+
return run
|
|
243
|
+
}
|
|
244
|
+
|
|
204
245
|
private async runTick(): Promise<TickResult> {
|
|
205
246
|
if (!this.deps.durableJobsEnabled()) return { fired: false, reason: 'durable_jobs_off' }
|
|
206
247
|
if (!this.deps.admissionsOpen()) return { fired: false, reason: 'admissions_closed' }
|
|
@@ -309,7 +350,14 @@ export class MorningBriefScheduler {
|
|
|
309
350
|
// Ledger first. A crash after this line is a resume, not a second brief.
|
|
310
351
|
this.replaceRun(run)
|
|
311
352
|
|
|
312
|
-
const
|
|
353
|
+
const digest = this.deps.taskDigest ? await this.deps.taskDigest(day) : undefined
|
|
354
|
+
const prompt = composeMorningBriefPrompt({
|
|
355
|
+
config: this.config,
|
|
356
|
+
day,
|
|
357
|
+
ownerName: this.deps.ownerName(),
|
|
358
|
+
trigger,
|
|
359
|
+
...(digest ? { taskDigest: digest } : {}),
|
|
360
|
+
})
|
|
313
361
|
try {
|
|
314
362
|
const admission = await this.deps.submit({
|
|
315
363
|
clientJobId,
|
|
@@ -78,9 +78,9 @@ if (pythonAvailable) {
|
|
|
78
78
|
* pipeline is configured; otherwise resolves to an empty/no-op result so the
|
|
79
79
|
* context builder degrades gracefully on a standalone install.
|
|
80
80
|
*/
|
|
81
|
-
export function callPython(args: string[], timeoutMs = 30_000): Promise<unknown> {
|
|
81
|
+
export function callPython(args: string[], timeoutMs = 30_000, input?: string): Promise<unknown> {
|
|
82
82
|
if (pythonAvailable) {
|
|
83
|
-
return callPythonDirect(args, timeoutMs)
|
|
83
|
+
return callPythonDirect(args, timeoutMs, input)
|
|
84
84
|
}
|
|
85
85
|
return Promise.resolve(standaloneNoop(args))
|
|
86
86
|
}
|
|
@@ -186,6 +186,13 @@ function standaloneNoop(args: string[]): unknown {
|
|
|
186
186
|
return { error: 'cos_pipeline_not_configured' }
|
|
187
187
|
}
|
|
188
188
|
case 'badges': return {}
|
|
189
|
+
case 'task-rows':
|
|
190
|
+
case 'task-capture':
|
|
191
|
+
case 'task-set-run-at':
|
|
192
|
+
case 'task-set-marker':
|
|
193
|
+
case 'task-move':
|
|
194
|
+
case 'task-check':
|
|
195
|
+
return { error: { code: 'cos_pipeline_not_configured' } }
|
|
189
196
|
default: return {}
|
|
190
197
|
}
|
|
191
198
|
}
|
|
@@ -199,15 +206,18 @@ function argLimit(args: string[], fallback: number): number {
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
/** Full Python bridge — requires the user's venv + cos_api_bridge.py. */
|
|
202
|
-
function callPythonDirect(args: string[], timeoutMs: number): Promise<unknown> {
|
|
209
|
+
function callPythonDirect(args: string[], timeoutMs: number, input?: string): Promise<unknown> {
|
|
203
210
|
return new Promise((resolvePromise, reject) => {
|
|
204
|
-
execFile(
|
|
211
|
+
const child = execFile(
|
|
205
212
|
PYTHON_BIN!,
|
|
206
213
|
[BRIDGE_SCRIPT!, ...args],
|
|
207
214
|
{ cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
|
|
208
215
|
(err, stdout, stderr) => {
|
|
209
216
|
if (err) {
|
|
210
217
|
const msg = stderr?.trim() || err.message
|
|
218
|
+
if (typeof msg === 'string' && msg.includes('unknown command')) {
|
|
219
|
+
return resolvePromise({ error: { code: 'cos_pipeline_not_configured', message: msg } })
|
|
220
|
+
}
|
|
211
221
|
return reject(new Error(`python-bridge: ${msg}`))
|
|
212
222
|
}
|
|
213
223
|
try {
|
|
@@ -217,5 +227,9 @@ function callPythonDirect(args: string[], timeoutMs: number): Promise<unknown> {
|
|
|
217
227
|
}
|
|
218
228
|
}
|
|
219
229
|
)
|
|
230
|
+
if (input != null) {
|
|
231
|
+
child.stdin?.write(input)
|
|
232
|
+
child.stdin?.end()
|
|
233
|
+
}
|
|
220
234
|
})
|
|
221
235
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
QueryJobStore,
|
|
4
4
|
QueryJobStoreError,
|
|
5
5
|
type QueryJobAdmissionResult,
|
|
6
|
+
type QueryJobMutationResult,
|
|
6
7
|
type QueryJobSubscription,
|
|
7
8
|
} from './query-job-store.js'
|
|
8
9
|
import {
|
|
@@ -502,7 +503,7 @@ export class QueryJobCoordinator {
|
|
|
502
503
|
}
|
|
503
504
|
}
|
|
504
505
|
|
|
505
|
-
async cancel(jobId: string, generation: number): Promise<
|
|
506
|
+
async cancel(jobId: string, generation: number): Promise<QueryJobMutationResult> {
|
|
506
507
|
const result = await this.store.cancel(jobId, generation)
|
|
507
508
|
if (result.applied) {
|
|
508
509
|
const active = this.active.get(jobId)
|
|
@@ -514,7 +515,12 @@ export class QueryJobCoordinator {
|
|
|
514
515
|
this.finishActive(active)
|
|
515
516
|
}
|
|
516
517
|
}
|
|
517
|
-
return result
|
|
518
|
+
return result
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
finishIfActive(jobId: string): void {
|
|
522
|
+
const active = this.active.get(jobId)
|
|
523
|
+
if (active) this.finishActive(active)
|
|
518
524
|
}
|
|
519
525
|
|
|
520
526
|
getSnapshot(jobId: string, generation?: number): Promise<QueryJobSnapshot> {
|
|
@@ -92,8 +92,14 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
|
|
|
92
92
|
}
|
|
93
93
|
try {
|
|
94
94
|
const resolved = await resolveQueryAttachments(input)
|
|
95
|
+
const { dispatch: _strippedDispatch, origin: rawOrigin, ...rest } = input
|
|
96
|
+
const origin = (rawOrigin && typeof rawOrigin === 'object' && (rawOrigin as { kind?: string }).kind === 'task')
|
|
97
|
+
? undefined
|
|
98
|
+
: rawOrigin
|
|
99
|
+
if (_strippedDispatch !== undefined || origin !== rawOrigin) queryJobStore.noteOriginStripped()
|
|
95
100
|
return {
|
|
96
|
-
...
|
|
101
|
+
...rest,
|
|
102
|
+
...(origin !== undefined ? { origin } : {}),
|
|
97
103
|
messageEra: activeEra,
|
|
98
104
|
attachmentIds: resolved.ids,
|
|
99
105
|
attachmentRefs: resolved.refs,
|
|
@@ -124,7 +130,7 @@ function originStamp(request: QueryJobRequest): { origin?: NonNullable<QueryJobR
|
|
|
124
130
|
* cache. Journaled request/response text always wins over bridge-written
|
|
125
131
|
* partial rows; validated media refs may be merged because output media can
|
|
126
132
|
* finish immediately before a crash. Exact provenance collapses duplicates. */
|
|
127
|
-
async function projectPublicConversationTerminal(
|
|
133
|
+
export async function projectPublicConversationTerminal(
|
|
128
134
|
job: QueryJobSnapshot,
|
|
129
135
|
request: QueryJobRequest,
|
|
130
136
|
): Promise<void> {
|
|
@@ -334,6 +340,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
334
340
|
requestAttachments: resolvedAttachments.refs,
|
|
335
341
|
attachmentPromptBlock: resolvedAttachments.promptBlock,
|
|
336
342
|
sessionLockHeld: true,
|
|
343
|
+
...(request.dispatch ? { dispatch: request.dispatch } : {}),
|
|
337
344
|
},
|
|
338
345
|
)
|
|
339
346
|
}
|
|
@@ -319,6 +319,7 @@ export class QueryJobStore {
|
|
|
319
319
|
interruptedOnBoot: 0,
|
|
320
320
|
evictedHydratedJobs: 0,
|
|
321
321
|
originDropped: 0,
|
|
322
|
+
originStripped: 0,
|
|
322
323
|
fingerprintMismatches: 0,
|
|
323
324
|
lastErrorCode: null,
|
|
324
325
|
lastSuccessfulWriteAt: null,
|
|
@@ -1162,6 +1163,10 @@ export class QueryJobStore {
|
|
|
1162
1163
|
this.health.counts = counts
|
|
1163
1164
|
}
|
|
1164
1165
|
|
|
1166
|
+
noteOriginStripped(): void {
|
|
1167
|
+
this.health.originStripped++
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1165
1170
|
getHealth(): QueryJobStoreHealth {
|
|
1166
1171
|
this.refreshHealth()
|
|
1167
1172
|
return clone(this.health)
|