@nanobpm/nano-workforce 0.193.1 → 0.194.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.
package/app/service.ts CHANGED
@@ -106,6 +106,9 @@ import {
106
106
  latestTrialMergeQuestion,
107
107
  type OpenEscalation,
108
108
  PLAN_REVIEW_ELEMENT,
109
+ PR_ESCALATION_PRODUCER_ELEMENTS,
110
+ PR_RECORD_ANSWER_ELEMENT,
111
+ PR_RECORD_MERGE_ANSWER_ELEMENT,
109
112
  PR_WAIT_ANSWER_ELEMENT,
110
113
  PR_WAIT_MERGE_ANSWER_ELEMENT,
111
114
  prEscalations,
@@ -251,6 +254,15 @@ export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDG
251
254
  * still reconciling genuinely-stranded rows. */
252
255
  export const FEATURE_ESCALATION_HEAL_GRACE_MS = 60_000;
253
256
 
257
+ /** Grace window (ms) before the `escalated`→`converging` PR self-heal (below) may act on a row — the
258
+ * convergence-path twin of `FEATURE_ESCALATION_HEAL_GRACE_MS` (issue #828). The sole writer of a PR's
259
+ * `status="escalated"` — `pr.persist-escalation` — stamps the row IMMEDIATELY BEFORE the engine creates
260
+ * the `wait-answer` / `wait-merge-answer` user task. A poll landing in that window would see
261
+ * `openUserTasks` report none and wrongly heal the just-raised escalation back to `converging`, hiding
262
+ * it before the operator can answer. Only heal rows whose escalation is older than this window, by when
263
+ * the user task must already be observable. */
264
+ export const PR_ESCALATION_HEAL_GRACE_MS = 60_000;
265
+
254
266
  /** Whether a converged PR is automatically driven to merge (the merge-loop). Default on; set
255
267
  * `NANO_PR_AUTO_MERGE=0` to stop at `converged` (review-only mode). */
256
268
  export const AUTO_MERGE = !["0", "false", "off", "no"].includes(
@@ -3325,6 +3337,152 @@ export async function pollUserTasks(
3325
3337
  if (queryErrored || stillParked) continue;
3326
3338
  await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
3327
3339
  }
3340
+
3341
+ // ── Self-heal: a convergence PR row holds `escalated` ONLY while parked (issue #828) ─────────────
3342
+ // The convergence-path twin of the feature self-heal above (issue #642). `pr.persist-escalation` is
3343
+ // the sole writer of a PR's `status="escalated"`, and the sole writer that moves it back off is the
3344
+ // `pr.answer-escalation` worker (the guarded `UPDATE … SET status='converging'` on user-task
3345
+ // completion). When that write is lost — e.g. the app restarts while a `wait-answer` completion /
3346
+ // `answer-escalation` job is in flight — the engine resumes the loop (token back on `review-round`,
3347
+ // no open user task) but the PR row stays `escalated` forever, since NOTHING on the PR path repairs
3348
+ // it (unlike the feature path). `/status` then shows `status="escalated"` with a null derived
3349
+ // `openEscalation` indefinitely, inviting a human to answer an already-answered question.
3350
+ //
3351
+ // Heal it engine-first, on the SAME positive-evidence contract as the feature sweep: a PR at
3352
+ // `status="escalated"` is reconciled back to `converging` only when the engine confirms NO open PR
3353
+ // escalation task (`wait-answer` / `wait-merge-answer`) for its instance. As above, presence in THIS
3354
+ // pass's `desired` set is itself positive evidence of parking (truncation only ever DROPS tasks), so a
3355
+ // PR whose escalation task was already swept is genuinely parked — skip its per-instance RPC. Only a
3356
+ // PR not confirmed parked by the sweep falls through to the per-instance `openUserTasks` check, and a
3357
+ // query error is negative evidence (skip the heal), so durable state flips only on positive proof.
3358
+ //
3359
+ // Candidate set: read through the ADR-0065 derived tracking VIEW and require `derived_status="escalated"`
3360
+ // (Copilot review of #829). A PR TERMINATED while its base status was `escalated` keeps that frozen base
3361
+ // status forever — the reconciler never rewrites it (app/instanceTracking.ts) — so a raw `prs.find({
3362
+ // status:"escalated" })` scan would reselect every such HISTORICAL row on every poll and burn up to three
3363
+ // sequential engine RPCs on each, indefinitely. The VIEW folds the terminal edge to `derived_status`
3364
+ // (out-of-band terminate → `abandoned`), so filtering on `derived_status="escalated"` bounds the pass to
3365
+ // LIVE/UNKNOWN candidates and leaves terminal rows to tracking as intended; the ACTIVE-instance gate below
3366
+ // is the correctness backstop for a candidate the VIEW hasn't yet reconciled. Writes still target `prs`.
3367
+ const sweptParkedPrEscalations = new Set<string>();
3368
+ for (const r of desired) {
3369
+ if (r.element_id !== PR_WAIT_ANSWER_ELEMENT && r.element_id !== PR_WAIT_MERGE_ANSWER_ELEMENT) continue;
3370
+ if (r.subject_type !== "pr") continue;
3371
+ if (r.process_key) sweptParkedPrEscalations.add(r.process_key);
3372
+ if (r.subject_key) sweptParkedPrEscalations.add(r.subject_key);
3373
+ }
3374
+ for (const pr of await prsTracking(data).find({ derived_status: "escalated" })) {
3375
+ if (!pr.process_key) continue;
3376
+ if (sweptParkedPrEscalations.has(pr.process_key) || sweptParkedPrEscalations.has(pr.pr_key)) continue; // parked this pass — no RPC, no heal
3377
+ // Don't race a just-raised escalation: `pr.persist-escalation` stamps `updated_at` immediately
3378
+ // before the engine creates the user task, so heal only rows past the grace window.
3379
+ const escalatedAt = Date.parse(pr.updated_at ?? "");
3380
+ if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < PR_ESCALATION_HEAL_GRACE_MS) continue;
3381
+ // Snapshot the PR generation (`process_key` + `updated_at`) and the `open` escalation rows we observe
3382
+ // NOW — BEFORE the remote engine reads below. The repair writes are fenced on this snapshot, so a
3383
+ // FRESH escalation the SAME instance opens in the read→write window is neither clobbered nor retired.
3384
+ // The convergence loop REUSES one process instance across rounds, so a re-escalation keeps the same
3385
+ // `process_key`; the discriminator is `updated_at`, which `pr.persist-escalation` re-stamps every time
3386
+ // it writes `status="escalated"`. Capturing the open-escalation ids here (not re-reading `status=open`
3387
+ // after the write) means a fresh escalation row — a NEW id inserted after this point — is never retired
3388
+ // to `stale` even though it is `open`, since it was not part of the snapshot we healed (issue #829).
3389
+ const snapshotProcessKey = pr.process_key;
3390
+ const snapshotUpdatedAt = pr.updated_at ?? null;
3391
+ const snapshotOpenEscalationIds = (await escs(data).find({ pr_key: pr.pr_key, status: "open" })).map((e) => e.id);
3392
+ let stillParked = false;
3393
+ let escalationInFlight = false;
3394
+ let instanceActive = false;
3395
+ let queryErrored = false;
3396
+ try {
3397
+ const openTasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
3398
+ stillParked = openTasks.some((t) => t.elementId === PR_WAIT_ANSWER_ELEMENT || t.elementId === PR_WAIT_MERGE_ANSWER_ELEMENT);
3399
+ // An empty open-task set is NOT yet proof the escalation is dead. Two distinct DB/engine boundaries
3400
+ // each leave the row `escalated` with NO open user task while an escalation is genuinely live:
3401
+ //
3402
+ // 1. The ANSWER side. `pr.answer-escalation` (`record-answer` / `record-merge-answer`) is the sole
3403
+ // writer that moves a PR OFF `escalated`, and both BPMN models flow the completed `wait-answer` /
3404
+ // `wait-merge-answer` user task DIRECTLY to that service task. So a normal answer has a window
3405
+ // where the user task is already gone (this query returns none) while the answer-recording job is
3406
+ // still queued or running.
3407
+ // 2. The PRODUCER side. `pr.persist-escalation` (the `PR_ESCALATION_PRODUCER_ELEMENTS`) COMMITS the
3408
+ // `open` row and `status="escalated"` IMMEDIATELY BEFORE the engine creates the `wait-answer` /
3409
+ // `wait-merge-answer` user task. The raise-time grace window covers the normal case, but if that
3410
+ // producer service task stays ACTIVE past the grace (e.g. an app restart / lease delay between
3411
+ // the DB commit and the job completing) the row is old enough to heal yet no user task exists.
3412
+ // 3. The PRODUCER→USER-TASK TOCTOU BETWEEN THE TWO READS. `openUserTasks` (read 1) can return empty
3413
+ // while a persist producer is still ACTIVE; that producer then COMPLETES and the engine creates
3414
+ // the `wait-answer` / `wait-merge-answer` user task BEFORE `searchElementInstances` (read 2). Read 2
3415
+ // then sees an ACTIVE WAIT element — not the producer. Because user-task creation does NOT re-stamp
3416
+ // the PR row's `updated_at` (only `pr.persist-escalation` does, before the task exists), the row is
3417
+ // past the grace, so neither the grace nor the producer/answer-recorder check covers it. Treat an
3418
+ // ACTIVE wait element as positive evidence here too, or the CAS wins and stales a live escalation.
3419
+ //
3420
+ // In EITHER window the escalation is arbitrarily old (past the raise-time grace), so the grace above
3421
+ // does not cover it. Healing here flips the row to `converging` and retires the still-`open`
3422
+ // escalation to `stale`, and the live escalation is lost — the answer-recorder finds no target and
3423
+ // silently drops the operator's answer, or the just-raised question never surfaces (issue #829). An
3424
+ // ACTIVE answer-recording, persist-escalation, OR wait-answer/wait-merge-answer element instance is
3425
+ // positive evidence an escalation is in flight, so treat it exactly like an open task and skip the
3426
+ // heal. But even a PR with NEITHER an open task NOR any ACTIVE escalation element is NOT yet
3427
+ // heal-eligible — it must ALSO be a confirmed-ACTIVE instance (the positive-liveness gate below);
3428
+ // a terminal/absent instance matches this negative shape too but never resumed (the #828 tear).
3429
+ if (!stillParked) {
3430
+ const elements = await engine.searchElementInstances({ processInstanceKey: pr.process_key });
3431
+ escalationInFlight = elements.some(
3432
+ (el) =>
3433
+ el.state === "ACTIVE" &&
3434
+ (el.elementId === PR_RECORD_ANSWER_ELEMENT ||
3435
+ el.elementId === PR_RECORD_MERGE_ANSWER_ELEMENT ||
3436
+ el.elementId === PR_WAIT_ANSWER_ELEMENT ||
3437
+ el.elementId === PR_WAIT_MERGE_ANSWER_ELEMENT ||
3438
+ (el.elementId != null && PR_ESCALATION_PRODUCER_ELEMENTS.includes(el.elementId))),
3439
+ );
3440
+ }
3441
+ // "No open task and no in-flight escalation element" is necessary but NOT sufficient to heal
3442
+ // (Copilot review of #829). A TERMINAL or ABSENT process instance — one cancelled, completed, or
3443
+ // dropped from the read model — ALSO reports no open task and no ACTIVE escalation element, yet its
3444
+ // loop never resumed: flipping such a frozen `escalated` row to `converging` and retiring its audit
3445
+ // rows fabricates a live loop the engine will never advance. Terminal/absent PRs are owned by the
3446
+ // ADR-0065 derived tracking + reconciliation (they read `abandoned`/settled on `derived_status`), so
3447
+ // leave them be. Require POSITIVE engine-truth that the instance is `ACTIVE` before healing — the
3448
+ // same tri-state contract as the feature-handoff probe (search ~L2562) and `makeEngineActiveProbe`
3449
+ // (app/reconcile.ts): ONLY a confirmed `ACTIVE` state heals; a known-terminal or genuinely-absent
3450
+ // instance is left to tracking; a missing/empty/unknown `state` (or a query error) is spared, never
3451
+ // healed off a wire shape we misread. So a healed row is provably the #828 lost-write tear: an
3452
+ // ACTIVE loop that resumed but whose `status="converging"` flip was lost.
3453
+ const snapshots = await engine.searchProcessInstances({ processInstanceKeys: [pr.process_key] });
3454
+ const match = snapshots.find((s) => String(s.processInstanceKey) === pr.process_key);
3455
+ instanceActive = match != null && String(match.state ?? "").trim().toUpperCase() === "ACTIVE";
3456
+ } catch (err) {
3457
+ console.error(`[poller] escalated-pr self-heal (${pr.pr_key} @ ${pr.process_key}): ${err}`);
3458
+ queryErrored = true;
3459
+ }
3460
+ if (queryErrored || stillParked || escalationInFlight || !instanceActive) continue;
3461
+ // Repair conditionally and atomically (Copilot review of #829). The read→write window between the
3462
+ // engine reads above and here is wide (two remote RPCs), so the PR may have re-escalated on the same
3463
+ // instance meanwhile; a blind `update` would then overwrite that fresh `escalated` status and retire
3464
+ // its new `open` escalation. The PR flip is therefore a SINGLE guarded UPDATE fenced on the inspected
3465
+ // snapshot — still `escalated`, same `process_key`, same `updated_at` — an atomic compare-and-swap
3466
+ // (SQLite serialises it against the worker's guarded writes in the other process). Only when that CAS
3467
+ // WON (`changed === 1`) do we retire the snapshot's own orphan escalation rows, in the SAME
3468
+ // transaction: a crash can never leave a healed PR with a dangling `open` row, nor an un-healed PR
3469
+ // with retired rows — a rolled-back heal is simply retried by a later pass. Retiring by the captured
3470
+ // snapshot ids (never a fresh `status=open` re-read) means a just-opened escalation is left untouched.
3471
+ const db = data.open();
3472
+ await db.tx(async (t) => {
3473
+ const flipped = await t.exec(
3474
+ `UPDATE "pull_requests" SET "status" = 'converging', "updated_at" = ? WHERE "pr_key" = ? AND "status" = 'escalated' AND "process_key" IS ? AND "updated_at" IS ?`,
3475
+ [at, pr.pr_key, snapshotProcessKey, snapshotUpdatedAt],
3476
+ );
3477
+ if (flipped.changed !== 1) return; // snapshot moved (a fresh escalation re-stamped the row) — leave it for a later pass
3478
+ // Retire the orphaned `open` escalation rows so the audit trail matches the healed PR row — the
3479
+ // `answer-escalation` write that would have answered/retired them was the very write we lost. We
3480
+ // mark them `stale` (not `answered`) because no operator answer was recorded for them.
3481
+ for (const escId of snapshotOpenEscalationIds) {
3482
+ await t.exec(`UPDATE "escalations" SET "status" = 'stale' WHERE "id" = ? AND "status" = 'open'`, [escId]);
3483
+ }
3484
+ });
3485
+ }
3328
3486
  }
