immune-brain 4.6.0 → 5.1.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 +374 -46
- package/README.zh-CN.md +384 -47
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -44
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +143 -474
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +78 -87
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
- package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
- package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
- package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
- package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
- package/plugins/immune-brain/dist/BASELINE.md +4 -4
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8601 -6975
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +27 -0
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
- package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
- package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
- package/plugins/immune-brain/dist/imm-planner.md +23 -8
- package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
- package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
- package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +231 -10
- package/plugins/immune-brain/dist/registry.yaml +9 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
- package/plugins/immune-brain/dist/role-prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +83 -67
- package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
- package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +6 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +78 -373
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +26 -2
- package/plugins/immune-brain/runtime/claude/review_host.ts +48 -16
- package/plugins/immune-brain/runtime/claude/role_agents.ts +84 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
- package/plugins/immune-brain/runtime/kernel/application.ts +6 -3
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +5 -3
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +149 -5
- package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
- package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
- package/plugins/immune-brain/runtime/local_initiative.ts +94 -0
- package/plugins/immune-brain/runtime/loop_contract.ts +77 -24
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
- package/plugins/immune-brain/runtime/prompts/lane-steward.md +44 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +42 -2
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +57 -27
- package/plugins/immune-brain/runtime/unattended/batch_integration.ts +387 -0
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +909 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +36 -11
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +53 -36
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +34 -29
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +46 -91
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +162 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +356 -8
- package/plugins/immune-brain/runtime/unattended/types.ts +21 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +34 -0
- package/plugins/immune-brain/skills/BASELINE.md +4 -4
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
- package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
- package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +11 -7
- package/plugins/immune-brain/skills/registry.yaml +9 -9
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -581
|
@@ -24,18 +24,18 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
|
|
|
24
24
|
import type { BatchPlanChild } from "./types";
|
|
25
25
|
import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
|
|
26
26
|
import {
|
|
27
|
+
createDefaultBatchGitPort,
|
|
27
28
|
type BatchRunnerGitPort,
|
|
28
|
-
type BatchGitPreflightResult,
|
|
29
|
-
runBatchGitPreflight,
|
|
30
|
-
commitBatchChild,
|
|
31
|
-
lookupBatchCommit,
|
|
32
29
|
} from "./batch_git";
|
|
33
30
|
import { classifyBatchLineage, expectedBatchHead } from "./batch_preflight";
|
|
31
|
+
import { laneRejectionReport, runLaneBatch, type LaneOffer } from "./batch_lanes";
|
|
34
32
|
import {
|
|
35
33
|
type BatchRunStateRecord,
|
|
36
34
|
type BatchChildRun,
|
|
37
35
|
type BatchRunReport,
|
|
36
|
+
isLaneBatchRecord,
|
|
38
37
|
isTerminalBatchState,
|
|
38
|
+
readAnyBatchRunState,
|
|
39
39
|
prepareBatchRunState,
|
|
40
40
|
readBatchRunState,
|
|
41
41
|
writeBatchRunState,
|
|
@@ -43,7 +43,9 @@ import {
|
|
|
43
43
|
writeBatchRunReport,
|
|
44
44
|
} from "./batch_state";
|
|
45
45
|
|
|
46
|
-
/** Kernel-facing ports the driver needs. Host adapters supply these.
|
|
46
|
+
/** Kernel-facing ports the driver needs. Host adapters supply these. Git
|
|
47
|
+
* operations are not Kernel-port members: the runner drives them through
|
|
48
|
+
* BatchRunnerGitPort alone (D5 of docs/specs/deepen-authority-seams.spec.md). */
|
|
47
49
|
export interface BatchRunnerKernelPort {
|
|
48
50
|
/** Enroll one child through the batch-derived capability. */
|
|
49
51
|
enrollTask(input: {
|
|
@@ -57,31 +59,6 @@ export interface BatchRunnerKernelPort {
|
|
|
57
59
|
}): Promise<{ record_revision: string }>;
|
|
58
60
|
/** Drive one enrolled child toward its own Kernel terminal settlement. */
|
|
59
61
|
advanceTask(root: string, taskId: string): Promise<BatchChildAdvanceResult>;
|
|
60
|
-
/** Scope-bound commit after Kernel reports a child done. */
|
|
61
|
-
commitChild?(
|
|
62
|
-
root: string,
|
|
63
|
-
taskId: string,
|
|
64
|
-
batchId: string,
|
|
65
|
-
head: string,
|
|
66
|
-
branch?: string,
|
|
67
|
-
intentPath?: string,
|
|
68
|
-
): Promise<{ commit: string }>;
|
|
69
|
-
/** review-3(5th round): read the already-created batch commit for a child,
|
|
70
|
-
* or null when none exists. Lets crash recovery adopt an existing commit
|
|
71
|
-
* instead of replaying the commitChild mutation. */
|
|
72
|
-
lookupBatchCommit?(
|
|
73
|
-
root: string,
|
|
74
|
-
taskId: string,
|
|
75
|
-
batchId: string,
|
|
76
|
-
expectedHead?: string,
|
|
77
|
-
branch?: string,
|
|
78
|
-
): Promise<{ commit: string } | null>;
|
|
79
|
-
/** Optional Git preflight check. */
|
|
80
|
-
gitPreflight?(input: {
|
|
81
|
-
root: string;
|
|
82
|
-
initiative_slug: string;
|
|
83
|
-
base_head: string;
|
|
84
|
-
}): Promise<BatchGitPreflightResult> | BatchGitPreflightResult;
|
|
85
62
|
/** review-2(5th round): read-only claim projection for enrollment
|
|
86
63
|
* reconciliation after an interruption between enrollTask and its state
|
|
87
64
|
* persistence. Uses the real AssuranceProjectionResult contract; batch
|
|
@@ -127,6 +104,16 @@ export interface StartBatchInput {
|
|
|
127
104
|
now: string;
|
|
128
105
|
kernel: BatchRunnerKernelPort;
|
|
129
106
|
git?: BatchRunnerGitPort;
|
|
107
|
+
/** Present only in lane mode: how many children may run at once. */
|
|
108
|
+
max_parallel?: number;
|
|
109
|
+
/** Present only in lane mode: Lanes someone else created, offered to the batch. */
|
|
110
|
+
lane_offers?: LaneOffer[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One git seam: the injected BatchRunnerGitPort, or the default adapter over
|
|
114
|
+
* the real repository operations when the Host supplies none. */
|
|
115
|
+
function batchGitPortOf(input: StartBatchInput): BatchRunnerGitPort {
|
|
116
|
+
return input.git ?? createDefaultBatchGitPort();
|
|
130
117
|
}
|
|
131
118
|
|
|
132
119
|
export type { BatchRunReport } from "./batch_state";
|
|
@@ -340,16 +327,10 @@ async function validatePersistedRun(input: StartBatchInput, record: BatchRunStat
|
|
|
340
327
|
if (child.commit !== null) throw new Error("uncommitted batch child has a commit");
|
|
341
328
|
continue;
|
|
342
329
|
}
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
: await lookupBatchCommit({
|
|
348
|
-
root: input.root,
|
|
349
|
-
taskId: child.task_id,
|
|
350
|
-
batchId: record.batch_id,
|
|
351
|
-
branch: record.branch,
|
|
352
|
-
});
|
|
330
|
+
const git = batchGitPortOf(input);
|
|
331
|
+
const evidence = await git.lookupBatchCommit(
|
|
332
|
+
input.root, child.task_id, record.batch_id, undefined, record.branch,
|
|
333
|
+
);
|
|
353
334
|
if (!evidence || evidence.commit !== child.commit) {
|
|
354
335
|
// Distinguish an unreachable recorded commit (external HEAD regression)
|
|
355
336
|
// from a fabricated record: reachability is only checkable in a real repo.
|
|
@@ -461,11 +442,17 @@ export async function startBatch(input: StartBatchInput): Promise<BatchRunReport
|
|
|
461
442
|
function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport {
|
|
462
443
|
const persisted = (() => {
|
|
463
444
|
try {
|
|
464
|
-
return
|
|
445
|
+
return readAnyBatchRunState(input.root, input.batch_id);
|
|
465
446
|
} catch {
|
|
466
447
|
return null;
|
|
467
448
|
}
|
|
468
449
|
})();
|
|
450
|
+
if (persisted !== null && isLaneBatchRecord(persisted))
|
|
451
|
+
return laneRejectionReport(
|
|
452
|
+
persisted,
|
|
453
|
+
reason,
|
|
454
|
+
"settle the reported kernel store condition and retry in the current Host",
|
|
455
|
+
) as unknown as BatchRunReport;
|
|
469
456
|
return reportFor(
|
|
470
457
|
{
|
|
471
458
|
...(persisted ?? prepareBatchRunState({ ...input, children: [], now: input.now })),
|
|
@@ -477,6 +464,12 @@ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport
|
|
|
477
464
|
}
|
|
478
465
|
|
|
479
466
|
async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport> {
|
|
467
|
+
// Lane mode is selected by `max_parallel` or by a recorded v2 batch; a batch
|
|
468
|
+
// given neither keeps the serial path below unchanged.
|
|
469
|
+
const recorded = readAnyBatchRunState(input.root, input.batch_id);
|
|
470
|
+
if (input.max_parallel !== undefined || (recorded !== null && isLaneBatchRecord(recorded)))
|
|
471
|
+
return (await runLaneBatch(input, recorded)) as unknown as BatchRunReport;
|
|
472
|
+
|
|
480
473
|
// Validation failure before the first enrollment: zero writes, rejected.
|
|
481
474
|
// reportFor only builds the report object; finalize would persist it and
|
|
482
475
|
// the spec forbids any write on a pre-enrollment rejection.
|
|
@@ -620,23 +613,11 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
|
|
|
620
613
|
}
|
|
621
614
|
|
|
622
615
|
// Mandatory batch branch preflight: run before any child is enrolled.
|
|
623
|
-
const preflightResult = input.
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
})
|
|
629
|
-
: input.kernel.gitPreflight
|
|
630
|
-
? await input.kernel.gitPreflight({
|
|
631
|
-
root: input.root,
|
|
632
|
-
initiative_slug: input.initiative_slug,
|
|
633
|
-
base_head: input.base_head,
|
|
634
|
-
})
|
|
635
|
-
: runBatchGitPreflight({
|
|
636
|
-
root: input.root,
|
|
637
|
-
initiative_slug: input.initiative_slug,
|
|
638
|
-
base_head: input.base_head,
|
|
639
|
-
});
|
|
616
|
+
const preflightResult = await batchGitPortOf(input).preflight({
|
|
617
|
+
root: input.root,
|
|
618
|
+
initiative_slug: input.initiative_slug,
|
|
619
|
+
base_head: input.base_head,
|
|
620
|
+
});
|
|
640
621
|
|
|
641
622
|
if (!preflightResult.ok) {
|
|
642
623
|
const rejected = prepareBatchRunState({ ...input, now: input.now });
|
|
@@ -1027,23 +1008,9 @@ async function driveInterruptedChild(
|
|
|
1027
1008
|
// review-1(6th round): a lookup failure must fail closed, not fall
|
|
1028
1009
|
// through to commitChild, which could replay an existing commit.
|
|
1029
1010
|
try {
|
|
1030
|
-
existing = input.
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
? await input.kernel.lookupBatchCommit(
|
|
1034
|
-
input.root,
|
|
1035
|
-
child.task_id,
|
|
1036
|
-
input.batch_id,
|
|
1037
|
-
head,
|
|
1038
|
-
record.branch,
|
|
1039
|
-
)
|
|
1040
|
-
: await lookupBatchCommit({
|
|
1041
|
-
root: input.root,
|
|
1042
|
-
taskId: child.task_id,
|
|
1043
|
-
batchId: input.batch_id,
|
|
1044
|
-
expectedHead: head,
|
|
1045
|
-
branch: record.branch,
|
|
1046
|
-
});
|
|
1011
|
+
existing = await batchGitPortOf(input).lookupBatchCommit(
|
|
1012
|
+
input.root, child.task_id, input.batch_id, head, record.branch,
|
|
1013
|
+
);
|
|
1047
1014
|
} catch (error: unknown) {
|
|
1048
1015
|
const message =
|
|
1049
1016
|
error instanceof Error ? error.message : String(error);
|
|
@@ -1075,22 +1042,10 @@ async function driveInterruptedChild(
|
|
|
1075
1042
|
}
|
|
1076
1043
|
const planChild = input.children.find((c) => c.task_id === child.task_id);
|
|
1077
1044
|
const intentPath = planChild?.intent_path ?? undefined;
|
|
1078
|
-
const doCommit = async () =>
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
if (input.kernel.commitChild) {
|
|
1083
|
-
return input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath);
|
|
1084
|
-
}
|
|
1085
|
-
return commitBatchChild({
|
|
1086
|
-
root: input.root,
|
|
1087
|
-
taskId: child.task_id,
|
|
1088
|
-
batchId: input.batch_id,
|
|
1089
|
-
expectedHead: head,
|
|
1090
|
-
branch: record.branch,
|
|
1091
|
-
intentPath,
|
|
1092
|
-
});
|
|
1093
|
-
};
|
|
1045
|
+
const doCommit = async () =>
|
|
1046
|
+
batchGitPortOf(input).commitChild(
|
|
1047
|
+
input.root, child.task_id, input.batch_id, head, record.branch, intentPath,
|
|
1048
|
+
);
|
|
1094
1049
|
if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
|
|
1095
1050
|
throw new Error("first unpersisted batch commit provenance is invalid");
|
|
1096
1051
|
const adopted =
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Pure scheduling for unattended batches. No I/O, no persisted state, no Host
|
|
2
|
+
// concept: given child states, dependency edges, scope hints and a parallelism
|
|
3
|
+
// limit it answers which children may start now, and which children of a plan
|
|
4
|
+
// serialize because their scopes cannot be proved disjoint.
|
|
5
|
+
import type { BatchPlanParallelGroups, BatchPlanScopeConflict } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Child states the scheduler understands. The serial record (v1) uses
|
|
9
|
+
* `committed`; the lane record names the same milestone `integrated`/`released`
|
|
10
|
+
* and adds the in-flight lane states. Listing both keeps this function usable
|
|
11
|
+
* for either record without a persisted-state change.
|
|
12
|
+
*/
|
|
13
|
+
export type ScheduleChildState =
|
|
14
|
+
| "pending"
|
|
15
|
+
| "lane_admitted"
|
|
16
|
+
| "enrolled"
|
|
17
|
+
| "settled"
|
|
18
|
+
| "lane_committed"
|
|
19
|
+
| "committed"
|
|
20
|
+
| "integrated"
|
|
21
|
+
| "released"
|
|
22
|
+
| "needs_human"
|
|
23
|
+
| "skipped_blocked";
|
|
24
|
+
|
|
25
|
+
const IN_FLIGHT_STATES: ReadonlySet<ScheduleChildState> = new Set([
|
|
26
|
+
"lane_admitted",
|
|
27
|
+
"enrolled",
|
|
28
|
+
"settled",
|
|
29
|
+
"lane_committed",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const DONE_STATES: ReadonlySet<ScheduleChildState> = new Set(["committed", "integrated", "released"]);
|
|
33
|
+
|
|
34
|
+
export interface ScheduleChild {
|
|
35
|
+
task_id: string;
|
|
36
|
+
state: ScheduleChildState;
|
|
37
|
+
blocked_by: string[];
|
|
38
|
+
scope_hint: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const WILDCARD = /[*?[\]{}!]/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reduce one scope entry to the directory prefix it is certain to live under.
|
|
45
|
+
* A literal path keeps all its segments. A pattern keeps only the whole
|
|
46
|
+
* segments before its first wildcard: `src/foo*` could match `src/foobar/x`, so
|
|
47
|
+
* the partial segment `foo` is not a safe prefix. An empty result means the
|
|
48
|
+
* entry can match anywhere. Anything that is not a plain repository-relative
|
|
49
|
+
* path is also treated as matching anywhere, because disjointness is only ever
|
|
50
|
+
* claimed for entries that can be proved.
|
|
51
|
+
*/
|
|
52
|
+
function scopePrefix(entry: string): string[] {
|
|
53
|
+
if (typeof entry !== "string") return [];
|
|
54
|
+
let value = entry.trim().toLowerCase();
|
|
55
|
+
while (value.startsWith("./")) value = value.slice(2);
|
|
56
|
+
if (!value || value.startsWith("/") || value.includes("\\")) return [];
|
|
57
|
+
const wildcard = value.search(WILDCARD);
|
|
58
|
+
const literal = wildcard === -1 ? value : value.slice(0, value.lastIndexOf("/", wildcard) + 1);
|
|
59
|
+
const segments = literal.split("/").filter((segment) => segment.length > 0);
|
|
60
|
+
if (segments.some((segment) => segment === "." || segment === "..")) return [];
|
|
61
|
+
return segments;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function prefixOverlaps(left: string[], right: string[]): boolean {
|
|
65
|
+
if (!left.length || !right.length) return true;
|
|
66
|
+
const shared = Math.min(left.length, right.length);
|
|
67
|
+
for (let index = 0; index < shared; index += 1) if (left[index] !== right[index]) return false;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Two scope lists overlap unless every pair of entries is provably disjoint:
|
|
73
|
+
* neither entry's directory prefix equals or contains the other. Identical,
|
|
74
|
+
* nested, and wildcard scopes therefore overlap; an empty list proves nothing
|
|
75
|
+
* about the child and overlaps everything.
|
|
76
|
+
*/
|
|
77
|
+
export function scopesOverlap(left: readonly string[], right: readonly string[]): boolean {
|
|
78
|
+
if (!left.length || !right.length) return true;
|
|
79
|
+
const rightPrefixes = right.map(scopePrefix);
|
|
80
|
+
return left.some((entry) => {
|
|
81
|
+
const prefix = scopePrefix(entry);
|
|
82
|
+
return rightPrefixes.some((other) => prefixOverlaps(prefix, other));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function positiveLimit(maxParallel: number): number {
|
|
87
|
+
if (maxParallel === Number.POSITIVE_INFINITY) return maxParallel;
|
|
88
|
+
if (!Number.isSafeInteger(maxParallel) || maxParallel <= 0)
|
|
89
|
+
throw new Error("max_parallel must be a positive safe integer");
|
|
90
|
+
return maxParallel;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The children that may start now, in record order. A pending child starts when
|
|
95
|
+
* every dependency is done, its scope is disjoint from every in-flight child and
|
|
96
|
+
* from every child already chosen in this call, and the in-flight count stays
|
|
97
|
+
* below `maxParallel`. With `maxParallel` 1 and nothing in flight this is
|
|
98
|
+
* exactly the serial runner's choice: the first pending child whose
|
|
99
|
+
* dependencies are done.
|
|
100
|
+
*/
|
|
101
|
+
export function startableChildren(children: readonly ScheduleChild[], maxParallel: number): string[] {
|
|
102
|
+
const limit = positiveLimit(maxParallel);
|
|
103
|
+
const done = new Set(children.filter((child) => DONE_STATES.has(child.state)).map((child) => child.task_id));
|
|
104
|
+
const occupied = children.filter((child) => IN_FLIGHT_STATES.has(child.state));
|
|
105
|
+
const startable: string[] = [];
|
|
106
|
+
for (const child of children) {
|
|
107
|
+
if (occupied.length >= limit) break;
|
|
108
|
+
if (child.state !== "pending") continue;
|
|
109
|
+
if (!child.blocked_by.every((id) => done.has(id))) continue;
|
|
110
|
+
if (occupied.some((other) => scopesOverlap(child.scope_hint, other.scope_hint))) continue;
|
|
111
|
+
startable.push(child.task_id);
|
|
112
|
+
occupied.push(child);
|
|
113
|
+
}
|
|
114
|
+
return startable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PlanScheduleChild {
|
|
118
|
+
task_id: string;
|
|
119
|
+
blocked_by: string[];
|
|
120
|
+
scope_hint: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Project a plan's enrollable children into start waves. Each wave is the
|
|
125
|
+
* largest set that is ready (every dependency in an earlier wave) and pairwise
|
|
126
|
+
* scope-disjoint, chosen in plan order. A child that was ready but left out of
|
|
127
|
+
* a wave because its scope overlaps a member of that wave is reported once with
|
|
128
|
+
* every child it overlapped, so a confirmation can say why it serializes.
|
|
129
|
+
*/
|
|
130
|
+
export function projectParallelGroups(children: readonly PlanScheduleChild[]): {
|
|
131
|
+
parallel_groups: BatchPlanParallelGroups;
|
|
132
|
+
scope_conflicts: BatchPlanScopeConflict[];
|
|
133
|
+
} {
|
|
134
|
+
const remaining = [...children];
|
|
135
|
+
const placed = new Set<string>();
|
|
136
|
+
const groups: BatchPlanParallelGroups = [];
|
|
137
|
+
const conflicts = new Map<string, Set<string>>();
|
|
138
|
+
while (remaining.length) {
|
|
139
|
+
const ready = remaining.filter((child) => child.blocked_by.every((id) => placed.has(id)));
|
|
140
|
+
if (!ready.length) throw new Error("Initiative Task dependencies must form an acyclic graph");
|
|
141
|
+
const wave: PlanScheduleChild[] = [];
|
|
142
|
+
for (const child of ready) {
|
|
143
|
+
const overlapped = wave.filter((member) => scopesOverlap(child.scope_hint, member.scope_hint));
|
|
144
|
+
if (overlapped.length) {
|
|
145
|
+
const known = conflicts.get(child.task_id) ?? new Set<string>();
|
|
146
|
+
for (const member of overlapped) known.add(member.task_id);
|
|
147
|
+
conflicts.set(child.task_id, known);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
wave.push(child);
|
|
151
|
+
}
|
|
152
|
+
groups.push(wave.map((child) => child.task_id));
|
|
153
|
+
for (const child of wave) {
|
|
154
|
+
placed.add(child.task_id);
|
|
155
|
+
remaining.splice(remaining.indexOf(child), 1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const scopeConflicts = children
|
|
159
|
+
.filter((child) => conflicts.has(child.task_id))
|
|
160
|
+
.map((child) => ({ task_id: child.task_id, overlaps_with: [...conflicts.get(child.task_id)!].sort() }));
|
|
161
|
+
return { parallel_groups: groups, scope_conflicts: scopeConflicts };
|
|
162
|
+
}
|