@nanobpm/nano-workforce 0.201.0 → 0.201.2

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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +153 -0
  3. package/app/agentActivationLedger.test.ts +109 -0
  4. package/app/agentActivationLedger.ts +144 -0
  5. package/app/agentActivationsPoll.test.ts +180 -0
  6. package/app/agentCompletion.test.ts +52 -0
  7. package/app/agentCompletion.ts +20 -5
  8. package/app/agentSlaEscalation.test.ts +137 -0
  9. package/app/agentSlaEscalation.ts +169 -0
  10. package/app/agentSlaMarkerGuard.test.ts +172 -0
  11. package/app/contracts.ts +9 -0
  12. package/app/deliveryGraph.test.ts +251 -0
  13. package/app/deliveryGraph.ts +334 -1
  14. package/app/deliveryGraphCompiler.test.ts +1345 -30
  15. package/app/deliveryGraphCompiler.ts +882 -240
  16. package/app/deliveryGraphDeploy.test.ts +472 -3
  17. package/app/deliveryGraphRun.ts +2 -2
  18. package/app/deliveryHuman.test.ts +59 -5
  19. package/app/deliveryHuman.ts +115 -3
  20. package/app/deliveryRunner.test.ts +3 -3
  21. package/app/mergeLoopBehaviour.test.ts +46 -1
  22. package/app/redactText.ts +88 -0
  23. package/app/service.ts +110 -2
  24. package/db/migrations/119_agent_job_activations.sql +50 -0
  25. package/e2e/agent-sla-boundary.e2e.ts +51 -8
  26. package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
  27. package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
  28. package/e2e/delivery-graph.e2e.ts +11 -7
  29. package/package.json +1 -1
  30. package/resources/forms/delivery-escalation.form +41 -0
  31. package/resources/forms/delivery-human-generic.form +4 -3
  32. package/resources/processes/convergence-loop.bpmn +10 -0
  33. package/resources/processes/implement-cell.bpmn +4 -1
  34. package/resources/processes/merge-cell.bpmn +4 -1
  35. package/resources/processes/merge-loop.bpmn +7 -0
  36. package/resources/processes/plan-fanout.bpmn +5 -0
  37. package/resources/processes/retro.bpmn +5 -2
  38. package/test/derivation-parity/flows.ts +5 -3
  39. package/workers/conformance-record/worker.ts +15 -1
  40. package/workers/persist-escalation/worker.ts +22 -3
  41. package/workers/record-feature-escalation/worker.ts +28 -12
  42. package/workers/record-trial-merge/worker.ts +16 -1
@@ -110,6 +110,7 @@
110
110
  <zeebe:ioMapping>
111
111
  <zeebe:output source="=null" target="status" />
112
112
  <zeebe:output source="=null" target="ciBlockedReconciled" />
113
+ <zeebe:output source="=null" target="agentSlaJobType" />
113
114
  </zeebe:ioMapping>
114
115
  </bpmn:extensionElements>
115
116
  <bpmn:incoming>f_m_deps</bpmn:incoming>
@@ -302,6 +303,7 @@
302
303
  <zeebe:ioMapping>
303
304
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
304
305
  <zeebe:input source="=&#34;This PR was reported as added to the merge queue but did not land within the merge-queue landing timeout (&#34; + landedWaitTimeout + &#34;). The repository may not have actually enqueued it — e.g. a Mergify repo where a plain merge does not queue and an explicit `@Mergifyio queue` is required, or a native merge queue that dropped it silently. Add the PR to the merge queue (or merge it) manually, then reply to retry.&#34;" target="question" />
306
+ <zeebe:input source="=null" target="agentSlaJobType" />
305
307
  </zeebe:ioMapping>
306
308
  </bpmn:extensionElements>
307
309
  <bpmn:incoming>f_m_landedStalled</bpmn:incoming>
@@ -447,6 +449,7 @@
447
449
  <bpmn:extensionElements>
448
450
  <zeebe:ioMapping>
449
451
  <zeebe:output source="=&#34;recheck&#34;" target="ciOutcome" />
452
+ <zeebe:output source="=null" target="agentSlaJobType" />
450
453
  </zeebe:ioMapping>
451
454
  </bpmn:extensionElements>
452
455
  <bpmn:incoming>f_cib_armed</bpmn:incoming>
@@ -463,6 +466,7 @@
463
466
  <bpmn:extensionElements>
464
467
  <zeebe:ioMapping>
465
468
  <zeebe:output source="=&#34;escalate&#34;" target="ciOutcome" />
469
+ <zeebe:output source="=null" target="agentSlaJobType" />
466
470
  </zeebe:ioMapping>
