@pasko70/pibo 1.11.2 → 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 (30) 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.2.vsix → pibo-vscode-ext-1.11.3.vsix} +0 -0
  18. package/dist/core/routed-session.js +143 -24
  19. package/dist/core/runtime-telemetry.js +90 -0
  20. package/dist/core/runtime.js +1 -0
  21. package/dist/core/session-router.js +262 -50
  22. package/dist/gateway/server.js +2 -0
  23. package/dist/gateway/web.js +1 -0
  24. package/dist/loops/accounting.js +8 -1
  25. package/dist/loops/cli.js +1 -1
  26. package/dist/loops/service.js +167 -27
  27. package/dist/loops/store.js +229 -17
  28. package/dist/loops/tools.js +30 -9
  29. package/dist/runs/registry.js +19 -0
  30. 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);
@@ -250,6 +250,25 @@ export class PiboRunRegistry {
250
250
  this.notify({ type: "run_acknowledged", run: output });
251
251
  return output;
252
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
+ }
253
272
  createNotification(controllerPiboSessionId, options = {}) {
254
273
  const records = [...this.runs.values()].filter((record) => this.needsNotification(record, controllerPiboSessionId, options));
255
274
  if (records.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "1.11.2",
3
+ "version": "1.11.3",
4
4
  "type": "module",
5
5
  "imports": {
6
6
  "vscode": "./src/apps/chat-vscode/extension/src/vscode-shim.js"