@nanobpm/nano-workforce 0.191.0 → 0.192.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/.github/workflows/mirror-install-dispatch.yml +6 -6
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraph.ts +1 -1
- package/app/deliveryGraphRun.test.ts +25 -2
- package/app/deliveryGraphRun.ts +18 -6
- package/app/deliveryHuman.test.ts +79 -2
- package/app/deliveryHuman.ts +92 -0
- package/app/github.test.ts +223 -1
- package/app/github.ts +122 -0
- package/app/mergeProtocol.ts +1 -1
- package/app/pollUserTasks.test.ts +39 -0
- package/app/roundProgress.test.ts +108 -1
- package/app/service.test.ts +132 -3
- package/app/service.ts +130 -55
- package/app/world/checkpoint.ts +5 -0
- package/app/world/selfHeal.test.ts +296 -0
- package/app/world/selfHeal.ts +194 -0
- package/app/world/store.test.ts +49 -0
- package/app/world/store.ts +37 -1
- package/db/migrations/115_world_checkpoint_process_key.sql +22 -0
- package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
- package/docs/adr/0003-epic-base-branch-admission.md +1 -1
- package/docs/adr/0004-shared-contract-coordination.md +1 -1
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/adr/0006-delivery-units-one-representation.md +1 -1
- package/package.json +1 -1
- package/test/worldDb.ts +1 -1
- package/workers/merge/worker.test.ts +2 -2
- package/workers/merge/worker.ts +1 -1
- package/workers/persist-round/worker.ts +6 -0
- package/workers/progress-check/worker.ts +117 -18
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
// transient GitHub hiccup can never fabricate a no-progress escalation.
|
|
18
18
|
import type { AgentInstanceSummary, AppApi, AppJobHandler } from "@nanobpm/urban";
|
|
19
19
|
import { type HeadReader, makeDefaultReadHead } from "../../app/currentHead.ts";
|
|
20
|
-
import { fetchBranchHead, fetchPrHead } from "../../app/github.ts";
|
|
20
|
+
import { compareCommits, fetchBranchHead, fetchPrHead, updateBranchRef } from "../../app/github.ts";
|
|
21
21
|
import { decideProgress, isAddressedStatus } from "../../app/roundProgress.ts";
|
|
22
22
|
import { parsePr } from "../../app/service.ts";
|
|
23
|
+
import { WorldStore } from "../../app/world/index.ts";
|
|
24
|
+
import { attemptNoAdvanceSelfHeal, type SelfHealResult } from "../../app/world/selfHeal.ts";
|
|
23
25
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
24
26
|
|
|
25
27
|
// Input/output typed off the model data envelopes (`PrProgressCheckIn` / `PrProgressCheckOut` in
|
|
@@ -75,6 +77,45 @@ export { type HeadReader, makeDefaultReadHead };
|
|
|
75
77
|
|
|
76
78
|
const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
|
|
77
79
|
|
|
80
|
+
/** Attempt the no-advance self-heal (#818): reconcile a recorded push-checkpoint against the PR head
|
|
81
|
+
* and fast-forward the head onto it when safe. Injectable so tests never touch git/network; the
|
|
82
|
+
* default binds the real `WorldStore` (over `app.data`) + GitHub transport with the env credential
|
|
83
|
+
* (the same `process.env.GITHUB_TOKEN` default the head reader uses). See {@link
|
|
84
|
+
* ../../app/world/selfHeal.ts}. */
|
|
85
|
+
export type SelfHealFn = (
|
|
86
|
+
app: AppApi,
|
|
87
|
+
repo: string,
|
|
88
|
+
prNumber: number,
|
|
89
|
+
prKey: string,
|
|
90
|
+
currentHead: string,
|
|
91
|
+
roundNo: number,
|
|
92
|
+
processKey: string | null,
|
|
93
|
+
stillOwns: () => Promise<boolean>,
|
|
94
|
+
) => Promise<SelfHealResult>;
|
|
95
|
+
|
|
96
|
+
const defaultSelfHeal: SelfHealFn = (app, repo, prNumber, prKey, currentHead, roundNo, processKey, stillOwns) => {
|
|
97
|
+
const tok = process.env.GITHUB_TOKEN ?? "";
|
|
98
|
+
const store = new WorldStore(app.data);
|
|
99
|
+
return attemptNoAdvanceSelfHeal(
|
|
100
|
+
{
|
|
101
|
+
lastCheckpoint: (pk, run, rn) => store.lastCheckpointForRun(pk, run, rn),
|
|
102
|
+
prHead: async (r, n) => {
|
|
103
|
+
const h = await fetchPrHead(r, n, tok);
|
|
104
|
+
return h ? { headRef: h.headRef, headRepo: h.headRepo } : null;
|
|
105
|
+
},
|
|
106
|
+
compare: (r, base, head) => compareCommits(r, base, head, tok),
|
|
107
|
+
advanceHead: (r, branch, sha, expected) => updateBranchRef(r, branch, sha, tok, expected),
|
|
108
|
+
stillOwns,
|
|
109
|
+
},
|
|
110
|
+
repo,
|
|
111
|
+
prNumber,
|
|
112
|
+
prKey,
|
|
113
|
+
currentHead,
|
|
114
|
+
roundNo,
|
|
115
|
+
processKey,
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
78
119
|
/** An agent-instance is "durable work" for husk purposes once it has reached a terminal state — it
|
|
79
120
|
* carries a `completionDate`, or a terminal lifecycle status. A husked instance never closes (it is
|
|
80
121
|
* stuck `THINKING` with null dates — jwulf/c8ctl-plugin-nano#230), so it is NOT counted. */
|
|
@@ -203,6 +244,7 @@ function agentWorkFromEngine(engine: AppApi["engine"]): AgentWorkReader {
|
|
|
203
244
|
export function makeHandler(deps: {
|
|
204
245
|
readHead: HeadReader;
|
|
205
246
|
readAgentWork?: AgentWorkReader;
|
|
247
|
+
selfHeal?: SelfHealFn;
|
|
206
248
|
}): AppJobHandler<In, Out> {
|
|
207
249
|
return async (job, app) => {
|
|
208
250
|
const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead } = job.variables;
|
|
@@ -241,25 +283,30 @@ export function makeHandler(deps: {
|
|
|
241
283
|
// the single writer of the review-wait park (#786) — persist-round no longer sets
|
|
242
284
|
// `waiting_review`, so the row rests on its running `converging` status until exactly one write
|
|
243
285
|
// here resolves it, and the poller never sees a transient `waiting_review` for a husk-retry round.
|
|
286
|
+
// PROCESS-INSTANCE FENCE (Copilot #789). A straggler progress-check from a SUPERSEDED
|
|
287
|
+
// convergence instance can slip past the replay guard (its idempotency stamp was cleared when
|
|
288
|
+
// `submitPr` re-opened the PR) and reach here AFTER `submitPr` has reset the per-run fields and
|
|
289
|
+
// started a NEW convergence instance. Its stale baseline / status / watermark / result must not
|
|
290
|
+
// clobber the fresh run (which could be parked on `waiting_review` or replay stale output) — nor
|
|
291
|
+
// may it mutate GitHub with the old run's checkpoint (see the self-heal fence below).
|
|
292
|
+
// Re-read the row's CURRENT owner and report whether this job's own `processInstanceKey` no
|
|
293
|
+
// longer owns the row — the straggler's token lives in a terminated instance. Fail open (not
|
|
294
|
+
// superseded) when either key is absent (an older instance, or a row whose `process_key` is not
|
|
295
|
+
// yet seeded) so normal single-run behaviour is untouched. Compare as strings — `process_key` is
|
|
296
|
+
// persisted via `String(processInstanceKey)`, and a job key can arrive numeric.
|
|
297
|
+
const isSuperseded = async (): Promise<boolean> => {
|
|
298
|
+
const currentOwner = (await prs.get(prKey))?.process_key ?? null;
|
|
299
|
+
const jobOwner = job.processInstanceKey ?? null;
|
|
300
|
+
return currentOwner !== null && jobOwner !== null && String(currentOwner) !== String(jobOwner);
|
|
301
|
+
};
|
|
244
302
|
const commit = async (
|
|
245
303
|
out: Out,
|
|
246
304
|
opts: { head?: string | null; status?: "waiting_review" | "converging"; agentWatermark?: string | null },
|
|
247
305
|
): Promise<Out> => {
|
|
248
306
|
const ts = new Date().toISOString();
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
// started a NEW convergence instance. Its stale baseline / status / watermark / result must not
|
|
253
|
-
// clobber the fresh run (which could be parked on `waiting_review` or replay stale output).
|
|
254
|
-
// Re-read the row's CURRENT owner immediately before the write and drop the write when this
|
|
255
|
-
// job's own `processInstanceKey` no longer owns the row — the straggler's token lives in a
|
|
256
|
-
// terminated instance, so acking without persisting is correct. Fail open when either key is
|
|
257
|
-
// absent (an older instance, or a row whose `process_key` is not yet seeded) so normal
|
|
258
|
-
// single-run behaviour is untouched. Compare as strings — `process_key` is persisted via
|
|
259
|
-
// `String(processInstanceKey)`, and a job key can arrive numeric.
|
|
260
|
-
const currentOwner = (await prs.get(prKey))?.process_key ?? null;
|
|
261
|
-
const jobOwner = job.processInstanceKey ?? null;
|
|
262
|
-
if (currentOwner && jobOwner && String(currentOwner) !== String(jobOwner)) {
|
|
307
|
+
// Re-check ownership immediately before the write and drop it when this job no longer owns the
|
|
308
|
+
// row — acking without persisting is correct (the straggler's token is in a terminated instance).
|
|
309
|
+
if (await isSuperseded()) {
|
|
263
310
|
return out;
|
|
264
311
|
}
|
|
265
312
|
await prs.update(prKey, {
|
|
@@ -386,9 +433,61 @@ export function makeHandler(deps: {
|
|
|
386
433
|
// once the husk retry has been ruled out, so a husk-retry round never transits `waiting_review`.
|
|
387
434
|
return commit(out, { head: currentHead, status: "waiting_review", agentWatermark });
|
|
388
435
|
}
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
//
|
|
436
|
+
|
|
437
|
+
// NO-ADVANCE SELF-HEAL (#818, Layer 2). Before escalating to a human, reconcile the round's work
|
|
438
|
+
// against the remote: a producer harness that provisioned the review shape wrong pushed the fix
|
|
439
|
+
// to a throwaway fallback branch, so it IS reachable on the remote (recorded as a durable
|
|
440
|
+
// push-checkpoint via the harness's `worldMarker`) but left the PR head un-advanced. If that
|
|
441
|
+
// checkpoint SHA STRICTLY fast-forward-descends the current head, advance the head onto it and
|
|
442
|
+
// continue the loop as real progress instead of parking a human — auto-recovering the exact
|
|
443
|
+
// divergence that stranded nanobpm/nano-coder#26/#28. `currentHead` is non-null here (a null head
|
|
444
|
+
// fails OPEN upstream via decideProgress and never reaches this escalation). Every non-heal
|
|
445
|
+
// outcome (no checkpoint, not a fast-forward, a fork head, an unreadable/refused move) degrades
|
|
446
|
+
// to escalation exactly as before — the self-heal is purely additive.
|
|
447
|
+
if (currentHead) {
|
|
448
|
+
// FENCE THE REF MUTATION (Copilot review on #818 / #819). The self-heal PATCHes the PR head on
|
|
449
|
+
// GitHub — an irreversible side effect the atomic `commit` fence below can only guard for the
|
|
450
|
+
// DB write, NOT for a mutation already made. A straggler from a SUPERSEDED instance (its stale
|
|
451
|
+
// checkpoint would resurrect old work on a PR `submitPr` has since reopened under a new
|
|
452
|
+
// `process_key`) must not move the head. Two layers guard this: (1) the run-scoped checkpoint
|
|
453
|
+
// lookup — the self-heal reads `lastCheckpointForRun(prKey, jobProcessKey, roundNo)`, so a fresh
|
|
454
|
+
// run never even sees the prior run's checkpoint; and (2) a cheap early-out here PLUS a LATE
|
|
455
|
+
// ownership re-check threaded INTO the heal (`isSuperseded` inverted → `stillOwns`), evaluated
|
|
456
|
+
// immediately before the ref PATCH, closing the check-to-mutation TOCTOU where a concurrent
|
|
457
|
+
// `submitPr` supersedes this run DURING the heal's awaits. A skipped/abandoned heal falls
|
|
458
|
+
// through to the escalation `commit`, which the same fence turns into a harmless ack-without-write.
|
|
459
|
+
const jobProcessKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
|
|
460
|
+
if (!(await isSuperseded())) {
|
|
461
|
+
const heal = await (deps.selfHeal ?? defaultSelfHeal)(
|
|
462
|
+
app,
|
|
463
|
+
ghRepo,
|
|
464
|
+
ghNumber,
|
|
465
|
+
prKey,
|
|
466
|
+
currentHead,
|
|
467
|
+
roundNo,
|
|
468
|
+
jobProcessKey,
|
|
469
|
+
async () => !(await isSuperseded()),
|
|
470
|
+
).catch((): SelfHealResult => ({ healed: false }));
|
|
471
|
+
if (heal.healed && heal.sha) {
|
|
472
|
+
app.log.info("no-advance self-heal advanced PR head to a reachable push-checkpoint", {
|
|
473
|
+
prKey,
|
|
474
|
+
round: roundNo,
|
|
475
|
+
from: currentHead,
|
|
476
|
+
to: heal.sha,
|
|
477
|
+
});
|
|
478
|
+
// Advance the baseline to the newly-pushed head and park at wait-review as genuine progress
|
|
479
|
+
// (husk counter reset), so the poller solicits the next Copilot review against the healed head.
|
|
480
|
+
return commit(
|
|
481
|
+
{ progressed: true, huskRetries: 0 },
|
|
482
|
+
{ head: heal.sha, status: "waiting_review", agentWatermark },
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The remaining outcome (progressed:false, huskRetry:false, no self-heal) escalates to a human;
|
|
489
|
+
// the persist-escalation-noprogress worker owns the status, so leave it unset — but still advance
|
|
490
|
+
// the baseline and stamp the idempotency record so a redelivered escalation replays instead of
|
|
392
491
|
// recomputing.
|
|
393
492
|
return commit(out, { head: currentHead, agentWatermark });
|
|
394
493
|
};
|