@pasko70/pibo 3.0.1 → 3.1.0

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 (122) hide show
  1. package/README.md +20 -6
  2. package/compute-image/Dockerfile +47 -0
  3. package/compute-image/Dockerfile.dockerignore +5 -0
  4. package/dist/agent-runtime/context-build.js +26 -2
  5. package/dist/agent-runtime/contract.js +78 -28
  6. package/dist/agent-runtime/portable-history.js +19 -9
  7. package/dist/agent-runtime/registry.js +8 -2
  8. package/dist/agent-runtime/routed-session.js +120 -15
  9. package/dist/agent-runtimes/codex-native/adapter.js +12 -0
  10. package/dist/agent-runtimes/codex-native/resource-delivery.js +35 -7
  11. package/dist/agent-runtimes/omp/adapter.js +3 -7
  12. package/dist/agent-runtimes/pi/adapter.js +5 -3
  13. package/dist/agent-runtimes/pi/routed-session.js +59 -12
  14. package/dist/agent-runtimes/pi/runtime.js +6 -0
  15. package/dist/apps/chat/agent-profiles.js +31 -2
  16. package/dist/apps/chat/agent-store.js +98 -6
  17. package/dist/apps/chat/chat-request-normalizers.js +31 -0
  18. package/dist/apps/chat/data/chat-data-mappers.js +61 -11
  19. package/dist/apps/chat/data/project-service.js +119 -15
  20. package/dist/apps/chat/data/room-service.js +21 -0
  21. package/dist/apps/chat/data/session-query-service.js +33 -10
  22. package/dist/apps/chat/data/timeline-query-service.js +60 -35
  23. package/dist/apps/chat/loop-api.js +10 -2
  24. package/dist/apps/chat/output-compactor.js +144 -21
  25. package/dist/apps/chat/output-event-policy.js +179 -2
  26. package/dist/apps/chat/stream.js +16 -7
  27. package/dist/apps/chat/trace-v2.js +1 -0
  28. package/dist/apps/chat/web-app.js +317 -86
  29. package/dist/apps/chat-ui/assets/{dist-BDIATCQt.js → dist-CUQJqggN.js} +1 -1
  30. package/dist/apps/chat-ui/assets/{dist-mqCviI-c.js → dist-ClUlQWYN.js} +1 -1
  31. package/dist/apps/chat-ui/assets/{dist-JnW4v8UE.js → dist-Djul9BmZ.js} +1 -1
  32. package/dist/apps/chat-ui/assets/{dist-lRiLP9rr.js → dist-GD0JGdCi.js} +1 -1
  33. package/dist/apps/chat-ui/assets/{dist-BBDMeU5-.js → dist-W5HOqHym.js} +1 -1
  34. package/dist/apps/chat-ui/assets/index-BcjkX-iP.js +228 -0
  35. package/dist/apps/chat-ui/assets/index-CmqRSbBU.css +1 -0
  36. package/dist/apps/chat-ui/index.html +2 -2
  37. package/dist/apps/chat-vscode-web/assets/index-JvrPUGvI.js +43 -0
  38. package/dist/apps/chat-vscode-web/index.html +1 -1
  39. package/dist/apps/cli-ui/InkSessionApp.js +111 -12
  40. package/dist/apps/cli-ui/cliSessionsCommand.js +34 -8
  41. package/dist/cli-session/localSessionSource.js +153 -161
  42. package/dist/cli.js +7 -11
  43. package/dist/compute/cli.js +35 -16
  44. package/dist/compute/docker.js +341 -94
  45. package/dist/config/config.js +3 -0
  46. package/dist/core/context-build.js +7 -16
  47. package/dist/core/output-persistence-retry.js +484 -0
  48. package/dist/core/output-render-sequence.js +331 -0
  49. package/dist/core/profiles.js +24 -0
  50. package/dist/core/provider-recovery.js +7 -1
  51. package/dist/core/session-model.js +37 -1
  52. package/dist/core/session-router.js +78 -110
  53. package/dist/data/ingest-service.js +91 -9
  54. package/dist/data/navigation-store.js +5 -5
  55. package/dist/data/pibo-store.js +8 -1
  56. package/dist/data/schema.js +243 -3
  57. package/dist/data/session-store.js +3 -3
  58. package/dist/debug/agents.js +91 -89
  59. package/dist/debug/index.js +46 -2
  60. package/dist/debug/pty.js +127 -48
  61. package/dist/gateway/cli.js +4 -0
  62. package/dist/gateway/web.js +8 -8
  63. package/dist/local/client.js +3 -2
  64. package/dist/loops/channel.js +3 -1
  65. package/dist/loops/cli.js +2 -1
  66. package/dist/loops/service.js +90 -11
  67. package/dist/loops/store.js +100 -74
  68. package/dist/mcp/agent-context.js +13 -26
  69. package/dist/mcp/commands/info.js +3 -1
  70. package/dist/mcp/config-command.js +46 -2
  71. package/dist/mcp/config.js +18 -4
  72. package/dist/plugins/context-files-store.js +36 -6
  73. package/dist/plugins/context-files.js +52 -1
  74. package/dist/plugins/registry.js +2 -0
  75. package/dist/plugins/user-profile-resources.js +22 -0
  76. package/dist/previews/base-url.js +34 -0
  77. package/dist/previews/config.js +2 -32
  78. package/dist/ralph/store.js +5 -0
  79. package/dist/reliability/store.js +378 -106
  80. package/dist/session-ui/terminalRows.js +8 -4
  81. package/dist/sessions/pibo-data-store.js +145 -0
  82. package/dist/sessions/store.js +42 -0
  83. package/dist/setup/cli.js +422 -6
  84. package/dist/setup/installation-profiles.js +464 -0
  85. package/dist/shared/deterministic-digest.js +94 -0
  86. package/dist/shared/trace-engine.js +54 -0
  87. package/dist/shared/trace-event-projection.js +124 -67
  88. package/dist/shared/trace-history.js +47 -12
  89. package/dist/shared/trace-live-reducer.js +23 -4
  90. package/dist/shared/trace-nodes.js +17 -28
  91. package/dist/shared/trace-order.js +41 -5
  92. package/dist/shared/trace-page-merge.js +44 -14
  93. package/dist/shared/trace-patch-nodes.js +2 -0
  94. package/dist/shared/trace-subagent-links.js +4 -1
  95. package/dist/shared/trace-tool-identity.js +15 -8
  96. package/dist/signals/projector.js +28 -6
  97. package/dist/subagents/observation-query.js +121 -0
  98. package/dist/subagents/tool.js +7 -3
  99. package/dist/tools/agent-browser-leases.js +58 -23
  100. package/dist/tools/agent-browser-wrapper.js +1 -1
  101. package/dist/tools/browser-use-leases.js +129 -16
  102. package/dist/tools/browser-use-wrapper.js +1 -1
  103. package/dist/tools/index.js +27 -11
  104. package/dist/tools/python-runtime.js +10 -3
  105. package/dist/tools/registry.js +1 -1
  106. package/dist/tools/runtime/node-backend.js +16 -5
  107. package/dist/tools/runtime/node-worker-source.js +112 -33
  108. package/dist/tools/runtime/python-backend.js +16 -5
  109. package/dist/tools/runtime/python-worker-source.js +167 -16
  110. package/dist/tools/runtime/registry.js +23 -10
  111. package/dist/user-skills/store.js +0 -1
  112. package/docs/ops/vscode-extension-release.md +9 -1
  113. package/npm-shrinkwrap.json +9 -2
  114. package/package.json +9 -2
  115. package/scripts/docker-entrypoint.sh +46 -0
  116. package/scripts/prepare-agent-browser-wrapper.sh +70 -0
  117. package/scripts/prepare-browser-use-wrapper.sh +255 -0
  118. package/dist/apps/chat-ui/assets/index-0XOOVxLP.js +0 -228
  119. package/dist/apps/chat-ui/assets/index-DV7_CgsC.css +0 -1
  120. package/dist/apps/chat-vscode-web/assets/index-Dtw2Z7jz.js +0 -43
  121. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  122. package/dist/apps/vscode-artifacts/pibo-vscode-ext-3.0.1.vsix +0 -0
