@nanobpm/nano-workforce 0.77.0 → 0.79.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 +14 -0
- package/SPEC.md +17 -14
- package/app/agentCompletion.ts +2 -0
- package/app/agentGuide.ts +2 -1
- package/app/agentic/README.md +20 -0
- package/app/agentic/cockpit/index.ts +5 -0
- package/app/agentic/cockpit/transcript-derive.test.ts +78 -0
- package/app/agentic/cockpit/transcript-derive.ts +90 -0
- package/app/agentic/transcript-events.drift.test.ts +55 -0
- package/app/agentic/transcript-events.test.ts +186 -0
- package/app/agentic/transcript-events.ts +470 -0
- package/app/agentic/transcript-fork.test.ts +156 -0
- package/app/agentic/transcript-fork.ts +151 -0
- package/app/agentic/transcript-read.ts +2 -1
- package/app/answer-escalation.test.ts +2 -2
- package/app/mergeEscalationUserTask.test.ts +77 -0
- package/app/pollUserTasks.test.ts +23 -0
- package/app/service.ts +8 -36
- package/app/userTasks.ts +7 -0
- package/docs/agent-guide.md +9 -6
- package/openapi.yaml +3 -5
- package/operations/getAgentInstructions.test.ts +1 -1
- package/operations/postMessage.ts +8 -24
- package/operations/startAndMessage.test.ts +15 -4
- package/package.json +1 -1
- package/pages/tasks.page.json +26 -3
- package/resources/processes/merge-loop.bpmn +47 -33
- package/workers/answer-escalation/worker.ts +9 -9
- package/app/answerEscalation.test.ts +0 -67
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// nano-workforce — replay-by-reseed / fork of a transcript log (ADR 0056, #251).
|
|
2
|
+
//
|
|
3
|
+
// The H3 read path (#222) can RESUME the same stream from an offset (reattach parity), but it cannot
|
|
4
|
+
// FORK: seed a NEW stream from an existing log so an exited agent's session can be branched or re-run
|
|
5
|
+
// from a chosen point ("what-if" a different continuation). dsh gets this for free because a session IS
|
|
6
|
+
// its append-only log, so forking is just re-seeding a new session from an existing log up to offset N.
|
|
7
|
+
// This module gives the transcript store the same capability WITHOUT touching the store package: it
|
|
8
|
+
// reads the source log and re-records it into a fresh stream through the store's own idempotent,
|
|
9
|
+
// offset-keyed {@link TranscriptStore.record} — so the fork is itself append-only and offset-parity
|
|
10
|
+
// with its source, and replays through the SAME resume-from-offset read path a native stream uses.
|
|
11
|
+
//
|
|
12
|
+
// Invariants preserved (ADR 0056): app-tier only, append-only (we only ever `record`, never mutate),
|
|
13
|
+
// advisory (a fork is a new advisory transcript — it gates no BPMN flow), and offset/resume wire-shape
|
|
14
|
+
// parity (the fork keeps the source offsets, so a reattach behaves identically on the branch).
|
|
15
|
+
|
|
16
|
+
import type { TranscriptChunk, TranscriptLifecycle, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
17
|
+
|
|
18
|
+
/** Raised when a fork/reseed cannot proceed — the source is missing, or the target already exists. */
|
|
19
|
+
export class TranscriptForkError extends Error {
|
|
20
|
+
readonly source: string;
|
|
21
|
+
readonly target: string;
|
|
22
|
+
constructor(source: string, target: string, message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "TranscriptForkError";
|
|
25
|
+
this.source = source;
|
|
26
|
+
this.target = target;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options controlling how a source log is reseeded into a new stream. */
|
|
31
|
+
export interface ForkTranscriptOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Seed only chunks with `offset <= throughOffset` (inclusive) — the point the branch diverges from.
|
|
34
|
+
* Omit to fork the WHOLE source log (every retained chunk). A `throughOffset` below the source's
|
|
35
|
+
* oldest retained offset yields an empty fork (a valid, if trivial, branch point).
|
|
36
|
+
*/
|
|
37
|
+
readonly throughOffset?: number;
|
|
38
|
+
/**
|
|
39
|
+
* The forked stream's retention lifecycle. Defaults to `ephemeral` — a fork is a captured branch,
|
|
40
|
+
* retained-whole then swept like any completed session, not a growing live stream.
|
|
41
|
+
*/
|
|
42
|
+
readonly lifecycle?: TranscriptLifecycle;
|
|
43
|
+
/**
|
|
44
|
+
* Allow reseeding into a target that already exists. Off by default: forking onto a populated stream
|
|
45
|
+
* would interleave two logs' bytes and defeat offset-parity, so we refuse rather than clobber. When
|
|
46
|
+
* on, seeding is still idempotent (offset-keyed), so re-running the SAME fork is a safe no-op — but
|
|
47
|
+
* the existing target must already hold exactly this seed prefix (same offsets, same chunk bytes,
|
|
48
|
+
* same lifecycle); a target that diverges from the prefix throws rather than silently interleaving.
|
|
49
|
+
*/
|
|
50
|
+
readonly allowExisting?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The outcome of a {@link forkTranscript}: the new stream, how many chunks it seeded, and its window. */
|
|
54
|
+
export interface ForkResult {
|
|
55
|
+
/** The forked stream id (the `target` argument). */
|
|
56
|
+
readonly stream: string;
|
|
57
|
+
/** The source stream the fork was seeded from. */
|
|
58
|
+
readonly source: string;
|
|
59
|
+
/** Number of chunks newly persisted into the fork. */
|
|
60
|
+
readonly seeded: number;
|
|
61
|
+
/** The highest source offset included in the fork (undefined when the fork is empty). */
|
|
62
|
+
readonly throughOffset?: number;
|
|
63
|
+
/** The forked stream's metadata after seeding. */
|
|
64
|
+
readonly meta: TranscriptStream;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fork a transcript: seed a NEW stream (`target`) from an existing log (`source`) up to a chosen offset,
|
|
69
|
+
* so an exited session can be branched and replayed independently.
|
|
70
|
+
*
|
|
71
|
+
* The fork keeps the SOURCE offsets (offset-parity), so it resumes through the identical
|
|
72
|
+
* resume-from-offset read path a native stream uses. It reads the source's retained window
|
|
73
|
+
* (`store.read`), takes the prefix at or below `throughOffset` (default: the whole log), and re-records
|
|
74
|
+
* it into `target` via the store's idempotent offset-keyed `record` — so the operation is append-only
|
|
75
|
+
* and safe to re-run. The branch is fully independent of its source thereafter: appending to either
|
|
76
|
+
* stream never affects the other.
|
|
77
|
+
*
|
|
78
|
+
* Throws {@link TranscriptForkError} when the source has no transcript, when the target already
|
|
79
|
+
* exists and `allowExisting` is not set, or when `allowExisting` is set but the existing target does
|
|
80
|
+
* not already match the reseed prefix exactly (divergent chunk bytes/offsets or a different lifecycle).
|
|
81
|
+
*/
|
|
82
|
+
export function forkTranscript(
|
|
83
|
+
store: TranscriptStore,
|
|
84
|
+
source: string,
|
|
85
|
+
target: string,
|
|
86
|
+
options: ForkTranscriptOptions = {},
|
|
87
|
+
): ForkResult {
|
|
88
|
+
if (source === target) {
|
|
89
|
+
throw new TranscriptForkError(source, target, "cannot fork a stream onto itself");
|
|
90
|
+
}
|
|
91
|
+
if (store.get(source) === undefined) {
|
|
92
|
+
throw new TranscriptForkError(source, target, `source stream "${source}" has no transcript to fork`);
|
|
93
|
+
}
|
|
94
|
+
const existing = store.get(target);
|
|
95
|
+
if (existing !== undefined && !options.allowExisting) {
|
|
96
|
+
throw new TranscriptForkError(source, target, `target stream "${target}" already exists (pass allowExisting to reseed it)`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const lifecycle: TranscriptLifecycle = options.lifecycle ?? "ephemeral";
|
|
100
|
+
const through = options.throughOffset;
|
|
101
|
+
const chunks: TranscriptChunk[] = store
|
|
102
|
+
.read(source)
|
|
103
|
+
.filter((c) => through === undefined || c.offset <= through);
|
|
104
|
+
|
|
105
|
+
// Reseeding onto an EXISTING target (allowExisting) is only safe when that target already holds
|
|
106
|
+
// exactly the prefix we are about to seed. `record()` is offset-keyed and idempotent, so it silently
|
|
107
|
+
// no-ops any offset already present — if the existing chunk at that offset differs (or the target
|
|
108
|
+
// carries offsets outside this prefix, or a different lifecycle), the reseed would leave a stream
|
|
109
|
+
// that is a MIXTURE of the prior data and the seed, breaking the documented offset-parity invariant.
|
|
110
|
+
// Validate the overlap before writing and refuse rather than clobber/interleave.
|
|
111
|
+
if (existing !== undefined) {
|
|
112
|
+
if (existing.lifecycle !== lifecycle) {
|
|
113
|
+
throw new TranscriptForkError(
|
|
114
|
+
source,
|
|
115
|
+
target,
|
|
116
|
+
`target stream "${target}" already exists with lifecycle "${existing.lifecycle}", cannot reseed as "${lifecycle}"`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const seedByOffset = new Map(chunks.map((c) => [c.offset, c.chunk]));
|
|
120
|
+
for (const c of store.read(target)) {
|
|
121
|
+
const expected = seedByOffset.get(c.offset);
|
|
122
|
+
if (expected === undefined || expected !== c.chunk) {
|
|
123
|
+
throw new TranscriptForkError(
|
|
124
|
+
source,
|
|
125
|
+
target,
|
|
126
|
+
`target stream "${target}" already contains data that does not match the reseed prefix at offset ${c.offset}; refusing to interleave`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Open the fork explicitly so an empty fork (throughOffset predating the log) is still a real,
|
|
133
|
+
// listed stream under its own lifecycle rather than a phantom — mirrors the store's open-then-record.
|
|
134
|
+
store.open(target, lifecycle);
|
|
135
|
+
const seeded = chunks.length > 0 ? store.record(target, chunks, lifecycle) : 0;
|
|
136
|
+
|
|
137
|
+
const meta = store.get(target);
|
|
138
|
+
if (meta === undefined) {
|
|
139
|
+
// Defensive: open() above guarantees a row, so this only fires on a store contract breach.
|
|
140
|
+
throw new TranscriptForkError(source, target, `fork of "${source}" into "${target}" did not persist a stream`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result: ForkResult = {
|
|
144
|
+
stream: target,
|
|
145
|
+
source,
|
|
146
|
+
seeded,
|
|
147
|
+
meta,
|
|
148
|
+
};
|
|
149
|
+
const last = chunks.at(-1);
|
|
150
|
+
return last !== undefined ? { ...result, throughOffset: last.offset } : result;
|
|
151
|
+
}
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
18
18
|
import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
|
|
19
19
|
import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
|
|
20
|
+
import { utf8ByteLength } from "./transcript-events.ts";
|
|
20
21
|
|
|
21
22
|
/** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
|
|
22
23
|
export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
|
|
23
24
|
let total = 0;
|
|
24
|
-
for (const c of chunks) total +=
|
|
25
|
+
for (const c of chunks) total += utf8ByteLength(c.chunk);
|
|
25
26
|
return total;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// completion and must transition the latest open row to `answered`, recording the submitted answer.
|
|
9
9
|
//
|
|
10
10
|
// It must ALSO move the `pull_requests` row off `status="escalated"` back to `"converging"`, exactly
|
|
11
|
-
//
|
|
11
|
+
// (the single reconcile step both the review and merge loops run). Otherwise the PR stays `escalated` (with
|
|
12
12
|
// a now-null `openEscalation`) until the re-entered round's `persist-round` runs — an inconsistent
|
|
13
13
|
// `/status` window and a divergence from the merge loop the two paths are meant to share.
|
|
14
14
|
import { test } from "node:test";
|
|
@@ -60,7 +60,7 @@ test("retires the latest open escalation to answered with the submitted answer",
|
|
|
60
60
|
assertEquals(typeof updates[0].patch.answered_at, "string", "answered_at is stamped");
|
|
61
61
|
assertEquals(prUpdates.length, 1, "the PR row is moved off `escalated`");
|
|
62
62
|
assertEquals(prUpdates[0].key, "o/r#1", "the PR keyed by prKey is updated");
|
|
63
|
-
assertEquals(prUpdates[0].patch.status, "converging", "
|
|
63
|
+
assertEquals(prUpdates[0].patch.status, "converging", "answered escalation returns the PR to converging");
|
|
64
64
|
assertEquals(typeof prUpdates[0].patch.updated_at, "string", "updated_at is stamped");
|
|
65
65
|
});
|
|
66
66
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Structural + cross-layer regression guard for converging the merge-loop escalation onto the ONE
|
|
2
|
+
// native user-task answer pathway (#256).
|
|
3
|
+
//
|
|
4
|
+
// Before #256 the merge loop parked on a durable `escalation-answered` message catch answered by a
|
|
5
|
+
// bespoke `answerEscalation()` publish — a SECOND answer pathway invisible to the Tasks inbox, so a
|
|
6
|
+
// merge escalation could not be answered from the nwf UI at all. It now parks on a native
|
|
7
|
+
// `wait-merge-answer` userTask (backed by `pr-escalation.form`) followed by the SAME
|
|
8
|
+
// `pr.answer-escalation` reconcile step the review loop's `wait-answer` runs, so both loops answer
|
|
9
|
+
// through the one canonical `completeUserTask` door and surface in the one Tasks inbox.
|
|
10
|
+
//
|
|
11
|
+
// These are pure text assertions over the committed BPMN (no engine), matching the repo's
|
|
12
|
+
// lightweight model-guard style (see mergeRebaseArm.test.ts), plus a drift guard tying the model's
|
|
13
|
+
// user-task element id to the completer's accepted escalation set so the two can't silently diverge.
|
|
14
|
+
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { ESCALATION_TASK_ELEMENTS, validateEscalationVariables } from "./agentCompletion.ts";
|
|
19
|
+
|
|
20
|
+
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
21
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
22
|
+
|
|
23
|
+
function hasFlow(source: string, target: string): boolean {
|
|
24
|
+
const re = new RegExp(
|
|
25
|
+
`<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
|
|
26
|
+
`<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
|
|
27
|
+
);
|
|
28
|
+
return re.test(flat);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("the merge escalation parks on a native wait-merge-answer userTask backed by pr-escalation.form", () => {
|
|
32
|
+
const task = flat.match(/<bpmn:userTask\b[^>]*\bid="wait-merge-answer"[\s\S]*?<\/bpmn:userTask>/);
|
|
33
|
+
assert(task, "wait-merge-answer must be a <bpmn:userTask>");
|
|
34
|
+
assertStringIncludes(task![0], 'formId="pr-escalation"', "it must render the shared pr-escalation form");
|
|
35
|
+
assertStringIncludes(task![0], "<zeebe:userTask", "it must be a native (Zeebe) user task");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the answered task reconciles the escalations row, then re-arms the merge poller", () => {
|
|
39
|
+
// wait-merge-answer → record-merge-answer (pr.answer-escalation) → arm-merge, mirroring the review
|
|
40
|
+
// loop's wait-answer → record-answer. Without the reconcile step the escalations row would stay
|
|
41
|
+
// `open` forever after the task completes (a phantom on /status).
|
|
42
|
+
const record = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-merge-answer"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
43
|
+
assert(record, "record-merge-answer service task must exist");
|
|
44
|
+
assertStringIncludes(record![0], 'type="pr.answer-escalation"', "it must run the shared reconcile worker");
|
|
45
|
+
assert(hasFlow("wait-merge-answer", "record-merge-answer"), "wait-merge-answer → record-merge-answer missing");
|
|
46
|
+
assert(hasFlow("record-merge-answer", "arm-merge"), "record-merge-answer → arm-merge (re-arm) missing");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the legacy escalation-answered message pathway is gone", () => {
|
|
50
|
+
assert(!flat.includes("escalation-answered"), "the escalation-answered message must be removed");
|
|
51
|
+
assert(!flat.includes("Message_mergeEscAnswered"), "the merge escalation message declaration must be removed");
|
|
52
|
+
// The answer wait must no longer be a message catch — it is now a user task.
|
|
53
|
+
assert(
|
|
54
|
+
!/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-merge-answer"/.test(flat),
|
|
55
|
+
"wait-merge-answer must no longer be an intermediateCatchEvent",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("drift guard: the model's merge user-task element is one the canonical completer accepts", () => {
|
|
60
|
+
// The completer refuses any user task outside ESCALATION_TASK_ELEMENTS, so a model that parks on
|
|
61
|
+
// `wait-merge-answer` while the code doesn't accept it would deploy but never be answerable — the
|
|
62
|
+
// exact silent-drift failure mode this guard closes.
|
|
63
|
+
assert(
|
|
64
|
+
ESCALATION_TASK_ELEMENTS.has("wait-merge-answer"),
|
|
65
|
+
"ESCALATION_TASK_ELEMENTS must accept wait-merge-answer",
|
|
66
|
+
);
|
|
67
|
+
// And it must map to the pr-escalation form contract (answer required) — a missing answer is
|
|
68
|
+
// rejected, proving the element resolves to the same form the model renders.
|
|
69
|
+
assert(
|
|
70
|
+
validateEscalationVariables("wait-merge-answer", {}) !== null,
|
|
71
|
+
"wait-merge-answer must enforce the pr-escalation form contract (answer required)",
|
|
72
|
+
);
|
|
73
|
+
assert(
|
|
74
|
+
validateEscalationVariables("wait-merge-answer", { answer: "rebased and retried" }) === null,
|
|
75
|
+
"a valid answer must satisfy the wait-merge-answer form contract",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
@@ -114,6 +114,29 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
114
114
|
assertEquals(byKey["ut-pr"].question, "conflicting reviews");
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
|
|
118
|
+
// During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
|
|
119
|
+
// parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
|
|
120
|
+
// review loop does, so the inbox surfaces it exactly like a review escalation — just labelled by
|
|
121
|
+
// stage. This guards the poller accepting the merge element alongside `wait-answer`.
|
|
122
|
+
const { data, stores } = memData({
|
|
123
|
+
pull_requests: [
|
|
124
|
+
{ pr_key: "o/r#31", status: "escalated", process_key: "mp-31", url: "https://github.com/o/r/pull/31" },
|
|
125
|
+
],
|
|
126
|
+
escalations: [{ id: 1, pr_key: "o/r#31", status: "open", question: "not mergeable — resolve the conflict" }],
|
|
127
|
+
});
|
|
128
|
+
const engine = fakeEngine({ "mp-31": [{ userTaskKey: "ut-merge", elementId: "wait-merge-answer" }] });
|
|
129
|
+
|
|
130
|
+
await pollUserTasks(data, engine);
|
|
131
|
+
|
|
132
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
133
|
+
assertEquals(Object.keys(byKey), ["ut-merge"]);
|
|
134
|
+
assertEquals(byKey["ut-merge"].element_id, "wait-merge-answer");
|
|
135
|
+
assertEquals(byKey["ut-merge"].kind_label, "PR merge");
|
|
136
|
+
assertEquals(byKey["ut-merge"].subject_type, "pr");
|
|
137
|
+
assertEquals(byKey["ut-merge"].question, "not mergeable — resolve the conflict");
|
|
138
|
+
});
|
|
139
|
+
|
|
117
140
|
test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
|
|
118
141
|
const { data, stores } = memData({
|
|
119
142
|
user_tasks: [
|
package/app/service.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
latestTrialMergeQuestion,
|
|
40
40
|
PLAN_REVIEW_ELEMENT,
|
|
41
41
|
PR_WAIT_ANSWER_ELEMENT,
|
|
42
|
+
PR_WAIT_MERGE_ANSWER_ELEMENT,
|
|
42
43
|
prEscalations,
|
|
43
44
|
reconcileUserTasks,
|
|
44
45
|
TRIAL_MERGE_ELEMENT,
|
|
@@ -578,35 +579,6 @@ export async function startMerge(
|
|
|
578
579
|
return { prKey: pr.prKey, mergeProcessKey: processInstanceKey };
|
|
579
580
|
}
|
|
580
581
|
|
|
581
|
-
/** Answer an open escalation → record it and resume the process. */
|
|
582
|
-
export async function answerEscalation(
|
|
583
|
-
data: DataLayer,
|
|
584
|
-
engine: EngineClient,
|
|
585
|
-
prKey: string,
|
|
586
|
-
answer: string,
|
|
587
|
-
) {
|
|
588
|
-
const open = (await escs(data).find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
|
|
589
|
-
if (open.length === 0) return { ok: false, reason: "no open escalation" };
|
|
590
|
-
const ts = now();
|
|
591
|
-
await escs(data).update(open[0].id, { answer, status: "answered", answered_at: ts });
|
|
592
|
-
// `pr.persist-escalation` always INSERTs a new open row, so a retry can leave duplicate open rows
|
|
593
|
-
// for this PR. Retire any older ones to `stale` so none is left `open` to phantom-surface on
|
|
594
|
-
// /status (mirrors `submitPr`'s resubmit cleanup and the review loop's `pr.answer-escalation`).
|
|
595
|
-
for (const dup of open.slice(1)) {
|
|
596
|
-
await escs(data).update(dup.id, { status: "stale" });
|
|
597
|
-
}
|
|
598
|
-
await prs(data).update(prKey, {
|
|
599
|
-
status: "converging",
|
|
600
|
-
updated_at: ts,
|
|
601
|
-
});
|
|
602
|
-
await engine.publishMessage({
|
|
603
|
-
name: "escalation-answered",
|
|
604
|
-
correlationKey: prKey,
|
|
605
|
-
variables: { answer, escalationId: open[0].id },
|
|
606
|
-
});
|
|
607
|
-
return { ok: true, escalationId: open[0].id };
|
|
608
|
-
}
|
|
609
|
-
|
|
610
582
|
/** A PR currently in flight, as reported by the status endpoint. */
|
|
611
583
|
export interface ActivePr {
|
|
612
584
|
prKey: string;
|
|
@@ -632,11 +604,11 @@ export interface ActivePr {
|
|
|
632
604
|
* without reading the datasource directly. The open-escalation question is derived from the
|
|
633
605
|
* canonical `escalations` audit row — the single source of truth (no denormalised PR-row
|
|
634
606
|
* pointer). A PR reads `status="escalated"` only while a token is parked awaiting a human answer,
|
|
635
|
-
* and the row it raised carries `status="open"` until that answer is recorded — by the
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
607
|
+
* and the row it raised carries `status="open"` until that answer is recorded — by the
|
|
608
|
+
* `pr.answer-escalation` step on the `wait-answer` (review loop) or `wait-merge-answer` (merge loop)
|
|
609
|
+
* user-task completion. Both loops now park on a native user task answered through the one canonical
|
|
610
|
+
* `completeUserTask` door (#256), so deriving from the row (not a per-loop wait mechanism) surfaces
|
|
611
|
+
* BOTH loops' escalations uniformly. Once answered the row leaves `open`, so `openEscalation`
|
|
640
612
|
* derives back to null. */
|
|
641
613
|
export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
642
614
|
const all = await prs(data).all();
|
|
@@ -1594,13 +1566,13 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1594
1566
|
continue;
|
|
1595
1567
|
}
|
|
1596
1568
|
for (const t of tasks) {
|
|
1597
|
-
if (t.elementId !== PR_WAIT_ANSWER_ELEMENT) continue;
|
|
1569
|
+
if (t.elementId !== PR_WAIT_ANSWER_ELEMENT && t.elementId !== PR_WAIT_MERGE_ANSWER_ELEMENT) continue;
|
|
1598
1570
|
const question = latestOpenEscalationQuestion(await prEscalations(data).find({ pr_key: pr.pr_key, status: "open" }));
|
|
1599
1571
|
push(
|
|
1600
1572
|
buildUserTaskRow(
|
|
1601
1573
|
{
|
|
1602
1574
|
userTaskKey: t.userTaskKey,
|
|
1603
|
-
elementId:
|
|
1575
|
+
elementId: t.elementId,
|
|
1604
1576
|
subjectType: "pr",
|
|
1605
1577
|
subjectKey: pr.pr_key,
|
|
1606
1578
|
subjectUrl: pr.url,
|
package/app/userTasks.ts
CHANGED
|
@@ -36,6 +36,12 @@ export const TRIAL_MERGE_ELEMENT = "trial-merge-decision";
|
|
|
36
36
|
* review loop and is handed to the next round. */
|
|
37
37
|
export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
|
|
38
38
|
|
|
39
|
+
/** The PR merge-loop escalation user task (merge-loop.bpmn) — a human answer that resumes the merge
|
|
40
|
+
* loop (re-arms the merge poller) when the PR can't be landed (not mergeable / merge blocked). The
|
|
41
|
+
* same native user-task path as the review loop's `wait-answer` (#256), answered through the one
|
|
42
|
+
* canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
|
|
43
|
+
export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
|
|
44
|
+
|
|
39
45
|
/** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
|
|
40
46
|
* the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
|
|
41
47
|
* task open; `pollUserTasks` deletes it once the task is gone. */
|
|
@@ -63,6 +69,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
|
63
69
|
[PLAN_REVIEW_ELEMENT]: "Plan review",
|
|
64
70
|
[TRIAL_MERGE_ELEMENT]: "Trial merge",
|
|
65
71
|
[PR_WAIT_ANSWER_ELEMENT]: "PR review",
|
|
72
|
+
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
|
|
66
73
|
};
|
|
67
74
|
|
|
68
75
|
/** The denormalised context the poller has resolved for an open escalation user task. */
|
package/docs/agent-guide.md
CHANGED
|
@@ -204,20 +204,23 @@ curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application
|
|
|
204
204
|
-d '{ "userTaskKey": "<key>", "variables": { "action": "rebase", "notes": "Re-run after the fix." } }'
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
-
**Answer a
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
**Answer a PR escalation** (both the review-loop `wait-answer` and the merge-loop
|
|
208
|
+
`wait-merge-answer` — both are now native user tasks answered the same way, #256).
|
|
209
|
+
Use the PR key's parked user task and submit the `pr-escalation` form's `{ answer }`:
|
|
210
210
|
|
|
211
211
|
```bash
|
|
212
|
-
curl -sS -X POST __BASE__/actions/
|
|
212
|
+
curl -sS -X POST __BASE__/actions/complete-user-task \
|
|
213
213
|
-H 'content-type: application/json' \
|
|
214
214
|
-d '{
|
|
215
|
-
"
|
|
216
|
-
"correlationKey": "owner/repo#123",
|
|
215
|
+
"userTaskKey": "<key>",
|
|
217
216
|
"variables": { "answer": "Yes — cap the retries at 5 and proceed." }
|
|
218
217
|
}'
|
|
219
218
|
```
|
|
220
219
|
|
|
220
|
+
The `userTaskKey` comes from `GET /status` or the Tasks inbox. This is the ONE
|
|
221
|
+
canonical answer door for every escalation kind; the merge loop no longer uses a
|
|
222
|
+
durable `escalation-answered` message catch.
|
|
223
|
+
|
|
221
224
|
If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
|
|
222
225
|
|
|
223
226
|
The answer is delivered to the agent as its next-round context (e.g. the `answer`,
|
package/openapi.yaml
CHANGED
|
@@ -1182,11 +1182,9 @@ paths:
|
|
|
1182
1182
|
/actions/message:
|
|
1183
1183
|
post:
|
|
1184
1184
|
operationId: postMessage
|
|
1185
|
-
summary: "Publish a message
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
kinds (task, plan-review, trial-merge, PR review-loop) are native user tasks answered via
|
|
1189
|
-
the task inbox (POST /tasks/api/complete), not this route."
|
|
1185
|
+
summary: "Publish a BPMN message (optionally correlated) into the engine. Every escalation kind
|
|
1186
|
+
(task, plan-review, trial-merge, PR review-loop, PR merge-loop) is a native user task answered
|
|
1187
|
+
via the Tasks inbox / POST /actions/complete-user-task, not this route."
|
|
1190
1188
|
requestBody:
|
|
1191
1189
|
required: true
|
|
1192
1190
|
content:
|
|
@@ -42,7 +42,7 @@ test("the guide covers every capability the endpoint promises", async () => {
|
|
|
42
42
|
assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
|
|
43
43
|
assert(md.includes("convergeOnly"), "documents review-only vs. merge");
|
|
44
44
|
assert(md.includes("start/plan-fanout"), "covers submitting an epic");
|
|
45
|
-
assert(md.includes("
|
|
45
|
+
assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
|
|
46
46
|
// …debug the system.
|
|
47
47
|
assert(md.includes("/jobs/search") && md.includes("/incidents/search"), "covers engine REST debugging");
|
|
48
48
|
assert(md.includes("processKey") || md.includes("process_key"), "relates instances to PRs");
|
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
// POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
|
|
2
|
-
// Replaces the hand-rolled action that overrode the generic publishMessage action
|
|
3
|
-
//
|
|
4
|
-
// message falls back to a plain publishMessage.
|
|
2
|
+
// Replaces the hand-rolled action that overrode the generic publishMessage action: publish an
|
|
3
|
+
// arbitrary BPMN message (optionally correlated) into the engine.
|
|
5
4
|
//
|
|
6
|
-
//
|
|
7
|
-
// `
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
|
|
13
|
-
// for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
|
|
14
|
-
// is app logic, not something the JSON schema can express.
|
|
5
|
+
// Every escalation kind is now a native `userTask` answered through the ONE canonical human/agent
|
|
6
|
+
// completer (`completeUserTask` → `completeUserTaskAttributed`) and surfaced in the Tasks inbox —
|
|
7
|
+
// including the merge-loop escalation, which converged from a durable `escalation-answered` message
|
|
8
|
+
// catch onto a native user task (#256). So this delegate no longer carries any bespoke
|
|
9
|
+
// escalation-answer discriminator; it is a thin, generic message publish. The runtime validates the
|
|
10
|
+
// body against openapi.yaml (`name` is required, so a missing name is a 400 for free).
|
|
15
11
|
|
|
16
|
-
import { answerEscalation } from "../app/service.ts";
|
|
17
12
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
18
13
|
|
|
19
14
|
export default defineOperation("postMessage", async ({ body }, app) => {
|
|
@@ -24,17 +19,6 @@ export default defineOperation("postMessage", async ({ body }, app) => {
|
|
|
24
19
|
return { status: 400, body: { error: "name is required" } };
|
|
25
20
|
}
|
|
26
21
|
|
|
27
|
-
if (name === "escalation-answered") {
|
|
28
|
-
const prKey = String(b.correlationKey ?? "");
|
|
29
|
-
const answer = String(b.variables?.answer ?? "").trim();
|
|
30
|
-
if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
31
|
-
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
32
|
-
const r = await answerEscalation(app.data, app.engine, prKey, answer);
|
|
33
|
-
if (r.ok) app.log.info("merge-loop escalation answered", { name, prKey });
|
|
34
|
-
else app.log.warn("postMessage: no open merge-loop escalation to answer", { name, prKey });
|
|
35
|
-
return { status: r.ok ? 200 : 404, body: r };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
22
|
await app.engine.publishMessage({
|
|
39
23
|
name,
|
|
40
24
|
correlationKey: b.correlationKey != null ? String(b.correlationKey) : undefined,
|
|
@@ -216,9 +216,20 @@ test("postMessage → 400 when name is blank", async () => {
|
|
|
216
216
|
assertEquals(r.body.error, "name is required");
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
-
test("postMessage
|
|
220
|
-
|
|
219
|
+
test("postMessage publishes any named message generically (no bespoke escalation branch)", async () => {
|
|
220
|
+
// Every escalation kind is now a native user task answered via /actions/complete-user-task (#256),
|
|
221
|
+
// so postMessage no longer special-cases `escalation-answered`; it is a thin generic publish. The
|
|
222
|
+
// former `escalation-answered`-without-correlationKey 400 branch is gone — such a message now just
|
|
223
|
+
// publishes (uncorrelated) like any other.
|
|
224
|
+
let published: { name: string; correlationKey?: string; variables?: unknown } | undefined;
|
|
225
|
+
const pubApp = {
|
|
226
|
+
log: noopLog(),
|
|
227
|
+
engine: { publishMessage: (m: any) => ((published = m), Promise.resolve()) },
|
|
228
|
+
} as any as AppApi;
|
|
229
|
+
const res = await postMessage(input({ name: "merge-ready", correlationKey: "o/r#1", variables: { x: 1 } }), pubApp);
|
|
221
230
|
const r = res as any;
|
|
222
|
-
assertEquals(r.status,
|
|
223
|
-
assertEquals(r.body.
|
|
231
|
+
assertEquals(r.status, 200);
|
|
232
|
+
assertEquals(r.body.ok, true);
|
|
233
|
+
assertEquals(published?.name, "merge-ready");
|
|
234
|
+
assertEquals(published?.correlationKey, "o/r#1");
|
|
224
235
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.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",
|
package/pages/tasks.page.json
CHANGED
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
"type": "dataGrid",
|
|
236
236
|
"id": "pr-reviews",
|
|
237
237
|
"props": {
|
|
238
|
-
"title": "PR
|
|
238
|
+
"title": "PR escalations",
|
|
239
239
|
"data": {
|
|
240
240
|
"kind": "datasource",
|
|
241
241
|
"source": "app",
|
|
@@ -244,7 +244,8 @@
|
|
|
244
244
|
{
|
|
245
245
|
"field": "element_id",
|
|
246
246
|
"in": [
|
|
247
|
-
"wait-answer"
|
|
247
|
+
"wait-answer",
|
|
248
|
+
"wait-merge-answer"
|
|
248
249
|
]
|
|
249
250
|
}
|
|
250
251
|
],
|
|
@@ -258,6 +259,10 @@
|
|
|
258
259
|
"field": "subject_key",
|
|
259
260
|
"header": "PR"
|
|
260
261
|
},
|
|
262
|
+
{
|
|
263
|
+
"field": "kind_label",
|
|
264
|
+
"header": "Stage"
|
|
265
|
+
},
|
|
261
266
|
{
|
|
262
267
|
"field": "subject_url",
|
|
263
268
|
"header": "PR link"
|
|
@@ -283,7 +288,25 @@
|
|
|
283
288
|
"field": "question",
|
|
284
289
|
"label": "Escalation question"
|
|
285
290
|
}
|
|
286
|
-
]
|
|
291
|
+
],
|
|
292
|
+
"form": {
|
|
293
|
+
"showWhenField": "user_task_key",
|
|
294
|
+
"title": "Answer escalation",
|
|
295
|
+
"promptField": "question",
|
|
296
|
+
"inputKey": "answer",
|
|
297
|
+
"inputLabel": "Your answer",
|
|
298
|
+
"submitLabel": "Answer & resume",
|
|
299
|
+
"action": {
|
|
300
|
+
"path": "/app/api/actions/complete-user-task",
|
|
301
|
+
"successLabel": "Answered — the loop will resume",
|
|
302
|
+
"body": {
|
|
303
|
+
"userTaskKey": "{{row.user_task_key}}",
|
|
304
|
+
"variables": {
|
|
305
|
+
"answer": "{{form.answer}}"
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
287
310
|
},
|
|
288
311
|
"refreshMs": 5000
|
|
289
312
|
}
|