instar 1.3.550 → 1.3.552

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 (35) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +25 -0
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +24 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  8. package/dist/core/PostUpdateMigrator.js +32 -0
  9. package/dist/core/PostUpdateMigrator.js.map +1 -1
  10. package/dist/core/RemoteAckStore.d.ts +71 -0
  11. package/dist/core/RemoteAckStore.d.ts.map +1 -0
  12. package/dist/core/RemoteAckStore.js +144 -0
  13. package/dist/core/RemoteAckStore.js.map +1 -0
  14. package/dist/core/types.d.ts +24 -1
  15. package/dist/core/types.d.ts.map +1 -1
  16. package/dist/core/types.js.map +1 -1
  17. package/dist/scaffold/templates.d.ts.map +1 -1
  18. package/dist/scaffold/templates.js +2 -0
  19. package/dist/scaffold/templates.js.map +1 -1
  20. package/dist/scheduler/JobScheduler.d.ts +33 -0
  21. package/dist/scheduler/JobScheduler.d.ts.map +1 -1
  22. package/dist/scheduler/JobScheduler.js +75 -0
  23. package/dist/scheduler/JobScheduler.js.map +1 -1
  24. package/dist/server/routes.d.ts.map +1 -1
  25. package/dist/server/routes.js +186 -0
  26. package/dist/server/routes.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/data/builtin-manifest.json +64 -64
  29. package/src/data/state-coherence-registry.json +12 -0
  30. package/src/scaffold/templates.ts +2 -0
  31. package/upgrades/1.3.551.md +23 -0
  32. package/upgrades/1.3.552.md +23 -0
  33. package/upgrades/side-effects/ws41-durable-ack.md +37 -0
  34. package/upgrades/side-effects/ws43-role-guard.md +40 -0
  35. package/upgrades/ws43-role-guard.eli16.md +47 -0
@@ -39,6 +39,7 @@ import { GuardRegistry } from '../monitoring/GuardRegistry.js';
39
39
  import { isPeerUrlAllowedForCredentials } from './peerUrlGuard.js';
40
40
  import { classifyMachineEmptyState } from './poolEmptyState.js';
41
41
  import { RemoteCloseAudit } from '../core/RemoteCloseAudit.js';
42
+ import { RemoteAckStore } from '../core/RemoteAckStore.js';
42
43
  import { FailureLedger } from '../monitoring/FailureLedger.js';
43
44
  import { FailureAttributionEngine } from '../monitoring/FailureAttributionEngine.js';
44
45
  import { FailureAnalyzer } from '../monitoring/FailureAnalyzer.js';
