@ordewell/core 0.6.0 → 0.6.1
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/dist/{IFileSystem-D4dAl2r8.d.ts → IFileSystem-DuEiG_kA.d.ts} +1 -1
- package/dist/{IFileSystem-CFYf4yR2.d.mts → IFileSystem-d_SV7jAH.d.mts} +1 -1
- package/dist/{ModeResolver-a4xJFlEp.d.mts → ModeResolver-DIReYAOq.d.mts} +1 -1
- package/dist/{ModeResolver-DVYVRbJp.d.ts → ModeResolver-DXxV4n_V.d.ts} +1 -1
- package/dist/{Task-CZJr6rmz.d.mts → Task-ib_U0YP7.d.mts} +56 -5
- package/dist/{Task-CZJr6rmz.d.ts → Task-ib_U0YP7.d.ts} +56 -5
- package/dist/{chunk-2SOVKO7P.mjs → chunk-I6U3PVHL.mjs} +39 -16
- package/dist/chunk-I6U3PVHL.mjs.map +1 -0
- package/dist/{chunk-EDGUFCIR.mjs → chunk-JRZPSNRO.mjs} +7 -3
- package/dist/chunk-JRZPSNRO.mjs.map +1 -0
- package/dist/{chunk-OM4D274O.mjs → chunk-M4KXBEFJ.mjs} +5 -3
- package/dist/chunk-M4KXBEFJ.mjs.map +1 -0
- package/dist/{chunk-C44UWIAD.mjs → chunk-N66ZHFGN.mjs} +2 -2
- package/dist/chunk-N66ZHFGN.mjs.map +1 -0
- package/dist/index.d.mts +129 -33
- package/dist/index.d.ts +129 -33
- package/dist/index.js +445 -74
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +402 -60
- package/dist/index.mjs.map +1 -1
- package/dist/order-labels.d.mts +1 -1
- package/dist/order-labels.d.ts +1 -1
- package/dist/{parsing-Bd6-b644.d.ts → parsing-C8Z22twN.d.ts} +2 -2
- package/dist/{parsing-DIaGZmiQ.d.mts → parsing-DjRJJX-y.d.mts} +2 -2
- package/dist/parsing.d.mts +3 -3
- package/dist/parsing.d.ts +3 -3
- package/dist/parsing.js +5 -2
- package/dist/parsing.js.map +1 -1
- package/dist/parsing.mjs +2 -2
- package/dist/{plan-utils-Ba-l8EZS.d.mts → plan-utils-D4ioaM8q.d.mts} +25 -4
- package/dist/{plan-utils-_T5S7Mnk.d.ts → plan-utils-DvHPzAfV.d.ts} +25 -4
- package/dist/plan-utils.d.mts +3 -3
- package/dist/plan-utils.d.ts +3 -3
- package/dist/plan-utils.js.map +1 -1
- package/dist/plan-utils.mjs +2 -2
- package/dist/testing.d.mts +14 -5
- package/dist/testing.d.ts +14 -5
- package/dist/testing.js +30 -3
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +30 -3
- package/dist/testing.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-2SOVKO7P.mjs.map +0 -1
- package/dist/chunk-C44UWIAD.mjs.map +0 -1
- package/dist/chunk-EDGUFCIR.mjs.map +0 -1
- package/dist/chunk-OM4D274O.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
resolveTaskMode,
|
|
16
16
|
runnerModesFrom,
|
|
17
17
|
validatePlanModification
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-M4KXBEFJ.mjs";
|
|
19
19
|
import {
|
|
20
20
|
CHECKPOINT_TRUNCATE_LENGTH,
|
|
21
21
|
EMPTY_CONVERSATION,
|
|
@@ -76,7 +76,7 @@ import {
|
|
|
76
76
|
unsendLatest,
|
|
77
77
|
usageLine,
|
|
78
78
|
validateTaskEdit
|
|
79
|
-
} from "./chunk-
|
|
79
|
+
} from "./chunk-I6U3PVHL.mjs";
|
|
80
80
|
import {
|
|
81
81
|
PLAN_ENVELOPE_KEY,
|
|
82
82
|
PlanParseError,
|
|
@@ -105,7 +105,7 @@ import {
|
|
|
105
105
|
updateTaskInPlan,
|
|
106
106
|
validateModifiedPlan,
|
|
107
107
|
warningsText
|
|
108
|
-
} from "./chunk-
|
|
108
|
+
} from "./chunk-JRZPSNRO.mjs";
|
|
109
109
|
import {
|
|
110
110
|
resolveOrderLabel,
|
|
111
111
|
taskOrderLabel
|
|
@@ -121,7 +121,7 @@ import {
|
|
|
121
121
|
migratePlanStateIsolation,
|
|
122
122
|
repoRootOf,
|
|
123
123
|
taskIsolationOf
|
|
124
|
-
} from "./chunk-
|
|
124
|
+
} from "./chunk-N66ZHFGN.mjs";
|
|
125
125
|
|
|
126
126
|
// src/models/TaskLog.ts
|
|
127
127
|
var HEAD_LINES = 60;
|
|
@@ -5143,6 +5143,18 @@ function leftoverMarkerFiles(check) {
|
|
|
5143
5143
|
const files = check.split(/\r?\n/).flatMap((line) => /^(.+):\d+: leftover conflict marker$/.exec(line)?.[1] ?? []);
|
|
5144
5144
|
return [...new Set(files)];
|
|
5145
5145
|
}
|
|
5146
|
+
function rawDiffStates(raw) {
|
|
5147
|
+
const parts = raw.split("\0");
|
|
5148
|
+
const states = {};
|
|
5149
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
5150
|
+
const meta = parts[i].trim();
|
|
5151
|
+
const file = parts[i + 1];
|
|
5152
|
+
if (!meta.startsWith(":") || !file) continue;
|
|
5153
|
+
const [, mode, , blob] = meta.slice(1).split(" ");
|
|
5154
|
+
states[file] = `${mode}:${blob}`;
|
|
5155
|
+
}
|
|
5156
|
+
return states;
|
|
5157
|
+
}
|
|
5146
5158
|
function settleStatus(record, status) {
|
|
5147
5159
|
record.status = status;
|
|
5148
5160
|
delete record.repairBase;
|
|
@@ -5242,6 +5254,8 @@ var GitWorktreeIsolation = class {
|
|
|
5242
5254
|
adminChains = /* @__PURE__ */ new Map();
|
|
5243
5255
|
waiting = [];
|
|
5244
5256
|
draining = false;
|
|
5257
|
+
/** Landings and Merge all take turns on this, so a merge never reads an integration branch mid-landing. */
|
|
5258
|
+
turns = Promise.resolve();
|
|
5245
5259
|
async isActive(workspaceRoot) {
|
|
5246
5260
|
if (!this.deps.config.worktreeIsolation) return { active: false, reason: "disabled" };
|
|
5247
5261
|
const version = await this.tryGit(void 0, ["--version"]);
|
|
@@ -5598,6 +5612,62 @@ var GitWorktreeIsolation = class {
|
|
|
5598
5612
|
return !linked.has(rel) && !linked.has(rel.split("/")[0]);
|
|
5599
5613
|
});
|
|
5600
5614
|
}
|
|
5615
|
+
async findInHead(run) {
|
|
5616
|
+
const found = [];
|
|
5617
|
+
for (const record of Object.values(run.tasks)) {
|
|
5618
|
+
if (record.status !== "merged" || record.inHead) continue;
|
|
5619
|
+
if (!await this.headContains(run, record)) continue;
|
|
5620
|
+
record.inHead = true;
|
|
5621
|
+
found.push(record.taskId);
|
|
5622
|
+
}
|
|
5623
|
+
return found;
|
|
5624
|
+
}
|
|
5625
|
+
async headContains(run, record) {
|
|
5626
|
+
for (const repo of run.repos) {
|
|
5627
|
+
const entry = record.repos[repo.path];
|
|
5628
|
+
if (!entry?.changed) continue;
|
|
5629
|
+
const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
|
|
5630
|
+
if (!merged.ok) return false;
|
|
5631
|
+
}
|
|
5632
|
+
return true;
|
|
5633
|
+
}
|
|
5634
|
+
async snapshotTree(workspaceRoot, exclude) {
|
|
5635
|
+
const scan = await this.scanGroup(workspaceRoot);
|
|
5636
|
+
if ("refused" in scan) return null;
|
|
5637
|
+
const repos = (await this.committedRepos(workspaceRoot, scan.paths)).filter((repoPath) => !exclude.includes(repoPath));
|
|
5638
|
+
if (repos.length === 0) return null;
|
|
5639
|
+
const snapshot = {};
|
|
5640
|
+
for (const repoPath of repos) {
|
|
5641
|
+
const states = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
|
|
5642
|
+
if (states) snapshot[repoPath] = states;
|
|
5643
|
+
}
|
|
5644
|
+
return snapshot;
|
|
5645
|
+
}
|
|
5646
|
+
async changedSince(workspaceRoot, snapshot) {
|
|
5647
|
+
const changed = [];
|
|
5648
|
+
for (const [repoPath, before] of Object.entries(snapshot)) {
|
|
5649
|
+
const now = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
|
|
5650
|
+
if (!now) continue;
|
|
5651
|
+
for (const [file, state] of Object.entries(now)) {
|
|
5652
|
+
if (before[file] !== state) changed.push(repoPath === SELF_REPO ? file : `${repoPath}/${file}`);
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5655
|
+
return changed.sort();
|
|
5656
|
+
}
|
|
5657
|
+
/**
|
|
5658
|
+
* Each tracked file that differs from HEAD, with what it holds. `git stash
|
|
5659
|
+
* create` records the working tree as a commit without touching the tree,
|
|
5660
|
+
* the index or the stash, so a file changed again is told apart from one
|
|
5661
|
+
* left as it was. Null when git cannot tell — a merge in progress, say.
|
|
5662
|
+
*/
|
|
5663
|
+
async trackedStates(root) {
|
|
5664
|
+
const stash = await this.tryGit(root, ["stash", "create"]);
|
|
5665
|
+
if (!stash.ok) return null;
|
|
5666
|
+
const commit = stash.stdout.trim();
|
|
5667
|
+
if (!commit) return {};
|
|
5668
|
+
const diff = await this.tryGit(root, ["diff", "--raw", "--no-abbrev", "--no-renames", "-z", "HEAD", commit]);
|
|
5669
|
+
return diff.ok ? rawDiffStates(diff.stdout) : null;
|
|
5670
|
+
}
|
|
5601
5671
|
/**
|
|
5602
5672
|
* One section per repo with changes. A repo below the workspace root gets a
|
|
5603
5673
|
* header and paths prefixed with its own, so the whole reads as one patch
|
|
@@ -5615,7 +5685,10 @@ var GitWorktreeIsolation = class {
|
|
|
5615
5685
|
}
|
|
5616
5686
|
return diff;
|
|
5617
5687
|
}
|
|
5618
|
-
|
|
5688
|
+
mergeIntoCheckedOut(run) {
|
|
5689
|
+
return this.inTurn(() => this.mergeAll(run));
|
|
5690
|
+
}
|
|
5691
|
+
async mergeAll(run) {
|
|
5619
5692
|
const partial = await this.settleLanding(run);
|
|
5620
5693
|
if (partial.length > 0) return { outcome: "blocked", blocked: partial.map((repo) => ({ repo, reason: "partial-landing", files: [] })) };
|
|
5621
5694
|
const repos = await this.reposWithWork(run);
|
|
@@ -5749,7 +5822,7 @@ var GitWorktreeIsolation = class {
|
|
|
5749
5822
|
if (entry2.run.id === runId && (pick < 0 || entry2.task.order < this.waiting[pick].task.order)) pick = i;
|
|
5750
5823
|
});
|
|
5751
5824
|
const [entry] = this.waiting.splice(pick, 1);
|
|
5752
|
-
entry.settle(await this.land(entry.task, entry.run, entry.persist).catch(() => "failed"));
|
|
5825
|
+
entry.settle(await this.inTurn(() => this.land(entry.task, entry.run, entry.persist)).catch(() => "failed"));
|
|
5753
5826
|
}
|
|
5754
5827
|
} finally {
|
|
5755
5828
|
this.draining = false;
|
|
@@ -5801,6 +5874,10 @@ var GitWorktreeIsolation = class {
|
|
|
5801
5874
|
return (await this.settleLanding(run)).length === 0 ? this.stopLanding(record, outcome, repo, files, error) : this.stopLanding(record, "failed", repo);
|
|
5802
5875
|
}
|
|
5803
5876
|
}
|
|
5877
|
+
for (const repo of changed) {
|
|
5878
|
+
const tip = await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", repo.integrationBranch]);
|
|
5879
|
+
if (tip.ok) record.repos[repo.path].landedTip = tip.stdout.trim();
|
|
5880
|
+
}
|
|
5804
5881
|
delete run.landing;
|
|
5805
5882
|
settleStatus(record, "merged");
|
|
5806
5883
|
delete record.conflictRepo;
|
|
@@ -6075,6 +6152,11 @@ var GitWorktreeIsolation = class {
|
|
|
6075
6152
|
async abortMerge(cwd) {
|
|
6076
6153
|
if (!(await this.tryGit(cwd, ["merge", "--abort"])).ok) await this.tryGit(cwd, ["reset", "--hard"]);
|
|
6077
6154
|
}
|
|
6155
|
+
inTurn(fn) {
|
|
6156
|
+
const turn = this.turns.then(fn, fn);
|
|
6157
|
+
this.turns = turn.catch(() => void 0);
|
|
6158
|
+
return turn;
|
|
6159
|
+
}
|
|
6078
6160
|
admin(repoKey, fn) {
|
|
6079
6161
|
const prior = this.adminChains.get(repoKey) ?? Promise.resolve();
|
|
6080
6162
|
const next = prior.then(fn, fn);
|
|
@@ -6491,8 +6573,22 @@ function researchPhaseBlock(harnessMode) {
|
|
|
6491
6573
|
"- Research and questions can interleave \u2014 you can explore a file, then ask a question grounded in it, in one response"
|
|
6492
6574
|
].join("\n");
|
|
6493
6575
|
}
|
|
6494
|
-
var ISOLATED_PARALLELISM_RULE = "- Each
|
|
6576
|
+
var ISOLATED_PARALLELISM_RULE = "- Each change task runs in its own git worktree, and its work is merged in afterwards, so tasks that edit the same files can still run in parallel. Never add a dependency just because two tasks touch the same file \u2014 keep dependencies for genuine logical ordering.";
|
|
6495
6577
|
var OVERLAP_AVOIDANCE_RULE = "- For parallel tasks, specify different target files to avoid merge conflicts.";
|
|
6578
|
+
function opsTaskSection(conversation) {
|
|
6579
|
+
return [
|
|
6580
|
+
"",
|
|
6581
|
+
"CHANGE AND OPS TASKS:",
|
|
6582
|
+
"- Every AI task is either a change task or an ops task. Decide by one test: is the task's result a change to files in the repository?",
|
|
6583
|
+
'- A change task (the default) edits repository files. Omit "ops".',
|
|
6584
|
+
`- An ops task changes no repository files: it acts on systems outside the repository (a cloud CLI, a deployment, a pipeline, a provisioned resource) or on the git refs and history of the user's branch (push, tag, reword commits). Set "ops": true. Only top-level "ai" tasks can be ops; a subtask runs with its parent.`,
|
|
6585
|
+
`- Ops tasks run in the user's own checkout, after the user has merged the change tasks they depend on. Give an ops task a dependency on every change task whose result it acts on \u2014 "deploy the fix" depends on the fix.`,
|
|
6586
|
+
'- Split a request that mixes the two into a change task and an ops task that depends on it: "bump the version and redeploy" is a change task that bumps and commits the version, then an ops task that pushes and watches the pipeline.',
|
|
6587
|
+
"- Ops tasks run in parallel as their dependencies allow. Two ops tasks whose git operations would collide (a push and a reword of the same branch) must not run in parallel \u2014 make one depend on the other.",
|
|
6588
|
+
"- An ops task runs until what it acts on has finished: one that watches a pipeline reports done only when the pipeline has ended.",
|
|
6589
|
+
...conversation ? [`- A question is not a task. "What is the frontend's URL?" is answered in this conversation, from your research; never make a task of it.`] : []
|
|
6590
|
+
];
|
|
6591
|
+
}
|
|
6496
6592
|
var SHARED_APPEND_FILE_RULE = "- Tasks that run in parallel must not each append to the same shared file (changelogs, registries, index lists) \u2014 give that edit to the final task, or to one task the others depend on.";
|
|
6497
6593
|
function repoGroupSection(isolated) {
|
|
6498
6594
|
if (!isolated) return [];
|
|
@@ -6572,6 +6668,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
|
|
|
6572
6668
|
' "taskMode": "' + modeExamples + '",',
|
|
6573
6669
|
' "autonomy": "AFK|HITL",',
|
|
6574
6670
|
' "sliceType": "AFK|HITL",',
|
|
6671
|
+
' "ops": true,',
|
|
6575
6672
|
' "userStoriesCovered": ["user story text"],',
|
|
6576
6673
|
' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
|
|
6577
6674
|
" }",
|
|
@@ -6597,6 +6694,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
|
|
|
6597
6694
|
SHARED_APPEND_FILE_RULE,
|
|
6598
6695
|
...variant.isolatedExecution ? [ISOLATED_PARALLELISM_RULE] : [],
|
|
6599
6696
|
...repoGroupSection(variant.isolatedExecution),
|
|
6697
|
+
...opsTaskSection(true),
|
|
6600
6698
|
"",
|
|
6601
6699
|
"RULES:",
|
|
6602
6700
|
"- Do NOT wrap the JSON in markdown code blocks. Output ONLY the JSON object when committing the plan.",
|
|
@@ -6672,6 +6770,7 @@ function corePlannerPrompt(isolatedExecution) {
|
|
|
6672
6770
|
' "taskMode": "{{MODE_EXAMPLES}}",',
|
|
6673
6771
|
' "autonomy": "AFK|HITL",',
|
|
6674
6772
|
' "sliceType": "AFK|HITL",',
|
|
6773
|
+
' "ops": true,',
|
|
6675
6774
|
' "userStoriesCovered": ["user story text"],',
|
|
6676
6775
|
' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
|
|
6677
6776
|
" }",
|
|
@@ -6699,6 +6798,7 @@ function corePlannerPrompt(isolatedExecution) {
|
|
|
6699
6798
|
SHARED_APPEND_FILE_RULE,
|
|
6700
6799
|
isolatedExecution ? ISOLATED_PARALLELISM_RULE : "- Slices that touch different areas of the codebase are naturally parallel.",
|
|
6701
6800
|
...repoGroupSection(isolatedExecution),
|
|
6801
|
+
...opsTaskSection(false),
|
|
6702
6802
|
"",
|
|
6703
6803
|
"RULES:",
|
|
6704
6804
|
'- Mark as type "ai" any task the coding assistant can do autonomously.',
|
|
@@ -8702,6 +8802,17 @@ You are running as: ${currentTask.assignedRunner}` : "";
|
|
|
8702
8802
|
lines.join("\n") + footer + runnerNote
|
|
8703
8803
|
].join("\n");
|
|
8704
8804
|
}
|
|
8805
|
+
function renderPreviousAttempt(output) {
|
|
8806
|
+
const tail = output.trim() ? defuseMarkers(output.trim()).split("\n").map((l) => " " + l).join("\n") : " (no output captured)";
|
|
8807
|
+
return [
|
|
8808
|
+
"## Previous attempt",
|
|
8809
|
+
"",
|
|
8810
|
+
"This task ran before and did not finish. What it did outside the repository was not undone: before acting, check what already exists \u2014 resources, deployments, pushed refs \u2014 and do not repeat a step that already took effect.",
|
|
8811
|
+
"",
|
|
8812
|
+
"The end of its output:",
|
|
8813
|
+
tail
|
|
8814
|
+
].join("\n");
|
|
8815
|
+
}
|
|
8705
8816
|
function renderCompletionMarker(task) {
|
|
8706
8817
|
return `
|
|
8707
8818
|
|
|
@@ -8753,6 +8864,7 @@ function composeAugmentedPrompt(task, allTasks, opts) {
|
|
|
8753
8864
|
const flat = flattenTasks(allTasks);
|
|
8754
8865
|
const outputs = collectDirectDependencyOutputs(task, flat);
|
|
8755
8866
|
if (outputs.length > 0) blocks.push(renderPriorOutputs(outputs));
|
|
8867
|
+
if (opts?.previousAttempt !== void 0) blocks.push(renderPreviousAttempt(opts.previousAttempt));
|
|
8756
8868
|
if (opts?.tddEnabled) {
|
|
8757
8869
|
blocks.push(renderTddInstruction());
|
|
8758
8870
|
}
|
|
@@ -8765,9 +8877,9 @@ function composeAugmentedPrompt(task, allTasks, opts) {
|
|
|
8765
8877
|
|
|
8766
8878
|
${basePrompt}${marker2}`;
|
|
8767
8879
|
}
|
|
8768
|
-
function composeContinuationPrompt(task, message) {
|
|
8880
|
+
function composeContinuationPrompt(task, message, opts = {}) {
|
|
8769
8881
|
const reminder = [
|
|
8770
|
-
"(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
|
|
8882
|
+
opts.ops ? "(Ordewell) You are continuing this task in the same session, in the same checkout. What your earlier attempt did outside the repository was not undone: check what already exists before acting again." : "(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
|
|
8771
8883
|
];
|
|
8772
8884
|
if (isHitlTask(task)) {
|
|
8773
8885
|
reminder.push(`If you reach a decision that needs human judgment, print one line holding only the checkpoint marker and wait for ORDEWELL_CONTINUE or ORDEWELL_REJECT. ${CHECKPOINT_MARKER_HOWTO}.`);
|
|
@@ -9507,6 +9619,8 @@ var PlanStore = class {
|
|
|
9507
9619
|
_planTasks = [];
|
|
9508
9620
|
_allTasks = [];
|
|
9509
9621
|
_taskMap = /* @__PURE__ */ new Map();
|
|
9622
|
+
/** Task id → the top-level task it hangs under, itself for a top-level task. */
|
|
9623
|
+
_rootMap = /* @__PURE__ */ new Map();
|
|
9510
9624
|
_planRunners = ["claude-code"];
|
|
9511
9625
|
_onMutate = null;
|
|
9512
9626
|
_executionLog = [];
|
|
@@ -9544,6 +9658,14 @@ var PlanStore = class {
|
|
|
9544
9658
|
get(taskId) {
|
|
9545
9659
|
return this._taskMap.get(taskId);
|
|
9546
9660
|
}
|
|
9661
|
+
/**
|
|
9662
|
+
* Whether a task runs as an ops task (ADR-0020). Only a top-level AI task
|
|
9663
|
+
* can be one; a subtask runs with the top-level task it hangs under.
|
|
9664
|
+
*/
|
|
9665
|
+
isOps(taskId) {
|
|
9666
|
+
const root = this._rootMap.get(taskId);
|
|
9667
|
+
return root?.type === "ai" && root.ops === true;
|
|
9668
|
+
}
|
|
9547
9669
|
/** A copy of the task tree, detached from the store: later status changes do not reach it. */
|
|
9548
9670
|
snapshot() {
|
|
9549
9671
|
return cloneTasks(this._planTasks);
|
|
@@ -9638,6 +9760,7 @@ ${taskB.description}`,
|
|
|
9638
9760
|
assignedRunner: taskA.assignedRunner,
|
|
9639
9761
|
assignedModel: taskA.assignedModel,
|
|
9640
9762
|
taskMode: taskA.taskMode,
|
|
9763
|
+
ops: taskA.ops === true && taskB.ops === true,
|
|
9641
9764
|
order: Math.min(taskA.order, taskB.order)
|
|
9642
9765
|
});
|
|
9643
9766
|
const mergedId = merged.id;
|
|
@@ -9667,7 +9790,8 @@ ${taskB.description}`,
|
|
|
9667
9790
|
taskMode: spec.taskMode ?? original.taskMode,
|
|
9668
9791
|
autonomy: spec.autonomy ?? original.autonomy,
|
|
9669
9792
|
sliceType: spec.sliceType ?? original.sliceType,
|
|
9670
|
-
userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
|
|
9793
|
+
userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered,
|
|
9794
|
+
ops: spec.ops ?? original.ops
|
|
9671
9795
|
}));
|
|
9672
9796
|
});
|
|
9673
9797
|
const tailId = finalized[finalized.length - 1].id;
|
|
@@ -9721,8 +9845,14 @@ ${taskB.description}`,
|
|
|
9721
9845
|
this.setStatus(id, "pending");
|
|
9722
9846
|
task.verdict = void 0;
|
|
9723
9847
|
task.outputSummary = void 0;
|
|
9848
|
+
delete task.forcedPastGate;
|
|
9724
9849
|
}
|
|
9725
9850
|
}
|
|
9851
|
+
/** Kept on the task: it was force-started past its merge gate, before these dependencies' work was merged (ADR-0020). */
|
|
9852
|
+
setForcedPastGate(id, dependencies) {
|
|
9853
|
+
const task = this._taskMap.get(id);
|
|
9854
|
+
if (task) task.forcedPastGate = dependencies;
|
|
9855
|
+
}
|
|
9726
9856
|
/** A reason outlives nothing: any status but `awaiting_user` drops it. */
|
|
9727
9857
|
setStatus(id, status) {
|
|
9728
9858
|
const task = this._taskMap.get(id);
|
|
@@ -9805,6 +9935,10 @@ ${taskB.description}`,
|
|
|
9805
9935
|
for (const task of this._allTasks) {
|
|
9806
9936
|
this._taskMap.set(task.id, task);
|
|
9807
9937
|
}
|
|
9938
|
+
this._rootMap.clear();
|
|
9939
|
+
for (const root of this._planTasks) {
|
|
9940
|
+
for (const task of flattenTasks([root])) this._rootMap.set(task.id, root);
|
|
9941
|
+
}
|
|
9808
9942
|
}
|
|
9809
9943
|
validateAssignedRunners() {
|
|
9810
9944
|
for (const task of this._allTasks) {
|
|
@@ -9858,9 +9992,9 @@ var IsolationRunController = class {
|
|
|
9858
9992
|
/** Copied paths already reported for the current run: every task gets the same copies. */
|
|
9859
9993
|
reportedCopies = /* @__PURE__ */ new Set();
|
|
9860
9994
|
/**
|
|
9861
|
-
* How the open run executes; null while no run is open
|
|
9862
|
-
*
|
|
9863
|
-
* is stopped.
|
|
9995
|
+
* How the open run executes; null while no run is open, `undecided` until
|
|
9996
|
+
* its first change task asks. A run is one Execute-Plan or one manual task
|
|
9997
|
+
* run, from its start until it settles or is stopped.
|
|
9864
9998
|
*/
|
|
9865
9999
|
mode = null;
|
|
9866
10000
|
/** Resolver task id → the conflicted task it resolves; see {@link linkResolver}. */
|
|
@@ -9884,6 +10018,10 @@ var IsolationRunController = class {
|
|
|
9884
10018
|
get isOpen() {
|
|
9885
10019
|
return this.mode !== null;
|
|
9886
10020
|
}
|
|
10021
|
+
/** The open run has decided whether it isolates. */
|
|
10022
|
+
get decided() {
|
|
10023
|
+
return this.mode === "isolated" || this.mode === "shared";
|
|
10024
|
+
}
|
|
9887
10025
|
/** The open run executes in worktrees. */
|
|
9888
10026
|
get isolating() {
|
|
9889
10027
|
return this.mode === "isolated" && this.run !== null;
|
|
@@ -9940,13 +10078,22 @@ var IsolationRunController = class {
|
|
|
9940
10078
|
this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
|
|
9941
10079
|
}
|
|
9942
10080
|
}
|
|
10081
|
+
/** Open a run if none is, leaving whether it isolates to its first change task. */
|
|
10082
|
+
open() {
|
|
10083
|
+
if (this.mode) return;
|
|
10084
|
+
this.mode = "undecided";
|
|
10085
|
+
this.listener.opened();
|
|
10086
|
+
}
|
|
9943
10087
|
/**
|
|
9944
|
-
*
|
|
9945
|
-
* worktrees
|
|
9946
|
-
*
|
|
10088
|
+
* Decide once per run, as its first change task starts, whether it executes
|
|
10089
|
+
* in worktrees (ADR-0020): an ops task needs none, so a run of only ops
|
|
10090
|
+
* tasks never asks. `resume` is what a dirty tree parks until the user
|
|
10091
|
+
* chooses how to go on. Resolves false while the run waits on that choice.
|
|
9947
10092
|
*/
|
|
9948
|
-
|
|
9949
|
-
|
|
10093
|
+
decide(resume) {
|
|
10094
|
+
this.open();
|
|
10095
|
+
if (this.decided) return Promise.resolve(true);
|
|
10096
|
+
if (this.blockedStart) return Promise.resolve(false);
|
|
9950
10097
|
this.opening ??= this.activate(resume).finally(() => {
|
|
9951
10098
|
this.opening = null;
|
|
9952
10099
|
});
|
|
@@ -9961,8 +10108,8 @@ var IsolationRunController = class {
|
|
|
9961
10108
|
* the safe rule then.
|
|
9962
10109
|
*/
|
|
9963
10110
|
async plannerLayout() {
|
|
9964
|
-
if (this.
|
|
9965
|
-
const decision = await this.
|
|
10111
|
+
if (this.decided) return this.isolating && this.run ? layoutOf(this.run) : false;
|
|
10112
|
+
const decision = await this.assess(this.workspaceRoot());
|
|
9966
10113
|
return decision.mode === "isolated" ? decision.layout : false;
|
|
9967
10114
|
}
|
|
9968
10115
|
/**
|
|
@@ -9997,7 +10144,7 @@ var IsolationRunController = class {
|
|
|
9997
10144
|
const { cwd: cwd2 } = await this.isolation.reopen(task, this.run);
|
|
9998
10145
|
return { cwd: cwd2, worktree: true };
|
|
9999
10146
|
}
|
|
10000
|
-
if (!this.isolating || !this.run) {
|
|
10147
|
+
if (opts.ops || !this.isolating || !this.run) {
|
|
10001
10148
|
await this.release(task.id, { keep: false });
|
|
10002
10149
|
return { cwd: this.workspaceRoot(), worktree: false };
|
|
10003
10150
|
}
|
|
@@ -10042,18 +10189,71 @@ var IsolationRunController = class {
|
|
|
10042
10189
|
}
|
|
10043
10190
|
this.listener.changed();
|
|
10044
10191
|
}
|
|
10045
|
-
/**
|
|
10192
|
+
/**
|
|
10193
|
+
* Close the open run. An isolated one hands its integration branch over for
|
|
10194
|
+
* review — unless the user merged everything it landed at merge gates
|
|
10195
|
+
* already, which leaves nothing to hand over, so it is cleared up as a Merge
|
|
10196
|
+
* all would have.
|
|
10197
|
+
*/
|
|
10046
10198
|
async close() {
|
|
10047
10199
|
const mode = this.mode;
|
|
10048
10200
|
this.mode = null;
|
|
10049
|
-
|
|
10201
|
+
const run = this.run;
|
|
10202
|
+
if (mode !== "isolated" || !run) return;
|
|
10203
|
+
await this.refreshInHead();
|
|
10204
|
+
const records = Object.values(run.tasks);
|
|
10205
|
+
if (records.length > 0 && records.every((r) => r.status === "merged" && r.inHead)) {
|
|
10206
|
+
this.tell("info", "Everything this run landed is merged into your branch already.");
|
|
10207
|
+
await this.clearMerged(run);
|
|
10208
|
+
return;
|
|
10209
|
+
}
|
|
10050
10210
|
try {
|
|
10051
|
-
this.listener.handoff(await this.isolation.handoff(
|
|
10211
|
+
this.listener.handoff(await this.isolation.handoff(run));
|
|
10052
10212
|
} catch (err) {
|
|
10053
10213
|
this.tell("warn", `Could not hand the run's integration branch over: ${err instanceof Error ? err.message : String(err)}`);
|
|
10054
10214
|
}
|
|
10055
10215
|
this.listener.changed();
|
|
10056
10216
|
}
|
|
10217
|
+
/**
|
|
10218
|
+
* Whether a task's landed work still waits to be merged into the user's
|
|
10219
|
+
* branch — what holds an ops or user task at its merge gate (ADR-0020). A
|
|
10220
|
+
* task with no record never landed anything to wait for.
|
|
10221
|
+
*/
|
|
10222
|
+
awaitsMerge(taskId) {
|
|
10223
|
+
const record = this.run?.tasks[taskId];
|
|
10224
|
+
return !!record && record.status === "merged" && !record.inHead;
|
|
10225
|
+
}
|
|
10226
|
+
/**
|
|
10227
|
+
* Look again for landed work the user has merged — by Merge all, or by hand
|
|
10228
|
+
* with git. Resolves true when some was found.
|
|
10229
|
+
*/
|
|
10230
|
+
async refreshInHead() {
|
|
10231
|
+
const run = this.run;
|
|
10232
|
+
if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
|
|
10233
|
+
const found = await this.isolation.findInHead(run).catch(() => []);
|
|
10234
|
+
if (found.length > 0) this.listener.changed();
|
|
10235
|
+
return found.length > 0;
|
|
10236
|
+
}
|
|
10237
|
+
/**
|
|
10238
|
+
* The tracked state of the workspace as an ops task starts, for
|
|
10239
|
+
* {@link filesChangedSince} (ADR-0020). Null where the check does not run:
|
|
10240
|
+
* a run that shares the workspace root, where every task's edits land in
|
|
10241
|
+
* it, or a workspace git cannot isolate at all.
|
|
10242
|
+
*/
|
|
10243
|
+
async snapshotWorkspace() {
|
|
10244
|
+
if (this.mode === "shared") return null;
|
|
10245
|
+
const root = this.workspaceRoot();
|
|
10246
|
+
if (!this.isolating) {
|
|
10247
|
+
const availability = await this.isolation.isActive(root).catch(() => null);
|
|
10248
|
+
if (!availability || !availability.active && availability.reason !== "dirty") return null;
|
|
10249
|
+
}
|
|
10250
|
+
return this.isolation.snapshotTree(root, this.run?.sharedRepos ?? []).catch(() => null);
|
|
10251
|
+
}
|
|
10252
|
+
/** Tracked files changed since `snapshot`; none when the run has since gone on in the workspace root. */
|
|
10253
|
+
async filesChangedSince(snapshot) {
|
|
10254
|
+
if (!snapshot || this.mode === "shared") return [];
|
|
10255
|
+
return this.isolation.changedSince(this.workspaceRoot(), snapshot).catch(() => []);
|
|
10256
|
+
}
|
|
10057
10257
|
/**
|
|
10058
10258
|
* A stop or a plan load: the open run ends without a handoff and a parked
|
|
10059
10259
|
* start is dropped. `keepOpen` spares a run the scheduler is still driving.
|
|
@@ -10084,19 +10284,23 @@ var IsolationRunController = class {
|
|
|
10084
10284
|
}
|
|
10085
10285
|
/**
|
|
10086
10286
|
* "Merge all": the run's integration branches into whatever the user has
|
|
10087
|
-
* checked out, in every repo or none.
|
|
10088
|
-
*
|
|
10089
|
-
*
|
|
10287
|
+
* checked out, in every repo or none. During a run it merges what has
|
|
10288
|
+
* landed so far and the run goes on (ADR-0020): its branches stay for the
|
|
10289
|
+
* tasks still to land. Once a settled run merged everything, it has nothing
|
|
10290
|
+
* left to hand over, so it is cleared up and forgotten; a branch the user's
|
|
10291
|
+
* HEAD somehow does not contain stays for the next run's sweep.
|
|
10090
10292
|
*/
|
|
10091
10293
|
async merge() {
|
|
10092
10294
|
const run = this.requireRun();
|
|
10295
|
+
const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
|
|
10093
10296
|
const result = await this.isolation.mergeIntoCheckedOut(run);
|
|
10094
10297
|
const branch = integrationBranchNameOf(run);
|
|
10095
10298
|
const group = run.repos.some((r) => r.path !== SELF_REPO);
|
|
10096
|
-
const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
|
|
10097
10299
|
const { level, message } = describeMergeResult(result, branch, group, repaired);
|
|
10098
10300
|
this.tell(level, message);
|
|
10099
|
-
if (result.outcome
|
|
10301
|
+
if (result.outcome !== "merged") return result;
|
|
10302
|
+
await this.refreshInHead();
|
|
10303
|
+
if (!this.isOpen) await this.clearMerged(run);
|
|
10100
10304
|
return result;
|
|
10101
10305
|
}
|
|
10102
10306
|
/** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
|
|
@@ -10128,7 +10332,7 @@ var IsolationRunController = class {
|
|
|
10128
10332
|
this.resolvers = {};
|
|
10129
10333
|
this.listener.changed();
|
|
10130
10334
|
}
|
|
10131
|
-
async
|
|
10335
|
+
async assess(root) {
|
|
10132
10336
|
const availability = await this.isolation.isActive(root);
|
|
10133
10337
|
const continued = this.continuableRun(root);
|
|
10134
10338
|
const continuing = continued !== null;
|
|
@@ -10143,7 +10347,7 @@ var IsolationRunController = class {
|
|
|
10143
10347
|
}
|
|
10144
10348
|
async activate(resume) {
|
|
10145
10349
|
const root = this.workspaceRoot();
|
|
10146
|
-
const decision = await this.
|
|
10350
|
+
const decision = await this.assess(root);
|
|
10147
10351
|
if (decision.mode === "blocked") {
|
|
10148
10352
|
this.blockedStart = resume;
|
|
10149
10353
|
this.blockedRepos = decision.repos;
|
|
@@ -10170,8 +10374,8 @@ var IsolationRunController = class {
|
|
|
10170
10374
|
return true;
|
|
10171
10375
|
}
|
|
10172
10376
|
begin(mode) {
|
|
10377
|
+
this.open();
|
|
10173
10378
|
this.mode = mode;
|
|
10174
|
-
this.listener.opened();
|
|
10175
10379
|
}
|
|
10176
10380
|
/** What earlier runs left merged in the group goes; a failure here is worth a word, never a stopped run. */
|
|
10177
10381
|
async sweep() {
|
|
@@ -10518,8 +10722,9 @@ var MessageQueue = class {
|
|
|
10518
10722
|
// src/services/readiness.ts
|
|
10519
10723
|
function selectReadyTasks(input) {
|
|
10520
10724
|
const { store, onHold, runs, active, maxParallel } = input;
|
|
10521
|
-
if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [] };
|
|
10725
|
+
if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
|
|
10522
10726
|
const availableSlots = maxParallel - active;
|
|
10727
|
+
const gated = [];
|
|
10523
10728
|
const candidates = store.allTasks.filter((t) => {
|
|
10524
10729
|
if (t.status !== "pending" && t.status !== "approved") return false;
|
|
10525
10730
|
if (t.type === "user") return false;
|
|
@@ -10527,13 +10732,21 @@ function selectReadyTasks(input) {
|
|
|
10527
10732
|
if (onHold.has(t.id)) return false;
|
|
10528
10733
|
if (isBlocked(t, store)) return false;
|
|
10529
10734
|
if (!t.dependencies.every((depId) => dependencyMet(depId, store, runs))) return false;
|
|
10735
|
+
if (store.isOps(t.id)) {
|
|
10736
|
+
if (input.merging) return false;
|
|
10737
|
+
if (mergeGate(t, store, runs).length > 0) {
|
|
10738
|
+
gated.push(t);
|
|
10739
|
+
return false;
|
|
10740
|
+
}
|
|
10741
|
+
}
|
|
10530
10742
|
return true;
|
|
10531
10743
|
});
|
|
10532
|
-
const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task,
|
|
10744
|
+
const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, input) }));
|
|
10533
10745
|
return {
|
|
10534
10746
|
ready: candidates.sort((a, b) => a.order - b.order).slice(0, availableSlots),
|
|
10535
10747
|
candidateCount: candidates.length,
|
|
10536
|
-
excluded
|
|
10748
|
+
excluded,
|
|
10749
|
+
gated
|
|
10537
10750
|
};
|
|
10538
10751
|
}
|
|
10539
10752
|
function isBlocked(task, store) {
|
|
@@ -10546,12 +10759,19 @@ function dependencyMet(depId, store, runs) {
|
|
|
10546
10759
|
const record = runs.openRecord(depId);
|
|
10547
10760
|
return !record || record.status === "merged";
|
|
10548
10761
|
}
|
|
10549
|
-
function
|
|
10762
|
+
function mergeGate(task, store, runs) {
|
|
10763
|
+
if (task.type !== "user" && !store.isOps(task.id)) return [];
|
|
10764
|
+
return task.dependencies.filter((depId) => store.isCompleted(depId) && runs.awaitsMerge(depId));
|
|
10765
|
+
}
|
|
10766
|
+
function exclusionReasons(task, input) {
|
|
10767
|
+
const { store, onHold, runs } = input;
|
|
10550
10768
|
const reasons = [];
|
|
10551
10769
|
if (task.status !== "pending" && task.status !== "approved") reasons.push(`status=${task.status}`);
|
|
10552
10770
|
if (onHold.has(task.id)) reasons.push("on-hold");
|
|
10553
10771
|
if (isBlocked(task, store)) reasons.push("blocked");
|
|
10554
10772
|
if (!task.dependencies.every((depId) => dependencyMet(depId, store, runs))) reasons.push("deps");
|
|
10773
|
+
if (mergeGate(task, store, runs).length > 0) reasons.push("merge-gate");
|
|
10774
|
+
if (input.merging && store.isOps(task.id)) reasons.push("merging");
|
|
10555
10775
|
return reasons;
|
|
10556
10776
|
}
|
|
10557
10777
|
|
|
@@ -13426,6 +13646,10 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13426
13646
|
* pending and nothing ran until the user also re-ran the whole plan.
|
|
13427
13647
|
*/
|
|
13428
13648
|
haltedByFailure = false;
|
|
13649
|
+
/** A Merge all is under way: no ops task starts until it ends (ADR-0020). */
|
|
13650
|
+
merging = false;
|
|
13651
|
+
/** The last "waiting for Merge all" notice, so a run paused at a gate says it once, not on every tick. */
|
|
13652
|
+
gateNotice = "";
|
|
13429
13653
|
/** The workspace's own variables for a task's cwd (ADR-0016); swapped out in tests. */
|
|
13430
13654
|
workspaceEnv;
|
|
13431
13655
|
/** What the workspace env has already warned about, so a run says it once, not per task. */
|
|
@@ -13734,9 +13958,48 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13734
13958
|
async reviewRunDiff() {
|
|
13735
13959
|
return this.runs.reviewDiff();
|
|
13736
13960
|
}
|
|
13737
|
-
/**
|
|
13961
|
+
/**
|
|
13962
|
+
* "Merge all"; a settled run that merged everything is cleared up and
|
|
13963
|
+
* forgotten. During a run it merges what has landed and the run goes on:
|
|
13964
|
+
* the tasks waiting at a merge gate for that work then start (ADR-0020).
|
|
13965
|
+
* Never while an ops task runs, and no ops task starts until it ends — the
|
|
13966
|
+
* one overlap the plan cannot order, because the merge is the user's.
|
|
13967
|
+
*/
|
|
13738
13968
|
async mergeRun() {
|
|
13739
|
-
|
|
13969
|
+
const ops = [...this.attempts.values()].find((a) => a.ops);
|
|
13970
|
+
if (ops) {
|
|
13971
|
+
const title = this.store.get(ops.taskId)?.title ?? ops.taskId;
|
|
13972
|
+
throw new TaskControlError(`Ops task "${title}" is running in your checkout \u2014 Merge all once it has finished.`);
|
|
13973
|
+
}
|
|
13974
|
+
this.merging = true;
|
|
13975
|
+
let result;
|
|
13976
|
+
try {
|
|
13977
|
+
result = await this.runs.merge();
|
|
13978
|
+
} finally {
|
|
13979
|
+
this.merging = false;
|
|
13980
|
+
}
|
|
13981
|
+
await this.tick();
|
|
13982
|
+
return result;
|
|
13983
|
+
}
|
|
13984
|
+
/**
|
|
13985
|
+
* The run as a surface offers Merge all mid-run (ADR-0020): what has landed
|
|
13986
|
+
* and is not merged, and whether the run is paused at its gates with nothing
|
|
13987
|
+
* else running. Null while no task waits at a gate.
|
|
13988
|
+
*/
|
|
13989
|
+
mergeGateView() {
|
|
13990
|
+
const handoff = this.runs.view()?.handoff;
|
|
13991
|
+
if (!handoff || !this.store.allTasks.some((t) => this.getMergeGate(t.id).length > 0)) return null;
|
|
13992
|
+
return { paused: this.running && this.attempts.size === 0, repos: handoff.repos, landed: handoff.landed };
|
|
13993
|
+
}
|
|
13994
|
+
/**
|
|
13995
|
+
* The dependencies an ops or user task waits on at its merge gate: those
|
|
13996
|
+
* whose landed work is not in the user's branch yet (ADR-0020). Empty once
|
|
13997
|
+
* the task has started, and for every other task.
|
|
13998
|
+
*/
|
|
13999
|
+
getMergeGate(taskId) {
|
|
14000
|
+
const task = this.store.get(taskId);
|
|
14001
|
+
if (!task || task.status !== "pending" && task.status !== "approved") return [];
|
|
14002
|
+
return mergeGate(task, this.store, this.runs);
|
|
13740
14003
|
}
|
|
13741
14004
|
/** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
|
|
13742
14005
|
async cleanupRun() {
|
|
@@ -13836,7 +14099,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13836
14099
|
this.emit("onReviewNeeded", { tasks: this.store.planTasks, planRunners: this.store.planRunners });
|
|
13837
14100
|
return;
|
|
13838
14101
|
}
|
|
13839
|
-
|
|
14102
|
+
this.runs.open();
|
|
13840
14103
|
console.log(`[TaskOrchestrator] Starting with ${this.store.allTasks.length} tasks (${this.store.allTasks.filter((t) => t.type === "ai" && t.prompt).length} AI ready)`);
|
|
13841
14104
|
this.running = true;
|
|
13842
14105
|
this.haltedByFailure = false;
|
|
@@ -13878,13 +14141,17 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13878
14141
|
const summary = await this.output.finalText({ ...attempt, completionMarker: task.completionMarker }, doneToken);
|
|
13879
14142
|
if (this.attempts.get(taskId) !== attempt) return;
|
|
13880
14143
|
const landing = verdict.outcome === "pass" ? await this.land(attempt, () => this.landing.landPassed(task, attempt)) : null;
|
|
14144
|
+
const changedFiles = landing && attempt.ops ? await this.runs.filesChangedSince(attempt.snapshot) : [];
|
|
13881
14145
|
const unresumedReason = this.unresumed(attempt) ? unresumedMessage(task, `${attempt.runner} could not find its saved session`) : null;
|
|
13882
14146
|
if (this.attempts.get(taskId) !== attempt) return;
|
|
13883
14147
|
this.endAttempt(taskId, "verdict");
|
|
13884
14148
|
this.store.setTaskVerdict(taskId, verdict);
|
|
13885
14149
|
console.error(`[TaskOrchestrator] Output summary:
|
|
13886
14150
|
${summary || "(empty \u2014 no output captured)"}`);
|
|
13887
|
-
if (
|
|
14151
|
+
if (changedFiles.length > 0) {
|
|
14152
|
+
this.store.markAwaitingUser(taskId, "files-changed");
|
|
14153
|
+
this.tell("warn", `Ops task "${task.title}" finished, but changed tracked files in your checkout: ${capConflictFiles(changedFiles)}. An ops task must not change repository files, so nothing was committed \u2014 look at the changes, then mark it complete or retry it.`);
|
|
14154
|
+
} else if (landing) {
|
|
13888
14155
|
await this.applyLanding(task, landing, () => this.notifications.info(`Task "${task.title}" completed.`));
|
|
13889
14156
|
} else if (classifyRunnerStop(attempt.session?.getOutput() ?? "") === "usage-limit") {
|
|
13890
14157
|
this.store.markAwaitingUser(taskId);
|
|
@@ -13986,21 +14253,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
13986
14253
|
});
|
|
13987
14254
|
}
|
|
13988
14255
|
getReadyTasks() {
|
|
13989
|
-
|
|
14256
|
+
return this.readiness().ready;
|
|
14257
|
+
}
|
|
14258
|
+
readiness() {
|
|
14259
|
+
if (!this.running) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
|
|
13990
14260
|
const maxParallel = this.config.maxParallelSessions;
|
|
13991
14261
|
const active = this.attempts.size;
|
|
13992
|
-
const
|
|
14262
|
+
const readiness = selectReadyTasks({
|
|
13993
14263
|
store: this.store,
|
|
13994
14264
|
onHold: this.onHold,
|
|
13995
14265
|
runs: this.runs,
|
|
13996
14266
|
active,
|
|
13997
|
-
maxParallel
|
|
14267
|
+
maxParallel,
|
|
14268
|
+
merging: this.merging
|
|
13998
14269
|
});
|
|
13999
|
-
for (const { task, reasons } of excluded) {
|
|
14270
|
+
for (const { task, reasons } of readiness.excluded) {
|
|
14000
14271
|
console.log(`[TaskOrchestrator] excluded: #${task.order} "${task.title}" \u2014 ${reasons.join(", ")}`);
|
|
14001
14272
|
}
|
|
14002
|
-
console.log(`[TaskOrchestrator] getReadyTasks: ${candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
|
|
14003
|
-
return
|
|
14273
|
+
console.log(`[TaskOrchestrator] getReadyTasks: ${readiness.candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
|
|
14274
|
+
return readiness;
|
|
14275
|
+
}
|
|
14276
|
+
/**
|
|
14277
|
+
* Open the run a task starts in, and for a change task decide whether it
|
|
14278
|
+
* isolates (ADR-0020): an ops task needs no worktree, so it never waits on
|
|
14279
|
+
* that choice. False while a dirty tree waits on the user; `resume` is what
|
|
14280
|
+
* their choice replays.
|
|
14281
|
+
*/
|
|
14282
|
+
async openFor(taskId, resume) {
|
|
14283
|
+
this.runs.open();
|
|
14284
|
+
return this.store.isOps(taskId) || this.runs.decide(resume);
|
|
14004
14285
|
}
|
|
14005
14286
|
/**
|
|
14006
14287
|
* Cancel a running (or scheduled) task: kill its session and return it to
|
|
@@ -14128,7 +14409,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14128
14409
|
const runner = this.store.resolveTaskRunner(task);
|
|
14129
14410
|
const route = routeTransport("structured", runner, this.registry);
|
|
14130
14411
|
if (route.transport !== "structured") throw new TaskControlError(`Task "${task.title}" cannot be continued: ${route.fallback}. Use Retry instead.`);
|
|
14131
|
-
if (!await this.
|
|
14412
|
+
if (!await this.openFor(taskId, () => this.continueTask(taskId, message))) return;
|
|
14132
14413
|
if (this.attempts.has(taskId) || !continuability(task).ok) return;
|
|
14133
14414
|
this.retryCounts.set(taskId, (this.retryCounts.get(taskId) ?? 0) + 1);
|
|
14134
14415
|
this.endAttempt(taskId, "retry");
|
|
@@ -14150,10 +14431,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14150
14431
|
const task = this.store.get(taskId);
|
|
14151
14432
|
if (!task || task.type !== "ai") return;
|
|
14152
14433
|
if (this.attempts.has(taskId)) return;
|
|
14153
|
-
|
|
14434
|
+
this.refuseOpsDuringMerge(task);
|
|
14435
|
+
if (!await this.openFor(taskId, () => this.forceStartTask(taskId))) return;
|
|
14154
14436
|
this.onHold.delete(taskId);
|
|
14437
|
+
this.notePastGate(task);
|
|
14155
14438
|
await this.startTask(task);
|
|
14156
14439
|
}
|
|
14440
|
+
refuseOpsDuringMerge(task) {
|
|
14441
|
+
if (this.merging && this.store.isOps(task.id)) {
|
|
14442
|
+
throw new TaskControlError(`A Merge all is under way \u2014 ops task "${task.title}" can start once it has finished.`);
|
|
14443
|
+
}
|
|
14444
|
+
}
|
|
14445
|
+
/**
|
|
14446
|
+
* A force start passes a merge gate (ADR-0020), and the surface confirmed it
|
|
14447
|
+
* first; the choice is kept on the task and said, because the work it acts
|
|
14448
|
+
* on is not in the user's branch.
|
|
14449
|
+
*/
|
|
14450
|
+
notePastGate(task) {
|
|
14451
|
+
const unmerged = mergeGate(task, this.store, this.runs);
|
|
14452
|
+
if (unmerged.length === 0) return;
|
|
14453
|
+
const titles = unmerged.map((id) => `"${this.store.get(id)?.title ?? id}"`);
|
|
14454
|
+
this.store.setForcedPastGate(task.id, titles);
|
|
14455
|
+
this.tell("warn", `Task "${task.title}" was force-started before the work of ${titles.join(", ")} was merged into your branch.`);
|
|
14456
|
+
}
|
|
14157
14457
|
/**
|
|
14158
14458
|
* Run exactly one task outside full-plan scheduling. The active/starting
|
|
14159
14459
|
* session still contributes to {@link hasLiveWork} so every surface exposes
|
|
@@ -14164,8 +14464,10 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14164
14464
|
if (this.hasLiveWork) return;
|
|
14165
14465
|
const task = this.store.get(taskId);
|
|
14166
14466
|
if (!task || task.type !== "ai") return;
|
|
14167
|
-
|
|
14467
|
+
this.refuseOpsDuringMerge(task);
|
|
14468
|
+
if (!await this.openFor(taskId, () => this.runTask(taskId))) return;
|
|
14168
14469
|
this.onHold.delete(taskId);
|
|
14470
|
+
this.notePastGate(task);
|
|
14169
14471
|
await this.startTask(task);
|
|
14170
14472
|
}
|
|
14171
14473
|
getCompletedCount() {
|
|
@@ -14205,7 +14507,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14205
14507
|
}
|
|
14206
14508
|
return;
|
|
14207
14509
|
}
|
|
14208
|
-
|
|
14510
|
+
let readiness = this.readiness();
|
|
14511
|
+
const atGate = readiness.gated.length > 0 || this.store.allTasks.some((t) => t.type === "user" && this.getMergeGate(t.id).length > 0);
|
|
14512
|
+
if (atGate && await this.runs.refreshInHead()) readiness = this.readiness();
|
|
14513
|
+
if (!this.running) return;
|
|
14514
|
+
const { ready } = readiness;
|
|
14209
14515
|
console.log(`[TaskOrchestrator] tick(): ${ready.length} ready, ${this.attempts.size} active, queue=${this.messageQueue.length}`);
|
|
14210
14516
|
if (ready.length === 0 && this.attempts.size === 0) {
|
|
14211
14517
|
const remaining = this.store.allTasks.filter((t) => t.status !== "completed");
|
|
@@ -14225,14 +14531,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14225
14531
|
this.emit("onExecutionComplete");
|
|
14226
14532
|
} else {
|
|
14227
14533
|
this.notifications.info("Remaining tasks require user action or are on hold.");
|
|
14534
|
+
this.sayGate();
|
|
14228
14535
|
this.emit("onTaskChanged");
|
|
14229
14536
|
this.emit("onTick");
|
|
14230
14537
|
}
|
|
14231
14538
|
return;
|
|
14232
14539
|
}
|
|
14233
|
-
for (const task of ready)
|
|
14540
|
+
for (const task of ready) {
|
|
14541
|
+
if (this.store.isOps(task.id) ? this.merging : !await this.runs.decide(() => this.tick())) continue;
|
|
14542
|
+
await this.startTask(task);
|
|
14543
|
+
}
|
|
14234
14544
|
this.emit("onTick");
|
|
14235
14545
|
}
|
|
14546
|
+
/**
|
|
14547
|
+
* A run paused at merge gates says so, as plainly as a task that waits on
|
|
14548
|
+
* the user: which tasks wait, and that Merge all lets them go on (ADR-0020).
|
|
14549
|
+
*/
|
|
14550
|
+
sayGate() {
|
|
14551
|
+
const waiting = this.store.allTasks.filter((t) => this.getMergeGate(t.id).length > 0);
|
|
14552
|
+
const notice = waiting.length === 0 ? "" : `Waiting for Merge all: ${waiting.map((t) => `"${t.title}"`).join(", ")} ${waiting.length === 1 ? "needs" : "need"} work that is not merged into your branch yet.`;
|
|
14553
|
+
if (notice === this.gateNotice) return;
|
|
14554
|
+
this.gateNotice = notice;
|
|
14555
|
+
if (notice) this.tell("info", notice);
|
|
14556
|
+
}
|
|
14236
14557
|
async startTask(task, continuation = null) {
|
|
14237
14558
|
if (this.attempts.has(task.id)) return;
|
|
14238
14559
|
const attempt = {
|
|
@@ -14246,6 +14567,8 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14246
14567
|
integration: null,
|
|
14247
14568
|
repair: this.landing.nextRepair(task.id),
|
|
14248
14569
|
continuation,
|
|
14570
|
+
ops: this.store.isOps(task.id),
|
|
14571
|
+
snapshot: null,
|
|
14249
14572
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
14250
14573
|
decided: false
|
|
14251
14574
|
};
|
|
@@ -14271,18 +14594,22 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14271
14594
|
*/
|
|
14272
14595
|
async spawnAttempt(task, attempt) {
|
|
14273
14596
|
try {
|
|
14274
|
-
const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null });
|
|
14597
|
+
const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null, ops: attempt.ops });
|
|
14275
14598
|
attempt.cwd = cwd;
|
|
14276
14599
|
attempt.worktree = worktree;
|
|
14600
|
+
if (attempt.ops) attempt.snapshot = await this.runs.snapshotWorkspace();
|
|
14277
14601
|
if (this.attempts.get(task.id) !== attempt) {
|
|
14278
14602
|
if (attempt.worktree && !this.attempts.has(task.id)) await this.runs.release(task.id, { keep: attempt.repair !== null });
|
|
14279
14603
|
this.abandonSpawn(task, attempt);
|
|
14280
14604
|
return false;
|
|
14281
14605
|
}
|
|
14282
|
-
const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
|
|
14606
|
+
const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message, { ops: attempt.ops }) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
|
|
14283
14607
|
planMapEnabled: this.config.planMapEnabled,
|
|
14284
14608
|
// A merge to resolve is not new behaviour to drive test-first.
|
|
14285
|
-
tddEnabled: !attempt.repair && this.tddEnabled()
|
|
14609
|
+
tddEnabled: !attempt.repair && this.tddEnabled(),
|
|
14610
|
+
// An ops task's effects outlive a failed attempt and are never rolled
|
|
14611
|
+
// back, so the next one is told what the last one did (ADR-0020).
|
|
14612
|
+
previousAttempt: attempt.ops ? this.output.liveTail(task.id, { maxLines: OPS_RETRY_TAIL_LINES })?.text : void 0
|
|
14286
14613
|
});
|
|
14287
14614
|
this.lingering.close(task.id);
|
|
14288
14615
|
const env = await this.envForTask(cwd);
|
|
@@ -14459,6 +14786,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14459
14786
|
return ended;
|
|
14460
14787
|
}
|
|
14461
14788
|
};
|
|
14789
|
+
var OPS_RETRY_TAIL_LINES = 60;
|
|
14462
14790
|
function unresumedMessage(task, why) {
|
|
14463
14791
|
return `Could not continue task "${task.title}": ${why}. Retry starts it afresh.`;
|
|
14464
14792
|
}
|
|
@@ -16916,7 +17244,7 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
|
|
|
16916
17244
|
return tasks.map((t) => {
|
|
16917
17245
|
const isMan = t.type === "user";
|
|
16918
17246
|
const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
|
|
16919
|
-
return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
|
|
17247
|
+
return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : t.ops ? "AI ops" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
|
|
16920
17248
|
});
|
|
16921
17249
|
}
|
|
16922
17250
|
};
|
|
@@ -17413,6 +17741,7 @@ var SessionEventRelay = class {
|
|
|
17413
17741
|
this.statusOwed = this.statusHeld;
|
|
17414
17742
|
if (this.statusHeld) return;
|
|
17415
17743
|
plan.tasks = this.store.snapshot();
|
|
17744
|
+
const gate = this.orchestrator.mergeGateView();
|
|
17416
17745
|
this.broadcast({
|
|
17417
17746
|
type: "status_update",
|
|
17418
17747
|
tasks: this.store.allTasks.map((t) => serializeTaskStatus(
|
|
@@ -17420,8 +17749,10 @@ var SessionEventRelay = class {
|
|
|
17420
17749
|
this.orchestrator.getIdleSince(t.id),
|
|
17421
17750
|
this.orchestrator.getTaskIsolation(t.id),
|
|
17422
17751
|
this.orchestrator.getQueuedTaskMessages(t.id),
|
|
17423
|
-
this.awaitingApproval(t.id)
|
|
17424
|
-
|
|
17752
|
+
this.awaitingApproval(t.id),
|
|
17753
|
+
this.orchestrator.getMergeGate(t.id)
|
|
17754
|
+
)),
|
|
17755
|
+
...gate ? { gate } : {}
|
|
17425
17756
|
});
|
|
17426
17757
|
}
|
|
17427
17758
|
/**
|
|
@@ -18754,6 +19085,14 @@ var Session = class {
|
|
|
18754
19085
|
isolationView() {
|
|
18755
19086
|
return this.orchestrator.isolationView();
|
|
18756
19087
|
}
|
|
19088
|
+
/** The dependencies a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
|
|
19089
|
+
mergeGate(taskId) {
|
|
19090
|
+
return this.orchestrator.getMergeGate(taskId);
|
|
19091
|
+
}
|
|
19092
|
+
/** What Merge all would merge mid-run, while any task waits at a merge gate; null otherwise. */
|
|
19093
|
+
mergeGateView() {
|
|
19094
|
+
return this.orchestrator.mergeGateView();
|
|
19095
|
+
}
|
|
18757
19096
|
/** The run's integration branch against its base ref, as a unified diff. */
|
|
18758
19097
|
async reviewRunDiff() {
|
|
18759
19098
|
if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
|
|
@@ -18763,10 +19102,12 @@ var Session = class {
|
|
|
18763
19102
|
* "Merge all": each repo's integration branch into whatever the user has
|
|
18764
19103
|
* checked out there — every repo, or none if any cannot take it. The one
|
|
18765
19104
|
* irreversible step of isolated execution, so this explicit call is the
|
|
18766
|
-
* only way it ever happens.
|
|
19105
|
+
* only way it ever happens. During a run it merges what has landed so far,
|
|
19106
|
+
* which is what opens a merge gate (ADR-0020); it waits for no ops task,
|
|
19107
|
+
* and refuses while one runs.
|
|
18767
19108
|
*/
|
|
18768
19109
|
async mergeRun() {
|
|
18769
|
-
this.
|
|
19110
|
+
if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
|
|
18770
19111
|
const result = await this.orchestrator.mergeRun();
|
|
18771
19112
|
this.broadcast({ type: "isolation_merge", result });
|
|
18772
19113
|
return result;
|
|
@@ -18856,7 +19197,8 @@ var Session = class {
|
|
|
18856
19197
|
* falls through to the no-op `store.update` below instead of throwing.
|
|
18857
19198
|
*/
|
|
18858
19199
|
async updateTask(taskId, changes) {
|
|
18859
|
-
if (
|
|
19200
|
+
if ("ops" in changes) changes = { ...changes, ops: changes.ops === true ? true : void 0 };
|
|
19201
|
+
if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode || "ops" in changes) && this.store.get(taskId)) {
|
|
18860
19202
|
const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
|
|
18861
19203
|
if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
|
|
18862
19204
|
if (check.clear?.length) {
|