@@ -5,6 +5,8 @@ import { ensurePrivatePiboHomeForPath, piboHomePath } from "../core/pibo-home.js
5
5
  import { protectPrivateFileSync } from "../core/private-path.js";
6
6
  import { sqliteTableColumns } from "../data/sqlite-schema.js";
7
7
  import { DatabaseSync } from "node:sqlite";
8
+ const MAX_ATTEMPTS_ERROR = "Job exhausted retry attempts.";
9
+ const MAX_ATTEMPTS_REASON = "max_attempts";
8
10
  function now() {
9
11
  return new Date().toISOString();
10
12
  }
@@ -36,6 +38,11 @@ function ensurePiboRunColumn(db, name, definition) {
36
38
  if (!columns.has(name))
37
39
  db.exec(`ALTER TABLE pibo_runs ADD COLUMN ${name} ${definition}`);
38
40
  }
41
+ function ensurePiboJobColumn(db, name, definition) {
42
+ const columns = sqliteTableColumns(db, "pibo_jobs");
43
+ if (!columns.has(name))
44
+ db.exec(`ALTER TABLE pibo_jobs ADD COLUMN ${name} ${definition}`);
45
+ }
39
46
  export class PiboReliabilityStore {
40
47
  db;
41
48
  appendEventStatement;
@@ -74,6 +81,13 @@ export class PiboReliabilityStore {
74
81
  CREATE INDEX IF NOT EXISTS idx_pibo_event_stream_topic_stream
75
82
  ON pibo_event_stream(topic, stream_id);
76
83
 
84
+ CREATE TABLE IF NOT EXISTS pibo_delivery_receipts (
85
+ delivery_id TEXT NOT NULL,
86
+ projection TEXT NOT NULL,
87
+ delivered_at TEXT NOT NULL,
88
+ PRIMARY KEY(delivery_id, projection)
89
+ );
90
+
77
91
  CREATE TABLE IF NOT EXISTS pibo_event_consumers (
78
92
  consumer TEXT NOT NULL,
79
93
  topic TEXT NOT NULL,
@@ -91,6 +105,7 @@ export class PiboReliabilityStore {
91
105
  priority INTEGER NOT NULL DEFAULT 0,
92
106
  worker_id TEXT,
93
107
  claim_expires_at TEXT,
108
+ claim_token INTEGER NOT NULL DEFAULT 0,
94
109
  attempts INTEGER NOT NULL DEFAULT 0,
95
110
  max_attempts INTEGER NOT NULL DEFAULT 1,
96
111
  idempotency_key TEXT,
@@ -157,6 +172,7 @@ export class PiboReliabilityStore {
157
172
  CREATE INDEX IF NOT EXISTS idx_pibo_runs_status
158
173
  ON pibo_runs(status);
159
174
  `);
175
+ ensurePiboJobColumn(this.db, "claim_token", "INTEGER NOT NULL DEFAULT 0");
160
176
  ensurePiboRunColumn(this.db, "timeout_ms", "INTEGER");
161
177
  ensurePiboRunColumn(this.db, "timeout_at", "TEXT");
162
178
  ensurePiboRunColumn(this.db, "timeout_phase", "TEXT");
@@ -189,6 +205,21 @@ export class PiboReliabilityStore {
189
205
  throw error;
190
206
  }
191
207
  }
208
+ hasDeliveryReceipt(deliveryId, projection) {
209
+ return this.db
210
+ .prepare("SELECT 1 AS found FROM pibo_delivery_receipts WHERE delivery_id = ? AND projection = ?")
211
+ .get(deliveryId, projection) !== undefined;
212
+ }
213
+ recordDeliveryReceipt(deliveryId, projection, deliveredAt = now()) {
214
+ this.db.prepare(`
215
+ INSERT OR IGNORE INTO pibo_delivery_receipts (delivery_id, projection, delivered_at)
216
+ VALUES (?, ?, ?)
217
+ `).run(deliveryId, projection, deliveredAt);
218
+ const row = this.db
219
+ .prepare("SELECT delivery_id, projection, delivered_at FROM pibo_delivery_receipts WHERE delivery_id = ? AND projection = ?")
220
+ .get(deliveryId, projection);
221
+ return { deliveryId: row.delivery_id, projection: row.projection, deliveredAt: row.delivered_at };
222
+ }
192
223
  list(input = {}) {
193
224
  const clauses = [];
194
225
  const values = [];
@@ -323,56 +354,123 @@ export class PiboReliabilityStore {
323
354
  claimBatch(workerId, limit, options = {}) {
324
355
  const timestamp = now();
325
356
  const claimExpiresAt = new Date(Date.now() + (options.visibilityTimeoutMs ?? 30000)).toISOString();
326
- const clauses = [
327
- "((state = 'pending' AND run_at <= ?) OR (state = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?))",
328
- "(expires_at IS NULL OR expires_at > ?)",
329
- ];
330
- const values = [timestamp, timestamp, timestamp];
331
- if (options.queue) {
332
- clauses.push("queue = ?");
333
- values.push(options.queue);
334
- }
335
- this.moveExpiredJobs(timestamp);
336
- const rows = this.db
337
- .prepare(`
338
- UPDATE pibo_jobs
339
- SET state = 'running',
340
- worker_id = ?,
341
- claim_expires_at = ?,
342
- attempts = attempts + 1,
343
- updated_at = ?
344
- WHERE job_id IN (
345
- SELECT job_id FROM pibo_jobs
346
- WHERE ${clauses.join(" AND ")}
347
- ORDER BY priority DESC, run_at ASC, created_at ASC
348
- LIMIT ?
349
- )
350
- RETURNING *
351
- `)
352
- .all(workerId, claimExpiresAt, timestamp, ...values, clampLimit(limit));
353
- return rows.map(jobFromRow);
357
+ return this.inImmediateTransaction(() => {
358
+ const clauses = [
359
+ "((state = 'pending' AND run_at <= ?) OR (state = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?))",
360
+ "(expires_at IS NULL OR expires_at > ?)",
361
+ "attempts < max_attempts",
362
+ ];
363
+ const values = [timestamp, timestamp, timestamp];
364
+ if (options.queue) {
365
+ clauses.push("queue = ?");
366
+ values.push(options.queue);
367
+ }
368
+ this.moveExpiredJobs(timestamp);
369
+ this.moveExhaustedRecoverableJobs(timestamp, { queue: options.queue });
370
+ const rows = this.db
371
+ .prepare(`
372
+ UPDATE pibo_jobs
373
+ SET state = 'running',
374
+ worker_id = ?,
375
+ claim_expires_at = ?,
376
+ claim_token = claim_token + 1,
377
+ attempts = attempts + 1,
378
+ updated_at = ?
379
+ WHERE job_id IN (
380
+ SELECT job_id FROM pibo_jobs
381
+ WHERE ${clauses.join(" AND ")}
382
+ ORDER BY priority DESC, run_at ASC, created_at ASC
383
+ LIMIT ?
384
+ )
385
+ RETURNING *
386
+ `)
387
+ .all(workerId, claimExpiresAt, timestamp, ...values, clampLimit(limit));
388
+ return rows.map(jobFromRow);
389
+ });
354
390
  }
355
391
  claimJob(jobId, workerId, visibilityTimeoutMs = 30000) {
356
392
  const timestamp = now();
357
393
  const claimExpiresAt = new Date(Date.now() + visibilityTimeoutMs).toISOString();
358
- const row = this.db
359
- .prepare(`
394
+ return this.inImmediateTransaction(() => {
395
+ this.moveExpiredJobs(timestamp);
396
+ this.moveExhaustedRecoverableJobs(timestamp, { jobId });
397
+ const row = this.db
398
+ .prepare(`
399
+ UPDATE pibo_jobs
400
+ SET state = 'running',
401
+ worker_id = ?,
402
+ claim_expires_at = ?,
403
+ claim_token = claim_token + 1,
404
+ attempts = attempts + 1,
405
+ updated_at = ?
406
+ WHERE job_id = ?
407
+ AND state = 'pending'
408
+ AND run_at <= ?
409
+ AND attempts < max_attempts
410
+ AND (expires_at IS NULL OR expires_at > ?)
411
+ RETURNING *
412
+ `)
413
+ .get(workerId, claimExpiresAt, timestamp, jobId, timestamp, timestamp);
414
+ return row ? jobFromRow(row) : undefined;
415
+ });
416
+ }
417
+ claimRecoverableJob(jobId, workerId, visibilityTimeoutMs = 30000, forcePending = false) {
418
+ const timestamp = now();
419
+ const claimExpiresAt = new Date(Date.now() + visibilityTimeoutMs).toISOString();
420
+ return this.inImmediateTransaction(() => {
421
+ this.moveExpiredJobs(timestamp);
422
+ this.moveExhaustedRecoverableJobs(timestamp, { jobId });
423
+ const row = this.db.prepare(`
360
424
  UPDATE pibo_jobs
361
425
  SET state = 'running',
362
426
  worker_id = ?,
363
427
  claim_expires_at = ?,
428
+ claim_token = claim_token + 1,
364
429
  attempts = attempts + 1,
365
430
  updated_at = ?
366
431
  WHERE job_id = ?
367
- AND state = 'pending'
368
- AND run_at <= ?
432
+ AND (
433
+ (state = 'pending' AND (? = 1 OR run_at <= ?))
434
+ OR (state = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
435
+ )
436
+ AND attempts < max_attempts
369
437
  AND (expires_at IS NULL OR expires_at > ?)
370
438
  RETURNING *
371
- `)
372
- .get(workerId, claimExpiresAt, timestamp, jobId, timestamp, timestamp);
373
- return row ? jobFromRow(row) : undefined;
439
+ `).get(workerId, claimExpiresAt, timestamp, jobId, forcePending ? 1 : 0, timestamp, timestamp, timestamp);
440
+ return row ? jobFromRow(row) : undefined;
441
+ });
442
+ }
443
+ updateJobPayload(jobId, workerId, payload, claimToken) {
444
+ const result = this.db.prepare(`
445
+ UPDATE pibo_jobs
446
+ SET payload_json = ?, updated_at = ?
447
+ WHERE job_id = ? AND state = 'running' AND worker_id = ?
448
+ AND (? IS NULL OR claim_token = ?)
449
+ AND claim_expires_at IS NOT NULL AND claim_expires_at > ?
450
+ `).run(json(payload), now(), jobId, workerId, claimToken ?? null, claimToken ?? null, now());
451
+ return Number(result.changes ?? 0) > 0;
374
452
  }
375
- ack(jobId, workerId) {
453
+ releaseJob(jobId, workerId, delayMs = 0, claimToken) {
454
+ const timestamp = now();
455
+ return this.inImmediateTransaction(() => {
456
+ const row = this.getLiveWorkerJob(jobId, workerId, timestamp, claimToken);
457
+ if (!row)
458
+ return false;
459
+ if (row.attempts >= row.max_attempts) {
460
+ this.moveJobToDead(row, MAX_ATTEMPTS_ERROR, MAX_ATTEMPTS_REASON, timestamp);
461
+ return true;
462
+ }
463
+ const runAt = new Date(Date.parse(timestamp) + Math.max(0, delayMs)).toISOString();
464
+ const result = this.db.prepare(`
465
+ UPDATE pibo_jobs
466
+ SET state = 'pending', worker_id = NULL, claim_expires_at = NULL, run_at = ?, updated_at = ?
467
+ WHERE job_id = ? AND state = 'running' AND worker_id = ?
468
+ AND (? IS NULL OR claim_token = ?)
469
+ `).run(runAt, timestamp, jobId, workerId, claimToken ?? null, claimToken ?? null);
470
+ return Number(result.changes ?? 0) > 0;
471
+ });
472
+ }
473
+ ack(jobId, workerId, claimToken) {
376
474
  const timestamp = now();
377
475
  const result = this.db
378
476
  .prepare(`
@@ -380,47 +478,53 @@ export class PiboReliabilityStore {
380
478
  WHERE job_id = ?
381
479
  AND state = 'running'
382
480
  AND worker_id = ?
481
+ AND (? IS NULL OR claim_token = ?)
383
482
  AND claim_expires_at IS NOT NULL
384
483
  AND claim_expires_at > ?
385
484
  `)
386
- .run(jobId, workerId, timestamp);
485
+ .run(jobId, workerId, claimToken ?? null, claimToken ?? null, timestamp);
387
486
  return Number(result.changes ?? 0) > 0;
388
487
  }
389
488
  retry(jobId, workerId, input = {}) {
390
489
  const timestamp = now();
391
- const row = this.getLiveWorkerJob(jobId, workerId, timestamp);
392
- if (!row)
393
- return false;
394
- const job = jobFromRow(row);
395
- if (job.attempts >= job.maxAttempts) {
396
- this.moveJobToDead(row, input.error ?? "Job exhausted retry attempts.", "max_attempts", timestamp);
397
- return true;
398
- }
399
- const delayMs = input.delayMs ?? retryDelayMs(job.attempts, input);
400
- const runAt = new Date(asDate(input.now).getTime() + delayMs).toISOString();
401
- this.db
402
- .prepare(`
403
- UPDATE pibo_jobs
404
- SET state = 'pending',
405
- worker_id = NULL,
406
- claim_expires_at = NULL,
407
- run_at = ?,
408
- updated_at = ?,
409
- last_error = ?
410
- WHERE job_id = ?
411
- `)
412
- .run(runAt, timestamp, input.error ?? null, jobId);
413
- return true;
490
+ return this.inImmediateTransaction(() => {
491
+ const row = this.getLiveWorkerJob(jobId, workerId, timestamp, input.claimToken);
492
+ if (!row)
493
+ return false;
494
+ const job = jobFromRow(row);
495
+ if (job.attempts >= job.maxAttempts) {
496
+ this.moveJobToDead(row, input.error ?? MAX_ATTEMPTS_ERROR, MAX_ATTEMPTS_REASON, timestamp);
497
+ return true;
498
+ }
499
+ const delayMs = input.delayMs ?? retryDelayMs(job.attempts, input);
500
+ const runAt = new Date(asDate(input.now).getTime() + delayMs).toISOString();
501
+ const result = this.db
502
+ .prepare(`
503
+ UPDATE pibo_jobs
504
+ SET state = 'pending',
505
+ worker_id = NULL,
506
+ claim_expires_at = NULL,
507
+ run_at = ?,
508
+ updated_at = ?,
509
+ last_error = ?
510
+ WHERE job_id = ? AND state = 'running' AND worker_id = ?
511
+ AND (? IS NULL OR claim_token = ?)
512
+ `)
513
+ .run(runAt, timestamp, input.error ?? null, jobId, workerId, input.claimToken ?? null, input.claimToken ?? null);
514
+ return Number(result.changes ?? 0) > 0;
515
+ });
414
516
  }
415
- fail(jobId, workerId, error) {
517
+ fail(jobId, workerId, error, claimToken) {
416
518
  const timestamp = now();
417
- const row = this.getLiveWorkerJob(jobId, workerId, timestamp);
418
- if (!row)
419
- return false;
420
- this.moveJobToDead(row, error, "failed", timestamp);
421
- return true;
519
+ return this.inImmediateTransaction(() => {
520
+ const row = this.getLiveWorkerJob(jobId, workerId, timestamp, claimToken);
521
+ if (!row)
522
+ return false;
523
+ this.moveJobToDead(row, error, "failed", timestamp);
524
+ return true;
525
+ });
422
526
  }
423
- heartbeat(jobId, workerId, extendMs) {
527
+ heartbeat(jobId, workerId, extendMs, claimToken) {
424
528
  const timestamp = now();
425
529
  const claimExpiresAt = new Date(Date.now() + Math.max(1, extendMs)).toISOString();
426
530
  const result = this.db
@@ -430,10 +534,11 @@ export class PiboReliabilityStore {
430
534
  WHERE job_id = ?
431
535
  AND state = 'running'
432
536
  AND worker_id = ?
537
+ AND (? IS NULL OR claim_token = ?)
433
538
  AND claim_expires_at IS NOT NULL
434
539
  AND claim_expires_at > ?
435
540
  `)
436
- .run(claimExpiresAt, timestamp, jobId, workerId, timestamp);
541
+ .run(claimExpiresAt, timestamp, jobId, workerId, claimToken ?? null, claimToken ?? null, timestamp);
437
542
  return Number(result.changes ?? 0) > 0;
438
543
  }
439
544
  listJobs(input = {}) {
@@ -447,11 +552,56 @@ export class PiboReliabilityStore {
447
552
  clauses.push("state = ?");
448
553
  values.push(input.state);
449
554
  }
555
+ if (input.excludeJobIds?.length) {
556
+ clauses.push(`job_id NOT IN (${input.excludeJobIds.map(() => "?").join(", ")})`);
557
+ values.push(...input.excludeJobIds);
558
+ }
450
559
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
451
560
  const rows = this.db
452
561
  .prepare(`SELECT * FROM pibo_jobs ${where} ORDER BY priority DESC, run_at ASC, created_at ASC LIMIT ?`)
453
562
  .all(...values, clampLimit(input.limit, 100));
454
- return rows.map(jobFromRow);
563
+ const jobs = [];
564
+ for (const row of rows) {
565
+ try {
566
+ jobs.push(jobFromRow(row));
567
+ }
568
+ catch {
569
+ this.quarantineJobRow(row, "payload_malformed");
570
+ }
571
+ }
572
+ return jobs;
573
+ }
574
+ listRecoverableJobs(input = {}) {
575
+ const timestamp = now();
576
+ const clauses = [
577
+ "((state = 'pending' AND (? = 1 OR run_at <= ?)) OR (state = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?))",
578
+ "(expires_at IS NULL OR expires_at > ?)",
579
+ ];
580
+ const values = [input.includeDeferredPending ? 1 : 0, timestamp, timestamp, timestamp];
581
+ if (input.queue) {
582
+ clauses.push("queue = ?");
583
+ values.push(input.queue);
584
+ }
585
+ if (input.excludeJobIds?.length) {
586
+ clauses.push(`job_id NOT IN (${input.excludeJobIds.map(() => "?").join(", ")})`);
587
+ values.push(...input.excludeJobIds);
588
+ }
589
+ const rows = this.db.prepare(`
590
+ SELECT * FROM pibo_jobs
591
+ WHERE ${clauses.join(" AND ")}
592
+ ORDER BY priority DESC, run_at ASC, created_at ASC
593
+ LIMIT ?
594
+ `).all(...values, clampLimit(input.limit, 100));
595
+ const jobs = [];
596
+ for (const row of rows) {
597
+ try {
598
+ jobs.push(jobFromRow(row));
599
+ }
600
+ catch {
601
+ this.quarantineJobRow(row, "payload_malformed");
602
+ }
603
+ }
604
+ return jobs;
455
605
  }
456
606
  listDead(input = {}) {
457
607
  const clauses = [];
@@ -466,6 +616,51 @@ export class PiboReliabilityStore {
466
616
  .all(...values, clampLimit(input.limit, 100));
467
617
  return rows.map(deadJobFromRow);
468
618
  }
619
+ hasLiveJob(jobId) {
620
+ return this.db.prepare("SELECT 1 AS found FROM pibo_jobs WHERE job_id = ?").get(jobId) !== undefined;
621
+ }
622
+ hasJobs(queue) {
623
+ return queue
624
+ ? this.db.prepare("SELECT 1 AS found FROM pibo_jobs WHERE queue = ? LIMIT 1").get(queue) !== undefined
625
+ : this.db.prepare("SELECT 1 AS found FROM pibo_jobs LIMIT 1").get() !== undefined;
626
+ }
627
+ hasRecoverableJobs(queue, includeDeferredPending = false) {
628
+ const timestamp = now();
629
+ return this.inImmediateTransaction(() => {
630
+ this.moveExpiredJobs(timestamp);
631
+ this.moveExhaustedRecoverableJobs(timestamp, { queue });
632
+ return this.db.prepare(`
633
+ SELECT 1 AS found FROM pibo_jobs
634
+ WHERE queue = ?
635
+ AND (
636
+ (state = 'pending' AND (? = 1 OR run_at <= ?))
637
+ OR (state = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
638
+ )
639
+ AND attempts < max_attempts
640
+ AND (expires_at IS NULL OR expires_at > ?)
641
+ LIMIT 1
642
+ `).get(queue, includeDeferredPending ? 1 : 0, timestamp, timestamp, timestamp) !== undefined;
643
+ });
644
+ }
645
+ quarantineJob(jobId, reason, correlation = {}) {
646
+ const row = this.db.prepare("SELECT * FROM pibo_jobs WHERE job_id = ?").get(jobId);
647
+ if (!row)
648
+ return false;
649
+ this.quarantineJobRow(row, reason, correlation);
650
+ return true;
651
+ }
652
+ deadLetter(input) {
653
+ const timestamp = now();
654
+ const jobId = input.jobId ?? `job_${randomUUID()}`;
655
+ this.db.prepare(`
656
+ INSERT OR REPLACE INTO pibo_dead_jobs (
657
+ job_id, queue, payload_json, attempts, max_attempts, idempotency_key, created_at,
658
+ updated_at, expires_at, last_error, dead_at, dead_reason
659
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
660
+ `).run(jobId, input.queue, json(input.payload ?? null), Math.max(0, input.attempts ?? 0), Math.max(1, input.maxAttempts ?? 1), input.idempotencyKey ?? null, timestamp, timestamp, input.error ?? input.reason, timestamp, input.reason);
661
+ const row = this.db.prepare("SELECT * FROM pibo_dead_jobs WHERE job_id = ?").get(jobId);
662
+ return deadJobFromRow(row);
663
+ }
469
664
  requeueDead(jobId, input = {}) {
470
665
  const dead = this.db.prepare("SELECT * FROM pibo_dead_jobs WHERE job_id = ?").get(jobId);
471
666
  if (!dead)
@@ -583,46 +778,62 @@ export class PiboReliabilityStore {
583
778
  return Number(result.changes ?? 0);
584
779
  }
585
780
  recoverInterruptedRuns(workerId = `run-registry:${process.pid}`) {
586
- const rows = this.db.prepare("SELECT * FROM pibo_runs WHERE status = 'running'").all();
587
- const recovered = [];
588
- const timestamp = now();
589
- for (const row of rows) {
590
- if (row.job_id && this.hasUnexpiredJobClaim(row.job_id, timestamp, workerId))
591
- continue;
592
- const run = runFromRow(row);
593
- if (run.timeoutAt && run.timeoutAt <= timestamp) {
594
- const error = `Run deadline ${run.timeoutAt} elapsed before the interrupted runtime recovered.`;
595
- if (run.jobId)
596
- this.moveLiveJobToDead(run.jobId, error, "timeout", timestamp);
597
- recovered.push(this.updateRun(run.runId, {
598
- status: "timed_out",
599
- error,
600
- timeoutPhase: "lifetime",
601
- summary: `${run.toolName} run started successfully, then reached its configured timeout.`,
602
- completedAt: timestamp,
603
- }) ?? run);
604
- continue;
605
- }
606
- if (run.retryable && run.maxAttempts > 1) {
607
- if (run.jobId)
608
- this.releaseJobForRetry(run.jobId, timestamp);
609
- recovered.push(this.updateRun(run.runId, {
610
- status: "queued",
611
- summary: `${run.toolName} run is queued for retry after interruption.`,
612
- }) ?? run);
613
- }
614
- else {
615
- if (run.jobId)
616
- this.moveLiveJobToDead(run.jobId, "Run was interrupted before completion and the tool is not retryable.", "interrupted", timestamp);
781
+ return this.inImmediateTransaction(() => {
782
+ const rows = this.db.prepare("SELECT * FROM pibo_runs WHERE status = 'running'").all();
783
+ const recovered = [];
784
+ const timestamp = now();
785
+ for (const row of rows) {
786
+ if (row.job_id && this.hasUnexpiredJobClaim(row.job_id, timestamp, workerId))
787
+ continue;
788
+ const run = runFromRow(row);
789
+ if (run.timeoutAt && run.timeoutAt <= timestamp) {
790
+ const error = `Run deadline ${run.timeoutAt} elapsed before the interrupted runtime recovered.`;
791
+ if (run.jobId)
792
+ this.moveLiveJobToDead(run.jobId, error, "timeout", timestamp);
793
+ recovered.push(this.updateRun(run.runId, {
794
+ status: "timed_out",
795
+ error,
796
+ timeoutPhase: "lifetime",
797
+ summary: `${run.toolName} run started successfully, then reached its configured timeout.`,
798
+ completedAt: timestamp,
799
+ }) ?? run);
800
+ continue;
801
+ }
802
+ const jobRow = run.jobId
803
+ ? this.db.prepare("SELECT * FROM pibo_jobs WHERE job_id = ?").get(run.jobId)
804
+ : undefined;
805
+ if (run.retryable && jobRow && jobRow.attempts < jobRow.max_attempts) {
806
+ this.releaseJobForRetry(jobRow.job_id, timestamp);
807
+ recovered.push(this.updateRun(run.runId, {
808
+ status: "queued",
809
+ summary: `${run.toolName} run is queued for retry after interruption.`,
810
+ }) ?? run);
811
+ continue;
812
+ }
813
+ if (run.retryable && jobRow && jobRow.attempts >= jobRow.max_attempts) {
814
+ this.moveJobToDead(jobRow, MAX_ATTEMPTS_ERROR, MAX_ATTEMPTS_REASON, timestamp);
815
+ recovered.push(this.updateRun(run.runId, {
816
+ status: "failed",
817
+ error: MAX_ATTEMPTS_ERROR,
818
+ summary: `${run.toolName} run failed after exhausting retry attempts.`,
819
+ completedAt: timestamp,
820
+ }) ?? run);
821
+ continue;
822
+ }
823
+ const error = run.retryable
824
+ ? "Run was interrupted before completion and its retry job is no longer available."
825
+ : "Run was interrupted before completion and the tool is not retryable.";
826
+ if (jobRow)
827
+ this.moveJobToDead(jobRow, error, "interrupted", timestamp);
617
828
  recovered.push(this.updateRun(run.runId, {
618
829
  status: "failed",
619
- error: "Run was interrupted before completion and the tool is not retryable.",
830
+ error,
620
831
  summary: `${run.toolName} run failed.`,
621
832
  completedAt: timestamp,
622
833
  }) ?? run);
623
834
  }
624
- }
625
- return recovered;
835
+ return recovered;
836
+ });
626
837
  }
627
838
  close() {
628
839
  this.db.close();
@@ -659,23 +870,42 @@ export class PiboReliabilityStore {
659
870
  .get(queue, idempotencyKey);
660
871
  return row ? jobFromRow(row) : undefined;
661
872
  }
662
- getLiveWorkerJob(jobId, workerId, timestamp) {
873
+ getLiveWorkerJob(jobId, workerId, timestamp, claimToken) {
663
874
  return this.db
664
875
  .prepare(`
665
876
  SELECT * FROM pibo_jobs
666
877
  WHERE job_id = ?
667
878
  AND state = 'running'
668
879
  AND worker_id = ?
880
+ AND (? IS NULL OR claim_token = ?)
669
881
  AND claim_expires_at IS NOT NULL
670
882
  AND claim_expires_at > ?
671
883
  `)
672
- .get(jobId, workerId, timestamp);
884
+ .get(jobId, workerId, claimToken ?? null, claimToken ?? null, timestamp);
673
885
  }
674
886
  moveExpiredJobs(timestamp) {
675
887
  const expired = this.db.prepare("SELECT * FROM pibo_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?").all(timestamp);
676
888
  for (const row of expired)
677
889
  this.moveJobToDead(row, row.last_error ?? "Job expired.", "expired", timestamp);
678
890
  }
891
+ moveExhaustedRecoverableJobs(timestamp, options = {}) {
892
+ const clauses = [
893
+ "attempts >= max_attempts",
894
+ "(state = 'pending' OR (state = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?))",
895
+ ];
896
+ const values = [timestamp];
897
+ if (options.jobId) {
898
+ clauses.push("job_id = ?");
899
+ values.push(options.jobId);
900
+ }
901
+ if (options.queue) {
902
+ clauses.push("queue = ?");
903
+ values.push(options.queue);
904
+ }
905
+ const exhausted = this.db.prepare(`SELECT * FROM pibo_jobs WHERE ${clauses.join(" AND ")}`).all(...values);
906
+ for (const row of exhausted)
907
+ this.moveJobToDead(row, MAX_ATTEMPTS_ERROR, MAX_ATTEMPTS_REASON, timestamp);
908
+ }
679
909
  hasUnexpiredJobClaim(jobId, timestamp, workerId) {
680
910
  const row = this.db
681
911
  .prepare(`
@@ -718,6 +948,39 @@ export class PiboReliabilityStore {
718
948
  .run(row.job_id, row.queue, row.payload_json, row.attempts, row.max_attempts, row.idempotency_key, row.created_at, timestamp, row.expires_at, error, timestamp, reason);
719
949
  this.db.prepare("DELETE FROM pibo_jobs WHERE job_id = ?").run(row.job_id);
720
950
  }
951
+ quarantineJobRow(row, reason, correlation = {}) {
952
+ this.inImmediateTransaction(() => {
953
+ const current = this.db.prepare("SELECT * FROM pibo_jobs WHERE job_id = ?").get(row.job_id);
954
+ if (!current)
955
+ return;
956
+ const payload = {
957
+ version: 1,
958
+ key: correlation.key ?? current.job_id,
959
+ ...(correlation.piboSessionId ? { piboSessionId: correlation.piboSessionId } : {}),
960
+ ...(correlation.eventId ? { eventId: correlation.eventId } : {}),
961
+ state: { phase: "quarantined", reasonCode: reason },
962
+ };
963
+ const sanitized = {
964
+ ...current,
965
+ payload_json: json(payload),
966
+ idempotency_key: correlation.key ?? null,
967
+ last_error: reason,
968
+ };
969
+ this.moveJobToDead(sanitized, reason, reason, now());
970
+ });
971
+ }
972
+ inImmediateTransaction(operation) {
973
+ this.db.exec("BEGIN IMMEDIATE");
974
+ try {
975
+ const result = operation();
976
+ this.db.exec("COMMIT");
977
+ return result;
978
+ }
979
+ catch (error) {
980
+ this.db.exec("ROLLBACK");
981
+ throw error;
982
+ }
983
+ }
721
984
  requireRun(runId) {
722
985
  const run = this.getRun(runId);
723
986
  if (!run)
@@ -750,6 +1013,7 @@ function jobFromRow(row) {
750
1013
  priority: row.priority,
751
1014
  workerId: row.worker_id ?? undefined,
752
1015
  claimExpiresAt: row.claim_expires_at ?? undefined,
1016
+ claimToken: row.claim_token,
753
1017
  attempts: row.attempts,
754
1018
  maxAttempts: row.max_attempts,
755
1019
  idempotencyKey: row.idempotency_key ?? undefined,
@@ -763,7 +1027,7 @@ function deadJobFromRow(row) {
763
1027
  return {
764
1028
  jobId: row.job_id,
765
1029
  queue: row.queue,
766
- payload: parseJson(row.payload_json),
1030
+ payload: safeDeadPayload(row.payload_json, row.dead_reason, row.job_id),
767
1031
  attempts: row.attempts,
768
1032
  maxAttempts: row.max_attempts,
769
1033
  idempotencyKey: row.idempotency_key ?? undefined,
@@ -775,6 +1039,14 @@ function deadJobFromRow(row) {
775
1039
  deadReason: row.dead_reason,
776
1040
  };
777
1041
  }
1042
+ function safeDeadPayload(value, reason, jobId) {
1043
+ try {
1044
+ return parseJson(value);
1045
+ }
1046
+ catch {
1047
+ return { version: 1, key: jobId, state: { phase: "quarantined", reasonCode: reason } };
1048
+ }
1049
+ }
778
1050
  function runFromRow(row) {
779
1051
  const output = {
780
1052
  runId: row.run_id,