@pasko70/pibo 1.11.1 → 1.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/apps/chat/loop-api.js +22 -6
  2. package/dist/apps/chat-ui/assets/{dist-BBVpFHAq.js → dist-1w_WVrcu.js} +1 -1
  3. package/dist/apps/chat-ui/assets/{dist-DBnh8gXR.js → dist-BEd6jKzd.js} +1 -1
  4. package/dist/apps/chat-ui/assets/{dist-BXyVMdHv.js → dist-BI1eS8pb.js} +1 -1
  5. package/dist/apps/chat-ui/assets/{dist-CCGKu-Wj.js → dist-BLdgeEs8.js} +1 -1
  6. package/dist/apps/chat-ui/assets/{dist-CuGiEm5l.js → dist-BQmnOdXD.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-DeOnZ-pw.js → dist-BmGSbokp.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-CE0MvPLM.js → dist-Bwx_CaKF.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-BiGfVaXN.js → dist-CRYLB6HZ.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-DnQYnLQS.js → dist-CoUOMSbW.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-VT4x40uL.js → dist-Dehi8o5p.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-xtnVygdr.js → dist-o1kTkdhi.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{index-DNeE4HrG.css → index-CYLZe0Y0.css} +1 -1
  14. package/dist/apps/chat-ui/assets/{index-vcg8JNj9.js → index-DT80TM0S.js} +12 -12
  15. package/dist/apps/chat-ui/index.html +2 -2
  16. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  17. package/dist/apps/vscode-artifacts/{pibo-vscode-ext-1.11.1.vsix → pibo-vscode-ext-1.11.3.vsix} +0 -0
  18. package/dist/core/gateway-resource-guard.js +51 -5
  19. package/dist/core/routed-session.js +143 -24
  20. package/dist/core/runtime-telemetry.js +90 -0
  21. package/dist/core/runtime.js +1 -0
  22. package/dist/core/session-router.js +285 -62
  23. package/dist/debug/index.js +3 -1
  24. package/dist/gateway/server.js +2 -0
  25. package/dist/gateway/web.js +1 -0
  26. package/dist/loops/accounting.js +8 -1
  27. package/dist/loops/cli.js +1 -1
  28. package/dist/loops/service.js +167 -27
  29. package/dist/loops/store.js +229 -17
  30. package/dist/loops/tools.js +30 -9
  31. package/dist/reliability/store.js +19 -5
  32. package/dist/runs/registry.js +29 -7
  33. package/package.json +1 -1
@@ -87,6 +87,18 @@ function parseRunAccounting(json) {
87
87
  }
88
88
  }
89
89
  function runAccountingJson(accounting) { return accounting ? JSON.stringify(accounting) : null; }
