immune-brain 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +19 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +10 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +6 -1
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +11 -6
- package/plugins/immune-brain/dist/BASELINE.md +18 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1039 -465
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
- package/plugins/immune-brain/dist/imm-loop.md +76 -4
- package/plugins/immune-brain/dist/imm-planner.md +65 -1
- package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +6 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +160 -12
- package/plugins/immune-brain/runtime/assurance/qa.ts +57 -42
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +20 -8
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +321 -60
- package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +6 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +73 -26
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +6 -0
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +194 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +126 -151
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +34 -4
- package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +31 -4
- package/plugins/immune-brain/skills/BASELINE.md +18 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
|
@@ -103,6 +103,19 @@ export interface InitiativeHistoricalChild {
|
|
|
103
103
|
binding: InitiativeAmendmentBinding;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* How much remote effect the caller may believe, stated explicitly instead of
|
|
108
|
+
* inferred from the status code.
|
|
109
|
+
*
|
|
110
|
+
* `confirmed` means every step settled and was read back. `uncertain` means the
|
|
111
|
+
* caller may not assume zero writes: the call must re-read exact ownership,
|
|
112
|
+
* topology and intent-hash state before replaying the same approved manifest.
|
|
113
|
+
* The layer never claims "zero writes" itself, because a status code cannot
|
|
114
|
+
* prove the absence of a remote effect; only the transport's own call record
|
|
115
|
+
* can, and a caller that needs that decides from its own readback.
|
|
116
|
+
*/
|
|
117
|
+
export type PublicationWriteState = "confirmed" | "uncertain";
|
|
118
|
+
|
|
106
119
|
export interface GithubInitiativePublicationResult {
|
|
107
120
|
contract: "immune_brain/github_initiative_publication/v1";
|
|
108
121
|
operation: "publish-initiative";
|
|
@@ -116,6 +129,12 @@ export interface GithubInitiativePublicationResult {
|
|
|
116
129
|
issue_url?: string;
|
|
117
130
|
node_id?: string;
|
|
118
131
|
}>;
|
|
132
|
+
/** Truthful effect reporting for the whole batch. */
|
|
133
|
+
write_state: PublicationWriteState;
|
|
134
|
+
/** Task ids whose step is confirmed written and read back. */
|
|
135
|
+
confirmed_steps: string[];
|
|
136
|
+
/** Task ids whose step is not yet confirmed. */
|
|
137
|
+
pending_steps: string[];
|
|
119
138
|
execution?: {
|
|
120
139
|
recommended_first_task_id: string;
|
|
121
140
|
recommended_first_issue_number: number;
|
|
@@ -170,6 +189,8 @@ export type TrackerOperation =
|
|
|
170
189
|
risk: "routine" | "material" | "critical";
|
|
171
190
|
acceptance: Array<{ id: string; summary: string }>;
|
|
172
191
|
projection?: TaskProjection;
|
|
192
|
+
/** The canonical TaskIntent content hash this Task was prepared from. */
|
|
193
|
+
intent_hash?: string;
|
|
173
194
|
}
|
|
174
195
|
| {
|
|
175
196
|
op: "mark-terminal";
|
|
@@ -187,9 +208,27 @@ export interface GhExecution {
|
|
|
187
208
|
}
|
|
188
209
|
|
|
189
210
|
export interface GhTransport {
|
|
190
|
-
run(args: string[], options?: { cwd?: string; stdin?: string }): Promise<GhExecution>;
|
|
211
|
+
run(args: string[], options?: { cwd?: string; stdin?: string; budget?: OperationBudget }): Promise<GhExecution>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One shared deadline and cancellation boundary for a whole operation. A
|
|
216
|
+
* per-call timeout alone cannot bound a multi-step publication: several calls
|
|
217
|
+
* that each finish inside their own limit still add up past the operation's
|
|
218
|
+
* budget. The entry point creates the budget once and passes it to every
|
|
219
|
+
* transport call, so a spent budget refuses the next remote write instead of
|
|
220
|
+
* starting one more.
|
|
221
|
+
*/
|
|
222
|
+
export interface OperationBudget {
|
|
223
|
+
/** Wall-clock time by which the whole operation must have finished. */
|
|
224
|
+
deadline_ms: number;
|
|
225
|
+
/** Optional external cancellation signal (user cancel / host abort). */
|
|
226
|
+
signal?: { readonly aborted: boolean };
|
|
191
227
|
}
|
|
192
228
|
|
|
229
|
+
/** Default whole-operation publication budget: two minutes. */
|
|
230
|
+
export const PUBLICATION_BUDGET_MS = 120_000;
|
|
231
|
+
|
|
193
232
|
interface RepositoryInfo {
|
|
194
233
|
id: number;
|
|
195
234
|
name_with_owner: string;
|
|
@@ -238,7 +277,7 @@ function countLiteral(value: string, needle: string): number {
|
|
|
238
277
|
return count;
|
|
239
278
|
}
|
|
240
279
|
|
|
241
|
-
function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id", value: string | number): string {
|
|
280
|
+
function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id" | "intent-hash", value: string | number): string {
|
|
242
281
|
return `<!-- immune-brain:${name}=${value} -->`;
|
|
243
282
|
}
|
|
244
283
|
|
|
@@ -436,6 +475,10 @@ function ghFailure(
|
|
|
436
475
|
): GithubTrackerResult {
|
|
437
476
|
const retryable = execution.timed_out
|
|
438
477
|
|| /timeout|timed out|network|connection|temporar|rate limit|502|503|504/i.test(execution.stderr);
|
|
478
|
+
// A transport failure is the one class where re-reading the approved state and
|
|
479
|
+
// replaying the same manifest is the correct recovery, so that action is
|
|
480
|
+
// attached here, at the origin. Any other failure keeps its own message as
|
|
481
|
+
// the single action; nothing generic is layered on top of it.
|
|
439
482
|
return result(
|
|
440
483
|
operation,
|
|
441
484
|
retryable ? "retryable_failure" : "permanent_failure",
|
|
@@ -449,6 +492,22 @@ export function createGhTransport(binary = "gh"): GhTransport {
|
|
|
449
492
|
return new Promise((complete) => {
|
|
450
493
|
let stdout: Buffer = Buffer.alloc(0);
|
|
451
494
|
let stderr: Buffer = Buffer.alloc(0);
|
|
495
|
+
// A spent or cancelled operation budget never starts another remote call.
|
|
496
|
+
const remaining = options.budget === undefined
|
|
497
|
+
? GH_TIMEOUT_MS
|
|
498
|
+
: Math.min(GH_TIMEOUT_MS, options.budget.deadline_ms - Date.now());
|
|
499
|
+
if (options.budget && (options.budget.signal?.aborted || remaining <= 0)) {
|
|
500
|
+
complete({
|
|
501
|
+
exit_code: 1,
|
|
502
|
+
stdout: "",
|
|
503
|
+
stderr: options.budget.signal?.aborted
|
|
504
|
+
? "operation cancelled by the caller"
|
|
505
|
+
: "publication deadline exceeded before the next remote call",
|
|
506
|
+
timed_out: true,
|
|
507
|
+
output_exceeded: false,
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
452
511
|
let timedOut = false;
|
|
453
512
|
let outputExceeded = false;
|
|
454
513
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -496,7 +555,7 @@ export function createGhTransport(binary = "gh"): GhTransport {
|
|
|
496
555
|
timer = setTimeout(() => {
|
|
497
556
|
timedOut = true;
|
|
498
557
|
child.kill("SIGKILL");
|
|
499
|
-
},
|
|
558
|
+
}, remaining);
|
|
500
559
|
child.once("close", (code) => { finish(code ?? 1); });
|
|
501
560
|
try {
|
|
502
561
|
childIn.end(options.stdin ?? "");
|
|
@@ -615,6 +674,73 @@ function initiativeLookup(issues: GithubIssue[], repositoryId: number, initiativ
|
|
|
615
674
|
return findIssue(issues, [initiative, KIND_INITIATIVE_MARKER], [marker("repo-id", repositoryId), initiative]);
|
|
616
675
|
}
|
|
617
676
|
|
|
677
|
+
/**
|
|
678
|
+
* The published Intent identity marker, when the Child carries one. `null` means
|
|
679
|
+
* this Child was published before the marker existed: an upgraded tracker still
|
|
680
|
+
* converges it (the marker is additive), which keeps a previously published
|
|
681
|
+
* batch idempotently replayable across the upgrade. It is never invented
|
|
682
|
+
* locally: the value is the canonical hash of the Intent the Child was prepared
|
|
683
|
+
* from.
|
|
684
|
+
*/
|
|
685
|
+
/** Any published-identity markers on the body, in order. */
|
|
686
|
+
function publishedIntentHashMarkers(body: string): string[] {
|
|
687
|
+
return [...body.matchAll(/<!-- immune-brain:intent-hash=([A-Za-z0-9][A-Za-z0-9._:-]{0,127}) -->/g)].map((match) => match[1]);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The published Intent identity of a Child, as three distinct states:
|
|
692
|
+
*
|
|
693
|
+
* - `absent`: published before the marker existed. It is a pre-upgrade
|
|
694
|
+
* publication and still converges, because the marker is additive.
|
|
695
|
+
* - `bound`: exactly one well-formed marker. It must agree with the Intent
|
|
696
|
+
* being published.
|
|
697
|
+
* - `ambiguous`: two or more markers. The Child states several conflicting
|
|
698
|
+
* identities, so nothing can be trusted about which Intent it came from; it
|
|
699
|
+
* fails closed rather than being read as `absent`.
|
|
700
|
+
*
|
|
701
|
+
* Collapsing `ambiguous` into `absent` would let a Child carry both the correct
|
|
702
|
+
* and a stale hash and still pass every check, so the three states are kept
|
|
703
|
+
* separate everywhere the identity is read.
|
|
704
|
+
*/
|
|
705
|
+
type PublishedIntentIdentity =
|
|
706
|
+
| { kind: "absent" }
|
|
707
|
+
| { kind: "bound"; hash: string }
|
|
708
|
+
| { kind: "ambiguous"; hashes: string[] };
|
|
709
|
+
|
|
710
|
+
function publishedIntentIdentity(body: string): PublishedIntentIdentity {
|
|
711
|
+
const hashes = publishedIntentHashMarkers(body);
|
|
712
|
+
if (hashes.length === 0) return { kind: "absent" };
|
|
713
|
+
if (hashes.length === 1) return { kind: "bound", hash: hashes[0] };
|
|
714
|
+
return { kind: "ambiguous", hashes };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** The identity mismatch message for one Child, or null when it is acceptable. */
|
|
718
|
+
function publishedIntentMismatch(body: string, taskId: string, expected: string | undefined): string | null {
|
|
719
|
+
const identity = publishedIntentIdentity(body);
|
|
720
|
+
if (identity.kind === "ambiguous")
|
|
721
|
+
return `published Task ${taskId} carries ${identity.hashes.length} conflicting intent-hash markers; its published Intent identity is ambiguous`;
|
|
722
|
+
if (identity.kind === "absent" || expected === undefined) return null;
|
|
723
|
+
if (identity.hash !== expected)
|
|
724
|
+
return `published Task ${taskId} was published from a different TaskIntent revision; the published identity no longer matches the Intent being published`;
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Bodies compared for drift, ignoring the optional published-identity marker.
|
|
730
|
+
*
|
|
731
|
+
* The identity marker is additive, so a Child published before it existed has
|
|
732
|
+
* none and must still compare equal here; the identity itself is checked
|
|
733
|
+
* separately, and a multiply-marked body is rejected outright rather than
|
|
734
|
+
* having its markers stripped and ignored.
|
|
735
|
+
*/
|
|
736
|
+
function sameTrackedBody(current: string, expected: string): boolean {
|
|
737
|
+
return stripPublishedIntentHash(current) === stripPublishedIntentHash(expected);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function stripPublishedIntentHash(body: string): string {
|
|
741
|
+
return body.replace(/<!-- immune-brain:intent-hash=[A-Za-z0-9][A-Za-z0-9._:-]{0,127} -->\n?/g, "");
|
|
742
|
+
}
|
|
743
|
+
|
|
618
744
|
function ownershipMarkerValue(body: string, name: "initiative-id" | "slice-id" | "task-id"): string | null {
|
|
619
745
|
const values = [...body.matchAll(new RegExp(`<!-- immune-brain:${name}=([A-Za-z0-9][A-Za-z0-9._-]{0,127}) -->`, "g"))];
|
|
620
746
|
return values.length === 1 ? values[0][1] : null;
|
|
@@ -937,6 +1063,10 @@ function childBody(
|
|
|
937
1063
|
marker("initiative-id", operation.initiative_id),
|
|
938
1064
|
marker("slice-id", operation.slice_id),
|
|
939
1065
|
marker("task-id", operation.task_id),
|
|
1066
|
+
// The approved Intent identity travels with the published Child, so an
|
|
1067
|
+
// idempotent replay can compare against what was actually published
|
|
1068
|
+
// instead of against whatever bytes it happens to read this time.
|
|
1069
|
+
...(operation.intent_hash ? [marker("intent-hash", operation.intent_hash)] : []),
|
|
940
1070
|
].join("\n")}\n\n## Parent\n\n| Initiative | \`${operation.initiative_id}\` |\n| Parent Issue | [#${parent.number}](${parent.url}) |\n| Slice | \`${operation.slice_id}\` |\n| Risk | \`${operation.risk}\` |\n\n## Current behavior\n\n${publicText(projection.current_behavior ?? "The current behavior is defined by the repository's existing contract.", "projection.current_behavior")}\n\n## Desired behavior\n\n${publicText(projection.desired_behavior ?? projection.result ?? operation.goal, "projection.desired_behavior")}\n\n## Key interfaces\n\n${listText(projection.key_interfaces, "- Canonical TaskIntent acceptance and Kernel lifecycle remain authoritative.")}\n\n## Acceptance criteria\n\n${acceptance}\n\n## Verification\n\n${publicText(projection.verification ?? "Run the focused acceptance verification declared by the TaskIntent.", "projection.verification")}\n\n## Blocked by\n\n${projection.blocked_by?.length ? projection.blocked_by.map((id) => `- \`${identifier(id, "blocked_by task_id")}\``).join("\n") : "None"}\n\n## Out of scope\n\n${listText(projection.out_of_scope, "- Scope not declared by the validated TaskIntent.")}\n\n## Agent handoff\n\n${publicText(projection.agent_handoff ?? "Implement only the bounded TaskIntent result and run the focused checks. Do not widen scope or treat GitHub as authorization.", "projection.agent_handoff")}\n\n${ISSUE_FOOTER}\n`;
|
|
941
1071
|
}
|
|
942
1072
|
|
|
@@ -1271,8 +1401,13 @@ async function upsertTask(
|
|
|
1271
1401
|
return result(operation.op, "ambiguous_remote_state", `new pending Task ${operation.task_id} is unbound but Issue #${child.number} already exists with divergent content; bind it to amend`, found.issue);
|
|
1272
1402
|
return updatePendingChild(root, gh, source, operation, child, undefined, blockers, approvedFinal, amendmentContext);
|
|
1273
1403
|
}
|
|
1274
|
-
if (found.issue.body
|
|
1404
|
+
if (!sameTrackedBody(found.issue.body, body) || found.issue.title !== title)
|
|
1275
1405
|
return result(operation.op, "permanent_failure", "Task Issue already exists with a different title or Agent Brief; edit the GitHub source or retry the original projection before changing native relations", found.issue);
|
|
1406
|
+
// The published surface records the approved Intent identity. A Child that
|
|
1407
|
+
// carries it must agree exactly; a Child published before the marker
|
|
1408
|
+
// existed has nothing to compare against and still converges.
|
|
1409
|
+
const intentMismatch = publishedIntentMismatch(found.issue.body, operation.task_id, operation.intent_hash);
|
|
1410
|
+
if (intentMismatch) return result(operation.op, "ambiguous_remote_state", intentMismatch, found.issue);
|
|
1276
1411
|
// A repeated complete batch is a convergence pass: managed label drift is
|
|
1277
1412
|
// repaired here without rewriting content the tracker never owns.
|
|
1278
1413
|
const observedLabelArgs = labelMutationArgs(child.labels, desiredTaskLabels(operation));
|
|
@@ -1301,8 +1436,10 @@ async function upsertTask(
|
|
|
1301
1436
|
const finalSource = await snapshot(root, gh, operation.op);
|
|
1302
1437
|
if ("contract" in finalSource) return finalSource;
|
|
1303
1438
|
const finalChild = ownedTaskLookup(finalSource.issues, finalSource.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
1304
|
-
if (finalChild.kind !== "found" || finalChild.issue.id !== child.id || finalChild.issue.title !== title || finalChild.issue.body
|
|
1439
|
+
if (finalChild.kind !== "found" || finalChild.issue.id !== child.id || finalChild.issue.title !== title || !sameTrackedBody(finalChild.issue.body, body))
|
|
1305
1440
|
return result(operation.op, "ambiguous_remote_state", "Task Issue changed identity, title, or body during dependency publication", child);
|
|
1441
|
+
const finalIdentityMismatch = publishedIntentMismatch(finalChild.issue.body, operation.task_id, operation.intent_hash);
|
|
1442
|
+
if (finalIdentityMismatch) return result(operation.op, "ambiguous_remote_state", finalIdentityMismatch, child);
|
|
1306
1443
|
const finalChildOwnership = await confirmTerminalOwnership(root, gh, operation.op, finalSource, finalChild.issue);
|
|
1307
1444
|
if (!("owned" in finalChildOwnership)) return finalChildOwnership;
|
|
1308
1445
|
child = finalChild.issue;
|
|
@@ -1574,21 +1711,58 @@ export async function runGithubTrackerOperation(
|
|
|
1574
1711
|
}
|
|
1575
1712
|
}
|
|
1576
1713
|
|
|
1577
|
-
|
|
1714
|
+
/**
|
|
1715
|
+
* The publication result builder. `plannedTaskIds` is the operation's complete
|
|
1716
|
+
* step set, known before the first remote write; every Task that is not
|
|
1717
|
+
* confirmed is a pending step, so a Parent failure, an early Child failure, or
|
|
1718
|
+
* a post-plan validation refusal still reports every unfinished Task instead of
|
|
1719
|
+
* an empty list.
|
|
1720
|
+
*/
|
|
1721
|
+
function publicationResultWithPlan(
|
|
1722
|
+
plannedTaskIds: readonly string[],
|
|
1578
1723
|
status: TrackerStatus,
|
|
1579
1724
|
message: string,
|
|
1580
1725
|
initiative?: GithubTrackerResult,
|
|
1581
1726
|
tasks: GithubInitiativePublicationResult["tasks"] = [],
|
|
1582
1727
|
execution?: GithubInitiativePublicationResult["execution"],
|
|
1583
1728
|
): GithubInitiativePublicationResult {
|
|
1729
|
+
const settledTaskSteps = tasks.filter((task) => isSuccessfulTrackerStatus(task.status));
|
|
1730
|
+
const confirmedIds = new Set(settledTaskSteps.map((task) => task.task_id));
|
|
1731
|
+
const planned = plannedTaskIds.length > 0 ? plannedTaskIds : tasks.map((task) => task.task_id);
|
|
1732
|
+
const pendingSteps = planned.filter((taskId) => !confirmedIds.has(taskId));
|
|
1733
|
+
// Truthful effect reporting: a caller must be able to tell a confirmed, read
|
|
1734
|
+
// back write from an outcome it may not treat as zero writes. A failure says
|
|
1735
|
+
// nothing about how much already landed, so its write state is `uncertain`,
|
|
1736
|
+
// never `confirmed` and never "zero writes".
|
|
1737
|
+
const writeState: PublicationWriteState = status === "created" || status === "updated" || status === "already_current"
|
|
1738
|
+
? "confirmed"
|
|
1739
|
+
: "uncertain";
|
|
1740
|
+
// A failure never proves zero writes, so that fact is always stated. The
|
|
1741
|
+
// replay *action* is offered only when replaying could actually work: a
|
|
1742
|
+
// permanent_failure needs its own specific fix first, so its own message stays
|
|
1743
|
+
// the single action instead of being joined by a replay suggestion.
|
|
1744
|
+
const uncertainty = writeState === "confirmed"
|
|
1745
|
+
? ""
|
|
1746
|
+
: "This failure does not prove zero writes; earlier steps may already have landed. ";
|
|
1747
|
+
// One action for the whole retryable class, so a failure that reaches this
|
|
1748
|
+
// builder without the transport-level attribution still gets exactly one
|
|
1749
|
+
// corrective action instead of none. Permanent failures keep their own.
|
|
1750
|
+
const recovery = status === "retryable_failure"
|
|
1751
|
+
? "Re-read the exact ownership, Sub-issue topology and intent hashes, then replay the same approved manifest. "
|
|
1752
|
+
: "";
|
|
1753
|
+
// A permanent_failure needs its own specific repair, and its own message is
|
|
1754
|
+
// that single action; nothing generic is layered on top of it.
|
|
1584
1755
|
return {
|
|
1585
1756
|
contract: "immune_brain/github_initiative_publication/v1",
|
|
1586
1757
|
operation: "publish-initiative",
|
|
1587
1758
|
status,
|
|
1588
1759
|
...(initiative ? { initiative } : {}),
|
|
1589
1760
|
tasks,
|
|
1761
|
+
write_state: writeState,
|
|
1762
|
+
confirmed_steps: settledTaskSteps.map((task) => task.task_id),
|
|
1763
|
+
pending_steps: pendingSteps,
|
|
1590
1764
|
...(execution ? { execution } : {}),
|
|
1591
|
-
message: redactGithubDiagnostic(message)
|
|
1765
|
+
message: `${uncertainty}${recovery}${redactGithubDiagnostic(message)}`,
|
|
1592
1766
|
};
|
|
1593
1767
|
}
|
|
1594
1768
|
|
|
@@ -1692,6 +1866,46 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1692
1866
|
return { initiative, ...plan, intent_bindings: intentBindings, foreign_dependers: foreignDependers };
|
|
1693
1867
|
}
|
|
1694
1868
|
|
|
1869
|
+
/**
|
|
1870
|
+
* Whole-batch validation before any recovery write.
|
|
1871
|
+
*
|
|
1872
|
+
* A repeated publication is a recovery pass: it may repair managed label drift
|
|
1873
|
+
* and converge pending Children. Before the first such write, every already
|
|
1874
|
+
* published Child of this batch must still be the one this Intent approves --
|
|
1875
|
+
* correct ownership markers, exactly one Slice entry in the Parent, and the
|
|
1876
|
+
* published Intent identity where the Child carries one. Otherwise a later
|
|
1877
|
+
* Child's mismatch could be discovered only after earlier Children had already
|
|
1878
|
+
* been written, which is exactly the partial recovery this pass must not cause.
|
|
1879
|
+
*/
|
|
1880
|
+
function publicationBatchDrift(
|
|
1881
|
+
source: RepositorySnapshot,
|
|
1882
|
+
initiativeId: string,
|
|
1883
|
+
order: readonly Extract<TrackerOperation, { op: "upsert-task" }>[],
|
|
1884
|
+
): string | null {
|
|
1885
|
+
const parent = initiativeLookup(source.issues, source.repository.id, initiativeId);
|
|
1886
|
+
// A missing Parent is not a batch-drift condition: creation is the normal path.
|
|
1887
|
+
if (parent.kind === "missing") return null;
|
|
1888
|
+
if (parent.kind === "ambiguous") return parent.message;
|
|
1889
|
+
// Narrow by construction: this check exists solely because the published
|
|
1890
|
+
// Intent identity is verified inside each Child's own convergence pass, so a
|
|
1891
|
+
// later Child's mismatch would otherwise be discovered only after an earlier
|
|
1892
|
+
// Child had already been repaired. Every other precondition keeps its
|
|
1893
|
+
// existing classification on the existing path.
|
|
1894
|
+
for (const operation of order) {
|
|
1895
|
+
const found = ownedTaskLookup(
|
|
1896
|
+
source.issues,
|
|
1897
|
+
source.repository.id,
|
|
1898
|
+
operation.task_id,
|
|
1899
|
+
initiativeId,
|
|
1900
|
+
operation.slice_id,
|
|
1901
|
+
);
|
|
1902
|
+
if (found.kind !== "found") continue;
|
|
1903
|
+
const mismatch = publishedIntentMismatch(found.issue.body, operation.task_id, operation.intent_hash);
|
|
1904
|
+
if (mismatch) return mismatch;
|
|
1905
|
+
}
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1695
1909
|
function publicationIntentDrift(
|
|
1696
1910
|
root: string,
|
|
1697
1911
|
bindings: Map<string, Pick<PreparedPublicationTask, "intent_path" | "intent_content_hash">>,
|
|
@@ -1715,11 +1929,22 @@ function publicationIssueDrift(expected: {
|
|
|
1715
1929
|
issue_number?: number;
|
|
1716
1930
|
issue_url?: string;
|
|
1717
1931
|
node_id?: string;
|
|
1718
|
-
}, actual: GithubIssue, title: string, body: string, label: string, options?: { allowTerminalSuffix?: boolean }): string | null {
|
|
1932
|
+
}, actual: GithubIssue, title: string, body: string, label: string, options?: { allowTerminalSuffix?: boolean; taskId?: string; expectedIntentHash?: string }): string | null {
|
|
1719
1933
|
if (expected.issue_number !== actual.number || expected.issue_url !== actual.url || expected.node_id !== String(actual.id))
|
|
1720
1934
|
return `${label} identity changed during Initiative publication`;
|
|
1721
1935
|
if (actual.state !== "open") return `${label} is no longer open`;
|
|
1722
|
-
|
|
1936
|
+
// The published Intent identity is verified on the final readback as well:
|
|
1937
|
+
// a Child whose hash changed only after the earlier per-Child check would
|
|
1938
|
+
// otherwise be reported as a confirmed success.
|
|
1939
|
+
if (options?.taskId)
|
|
1940
|
+
return publishedIntentMismatch(actual.body, options.taskId, options.expectedIntentHash) ?? driftOnContent(actual, title, body, label, options);
|
|
1941
|
+
// The published-identity marker is additive and compared separately, so a Child
|
|
1942
|
+
// published before it existed is still the same tracked content here.
|
|
1943
|
+
return driftOnContent(actual, title, body, label, options);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function driftOnContent(actual: GithubIssue, title: string, body: string, label: string, options?: { allowTerminalSuffix?: boolean }): string | null {
|
|
1947
|
+
if (actual.title !== title || !sameTrackedBody(actual.body, body)) {
|
|
1723
1948
|
// Only a pending amendment Child left open with a validated terminal suffix
|
|
1724
1949
|
// (failed terminal close) matches its suffix-free expected content. The
|
|
1725
1950
|
// Parent and the strict non-amendment path have no terminal-suffix
|
|
@@ -2243,14 +2468,30 @@ async function revalidatePendingChildBeforeWrite(
|
|
|
2243
2468
|
export async function runGithubInitiativePublication(
|
|
2244
2469
|
root: string,
|
|
2245
2470
|
input: InitiativePublicationInput,
|
|
2246
|
-
|
|
2471
|
+
rawGh: GhTransport = createGhTransport(),
|
|
2472
|
+
budget: OperationBudget = { deadline_ms: Date.now() + PUBLICATION_BUDGET_MS },
|
|
2247
2473
|
): Promise<GithubInitiativePublicationResult> {
|
|
2248
2474
|
const absoluteRoot = resolve(root);
|
|
2475
|
+
// The whole publication shares one deadline and cancellation signal, supplied
|
|
2476
|
+
// by the caller so one entry point owns the budget. The incoming transport is
|
|
2477
|
+
// shadowed by this budget-carrying adapter, so *every* remote call in the
|
|
2478
|
+
// publication -- including the nested label and amendment helpers -- carries
|
|
2479
|
+
// the same budget by construction instead of by remembering to pass it.
|
|
2480
|
+
const gh: GhTransport = {
|
|
2481
|
+
run: (args, options = {}) => rawGh.run(args, { ...options, budget }),
|
|
2482
|
+
};
|
|
2483
|
+
// The complete planned step set, known before the first remote write. Every
|
|
2484
|
+
// unconfirmed Task from this set is a pending step, so a Parent failure or an
|
|
2485
|
+
// early Child failure still names each unfinished Task.
|
|
2249
2486
|
let prepared: ReturnType<typeof preflightPublication>;
|
|
2250
2487
|
let amendment: ReturnType<typeof validateAmendment> | undefined;
|
|
2251
2488
|
let amendmentContext: AmendmentExecutionContext | undefined;
|
|
2489
|
+
// Assigned the moment the plan exists. Every result built after that point
|
|
2490
|
+
// reports the same planned Task set, including the error paths.
|
|
2491
|
+
let plannedTaskIds: string[] = [];
|
|
2252
2492
|
try {
|
|
2253
2493
|
prepared = preflightPublication(absoluteRoot, input);
|
|
2494
|
+
plannedTaskIds = prepared.order.map((operation) => operation.task_id);
|
|
2254
2495
|
if (input.amendment !== undefined) {
|
|
2255
2496
|
if (prepared.order.length < 1)
|
|
2256
2497
|
throw new Error("an amendment requires at least one pending Task");
|
|
@@ -2280,22 +2521,33 @@ export async function runGithubInitiativePublication(
|
|
|
2280
2521
|
amendment = validated;
|
|
2281
2522
|
}
|
|
2282
2523
|
} catch (error) {
|
|
2283
|
-
return
|
|
2524
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", error instanceof Error ? error.message : String(error));
|
|
2284
2525
|
}
|
|
2285
2526
|
const conflict = carrierConflict(absoluteRoot, "create-initiative", prepared.initiative.initiative_id);
|
|
2286
|
-
if (conflict) return
|
|
2527
|
+
if (conflict) return publicationResultWithPlan(plannedTaskIds, conflict.status, conflict.message, conflict);
|
|
2287
2528
|
const initial = await snapshot(absoluteRoot, gh, "create-initiative");
|
|
2288
|
-
if ("contract" in initial) return
|
|
2529
|
+
if ("contract" in initial) return publicationResultWithPlan(plannedTaskIds, initial.status, initial.message, initial);
|
|
2530
|
+
if (!amendment) {
|
|
2531
|
+
// Whole-batch prevalidation: every already published Child of this batch must
|
|
2532
|
+
// still match the Intent being published, and the check happens before the
|
|
2533
|
+
// first recovery write rather than after earlier Children were written.
|
|
2534
|
+
const batchDrift = publicationBatchDrift(
|
|
2535
|
+
initial,
|
|
2536
|
+
prepared.initiative.initiative_id,
|
|
2537
|
+
prepared.order as Extract<TrackerOperation, { op: "upsert-task" }>[],
|
|
2538
|
+
);
|
|
2539
|
+
if (batchDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", batchDrift);
|
|
2540
|
+
}
|
|
2289
2541
|
const initialParent = initiativeLookup(initial.issues, initial.repository.id, prepared.initiative.initiative_id);
|
|
2290
|
-
if (initialParent.kind === "ambiguous") return
|
|
2542
|
+
if (initialParent.kind === "ambiguous") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", initialParent.message);
|
|
2291
2543
|
if (amendment && initialParent.kind === "missing")
|
|
2292
|
-
return
|
|
2544
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
2293
2545
|
// Label availability is validated before any remote mutation: the tracker
|
|
2294
2546
|
// never creates labels, so a missing label fails the whole batch closed
|
|
2295
2547
|
// instead of half-publishing an Initiative.
|
|
2296
2548
|
const requiredLabels = [...new Set(prepared.order.flatMap((operation) => desiredTaskLabels(operation)))];
|
|
2297
2549
|
const labelFailure = await labelAvailabilityFailure(absoluteRoot, gh, initial.repository, requiredLabels);
|
|
2298
|
-
if (labelFailure) return
|
|
2550
|
+
if (labelFailure) return publicationResultWithPlan(plannedTaskIds, labelFailure.status, labelFailure.message, labelFailure);
|
|
2299
2551
|
const parentForPreflight = initialParent.kind === "found" ? initialParent.issue : {
|
|
2300
2552
|
id: Number.MAX_SAFE_INTEGER,
|
|
2301
2553
|
number: Number.MAX_SAFE_INTEGER,
|
|
@@ -2307,15 +2559,15 @@ export async function runGithubInitiativePublication(
|
|
|
2307
2559
|
labels: [],
|
|
2308
2560
|
};
|
|
2309
2561
|
const parentBodyFailure = bodyLimitFailure("create-initiative", createInitiativeBody(initial.repository, prepared.initiative));
|
|
2310
|
-
if (parentBodyFailure) return
|
|
2562
|
+
if (parentBodyFailure) return publicationResultWithPlan(plannedTaskIds, parentBodyFailure.status, parentBodyFailure.message, parentBodyFailure);
|
|
2311
2563
|
for (const operation of prepared.order) {
|
|
2312
2564
|
const childFailure = bodyLimitFailure("upsert-task", childBody(initial.repository, operation, parentForPreflight), MAX_TERMINAL_SUFFIX_BYTES);
|
|
2313
|
-
if (childFailure) return
|
|
2565
|
+
if (childFailure) return publicationResultWithPlan(plannedTaskIds, childFailure.status, childFailure.message, childFailure);
|
|
2314
2566
|
}
|
|
2315
2567
|
|
|
2316
2568
|
if (amendment) {
|
|
2317
2569
|
if (initialParent.kind !== "found")
|
|
2318
|
-
return
|
|
2570
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
2319
2571
|
// Caller-controlled binding constraints (missing or duplicate historical
|
|
2320
2572
|
// Slice markers) must surface as structured fail-closed publication results,
|
|
2321
2573
|
// never as thrown exceptions escaping the guarded validation boundary.
|
|
@@ -2323,13 +2575,17 @@ export async function runGithubInitiativePublication(
|
|
|
2323
2575
|
try {
|
|
2324
2576
|
approvedFinalParent = approvedAmendmentContent(absoluteRoot, initial.repository, initialParent.issue, prepared, amendment);
|
|
2325
2577
|
} catch (error) {
|
|
2326
|
-
return
|
|
2578
|
+
return publicationResultWithPlan(
|
|
2579
|
+
plannedTaskIds,
|
|
2580
|
+
"permanent_failure",
|
|
2581
|
+
error instanceof Error ? error.message : String(error),
|
|
2582
|
+
);
|
|
2327
2583
|
}
|
|
2328
2584
|
// Slice identity collisions are deterministic input contract violations (the
|
|
2329
2585
|
// batch itself declares two Children with one Slice id), not remote drift.
|
|
2330
2586
|
if (typeof approvedFinalParent === "string" && approvedFinalParent.includes("collides with a historical Task Slice"))
|
|
2331
|
-
return
|
|
2332
|
-
if (typeof approvedFinalParent === "string") return
|
|
2587
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", approvedFinalParent);
|
|
2588
|
+
if (typeof approvedFinalParent === "string") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", approvedFinalParent);
|
|
2333
2589
|
const { parent, pendingContent, historicalSlices } = approvedFinalParent;
|
|
2334
2590
|
// Deterministic prerequisite completion is validated before any write: a
|
|
2335
2591
|
// historical prerequisite already closed without state_reason "completed"
|
|
@@ -2341,16 +2597,16 @@ export async function runGithubInitiativePublication(
|
|
|
2341
2597
|
if (!amendment.historical.has(blockerId)) continue;
|
|
2342
2598
|
const blocker = taskLookup(initial.issues, initial.repository.id, blockerId);
|
|
2343
2599
|
if (blocker.kind === "found" && (blocker.issue.state !== "closed" || blocker.issue.state_reason !== "completed"))
|
|
2344
|
-
return
|
|
2600
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `Task ${operation.task_id} depends on stopped historical prerequisite ${blockerId}`);
|
|
2345
2601
|
}
|
|
2346
2602
|
}
|
|
2347
2603
|
const parentBaseline = amendment.parent.body !== initialParent.issue.body || amendment.parent.title !== initialParent.issue.title;
|
|
2348
2604
|
const parentApproved = initialParent.issue.title === parent.title && initialParent.issue.body === parent.body;
|
|
2349
|
-
if (parentBaseline && !parentApproved) return
|
|
2605
|
+
if (parentBaseline && !parentApproved) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `amendment Parent changed since the approved amendment baseline`);
|
|
2350
2606
|
if (amendment.parent.issue_number !== initialParent.issue.number)
|
|
2351
|
-
return
|
|
2607
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `amendment Parent is bound to Issue #${amendment.parent.issue_number} but observed Issue #${initialParent.issue.number}`);
|
|
2352
2608
|
if (initialParent.issue.state !== "open")
|
|
2353
|
-
return
|
|
2609
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "an amendment requires the Initiative Parent to remain open");
|
|
2354
2610
|
const topology = await validateAmendmentTopology(
|
|
2355
2611
|
absoluteRoot, gh, initial, prepared.initiative.initiative_id, initialParent.issue,
|
|
2356
2612
|
amendment.tasks, amendment.historical,
|
|
@@ -2358,12 +2614,12 @@ export async function runGithubInitiativePublication(
|
|
|
2358
2614
|
{ pendingContent, parent, historicalSlices },
|
|
2359
2615
|
prepared.order,
|
|
2360
2616
|
);
|
|
2361
|
-
if (!("get" in topology)) return
|
|
2617
|
+
if (!("get" in topology)) return publicationResultWithPlan(plannedTaskIds, topology.status, topology.message, topology);
|
|
2362
2618
|
const historicalRelations = new Map<string, { blocked_by: number[]; state_reason: string | null }>();
|
|
2363
2619
|
for (const [taskId] of amendment.historical) {
|
|
2364
2620
|
const issue = topology.get(taskId)!;
|
|
2365
2621
|
const observed = await readBlockedByIds(absoluteRoot, gh, "upsert-task", initial.repository, issue.number);
|
|
2366
|
-
if (!Array.isArray(observed)) return
|
|
2622
|
+
if (!Array.isArray(observed)) return publicationResultWithPlan(plannedTaskIds, observed.status, observed.message, observed);
|
|
2367
2623
|
historicalRelations.set(taskId, { blocked_by: observed, state_reason: issue.state_reason });
|
|
2368
2624
|
}
|
|
2369
2625
|
amendmentContext = {
|
|
@@ -2376,7 +2632,7 @@ export async function runGithubInitiativePublication(
|
|
|
2376
2632
|
}
|
|
2377
2633
|
|
|
2378
2634
|
const beforeParentWrite = publicationIntentDrift(absoluteRoot, prepared.intent_bindings);
|
|
2379
|
-
if (beforeParentWrite) return
|
|
2635
|
+
if (beforeParentWrite) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", beforeParentWrite);
|
|
2380
2636
|
let parentResult: GithubTrackerResult;
|
|
2381
2637
|
if (amendment && amendmentContext) {
|
|
2382
2638
|
parentResult = await amendInitiativeParent(absoluteRoot, gh, prepared.initiative, initial, amendment.parent, amendmentContext);
|
|
@@ -2384,11 +2640,11 @@ export async function runGithubInitiativePublication(
|
|
|
2384
2640
|
parentResult = await runGithubTrackerOperation(absoluteRoot, prepared.initiative, gh);
|
|
2385
2641
|
}
|
|
2386
2642
|
if (!isSuccessfulTrackerStatus(parentResult.status))
|
|
2387
|
-
return
|
|
2643
|
+
return publicationResultWithPlan(plannedTaskIds, parentResult.status, parentResult.message, parentResult);
|
|
2388
2644
|
const taskResults: GithubInitiativePublicationResult["tasks"] = [];
|
|
2389
2645
|
for (const operation of prepared.order) {
|
|
2390
2646
|
const intentDrift = publicationIntentDrift(absoluteRoot, prepared.intent_bindings, [operation.task_id]);
|
|
2391
|
-
if (intentDrift) return
|
|
2647
|
+
if (intentDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", intentDrift, parentResult, taskResults);
|
|
2392
2648
|
const taskResult = amendment
|
|
2393
2649
|
? await runAmendmentTaskOperation(absoluteRoot, gh, operation, amendment.tasks.get(operation.task_id), amendmentContext)
|
|
2394
2650
|
: await runGithubTrackerOperation(absoluteRoot, operation, gh);
|
|
@@ -2401,23 +2657,23 @@ export async function runGithubInitiativePublication(
|
|
|
2401
2657
|
...(taskResult.node_id === undefined ? {} : { node_id: taskResult.node_id }),
|
|
2402
2658
|
});
|
|
2403
2659
|
if (!isSuccessfulTrackerStatus(taskResult.status))
|
|
2404
|
-
return
|
|
2660
|
+
return publicationResultWithPlan(plannedTaskIds, taskResult.status, taskResult.message, parentResult, taskResults);
|
|
2405
2661
|
}
|
|
2406
2662
|
|
|
2407
2663
|
const finalIntentDrift = publicationIntentDrift(absoluteRoot, prepared.intent_bindings);
|
|
2408
|
-
if (finalIntentDrift) return
|
|
2664
|
+
if (finalIntentDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", finalIntentDrift, parentResult, taskResults);
|
|
2409
2665
|
const finalSource = await snapshot(absoluteRoot, gh, "upsert-task");
|
|
2410
|
-
if ("contract" in finalSource) return
|
|
2666
|
+
if ("contract" in finalSource) return publicationResultWithPlan(plannedTaskIds, finalSource.status, finalSource.message, parentResult, taskResults);
|
|
2411
2667
|
if (amendment) {
|
|
2412
2668
|
// Repeat complete observed-membership classification on the final snapshot:
|
|
2413
2669
|
// an undeclared closed Task carrying this Initiative's markers that becomes
|
|
2414
2670
|
// observable only after preflight (detached, so the Sub-issue parity check
|
|
2415
2671
|
// cannot see it) must still fail the publication, not report success.
|
|
2416
2672
|
const finalClassified = classifyObservedChildren(finalSource, prepared.initiative.initiative_id, amendment.tasks, amendment.historical);
|
|
2417
|
-
if (typeof finalClassified === "string") return
|
|
2673
|
+
if (typeof finalClassified === "string") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", finalClassified, parentResult, taskResults);
|
|
2418
2674
|
}
|
|
2419
2675
|
const parent = initiativeLookup(finalSource.issues, finalSource.repository.id, prepared.initiative.initiative_id);
|
|
2420
|
-
if (parent.kind !== "found") return
|
|
2676
|
+
if (parent.kind !== "found") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", parent.kind === "ambiguous" ? parent.message : "published Initiative Parent disappeared", parentResult, taskResults);
|
|
2421
2677
|
const amendedFinalSliceIds = new Set(prepared.initiative.slices.map((slice) => slice.id));
|
|
2422
2678
|
const parentDrift = publicationIssueDrift(
|
|
2423
2679
|
parentResult,
|
|
@@ -2428,7 +2684,7 @@ export async function runGithubInitiativePublication(
|
|
|
2428
2684
|
: createInitiativeBody(finalSource.repository, prepared.initiative),
|
|
2429
2685
|
"Initiative Parent",
|
|
2430
2686
|
);
|
|
2431
|
-
if (parentDrift) return
|
|
2687
|
+
if (parentDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", parentDrift, parentResult, taskResults);
|
|
2432
2688
|
if (amendmentContext) {
|
|
2433
2689
|
// The rewritten Parent must carry each declared historical Slice marker
|
|
2434
2690
|
// exactly once: generation drops or duplicates would break terminal
|
|
@@ -2436,70 +2692,74 @@ export async function runGithubInitiativePublication(
|
|
|
2436
2692
|
for (const line of amendmentContext.historicalSlices) {
|
|
2437
2693
|
const sliceId = ownershipMarkerValue(line, "slice-id");
|
|
2438
2694
|
if (!sliceId || countLiteral(parent.issue.body, marker("slice-id", sliceId)) !== 1)
|
|
2439
|
-
return
|
|
2695
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `regenerated Parent does not carry historical Slice ${sliceId ?? "?"} exactly once`, parentResult, taskResults);
|
|
2440
2696
|
}
|
|
2441
2697
|
}
|
|
2442
2698
|
const resultByTask = new Map(taskResults.map((task) => [task.task_id, task]));
|
|
2443
2699
|
const expectedNumbers: number[] = [];
|
|
2444
2700
|
for (const operation of prepared.order) {
|
|
2445
2701
|
const child = ownedTaskLookup(finalSource.issues, finalSource.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
2446
|
-
if (child.kind !== "found") return
|
|
2702
|
+
if (child.kind !== "found") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", child.kind === "ambiguous" ? child.message : `published Task ${operation.task_id} disappeared`, parentResult, taskResults);
|
|
2447
2703
|
const childResult = resultByTask.get(operation.task_id);
|
|
2448
|
-
if (!childResult) return
|
|
2704
|
+
if (!childResult) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `published Task ${operation.task_id} has no batch result`, parentResult, taskResults);
|
|
2449
2705
|
const childDrift = publicationIssueDrift(
|
|
2450
2706
|
childResult,
|
|
2451
2707
|
child.issue,
|
|
2452
2708
|
taskIssueTitle(operation, sliceOrdinalFromChecklist(parent.issue.body, operation.slice_id, operation.projection?.slice_ordinal ?? 1)),
|
|
2453
2709
|
childBody(finalSource.repository, operation, parent.issue),
|
|
2454
2710
|
`Task ${operation.task_id}`,
|
|
2455
|
-
{
|
|
2711
|
+
{
|
|
2712
|
+
allowTerminalSuffix: amendmentContext !== undefined,
|
|
2713
|
+
taskId: operation.task_id,
|
|
2714
|
+
expectedIntentHash: operation.intent_hash,
|
|
2715
|
+
},
|
|
2456
2716
|
);
|
|
2457
|
-
if (childDrift) return
|
|
2717
|
+
if (childDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", childDrift, parentResult, taskResults);
|
|
2458
2718
|
const expectedLabels = desiredTaskLabels(operation);
|
|
2459
2719
|
if (childResult.status !== "already_current" && expectedLabels.some((label) => !child.issue.labels.includes(label)))
|
|
2460
|
-
return
|
|
2720
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `Task ${operation.task_id} is missing publication labels after publication`, parentResult, taskResults);
|
|
2461
2721
|
expectedNumbers.push(child.issue.number);
|
|
2462
2722
|
const ownership = await confirmTerminalOwnership(absoluteRoot, gh, "upsert-task", finalSource, child.issue);
|
|
2463
|
-
if (!("owned" in ownership)) return
|
|
2723
|
+
if (!("owned" in ownership)) return publicationResultWithPlan(plannedTaskIds, ownership.status, ownership.message, parentResult, taskResults);
|
|
2464
2724
|
const blockers: GithubIssue[] = [];
|
|
2465
2725
|
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2466
2726
|
const blocker = taskLookup(finalSource.issues, finalSource.repository.id, blockerId);
|
|
2467
|
-
if (blocker.kind !== "found") return
|
|
2727
|
+
if (blocker.kind !== "found") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `published blocker ${blockerId} disappeared`, parentResult, taskResults);
|
|
2468
2728
|
blockers.push(blocker.issue);
|
|
2469
2729
|
}
|
|
2470
2730
|
const dependencies = await confirmBlockedBy(absoluteRoot, gh, "upsert-task", finalSource.repository, child.issue.number, blockers);
|
|
2471
|
-
if (!("complete" in dependencies)) return
|
|
2472
|
-
if (!dependencies.complete) return
|
|
2731
|
+
if (!("complete" in dependencies)) return publicationResultWithPlan(plannedTaskIds, dependencies.status, dependencies.message, parentResult, taskResults);
|
|
2732
|
+
if (!dependencies.complete) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `Task ${operation.task_id} has incomplete blocking relations`, parentResult, taskResults);
|
|
2473
2733
|
}
|
|
2474
2734
|
const attached = await readSubIssueNumbers(absoluteRoot, gh, "upsert-task", finalSource.repository, parent.issue.number);
|
|
2475
|
-
if (!Array.isArray(attached)) return
|
|
2735
|
+
if (!Array.isArray(attached)) return publicationResultWithPlan(plannedTaskIds, attached.status, attached.message, parentResult, taskResults);
|
|
2476
2736
|
if (amendment) {
|
|
2477
2737
|
for (const [taskId, binding] of amendment.historical) {
|
|
2478
2738
|
// Full ownership lookup: repository, protocol, marker uniqueness, and
|
|
2479
2739
|
// repo-wide Task identity must resolve the exact bound Issue.
|
|
2480
2740
|
const owned = taskLookup(finalSource.issues, finalSource.repository.id, taskId);
|
|
2481
2741
|
if (owned.kind !== "found" || owned.issue.number !== binding.issue_number)
|
|
2482
|
-
return
|
|
2742
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `historical Task ${taskId} ownership failed after publication`, parentResult, taskResults);
|
|
2483
2743
|
const historical = owned.issue;
|
|
2484
2744
|
const drift = amendmentBindingDrift(binding, historical, `historical Task ${taskId}`);
|
|
2485
|
-
if (drift) return
|
|
2745
|
+
if (drift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", drift, parentResult, taskResults);
|
|
2486
2746
|
if (!attached.includes(historical.number))
|
|
2487
|
-
return
|
|
2747
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `historical Task ${taskId} lost its native Sub-issue attachment`, parentResult, taskResults);
|
|
2488
2748
|
// R1 final check: historical Slice identities must not collide with any
|
|
2489
2749
|
// pending Slice published by this batch — two Children must never share
|
|
2490
2750
|
// one Slice identity, including historical Children whose Parent lines are
|
|
2491
2751
|
// carried over rather than re-rendered.
|
|
2492
2752
|
const historicalSliceId = ownershipMarkerValue(historical.body, "slice-id");
|
|
2493
2753
|
if (historicalSliceId && amendedFinalSliceIds.has(historicalSliceId))
|
|
2494
|
-
return
|
|
2754
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `historical Task ${taskId} Slice ${historicalSliceId} collides with a pending Task Slice of the same id`, parentResult, taskResults);
|
|
2495
2755
|
const snapshotRelations = amendmentContext?.historicalRelations.get(taskId);
|
|
2496
2756
|
if (snapshotRelations) {
|
|
2497
2757
|
if (historical.state_reason !== snapshotRelations.state_reason)
|
|
2498
|
-
return
|
|
2758
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `historical Task ${taskId} terminal state_reason changed during amendment`, parentResult, taskResults);
|
|
2499
2759
|
const finalBlockedBy = await readBlockedByIds(absoluteRoot, gh, "upsert-task", finalSource.repository, historical.number);
|
|
2500
|
-
if (!Array.isArray(finalBlockedBy)) return
|
|
2760
|
+
if (!Array.isArray(finalBlockedBy)) return publicationResultWithPlan(plannedTaskIds, finalBlockedBy.status, finalBlockedBy.message, parentResult, taskResults);
|
|
2501
2761
|
if (finalBlockedBy.length !== snapshotRelations.blocked_by.length || finalBlockedBy.some((id, index) => id !== snapshotRelations.blocked_by[index]))
|
|
2502
|
-
return
|
|
2762
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `historical Task ${taskId} native blocked_by relations changed during amendment`, parentResult, taskResults);
|
|
2503
2763
|
}
|
|
2504
2764
|
}
|
|
2505
2765
|
const attachedNumbers = new Set(attached);
|
|
@@ -2507,14 +2767,14 @@ export async function runGithubInitiativePublication(
|
|
|
2507
2767
|
const issue = finalSource.issues.find((candidate) => candidate.number === number);
|
|
2508
2768
|
const taskId = issue ? ownershipMarkerValue(issue.body, "task-id") : null;
|
|
2509
2769
|
if (!taskId || (!amendment.tasks.has(taskId) && !amendment.historical.has(taskId)))
|
|
2510
|
-
return
|
|
2770
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `Parent Sub-issue #${number} is not part of the declared amendment membership`, parentResult, taskResults);
|
|
2511
2771
|
}
|
|
2512
2772
|
for (const operation of prepared.order) {
|
|
2513
2773
|
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2514
2774
|
if (amendment.historical.has(blockerId)) {
|
|
2515
2775
|
const blocker = taskLookup(finalSource.issues, finalSource.repository.id, blockerId);
|
|
2516
2776
|
if (blocker.kind !== "found" || blocker.issue.state_reason !== "completed")
|
|
2517
|
-
return
|
|
2777
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `Task ${operation.task_id} depends on stopped historical prerequisite ${blockerId}`, parentResult, taskResults);
|
|
2518
2778
|
}
|
|
2519
2779
|
}
|
|
2520
2780
|
}
|
|
@@ -2522,17 +2782,17 @@ export async function runGithubInitiativePublication(
|
|
|
2522
2782
|
const sortedAttached = [...attached].sort((left, right) => left - right);
|
|
2523
2783
|
const sortedExpected = [...expectedNumbers, ...(amendment ? [...amendment.historical.values()].map((binding) => binding.issue_number) : [])].sort((left, right) => left - right);
|
|
2524
2784
|
if (sortedAttached.length !== sortedExpected.length || sortedAttached.some((number, index) => number !== sortedExpected[index]))
|
|
2525
|
-
return
|
|
2785
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "Initiative Parent Sub-issues do not match the complete publication batch", parentResult, taskResults);
|
|
2526
2786
|
const statuses = [parentResult.status, ...taskResults.map((task) => task.status)];
|
|
2527
2787
|
const status: TrackerStatus = statuses.every((item) => item === "created")
|
|
2528
2788
|
? "created"
|
|
2529
2789
|
: statuses.every((item) => item === "already_current") ? "already_current" : "updated";
|
|
2530
2790
|
const issueByTask = new Map(taskResults.map((task) => [task.task_id, task.issue_number]));
|
|
2531
2791
|
if ([...issueByTask.values()].some((number) => number === undefined))
|
|
2532
|
-
return
|
|
2792
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "published Task result is missing an Issue number", parentResult, taskResults);
|
|
2533
2793
|
const issueNumber = (taskId: string): number => issueByTask.get(taskId)!;
|
|
2534
2794
|
const firstTaskId = prepared.order[0].task_id;
|
|
2535
|
-
return
|
|
2795
|
+
return publicationResultWithPlan(plannedTaskIds, status, "complete Initiative Parent, Children, and dependency graph published", parentResult, taskResults, {
|
|
2536
2796
|
recommended_first_task_id: firstTaskId,
|
|
2537
2797
|
recommended_first_issue_number: issueNumber(firstTaskId),
|
|
2538
2798
|
order: prepared.order.map((operation) => operation.task_id),
|
|
@@ -2602,6 +2862,7 @@ function taskPublication(
|
|
|
2602
2862
|
risk: intent.risk,
|
|
2603
2863
|
acceptance: intent.acceptance.map((item) => publicById.get(item.id)!),
|
|
2604
2864
|
projection: stampedProjection,
|
|
2865
|
+
intent_hash: read.content_hash,
|
|
2605
2866
|
}) as Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
2606
2867
|
intent_path: read.intent_ref.path,
|
|
2607
2868
|
intent_content_hash: read.content_hash,
|