467
471
  </bpmn:extensionElements>
468
472
  <bpmn:incoming>f_cib_esc</bpmn:incoming>
@@ -471,6 +475,7 @@
471
475
  <bpmn:extensionElements>
472
476
  <zeebe:ioMapping>
473
477
  <zeebe:output source="=&#34;escalate&#34;" target="ciOutcome" />
478
+ <zeebe:output source="=&#34;senior:fix-ci&#34;" target="agentSlaJobType" />
474
479
  </zeebe:ioMapping>
475
480
  </bpmn:extensionElements>
476
481
  <bpmn:incoming>f_ci_sla</bpmn:incoming>
@@ -578,6 +583,7 @@
578
583
  <bpmn:extensionElements>
579
584
  <zeebe:ioMapping>
580
585
  <zeebe:output source="=&#34;escalate-attempt&#34;" target="rebaseOutcome" />
586
+ <zeebe:output source="=null" target="agentSlaJobType" />
581
587
  </zeebe:ioMapping>
582
588
  </bpmn:extensionElements>
583
589
  <bpmn:incoming>f_reb_blocked</bpmn:incoming>
@@ -586,6 +592,7 @@
586
592
  <bpmn:extensionElements>
587
593
  <zeebe:ioMapping>
588
594
  <zeebe:output source="=&#34;escalate-conflict&#34;" target="rebaseOutcome" />
595
+ <zeebe:output source="=&#34;senior:rebase&#34;" target="agentSlaJobType" />
589
596
  </zeebe:ioMapping>
590
597
  </bpmn:extensionElements>
591
598
  <bpmn:incoming>f_reb_sla</bpmn:incoming>
@@ -74,6 +74,8 @@
74
74
  <nano:extend name="conflicts" type="string" list="true" optional="true" />
75
75
  <nano:extend name="failing" type="string" list="true" optional="true" />
76
76
  <nano:extend name="summary" type="string" optional="true" />
77
+ <nano:extend name="agentSlaJobType" type="string" optional="true" />
78
+ <nano:extend name="agentSlaTimeout" type="string" optional="true" />
77
79
  </nano:shape>
78
80
  <nano:shape id="RecordPlanReviewIn" name="Record plan review — input">
79
81
  <nano:extend name="planKey" type="string" />
@@ -563,6 +565,9 @@
563
565
  <zeebe:properties>
564
566
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordTrialMergeIn" />
565
567
  </zeebe:properties>
568
+ <zeebe:ioMapping>
569
+ <zeebe:input source="=null" target="agentSlaJobType" />
570
+ </zeebe:ioMapping>
566
571
  </bpmn:extensionElements>
567
572
  <bpmn:incoming>f_toRecordTrialMerge</bpmn:incoming>
568
573
  <bpmn:outgoing>f_toGwTrial</bpmn:outgoing>
@@ -25,6 +25,8 @@
25
25
  <nano:extend name="hasDeviations" type="boolean" optional="true" />
26
26
  <nano:extend name="agentSlaElapsed" type="boolean" optional="true" />
27
27
  <nano:extend name="preserveConformance" type="boolean" optional="true" />
28
+ <nano:extend name="agentSlaJobType" type="string" optional="true" />
29
+ <nano:extend name="agentSlaTimeout" type="string" optional="true" />
28
30
  <nano:extend name="summary" type="string" optional="true" />
29
31
  </nano:shape>
30
32
  </nano:shapes>
@@ -71,6 +73,7 @@
71
73
  <zeebe:ioMapping>
72
74
  <zeebe:input source="=false" target="agentSlaElapsed" />
73
75
  <zeebe:input source="=false" target="preserveConformance" />
76
+ <zeebe:input source="=null" target="agentSlaJobType" />
74
77
  </zeebe:ioMapping>
75
78
  </bpmn:extensionElements>
76
79
  <bpmn:incoming>f_toRecordConformance</bpmn:incoming>
@@ -85,7 +88,7 @@
85
88
  <zeebe:ioMapping>
86
89
  <zeebe:input source="=true" target="agentSlaElapsed" />
87
90
  <zeebe:input source="=false" target="preserveConformance" />
88
- <zeebe:input source="=&#34;The spec-conformance agent exceeded its time budget (SLA &#34; + agentSlaTimeout + &#34;) without returning a verdict — it is hung or looping. Acknowledge to record the timeout and continue to the retrospective synthesis.&#34;" target="summary" />
91
+ <zeebe:input source="=&#34;senior:conformance&#34;" target="agentSlaJobType" />
89
92
  </zeebe:ioMapping>
