// nano-workforce — planning fan-out logic (issue #14).
//
// A planning agent (`senior:plan`) decomposes an issue into a list of tasks; the
// `plan-fanout` process then fans those tasks out over a parallel multi-instance
// service task (`senior:feature`), one implementation agent per task. Each agent
// opens a PR, which `record-results` enrolls into the existing convergence loop.
//
// This module is the seam the start actions and the record workers call: it owns
// issue parsing, the plan/plan_tasks row shapes, the prompt assets, and starting
// the process. Data access goes through the record gateway (`data.table`), never
// hand-written SQL — matching app/service.ts.
import type { DataLayer, EngineClient } from "@nanobpm/urban";
import {
InvalidBaseBranchError,
isPlausibleBranchName,
MissingBaseBranchError,
normalizeBaseBranch,
} from "./baseBranch.ts";
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
import { EPIC_PHASE } from "./epicPhase.ts";
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
import {
BaseBranchMustExistError,
coalesceTitle,
ensureBaseBranch,
fetchDefaultBranch,
fetchIssueTitle,
} from "./github.ts";
import { derivedTrackingTable } from "./instanceTracking.ts";
import { clearExclusions } from "./mergeExclusion.ts";
import type { ReadinessProbe } from "./readiness.ts";
import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
import { clearTaskDeltas } from "./taskDelta.ts";
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
export const PLAN_PROCESS_ID = "plan-fanout";
/** The fleet-wide escalation SLA (ISO-8601 duration) seeded onto every plan-fanout instance as the
* `escalationSlaTimeout` process variable and evaluated by each escalation user task's interrupting
* timer boundary. An operator sets `NANO_ESCALATION_SLA_TIMEOUT`; a malformed value falls back to
* {@link DEFAULT_ESCALATION_SLA_TIMEOUT} so a bad env can never deploy an uninterpretable timer. */
export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
process.env.NANO_ESCALATION_SLA_TIMEOUT,
DEFAULT_ESCALATION_SLA_TIMEOUT,
);
/** The fleet-wide capability-barrier bound (ISO-8601 duration) seeded onto every plan-fanout instance
* as the `capsWaitTimeout` process variable and evaluated by the `wait-caps-timeout` timer arm of the
* `wait-caps-resolved` event-based gateway. An operator sets `NANO_CAPS_WAIT_TIMEOUT`; a malformed
* value falls back to {@link DEFAULT_CAPS_WAIT_TIMEOUT} so a bad env can never deploy an
* uninterpretable timer. Bounds a task's wait on an unresolvable cross-repo capability so it escalates
* to an operator instead of parking forever. */
export const CAPS_WAIT_TIMEOUT = capsWaitTimeout(
process.env.NANO_CAPS_WAIT_TIMEOUT,
DEFAULT_CAPS_WAIT_TIMEOUT,
);
const now = () => new Date().toISOString();
// Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
// `senior:feature` prompts are generic resources (`resources/prompts/plan.md` /
// `resources/prompts/plan-review.md` / `resources/prompts/feature.md`, deployed under the
// `resources/` deploy-by-convention layout — nano.app.json declares no `models`) linked into each
// task as
// `` and resolved by the engine at
// job activation. Per-instance dynamic context (a plan's rejection findings, a task's brief) rides
// `appendPrompt`, which the harness concatenates onto the linked base. The host only carries
// runtime identity + `planFindings`.
export interface Plan {
plan_key: string;
repo: string;
issue_number: number;
issue_url: string;
title: string | null;
status: string;
task_count: number;
process_key: string | null;
outcome: string | null;
// Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
// waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
gate_wave: number | null;
// Operator-visibility wave progress is now a DERIVED read model (epic #412): the `wave_count` /
// `current_wave` / `wave_label` columns (022_plan_wave_progress.sql) were RETIRED — the pages read
// them from the `plan_wave_label` / `plan_read_model` SQL VIEWs (060/061), and `pollWaitGate`
// derives "has the epic fanned out?" at read time from `plan_tasks`. There is no write-path and no
// stored column any more, so nothing on `plans` denormalises wave progress.
// Per-plan capability token for the coordination blackboard (009_plan_blackboard.sql, #51).
// Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
// plans created before the blackboard shipped.
blackboard_token: string | null;
// Target base branch (019_plan_base_branch.sql; ADR 0003): the fleet branches off this branch and
// opens every task PR against it instead of the repository's default branch, landing the whole
// epic on a long-lived integration branch. New launches always set it (base is required at
// admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
// carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
base_branch: string | null;
// The derived epic delivery signal (029_plan_delivery.sql) was RETIRED as a stored column (epic
// #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
// `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
// (app/delivery.ts). The pages read them off the view; the pollers that still need the signal
// (`pollPromotion` for `isPromotable`) recompute it at
// read time via `deriveDelivery`, as does the `plan_read_model` VIEW's bucket derivation (074).
// There is no stored column and no write-path any more.
// Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
// Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
// projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
// view can show which phase the epic is IN rather than only the process-instance terminal status.
// Display-only; NULL until the lifecycle first stamps it (grandfathers pre-#261 rows).
epic_phase: string | null;
// Epic integration-branch → default-branch promotion (042_plan_promotion.sql, #299). When an epic
// targets a custom `epic/*` integration branch and every slice PR has merged (`delivery = landed`),
// the poller's `pollPromotion` pass opens exactly ONE `epic/* → ` promotion PR and drives
// it through the same convergence + merge protocol as every other PR (see app/promotion.ts).
// • promotion_pr — the `owner/repo#N` key of that promotion PR, or NULL until one is opened.
// PRIMARY idempotency key: a set value never re-opens a second PR.
// • promotion_state — the epic-card progression 'ready' → 'open' → 'promoted', or NULL until the
// epic first becomes promotable (also NULL forever for a `main`-based epic,
// which has nothing to promote). Display-only; projected by the poller.
promotion_pr: string | null;
promotion_state: string | null;
// Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). RETIRED as a
// write-time projection (issue #439): `list_bucket`/`ack_open` are now DERIVED by the
// `plan_read_model` VIEW (074) from `status`, `acknowledged_at`, and the derived `plan_delivery`
// signal — mirroring the pure `deriveEpicBucket` / `epicIsAcknowledgeable` (app/delivery.ts). The
// Epics pages bind the VIEW, never these base columns, so a raw-datasource `status` write (the
// `instanceTracking` reconciler) can no longer leave them stale, and the delivery-aware
// `pollPlanBucket` correction is retired (the VIEW sees the live signal). The base columns survive
// (expand/contract — a later migration drops them) but are no longer written or read.
// • acknowledged_at — NULL until an operator dismisses a resolved `done` epic (acknowledge-epic).
// Still written; the sole live input the derivation reads off the row.
// • list_bucket — 'active' | 'history': VESTIGIAL base column; the pages filter the VIEW's.
// • ack_open — 1 | 0: VESTIGIAL base column; the pages gate Dismiss on the VIEW's.
acknowledged_at: string | null;
list_bucket: string | null;
ack_open: number | null;
// Operator visibility for the inter-epic gate (047_plan_wait_gate.sql, #292 slice S4). Derived,
// display-only projection over the S1 `plan_deps` edges + this epic's own S3 preflight lifecycle —
// recomputed idempotently by `pollWaitGate` (app/service.ts) from the pure `deriveWaitGate`
// (app/waitGate.ts); NEVER written by admission/scheduling (this slice is read-only). A ROOT epic
// (no inbound edge) carries NULL for both — it shows no wait-gate; pre-S4 rows grandfather in NULL.
// • wait_gate — 'waiting' (parked at the preflight, blocked on a producer's capability) |
// 'ready' (preflight green, fanned out, bound to a version) | 'escalated'
// (the gate's bounded timeout elapsed with no publish).
// • wait_gate_label — the human at-a-glance rollup the epic index/detail read as a flat column.
wait_gate: string | null;
wait_gate_label: string | null;
// JSON array of the resolved `pkg@version` strings the S3 preflight bound (the exact versions first
// carrying each producer's capability), stamped by the `select-wave` worker from the
// `resolvedArtifacts` process variable once the gate goes green. NULL until green / for roots.
bound_artifacts: string | null;
created_at: string;
updated_at: string;
}
export interface PlanTask {
id: number;
plan_key: string;
task_index: number;
task_id: string;
title: string | null;
prompt: string | null;
status: PlanTaskStatus;
pr_key: string | null;
summary: string | null;
wave: number | null;
// Implementation-phase escalation (issue #25): the agent's open question, the
// human's answer, the work-preserving draft PR, and the message correlation key
// (`:`) the process parks on. NULL unless the task escalated.
open_question: string | null;
answer: string | null;
draft_pr_key: string | null;
corr_key: string | null;
created_at: string;
updated_at: string;
}
export const PLAN_TASK_STATUSES = [
"pending",
"opened",
"blocked",
"skipped",
"escalated",
"waiting-for-lane",
// Terminal: the task's PR was closed on GitHub without merging (abandoned / superseded /
// perpetually conflicting). Set by the canonical abandon writer (`abandonClosedPr`, app/service.ts)
// reached from BOTH the merge stage and the wave-merge gate. An `abandoned` task drops out of
// `waveMergeTargets` (so a dead member never wedges the wave barrier — #352) and stops
// `isPlanComplete`/the Epics table counting a phantom open task.
"abandoned",
] as const;
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
/** The `plans` record gateway (keyed on `plan_key`) — a plain record table.
*
* The epic-bucket projection (`list_bucket`/`ack_open`) is NO LONGER a write-time projection here
* (issue #439): it is DERIVED by the `plan_read_model` VIEW (074) from each row's own `status` /
* `acknowledged_at` and the derived `plan_delivery` signal, mirroring `deriveEpicBucket` /
* `epicIsAcknowledgeable` (app/delivery.ts). The Epics pages bind the VIEW, never this table's stored
* derived columns. Removing the write-time projection closes the drift the framework
* `instanceTracking` reconciler opened (a raw-datasource `{status:"abandoned"}` write on a terminated
* instance bypassed the projecting gateway and froze the bucket) AND retires the read-time
* `pollPlanBucket` correction: the VIEW sees the live delivery signal, so a still-converging epic never
* offers Dismiss without a poller pass. The pure helpers stay the acknowledge-epic guard and the VIEW's
* test oracle (app/plansReadModel.test.ts). */
export const plans = (data: DataLayer) => data.table("plans", "plan_key");
/** A plan row as seen through its derived tracking VIEW (`plans__tracking`): the base columns plus
* urban's ADR-0065 `derived_status`, which folds the reconciler's terminal edge (out-of-band
* terminate / in-app cancel → `abandoned`) over the worker-owned transient. */
type TrackedPlan = Plan & { derived_status: string };
/** Read-only accessor over the plan derived tracking VIEW. Use this — and read `derived_status`, not
* `status` — for any terminal/active classification (the shared-base admission filter, the
* epic-admission idempotency gate), so an out-of-band-terminated epic (whose base row is still
* `planning`/`dispatched`) is correctly seen as `abandoned`. Worker-written terminals (`done`/
* `failed`) pass through unchanged. Writes stay on `plans`. */
export const plansTracking = (data: DataLayer) =>
derivedTrackingTable(data, "plans", "plan_key");
export const planTasks = (data: DataLayer) => data.table("plan_tasks", "id");
/** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
* Keyed on `plan_key` so a single delete clears a plan's whole edge set (as pr_dependencies). */
export interface PlanTaskDep {
plan_key: string;
task_id: string;
depends_on_task_id: string;
}
export const planTaskDeps = (data: DataLayer) =>
data.table("plan_task_deps", "plan_key");
/** One cross-repo CAPABILITY EDGE on a plan task (049_plan_task_needs.sql, issue #289): the
* consuming `task_id` must not start until the upstream capability `capability_ref` first ships as
* a published `package` version. Levelized from the planner's `RecordPlanTask.needs[]` by
* `pr.record-plan`, read back by `pr.select-wave` to gate the task before dispatch. Keyed on
* `plan_key` (like {@link PlanTaskDep}) so one delete clears a plan's whole need set on re-plan.
*
* `capability_ref` is the STABLE handle (`owner/repo#NNN` | `repo#NNN` | `#NNN`) — NEVER a version
* (the #263 core decision). `package` is the per-package-scoped provenance artifact. `verify_command`
* is the optional gated empirical fallback (#274 decision 5); NULL means deterministic-provenance-only. */
export interface PlanTaskNeed {
plan_key: string;
task_id: string;
capability_ref: string;
package: string;
verify_command: string | null;
}
export const planTaskNeeds = (data: DataLayer) =>
data.table("plan_task_needs", "plan_key");
/** One host-orchestrated CAPABILITY GATE (050_capability_gates.sql, issue #289): the durable,
* idempotent state the `pollCapabilityGatesImpl` reconciler keeps for ONE (plan, task, capability
* need) while its plan-fanout fan-out is parked at the `wait-caps-resolved` barrier. The host starts
* the EXISTING `readiness-gate` process (#258) per need — recording its instance key on `process_key`
* so it starts exactly once — and, each pass, reconciles whether the capability has shipped as a
* published `pkg@version`; on match it stamps `resolved_artifact` and flips `status` to `resolved`.
* When every one of a task's needs is `resolved` the reconciler publishes `caps-resolved` (releasing
* the barrier with the late-bound brief). Keyed on the readiness-gate correlation key
* `:::` ({@link capabilityGateKey}) so a host restart
* re-derives the whole picture from the DB — never re-starting a gate nor re-publishing a settled
* barrier. `package` is part of the key so two needs sharing a `capability_ref` across different
* packages never collide on one gate row. */
export interface CapabilityGate {
gate_key: string;
plan_key: string;
task_id: string;
capability_ref: string;
package: string;
status: string;
resolved_artifact: string | null;
process_key: string | null;
created_at: string;
updated_at: string;
}
export const capabilityGates = (data: DataLayer) =>
data.table("capability_gates", "gate_key");
/** One INTER-epic dependency edge in the plan-set DAG (issue #292, slice S1): the epic `plan_key`
* waits for the producer epic `depends_on_plan_key` to publish a capability before it may fan out.
*
* This is the coarser sibling of {@link PlanTaskDep} (which orders TASKS *within* one epic into
* waves). A `PlanDep` orders whole EPICS relative to each other. The edge additionally carries the
* gating contract descriptor the capability probe (slice S3) resolves against:
* • `package` — the producer epic's published package name, and
* • `capability_ref` — the producer epic's issue handle, used to resolve which published
* `pkg@version` FIRST carries the awaited capability (late-bound into the dependent's build).
* Keyed on `plan_key` (the dependent) so a single delete clears a dependent's whole inbound edge set
* — mirroring how `plan_task_deps` is keyed on `plan_key`. See db/migrations/041_inter_epic_plan_deps.sql
* for the durable constraints (one edge per consumer→producer pair; no self-edge). */
export interface PlanDep {
plan_key: string;
depends_on_plan_key: string;
package: string;
capability_ref: string;
created_at: string;
}
export const planDeps = (data: DataLayer) => data.table("plan_deps", "plan_key");
/** The fields an admission caller supplies for one inter-epic edge; `created_at` is stamped here. */
export type PlanDepInput = Omit;
/** The record-gateway shape both the durable `plan_deps` table and its FK-free admission-staging
* twin `admitted_plan_deps` expose. Both hold the identical {@link PlanDep} row, so the idempotent
* edge-insert below is written once against this shape and reused for both. */
type PlanDepTable = ReturnType;
/** Insert one inter-epic edge into `table` idempotently, enforcing the schema's two invariants at
* the app layer too (the durable table backstops both, but the in-memory test data layer does not):
* an epic may not depend on itself, and a consumer→producer edge is recorded at most once. A
* duplicate re-submission is a no-op that returns the existing row rather than throwing, so batch
* admission (S2) stays idempotent; a self-edge is a programming/validation error and throws. `label`
* only names the offending table in the self-edge error. */
async function insertEdgeIdempotent(
table: PlanDepTable,
label: string,
edge: PlanDepInput,
): Promise {
if (edge.plan_key === edge.depends_on_plan_key) {
throw new Error(`${label}: self-edge rejected — epic ${edge.plan_key} cannot depend on itself`);
}
const match = { plan_key: edge.plan_key, depends_on_plan_key: edge.depends_on_plan_key };
const existing = (await table.find(match))[0];
if (existing) return existing;
const row: PlanDep = { ...edge, created_at: now() };
try {
await table.insert(row);
return row;
} catch (err) {
// A concurrent caller may have inserted the same consumer→producer pair between our find and
// our insert (classic check-then-insert race); the composite PRIMARY KEY is the durable
// backstop that rejects the loser. Honour the "duplicate re-submission is a no-op" contract by
// re-reading and returning the winning row rather than surfacing the constraint error. Only a
// genuine non-collision failure (the pair still absent after the re-read) is re-raised.
const raced = (await table.find(match))[0];
if (raced) return raced;
throw err;
}
}
/** Record one inter-epic dependency edge into the durable `plan_deps` graph. Idempotent on the
* consumer→producer pair; a self-edge throws. NOTE: `plan_deps.plan_key` foreign-keys to an admitted
* `plans` row, so this is written by slice S3 (planner lowering), NOT by the S2 admission door —
* S2 stages edges FK-free via {@link recordAdmittedPlanDep} instead. */
export function recordPlanDep(data: DataLayer, edge: PlanDepInput): Promise {
return insertEdgeIdempotent(planDeps(data), "plan_deps", edge);
}
/** One STAGED admitted epic (issue #292 slice S2, db/migrations/045_epic_set_admission_staging.sql):
* the FK-free record the set/batch admission door persists per admitted epic so a crash between
* admission and lowering does not lose the set. It carries exactly what slice S3 (lowering) needs to
* MATERIALIZE the durable `plans` row — repo, issue number/url, and the normalized integration base
* branch admitPlan resolved. Keyed on `plan_key`, one staged row per epic (idempotent re-submit). */
export interface AdmittedEpic {
plan_key: string;
repo: string;
issue_number: number;
issue_url: string;
base_branch: string;
created_at: string;
}
export const admittedEpics = (data: DataLayer) =>
data.table("admitted_epics", "plan_key");
/** The FK-free staging twin of `plan_deps` (db/migrations/045_epic_set_admission_staging.sql): the
* validated inter-epic edges the S2 admission door stages before any `plans` row exists. Same row
* shape as {@link PlanDep}; slice S3 reads it to materialize the durable `plan_deps` edges. */
export const admittedPlanDeps = (data: DataLayer) =>
data.table("admitted_plan_deps", "plan_key");
/** The fields an admission caller supplies for one staged admitted epic; `created_at` is stamped
* here (mirrors {@link PlanDepInput}). */
export type AdmittedEpicInput = Omit;
/** Stage one admitted epic (issue #292 slice S2). Idempotent on `plan_key`: a re-submitted set that
* re-admits the same epic is a no-op returning the existing staged row, so the whole set door stays
* idempotent (mirroring {@link recordAdmittedPlanDep}). */
export async function recordAdmittedEpic(
data: DataLayer,
epic: AdmittedEpicInput,
): Promise {
const table = admittedEpics(data);
const existing = await table.get(epic.plan_key);
if (existing) return existing;
const row: AdmittedEpic = { ...epic, created_at: now() };
try {
await table.insert(row);
return row;
} catch (err) {
// Concurrent re-admission of the same epic races on the PRIMARY KEY (plan_key); honour the
// idempotent no-op contract by returning the winning row rather than surfacing the collision.
const raced = await table.get(epic.plan_key);
if (raced) return raced;
throw err;
}
}
/** Stage one validated inter-epic edge (issue #292 slice S2) into the FK-free `admitted_plan_deps`
* table. Idempotent on the consumer→producer pair; a self-edge throws. This is the S2 door's
* persistence for edges — it does NOT touch `plan_deps` (whose FK requires a `plans` row S2 has not
* created); slice S3 materializes the durable edge from this staging. */
export function recordAdmittedPlanDep(data: DataLayer, edge: PlanDepInput): Promise {
return insertEdgeIdempotent(admittedPlanDeps(data), "admitted_plan_deps", edge);
}
/** All INBOUND edges for `planKey` — i.e. every producer epic this dependent waits on. Empty for a
* root epic (no inter-epic dependencies). */
export function inboundPlanDeps(data: DataLayer, planKey: string): Promise {
return planDeps(data).find({ plan_key: planKey });
}
/** Every inter-epic edge whose dependent is in `planKeys` — the whole DAG for a submitted plan set.
* Reads per-key (not a table scan) so it composes with the same equality-filtered data layer the
* unit tests exercise. Producers outside the set are still returned as edge fields; the set
* validator (S3) is what rejects an edge naming an unsubmitted epic. */
export async function planDepsForSet(data: DataLayer, planKeys: string[]): Promise {
const seen = new Set();
const out: PlanDep[] = [];
// De-duplicate the keys first so a repeated key (retries / accidental repeats) does not trigger a
// redundant per-key inbound read; the edge de-dup below still guards against any overlap.
for (const key of new Set(planKeys)) {
for (const edge of await inboundPlanDeps(data, key)) {
const id = `${edge.plan_key}\u0000${edge.depends_on_plan_key}`;
if (seen.has(id)) continue;
seen.add(id);
out.push(edge);
}
}
return out;
}
/** One adversarial plan-review round (006_plan_review.sql): the `senior:plan-review` agent's
* verdict on the plan before fan-out. Append-only within a plan run; the current round is
* `count(plan_reviews)`. Re-planning a finished issue clears the prior rows (see startPlan) so
* the round index restarts at 0.
* `job_key` is the engine job key that wrote the row — an idempotency guard so a retried job
* (crash/timeout after the insert) reuses its row instead of appending a duplicate round. */
export interface PlanReview {
plan_key: string;
epoch: number;
round: number;
approved: number;
findings: string | null;
created_at: string;
job_key: string | null;
}
export const planReviews = (data: DataLayer) => data.table("plan_reviews", "plan_key");
/** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
* (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
* cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
export function positiveIntEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw == null || raw.trim() === "") return fallback;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : fallback;
}
/** Max adversarial plan-review rounds per epoch. Reaching the cap WITHOUT approval parks the
* fan-out on a human plan-review escalation rather than dispatching an un-approved plan (issue
* #86). A human `revise` answer starts a fresh epoch, so the next plan gets a full new budget. */
export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
/** The current review epoch is a durable process variable (`planReviewEpoch`) bumped by the
* `plan-review-decision` user task each time a human answers a plan-review escalation. It is read
* back by `record-plan-review` to reset the round budget — there is no derived counter here. */
/** A plan is "done" in exactly these states; everything else (planning, dispatched)
* is in flight. The cancel guard and the active view key off this. */
export const PLAN_TERMINAL_STATUSES = ["done", "failed", "abandoned"] as const;
export interface ParsedIssue {
repo: string;
number: number;
url: string;
planKey: string;
}
/** Parse "owner/repo#123" or a canonical issue URL into its parts. Mirrors parsePr
* (app/service.ts) but for the /issues/ path. */
export function parseIssue(input: string): ParsedIssue | null {
const s = input.trim();
let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/i);
if (m) {
const repo = `${m[1]}/${m[2]}`;
const number = Number(m[3]);
return { repo, number, url: `https://github.com/${repo}/issues/${number}`, planKey: `${repo}#${number}` };
}
m = s.match(/^([^/]+\/[^#]+)#(\d+)$/);
if (m) {
const repo = m[1];
const number = Number(m[2]);
return { repo, number, url: `https://github.com/${repo}/issues/${number}`, planKey: `${repo}#${number}` };
}
return null;
}
/** The base-branch validation gate lives in the side-effect-free leaf `./baseBranch.ts` so an API
* door (e.g. `operations/dispatchDeliveryGraph.ts`) can reuse it without importing this heavy module
* and its import-time env seeding. Re-exported here so existing importers keep resolving through
* `plan.ts` — one implementation (derivation over duplication), just hoisted below the heavy module. */
export { InvalidBaseBranchError, isPlausibleBranchName, MissingBaseBranchError, normalizeBaseBranch };
/** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
* branch. It is authoritative over the static "branch off the default branch" wording in
* resources/prompts/feature.md, so the agent branches off — and opens its PR against — the integration
* branch, and reads the epic's latest landed state there rather than the repo default branch. */
export function renderBaseBranchBrief(baseBranch: string): string {
return [
"",
"",
"---",
"",
`**Base branch (authoritative — overrides any "default branch" instruction above): \`${baseBranch}\`.**`,
"",
`This epic lands on \`${baseBranch}\`, NOT the repository default branch. Everywhere the`,
"instructions say \"default branch\", use this branch instead:",
"",
`- Branch off it: \`git fetch origin ${baseBranch} && git checkout -b feat/ origin/${baseBranch}\`.`,
`- Read the epic's latest landed state from \`${baseBranch}\` (your prerequisites merged there, not into the default branch).`,
`- Open your PR against it: \`gh pr create --base ${baseBranch} ...\`.`,
"",
"Do not target the repository default branch — a PR opened against it will not be merged into the epic.",
].join("\n");
}
/** Raised when the explicit base branch IS the repository default branch but the caller did not
* acknowledge the consequence with `confirmDefaultBase: true` (ADR 0003 rule 3). Naming the default
* is the one dangerous explicit value: every task lands directly on it with no integration buffer,
* and any merge-to-default side effect fires per task. The operation edge maps this to a 400. */
export class DefaultBaseNotConfirmedError extends Error {
readonly branch: string;
constructor(branch: string) {
super(
`base branch "${branch}" is the repository default branch: every task would land directly ` +
`on "${branch}" with NO integration branch, and any merge-to-default side effect (e.g. ` +
`auto-publish) would fire per task. Re-submit with confirmDefaultBase: true to acknowledge ` +
`and proceed, or name an epic/* integration branch instead.`,
);
this.name = "DefaultBaseNotConfirmedError";
this.branch = branch;
}
}
/** Raised when another ACTIVE plan (status ∉ PLAN_TERMINAL_STATUSES) already targets the same repo
* + same custom base branch, and the caller did not pass `allowSharedBase: true` (ADR 0003 rule 4).
* Two in-flight epics sharing one integration branch interleave commits and poison each other's
* base. The default branch is EXEMPT (many epics target it concurrently without colliding — each
* task PR is independent). The operation edge maps this to a 409. */
export class SharedBaseError extends Error {
readonly repo: string;
readonly branch: string;
constructor(repo: string, branch: string) {
super(
`base branch "${branch}" on ${repo} is already in use by another active epic. Sharing one ` +
`integration branch across epics interleaves their commits and poisons the base. Re-submit ` +
`with allowSharedBase: true only if you intend to stack on it, or name a distinct epic/* ` +
`branch.`,
);
this.name = "SharedBaseError";
this.repo = repo;
this.branch = branch;
}
}
/** Find plans on `repo` targeting `base` whose status is NOT terminal (i.e. still active). Used by
* the shared-base admission guard to detect a second epic reaching for the same integration branch.
* Grandfathered `base_branch = null` rows never match a non-null `base`, so they are ignored. */
export async function findActivePlansByBase(
data: DataLayer,
repo: string,
base: string,
): Promise {
const rows = await plansTracking(data).find({ repo, base_branch: base });
// ADR-0065: classify "active" on the DERIVED terminal edge, not the base transient — an epic whose
// engine instance was terminated out-of-band (or by an ordinary in-app cancel) keeps its base
// `status` frozen at `planning`/`dispatched` but reads `abandoned` on `plans__tracking.derived_status`.
// Reading the base `status` here counted a dead epic as ACTIVE and raised a false same-repo conflict.
return rows.filter((p) => !PLAN_TERMINAL_STATUSES.some((s) => s === p.derived_status));
}
/** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
* default to `false` — a "warn you can't skip": the operator must consciously opt in. */
export interface AdmitPlanOptions {
allowSharedBase?: boolean;
confirmDefaultBase?: boolean;
/** The `plan_key` of the launch being admitted. When set, the shared-base guard (rule 4)
* EXCLUDES this plan's own active row, so an idempotent re-submit of the same issue does not
* trip `SharedBaseError` against itself — `startPlan` is idempotent on `plan_key` and returns
* `alreadyRunning` for an active plan, so the retry must reach it, not 409 on rule 4. */
selfPlanKey?: string;
}
/** Fail-fast admission gate for an epic launch (ADR 0003 §Decision). Composes the four ordered
* admission rules BEFORE any task fans out and returns the normalized base branch on success. The
* ORDER is load-bearing — the cheapest / most fundamental reject (missing or typo'd base) fires
* first, so it is NOT reordered:
*
* 1. Required + explicit — `normalizeBaseBranch` (blank/absent → `MissingBaseBranchError`;
* implausible → `InvalidBaseBranchError`).
* 2. Create-if-missing (epic/* guard), synchronously — `ensureBaseBranch`: a missing non-`epic/*`
* base throws `BaseBranchMustExistError` HERE (so a typo is a clean edge 400, not a late
* per-task failure); a missing `epic/*` base is created off default HEAD before fan-out; an
* existing base is a no-op. It is idempotent, so the durable `ensure-base-branch` head task
* re-runs it as belt-and-suspenders.
* 3. Confirm-default — if the base equals the repo default branch and `confirmDefaultBase` is not
* `true`, throw `DefaultBaseNotConfirmedError`. The default branch is then EXEMPT from rule 4.
* 4. Shared-base — if a DIFFERENT active plan already targets this same custom base and
* `allowSharedBase` is not `true`, throw `SharedBaseError`. The launch's own active row is
* excluded (via `options.selfPlanKey`) so an idempotent same-issue re-submit is not a 409.
*/
export async function admitPlan(
data: DataLayer,
repo: string,
baseBranch: string | null | undefined,
token: string,
options: AdmitPlanOptions = {},
): Promise {
// Rule 1 — required + explicit.
const base = normalizeBaseBranch(baseBranch);
// Rule 2 — create-if-missing (epic/* guard), synchronously at admission. A missing non-epic/*
// base throws BaseBranchMustExistError → clean edge 400; a missing epic/* base is created off
// default HEAD; an existing base is a no-op. Idempotent, so the head task safely re-runs it.
await ensureBaseBranch(repo, base, token);
// Rule 3 — confirm-default. Naming the repo default branch is deliberate and requires an explicit
// acknowledgement. When the base IS the default, it is exempt from the shared-base guard (rule 4),
// so return here on a confirmed default.
const defaultBranch = await fetchDefaultBranch(repo, token);
if (defaultBranch !== null && base === defaultBranch) {
if (options.confirmDefaultBase !== true) throw new DefaultBaseNotConfirmedError(base);
return base;
}
// Rule 4 — shared-base guard on a custom integration branch. Exclude this launch's OWN active row
// (when `selfPlanKey` is given) so an idempotent same-issue re-submit reaches `startPlan`'s
// `alreadyRunning` short-circuit instead of tripping a 409 against itself.
if (options.allowSharedBase !== true) {
const active = (await findActivePlansByBase(data, repo, base)).filter(
(p) => p.plan_key !== options.selfPlanKey,
);
if (active.length > 0) throw new SharedBaseError(repo, base);
}
return base;
}
/** Map an error thrown by {@link admitPlan} to the HTTP status + message the admission-door
* operations return at the edge, or `null` when the error is not an admission decision and must be
* re-raised (a genuine 500). Shared by the single-issue door (`startPlanFanout`) and the set/batch
* door (`startEpicSet`, issue #292 S2) so both map an epic's admission failure identically — a base
* rule reject is a 400, the shared-base conflict a 409. Keeping this in ONE place stops the two doors
* drifting on which admission failure maps to which status. */
export function admitPlanErrorResponse(err: unknown): { status: number; error: string } | null {
if (err instanceof MissingBaseBranchError) {
return {
status: 400,
error: "baseBranch is required (name the integration branch, e.g. epic/agent-protocol)",
};
}
if (err instanceof InvalidBaseBranchError) {
return {
status: 400,
error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)",
};
}
if (err instanceof BaseBranchMustExistError) {
return {
status: 400,
error:
`baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
`auto-created — create it first, or use the epic/* convention`,
};
}
if (err instanceof DefaultBaseNotConfirmedError) {
return {
status: 400,
error:
`baseBranch "${err.branch}" is the repository default branch — every task would land ` +
`directly on it with no integration branch. Re-submit with confirmDefaultBase: true to proceed`,
};
}
if (err instanceof SharedBaseError) {
return {
status: 409,
error:
`baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
`allowSharedBase: true to stack on it, or name a distinct epic/* branch`,
};
}
return null;
}
// ── Set/batch admission (issue #292, slice S2) ───────────────────────────────
// The set-admission door (`operations/startEpicSet.ts`) admits a WHOLE set of epics plus the
// inter-epic dependency edges between them in one all-or-nothing call. The pure validation below
// (reference integrity + DAG check) runs BEFORE any `admitPlan` side effect, so a malformed set is a
// clean 4xx with nothing half-started (no base branch created, no edge persisted). The door's only
// durable write is staging the admitted epics + validated edges FK-free into `admitted_epics` /
// `admitted_plan_deps`; materializing them into `plans` / `plan_deps` and scheduling/lowering
// (starting roots, seeding the capability readiness-gate, version binding) is slice S3.
/** One inter-epic dependency edge as SUBMITTED to the set door: the `consumer` epic waits for the
* `producer` epic to publish the `{ package, capabilityRef }` capability. `consumer`/`producer` are
* epic references (`owner/repo#N` or an issue URL); the door resolves them to plan keys. This type
* documents the wire contract only — the actual `deps[]` arrives untyped, so {@link validateEpicSet}
* validates each entry against this shape at runtime rather than trusting the type. */
export interface EpicSetDepInput {
consumer: string;
producer: string;
package: string;
capabilityRef: string;
}
/** A validated inter-epic edge — both endpoints resolved to plan keys, ready to persist as a
* {@link PlanDep} (`plan_key = consumer`, `depends_on_plan_key = producer`). */
export interface ResolvedEpicSetDep {
consumer: string;
producer: string;
package: string;
capabilityRef: string;
}
/** A set-admission validation failure that carries the HTTP status the door returns at the edge
* (always a 4xx — a malformed set is the caller's error, not a server fault). */
export class EpicSetValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "EpicSetValidationError";
this.status = status;
}
}
/** Narrow an untyped value to a plain object so its fields can be read as `unknown`. */
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
/** Pure, side-effect-free validation of a submitted epic set's SHAPE and DAG — run BEFORE any
* `admitPlan` call so a cycle or dangling edge is rejected with nothing half-started. Given the
* submitted set's plan keys (already parsed, in submission order) and the raw edges, it:
* • rejects an empty set;
* • rejects a duplicate epic in the set;
* • parses each edge endpoint and rejects an unparseable/self/dangling edge (an endpoint not in
* the submitted set);
* • rejects a blank `package`/`capabilityRef`;
* • rejects a cycle in the consumer→producer graph, naming the edge that closes it.
* Returns the edges with both endpoints resolved to plan keys. Throws {@link EpicSetValidationError}
* (status 400) at the first offending input. Idempotent-friendly: a duplicate EDGE (same
* consumer→producer submitted twice) is collapsed, not rejected, so a retried set validates.
*
* `deps` is accepted as `unknown[]` because it arrives straight from an untyped request body
* (`startEpicSet` forwards `body.deps` verbatim). Every entry's shape is therefore validated
* defensively here — a non-object entry (`null`, `{}`), or a non-string endpoint / `package` /
* `capabilityRef`, maps to a clean {@link EpicSetValidationError} (400), never an uncaught
* TypeError (500). */
export function validateEpicSet(planKeys: string[], deps: readonly unknown[]): ResolvedEpicSetDep[] {
if (planKeys.length === 0) {
throw new EpicSetValidationError(400, "epic set is empty — submit at least one epic");
}
const inSet = new Set();
for (const key of planKeys) {
if (inSet.has(key)) {
throw new EpicSetValidationError(400, `epic ${key} appears more than once in the submitted set`);
}
inSet.add(key);
}
const resolveEndpoint = (ref: string, role: "consumer" | "producer"): string => {
// Trim like the epic-member path (`parseIssue(ref.trim())` in startEpicSet) so an otherwise-valid
// padded endpoint (" owner/repo#1 ") from an untyped JSON payload is not rejected as unparseable.
const parsed = parseIssue(ref.trim());
if (!parsed) {
throw new EpicSetValidationError(
400,
`dependency ${role} "${ref}" could not be parsed (use owner/repo#123 or an issue URL)`,
);
}
if (!inSet.has(parsed.planKey)) {
throw new EpicSetValidationError(
400,
`dependency ${role} ${parsed.planKey} is not one of the submitted epics — every edge must ` +
`connect two epics in the set`,
);
}
return parsed.planKey;
};
const resolved: ResolvedEpicSetDep[] = [];
const seenEdges = new Set();
// consumer plan key → set of producer plan keys it depends on (for the DAG / cycle check).
const adjacency = new Map>();
for (const rawDep of deps) {
if (!isRecord(rawDep)) {
throw new EpicSetValidationError(
400,
"each dependency must be an object with consumer, producer, package and capabilityRef fields",
);
}
if (typeof rawDep.consumer !== "string" || typeof rawDep.producer !== "string") {
throw new EpicSetValidationError(
400,
"each dependency needs string consumer and producer endpoints (owner/repo#123 or an issue URL)",
);
}
const consumer = resolveEndpoint(rawDep.consumer, "consumer");
const producer = resolveEndpoint(rawDep.producer, "producer");
if (consumer === producer) {
throw new EpicSetValidationError(400, `epic ${consumer} cannot depend on itself`);
}
const pkg = (typeof rawDep.package === "string" ? rawDep.package : "").trim();
const capabilityRef = (typeof rawDep.capabilityRef === "string" ? rawDep.capabilityRef : "").trim();
if (pkg.length === 0) {
throw new EpicSetValidationError(
400,
`dependency ${consumer} → ${producer} is missing a package (the producer's published package)`,
);
}
if (capabilityRef.length === 0) {
throw new EpicSetValidationError(
400,
`dependency ${consumer} → ${producer} is missing a capabilityRef (the producer's issue handle)`,
);
}
const edgeId = `${consumer}\u0000${producer}`;
if (seenEdges.has(edgeId)) continue; // duplicate edge in one submission → collapse (idempotent)
seenEdges.add(edgeId);
resolved.push({ consumer, producer, package: pkg, capabilityRef });
const producers = adjacency.get(consumer) ?? new Set();
producers.add(producer);
adjacency.set(consumer, producers);
}
assertAcyclic(adjacency);
return resolved;
}
/** Depth-first cycle check over the consumer→producer graph. Throws {@link EpicSetValidationError}
* (400) naming an edge on the cycle the moment one is found — the "reject at the offending edge"
* guarantee. A pure in-memory walk (no I/O), so it runs before any admission side effect. */
function assertAcyclic(adjacency: Map>): void {
const VISITING = 1;
const DONE = 2;
const state = new Map();
const visit = (node: string, stack: string[]): void => {
state.set(node, VISITING);
stack.push(node);
for (const next of adjacency.get(node) ?? []) {
const s = state.get(next);
if (s === VISITING) {
throw new EpicSetValidationError(
400,
`dependency cycle detected: ${[...stack, next].join(" → ")} — the edge set must be a DAG`,
);
}
if (s !== DONE) visit(next, stack);
}
stack.pop();
state.set(node, DONE);
};
for (const node of adjacency.keys()) {
if (state.get(node) !== DONE) visit(node, []);
}
}
/** Optional scheduling inputs a planner (slice S3) threads into a plan-fanout instance at start.
*
* `readinessProbes` is the set of `capability` {@link ReadinessProbe} descriptors a DEPENDENT epic
* must satisfy before it fans out any wave — one per inbound inter-epic edge (its producers). The
* plan-fanout process runs them as a LEADING readiness-gate preflight (resources/processes/plan-fanout.bpmn):
* a root epic (no inbound edge) is started with `undefined`/empty here and skips the gate entirely,
* fanning out immediately exactly as a single epic does today. `probeTimeout` is the ISO-8601 bound
* the preflight's timers fire off (derived once, via {@link readinessTimeout}, from the same probes)
* so a never-publishing producer escalates in bounded time instead of wedging the dependent. */
export interface StartPlanOptions {
readinessProbes?: ReadinessProbe[];
probeTimeout?: string;
probePollEvery?: string;
}
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
* planKey: a plan already in flight is not restarted. */
export async function startPlan(
data: DataLayer,
engine: EngineClient,
parsed: ParsedIssue,
baseBranch: string,
opts: StartPlanOptions = {},
) {
// A gated dependent must carry BOTH its probes and the bound its preflight timers fire off:
// `pr.readiness-probe` rejects a blank `probeTimeout` (worker.ts) and the preflight escalation
// timers read `=probeTimeout`, so a non-empty probe set with a null/blank bound would incident at
// runtime. Fail fast at the start door instead — the lowering (planLowering.ts) always derives the
// two together via `readinessTimeout`, so this only fires for a mis-seeded direct caller.
const probes = opts.readinessProbes && opts.readinessProbes.length > 0 ? opts.readinessProbes : null;
if (probes && (opts.probeTimeout ?? "").trim() === "") {
throw new Error(
`startPlan(${parsed.planKey}): ${probes.length} readiness probe(s) seeded without a probeTimeout — ` +
"the preflight escalation timers (=probeTimeout) and pr.readiness-probe both require a non-blank " +
"bound. Derive it via readinessTimeout (see planLowering) before starting a gated dependent.",
);
}
if (probes && (opts.probePollEvery ?? "").trim() === "") {
throw new Error(
`startPlan(${parsed.planKey}): ${probes.length} readiness probe(s) seeded without a probePollEvery — ` +
"the preflight retry timers (=probePollEvery) require a non-blank cadence. Derive it via " +
"readinessPollEvery (see planLowering) before starting a gated dependent.",
);
}
const table = plans(data);
const existing = await table.get(parsed.planKey);
// ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. An epic
// whose engine instance was terminated out-of-band (or by an ordinary in-app cancel — derive-only
// under urban 0.81.0) has a base row frozen at `planning`/`dispatched` but a
// `plans__tracking.derived_status` of `abandoned`; reading the base `status` here wedged a cancelled
// epic `alreadyRunning` (the `submitPr`-wedge twin). Route the idempotency gate through the derived
// view so a terminated epic is correctly seen terminal and RE-ADMITTABLE.
const trackedExisting = existing ? await plansTracking(data).get(parsed.planKey) : undefined;
if (trackedExisting && !PLAN_TERMINAL_STATUSES.some((s) => s === trackedExisting.derived_status)) {
return { planKey: parsed.planKey, alreadyRunning: true };
}
const base = normalizeBaseBranch(baseBranch);
const ts = now();
// Human-readable identity for the epics grids (issue #248): fetch the epic issue's title,
// best-effort. Coalesce to the `owner/repo#N` key at write time so `plans.title` is ALWAYS
// non-blank — the grid's `{{title}}` template then needs no fallback, and a failed/absent/blank
// fetch still shows a usable identity (the key) rather than an empty cell. A fetch failure never
// blocks the start (`fetchIssueTitle` returns null on any error).
const title = coalesceTitle(
await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
parsed.planKey,
);
// Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
// coordination brief that carries its concrete URL. The token is the credential; agents reach
// the blackboard directly with the URL we seed into `appendPrompt` below (#51).
const token = existing?.blackboard_token ?? mintBlackboardToken();
const bbUrl = blackboardUrl(token);
if (existing) {
// Re-plan a previously finished issue: clear the old tasks and start fresh.
for (const t of await planTasks(data).find({ plan_key: parsed.planKey })) {
await planTasks(data).delete(t.id);
}
// `plan_reviews` is append-only and the review round is derived from
// `count(plan_reviews)`, so stale rows from the prior run would inflate the
// next round index and reach the review-round cap early (bypassing the gate).
// Clear them here — the table is keyed on `plan_key`, so one delete drops the
// whole set (mirrors how record-plan clears `plan_task_deps`).
await planReviews(data).delete(parsed.planKey);
// Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
// rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
await clearTaskDeltas(data, parsed.planKey);
// And the merge-exclusion graph (D1, #57): stale edges would mislead the merge-train.
await clearExclusions(data, parsed.planKey);
await table.update(parsed.planKey, {
status: "planning",
task_count: 0,
issue_url: parsed.url,
title,
outcome: null,
// Genesis of the domain lifecycle (#261): the epic re-enters Planning. Cleared of any stale
// terminal phase from the prior run so the re-plan reads correctly from the first pass.
epic_phase: EPIC_PHASE.PLANNING,
blackboard_token: token,
base_branch: base,
updated_at: ts,
});
} else {
await table.insert({
plan_key: parsed.planKey,
repo: parsed.repo,
issue_number: parsed.number,
issue_url: parsed.url,
title,
status: "planning",
task_count: 0,
// Genesis of the domain lifecycle (#261): a fresh epic starts in Planning.
epic_phase: EPIC_PHASE.PLANNING,
blackboard_token: token,
base_branch: base,
created_at: ts,
updated_at: ts,
});
}
const { processInstanceKey } = await engine.createInstance({
processDefinitionId: PLAN_PROCESS_ID,
variables: {
planKey: parsed.planKey,
repo: parsed.repo,
issue: parsed.planKey,
issueNumber: parsed.number,
issueUrl: parsed.url,
planFindings: null,
// The plan-review epoch is a durable process variable, bumped by the `plan-review-decision`
// user task each time a human answers a plan-review escalation. `record-plan-review` reads it
// to reset the per-epoch round budget; it starts at 0 for the first review round.
planReviewEpoch: 0,
// Escalation-of-the-escalation SLA (U5, #156): the validated ISO-8601 duration seeded onto the
// instance and read by each escalation user task's interrupting timer boundary
// (`=escalationSlaTimeout`). If a human never answers, the boundary fires and
// the process auto-proceeds down the gateway's safe-default arm — durable in-process liveness,
// not a poller-side watchdog. `escalationAssignee` is the optional named assignee the escalation
// user tasks' `zeebe:assignmentDefinition` resolves (null = unassigned, routed via the
// `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
escalationAssignee: null,
// Capability-barrier bound (#289): the validated ISO-8601 duration read by the
// `wait-caps-timeout` timer arm of the `wait-caps-resolved` event-based gateway. A task whose
// declared cross-repo capabilities never resolve (most acutely an unresolvable capabilityRef the
// host reconciler can never gate) escalates to the `feature-escalation` operator user task when
// this elapses, instead of parking at the barrier forever — durable in-process liveness.
capsWaitTimeout: CAPS_WAIT_TIMEOUT,
// Coordination blackboard (#51): the capability URL + the protocol brief that each
// implementer agent gets appended to its prompt (composed into `appendPrompt` in
// plan-fanout.bpmn's implement-task). Advisory shared state, delivered in-band, used
// out-of-band.
blackboardUrl: bbUrl,
blackboardBrief: renderCoordinationBrief(bbUrl),
// Epic base branch (019_plan_base_branch.sql; ADR 0003): the branch the fleet branches off
// and opens every PR against instead of the repo default. `baseBranchBrief` rides
// `appendPrompt` in the implement-task (like `blackboardBrief`). Base is now always explicit
// (normalizeBaseBranch rejects blank), so the brief is always rendered.
baseBranch: base,
baseBranchBrief: renderBaseBranchBrief(base),
// Inter-epic scheduling (issue #292, slice S3): the leading capability readiness-gate the
// plan-fanout runs as a PREFLIGHT before wave 0. `readinessProbes` is a DEPENDENT epic's set
// of `capability` probes (one per inbound `plan_deps` edge / producer); it is `null` for a
// ROOT (no inbound edge), whose preflight gateway then routes straight past the gate so it
// fans out immediately. `probeTimeout` bounds the preflight's escalation timers (derived once
// from the same probes) so a never-publishing producer escalates without wedging the set.
// `resolvedArtifacts` is filled by the preflight on green — the exact `pkg@version`s carrying
// each awaited capability (one per probe, `null` for any that escalated). It rides the
// implement task's `appendPrompt` (like `baseBranchBrief`) so slices build against exactly the
// bound version. Seeded `null` here so a ROOT (which never runs the preflight) still resolves
// the variable in that FEEL expression instead of raising an incident.
readinessProbes: probes,
probeTimeout: opts.probeTimeout ?? null,
probePollEvery: opts.probePollEvery ?? null,
// The preflight probe worker (`pr.readiness-probe`) requires a non-blank `gateKey` correlation
// key (it publishes `readiness-ready` on it). The typed `ReadinessProbeIn` envelope projects it
// from THIS process scope (not task-local ioMapping), so it is seeded here — one per dependent
// instance. A ROOT never runs the preflight, so its `gateKey` stays `null`, unused.
gateKey: probes ? `preflight:${parsed.planKey}` : null,
resolvedArtifacts: null,
// Host-git provisioning (c8ctl, issue #684): deliver the repository envelope so each epic slice's
// `senior:feature` implementation agent (plan-fanout's per-wave `implement-cell`) gets an
// ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
// copilot workers on one host share (and clobber) a single checkout, violating the durable-resume
// design. This is the whole-epic seed, so it carries `ref = base` (the epic integration branch)
// but NO `branchCreate`: each slice's deterministic `feat/` branch differs per MI child,
// so the agent cuts its own branch inside the isolated clone (per resources/prompts/feature.md).
// The process-level variable propagates through the wave subprocess + `implement-cell` callActivity
// into each agent job. This is a fan-out seed, so it is REQUIRED (issue #729): `parsed.repo` is
// regex-bound (`parseIssue`) and `base` is `normalizeBaseBranch`-validated non-blank, so the
// `requireRepoEnvelopeVars` guard never trips here — but it makes an unresolved repo/base a HARD
// launch failure rather than a silent `{}` that would degrade every slice to the shared launch dir.
...requireRepoEnvelopeVars(parsed.repo, base),
},
});
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
if (processKey != null) {
await table.update(parsed.planKey, { process_key: processKey, updated_at: now() });
}
return { planKey: parsed.planKey, processKey };
}