3329
3487
 
3330
3488
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
package/app/userTasks.ts CHANGED
@@ -52,6 +52,40 @@ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
52
52
  * canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
53
53
  export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
54
54
 
55
+ /** The PR review-loop answer-recording service task (convergence-loop.bpmn) — the `pr.answer-escalation`
56
+ * job the token moves to IMMEDIATELY when `wait-answer` completes. Its ACTIVE element instance is the
57
+ * positive-evidence marker that an operator's answer is in-flight (recorded but the loop hasn't looped
58
+ * yet), so the escalation self-heal must NOT steal the row while it runs (issue #829). */
59
+ export const PR_RECORD_ANSWER_ELEMENT = "record-answer";
60
+
61
+ /** The PR merge-loop answer-recording service task (merge-loop.bpmn) — the merge-path twin of
62
+ * `PR_RECORD_ANSWER_ELEMENT`, reached when `wait-merge-answer` completes (issue #829). */
63
+ export const PR_RECORD_MERGE_ANSWER_ELEMENT = "record-merge-answer";
64
+
65
+ /** Every `pr.persist-escalation` PRODUCER element across the two PR loops — the service tasks that write
66
+ * `status="escalated"` (and INSERT the `open` escalation row) IMMEDIATELY BEFORE the engine creates the
67
+ * `wait-answer` / `wait-merge-answer` user task. An ACTIVE instance of any of these is positive evidence
68
+ * an escalation is being RAISED right now: the row is already `escalated` but no user task exists yet, so
69
+ * neither the open-task probe nor the answer-recorder probe covers it. If such a producer stays ACTIVE
70
+ * past the raise-time grace window (e.g. an app restart / lease delay between the DB commit and the job
71
+ * completing), the escalated-PR self-heal would otherwise see no parked task and wrongly retire the live
72
+ * escalation. So the heal must treat an ACTIVE producer as in-flight too (issue #829). This is the single
73
+ * source of truth for that set; `app/persistEscalationElements.test.ts` fails if it drifts from the
74
+ * `pr.persist-escalation` tasks actually modelled in the BPMN. */
75
+ export const PR_ESCALATION_PRODUCER_ELEMENTS: readonly string[] = [
76
+ // convergence-loop.bpmn
77
+ "persist-escalation",
78
+ "persist-escalation-noprogress",
79
+ "persist-escalation-maxrounds",
80
+ "persist-escalation-blockedcomments",
81
+ "persist-escalation-scope",
82
+ "persist-review-stalled",
83
+ // merge-loop.bpmn
84
+ "merge-esc-conflict",
85
+ "merge-esc-attempt",
86
+ "merge-esc-landed",
87
+ ];
88
+
55
89
  /** The ACP permission-prompt escalation (issue #559, ADR 0056) — the Tasks-inbox kind a bridged
56
90
  * `session/request_permission` surfaces under when an escalate-policy agent asks a human to Allow/Deny
57
91
  * a proposed action. Unlike the other escalation elements this is NOT a BPMN user-task element; it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.193.1",
3
+ "version": "0.194.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -83,7 +83,7 @@
83
83
  "yaml": "^2.9.0"
84
84
  },
85
85
  "overrides": {
86
- "@nanobpm/engine-wasm": "^0.9.0",
86
+ "@nanobpm/engine-wasm": "^0.10.0",
87
87
  "@semantic-release/npm": "^13.1.5"
88
88
  }
89
89
  }
@@ -316,9 +316,24 @@
316
316
  display: flex;
317
317
  gap: 8px;
318
318
  align-items: baseline;
319
- margin-bottom: 4px;
319
+ cursor: pointer;
320
+ list-style: none;
321
+ user-select: none;
322
+ }
323
+
324
+ .cockpit-agent-turn-meta::-webkit-details-marker { display: none; }
325
+
326
+ .cockpit-agent-turn-meta::before {
327
+ content: "\25B8";
328
+ color: var(--cockpit-muted);
329
+ font-size: 11px;
330
+ width: 10px;
320
331
  }
321
332
 
333
+ .cockpit-agent-turn[open] > .cockpit-agent-turn-meta::before { content: "\25BE"; }
334
+
335
+ .cockpit-agent-turn-body { margin-top: 4px; padding-left: 18px; }
336
+
322
337
  .cockpit-agent-turn-role {
323
338
  font-size: 11px;
324
339
  text-transform: uppercase;
@@ -327,6 +342,7 @@
327
342
  }
328
343
 
329
344
  .cockpit-agent-turn-iter,
345
+ .cockpit-agent-turn-time,
330
346
  .cockpit-agent-turn-metrics {
331
347
  font-size: 11px;
332
348
  color: var(--cockpit-muted);
@@ -574,6 +574,15 @@ function turnMetrics(m) {
574
574
  return dur != null ? `${base} \u00b7 ${dur}` : base;
575
575
  }
576
576
 
577
+ function turnTime(iso) {
578
+ if (iso == null || iso === "") return undefined;
579
+ const ms = Date.parse(iso);
580
+ if (!Number.isFinite(ms)) return undefined;
581
+ const d = new Date(ms);
582
+ const pad = (n) => String(n).padStart(2, "0");
583
+ return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
584
+ }
585
+
577
586
  function agentHistoryView(report) {
578
587
  const turns = (report.records ?? []).map((r) => ({
579
588
  historyItemKey: r.historyItemKey,
@@ -582,6 +591,8 @@ function agentHistoryView(report) {
582
591
  text: turnText(r),
583
592
  toolCalls: (r.toolCalls ?? []).map((c) => ({ toolCallId: c.toolCallId, toolName: c.toolName, elementId: c.elementId })),
584
593
  metrics: turnMetrics(r.metrics),
594
+ producedAt: r.producedAt != null && r.producedAt !== "" ? r.producedAt : undefined,
595
+ time: turnTime(r.producedAt),
585
596
  }));
586
597
  return {
587
598
  agentInstanceKey: report.agentInstanceKey,
@@ -639,17 +650,28 @@ function renderAgentSessions(host, doc, view, onSelect, activeInstanceKey) {
639
650
  host.appendChild(root);
640
651
  }
641
652
 
653
+ // One turn as a native collapsible <details> (collapsed by default); its <summary> heading (role,
654
+ // iteration, timestamp, metrics) toggles the details body on click (#831).
642
655
  function agentTurnBlock(doc, t) {
643
- const block = el(doc, "div", "cockpit-agent-turn");
656
+ const block = el(doc, "details", "cockpit-agent-turn");
644
657
  block.setAttribute("data-history-item-key", t.historyItemKey);
645
658
  block.setAttribute("data-role", t.role);
646
659
  block.setAttribute("data-loop-iteration", String(t.loopIteration));
647
- const meta = el(doc, "div", "cockpit-agent-turn-meta");
660
+ const meta = el(doc, "summary", "cockpit-agent-turn-meta");
648
661
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
649
662
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
663
+ if (t.time != null) {
664
+ const time = el(doc, "time", "cockpit-agent-turn-time", t.time);
665
+ if (t.producedAt != null) {
666
+ time.setAttribute("datetime", t.producedAt);
667
+ time.setAttribute("title", t.producedAt);
668
+ }
669
+ meta.appendChild(time);
670
+ }
650
671
  if (t.metrics != null) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
651
672
  block.appendChild(meta);
652
- if (t.text !== "") block.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
673
+ const body = el(doc, "div", "cockpit-agent-turn-body");
674
+ if (t.text !== "") body.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
653
675
  if (t.toolCalls.length > 0) {
654
676
  const tools = el(doc, "ul", "cockpit-agent-turn-tools");
655
677
  for (const call of t.toolCalls) {
@@ -657,8 +679,9 @@ function agentTurnBlock(doc, t) {
657
679
  li.setAttribute("data-tool-call-id", call.toolCallId);
658
680
  tools.appendChild(li);
659
681
  }
660
- block.appendChild(tools);
682
+ body.appendChild(tools);
661
683
  }
684
+ block.appendChild(body);
662
685
  return block;
663
686
  }
664
687
 
@@ -111,20 +111,35 @@ const handler: AppJobHandler<In> = async (job, app) => {
111
111
  process_instance_key: job.processInstanceKey != null ? String(job.processInstanceKey) : null,
112
112
  });
113
113
  }
114
- const escalationId = await app.data.table("escalations", "id").insert({
115
- pr_key: prKey,
116
- round_no: round,
117
- kind,
118
- question,
119
- transcript,
120
- worker,
121
- status: "open",
122
- asked_at: now,
123
- });
124
- await app.data.table("pull_requests", "pr_key").update(prKey, {
125
- status: "escalated",
126
- current_round: round,
127
- updated_at: now,
114
+ // Open the escalation row and stamp the PR to `escalated` ATOMICALLY, in ONE transaction (Copilot
115
+ // review of #829). `pr.persist-escalation` is the SOLE producer of a PR's escalated generation, and the
116
+ // escalated-PR self-heal (`pollUserTasks` in app/service.ts) fences its repair on a snapshot of the PR
117
+ // generation (`process_key` + `updated_at`) plus the `open` escalation ids it observes. If the fresh
118
+ // `open` row were INSERTED before the PR's `updated_at` re-stamp — two separate writes, as this worker
119
+ // used to do — a heal snapshot landing between them would capture the new escalation while the PR still
120
+ // matched the OLD generation; its compare-and-swap, still matching the un-bumped `updated_at`, could then
121
+ // WIN and retire the just-opened escalation to `stale`, orphaning a live user task and dropping the
122
+ // operator's next answer. Committing the insert and the generation re-stamp together closes that window
123
+ // categorically: the heal can only ever observe the new escalation AND the bumped `updated_at` atomically,
124
+ // so its snapshot-fenced CAS misses (the discriminator moved) and never clobbers. The same transaction
125
+ // also means a crash mid-write rolls back cleanly — never an `open` escalation row with no `escalated` PR.
126
+ let escalationId: number | bigint = 0;
127
+ await app.data.open().tx(async (t) => {
128
+ escalationId = await t.table("escalations", "id").insert({
129
+ pr_key: prKey,
130
+ round_no: round,
131
+ kind,
132
+ question,
133
+ transcript,
134
+ worker,
135
+ status: "open",
136
+ asked_at: now,
137
+ });
138
+ await t.table("pull_requests", "pr_key").update(prKey, {
139
+ status: "escalated",
140
+ current_round: round,
141
+ updated_at: now,
142
+ });
128
143
  });
129
144
 
130
145
  return { escalationId: Number(escalationId), escalated: true, question };