90
93
  </bpmn:extensionElements>
91
94
  <bpmn:incoming>f_conformanceSla</bpmn:incoming>
@@ -100,7 +103,7 @@
100
103
  <zeebe:ioMapping>
101
104
  <zeebe:input source="=true" target="agentSlaElapsed" />
102
105
  <zeebe:input source="=true" target="preserveConformance" />
103
- <zeebe:input source="=&#34;The retrospective-synthesis agent exceeded its time budget (SLA &#34; + agentSlaTimeout + &#34;) without completing — it is hung or looping. Acknowledge to record the timeout and continue.&#34;" target="summary" />
106
+ <zeebe:input source="=&#34;senior:retro&#34;" target="agentSlaJobType" />
104
107
  </zeebe:ioMapping>
105
108
  </bpmn:extensionElements>
106
109
  <bpmn:incoming>f_synthesizeSla</bpmn:incoming>
@@ -137,13 +137,15 @@ export const retroFlow: DeclarativeFlow = defineFlow(
137
137
  jobType: "pr.conformance-record",
138
138
  // The golden pins both SLA-mode control flags to `=false` on this NORMAL-path recorder
139
139
  // (round-7 review, PR #864): the flags share the external conformance agent's result
140
- // namespace, so a hoisted agent output must never select the SLA/preserve branch. The port
141
- // reproduces those pins so the parity diagnostic's only residual is the class-3 marker +
142
- // SLA boundaries.
140
+ // namespace, so a hoisted agent output must never select the SLA/preserve branch. It also
141
+ // pins `agentSlaJobType` to `=null` (PR #881 review r4214170285) so a hoisted agent-supplied
142
+ // marker can never select the SLA escalation builder. The port reproduces those pins so the
143
+ // parity diagnostic's only residual is the class-3 marker + SLA boundaries.
143
144
  io: {
144
145
  input: [
145
146
  { source: "=false", target: "agentSlaElapsed" },
146
147
  { source: "=false", target: "preserveConformance" },
148
+ { source: "=null", target: "agentSlaJobType" },
147
149
  ],
148
150
  },
149
151
  });
@@ -9,6 +9,7 @@
9
9
  // reads `status`/`pr`/`summary` through), and exposes the raw transcript under the
10
10
  // `io.nanobpm.agentResult` envelope's `.output`.
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
+ import { agentSlaEscalationQuestion } from "../../app/agentSlaEscalation.ts";
12
13
  import { markConformanceSynthesisSla, recordConformance } from "../../app/conformance.ts";
13
14
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
14
15
 