@@ -9235,6 +9236,80 @@ export function createRoutes(ctx) {
9235
9236
  const attentionPoolCache = new Map();
9236
9237
  const ATTENTION_POOL_CACHE_TTL_MS = 3_000;
9237
9238
  const ATTENTION_PEER_TIMEOUT_MS = 5_000;
9239
+ // WS4.1 follow-up (CMT-1416): durable operator-bound /ack across machines.
9240
+ // Plain seamlessness boolean read LIVE (mirrors ws21PreferencesPool) — off ⇒
9241
+ // POST /attention/:id/remote-ack 503s, the PATCH precedence guard is inert,
9242
+ // and the store is never touched (strict no-op on a flag-off agent).
9243
+ const ws41DurableAckEnabled = () => (ctx.config.multiMachine?.seamlessness ?? {}).ws41DurableAck === true;
9244
+ const remoteAckStore = new RemoteAckStore(ctx.config.stateDir);
9245
+ const remoteAckLimiter = rateLimiter(60_000, 30);
9246
+ const REMOTE_ACK_GIVE_UP_ATTEMPTS = 50;
9247
+ // Best-effort delivery of one pending ack intent to its owning machine via the
9248
+ // peer's existing PATCH /attention/:id (carrying the X-Instar-Remote-Ack
9249
+ // annotation so the owner runs its precedence guard). Returns the terminal
9250
+ // disposition; 'queued' means the owner is still unreachable (keep the intent).
9251
+ async function deliverRemoteAck(intent) {
9252
+ const machine = (ctx.listPoolMachines?.() ?? []).find((m) => m.machineId === intent.targetMachineId);
9253
+ if (!machine || !machine.lastKnownUrl)
9254
+ return 'queued';
9255
+ const extraAllowlist = ctx.config.multiMachine?.peerUrlAllowlist;
9256
+ if (!isPeerUrlAllowedForCredentials(machine.lastKnownUrl, extraAllowlist).ok)
9257
+ return 'queued';
9258
+ try {
9259
+ const r = await fetch(`${machine.lastKnownUrl}/attention/${encodeURIComponent(intent.itemId)}`, {
9260
+ method: 'PATCH',
9261
+ headers: {
9262
+ Authorization: `Bearer ${ctx.config.authToken}`,
9263
+ 'Content-Type': 'application/json',
9264
+ 'X-Instar-Remote-Ack': intent.operatorUid || 'operator',
9265
+ },
9266
+ body: JSON.stringify({ status: intent.status }),
9267
+ signal: AbortSignal.timeout(ATTENTION_PEER_TIMEOUT_MS),
9268
+ });
9269
+ if (r.ok)
9270
+ return 'delivered';
9271
+ // 425 = the owner's precedence guard rejected a stale resolve (item since
9272
+ // escalated). 404 = item already gone on the owner. Both are TERMINAL —
9273
+ // drop the intent; retrying can never succeed.
9274
+ if (r.status === 425 || r.status === 404)
9275
+ return 'stale-superseded';
9276
+ return 'queued';
9277
+ }
9278
+ catch {
9279
+ // @silent-fallback-ok — unreachable owner (timeout/offline) keeps the
9280
+ // intent durable for the next drain; never a thrown error in the ack path.
9281
+ return 'queued';
9282
+ }
9283
+ }
9284
+ // Drain every still-pending intent (called on the route's enqueue path AND on
9285
+ // a peer-online signal). Gives up loudly past the attempt cap so a permanently
9286
+ // unreachable owner doesn't leave an intent retrying forever.
9287
+ async function drainRemoteAcks() {
9288
+ if (!ws41DurableAckEnabled())
9289
+ return { delivered: 0, dropped: 0, pending: 0 };
9290
+ let delivered = 0;
9291
+ let dropped = 0;
9292
+ for (const intent of remoteAckStore.list()) {
9293
+ if (intent.attempts >= REMOTE_ACK_GIVE_UP_ATTEMPTS) {
9294
+ remoteAckStore.resolve(intent.itemId, intent.targetMachineId);
9295
+ dropped += 1;
9296
+ continue;
9297
+ }
9298
+ const disposition = await deliverRemoteAck(intent);
9299
+ if (disposition === 'delivered') {
9300
+ remoteAckStore.resolve(intent.itemId, intent.targetMachineId);
9301
+ delivered += 1;
9302
+ }
9303
+ else if (disposition === 'stale-superseded') {
9304
+ remoteAckStore.resolve(intent.itemId, intent.targetMachineId);
9305
+ dropped += 1;
9306
+ }
9307
+ else {
9308
+ remoteAckStore.recordAttempt(intent.itemId, intent.targetMachineId, 'unreachable');
9309
+ }
9310
+ }
9311
+ return { delivered, dropped, pending: remoteAckStore.size };
9312
+ }
9238
9313
  // P17 coalesce key: items sharing this key across machines collapse to ONE
9239
9314
  // merged row (a pool-wide event whose per-machine tripwires each raised an
9240
9315
  // item). Mirrors the WS3.3 episode-key pattern: prefer an explicit
@@ -9349,6 +9424,14 @@ export function createRoutes(ctx) {
9349
9424
  // per-machine budgets remain the write-side bound). Plain GET stays a
9350
9425
  // back-compatible object.
9351
9426
  if (req.query.scope === 'pool') {
9427
+ // WS4.1 follow-up: opportunistically drain any pending durable remote-acks
9428
+ // — the pool read is exactly when a previously-dark owner is likely back
9429
+ // online (the dashboard polls this). Fire-and-forget; never blocks the read.
9430
+ if (ws41DurableAckEnabled() && remoteAckStore.size > 0) {
9431
+ void drainRemoteAcks().catch(() => {
9432
+ /* @silent-fallback-ok — drain is best-effort; failures keep intents durable */
9433
+ });
9434
+ }
9352
9435
  const merged = await attentionPoolMerge(localItems, status);
9353
9436
  res.json(merged);
9354
9437
  return;
@@ -9377,6 +9460,35 @@ export function createRoutes(ctx) {
9377
9460
  res.status(400).json({ error: `"status" must be one of: ${ATTENTION_STATUSES.join(', ')} (aliases: resolved, done, ack, in_progress, wontdo, reopen)` });
9378
9461
  return;
9379
9462
  }
9463
+ // WS4.1 follow-up (CMT-1416) receiver-side precedence guard. When a PATCH
9464
+ // arrives carrying X-Instar-Remote-Ack (a relayed operator ack from a peer,
9465
+ // possibly enqueued while THIS machine was offline), the OWNER's CURRENT
9466
+ // item state wins: a stale resolve against an item that has SINCE escalated
9467
+ // to HIGH/URGENT is rejected (and surfaced) rather than silently applied.
9468
+ // Gated on ws41DurableAck — off ⇒ this whole block is inert (the header is
9469
+ // ignored and the PATCH behaves exactly as before). The header is only an
9470
+ // ANNOTATION; the Bearer auth (already enforced upstream) is the authority.
9471
+ if (req.get('X-Instar-Remote-Ack') && ws41DurableAckEnabled()) {
9472
+ const current = ctx.telegram.getAttentionItem(req.params.id);
9473
+ if (!current) {
9474
+ res.status(404).json({ error: 'Attention item not found' });
9475
+ return;
9476
+ }
9477
+ const currentPriority = String(current.priority ?? 'NORMAL');
9478
+ const downgrades = status === 'DONE' || status === 'WONT_DO' || status === 'ACKNOWLEDGED';
9479
+ if ((currentPriority === 'HIGH' || currentPriority === 'URGENT') && downgrades) {
9480
+ // Reconcile precedence: never auto-resolve a since-escalated critical
9481
+ // item via a stale relayed ack. Surfaced to the relayer (425) so the
9482
+ // RemoteAckStore drops the stale intent rather than retrying forever.
9483
+ res.status(425).json({
9484
+ error: 'stale-superseded',
9485
+ reason: `item has escalated to ${currentPriority} since the ack was issued — current state wins`,
9486
+ itemId: req.params.id,
9487
+ currentPriority,
9488
+ });
9489
+ return;
9490
+ }
9491
+ }
9380
9492
  const success = await ctx.telegram.updateAttentionStatus(req.params.id, status);
9381
9493
  if (!success) {
9382
9494
  res.status(404).json({ error: 'Attention item not found' });
@@ -9385,6 +9497,80 @@ export function createRoutes(ctx) {
9385
9497
  const item = ctx.telegram.getAttentionItem(req.params.id);
9386
9498
  res.json(item);
9387
9499
  });
9500
+ // WS4.1 follow-up (CMT-1416): durable operator-bound /ack for an attention
9501
+ // item OWNED BY ANOTHER MACHINE. The dashboard sends this when the operator
9502
+ // acks a pooled (remote) item; the ack is delivered to the owner immediately
9503
+ // when reachable, else persisted (with the authenticated operator principal)
9504
+ // and re-delivered when the owner returns — so the operator's intent survives
9505
+ // a briefly-dark owner instead of evaporating. Bearer auth (already enforced)
9506
+ // is the operator authority; the topicId resolves the bound operator uid for
9507
+ // the owner's revalidation. Mutating mesh verb → rate-limited like remote-close.
9508
+ router.post('/attention/:id/remote-ack', remoteAckLimiter, async (req, res) => {
9509
+ if (!ws41DurableAckEnabled()) {
9510
+ res.status(503).json({ error: 'WS4.1 durable remote-ack is disabled (multiMachine.seamlessness.ws41DurableAck)' });
9511
+ return;
9512
+ }
9513
+ const { machineId, status: rawStatus, topicId } = (req.body ?? {});
9514
+ // machineId is a registry LOOKUP KEY ONLY — charset-clamped, never a URL.
9515
+ if (typeof machineId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(machineId)) {
9516
+ res.status(400).json({ error: 'machineId required (the owning machine)' });
9517
+ return;
9518
+ }
9519
+ const status = normalizeAttentionStatus(rawStatus);
9520
+ if (!status) {
9521
+ res.status(400).json({ error: `"status" must be one of: ${ATTENTION_STATUSES.join(', ')}` });
9522
+ return;
9523
+ }
9524
+ // Self-ack is a plain local PATCH — refuse the mesh verb for own items so a
9525
+ // single-machine agent (or an item that lives here) never round-trips.
9526
+ if (ctx.meshSelfId && machineId === ctx.meshSelfId) {
9527
+ res.status(400).json({ error: 'item is owned by this machine — use PATCH /attention/:id' });
9528
+ return;
9529
+ }
9530
+ // Bind the authenticated operator principal from the topic-operator store
9531
+ // when a topicId is supplied (the same VERIFIED-binding source as Operator
9532
+ // Binding — never a name from content). Absent/unresolvable → 'operator'.
9533
+ let operatorUid = 'operator';
9534
+ let operatorDisplayName;
9535
+ if (typeof topicId === 'number' || (typeof topicId === 'string' && topicId.trim())) {
9536
+ const op = ctx.topicOperatorStore?.asVerifiedOperator(topicId);
9537
+ if (op) {
9538
+ operatorUid = op.uid;
9539
+ operatorDisplayName = op.names?.[0];
9540
+ }
9541
+ }
9542
+ const intent = { itemId: req.params.id, targetMachineId: machineId, status, operatorUid, operatorDisplayName };
9543
+ // Try immediate delivery; on success no durable row is needed.
9544
+ const disposition = await deliverRemoteAck(intent);
9545
+ if (disposition === 'delivered') {
9546
+ res.json({ ok: true, delivered: true, itemId: req.params.id, machineId });
9547
+ return;
9548
+ }
9549
+ if (disposition === 'stale-superseded') {
9550
+ res.status(409).json({ ok: false, staleSuperseded: true, itemId: req.params.id, machineId, reason: 'owner rejected — item has since escalated; current state wins' });
9551
+ return;
9552
+ }
9553
+ // Owner unreachable: persist the intent durably and report it queued.
9554
+ remoteAckStore.enqueue(intent);
9555
+ res.json({ ok: true, queued: true, itemId: req.params.id, machineId, reason: 'owner unreachable — ack persisted and will be delivered when it returns', pending: remoteAckStore.size });
9556
+ });
9557
+ // Pending durable remote-acks (observability + a manual drain trigger). Two
9558
+ // segments past /attention so it never collides with GET /attention/:id.
9559
+ router.get('/attention/_remote-ack/pending', (_req, res) => {
9560
+ if (!ws41DurableAckEnabled()) {
9561
+ res.status(503).json({ error: 'WS4.1 durable remote-ack is disabled (multiMachine.seamlessness.ws41DurableAck)' });
9562
+ return;
9563
+ }
9564
+ res.json({ pending: remoteAckStore.list(), count: remoteAckStore.size });
9565
+ });
9566
+ router.post('/attention/_remote-ack/drain', async (_req, res) => {
9567
+ if (!ws41DurableAckEnabled()) {
9568
+ res.status(503).json({ error: 'WS4.1 durable remote-ack is disabled (multiMachine.seamlessness.ws41DurableAck)' });
9569
+ return;
9570
+ }
9571
+ const result = await drainRemoteAcks();
9572
+ res.json({ ok: true, ...result });
9573
+ });
9388
9574
  router.delete('/attention/:id', async (req, res) => {
9389
9575
  if (!ctx.telegram) {
9390
9576
  res.status(503).json({ error: 'Telegram not configured' });