immune-brain 4.4.1 → 4.6.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/README.md +3 -2
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
- package/plugins/immune-brain/dist/BASELINE.md +18 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +1401 -1101
- 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 +52 -6
- package/plugins/immune-brain/dist/imm-planner.md +102 -13
- package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +956 -634
- package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
- package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
- 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/qa.md +13 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +152 -46
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +193 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +112 -122
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +57 -16
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +75 -12
- 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
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
|
@@ -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,7 +208,18 @@ 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; signal?: { readonly aborted: boolean } }): Promise<GhExecution>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* External cancellation for a multi-step operation. There is no cumulative
|
|
216
|
+
* deadline: one publication call is a finite step sequence that stops at its
|
|
217
|
+
* first failed call, and each call keeps its own per-call timeout. Only the
|
|
218
|
+
* caller's abort signal is forwarded to every call.
|
|
219
|
+
*/
|
|
220
|
+
export interface OperationCancellation {
|
|
221
|
+
/** Optional external cancellation signal (user cancel / host abort). */
|
|
222
|
+
signal?: { readonly aborted: boolean };
|
|
191
223
|
}
|
|
192
224
|
|
|
193
225
|
interface RepositoryInfo {
|
|
@@ -238,7 +270,7 @@ function countLiteral(value: string, needle: string): number {
|
|
|
238
270
|
return count;
|
|
239
271
|
}
|
|
240
272
|
|
|
241
|
-
function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id", value: string | number): string {
|
|
273
|
+
function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id" | "intent-hash", value: string | number): string {
|
|
242
274
|
return `<!-- immune-brain:${name}=${value} -->`;
|
|
243
275
|
}
|
|
244
276
|
|
|
@@ -436,6 +468,10 @@ function ghFailure(
|
|
|
436
468
|
): GithubTrackerResult {
|
|
437
469
|
const retryable = execution.timed_out
|
|
438
470
|
|| /timeout|timed out|network|connection|temporar|rate limit|502|503|504/i.test(execution.stderr);
|
|
471
|
+
// A transport failure is the one class where re-reading the approved state and
|
|
472
|
+
// replaying the same manifest is the correct recovery, so that action is
|
|
473
|
+
// attached here, at the origin. Any other failure keeps its own message as
|
|
474
|
+
// the single action; nothing generic is layered on top of it.
|
|
439
475
|
return result(
|
|
440
476
|
operation,
|
|
441
477
|
retryable ? "retryable_failure" : "permanent_failure",
|
|
@@ -449,6 +485,18 @@ export function createGhTransport(binary = "gh"): GhTransport {
|
|
|
449
485
|
return new Promise((complete) => {
|
|
450
486
|
let stdout: Buffer = Buffer.alloc(0);
|
|
451
487
|
let stderr: Buffer = Buffer.alloc(0);
|
|
488
|
+
// A cancelled operation never starts another remote call.
|
|
489
|
+
if (options.signal?.aborted) {
|
|
490
|
+
complete({
|
|
491
|
+
exit_code: 1,
|
|
492
|
+
stdout: "",
|
|
493
|
+
stderr: "operation cancelled by the caller",
|
|
494
|
+
timed_out: true,
|
|
495
|
+
output_exceeded: false,
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const remaining = GH_TIMEOUT_MS;
|
|
452
500
|
let timedOut = false;
|
|
453
501
|
let outputExceeded = false;
|
|
454
502
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -496,7 +544,7 @@ export function createGhTransport(binary = "gh"): GhTransport {
|
|
|
496
544
|
timer = setTimeout(() => {
|
|
497
545
|
timedOut = true;
|
|
498
546
|
child.kill("SIGKILL");
|
|
499
|
-
},
|
|
547
|
+
}, remaining);
|
|
500
548
|
child.once("close", (code) => { finish(code ?? 1); });
|
|
501
549
|
try {
|
|
502
550
|
childIn.end(options.stdin ?? "");
|
|
@@ -615,6 +663,73 @@ function initiativeLookup(issues: GithubIssue[], repositoryId: number, initiativ
|
|
|
615
663
|
return findIssue(issues, [initiative, KIND_INITIATIVE_MARKER], [marker("repo-id", repositoryId), initiative]);
|
|
616
664
|
}
|
|
617
665
|
|
|
666
|
+
/**
|
|
667
|
+
* The published Intent identity marker, when the Child carries one. `null` means
|
|
668
|
+
* this Child was published before the marker existed: an upgraded tracker still
|
|
669
|
+
* converges it (the marker is additive), which keeps a previously published
|
|
670
|
+
* batch idempotently replayable across the upgrade. It is never invented
|
|
671
|
+
* locally: the value is the canonical hash of the Intent the Child was prepared
|
|
672
|
+
* from.
|
|
673
|
+
*/
|
|
674
|
+
/** Any published-identity markers on the body, in order. */
|
|
675
|
+
function publishedIntentHashMarkers(body: string): string[] {
|
|
676
|
+
return [...body.matchAll(/<!-- immune-brain:intent-hash=([A-Za-z0-9][A-Za-z0-9._:-]{0,127}) -->/g)].map((match) => match[1]);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The published Intent identity of a Child, as three distinct states:
|
|
681
|
+
*
|
|
682
|
+
* - `absent`: published before the marker existed. It is a pre-upgrade
|
|
683
|
+
* publication and still converges, because the marker is additive.
|
|
684
|
+
* - `bound`: exactly one well-formed marker. It must agree with the Intent
|
|
685
|
+
* being published.
|
|
686
|
+
* - `ambiguous`: two or more markers. The Child states several conflicting
|
|
687
|
+
* identities, so nothing can be trusted about which Intent it came from; it
|
|
688
|
+
* fails closed rather than being read as `absent`.
|
|
689
|
+
*
|
|
690
|
+
* Collapsing `ambiguous` into `absent` would let a Child carry both the correct
|
|
691
|
+
* and a stale hash and still pass every check, so the three states are kept
|
|
692
|
+
* separate everywhere the identity is read.
|
|
693
|
+
*/
|
|
694
|
+
type PublishedIntentIdentity =
|
|
695
|
+
| { kind: "absent" }
|
|
696
|
+
| { kind: "bound"; hash: string }
|
|
697
|
+
| { kind: "ambiguous"; hashes: string[] };
|
|
698
|
+
|
|
699
|
+
function publishedIntentIdentity(body: string): PublishedIntentIdentity {
|
|
700
|
+
const hashes = publishedIntentHashMarkers(body);
|
|
701
|
+
if (hashes.length === 0) return { kind: "absent" };
|
|
702
|
+
if (hashes.length === 1) return { kind: "bound", hash: hashes[0] };
|
|
703
|
+
return { kind: "ambiguous", hashes };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** The identity mismatch message for one Child, or null when it is acceptable. */
|
|
707
|
+
function publishedIntentMismatch(body: string, taskId: string, expected: string | undefined): string | null {
|
|
708
|
+
const identity = publishedIntentIdentity(body);
|
|
709
|
+
if (identity.kind === "ambiguous")
|
|
710
|
+
return `published Task ${taskId} carries ${identity.hashes.length} conflicting intent-hash markers; its published Intent identity is ambiguous`;
|
|
711
|
+
if (identity.kind === "absent" || expected === undefined) return null;
|
|
712
|
+
if (identity.hash !== expected)
|
|
713
|
+
return `published Task ${taskId} was published from a different TaskIntent revision; the published identity no longer matches the Intent being published`;
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Bodies compared for drift, ignoring the optional published-identity marker.
|
|
719
|
+
*
|
|
720
|
+
* The identity marker is additive, so a Child published before it existed has
|
|
721
|
+
* none and must still compare equal here; the identity itself is checked
|
|
722
|
+
* separately, and a multiply-marked body is rejected outright rather than
|
|
723
|
+
* having its markers stripped and ignored.
|
|
724
|
+
*/
|
|
725
|
+
function sameTrackedBody(current: string, expected: string): boolean {
|
|
726
|
+
return stripPublishedIntentHash(current) === stripPublishedIntentHash(expected);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function stripPublishedIntentHash(body: string): string {
|
|
730
|
+
return body.replace(/<!-- immune-brain:intent-hash=[A-Za-z0-9][A-Za-z0-9._:-]{0,127} -->\n?/g, "");
|
|
731
|
+
}
|
|
732
|
+
|
|
618
733
|
function ownershipMarkerValue(body: string, name: "initiative-id" | "slice-id" | "task-id"): string | null {
|
|
619
734
|
const values = [...body.matchAll(new RegExp(`<!-- immune-brain:${name}=([A-Za-z0-9][A-Za-z0-9._-]{0,127}) -->`, "g"))];
|
|
620
735
|
return values.length === 1 ? values[0][1] : null;
|
|
@@ -858,7 +973,7 @@ function createInitiativeBody(
|
|
|
858
973
|
KIND_INITIATIVE_MARKER,
|
|
859
974
|
marker("repo-id", repository.id),
|
|
860
975
|
marker("initiative-id", operation.initiative_id),
|
|
861
|
-
].join("\n")}\n\n${provenance}## How to use this Issue\n\n- Edit planning prose and Slice ordering directly after creation.\n- Keep each Slice marker attached to exactly one stable Slice entry.\n- The tracker never rewrites
|
|
976
|
+
].join("\n")}\n\n${provenance}## How to use this Issue\n\n- Edit planning prose and Slice ordering directly after creation.\n- Keep each Slice marker attached to exactly one stable Slice entry.\n- The tracker never rewrites this Parent after creation; the tracker closes it as completed once every Slice Child is completed, and never closes it otherwise.\n\n## Problem\n\n${publicText(projection.problem ?? "The Initiative addresses the bounded delivery described below.", "projection.problem")}\n\n## Result\n\n${publicText(projection.result ?? operation.goal, "projection.result")}\n\n## Initiative design\n\n${publicText(projection.design ?? "Each Child preserves the shared Initiative decisions and boundaries recorded here.", "projection.design")}\n\n## Decisions\n\n${listText(projection.decisions, "- No additional Initiative decisions recorded.")}\n\n## Testing strategy\n\n${publicText(projection.testing_strategy ?? "Each Child closes from its focused acceptance verification.", "projection.testing_strategy")}\n\n## Out of scope\n\n${listText(projection.out_of_scope, "- Unrelated work outside this Initiative.")}\n\n## Slices\n\n${operation.slices.length + historicalSlices.length === 0 ? "No Slices recorded yet." : [...historicalSlices, ...operation.slices.map((slice) => `- [ ] ${marker("slice-id", slice.id)} **${slice.id}**: ${slice.result ?? slice.goal}${slice.blocked_by?.length ? ` (blocked by: ${slice.blocked_by.join(", ")})` : ""}`)].join("\n")}\n\n${ISSUE_FOOTER}\n`;
|
|
862
977
|
}
|
|
863
978
|
|
|
864
979
|
async function createInitiative(
|
|
@@ -937,6 +1052,10 @@ function childBody(
|
|
|
937
1052
|
marker("initiative-id", operation.initiative_id),
|
|
938
1053
|
marker("slice-id", operation.slice_id),
|
|
939
1054
|
marker("task-id", operation.task_id),
|
|
1055
|
+
// The approved Intent identity travels with the published Child, so an
|
|
1056
|
+
// idempotent replay can compare against what was actually published
|
|
1057
|
+
// instead of against whatever bytes it happens to read this time.
|
|
1058
|
+
...(operation.intent_hash ? [marker("intent-hash", operation.intent_hash)] : []),
|
|
940
1059
|
].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
1060
|
}
|
|
942
1061
|
|
|
@@ -946,12 +1065,8 @@ interface AmendmentExecutionContext {
|
|
|
946
1065
|
pendingContent: Map<string, { title: string; body: string }>;
|
|
947
1066
|
/** Approved final Parent title/body. */
|
|
948
1067
|
parent: { title: string; body: string };
|
|
949
|
-
/** Approved bound Parent issue number. */
|
|
950
|
-
parentIssueNumber: number;
|
|
951
1068
|
/** Historical Slice lines derived from the approved Parent baseline (exact bytes). */
|
|
952
1069
|
historicalSlices: string[];
|
|
953
|
-
/** Historical dependency database IDs and terminal state_reason snapshotted before any write. */
|
|
954
|
-
historicalRelations: Map<string, { blocked_by: number[]; state_reason: string | null }>;
|
|
955
1070
|
}
|
|
956
1071
|
|
|
957
1072
|
/**
|
|
@@ -1024,9 +1139,7 @@ function approvedAmendmentContent(
|
|
|
1024
1139
|
title: initiativeIssueTitle(parent.initiative_id, parent.projection),
|
|
1025
1140
|
body: parentBody,
|
|
1026
1141
|
},
|
|
1027
|
-
parentIssueNumber: parentIssue.number,
|
|
1028
1142
|
historicalSlices,
|
|
1029
|
-
historicalRelations: new Map(),
|
|
1030
1143
|
};
|
|
1031
1144
|
}
|
|
1032
1145
|
|
|
@@ -1068,66 +1181,12 @@ function baselineHistoricalSlices(parentBaselineBody: string, amendedSliceIds: S
|
|
|
1068
1181
|
return lines;
|
|
1069
1182
|
}
|
|
1070
1183
|
|
|
1071
|
-
/** Edit an open Initiative Parent's title/body to the approved amendment content. */
|
|
1072
|
-
async function amendInitiativeParent(
|
|
1073
|
-
root: string,
|
|
1074
|
-
gh: GhTransport,
|
|
1075
|
-
operation: Extract<TrackerOperation, { op: "create-initiative" }>,
|
|
1076
|
-
source: RepositorySnapshot,
|
|
1077
|
-
binding: InitiativeAmendmentBinding,
|
|
1078
|
-
context: AmendmentExecutionContext,
|
|
1079
|
-
): Promise<GithubTrackerResult> {
|
|
1080
|
-
const lookup = (issues: GithubIssue[]) => initiativeLookup(issues, source.repository.id, operation.initiative_id);
|
|
1081
|
-
const body = context.parent.body;
|
|
1082
|
-
const oversized = bodyLimitFailure(operation.op, body);
|
|
1083
|
-
if (oversized) return oversized;
|
|
1084
|
-
const title = context.parent.title;
|
|
1085
|
-
// Re-read the Parent immediately before writing: earlier topology and
|
|
1086
|
-
// historical-dependency reads may have raced a concurrent user edit.
|
|
1087
|
-
const reread = await snapshot(root, gh, operation.op);
|
|
1088
|
-
if ("contract" in reread) return reread;
|
|
1089
|
-
const found = lookup(reread.issues);
|
|
1090
|
-
if (found.kind !== "found")
|
|
1091
|
-
return result(operation.op, "permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
1092
|
-
if (found.issue.state !== "open")
|
|
1093
|
-
return result(operation.op, "ambiguous_remote_state", "an amendment requires the Initiative Parent to remain open", found.issue);
|
|
1094
|
-
if (binding.issue_number !== found.issue.number)
|
|
1095
|
-
return result(operation.op, "ambiguous_remote_state", `amendment Parent is bound to Issue #${binding.issue_number} but observed Issue #${found.issue.number}`, found.issue);
|
|
1096
|
-
if (found.issue.body === body && found.issue.title === title && !labelMutationArgs(found.issue.labels, []).length)
|
|
1097
|
-
return result(operation.op, "already_current", "Initiative Issue already carries the requested amended content", found.issue);
|
|
1098
|
-
// Content that already matches the approved final bytes still converges labels;
|
|
1099
|
-
// anything else must still be the approved amendment baseline.
|
|
1100
|
-
if ((found.issue.body !== body || found.issue.title !== title)
|
|
1101
|
-
&& (found.issue.body !== binding.body || found.issue.title !== binding.title))
|
|
1102
|
-
return result(operation.op, "ambiguous_remote_state", "Initiative Parent changed since the approved amendment baseline", found.issue);
|
|
1103
|
-
// The Parent carries no Task state labels: any managed label observed on it is
|
|
1104
|
-
// drift and is removed by the same edit that writes the amended content.
|
|
1105
|
-
const labelArgs = labelMutationArgs(found.issue.labels, []);
|
|
1106
|
-
const edited = await gh.run([
|
|
1107
|
-
"issue", "edit", String(found.issue.number), "--repo", source.repository.name_with_owner,
|
|
1108
|
-
"--title", title,
|
|
1109
|
-
"--body-file", "-",
|
|
1110
|
-
...labelArgs,
|
|
1111
|
-
], { cwd: root, stdin: body });
|
|
1112
|
-
if (edited.exit_code !== 0 || edited.output_exceeded) return ghFailure(operation.op, edited, "Initiative amendment edit failed");
|
|
1113
|
-
const refreshed = await snapshot(root, gh, operation.op);
|
|
1114
|
-
if ("contract" in refreshed) return refreshed;
|
|
1115
|
-
const confirmed = lookup(refreshed.issues);
|
|
1116
|
-
if (confirmed.kind !== "found") return result(operation.op, "ambiguous_remote_state", "Initiative Parent became ambiguous after amendment", found.issue);
|
|
1117
|
-
if (MANAGED_ISSUE_LABELS.some((label) => confirmed.issue.labels.includes(label)))
|
|
1118
|
-
return result(operation.op, "retryable_failure", "Initiative amendment did not converge to an unlabeled Parent", confirmed.issue);
|
|
1119
|
-
return confirmed.issue.body === body && confirmed.issue.title === title
|
|
1120
|
-
? result(operation.op, "updated", "Initiative Issue updated with approved amendment content", confirmed.issue)
|
|
1121
|
-
: result(operation.op, "retryable_failure", "Initiative amendment did not converge to the requested title and body", confirmed.issue);
|
|
1122
|
-
}
|
|
1123
1184
|
|
|
1124
1185
|
async function upsertTask(
|
|
1125
1186
|
root: string,
|
|
1126
1187
|
gh: GhTransport,
|
|
1127
1188
|
operation: Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
1128
1189
|
source: RepositorySnapshot,
|
|
1129
|
-
pendingBinding: InitiativeAmendmentBinding | undefined | null = null,
|
|
1130
|
-
amendmentContext: AmendmentExecutionContext | undefined = undefined,
|
|
1131
1190
|
): Promise<GithubTrackerResult> {
|
|
1132
1191
|
const labelFailure = await labelAvailabilityFailure(root, gh, source.repository, desiredTaskLabels(operation));
|
|
1133
1192
|
if (labelFailure) return labelFailure;
|
|
@@ -1145,8 +1204,6 @@ async function upsertTask(
|
|
|
1145
1204
|
const lookup = (issues: GithubIssue[]) => taskLookup(issues, source.repository.id, operation.task_id);
|
|
1146
1205
|
const found = lookup(source.issues);
|
|
1147
1206
|
if (found.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", found.message);
|
|
1148
|
-
if (found.kind === "missing" && pendingBinding !== null && pendingBinding !== undefined)
|
|
1149
|
-
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} is bound to Issue #${pendingBinding.issue_number} but that Issue no longer holds its Task marker; amend the binding or restore the marker instead of recreating`, parent.issue);
|
|
1150
1207
|
const blockerIds = operation.projection?.blocked_by ?? [];
|
|
1151
1208
|
const blockers: GithubIssue[] = [];
|
|
1152
1209
|
for (const blockerId of blockerIds) {
|
|
@@ -1167,40 +1224,6 @@ async function upsertTask(
|
|
|
1167
1224
|
let createdChild = false;
|
|
1168
1225
|
let labelsConverged = false;
|
|
1169
1226
|
if (found.kind === "missing") {
|
|
1170
|
-
// Pre-create re-read (amendment path): a concurrent writer may have already
|
|
1171
|
-
// created this unbound Task between the initial snapshot and our create — the
|
|
1172
|
-
// same resumable-creation contract applies before we issue any write, so we
|
|
1173
|
-
// fail closed on divergent content instead of issuing a duplicate create.
|
|
1174
|
-
if (amendmentContext !== undefined) {
|
|
1175
|
-
const reRead = await snapshot(root, gh, operation.op);
|
|
1176
|
-
if ("contract" in reRead) return reRead;
|
|
1177
|
-
// The re-read snapshot must also still hold the amendment's Parent exactly
|
|
1178
|
-
// as approved: a Parent closed or edited after the initial snapshot fails
|
|
1179
|
-
// closed here, before any Child create (avoiding an avoidable remote write
|
|
1180
|
-
// that the post-create attachment guard would otherwise reject).
|
|
1181
|
-
const reReadParent = initiativeLookup(reRead.issues, reRead.repository.id, operation.initiative_id);
|
|
1182
|
-
if (reReadParent.kind !== "found")
|
|
1183
|
-
return result(operation.op, "ambiguous_remote_state", reReadParent.kind === "ambiguous" ? reReadParent.message : "amendment Parent is not observable before creating a new Child", parent.issue);
|
|
1184
|
-
if (reReadParent.issue.number !== amendmentContext.parentIssueNumber)
|
|
1185
|
-
return result(operation.op, "ambiguous_remote_state", `amendment Parent is bound to Issue #${amendmentContext.parentIssueNumber} but observed Issue #${reReadParent.issue.number} before creating a new Child`, reReadParent.issue);
|
|
1186
|
-
if (reReadParent.issue.state !== "open")
|
|
1187
|
-
return result(operation.op, "ambiguous_remote_state", "amendment Parent is no longer open before creating a new Child", reReadParent.issue);
|
|
1188
|
-
if (amendmentContext.parent.title !== reReadParent.issue.title || amendmentContext.parent.body !== reReadParent.issue.body)
|
|
1189
|
-
return result(operation.op, "ambiguous_remote_state", "amendment Parent content changed before creating a new Child", reReadParent.issue);
|
|
1190
|
-
if (sliceCount(reReadParent.issue.body, operation.slice_id) !== 1)
|
|
1191
|
-
return result(operation.op, "ambiguous_remote_state", `Parent Issue #${reReadParent.issue.number} lost its exact Slice marker ${operation.slice_id} before creating a new Child`, reReadParent.issue);
|
|
1192
|
-
const raced = lookup(reRead.issues);
|
|
1193
|
-
if (raced.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", raced.message);
|
|
1194
|
-
if (raced.kind === "found") {
|
|
1195
|
-
const approved = amendmentContext.pendingContent.get(operation.task_id);
|
|
1196
|
-
const resumable = approved !== undefined
|
|
1197
|
-
&& raced.issue.state === "open"
|
|
1198
|
-
&& carriesApprovedContent(raced.issue.title, raced.issue.body, approved);
|
|
1199
|
-
if (!resumable)
|
|
1200
|
-
return result(operation.op, "ambiguous_remote_state", `new pending Task ${operation.task_id} is unbound but Issue #${raced.issue.number} already exists with divergent content; bind it to amend`, raced.issue);
|
|
1201
|
-
return updatePendingChild(root, gh, reRead, operation, raced.issue, undefined, blockers, approved, amendmentContext);
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
1227
|
const mutation = await gh.run([
|
|
1205
1228
|
"issue", "create", "--repo", source.repository.name_with_owner,
|
|
1206
1229
|
"--title", title,
|
|
@@ -1218,61 +1241,19 @@ async function upsertTask(
|
|
|
1218
1241
|
}
|
|
1219
1242
|
if (created.issue.body !== body || created.issue.title !== title)
|
|
1220
1243
|
return result(operation.op, "retryable_failure", "Task Issue did not converge to the requested title and body", created.issue);
|
|
1221
|
-
if (amendmentContext !== undefined) {
|
|
1222
|
-
// Amendment unbound new Child: creation converged, but the Child must
|
|
1223
|
-
// still be open — a concurrent close between create and read fails closed
|
|
1224
|
-
// instead of attaching and wiring dependencies onto closed work.
|
|
1225
|
-
if (created.issue.state !== "open")
|
|
1226
|
-
return result(operation.op, "ambiguous_remote_state", `new pending Task ${operation.task_id} (Issue #${created.issue.number}) is not open after creation`, created.issue);
|
|
1227
|
-
// Route through the amendment convergence path so attachment (R4
|
|
1228
|
-
// re-attach) and every dependency write carry the same pre-write
|
|
1229
|
-
// revalidation as bound pending Children.
|
|
1230
|
-
return updatePendingChild(root, gh, source, operation, created.issue, undefined, blockers, amendmentContext.pendingContent.get(operation.task_id), amendmentContext);
|
|
1231
|
-
}
|
|
1232
1244
|
child = created.issue;
|
|
1233
1245
|
createdChild = true;
|
|
1234
1246
|
} else {
|
|
1235
1247
|
const owned = ownedTaskLookup(source.issues, source.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
1236
1248
|
if (owned.kind !== "found") return result(operation.op, "ambiguous_remote_state", owned.kind === "ambiguous" ? owned.message : "Task Issue ownership changed during publication", found.issue);
|
|
1237
1249
|
child = owned.issue;
|
|
1238
|
-
if (
|
|
1239
|
-
if (child.state !== "open")
|
|
1240
|
-
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} is closed and cannot be amended as pending work`, found.issue);
|
|
1241
|
-
// The bound pending Child must still be the Issue its binding pins (review-5):
|
|
1242
|
-
// a replacement Issue that took over the markers fails closed instead of
|
|
1243
|
-
// being edited or recreated.
|
|
1244
|
-
if (pendingBinding !== undefined && pendingBinding.issue_number !== child.number)
|
|
1245
|
-
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} is bound to Issue #${pendingBinding.issue_number} but observed Issue #${child.number}`, found.issue);
|
|
1246
|
-
const approvedFinal = amendmentContext?.pendingContent.get(operation.task_id);
|
|
1247
|
-
// A Child left open with a validated terminal suffix (failed terminal close)
|
|
1248
|
-
// counts as approved-final when its suffix-free bytes match the approved bytes.
|
|
1249
|
-
const isFinal = approvedFinal !== undefined && carriesApprovedContent(found.issue.title, found.issue.body, approvedFinal);
|
|
1250
|
-
if (!isFinal) {
|
|
1251
|
-
// Suffix-aware baseline equality: a Child whose remote bytes carry a validated
|
|
1252
|
-
// terminal suffix over the binding baseline (failed terminal close) still
|
|
1253
|
-
// matches its binding, because the suffix is terminal evidence, not drift.
|
|
1254
|
-
const baselineMatches = pendingBinding !== undefined
|
|
1255
|
-
&& pendingBinding.title === found.issue.title
|
|
1256
|
-
&& carriesApprovedContent(found.issue.title, found.issue.body, pendingBinding);
|
|
1257
|
-
if (!baselineMatches)
|
|
1258
|
-
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} changed since the approved amendment baseline`, found.issue);
|
|
1259
|
-
}
|
|
1260
|
-
return updatePendingChild(root, gh, source, operation, child, pendingBinding?.issue_number, blockers, approvedFinal, amendmentContext);
|
|
1261
|
-
}
|
|
1262
|
-
if (amendmentContext !== undefined) {
|
|
1263
|
-
// Amendment unbound Child observed before any write: it is only valid
|
|
1264
|
-
// as the exact approved-final creation of this same batch (resumable
|
|
1265
|
-
// creation); anything else fails closed.
|
|
1266
|
-
const approvedFinal = amendmentContext.pendingContent.get(operation.task_id);
|
|
1267
|
-
const resumable = approvedFinal !== undefined
|
|
1268
|
-
&& child.state === "open"
|
|
1269
|
-
&& carriesApprovedContent(found.issue.title, found.issue.body, approvedFinal);
|
|
1270
|
-
if (!resumable)
|
|
1271
|
-
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
|
-
return updatePendingChild(root, gh, source, operation, child, undefined, blockers, approvedFinal, amendmentContext);
|
|
1273
|
-
}
|
|
1274
|
-
if (found.issue.body !== body || found.issue.title !== title)
|
|
1250
|
+
if (!sameTrackedBody(found.issue.body, body) || found.issue.title !== title)
|
|
1275
1251
|
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);
|
|
1252
|
+
// The published surface records the approved Intent identity. A Child that
|
|
1253
|
+
// carries it must agree exactly; a Child published before the marker
|
|
1254
|
+
// existed has nothing to compare against and still converges.
|
|
1255
|
+
const intentMismatch = publishedIntentMismatch(found.issue.body, operation.task_id, operation.intent_hash);
|
|
1256
|
+
if (intentMismatch) return result(operation.op, "ambiguous_remote_state", intentMismatch, found.issue);
|
|
1276
1257
|
// A repeated complete batch is a convergence pass: managed label drift is
|
|
1277
1258
|
// repaired here without rewriting content the tracker never owns.
|
|
1278
1259
|
const observedLabelArgs = labelMutationArgs(child.labels, desiredTaskLabels(operation));
|
|
@@ -1301,8 +1282,10 @@ async function upsertTask(
|
|
|
1301
1282
|
const finalSource = await snapshot(root, gh, operation.op);
|
|
1302
1283
|
if ("contract" in finalSource) return finalSource;
|
|
1303
1284
|
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
|
|
1285
|
+
if (finalChild.kind !== "found" || finalChild.issue.id !== child.id || finalChild.issue.title !== title || !sameTrackedBody(finalChild.issue.body, body))
|
|
1305
1286
|
return result(operation.op, "ambiguous_remote_state", "Task Issue changed identity, title, or body during dependency publication", child);
|
|
1287
|
+
const finalIdentityMismatch = publishedIntentMismatch(finalChild.issue.body, operation.task_id, operation.intent_hash);
|
|
1288
|
+
if (finalIdentityMismatch) return result(operation.op, "ambiguous_remote_state", finalIdentityMismatch, child);
|
|
1306
1289
|
const finalChildOwnership = await confirmTerminalOwnership(root, gh, operation.op, finalSource, finalChild.issue);
|
|
1307
1290
|
if (!("owned" in finalChildOwnership)) return finalChildOwnership;
|
|
1308
1291
|
child = finalChild.issue;
|
|
@@ -1376,7 +1359,7 @@ async function closeTerminalIssue(
|
|
|
1376
1359
|
: result(operation.op, "retryable_failure", "terminal Issue closure did not converge", found.issue);
|
|
1377
1360
|
}
|
|
1378
1361
|
|
|
1379
|
-
async function
|
|
1362
|
+
async function markChildTerminal(
|
|
1380
1363
|
root: string,
|
|
1381
1364
|
gh: GhTransport,
|
|
1382
1365
|
operation: Extract<TrackerOperation, { op: "mark-terminal" }>,
|
|
@@ -1422,6 +1405,86 @@ async function markTerminal(
|
|
|
1422
1405
|
return closeTerminalIssue(root, gh, operation, refreshed, reread.issue, lookup);
|
|
1423
1406
|
}
|
|
1424
1407
|
|
|
1408
|
+
/**
|
|
1409
|
+
* The Parent decision reads only Issue-scoped endpoints (the Parent and its
|
|
1410
|
+
* Sub-issue list), never a repository listing. A failed read or close is a
|
|
1411
|
+
* tracker observation whose single retry action is the same `mark-terminal`;
|
|
1412
|
+
* the Child's terminal closure is already confirmed and stays unchanged.
|
|
1413
|
+
*/
|
|
1414
|
+
async function closeParentWhenComplete(
|
|
1415
|
+
root: string,
|
|
1416
|
+
gh: GhTransport,
|
|
1417
|
+
operation: Extract<TrackerOperation, { op: "mark-terminal" }>,
|
|
1418
|
+
source: RepositorySnapshot,
|
|
1419
|
+
childResult: GithubTrackerResult,
|
|
1420
|
+
): Promise<GithubTrackerResult> {
|
|
1421
|
+
const child = taskLookup(source.issues, source.repository.id, operation.task_id);
|
|
1422
|
+
if (child.kind !== "found") return childResult;
|
|
1423
|
+
const initiativeId = ownershipMarkerValue(child.issue.body, "initiative-id");
|
|
1424
|
+
if (!initiativeId) return childResult;
|
|
1425
|
+
const parentLookup = initiativeLookup(source.issues, source.repository.id, initiativeId);
|
|
1426
|
+
if (parentLookup.kind !== "found") return childResult;
|
|
1427
|
+
const parentNumber = parentLookup.issue.number;
|
|
1428
|
+
const retry = (message: string, status: TrackerStatus = "retryable_failure") =>
|
|
1429
|
+
result(operation.op, status, `Parent #${parentNumber}: ${message}; the Task closure is unchanged, retry the same mark-terminal`, child.issue);
|
|
1430
|
+
const endpoint = `repos/${source.repository.name_with_owner}/issues/${parentNumber}`;
|
|
1431
|
+
const readParent = async (): Promise<GithubIssue | GithubTrackerResult> => {
|
|
1432
|
+
const read = await gh.run(["api", endpoint], { cwd: root });
|
|
1433
|
+
if (read.exit_code !== 0 || read.output_exceeded) {
|
|
1434
|
+
const failed = ghFailure(operation.op, read, "cannot read the Initiative Parent");
|
|
1435
|
+
return retry(failed.message, failed.status);
|
|
1436
|
+
}
|
|
1437
|
+
try {
|
|
1438
|
+
const [issue] = parseIssues(`[${read.stdout}]`);
|
|
1439
|
+
return issue ?? retry("gh returned a malformed Issue", "permanent_failure");
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
return retry(error instanceof Error ? error.message : String(error), "permanent_failure");
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
const parent = await readParent();
|
|
1445
|
+
if ("contract" in parent) return parent;
|
|
1446
|
+
if (parent.state === "closed") return childResult;
|
|
1447
|
+
const listed = await gh.run(["api", "--paginate", "--slurp", `${endpoint}/sub_issues?per_page=100`], { cwd: root });
|
|
1448
|
+
if (listed.exit_code !== 0 || listed.output_exceeded) {
|
|
1449
|
+
const failed = ghFailure(operation.op, listed, "cannot read the Initiative Sub-issues");
|
|
1450
|
+
return retry(failed.message, failed.status);
|
|
1451
|
+
}
|
|
1452
|
+
let children: GithubIssue[];
|
|
1453
|
+
try {
|
|
1454
|
+
children = parseIssues(listed.stdout);
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
return retry(error instanceof Error ? error.message : String(error), "permanent_failure");
|
|
1457
|
+
}
|
|
1458
|
+
const sliceChildren = children.filter((candidate) =>
|
|
1459
|
+
candidate.body.includes(marker("initiative-id", initiativeId))
|
|
1460
|
+
&& /<!-- immune-brain:slice-id=[^>]+ -->/.test(candidate.body));
|
|
1461
|
+
if (sliceChildren.length === 0
|
|
1462
|
+
|| !sliceChildren.every((candidate) => candidate.state === "closed" && candidate.state_reason === "completed"))
|
|
1463
|
+
return childResult;
|
|
1464
|
+
const close = await gh.run(["issue", "close", String(parentNumber), "--repo", source.repository.name_with_owner, "--reason", "completed"], { cwd: root });
|
|
1465
|
+
const confirmed = await readParent();
|
|
1466
|
+
if ("contract" in confirmed) return confirmed;
|
|
1467
|
+
if (confirmed.state === "closed" && confirmed.state_reason === "completed")
|
|
1468
|
+
return result(operation.op, "updated", `${childResult.message}; Initiative Parent #${parentNumber} closed as completed`, child.issue);
|
|
1469
|
+
if (close.exit_code !== 0 || close.output_exceeded) {
|
|
1470
|
+
const failed = ghFailure(operation.op, close, "Initiative Parent closure failed");
|
|
1471
|
+
return retry(failed.message, failed.status);
|
|
1472
|
+
}
|
|
1473
|
+
return retry("closure did not converge");
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function markTerminal(
|
|
1477
|
+
root: string,
|
|
1478
|
+
gh: GhTransport,
|
|
1479
|
+
operation: Extract<TrackerOperation, { op: "mark-terminal" }>,
|
|
1480
|
+
source: RepositorySnapshot,
|
|
1481
|
+
): Promise<GithubTrackerResult> {
|
|
1482
|
+
const terminal = await markChildTerminal(root, gh, operation, source);
|
|
1483
|
+
if (operation.phase !== "done" || (terminal.status !== "updated" && terminal.status !== "already_current") || !terminal.association_found)
|
|
1484
|
+
return terminal;
|
|
1485
|
+
return closeParentWhenComplete(root, gh, operation, source, terminal);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1425
1488
|
function normalizedList(value: unknown, name: string, max = 2_000): string[] | undefined {
|
|
1426
1489
|
if (value === undefined) return undefined;
|
|
1427
1490
|
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
|
@@ -1535,19 +1598,6 @@ function validateOperation(operation: TrackerOperation): TrackerOperation {
|
|
|
1535
1598
|
};
|
|
1536
1599
|
}
|
|
1537
1600
|
|
|
1538
|
-
/** Run one amendment pending Task: create unbound new Children, amend bound drift. */
|
|
1539
|
-
async function runAmendmentTaskOperation(
|
|
1540
|
-
root: string,
|
|
1541
|
-
gh: GhTransport,
|
|
1542
|
-
operation: Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
1543
|
-
pendingBinding: InitiativeAmendmentBinding | undefined | null,
|
|
1544
|
-
context: AmendmentExecutionContext | undefined,
|
|
1545
|
-
): Promise<GithubTrackerResult> {
|
|
1546
|
-
const absoluteRoot = resolve(root);
|
|
1547
|
-
const source = await snapshot(absoluteRoot, gh, operation.op);
|
|
1548
|
-
if ("contract" in source) return source;
|
|
1549
|
-
return upsertTask(absoluteRoot, gh, operation, source, pendingBinding, context);
|
|
1550
|
-
}
|
|
1551
1601
|
|
|
1552
1602
|
export async function runGithubTrackerOperation(
|
|
1553
1603
|
root: string,
|
|
@@ -1574,21 +1624,58 @@ export async function runGithubTrackerOperation(
|
|
|
1574
1624
|
}
|
|
1575
1625
|
}
|
|
1576
1626
|
|
|
1577
|
-
|
|
1627
|
+
/**
|
|
1628
|
+
* The publication result builder. `plannedTaskIds` is the operation's complete
|
|
1629
|
+
* step set, known before the first remote write; every Task that is not
|
|
1630
|
+
* confirmed is a pending step, so a Parent failure, an early Child failure, or
|
|
1631
|
+
* a post-plan validation refusal still reports every unfinished Task instead of
|
|
1632
|
+
* an empty list.
|
|
1633
|
+
*/
|
|
1634
|
+
function publicationResultWithPlan(
|
|
1635
|
+
plannedTaskIds: readonly string[],
|
|
1578
1636
|
status: TrackerStatus,
|
|
1579
1637
|
message: string,
|
|
1580
1638
|
initiative?: GithubTrackerResult,
|
|
1581
1639
|
tasks: GithubInitiativePublicationResult["tasks"] = [],
|
|
1582
1640
|
execution?: GithubInitiativePublicationResult["execution"],
|
|
1583
1641
|
): GithubInitiativePublicationResult {
|
|
1642
|
+
const settledTaskSteps = tasks.filter((task) => isSuccessfulTrackerStatus(task.status));
|
|
1643
|
+
const confirmedIds = new Set(settledTaskSteps.map((task) => task.task_id));
|
|
1644
|
+
const planned = plannedTaskIds.length > 0 ? plannedTaskIds : tasks.map((task) => task.task_id);
|
|
1645
|
+
const pendingSteps = planned.filter((taskId) => !confirmedIds.has(taskId));
|
|
1646
|
+
// Truthful effect reporting: a caller must be able to tell a confirmed, read
|
|
1647
|
+
// back write from an outcome it may not treat as zero writes. A failure says
|
|
1648
|
+
// nothing about how much already landed, so its write state is `uncertain`,
|
|
1649
|
+
// never `confirmed` and never "zero writes".
|
|
1650
|
+
const writeState: PublicationWriteState = status === "created" || status === "updated" || status === "already_current"
|
|
1651
|
+
? "confirmed"
|
|
1652
|
+
: "uncertain";
|
|
1653
|
+
// A failure never proves zero writes, so that fact is always stated. The
|
|
1654
|
+
// replay *action* is offered only when replaying could actually work: a
|
|
1655
|
+
// permanent_failure needs its own specific fix first, so its own message stays
|
|
1656
|
+
// the single action instead of being joined by a replay suggestion.
|
|
1657
|
+
const uncertainty = writeState === "confirmed"
|
|
1658
|
+
? ""
|
|
1659
|
+
: "This failure does not prove zero writes; earlier steps may already have landed. ";
|
|
1660
|
+
// One action for the whole retryable class, so a failure that reaches this
|
|
1661
|
+
// builder without the transport-level attribution still gets exactly one
|
|
1662
|
+
// corrective action instead of none. Permanent failures keep their own.
|
|
1663
|
+
const recovery = status === "retryable_failure"
|
|
1664
|
+
? "Re-read the exact ownership, Sub-issue topology and intent hashes, then replay the same approved manifest. "
|
|
1665
|
+
: "";
|
|
1666
|
+
// A permanent_failure needs its own specific repair, and its own message is
|
|
1667
|
+
// that single action; nothing generic is layered on top of it.
|
|
1584
1668
|
return {
|
|
1585
1669
|
contract: "immune_brain/github_initiative_publication/v1",
|
|
1586
1670
|
operation: "publish-initiative",
|
|
1587
1671
|
status,
|
|
1588
1672
|
...(initiative ? { initiative } : {}),
|
|
1589
1673
|
tasks,
|
|
1674
|
+
write_state: writeState,
|
|
1675
|
+
confirmed_steps: settledTaskSteps.map((task) => task.task_id),
|
|
1676
|
+
pending_steps: pendingSteps,
|
|
1590
1677
|
...(execution ? { execution } : {}),
|
|
1591
|
-
message: redactGithubDiagnostic(message)
|
|
1678
|
+
message: `${uncertainty}${recovery}${redactGithubDiagnostic(message)}`,
|
|
1592
1679
|
};
|
|
1593
1680
|
}
|
|
1594
1681
|
|
|
@@ -1692,6 +1779,46 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1692
1779
|
return { initiative, ...plan, intent_bindings: intentBindings, foreign_dependers: foreignDependers };
|
|
1693
1780
|
}
|
|
1694
1781
|
|
|
1782
|
+
/**
|
|
1783
|
+
* Whole-batch validation before any recovery write.
|
|
1784
|
+
*
|
|
1785
|
+
* A repeated publication is a recovery pass: it may repair managed label drift
|
|
1786
|
+
* and converge pending Children. Before the first such write, every already
|
|
1787
|
+
* published Child of this batch must still be the one this Intent approves --
|
|
1788
|
+
* correct ownership markers, exactly one Slice entry in the Parent, and the
|
|
1789
|
+
* published Intent identity where the Child carries one. Otherwise a later
|
|
1790
|
+
* Child's mismatch could be discovered only after earlier Children had already
|
|
1791
|
+
* been written, which is exactly the partial recovery this pass must not cause.
|
|
1792
|
+
*/
|
|
1793
|
+
function publicationBatchDrift(
|
|
1794
|
+
source: RepositorySnapshot,
|
|
1795
|
+
initiativeId: string,
|
|
1796
|
+
order: readonly Extract<TrackerOperation, { op: "upsert-task" }>[],
|
|
1797
|
+
): string | null {
|
|
1798
|
+
const parent = initiativeLookup(source.issues, source.repository.id, initiativeId);
|
|
1799
|
+
// A missing Parent is not a batch-drift condition: creation is the normal path.
|
|
1800
|
+
if (parent.kind === "missing") return null;
|
|
1801
|
+
if (parent.kind === "ambiguous") return parent.message;
|
|
1802
|
+
// Narrow by construction: this check exists solely because the published
|
|
1803
|
+
// Intent identity is verified inside each Child's own convergence pass, so a
|
|
1804
|
+
// later Child's mismatch would otherwise be discovered only after an earlier
|
|
1805
|
+
// Child had already been repaired. Every other precondition keeps its
|
|
1806
|
+
// existing classification on the existing path.
|
|
1807
|
+
for (const operation of order) {
|
|
1808
|
+
const found = ownedTaskLookup(
|
|
1809
|
+
source.issues,
|
|
1810
|
+
source.repository.id,
|
|
1811
|
+
operation.task_id,
|
|
1812
|
+
initiativeId,
|
|
1813
|
+
operation.slice_id,
|
|
1814
|
+
);
|
|
1815
|
+
if (found.kind !== "found") continue;
|
|
1816
|
+
const mismatch = publishedIntentMismatch(found.issue.body, operation.task_id, operation.intent_hash);
|
|
1817
|
+
if (mismatch) return mismatch;
|
|
1818
|
+
}
|
|
1819
|
+
return null;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1695
1822
|
function publicationIntentDrift(
|
|
1696
1823
|
root: string,
|
|
1697
1824
|
bindings: Map<string, Pick<PreparedPublicationTask, "intent_path" | "intent_content_hash">>,
|
|
@@ -1711,24 +1838,6 @@ function publicationIntentDrift(
|
|
|
1711
1838
|
return null;
|
|
1712
1839
|
}
|
|
1713
1840
|
|
|
1714
|
-
function publicationIssueDrift(expected: {
|
|
1715
|
-
issue_number?: number;
|
|
1716
|
-
issue_url?: string;
|
|
1717
|
-
node_id?: string;
|
|
1718
|
-
}, actual: GithubIssue, title: string, body: string, label: string, options?: { allowTerminalSuffix?: boolean }): string | null {
|
|
1719
|
-
if (expected.issue_number !== actual.number || expected.issue_url !== actual.url || expected.node_id !== String(actual.id))
|
|
1720
|
-
return `${label} identity changed during Initiative publication`;
|
|
1721
|
-
if (actual.state !== "open") return `${label} is no longer open`;
|
|
1722
|
-
if (actual.title !== title || actual.body !== body) {
|
|
1723
|
-
// Only a pending amendment Child left open with a validated terminal suffix
|
|
1724
|
-
// (failed terminal close) matches its suffix-free expected content. The
|
|
1725
|
-
// Parent and the strict non-amendment path have no terminal-suffix
|
|
1726
|
-
// lifecycle: byte-exact comparison, any extra suffix is real drift.
|
|
1727
|
-
if (options?.allowTerminalSuffix && carriesApprovedContent(actual.title, actual.body, { title, body })) return null;
|
|
1728
|
-
return `${label} content changed during Initiative publication`;
|
|
1729
|
-
}
|
|
1730
|
-
return null;
|
|
1731
|
-
}
|
|
1732
1841
|
|
|
1733
1842
|
/** One bound Issue's expected remote identity and content. */
|
|
1734
1843
|
function validateAmendmentBinding(binding: unknown, label: string): InitiativeAmendmentBinding {
|
|
@@ -1999,258 +2108,645 @@ function carriesApprovedContent(title: string, body: string, approved: { title:
|
|
|
1999
2108
|
return typeof stripped === "string" && stripped.trimEnd() === approved.body.trimEnd();
|
|
2000
2109
|
}
|
|
2001
2110
|
|
|
2002
|
-
|
|
2111
|
+
/**
|
|
2112
|
+
* The Issue number at the end of a `gh issue create` response URL, or null.
|
|
2113
|
+
* Creation takes its number from the create response: no read-back follows a
|
|
2114
|
+
* write, so a response that cannot be parsed leaves the outcome uncertain
|
|
2115
|
+
* rather than asking the repository what landed.
|
|
2116
|
+
*/
|
|
2117
|
+
function parseCreatedIssueNumber(stdout: string): number | null {
|
|
2118
|
+
const match = stdout.trim().match(/\/issues\/(\d+)\/?$/);
|
|
2119
|
+
if (!match) return null;
|
|
2120
|
+
const number = Number(match[1]);
|
|
2121
|
+
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* Resolve one Issue's database id. Assumption A1 fallback: `gh issue create`
|
|
2126
|
+
* returns only the Issue URL, so the id the relation endpoints require is read
|
|
2127
|
+
* once per created Issue immediately after its create. The id is never
|
|
2128
|
+
* re-resolved for an Issue the start listing already carries.
|
|
2129
|
+
*/
|
|
2130
|
+
async function resolveIssueId(
|
|
2003
2131
|
root: string,
|
|
2004
2132
|
gh: GhTransport,
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
const
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
const
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2133
|
+
repository: RepositoryInfo,
|
|
2134
|
+
number: number,
|
|
2135
|
+
): Promise<GithubTrackerResult | number> {
|
|
2136
|
+
const read = await gh.run(
|
|
2137
|
+
["api", "--jq", ".id", `repos/${repository.name_with_owner}/issues/${number}`],
|
|
2138
|
+
{ cwd: root },
|
|
2139
|
+
);
|
|
2140
|
+
if (read.exit_code !== 0 || read.output_exceeded)
|
|
2141
|
+
return ghFailure("upsert-task", read, `cannot resolve the database id of Issue #${number}`);
|
|
2142
|
+
const id = Number(read.stdout.trim());
|
|
2143
|
+
return Number.isSafeInteger(id) && id > 0
|
|
2144
|
+
? id
|
|
2145
|
+
: result("upsert-task", "retryable_failure", `Issue #${number} identity could not be resolved`);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* Direct publication: S1 of docs/specs/tracker-direct-publication.spec.md.
|
|
2150
|
+
*
|
|
2151
|
+
* Mirrors the `to-spec`/`to-tickets` write flow. One start-of-run read of the
|
|
2152
|
+
* repository identity and the Issue listing (plus the label list, resolved by
|
|
2153
|
+
* the caller), then one create per absent Issue in dependency order, one native
|
|
2154
|
+
* Sub-issue attach per Child, and one `blocked_by` write per dependency edge --
|
|
2155
|
+
* every Issue number and id taken from its create response. Nothing is re-read
|
|
2156
|
+
* after a write, so a half-landed batch is recovered by rerunning the same
|
|
2157
|
+
* approved manifest: the next start listing adopts whatever landed and only the
|
|
2158
|
+
* missing relations are written. The reads that are not the start listing -- the
|
|
2159
|
+
* Parent's native Sub-issue list, each adopted Child's `blocked_by` set, and one
|
|
2160
|
+
* id resolution per created Issue -- are targeted at Issues the batch itself
|
|
2161
|
+
* owns and all happen before the relation write they inform.
|
|
2162
|
+
*/
|
|
2163
|
+
async function publishInitiativeDirect(
|
|
2164
|
+
root: string,
|
|
2165
|
+
gh: GhTransport,
|
|
2166
|
+
prepared: ReturnType<typeof preflightPublication>,
|
|
2167
|
+
initial: RepositorySnapshot,
|
|
2168
|
+
): Promise<GithubInitiativePublicationResult> {
|
|
2169
|
+
const plannedTaskIds = prepared.order.map((operation) => operation.task_id);
|
|
2170
|
+
const initiativeId = prepared.initiative.initiative_id;
|
|
2171
|
+
const repository = initial.repository;
|
|
2172
|
+
const desiredParentTitle = initiativeIssueTitle(initiativeId, prepared.initiative.projection);
|
|
2173
|
+
const desiredParentBody = createInitiativeBody(repository, prepared.initiative);
|
|
2174
|
+
let initiativeResult: GithubTrackerResult | undefined;
|
|
2175
|
+
const taskResults: GithubInitiativePublicationResult["tasks"] = [];
|
|
2176
|
+
const failed = (failure: GithubTrackerResult): GithubInitiativePublicationResult =>
|
|
2177
|
+
publicationResultWithPlan(plannedTaskIds, failure.status, failure.message, initiativeResult, taskResults);
|
|
2178
|
+
|
|
2179
|
+
const parentLookup = initiativeLookup(initial.issues, repository.id, initiativeId);
|
|
2180
|
+
if (parentLookup.kind === "ambiguous")
|
|
2181
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", parentLookup.message);
|
|
2182
|
+
const existingParent = parentLookup.kind === "found" ? parentLookup.issue : null;
|
|
2183
|
+
// A closed Parent is start-of-run drift: it is state the listing already
|
|
2184
|
+
// reveals, so it fails closed here rather than being adopted as current.
|
|
2185
|
+
if (existingParent && existingParent.state !== "open")
|
|
2186
|
+
return publicationResultWithPlan(
|
|
2187
|
+
plannedTaskIds,
|
|
2188
|
+
"ambiguous_remote_state",
|
|
2189
|
+
"Initiative Parent is no longer open",
|
|
2190
|
+
result("create-initiative", "ambiguous_remote_state", "Initiative Parent is no longer open", existingParent),
|
|
2191
|
+
);
|
|
2192
|
+
// Whole-batch validation before the first write: every already-published
|
|
2193
|
+
// Child of this batch must still be the one this Intent approves, including
|
|
2194
|
+
// its published Intent identity. This runs before the Parent content check
|
|
2195
|
+
// because the Parent body embeds the Child goal prose, so a stale Intent must
|
|
2196
|
+
// be reported as the identity mismatch it is, not as Parent drift.
|
|
2197
|
+
const batchDrift = publicationBatchDrift(
|
|
2198
|
+
initial,
|
|
2199
|
+
initiativeId,
|
|
2200
|
+
prepared.order as Extract<TrackerOperation, { op: "upsert-task" }>[],
|
|
2201
|
+
);
|
|
2202
|
+
if (batchDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", batchDrift);
|
|
2203
|
+
// The Parent is never rewritten: an existing Parent that no longer carries
|
|
2204
|
+
// the exact approved bytes is a permanent, caller-fixable drift.
|
|
2205
|
+
if (existingParent && (existingParent.title !== desiredParentTitle || existingParent.body !== desiredParentBody))
|
|
2206
|
+
return publicationResultWithPlan(
|
|
2207
|
+
plannedTaskIds,
|
|
2208
|
+
"permanent_failure",
|
|
2209
|
+
"Initiative Issue already exists and the tracker never rewrites it; edit the GitHub source directly for later planning changes",
|
|
2210
|
+
result("create-initiative", "permanent_failure", "Initiative Issue already exists and the tracker never rewrites it", existingParent),
|
|
2211
|
+
);
|
|
2212
|
+
|
|
2213
|
+
const existingChildren = new Map<string, GithubIssue>();
|
|
2214
|
+
for (const operation of prepared.order) {
|
|
2215
|
+
const owned = ownedTaskLookup(initial.issues, repository.id, operation.task_id, initiativeId, operation.slice_id);
|
|
2216
|
+
if (owned.kind === "ambiguous")
|
|
2217
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", owned.message);
|
|
2218
|
+
if (owned.kind === "found") existingChildren.set(operation.task_id, owned.issue);
|
|
2219
|
+
}
|
|
2220
|
+
// An owned Child whose Parent is gone cannot be adopted under a new Parent:
|
|
2221
|
+
// the Child still carries the old Parent link, so recreating the Parent would
|
|
2222
|
+
// silently re-home it. This is caller-fixable remote drift, not a create path.
|
|
2223
|
+
if (!existingParent && existingChildren.size > 0)
|
|
2224
|
+
return publicationResultWithPlan(
|
|
2225
|
+
plannedTaskIds,
|
|
2226
|
+
"ambiguous_remote_state",
|
|
2227
|
+
"an owned Child of this Initiative exists but its Parent Issue is missing; the tracker never recreates a Parent over its existing Children",
|
|
2228
|
+
);
|
|
2229
|
+
// A closed Child is start-of-run drift for the same reason as a closed Parent.
|
|
2230
|
+
for (const operation of prepared.order) {
|
|
2231
|
+
const issue = existingChildren.get(operation.task_id);
|
|
2232
|
+
if (issue && issue.state !== "open")
|
|
2233
|
+
return publicationResultWithPlan(
|
|
2234
|
+
plannedTaskIds,
|
|
2235
|
+
"ambiguous_remote_state",
|
|
2236
|
+
`Task ${operation.task_id} is no longer open`,
|
|
2237
|
+
result("upsert-task", "ambiguous_remote_state", `Task ${operation.task_id} is no longer open`, issue),
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
if (existingParent) {
|
|
2241
|
+
for (const operation of prepared.order) {
|
|
2242
|
+
const issue = existingChildren.get(operation.task_id);
|
|
2243
|
+
if (!issue) continue;
|
|
2244
|
+
const title = taskIssueTitle(operation, sliceOrdinalFromChecklist(desiredParentBody, operation.slice_id, operation.projection?.slice_ordinal ?? 1));
|
|
2245
|
+
const body = childBody(repository, operation, existingParent);
|
|
2246
|
+
if (issue.title !== title || !sameTrackedBody(issue.body, body))
|
|
2247
|
+
return publicationResultWithPlan(
|
|
2248
|
+
plannedTaskIds,
|
|
2249
|
+
"ambiguous_remote_state",
|
|
2250
|
+
`Task ${operation.task_id} published content no longer matches the approved Intent publication`,
|
|
2251
|
+
result("upsert-task", "ambiguous_remote_state", `Task ${operation.task_id} published content drifted`, issue),
|
|
2252
|
+
);
|
|
2064
2253
|
}
|
|
2065
2254
|
}
|
|
2066
|
-
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
//
|
|
2070
|
-
|
|
2071
|
-
const
|
|
2072
|
-
if (
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2255
|
+
|
|
2256
|
+
// The only reads besides the start listing; all before the first write. They
|
|
2257
|
+
// exist to find MISSING relations. A relation that is already present but not
|
|
2258
|
+
// approved by this Intent is drift the listing makes visible, so it fails
|
|
2259
|
+
// closed here instead of being reported as already_current.
|
|
2260
|
+
const attachedNumbers: number[] = [];
|
|
2261
|
+
if (existingParent) {
|
|
2262
|
+
const attached = await readSubIssueNumbers(root, gh, "upsert-task", repository, existingParent.number);
|
|
2263
|
+
if (!Array.isArray(attached))
|
|
2264
|
+
return publicationResultWithPlan(plannedTaskIds, attached.status, attached.message);
|
|
2265
|
+
if (new Set(attached).size !== attached.length)
|
|
2266
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "the Initiative Parent lists the same Sub-issue more than once");
|
|
2267
|
+
const ownedNumbers = new Set([...existingChildren.values()].map((issue) => issue.number));
|
|
2268
|
+
if (attached.some((number) => !ownedNumbers.has(number)))
|
|
2269
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "the Initiative Parent carries Sub-issues this batch does not own");
|
|
2270
|
+
attachedNumbers.push(...attached);
|
|
2271
|
+
}
|
|
2272
|
+
const existingBlockerIds = new Map<string, number[]>();
|
|
2273
|
+
for (const operation of prepared.order) {
|
|
2274
|
+
const issue = existingChildren.get(operation.task_id);
|
|
2275
|
+
if (!issue) continue;
|
|
2276
|
+
const ids = await readBlockedByIds(root, gh, "upsert-task", repository, issue.number);
|
|
2277
|
+
if (!Array.isArray(ids))
|
|
2278
|
+
return publicationResultWithPlan(plannedTaskIds, ids.status, ids.message);
|
|
2279
|
+
const approved = new Set<number>();
|
|
2280
|
+
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2281
|
+
const blocker = taskLookup(initial.issues, repository.id, blockerId);
|
|
2282
|
+
if (blocker.kind === "found") approved.add(blocker.issue.id);
|
|
2283
|
+
}
|
|
2284
|
+
if (ids.some((id) => !approved.has(id)))
|
|
2285
|
+
return publicationResultWithPlan(
|
|
2286
|
+
plannedTaskIds,
|
|
2287
|
+
"ambiguous_remote_state",
|
|
2288
|
+
`Task ${operation.task_id} carries native blocked_by relations this Intent does not approve`,
|
|
2289
|
+
);
|
|
2290
|
+
existingBlockerIds.set(operation.task_id, ids);
|
|
2291
|
+
}
|
|
2292
|
+
const beforeParentWrite = publicationIntentDrift(root, prepared.intent_bindings);
|
|
2293
|
+
if (beforeParentWrite)
|
|
2294
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", beforeParentWrite);
|
|
2295
|
+
|
|
2296
|
+
let parentNumber: number;
|
|
2297
|
+
if (existingParent) {
|
|
2298
|
+
parentNumber = existingParent.number;
|
|
2299
|
+
const labelArgs = labelMutationArgs(existingParent.labels, []);
|
|
2300
|
+
if (labelArgs.length === 0) {
|
|
2301
|
+
initiativeResult = result("create-initiative", "already_current", "Initiative Issue already carries the requested initial source", existingParent);
|
|
2302
|
+
} else {
|
|
2303
|
+
const edited = await gh.run(
|
|
2304
|
+
["issue", "edit", String(parentNumber), "--repo", repository.name_with_owner, ...labelArgs],
|
|
2305
|
+
{ cwd: root },
|
|
2306
|
+
);
|
|
2307
|
+
if (edited.exit_code !== 0 || edited.output_exceeded)
|
|
2308
|
+
return failed(ghFailure("create-initiative", edited, "Initiative Parent label convergence failed"));
|
|
2309
|
+
initiativeResult = result(
|
|
2310
|
+
"create-initiative",
|
|
2311
|
+
"updated",
|
|
2312
|
+
"Initiative Issue managed labels converged",
|
|
2313
|
+
{ ...existingParent, labels: [] },
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
} else {
|
|
2317
|
+
const created = await gh.run(
|
|
2318
|
+
["issue", "create", "--repo", repository.name_with_owner, "--title", desiredParentTitle, "--body-file", "-"],
|
|
2319
|
+
{ cwd: root, stdin: desiredParentBody },
|
|
2083
2320
|
);
|
|
2084
|
-
if (
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
:
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2321
|
+
if (created.exit_code !== 0 || created.output_exceeded)
|
|
2322
|
+
return failed(ghFailure("create-initiative", created, "Initiative Issue creation failed"));
|
|
2323
|
+
const number = parseCreatedIssueNumber(created.stdout);
|
|
2324
|
+
if (number === null)
|
|
2325
|
+
return publicationResultWithPlan(plannedTaskIds, "retryable_failure", "Initiative creation could not be confirmed", initiativeResult, taskResults);
|
|
2326
|
+
parentNumber = number;
|
|
2327
|
+
initiativeResult = {
|
|
2328
|
+
contract: CONTRACT,
|
|
2329
|
+
operation: "create-initiative",
|
|
2330
|
+
status: "created",
|
|
2331
|
+
association_found: true,
|
|
2332
|
+
issue_number: number,
|
|
2333
|
+
issue_url: created.stdout.trim(),
|
|
2334
|
+
message: "Initiative Issue created as the single GitHub source",
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
const parentReference: GithubIssue = existingParent ?? {
|
|
2338
|
+
id: 0,
|
|
2339
|
+
number: parentNumber,
|
|
2340
|
+
url: initiativeResult.issue_url ?? `https://github.com/${repository.name_with_owner}/issues/${parentNumber}`,
|
|
2341
|
+
title: desiredParentTitle,
|
|
2342
|
+
body: desiredParentBody,
|
|
2343
|
+
state: "open",
|
|
2344
|
+
state_reason: null,
|
|
2345
|
+
labels: [],
|
|
2346
|
+
};
|
|
2347
|
+
const numberByTask = new Map<string, number>();
|
|
2348
|
+
const numberByTaskId = new Map<string, number>();
|
|
2349
|
+
const blockerNumber = (blockerId: string): number | undefined =>
|
|
2350
|
+
numberByTask.get(blockerId) ?? existingChildren.get(blockerId)?.number;
|
|
2351
|
+
|
|
2352
|
+
for (const operation of prepared.order) {
|
|
2353
|
+
const intentDrift = publicationIntentDrift(root, prepared.intent_bindings, [operation.task_id]);
|
|
2354
|
+
if (intentDrift) return failed(result("upsert-task", "ambiguous_remote_state", intentDrift));
|
|
2355
|
+
const blockers: Array<{ taskId: string; number: number; id: number | undefined }> = [];
|
|
2356
|
+
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2357
|
+
if (blockerId === operation.task_id)
|
|
2358
|
+
return failed(result("upsert-task", "ambiguous_remote_state", "a Task cannot block itself"));
|
|
2359
|
+
const number = blockerNumber(blockerId);
|
|
2360
|
+
if (number === undefined)
|
|
2361
|
+
return failed(result("upsert-task", "permanent_failure", `blocking Task ${blockerId} has not been published`));
|
|
2362
|
+
blockers.push({ taskId: blockerId, number, id: numberByTaskId.get(blockerId) ?? existingChildren.get(blockerId)?.id });
|
|
2363
|
+
}
|
|
2364
|
+
const existing = existingChildren.get(operation.task_id);
|
|
2365
|
+
if (existing) {
|
|
2366
|
+
let mutated = false;
|
|
2367
|
+
const labelArgs = labelMutationArgs(existing.labels, desiredTaskLabels(operation));
|
|
2368
|
+
if (labelArgs.length) {
|
|
2369
|
+
const edited = await gh.run(
|
|
2370
|
+
["issue", "edit", String(existing.number), "--repo", repository.name_with_owner, ...labelArgs],
|
|
2371
|
+
{ cwd: root },
|
|
2372
|
+
);
|
|
2373
|
+
if (edited.exit_code !== 0 || edited.output_exceeded)
|
|
2374
|
+
return failed(ghFailure("upsert-task", edited, `Task Issue #${existing.number} label convergence failed`));
|
|
2375
|
+
mutated = true;
|
|
2376
|
+
}
|
|
2377
|
+
if (!attachedNumbers.includes(existing.number)) {
|
|
2378
|
+
const attach = await attachChildDirect(root, gh, repository, parentNumber, existing.id, existing.number);
|
|
2379
|
+
if ("contract" in attach) return failed(attach);
|
|
2380
|
+
attachedNumbers.push(existing.number);
|
|
2381
|
+
mutated = true;
|
|
2382
|
+
}
|
|
2383
|
+
const currentIds = existingBlockerIds.get(operation.task_id) ?? [];
|
|
2384
|
+
for (const blocker of blockers) {
|
|
2385
|
+
if (blocker.id !== undefined && currentIds.includes(blocker.id)) continue;
|
|
2386
|
+
const added = await addBlockedByDirect(root, gh, repository, existing.number, blocker.id, blocker.number);
|
|
2387
|
+
if ("contract" in added) return failed(added);
|
|
2388
|
+
mutated = true;
|
|
2389
|
+
}
|
|
2390
|
+
taskResults.push({
|
|
2391
|
+
task_id: operation.task_id,
|
|
2392
|
+
slice_id: operation.slice_id,
|
|
2393
|
+
status: mutated ? "updated" : "already_current",
|
|
2394
|
+
issue_number: existing.number,
|
|
2395
|
+
issue_url: existing.url,
|
|
2396
|
+
node_id: String(existing.id),
|
|
2397
|
+
});
|
|
2398
|
+
continue;
|
|
2399
|
+
}
|
|
2400
|
+
const title = taskIssueTitle(operation, sliceOrdinalFromChecklist(desiredParentBody, operation.slice_id, operation.projection?.slice_ordinal ?? 1));
|
|
2401
|
+
const body = childBody(repository, operation, parentReference);
|
|
2402
|
+
const created = await gh.run(
|
|
2403
|
+
[
|
|
2404
|
+
"issue", "create", "--repo", repository.name_with_owner,
|
|
2098
2405
|
"--title", title,
|
|
2099
2406
|
"--body-file", "-",
|
|
2100
|
-
...
|
|
2101
|
-
],
|
|
2102
|
-
|
|
2103
|
-
|
|
2407
|
+
...desiredTaskLabels(operation).flatMap((label) => ["--label", label]),
|
|
2408
|
+
],
|
|
2409
|
+
{ cwd: root, stdin: body },
|
|
2410
|
+
);
|
|
2411
|
+
if (created.exit_code !== 0 || created.output_exceeded)
|
|
2412
|
+
return failed(ghFailure("upsert-task", created, "Task Issue creation failed"));
|
|
2413
|
+
const number = parseCreatedIssueNumber(created.stdout);
|
|
2414
|
+
if (number === null)
|
|
2415
|
+
return publicationResultWithPlan(plannedTaskIds, "retryable_failure", "Task creation could not be confirmed", initiativeResult, taskResults);
|
|
2416
|
+
const id = await resolveIssueId(root, gh, repository, number);
|
|
2417
|
+
if (typeof id !== "number") return failed(id);
|
|
2418
|
+
const attach = await attachChildDirect(root, gh, repository, parentNumber, id, number);
|
|
2419
|
+
if ("contract" in attach) return failed(attach);
|
|
2420
|
+
for (const blocker of blockers) {
|
|
2421
|
+
const added = await addBlockedByDirect(root, gh, repository, number, blocker.id, blocker.number);
|
|
2422
|
+
if ("contract" in added) return failed(added);
|
|
2104
2423
|
}
|
|
2424
|
+
numberByTask.set(operation.task_id, number);
|
|
2425
|
+
numberByTaskId.set(operation.task_id, id);
|
|
2426
|
+
taskResults.push({
|
|
2427
|
+
task_id: operation.task_id,
|
|
2428
|
+
slice_id: operation.slice_id,
|
|
2429
|
+
status: "created",
|
|
2430
|
+
issue_number: number,
|
|
2431
|
+
issue_url: created.stdout.trim(),
|
|
2432
|
+
});
|
|
2105
2433
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
const
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
/** Converge the exact approved dependency set on a pending Child (amendment only). */
|
|
2130
|
-
async function convergePendingDependencies(
|
|
2434
|
+
|
|
2435
|
+
const finalIntentDrift = publicationIntentDrift(root, prepared.intent_bindings);
|
|
2436
|
+
if (finalIntentDrift) return failed(result("upsert-task", "ambiguous_remote_state", finalIntentDrift));
|
|
2437
|
+
const statuses = [initiativeResult.status, ...taskResults.map((task) => task.status)];
|
|
2438
|
+
const status: TrackerStatus = statuses.every((item) => item === "created")
|
|
2439
|
+
? "created"
|
|
2440
|
+
: statuses.every((item) => item === "already_current") ? "already_current" : "updated";
|
|
2441
|
+
const issueNumber = (taskId: string): number => numberByTask.get(taskId) ?? existingChildren.get(taskId)!.number;
|
|
2442
|
+
const firstTaskId = prepared.order[0].task_id;
|
|
2443
|
+
return publicationResultWithPlan(plannedTaskIds, status, "complete Initiative Parent, Children, and dependency graph published", initiativeResult, taskResults, {
|
|
2444
|
+
recommended_first_task_id: firstTaskId,
|
|
2445
|
+
recommended_first_issue_number: issueNumber(firstTaskId),
|
|
2446
|
+
order: prepared.order.map((operation) => operation.task_id),
|
|
2447
|
+
issue_order: prepared.order.map((operation) => issueNumber(operation.task_id)),
|
|
2448
|
+
parallel_groups: prepared.parallel_groups,
|
|
2449
|
+
parallel_issue_groups: prepared.parallel_groups.map((group) => group.map(issueNumber)),
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/** Attach one Child as a native Sub-issue of the Parent. */
|
|
2454
|
+
async function attachChildDirect(
|
|
2131
2455
|
root: string,
|
|
2132
2456
|
gh: GhTransport,
|
|
2133
|
-
|
|
2457
|
+
repository: RepositoryInfo,
|
|
2458
|
+
parentNumber: number,
|
|
2459
|
+
childId: number,
|
|
2460
|
+
childNumber: number,
|
|
2461
|
+
): Promise<GithubTrackerResult | { attached: true }> {
|
|
2462
|
+
const mutation = await gh.run(
|
|
2463
|
+
["api", "-F", `sub_issue_id=${childId}`, `repos/${repository.name_with_owner}/issues/${parentNumber}/sub_issues`],
|
|
2464
|
+
{ cwd: root },
|
|
2465
|
+
);
|
|
2466
|
+
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
2467
|
+
return ghFailure("upsert-task", mutation, `native Sub-issue attachment failed for Issue #${childNumber}`);
|
|
2468
|
+
return { attached: true };
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** Create one native `blocked_by` edge; the blocker id is already known. */
|
|
2472
|
+
async function addBlockedByDirect(
|
|
2473
|
+
root: string,
|
|
2474
|
+
gh: GhTransport,
|
|
2475
|
+
repository: RepositoryInfo,
|
|
2134
2476
|
childNumber: number,
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
approvedFinal: { title: string; body: string } | undefined,
|
|
2138
|
-
parentApprovedContent: { title: string; body: string } | undefined = undefined,
|
|
2139
|
-
parentBoundNumber: number | undefined = undefined,
|
|
2477
|
+
blockerId: number | undefined,
|
|
2478
|
+
blockerNumber: number,
|
|
2140
2479
|
): Promise<GithubTrackerResult | { complete: true }> {
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
`repos/${source.repository.name_with_owner}/issues/${childNumber}/dependencies/blocked_by/${id}`,
|
|
2151
|
-
], { cwd: root });
|
|
2152
|
-
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
2153
|
-
return ghFailure("upsert-task", mutation, `native blocked_by removal failed for Issue #${childNumber}`);
|
|
2154
|
-
}
|
|
2155
|
-
const additions = requestedBlockers.filter((blocker) => !existing.includes(blocker.id));
|
|
2156
|
-
for (const blocker of additions) {
|
|
2157
|
-
const revalidated = await revalidatePendingChildBeforeWrite(root, gh, childNumber, childTaskId, approvedFinal, undefined, false, parentApprovedContent, parentBoundNumber);
|
|
2158
|
-
if ("contract" in revalidated) return revalidated;
|
|
2159
|
-
const mutation = await gh.run([
|
|
2160
|
-
"api", "-F", `issue_id=${blocker.id}`,
|
|
2161
|
-
`repos/${source.repository.name_with_owner}/issues/${childNumber}/dependencies/blocked_by`,
|
|
2162
|
-
], { cwd: root });
|
|
2163
|
-
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
2164
|
-
return ghFailure("upsert-task", mutation, `native blocked_by attachment failed for Issue #${blocker.number}`);
|
|
2165
|
-
}
|
|
2166
|
-
const confirm = await confirmBlockedBy(root, gh, "upsert-task", source.repository, childNumber, requestedBlockers);
|
|
2167
|
-
if (!("complete" in confirm)) return confirm;
|
|
2168
|
-
return confirm.complete ? { complete: true } : { complete: true };
|
|
2480
|
+
const resolved = blockerId ?? await resolveIssueId(root, gh, repository, blockerNumber);
|
|
2481
|
+
if (typeof resolved !== "number") return resolved;
|
|
2482
|
+
const mutation = await gh.run(
|
|
2483
|
+
["api", "-F", `issue_id=${resolved}`, `repos/${repository.name_with_owner}/issues/${childNumber}/dependencies/blocked_by`],
|
|
2484
|
+
{ cwd: root },
|
|
2485
|
+
);
|
|
2486
|
+
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
2487
|
+
return ghFailure("upsert-task", mutation, `native blocked_by attachment failed for Issue #${childNumber}`);
|
|
2488
|
+
return { complete: true };
|
|
2169
2489
|
}
|
|
2170
2490
|
|
|
2171
2491
|
/**
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
2176
|
-
*
|
|
2177
|
-
*
|
|
2178
|
-
*
|
|
2179
|
-
*
|
|
2492
|
+
* Direct amendment: S2 of docs/specs/tracker-direct-publication.spec.md.
|
|
2493
|
+
*
|
|
2494
|
+
* The approved baseline is verified once at the start, against the start listing
|
|
2495
|
+
* plus Issue-scoped reads (the Parent's Sub-issue list and each adopted Child's
|
|
2496
|
+
* `blocked_by` set). Then the Parent is edited at most once, each pending brief is
|
|
2497
|
+
* edited at most once, absent pending Children are created as in S1, and each
|
|
2498
|
+
* pending Child's `blocked_by` set is converged to the approved set from the
|
|
2499
|
+
* start read. Nothing is re-read after a write and no closing recheck runs, so a
|
|
2500
|
+
* baseline drift that appears after the start listing is not detected in this
|
|
2501
|
+
* run; it surfaces at the start of the next run.
|
|
2180
2502
|
*/
|
|
2181
|
-
async function
|
|
2503
|
+
async function publishAmendmentDirect(
|
|
2182
2504
|
root: string,
|
|
2183
2505
|
gh: GhTransport,
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
if (!
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
if (!attachedNow.includes(childNumber))
|
|
2235
|
-
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) is no longer attached to the Parent before a dependency write`, child);
|
|
2506
|
+
prepared: ReturnType<typeof preflightPublication>,
|
|
2507
|
+
initial: RepositorySnapshot,
|
|
2508
|
+
amendment: ReturnType<typeof validateAmendment>,
|
|
2509
|
+
context: AmendmentExecutionContext,
|
|
2510
|
+
): Promise<GithubInitiativePublicationResult> {
|
|
2511
|
+
const plannedTaskIds = prepared.order.map((operation) => operation.task_id);
|
|
2512
|
+
const initiativeId = prepared.initiative.initiative_id;
|
|
2513
|
+
const repository = initial.repository;
|
|
2514
|
+
const taskResults: GithubInitiativePublicationResult["tasks"] = [];
|
|
2515
|
+
// The confirmed Parent result, when it exists, must survive a later Child or
|
|
2516
|
+
// relation failure: a step whose response was already received is not lost just
|
|
2517
|
+
// because a later step failed. Set once the Parent write has been confirmed.
|
|
2518
|
+
let confirmedParent: GithubTrackerResult | undefined;
|
|
2519
|
+
const failed = (failure: GithubTrackerResult): GithubInitiativePublicationResult =>
|
|
2520
|
+
publicationResultWithPlan(plannedTaskIds, failure.status, failure.message, confirmedParent, taskResults);
|
|
2521
|
+
|
|
2522
|
+
const parentLookup = initiativeLookup(initial.issues, repository.id, initiativeId);
|
|
2523
|
+
if (parentLookup.kind !== "found")
|
|
2524
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
2525
|
+
const parentIssue = parentLookup.issue;
|
|
2526
|
+
|
|
2527
|
+
// Baseline verification, once, before any write: membership, bound identity,
|
|
2528
|
+
// baseline-or-approved-final content, native attachment and terminal ownership.
|
|
2529
|
+
const topology = await validateAmendmentTopology(
|
|
2530
|
+
root, gh, initial, initiativeId, parentIssue,
|
|
2531
|
+
amendment.tasks, amendment.historical,
|
|
2532
|
+
prepared.foreign_dependers,
|
|
2533
|
+
{ pendingContent: context.pendingContent, parent: context.parent, historicalSlices: context.historicalSlices },
|
|
2534
|
+
prepared.order,
|
|
2535
|
+
);
|
|
2536
|
+
if (!("get" in topology)) return failed(topology);
|
|
2537
|
+
const bound = topology;
|
|
2538
|
+
|
|
2539
|
+
// The only reads besides the start listing; all before the first write. They
|
|
2540
|
+
// exist to find MISSING relations, so an unapproved relation already present
|
|
2541
|
+
// fails closed here instead of being reported as already_current.
|
|
2542
|
+
const attached = await readSubIssueNumbers(root, gh, "upsert-task", repository, parentIssue.number);
|
|
2543
|
+
if (!Array.isArray(attached)) return failed(attached);
|
|
2544
|
+
if (new Set(attached).size !== attached.length)
|
|
2545
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "the Initiative Parent lists the same Sub-issue more than once");
|
|
2546
|
+
const attachedNumbers = new Set(attached);
|
|
2547
|
+
const blockerIdsByTask = new Map<string, number[]>();
|
|
2548
|
+
for (const operation of prepared.order) {
|
|
2549
|
+
const issue = bound.get(operation.task_id);
|
|
2550
|
+
if (!issue) continue;
|
|
2551
|
+
const ids = await readBlockedByIds(root, gh, "upsert-task", repository, issue.number);
|
|
2552
|
+
if (!Array.isArray(ids)) return failed(ids);
|
|
2553
|
+
// The approved set is the convergence target, so an unapproved edge present at
|
|
2554
|
+
// start is simply removed by the same run; it is state this run owns, not drift.
|
|
2555
|
+
blockerIdsByTask.set(operation.task_id, ids);
|
|
2236
2556
|
}
|
|
2237
|
-
const
|
|
2238
|
-
if (
|
|
2239
|
-
|
|
2240
|
-
|
|
2557
|
+
const beforeWrite = publicationIntentDrift(root, prepared.intent_bindings);
|
|
2558
|
+
if (beforeWrite) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", beforeWrite);
|
|
2559
|
+
|
|
2560
|
+
// One Parent edit at most, and only when the approved bytes or labels differ.
|
|
2561
|
+
const parentNeedsEdit = parentIssue.title !== context.parent.title
|
|
2562
|
+
|| parentIssue.body !== context.parent.body
|
|
2563
|
+
|| labelMutationArgs(parentIssue.labels, []).length > 0;
|
|
2564
|
+
let parentResult: GithubTrackerResult;
|
|
2565
|
+
if (!parentNeedsEdit) {
|
|
2566
|
+
parentResult = result("create-initiative", "already_current", "Initiative Issue already carries the requested amended content", parentIssue);
|
|
2567
|
+
} else {
|
|
2568
|
+
const edited = await gh.run(
|
|
2569
|
+
[
|
|
2570
|
+
"issue", "edit", String(parentIssue.number), "--repo", repository.name_with_owner,
|
|
2571
|
+
"--title", context.parent.title,
|
|
2572
|
+
"--body-file", "-",
|
|
2573
|
+
...labelMutationArgs(parentIssue.labels, []),
|
|
2574
|
+
],
|
|
2575
|
+
{ cwd: root, stdin: context.parent.body },
|
|
2576
|
+
);
|
|
2577
|
+
if (edited.exit_code !== 0 || edited.output_exceeded)
|
|
2578
|
+
return failed(ghFailure("create-initiative", edited, "Initiative amendment edit failed"));
|
|
2579
|
+
parentResult = result("create-initiative", "updated", "Initiative Issue updated with approved amendment content", {
|
|
2580
|
+
...parentIssue,
|
|
2581
|
+
title: context.parent.title,
|
|
2582
|
+
body: context.parent.body,
|
|
2583
|
+
labels: [],
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
confirmedParent = parentResult;
|
|
2587
|
+
|
|
2588
|
+
const numberByTask = new Map<string, number>();
|
|
2589
|
+
const idByTask = new Map<string, number>();
|
|
2590
|
+
const blockerRef = (blockerId: string): { number: number; id: number } | undefined => {
|
|
2591
|
+
const number = numberByTask.get(blockerId) ?? bound.get(blockerId)?.number;
|
|
2592
|
+
if (number === undefined) return undefined;
|
|
2593
|
+
const observedBlocker = taskLookup(initial.issues, repository.id, blockerId);
|
|
2594
|
+
const id = idByTask.get(blockerId) ?? bound.get(blockerId)?.id
|
|
2595
|
+
?? (observedBlocker.kind === "found" ? observedBlocker.issue.id : undefined);
|
|
2596
|
+
if (id === undefined || !Number.isSafeInteger(id)) return undefined;
|
|
2597
|
+
return { number, id };
|
|
2598
|
+
};
|
|
2599
|
+
|
|
2600
|
+
for (const operation of prepared.order) {
|
|
2601
|
+
const intentDrift = publicationIntentDrift(root, prepared.intent_bindings, [operation.task_id]);
|
|
2602
|
+
if (intentDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", intentDrift, parentResult, taskResults);
|
|
2603
|
+
const approved = context.pendingContent.get(operation.task_id);
|
|
2604
|
+
if (!approved) return failed(result("upsert-task", "permanent_failure", `pending Task ${operation.task_id} has no approved amendment content`));
|
|
2605
|
+
const desiredBlockers: Array<{ number: number; id: number }> = [];
|
|
2606
|
+
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2607
|
+
if (blockerId === operation.task_id)
|
|
2608
|
+
return failed(result("upsert-task", "ambiguous_remote_state", "a Task cannot block itself"));
|
|
2609
|
+
const resolved = blockerRef(blockerId);
|
|
2610
|
+
if (!resolved)
|
|
2611
|
+
return failed(result("upsert-task", "permanent_failure", `blocking Task ${blockerId} has not been published`));
|
|
2612
|
+
desiredBlockers.push(resolved);
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
const observed = bound.get(operation.task_id);
|
|
2616
|
+
let number: number;
|
|
2617
|
+
let id: number;
|
|
2618
|
+
// Content/label mutation decides the reported status; relation convergence
|
|
2619
|
+
// alone leaves an already-current pending brief as already_current.
|
|
2620
|
+
let contentMutated = false;
|
|
2621
|
+
if (observed) {
|
|
2622
|
+
number = observed.number;
|
|
2623
|
+
id = observed.id;
|
|
2624
|
+
} else {
|
|
2625
|
+
// Absent pending Child: one create, number from the create response and id
|
|
2626
|
+
// resolved once for the relation endpoints (Assumption A1).
|
|
2627
|
+
const created = await gh.run(
|
|
2628
|
+
[
|
|
2629
|
+
"issue", "create", "--repo", repository.name_with_owner,
|
|
2630
|
+
"--title", approved.title,
|
|
2631
|
+
"--body-file", "-",
|
|
2632
|
+
...desiredTaskLabels(operation).flatMap((label) => ["--label", label]),
|
|
2633
|
+
],
|
|
2634
|
+
{ cwd: root, stdin: approved.body },
|
|
2635
|
+
);
|
|
2636
|
+
if (created.exit_code !== 0 || created.output_exceeded)
|
|
2637
|
+
return failed(ghFailure("upsert-task", created, `Task Issue creation failed for ${operation.task_id}`));
|
|
2638
|
+
const parsed = parseCreatedIssueNumber(created.stdout);
|
|
2639
|
+
if (parsed === null)
|
|
2640
|
+
return publicationResultWithPlan(plannedTaskIds, "retryable_failure", `Task ${operation.task_id} creation could not be confirmed`, parentResult, taskResults);
|
|
2641
|
+
number = parsed;
|
|
2642
|
+
const resolvedId = await resolveIssueId(root, gh, repository, number);
|
|
2643
|
+
if (typeof resolvedId !== "number") return failed(resolvedId);
|
|
2644
|
+
id = resolvedId;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
if (!attachedNumbers.has(number)) {
|
|
2648
|
+
const attach = await attachChildDirect(root, gh, repository, parentIssue.number, id, number);
|
|
2649
|
+
if ("contract" in attach) return failed(attach);
|
|
2650
|
+
attachedNumbers.add(number);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
if (observed) {
|
|
2654
|
+
// One edit at most, and only when the approved content or labels differ. A
|
|
2655
|
+
// validated terminal suffix observed in the start listing is terminal
|
|
2656
|
+
// evidence, so it is preserved by the rewrite instead of being dropped.
|
|
2657
|
+
const suffixEvent = issueTerminalEventId(observed.body);
|
|
2658
|
+
if (suffixEvent === "multiple")
|
|
2659
|
+
return failed(result("upsert-task", "ambiguous_remote_state", `pending Task ${operation.task_id} has multiple terminal markers`, observed));
|
|
2660
|
+
if (suffixEvent === "malformed")
|
|
2661
|
+
return failed(result("upsert-task", "ambiguous_remote_state", `pending Task ${operation.task_id} carries a malformed terminal marker (marker without its exact canonical suffix)`, observed));
|
|
2662
|
+
const writeBody = suffixEvent !== null ? `${approved.body.trimEnd()}${terminalSuffix(suffixEvent)}` : approved.body;
|
|
2663
|
+
const labelArgs = labelMutationArgs(observed.labels, desiredTaskLabels(operation));
|
|
2664
|
+
if (!carriesApprovedContent(observed.title, observed.body, approved) || labelArgs.length) {
|
|
2665
|
+
const edited = await gh.run(
|
|
2666
|
+
[
|
|
2667
|
+
"issue", "edit", String(number), "--repo", repository.name_with_owner,
|
|
2668
|
+
"--title", approved.title,
|
|
2669
|
+
"--body-file", "-",
|
|
2670
|
+
...labelArgs,
|
|
2671
|
+
],
|
|
2672
|
+
{ cwd: root, stdin: writeBody },
|
|
2673
|
+
);
|
|
2674
|
+
if (edited.exit_code !== 0 || edited.output_exceeded)
|
|
2675
|
+
return failed(ghFailure("upsert-task", edited, `pending Task Issue #${number} update failed`));
|
|
2676
|
+
contentMutated = true;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// Converge the approved dependency set from the start read: no read follows
|
|
2681
|
+
// the relation writes, and only the missing or unapproved edges are touched.
|
|
2682
|
+
const current = blockerIdsByTask.get(operation.task_id) ?? [];
|
|
2683
|
+
const expected = desiredBlockers.map((blocker) => blocker.id);
|
|
2684
|
+
for (const removedId of current.filter((currentId) => !expected.includes(currentId))) {
|
|
2685
|
+
const mutation = await gh.run(
|
|
2686
|
+
["api", "--method", "DELETE", `repos/${repository.name_with_owner}/issues/${number}/dependencies/blocked_by/${removedId}`],
|
|
2687
|
+
{ cwd: root },
|
|
2688
|
+
);
|
|
2689
|
+
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
2690
|
+
return failed(ghFailure("upsert-task", mutation, `native blocked_by removal failed for Issue #${number}`));
|
|
2691
|
+
}
|
|
2692
|
+
for (const blocker of desiredBlockers.filter((candidate) => !current.includes(candidate.id))) {
|
|
2693
|
+
const added = await addBlockedByDirect(root, gh, repository, number, blocker.id, blocker.number);
|
|
2694
|
+
if ("contract" in added) return failed(added);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
numberByTask.set(operation.task_id, number);
|
|
2698
|
+
idByTask.set(operation.task_id, id);
|
|
2699
|
+
taskResults.push({
|
|
2700
|
+
task_id: operation.task_id,
|
|
2701
|
+
slice_id: operation.slice_id,
|
|
2702
|
+
status: !observed ? "created" : contentMutated ? "updated" : "already_current",
|
|
2703
|
+
issue_number: number,
|
|
2704
|
+
issue_url: observed?.url ?? `https://github.com/${repository.name_with_owner}/issues/${number}`,
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const finalIntentDrift = publicationIntentDrift(root, prepared.intent_bindings);
|
|
2709
|
+
if (finalIntentDrift) return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", finalIntentDrift, parentResult, taskResults);
|
|
2710
|
+
const statuses = [parentResult.status, ...taskResults.map((task) => task.status)];
|
|
2711
|
+
const status: TrackerStatus = statuses.every((item) => item === "created")
|
|
2712
|
+
? "created"
|
|
2713
|
+
: statuses.every((item) => item === "already_current") ? "already_current" : "updated";
|
|
2714
|
+
const issueNumber = (taskId: string): number => numberByTask.get(taskId) ?? bound.get(taskId)!.number;
|
|
2715
|
+
const firstTaskId = prepared.order[0].task_id;
|
|
2716
|
+
return publicationResultWithPlan(plannedTaskIds, status, "complete Initiative Parent, Children, and dependency graph published", parentResult, taskResults, {
|
|
2717
|
+
recommended_first_task_id: firstTaskId,
|
|
2718
|
+
recommended_first_issue_number: issueNumber(firstTaskId),
|
|
2719
|
+
order: prepared.order.map((operation) => operation.task_id),
|
|
2720
|
+
issue_order: prepared.order.map((operation) => issueNumber(operation.task_id)),
|
|
2721
|
+
parallel_groups: prepared.parallel_groups,
|
|
2722
|
+
parallel_issue_groups: prepared.parallel_groups.map((group) => group.map(issueNumber)),
|
|
2723
|
+
});
|
|
2241
2724
|
}
|
|
2242
2725
|
|
|
2243
2726
|
export async function runGithubInitiativePublication(
|
|
2244
2727
|
root: string,
|
|
2245
2728
|
input: InitiativePublicationInput,
|
|
2246
|
-
|
|
2729
|
+
rawGh: GhTransport = createGhTransport(),
|
|
2730
|
+
budget?: OperationCancellation,
|
|
2247
2731
|
): Promise<GithubInitiativePublicationResult> {
|
|
2248
2732
|
const absoluteRoot = resolve(root);
|
|
2733
|
+
// One publication call is a finite remote step sequence with no cumulative
|
|
2734
|
+
// deadline: the caller may still supply a cancellation signal, which is
|
|
2735
|
+
// forwarded to every call, and each call keeps its own per-call timeout.
|
|
2736
|
+
const gh: GhTransport = budget?.signal === undefined
|
|
2737
|
+
? rawGh
|
|
2738
|
+
: { run: (args, options = {}) => rawGh.run(args, { ...options, signal: budget.signal }) };
|
|
2739
|
+
// The complete planned step set, known before the first remote write. Every
|
|
2740
|
+
// unconfirmed Task from this set is a pending step, so a Parent failure or an
|
|
2741
|
+
// early Child failure still names each unfinished Task.
|
|
2249
2742
|
let prepared: ReturnType<typeof preflightPublication>;
|
|
2250
2743
|
let amendment: ReturnType<typeof validateAmendment> | undefined;
|
|
2251
|
-
|
|
2744
|
+
// Assigned the moment the plan exists. Every result built after that point
|
|
2745
|
+
// reports the same planned Task set, including the error paths.
|
|
2746
|
+
let plannedTaskIds: string[] = [];
|
|
2252
2747
|
try {
|
|
2253
2748
|
prepared = preflightPublication(absoluteRoot, input);
|
|
2749
|
+
plannedTaskIds = prepared.order.map((operation) => operation.task_id);
|
|
2254
2750
|
if (input.amendment !== undefined) {
|
|
2255
2751
|
if (prepared.order.length < 1)
|
|
2256
2752
|
throw new Error("an amendment requires at least one pending Task");
|
|
@@ -2280,22 +2776,22 @@ export async function runGithubInitiativePublication(
|
|
|
2280
2776
|
amendment = validated;
|
|
2281
2777
|
}
|
|
2282
2778
|
} catch (error) {
|
|
2283
|
-
return
|
|
2779
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", error instanceof Error ? error.message : String(error));
|
|
2284
2780
|
}
|
|
2285
2781
|
const conflict = carrierConflict(absoluteRoot, "create-initiative", prepared.initiative.initiative_id);
|
|
2286
|
-
if (conflict) return
|
|
2782
|
+
if (conflict) return publicationResultWithPlan(plannedTaskIds, conflict.status, conflict.message, conflict);
|
|
2287
2783
|
const initial = await snapshot(absoluteRoot, gh, "create-initiative");
|
|
2288
|
-
if ("contract" in initial) return
|
|
2784
|
+
if ("contract" in initial) return publicationResultWithPlan(plannedTaskIds, initial.status, initial.message, initial);
|
|
2289
2785
|
const initialParent = initiativeLookup(initial.issues, initial.repository.id, prepared.initiative.initiative_id);
|
|
2290
|
-
if (initialParent.kind === "ambiguous") return
|
|
2786
|
+
if (initialParent.kind === "ambiguous") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", initialParent.message);
|
|
2291
2787
|
if (amendment && initialParent.kind === "missing")
|
|
2292
|
-
return
|
|
2788
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
2293
2789
|
// Label availability is validated before any remote mutation: the tracker
|
|
2294
2790
|
// never creates labels, so a missing label fails the whole batch closed
|
|
2295
2791
|
// instead of half-publishing an Initiative.
|
|
2296
2792
|
const requiredLabels = [...new Set(prepared.order.flatMap((operation) => desiredTaskLabels(operation)))];
|
|
2297
2793
|
const labelFailure = await labelAvailabilityFailure(absoluteRoot, gh, initial.repository, requiredLabels);
|
|
2298
|
-
if (labelFailure) return
|
|
2794
|
+
if (labelFailure) return publicationResultWithPlan(plannedTaskIds, labelFailure.status, labelFailure.message, labelFailure);
|
|
2299
2795
|
const parentForPreflight = initialParent.kind === "found" ? initialParent.issue : {
|
|
2300
2796
|
id: Number.MAX_SAFE_INTEGER,
|
|
2301
2797
|
number: Number.MAX_SAFE_INTEGER,
|
|
@@ -2307,241 +2803,66 @@ export async function runGithubInitiativePublication(
|
|
|
2307
2803
|
labels: [],
|
|
2308
2804
|
};
|
|
2309
2805
|
const parentBodyFailure = bodyLimitFailure("create-initiative", createInitiativeBody(initial.repository, prepared.initiative));
|
|
2310
|
-
if (parentBodyFailure) return
|
|
2806
|
+
if (parentBodyFailure) return publicationResultWithPlan(plannedTaskIds, parentBodyFailure.status, parentBodyFailure.message, parentBodyFailure);
|
|
2311
2807
|
for (const operation of prepared.order) {
|
|
2312
2808
|
const childFailure = bodyLimitFailure("upsert-task", childBody(initial.repository, operation, parentForPreflight), MAX_TERMINAL_SUFFIX_BYTES);
|
|
2313
|
-
if (childFailure) return
|
|
2809
|
+
if (childFailure) return publicationResultWithPlan(plannedTaskIds, childFailure.status, childFailure.message, childFailure);
|
|
2314
2810
|
}
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
if (typeof approvedFinalParent === "string") return publicationResult("ambiguous_remote_state", approvedFinalParent);
|
|
2333
|
-
const { parent, pendingContent, historicalSlices } = approvedFinalParent;
|
|
2334
|
-
// Deterministic prerequisite completion is validated before any write: a
|
|
2335
|
-
// historical prerequisite already closed without state_reason "completed"
|
|
2336
|
-
// (e.g. not_planned) makes the batch permanently unfulfillable, so it must
|
|
2337
|
-
// fail closed in preflight with zero mutations instead of after the Parent
|
|
2338
|
-
// and Child writes (the final check still guards concurrent races).
|
|
2339
|
-
for (const operation of prepared.order) {
|
|
2340
|
-
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2341
|
-
if (!amendment.historical.has(blockerId)) continue;
|
|
2342
|
-
const blocker = taskLookup(initial.issues, initial.repository.id, blockerId);
|
|
2343
|
-
if (blocker.kind === "found" && (blocker.issue.state !== "closed" || blocker.issue.state_reason !== "completed"))
|
|
2344
|
-
return publicationResult("ambiguous_remote_state", `Task ${operation.task_id} depends on stopped historical prerequisite ${blockerId}`);
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
const parentBaseline = amendment.parent.body !== initialParent.issue.body || amendment.parent.title !== initialParent.issue.title;
|
|
2348
|
-
const parentApproved = initialParent.issue.title === parent.title && initialParent.issue.body === parent.body;
|
|
2349
|
-
if (parentBaseline && !parentApproved) return publicationResult("ambiguous_remote_state", `amendment Parent changed since the approved amendment baseline`);
|
|
2350
|
-
if (amendment.parent.issue_number !== initialParent.issue.number)
|
|
2351
|
-
return publicationResult("ambiguous_remote_state", `amendment Parent is bound to Issue #${amendment.parent.issue_number} but observed Issue #${initialParent.issue.number}`);
|
|
2352
|
-
if (initialParent.issue.state !== "open")
|
|
2353
|
-
return publicationResult("ambiguous_remote_state", "an amendment requires the Initiative Parent to remain open");
|
|
2354
|
-
const topology = await validateAmendmentTopology(
|
|
2355
|
-
absoluteRoot, gh, initial, prepared.initiative.initiative_id, initialParent.issue,
|
|
2356
|
-
amendment.tasks, amendment.historical,
|
|
2357
|
-
prepared.foreign_dependers,
|
|
2358
|
-
{ pendingContent, parent, historicalSlices },
|
|
2359
|
-
prepared.order,
|
|
2811
|
+
// Ordinary publication and amendment share the direct write flow: neither
|
|
2812
|
+
// re-reads after a write.
|
|
2813
|
+
if (!amendment) return publishInitiativeDirect(absoluteRoot, gh, prepared, initial);
|
|
2814
|
+
|
|
2815
|
+
if (initialParent.kind !== "found")
|
|
2816
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
2817
|
+
// Caller-controlled binding constraints (missing or duplicate historical
|
|
2818
|
+
// Slice markers) must surface as structured fail-closed publication results,
|
|
2819
|
+
// never as thrown exceptions escaping the guarded validation boundary.
|
|
2820
|
+
let approvedFinalParent: ReturnType<typeof approvedAmendmentContent>;
|
|
2821
|
+
try {
|
|
2822
|
+
approvedFinalParent = approvedAmendmentContent(absoluteRoot, initial.repository, initialParent.issue, prepared, amendment);
|
|
2823
|
+
} catch (error) {
|
|
2824
|
+
return publicationResultWithPlan(
|
|
2825
|
+
plannedTaskIds,
|
|
2826
|
+
"permanent_failure",
|
|
2827
|
+
error instanceof Error ? error.message : String(error),
|
|
2360
2828
|
);
|
|
2361
|
-
if (!("get" in topology)) return publicationResult(topology.status, topology.message, topology);
|
|
2362
|
-
const historicalRelations = new Map<string, { blocked_by: number[]; state_reason: string | null }>();
|
|
2363
|
-
for (const [taskId] of amendment.historical) {
|
|
2364
|
-
const issue = topology.get(taskId)!;
|
|
2365
|
-
const observed = await readBlockedByIds(absoluteRoot, gh, "upsert-task", initial.repository, issue.number);
|
|
2366
|
-
if (!Array.isArray(observed)) return publicationResult(observed.status, observed.message, observed);
|
|
2367
|
-
historicalRelations.set(taskId, { blocked_by: observed, state_reason: issue.state_reason });
|
|
2368
|
-
}
|
|
2369
|
-
amendmentContext = {
|
|
2370
|
-
pendingContent,
|
|
2371
|
-
parent,
|
|
2372
|
-
parentIssueNumber: amendment.parent.issue_number,
|
|
2373
|
-
historicalSlices,
|
|
2374
|
-
historicalRelations,
|
|
2375
|
-
};
|
|
2376
|
-
}
|
|
2377
|
-
|
|
2378
|
-
const beforeParentWrite = publicationIntentDrift(absoluteRoot, prepared.intent_bindings);
|
|
2379
|
-
if (beforeParentWrite) return publicationResult("ambiguous_remote_state", beforeParentWrite);
|
|
2380
|
-
let parentResult: GithubTrackerResult;
|
|
2381
|
-
if (amendment && amendmentContext) {
|
|
2382
|
-
parentResult = await amendInitiativeParent(absoluteRoot, gh, prepared.initiative, initial, amendment.parent, amendmentContext);
|
|
2383
|
-
} else {
|
|
2384
|
-
parentResult = await runGithubTrackerOperation(absoluteRoot, prepared.initiative, gh);
|
|
2385
|
-
}
|
|
2386
|
-
if (!isSuccessfulTrackerStatus(parentResult.status))
|
|
2387
|
-
return publicationResult(parentResult.status, parentResult.message, parentResult);
|
|
2388
|
-
const taskResults: GithubInitiativePublicationResult["tasks"] = [];
|
|
2389
|
-
for (const operation of prepared.order) {
|
|
2390
|
-
const intentDrift = publicationIntentDrift(absoluteRoot, prepared.intent_bindings, [operation.task_id]);
|
|
2391
|
-
if (intentDrift) return publicationResult("ambiguous_remote_state", intentDrift, parentResult, taskResults);
|
|
2392
|
-
const taskResult = amendment
|
|
2393
|
-
? await runAmendmentTaskOperation(absoluteRoot, gh, operation, amendment.tasks.get(operation.task_id), amendmentContext)
|
|
2394
|
-
: await runGithubTrackerOperation(absoluteRoot, operation, gh);
|
|
2395
|
-
taskResults.push({
|
|
2396
|
-
task_id: operation.task_id,
|
|
2397
|
-
slice_id: operation.slice_id,
|
|
2398
|
-
status: taskResult.status,
|
|
2399
|
-
...(taskResult.issue_number === undefined ? {} : { issue_number: taskResult.issue_number }),
|
|
2400
|
-
...(taskResult.issue_url === undefined ? {} : { issue_url: taskResult.issue_url }),
|
|
2401
|
-
...(taskResult.node_id === undefined ? {} : { node_id: taskResult.node_id }),
|
|
2402
|
-
});
|
|
2403
|
-
if (!isSuccessfulTrackerStatus(taskResult.status))
|
|
2404
|
-
return publicationResult(taskResult.status, taskResult.message, parentResult, taskResults);
|
|
2405
|
-
}
|
|
2406
|
-
|
|
2407
|
-
const finalIntentDrift = publicationIntentDrift(absoluteRoot, prepared.intent_bindings);
|
|
2408
|
-
if (finalIntentDrift) return publicationResult("ambiguous_remote_state", finalIntentDrift, parentResult, taskResults);
|
|
2409
|
-
const finalSource = await snapshot(absoluteRoot, gh, "upsert-task");
|
|
2410
|
-
if ("contract" in finalSource) return publicationResult(finalSource.status, finalSource.message, parentResult, taskResults);
|
|
2411
|
-
if (amendment) {
|
|
2412
|
-
// Repeat complete observed-membership classification on the final snapshot:
|
|
2413
|
-
// an undeclared closed Task carrying this Initiative's markers that becomes
|
|
2414
|
-
// observable only after preflight (detached, so the Sub-issue parity check
|
|
2415
|
-
// cannot see it) must still fail the publication, not report success.
|
|
2416
|
-
const finalClassified = classifyObservedChildren(finalSource, prepared.initiative.initiative_id, amendment.tasks, amendment.historical);
|
|
2417
|
-
if (typeof finalClassified === "string") return publicationResult("ambiguous_remote_state", finalClassified, parentResult, taskResults);
|
|
2418
2829
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
: createInitiativeBody(finalSource.repository, prepared.initiative),
|
|
2429
|
-
"Initiative Parent",
|
|
2430
|
-
);
|
|
2431
|
-
if (parentDrift) return publicationResult("ambiguous_remote_state", parentDrift, parentResult, taskResults);
|
|
2432
|
-
if (amendmentContext) {
|
|
2433
|
-
// The rewritten Parent must carry each declared historical Slice marker
|
|
2434
|
-
// exactly once: generation drops or duplicates would break terminal
|
|
2435
|
-
// ownership without failing any other final check.
|
|
2436
|
-
for (const line of amendmentContext.historicalSlices) {
|
|
2437
|
-
const sliceId = ownershipMarkerValue(line, "slice-id");
|
|
2438
|
-
if (!sliceId || countLiteral(parent.issue.body, marker("slice-id", sliceId)) !== 1)
|
|
2439
|
-
return publicationResult("ambiguous_remote_state", `regenerated Parent does not carry historical Slice ${sliceId ?? "?"} exactly once`, parentResult, taskResults);
|
|
2440
|
-
}
|
|
2441
|
-
}
|
|
2442
|
-
const resultByTask = new Map(taskResults.map((task) => [task.task_id, task]));
|
|
2443
|
-
const expectedNumbers: number[] = [];
|
|
2830
|
+
// Slice identity collisions are deterministic input contract violations (the
|
|
2831
|
+
// batch itself declares two Children with one Slice id), not remote drift.
|
|
2832
|
+
if (typeof approvedFinalParent === "string" && approvedFinalParent.includes("collides with a historical Task Slice"))
|
|
2833
|
+
return publicationResultWithPlan(plannedTaskIds, "permanent_failure", approvedFinalParent);
|
|
2834
|
+
if (typeof approvedFinalParent === "string") return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", approvedFinalParent);
|
|
2835
|
+
const { parent, pendingContent, historicalSlices } = approvedFinalParent;
|
|
2836
|
+
// Deterministic prerequisite completion is validated before any write: a
|
|
2837
|
+
// historical prerequisite already closed without state_reason "completed"
|
|
2838
|
+
// (e.g. not_planned) makes the batch permanently unfulfillable.
|
|
2444
2839
|
for (const operation of prepared.order) {
|
|
2445
|
-
const child = ownedTaskLookup(finalSource.issues, finalSource.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
2446
|
-
if (child.kind !== "found") return publicationResult("ambiguous_remote_state", child.kind === "ambiguous" ? child.message : `published Task ${operation.task_id} disappeared`, parentResult, taskResults);
|
|
2447
|
-
const childResult = resultByTask.get(operation.task_id);
|
|
2448
|
-
if (!childResult) return publicationResult("ambiguous_remote_state", `published Task ${operation.task_id} has no batch result`, parentResult, taskResults);
|
|
2449
|
-
const childDrift = publicationIssueDrift(
|
|
2450
|
-
childResult,
|
|
2451
|
-
child.issue,
|
|
2452
|
-
taskIssueTitle(operation, sliceOrdinalFromChecklist(parent.issue.body, operation.slice_id, operation.projection?.slice_ordinal ?? 1)),
|
|
2453
|
-
childBody(finalSource.repository, operation, parent.issue),
|
|
2454
|
-
`Task ${operation.task_id}`,
|
|
2455
|
-
{ allowTerminalSuffix: amendmentContext !== undefined },
|
|
2456
|
-
);
|
|
2457
|
-
if (childDrift) return publicationResult("ambiguous_remote_state", childDrift, parentResult, taskResults);
|
|
2458
|
-
const expectedLabels = desiredTaskLabels(operation);
|
|
2459
|
-
if (childResult.status !== "already_current" && expectedLabels.some((label) => !child.issue.labels.includes(label)))
|
|
2460
|
-
return publicationResult("ambiguous_remote_state", `Task ${operation.task_id} is missing publication labels after publication`, parentResult, taskResults);
|
|
2461
|
-
expectedNumbers.push(child.issue.number);
|
|
2462
|
-
const ownership = await confirmTerminalOwnership(absoluteRoot, gh, "upsert-task", finalSource, child.issue);
|
|
2463
|
-
if (!("owned" in ownership)) return publicationResult(ownership.status, ownership.message, parentResult, taskResults);
|
|
2464
|
-
const blockers: GithubIssue[] = [];
|
|
2465
2840
|
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2841
|
+
if (!amendment.historical.has(blockerId)) continue;
|
|
2842
|
+
const blocker = taskLookup(initial.issues, initial.repository.id, blockerId);
|
|
2843
|
+
if (blocker.kind === "found" && (blocker.issue.state !== "closed" || blocker.issue.state_reason !== "completed"))
|
|
2844
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `Task ${operation.task_id} depends on stopped historical prerequisite ${blockerId}`);
|
|
2469
2845
|
}
|
|
2470
|
-
const dependencies = await confirmBlockedBy(absoluteRoot, gh, "upsert-task", finalSource.repository, child.issue.number, blockers);
|
|
2471
|
-
if (!("complete" in dependencies)) return publicationResult(dependencies.status, dependencies.message, parentResult, taskResults);
|
|
2472
|
-
if (!dependencies.complete) return publicationResult("ambiguous_remote_state", `Task ${operation.task_id} has incomplete blocking relations`, parentResult, taskResults);
|
|
2473
2846
|
}
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
// one Slice identity, including historical Children whose Parent lines are
|
|
2491
|
-
// carried over rather than re-rendered.
|
|
2492
|
-
const historicalSliceId = ownershipMarkerValue(historical.body, "slice-id");
|
|
2493
|
-
if (historicalSliceId && amendedFinalSliceIds.has(historicalSliceId))
|
|
2494
|
-
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} Slice ${historicalSliceId} collides with a pending Task Slice of the same id`, parentResult, taskResults);
|
|
2495
|
-
const snapshotRelations = amendmentContext?.historicalRelations.get(taskId);
|
|
2496
|
-
if (snapshotRelations) {
|
|
2497
|
-
if (historical.state_reason !== snapshotRelations.state_reason)
|
|
2498
|
-
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} terminal state_reason changed during amendment`, parentResult, taskResults);
|
|
2499
|
-
const finalBlockedBy = await readBlockedByIds(absoluteRoot, gh, "upsert-task", finalSource.repository, historical.number);
|
|
2500
|
-
if (!Array.isArray(finalBlockedBy)) return publicationResult(finalBlockedBy.status, finalBlockedBy.message, parentResult, taskResults);
|
|
2501
|
-
if (finalBlockedBy.length !== snapshotRelations.blocked_by.length || finalBlockedBy.some((id, index) => id !== snapshotRelations.blocked_by[index]))
|
|
2502
|
-
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} native blocked_by relations changed during amendment`, parentResult, taskResults);
|
|
2503
|
-
}
|
|
2504
|
-
}
|
|
2505
|
-
const attachedNumbers = new Set(attached);
|
|
2506
|
-
for (const number of attachedNumbers) {
|
|
2507
|
-
const issue = finalSource.issues.find((candidate) => candidate.number === number);
|
|
2508
|
-
const taskId = issue ? ownershipMarkerValue(issue.body, "task-id") : null;
|
|
2509
|
-
if (!taskId || (!amendment.tasks.has(taskId) && !amendment.historical.has(taskId)))
|
|
2510
|
-
return publicationResult("ambiguous_remote_state", `Parent Sub-issue #${number} is not part of the declared amendment membership`, parentResult, taskResults);
|
|
2511
|
-
}
|
|
2512
|
-
for (const operation of prepared.order) {
|
|
2513
|
-
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2514
|
-
if (amendment.historical.has(blockerId)) {
|
|
2515
|
-
const blocker = taskLookup(finalSource.issues, finalSource.repository.id, blockerId);
|
|
2516
|
-
if (blocker.kind !== "found" || blocker.issue.state_reason !== "completed")
|
|
2517
|
-
return publicationResult("ambiguous_remote_state", `Task ${operation.task_id} depends on stopped historical prerequisite ${blockerId}`, parentResult, taskResults);
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
const sortedAttached = [...attached].sort((left, right) => left - right);
|
|
2523
|
-
const sortedExpected = [...expectedNumbers, ...(amendment ? [...amendment.historical.values()].map((binding) => binding.issue_number) : [])].sort((left, right) => left - right);
|
|
2524
|
-
if (sortedAttached.length !== sortedExpected.length || sortedAttached.some((number, index) => number !== sortedExpected[index]))
|
|
2525
|
-
return publicationResult("ambiguous_remote_state", "Initiative Parent Sub-issues do not match the complete publication batch", parentResult, taskResults);
|
|
2526
|
-
const statuses = [parentResult.status, ...taskResults.map((task) => task.status)];
|
|
2527
|
-
const status: TrackerStatus = statuses.every((item) => item === "created")
|
|
2528
|
-
? "created"
|
|
2529
|
-
: statuses.every((item) => item === "already_current") ? "already_current" : "updated";
|
|
2530
|
-
const issueByTask = new Map(taskResults.map((task) => [task.task_id, task.issue_number]));
|
|
2531
|
-
if ([...issueByTask.values()].some((number) => number === undefined))
|
|
2532
|
-
return publicationResult("ambiguous_remote_state", "published Task result is missing an Issue number", parentResult, taskResults);
|
|
2533
|
-
const issueNumber = (taskId: string): number => issueByTask.get(taskId)!;
|
|
2534
|
-
const firstTaskId = prepared.order[0].task_id;
|
|
2535
|
-
return publicationResult(status, "complete Initiative Parent, Children, and dependency graph published", parentResult, taskResults, {
|
|
2536
|
-
recommended_first_task_id: firstTaskId,
|
|
2537
|
-
recommended_first_issue_number: issueNumber(firstTaskId),
|
|
2538
|
-
order: prepared.order.map((operation) => operation.task_id),
|
|
2539
|
-
issue_order: prepared.order.map((operation) => issueNumber(operation.task_id)),
|
|
2540
|
-
parallel_groups: prepared.parallel_groups,
|
|
2541
|
-
parallel_issue_groups: prepared.parallel_groups.map((group) => group.map(issueNumber)),
|
|
2542
|
-
});
|
|
2847
|
+
// The approved baseline is the only accepted starting point. Every fact here
|
|
2848
|
+
// comes from the start listing, before any write.
|
|
2849
|
+
const parentBaseline = amendment.parent.body !== initialParent.issue.body || amendment.parent.title !== initialParent.issue.title;
|
|
2850
|
+
const parentApproved = initialParent.issue.title === parent.title && initialParent.issue.body === parent.body;
|
|
2851
|
+
if (parentBaseline && !parentApproved)
|
|
2852
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "amendment Parent changed since the approved amendment baseline");
|
|
2853
|
+
if (amendment.parent.issue_number !== initialParent.issue.number)
|
|
2854
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", `amendment Parent is bound to Issue #${amendment.parent.issue_number} but observed Issue #${initialParent.issue.number}`);
|
|
2855
|
+
if (initialParent.issue.state !== "open")
|
|
2856
|
+
return publicationResultWithPlan(plannedTaskIds, "ambiguous_remote_state", "an amendment requires the Initiative Parent to remain open");
|
|
2857
|
+
const amendmentContextValue: AmendmentExecutionContext = {
|
|
2858
|
+
pendingContent,
|
|
2859
|
+
parent,
|
|
2860
|
+
historicalSlices,
|
|
2861
|
+
};
|
|
2862
|
+
return publishAmendmentDirect(absoluteRoot, gh, prepared, initial, amendment, amendmentContextValue);
|
|
2543
2863
|
}
|
|
2544
2864
|
|
|
2865
|
+
|
|
2545
2866
|
function isSuccessfulTrackerStatus(status: TrackerStatus): boolean {
|
|
2546
2867
|
return status === "created" || status === "updated" || status === "already_current";
|
|
2547
2868
|
}
|
|
@@ -2602,6 +2923,7 @@ function taskPublication(
|
|
|
2602
2923
|
risk: intent.risk,
|
|
2603
2924
|
acceptance: intent.acceptance.map((item) => publicById.get(item.id)!),
|
|
2604
2925
|
projection: stampedProjection,
|
|
2926
|
+
intent_hash: read.content_hash,
|
|
2605
2927
|
}) as Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
2606
2928
|
intent_path: read.intent_ref.path,
|
|
2607
2929
|
intent_content_hash: read.content_hash,
|