@nanobpm/nano-workforce 0.209.1 → 0.210.1
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/SPEC.md +11 -1
- package/app/behindBaseRemedy.test.ts +1 -1
- package/app/convergeAdversarial.test.ts +152 -0
- package/app/failingChecksSupersede.test.ts +288 -1
- package/app/feature.test.ts +31 -0
- package/app/feature.ts +12 -0
- package/app/github.test.ts +200 -10
- package/app/github.ts +365 -59
- package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
- package/app/mergeLoopBehaviour.test.ts +48 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/readiness.test.ts +90 -1
- package/app/readiness.ts +81 -3
- package/app/service.test.ts +125 -1
- package/app/service.ts +63 -1
- package/build-info.json +4 -4
- package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
- package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
- package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
- package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
- package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
- package/docs/agent-guide.md +1 -0
- package/openapi.yaml +24 -0
- package/operations/listActivePrs.test.ts +17 -0
- package/operations/startAndMessage.test.ts +80 -0
- package/operations/startConvergenceLoop.ts +13 -1
- package/package.json +1 -1
- package/pages/home.page.json +7 -2
- package/resources/processes/convergence-loop.bpmn +8 -2
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/merge-loop.bpmn +6 -2
- package/workers/converge-feature/worker.ts +10 -1
- package/workers/landed-probe/worker.test.ts +1 -1
- package/workers/merge-stall-probe/worker.test.ts +2 -2
- package/workers/reconverge/worker.test.ts +50 -0
- package/workers/reconverge/worker.ts +7 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
-- Durable operator-steering TEXT on the feature run (issue #956 review, Copilot thread
|
|
2
|
+
-- 4547353302). `startFeature` already seeds `customInstructions` onto the feature INSTANCE's
|
|
3
|
+
-- variables, and the `pr.converge-feature` worker forwards it to `submitPr` — but the worker
|
|
4
|
+
-- publishes `converging` BEFORE enrolling, so `pollFeatureDelivery`'s re-enroll edge can win the
|
|
5
|
+
-- race (or recover an interrupted handoff) and call `submitPr` with NO job variables, starting a
|
|
6
|
+
-- loop without the instructions; the worker's later call then returns `alreadyRunning` without
|
|
7
|
+
-- updating the live loop. Persist the trimmed text on the run row BEFORE the instance/handoff
|
|
8
|
+
-- exists so the poller's enrollment path (and the worker) read the SAME durable source. The text
|
|
9
|
+
-- is the operator's own input (already written to the instance and prompts), so a TEXT column
|
|
10
|
+
-- carries no new sensitivity class. NULL when the run carries no steering.
|
|
11
|
+
--
|
|
12
|
+
-- The column is mirrored through the `delivery_units` aggregate so the `feature_runs__units` compat
|
|
13
|
+
-- VIEW stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in
|
|
14
|
+
-- app/deliveryUnit.test.ts): the Feature sync triggers (089) and VIEW (091) are re-created verbatim
|
|
15
|
+
-- plus `custom_instructions`, exactly as migration 125 did for the pr-link columns.
|
|
16
|
+
|
|
17
|
+
ALTER TABLE feature_runs ADD COLUMN custom_instructions TEXT;
|
|
18
|
+
|
|
19
|
+
ALTER TABLE delivery_units ADD COLUMN custom_instructions TEXT;
|
|
20
|
+
|
|
21
|
+
DROP TRIGGER IF EXISTS feature_runs__du_ai;
|
|
22
|
+
CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
|
|
23
|
+
BEGIN
|
|
24
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, custom_instructions, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
25
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.custom_instructions, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
26
|
+
END;
|
|
27
|
+
|
|
28
|
+
DROP TRIGGER IF EXISTS feature_runs__du_au;
|
|
29
|
+
CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
|
|
30
|
+
BEGIN
|
|
31
|
+
INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, custom_instructions, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
|
|
32
|
+
VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.custom_instructions, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
DROP VIEW IF EXISTS feature_runs__units;
|
|
36
|
+
CREATE VIEW feature_runs__units AS
|
|
37
|
+
SELECT
|
|
38
|
+
du.legacy_key AS feature_key,
|
|
39
|
+
du.repo AS repo,
|
|
40
|
+
du.issue_number AS issue_number,
|
|
41
|
+
du.issue_url AS issue_url,
|
|
42
|
+
du.base_branch AS base_branch,
|
|
43
|
+
du.status AS status,
|
|
44
|
+
du.process_key AS process_key,
|
|
45
|
+
du.pr_key AS pr_key,
|
|
46
|
+
du.converge AS converge,
|
|
47
|
+
du.auto_merge AS auto_merge,
|
|
48
|
+
du.human_approval AS human_approval,
|
|
49
|
+
du.auto_resolve_escalations AS auto_resolve_escalations,
|
|
50
|
+
du.custom_instructions AS custom_instructions,
|
|
51
|
+
du.outcome AS outcome,
|
|
52
|
+
du.created_at AS created_at,
|
|
53
|
+
du.updated_at AS updated_at,
|
|
54
|
+
du.delivery_label AS delivery_label,
|
|
55
|
+
du.pr_linked_pr_key AS pr_linked_pr_key,
|
|
56
|
+
du.pr_link_want_pr_key AS pr_link_want_pr_key,
|
|
57
|
+
du.pr_link_lease_at AS pr_link_lease_at,
|
|
58
|
+
du.title AS title,
|
|
59
|
+
du.acknowledged_at AS acknowledged_at,
|
|
60
|
+
du.stage AS stage,
|
|
61
|
+
du.stage_state AS stage_state,
|
|
62
|
+
du.stage_skipped AS stage_skipped,
|
|
63
|
+
du.attention AS attention,
|
|
64
|
+
du.list_bucket AS list_bucket
|
|
65
|
+
FROM delivery_units du
|
|
66
|
+
WHERE du.kind = 'feature';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
|
|
2
|
+
-- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
|
|
3
|
+
-- re-emission of 135 with `custom_instructions_text` (136, issue #956 review) added; the derived columns are emitted
|
|
4
|
+
-- VERBATIM from app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each
|
|
5
|
+
-- file). Numbered after 137.
|
|
6
|
+
|
|
7
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
8
|
+
|
|
9
|
+
CREATE VIEW pull_requests_read_model AS
|
|
10
|
+
SELECT
|
|
11
|
+
pr.pr_key AS pr_key,
|
|
12
|
+
pr.repo AS repo,
|
|
13
|
+
pr.number AS number,
|
|
14
|
+
pr.url AS url,
|
|
15
|
+
pr.title AS title,
|
|
16
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
17
|
+
pr.current_round AS current_round,
|
|
18
|
+
pr.process_key AS process_key,
|
|
19
|
+
pr.waiting_since AS waiting_since,
|
|
20
|
+
pr.last_review_id AS last_review_id,
|
|
21
|
+
pr.outcome AS outcome,
|
|
22
|
+
pr.created_at AS created_at,
|
|
23
|
+
pr.updated_at AS updated_at,
|
|
24
|
+
pr.converged_at AS converged_at,
|
|
25
|
+
pr.merged_at AS merged_at,
|
|
26
|
+
pr.active_worker AS active_worker,
|
|
27
|
+
pr.lease_until AS lease_until,
|
|
28
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
29
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
30
|
+
pr.abandon_token AS abandon_token,
|
|
31
|
+
pr.incident_key AS incident_key,
|
|
32
|
+
pr.incident_type AS incident_type,
|
|
33
|
+
pr.incident_since AS incident_since,
|
|
34
|
+
pr.incident_message AS incident_message,
|
|
35
|
+
pr.last_round_head AS last_round_head,
|
|
36
|
+
pr.last_progress_job_key AS last_progress_job_key,
|
|
37
|
+
pr.last_progress_result AS last_progress_result,
|
|
38
|
+
pr.last_progress_agent_watermark AS last_progress_agent_watermark,
|
|
39
|
+
pr.churn_escalated_round AS churn_escalated_round,
|
|
40
|
+
pr.root_request_key AS root_request_key,
|
|
41
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
42
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
43
|
+
pr.auto_resolve_escalations AS auto_resolve_escalations,
|
|
44
|
+
pr.termination_reason AS termination_reason,
|
|
45
|
+
pr.termination_process_key AS termination_process_key,
|
|
46
|
+
pr.retire_pending_key AS retire_pending_key,
|
|
47
|
+
pr.merge_requested AS merge_requested,
|
|
48
|
+
pr.merge_human_approval AS merge_human_approval,
|
|
49
|
+
pr.custom_instructions AS custom_instructions,
|
|
50
|
+
pr.custom_instructions_text AS custom_instructions_text,
|
|
51
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
52
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
53
|
+
FROM pull_requests__tracking pr;
|
package/docs/agent-guide.md
CHANGED
|
@@ -76,6 +76,7 @@ curl -sS -X POST __BASE__/actions/start/convergence-loop \
|
|
|
76
76
|
| `maxRounds` | integer | round cap before escalating (1–100; default `NANO_PR_MAX_ROUNDS`, 20). |
|
|
77
77
|
| `dependsOn` | string[] | `prKey`s that must land before this one merges. |
|
|
78
78
|
| `autoResolveEscalations` | boolean | opt in to the escalation steward (§3). Default `false`. |
|
|
79
|
+
| `customInstructions` | string | OPTIONAL free-text steering appended to **every** converge round's agent prompt (review-round and adversarial-review) under a `## Operator custom instructions` block — e.g. `"commit as josh.wulf@camunda.com"`, `"don't touch file X"`, `"keep the diff small"`. Blank/whitespace is treated as absent (the prompt stays byte-identical to no-instructions); a non-string is a `400`. Persists across reconverge re-dispatches. Read it back as `hasCustomInstructions` on the PR's `/status` row. |
|
|
79
80
|
|
|
80
81
|
Idempotent on the PR key: re-POSTing refreshes the record instead of starting a second
|
|
81
82
|
loop. The 202 response echoes `prKey` and `processKey`.
|
package/openapi.yaml
CHANGED
|
@@ -92,6 +92,7 @@ components:
|
|
|
92
92
|
- activeWorker
|
|
93
93
|
- leaseUntil
|
|
94
94
|
- autoResolveEscalations
|
|
95
|
+
- hasCustomInstructions
|
|
95
96
|
- merge
|
|
96
97
|
- incident
|
|
97
98
|
- terminationReason
|
|
@@ -172,6 +173,13 @@ components:
|
|
|
172
173
|
Whether this PR's run opted into automatic escalation resolution by the escalation
|
|
173
174
|
steward (issue #889). Derived from the subject row's durable `auto_resolve_escalations`
|
|
174
175
|
flag, never a clobbered process variable.
|
|
176
|
+
hasCustomInstructions:
|
|
177
|
+
type: boolean
|
|
178
|
+
description: >-
|
|
179
|
+
Whether this run's converge agents carry operator custom instructions (issue #956) —
|
|
180
|
+
the loop was started with `customInstructions` and every round's agent prompt is being
|
|
181
|
+
steered. Served from the durable `custom_instructions` row projection that `submitPr`
|
|
182
|
+
stamps at submit/reopen; a pre-#956 (NULL) row reads false. Never an engine read.
|
|
175
183
|
merge:
|
|
176
184
|
type: object
|
|
177
185
|
additionalProperties: false
|
|
@@ -2253,6 +2261,10 @@ components:
|
|
|
2253
2261
|
autoResolveEscalations:
|
|
2254
2262
|
type: boolean
|
|
2255
2263
|
description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
|
|
2264
|
+
customInstructions:
|
|
2265
|
+
type: string
|
|
2266
|
+
maxLength: 8000
|
|
2267
|
+
description: "OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`."
|
|
2256
2268
|
convergeOnly:
|
|
2257
2269
|
type: boolean
|
|
2258
2270
|
deprecated: true
|
|
@@ -2280,6 +2292,10 @@ components:
|
|
|
2280
2292
|
autoResolveEscalations:
|
|
2281
2293
|
type: boolean
|
|
2282
2294
|
description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
|
|
2295
|
+
customInstructions:
|
|
2296
|
+
type: string
|
|
2297
|
+
maxLength: 8000
|
|
2298
|
+
description: "OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`."
|
|
2283
2299
|
convergeOnly:
|
|
2284
2300
|
type: boolean
|
|
2285
2301
|
deprecated: true
|
|
@@ -5304,6 +5320,10 @@ paths:
|
|
|
5304
5320
|
autoResolveEscalations:
|
|
5305
5321
|
type: boolean
|
|
5306
5322
|
description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
|
|
5323
|
+
customInstructions:
|
|
5324
|
+
type: string
|
|
5325
|
+
maxLength: 8000
|
|
5326
|
+
description: OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`.
|
|
5307
5327
|
convergeOnly:
|
|
5308
5328
|
type: boolean
|
|
5309
5329
|
deprecated: true
|
|
@@ -5330,6 +5350,10 @@ paths:
|
|
|
5330
5350
|
autoResolveEscalations:
|
|
5331
5351
|
type: boolean
|
|
5332
5352
|
description: "Opt this loop's escalations into the escalation steward (issue #889) — it reviews each open escalation every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions. Default off."
|
|
5353
|
+
customInstructions:
|
|
5354
|
+
type: string
|
|
5355
|
+
maxLength: 8000
|
|
5356
|
+
description: OPTIONAL free-text steering appended to every converge round's agent prompt (review-round and adversarial-review), like `FeatureStartByIssue.customInstructions` for the implementer. Blank is treated as absent; a non-string is a 400. Surfaced on `/status` as `hasCustomInstructions`.
|
|
5333
5357
|
convergeOnly:
|
|
5334
5358
|
type: boolean
|
|
5335
5359
|
deprecated: true
|
|
@@ -66,6 +66,23 @@ test("returns 200 with a count + projected active PRs", async () => {
|
|
|
66
66
|
assertEquals(r.body.prs[0].processKey, "9");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
test("surfaces hasCustomInstructions from the durable row projection (issue #956)", async () => {
|
|
70
|
+
// The operator can see that a loop is being steered without opening the engine: `submitPr` stamps
|
|
71
|
+
// the `custom_instructions` column (1/0) at submit/reopen, and `/status` serves it as a boolean.
|
|
72
|
+
// A legacy row (NULL — written before the projection existed) reads false, never an engine read.
|
|
73
|
+
const app = memApp([
|
|
74
|
+
{ pr_key: "o/r#30", repo: "o/r", number: 30, url: "u30", title: "steered", status: "converging", current_round: 1, process_key: "PI-30", updated_at: "2026-01-03", custom_instructions: 1 },
|
|
75
|
+
{ pr_key: "o/r#31", repo: "o/r", number: 31, url: "u31", title: "plain", status: "converging", current_round: 1, process_key: "PI-31", updated_at: "2026-01-02", custom_instructions: 0 },
|
|
76
|
+
{ pr_key: "o/r#32", repo: "o/r", number: 32, url: "u32", title: "legacy", status: "converging", current_round: 1, process_key: "PI-32", updated_at: "2026-01-01" },
|
|
77
|
+
]);
|
|
78
|
+
const res = (await handler(input(), app)) as any;
|
|
79
|
+
assertEquals(res.status, 200);
|
|
80
|
+
const byKey = (k: string) => res.body.prs.find((p: any) => p.prKey === k);
|
|
81
|
+
assertEquals(byKey("o/r#30").hasCustomInstructions, true, "a steered loop is visible");
|
|
82
|
+
assertEquals(byKey("o/r#31").hasCustomInstructions, false, "an unsteered loop reads false");
|
|
83
|
+
assertEquals(byKey("o/r#32").hasCustomInstructions, false, "a legacy (NULL) row reads false");
|
|
84
|
+
});
|
|
85
|
+
|
|
69
86
|
test("carries the running app's identity as a top-level version object (issue #925)", async () => {
|
|
70
87
|
// `/status` is the endpoint everyone polls, so it must answer "is the fix live?" on its own:
|
|
71
88
|
// version.version + version.gitSha, derived from the SAME app/version.ts getter /version uses.
|
|
@@ -207,6 +207,86 @@ test("startConvergenceLoop → 400 on a non-boolean autoMerge (issue #916)", asy
|
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
+
// Issue #956: the convergence-loop start door accepts an optional `customInstructions` string and
|
|
211
|
+
// threads it to every converge round's agent prompt — mirroring the feature door. The capture
|
|
212
|
+
// harness above records `convergeOnly`; this one records `customInstructions` off the same
|
|
213
|
+
// createInstance variables.
|
|
214
|
+
function captureInstructions() {
|
|
215
|
+
const rows: Record<string, unknown>[] = [];
|
|
216
|
+
let captured: unknown;
|
|
217
|
+
const data = {
|
|
218
|
+
table: (_name: string, key: string) => ({
|
|
219
|
+
get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
220
|
+
find: (q: Record<string, unknown>) =>
|
|
221
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
222
|
+
insert: (r: Record<string, unknown>) => {
|
|
223
|
+
rows.push(r);
|
|
224
|
+
return Promise.resolve(r);
|
|
225
|
+
},
|
|
226
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
227
|
+
const row = rows.find((r) => r[key] === k);
|
|
228
|
+
if (row) Object.assign(row, patch);
|
|
229
|
+
return Promise.resolve(row);
|
|
230
|
+
},
|
|
231
|
+
delete: (k: unknown) => {
|
|
232
|
+
const i = rows.findIndex((r) => r[key] === k);
|
|
233
|
+
if (i >= 0) rows.splice(i, 1);
|
|
234
|
+
return Promise.resolve();
|
|
235
|
+
},
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
const engine = {
|
|
239
|
+
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
240
|
+
captured = req.variables?.customInstructions;
|
|
241
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
return { app: { data, engine, log: noopLog() } as any as AppApi, get: () => captured };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
test("startConvergenceLoop threads customInstructions (trimmed) into the loop's variables (#956)", async () => {
|
|
248
|
+
await withGithubOff(async () => {
|
|
249
|
+
const { app: capApp, get } = captureInstructions();
|
|
250
|
+
const res = await startConvergenceLoop(
|
|
251
|
+
input({ pr: "owner/repo#20", customInstructions: " commit as josh.wulf@camunda.com " }),
|
|
252
|
+
capApp,
|
|
253
|
+
);
|
|
254
|
+
assertEquals((res as any).status, 202);
|
|
255
|
+
assertEquals(get(), "commit as josh.wulf@camunda.com");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("startConvergenceLoop seeds customInstructions null when omitted or blank (#956)", async () => {
|
|
260
|
+
await withGithubOff(async () => {
|
|
261
|
+
const omitted = captureInstructions();
|
|
262
|
+
const res = await startConvergenceLoop(input({ pr: "owner/repo#21" }), omitted.app);
|
|
263
|
+
assertEquals((res as any).status, 202);
|
|
264
|
+
assertEquals(omitted.get(), null, "omitted ⇒ null, so the appendPrompt FEEL skips the block");
|
|
265
|
+
|
|
266
|
+
const blank = captureInstructions();
|
|
267
|
+
const blankRes = await startConvergenceLoop(
|
|
268
|
+
input({ pr: "owner/repo#22", customInstructions: " " }),
|
|
269
|
+
blank.app,
|
|
270
|
+
);
|
|
271
|
+
assertEquals((blankRes as any).status, 202);
|
|
272
|
+
assertEquals(blank.get(), null, "blank/whitespace ⇒ null (byte-identical prompt to today)");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Mirrors the `autoMerge` non-boolean 400 (issue #916): a present-but-non-string `customInstructions`
|
|
277
|
+
// must not be silently dropped — the operator believes they steered the loop when nothing was
|
|
278
|
+
// appended. Rejected at the edge with a 400.
|
|
279
|
+
test("startConvergenceLoop → 400 on a non-string customInstructions (#956)", async () => {
|
|
280
|
+
await withGithubOff(async () => {
|
|
281
|
+
const { app: capApp, get } = captureInstructions();
|
|
282
|
+
const res = await startConvergenceLoop(input({ pr: "owner/repo#23", customInstructions: 42 }), capApp);
|
|
283
|
+
const r = res as any;
|
|
284
|
+
assertEquals(r.status, 400);
|
|
285
|
+
assertEquals(typeof r.body.error, "string");
|
|
286
|
+
assertEquals(get(), undefined, "a rejected door never reaches the engine");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
210
290
|
test("startPlanFanout → 400 on an unparseable issue reference", async () => {
|
|
211
291
|
const res = await startPlanFanout(input({ issue: "" }), app);
|
|
212
292
|
const r = res as any;
|
|
@@ -44,7 +44,18 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
|
|
|
44
44
|
// Opt-in escalation steward (issue #889): only when explicitly requested do this loop's
|
|
45
45
|
// escalations become eligible for steward auto-resolution. Default off.
|
|
46
46
|
const autoResolveEscalations = "autoResolveEscalations" in body && body.autoResolveEscalations === true;
|
|
47
|
-
|
|
47
|
+
// Optional operator steering (issue #956), mirrored from the feature door: a present-but-non-string
|
|
48
|
+
// `customInstructions` is a 400 (the same way `autoMerge` is validated, #916) — silently dropping it
|
|
49
|
+
// would leave the operator believing they steered the loop when nothing was appended. Blank is
|
|
50
|
+
// normalized to null downstream (submitPr) so the prompt stays byte-identical to no-instructions.
|
|
51
|
+
if ("customInstructions" in body && typeof body.customInstructions !== "string") {
|
|
52
|
+
app.log.warn("start-convergence rejected: customInstructions must be a string", { customInstructions: body.customInstructions });
|
|
53
|
+
return { status: 400, body: { error: "customInstructions must be a string" } };
|
|
54
|
+
}
|
|
55
|
+
const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
|
|
56
|
+
? body.customInstructions
|
|
57
|
+
: null;
|
|
58
|
+
const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, false, null, false, autoResolveEscalations, autoMerge, null, null, customInstructions);
|
|
48
59
|
app.log.info("convergence loop started", {
|
|
49
60
|
prKey: parsed.prKey,
|
|
50
61
|
alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
|
|
@@ -52,6 +63,7 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
|
|
|
52
63
|
maxRounds,
|
|
53
64
|
autoMerge: autoMerge ?? body.convergeOnly !== true,
|
|
54
65
|
autoResolveEscalations,
|
|
66
|
+
hasCustomInstructions: customInstructions != null && customInstructions.trim() !== "",
|
|
55
67
|
});
|
|
56
68
|
return { status: 202, body: result };
|
|
57
69
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.210.1",
|
|
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/home.page.json
CHANGED
|
@@ -136,12 +136,17 @@
|
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
"key": "autoMerge",
|
|
139
|
-
"label": "Auto-merge
|
|
139
|
+
"label": "Auto-merge — after convergence, drive the merge-loop",
|
|
140
140
|
"type": "checkbox"
|
|
141
141
|
},
|
|
142
|
+
{
|
|
143
|
+
"key": "customInstructions",
|
|
144
|
+
"label": "Custom instructions (optional) — extra steering appended to every converge round's agent prompt, e.g. “commit as josh.wulf@camunda.com; keep the diff small; don't touch file X”",
|
|
145
|
+
"type": "text"
|
|
146
|
+
},
|
|
142
147
|
{
|
|
143
148
|
"key": "autoResolveEscalations",
|
|
144
|
-
"label": "Automatically resolve escalations
|
|
149
|
+
"label": "Automatically resolve escalations — the opt-in escalation steward reviews this loop's open escalations every 30 min and submits a recommended choice, recorded reviewably in Auto-resolutions",
|
|
145
150
|
"type": "checkbox"
|
|
146
151
|
}
|
|
147
152
|
]
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrReviewRoundOut" />
|
|
150
150
|
</zeebe:properties>
|
|
151
151
|
<zeebe:ioMapping>
|
|
152
|
-
<zeebe:input source="=(if (abandonBrief = null) then "" else abandonBrief)" target="appendPrompt" />
|
|
152
|
+
<zeebe:input source="=(if (abandonBrief = null) then "" else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
153
153
|
<zeebe:input source="=round" target="guardRound" />
|
|
154
154
|
<zeebe:input source="=maxRounds" target="guardMaxRounds" />
|
|
155
155
|
<zeebe:input source="=advPass" target="guardAdvPass" />
|
|
@@ -169,6 +169,7 @@
|
|
|
169
169
|
<zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
|
|
170
170
|
<zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
|
|
171
171
|
<zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
|
|
172
|
+
<zeebe:input source="=customInstructions" target="guardCustomInstructions" />
|
|
172
173
|
<zeebe:output source="=null" target="answer" />
|
|
173
174
|
<zeebe:output source="=false" target="scopePending" />
|
|
174
175
|
<zeebe:output source="=null" target="adversarialFindings" />
|
|
@@ -191,6 +192,7 @@
|
|
|
191
192
|
<zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
|
|
192
193
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
193
194
|
<zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
|
|
195
|
+
<zeebe:output source="=guardCustomInstructions" target="customInstructions" />
|
|
194
196
|
</zeebe:ioMapping>
|
|
195
197
|
</bpmn:extensionElements>
|
|
196
198
|
<bpmn:incoming>f_capture</bpmn:incoming>
|
|
@@ -245,7 +247,7 @@
|
|
|
245
247
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrAdversarialReviewOut" />
|
|
246
248
|
</zeebe:properties>
|
|
247
249
|
<zeebe:ioMapping>
|
|
248
|
-
<zeebe:input source="=(if (abandonBrief = null) then "" else abandonBrief)" target="appendPrompt" />
|
|
250
|
+
<zeebe:input source="=(if (abandonBrief = null) then "" else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
249
251
|
<zeebe:input source="=round" target="guardRound" />
|
|
250
252
|
<zeebe:input source="=maxRounds" target="guardMaxRounds" />
|
|
251
253
|
<zeebe:input source="=advPass" target="guardAdvPass" />
|
|
@@ -265,6 +267,7 @@
|
|
|
265
267
|
<zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
|
|
266
268
|
<zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
|
|
267
269
|
<zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
|
|
270
|
+
<zeebe:input source="=customInstructions" target="guardCustomInstructions" />
|
|
268
271
|
<zeebe:output source="=adversarialFindings" target="adversarialFindings" />
|
|
269
272
|
<zeebe:output source="=(if guardAdvPass = null then 0 else guardAdvPass) + 1" target="advPass" />
|
|
270
273
|
<zeebe:output source="=(if adversarialFindings != null and trim(adversarialFindings) != "" then null else status)" target="status" />
|
|
@@ -288,6 +291,7 @@
|
|
|
288
291
|
<zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
|
|
289
292
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
290
293
|
<zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
|
|
294
|
+
<zeebe:output source="=guardCustomInstructions" target="customInstructions" />
|
|
291
295
|
</zeebe:ioMapping>
|
|
292
296
|
</bpmn:extensionElements>
|
|
293
297
|
<bpmn:incoming>f_advDue</bpmn:incoming>
|
|
@@ -579,6 +583,7 @@
|
|
|
579
583
|
<zeebe:input source="=roundEntryHead" target="guardRoundEntryHead" />
|
|
580
584
|
<zeebe:input source="=abandonUrl" target="guardAbandonUrl" />
|
|
581
585
|
<zeebe:input source="=abandonBrief" target="guardAbandonBrief" />
|
|
586
|
+
<zeebe:input source="=customInstructions" target="guardCustomInstructions" />
|
|
582
587
|
<zeebe:output source="=null" target="scopeAnswer" />
|
|
583
588
|
<zeebe:output source="=guardRound" target="round" />
|
|
584
589
|
<zeebe:output source="=guardMaxRounds" target="maxRounds" />
|
|
@@ -599,6 +604,7 @@
|
|
|
599
604
|
<zeebe:output source="=guardRoundEntryHead" target="roundEntryHead" />
|
|
600
605
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
601
606
|
<zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
|
|
607
|
+
<zeebe:output source="=guardCustomInstructions" target="customInstructions" />
|
|
602
608
|
</zeebe:ioMapping>
|
|
603
609
|
</bpmn:extensionElements>
|
|
604
610
|
<bpmn:incoming>f_convergeOk</bpmn:incoming>
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<nano:extend name="prKey" type="string" optional="true" />
|
|
20
20
|
<nano:extend name="autoMerge" type="boolean" optional="true" />
|
|
21
21
|
<nano:extend name="humanApproval" type="boolean" optional="true" />
|
|
22
|
+
<nano:extend name="customInstructions" type="string" optional="true" />
|
|
22
23
|
</nano:shape>
|
|
23
24
|
</nano:shapes>
|
|
24
25
|
</bpmn:extensionElements>
|
|
@@ -477,8 +477,9 @@
|
|
|
477
477
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="FixCiOut" />
|
|
478
478
|
</zeebe:properties>
|
|
479
479
|
<zeebe:ioMapping>
|
|
480
|
-
<zeebe:input source="=(if (failingChecksList = null or failingChecksList = "") then "" else " Failing checks to make green: " + failingChecksList) + (if (abandonBrief = null) then "" else abandonBrief)" target="appendPrompt" />
|
|
480
|
+
<zeebe:input source="=(if (failingChecksList = null or failingChecksList = "") then "" else " Failing checks to make green: " + failingChecksList) + (if (abandonBrief = null) then "" else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
481
481
|
<zeebe:input source="=ciFixRound" target="guardCiFixRound" />
|
|
482
|
+
<zeebe:input source="=customInstructions" target="guardCustomInstructions" />
|
|
482
483
|
<zeebe:input source="=ciFixMax" target="guardCiFixMax" />
|
|
483
484
|
<zeebe:input source="=rebaseRound" target="guardRebaseRound" />
|
|
484
485
|
<zeebe:input source="=rebaseMax" target="guardRebaseMax" />
|
|
@@ -522,6 +523,7 @@
|
|
|
522
523
|
<zeebe:output source="=guardRound" target="round" />
|
|
523
524
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
524
525
|
<zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
|
|
526
|
+
<zeebe:output source="=guardCustomInstructions" target="customInstructions" />
|
|
525
527
|
<zeebe:output source="=guardReviewRequestRound" target="reviewRequestRound" />
|
|
526
528
|
<zeebe:output source="=guardReviewRequestMax" target="reviewRequestMax" />
|
|
527
529
|
<zeebe:output source="=guardReviewRequestWait" target="reviewRequestWait" />
|
|
@@ -687,8 +689,9 @@
|
|
|
687
689
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="RebaseOut" />
|
|
688
690
|
</zeebe:properties>
|
|
689
691
|
<zeebe:ioMapping>
|
|
690
|
-
<zeebe:input source="=(if (abandonBrief = null) then "" else abandonBrief)" target="appendPrompt" />
|
|
692
|
+
<zeebe:input source="=(if (abandonBrief = null) then "" else abandonBrief) + (if (is defined(customInstructions) = false or customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions)" target="appendPrompt" />
|
|
691
693
|
<zeebe:input source="=ciFixRound" target="guardCiFixRound" />
|
|
694
|
+
<zeebe:input source="=customInstructions" target="guardCustomInstructions" />
|
|
692
695
|
<zeebe:input source="=ciFixMax" target="guardCiFixMax" />
|
|
693
696
|
<zeebe:input source="=rebaseRound" target="guardRebaseRound" />
|
|
694
697
|
<zeebe:input source="=rebaseMax" target="guardRebaseMax" />
|
|
@@ -732,6 +735,7 @@
|
|
|
732
735
|
<zeebe:output source="=guardRound" target="round" />
|
|
733
736
|
<zeebe:output source="=guardAbandonUrl" target="abandonUrl" />
|
|
734
737
|
<zeebe:output source="=guardAbandonBrief" target="abandonBrief" />
|
|
738
|
+
<zeebe:output source="=guardCustomInstructions" target="customInstructions" />
|
|
735
739
|
<zeebe:output source="=guardReviewRequestRound" target="reviewRequestRound" />
|
|
736
740
|
<zeebe:output source="=guardReviewRequestMax" target="reviewRequestMax" />
|
|
737
741
|
<zeebe:output source="=guardReviewRequestWait" target="reviewRequestWait" />
|
|
@@ -53,13 +53,22 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
|
53
53
|
// read the durable flag off the `feature_runs` row (the source of truth) and hand it to `submitPr`.
|
|
54
54
|
const featureRow = await featureRuns(app.data).get(featureKey);
|
|
55
55
|
const autoResolve = featureRow?.auto_resolve_escalations === 1;
|
|
56
|
-
|
|
56
|
+
// Propagate the run's operator custom instructions (issue #956) the same way. Read the DURABLE
|
|
57
|
+
// text off the `feature_runs` row — persisted by `startFeature` before the instance/handoff
|
|
58
|
+
// existed (issue #956 review) — NOT the job variable: the worker publishes `converging` BEFORE
|
|
59
|
+
// enrolling, so `pollFeatureDelivery`'s re-enroll edge can win the race (or recover an
|
|
60
|
+
// interrupted handoff) with no job variables at all. Both paths now read this one durable source,
|
|
61
|
+
// so the steering survives the worker/poller interleaving.
|
|
62
|
+
const rowInstructions = featureRow?.custom_instructions;
|
|
63
|
+
const customInstructions = typeof rowInstructions === "string" && rowInstructions.trim() !== "" ? rowInstructions : null;
|
|
64
|
+
await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, false, featureKey, humanApproval, autoResolve, autoMerge, null, null, customInstructions);
|
|
57
65
|
app.log.info("converge-feature: enrolled PR into convergence loop", {
|
|
58
66
|
featureKey,
|
|
59
67
|
prKey: parsed.prKey,
|
|
60
68
|
autoMerge,
|
|
61
69
|
humanApproval,
|
|
62
70
|
autoResolveEscalations: autoResolve,
|
|
71
|
+
hasCustomInstructions: customInstructions != null,
|
|
63
72
|
});
|
|
64
73
|
return {};
|
|
65
74
|
};
|
|
@@ -18,7 +18,7 @@ function state(over: Partial<PrState>): PrState {
|
|
|
18
18
|
failingCheckNames: [],
|
|
19
19
|
presentCheckNames: ["build", "Mergify Merge Queue"],
|
|
20
20
|
pendingCheckNames: [],
|
|
21
|
-
|
|
21
|
+
checkGateConclusions: { build: ["SUCCESS"], "Mergify Merge Queue": ["NEUTRAL"] },
|
|
22
22
|
totalChecks: 2,
|
|
23
23
|
isDraft: false,
|
|
24
24
|
headRefOid: "abc",
|
|
@@ -80,7 +80,7 @@ test("merge-stall-probe: BLOCKED gh-merge PR with green checks still waits (#951
|
|
|
80
80
|
// must NOT classify that head's stale check failures as `blocked` (which starts fix-ci / escalates).
|
|
81
81
|
// It defers to `waiting` (bounded re-poll) until the head moves or the remedy's window lapses.
|
|
82
82
|
test("merge-stall-probe: a blocked verdict on a pending-update old head defers to waiting (#951)", async () => {
|
|
83
|
-
const blocked = state({ failingChecks: 1, failingCheckNames: ["build"],
|
|
83
|
+
const blocked = state({ failingChecks: 1, failingCheckNames: ["build"], checkGateConclusions: { build: ["FAILURE"] } });
|
|
84
84
|
// Sanity: without a pending update this IS blocked (would route to fix-ci).
|
|
85
85
|
assertEquals((await run(blocked, MERGIFY, false)).mergeState, "blocked");
|
|
86
86
|
// With the update still pending on this head, the probe holds remediation.
|
|
@@ -108,3 +108,53 @@ test("a head read that resolves no SHA degrades to the checkpoint restore (no ov
|
|
|
108
108
|
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
109
109
|
assertEquals(args[11], null, "no live head SHA → no override (checkpoint restore, the pre-fix behaviour)");
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
// Issue #956 review (Copilot thread 4547353262): a merge-stage reconverge re-enters `submitPr`
|
|
113
|
+
// WITHOUT the run's operator custom instructions, so the replacement convergence loop runs
|
|
114
|
+
// unsteered (and `submitPr`'s run-scoped reopen resets `custom_instructions` to 0). The worker must
|
|
115
|
+
// read the durable text off the PR row (`custom_instructions_text`, stamped by `submitPr`) and pass
|
|
116
|
+
// it through, so convergence → merge → reconvergence keeps the steering.
|
|
117
|
+
test("preserves the run's custom instructions across the merge→reconvergence handoff (#956)", async () => {
|
|
118
|
+
const app = {
|
|
119
|
+
data: {
|
|
120
|
+
table: (name: string) => ({
|
|
121
|
+
get: async () =>
|
|
122
|
+
name === "pull_requests"
|
|
123
|
+
? { auto_resolve_escalations: 1, custom_instructions_text: " commit as josh.wulf@camunda.com " }
|
|
124
|
+
: undefined,
|
|
125
|
+
insert: async () => 1,
|
|
126
|
+
}),
|
|
127
|
+
},
|
|
128
|
+
engine: {},
|
|
129
|
+
log: noopLog(),
|
|
130
|
+
} as any;
|
|
131
|
+
let args: unknown[] = [];
|
|
132
|
+
const submit = (async (...a: unknown[]) => {
|
|
133
|
+
args = a;
|
|
134
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
135
|
+
}) as unknown as Submit;
|
|
136
|
+
const head = (async () => null) as unknown as FetchPrHead;
|
|
137
|
+
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
138
|
+
assertEquals(args[12], " commit as josh.wulf@camunda.com ", "the durable instruction text rides the handoff into the fresh convergence run");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a PR row with no stored instructions hands off with null (no steering)", async () => {
|
|
142
|
+
const app = {
|
|
143
|
+
data: {
|
|
144
|
+
table: (name: string) => ({
|
|
145
|
+
get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0, custom_instructions_text: null } : undefined),
|
|
146
|
+
insert: async () => 1,
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
engine: {},
|
|
150
|
+
log: noopLog(),
|
|
151
|
+
} as any;
|
|
152
|
+
let args: unknown[] = [];
|
|
153
|
+
const submit = (async (...a: unknown[]) => {
|
|
154
|
+
args = a;
|
|
155
|
+
return { prKey: "acme/w#7", processKey: "999" };
|
|
156
|
+
}) as unknown as Submit;
|
|
157
|
+
const head = (async () => null) as unknown as FetchPrHead;
|
|
158
|
+
await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
|
|
159
|
+
assertEquals(args[12], null, "no stored text → null (the fresh run's prompts stay byte-identical to no-instructions)");
|
|
160
|
+
});
|
|
@@ -27,6 +27,12 @@ export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead
|
|
|
27
27
|
const row = await app.data.table("pull_requests", "pr_key").get(prKey);
|
|
28
28
|
// biome-ignore lint/plugin: runtime/framework contract boundary for the untyped row shape
|
|
29
29
|
const optIn = (row as { auto_resolve_escalations?: number } | undefined)?.auto_resolve_escalations === 1;
|
|
30
|
+
// Operator steering (issue #956 review): re-supply the run's durable instruction text so the
|
|
31
|
+
// merge→reconvergence handoff never starts an unsteered replacement loop (and never resets the
|
|
32
|
+
// `custom_instructions` projection). Read the SAME `pull_requests` row the opt-in came from.
|
|
33
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for the untyped row shape
|
|
34
|
+
const rowInstructions = (row as { custom_instructions_text?: string | null } | undefined)?.custom_instructions_text;
|
|
35
|
+
const customInstructions = typeof rowInstructions === "string" && rowInstructions.trim() !== "" ? rowInstructions : null;
|
|
30
36
|
await app.data.table("merges", "id").insert({
|
|
31
37
|
pr_key: prKey,
|
|
32
38
|
outcome: "reconverge",
|
|
@@ -63,6 +69,7 @@ export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead
|
|
|
63
69
|
true,
|
|
64
70
|
job.processInstanceKey != null ? String(job.processInstanceKey) : null,
|
|
65
71
|
headSha,
|
|
72
|
+
customInstructions,
|
|
66
73
|
);
|
|
67
74
|
// `submitPr` can return `processKey: null` after rewriting the row to `converging` with no owner
|
|
68
75
|
// (the create-instance failure window). Completing HERE would end the merge loop at
|