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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,7 +57,6 @@ describe('migrations', () => {
|
||||
// Running again should not throw
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Agent Groups ──
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user