Two independent features landed together in this checkout: - Isolated per-job git worktrees for the flake repo (only) so Builder jobs stop serializing on one shared checkout, plus a second dedicated builder instance/inbox for the board's "leicht" column (cheap OpenRouter model) alongside the existing "schwer" one. Fixes bundled in: correct -C/prompt path once a worktree is in play, git+ssh in the deploy runner's PATH, ProtectHome write access for its own sync-to-dev step, and per-(role,sender,board_slug) scoping for the Builder's carried-forward context so concurrent jobs sharing the board's fixed owner sender no longer bleed context into each other. - A new Operator role and deterministic scheduler between planning and Builder execution (docs/intent/board-intent-operator-orchestration.md): moving a planned intent into the Operator column analyzes it read-only and automatically dispatches either a direct Builder run or a validated split into dependency-aware, wave-scheduled child intents, capped at two concurrent Operator-originated Builder jobs. Synthesis only fires once every child has passed review. Also fixes a live incident hit while deploying this: board-agent-worker and board-agent-completion-watcher gained ReadWritePaths entries for the new leicht-lane inbox/processing dirs without a matching tmpfiles rule, so the directories not existing yet crashed the service (start-limit-hit) the first time a job needed them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GurGgq83aNurjWbaNQ71Jr
188 lines
6.2 KiB
Python
188 lines
6.2 KiB
Python
"""SQLite persistence for the XMPP Codex roles.
|
|
|
|
The job queues and approval workflows remain file based because systemd path
|
|
units consume them. This first migration phase only stores conversational
|
|
state.
|
|
"""
|
|
import contextlib
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
role TEXT NOT NULL,
|
|
sender TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (role, sender)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS introductions (
|
|
role TEXT NOT NULL,
|
|
sender TEXT NOT NULL,
|
|
introduced_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (role, sender)
|
|
);
|
|
|
|
-- Carries the previous reply forward as plain-text context for a
|
|
-- sender's next job, for roles that run atomic per-job Codex sessions
|
|
-- instead of a resumed one (see docs/intent/builder-iterationslimit.md).
|
|
-- Deliberately separate from `sessions` above, which stays session-id
|
|
-- based for roles that do resume (the Agent role in worker.py).
|
|
--
|
|
-- `scope` defaults to "" (plain per-sender conversational continuity,
|
|
-- e.g. direct XMPP chat with the Builder) but board-triggered jobs pass
|
|
-- their board_slug instead -- every board job shares one fixed owner
|
|
-- sender identity (BOARD_BUILDER_OWNER_JID), so a bare (role, sender)
|
|
-- key let two genuinely unrelated jobs for different cards (or a card
|
|
-- and an unrelated direct chat) bleed context into each other once two
|
|
-- agent-codex-builder instances could run concurrently for the same
|
|
-- sender (found live 2026-09-09, board-worktree-parallelism-smoke-test-a:
|
|
-- an unrelated Prosody/XMPP conversation's reply got threaded into the
|
|
-- smoke test's prompt). Scoping by board_slug keeps the intentional
|
|
-- retry-carries-forward-its-own-interim-report behavior
|
|
-- (docs/intent/builder-iterationslimit.md) isolated per card.
|
|
CREATE TABLE IF NOT EXISTS context (
|
|
role TEXT NOT NULL,
|
|
sender TEXT NOT NULL,
|
|
scope TEXT NOT NULL DEFAULT '',
|
|
context_text TEXT NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (role, sender, scope)
|
|
);
|
|
|
|
"""
|
|
|
|
|
|
def _migrate_context_table(connection):
|
|
"""One-time, in-place migration for a `context` table created before
|
|
`scope` existed -- see the schema comment above. Conversational cache,
|
|
not durable data: copying forward under scope='' (today's only
|
|
existing rows) rather than discarding it is a nicety, not a
|
|
correctness requirement."""
|
|
columns = [row[1] for row in connection.execute("PRAGMA table_info(context)")]
|
|
if not columns or "scope" in columns:
|
|
return
|
|
connection.executescript(
|
|
"""
|
|
ALTER TABLE context RENAME TO context_pre_scope;
|
|
CREATE TABLE context (
|
|
role TEXT NOT NULL,
|
|
sender TEXT NOT NULL,
|
|
scope TEXT NOT NULL DEFAULT '',
|
|
context_text TEXT NOT NULL,
|
|
updated_at REAL NOT NULL,
|
|
PRIMARY KEY (role, sender, scope)
|
|
);
|
|
INSERT INTO context (role, sender, scope, context_text, updated_at)
|
|
SELECT role, sender, '', context_text, updated_at FROM context_pre_scope;
|
|
DROP TABLE context_pre_scope;
|
|
"""
|
|
)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def connect():
|
|
"""Open the configured database and initialize its small local schema."""
|
|
path = os.environ["AGENT_DB_PATH"]
|
|
connection = sqlite3.connect(path, timeout=30)
|
|
try:
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
connection.executescript(SCHEMA)
|
|
_migrate_context_table(connection)
|
|
yield connection
|
|
connection.commit()
|
|
except BaseException:
|
|
connection.rollback()
|
|
raise
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def load_session(role, sender):
|
|
with connect() as connection:
|
|
return connection.execute(
|
|
"SELECT session_id, updated_at FROM sessions WHERE role = ? AND sender = ?",
|
|
(role, sender),
|
|
).fetchone()
|
|
|
|
|
|
def save_session(role, sender, session_id, updated_at=None):
|
|
if updated_at is None:
|
|
updated_at = time.time()
|
|
with connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO sessions (role, sender, session_id, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(role, sender) DO UPDATE SET
|
|
session_id = excluded.session_id,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(role, sender, session_id, updated_at),
|
|
)
|
|
|
|
|
|
def reset_session(role, sender):
|
|
with connect() as connection:
|
|
connection.execute(
|
|
"DELETE FROM sessions WHERE role = ? AND sender = ?",
|
|
(role, sender),
|
|
)
|
|
|
|
|
|
def load_context(role, sender, scope=""):
|
|
with connect() as connection:
|
|
return connection.execute(
|
|
"SELECT context_text, updated_at FROM context WHERE role = ? AND sender = ? AND scope = ?",
|
|
(role, sender, scope),
|
|
).fetchone()
|
|
|
|
|
|
def save_context(role, sender, context_text, scope="", updated_at=None):
|
|
if updated_at is None:
|
|
updated_at = time.time()
|
|
with connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO context (role, sender, scope, context_text, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(role, sender, scope) DO UPDATE SET
|
|
context_text = excluded.context_text,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(role, sender, scope, context_text, updated_at),
|
|
)
|
|
|
|
|
|
def reset_context(role, sender, scope=""):
|
|
with connect() as connection:
|
|
connection.execute(
|
|
"DELETE FROM context WHERE role = ? AND sender = ? AND scope = ?",
|
|
(role, sender, scope),
|
|
)
|
|
|
|
|
|
def is_introduced(role, sender):
|
|
with connect() as connection:
|
|
return connection.execute(
|
|
"SELECT 1 FROM introductions WHERE role = ? AND sender = ?",
|
|
(role, sender),
|
|
).fetchone() is not None
|
|
|
|
|
|
def mark_introduced(role, sender, updated_at=None):
|
|
if updated_at is None:
|
|
updated_at = time.time()
|
|
with connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO introductions (role, sender, introduced_at, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(role, sender) DO UPDATE SET updated_at = excluded.updated_at
|
|
""",
|
|
(role, sender, updated_at, updated_at),
|
|
)
|