talon-agent 5.18.2 → 5.19.1
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/README.md +2 -1
- package/package.json +2 -2
- package/prompts/system/agent-brief.md +20 -3
- package/src/app.ts +13 -0
- package/src/backend/claude-sdk/handler.ts +4 -4
- package/src/backend/claude-sdk/mcp-ready.ts +16 -2
- package/src/backend/claude-sdk/one-shot.ts +32 -2
- package/src/backend/claude-sdk/stream.ts +2 -2
- package/src/backend/codex/auth.ts +1 -1
- package/src/backend/codex/handler/message.ts +11 -11
- package/src/backend/codex/init.ts +4 -9
- package/src/backend/codex/mcp-config.ts +1 -2
- package/src/backend/codex/oauth-incompat.ts +8 -4
- package/src/backend/codex/one-shot.ts +1 -1
- package/src/backend/openai-agents/builtins.ts +55 -27
- package/src/backend/openai-agents/factory.ts +3 -3
- package/src/backend/openai-agents/handler/message.ts +3 -5
- package/src/backend/openai-agents/mcp-pool.ts +10 -27
- package/src/backend/remote-server/chat-turn.ts +6 -6
- package/src/backend/remote-server/events.ts +1 -5
- package/src/backend/remote-server/index.ts +0 -1
- package/src/backend/remote-server/messages.ts +3 -7
- package/src/backend/remote-server/one-shot.ts +1 -3
- package/src/backend/remote-server/session-helpers.ts +1 -4
- package/src/backend/remote-server/sse-stream.ts +8 -9
- package/src/backend/runtime/metrics.ts +7 -13
- package/src/backend/runtime/sleep.ts +1 -2
- package/src/backend/runtime/turn/handle-retry.ts +48 -2
- package/src/backend/runtime/turn/handler-to-events.ts +3 -4
- package/src/bootstrap.ts +9 -1
- package/src/cli/doctor.ts +3 -0
- package/src/cli/index.ts +8 -10
- package/src/cli/logs.ts +148 -9
- package/src/cli/setup.ts +9 -11
- package/src/cli/status.ts +29 -0
- package/src/core/agent-runtime/README.md +5 -19
- package/src/core/agent-runtime/events.ts +3 -39
- package/src/core/agent-runtime/model-ref.ts +0 -8
- package/src/core/agents/registry.ts +65 -2
- package/src/core/auth/expiry-monitor.ts +9 -1
- package/src/core/auth/login-flow.ts +9 -1
- package/src/core/auth/status.ts +31 -3
- package/src/core/background/cron/scheduler.ts +25 -5
- package/src/core/background/dream/index.ts +29 -10
- package/src/core/background/failure-backoff.ts +30 -0
- package/src/core/background/heartbeat/agent.ts +2 -27
- package/src/core/background/heartbeat/index.ts +0 -2
- package/src/core/background/heartbeat/scheduler.ts +21 -13
- package/src/core/background/heartbeat/state.ts +10 -2
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/background/pulse/pulse.ts +9 -0
- package/src/core/background/triggers/exit.ts +54 -0
- package/src/core/background/triggers/index.ts +1 -3
- package/src/core/background/triggers/resume.ts +2 -4
- package/src/core/backup/archive/tar.ts +14 -4
- package/src/core/backup/plan.ts +1 -0
- package/src/core/backup/restore.ts +21 -13
- package/src/core/backup/scheduler.ts +28 -9
- package/src/core/backup/snapshot.ts +43 -10
- package/src/core/backup/store.ts +7 -19
- package/src/core/backup/targets.ts +69 -17
- package/src/core/config/index.ts +14 -0
- package/src/core/daemon/crash-marker.ts +141 -0
- package/src/core/daemon/crash.ts +9 -2
- package/src/core/daemon/handoff.ts +15 -0
- package/src/core/daemon/health-alerts.ts +297 -0
- package/src/core/daemon/log-reader.ts +289 -0
- package/src/core/doctor/index.ts +18 -2
- package/src/core/doctor/logs.ts +124 -0
- package/src/core/doctor/types.ts +1 -1
- package/src/core/engine/backend-controller/index.ts +1 -13
- package/src/core/engine/backend-router/router.ts +1 -1
- package/src/core/engine/dispatcher.ts +55 -2
- package/src/core/engine/fault-text.ts +40 -0
- package/src/core/engine/gateway-actions/agents/index.ts +3 -2
- package/src/core/engine/gateway-actions/agents/report.ts +62 -0
- package/src/core/engine/gateway-actions/history.ts +2 -4
- package/src/core/engine/gateway-actions/mesh.ts +26 -14
- package/src/core/engine/gateway-actions/native/exec.ts +13 -16
- package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
- package/src/core/engine/gateway.ts +60 -1
- package/src/core/engine/turn-health.ts +222 -0
- package/src/core/errors.ts +2 -2
- package/src/core/frontend-runtime/admin-notify.ts +1 -1
- package/src/core/frontend-runtime/alerts.ts +130 -0
- package/src/core/mcp-hub/children.ts +78 -29
- package/src/core/mcp-hub/index.ts +21 -18
- package/src/core/mcp-hub/proxy-server.ts +8 -4
- package/src/core/mcp-hub/talon-server.ts +5 -12
- package/src/core/mesh/credentials/store.ts +16 -1
- package/src/core/mesh/devices/registry.ts +1 -17
- package/src/core/mesh/devices/service.ts +49 -17
- package/src/core/mesh/devices/teleport.ts +14 -2
- package/src/core/mesh/links/node-binaries.ts +13 -6
- package/src/core/mesh/persist.ts +22 -10
- package/src/core/mesh/transfers/device-files.ts +5 -23
- package/src/core/mesh/transfers/transfers.ts +16 -3
- package/src/core/models/active-model.ts +2 -55
- package/src/core/plugin/actions.ts +19 -20
- package/src/core/plugin/builtins.ts +80 -90
- package/src/core/plugin/index.ts +1 -4
- package/src/core/plugin/loader.ts +25 -33
- package/src/core/plugin/mcp.ts +3 -5
- package/src/core/plugin/registry.ts +19 -35
- package/src/core/plugin/types.ts +2 -5
- package/src/core/prompt/assemble.ts +15 -3
- package/src/core/scripts/lua.ts +6 -2
- package/src/core/tasks/table.ts +8 -2
- package/src/core/tools/bridge.ts +2 -4
- package/src/core/tools/chat/cross-send.ts +1 -1
- package/src/core/tools/chat/messaging.ts +1 -1
- package/src/core/tools/index.ts +2 -2
- package/src/core/tools/mcp-env.ts +2 -59
- package/src/core/tools/ops/agents.ts +22 -1
- package/src/core/tools/schemas.ts +4 -9
- package/src/core/vfs/fusefs.ts +0 -5
- package/src/core/vfs/index.ts +9 -2
- package/src/core/vfs/mounts/diagnostics.ts +109 -0
- package/src/core/vfs/mounts/proc.ts +17 -1
- package/src/core/vfs/workspace.ts +7 -3
- package/src/core/weaver/shuttle.ts +8 -1
- package/src/core/weaver/turn-log.ts +320 -0
- package/src/core/weaver/weaver.ts +48 -5
- package/src/frontend/discord/actions/index.ts +8 -1
- package/src/frontend/discord/diagnostics.ts +82 -6
- package/src/frontend/discord/handlers/index.ts +0 -2
- package/src/frontend/discord/middleware.ts +7 -13
- package/src/frontend/discord/runtime.ts +1 -3
- package/src/frontend/health/delivery.ts +115 -0
- package/src/frontend/health/outage.ts +116 -0
- package/src/frontend/native/bridge/routes/chats.ts +3 -5
- package/src/frontend/native/bridge/server.ts +106 -21
- package/src/frontend/native/index.ts +1 -1
- package/src/frontend/native/media/media.ts +5 -1
- package/src/frontend/native/runtime.ts +12 -7
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/frontend/native/surface/memory.ts +1 -1
- package/src/frontend/native/surface/models.ts +3 -3
- package/src/frontend/native/surface/settings.ts +20 -8
- package/src/frontend/native/turn/context.ts +6 -8
- package/src/frontend/native/turn/turn-meta.ts +2 -5
- package/src/frontend/native/turn/turn.ts +8 -10
- package/src/frontend/presentation/format.ts +2 -4
- package/src/frontend/presentation/session-status.ts +2 -6
- package/src/frontend/teams/actions.ts +8 -1
- package/src/frontend/teams/graph.ts +0 -1
- package/src/frontend/teams/index.ts +1 -4
- package/src/frontend/teams/poll.ts +40 -2
- package/src/frontend/teams/runtime.ts +14 -5
- package/src/frontend/telegram/actions/index.ts +4 -1
- package/src/frontend/telegram/actions/send.ts +8 -0
- package/src/frontend/telegram/handlers/context.ts +13 -2
- package/src/frontend/telegram/handlers/delivery.ts +12 -9
- package/src/frontend/telegram/handlers/index.ts +0 -2
- package/src/frontend/telegram/index.ts +35 -9
- package/src/frontend/telegram/polling/poll-health.ts +110 -0
- package/src/frontend/telegram/userbot.ts +100 -36
- package/src/frontend/terminal/builtins/session.ts +2 -2
- package/src/frontend/terminal/index.ts +1 -3
- package/src/frontend/terminal/renderer.ts +2 -18
- package/src/frontend/whatsapp/actions/index.ts +12 -1
- package/src/frontend/whatsapp/actions/messaging.ts +7 -2
- package/src/frontend/whatsapp/connection/connection.ts +29 -9
- package/src/frontend/whatsapp/connection/health.ts +89 -0
- package/src/frontend/whatsapp/connection/identity.ts +4 -4
- package/src/frontend/whatsapp/runtime.ts +11 -5
- package/src/native/blake3.ts +28 -2
- package/src/native/fusefs.ts +23 -5
- package/src/native/registry.ts +1 -1
- package/src/native/warden.ts +33 -5
- package/src/plugins/github/index.ts +0 -1
- package/src/plugins/mempalace/index.ts +9 -3
- package/src/plugins/playwright/index.ts +2 -4
- package/src/plugins/playwright/provision.ts +12 -4
- package/src/storage/chat-settings.ts +6 -1
- package/src/storage/cron.ts +29 -4
- package/src/storage/daily-log.ts +43 -47
- package/src/storage/db.ts +61 -33
- package/src/storage/history.ts +6 -1
- package/src/storage/journal.ts +9 -2
- package/src/storage/kv.ts +19 -6
- package/src/storage/media-index.ts +28 -6
- package/src/storage/repositories/chat-settings-repo.ts +8 -2
- package/src/storage/repositories/sessions-repo.ts +10 -3
- package/src/storage/scripts.ts +24 -13
- package/src/storage/sessions.ts +11 -2
- package/src/storage/skills.ts +21 -2
- package/src/storage/stickers.ts +17 -3
- package/src/storage/triggers.ts +8 -3
- package/src/storage/turn-meta.ts +25 -7
- package/src/util/log.ts +189 -6
- package/src/util/logging/turn-scope.ts +85 -0
- package/src/util/time.ts +3 -3
- package/src/util/watchdog.ts +30 -0
- package/src/core/engine/backend-controller/legacy.ts +0 -111
package/src/storage/daily-log.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
unlinkSync,
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
|
-
import { log as logInfo, logError } from "../util/log.js";
|
|
14
|
+
import { log as logInfo, logError, logWarn } from "../util/log.js";
|
|
15
15
|
import { dirs } from "../util/paths.js";
|
|
16
16
|
import { toYMD } from "../util/time.js";
|
|
17
17
|
|
|
@@ -115,56 +115,52 @@ export function todayLogDate(): string {
|
|
|
115
115
|
/** Matches YYYY-MM-DD.md filenames strictly. */
|
|
116
116
|
const DAILY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
|
|
117
117
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Unlink YYYY-MM-DD.md files in `dir` dated before `cutoff`. Never
|
|
120
|
+
* throws; a missing dir is the normal first-run case, anything else
|
|
121
|
+
* (unreadable dir, a file that won't unlink) is logged.
|
|
122
|
+
*/
|
|
123
|
+
function pruneDatedFiles(dir: string, cutoff: string, what: string): void {
|
|
124
|
+
let deleted = 0;
|
|
125
|
+
let failed = 0;
|
|
126
|
+
let firstFailure = "";
|
|
120
127
|
try {
|
|
121
|
-
if (existsSync(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
try {
|
|
130
|
-
unlinkSync(resolve(LOGS_DIR, file));
|
|
131
|
-
deleted++;
|
|
132
|
-
} catch {
|
|
133
|
-
/* skip */
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
if (deleted > 0) {
|
|
138
|
-
logInfo("workspace", `Cleaned up ${deleted} old daily log(s)`);
|
|
128
|
+
if (!existsSync(dir)) return;
|
|
129
|
+
for (const file of readdirSync(dir)) {
|
|
130
|
+
if (!DAILY_FILE_RE.test(file) || file >= cutoff) continue;
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(resolve(dir, file));
|
|
133
|
+
deleted++;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (failed++ === 0) firstFailure = `${file}: ${errText(err)}`;
|
|
139
136
|
}
|
|
140
137
|
}
|
|
141
|
-
} catch {
|
|
142
|
-
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logWarn("workspace", `${what} cleanup failed dir=${dir}: ${errText(err)}`);
|
|
140
|
+
}
|
|
141
|
+
if (deleted > 0) {
|
|
142
|
+
logInfo("workspace", `Cleaned up ${deleted} old ${what}(s)`);
|
|
143
|
+
}
|
|
144
|
+
if (failed > 0) {
|
|
145
|
+
logWarn(
|
|
146
|
+
"workspace",
|
|
147
|
+
`Could not remove ${failed} old ${what}(s) dir=${dir}; first=${firstFailure}`,
|
|
148
|
+
);
|
|
143
149
|
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function errText(err: unknown): string {
|
|
153
|
+
return err instanceof Error ? err.message : String(err);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Remove daily logs older than MAX_LOG_DAYS. Called on startup. */
|
|
157
|
+
export function cleanupOldLogs(): void {
|
|
158
|
+
const cutoff = new Date();
|
|
159
|
+
cutoff.setDate(cutoff.getDate() - MAX_LOG_DAYS);
|
|
160
|
+
pruneDatedFiles(LOGS_DIR, localDateKey(cutoff), "daily log");
|
|
144
161
|
|
|
145
162
|
// Clean up old daily memory files (independent of logs dir)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const cutoffDate = new Date();
|
|
150
|
-
cutoffDate.setDate(cutoffDate.getDate() - MAX_LOG_DAYS);
|
|
151
|
-
const cutoffMem = toYMD(cutoffDate);
|
|
152
|
-
|
|
153
|
-
let deletedMem = 0;
|
|
154
|
-
for (const file of readdirSync(dailyMemDir)) {
|
|
155
|
-
if (DAILY_FILE_RE.test(file) && file < cutoffMem) {
|
|
156
|
-
try {
|
|
157
|
-
unlinkSync(resolve(dailyMemDir, file));
|
|
158
|
-
deletedMem++;
|
|
159
|
-
} catch {
|
|
160
|
-
/* skip */
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (deletedMem > 0) {
|
|
165
|
-
logInfo("workspace", `Cleaned up ${deletedMem} old daily memory file(s)`);
|
|
166
|
-
}
|
|
167
|
-
} catch {
|
|
168
|
-
/* skip */
|
|
169
|
-
}
|
|
163
|
+
const cutoffDate = new Date();
|
|
164
|
+
cutoffDate.setDate(cutoffDate.getDate() - MAX_LOG_DAYS);
|
|
165
|
+
pruneDatedFiles(dirs.dailyMemory, toYMD(cutoffDate), "daily memory file");
|
|
170
166
|
}
|
package/src/storage/db.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { mkdirSync } from "node:fs";
|
|
28
28
|
import { dirname } from "node:path";
|
|
29
29
|
import { files } from "../util/paths.js";
|
|
30
|
-
import { log, logError } from "../util/log.js";
|
|
30
|
+
import { log, logError, logWarn } from "../util/log.js";
|
|
31
31
|
import { SCHEMA, dbSql } from "./sql/statements.generated.js";
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -66,18 +66,20 @@ const ReadOnlyDatabase = Database as unknown as new (
|
|
|
66
66
|
|
|
67
67
|
let db: SqlDatabase | null = null;
|
|
68
68
|
|
|
69
|
+
/** How long a write waits on another connection's lock before SQLITE_BUSY. */
|
|
70
|
+
const BUSY_TIMEOUT_MS = 5_000;
|
|
71
|
+
|
|
69
72
|
/**
|
|
70
73
|
* Apply the complete schema. Every statement is IF NOT EXISTS, so this
|
|
71
74
|
* is a no-op on an up-to-date database and creates exactly what's
|
|
72
75
|
* missing on a fresh or older one.
|
|
73
76
|
*
|
|
74
77
|
* Column reconciliation runs first: `ALTER TABLE … ADD COLUMN` has no
|
|
75
|
-
* IF NOT EXISTS form, so columns added to already-shipped tables
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* CREATE TABLE in schema.sql includes the column).
|
|
78
|
+
* IF NOT EXISTS form, so columns added to already-shipped tables are
|
|
79
|
+
* ensured by attempting the ALTER and swallowing the two expected
|
|
80
|
+
* failures — "duplicate column name" (column already there) and "no
|
|
81
|
+
* such table" (fresh database; the CREATE TABLE in schema.sql includes
|
|
82
|
+
* the column).
|
|
81
83
|
*/
|
|
82
84
|
function ensureSchema(database: SqlDatabase): void {
|
|
83
85
|
const row = database
|
|
@@ -85,30 +87,27 @@ function ensureSchema(database: SqlDatabase): void {
|
|
|
85
87
|
"SELECT COUNT(*) AS tables FROM sqlite_master WHERE type = 'table'",
|
|
86
88
|
)
|
|
87
89
|
.get() as { tables: number };
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
database.exec(dbSql.addSessionsLastTurnEndedAtColumn);
|
|
110
|
-
} catch {
|
|
111
|
-
/* duplicate column or no such table — both mean nothing to do */
|
|
90
|
+
for (const addColumn of [
|
|
91
|
+
dbSql.addMediaContentHashColumn,
|
|
92
|
+
dbSql.addHistorySenderHandleColumn,
|
|
93
|
+
dbSql.addSessionsMetricsColumn,
|
|
94
|
+
dbSql.addHistoryAttachmentsColumn,
|
|
95
|
+
dbSql.addSessionsLastTurnEndedAtColumn,
|
|
96
|
+
]) {
|
|
97
|
+
try {
|
|
98
|
+
database.exec(addColumn);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Duplicate column or no such table both mean nothing to do. Any
|
|
101
|
+
// other failure (read-only file, full disk) is a real fault the
|
|
102
|
+
// schema step below will likely trip over too — say which ALTER.
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
if (!/duplicate column name|no such table/i.test(msg)) {
|
|
105
|
+
logWarn(
|
|
106
|
+
"db",
|
|
107
|
+
`schema column reconcile failed: ${msg}${dbErrorFields(err)} sql=${JSON.stringify(addColumn.slice(0, 60))}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
112
111
|
}
|
|
113
112
|
database.exec("BEGIN");
|
|
114
113
|
try {
|
|
@@ -121,6 +120,24 @@ function ensureSchema(database: SqlDatabase): void {
|
|
|
121
120
|
if (row.tables === 0) log("db", "Initialized database schema");
|
|
122
121
|
}
|
|
123
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The SQLite result code behind a driver error, as ` key=value` log
|
|
125
|
+
* fields (leading space; empty when there is none). node:sqlite keeps
|
|
126
|
+
* it out of the message — "database or disk is full" arrives as
|
|
127
|
+
* `errcode: 13` — so a log line built from `err.message` alone can't
|
|
128
|
+
* be grepped for SQLITE_FULL (13), SQLITE_BUSY (5) or SQLITE_READONLY (8).
|
|
129
|
+
*/
|
|
130
|
+
export function dbErrorFields(err: unknown): string {
|
|
131
|
+
if (!err || typeof err !== "object") return "";
|
|
132
|
+
const e = err as { code?: unknown; errcode?: unknown; errno?: unknown };
|
|
133
|
+
let out = "";
|
|
134
|
+
if (typeof e.code === "string") out += ` code=${e.code}`;
|
|
135
|
+
// node:sqlite names it errcode, bun:sqlite errno.
|
|
136
|
+
const rc = typeof e.errcode === "number" ? e.errcode : e.errno;
|
|
137
|
+
if (typeof rc === "number") out += ` errcode=${rc}`;
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
function defaultPath(): string {
|
|
125
142
|
// Test isolation: the vitest setup file points every worker at a
|
|
126
143
|
// throwaway database so suites never touch ~/.talon/data/talon.db.
|
|
@@ -137,6 +154,12 @@ export function getDatabase(path: string = defaultPath()): SqlDatabase {
|
|
|
137
154
|
mkdirSync(dirname(path), { recursive: true });
|
|
138
155
|
const database = new Database(path);
|
|
139
156
|
try {
|
|
157
|
+
// Other processes write this file too (CLI commands, a respawned
|
|
158
|
+
// successor overlapping its predecessor). Both drivers default to a
|
|
159
|
+
// zero busy timeout, i.e. "database is locked" the instant a write
|
|
160
|
+
// meets theirs — wait for the lock instead. First, so the pragmas
|
|
161
|
+
// and schema setup below get it too.
|
|
162
|
+
database.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
140
163
|
// WAL: readers don't block the writer, and crash recovery is
|
|
141
164
|
// journal-based instead of "hope the rename was atomic".
|
|
142
165
|
database.exec("PRAGMA journal_mode = WAL");
|
|
@@ -216,8 +239,13 @@ export function flushDatabase(): void {
|
|
|
216
239
|
if (!db) return;
|
|
217
240
|
try {
|
|
218
241
|
db.exec(dbSql.walCheckpoint);
|
|
219
|
-
} catch {
|
|
220
|
-
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// Shutting down — best effort, but a checkpoint that fails here
|
|
244
|
+
// (disk full, I/O error) is the last word on why the WAL grew.
|
|
245
|
+
logWarn(
|
|
246
|
+
"db",
|
|
247
|
+
`WAL checkpoint failed: ${err instanceof Error ? err.message : String(err)}${dbErrorFields(err)}`,
|
|
248
|
+
);
|
|
221
249
|
}
|
|
222
250
|
}
|
|
223
251
|
|
package/src/storage/history.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { recordError } from "../util/watchdog.js";
|
|
|
24
24
|
import { files } from "../util/paths.js";
|
|
25
25
|
import { formatSmartTimestamp, formatRelativeAge } from "../util/time.js";
|
|
26
26
|
import { importLegacyJson } from "./legacy-import.js";
|
|
27
|
+
import { dbErrorFields } from "./db.js";
|
|
27
28
|
import * as repo from "./repositories/history-repo.js";
|
|
28
29
|
|
|
29
30
|
export type {
|
|
@@ -77,7 +78,11 @@ export function pushMessage(chatId: string, msg: HistoryMessage): void {
|
|
|
77
78
|
try {
|
|
78
79
|
repo.insert(chatId, msg);
|
|
79
80
|
} catch (err) {
|
|
80
|
-
logError(
|
|
81
|
+
logError(
|
|
82
|
+
"history",
|
|
83
|
+
`Failed to persist message chat=${chatId} msg=${msg.msgId}${dbErrorFields(err)}`,
|
|
84
|
+
err,
|
|
85
|
+
);
|
|
81
86
|
recordError(
|
|
82
87
|
`History write failed: ${err instanceof Error ? err.message : err}`,
|
|
83
88
|
);
|
package/src/storage/journal.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* PRUNE_EVERY appends) rather than on a timer.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { logError } from "../util/log.js";
|
|
18
|
+
import { logError, logWarn } from "../util/log.js";
|
|
19
19
|
import * as repo from "./repositories/journal-repo.js";
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -82,6 +82,7 @@ export function readJournal<E extends JournalRecord = JournalRecord>(
|
|
|
82
82
|
? repo.recentByType(options.type, limit)
|
|
83
83
|
: repo.recent(limit);
|
|
84
84
|
const entries: JournalEntry<E>[] = [];
|
|
85
|
+
const corrupt: number[] = [];
|
|
85
86
|
for (const row of rows) {
|
|
86
87
|
try {
|
|
87
88
|
entries.push({
|
|
@@ -90,9 +91,15 @@ export function readJournal<E extends JournalRecord = JournalRecord>(
|
|
|
90
91
|
event: JSON.parse(row.payload) as E,
|
|
91
92
|
});
|
|
92
93
|
} catch {
|
|
93
|
-
// skip the corrupt row
|
|
94
|
+
corrupt.push(row.seq); // skip the corrupt row
|
|
94
95
|
}
|
|
95
96
|
}
|
|
97
|
+
if (corrupt.length > 0) {
|
|
98
|
+
logWarn(
|
|
99
|
+
"journal",
|
|
100
|
+
`Skipped ${corrupt.length} corrupt journal row(s) seq=${corrupt.slice(0, 5).join(",")}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
96
103
|
return entries;
|
|
97
104
|
}
|
|
98
105
|
|
package/src/storage/kv.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import * as repo from "./repositories/kv-repo.js";
|
|
17
|
+
import { dbErrorFields } from "./db.js";
|
|
17
18
|
import { logError } from "../util/log.js";
|
|
18
19
|
|
|
19
20
|
export function kvGet<T>(key: string): T | undefined {
|
|
@@ -21,7 +22,7 @@ export function kvGet<T>(key: string): T | undefined {
|
|
|
21
22
|
try {
|
|
22
23
|
raw = repo.get(key);
|
|
23
24
|
} catch (err) {
|
|
24
|
-
logError("kv", `Failed to read ${key}`, err);
|
|
25
|
+
logError("kv", `Failed to read ${key}${dbErrorFields(err)}`, err);
|
|
25
26
|
return undefined;
|
|
26
27
|
}
|
|
27
28
|
if (raw === undefined) return undefined;
|
|
@@ -33,12 +34,24 @@ export function kvGet<T>(key: string): T | undefined {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Persist a JSON-serialisable value. Storage failure logs (with the
|
|
39
|
+
* SQLite result code, so a full disk reads as errcode=13) and never
|
|
40
|
+
* throws; the return says whether the write landed.
|
|
41
|
+
*/
|
|
42
|
+
export function kvSet(key: string, value: unknown): boolean {
|
|
43
|
+
let json: string | undefined;
|
|
38
44
|
try {
|
|
39
|
-
|
|
45
|
+
json = JSON.stringify(value);
|
|
46
|
+
repo.set(key, json, Date.now());
|
|
47
|
+
return true;
|
|
40
48
|
} catch (err) {
|
|
41
|
-
logError(
|
|
49
|
+
logError(
|
|
50
|
+
"kv",
|
|
51
|
+
`Failed to write ${key} bytes=${json?.length ?? "?"}${dbErrorFields(err)}`,
|
|
52
|
+
err,
|
|
53
|
+
);
|
|
54
|
+
return false;
|
|
42
55
|
}
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -46,7 +59,7 @@ export function kvDelete(key: string): boolean {
|
|
|
46
59
|
try {
|
|
47
60
|
return repo.remove(key);
|
|
48
61
|
} catch (err) {
|
|
49
|
-
logError("kv", `Failed to delete ${key}`, err);
|
|
62
|
+
logError("kv", `Failed to delete ${key}${dbErrorFields(err)}`, err);
|
|
50
63
|
return false;
|
|
51
64
|
}
|
|
52
65
|
}
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import { existsSync, unlinkSync } from "node:fs";
|
|
19
19
|
import { blake3HexFile } from "../native/blake3.js";
|
|
20
|
-
import { log, logError } from "../util/log.js";
|
|
20
|
+
import { log, logError, logWarn } from "../util/log.js";
|
|
21
21
|
import { recordError } from "../util/watchdog.js";
|
|
22
22
|
import { files } from "../util/paths.js";
|
|
23
23
|
import { importLegacyJson } from "./legacy-import.js";
|
|
24
24
|
import { setMessageFilePath } from "./history.js";
|
|
25
|
+
import { dbErrorFields } from "./db.js";
|
|
25
26
|
import * as repo from "./repositories/media-index-repo.js";
|
|
26
27
|
|
|
27
28
|
export type { MediaEntry } from "./repositories/media-index-repo.js";
|
|
@@ -75,7 +76,11 @@ export function addMedia(entry: Omit<MediaEntry, "id">): void {
|
|
|
75
76
|
try {
|
|
76
77
|
repo.upsert(entry);
|
|
77
78
|
} catch (err) {
|
|
78
|
-
logError(
|
|
79
|
+
logError(
|
|
80
|
+
"media",
|
|
81
|
+
`Media index save failed chat=${entry.chatId} msg=${entry.msgId} path=${entry.filePath}${dbErrorFields(err)}`,
|
|
82
|
+
err,
|
|
83
|
+
);
|
|
79
84
|
recordError(
|
|
80
85
|
`Media index write failed: ${err instanceof Error ? err.message : err}`,
|
|
81
86
|
);
|
|
@@ -116,8 +121,13 @@ async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<void> {
|
|
|
116
121
|
"media",
|
|
117
122
|
`Deduped ${entry.filePath} -> ${canonical.filePath} (blake3 ${hash.slice(0, 12)}…)`,
|
|
118
123
|
);
|
|
119
|
-
} catch {
|
|
120
|
-
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Dedupe is best-effort — the entry already points at the canonical
|
|
126
|
+
// copy — but the duplicate now sits on disk unreferenced.
|
|
127
|
+
logWarn(
|
|
128
|
+
"media",
|
|
129
|
+
`Dedupe unlink failed path=${entry.filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
130
|
+
);
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
}
|
|
@@ -161,17 +171,29 @@ function purgeExpired(): void {
|
|
|
161
171
|
const removed = repo.deleteOlderThan(cutoff);
|
|
162
172
|
// Rows first, files second: content dedupe means several entries can
|
|
163
173
|
// share one file, so only unlink paths no surviving row references.
|
|
174
|
+
let unlinkFailures = 0;
|
|
175
|
+
let firstFailure = "";
|
|
164
176
|
for (const path of new Set(expired.map((e) => e.filePath))) {
|
|
165
177
|
if (repo.countByFilePath(path) > 0) continue;
|
|
166
178
|
try {
|
|
167
179
|
if (existsSync(path)) unlinkSync(path);
|
|
168
|
-
} catch {
|
|
169
|
-
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// Skip it, but count: files left behind are disk that the purge
|
|
182
|
+
// thinks it reclaimed.
|
|
183
|
+
if (unlinkFailures++ === 0) {
|
|
184
|
+
firstFailure = `${path}: ${err instanceof Error ? err.message : String(err)}`;
|
|
185
|
+
}
|
|
170
186
|
}
|
|
171
187
|
}
|
|
172
188
|
if (removed > 0) {
|
|
173
189
|
log("media", `Purged ${removed} expired media entries`);
|
|
174
190
|
}
|
|
191
|
+
if (unlinkFailures > 0) {
|
|
192
|
+
logWarn(
|
|
193
|
+
"media",
|
|
194
|
+
`Media purge left ${unlinkFailures} expired file(s) on disk; first=${firstFailure}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
175
197
|
} catch (err) {
|
|
176
198
|
logError("media", "Media index purge failed", err);
|
|
177
199
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* queried independently in SQL.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { logWarn } from "../../util/log.js";
|
|
14
15
|
import { getDatabase, inTransaction } from "../db.js";
|
|
15
16
|
import { chatSettingsSql } from "../sql/statements.generated.js";
|
|
16
17
|
import type { ReasoningEffortLevel } from "../../types/effort.js";
|
|
@@ -102,8 +103,13 @@ export function all(): Array<{ chatId: string; settings: ChatSettings }> {
|
|
|
102
103
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
103
104
|
result.push({ chatId: row.chat_id, settings: parsed });
|
|
104
105
|
}
|
|
105
|
-
} catch {
|
|
106
|
-
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Skip the unparseable row; the chat silently falls back to
|
|
108
|
+
// defaults otherwise.
|
|
109
|
+
logWarn(
|
|
110
|
+
"settings",
|
|
111
|
+
`Skipped corrupt chat settings chat=${row.chat_id}: ${err instanceof Error ? err.message : String(err)}`,
|
|
112
|
+
);
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
return result;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* column default can hold.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { logWarn } from "../../util/log.js";
|
|
15
16
|
import { getDatabase, inTransaction } from "../db.js";
|
|
16
17
|
import { sessionsSql } from "../sql/statements.generated.js";
|
|
17
18
|
import {
|
|
@@ -46,13 +47,19 @@ type Row = {
|
|
|
46
47
|
metrics: string | null;
|
|
47
48
|
};
|
|
48
49
|
|
|
49
|
-
function parseMetrics(raw: string | null): SessionMetrics {
|
|
50
|
+
function parseMetrics(raw: string | null, chatId: string): SessionMetrics {
|
|
50
51
|
if (!raw) return emptyMetrics();
|
|
51
52
|
try {
|
|
52
53
|
// Normalise at the load boundary: backfills partial blobs and restores
|
|
53
54
|
// the Infinity minMs sentinel that JSON round-trips as null.
|
|
54
55
|
return normaliseMetrics(JSON.parse(raw));
|
|
55
|
-
} catch {
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Degrade to empty — and say so, since the next upsert overwrites
|
|
58
|
+
// the corrupt blob and the chat's lifetime counters restart at zero.
|
|
59
|
+
logWarn(
|
|
60
|
+
"sessions",
|
|
61
|
+
`Corrupt session metrics chat=${chatId} bytes=${raw.length} — resetting: ${err instanceof Error ? err.message : String(err)}`,
|
|
62
|
+
);
|
|
56
63
|
return emptyMetrics();
|
|
57
64
|
}
|
|
58
65
|
}
|
|
@@ -78,7 +85,7 @@ function rowToSession(row: Row): SessionState {
|
|
|
78
85
|
// NULL = no timed turn yet — the domain sentinel is Infinity.
|
|
79
86
|
fastestResponseMs: row.fastest_response_ms ?? Infinity,
|
|
80
87
|
},
|
|
81
|
-
metrics: parseMetrics(row.metrics),
|
|
88
|
+
metrics: parseMetrics(row.metrics, row.chat_id),
|
|
82
89
|
lastBotMessageId: row.last_bot_message_id ?? undefined,
|
|
83
90
|
sessionName: row.session_name ?? undefined,
|
|
84
91
|
lastModel: row.last_model ?? undefined,
|
package/src/storage/scripts.ts
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
|
-
import { mkdirSync, rmSync
|
|
20
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
21
21
|
import { dirname, resolve } from "node:path";
|
|
22
|
+
import writeFileAtomic from "write-file-atomic";
|
|
23
|
+
import { logWarn } from "../util/log.js";
|
|
22
24
|
import { dirs } from "../util/paths.js";
|
|
23
25
|
import * as repo from "./repositories/scripts-repo.js";
|
|
24
26
|
|
|
@@ -93,8 +95,9 @@ function writeScriptFile(
|
|
|
93
95
|
): string {
|
|
94
96
|
const path = scriptFilePath(name, lang);
|
|
95
97
|
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
-
// 0o700: only the user running Talon should be able to read/exec scripts
|
|
97
|
-
|
|
98
|
+
// 0o700: only the user running Talon should be able to read/exec scripts.
|
|
99
|
+
// Atomic: a failed replace (ENOSPC) must keep the previous body.
|
|
100
|
+
writeFileAtomic.sync(path, body, { encoding: "utf-8", mode: 0o700 });
|
|
98
101
|
return path;
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -117,11 +120,7 @@ export function saveScript(input: {
|
|
|
117
120
|
}): Script {
|
|
118
121
|
const existing = repo.getByName(input.name);
|
|
119
122
|
if (existing && existing.language !== input.language) {
|
|
120
|
-
|
|
121
|
-
rmSync(existing.scriptPath, { force: true });
|
|
122
|
-
} catch {
|
|
123
|
-
/* best effort */
|
|
124
|
-
}
|
|
123
|
+
removeScriptFile(existing.scriptPath, input.name);
|
|
125
124
|
}
|
|
126
125
|
const scriptPath = writeScriptFile(input.name, input.language, input.script);
|
|
127
126
|
const now = Date.now();
|
|
@@ -153,15 +152,27 @@ export function recordScriptUse(name: string): void {
|
|
|
153
152
|
}
|
|
154
153
|
|
|
155
154
|
/** Delete a script and best-effort clean up its on-disk file. */
|
|
155
|
+
/**
|
|
156
|
+
* Best-effort unlink. `force` already absorbs a missing file, so any
|
|
157
|
+
* throw here is a real fault (permissions, a directory in the way) that
|
|
158
|
+
* leaves an orphan script on disk.
|
|
159
|
+
*/
|
|
160
|
+
function removeScriptFile(path: string, name: string): void {
|
|
161
|
+
try {
|
|
162
|
+
rmSync(path, { force: true });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
logWarn(
|
|
165
|
+
"scripts",
|
|
166
|
+
`Could not remove script file name=${name} path=${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
export function deleteScript(name: string): boolean {
|
|
157
172
|
const script = repo.getByName(name);
|
|
158
173
|
if (!script) return false;
|
|
159
174
|
repo.removeByName(name);
|
|
160
|
-
|
|
161
|
-
rmSync(script.scriptPath, { force: true });
|
|
162
|
-
} catch {
|
|
163
|
-
/* best effort */
|
|
164
|
-
}
|
|
175
|
+
removeScriptFile(script.scriptPath, name);
|
|
165
176
|
return true;
|
|
166
177
|
}
|
|
167
178
|
|
package/src/storage/sessions.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { log, logError } from "../util/log.js";
|
|
|
22
22
|
import { recordError } from "../util/watchdog.js";
|
|
23
23
|
import { files } from "../util/paths.js";
|
|
24
24
|
import { importLegacyJson } from "./legacy-import.js";
|
|
25
|
+
import { dbErrorFields } from "./db.js";
|
|
25
26
|
import * as repo from "./repositories/sessions-repo.js";
|
|
26
27
|
|
|
27
28
|
export type {
|
|
@@ -204,7 +205,11 @@ function persist(chatId: string, session: SessionState): void {
|
|
|
204
205
|
try {
|
|
205
206
|
repo.upsert(chatId, session);
|
|
206
207
|
} catch (err) {
|
|
207
|
-
logError(
|
|
208
|
+
logError(
|
|
209
|
+
"sessions",
|
|
210
|
+
`Failed to persist sessions chat=${chatId}${dbErrorFields(err)}`,
|
|
211
|
+
err,
|
|
212
|
+
);
|
|
208
213
|
recordError(
|
|
209
214
|
`Session save failed: ${err instanceof Error ? err.message : err}`,
|
|
210
215
|
);
|
|
@@ -518,7 +523,11 @@ function removeSessionRow(chatId: string): void {
|
|
|
518
523
|
try {
|
|
519
524
|
repo.remove(chatId);
|
|
520
525
|
} catch (err) {
|
|
521
|
-
logError(
|
|
526
|
+
logError(
|
|
527
|
+
"sessions",
|
|
528
|
+
`Failed to delete session row chat=${chatId}${dbErrorFields(err)}`,
|
|
529
|
+
err,
|
|
530
|
+
);
|
|
522
531
|
recordError(
|
|
523
532
|
`Session delete failed: ${err instanceof Error ? err.message : err}`,
|
|
524
533
|
);
|
package/src/storage/skills.ts
CHANGED
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
writeFileSync,
|
|
27
27
|
} from "node:fs";
|
|
28
28
|
import { basename, resolve } from "node:path";
|
|
29
|
+
import writeFileAtomic from "write-file-atomic";
|
|
29
30
|
import { parseDocument, stringify } from "yaml";
|
|
31
|
+
import { logWarn } from "../util/log.js";
|
|
30
32
|
import { dirs } from "../util/paths.js";
|
|
31
33
|
|
|
32
34
|
export type Skill = {
|
|
@@ -123,6 +125,13 @@ function serializeSkill(input: {
|
|
|
123
125
|
return ["---", yaml, "---", "", input.body.trimEnd(), ""].join("\n");
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Frontmatter errors already reported, keyed by path + message: skills
|
|
130
|
+
* are re-read for every prompt, so a broken SKILL.md warns once, not
|
|
131
|
+
* once per turn.
|
|
132
|
+
*/
|
|
133
|
+
const reportedFrontmatterErrors = new Set<string>();
|
|
134
|
+
|
|
126
135
|
function parseSkill(
|
|
127
136
|
path: string,
|
|
128
137
|
raw: string,
|
|
@@ -161,7 +170,15 @@ function parseSkill(
|
|
|
161
170
|
if (value && typeof value === "object") {
|
|
162
171
|
parsed = value as Record<string, unknown>;
|
|
163
172
|
}
|
|
164
|
-
} catch {
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// The skill still loads (name falls back to its directory, no
|
|
175
|
+
// description) — which is exactly why the reason must be visible.
|
|
176
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
177
|
+
const key = `${path}\0${msg}`;
|
|
178
|
+
if (!reportedFrontmatterErrors.has(key)) {
|
|
179
|
+
reportedFrontmatterErrors.add(key);
|
|
180
|
+
logWarn("skills", `Unparseable frontmatter in ${path}: ${msg}`);
|
|
181
|
+
}
|
|
165
182
|
parsed = {};
|
|
166
183
|
}
|
|
167
184
|
|
|
@@ -216,7 +233,9 @@ export function saveSkill(input: {
|
|
|
216
233
|
mkdirSync(dir, { recursive: true });
|
|
217
234
|
const path = skillFilePath(input.name);
|
|
218
235
|
const content = serializeSkill(input);
|
|
219
|
-
|
|
236
|
+
// Atomic: a failed replace (ENOSPC) must keep the previous SKILL.md,
|
|
237
|
+
// not leave a truncated one.
|
|
238
|
+
writeFileAtomic.sync(path, content, { encoding: "utf-8", mode: 0o600 });
|
|
220
239
|
return readSkill(input.name)!;
|
|
221
240
|
}
|
|
222
241
|
|