@@ -50,7 +51,20 @@ const handler: AppJobHandler<In> = async (job, app) => {
50
51
 
51
52
  const commentUrl = asStr(job.variables.commentUrl);
52
53
  const status = asStatus(job.variables.status, commentUrl !== null);
53
- const summary = asStr(job.variables.summary);
54
+ // Agent-task SLA path (issue #879): the conformance/synthesize agent's interrupting timer boundary
55
+ // routed here with `agentSlaJobType` set, so there is no agent-produced summary — build the accurate
56
+ // never-started-vs-hung reason (named job type + wait duration) from the durable activation ledger,
57
+ // replacing the old blanket "hung or looping" wording. Unset on the normal path, where the agent's
58
+ // own hoisted `summary` is used.
59
+ const slaJobType = asStr(job.variables.agentSlaJobType);
60
+ const summary = slaJobType
61
+ ? await agentSlaEscalationQuestion(app.data, {
62
+ processInstanceKey: job.processInstanceKey != null ? String(job.processInstanceKey) : "",
63
+ jobType: slaJobType,
64
+ sla: asStr(job.variables.agentSlaTimeout) ?? "PT2H",
65
+ recovery: "Acknowledge to record the timeout and continue the retrospective.",
66
+ })
67
+ : asStr(job.variables.summary);
54
68
 
55
69
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
56
70
  const env = (job.variables as Record<string, unknown>)[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
@@ -18,6 +18,7 @@
18
18
  // a token on a durable wait that has no escalation for a human to answer.
19
19
  import type { AppJobHandler } from "@nanobpm/urban";
20
20
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
21
+ import { agentSlaEscalationQuestion } from "../../app/agentSlaEscalation.ts";
21
22
  import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
22
23
  import { ensurePr, parsePr } from "../../app/service.ts";
23
24
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
@@ -60,16 +61,34 @@ const handler: AppJobHandler<In> = async (job, app) => {
60
61
  const status = rawStatus ?? "needs_input";
61
62
  const transcript = transcriptOf(job.variables);
62
63
  const worker = workerOf(job.variables);
64
+ // Agent-task SLA path (issue #879): the merge-loop's fix-ci / rebase interrupting SLA timer boundary
65
+ // routed here with `agentSlaJobType` set on the SLA end event. Rather than the old blanket "exceeded
66
+ // its time budget … check its progress" FEEL `else` (which misdiagnoses a never-activated,
67
+ // queue-starved job as hung), build the accurate never-started-vs-hung question — naming the job type
68
+ // and how long it waited — from the durable activation ledger. `agentSlaJobType` is set ONLY on the
69
+ // SLA arm, so it authoritatively selects this path; every other escalation (agent verdict, merge
70
+ // blocked, rebase conflict) leaves it unset and keeps its FEEL-built `question`. It is CLEARED on
71
+ // every return below so a later non-SLA escalation in the same looping instance can never inherit a
72
+ // stale value.
73
+ const slaJobType = nonBlank(job.variables.agentSlaJobType);
74
+ const slaQuestion = slaJobType
75
+ ? await agentSlaEscalationQuestion(app.data, {
76
+ processInstanceKey: job.processInstanceKey != null ? String(job.processInstanceKey) : "",
77
+ jobType: slaJobType,
78
+ sla: nonBlank(job.variables.agentSlaTimeout) ?? "PT2H",
79
+ recovery: "Reply to retry, or investigate why no capacity was available.",
80
+ })
81
+ : undefined;
63
82
  // Classify this job through the single canonical taxonomy (ADR 0002 §1). Only a
64
83
  // decision-required escalation opens an answerable escalation row. A blank question (or any
65
84
  // non-human-blocking status that reached here defensively) is a NON-escalation: open nothing
66
85
  // and report `escalated:false` — never FABRICATE a question and never throw. The `gw-status`
67
86
  // gateway already routes blank-question rounds back into the durable review wait, so this is
68
87
  // defence-in-depth, not the primary guard.
69
- const question = nonBlank(job.variables.question);
88
+ const question = slaQuestion ?? nonBlank(job.variables.question);
70
89
  const disposition = classifyEscalation({ kind: "review-round", status: rawStatus, question });
71
90
  if (disposition !== "decision-required" || question === undefined) {
72
- return { escalationId: null, escalated: false };
91
+ return { escalationId: null, escalated: false, agentSlaJobType: null };
73
92
  }
74
93
  const kind = status === "needs_input" ? "question" : "blocker";
75
94
  const now = new Date().toISOString();
@@ -142,7 +161,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
142
161
  });
143
162
  });
144
163
 
145
- return { escalationId: Number(escalationId), escalated: true, question };
164
+ return { escalationId: Number(escalationId), escalated: true, question, agentSlaJobType: null };
146
165
  };
147
166
 
148
167
  export default handler;
@@ -23,6 +23,7 @@
23
23
  // variables through the user-task query, so the process variable must be persisted while it is still in
24
24
  // scope on this job.
25
25
  import type { AppJobHandler } from "@nanobpm/urban";
26
+ import { agentSlaEscalationQuestion } from "../../app/agentSlaEscalation.ts";
26
27
  import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
27
28
  import { featureRuns, recordFeatureEscalation } from "../../app/feature.ts";
28
29
  import { implementEscalationQuestion, NO_RESULT_QUESTION } from "../../app/implementEscalationReason.ts";
@@ -59,6 +60,13 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
59
60
  // blank-question case.
60
61
  const agentEscalated = classifyEscalation({ kind: "task", status: job.variables.status, question: rawQuestion }) ===
61
62
  "decision-required";
63
+ // Agent-task SLA path (issue #879): the interrupting SLA timer boundary (not the agent) routed here,
64
+ // so there is NO agent result to classify — the job either never started (queue starvation) or ran but
65
+ // overran its budget. `agentSlaJobType` is set ONLY on the SLA-boundary arm (`record-escalation-sla`),
66
+ // so its presence authoritatively selects the SLA builder, which reads the durable activation ledger
67
+ // to distinguish never-started from started-but-hung and names the job type + how long it waited —
68
+ // never the old blanket "hung or looping" misdiagnosis of a queue-starved job.
69
+ const slaJobType = str(job.variables.agentSlaJobType);
62
70
  // Build the escalation question through the single canonical builder (issue #865), ALWAYS — including
63
71
  // when the agent raised its own answerable question. Passing the agent's `question` lets the builder
64
72
  // lead with it AND fold in the agent's `summary` + transcript as supporting context (#865 review —
