docs(v2): cross-mount invariants + diagrams; inline a2a routing

- session-manager.ts: shrink the cross-mount invariant header from 31
  lines to 12, keeping each invariant's cause and consequence inline.
- agent-runner/db/connection.ts: parallel cross-mount comment for the
  container-side reader (inbound.db must be journal_mode=DELETE).
- agent-runner/db/messages-out.ts: document that even/odd seq parity
  is load-bearing — seq is the agent-facing message ID returned by
  send_message and consumed by edit_message / add_reaction, looked
  up across both tables.
- v2-checklist.md: record the cross-mount invariants and seq parity
  under Core Architecture so future "simplifications" don't regress
  them.
- scripts/sanity-live-poll.ts: empirical validation harness for the
  three cross-mount invariants — flips each one and observes silent
  message loss / corruption.
- delivery.ts: inline routeAgentMessage at its single callsite (-17
  net lines). The wrapper added more boilerplate than it factored.
- docs/v2-architecture-diagram.{md,html}: rendered Mermaid diagrams
  of the v2 system, message flow, named destinations, entity model,
  and the two-DB split.
- channels/adapter.ts, chat-sdk-bridge.ts, credentials.ts,
  db/sessions.ts, db/db-v2.test.ts: prettier format pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-12 00:21:12 +03:00
parent c9fa5cdbed
commit 9dda75bb21
13 changed files with 788 additions and 86 deletions

View File

