@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/CHANGELOG.md +12 -0
- package/app/agentic/cockpit/agent-history-render.test.ts +24 -0
- package/app/agentic/cockpit/agent-history-render.ts +18 -4
- package/app/agentic/cockpit/agent-history-view.test.ts +16 -0
- package/app/agentic/cockpit/agent-history-view.ts +17 -0
- package/app/agentic/cockpit/mount.test.ts +12 -0
- package/app/persist-escalation.test.ts +56 -15
- package/app/persistEscalationElements.test.ts +40 -0
- package/app/pollUserTasks.test.ts +572 -3
- package/app/service.ts +158 -0
- package/app/userTasks.ts +34 -0
- package/package.json +2 -2
- package/pages/cockpit/cockpit.css +17 -1
- package/pages/cockpit/mount.js +27 -4
- package/workers/persist-escalation/worker.ts +29 -14
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.
|
|
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.
|
|
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
|
-
|
|
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);
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -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, "
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 };
|