@dsh-enhanced/assistant-delivery 0.1.62 → 0.1.63

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 (46) hide show
  1. package/README.md +27 -10
  2. package/bin/dsh-restart-supervisor.js +76 -0
  3. package/lib/agent-runtime.d.ts +19 -1
  4. package/lib/agent-runtime.d.ts.map +1 -1
  5. package/lib/agent-runtime.js +122 -15
  6. package/lib/agent-runtime.js.map +1 -1
  7. package/lib/operator-snapshot.d.ts +1 -1
  8. package/lib/operator-snapshot.d.ts.map +1 -1
  9. package/lib/operator-snapshot.js +4 -4
  10. package/lib/operator-snapshot.js.map +1 -1
  11. package/lib/restart-native.d.ts +47 -0
  12. package/lib/restart-native.d.ts.map +1 -0
  13. package/lib/restart-native.js +170 -0
  14. package/lib/restart-native.js.map +1 -0
  15. package/lib/restart-runtime.d.ts +38 -0
  16. package/lib/restart-runtime.d.ts.map +1 -0
  17. package/lib/restart-runtime.js +335 -0
  18. package/lib/restart-runtime.js.map +1 -0
  19. package/lib/restart-store.d.ts +132 -0
  20. package/lib/restart-store.d.ts.map +1 -0
  21. package/lib/restart-store.js +757 -0
  22. package/lib/restart-store.js.map +1 -0
  23. package/lib/restart-supervisor.d.ts +129 -0
  24. package/lib/restart-supervisor.d.ts.map +1 -0
  25. package/lib/restart-supervisor.js +615 -0
  26. package/lib/restart-supervisor.js.map +1 -0
  27. package/lib/service.d.ts +42 -1
  28. package/lib/service.d.ts.map +1 -1
  29. package/lib/service.js +455 -6
  30. package/lib/service.js.map +1 -1
  31. package/lib/sqlite.d.ts +1 -1
  32. package/lib/sqlite.d.ts.map +1 -1
  33. package/lib/sqlite.js +44 -2
  34. package/lib/sqlite.js.map +1 -1
  35. package/lib/store.d.ts +23 -0
  36. package/lib/store.d.ts.map +1 -1
  37. package/lib/store.js +222 -85
  38. package/lib/store.js.map +1 -1
  39. package/lib/tools.d.ts.map +1 -1
  40. package/lib/tools.js +21 -2
  41. package/lib/tools.js.map +1 -1
  42. package/lib/types.d.ts +2 -0
  43. package/lib/types.d.ts.map +1 -1
  44. package/lib/version.d.ts +1 -1
  45. package/lib/version.js +1 -1
  46. package/package.json +11 -10
