@@ -56,9 +56,24 @@ const ACTIVE_TRUE: number = 1;
5656/** Inactive flag value. */
5757const ACTIVE_FALSE : number = 0 ;
5858
59- /** Broadcast recipient marker. */
59+ /** Broadcast recipient marker. Also the reserved agent_name of the sentinel
60+ * identity row that lets the messages.to_agent foreign key target broadcasts
61+ * without orphaning them. The sentinel is created with active=0 so
62+ * listAgents (which filters by active=1) never surfaces it in the UI. */
6063const BROADCAST_RECIPIENT : string = "*" ;
6164
65+ /** Insert the reserved '*' identity row required by the messages.to_agent FK
66+ * for broadcasts. Idempotent — re-runs on every DB open are safe. The row
67+ * is kept active=0 so it never appears in agent listings. */
68+ const seedBroadcastSentinel : ( db : Database . Database ) => void = (
69+ db : Database . Database ,
70+ ) : void => {
71+ const timestamp : number = now ( ) ;
72+ db . prepare (
73+ "INSERT OR IGNORE INTO identity (agent_name, agent_key, active, registered_at, last_active) VALUES (?, ?, 0, ?, ?)" ,
74+ ) . run ( BROADCAST_RECIPIENT , `__broadcast_sentinel_${ generateKey ( ) } ` , timestamp , timestamp ) ;
75+ } ;
76+
6277/** SQLite-specific retryable errors. */
6378const isSqliteRetryable : ( err : string ) => boolean = ( err : string ) : boolean =>
6479 err . includes ( "disk I/O error" ) ||
@@ -136,6 +151,7 @@ const openAndInit: (
136151 try {
137152 db = new Database ( config . dbPath ) ;
138153 db . pragma ( "foreign_keys = ON" ) ;
154+ seedBroadcastSentinel ( db ) ;
139155 } catch ( e : unknown ) {
140156 return error ( `Failed to open database: ${ String ( e ) } ` ) ;
141157 }
@@ -211,6 +227,10 @@ const register: (
211227 log . warn ( "Registration failed: invalid name length" ) ;
212228 return error ( { code : ERR_VALIDATION , message : "Name must be 1-50 chars" } ) ;
213229 }
230+ if ( name === BROADCAST_RECIPIENT ) {
231+ log . warn ( "Registration failed: reserved broadcast name" ) ;
232+ return error ( { code : ERR_VALIDATION , message : "Name '*' is reserved for broadcasts" } ) ;
233+ }
214234 const key : string = generateKey ( ) ;
215235 const timestamp : number = now ( ) ;
216236 try {
@@ -905,14 +925,14 @@ const adminDeleteAgent: (
905925 agentName : string ,
906926) : Result < void , DbError > => {
907927 log . warn ( `Admin deleting agent ${ agentName } ` ) ;
928+ if ( agentName === BROADCAST_RECIPIENT ) {
929+ return error ( { code : ERR_VALIDATION , message : "Cannot delete broadcast sentinel" } ) ;
930+ }
908931 try {
909- // Delete child rows explicitly (in FK-safe order) before deleting the identity row.
910- // Cascade is defined in the schema but must not be relied upon — explicit deletes
911- // are more reliable across SQLite versions and PRAGMA states.
912- db . prepare ( "DELETE FROM locks WHERE agent_name = ?" ) . run ( agentName ) ;
913- db . prepare ( "DELETE FROM plans WHERE agent_name = ?" ) . run ( agentName ) ;
914- db . prepare ( "DELETE FROM messages WHERE from_agent = ?" ) . run ( agentName ) ;
915- db . prepare ( "DELETE FROM messages WHERE to_agent = ?" ) . run ( agentName ) ;
932+ // Cascade is enforced by the schema (locks, plans, messages.from_agent,
933+ // messages.to_agent all ON DELETE CASCADE), so the single DELETE on
934+ // identity removes every dependent row atomically. No manual fan-out —
935+ // doing so would mask FK regressions.
916936 const result : Database . RunResult = db
917937 . prepare ( "DELETE FROM identity WHERE agent_name = ?" )
918938 . run ( agentName ) ;
@@ -1014,9 +1034,17 @@ const adminSendMessage: (
10141034 const timestamp : number = now ( ) ;
10151035 try {
10161036 const ensureStmt : Database . Statement = db . prepare (
1017- "INSERT OR IGNORE INTO identity (agent_name, agent_key, registered_at, last_active) VALUES (?, ?, ?, ?)" ,
1037+ "INSERT OR IGNORE INTO identity (agent_name, agent_key, active, registered_at, last_active) VALUES (?, ?, 0 , ?, ?)" ,
10181038 ) ;
1039+ // Auto-create sender (existing behaviour) AND recipient so the to_agent
1040+ // FK is satisfied. '*' is skipped because the broadcast sentinel is
1041+ // seeded at DB open. Auto-created peers are inactive so they don't
1042+ // pollute agent listings; if they later register for real, the upsert
1043+ // in register() reactivates them.
10191044 ensureStmt . run ( fromAgent , generateKey ( ) , timestamp , timestamp ) ;
1045+ if ( toAgent !== BROADCAST_RECIPIENT ) {
1046+ ensureStmt . run ( toAgent , generateKey ( ) , timestamp , timestamp ) ;
1047+ }
10201048 } catch ( e : unknown ) {
10211049 return error ( { code : ERR_DATABASE , message : String ( e ) } ) ;
10221050 }
0 commit comments