@@ -68,18 +76,26 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
68
76
  // such as `completed`) and from a reported non-completion status — never the false "no status was
69
77
  // reported" diagnosis for a reported `escalated`/`failed` (#865 review). The reason can never drift
70
78
  // from the reconcile step because both read the same canonical builder.
71
- const question = implementEscalationQuestion({
72
- status: job.variables.status,
73
- question: rawQuestion,
74
- summary: job.variables.summary,
75
- transcriptUrl: job.variables.transcriptUrl,
76
- // Delivery EVIDENCE (#865 review): the preserved PR (an `escalated`/`failed` with a blank question
77
- // retains it — never claim "opened no pull request") and whether the reconcile step's GitHub lookup
78
- // actually CONFIRMED the delivery state (only then may the reason say "none was found"). Both are
79
- // process variables the upstream `reconcile-implement` step emits into scope.
80
- pr: job.variables.pr,
81
- deliveryVerified: job.variables.deliveryVerified,
82
- });
79
+ const question = slaJobType
80
+ ? await agentSlaEscalationQuestion(app.data, {
81
+ processInstanceKey: job.processInstanceKey != null ? String(job.processInstanceKey) : "",
82
+ jobType: slaJobType,
83
+ sla: str(job.variables.agentSlaTimeout) ?? "PT2H",
84
+ recovery:
85
+ "Answer to resume (the run continues on its deterministic feat/<task.id> branch), or abandon it.",
86
+ })
87
+ : implementEscalationQuestion({
88
+ status: job.variables.status,
89
+ question: rawQuestion,
90
+ summary: job.variables.summary,
91
+ transcriptUrl: job.variables.transcriptUrl,
92
+ // Delivery EVIDENCE (#865 review): the preserved PR (an `escalated`/`failed` with a blank
93
+ // question retains it — never claim "opened no pull request") and whether the reconcile step's
94
+ // GitHub lookup actually CONFIRMED the delivery state (only then may the reason say "none was
95
+ // found"). Both are process variables the upstream `reconcile-implement` step emits into scope.
96
+ pr: job.variables.pr,
97
+ deliveryVerified: job.variables.deliveryVerified,
98
+ });
83
99
 
84
100
  // Append to the canonical `feature_escalations` audit log (the surviving table `pollUserTasks` reads),
85
101
  // keyed by `subjectKey` — the feature run's `feature_key`, or the epic's `plan_key` for a wave slice.
@@ -2,6 +2,7 @@
2
2
  // Only `suite-failed` escalates. `clean` and textual `merge-conflict` proceed; D2/D6 own textual
3
3
  // conflict ordering, while D3 owns clean-merge/combined-suite-red semantic conflicts.
4
4
  import type { AppJobHandler } from "@nanobpm/urban";
5
+ import { agentSlaEscalationQuestion } from "../../app/agentSlaEscalation.ts";
5
6
  import {
6
7
  recordTrialMergeAudit,
7
8
  type TrialMergeResult,
@@ -46,7 +47,21 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
46
47
  const planKey = job.variables.planKey;
47
48
  const wave = waveNo(job.variables.trialMergeWave ?? job.variables.currentWave);
48
49
  const result = parseResult(job.variables.result);
49
- const summary = str(job.variables.summary) ??
50
+ // Agent-task SLA path (issue #879): the interrupting SLA timer boundary routed here with
51
+ // `agentSlaJobType` set, so there is no agent result — build the accurate never-started-vs-hung
52
+ // summary (named job type + wait duration) from the durable activation ledger instead of the old
53
+ // blanket "hung or looping" wording. On every other caller (a real agent result, the plan-fanout D3
54
+ // gate) `agentSlaJobType` is unset and the summary is derived from the agent's own `summary`/result.
55
+ const slaJobType = str(job.variables.agentSlaJobType);
56
+ const slaSummary = slaJobType
57
+ ? await agentSlaEscalationQuestion(app.data, {
58
+ processInstanceKey: job.processInstanceKey != null ? String(job.processInstanceKey) : "",
59
+ jobType: slaJobType,
60
+ sla: str(job.variables.agentSlaTimeout) ?? "PT2H",
61
+ recovery: "Acknowledge to record the timeout, then decide whether to rerun the trial merge.",
62
+ })
63
+ : undefined;
64
+ const summary = slaSummary ?? str(job.variables.summary) ??
50
65
  (result === "suite-failed" ? "Trial merge suite failed or returned no machine-readable result" : result);
51
66
  const legacyJobKey = Reflect.get(job, "key");
52
67
  const jobKey = job.jobKey ?? (legacyJobKey == null ? null : String(legacyJobKey));