package/lib/store.js CHANGED
@@ -2649,6 +2649,94 @@ export class DeliveryStore {
2649
2649
  `).all(afterSequence, limit);
2650
2650
  return rows.map(inboxFromRow);
2651
2651
  }
2652
+ /** Scan durable, unacknowledged dispatched turns without replaying their Agent work. */
2653
+ listInterruptedInboxNotices(input = {}) {
2654
+ this.assertOpen();
2655
+ const afterSequence = input.afterSequence ?? 0;
2656
+ if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
2657
+ throw new DeliveryStoreError('conflict', 'invalid interrupted Inbox cursor');
2658
+ }
2659
+ const limit = boundedMaintenanceLimit(input.limit, 100, 'interrupted Inbox');
2660
+ const rows = this.database.prepare(`${inboxSelect}
2661
+ WHERE inbox_messages.status = 'dead_letter'
2662
+ AND inbox_messages.failure_code = 'dispatch-ambiguous'
2663
+ AND json_extract(inbox_messages.envelope_json, '$.kind') = 'text'
2664
+ AND inbox_messages.channel != 'web'
2665
+ AND inbox_messages.binding_id IS NOT NULL
2666
+ AND EXISTS (
2667
+ SELECT 1 FROM delivery_foreground_executions AS execution
2668
+ WHERE execution.inbox_id = inbox_messages.id
2669
+ )
2670
+ AND admission_sequence > ?
2671
+ AND NOT EXISTS (
2672
+ SELECT 1 FROM dead_letter_resolutions AS resolution
2673
+ WHERE resolution.kind = 'inbox' AND resolution.message_id = inbox_messages.id
2674
+ AND resolution.attempt_count = inbox_messages.attempt_count
2675
+ )
2676
+ AND NOT EXISTS (
2677
+ SELECT 1 FROM outbox_messages AS reply
2678
+ WHERE reply.idempotency_key IN (
2679
+ 'inbound:' || inbox_messages.id || ':reply',
2680
+ 'inbound:' || inbox_messages.event_id || ':reply',
2681
+ 'inbound:' || inbox_messages.id || ':interrupted'
2682
+ )
2683
+ )
2684
+ ORDER BY admission_sequence LIMIT ?
2685
+ `).all(afterSequence, limit);
2686
+ return rows.map(inboxFromRow);
2687
+ }
2688
+ /** The final notice decision and Outbox insert share one SQLite writer fence. */
2689
+ enqueueInterruptedInboxNotice(input) {
2690
+ this.assertOpen();
2691
+ return this.transaction(() => {
2692
+ if (!this.interruptedInboxNoticeCurrent(input)) {
2693
+ throw new DeliveryStoreError('version-conflict', 'interrupted Inbox notice lost its exact owner or reply fence');
2694
+ }
2695
+ return this.enqueueIntent(input.intent, false);
2696
+ });
2697
+ }
2698
+ /** Recheck a queued interruption notice immediately before an adapter send. */
2699
+ canSendInterruptedInboxNotice(record) {
2700
+ this.assertOpen();
2701
+ const key = record.intent.idempotencyKey;
2702
+ if (!key.startsWith('inbound:') || !key.endsWith(':interrupted'))
2703
+ return false;
2704
+ const inboxId = key.slice('inbound:'.length, -':interrupted'.length);
2705
+ const binding = this.getBinding(record.intent.bindingId);
2706
+ return binding !== undefined
2707
+ && this.getOutboxByIdempotencyKey(key)?.id === record.id
2708
+ && this.interruptedInboxNoticeCurrent({ inboxId, binding, intent: record.intent });
2709
+ }
2710
+ interruptedInboxNoticeCurrent(input) {
2711
+ const inbox = this.getInbox(input.inboxId);
2712
+ const binding = inbox?.bindingId === undefined ? undefined : this.getBinding(inbox.bindingId);
2713
+ const owner = binding === undefined ? undefined : this.getBindingPrincipal(binding.id);
2714
+ const execution = this.database.prepare(`
2715
+ SELECT principal_record_id, principal_version, binding_id, binding_version,
2716
+ binding_generation, workspace, preset
2717
+ FROM delivery_foreground_executions WHERE inbox_id = ?
2718
+ `).get(input.inboxId);
2719
+ return inbox?.status === 'dead_letter' && inbox.failureCode === 'dispatch-ambiguous'
2720
+ && inbox.envelope.kind === 'text' && inbox.channel !== 'web'
2721
+ && binding?.status === 'active' && binding.id === input.binding.id
2722
+ && binding.version === input.binding.version && binding.generation === input.binding.generation
2723
+ && binding.sessionId === input.binding.sessionId
2724
+ && this.getActiveBinding(inbox.envelope.conversation)?.id === binding.id
2725
+ && owner?.status === 'active' && owner.role === 'owner'
2726
+ && execution?.principal_record_id === owner.id && execution.principal_version === owner.version
2727
+ && execution.binding_id === binding.id && execution.binding_version === binding.version
2728
+ && execution.binding_generation === binding.generation
2729
+ && execution.workspace === binding.workspace && execution.preset === binding.agentPreset
2730
+ && principalHash(inbox.envelope.principal) === principalHash(binding.principal)
2731
+ && conversationHash(inbox.envelope.conversation) === conversationHash(binding.conversation)
2732
+ && input.intent.bindingId === binding.id
2733
+ && input.intent.replyToEventId === inbox.eventId
2734
+ && input.intent.idempotencyKey === `inbound:${inbox.id}:interrupted`
2735
+ && this.getDeadLetterResolution({ kind: 'inbox', id: inbox.id,
2736
+ attemptCount: inbox.attemptCount }) === undefined
2737
+ && this.getOutboxByIdempotencyKey(`inbound:${inbox.id}:reply`) === undefined
2738
+ && this.getOutboxByIdempotencyKey(`inbound:${inbox.eventId}:reply`) === undefined;
2739
+ }
2652
2740
  listInbox(input = {}) {
2653
2741
  this.assertOpen();
2654
2742
  const limit = Math.max(1, Math.min(100, input.limit ?? 20));
@@ -2804,6 +2892,40 @@ export class DeliveryStore {
2804
2892
  }
2805
2893
  return this.getInbox(inboxId);
2806
2894
  }
2895
+ /** A durable control message wins even while its async binding transition is waiting. */
2896
+ hasRestartCancellationAfter(inboxId, binding) {
2897
+ this.assertOpen();
2898
+ const rows = this.database.prepare(`SELECT message.envelope_json FROM inbox_messages AS message
2899
+ JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
2900
+ WHERE admission.admission_sequence > (SELECT admission_sequence FROM delivery_inbox_admissions WHERE inbox_id = ?)
2901
+ AND message.status != 'dead_letter'
2902
+ AND json_extract(message.envelope_json, '$.kind') = 'command'
2903
+ AND json_extract(message.envelope_json, '$.conversation') = json(?)
2904
+ AND json_extract(message.envelope_json, '$.principal') = json(?)`)
2905
+ .all(inboxId, conversationJson(binding.conversation), principalJson(binding.principal));
2906
+ return rows.some(row => isExactDeliveryCommand(parseDeliveryCommand(JSON.parse(row.envelope_json)), 'stop', 'new', 'clear'));
2907
+ }
2908
+ /** The fence and Inbox claims share this database's write transaction. */
2909
+ setRestartFence(requestId, sourceInvocationId) {
2910
+ this.assertOpen();
2911
+ this.transaction(() => {
2912
+ const current = this.getRestartFence();
2913
+ if (current !== undefined && (current.requestId !== requestId || current.sourceInvocationId !== sourceInvocationId)) {
2914
+ throw new DeliveryStoreError('conflict', 'another restart owns the delivery drain fence');
2915
+ }
2916
+ this.database.prepare('INSERT OR IGNORE INTO delivery_restart_fence VALUES (1, ?, ?)').run(requestId, sourceInvocationId);
2917
+ });
2918
+ }
2919
+ getRestartFence() {
2920
+ this.assertOpen();
2921
+ const row = this.database.prepare('SELECT request_id, source_invocation_id FROM delivery_restart_fence WHERE singleton = 1')
2922
+ .get();
2923
+ return row === undefined ? undefined : { requestId: row.request_id, sourceInvocationId: row.source_invocation_id };
2924
+ }
2925
+ clearRestartFence(requestId) {
2926
+ this.assertOpen();
2927
+ this.database.prepare('DELETE FROM delivery_restart_fence WHERE singleton = 1 AND request_id = ?').run(requestId);
2928
+ }
2807
2929
  claimInbox(input) {
2808
2930
  this.assertOpen();
2809
2931
  const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
@@ -2819,6 +2941,8 @@ export class DeliveryStore {
2819
2941
  const now = this.now();
2820
2942
  const claims = [];
2821
2943
  this.transaction(() => {
2944
+ if (this.getRestartFence() !== undefined)
2945
+ return;
2822
2946
  this.database.prepare(`
2823
2947
  UPDATE inbox_messages SET status = 'dead_letter', failure_code = 'attempts-exhausted',
2824
2948
  next_attempt_at = NULL, updated_at = ?
@@ -4397,93 +4521,98 @@ export class DeliveryStore {
4397
4521
  const claims = [];
4398
4522
  const validatedOwnerRouteIds = [];
4399
4523
  this.transaction(() => {
4400
- this.database.prepare(`
4401
- UPDATE outbox_messages SET status = 'dead', failure_code = 'attempts-exhausted',
4402
- next_attempt_at = NULL, updated_at = ?
4403
- WHERE rowid IN (
4404
- SELECT candidate.rowid FROM outbox_messages AS candidate
4405
- WHERE candidate.status = 'retry_wait' AND candidate.next_attempt_at <= ?
4406
- AND (
4407
- SELECT COUNT(*) FROM outbox_attempts AS history
4408
- WHERE history.outbox_id = candidate.id AND history.operation = 'send'
4409
- ) >= ?
4410
- ORDER BY candidate.rowid LIMIT ?
4411
- )
4412
- `).run(now, now, input.maxAttempts, maintenanceLimit);
4413
- this.database.prepare(`
4414
- UPDATE outbox_messages SET status = 'dead', failure_code = 'reconcile-attempts-exhausted',
4415
- next_attempt_at = NULL, updated_at = ?
4416
- WHERE rowid IN (
4417
- SELECT candidate.rowid FROM outbox_messages AS candidate
4418
- WHERE candidate.status = 'unknown_after_send'
4419
- AND NOT EXISTS (
4420
- SELECT 1 FROM dead_letter_resolutions AS resolution
4421
- WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
4422
- AND resolution.attempt_count = candidate.attempt_count AND resolution.resolution = 'cancel'
4423
- )
4424
- AND (
4425
- SELECT COUNT(*) FROM outbox_attempts AS history
4426
- WHERE history.outbox_id = candidate.id AND history.operation = 'reconcile'
4427
- ) >= ?
4428
- ORDER BY candidate.rowid LIMIT ?
4429
- )
4430
- `).run(now, input.maxAttempts, maintenanceLimit);
4431
- const ownerRouteRows = this.database.prepare(`
4432
- SELECT id FROM outbox_messages
4433
- WHERE status IN ('pending', 'retry_wait') AND claimed_by IS NULL
4434
- AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
4435
- AND json_type(intent_json, '$.metadata."dsh.route.authority"') IS NOT NULL
4436
- ORDER BY rowid LIMIT ?
4437
- `).all(now, maintenanceLimit);
4438
- const ownerRouteGuard = input.ownerRouteGuard ?? { ownerRoutes: [], authorize: () => false };
4439
- for (const row of ownerRouteRows) {
4440
- const record = this.getOutbox(row.id);
4441
- if (record === undefined)
4442
- continue;
4443
- const inspected = this.inspectOwnerRouteDispatch(record, ownerRouteGuard);
4444
- if (inspected.kind === 'deferred') {
4445
- this.deferOwnerRouteBeforeClaim(record, inspected.failureCode, now, now + Math.max(1, Math.min(input.leaseMs, 30_000)));
4446
- continue;
4447
- }
4448
- if (inspected.kind !== 'authorized') {
4449
- const failureCode = inspected.kind === 'denied'
4450
- ? inspected.failureCode
4451
- : 'owner-route-receipt-invalid';
4452
- this.deadLetterOwnerRouteBeforeClaim(record, ownerId, failureCode, now);
4453
- continue;
4454
- }
4455
- if (!this.ownerRouteIsProvablyUnsent(record)) {
4456
- this.deadLetterOwnerRouteBeforeClaim(record, ownerId, 'owner-route-send-proof-missing', now);
4457
- continue;
4458
- }
4459
- if (!this.rebindOwnerRouteBeforeClaim(record, inspected)) {
4460
- this.deadLetterOwnerRouteBeforeClaim(record, ownerId, 'owner-route-rebind-conflict', now);
4461
- continue;
4524
+ const restartFenceActive = this.getRestartFence() !== undefined;
4525
+ // Drain without changing queued retries or unresolved sends. Only a
4526
+ // plain maintenance acknowledgement may start during this fence.
4527
+ if (!restartFenceActive) {
4528
+ this.database.prepare(`
4529
+ UPDATE outbox_messages SET status = 'dead', failure_code = 'attempts-exhausted',
4530
+ next_attempt_at = NULL, updated_at = ?
4531
+ WHERE rowid IN (
4532
+ SELECT candidate.rowid FROM outbox_messages AS candidate
4533
+ WHERE candidate.status = 'retry_wait' AND candidate.next_attempt_at <= ?
4534
+ AND (
4535
+ SELECT COUNT(*) FROM outbox_attempts AS history
4536
+ WHERE history.outbox_id = candidate.id AND history.operation = 'send'
4537
+ ) >= ?
4538
+ ORDER BY candidate.rowid LIMIT ?
4539
+ )
4540
+ `).run(now, now, input.maxAttempts, maintenanceLimit);
4541
+ this.database.prepare(`
4542
+ UPDATE outbox_messages SET status = 'dead', failure_code = 'reconcile-attempts-exhausted',
4543
+ next_attempt_at = NULL, updated_at = ?
4544
+ WHERE rowid IN (
4545
+ SELECT candidate.rowid FROM outbox_messages AS candidate
4546
+ WHERE candidate.status = 'unknown_after_send'
4547
+ AND NOT EXISTS (
4548
+ SELECT 1 FROM dead_letter_resolutions AS resolution
4549
+ WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
4550
+ AND resolution.attempt_count = candidate.attempt_count AND resolution.resolution = 'cancel'
4551
+ )
4552
+ AND (
4553
+ SELECT COUNT(*) FROM outbox_attempts AS history
4554
+ WHERE history.outbox_id = candidate.id AND history.operation = 'reconcile'
4555
+ ) >= ?
4556
+ ORDER BY candidate.rowid LIMIT ?
4557
+ )
4558
+ `).run(now, input.maxAttempts, maintenanceLimit);
4559
+ const ownerRouteRows = this.database.prepare(`
4560
+ SELECT id FROM outbox_messages
4561
+ WHERE status IN ('pending', 'retry_wait') AND claimed_by IS NULL
4562
+ AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
4563
+ AND json_type(intent_json, '$.metadata."dsh.route.authority"') IS NOT NULL
4564
+ ORDER BY rowid LIMIT ?
4565
+ `).all(now, maintenanceLimit);
4566
+ const ownerRouteGuard = input.ownerRouteGuard ?? { ownerRoutes: [], authorize: () => false };
4567
+ for (const row of ownerRouteRows) {
4568
+ const record = this.getOutbox(row.id);
4569
+ if (record === undefined)
4570
+ continue;
4571
+ const inspected = this.inspectOwnerRouteDispatch(record, ownerRouteGuard);
4572
+ if (inspected.kind === 'deferred') {
4573
+ this.deferOwnerRouteBeforeClaim(record, inspected.failureCode, now, now + Math.max(1, Math.min(input.leaseMs, 30_000)));
4574
+ continue;
4575
+ }
4576
+ if (inspected.kind !== 'authorized') {
4577
+ const failureCode = inspected.kind === 'denied'
4578
+ ? inspected.failureCode
4579
+ : 'owner-route-receipt-invalid';
4580
+ this.deadLetterOwnerRouteBeforeClaim(record, ownerId, failureCode, now);
4581
+ continue;
4582
+ }
4583
+ if (!this.ownerRouteIsProvablyUnsent(record)) {
4584
+ this.deadLetterOwnerRouteBeforeClaim(record, ownerId, 'owner-route-send-proof-missing', now);
4585
+ continue;
4586
+ }
4587
+ if (!this.rebindOwnerRouteBeforeClaim(record, inspected)) {
4588
+ this.deadLetterOwnerRouteBeforeClaim(record, ownerId, 'owner-route-rebind-conflict', now);
4589
+ continue;
4590
+ }
4591
+ validatedOwnerRouteIds.push(record.id);
4462
4592
  }
4463
- validatedOwnerRouteIds.push(record.id);
4593
+ this.database.prepare(`
4594
+ UPDATE outbox_messages SET status = 'dead', failure_code = CASE
4595
+ WHEN status = 'unknown_after_send' THEN 'binding-revoked-unknown'
4596
+ ELSE 'binding-revoked'
4597
+ END,
4598
+ next_attempt_at = NULL, updated_at = ?
4599
+ WHERE rowid IN (
4600
+ SELECT candidate.rowid FROM outbox_messages AS candidate
4601
+ WHERE candidate.status IN ('pending', 'retry_wait', 'unknown_after_send')
4602
+ AND json_type(candidate.intent_json, '$.metadata."dsh.route.authority"') IS NULL
4603
+ AND NOT EXISTS (
4604
+ SELECT 1 FROM dead_letter_resolutions AS resolution
4605
+ WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
4606
+ AND resolution.attempt_count = candidate.attempt_count AND resolution.resolution = 'cancel'
4607
+ )
4608
+ AND NOT EXISTS (
4609
+ SELECT 1 FROM conversation_bindings AS binding
4610
+ WHERE binding.id = candidate.binding_id AND binding.status = 'active'
4611
+ )
4612
+ ORDER BY candidate.rowid LIMIT ?
4613
+ )
4614
+ `).run(now, maintenanceLimit);
4464
4615
  }
4465
- this.database.prepare(`
4466
- UPDATE outbox_messages SET status = 'dead', failure_code = CASE
4467
- WHEN status = 'unknown_after_send' THEN 'binding-revoked-unknown'
4468
- ELSE 'binding-revoked'
4469
- END,
4470
- next_attempt_at = NULL, updated_at = ?
4471
- WHERE rowid IN (
4472
- SELECT candidate.rowid FROM outbox_messages AS candidate
4473
- WHERE candidate.status IN ('pending', 'retry_wait', 'unknown_after_send')
4474
- AND json_type(candidate.intent_json, '$.metadata."dsh.route.authority"') IS NULL
4475
- AND NOT EXISTS (
4476
- SELECT 1 FROM dead_letter_resolutions AS resolution
4477
- WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
4478
- AND resolution.attempt_count = candidate.attempt_count AND resolution.resolution = 'cancel'
4479
- )
4480
- AND NOT EXISTS (
4481
- SELECT 1 FROM conversation_bindings AS binding
4482
- WHERE binding.id = candidate.binding_id AND binding.status = 'active'
4483
- )
4484
- ORDER BY candidate.rowid LIMIT ?
4485
- )
4486
- `).run(now, maintenanceLimit);
4487
4616
  const ownerRouteClaimClause = validatedOwnerRouteIds.length === 0
4488
4617
  ? `AND (
4489
4618
  json_type(candidate.intent_json, '$.metadata."dsh.route.authority"') IS NULL
@@ -4498,6 +4627,13 @@ export class DeliveryStore {
4498
4627
  SELECT candidate.id, candidate.status FROM outbox_messages AS candidate
4499
4628
  WHERE candidate.status IN ('pending', 'retry_wait', 'unknown_after_send')
4500
4629
  AND candidate.claimed_by IS NULL
4630
+ ${restartFenceActive ? `AND candidate.status IN ('pending', 'retry_wait')
4631
+ AND candidate.idempotency_key GLOB 'inbound:*:maintenance'
4632
+ AND json_extract(candidate.intent_json, '$.format') = 'plain'
4633
+ AND json_type(candidate.intent_json, '$.metadata') IS NULL
4634
+ AND json_type(candidate.intent_json, '$.approval') IS NULL
4635
+ AND json_type(candidate.intent_json, '$.modelPicker') IS NULL
4636
+ AND json_type(candidate.intent_json, '$.permissionPicker') IS NULL` : ''}
4501
4637
  AND NOT EXISTS (
4502
4638
  SELECT 1 FROM dead_letter_resolutions AS resolution
4503
4639
  WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
@@ -4532,6 +4668,7 @@ export class DeliveryStore {
4532
4668
  WHERE earlier.lane_hash = candidate.lane_hash
4533
4669
  AND earlier.rowid < candidate.rowid
4534
4670
  AND earlier.status NOT IN ('accepted', 'delivered', 'read', 'dead')
4671
+ ${restartFenceActive ? "AND earlier.idempotency_key GLOB 'inbound:*:maintenance'" : ''}
4535
4672
  AND NOT EXISTS (
4536
4673
  SELECT 1 FROM dead_letter_resolutions AS resolution
4537
4674
  WHERE resolution.kind = 'outbox' AND resolution.message_id = earlier.id