Bisher teilte sich eine Budgetablehnung den Ausgang NichtGelaufen mit dem Herunterfahren. Einmalige Aufgaben wurden den Rest des Tages alle 30 Minuten erneut abgelehnt (jedes Mal ein Beleg) und liefen nach Mitternacht still zu Lasten des neuen Tages. Wiederkehrende verfielen still. - TaskLauf.KeinBudget als eigener Ausgang; der Dispatcher bildet BudgetExceeded darauf ab. - Vier Felder an der Aufgabe: BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending (Spalten werden fuer bestehende Datenbanken nachgeruestet). - Einmalige Aufgaben werden mitgenommen: Der Termin bleibt erhalten, faellig ist die Aufgabe wieder ab der naechsten lokalen Mitternacht, nicht vorher. - Wiederkehrende Aufgaben folgen der Regel des Agenten (budget.whenExhausted): expire vermerkt den Ausfall und laesst den Termin verfallen; catchUpOnce fasst alle verpassten Termine zu einem Nachholauf am naechsten Tag zusammen. Ein regulaer faelliger Termin deckt das Nachholen mit ab, es bleibt bei einem Lauf. - Der neue Tag hat Vorfahrt: Mitgenommenes und Nachholaeufe kommen nach allem regulaer Faelligen, unabhaengig von der Prioritaet. - Beim ersten Takt eines neuen Tages eine Zusammenfassung je Agent im Log. - Sichtbar: Die Aufgabenkarte der WebUI zeigt Ausfaelle, Mitnahme und Nachholen; die Regel ist im Desktop je Agent einstellbar. Nebenbei sind "Next:"/"Last:" auf der Karte ins Sprachpaket gewandert. Stufen 2 bis 4 (Reservierung, Spielraum statt pauschalem Anteil, Vorbeugen) folgen. Tests: BudgetMissTests (9 neu). 1.238 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
383 lines
16 KiB
C#
383 lines
16 KiB
C#
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ClawdDotNet.Core.Storage;
|
|
|
|
/// <summary>
|
|
/// Zentrale Stelle für den Zugang zur Instanz-Datenbank.
|
|
///
|
|
/// Die frühere Fassung öffnete pro Aufruf eine Verbindung ohne weitere Einstellungen —
|
|
/// ohne WAL und ohne Wartezeit bei Sperren. Sobald mehrere Agenten gleichzeitig
|
|
/// schreiben, quittiert SQLite das mit "database is locked". Das sah nach einer Grenze
|
|
/// von SQLite aus, war aber nur fehlende Konfiguration.
|
|
///
|
|
/// - WAL erlaubt beliebig viele Leser parallel zu einem Schreiber.
|
|
/// - busy_timeout lässt einen Schreiber kurz warten, statt sofort zu scheitern.
|
|
/// - Connection-Pooling vermeidet den Aufbau je Aufruf.
|
|
/// </summary>
|
|
public sealed class SqliteStorage
|
|
{
|
|
private readonly string _connectionString;
|
|
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
|
|
|
public string DatabasePath { get; }
|
|
|
|
public SqliteStorage(string databasePath)
|
|
{
|
|
DatabasePath = databasePath;
|
|
|
|
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
|
|
if (!string.IsNullOrEmpty(directory))
|
|
Directory.CreateDirectory(directory);
|
|
|
|
_connectionString = new SqliteConnectionStringBuilder
|
|
{
|
|
DataSource = databasePath,
|
|
Mode = SqliteOpenMode.ReadWriteCreate,
|
|
Cache = SqliteCacheMode.Shared,
|
|
Pooling = true,
|
|
DefaultTimeout = 30
|
|
}.ToString();
|
|
|
|
Initialize();
|
|
|
|
// #575: SQLite legt die Datenbank mit der Standardmaske an (auf der Test-VM 0644),
|
|
// und WAL- und Shared-Memory-Datei erben deren Rechte. Darin stehen Chatverläufe,
|
|
// Gedächtnis und die Argumente jedes Werkzeugaufrufs. Auch für bestehende Dateien,
|
|
// die noch aus der Zeit vor dieser Zeile stammen.
|
|
foreach (var datei in new[] { databasePath, databasePath + "-wal", databasePath + "-shm" })
|
|
{
|
|
if (File.Exists(datei))
|
|
AppPaths.RestrictToOwner(datei);
|
|
}
|
|
}
|
|
|
|
public SqliteConnection OpenConnection()
|
|
{
|
|
var connection = new SqliteConnection(_connectionString);
|
|
connection.Open();
|
|
ApplyPragmas(connection);
|
|
return connection;
|
|
}
|
|
|
|
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken ct)
|
|
{
|
|
var connection = new SqliteConnection(_connectionString);
|
|
await connection.OpenAsync(ct);
|
|
ApplyPragmas(connection);
|
|
return connection;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serialisiert Schreibvorgänge im Prozess. SQLite lässt ohnehin nur einen Schreiber
|
|
/// zu — die Warteschlange hier ist verlässlicher als das Zurückweisen durch die
|
|
/// Datenbank und macht Fehlerbilder reproduzierbar.
|
|
/// </summary>
|
|
public async Task<T> WriteAsync<T>(Func<SqliteConnection, Task<T>> action, CancellationToken ct)
|
|
{
|
|
await _writeGate.WaitAsync(ct);
|
|
try
|
|
{
|
|
await using var connection = await OpenConnectionAsync(ct);
|
|
return await action(connection);
|
|
}
|
|
finally
|
|
{
|
|
_writeGate.Release();
|
|
}
|
|
}
|
|
|
|
public async Task WriteAsync(Func<SqliteConnection, Task> action, CancellationToken ct)
|
|
=> await WriteAsync<object?>(async conn => { await action(conn); return null; }, ct);
|
|
|
|
private static void ApplyPragmas(SqliteConnection connection)
|
|
{
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = """
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA busy_timeout = 5000;
|
|
PRAGMA synchronous = NORMAL;
|
|
PRAGMA foreign_keys = ON;
|
|
""";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
private void Initialize()
|
|
{
|
|
using var connection = OpenConnection();
|
|
using var cmd = connection.CreateCommand();
|
|
|
|
cmd.CommandText = """
|
|
CREATE TABLE IF NOT EXISTS ToolState (
|
|
Key TEXT PRIMARY KEY,
|
|
Value TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS Memories (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
Scope TEXT NOT NULL,
|
|
OwnerId TEXT NOT NULL,
|
|
Category TEXT NOT NULL,
|
|
Subject TEXT NOT NULL,
|
|
MemoryKey TEXT NULL,
|
|
Content TEXT NOT NULL,
|
|
Tags TEXT NOT NULL DEFAULT '',
|
|
Importance INTEGER NOT NULL DEFAULT 3,
|
|
CreatedAt TEXT NOT NULL,
|
|
UpdatedAt TEXT NOT NULL,
|
|
CreatedBy TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Memories_Lookup
|
|
ON Memories (Scope, OwnerId, Subject);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Memories_Recent
|
|
ON Memories (Scope, OwnerId, UpdatedAt DESC);
|
|
|
|
-- Ein Schluessel identifiziert eine Erinnerung eindeutig; erneutes Merken
|
|
-- unter demselben Schluessel aktualisiert sie, statt eine zweite anzulegen.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS UX_Memories_Key
|
|
ON Memories (Scope, OwnerId, MemoryKey)
|
|
WHERE MemoryKey IS NOT NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS RunUsage (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL,
|
|
PromptTokens INTEGER NOT NULL,
|
|
CompletionTokens INTEGER NOT NULL,
|
|
CachedTokens INTEGER NOT NULL DEFAULT 0,
|
|
CostUsd TEXT NOT NULL DEFAULT '0',
|
|
CostIsKnown INTEGER NOT NULL DEFAULT 0,
|
|
Status TEXT NOT NULL DEFAULT '',
|
|
StepCount INTEGER NOT NULL DEFAULT 0,
|
|
DurationMs INTEGER NOT NULL DEFAULT 0,
|
|
OccurredAt TEXT NOT NULL,
|
|
-- Ortsdatum, damit ein Tagesbudget der Wahrnehmung des Benutzers folgt
|
|
-- und die Abfrage ohne Zeitzonenrechnerei auskommt.
|
|
UsageDate TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunUsage_Day
|
|
ON RunUsage (UsageDate, AgentId);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent
|
|
ON RunUsage (OccurredAt DESC);
|
|
|
|
-- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in
|
|
-- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar.
|
|
CREATE TABLE IF NOT EXISTS Tasks (
|
|
Id TEXT PRIMARY KEY,
|
|
Title TEXT NOT NULL DEFAULT '',
|
|
Status TEXT NOT NULL DEFAULT 'todo',
|
|
Type TEXT NOT NULL DEFAULT 'work',
|
|
Priority INTEGER NOT NULL DEFAULT 3,
|
|
Assignee TEXT NOT NULL DEFAULT '@human',
|
|
WhenKind TEXT NULL,
|
|
WhenValue TEXT NULL,
|
|
WhenTz TEXT NULL,
|
|
RequireApproval INTEGER NOT NULL DEFAULT 0,
|
|
Acceptance TEXT NOT NULL DEFAULT '',
|
|
-- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche.
|
|
BlockedBy TEXT NOT NULL DEFAULT '',
|
|
OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0,
|
|
Body TEXT NOT NULL DEFAULT '',
|
|
FileName TEXT NOT NULL DEFAULT '',
|
|
-- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird.
|
|
ToolName TEXT NOT NULL DEFAULT '',
|
|
JobTypeId TEXT NOT NULL DEFAULT '',
|
|
-- Ausführungszustand: nur das Board schreibt hier.
|
|
LastOccurrence TEXT NULL,
|
|
ClaimToken TEXT NULL,
|
|
ClaimedAt TEXT NULL,
|
|
CreatedAt TEXT NOT NULL,
|
|
UpdatedAt TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Tasks_Status
|
|
ON Tasks (Status);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee
|
|
ON Tasks (Assignee);
|
|
|
|
-- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft
|
|
-- stempelt die Engine, Korrekturen sind neue Zeilen.
|
|
CREATE TABLE IF NOT EXISTS AuditLog (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RunId TEXT NOT NULL,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL DEFAULT '',
|
|
Source TEXT NOT NULL DEFAULT 'unknown',
|
|
Tool TEXT NOT NULL,
|
|
Arguments TEXT NOT NULL DEFAULT '',
|
|
Status TEXT NOT NULL,
|
|
Summary TEXT NOT NULL DEFAULT '',
|
|
DurationMs INTEGER NOT NULL DEFAULT 0,
|
|
OccurredAt TEXT NOT NULL,
|
|
-- Paart den "angefangen"-Eintrag mit seinem Abschluss (#554).
|
|
-- Wird fuer bestehende Datenbanken in Nachruesten() ergaenzt.
|
|
CallId TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
|
|
ON AuditLog (RunId, Id);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent
|
|
ON AuditLog (Id DESC);
|
|
|
|
-- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task.
|
|
CREATE TABLE IF NOT EXISTS RunReceipts (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RunId TEXT NOT NULL,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL DEFAULT '',
|
|
Source TEXT NOT NULL DEFAULT 'unknown',
|
|
TaskId TEXT NULL,
|
|
Status TEXT NOT NULL DEFAULT '',
|
|
StepCount INTEGER NOT NULL DEFAULT 0,
|
|
PromptTokens INTEGER NOT NULL DEFAULT 0,
|
|
CompletionTokens INTEGER NOT NULL DEFAULT 0,
|
|
CachedTokens INTEGER NOT NULL DEFAULT 0,
|
|
CostUsd TEXT NOT NULL DEFAULT '0',
|
|
CostIsKnown INTEGER NOT NULL DEFAULT 0,
|
|
DurationMs INTEGER NOT NULL DEFAULT 0,
|
|
ResultRef TEXT NOT NULL DEFAULT '',
|
|
OccurredAt TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task
|
|
ON RunReceipts (TaskId);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run
|
|
ON RunReceipts (RunId);
|
|
|
|
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
|
|
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
|
|
CREATE TABLE IF NOT EXISTS StagedCalls (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RunId TEXT NOT NULL,
|
|
AgentId TEXT NOT NULL,
|
|
InstanceId TEXT NOT NULL DEFAULT '',
|
|
Tool TEXT NOT NULL,
|
|
Action TEXT NULL,
|
|
ArgumentsJson TEXT NOT NULL DEFAULT '',
|
|
Proposal TEXT NOT NULL DEFAULT '',
|
|
Status TEXT NOT NULL DEFAULT 'Pending',
|
|
CreatedAt TEXT NOT NULL,
|
|
DecidedAt TEXT NULL,
|
|
DecidedBy TEXT NULL,
|
|
ResultRef TEXT NULL,
|
|
RejectionReason TEXT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
|
|
ON StagedCalls (Status, Id);
|
|
|
|
-- #641: "Gleichartiges kuenftig erlauben" - je Agent, Werkzeug und Operation.
|
|
-- Ein Widerruf setzt WiderrufenAm; geloescht wird nichts.
|
|
CREATE TABLE IF NOT EXISTS Dauerfreigaben (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
AgentId TEXT NOT NULL,
|
|
Tool TEXT NOT NULL,
|
|
Operation TEXT NOT NULL,
|
|
ErteiltVon TEXT NOT NULL,
|
|
ErteiltAm TEXT NOT NULL,
|
|
AnlassId INTEGER NULL,
|
|
WiderrufenAm TEXT NULL,
|
|
WiderrufenVon TEXT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent
|
|
ON Dauerfreigaben (AgentId, Tool, Operation);
|
|
|
|
-- #660: Prompt history - prompt and response of every run, kept as long as the
|
|
-- retention of the agent allows. System prompts are stored once per text.
|
|
CREATE TABLE IF NOT EXISTS RunTranscripts (
|
|
RunId TEXT PRIMARY KEY,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL DEFAULT '',
|
|
Source TEXT NOT NULL DEFAULT '',
|
|
TaskId TEXT NULL,
|
|
StartedAt TEXT NOT NULL,
|
|
CompletedAt TEXT NULL,
|
|
Status TEXT NOT NULL DEFAULT '',
|
|
SystemPromptHash TEXT NULL,
|
|
Prompt TEXT NOT NULL DEFAULT '',
|
|
Response TEXT NOT NULL DEFAULT '',
|
|
EntriesJson TEXT NOT NULL DEFAULT '[]',
|
|
SizeChars INTEGER NOT NULL DEFAULT 0,
|
|
Truncated INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Agent
|
|
ON RunTranscripts (AgentId, StartedAt);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Started
|
|
ON RunTranscripts (StartedAt DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS SystemPrompts (
|
|
Hash TEXT PRIMARY KEY,
|
|
Text TEXT NOT NULL,
|
|
FirstSeenAt TEXT NOT NULL
|
|
);
|
|
""";
|
|
|
|
cmd.ExecuteNonQuery();
|
|
|
|
Nachruesten(connection);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spalten, die nach der ersten Fassung einer Tabelle dazugekommen sind.
|
|
///
|
|
/// <para><c>CREATE TABLE IF NOT EXISTS</c> allein genügt dafür nicht: Bei einer
|
|
/// bestehenden Datenbank tut die Anweisung gar nichts, die neue Spalte fehlt, und
|
|
/// erst das nächste <c>INSERT</c>, das sie benennt, scheitert — im Betrieb, beim
|
|
/// Anwender, nicht beim Entwickler mit der frischen Datei.</para>
|
|
/// </summary>
|
|
private static void Nachruesten(SqliteConnection connection)
|
|
{
|
|
// #554: Paart den "angefangen"-Eintrag mit seinem Abschluss.
|
|
SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
|
|
|
|
// #716: What happened when the budget refused a run.
|
|
SpalteErgaenzen(connection, "Tasks", "BudgetMissCount", "INTEGER NOT NULL DEFAULT 0");
|
|
SpalteErgaenzen(connection, "Tasks", "LastBudgetMissAt", "TEXT NULL");
|
|
SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL");
|
|
SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0");
|
|
|
|
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
|
using var index = connection.CreateCommand();
|
|
index.CommandText = """
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Call
|
|
ON AuditLog (CallId, Id);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Agent
|
|
ON AuditLog (AgentId, Id DESC);
|
|
""";
|
|
index.ExecuteNonQuery();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ergänzt eine Spalte, falls sie fehlt. Ist sie da, geschieht nichts.
|
|
///
|
|
/// <para>SQLite kennt kein <c>ADD COLUMN IF NOT EXISTS</c>, deshalb wird erst
|
|
/// nachgesehen. Die Vorgabe ist Pflicht: Eine <c>NOT NULL</c>-Spalte ohne
|
|
/// Vorgabewert lässt sich einer gefüllten Tabelle nicht hinzufügen.</para>
|
|
/// </summary>
|
|
private static void SpalteErgaenzen(SqliteConnection connection, string tabelle, string spalte, string definition)
|
|
{
|
|
using (var pruefen = connection.CreateCommand())
|
|
{
|
|
pruefen.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{tabelle}') WHERE name = @spalte";
|
|
pruefen.Parameters.AddWithValue("@spalte", spalte);
|
|
|
|
if (Convert.ToInt32(pruefen.ExecuteScalar()) > 0)
|
|
return;
|
|
}
|
|
|
|
using var ergaenzen = connection.CreateCommand();
|
|
ergaenzen.CommandText = $"ALTER TABLE {tabelle} ADD COLUMN {spalte} {definition}";
|
|
ergaenzen.ExecuteNonQuery();
|
|
}
|
|
}
|