@@ -39,20 +39,49 @@ export class TaskBriefConflictError extends Error {
3939 }
4040}
4141
42+ interface PendingOutputEvent {
43+ task_id : number ;
44+ run_id : number ;
45+ event_type : string ;
46+ content : string ;
47+ }
48+
4249function expandUser ( p : string ) : string {
4350 return p . startsWith ( "~" ) ? path . join ( os . homedir ( ) , p . slice ( 1 ) ) : p ;
4451}
4552
4653export class TaskDB {
54+ static readonly OUTPUT_EVENT_BATCH_SIZE = 32 ;
55+ static readonly OUTPUT_EVENT_FLUSH_MS = 50 ;
56+
4757 db_path : string ;
4858 conn : Database ;
4959 private transaction_depth = 0 ;
60+ private _pending_output_events : PendingOutputEvent [ ] = [ ] ;
61+ private _output_event_flush_timer : ReturnType < typeof setTimeout > | null =
62+ null ;
63+ private _output_event_insert : ReturnType < Database [ "query" ] > ;
64+ private _closed = false ;
5065
5166 constructor ( db_path : string = "~/.agentforge/tasks.db" ) {
5267 this . db_path = expandUser ( db_path ) ;
5368 fs . mkdirSync ( path . dirname ( this . db_path ) , { recursive : true } ) ;
5469 this . conn = new Database ( this . db_path , { create : true } ) ;
5570 this . _init_db ( ) ;
71+ this . _output_event_insert = this . conn . query ( `
72+ INSERT INTO task_output_events (task_id, run_id, event_type, content)
73+ VALUES (?, ?, ?, ?)
74+ ` ) ;
75+ }
76+
77+ /** Flush pending output events and close SQLite exactly once. */
78+ close ( ) : void {
79+ if ( this . _closed ) {
80+ return ;
81+ }
82+ this . flush_output_events ( ) ;
83+ this . _closed = true ;
84+ this . conn . close ( ) ;
5685 }
5786
5887 /** Run a migration statement, ignoring "column already exists" errors. */
@@ -277,6 +306,14 @@ export class TaskDB {
277306 CREATE INDEX IF NOT EXISTS idx_task_output_events_task_timestamp
278307 ON task_output_events(task_id, timestamp DESC)
279308 ` ) ;
309+ this . conn . run ( `
310+ CREATE INDEX IF NOT EXISTS idx_task_output_events_task_id_id
311+ ON task_output_events(task_id, id DESC)
312+ ` ) ;
313+ this . conn . run ( `
314+ CREATE INDEX IF NOT EXISTS idx_task_output_events_run_id_id
315+ ON task_output_events(run_id, id ASC)
316+ ` ) ;
280317
281318 // DAG dependency table
282319 this . conn . run ( `
@@ -1405,6 +1442,7 @@ export class TaskDB {
14051442 error : string | null = null ,
14061443 raw_output : string | null = null ,
14071444 ) : void {
1445+ this . flush_output_events ( ) ;
14081446 this . conn
14091447 . query (
14101448 `
@@ -1426,6 +1464,7 @@ export class TaskDB {
14261464 run_error : string | null = null ,
14271465 raw_output : string | null = null ,
14281466 ) : void {
1467+ this . flush_output_events ( ) ;
14291468 const updates : Record < string , unknown > = { ...task_updates } ;
14301469 updates [ "updated_at" ] = nowIso ( ) ;
14311470 const sets = Object . keys ( updates )
@@ -1460,35 +1499,95 @@ export class TaskDB {
14601499 return rows . map ( ( r ) => ( { ...r } ) ) ;
14611500 }
14621501
1463- /** Add a new output event to the database. */
1502+ /**
1503+ * Queue an output event for a small batched insert.
1504+ *
1505+ * A full batch is written synchronously. Partial batches are written within
1506+ * OUTPUT_EVENT_FLUSH_MS, bounding API visibility latency while avoiding one
1507+ * SQLite transaction and statement preparation per NDJSON line.
1508+ */
14641509 add_output_event (
14651510 task_id : number ,
14661511 run_id : number ,
14671512 event_type : string ,
14681513 content : string ,
14691514 ) : void {
1470- this . conn
1471- . query (
1472- `
1473- INSERT INTO task_output_events (task_id, run_id, event_type, content)
1474- VALUES (?, ?, ?, ?)
1475- ` ,
1476- )
1477- . run ( task_id , run_id , event_type , content ) ;
1515+ if ( this . _closed ) {
1516+ throw new Error ( "TaskDB is closed" ) ;
1517+ }
1518+ this . _pending_output_events . push ( {
1519+ task_id,
1520+ run_id,
1521+ event_type,
1522+ content,
1523+ } ) ;
1524+ if ( this . _pending_output_events . length >= TaskDB . OUTPUT_EVENT_BATCH_SIZE ) {
1525+ this . flush_output_events ( ) ;
1526+ return ;
1527+ }
1528+ this . _schedule_output_event_flush ( ) ;
1529+ }
1530+
1531+ private _schedule_output_event_flush ( ) : void {
1532+ if (
1533+ this . _pending_output_events . length === 0 ||
1534+ this . _output_event_flush_timer !== null
1535+ ) {
1536+ return ;
1537+ }
1538+ this . _output_event_flush_timer = setTimeout ( ( ) => {
1539+ this . _output_event_flush_timer = null ;
1540+ try {
1541+ this . flush_output_events ( ) ;
1542+ } catch ( e ) {
1543+ logger . error ( `Failed to flush output events: ${ String ( e ) } ` ) ;
1544+ this . _schedule_output_event_flush ( ) ;
1545+ }
1546+ } , TaskDB . OUTPUT_EVENT_FLUSH_MS ) ;
1547+ }
1548+
1549+ /** Persist every queued output event in insertion order. */
1550+ flush_output_events ( ) : void {
1551+ if ( this . _output_event_flush_timer !== null ) {
1552+ clearTimeout ( this . _output_event_flush_timer ) ;
1553+ this . _output_event_flush_timer = null ;
1554+ }
1555+ if ( this . _pending_output_events . length === 0 ) {
1556+ return ;
1557+ }
1558+
1559+ const batch = this . _pending_output_events ;
1560+ this . _pending_output_events = [ ] ;
1561+ try {
1562+ this . transaction ( ( ) => {
1563+ for ( const event of batch ) {
1564+ this . _output_event_insert . run (
1565+ event . task_id ,
1566+ event . run_id ,
1567+ event . event_type ,
1568+ event . content ,
1569+ ) ;
1570+ }
1571+ } ) ;
1572+ } catch ( e ) {
1573+ this . _pending_output_events = [ ...batch , ...this . _pending_output_events ] ;
1574+ throw e ;
1575+ }
14781576 }
14791577
1480- /** Get output events for a task, ordered by timestamp . */
1578+ /** Get output events for a task, newest insertion first . */
14811579 get_output_events (
14821580 task_id : number ,
14831581 limit : number = 1000 ,
14841582 offset : number = 0 ,
14851583 ) : Row [ ] {
1584+ this . flush_output_events ( ) ;
14861585 const rows = this . conn
14871586 . query (
14881587 `
14891588 SELECT * FROM task_output_events
14901589 WHERE task_id = ?
1491- ORDER BY timestamp DESC
1590+ ORDER BY id DESC
14921591 LIMIT ? OFFSET ?
14931592 ` ,
14941593 )
@@ -1498,12 +1597,13 @@ export class TaskDB {
14981597
14991598 /** Get output events for a specific run. */
15001599 get_run_output_events ( run_id : number , limit : number = 1000 ) : Row [ ] {
1600+ this . flush_output_events ( ) ;
15011601 const rows = this . conn
15021602 . query (
15031603 `
15041604 SELECT * FROM task_output_events
15051605 WHERE run_id = ?
1506- ORDER BY timestamp ASC
1606+ ORDER BY id ASC
15071607 LIMIT ?
15081608 ` ,
15091609 )
@@ -2239,6 +2339,9 @@ export class TaskDB {
22392339 }
22402340
22412341 delete_task ( task_id : number ) : void {
2342+ // Do not let a delayed insert recreate output rows after their task/run
2343+ // records have been deleted.
2344+ this . flush_output_events ( ) ;
22422345 this . transaction ( ( ) => {
22432346 this . conn
22442347 . query ( "DELETE FROM task_output_events WHERE task_id = ?" )
0 commit comments