90
+ function parseSessionErrorDetails(json) {
91
+ if (!json)
92
+ return undefined;
93
+ try {
94
+ const value = JSON.parse(json);
95
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ function sessionErrorDetailsJson(details) { return details ? JSON.stringify(details) : null; }
90
102
  function normalizeJobState(state, mode, enabled, createdAt) {
91
103
  if (mode !== 'goal')
92
104
  return state;
@@ -105,7 +117,8 @@ function jobFromRow(row) {
105
117
  function runFromRow(row) {
106
118
  const resources = parseResourceMetadata(row.resource_json);
107
119
  const accounting = parseRunAccounting(row.accounting_json);
108
- return { id: row.id, jobId: row.job_id, piboSessionId: row.pibo_session_id ?? undefined, status: row.status, reason: row.reason ?? undefined, error: row.error ?? undefined, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, ...(accounting ? { accounting } : {}), ...(resources ? { resources } : {}), createdAt: row.created_at, updatedAt: row.updated_at };
120
+ const errorDetails = parseSessionErrorDetails(row.error_details_json);
121
+ return { id: row.id, jobId: row.job_id, piboSessionId: row.pibo_session_id ?? undefined, status: row.status, reason: row.reason ?? undefined, error: row.error ?? undefined, ...(errorDetails ? { errorDetails } : {}), messageEventId: row.message_event_id ?? undefined, messageState: row.message_state ?? undefined, startedAt: row.started_at ?? undefined, completedAt: row.completed_at ?? undefined, ...(accounting ? { accounting } : {}), ...(resources ? { resources } : {}), createdAt: row.created_at, updatedAt: row.updated_at };
109
122
  }
110
123
  function mergeResourceMetadata(jobResources, runResources) {
111
124
  if (!jobResources && !runResources)
@@ -142,6 +155,7 @@ function goalStatus(job) {
142
155
  return undefined;
143
156
  return job.state.goalStatus ?? (job.enabled ? 'active' : 'paused');
144
157
  }
158
+ function isTerminalGoalStatus(status) { return status === 'complete' || status === 'blocked' || status === 'budget_limited'; }
145
159
  function normalizeModelOverride(value) {
146
160
  if (value === undefined || value === null)
147
161
  return undefined;
@@ -294,16 +308,89 @@ export class PiboLoopStore {
294
308
  const row = this.db.prepare("SELECT * FROM pibo_ralph_jobs WHERE loop_mode = 'goal' AND json_extract(state_json, '$.lastPiboSessionId') = ? ORDER BY created_at DESC LIMIT 1").get(piboSessionId);
295
309
  return row ? jobFromRow(row) : undefined;
296
310
  }
311
+ listGoalsForSession(piboSessionId) {
312
+ return this.db.prepare("SELECT * FROM pibo_ralph_jobs WHERE loop_mode = 'goal' AND json_extract(state_json, '$.lastPiboSessionId') = ? ORDER BY created_at DESC").all(piboSessionId).map(jobFromRow);
313
+ }
314
+ getSessionGoalOwner(piboSessionId) {
315
+ return this.listGoalsForSession(piboSessionId).find((job) => {
316
+ if ((goalStatus(job) ?? 'paused') !== 'complete')
317
+ return true;
318
+ return Boolean(this.db.prepare("SELECT 1 FROM pibo_ralph_runs WHERE job_id = ? AND status = 'running' LIMIT 1").get(job.id));
319
+ });
320
+ }
321
+ createSessionGoal(input, now = new Date()) {
322
+ this.db.exec('BEGIN IMMEDIATE');
323
+ try {
324
+ const owner = this.getSessionGoalOwner(input.initialPiboSessionId);
325
+ if (owner)
326
+ throw new Error(`cannot create a new goal because this Pibo Session has an unfinished goal or in-flight run owned by ${owner.id}`);
327
+ const job = this.createJob({ ...input, mode: 'goal', enabled: true }, now);
328
+ this.db.exec('COMMIT');
329
+ return job;
330
+ }
331
+ catch (error) {
332
+ this.db.exec('ROLLBACK');
333
+ throw error;
334
+ }
335
+ }
336
+ reopenGoal(id, input, now = new Date()) {
337
+ const actorId = input.actorId.trim();
338
+ if (!actorId)
339
+ throw new Error('reopen actorId is required');
340
+ this.db.exec('BEGIN IMMEDIATE');
341
+ try {
342
+ const job = this.getJob(id);
343
+ if (!job || job.mode !== 'goal')
344
+ throw new Error('Goal not found');
345
+ const previousStatus = goalStatus(job) ?? (job.enabled ? 'active' : 'paused');
346
+ if (job.enabled || !['complete', 'blocked', 'budget_limited'].includes(previousStatus))
347
+ throw new Error('Only a disabled terminal Goal can be reopened');
348
+ const piboSessionId = job.state.lastPiboSessionId;
349
+ if (!piboSessionId)
350
+ throw new Error('Goal cannot be reopened because it has no originating Pibo Session');
351
+ if (this.db.prepare("SELECT 1 FROM pibo_ralph_runs WHERE job_id = ? AND status = 'running' LIMIT 1").get(job.id))
352
+ throw new Error('Goal cannot be reopened while a Loop run is active or queued');
353
+ const competitor = this.listGoalsForSession(piboSessionId).find((candidate) => {
354
+ if (candidate.id === job.id)
355
+ return false;
356
+ if ((goalStatus(candidate) ?? 'paused') !== 'complete')
357
+ return true;
358
+ return Boolean(this.db.prepare("SELECT 1 FROM pibo_ralph_runs WHERE job_id = ? AND status = 'running' LIMIT 1").get(candidate.id));
359
+ });
360
+ if (competitor)
361
+ throw new Error(`Goal cannot be reopened because ${competitor.id} owns the Pibo Session`);
362
+ const timestamp = nowIso(now);
363
+ const fact = {
364
+ id: `rfact_${randomUUID()}`,
365
+ jobId: job.id,
366
+ piboSessionId,
367
+ type: 'pibo.loop.goal-reopened',
368
+ source: 'pibo',
369
+ payload: { actorId, previousStatus, previousGoalEndedAt: job.state.goalEndedAt ?? null, confirmation: 'confirm-terminal-reopen' },
370
+ createdAt: timestamp,
371
+ };
372
+ this.db.prepare('INSERT INTO pibo_ralph_run_facts (id, job_id, run_id, pibo_session_id, type, source, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(fact.id, fact.jobId, null, piboSessionId, fact.type, fact.source, JSON.stringify(fact.payload), fact.createdAt);
373
+ const state = { ...job.state, goalStatus: 'active', goalEndedAt: undefined, stopRequestedAt: undefined, cancelRequestedAt: undefined, runningAt: undefined };
374
+ this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = 1, state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), timestamp, job.id);
375
+ this.db.exec('COMMIT');
376
+ return this.getJob(id);
377
+ }
378
+ catch (error) {
379
+ this.db.exec('ROLLBACK');
380
+ throw error;
381
+ }
382
+ }
297
383
  updateGoalStatus(id, status, now = new Date()) {
298
384
  const job = this.getJob(id);
299
385
  if (!job || job.mode !== 'goal')
300
386
  return undefined;
387
+ const currentStatus = goalStatus(job);
388
+ if (currentStatus === status)
389
+ return job;
390
+ if (isTerminalGoalStatus(currentStatus))
391
+ throw new Error(`Cannot change terminal goal status from ${currentStatus} to ${status}`);
301
392
  const timestamp = nowIso(now);
302
- const state = { ...job.state, goalStatus: status, runningAt: job.state.runningAt };
303
- if (job.state.runningAt)
304
- delete state.goalEndedAt;
305
- else
306
- state.goalEndedAt = timestamp;
393
+ const state = { ...job.state, goalStatus: status, goalEndedAt: job.state.goalEndedAt ?? timestamp, runningAt: job.state.runningAt };
307
394
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = 0, state_json = ?, updated_at = ? WHERE id = ?').run(JSON.stringify(state), timestamp, id);
308
395
  return this.getJob(id);
309
396
  }
@@ -323,8 +410,8 @@ export class PiboLoopStore {
323
410
  goalStatus: budgetLimited ? 'budget_limited' : currentStatus,
324
411
  };
325
412
  const timestamp = nowIso(now);
326
- if (budgetLimited && !job.state.runningAt)
327
- state.goalEndedAt = timestamp;
413
+ if (budgetLimited)
414
+ state.goalEndedAt = job.state.goalEndedAt ?? timestamp;
328
415
  this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = ?, state_json = ?, updated_at = ? WHERE id = ?').run(budgetLimited ? 0 : job.enabled ? 1 : 0, JSON.stringify(state), timestamp, id);
329
416
  return this.getJob(id);
330
417
  }
@@ -409,6 +496,10 @@ export class PiboLoopStore {
409
496
  delete state.goalEndedAt;
410
497
  state.stopRequestedAt = undefined;
411
498
  state.cancelRequestedAt = undefined;
499
+ state.lastFailure = undefined;
500
+ state.nextAttemptAt = undefined;
501
+ state.retryBackoffMs = undefined;
502
+ state.consecutiveErrors = 0;
412
503
  }
413
504
  else {
414
505
  state.goalStatus = currentGoalStatus === 'active' ? 'paused' : currentGoalStatus;
@@ -441,7 +532,26 @@ export class PiboLoopStore {
441
532
  const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ?').get(input.runId);
442
533
  return row ? runFromRow(row) : undefined;
443
534
  }
444
- removeJob(id) { const result = this.db.prepare('DELETE FROM pibo_ralph_jobs WHERE id = ?').run(id); return Number(result.changes ?? 0) > 0; }
535
+ removeJob(id) {
536
+ this.db.exec('BEGIN IMMEDIATE');
537
+ try {
538
+ if (!this.getJob(id)) {
539
+ this.db.exec('COMMIT');
540
+ return false;
541
+ }
542
+ if (this.db.prepare("SELECT 1 FROM pibo_ralph_runs WHERE job_id = ? AND status = 'running' LIMIT 1").get(id))
543
+ throw new Error('Loop job has an active run; cancel it before removal');
544
+ this.db.prepare('DELETE FROM pibo_ralph_run_facts WHERE job_id = ?').run(id);
545
+ this.db.prepare('DELETE FROM pibo_ralph_runs WHERE job_id = ?').run(id);
546
+ const result = this.db.prepare('DELETE FROM pibo_ralph_jobs WHERE id = ?').run(id);
547
+ this.db.exec('COMMIT');
548
+ return Number(result.changes ?? 0) > 0;
549
+ }
550
+ catch (error) {
551
+ this.db.exec('ROLLBACK');
552
+ throw error;
553
+ }
554
+ }
445
555
  listRuns(input = {}) {
446
556
  const clauses = [];
447
557
  const values = [];
@@ -452,6 +562,24 @@ export class PiboLoopStore {
452
562
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
453
563
  return this.db.prepare(`SELECT * FROM pibo_ralph_runs ${where} ORDER BY created_at DESC LIMIT ?`).all(...values, Math.max(1, Math.min(input.limit ?? 100, 500))).map(runFromRow);
454
564
  }
565
+ getRun(id) {
566
+ const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE id = ?').get(id);
567
+ return row ? runFromRow(row) : undefined;
568
+ }
569
+ getRunByMessageEventId(eventId) {
570
+ const row = this.db.prepare('SELECT * FROM pibo_ralph_runs WHERE message_event_id = ?').get(eventId);
571
+ return row ? runFromRow(row) : undefined;
572
+ }
573
+ attachRunMessage(jobId, runId, eventId, now = new Date()) {
574
+ const timestamp = nowIso(now);
575
+ const result = this.db.prepare("UPDATE pibo_ralph_runs SET message_event_id = ?, message_state = 'queued', updated_at = ? WHERE id = ? AND job_id = ? AND status = 'running'").run(eventId, timestamp, runId, jobId);
576
+ return Number(result.changes ?? 0) > 0 ? this.getRun(runId) : undefined;
577
+ }
578
+ updateRunMessageState(eventId, state, now = new Date()) {
579
+ const timestamp = nowIso(now);
580
+ const result = this.db.prepare('UPDATE pibo_ralph_runs SET message_state = ?, updated_at = ? WHERE message_event_id = ?').run(state, timestamp, eventId);
581
+ return Number(result.changes ?? 0) > 0 ? this.getRunByMessageEventId(eventId) : undefined;
582
+ }
455
583
  reserveRun(id, now = new Date()) { this.updateJob(id, { enabled: true }, now); return this.reserveJob(id, now); }
456
584
  reserveDueRuns(limit, now = new Date()) {
457
585
  const rows = this.db.prepare('SELECT * FROM pibo_ralph_jobs WHERE enabled = 1 ORDER BY updated_at ASC').all();
@@ -504,11 +632,41 @@ export class PiboLoopStore {
504
632
  return;
505
633
  const completedIterations = (job.state.completedIterations ?? 0) + 1;
506
634
  const reachedMaxIterations = job.maxIterations !== undefined && completedIterations >= job.maxIterations;
507
- const terminalGoalStatus = job.mode === 'goal' && ['complete', 'blocked', 'budget_limited'].includes(goalStatus(job) ?? '');
635
+ const currentGoalStatus = goalStatus(job);
636
+ const nextGoalStatus = job.mode === 'goal' ? isTerminalGoalStatus(currentGoalStatus) ? currentGoalStatus : input.goalStatus ?? currentGoalStatus : undefined;
637
+ const terminalGoalStatus = job.mode === 'goal' && isTerminalGoalStatus(nextGoalStatus);
508
638
  const shouldDisable = terminalGoalStatus || reachedMaxIterations || input.stopAfterRun === true || input.stopEvaluation?.finalAction === 'stop-after-run' || input.stopEvaluation?.finalAction === 'cancel-current-run';
509
- const state = { ...job.state, runningAt: undefined, completedIterations, lastRunAt: timestamp, lastRunId: input.runId, lastStatus: input.status === 'error' ? 'error' : input.status === 'cancelled' ? 'cancelled' : 'ok', lastError: input.error, lastPiboSessionId: input.piboSessionId ?? job.state.lastPiboSessionId, consecutiveErrors: input.status === 'error' ? (job.state.consecutiveErrors ?? 0) + 1 : 0, conditionStates: input.conditionStates ?? job.state.conditionStates, lastStopEvaluation: input.stopEvaluation ?? job.state.lastStopEvaluation, ...(terminalGoalStatus ? { goalEndedAt: job.state.goalEndedAt ?? timestamp } : {}) };
510
- this.db.prepare('UPDATE pibo_ralph_runs SET status = ?, pibo_session_id = COALESCE(?, pibo_session_id), reason = ?, error = ?, completed_at = ?, updated_at = ? WHERE id = ?').run(input.status, input.piboSessionId ?? null, input.reason ?? input.stopEvaluation?.reason ?? null, input.error ?? null, timestamp, timestamp, input.runId);
511
- this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = ?, state_json = ?, updated_at = ? WHERE id = ?').run(shouldDisable ? 0 : job.enabled ? 1 : 0, JSON.stringify(state), timestamp, job.id);
639
+ const state = {
640
+ ...job.state,
641
+ runningAt: undefined,
642
+ completedIterations,
643
+ lastRunAt: timestamp,
644
+ lastRunId: input.runId,
645
+ lastStatus: input.status === 'error' ? 'error' : input.status === 'cancelled' ? 'cancelled' : 'ok',
646
+ lastError: input.error,
647
+ lastFailure: input.status === 'error' ? input.failure : undefined,
648
+ nextAttemptAt: input.status === 'error' ? input.failure?.nextAttemptAt : undefined,
649
+ retryBackoffMs: input.status === 'error' ? input.failure?.retryBackoffMs : undefined,
650
+ lastPiboSessionId: input.piboSessionId ?? job.state.lastPiboSessionId,
651
+ consecutiveErrors: input.status === 'error' ? (job.state.consecutiveErrors ?? 0) + 1 : 0,
652
+ conditionStates: input.conditionStates ?? job.state.conditionStates,
653
+ lastStopEvaluation: input.stopEvaluation ?? job.state.lastStopEvaluation,
654
+ ...(nextGoalStatus ? { goalStatus: nextGoalStatus } : {}),
655
+ ...(terminalGoalStatus ? { goalEndedAt: job.state.goalEndedAt ?? timestamp } : {}),
656
+ };
657
+ this.db.exec('BEGIN IMMEDIATE');
658
+ try {
659
+ this.db.prepare("UPDATE pibo_ralph_runs SET status = ?, pibo_session_id = COALESCE(?, pibo_session_id), reason = ?, error = ?, error_details_json = ?, message_state = CASE WHEN message_state = 'invalidated' THEN message_state ELSE 'finished' END, completed_at = ?, updated_at = ? WHERE id = ?").run(input.status, input.piboSessionId ?? null, input.reason ?? input.stopEvaluation?.reason ?? null, input.error ?? null, sessionErrorDetailsJson(input.errorDetails), timestamp, timestamp, input.runId);
660
+ this.db.prepare('UPDATE pibo_ralph_jobs SET enabled = ?, state_json = ?, updated_at = ? WHERE id = ?').run(shouldDisable ? 0 : job.enabled ? 1 : 0, JSON.stringify(state), timestamp, job.id);
661
+ this.db.exec('COMMIT');
662
+ }
663
+ catch (error) {
664
+ try {
665
+ this.db.exec('ROLLBACK');
666
+ }
667
+ catch { /* ignore rollback failure */ }
668
+ throw error;
669
+ }
512
670
  }
513
671
  appendRunFact(input) {
514
672
  if (!input.jobId.trim())
@@ -574,6 +732,10 @@ export class PiboLoopStore {
574
732
  this.db.exec('COMMIT');
575
733
  return undefined;
576
734
  }
735
+ if (job.state.nextAttemptAt && job.state.nextAttemptAt > timestamp) {
736
+ this.db.exec('COMMIT');
737
+ return undefined;
738
+ }
577
739
  if (job.mode === 'goal' && job.tokenBudget !== undefined) {
578
740
  const remaining = Math.max(0, job.tokenBudget - (job.state.tokensUsed ?? 0));
579
741
  if (remaining <= (job.tokenReserve ?? 0)) {
@@ -584,7 +746,7 @@ export class PiboLoopStore {
584
746
  }
585
747
  }
586
748
  const run = this.createRunLocked(job, timestamp);
587
- const state = { ...job.state, runningAt: timestamp, lastRunAt: timestamp, lastRunId: run.id };
749
+ const state = { ...job.state, runningAt: timestamp, lastRunAt: timestamp, lastRunId: run.id, nextAttemptAt: undefined, retryBackoffMs: undefined };
588
750
  this.updateJobStateLocked(job.id, state, timestamp);
589
751
  this.db.exec('COMMIT');
590
752
  return { job: { ...job, state, updatedAt: timestamp }, run };
@@ -605,9 +767,26 @@ export class PiboLoopStore {
605
767
  this.ensureJobColumn('resource_json', 'TEXT');
606
768
  this.ensureRunColumn('resource_json', 'TEXT');
607
769
  this.ensureRunColumn('accounting_json', 'TEXT');
770
+ this.ensureRunColumn('error_details_json', 'TEXT');
771
+ this.ensureRunColumn('message_event_id', 'TEXT');
772
+ this.ensureRunColumn('message_state', 'TEXT');
773
+ this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_pibo_ralph_runs_message_event ON pibo_ralph_runs(message_event_id) WHERE message_event_id IS NOT NULL');
774
+ this.repairOrphanedChildren();
608
775
  }
609
776
  createFreshSchema() {
610
- this.db.exec(`CREATE TABLE IF NOT EXISTS pibo_ralph_jobs (id TEXT PRIMARY KEY, loop_mode TEXT NOT NULL DEFAULT 'goal', name TEXT NOT NULL, description TEXT, enabled INTEGER NOT NULL, target_json TEXT NOT NULL, profile TEXT NOT NULL, prompt TEXT NOT NULL, max_iterations INTEGER, token_budget INTEGER, token_reserve INTEGER, runtime_options_json TEXT, stop_policy_json TEXT, resource_json TEXT, state_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_jobs_enabled ON pibo_ralph_jobs(enabled, updated_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_runs (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, pibo_session_id TEXT, status TEXT NOT NULL, reason TEXT, error TEXT, accounting_json TEXT, resource_json TEXT, started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_runs_job_created ON pibo_ralph_runs(job_id, created_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_run_facts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL, run_id TEXT, pibo_session_id TEXT, type TEXT NOT NULL, source TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_job_created ON pibo_ralph_run_facts(job_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_run_type ON pibo_ralph_run_facts(run_id, type, created_at DESC);`);
777
+ this.db.exec(`CREATE TABLE IF NOT EXISTS pibo_ralph_jobs (id TEXT PRIMARY KEY, loop_mode TEXT NOT NULL DEFAULT 'goal', name TEXT NOT NULL, description TEXT, enabled INTEGER NOT NULL, target_json TEXT NOT NULL, profile TEXT NOT NULL, prompt TEXT NOT NULL, max_iterations INTEGER, token_budget INTEGER, token_reserve INTEGER, runtime_options_json TEXT, stop_policy_json TEXT, resource_json TEXT, state_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_jobs_enabled ON pibo_ralph_jobs(enabled, updated_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_runs (id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES pibo_ralph_jobs(id) ON DELETE CASCADE, pibo_session_id TEXT, status TEXT NOT NULL, reason TEXT, error TEXT, error_details_json TEXT, message_event_id TEXT, message_state TEXT, accounting_json TEXT, resource_json TEXT, started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_runs_job_created ON pibo_ralph_runs(job_id, created_at DESC); CREATE TABLE IF NOT EXISTS pibo_ralph_run_facts (id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES pibo_ralph_jobs(id) ON DELETE CASCADE, run_id TEXT, pibo_session_id TEXT, type TEXT NOT NULL, source TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_job_created ON pibo_ralph_run_facts(job_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pibo_ralph_facts_run_type ON pibo_ralph_run_facts(run_id, type, created_at DESC);`);
778
+ }
779
+ repairOrphanedChildren() {
780
+ this.db.exec('BEGIN IMMEDIATE');
781
+ try {
782
+ this.db.exec('DELETE FROM pibo_ralph_run_facts WHERE NOT EXISTS (SELECT 1 FROM pibo_ralph_jobs WHERE pibo_ralph_jobs.id = pibo_ralph_run_facts.job_id)');
783
+ this.db.exec('DELETE FROM pibo_ralph_runs WHERE NOT EXISTS (SELECT 1 FROM pibo_ralph_jobs WHERE pibo_ralph_jobs.id = pibo_ralph_runs.job_id)');
784
+ this.db.exec('COMMIT');
785
+ }
786
+ catch (error) {
787
+ this.db.exec('ROLLBACK');
788
+ throw error;
789
+ }
611
790
  }
612
791
  ensureJobColumn(name, definition) {
613
792
  const columns = this.tableColumns('pibo_ralph_jobs');
@@ -634,9 +813,42 @@ export class PiboLoopStore {
634
813
  tokensUsed: 0,
635
814
  overshootTokens: 0,
636
815
  } : undefined;
637
- const run = { id: job.mode === 'ralph' ? `rrun_${randomUUID()}` : `lrun_${randomUUID()}`, jobId: job.id, status: 'running', startedAt: timestamp, ...(accounting ? { accounting } : {}), ...(job.resources ? { resources: job.resources } : {}), createdAt: timestamp, updatedAt: timestamp };
638
- this.db.prepare('INSERT INTO pibo_ralph_runs (id, job_id, pibo_session_id, status, reason, error, accounting_json, resource_json, started_at, completed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(run.id, run.jobId, null, run.status, null, null, runAccountingJson(run.accounting), resourceMetadataJson(run.resources), run.startedAt ?? null, null, run.createdAt, run.updatedAt);
816
+ const run = { id: job.mode === 'ralph' ? `rrun_${randomUUID()}` : `lrun_${randomUUID()}`, jobId: job.id, status: 'running', messageState: 'reserved', startedAt: timestamp, ...(accounting ? { accounting } : {}), ...(job.resources ? { resources: job.resources } : {}), createdAt: timestamp, updatedAt: timestamp };
817
+ this.db.prepare('INSERT INTO pibo_ralph_runs (id, job_id, pibo_session_id, status, reason, error, error_details_json, message_event_id, message_state, accounting_json, resource_json, started_at, completed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(run.id, run.jobId, null, run.status, null, null, null, null, 'reserved', runAccountingJson(run.accounting), resourceMetadataJson(run.resources), run.startedAt ?? null, null, run.createdAt, run.updatedAt);
639
818
  return run;
640
819
  }
641
820
  }
642
821
  export function createDefaultPiboLoopStore(options = {}) { return new PiboLoopStore(options); }
822
+ export function createLoopMessagePreflight(options = {}) {
823
+ return (event) => {
824
+ if (event.provenance?.kind !== 'loop-run')
825
+ return { allowed: true };
826
+ const store = createDefaultPiboLoopStore(options);
827
+ try {
828
+ const { jobId, runId } = event.provenance;
829
+ const job = store.getJob(jobId);
830
+ const run = store.getRun(runId);
831
+ const status = job?.mode === 'goal' ? goalStatus(job) ?? (job.enabled ? 'active' : 'paused') : undefined;
832
+ const allowed = Boolean(job
833
+ && run
834
+ && run.jobId === jobId
835
+ && run.status === 'running'
836
+ && run.messageEventId === event.id
837
+ && (!run.piboSessionId || run.piboSessionId === event.piboSessionId)
838
+ && job.enabled
839
+ && job.state.runningAt
840
+ && job.state.lastRunId === runId
841
+ && (job.mode !== 'goal' || status === 'active'));
842
+ if (allowed)
843
+ return { allowed: true };
844
+ return {
845
+ allowed: false,
846
+ code: 'loop_continuation_invalidated',
847
+ reason: `Loop continuation ${runId} is no longer authorized for job ${jobId}${status ? ` (${status})` : ''}`,
848
+ };
849
+ }
850
+ finally {
851
+ store.close();
852
+ }
853
+ };
854
+ }
@@ -27,6 +27,30 @@ function requireSessionContext(context) {
27
27
  profileName: context.profileName?.trim() || 'base',
28
28
  };
29
29
  }
30
+ function resolveGoalForTurn(store, context, piboSessionId) {
31
+ const activeMessage = context.getActiveMessage?.();
32
+ const provenance = activeMessage?.provenance;
33
+ if (provenance?.kind !== 'loop-run')
34
+ return store.getSessionGoalOwner(piboSessionId) ?? store.getLatestGoalForSession(piboSessionId);
35
+ const run = store.getRun(provenance.runId);
36
+ if (!run || run.jobId !== provenance.jobId || run.piboSessionId !== piboSessionId || run.messageEventId !== activeMessage?.id) {
37
+ throw new Error('cannot resolve goal because this turn has stale or invalid Loop provenance');
38
+ }
39
+ const job = store.getJob(provenance.jobId);
40
+ if (!job || job.mode !== 'goal')
41
+ throw new Error('cannot resolve goal because the originating Goal no longer exists');
42
+ return job;
43
+ }
44
+ function requireExplicitGoalCreationAuthority(context) {
45
+ const activeMessage = context.getActiveMessage?.();
46
+ if (!activeMessage)
47
+ return;
48
+ if (activeMessage.provenance?.kind === 'loop-run')
49
+ throw new Error('automatic Loop continuations cannot create replacement goals');
50
+ if (activeMessage.source !== 'user' && activeMessage.source !== 'ui' && activeMessage.source !== 'actor') {
51
+ throw new Error('create_goal requires a fresh explicit user or actor turn');
52
+ }
53
+ }
30
54
  function positiveInteger(value, field) {
31
55
  if (value === undefined)
32
56
  return undefined;
@@ -57,6 +81,8 @@ function goalPayload(job) {
57
81
  elapsedWallClockSeconds: goalElapsedWallClockSeconds(job),
58
82
  goalStartedAt: job.state.goalStartedAt ?? null,
59
83
  goalEndedAt: job.state.goalEndedAt ?? null,
84
+ nextAttemptAt: job.state.nextAttemptAt ?? null,
85
+ failure: job.state.lastFailure ?? null,
60
86
  wallClockIncludesPausedTime: true,
61
87
  };
62
88
  }
@@ -85,7 +111,7 @@ function createGetGoalTool(context, options) {
85
111
  try {
86
112
  const { piboSessionId } = requireSessionContext(context);
87
113
  return await withStore(options, (store) => {
88
- const job = store.getLatestGoalForSession(piboSessionId);
114
+ const job = resolveGoalForTurn(store, context, piboSessionId);
89
115
  return toolResult({ ok: true, goal: job ? goalPayload(job) : null });
90
116
  });
91
117
  }
@@ -109,19 +135,14 @@ function createCreateGoalTool(context, options) {
109
135
  async execute(_toolCallId, params) {
110
136
  try {
111
137
  const session = requireSessionContext(context);
138
+ requireExplicitGoalCreationAuthority(context);
112
139
  const objective = params.objective?.trim();
113
140
  if (!objective)
114
141
  throw new Error('objective is required');
115
142
  const tokenBudget = positiveInteger(params.token_budget, 'token_budget');
116
143
  const tokenReserve = nonNegativeInteger(params.token_reserve, 'token_reserve');
117
144
  return await withStore(options, (store) => {
118
- const existing = store.getLatestGoalForSession(session.piboSessionId);
119
- if (existing && effectiveGoalStatus(existing) !== 'complete') {
120
- throw new Error('cannot create a new goal because this Pibo Session has an unfinished goal; complete the existing goal first');
121
- }
122
- const job = store.createJob({
123
- mode: 'goal',
124
- enabled: true,
145
+ const job = store.createSessionGoal({
125
146
  target: session.piboRoomId ? { kind: 'room', roomId: session.piboRoomId } : { kind: 'default-chat' },
126
147
  profile: session.profileName,
127
148
  prompt: objective,
@@ -154,7 +175,7 @@ function createUpdateGoalTool(context, options) {
154
175
  throw new Error('status must be complete or blocked');
155
176
  const status = params.status;
156
177
  return await withStore(options, (store) => {
157
- const existing = store.getLatestGoalForSession(piboSessionId);
178
+ const existing = resolveGoalForTurn(store, context, piboSessionId);
158
179
  if (!existing)
159
180
  throw new Error('cannot update goal because this Pibo Session has no goal');
160
181
  const job = store.updateGoalStatus(existing.id, status);
@@ -500,7 +500,7 @@ export class PiboReliabilityStore {
500
500
  ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, ?)
501
501
  `)
502
502
  .run(runId, input.controllerPiboSessionId, input.completionPolicy, input.toolName, `${input.toolName} run is running.`, timestamp, timestamp, job.jobId, input.retryable ? 1 : 0, maxAttempts, input.timeoutMs ?? null, timeoutAt ?? null, input.serviceWarning ?? null);
503
- this.claimJob(job.jobId, `run-registry:${process.pid}`, 24 * 60 * 60 * 1000);
503
+ this.claimJob(job.jobId, input.workerId ?? `run-registry:${process.pid}`, 24 * 60 * 60 * 1000);
504
504
  return this.requireRun(runId);
505
505
  }
506
506
  updateRun(runId, patch) {
@@ -572,14 +572,27 @@ export class PiboReliabilityStore {
572
572
  const result = this.db.prepare(`DELETE FROM pibo_runs WHERE run_id IN (${placeholders})`).run(...ids);
573
573
  return Number(result.changes ?? 0);
574
574
  }
575
- recoverInterruptedRuns() {
575
+ recoverInterruptedRuns(workerId = `run-registry:${process.pid}`) {
576
576
  const rows = this.db.prepare("SELECT * FROM pibo_runs WHERE status = 'running'").all();
577
577
  const recovered = [];
578
578
  const timestamp = now();
579
579
  for (const row of rows) {
580
- if (row.job_id && this.hasUnexpiredJobClaim(row.job_id, timestamp))
580
+ if (row.job_id && this.hasUnexpiredJobClaim(row.job_id, timestamp, workerId))
581
581
  continue;
582
582
  const run = runFromRow(row);
583
+ if (run.timeoutAt && run.timeoutAt <= timestamp) {
584
+ const error = `Run deadline ${run.timeoutAt} elapsed before the interrupted runtime recovered.`;
585
+ if (run.jobId)
586
+ this.moveLiveJobToDead(run.jobId, error, "timeout", timestamp);
587
+ recovered.push(this.updateRun(run.runId, {
588
+ status: "timed_out",
589
+ error,
590
+ timeoutPhase: "lifetime",
591
+ summary: `${run.toolName} run started successfully, then reached its configured timeout.`,
592
+ completedAt: timestamp,
593
+ }) ?? run);
594
+ continue;
595
+ }
583
596
  if (run.retryable && run.maxAttempts > 1) {
584
597
  if (run.jobId)
585
598
  this.releaseJobForRetry(run.jobId, timestamp);
@@ -653,16 +666,17 @@ export class PiboReliabilityStore {
653
666
  for (const row of expired)
654
667
  this.moveJobToDead(row, row.last_error ?? "Job expired.", "expired", timestamp);
655
668
  }
656
- hasUnexpiredJobClaim(jobId, timestamp) {
669
+ hasUnexpiredJobClaim(jobId, timestamp, workerId) {
657
670
  const row = this.db
658
671
  .prepare(`
659
672
  SELECT job_id FROM pibo_jobs
660
673
  WHERE job_id = ?
661
674
  AND state = 'running'
675
+ AND worker_id = ?
662
676
  AND claim_expires_at IS NOT NULL
663
677
  AND claim_expires_at > ?
664
678
  `)
665
- .get(jobId, timestamp);
679
+ .get(jobId, workerId, timestamp);
666
680
  return row !== undefined;
667
681
  }
668
682
  releaseJobForRetry(jobId, timestamp) {
@@ -46,14 +46,16 @@ export class PiboRunRegistry {
46
46
  runs = new Map();
47
47
  waiters = new Map();
48
48
  listeners = new Set();
49
+ workerId;
49
50
  subscribe(listener) {
50
51
  this.listeners.add(listener);
51
52
  return () => this.listeners.delete(listener);
52
53
  }
53
54
  constructor(options = {}) {
54
55
  this.options = options;
56
+ this.workerId = options.workerId ?? `run-registry:${process.pid}:${randomUUID()}`;
55
57
  if (this.options.store) {
56
- this.options.store.recoverInterruptedRuns();
58
+ this.options.store.recoverInterruptedRuns(this.workerId);
57
59
  for (const record of this.options.store.listRuns({ includeConsumed: true, includeDetached: true })) {
58
60
  this.runs.set(record.runId, recordFromStored(record));
59
61
  }
@@ -71,6 +73,7 @@ export class PiboRunRegistry {
71
73
  maxAttempts: input.maxAttempts ?? 1,
72
74
  timeoutMs: input.timeoutMs,
73
75
  serviceWarning: input.serviceWarning,
76
+ workerId: this.workerId,
74
77
  });
75
78
  const record = recordFromStored(stored);
76
79
  this.runs.set(record.runId, record);
@@ -112,7 +115,7 @@ export class PiboRunRegistry {
112
115
  this.finish(record);
113
116
  this.options.store?.updateRun(runId, record);
114
117
  if (record.jobId)
115
- this.options.store?.ack(record.jobId, `run-registry:${process.pid}`);
118
+ this.options.store?.ack(record.jobId, this.workerId);
116
119
  const output = snapshot(record);
117
120
  this.notify({ type: "run_changed", run: output, previousStatus });
118
121
  return output;
@@ -128,7 +131,7 @@ export class PiboRunRegistry {
128
131
  this.finish(record);
129
132
  this.options.store?.updateRun(runId, record);
130
133
  if (record.jobId)
131
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, error);
134
+ this.options.store?.fail(record.jobId, this.workerId, error);
132
135
  const output = snapshot(record);
133
136
  this.notify({ type: "run_changed", run: output, previousStatus, reason: error });
134
137
  return output;
@@ -147,7 +150,7 @@ export class PiboRunRegistry {
147
150
  this.finish(record);
148
151
  this.options.store?.updateRun(runId, record);
149
152
  if (record.jobId)
150
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, error);
153
+ this.options.store?.fail(record.jobId, this.workerId, error);
151
154
  const output = snapshot(record);
152
155
  this.notify({ type: "run_changed", run: output, previousStatus, reason: error });
153
156
  return output;
@@ -227,7 +230,7 @@ export class PiboRunRegistry {
227
230
  record.summary = `${record.toolName} run cancelled.`;
228
231
  this.finish(record);
229
232
  if (record.jobId)
230
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, "Run was cancelled.");
233
+ this.options.store?.fail(record.jobId, this.workerId, "Run was cancelled.");
231
234
  }
232
235
  record.consumed = true;
233
236
  record.updatedAt = now();
@@ -247,6 +250,25 @@ export class PiboRunRegistry {
247
250
  this.notify({ type: "run_acknowledged", run: output });
248
251
  return output;
249
252
  }
253
+ suppressNotification(controllerPiboSessionId, runId) {
254
+ const record = this.requireRunForController(controllerPiboSessionId, runId);
255
+ record.acknowledgedStatus = record.status;
256
+ record.updatedAt = now();
257
+ this.options.store?.updateRun(runId, record);
258
+ return snapshot(record);
259
+ }
260
+ suppressControllerNotifications(controllerPiboSessionId) {
261
+ const suppressed = [];
262
+ for (const record of this.runs.values()) {
263
+ if (record.controllerPiboSessionId !== controllerPiboSessionId || record.completionPolicy !== "tracked")
264
+ continue;
265
+ record.acknowledgedStatus = record.status;
266
+ record.updatedAt = now();
267
+ this.options.store?.updateRun(record.runId, record);
268
+ suppressed.push(snapshot(record));
269
+ }
270
+ return suppressed;
271
+ }
250
272
  createNotification(controllerPiboSessionId, options = {}) {
251
273
  const records = [...this.runs.values()].filter((record) => this.needsNotification(record, controllerPiboSessionId, options));
252
274
  if (records.length === 0)
@@ -292,7 +314,7 @@ export class PiboRunRegistry {
292
314
  this.finish(record);
293
315
  this.options.store?.updateRun(record.runId, record);
294
316
  if (record.jobId)
295
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, reason);
317
+ this.options.store?.fail(record.jobId, this.workerId, reason);
296
318
  const output = snapshot(record);
297
319
  this.notify({ type: "run_changed", run: output, previousStatus: "running", reason });
298
320
  cancelled.push(output);
@@ -311,7 +333,7 @@ export class PiboRunRegistry {
311
333
  this.finish(record);
312
334
  this.options.store?.updateRun(record.runId, record);
313
335
  if (record.jobId)
314
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, reason);
336
+ this.options.store?.fail(record.jobId, this.workerId, reason);
315
337
  const output = snapshot(record);
316
338
  this.notify({ type: "run_changed", run: output, previousStatus: "running", reason });
317
339
  cancelled.push(output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "1.11.1",
3
+ "version": "1.11.3",
4
4
  "type": "module",
5
5
  "imports": {
6
6
  "vscode": "./src/apps/chat-vscode/extension/src/vscode-shim.js"