@@ -29,7 +29,9 @@ export interface ChannelSetup {
onAction(questionId: string, selectedOption: string, userId: string): void;
/** Credential collection hooks — used by chat-sdk-bridge to route the modal flow. */
getCredentialForModal?(credentialId: string): { name: string; description: string | null; hostPattern: string } | null;
getCredentialForModal?(
credentialId: string,
): { name: string; description: string | null; hostPattern: string } | null;
onCredentialReject?(credentialId: string): void;
onCredentialSubmit?(credentialId: string, value: string): void;
onCredentialChannelUnsupported?(credentialId: string): void;

View File

@@ -188,10 +188,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
}
try {
const modalChildren = [
CardText(
pending.description ??
`Enter the value for ${pending.name} (host: ${pending.hostPattern}).`,
),
CardText(pending.description ?? `Enter the value for ${pending.name} (host: ${pending.hostPattern}).`),
TextInput({
id: 'value',
label: pending.name,

View File

@@ -34,10 +34,7 @@ export function setCredentialDeliveryAdapter(adapter: ChannelDeliveryAdapter): v
}
/** Handle a `request_credential` system action from a container. */
export async function handleCredentialRequest(
content: Record<string, unknown>,
session: Session,
): Promise<void> {
export async function handleCredentialRequest(content: Record<string, unknown>, session: Session): Promise<void> {
if (!adapterRef) {
notifyAgentCredentialResult(session, content.credentialId as string, 'failed', 'delivery adapter not ready');
return;
@@ -53,12 +50,7 @@ export async function handleCredentialRequest(
const description = (content.description as string) || null;
if (!credentialId || !name || !hostPattern) {
notifyAgentCredentialResult(
session,
credentialId,
'failed',
'name and hostPattern are required',
);
notifyAgentCredentialResult(session, credentialId, 'failed', 'name and hostPattern are required');
return;
}
@@ -299,11 +291,7 @@ function buildCardText(opts: {
valueFormat: string | null;
description: string | null;
}): string {
const lines = [
`🔑 Credential request: ${opts.name}`,
'',
`Host: \`${opts.hostPattern}\``,
];
const lines = [`🔑 Credential request: ${opts.name}`, '', `Host: \`${opts.hostPattern}\``];
if (opts.headerName) lines.push(`Header: \`${opts.headerName}\``);
if (opts.valueFormat) lines.push(`Format: \`${opts.valueFormat}\``);
if (opts.description) lines.push('', opts.description);

View File

@@ -57,7 +57,6 @@ describe('migrations', () => {
// Running again should not throw
runMigrations(db);
});
});
// ── Agent Groups ──

View File

@@ -93,7 +93,10 @@ export function deletePendingQuestion(questionId: string): void {
// ── Pending Approvals ──
export function createPendingApproval(pa: Partial<PendingApproval> & Pick<PendingApproval, 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at'>): void {
export function createPendingApproval(
pa: Partial<PendingApproval> &
Pick<PendingApproval, 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at'>,
): void {
getDb()
.prepare(
`INSERT INTO pending_approvals

View File

@@ -312,9 +312,44 @@ async function deliverMessage(
return;
}
// Agent-to-agent — route to target session (with permission check)
// Agent-to-agent — route to target session (with permission check).
// Permission is enforced via agent_destinations — the source agent must have
// a row for the target. Content is copied verbatim; the target's formatter
// will look up the source agent in its own local map to display a name.
if (msg.channel_type === 'agent') {
await routeAgentMessage(msg, session);
const targetAgentGroupId = msg.platform_id;
if (!targetAgentGroupId) {
log.warn('Agent message missing target agent group ID', { id: msg.id });
return;
}
if (!hasDestination(session.agent_group_id, 'agent', targetAgentGroupId)) {
log.warn('Unauthorized agent-to-agent message — dropping', {
source: session.agent_group_id,
target: targetAgentGroupId,
});
return;
}
if (!getAgentGroup(targetAgentGroupId)) {
log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId });
return;
}
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
writeSessionMessage(targetAgentGroupId, targetSession.id, {
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: msg.content,
});
log.info('Agent message routed', {
from: session.agent_group_id,
to: targetAgentGroupId,
targetSession: targetSession.id,
});
const fresh = getSession(targetSession.id);
if (fresh) await wakeContainer(fresh);
return;
}
@@ -393,58 +428,6 @@ async function deliverMessage(
return platformMsgId;
}
/**
* Route an agent-to-agent message to the target agent's session.
*
* Permission is enforced via agent_destinations — the source agent must have
* a row for the target. Content is copied verbatim; the target's formatter
* will look up the source agent in its own local map to display a name.
*/
async function routeAgentMessage(
msg: { id: string; platform_id: string | null; content: string },
sourceSession: Session,
): Promise<void> {
const targetAgentGroupId = msg.platform_id;
if (!targetAgentGroupId) {
log.warn('Agent message missing target agent group ID', { id: msg.id });
return;
}
if (!hasDestination(sourceSession.agent_group_id, 'agent', targetAgentGroupId)) {
log.warn('Unauthorized agent-to-agent message — dropping', {
source: sourceSession.agent_group_id,
target: targetAgentGroupId,
});
return;
}
if (!getAgentGroup(targetAgentGroupId)) {
log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId });
return;
}
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
writeSessionMessage(targetAgentGroupId, targetSession.id, {
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: sourceSession.agent_group_id,
channelType: 'agent',
threadId: null,
content: msg.content,
});
log.info('Agent message routed', {
from: sourceSession.agent_group_id,
to: targetAgentGroupId,
targetSession: targetSession.id,
});
const fresh = getSession(targetSession.id);
if (fresh) await wakeContainer(fresh);
}
/** Ensure the delivered table has new columns (migration for existing sessions). */
function migrateDeliveredTable(db: Database.Database): void {
const cols = new Set(

View File

@@ -1,10 +1,14 @@
/**
* Session lifecycle management.
* Creates session folders + DBs, writes messages, manages container status.
* Session lifecycle: folders, DBs, messages, container status.
*
* Two-DB architecture: each session has inbound.db (host-owned) and outbound.db
* (container-owned). This eliminates SQLite write contention across the
* host-container mount boundary — each file has exactly one writer.
* Two-DB split — inbound.db (host writes) + outbound.db (container writes).
* Three cross-mount invariants are load-bearing:
* 1. journal_mode=DELETE — WAL's mmapped -shm doesn't refresh host→guest;
* the container would silently miss every new message.
* 2. Host opens-writes-CLOSES per op — close invalidates the container's
* page cache; a long-lived connection freezes its view at first read.
* 3. One writer per file — DELETE-mode journal-unlink isn't atomic across
* the mount; concurrent writers corrupt the DB.
*/
import Database from 'better-sqlite3';
import fs from 'fs';
@@ -260,7 +264,13 @@ export function writeDestinations(agentGroupId: string, sessionId: string): void
log.debug('Destination map written', { sessionId, count: resolved.length });
}
/** Write a message to a session's inbound DB (messages_in). Host-only. */
/**
* Write a message to a session's inbound DB (messages_in). Host-only.
*
* ⚠ Opens and closes the DB on every call. Do not refactor to reuse a
* long-lived connection — see the "Cross-mount visibility invariants" note
* at the top of this file.
*/
export function writeSessionMessage(
agentGroupId: string,
sessionId: string,
@@ -285,8 +295,13 @@ export function writeSessionMessage(
db.pragma('busy_timeout = 5000');
try {
// Host uses even seq numbers, container uses odd — prevents collisions
// across the two-DB boundary without cross-DB coordination.
// Host uses even seq, container uses odd. This is not just collision
// avoidance between the two DB files — the seq is the agent-facing
// message ID returned by send_message and accepted by edit_message /
// add_reaction, and those tools look up by seq across BOTH tables
// (see container/agent-runner/src/db/messages-out.ts:getMessageIdBySeq).
// So the {messages_in.seq, messages_out.seq} namespace MUST be disjoint,
// or the agent's "edit message #5" could resolve to the wrong row.
const maxSeq = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
const nextSeq = maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2); // next even