@nanobpm/nano-workforce 0.171.6 → 0.171.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/agentic/families/relay.family.test.ts +226 -2
- package/app/agentic/families/relay.family.ts +194 -1
- package/app/baseBranch.ts +56 -0
- package/app/contracts.ts +1 -1
- package/app/deliveryGraphDispatch.ts +6 -1
- package/app/deliveryRunner.test.ts +56 -0
- package/app/deliveryRunner.ts +29 -0
- package/app/plan.ts +11 -46
- package/openapi.yaml +23 -0
- package/operations/dispatchDeliveryGraph.test.ts +62 -0
- package/operations/dispatchDeliveryGraph.ts +34 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.171.8](https://github.com/nanobpm/nano-workforce/compare/v0.171.7...v0.171.8) (2026-09-01)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **agentic:** release job⇄worker correlation on terminal lifecycle event ([#688](https://github.com/nanobpm/nano-workforce/issues/688)) ([58dde6c](https://github.com/nanobpm/nano-workforce/commit/58dde6cf1c91f75828c8a38de52fef9409e3484c)), closes [#661](https://github.com/nanobpm/nano-workforce/issues/661) [#544](https://github.com/nanobpm/nano-workforce/issues/544) [#661](https://github.com/nanobpm/nano-workforce/issues/661)
|
|
6
|
+
|
|
7
|
+
## [0.171.7](https://github.com/nanobpm/nano-workforce/compare/v0.171.6...v0.171.7) (2026-09-01)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **agentic:** seed repository isolation envelope on delivery-graph agent cells ([#687](https://github.com/nanobpm/nano-workforce/issues/687)) ([8c96a7d](https://github.com/nanobpm/nano-workforce/commit/8c96a7db34a6e389d09d5b9968fbcbc362d85cd5)), closes [#686](https://github.com/nanobpm/nano-workforce/issues/686) [#684](https://github.com/nanobpm/nano-workforce/issues/684) [#685](https://github.com/nanobpm/nano-workforce/issues/685) [#551](https://github.com/nanobpm/nano-workforce/issues/551) [#686](https://github.com/nanobpm/nano-workforce/issues/686)
|
|
12
|
+
|
|
1
13
|
## [0.171.6](https://github.com/nanobpm/nano-workforce/compare/v0.171.5...v0.171.6) (2026-09-01)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -16,16 +16,19 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
17
17
|
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
18
18
|
import { RELAY_FAMILY } from "@nanobpm/agentic/relay";
|
|
19
|
-
import { type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcript";
|
|
20
|
-
import { assert, assertEquals } from "#test-assert";
|
|
19
|
+
import { encodeTranscriptEvent, type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcript";
|
|
20
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
21
21
|
import { noopLog } from "../../../test/log.ts";
|
|
22
22
|
import { CorrelationRegistry, jobStream } from "../correlation.ts";
|
|
23
23
|
import { AgenticCorrelationStore } from "../correlation-store.ts";
|
|
24
|
+
import { createPresenceStore, PresenceRegistry } from "./presence.family.ts";
|
|
24
25
|
import {
|
|
25
26
|
type CorrelationLink,
|
|
26
27
|
createRelayFamily,
|
|
27
28
|
currentRelayTranscriptService,
|
|
29
|
+
engineReconcileMs,
|
|
28
30
|
family as relayFamily,
|
|
31
|
+
guardOverlappingPasses,
|
|
29
32
|
RELAY_FAMILY_NAME,
|
|
30
33
|
RelayTranscriptService,
|
|
31
34
|
sweepIntervalMs,
|
|
@@ -542,6 +545,227 @@ test("#544 element-instance enrichment: an unresolved job (never parked) leaves
|
|
|
542
545
|
service.teardown();
|
|
543
546
|
});
|
|
544
547
|
|
|
548
|
+
/** A `produce` frame whose chunk is a typed transcript LIFECYCLE event at `phase` (#661). */
|
|
549
|
+
const lifecycle = (stream: string, incarnation: number, phase: "open" | "completed" | "exited"): Frame =>
|
|
550
|
+
produce(stream, incarnation, encodeTranscriptEvent({ kind: "lifecycle", phase, offset: 0 }));
|
|
551
|
+
|
|
552
|
+
test("#661 primary release: a terminal lifecycle event clears an idle-but-connected worker's finished job", () => {
|
|
553
|
+
const registry = new ConnectionRegistry();
|
|
554
|
+
const correlation = new CorrelationRegistry();
|
|
555
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
556
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
557
|
+
const p = connect("prod", registry);
|
|
558
|
+
|
|
559
|
+
// The worker relays its job's terminal → linked as active.
|
|
560
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
561
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "the job is active while it runs");
|
|
562
|
+
assertEquals(correlation.count(), 1);
|
|
563
|
+
|
|
564
|
+
// The job ends: the worker emits the terminal `lifecycle` event on the SAME live connection and then
|
|
565
|
+
// goes idle — it does NOT disconnect and does NOT take a new job (no supersede). Before this fix that
|
|
566
|
+
// finished job lingered forever as a phantom active job; now the terminal event releases it.
|
|
567
|
+
hub.handler?.(lifecycle(jobStream("k1"), 1, "completed"), p.conn);
|
|
568
|
+
assertEquals(correlation.jobKeysFor("worker-A"), [], "the finished job is released on the terminal event");
|
|
569
|
+
assertEquals(correlation.count(), 0, "count() drops — no phantom active job");
|
|
570
|
+
|
|
571
|
+
// Assert the RENDERED supply row clears too (not just the registry): the presence snapshot seeds
|
|
572
|
+
// per-worker jobKeys from the correlation registry, so an idle-but-connected worker shows no job.
|
|
573
|
+
const store = createPresenceStore(memoryDb());
|
|
574
|
+
store.ensureSchema();
|
|
575
|
+
store.register({ instance: "worker-A", connectionId: "prod", identity: "leaf", capability: {} });
|
|
576
|
+
const presence = new PresenceRegistry(store, () => new Set(["prod"]));
|
|
577
|
+
const row = presence.snapshot({ jobKeysFor: (i) => correlation.jobKeysFor(i) }).workers[0];
|
|
578
|
+
assertEquals(row.jobKeys, [], "the supply row shows no active job for the idle worker");
|
|
579
|
+
assert(row.live, "the worker is still connected — the connection persists across jobs");
|
|
580
|
+
|
|
581
|
+
// The terminal event itself is captured in the flushed transcript (release runs AFTER the ring append).
|
|
582
|
+
const meta = service.transcriptOf(jobStream("k1"));
|
|
583
|
+
assertEquals(meta?.status, "completed", "the finished job becomes a completed past session");
|
|
584
|
+
assertEquals(service.reattach(jobStream("k1"), 0)?.entries.length, 2, "the terminal event is part of the transcript");
|
|
585
|
+
service.teardown();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("#661 primary release: an `exited` lifecycle also releases; a non-terminal `open` does not", () => {
|
|
589
|
+
const registry = new ConnectionRegistry();
|
|
590
|
+
const correlation = new CorrelationRegistry();
|
|
591
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
592
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
593
|
+
const p = connect("prod", registry);
|
|
594
|
+
|
|
595
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
596
|
+
// A `phase: "open"` lifecycle is NOT terminal — a genuinely active job must not be cleared.
|
|
597
|
+
hub.handler?.(lifecycle(jobStream("k1"), 1, "open"), p.conn);
|
|
598
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "an open lifecycle keeps the active job");
|
|
599
|
+
assertEquals(correlation.count(), 1);
|
|
600
|
+
|
|
601
|
+
// An `exited` lifecycle (a crash/kill the worker still managed to report) IS terminal → released.
|
|
602
|
+
hub.handler?.(lifecycle(jobStream("k1"), 1, "exited"), p.conn);
|
|
603
|
+
assertEquals(correlation.jobKeysFor("worker-A"), [], "an exited lifecycle releases the job");
|
|
604
|
+
assertEquals(correlation.count(), 0);
|
|
605
|
+
service.teardown();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("#661 no-regression: an ordinary (non-lifecycle) chunk never clears a genuinely active job", () => {
|
|
609
|
+
const registry = new ConnectionRegistry();
|
|
610
|
+
const correlation = new CorrelationRegistry();
|
|
611
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
612
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
613
|
+
const p = connect("prod", registry);
|
|
614
|
+
|
|
615
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
616
|
+
// Ordinary terminal output (raw bytes, not a typed envelope) must NOT be read as a job-end signal.
|
|
617
|
+
for (let i = 0; i < 5; i++) hub.handler?.(produce(jobStream("k1"), 1, `output ${i}`), p.conn);
|
|
618
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "a running job stays active across ordinary output");
|
|
619
|
+
assertEquals(correlation.count(), 1);
|
|
620
|
+
assertEquals(service.transcriptOf(jobStream("k1")), undefined, "the live job is not completed");
|
|
621
|
+
service.teardown();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("#661 defensive reconcile: an unclean exit (no lifecycle) whose engine job is gone is dropped by the reconcile pass", async () => {
|
|
625
|
+
const registry = new ConnectionRegistry();
|
|
626
|
+
const correlation = new CorrelationRegistry();
|
|
627
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
628
|
+
// The engine reports job k1 as a live JOB park (element instance ei-1) — until the worker exits
|
|
629
|
+
// UNCLEANLY (crash/kill): it emits no terminal lifecycle event, keeps no connection to reconcile,
|
|
630
|
+
// and the engine park is gone → the resolver returns undefined.
|
|
631
|
+
let parked = true;
|
|
632
|
+
const resolveElementInstance = (jobKey: string) =>
|
|
633
|
+
Promise.resolve(parked && jobKey === "k1" ? "ei-1" : undefined);
|
|
634
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
|
|
635
|
+
resolveElementInstance,
|
|
636
|
+
});
|
|
637
|
+
const p = connect("prod", registry);
|
|
638
|
+
|
|
639
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
640
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "the job links while it runs");
|
|
641
|
+
|
|
642
|
+
// While the engine still parks the job, the reconcile pass leaves a genuinely active job alone.
|
|
643
|
+
await service.reconcileEngineCorrelations();
|
|
644
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "a genuinely active job is NOT cleared by the safety net");
|
|
645
|
+
assertEquals(correlation.count(), 1);
|
|
646
|
+
|
|
647
|
+
// The worker exits uncleanly (no lifecycle event) — its engine park vanishes. The next reconcile
|
|
648
|
+
// pass drops the stale correlation even though nothing on the wire signalled job end.
|
|
649
|
+
parked = false;
|
|
650
|
+
await service.reconcileEngineCorrelations();
|
|
651
|
+
assertEquals(correlation.jobKeysFor("worker-A"), [], "the reconcile pass drops the stale correlation");
|
|
652
|
+
assertEquals(correlation.count(), 0, "no phantom active job survives an unclean exit");
|
|
653
|
+
service.teardown();
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("#661 defensive reconcile: a transient engine read failure never falsely releases a job", async () => {
|
|
657
|
+
const registry = new ConnectionRegistry();
|
|
658
|
+
const correlation = new CorrelationRegistry();
|
|
659
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
660
|
+
// The engine read throws (unavailable) — a transient fault must be treated as "unknown, keep it",
|
|
661
|
+
// NEVER as "job gone", or a live job would be wrongly cleared on every engine blip.
|
|
662
|
+
const resolveElementInstance = () => Promise.reject(new Error("engine unavailable"));
|
|
663
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
|
|
664
|
+
resolveElementInstance,
|
|
665
|
+
});
|
|
666
|
+
const p = connect("prod", registry);
|
|
667
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
668
|
+
|
|
669
|
+
await service.reconcileEngineCorrelations();
|
|
670
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "a transient engine failure leaves the job linked");
|
|
671
|
+
assertEquals(correlation.count(), 1);
|
|
672
|
+
service.teardown();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("#661 defensive reconcile: a no-op when no engine resolver is wired", async () => {
|
|
676
|
+
const registry = new ConnectionRegistry();
|
|
677
|
+
const correlation = new CorrelationRegistry();
|
|
678
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
679
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
680
|
+
const p = connect("prod", registry);
|
|
681
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
682
|
+
|
|
683
|
+
// With no element-instance resolver (engine-less host), the safety net cannot query the engine —
|
|
684
|
+
// it must be an inert no-op, leaving the correlation exactly as the primary path manages it.
|
|
685
|
+
await service.reconcileEngineCorrelations();
|
|
686
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "no resolver → the reconcile pass is inert");
|
|
687
|
+
service.teardown();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("#661 engineReconcileMs: defaults, disables on a non-positive/non-finite value, caps at the timer max", () => {
|
|
691
|
+
assertEquals(engineReconcileMs(), 30_000, "an omitted config uses the default cadence");
|
|
692
|
+
assertEquals(engineReconcileMs(5_000), 5_000, "a finite positive value is honoured");
|
|
693
|
+
assertEquals(engineReconcileMs(0), undefined, "zero disables the pass");
|
|
694
|
+
assertEquals(engineReconcileMs(-1), undefined, "a negative value disables the pass");
|
|
695
|
+
assertEquals(engineReconcileMs(Number.NaN), undefined, "a non-finite value disables the pass");
|
|
696
|
+
assertEquals(engineReconcileMs(Number.POSITIVE_INFINITY), undefined, "infinity disables the pass");
|
|
697
|
+
assertEquals(engineReconcileMs(2 ** 32), 2_147_483_647, "a huge value is capped at the Node timer ceiling");
|
|
698
|
+
assertEquals(engineReconcileMs(0.5), 1, "a sub-millisecond positive value floors to 1ms, never 0 (no busy setInterval(0))");
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("#661 guardOverlappingPasses: a tick while a pass is in flight is skipped; only one pass runs at a time", async () => {
|
|
702
|
+
let starts = 0;
|
|
703
|
+
let active = 0;
|
|
704
|
+
let maxActive = 0;
|
|
705
|
+
let release!: () => void;
|
|
706
|
+
// A pass that blocks until we release it, so we can hold one "in flight" while further ticks fire.
|
|
707
|
+
const pass = () => {
|
|
708
|
+
starts++;
|
|
709
|
+
active++;
|
|
710
|
+
maxActive = Math.max(maxActive, active);
|
|
711
|
+
return new Promise<void>((resolve) => {
|
|
712
|
+
release = () => {
|
|
713
|
+
active--;
|
|
714
|
+
resolve();
|
|
715
|
+
};
|
|
716
|
+
});
|
|
717
|
+
};
|
|
718
|
+
const tick = guardOverlappingPasses(pass);
|
|
719
|
+
|
|
720
|
+
tick(); // first tick starts a pass — now in flight
|
|
721
|
+
tick(); // skipped while the first pass is still pending
|
|
722
|
+
tick(); // skipped
|
|
723
|
+
assertEquals(starts, 1, "overlapping ticks are dropped while a pass is in flight");
|
|
724
|
+
assertEquals(maxActive, 1, "never more than one pass runs concurrently");
|
|
725
|
+
|
|
726
|
+
release(); // let the first pass settle
|
|
727
|
+
await new Promise((r) => setImmediate(r)); // flush the `finally` that clears the in-flight flag
|
|
728
|
+
|
|
729
|
+
tick(); // a tick after the previous pass settled starts a fresh pass
|
|
730
|
+
assertEquals(starts, 2, "a tick after the in-flight pass settles starts a new pass");
|
|
731
|
+
assertEquals(maxActive, 1, "still only ever one pass at a time");
|
|
732
|
+
release();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("#661 guardOverlappingPasses: a pass whose work rejects but is self-caught still re-arms the guard", async () => {
|
|
736
|
+
// The guard clears its in-flight flag via `.finally`, so it re-arms whether the pass resolves OR
|
|
737
|
+
// rejects — a rejection does NOT wedge the guard. The real hazard of a rejecting pass is an
|
|
738
|
+
// *unhandled rejection* (the tick voids the returned promise), which is why the mount wraps
|
|
739
|
+
// `reconcileEngineCorrelations()` in `.catch`. Here the pass's work rejects but is self-caught
|
|
740
|
+
// (mirroring that wrapper), so there is no unhandled rejection, and we assert the guard re-arms.
|
|
741
|
+
let starts = 0;
|
|
742
|
+
const tick = guardOverlappingPasses(() => {
|
|
743
|
+
starts++;
|
|
744
|
+
// Work that rejects but settles its own error — exactly like the mount's `.catch` wrapper.
|
|
745
|
+
return Promise.reject(new Error("pass work failed")).catch(() => {});
|
|
746
|
+
});
|
|
747
|
+
tick();
|
|
748
|
+
await new Promise((r) => setImmediate(r));
|
|
749
|
+
tick();
|
|
750
|
+
await new Promise((r) => setImmediate(r));
|
|
751
|
+
assertEquals(starts, 2, "a self-caught rejecting pass settles and re-arms the guard for the next tick");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("#661 guardOverlappingPasses: a pass that throws SYNCHRONOUSLY re-arms the guard (no wedge) and stays loud", () => {
|
|
755
|
+
// A synchronous throw from `pass` escapes before `.finally` is attached, so the guard must catch it,
|
|
756
|
+
// re-arm, and re-throw — otherwise a future non-`async` caller/refactor would wedge the guard
|
|
757
|
+
// permanently in-flight. We assert both: the throw propagates (stays loud, not swallowed) AND the
|
|
758
|
+
// next tick starts a fresh pass (the guard re-armed rather than sticking at in-flight).
|
|
759
|
+
let starts = 0;
|
|
760
|
+
const tick = guardOverlappingPasses(() => {
|
|
761
|
+
starts++;
|
|
762
|
+
throw new Error("synchronous boom"); // violates the "must not throw synchronously" contract
|
|
763
|
+
});
|
|
764
|
+
assertThrows(() => tick(), Error, "synchronous boom");
|
|
765
|
+
assertThrows(() => tick(), Error, "synchronous boom");
|
|
766
|
+
assertEquals(starts, 2, "the guard re-armed after a synchronous throw, so the next tick ran the pass");
|
|
767
|
+
});
|
|
768
|
+
|
|
545
769
|
/**
|
|
546
770
|
* A {@link CorrelationLink} wrapper that delegates to a real registry but can be flipped to throw on
|
|
547
771
|
* `link()`/`releaseJob()`, exercising the advisory-resilience contract: `#link`/`#unlink` are
|
|
@@ -24,6 +24,7 @@ import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
|
24
24
|
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
25
25
|
import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
|
|
26
26
|
import {
|
|
27
|
+
parseTranscriptEvent,
|
|
27
28
|
type SqliteDb,
|
|
28
29
|
type TranscriptLifecycle,
|
|
29
30
|
type TranscriptRing,
|
|
@@ -50,6 +51,14 @@ const SWEEP_DIVISOR = 4;
|
|
|
50
51
|
* 32-bit timer and Node silently clamps it to 1ms — turning a slow periodic tick into a busy loop. */
|
|
51
52
|
const MAX_TIMER_MS = 2_147_483_647;
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Default cadence (ms) of the defensive engine-reconcile pass (#661) — the safety net that releases a
|
|
56
|
+
* correlation whose engine JOB park is gone but whose terminal `lifecycle` event never arrived (an
|
|
57
|
+
* unclean worker exit). 30s trades a small staleness bound for a light engine-read load; the precise,
|
|
58
|
+
* immediate release stays the terminal-lifecycle path, so this only ever mops up unclean exits.
|
|
59
|
+
*/
|
|
60
|
+
const DEFAULT_ENGINE_RECONCILE_MS = 30_000;
|
|
61
|
+
|
|
53
62
|
/**
|
|
54
63
|
* The retention-sweep cadence (ms) for a given ephemeral-retention window: a fraction of the window,
|
|
55
64
|
* floored at 1ms and — crucially — capped at {@link MAX_TIMER_MS} so a large retention config (e.g.
|
|
@@ -64,12 +73,75 @@ export function sweepIntervalMs(ephemeralRetentionMs: number): number {
|
|
|
64
73
|
return Math.min(MAX_TIMER_MS, Math.max(1, interval));
|
|
65
74
|
}
|
|
66
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The defensive engine-reconcile cadence (ms) for a given config (#661), or `undefined` to DISABLE the
|
|
78
|
+
* pass. An omitted config uses {@link DEFAULT_ENGINE_RECONCILE_MS}; a non-finite or non-positive value
|
|
79
|
+
* (a broken config, or a deliberate opt-out) disables the pass rather than degrading into a 1ms busy
|
|
80
|
+
* loop; a finite positive value is floored at 1ms (so a sub-millisecond config like 0.5 cannot floor
|
|
81
|
+
* to 0 and degrade into a busy `setInterval(0)`) and capped at {@link MAX_TIMER_MS} so a large window
|
|
82
|
+
* cannot overflow Node's 32-bit timer.
|
|
83
|
+
*/
|
|
84
|
+
export function engineReconcileMs(configuredMs?: number): number | undefined {
|
|
85
|
+
const value = configuredMs ?? DEFAULT_ENGINE_RECONCILE_MS;
|
|
86
|
+
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
87
|
+
return Math.min(MAX_TIMER_MS, Math.max(1, Math.floor(value)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wrap an async pass in an in-flight guard so a periodic `setInterval` never fires OVERLAPPING runs
|
|
92
|
+
* (#661). A single pass of {@link RelayTranscriptService.reconcileEngineCorrelations} awaits an engine
|
|
93
|
+
* read per linked job, so a pass can outlast its interval (a small configured cadence, or a slow/large
|
|
94
|
+
* engine read-model); an unguarded `setInterval` would then stack concurrent passes, piling up engine
|
|
95
|
+
* reads and log volume. While a pass is still pending, every subsequent tick is skipped; the next tick
|
|
96
|
+
* after it settles — whether it resolves OR rejects, since the guard clears via `.finally` — starts a
|
|
97
|
+
* fresh pass. The guard clears on BOTH failure modes so it can never wedge in-flight: (1) a
|
|
98
|
+
* *synchronous* throw from `pass` escapes before `.finally` is attached, so it is caught here — the
|
|
99
|
+
* guard is re-armed and the fault is re-thrown so it stays loud rather than being silently swallowed;
|
|
100
|
+
* (2) an async rejection is cleared by `.finally`, but the tick `void`s (does not await) the returned
|
|
101
|
+
* promise, so `pass` must still settle its own rejections or an unhandled rejection results — which is
|
|
102
|
+
* why the mount wraps `reconcileEngineCorrelations()` in `.catch`. Making the guard resilient to (1)
|
|
103
|
+
* removes a subtle footgun for a future caller (or refactor) that returns a non-`async` `pass`. Mirrors
|
|
104
|
+
* the "one pass at a time" discipline the main poll loop enforces by self-scheduling. Returns the tick
|
|
105
|
+
* callback to hand to `setInterval`.
|
|
106
|
+
*/
|
|
107
|
+
export function guardOverlappingPasses(pass: () => Promise<void>): () => void {
|
|
108
|
+
let inFlight = false;
|
|
109
|
+
return () => {
|
|
110
|
+
if (inFlight) return;
|
|
111
|
+
inFlight = true;
|
|
112
|
+
try {
|
|
113
|
+
void pass().finally(() => {
|
|
114
|
+
inFlight = false;
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// A synchronous throw never attaches the `.finally`; re-arm the guard so it does not wedge,
|
|
118
|
+
// then re-throw so a contract violation still surfaces instead of being silently swallowed.
|
|
119
|
+
inFlight = false;
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
67
125
|
/** Read a property off an unknown value without an unsafe `as` cast (mirrors the loader's helper). */
|
|
68
126
|
function readProp(value: unknown, key: string): unknown {
|
|
69
127
|
if (!value || typeof value !== "object") return undefined;
|
|
70
128
|
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
71
129
|
}
|
|
72
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Decode a single relay chunk through the ONE canonical transcript parser and report whether it is a
|
|
133
|
+
* TERMINAL `lifecycle` event (`phase` `completed`/`exited`) — the authoritative "job end" signal a
|
|
134
|
+
* clean agent run emits on its transcript stream (#661). Anything else — raw terminal bytes, a
|
|
135
|
+
* non-envelope JSON value, a `phase: "open"` lifecycle, any other event kind — is not terminal.
|
|
136
|
+
* Reusing {@link parseTranscriptEvent} keeps transcript-vocab knowledge out of the content-agnostic
|
|
137
|
+
* relay ring and off any forked decoder (Derivation Over Duplication): the ring still sees opaque
|
|
138
|
+
* bytes; only this narrow seam classifies them.
|
|
139
|
+
*/
|
|
140
|
+
function isTerminalLifecycleChunk(chunk: string): boolean {
|
|
141
|
+
const event = parseTranscriptEvent({ offset: 0, chunk });
|
|
142
|
+
return event.kind === "lifecycle" && (event.phase === "completed" || event.phase === "exited");
|
|
143
|
+
}
|
|
144
|
+
|
|
73
145
|
/**
|
|
74
146
|
* A resume-from-offset source with no retained chunks — used to flush/complete a stream that a
|
|
75
147
|
* producer opened logically but never wrote to, so its transcript is still stamped `completed`
|
|
@@ -424,11 +496,41 @@ export class RelayTranscriptService {
|
|
|
424
496
|
this.#streams.clear();
|
|
425
497
|
}
|
|
426
498
|
|
|
427
|
-
/** Handle one inbound `relay` frame: reconcile dead producers, observe ownership,
|
|
499
|
+
/** Handle one inbound `relay` frame: reconcile dead producers, observe ownership, delegate, then
|
|
500
|
+
* release on a terminal `lifecycle` event (#661 — AFTER the hub appended the chunk, so the terminal
|
|
501
|
+
* event itself is captured in the flushed transcript). */
|
|
428
502
|
#onFrame(frame: Frame, conn: RelayConnectionCtx): void {
|
|
429
503
|
this.#reconcile();
|
|
430
504
|
this.#observe(frame, conn);
|
|
431
505
|
this.relay.handle(frame, conn);
|
|
506
|
+
this.#observeTerminalLifecycle(frame);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Primary job-end release (#661): when a `produce` frame carries the terminal `lifecycle` event
|
|
511
|
+
* (`phase` `completed`/`exited`), complete its stream so the worker's job⇄instance correlation is
|
|
512
|
+
* released the moment the job ends — even though the worker's relay connection stays open across jobs
|
|
513
|
+
* (the disconnect/supersede release paths miss that idle-after-last-job tail, so an idle worker's
|
|
514
|
+
* finished job would otherwise linger as a phantom active job on its supply row). Runs AFTER the hub
|
|
515
|
+
* appends the chunk to the ring, so the terminal event is part of the flushed transcript. A narrow,
|
|
516
|
+
* self-contained decode at the correlation seam that reuses the ONE canonical
|
|
517
|
+
* {@link parseTranscriptEvent} — the content-agnostic relay ring keeps treating chunks as opaque
|
|
518
|
+
* bytes, and no transcript-vocab knowledge is forked into it. Non-terminal chunks (raw bytes, a
|
|
519
|
+
* `phase: "open"` lifecycle, any other event kind) never complete a live stream, so a genuinely
|
|
520
|
+
* active job is never cleared. Advisory — never throws into the frame handler.
|
|
521
|
+
*/
|
|
522
|
+
#observeTerminalLifecycle(frame: Frame): void {
|
|
523
|
+
if (readProp(frame.payload, "op") !== "produce") return;
|
|
524
|
+
const stream = readProp(frame.payload, "stream");
|
|
525
|
+
if (typeof stream !== "string" || stream === "") return;
|
|
526
|
+
const chunk = readProp(frame.payload, "chunk");
|
|
527
|
+
if (typeof chunk !== "string" || chunk === "") return;
|
|
528
|
+
const state = this.#streams.get(stream);
|
|
529
|
+
// Idempotent: an unknown or already-completed stream needs no (further) release — a second terminal
|
|
530
|
+
// event, or a late one after the disconnect/supersede path already completed the stream, is a no-op.
|
|
531
|
+
if (state === undefined || state.completed) return;
|
|
532
|
+
if (!isTerminalLifecycleChunk(chunk)) return;
|
|
533
|
+
this.completeStream(stream);
|
|
432
534
|
}
|
|
433
535
|
|
|
434
536
|
/** Record `produce` ownership so a producer disconnect can drive ephemeral completion. */
|
|
@@ -624,6 +726,61 @@ export class RelayTranscriptService {
|
|
|
624
726
|
}
|
|
625
727
|
}
|
|
626
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Defensive engine-reconcile safety net (#661): release any linked jobKey whose engine JOB park is
|
|
731
|
+
* no longer live. The precise, fast release is the terminal `lifecycle` event
|
|
732
|
+
* ({@link #observeTerminalLifecycle}), but an UNCLEAN worker exit (crash/kill) can skip that event —
|
|
733
|
+
* and because the worker's relay connection is persistent across jobs, the disconnect release never
|
|
734
|
+
* fires either, so the finished job would linger as a phantom active job on the worker's supply row.
|
|
735
|
+
* This periodic pass asks the engine read model (the same {@link ElementInstanceResolver} the link
|
|
736
|
+
* path uses at #544) whether each linked job is still parked; a job the engine no longer parks
|
|
737
|
+
* (resolver returns `undefined`) is released and its transcript completed. Bounds staleness regardless
|
|
738
|
+
* of whether the worker emitted a clean terminal event — and also covers a worker that emitted nothing
|
|
739
|
+
* and merely went quiet.
|
|
740
|
+
*
|
|
741
|
+
* Advisory and best-effort: a no-op with no resolver wired, and a resolver THROW / REJECTION for a
|
|
742
|
+
* given job is treated as "unknown — keep it linked" (never a false release of a genuinely active
|
|
743
|
+
* job). The linked set is snapshotted before any await so a concurrent completion (a terminal
|
|
744
|
+
* lifecycle event landing mid-pass) cannot corrupt iteration, and each release re-checks the current
|
|
745
|
+
* stream state so a job already released between snapshot and resolution is not double-completed.
|
|
746
|
+
*/
|
|
747
|
+
async reconcileEngineCorrelations(): Promise<void> {
|
|
748
|
+
const resolve = this.#resolveElementInstance;
|
|
749
|
+
if (resolve === undefined) return;
|
|
750
|
+
const linked: { stream: string; jobKey: string; processInstanceKey?: string }[] = [];
|
|
751
|
+
for (const [stream, state] of this.#streams) {
|
|
752
|
+
if (!state.linked || state.completed) continue;
|
|
753
|
+
const jobKey = jobKeyOfStream(stream);
|
|
754
|
+
if (jobKey === undefined) continue;
|
|
755
|
+
const processInstanceKey = this.#correlation()?.resolve?.(jobKey)?.processInstanceKey;
|
|
756
|
+
linked.push({ stream, jobKey, processInstanceKey });
|
|
757
|
+
}
|
|
758
|
+
for (const { stream, jobKey, processInstanceKey } of linked) {
|
|
759
|
+
let activeKey: string | undefined;
|
|
760
|
+
try {
|
|
761
|
+
activeKey = await resolve(jobKey, processInstanceKey);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
// A transient engine read failure must NOT be read as "job gone" — leave the job linked; a
|
|
764
|
+
// later pass (or the terminal lifecycle event) releases it. Advisory, never a false release.
|
|
765
|
+
this.#log.warn("agentic relay engine-reconcile read failed — leaving correlation linked", {
|
|
766
|
+
stream,
|
|
767
|
+
jobKey,
|
|
768
|
+
err: String(err),
|
|
769
|
+
});
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
// A live JOB park (a resolved element-instance key) means the job is genuinely active — keep it.
|
|
773
|
+
if (activeKey !== undefined) continue;
|
|
774
|
+
// The engine no longer parks this job → it ended (possibly via an unclean exit that skipped the
|
|
775
|
+
// terminal lifecycle event). Re-check the current state — a concurrent completion may already
|
|
776
|
+
// have released it — then release its correlation and flush its transcript.
|
|
777
|
+
const current = this.#streams.get(stream);
|
|
778
|
+
if (current === undefined || current.completed || !current.linked) continue;
|
|
779
|
+
this.#log.info("agentic relay engine-reconcile released a stale correlation", { stream, jobKey });
|
|
780
|
+
this.completeStream(stream);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
627
784
|
/**
|
|
628
785
|
* The still-live relay ring for a stream, for the read path to serve BEFORE a durable flush (#486).
|
|
629
786
|
*
|
|
@@ -670,11 +827,18 @@ export function createRelayFamily(options: {
|
|
|
670
827
|
readonly relay?: RelayHubOptions;
|
|
671
828
|
readonly transcript?: TranscriptStoreOptions;
|
|
672
829
|
readonly ensureSchema?: boolean;
|
|
830
|
+
/**
|
|
831
|
+
* Cadence (ms) of the defensive engine-reconcile pass (#661). Defaults to
|
|
832
|
+
* {@link DEFAULT_ENGINE_RECONCILE_MS}. Clamped to Node's 32-bit timer ceiling; a non-positive /
|
|
833
|
+
* non-finite value disables the pass (the terminal-lifecycle release path still runs).
|
|
834
|
+
*/
|
|
835
|
+
readonly engineReconcileIntervalMs?: number;
|
|
673
836
|
/** Called with the live service once mounted, so a driver can drive completion/reattach. */
|
|
674
837
|
readonly onMounted?: (service: RelayTranscriptService) => void;
|
|
675
838
|
} = {}): AgenticFamily {
|
|
676
839
|
let service: RelayTranscriptService | undefined;
|
|
677
840
|
let sweepTimer: ReturnType<typeof setInterval> | undefined;
|
|
841
|
+
let engineReconcileTimer: ReturnType<typeof setInterval> | undefined;
|
|
678
842
|
return {
|
|
679
843
|
name: RELAY_FAMILY_NAME,
|
|
680
844
|
mount(ctx: AgenticContext): void {
|
|
@@ -729,6 +893,31 @@ export function createRelayFamily(options: {
|
|
|
729
893
|
tick();
|
|
730
894
|
}
|
|
731
895
|
|
|
896
|
+
// Defensive engine-reconcile safety net (#661): periodically release any linked correlation
|
|
897
|
+
// whose engine JOB park is gone but whose terminal `lifecycle` event never arrived (an unclean
|
|
898
|
+
// worker exit), so a crashed worker's finished job stops showing as a phantom active job. Only
|
|
899
|
+
// useful when an element-instance resolver is wired (engine read-model access); harmless no-op
|
|
900
|
+
// otherwise. Advisory — a reconcile fault is logged, never thrown, and never keeps the process
|
|
901
|
+
// alive on its own.
|
|
902
|
+
if (ctx.resolveElementInstance !== undefined) {
|
|
903
|
+
const reconcileInterval = engineReconcileMs(options.engineReconcileIntervalMs);
|
|
904
|
+
if (reconcileInterval !== undefined) {
|
|
905
|
+
// In-flight guard: `reconcileEngineCorrelations()` is async and awaits an engine read per
|
|
906
|
+
// linked job, so a pass can outlast `reconcileInterval` (a small interval, or a slow/large
|
|
907
|
+
// engine read-model). Without a guard, `setInterval` would fire overlapping passes that pile
|
|
908
|
+
// up concurrent engine reads and log volume. {@link guardOverlappingPasses} skips a tick while
|
|
909
|
+
// the previous pass is still running so only one reconcile runs at a time — the same "one pass
|
|
910
|
+
// at a time" discipline the main poll loop enforces by self-scheduling.
|
|
911
|
+
const reconcileTick = guardOverlappingPasses(() =>
|
|
912
|
+
(service?.reconcileEngineCorrelations() ?? Promise.resolve()).catch((err: unknown) => {
|
|
913
|
+
ctx.log.warn("agentic relay engine-reconcile failed", { err: String(err) });
|
|
914
|
+
}),
|
|
915
|
+
);
|
|
916
|
+
engineReconcileTimer = setInterval(reconcileTick, reconcileInterval);
|
|
917
|
+
engineReconcileTimer.unref?.();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
732
921
|
options.onMounted?.(service);
|
|
733
922
|
},
|
|
734
923
|
teardown(): void {
|
|
@@ -736,6 +925,10 @@ export function createRelayFamily(options: {
|
|
|
736
925
|
clearInterval(sweepTimer);
|
|
737
926
|
sweepTimer = undefined;
|
|
738
927
|
}
|
|
928
|
+
if (engineReconcileTimer !== undefined) {
|
|
929
|
+
clearInterval(engineReconcileTimer);
|
|
930
|
+
engineReconcileTimer = undefined;
|
|
931
|
+
}
|
|
739
932
|
service?.teardown();
|
|
740
933
|
if (currentService === service) setCurrentRelayTranscriptService(undefined);
|
|
741
934
|
service = undefined;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Base-branch name validation — the canonical, side-effect-free gate shared by every door that
|
|
2
|
+
// accepts a caller-supplied branch name (the epic launch doors via `app/plan.ts`, and the
|
|
3
|
+
// operator delivery-graph dispatch door in `operations/dispatchDeliveryGraph.ts`).
|
|
4
|
+
//
|
|
5
|
+
// This is a deliberate LEAF module: it imports nothing and runs no top-level initialization, so an
|
|
6
|
+
// API door can pull in the validator without dragging in `app/plan.ts`'s substantial transitive
|
|
7
|
+
// imports and its import-time env seeding (`ESCALATION_SLA_TIMEOUT`/`CAPS_WAIT_TIMEOUT`). `plan.ts`
|
|
8
|
+
// re-exports these symbols, so existing importers are unaffected — this is derivation over
|
|
9
|
+
// duplication (one implementation), just hoisted below the heavy module.
|
|
10
|
+
|
|
11
|
+
/** Raised when a caller supplies a `baseBranch` that isn't a plausible git branch name. The
|
|
12
|
+
* value is interpolated into the authoritative implementer prompt (which carries `git`/`gh`
|
|
13
|
+
* shell snippets and inline-code Markdown), so a non-ref value could break the rendered
|
|
14
|
+
* instructions or smuggle in a command/prompt fragment — reject it at the edge instead. */
|
|
15
|
+
export class InvalidBaseBranchError extends Error {
|
|
16
|
+
readonly value: string;
|
|
17
|
+
constructor(value: string) {
|
|
18
|
+
super(`invalid base branch name: ${JSON.stringify(value)}`);
|
|
19
|
+
this.name = "InvalidBaseBranchError";
|
|
20
|
+
this.value = value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
|
|
25
|
+
* branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
|
|
26
|
+
* (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
|
|
27
|
+
export class MissingBaseBranchError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super("base branch is required (blank/absent base branches are rejected)");
|
|
30
|
+
this.name = "MissingBaseBranchError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
|
|
35
|
+
* purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
|
|
36
|
+
* no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
|
|
37
|
+
* This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
|
|
38
|
+
export function isPlausibleBranchName(s: string): boolean {
|
|
39
|
+
if (s.length === 0 || s.length > 255) return false;
|
|
40
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
|
|
41
|
+
if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
|
|
42
|
+
if (s.includes("..") || s.includes("//")) return false;
|
|
43
|
+
return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
|
|
47
|
+
* (`MissingBaseBranchError`) — ADR 0003 removed the implicit default-branch fallback, so every epic
|
|
48
|
+
* launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
|
|
49
|
+
* rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
|
|
50
|
+
* operation edge maps both to a 400. Always returns a non-null branch on success. */
|
|
51
|
+
export function normalizeBaseBranch(input: string | null | undefined): string {
|
|
52
|
+
const s = (input ?? "").trim();
|
|
53
|
+
if (s.length === 0) throw new MissingBaseBranchError();
|
|
54
|
+
if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
|
|
55
|
+
return s;
|
|
56
|
+
}
|
package/app/contracts.ts
CHANGED
|
@@ -384,7 +384,7 @@ export const WIRE_CONTRACTS = {
|
|
|
384
384
|
name: "io.nanobpm.agentTask.repository",
|
|
385
385
|
owner: "app/repoEnvelope.ts",
|
|
386
386
|
semantics:
|
|
387
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed
|
|
387
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted only for a single-task feature run; the epic seed AND the delivery-graph run-root seed omit it so each fan-out slice's agent branches per node/MI child). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `commitSha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
388
388
|
shape:
|
|
389
389
|
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string, branch?: { create: string } }',
|
|
390
390
|
},
|
|
@@ -49,7 +49,7 @@ export type DispatchDeliveryGraphResult =
|
|
|
49
49
|
export async function dispatchDeliveryGraphRun(
|
|
50
50
|
app: Pick<AppApi, "data" | "engine" | "log">,
|
|
51
51
|
graph: unknown,
|
|
52
|
-
options: { runKey?: string | null; title?: string | null } & DeliveryRunTimeouts = {},
|
|
52
|
+
options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null } & DeliveryRunTimeouts = {},
|
|
53
53
|
): Promise<DispatchDeliveryGraphResult> {
|
|
54
54
|
const validationErrors = validateDeliveryGraph(graph);
|
|
55
55
|
if (validationErrors.length > 0) {
|
|
@@ -140,6 +140,11 @@ export async function dispatchDeliveryGraphRun(
|
|
|
140
140
|
escalationSlaTimeout: options.escalationSlaTimeout,
|
|
141
141
|
probePollEvery: options.probePollEvery,
|
|
142
142
|
escalationAssignee: options.escalationAssignee,
|
|
143
|
+
// Host-git provisioning (#684/#686): forward the run-level repo/base so the runner seeds the
|
|
144
|
+
// `io.nanobpm.agentTask.repository` isolation envelope onto every agent cell's job (absent → the
|
|
145
|
+
// runner emits no envelope and the harness keeps its legacy launch-dir behaviour).
|
|
146
|
+
repository: options.repository,
|
|
147
|
+
baseBranch: options.baseBranch,
|
|
143
148
|
});
|
|
144
149
|
} catch (err) {
|
|
145
150
|
await markClaimFailed();
|
|
@@ -363,6 +363,62 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
|
|
|
363
363
|
assertEquals(typeof r.handle.processInstanceKey, "string");
|
|
364
364
|
});
|
|
365
365
|
|
|
366
|
+
// Host-git provisioning (issue #684/#686): the delivery-graph runner must seed the canonical
|
|
367
|
+
// `io.nanobpm.agentTask.repository` isolation envelope (`repoEnvelopeVars`) as a run-root process
|
|
368
|
+
// variable so every agent cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
|
|
369
|
+
// instead of mutating the worker's launch dir — the delivery-graph analog of the plan.ts epic seed.
|
|
370
|
+
// These pin the createInstance variables the harness (headers ∪ variables) reads.
|
|
371
|
+
function captureCreateInstanceVars(): { engine: Parameters<typeof runDeliveryGraph>[0]; seen: () => Record<string, unknown> } {
|
|
372
|
+
let captured: Record<string, unknown> = {};
|
|
373
|
+
const engine = {
|
|
374
|
+
deployResources: async () => [],
|
|
375
|
+
createInstance: async (req: { variables?: Record<string, unknown> }) => {
|
|
376
|
+
captured = req.variables ?? {};
|
|
377
|
+
return { processInstanceKey: "1" };
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
return { engine, seen: () => captured };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
test("runDeliveryGraph seeds the repository isolation envelope when repository + baseBranch are supplied (#684/#686)", async () => {
|
|
384
|
+
const { engine, seen } = captureCreateInstanceVars();
|
|
385
|
+
const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo", baseBranch: "main" });
|
|
386
|
+
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
387
|
+
const env = (seen() as Record<string, { repository?: Record<string, unknown> }>)["io.nanobpm.agentTask"];
|
|
388
|
+
assert(env?.repository, `expected the run-root vars to carry io.nanobpm.agentTask.repository, got ${JSON.stringify(seen())}`);
|
|
389
|
+
const repo = env.repository as Record<string, unknown>;
|
|
390
|
+
// PRE-PR shape: `ref = base` (the harness checks out the base; each agent cuts its own feat/<node.id>).
|
|
391
|
+
assertEquals(repo.ref, "main");
|
|
392
|
+
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
393
|
+
assertEquals(repo.provider, "github");
|
|
394
|
+
// Branch-scoped blobless clone (#287) so large monorepos provision within the clone timeout.
|
|
395
|
+
assertEquals(repo.singleBranch, true);
|
|
396
|
+
assertEquals(repo.filter, "blob:none");
|
|
397
|
+
// baseRef = base too, so `origin/<base>` stays reachable for the review 3-dot diff.
|
|
398
|
+
assertEquals(repo.baseRef, "main");
|
|
399
|
+
// NO branch.create at the run root — a run fans out to many agent nodes, each needing its own
|
|
400
|
+
// feat/<node.id>, so a single run-level envelope names none (mirrors the plan.ts epic seed).
|
|
401
|
+
assertEquals("branch" in repo, false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("runDeliveryGraph emits NO envelope when repository/baseBranch are absent — repo-less graphs unchanged (#686)", async () => {
|
|
405
|
+
for (const options of [{}, { repository: "owner/repo" }, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
|
|
406
|
+
const { engine, seen } = captureCreateInstanceVars();
|
|
407
|
+
const r = await runDeliveryGraph(engine, GRAPH, options);
|
|
408
|
+
assert(r.ok, `expected ok:true for ${JSON.stringify(options)}, got ${JSON.stringify(r)}`);
|
|
409
|
+
assertEquals("io.nanobpm.agentTask" in seen(), false, `no envelope expected for ${JSON.stringify(options)}`);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("runDeliveryGraph drops a malformed repository rather than emitting a bogus clone URL (#686)", async () => {
|
|
414
|
+
const { engine, seen } = captureCreateInstanceVars();
|
|
415
|
+
// A value that is not exactly `owner/repo` (a trailing `.git`) must degrade to NO envelope — the
|
|
416
|
+
// helper's defence-in-depth guard — never a double-suffixed `…/owner/repo.git.git` clone URL.
|
|
417
|
+
const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo.git", baseBranch: "main" });
|
|
418
|
+
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
419
|
+
assertEquals("io.nanobpm.agentTask" in seen(), false);
|
|
420
|
+
});
|
|
421
|
+
|
|
366
422
|
test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATCHES with a fact-bound wait target (#570)", async () => {
|
|
367
423
|
// Regression for #570: a `wait[pr]` node whose `target` is a fact reference (`open.pr`, the #548
|
|
368
424
|
// late-binding shape the guide documents as canonical) COMPILED+staged but threw at dispatch —
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
|
|
|
19
19
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
20
20
|
import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
21
21
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
22
|
+
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
22
23
|
import { isoDuration } from "./reviewWait.ts";
|
|
23
24
|
|
|
24
25
|
/** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
|
|
@@ -52,6 +53,18 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
52
53
|
* cross-correlate. Pass an explicit `runKey` only when you need a reproducible/externally-owned gate
|
|
53
54
|
* scope. */
|
|
54
55
|
runKey?: string;
|
|
56
|
+
/** OPTIONAL `owner/repo` the run's `agent` nodes implement against. When supplied together with
|
|
57
|
+
* `baseBranch`, the runner seeds the canonical repository-provisioning envelope
|
|
58
|
+
* (`io.nanobpm.agentTask.repository`, via `repoEnvelopeVars`) as a run-root `createInstance` process
|
|
59
|
+
* variable so each `agent` cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
|
|
60
|
+
* instead of inheriting the worker's launch dir (issue #684/#686 — the same isolation the legacy
|
|
61
|
+
* feature/plan paths got in #685). Absent/unresolved → NO envelope is emitted and the harness falls
|
|
62
|
+
* back to the legacy launch-dir behaviour, so today's repo-less graphs are unchanged. */
|
|
63
|
+
repository?: string | null;
|
|
64
|
+
/** OPTIONAL base branch the run's `agent` nodes branch off — the `ref` the harness checks out in the
|
|
65
|
+
* isolated clone (the PRE-PR shape: no PR head exists yet, so the agent cuts its own `feat/<node.id>`
|
|
66
|
+
* branch off this base inside the clone). Only consulted when `repository` is also set. */
|
|
67
|
+
baseBranch?: string | null;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
|
|
@@ -146,6 +159,8 @@ export async function runDeliveryGraph(
|
|
|
146
159
|
const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
|
|
147
160
|
|
|
148
161
|
await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
162
|
+
const base = typeof options.baseBranch === "string" && options.baseBranch.trim() !== "" ? options.baseBranch.trim() : null;
|
|
163
|
+
const repo = typeof options.repository === "string" && options.repository.trim() !== "" ? options.repository.trim() : null;
|
|
149
164
|
const { processInstanceKey } = await engine.createInstance({
|
|
150
165
|
processDefinitionId,
|
|
151
166
|
variables: {
|
|
@@ -155,6 +170,20 @@ export async function runDeliveryGraph(
|
|
|
155
170
|
// node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
|
|
156
171
|
// every node — and read down into each agent job via `=transcriptUrlBase`.
|
|
157
172
|
[TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
|
|
173
|
+
// Host-git provisioning (c8ctl, issue #684/#686): deliver the ONE canonical repository envelope
|
|
174
|
+
// (`repoEnvelopeVars`, app/repoEnvelope.ts) so every `agent` node's servicing `senior:*` job gets
|
|
175
|
+
// an ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
|
|
176
|
+
// copilot workers on one host share (and clobber) a single checkout, the exact field failure #684
|
|
177
|
+
// described. This is the delivery-graph analog of the whole-epic seed in `app/plan.ts`: a single
|
|
178
|
+
// run-root `createInstance` process variable that propagates through each agent cell's subProcess
|
|
179
|
+
// into its job. Like plan.ts's fan-out seed it carries `ref = base` but NO `branchCreate` — a run
|
|
180
|
+
// fans out to MANY agent nodes, each needing its own deterministic `feat/<node.id>` branch, so a
|
|
181
|
+
// single run-level envelope can't name one; each agent cuts its own branch off `base` inside the
|
|
182
|
+
// isolated clone (the agent-guide's `feat/*` convention, kept idempotent by the #551 preflight).
|
|
183
|
+
// `baseRef = base` too, so the harness keeps `origin/<base>` reachable for the review 3-dot diff.
|
|
184
|
+
// Spread last so an unresolved repo/base (`{}`) leaves the other run-root vars untouched — a
|
|
185
|
+
// repo-less graph is then dispatched exactly as before (legacy launch-dir behaviour).
|
|
186
|
+
...repoEnvelopeVars(repo ?? "", base, base),
|
|
158
187
|
},
|
|
159
188
|
});
|
|
160
189
|
// The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
|
package/app/plan.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// the process. Data access goes through the record gateway (`data.table`), never
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
|
+
import {
|
|
14
|
+
InvalidBaseBranchError,
|
|
15
|
+
isPlausibleBranchName,
|
|
16
|
+
MissingBaseBranchError,
|
|
17
|
+
normalizeBaseBranch,
|
|
18
|
+
} from "./baseBranch.ts";
|
|
13
19
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
20
|
import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
|
|
15
21
|
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
@@ -486,52 +492,11 @@ export function parseIssue(input: string): ParsedIssue | null {
|
|
|
486
492
|
return null;
|
|
487
493
|
}
|
|
488
494
|
|
|
489
|
-
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
export
|
|
494
|
-
readonly value: string;
|
|
495
|
-
constructor(value: string) {
|
|
496
|
-
super(`invalid base branch name: ${JSON.stringify(value)}`);
|
|
497
|
-
this.name = "InvalidBaseBranchError";
|
|
498
|
-
this.value = value;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
|
|
503
|
-
* branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
|
|
504
|
-
* (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
|
|
505
|
-
export class MissingBaseBranchError extends Error {
|
|
506
|
-
constructor() {
|
|
507
|
-
super("base branch is required (blank/absent base branches are rejected)");
|
|
508
|
-
this.name = "MissingBaseBranchError";
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
|
|
513
|
-
* purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
|
|
514
|
-
* no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
|
|
515
|
-
* This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
|
|
516
|
-
function isPlausibleBranchName(s: string): boolean {
|
|
517
|
-
if (s.length === 0 || s.length > 255) return false;
|
|
518
|
-
if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
|
|
519
|
-
if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
|
|
520
|
-
if (s.includes("..") || s.includes("//")) return false;
|
|
521
|
-
return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
|
|
525
|
-
* (`MissingBaseBranchError`) — ADR 0003 removed the implicit default-branch fallback, so every epic
|
|
526
|
-
* launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
|
|
527
|
-
* rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
|
|
528
|
-
* operation edge maps both to a 400. Always returns a non-null branch on success. */
|
|
529
|
-
export function normalizeBaseBranch(input: string | null | undefined): string {
|
|
530
|
-
const s = (input ?? "").trim();
|
|
531
|
-
if (s.length === 0) throw new MissingBaseBranchError();
|
|
532
|
-
if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
|
|
533
|
-
return s;
|
|
534
|
-
}
|
|
495
|
+
/** The base-branch validation gate lives in the side-effect-free leaf `./baseBranch.ts` so an API
|
|
496
|
+
* door (e.g. `operations/dispatchDeliveryGraph.ts`) can reuse it without importing this heavy module
|
|
497
|
+
* and its import-time env seeding. Re-exported here so existing importers keep resolving through
|
|
498
|
+
* `plan.ts` — one implementation (derivation over duplication), just hoisted below the heavy module. */
|
|
499
|
+
export { InvalidBaseBranchError, isPlausibleBranchName, MissingBaseBranchError, normalizeBaseBranch };
|
|
535
500
|
|
|
536
501
|
/** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
|
|
537
502
|
* branch. It is authoritative over the static "branch off the default branch" wording in
|
package/openapi.yaml
CHANGED
|
@@ -2090,6 +2090,29 @@ components:
|
|
|
2090
2090
|
description: >-
|
|
2091
2091
|
OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
|
|
2092
2092
|
outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
|
|
2093
|
+
repository:
|
|
2094
|
+
type: string
|
|
2095
|
+
maxLength: 255
|
|
2096
|
+
pattern: '^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$'
|
|
2097
|
+
description: >-
|
|
2098
|
+
OPTIONAL `owner/repo` the run's `agent` nodes implement against (#684/#686). When supplied
|
|
2099
|
+
together with `baseBranch`, the runner seeds the canonical `io.nanobpm.agentTask.repository`
|
|
2100
|
+
provisioning envelope (`repoEnvelopeVars`) as a run-root process variable so every agent
|
|
2101
|
+
node's servicing `senior:*` job gets an ISOLATED throwaway clone instead of inheriting the
|
|
2102
|
+
worker's launch dir. Absent → no envelope (legacy launch-dir behaviour, unchanged). A value
|
|
2103
|
+
that is not exactly `owner/repo` is rejected at submit.
|
|
2104
|
+
baseBranch:
|
|
2105
|
+
type: string
|
|
2106
|
+
maxLength: 255
|
|
2107
|
+
pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
|
|
2108
|
+
description: >-
|
|
2109
|
+
OPTIONAL base branch the run's `agent` nodes branch off (#684/#686) — the `ref` the harness
|
|
2110
|
+
checks out in the isolated clone (the PRE-PR shape: each agent cuts its own `feat/<node.id>`
|
|
2111
|
+
branch off this base). Only consulted when `repository` is also set; absent → no envelope. A
|
|
2112
|
+
value that is not a plausible git branch name (whitespace, shell metacharacters, a leading
|
|
2113
|
+
`-`, `..`/`//`, a path segment starting with `.` or ending in `.lock`, etc.) is rejected at
|
|
2114
|
+
submit. This pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`,
|
|
2115
|
+
app/baseBranch.ts) so the documented contract and the door agree.
|
|
2093
2116
|
DeliveryGraphDismissRequest:
|
|
2094
2117
|
description: >-
|
|
2095
2118
|
The OPERATOR dismiss request (#520). The cockpit's staged-proposals grid posts the content
|
|
@@ -247,4 +247,66 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
|
|
|
247
247
|
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
248
248
|
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
249
249
|
});
|
|
250
|
+
|
|
251
|
+
test("a malformed `repository` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
252
|
+
const app = await boot();
|
|
253
|
+
assert.ok(app.api);
|
|
254
|
+
const api = app.api;
|
|
255
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
256
|
+
// Not an `owner/repo` reference — refused at submit (by the edge `pattern` or the door's own guard),
|
|
257
|
+
// rather than silently dropped into a bogus clone URL. Nothing launches; the proposal stays staged.
|
|
258
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
259
|
+
body: { digest: staged.body.digest, repository: "not a repo!", baseBranch: "main" },
|
|
260
|
+
});
|
|
261
|
+
assert.equal(res.status, 400);
|
|
262
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
263
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("a malformed `baseBranch` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
267
|
+
const app = await boot();
|
|
268
|
+
assert.ok(app.api);
|
|
269
|
+
const api = app.api;
|
|
270
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
271
|
+
// Not a plausible git branch name (a leading dash reads as a CLI flag / shell metacharacters) — the
|
|
272
|
+
// door's conservative allowlist refuses it at submit rather than seeding an invalid-ref envelope.
|
|
273
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
274
|
+
body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "-rf; rm main" },
|
|
275
|
+
});
|
|
276
|
+
assert.equal(res.status, 400);
|
|
277
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
278
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a `.lock`-suffixed `baseBranch` segment is rejected at submit → 400, nothing launched (#684/#686)", async () => {
|
|
282
|
+
const app = await boot();
|
|
283
|
+
assert.ok(app.api);
|
|
284
|
+
const api = app.api;
|
|
285
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
286
|
+
// A path segment ending in `.lock` (or one starting with `.`) is a valid-looking ref the loose
|
|
287
|
+
// charset would admit but `isPlausibleBranchName` rejects — the door must refuse it, matching the
|
|
288
|
+
// (now tightened) OpenAPI `baseBranch` pattern rather than seeding an invalid-ref envelope.
|
|
289
|
+
const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
|
|
290
|
+
body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "feat/x.lock" },
|
|
291
|
+
});
|
|
292
|
+
assert.equal(res.status, 400);
|
|
293
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
|
|
294
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a valid repository + baseBranch dispatches the run for isolated provisioning → 202 running (#684/#686)", async () => {
|
|
298
|
+
const app = await boot();
|
|
299
|
+
assert.ok(app.api);
|
|
300
|
+
const api = app.api;
|
|
301
|
+
const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
|
|
302
|
+
const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
|
|
303
|
+
body: { digest: staged.body.digest, repository: "owner/repo", baseBranch: "main" },
|
|
304
|
+
});
|
|
305
|
+
assert.equal(res.status, 202);
|
|
306
|
+
assert.equal(res.body.ok, true);
|
|
307
|
+
assert.equal(res.body.status, "running");
|
|
308
|
+
await app.settle();
|
|
309
|
+
assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
|
|
310
|
+
assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
|
|
311
|
+
});
|
|
250
312
|
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// re-dispatch of an already-running run short-circuits with `alreadyRunning`. An unknown / expired /
|
|
11
11
|
// superseded / already-dispatched digest is a clean 400.
|
|
12
12
|
|
|
13
|
+
import { isPlausibleBranchName } from "../app/baseBranch.ts";
|
|
13
14
|
import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
|
|
14
15
|
import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
|
|
15
16
|
import { isValidIsoDuration } from "../app/reviewWait.ts";
|
|
@@ -75,6 +76,38 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
75
76
|
if (parsed.value !== undefined) timeouts[field] = parsed.value;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Host-git provisioning override (#684/#686) — the OPTIONAL `owner/repo` + base branch the run's
|
|
80
|
+
// `agent` nodes implement against. When both are present the runner seeds the canonical
|
|
81
|
+
// `io.nanobpm.agentTask.repository` isolation envelope (`repoEnvelopeVars`) onto every agent cell's
|
|
82
|
+
// job so it provisions a throwaway clone instead of mutating the worker's launch dir. `repository` is
|
|
83
|
+
// shape-validated here (mirrors `repoEnvelopeVars`' own `owner/repo` allowlist) so a malformed value
|
|
84
|
+
// is a clean 400 rather than a silently-dropped envelope; both absent → no envelope (legacy behaviour).
|
|
85
|
+
const repoRaw = body && typeof body === "object" && "repository" in body && typeof body.repository === "string" ? body.repository.trim() : "";
|
|
86
|
+
let repository: string | undefined;
|
|
87
|
+
if (repoRaw !== "") {
|
|
88
|
+
if (repoRaw.length > 255 || !/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repoRaw) || /\.git$/i.test(repoRaw)) {
|
|
89
|
+
const shown = truncateForEcho(repoRaw);
|
|
90
|
+
app.log.warn("dispatch-delivery-graph rejected: invalid repository", { value: shown });
|
|
91
|
+
return { status: 400, body: { ok: false, error: `\`repository\` must be an \`owner/repo\` reference; got \`${shown}\`` } };
|
|
92
|
+
}
|
|
93
|
+
repository = repoRaw;
|
|
94
|
+
}
|
|
95
|
+
const baseRaw = body && typeof body === "object" && "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch.trim() : "";
|
|
96
|
+
let baseBranch: string | undefined;
|
|
97
|
+
if (baseRaw !== "") {
|
|
98
|
+
// `baseBranch` becomes the isolation envelope's `ref` — a real Git ref the harness checks out and
|
|
99
|
+
// branches off. Gate it with the canonical conservative branch-name allowlist (`app/plan.ts`,
|
|
100
|
+
// shared with the epic/feature launch paths) so whitespace, shell metacharacters, newlines, a
|
|
101
|
+
// leading `-`, `..`/`//`, etc. are a clean 400 rather than an invalid-ref/argument-parsing edge
|
|
102
|
+
// case in a downstream git invocation.
|
|
103
|
+
if (baseRaw.length > 255 || !isPlausibleBranchName(baseRaw)) {
|
|
104
|
+
const shown = truncateForEcho(baseRaw);
|
|
105
|
+
app.log.warn("dispatch-delivery-graph rejected: invalid baseBranch", { value: shown });
|
|
106
|
+
return { status: 400, body: { ok: false, error: `\`baseBranch\` must be a plausible git branch name; got \`${shown}\`` } };
|
|
107
|
+
}
|
|
108
|
+
baseBranch = baseRaw;
|
|
109
|
+
}
|
|
110
|
+
|
|
78
111
|
// Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
|
|
79
112
|
// dispatched digest cleanly (no run is launched).
|
|
80
113
|
const proposal = await getStagedProposal(app.data, digest);
|
|
@@ -98,7 +131,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
|
|
|
98
131
|
return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
|
|
99
132
|
}
|
|
100
133
|
|
|
101
|
-
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, ...timeouts });
|
|
134
|
+
const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, ...timeouts });
|
|
102
135
|
if (!dispatched.ok) {
|
|
103
136
|
app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
|
|
104
137
|
const outBody: DeliveryGraphTextResult = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.8",
|
|
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",
|