@ordewell/core 0.6.0 → 0.6.2
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 +181 -48
- package/dist/index.d.ts +181 -48
- package/dist/index.js +1207 -127
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1153 -105
- 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-_T5S7Mnk.d.ts → plan-utils-2K_N5ZJe.d.ts} +30 -4
- package/dist/{plan-utils-Ba-l8EZS.d.mts → plan-utils-D94CJ7CA.d.mts} +30 -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.js
CHANGED
|
@@ -67,6 +67,7 @@ __export(index_exports, {
|
|
|
67
67
|
FsPluginStore: () => FsPluginStore,
|
|
68
68
|
GIT_READONLY_SUBCOMMANDS: () => GIT_READONLY_SUBCOMMANDS,
|
|
69
69
|
GREP_DEFAULT_HEAD_LIMIT: () => GREP_DEFAULT_HEAD_LIMIT,
|
|
70
|
+
GREP_PCRE_PROBE_ARGS: () => GREP_PCRE_PROBE_ARGS,
|
|
70
71
|
GeminiService: () => GeminiService,
|
|
71
72
|
HeadlessRunner: () => HeadlessRunner,
|
|
72
73
|
HeadlessSession: () => HeadlessSession,
|
|
@@ -206,6 +207,7 @@ __export(index_exports, {
|
|
|
206
207
|
dependencyCandidates: () => dependencyCandidates,
|
|
207
208
|
dependentsOf: () => dependentsOf,
|
|
208
209
|
describeMergeResult: () => describeMergeResult,
|
|
210
|
+
digestTaskLog: () => digestTaskLog,
|
|
209
211
|
discoverGeminiModels: () => discoverGeminiModels,
|
|
210
212
|
drainNext: () => drainNext,
|
|
211
213
|
effectiveAllowlist: () => effectiveAllowlist,
|
|
@@ -238,6 +240,7 @@ __export(index_exports, {
|
|
|
238
240
|
getStateDir: () => getStateDir,
|
|
239
241
|
globalDataDir: () => globalDataDir,
|
|
240
242
|
grantScopeFor: () => grantScopeFor,
|
|
243
|
+
grepRegexFromProbe: () => grepRegexFromProbe,
|
|
241
244
|
hasHiddenDetail: () => hasHiddenDetail,
|
|
242
245
|
hasTmux: () => hasTmux,
|
|
243
246
|
holdPrompt: () => holdPrompt,
|
|
@@ -400,7 +403,7 @@ function resolveOrderLabel(tasks, label) {
|
|
|
400
403
|
|
|
401
404
|
// src/models/Task.ts
|
|
402
405
|
function isAwaitingReason(value) {
|
|
403
|
-
return value === "input" || value === "checkpoint" || value === "conflict";
|
|
406
|
+
return value === "input" || value === "checkpoint" || value === "conflict" || value === "files-changed";
|
|
404
407
|
}
|
|
405
408
|
function emptyWarnings() {
|
|
406
409
|
return { deletedCompleted: [], changedCompleted: [], deletedInProgress: [], modifiedInProgress: [], brokenDependencies: [] };
|
|
@@ -548,7 +551,8 @@ function createTask(overrides = {}) {
|
|
|
548
551
|
completionMarker: overrides.completionMarker ?? (0, import_uuid.v4)(),
|
|
549
552
|
autonomy: overrides.autonomy,
|
|
550
553
|
sliceType: overrides.sliceType,
|
|
551
|
-
userStoriesCovered: overrides.userStoriesCovered
|
|
554
|
+
userStoriesCovered: overrides.userStoriesCovered,
|
|
555
|
+
...overrides.ops === true && (overrides.type ?? "ai") === "ai" ? { ops: true } : {}
|
|
552
556
|
};
|
|
553
557
|
}
|
|
554
558
|
function createEmptyPlan() {
|
|
@@ -648,6 +652,9 @@ function keepExecutionState(current, rewrite) {
|
|
|
648
652
|
verdict: prior?.verdict,
|
|
649
653
|
outputSummary: prior?.outputSummary,
|
|
650
654
|
transport: prior?.transport,
|
|
655
|
+
// Where a task that has run ran is fixed, like its transport (ADR-0020).
|
|
656
|
+
...prior?.status === "failed" ? { ops: prior.ops } : {},
|
|
657
|
+
forcedPastGate: prior?.forcedPastGate,
|
|
651
658
|
subtasks: overlay(t.subtasks ?? [], prior?.subtasks ?? [])
|
|
652
659
|
};
|
|
653
660
|
});
|
|
@@ -762,6 +769,8 @@ function plannerContextFill(usage) {
|
|
|
762
769
|
var HEAD_LINES = 60;
|
|
763
770
|
var TAIL_LINES = 40;
|
|
764
771
|
var MAX_LINE_CHARS = 2e3;
|
|
772
|
+
var DIGEST_ARGS_CHARS = 120;
|
|
773
|
+
var DIGEST_MESSAGE_CHARS = 1e3;
|
|
765
774
|
function capLine(line) {
|
|
766
775
|
return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
|
|
767
776
|
}
|
|
@@ -844,6 +853,33 @@ function coalesceTaskLog(events) {
|
|
|
844
853
|
}
|
|
845
854
|
return out;
|
|
846
855
|
}
|
|
856
|
+
function digestTaskLog(events, maxCalls) {
|
|
857
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
858
|
+
let lastText;
|
|
859
|
+
let lastError;
|
|
860
|
+
for (const event of events) {
|
|
861
|
+
if (event.type === "tool_result") outcomes.set(event.id, event.success);
|
|
862
|
+
else if (event.type === "text") lastText = event.text;
|
|
863
|
+
else if (event.type === "error") lastError = event.message;
|
|
864
|
+
}
|
|
865
|
+
const lines = [];
|
|
866
|
+
for (const event of events) {
|
|
867
|
+
if (event.type !== "tool_call") continue;
|
|
868
|
+
const outcome = outcomes.get(event.id);
|
|
869
|
+
const args = event.args.length > DIGEST_ARGS_CHARS ? `${event.args.slice(0, DIGEST_ARGS_CHARS)}\u2026` : event.args;
|
|
870
|
+
lines.push(`- ${event.name} ${args} \u2192 ${outcome === void 0 ? "no result" : outcome ? "ok" : "failed"}`);
|
|
871
|
+
}
|
|
872
|
+
const omitted = lines.length - maxCalls;
|
|
873
|
+
const out = omitted > 0 ? [`\u2026 ${omitted} earlier call${omitted === 1 ? "" : "s"} omitted \u2026`, ...lines.slice(omitted)] : lines;
|
|
874
|
+
const gap = () => out.length > 0 ? [""] : [];
|
|
875
|
+
const said2 = lastText?.trim();
|
|
876
|
+
if (said2) {
|
|
877
|
+
const end = said2.length > DIGEST_MESSAGE_CHARS ? `\u2026${said2.slice(-DIGEST_MESSAGE_CHARS)}` : said2;
|
|
878
|
+
out.push(...gap(), "Its last message:", ...end.split("\n").map((l) => ` ${l}`));
|
|
879
|
+
}
|
|
880
|
+
if (lastError) out.push(...gap(), `It stopped with an error: ${lastError}`);
|
|
881
|
+
return out.join("\n");
|
|
882
|
+
}
|
|
847
883
|
|
|
848
884
|
// src/interfaces/IFileSystem.ts
|
|
849
885
|
var GREP_DEFAULT_HEAD_LIMIT = 100;
|
|
@@ -3081,6 +3117,8 @@ function lex(command, nested, dialect) {
|
|
|
3081
3117
|
let unsafeRedirect;
|
|
3082
3118
|
let processSubstitution = false;
|
|
3083
3119
|
let unbalanced = false;
|
|
3120
|
+
let grouped = false;
|
|
3121
|
+
let joinedBy;
|
|
3084
3122
|
let tokens = [];
|
|
3085
3123
|
let expandable = false;
|
|
3086
3124
|
let current = "";
|
|
@@ -3110,13 +3148,14 @@ function lex(command, nested, dialect) {
|
|
|
3110
3148
|
current = "";
|
|
3111
3149
|
started = false;
|
|
3112
3150
|
};
|
|
3113
|
-
const endSegment = (nextPiped) => {
|
|
3151
|
+
const endSegment = (nextPiped, next = "other") => {
|
|
3114
3152
|
endToken();
|
|
3115
|
-
if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs });
|
|
3153
|
+
if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs, joinedBy });
|
|
3116
3154
|
tokens = [];
|
|
3117
3155
|
inputs = [];
|
|
3118
3156
|
expandable = false;
|
|
3119
3157
|
piped = nextPiped;
|
|
3158
|
+
joinedBy = next;
|
|
3120
3159
|
};
|
|
3121
3160
|
let i = 0;
|
|
3122
3161
|
while (i < command.length) {
|
|
@@ -3238,17 +3277,18 @@ function lex(command, nested, dialect) {
|
|
|
3238
3277
|
}
|
|
3239
3278
|
if (c === "(" || c === ")") {
|
|
3240
3279
|
endToken();
|
|
3280
|
+
grouped = true;
|
|
3241
3281
|
i++;
|
|
3242
3282
|
continue;
|
|
3243
3283
|
}
|
|
3244
3284
|
if (c === "|") {
|
|
3245
3285
|
const double = command[i + 1] === "|";
|
|
3246
|
-
endSegment(!double);
|
|
3286
|
+
endSegment(!double, double ? "other" : "pipe");
|
|
3247
3287
|
i += double || command[i + 1] === "&" ? 2 : 1;
|
|
3248
3288
|
continue;
|
|
3249
3289
|
}
|
|
3250
3290
|
if (c === "&" || c === ";" || c === "\n") {
|
|
3251
|
-
endSegment(false);
|
|
3291
|
+
endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
|
|
3252
3292
|
i += command[i + 1] === c ? 2 : 1;
|
|
3253
3293
|
continue;
|
|
3254
3294
|
}
|
|
@@ -3266,7 +3306,7 @@ function lex(command, nested, dialect) {
|
|
|
3266
3306
|
}
|
|
3267
3307
|
if (quote !== "") unbalanced = true;
|
|
3268
3308
|
endSegment(false);
|
|
3269
|
-
return { segments, unsafeRedirect, processSubstitution, unbalanced };
|
|
3309
|
+
return { segments, unsafeRedirect, processSubstitution, unbalanced, grouped };
|
|
3270
3310
|
}
|
|
3271
3311
|
function binaryName(token, dialect) {
|
|
3272
3312
|
const base = token.split(/[/\\]/).pop() ?? "";
|
|
@@ -3406,10 +3446,11 @@ function unwrap(seg, dialect) {
|
|
|
3406
3446
|
}
|
|
3407
3447
|
return { seg: { ...current, assignments }, wrappers, runner };
|
|
3408
3448
|
}
|
|
3409
|
-
function lexAll(command, dialect) {
|
|
3449
|
+
function lexAll(command, dialect, cdpathSet = false) {
|
|
3410
3450
|
const queue = [];
|
|
3411
3451
|
const top = lex(command, queue, dialect);
|
|
3412
3452
|
const all = { ...top, segments: top.segments.filter((s) => s.binary) };
|
|
3453
|
+
if (!top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
|
|
3413
3454
|
for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
|
|
3414
3455
|
const inner = lex(queue.shift(), queue, dialect);
|
|
3415
3456
|
all.segments.push(...inner.segments.filter((s) => s.binary));
|
|
@@ -3419,15 +3460,32 @@ function lexAll(command, dialect) {
|
|
|
3419
3460
|
}
|
|
3420
3461
|
return all;
|
|
3421
3462
|
}
|
|
3463
|
+
function literalCdTarget(seg) {
|
|
3464
|
+
if (seg.binary !== "cd" || seg.assignments.length > 0 || seg.expandable || seg.inputs.length > 0) return void 0;
|
|
3465
|
+
if (seg.args.length !== 1) return void 0;
|
|
3466
|
+
const [target] = seg.args;
|
|
3467
|
+
return target === "" || target.startsWith("-") || target.startsWith("~") || /[*?[]/.test(target) ? void 0 : target;
|
|
3468
|
+
}
|
|
3469
|
+
function followCd(segments) {
|
|
3470
|
+
if (segments.some((s) => s.joinedBy === "other")) return;
|
|
3471
|
+
let chain = [];
|
|
3472
|
+
segments.forEach((seg, i) => {
|
|
3473
|
+
seg.cwd = chain;
|
|
3474
|
+
const inPipeline = seg.joinedBy === "pipe" || segments[i + 1]?.joinedBy === "pipe";
|
|
3475
|
+
const target = inPipeline ? void 0 : literalCdTarget(seg);
|
|
3476
|
+
if (target !== void 0) chain = [...chain, target];
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3422
3479
|
function looksLikePath(arg) {
|
|
3423
3480
|
if (arg.startsWith("--") && arg.includes("=")) return looksLikePath(arg.slice(arg.indexOf("=") + 1));
|
|
3424
3481
|
return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\") || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
|
|
3425
3482
|
}
|
|
3426
|
-
function
|
|
3483
|
+
function pathRefs(command, opts = {}) {
|
|
3427
3484
|
const dialect = dialectFor(opts.dialect);
|
|
3428
|
-
return lexAll(command, dialect).segments.flatMap((seg) => {
|
|
3485
|
+
return lexAll(command, dialect, opts.cdpathSet).segments.flatMap((seg) => {
|
|
3429
3486
|
const patterns = patternArgs(seg);
|
|
3430
|
-
|
|
3487
|
+
const cwd = seg.cwd ?? [];
|
|
3488
|
+
return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path27) => ({ path: path27, cwd }));
|
|
3431
3489
|
});
|
|
3432
3490
|
}
|
|
3433
3491
|
function pathIn(a) {
|
|
@@ -3591,7 +3649,8 @@ function scopeFor(seg) {
|
|
|
3591
3649
|
}
|
|
3592
3650
|
return [seg.binary, ...lead].join(" ");
|
|
3593
3651
|
}
|
|
3594
|
-
function isAuto(seg) {
|
|
3652
|
+
function isAuto(seg, opts) {
|
|
3653
|
+
if (seg.binary === "cd") return !opts.cdpathSet && literalCdTarget(seg) !== void 0;
|
|
3595
3654
|
if (!AUTO_COMMANDS.includes(seg.binary)) return false;
|
|
3596
3655
|
if (seg.expandable) return false;
|
|
3597
3656
|
if (seg.binary !== "git") return true;
|
|
@@ -3686,7 +3745,7 @@ function classifyCommand(command, opts = {}) {
|
|
|
3686
3745
|
const trimmed = command.trim();
|
|
3687
3746
|
if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
|
|
3688
3747
|
const dialect = dialectFor(opts.dialect);
|
|
3689
|
-
const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
|
|
3748
|
+
const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect, opts.cdpathSet);
|
|
3690
3749
|
if (unbalanced) {
|
|
3691
3750
|
return {
|
|
3692
3751
|
tier: "refuse",
|
|
@@ -3723,7 +3782,7 @@ function classifyCommand(command, opts = {}) {
|
|
|
3723
3782
|
};
|
|
3724
3783
|
}
|
|
3725
3784
|
}
|
|
3726
|
-
const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg));
|
|
3785
|
+
const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg, opts));
|
|
3727
3786
|
if (nonAuto.length === 0) return { tier: "auto", scope: "" };
|
|
3728
3787
|
const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
|
|
3729
3788
|
return { tier: "ask", scope };
|
|
@@ -3803,11 +3862,15 @@ function expandHome(target) {
|
|
|
3803
3862
|
if (target === "~" || target.startsWith("~/")) return path2.join(home, target.slice(1));
|
|
3804
3863
|
return path2.join(path2.dirname(home), target.slice(1));
|
|
3805
3864
|
}
|
|
3806
|
-
function resolveWithin(root, target) {
|
|
3807
|
-
const abs = path2.resolve(
|
|
3865
|
+
function resolveWithin(root, target, from = root) {
|
|
3866
|
+
const abs = path2.resolve(from, expandHome(target));
|
|
3808
3867
|
const rel = path2.relative(root, abs);
|
|
3809
3868
|
return { abs, inside: rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel) };
|
|
3810
3869
|
}
|
|
3870
|
+
function resolveRef(root, ref) {
|
|
3871
|
+
const from = ref.cwd.reduce((dir, target) => resolveWithin(root, target, dir).abs, root);
|
|
3872
|
+
return resolveWithin(root, ref.path, from);
|
|
3873
|
+
}
|
|
3811
3874
|
function grantScopeFor(abs, kind) {
|
|
3812
3875
|
if (kind === "directory") return path2.join(abs, "*");
|
|
3813
3876
|
const parent = path2.dirname(abs);
|
|
@@ -4013,14 +4076,17 @@ ${refs.output.trim()}`);
|
|
|
4013
4076
|
outsidePaths(command) {
|
|
4014
4077
|
const root = this.getWorkspaceRoot();
|
|
4015
4078
|
const found = /* @__PURE__ */ new Map();
|
|
4016
|
-
for (const
|
|
4017
|
-
const { abs, inside } =
|
|
4079
|
+
for (const ref of pathRefs(command, this.policyOptions())) {
|
|
4080
|
+
const { abs, inside } = resolveRef(root, ref);
|
|
4018
4081
|
if (inside || isInertDevice(abs)) continue;
|
|
4019
4082
|
const scope = grantScopeFor(abs, "file");
|
|
4020
4083
|
if (!found.has(scope)) found.set(scope, abs);
|
|
4021
4084
|
}
|
|
4022
4085
|
return [...found].map(([scope, abs]) => ({ abs, scope }));
|
|
4023
4086
|
}
|
|
4087
|
+
policyOptions() {
|
|
4088
|
+
return { dialect: this.researchShell.dialect, cdpathSet: !!process.env.CDPATH };
|
|
4089
|
+
}
|
|
4024
4090
|
/**
|
|
4025
4091
|
* Three tiers (see `commandPolicy.ts`): read-only inspection runs silently,
|
|
4026
4092
|
* anything else asks once and is remembered, and writes are refused outright
|
|
@@ -4032,7 +4098,7 @@ ${refs.output.trim()}`);
|
|
|
4032
4098
|
* directory the next command names.
|
|
4033
4099
|
*/
|
|
4034
4100
|
async bash(command, signal) {
|
|
4035
|
-
const { tier, scope, reason } = classifyCommand(command,
|
|
4101
|
+
const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
|
|
4036
4102
|
if (tier === "refuse") {
|
|
4037
4103
|
return { success: false, output: `Command refused: ${reason}`, truncated: false };
|
|
4038
4104
|
}
|
|
@@ -5208,10 +5274,78 @@ function buildGrepArgs(pattern, opts, root) {
|
|
|
5208
5274
|
function buildGlobArgs(pattern, root) {
|
|
5209
5275
|
return ["--files", ...SORT_BY_RECENCY, "--glob", pattern, ...exclusionArgs(), "--", root];
|
|
5210
5276
|
}
|
|
5211
|
-
|
|
5277
|
+
var GREP_PCRE_PROBE_ARGS = ["-P", "-e", "x", "/dev/null"];
|
|
5278
|
+
function grepRegexFromProbe(exitCode) {
|
|
5279
|
+
return exitCode === 0 || exitCode === 1 ? "pcre" : "ere";
|
|
5280
|
+
}
|
|
5281
|
+
function toPosixClasses(pattern) {
|
|
5282
|
+
const CLASS_ESCAPES = { s: "[:space:]", w: "[:alnum:]_", d: "[:digit:]", t: " " };
|
|
5283
|
+
let out = "";
|
|
5284
|
+
let i = 0;
|
|
5285
|
+
while (i < pattern.length) {
|
|
5286
|
+
const c = pattern[i];
|
|
5287
|
+
if (c === "\\") {
|
|
5288
|
+
out += pattern.slice(i, i + 2);
|
|
5289
|
+
i += 2;
|
|
5290
|
+
continue;
|
|
5291
|
+
}
|
|
5292
|
+
if (c !== "[") {
|
|
5293
|
+
out += c;
|
|
5294
|
+
i++;
|
|
5295
|
+
continue;
|
|
5296
|
+
}
|
|
5297
|
+
const start = i;
|
|
5298
|
+
i++;
|
|
5299
|
+
let negate = false;
|
|
5300
|
+
if (pattern[i] === "^") {
|
|
5301
|
+
negate = true;
|
|
5302
|
+
i++;
|
|
5303
|
+
}
|
|
5304
|
+
let body = "";
|
|
5305
|
+
let closeBracket = false;
|
|
5306
|
+
let openBracket = false;
|
|
5307
|
+
let dash = false;
|
|
5308
|
+
let closed = false;
|
|
5309
|
+
while (i < pattern.length) {
|
|
5310
|
+
const ch = pattern[i];
|
|
5311
|
+
if (ch === "]") {
|
|
5312
|
+
closed = true;
|
|
5313
|
+
i++;
|
|
5314
|
+
break;
|
|
5315
|
+
}
|
|
5316
|
+
if (ch === "[" && pattern[i + 1] === ":") {
|
|
5317
|
+
const end = pattern.indexOf(":]", i + 2);
|
|
5318
|
+
if (end !== -1) {
|
|
5319
|
+
body += pattern.slice(i, end + 2);
|
|
5320
|
+
i = end + 2;
|
|
5321
|
+
continue;
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
if (ch === "\\" && i + 1 < pattern.length) {
|
|
5325
|
+
const e = pattern[i + 1];
|
|
5326
|
+
i += 2;
|
|
5327
|
+
if (e in CLASS_ESCAPES) body += CLASS_ESCAPES[e];
|
|
5328
|
+
else if (e === "]") closeBracket = true;
|
|
5329
|
+
else if (e === "[") openBracket = true;
|
|
5330
|
+
else if (e === "-") dash = true;
|
|
5331
|
+
else body += e;
|
|
5332
|
+
continue;
|
|
5333
|
+
}
|
|
5334
|
+
body += ch;
|
|
5335
|
+
i++;
|
|
5336
|
+
}
|
|
5337
|
+
if (!closed) {
|
|
5338
|
+
out += pattern.slice(start);
|
|
5339
|
+
break;
|
|
5340
|
+
}
|
|
5341
|
+
out += `[${negate ? "^" : ""}${closeBracket ? "]" : ""}${body}${openBracket ? "[" : ""}${dash ? "-" : ""}]`;
|
|
5342
|
+
}
|
|
5343
|
+
return out;
|
|
5344
|
+
}
|
|
5345
|
+
function buildFallbackGrepArgs(pattern, opts, root, regex = "pcre") {
|
|
5212
5346
|
const args = ["-r", "-n"];
|
|
5213
5347
|
if (opts.literal) args.push("-F");
|
|
5214
|
-
else args.push("-P");
|
|
5348
|
+
else args.push(regex === "pcre" ? "-P" : "-E");
|
|
5215
5349
|
if (opts.caseInsensitive) args.push("-i");
|
|
5216
5350
|
if (opts.outputMode === "files") args.push("-l");
|
|
5217
5351
|
if (opts.outputMode === "count") args.push("-c");
|
|
@@ -5221,7 +5355,7 @@ function buildFallbackGrepArgs(pattern, opts, root) {
|
|
|
5221
5355
|
}
|
|
5222
5356
|
if (opts.include) args.push(`--include=${opts.include}`);
|
|
5223
5357
|
for (const dir of SEARCH_EXCLUSIONS) args.push(`--exclude-dir=${dir}`);
|
|
5224
|
-
args.push("-e", pattern, root);
|
|
5358
|
+
args.push("-e", !opts.literal && regex === "ere" ? toPosixClasses(pattern) : pattern, root);
|
|
5225
5359
|
return args;
|
|
5226
5360
|
}
|
|
5227
5361
|
function globSegmentToRegex(segment) {
|
|
@@ -6168,7 +6302,7 @@ function taskIsolationOf(record, repairLimit) {
|
|
|
6168
6302
|
};
|
|
6169
6303
|
}
|
|
6170
6304
|
function handoffOf(run) {
|
|
6171
|
-
const merged = Object.values(run.tasks).filter((r) => r.status === "merged").sort((a, b) => a.order - b.order);
|
|
6305
|
+
const merged = Object.values(run.tasks).filter((r) => r.status === "merged" && !r.inHead).sort((a, b) => a.order - b.order);
|
|
6172
6306
|
const entry = (r) => ({
|
|
6173
6307
|
taskId: r.taskId,
|
|
6174
6308
|
order: r.order,
|
|
@@ -6550,6 +6684,18 @@ function leftoverMarkerFiles(check) {
|
|
|
6550
6684
|
const files = check.split(/\r?\n/).flatMap((line) => /^(.+):\d+: leftover conflict marker$/.exec(line)?.[1] ?? []);
|
|
6551
6685
|
return [...new Set(files)];
|
|
6552
6686
|
}
|
|
6687
|
+
function rawDiffStates(raw) {
|
|
6688
|
+
const parts = raw.split("\0");
|
|
6689
|
+
const states = {};
|
|
6690
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
6691
|
+
const meta = parts[i].trim();
|
|
6692
|
+
const file = parts[i + 1];
|
|
6693
|
+
if (!meta.startsWith(":") || !file) continue;
|
|
6694
|
+
const [, mode, , blob] = meta.slice(1).split(" ");
|
|
6695
|
+
states[file] = `${mode}:${blob}`;
|
|
6696
|
+
}
|
|
6697
|
+
return states;
|
|
6698
|
+
}
|
|
6553
6699
|
function settleStatus(record, status) {
|
|
6554
6700
|
record.status = status;
|
|
6555
6701
|
delete record.repairBase;
|
|
@@ -6649,6 +6795,8 @@ var GitWorktreeIsolation = class {
|
|
|
6649
6795
|
adminChains = /* @__PURE__ */ new Map();
|
|
6650
6796
|
waiting = [];
|
|
6651
6797
|
draining = false;
|
|
6798
|
+
/** Landings and Merge all take turns on this, so a merge never reads an integration branch mid-landing. */
|
|
6799
|
+
turns = Promise.resolve();
|
|
6652
6800
|
async isActive(workspaceRoot) {
|
|
6653
6801
|
if (!this.deps.config.worktreeIsolation) return { active: false, reason: "disabled" };
|
|
6654
6802
|
const version = await this.tryGit(void 0, ["--version"]);
|
|
@@ -7005,6 +7153,62 @@ var GitWorktreeIsolation = class {
|
|
|
7005
7153
|
return !linked.has(rel) && !linked.has(rel.split("/")[0]);
|
|
7006
7154
|
});
|
|
7007
7155
|
}
|
|
7156
|
+
async findInHead(run) {
|
|
7157
|
+
const found = [];
|
|
7158
|
+
for (const record of Object.values(run.tasks)) {
|
|
7159
|
+
if (record.status !== "merged" || record.inHead) continue;
|
|
7160
|
+
if (!await this.headContains(run, record)) continue;
|
|
7161
|
+
record.inHead = true;
|
|
7162
|
+
found.push(record.taskId);
|
|
7163
|
+
}
|
|
7164
|
+
return found;
|
|
7165
|
+
}
|
|
7166
|
+
async headContains(run, record) {
|
|
7167
|
+
for (const repo of run.repos) {
|
|
7168
|
+
const entry = record.repos[repo.path];
|
|
7169
|
+
if (!entry?.changed) continue;
|
|
7170
|
+
const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
|
|
7171
|
+
if (!merged.ok) return false;
|
|
7172
|
+
}
|
|
7173
|
+
return true;
|
|
7174
|
+
}
|
|
7175
|
+
async snapshotTree(workspaceRoot, exclude) {
|
|
7176
|
+
const scan = await this.scanGroup(workspaceRoot);
|
|
7177
|
+
if ("refused" in scan) return null;
|
|
7178
|
+
const repos = (await this.committedRepos(workspaceRoot, scan.paths)).filter((repoPath) => !exclude.includes(repoPath));
|
|
7179
|
+
if (repos.length === 0) return null;
|
|
7180
|
+
const snapshot = {};
|
|
7181
|
+
for (const repoPath of repos) {
|
|
7182
|
+
const states = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
|
|
7183
|
+
if (states) snapshot[repoPath] = states;
|
|
7184
|
+
}
|
|
7185
|
+
return snapshot;
|
|
7186
|
+
}
|
|
7187
|
+
async changedSince(workspaceRoot, snapshot) {
|
|
7188
|
+
const changed = [];
|
|
7189
|
+
for (const [repoPath, before] of Object.entries(snapshot)) {
|
|
7190
|
+
const now = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
|
|
7191
|
+
if (!now) continue;
|
|
7192
|
+
for (const [file, state] of Object.entries(now)) {
|
|
7193
|
+
if (before[file] !== state) changed.push(repoPath === SELF_REPO ? file : `${repoPath}/${file}`);
|
|
7194
|
+
}
|
|
7195
|
+
}
|
|
7196
|
+
return changed.sort();
|
|
7197
|
+
}
|
|
7198
|
+
/**
|
|
7199
|
+
* Each tracked file that differs from HEAD, with what it holds. `git stash
|
|
7200
|
+
* create` records the working tree as a commit without touching the tree,
|
|
7201
|
+
* the index or the stash, so a file changed again is told apart from one
|
|
7202
|
+
* left as it was. Null when git cannot tell — a merge in progress, say.
|
|
7203
|
+
*/
|
|
7204
|
+
async trackedStates(root) {
|
|
7205
|
+
const stash = await this.tryGit(root, ["stash", "create"]);
|
|
7206
|
+
if (!stash.ok) return null;
|
|
7207
|
+
const commit = stash.stdout.trim();
|
|
7208
|
+
if (!commit) return {};
|
|
7209
|
+
const diff = await this.tryGit(root, ["diff", "--raw", "--no-abbrev", "--no-renames", "-z", "HEAD", commit]);
|
|
7210
|
+
return diff.ok ? rawDiffStates(diff.stdout) : null;
|
|
7211
|
+
}
|
|
7008
7212
|
/**
|
|
7009
7213
|
* One section per repo with changes. A repo below the workspace root gets a
|
|
7010
7214
|
* header and paths prefixed with its own, so the whole reads as one patch
|
|
@@ -7022,7 +7226,10 @@ var GitWorktreeIsolation = class {
|
|
|
7022
7226
|
}
|
|
7023
7227
|
return diff;
|
|
7024
7228
|
}
|
|
7025
|
-
|
|
7229
|
+
mergeIntoCheckedOut(run) {
|
|
7230
|
+
return this.inTurn(() => this.mergeAll(run));
|
|
7231
|
+
}
|
|
7232
|
+
async mergeAll(run) {
|
|
7026
7233
|
const partial = await this.settleLanding(run);
|
|
7027
7234
|
if (partial.length > 0) return { outcome: "blocked", blocked: partial.map((repo) => ({ repo, reason: "partial-landing", files: [] })) };
|
|
7028
7235
|
const repos = await this.reposWithWork(run);
|
|
@@ -7156,7 +7363,7 @@ var GitWorktreeIsolation = class {
|
|
|
7156
7363
|
if (entry2.run.id === runId && (pick < 0 || entry2.task.order < this.waiting[pick].task.order)) pick = i;
|
|
7157
7364
|
});
|
|
7158
7365
|
const [entry] = this.waiting.splice(pick, 1);
|
|
7159
|
-
entry.settle(await this.land(entry.task, entry.run, entry.persist).catch(() => "failed"));
|
|
7366
|
+
entry.settle(await this.inTurn(() => this.land(entry.task, entry.run, entry.persist)).catch(() => "failed"));
|
|
7160
7367
|
}
|
|
7161
7368
|
} finally {
|
|
7162
7369
|
this.draining = false;
|
|
@@ -7208,6 +7415,10 @@ var GitWorktreeIsolation = class {
|
|
|
7208
7415
|
return (await this.settleLanding(run)).length === 0 ? this.stopLanding(record, outcome, repo, files, error) : this.stopLanding(record, "failed", repo);
|
|
7209
7416
|
}
|
|
7210
7417
|
}
|
|
7418
|
+
for (const repo of changed) {
|
|
7419
|
+
const tip = await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", repo.integrationBranch]);
|
|
7420
|
+
if (tip.ok) record.repos[repo.path].landedTip = tip.stdout.trim();
|
|
7421
|
+
}
|
|
7211
7422
|
delete run.landing;
|
|
7212
7423
|
settleStatus(record, "merged");
|
|
7213
7424
|
delete record.conflictRepo;
|
|
@@ -7482,6 +7693,11 @@ var GitWorktreeIsolation = class {
|
|
|
7482
7693
|
async abortMerge(cwd) {
|
|
7483
7694
|
if (!(await this.tryGit(cwd, ["merge", "--abort"])).ok) await this.tryGit(cwd, ["reset", "--hard"]);
|
|
7484
7695
|
}
|
|
7696
|
+
inTurn(fn) {
|
|
7697
|
+
const turn = this.turns.then(fn, fn);
|
|
7698
|
+
this.turns = turn.catch(() => void 0);
|
|
7699
|
+
return turn;
|
|
7700
|
+
}
|
|
7485
7701
|
admin(repoKey, fn) {
|
|
7486
7702
|
const prior = this.adminChains.get(repoKey) ?? Promise.resolve();
|
|
7487
7703
|
const next = prior.then(fn, fn);
|
|
@@ -8155,8 +8371,22 @@ function researchPhaseBlock(harnessMode) {
|
|
|
8155
8371
|
"- Research and questions can interleave \u2014 you can explore a file, then ask a question grounded in it, in one response"
|
|
8156
8372
|
].join("\n");
|
|
8157
8373
|
}
|
|
8158
|
-
var ISOLATED_PARALLELISM_RULE = "- Each
|
|
8374
|
+
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.";
|
|
8159
8375
|
var OVERLAP_AVOIDANCE_RULE = "- For parallel tasks, specify different target files to avoid merge conflicts.";
|
|
8376
|
+
function opsTaskSection(conversation) {
|
|
8377
|
+
return [
|
|
8378
|
+
"",
|
|
8379
|
+
"CHANGE AND OPS TASKS:",
|
|
8380
|
+
"- 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?",
|
|
8381
|
+
'- A change task (the default) edits repository files. Omit "ops".',
|
|
8382
|
+
`- 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.`,
|
|
8383
|
+
`- 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.`,
|
|
8384
|
+
'- 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.',
|
|
8385
|
+
"- 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.",
|
|
8386
|
+
"- An ops task runs until what it acts on has finished: one that watches a pipeline reports done only when the pipeline has ended.",
|
|
8387
|
+
...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.`] : []
|
|
8388
|
+
];
|
|
8389
|
+
}
|
|
8160
8390
|
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.";
|
|
8161
8391
|
function repoGroupSection(isolated) {
|
|
8162
8392
|
if (!isolated) return [];
|
|
@@ -8236,6 +8466,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
|
|
|
8236
8466
|
' "taskMode": "' + modeExamples + '",',
|
|
8237
8467
|
' "autonomy": "AFK|HITL",',
|
|
8238
8468
|
' "sliceType": "AFK|HITL",',
|
|
8469
|
+
' "ops": true,',
|
|
8239
8470
|
' "userStoriesCovered": ["user story text"],',
|
|
8240
8471
|
' "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": [] }]',
|
|
8241
8472
|
" }",
|
|
@@ -8261,6 +8492,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
|
|
|
8261
8492
|
SHARED_APPEND_FILE_RULE,
|
|
8262
8493
|
...variant.isolatedExecution ? [ISOLATED_PARALLELISM_RULE] : [],
|
|
8263
8494
|
...repoGroupSection(variant.isolatedExecution),
|
|
8495
|
+
...opsTaskSection(true),
|
|
8264
8496
|
"",
|
|
8265
8497
|
"RULES:",
|
|
8266
8498
|
"- Do NOT wrap the JSON in markdown code blocks. Output ONLY the JSON object when committing the plan.",
|
|
@@ -8336,6 +8568,7 @@ function corePlannerPrompt(isolatedExecution) {
|
|
|
8336
8568
|
' "taskMode": "{{MODE_EXAMPLES}}",',
|
|
8337
8569
|
' "autonomy": "AFK|HITL",',
|
|
8338
8570
|
' "sliceType": "AFK|HITL",',
|
|
8571
|
+
' "ops": true,',
|
|
8339
8572
|
' "userStoriesCovered": ["user story text"],',
|
|
8340
8573
|
' "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": [] }]',
|
|
8341
8574
|
" }",
|
|
@@ -8363,6 +8596,7 @@ function corePlannerPrompt(isolatedExecution) {
|
|
|
8363
8596
|
SHARED_APPEND_FILE_RULE,
|
|
8364
8597
|
isolatedExecution ? ISOLATED_PARALLELISM_RULE : "- Slices that touch different areas of the codebase are naturally parallel.",
|
|
8365
8598
|
...repoGroupSection(isolatedExecution),
|
|
8599
|
+
...opsTaskSection(false),
|
|
8366
8600
|
"",
|
|
8367
8601
|
"RULES:",
|
|
8368
8602
|
'- Mark as type "ai" any task the coding assistant can do autonomously.',
|
|
@@ -8759,7 +8993,9 @@ function parseTask(raw, runners, runnerModes, autonomousDefault = true, inherite
|
|
|
8759
8993
|
),
|
|
8760
8994
|
autonomy,
|
|
8761
8995
|
sliceType,
|
|
8762
|
-
userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0
|
|
8996
|
+
userStoriesCovered: Array.isArray(raw.userStoriesCovered) ? raw.userStoriesCovered.map(String) : void 0,
|
|
8997
|
+
// A subtask runs with its parent, so only a top-level AI task carries it (ADR-0020).
|
|
8998
|
+
ops: !inherited && taskType === "ai" && raw.ops === true
|
|
8763
8999
|
});
|
|
8764
9000
|
}
|
|
8765
9001
|
function validateVerticalSliceShape(tasks, raw, parentTitle) {
|
|
@@ -9013,7 +9249,7 @@ function coerceAssignments(tasks, perRunnerAllowlist, allowedRunners, modelsByRu
|
|
|
9013
9249
|
}
|
|
9014
9250
|
|
|
9015
9251
|
// src/services/TaskEditValidator.ts
|
|
9016
|
-
var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy"];
|
|
9252
|
+
var AI_ONLY_FIELDS = ["assignedModel", "thinkingEffort", "taskMode", "autonomy", "ops"];
|
|
9017
9253
|
function checkModelAndModeValidity(runner, assignedModel, taskMode, catalog) {
|
|
9018
9254
|
if (!catalog) return { ok: true };
|
|
9019
9255
|
if (assignedModel) {
|
|
@@ -9050,6 +9286,19 @@ function typeCoherenceCheck(target, changes) {
|
|
|
9050
9286
|
}
|
|
9051
9287
|
return { ok: true, clear: target.userSteps && target.userSteps.length > 0 ? ["userSteps"] : [] };
|
|
9052
9288
|
}
|
|
9289
|
+
var NOT_STARTED = /* @__PURE__ */ new Set(["pending", "approved", "blocked"]);
|
|
9290
|
+
function opsCheck(tasks, target, changes) {
|
|
9291
|
+
if (!("ops" in changes) || !!changes.ops === !!target.ops) return null;
|
|
9292
|
+
if (!NOT_STARTED.has(target.status)) {
|
|
9293
|
+
return { ok: false, error: `"${target.title}" has started, so whether it is an ops task is fixed` };
|
|
9294
|
+
}
|
|
9295
|
+
if (!changes.ops) return null;
|
|
9296
|
+
if ((changes.type ?? target.type) !== "ai") return { ok: false, error: `"${target.title}" is a manual task \u2014 only an AI task can be an ops task` };
|
|
9297
|
+
if (tasks.some((t) => t.subtasks?.some((s) => s.id === target.id))) {
|
|
9298
|
+
return { ok: false, error: `"${target.title}" is a subtask, which runs with its parent \u2014 only a top-level task can be an ops task` };
|
|
9299
|
+
}
|
|
9300
|
+
return null;
|
|
9301
|
+
}
|
|
9053
9302
|
function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
|
|
9054
9303
|
const target = tasks.find((t) => t.id === taskId);
|
|
9055
9304
|
if (!target) return { ok: false, error: "Task not found" };
|
|
@@ -9063,6 +9312,8 @@ function validateTaskEdit(actor, tasks, taskId, changes, catalog) {
|
|
|
9063
9312
|
}
|
|
9064
9313
|
const coherence = typeCoherenceCheck(target, changes);
|
|
9065
9314
|
if (coherence && !coherence.ok) return coherence;
|
|
9315
|
+
const ops = opsCheck(tasks, target, changes);
|
|
9316
|
+
if (ops) return ops;
|
|
9066
9317
|
const runner = changes.assignedRunner ?? target.assignedRunner;
|
|
9067
9318
|
const validity = checkModelAndModeValidity(runner, changes.assignedModel, changes.taskMode, catalog);
|
|
9068
9319
|
if (!validity.ok) return validity;
|
|
@@ -9082,7 +9333,8 @@ var UPDATABLE_FIELDS = [
|
|
|
9082
9333
|
"type",
|
|
9083
9334
|
"userSteps",
|
|
9084
9335
|
"autonomy",
|
|
9085
|
-
"sliceType"
|
|
9336
|
+
"sliceType",
|
|
9337
|
+
"ops"
|
|
9086
9338
|
];
|
|
9087
9339
|
function taskOpsProtocol(executionNote = "") {
|
|
9088
9340
|
const updateChanges = UPDATABLE_FIELDS.map((f) => f === "dependencies" ? '"dependencies"?:["<id or #order>"]' : `"${f}"?`).join(",");
|
|
@@ -9090,7 +9342,7 @@ function taskOpsProtocol(executionNote = "") {
|
|
|
9090
9342
|
"- To modify specific tasks, reply with ONLY this JSON object:",
|
|
9091
9343
|
' {"taskOps": [',
|
|
9092
9344
|
` {"op":"update","taskId":"<id or #order>","changes":{${updateChanges}}},`,
|
|
9093
|
-
' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?},"handle"?:"<name>"},',
|
|
9345
|
+
' {"op":"add","task":{"title","description","prompt","dependencies":["<id or #order>"],"assignedRunner"?,"assignedModel"?,"ops"?},"handle"?:"<name>"},',
|
|
9094
9346
|
' {"op":"remove","taskId":"<id or #order>"},',
|
|
9095
9347
|
' {"op":"reorder","taskIds":["<id or #order>", "... every task exactly once"]}, // only to re-prioritise INDEPENDENT tasks',
|
|
9096
9348
|
' {"op":"merge","taskIds":["<id or #order>", "..."],"merged":{"title","description","prompt"?,"assignedRunner"?,"assignedModel"?,...},"handle"?:"<name>"},',
|
|
@@ -9142,7 +9394,7 @@ function parseTaskOpsObject(json, text2) {
|
|
|
9142
9394
|
}
|
|
9143
9395
|
return ops;
|
|
9144
9396
|
}
|
|
9145
|
-
function
|
|
9397
|
+
function resolveRef2(ref, originalScope, liveScope, handleOwner, handleId, opIndex) {
|
|
9146
9398
|
if (typeof ref !== "string" && typeof ref !== "number") return { error: `invalid reference "${String(ref)}"` };
|
|
9147
9399
|
const s = String(ref).trim();
|
|
9148
9400
|
const ownerIdx = handleOwner.get(s);
|
|
@@ -9193,6 +9445,14 @@ function refCollidesWithExisting(name, originalFlatAll) {
|
|
|
9193
9445
|
if (/^\d+$/.test(orderStr) && originalFlatAll.some((t) => t.order === Number(orderStr))) return true;
|
|
9194
9446
|
return originalFlatAll.some((t) => t.title === name);
|
|
9195
9447
|
}
|
|
9448
|
+
function updatableChanges(raw) {
|
|
9449
|
+
const changes = {};
|
|
9450
|
+
for (const key of UPDATABLE_FIELDS) {
|
|
9451
|
+
if (raw && key in raw) changes[key] = raw[key];
|
|
9452
|
+
}
|
|
9453
|
+
if ("ops" in changes) changes.ops = changes.ops === true ? true : void 0;
|
|
9454
|
+
return changes;
|
|
9455
|
+
}
|
|
9196
9456
|
function findInTasks(tasks, id) {
|
|
9197
9457
|
return flattenTasks(tasks).find((t) => t.id === id);
|
|
9198
9458
|
}
|
|
@@ -9347,7 +9607,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9347
9607
|
const bad = [];
|
|
9348
9608
|
const liveFlat = flattenTasks(tasks);
|
|
9349
9609
|
for (const d of Array.isArray(deps) ? deps : []) {
|
|
9350
|
-
const r =
|
|
9610
|
+
const r = resolveRef2(d, originalFlatAll, liveFlat, handleOwner, handleId, opIndex);
|
|
9351
9611
|
if ("error" in r) {
|
|
9352
9612
|
bad.push(`${String(d)} (${r.error})`);
|
|
9353
9613
|
continue;
|
|
@@ -9364,7 +9624,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9364
9624
|
const label = `op ${i + 1} (${op.op})`;
|
|
9365
9625
|
switch (op.op) {
|
|
9366
9626
|
case "update": {
|
|
9367
|
-
const ref =
|
|
9627
|
+
const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
|
|
9368
9628
|
if ("error" in ref) {
|
|
9369
9629
|
errors.push(`${label}: ${ref.error}`);
|
|
9370
9630
|
break;
|
|
@@ -9374,10 +9634,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9374
9634
|
errors.push(`${label}: task "${op.taskId}" no longer exists in this batch (removed, merged, or split by an earlier op)`);
|
|
9375
9635
|
break;
|
|
9376
9636
|
}
|
|
9377
|
-
const changes =
|
|
9378
|
-
for (const key of UPDATABLE_FIELDS) {
|
|
9379
|
-
if (op.changes && key in op.changes) changes[key] = op.changes[key];
|
|
9380
|
-
}
|
|
9637
|
+
const changes = updatableChanges(op.changes);
|
|
9381
9638
|
if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
|
|
9382
9639
|
errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
|
|
9383
9640
|
break;
|
|
@@ -9438,7 +9695,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9438
9695
|
break;
|
|
9439
9696
|
}
|
|
9440
9697
|
case "remove": {
|
|
9441
|
-
const ref =
|
|
9698
|
+
const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
|
|
9442
9699
|
if ("error" in ref) {
|
|
9443
9700
|
errors.push(`${label}: ${ref.error}`);
|
|
9444
9701
|
break;
|
|
@@ -9461,7 +9718,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9461
9718
|
break;
|
|
9462
9719
|
}
|
|
9463
9720
|
case "reorder": {
|
|
9464
|
-
const refs = (op.taskIds ?? []).map((r) =>
|
|
9721
|
+
const refs = (op.taskIds ?? []).map((r) => resolveRef2(r, currentTasks, tasks, handleOwner, handleId, i));
|
|
9465
9722
|
const badRef = refs.find((r) => "error" in r);
|
|
9466
9723
|
if (badRef) {
|
|
9467
9724
|
errors.push(`${label}: ${badRef.error}`);
|
|
@@ -9483,7 +9740,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9483
9740
|
break;
|
|
9484
9741
|
}
|
|
9485
9742
|
case "merge": {
|
|
9486
|
-
const refs = (op.taskIds ?? []).map((r) =>
|
|
9743
|
+
const refs = (op.taskIds ?? []).map((r) => resolveRef2(r, currentTasks, tasks, handleOwner, handleId, i));
|
|
9487
9744
|
const badRef = refs.find((r) => "error" in r);
|
|
9488
9745
|
if (badRef) {
|
|
9489
9746
|
errors.push(`${label}: ${badRef.error}`);
|
|
@@ -9529,6 +9786,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9529
9786
|
taskMode: spec.taskMode ?? toMerge[0].taskMode,
|
|
9530
9787
|
autonomy: spec.autonomy ?? toMerge.find((t) => t.autonomy)?.autonomy,
|
|
9531
9788
|
sliceType: spec.sliceType ?? toMerge.find((t) => t.sliceType)?.sliceType,
|
|
9789
|
+
ops: spec.ops ?? toMerge.every((t) => t.ops),
|
|
9532
9790
|
userStoriesCovered: spec.userStoriesCovered ?? (toMerge.flatMap((t) => t.userStoriesCovered ?? []).length ? [...new Set(toMerge.flatMap((t) => t.userStoriesCovered ?? []))] : void 0)
|
|
9533
9791
|
});
|
|
9534
9792
|
const mergedId = mergedTask.id;
|
|
@@ -9552,7 +9810,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9552
9810
|
break;
|
|
9553
9811
|
}
|
|
9554
9812
|
case "split": {
|
|
9555
|
-
const ref =
|
|
9813
|
+
const ref = resolveRef2(op.taskId, currentTasks, tasks, handleOwner, handleId, i);
|
|
9556
9814
|
if ("error" in ref) {
|
|
9557
9815
|
errors.push(`${label}: ${ref.error}`);
|
|
9558
9816
|
break;
|
|
@@ -9609,6 +9867,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9609
9867
|
taskMode: spec.taskMode ?? target.taskMode,
|
|
9610
9868
|
autonomy: spec.autonomy ?? target.autonomy,
|
|
9611
9869
|
sliceType: spec.sliceType ?? target.sliceType,
|
|
9870
|
+
ops: spec.ops ?? target.ops,
|
|
9612
9871
|
userStoriesCovered: spec.userStoriesCovered ?? target.userStoriesCovered
|
|
9613
9872
|
});
|
|
9614
9873
|
newTasks.push(nt);
|
|
@@ -9630,7 +9889,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9630
9889
|
break;
|
|
9631
9890
|
}
|
|
9632
9891
|
case "rearm": {
|
|
9633
|
-
const ref =
|
|
9892
|
+
const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
|
|
9634
9893
|
if ("error" in ref) {
|
|
9635
9894
|
errors.push(`${label}: ${ref.error}`);
|
|
9636
9895
|
break;
|
|
@@ -9644,10 +9903,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
|
|
|
9644
9903
|
errors.push(`${label}: "${target.title}" is running and cannot be re-armed`);
|
|
9645
9904
|
break;
|
|
9646
9905
|
}
|
|
9647
|
-
const changes =
|
|
9648
|
-
for (const key of UPDATABLE_FIELDS) {
|
|
9649
|
-
if (op.changes && key in op.changes) changes[key] = op.changes[key];
|
|
9650
|
-
}
|
|
9906
|
+
const changes = updatableChanges(op.changes);
|
|
9651
9907
|
if (changes.assignedRunner && !runners.includes(changes.assignedRunner)) {
|
|
9652
9908
|
errors.push(`${label}: runner "${changes.assignedRunner}" is not in this plan's runner set [${runners.join(", ")}]`);
|
|
9653
9909
|
break;
|
|
@@ -10277,14 +10533,15 @@ function nonPromptingFs(fs16) {
|
|
|
10277
10533
|
listDir: (p, depth) => withinWorkspace(p) ? fs16.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
|
|
10278
10534
|
bash: async (command, signal) => {
|
|
10279
10535
|
const dialect = resolveResearchShell().dialect;
|
|
10280
|
-
const
|
|
10536
|
+
const policy = { dialect, cdpathSet: !!process.env.CDPATH };
|
|
10537
|
+
const { tier, reason } = classifyCommand(command, policy);
|
|
10281
10538
|
if (tier !== "auto") {
|
|
10282
10539
|
return refused2(
|
|
10283
10540
|
tier === "refuse" ? `Command refused: ${reason}` : `"${command}" needs user approval, and research agents run in the background where nothing can be approved. Use the read-only research tools, and note in your digest that this command would answer the question.`
|
|
10284
10541
|
);
|
|
10285
10542
|
}
|
|
10286
|
-
const escaping =
|
|
10287
|
-
if (escaping) return outsideRefusal(escaping);
|
|
10543
|
+
const escaping = pathRefs(command, policy).find((ref) => !resolveRef(fs16.getWorkspaceRoot(), ref).inside);
|
|
10544
|
+
if (escaping) return outsideRefusal(escaping.path);
|
|
10288
10545
|
return fs16.bash(command, signal);
|
|
10289
10546
|
}
|
|
10290
10547
|
};
|
|
@@ -11477,6 +11734,17 @@ You are running as: ${currentTask.assignedRunner}` : "";
|
|
|
11477
11734
|
lines.join("\n") + footer + runnerNote
|
|
11478
11735
|
].join("\n");
|
|
11479
11736
|
}
|
|
11737
|
+
function renderPreviousAttempt(output) {
|
|
11738
|
+
const tail = output.trim() ? defuseMarkers(output.trim()).split("\n").map((l) => " " + l).join("\n") : " (no output captured)";
|
|
11739
|
+
return [
|
|
11740
|
+
"## Previous attempt",
|
|
11741
|
+
"",
|
|
11742
|
+
"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.",
|
|
11743
|
+
"",
|
|
11744
|
+
"The end of its output:",
|
|
11745
|
+
tail
|
|
11746
|
+
].join("\n");
|
|
11747
|
+
}
|
|
11480
11748
|
function renderCompletionMarker(task) {
|
|
11481
11749
|
return `
|
|
11482
11750
|
|
|
@@ -11528,6 +11796,7 @@ function composeAugmentedPrompt(task, allTasks, opts) {
|
|
|
11528
11796
|
const flat = flattenTasks(allTasks);
|
|
11529
11797
|
const outputs = collectDirectDependencyOutputs(task, flat);
|
|
11530
11798
|
if (outputs.length > 0) blocks.push(renderPriorOutputs(outputs));
|
|
11799
|
+
if (opts?.previousAttempt !== void 0) blocks.push(renderPreviousAttempt(opts.previousAttempt));
|
|
11531
11800
|
if (opts?.tddEnabled) {
|
|
11532
11801
|
blocks.push(renderTddInstruction());
|
|
11533
11802
|
}
|
|
@@ -11540,9 +11809,9 @@ function composeAugmentedPrompt(task, allTasks, opts) {
|
|
|
11540
11809
|
|
|
11541
11810
|
${basePrompt}${marker2}`;
|
|
11542
11811
|
}
|
|
11543
|
-
function composeContinuationPrompt(task, message) {
|
|
11812
|
+
function composeContinuationPrompt(task, message, opts = {}) {
|
|
11544
11813
|
const reminder = [
|
|
11545
|
-
"(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."
|
|
11814
|
+
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."
|
|
11546
11815
|
];
|
|
11547
11816
|
if (isHitlTask(task)) {
|
|
11548
11817
|
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}.`);
|
|
@@ -11586,6 +11855,12 @@ var VerdictEngine = class {
|
|
|
11586
11855
|
idleSince = /* @__PURE__ */ new Map();
|
|
11587
11856
|
/** Tasks waiting on the user, whose silence is expected rather than a sign of a stuck runner. */
|
|
11588
11857
|
idlePaused = /* @__PURE__ */ new Set();
|
|
11858
|
+
/**
|
|
11859
|
+
* Open tool approvals per structured task. They arrive mid-turn and leave the
|
|
11860
|
+
* status alone (ADR-0018, W1), but a task waiting on one is waiting on the
|
|
11861
|
+
* user, so it is not idle either.
|
|
11862
|
+
*/
|
|
11863
|
+
openApprovals = /* @__PURE__ */ new Map();
|
|
11589
11864
|
onVerdict(listener) {
|
|
11590
11865
|
this.listeners.push(listener);
|
|
11591
11866
|
}
|
|
@@ -11614,7 +11889,7 @@ var VerdictEngine = class {
|
|
|
11614
11889
|
}
|
|
11615
11890
|
/** Restart the silence timer on fresh output; broadcasts the null transition if it was idle. */
|
|
11616
11891
|
touchIdle(taskId, gen) {
|
|
11617
|
-
if (this.idlePaused.has(taskId)) return;
|
|
11892
|
+
if (this.idlePaused.has(taskId) || this.openApprovals.get(taskId)?.size) return;
|
|
11618
11893
|
const existing = this.idleTimers.get(taskId);
|
|
11619
11894
|
if (existing) clearTimeout(existing);
|
|
11620
11895
|
if (this.idleSince.get(taskId)) {
|
|
@@ -11696,7 +11971,10 @@ ${line}
|
|
|
11696
11971
|
});
|
|
11697
11972
|
if (isStructuredSession(session)) {
|
|
11698
11973
|
session.onEvent((event) => {
|
|
11699
|
-
if (
|
|
11974
|
+
if (this.generations.get(task.id) !== gen) return;
|
|
11975
|
+
if (event.type === "turn_start") this.resumeIdle(task.id);
|
|
11976
|
+
else if (event.type === "permission_request" && !event.decided) this.approvalOpened(task.id, event.id);
|
|
11977
|
+
else if (event.type === "permission_decided" || event.type === "permission_withdrawn") this.approvalClosed(task.id, event.id, gen);
|
|
11700
11978
|
});
|
|
11701
11979
|
}
|
|
11702
11980
|
session.onExit((exitCode) => {
|
|
@@ -11707,6 +11985,18 @@ ${line}
|
|
|
11707
11985
|
for (const l of this.listeners) l(task.id, verdict);
|
|
11708
11986
|
});
|
|
11709
11987
|
}
|
|
11988
|
+
approvalOpened(taskId, approvalId) {
|
|
11989
|
+
const open = this.openApprovals.get(taskId) ?? /* @__PURE__ */ new Set();
|
|
11990
|
+
open.add(approvalId);
|
|
11991
|
+
this.openApprovals.set(taskId, open);
|
|
11992
|
+
this.clearIdle(taskId);
|
|
11993
|
+
}
|
|
11994
|
+
approvalClosed(taskId, approvalId, gen) {
|
|
11995
|
+
const open = this.openApprovals.get(taskId);
|
|
11996
|
+
if (!open?.delete(approvalId) || open.size) return;
|
|
11997
|
+
this.openApprovals.delete(taskId);
|
|
11998
|
+
this.touchIdle(taskId, gen);
|
|
11999
|
+
}
|
|
11710
12000
|
/** Scan only the new text plus the unmatched carry, so a long run stays linear. */
|
|
11711
12001
|
scanCheckpoints(taskId, session, text2) {
|
|
11712
12002
|
const scan = (this.checkpointCarry.get(taskId) ?? "") + text2;
|
|
@@ -11728,6 +12018,7 @@ ${line}
|
|
|
11728
12018
|
this.checkpointCarry.delete(taskId);
|
|
11729
12019
|
this.pausedSessions.delete(taskId);
|
|
11730
12020
|
this.idlePaused.delete(taskId);
|
|
12021
|
+
this.openApprovals.delete(taskId);
|
|
11731
12022
|
this.clearIdle(taskId);
|
|
11732
12023
|
}
|
|
11733
12024
|
/** Manual "Mark complete" override: a pass verdict that bypasses evidence. */
|
|
@@ -11765,6 +12056,7 @@ ${line}
|
|
|
11765
12056
|
this.idleTimers.clear();
|
|
11766
12057
|
this.idleSince.clear();
|
|
11767
12058
|
this.idlePaused.clear();
|
|
12059
|
+
this.openApprovals.clear();
|
|
11768
12060
|
this.generations.clear();
|
|
11769
12061
|
}
|
|
11770
12062
|
decide(task, exitCode) {
|
|
@@ -12282,6 +12574,8 @@ var PlanStore = class {
|
|
|
12282
12574
|
_planTasks = [];
|
|
12283
12575
|
_allTasks = [];
|
|
12284
12576
|
_taskMap = /* @__PURE__ */ new Map();
|
|
12577
|
+
/** Task id → the top-level task it hangs under, itself for a top-level task. */
|
|
12578
|
+
_rootMap = /* @__PURE__ */ new Map();
|
|
12285
12579
|
_planRunners = ["claude-code"];
|
|
12286
12580
|
_onMutate = null;
|
|
12287
12581
|
_executionLog = [];
|
|
@@ -12319,6 +12613,14 @@ var PlanStore = class {
|
|
|
12319
12613
|
get(taskId) {
|
|
12320
12614
|
return this._taskMap.get(taskId);
|
|
12321
12615
|
}
|
|
12616
|
+
/**
|
|
12617
|
+
* Whether a task runs as an ops task (ADR-0020). Only a top-level AI task
|
|
12618
|
+
* can be one; a subtask runs with the top-level task it hangs under.
|
|
12619
|
+
*/
|
|
12620
|
+
isOps(taskId) {
|
|
12621
|
+
const root = this._rootMap.get(taskId);
|
|
12622
|
+
return root?.type === "ai" && root.ops === true;
|
|
12623
|
+
}
|
|
12322
12624
|
/** A copy of the task tree, detached from the store: later status changes do not reach it. */
|
|
12323
12625
|
snapshot() {
|
|
12324
12626
|
return cloneTasks(this._planTasks);
|
|
@@ -12413,6 +12715,7 @@ ${taskB.description}`,
|
|
|
12413
12715
|
assignedRunner: taskA.assignedRunner,
|
|
12414
12716
|
assignedModel: taskA.assignedModel,
|
|
12415
12717
|
taskMode: taskA.taskMode,
|
|
12718
|
+
ops: taskA.ops === true && taskB.ops === true,
|
|
12416
12719
|
order: Math.min(taskA.order, taskB.order)
|
|
12417
12720
|
});
|
|
12418
12721
|
const mergedId = merged.id;
|
|
@@ -12442,7 +12745,8 @@ ${taskB.description}`,
|
|
|
12442
12745
|
taskMode: spec.taskMode ?? original.taskMode,
|
|
12443
12746
|
autonomy: spec.autonomy ?? original.autonomy,
|
|
12444
12747
|
sliceType: spec.sliceType ?? original.sliceType,
|
|
12445
|
-
userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
|
|
12748
|
+
userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered,
|
|
12749
|
+
ops: spec.ops ?? original.ops
|
|
12446
12750
|
}));
|
|
12447
12751
|
});
|
|
12448
12752
|
const tailId = finalized[finalized.length - 1].id;
|
|
@@ -12496,8 +12800,14 @@ ${taskB.description}`,
|
|
|
12496
12800
|
this.setStatus(id, "pending");
|
|
12497
12801
|
task.verdict = void 0;
|
|
12498
12802
|
task.outputSummary = void 0;
|
|
12803
|
+
delete task.forcedPastGate;
|
|
12499
12804
|
}
|
|
12500
12805
|
}
|
|
12806
|
+
/** Kept on the task: it was force-started past its merge gate, before these dependencies' work was merged (ADR-0020). */
|
|
12807
|
+
setForcedPastGate(id, dependencies) {
|
|
12808
|
+
const task = this._taskMap.get(id);
|
|
12809
|
+
if (task) task.forcedPastGate = dependencies;
|
|
12810
|
+
}
|
|
12501
12811
|
/** A reason outlives nothing: any status but `awaiting_user` drops it. */
|
|
12502
12812
|
setStatus(id, status) {
|
|
12503
12813
|
const task = this._taskMap.get(id);
|
|
@@ -12580,6 +12890,10 @@ ${taskB.description}`,
|
|
|
12580
12890
|
for (const task of this._allTasks) {
|
|
12581
12891
|
this._taskMap.set(task.id, task);
|
|
12582
12892
|
}
|
|
12893
|
+
this._rootMap.clear();
|
|
12894
|
+
for (const root of this._planTasks) {
|
|
12895
|
+
for (const task of flattenTasks([root])) this._rootMap.set(task.id, root);
|
|
12896
|
+
}
|
|
12583
12897
|
}
|
|
12584
12898
|
validateAssignedRunners() {
|
|
12585
12899
|
for (const task of this._allTasks) {
|
|
@@ -12633,9 +12947,9 @@ var IsolationRunController = class {
|
|
|
12633
12947
|
/** Copied paths already reported for the current run: every task gets the same copies. */
|
|
12634
12948
|
reportedCopies = /* @__PURE__ */ new Set();
|
|
12635
12949
|
/**
|
|
12636
|
-
* How the open run executes; null while no run is open
|
|
12637
|
-
*
|
|
12638
|
-
* is stopped.
|
|
12950
|
+
* How the open run executes; null while no run is open, `undecided` until
|
|
12951
|
+
* its first change task asks. A run is one Execute-Plan or one manual task
|
|
12952
|
+
* run, from its start until it settles or is stopped.
|
|
12639
12953
|
*/
|
|
12640
12954
|
mode = null;
|
|
12641
12955
|
/** Resolver task id → the conflicted task it resolves; see {@link linkResolver}. */
|
|
@@ -12659,6 +12973,10 @@ var IsolationRunController = class {
|
|
|
12659
12973
|
get isOpen() {
|
|
12660
12974
|
return this.mode !== null;
|
|
12661
12975
|
}
|
|
12976
|
+
/** The open run has decided whether it isolates. */
|
|
12977
|
+
get decided() {
|
|
12978
|
+
return this.mode === "isolated" || this.mode === "shared";
|
|
12979
|
+
}
|
|
12662
12980
|
/** The open run executes in worktrees. */
|
|
12663
12981
|
get isolating() {
|
|
12664
12982
|
return this.mode === "isolated" && this.run !== null;
|
|
@@ -12715,13 +13033,22 @@ var IsolationRunController = class {
|
|
|
12715
13033
|
this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
|
|
12716
13034
|
}
|
|
12717
13035
|
}
|
|
13036
|
+
/** Open a run if none is, leaving whether it isolates to its first change task. */
|
|
13037
|
+
open() {
|
|
13038
|
+
if (this.mode) return;
|
|
13039
|
+
this.mode = "undecided";
|
|
13040
|
+
this.listener.opened();
|
|
13041
|
+
}
|
|
12718
13042
|
/**
|
|
12719
|
-
*
|
|
12720
|
-
* worktrees
|
|
12721
|
-
*
|
|
13043
|
+
* Decide once per run, as its first change task starts, whether it executes
|
|
13044
|
+
* in worktrees (ADR-0020): an ops task needs none, so a run of only ops
|
|
13045
|
+
* tasks never asks. `resume` is what a dirty tree parks until the user
|
|
13046
|
+
* chooses how to go on. Resolves false while the run waits on that choice.
|
|
12722
13047
|
*/
|
|
12723
|
-
|
|
12724
|
-
|
|
13048
|
+
decide(resume) {
|
|
13049
|
+
this.open();
|
|
13050
|
+
if (this.decided) return Promise.resolve(true);
|
|
13051
|
+
if (this.blockedStart) return Promise.resolve(false);
|
|
12725
13052
|
this.opening ??= this.activate(resume).finally(() => {
|
|
12726
13053
|
this.opening = null;
|
|
12727
13054
|
});
|
|
@@ -12736,8 +13063,8 @@ var IsolationRunController = class {
|
|
|
12736
13063
|
* the safe rule then.
|
|
12737
13064
|
*/
|
|
12738
13065
|
async plannerLayout() {
|
|
12739
|
-
if (this.
|
|
12740
|
-
const decision = await this.
|
|
13066
|
+
if (this.decided) return this.isolating && this.run ? layoutOf(this.run) : false;
|
|
13067
|
+
const decision = await this.assess(this.workspaceRoot());
|
|
12741
13068
|
return decision.mode === "isolated" ? decision.layout : false;
|
|
12742
13069
|
}
|
|
12743
13070
|
/**
|
|
@@ -12772,7 +13099,7 @@ var IsolationRunController = class {
|
|
|
12772
13099
|
const { cwd: cwd2 } = await this.isolation.reopen(task, this.run);
|
|
12773
13100
|
return { cwd: cwd2, worktree: true };
|
|
12774
13101
|
}
|
|
12775
|
-
if (!this.isolating || !this.run) {
|
|
13102
|
+
if (opts.ops || !this.isolating || !this.run) {
|
|
12776
13103
|
await this.release(task.id, { keep: false });
|
|
12777
13104
|
return { cwd: this.workspaceRoot(), worktree: false };
|
|
12778
13105
|
}
|
|
@@ -12817,18 +13144,71 @@ var IsolationRunController = class {
|
|
|
12817
13144
|
}
|
|
12818
13145
|
this.listener.changed();
|
|
12819
13146
|
}
|
|
12820
|
-
/**
|
|
13147
|
+
/**
|
|
13148
|
+
* Close the open run. An isolated one hands its integration branch over for
|
|
13149
|
+
* review — unless the user merged everything it landed at merge gates
|
|
13150
|
+
* already, which leaves nothing to hand over, so it is cleared up as a Merge
|
|
13151
|
+
* all would have.
|
|
13152
|
+
*/
|
|
12821
13153
|
async close() {
|
|
12822
13154
|
const mode = this.mode;
|
|
12823
13155
|
this.mode = null;
|
|
12824
|
-
|
|
13156
|
+
const run = this.run;
|
|
13157
|
+
if (mode !== "isolated" || !run) return;
|
|
13158
|
+
await this.refreshInHead();
|
|
13159
|
+
const records = Object.values(run.tasks);
|
|
13160
|
+
if (records.length > 0 && records.every((r) => r.status === "merged" && r.inHead)) {
|
|
13161
|
+
this.tell("info", "Everything this run landed is merged into your branch already.");
|
|
13162
|
+
await this.clearMerged(run);
|
|
13163
|
+
return;
|
|
13164
|
+
}
|
|
12825
13165
|
try {
|
|
12826
|
-
this.listener.handoff(await this.isolation.handoff(
|
|
13166
|
+
this.listener.handoff(await this.isolation.handoff(run));
|
|
12827
13167
|
} catch (err) {
|
|
12828
13168
|
this.tell("warn", `Could not hand the run's integration branch over: ${err instanceof Error ? err.message : String(err)}`);
|
|
12829
13169
|
}
|
|
12830
13170
|
this.listener.changed();
|
|
12831
13171
|
}
|
|
13172
|
+
/**
|
|
13173
|
+
* Whether a task's landed work still waits to be merged into the user's
|
|
13174
|
+
* branch — what holds an ops or user task at its merge gate (ADR-0020). A
|
|
13175
|
+
* task with no record never landed anything to wait for.
|
|
13176
|
+
*/
|
|
13177
|
+
awaitsMerge(taskId) {
|
|
13178
|
+
const record = this.run?.tasks[taskId];
|
|
13179
|
+
return !!record && record.status === "merged" && !record.inHead;
|
|
13180
|
+
}
|
|
13181
|
+
/**
|
|
13182
|
+
* Look again for landed work the user has merged — by Merge all, or by hand
|
|
13183
|
+
* with git. Resolves true when some was found.
|
|
13184
|
+
*/
|
|
13185
|
+
async refreshInHead() {
|
|
13186
|
+
const run = this.run;
|
|
13187
|
+
if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
|
|
13188
|
+
const found = await this.isolation.findInHead(run).catch(() => []);
|
|
13189
|
+
if (found.length > 0) this.listener.changed();
|
|
13190
|
+
return found.length > 0;
|
|
13191
|
+
}
|
|
13192
|
+
/**
|
|
13193
|
+
* The tracked state of the workspace as an ops task starts, for
|
|
13194
|
+
* {@link filesChangedSince} (ADR-0020). Null where the check does not run:
|
|
13195
|
+
* a run that shares the workspace root, where every task's edits land in
|
|
13196
|
+
* it, or a workspace git cannot isolate at all.
|
|
13197
|
+
*/
|
|
13198
|
+
async snapshotWorkspace() {
|
|
13199
|
+
if (this.mode === "shared") return null;
|
|
13200
|
+
const root = this.workspaceRoot();
|
|
13201
|
+
if (!this.isolating) {
|
|
13202
|
+
const availability = await this.isolation.isActive(root).catch(() => null);
|
|
13203
|
+
if (!availability || !availability.active && availability.reason !== "dirty") return null;
|
|
13204
|
+
}
|
|
13205
|
+
return this.isolation.snapshotTree(root, this.run?.sharedRepos ?? []).catch(() => null);
|
|
13206
|
+
}
|
|
13207
|
+
/** Tracked files changed since `snapshot`; none when the run has since gone on in the workspace root. */
|
|
13208
|
+
async filesChangedSince(snapshot) {
|
|
13209
|
+
if (!snapshot || this.mode === "shared") return [];
|
|
13210
|
+
return this.isolation.changedSince(this.workspaceRoot(), snapshot).catch(() => []);
|
|
13211
|
+
}
|
|
12832
13212
|
/**
|
|
12833
13213
|
* A stop or a plan load: the open run ends without a handoff and a parked
|
|
12834
13214
|
* start is dropped. `keepOpen` spares a run the scheduler is still driving.
|
|
@@ -12859,19 +13239,23 @@ var IsolationRunController = class {
|
|
|
12859
13239
|
}
|
|
12860
13240
|
/**
|
|
12861
13241
|
* "Merge all": the run's integration branches into whatever the user has
|
|
12862
|
-
* checked out, in every repo or none.
|
|
12863
|
-
*
|
|
12864
|
-
*
|
|
13242
|
+
* checked out, in every repo or none. During a run it merges what has
|
|
13243
|
+
* landed so far and the run goes on (ADR-0020): its branches stay for the
|
|
13244
|
+
* tasks still to land. Once a settled run merged everything, it has nothing
|
|
13245
|
+
* left to hand over, so it is cleared up and forgotten; a branch the user's
|
|
13246
|
+
* HEAD somehow does not contain stays for the next run's sweep.
|
|
12865
13247
|
*/
|
|
12866
13248
|
async merge() {
|
|
12867
13249
|
const run = this.requireRun();
|
|
13250
|
+
const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
|
|
12868
13251
|
const result = await this.isolation.mergeIntoCheckedOut(run);
|
|
12869
13252
|
const branch = integrationBranchNameOf(run);
|
|
12870
13253
|
const group = run.repos.some((r) => r.path !== SELF_REPO);
|
|
12871
|
-
const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
|
|
12872
13254
|
const { level, message } = describeMergeResult(result, branch, group, repaired);
|
|
12873
13255
|
this.tell(level, message);
|
|
12874
|
-
if (result.outcome
|
|
13256
|
+
if (result.outcome !== "merged") return result;
|
|
13257
|
+
await this.refreshInHead();
|
|
13258
|
+
if (!this.isOpen) await this.clearMerged(run);
|
|
12875
13259
|
return result;
|
|
12876
13260
|
}
|
|
12877
13261
|
/** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
|
|
@@ -12903,7 +13287,7 @@ var IsolationRunController = class {
|
|
|
12903
13287
|
this.resolvers = {};
|
|
12904
13288
|
this.listener.changed();
|
|
12905
13289
|
}
|
|
12906
|
-
async
|
|
13290
|
+
async assess(root) {
|
|
12907
13291
|
const availability = await this.isolation.isActive(root);
|
|
12908
13292
|
const continued = this.continuableRun(root);
|
|
12909
13293
|
const continuing = continued !== null;
|
|
@@ -12918,7 +13302,7 @@ var IsolationRunController = class {
|
|
|
12918
13302
|
}
|
|
12919
13303
|
async activate(resume) {
|
|
12920
13304
|
const root = this.workspaceRoot();
|
|
12921
|
-
const decision = await this.
|
|
13305
|
+
const decision = await this.assess(root);
|
|
12922
13306
|
if (decision.mode === "blocked") {
|
|
12923
13307
|
this.blockedStart = resume;
|
|
12924
13308
|
this.blockedRepos = decision.repos;
|
|
@@ -12945,8 +13329,8 @@ var IsolationRunController = class {
|
|
|
12945
13329
|
return true;
|
|
12946
13330
|
}
|
|
12947
13331
|
begin(mode) {
|
|
13332
|
+
this.open();
|
|
12948
13333
|
this.mode = mode;
|
|
12949
|
-
this.listener.opened();
|
|
12950
13334
|
}
|
|
12951
13335
|
/** What earlier runs left merged in the group goes; a failure here is worth a word, never a stopped run. */
|
|
12952
13336
|
async sweep() {
|
|
@@ -13293,8 +13677,9 @@ var MessageQueue = class {
|
|
|
13293
13677
|
// src/services/readiness.ts
|
|
13294
13678
|
function selectReadyTasks(input) {
|
|
13295
13679
|
const { store, onHold, runs, active, maxParallel } = input;
|
|
13296
|
-
if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [] };
|
|
13680
|
+
if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
|
|
13297
13681
|
const availableSlots = maxParallel - active;
|
|
13682
|
+
const gated = [];
|
|
13298
13683
|
const candidates = store.allTasks.filter((t) => {
|
|
13299
13684
|
if (t.status !== "pending" && t.status !== "approved") return false;
|
|
13300
13685
|
if (t.type === "user") return false;
|
|
@@ -13302,13 +13687,21 @@ function selectReadyTasks(input) {
|
|
|
13302
13687
|
if (onHold.has(t.id)) return false;
|
|
13303
13688
|
if (isBlocked(t, store)) return false;
|
|
13304
13689
|
if (!t.dependencies.every((depId) => dependencyMet(depId, store, runs))) return false;
|
|
13690
|
+
if (store.isOps(t.id)) {
|
|
13691
|
+
if (input.merging) return false;
|
|
13692
|
+
if (mergeGate(t, store, runs).length > 0) {
|
|
13693
|
+
gated.push(t);
|
|
13694
|
+
return false;
|
|
13695
|
+
}
|
|
13696
|
+
}
|
|
13305
13697
|
return true;
|
|
13306
13698
|
});
|
|
13307
|
-
const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task,
|
|
13699
|
+
const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, input) }));
|
|
13308
13700
|
return {
|
|
13309
13701
|
ready: candidates.sort((a, b) => a.order - b.order).slice(0, availableSlots),
|
|
13310
13702
|
candidateCount: candidates.length,
|
|
13311
|
-
excluded
|
|
13703
|
+
excluded,
|
|
13704
|
+
gated
|
|
13312
13705
|
};
|
|
13313
13706
|
}
|
|
13314
13707
|
function isBlocked(task, store) {
|
|
@@ -13321,12 +13714,19 @@ function dependencyMet(depId, store, runs) {
|
|
|
13321
13714
|
const record = runs.openRecord(depId);
|
|
13322
13715
|
return !record || record.status === "merged";
|
|
13323
13716
|
}
|
|
13324
|
-
function
|
|
13717
|
+
function mergeGate(task, store, runs) {
|
|
13718
|
+
if (task.type !== "user" && !store.isOps(task.id)) return [];
|
|
13719
|
+
return task.dependencies.filter((depId) => store.isCompleted(depId) && runs.awaitsMerge(depId));
|
|
13720
|
+
}
|
|
13721
|
+
function exclusionReasons(task, input) {
|
|
13722
|
+
const { store, onHold, runs } = input;
|
|
13325
13723
|
const reasons = [];
|
|
13326
13724
|
if (task.status !== "pending" && task.status !== "approved") reasons.push(`status=${task.status}`);
|
|
13327
13725
|
if (onHold.has(task.id)) reasons.push("on-hold");
|
|
13328
13726
|
if (isBlocked(task, store)) reasons.push("blocked");
|
|
13329
13727
|
if (!task.dependencies.every((depId) => dependencyMet(depId, store, runs))) reasons.push("deps");
|
|
13728
|
+
if (mergeGate(task, store, runs).length > 0) reasons.push("merge-gate");
|
|
13729
|
+
if (input.merging && store.isOps(task.id)) reasons.push("merging");
|
|
13330
13730
|
return reasons;
|
|
13331
13731
|
}
|
|
13332
13732
|
|
|
@@ -15320,9 +15720,502 @@ ${item.text}` : item.text });
|
|
|
15320
15720
|
|
|
15321
15721
|
// src/services/harness/OpenCodeAdapter.ts
|
|
15322
15722
|
var import_crypto2 = require("crypto");
|
|
15723
|
+
|
|
15724
|
+
// src/services/harness/OpenCodeV2.ts
|
|
15725
|
+
var STREAM_CONNECT_TIMEOUT_MS = 5e3;
|
|
15726
|
+
var ACTIVE_POLL_INTERVAL_MS = 1e3;
|
|
15727
|
+
var IDLE_POLLS_BEFORE_START = 3;
|
|
15728
|
+
var PLANNER_RULES = [
|
|
15729
|
+
{ action: "question", resource: "*", effect: "deny" },
|
|
15730
|
+
{ action: "edit", resource: "*", effect: "deny" }
|
|
15731
|
+
];
|
|
15732
|
+
var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
|
|
15733
|
+
var AUTO_APPROVALS = "auto";
|
|
15734
|
+
function newTurnState() {
|
|
15735
|
+
return { seen: /* @__PURE__ */ new Set(), toolNames: /* @__PURE__ */ new Map(), stepModels: /* @__PURE__ */ new Map(), textRuns: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map(), briefs: /* @__PURE__ */ new Map(), heldFrames: /* @__PURE__ */ new Map(), streamedMessages: /* @__PURE__ */ new Set() };
|
|
15736
|
+
}
|
|
15737
|
+
function splitModelId(id) {
|
|
15738
|
+
const slash = id.indexOf("/");
|
|
15739
|
+
if (slash <= 0 || slash === id.length - 1) return null;
|
|
15740
|
+
return { providerID: id.slice(0, slash), id: id.slice(slash + 1) };
|
|
15741
|
+
}
|
|
15742
|
+
function delay(ms, signal) {
|
|
15743
|
+
return new Promise((resolve7) => {
|
|
15744
|
+
const timer = setTimeout(resolve7, ms);
|
|
15745
|
+
timer.unref?.();
|
|
15746
|
+
signal?.addEventListener("abort", () => {
|
|
15747
|
+
clearTimeout(timer);
|
|
15748
|
+
resolve7();
|
|
15749
|
+
}, { once: true });
|
|
15750
|
+
});
|
|
15751
|
+
}
|
|
15752
|
+
function contentText(content) {
|
|
15753
|
+
return (content ?? []).filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
15754
|
+
}
|
|
15755
|
+
function subagentDigest(output) {
|
|
15756
|
+
const inner = output.match(/<subagent[^>]*>\n?([\s\S]*?)\n?<\/subagent>/);
|
|
15757
|
+
return inner ? inner[1] : output;
|
|
15758
|
+
}
|
|
15759
|
+
function permissionReply(decision) {
|
|
15760
|
+
if (decision.decision === "allow") return { decision: "once" };
|
|
15761
|
+
if (decision.decision === "allowForTask") return { decision: "always" };
|
|
15762
|
+
const note = decision.note?.trim();
|
|
15763
|
+
return note ? { decision: "reject", message: note } : { decision: "reject" };
|
|
15764
|
+
}
|
|
15765
|
+
var OpenCodeV2 = class {
|
|
15766
|
+
constructor(host, opts, role) {
|
|
15767
|
+
this.host = host;
|
|
15768
|
+
this.opts = opts;
|
|
15769
|
+
this.role = role;
|
|
15770
|
+
this.agent = opts.kind === "task" ? opts.flags.permissionMode : "plan";
|
|
15771
|
+
const split = opts.model ? splitModelId(opts.model) : null;
|
|
15772
|
+
const variant = opts.kind === "task" ? opts.flags.effort : opts.effort;
|
|
15773
|
+
if (split) this.model = { ...split, ...variant ? { variant } : {} };
|
|
15774
|
+
}
|
|
15775
|
+
host;
|
|
15776
|
+
opts;
|
|
15777
|
+
role;
|
|
15778
|
+
sessionId = null;
|
|
15779
|
+
model = null;
|
|
15780
|
+
agent;
|
|
15781
|
+
turn = null;
|
|
15782
|
+
turnHasText = false;
|
|
15783
|
+
interruptRequested = false;
|
|
15784
|
+
/** A task's requests waiting for an answer, by request id, with the session that asked. */
|
|
15785
|
+
openPermissions = /* @__PURE__ */ new Map();
|
|
15786
|
+
nativeSessionId() {
|
|
15787
|
+
return this.sessionId;
|
|
15788
|
+
}
|
|
15789
|
+
async start() {
|
|
15790
|
+
const resume = this.opts.resumeSessionId;
|
|
15791
|
+
if (resume) {
|
|
15792
|
+
const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
|
|
15793
|
+
if (existing?.data?.id) {
|
|
15794
|
+
this.sessionId = existing.data.id;
|
|
15795
|
+
if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
|
|
15796
|
+
await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
|
|
15797
|
+
await this.applyInstructions();
|
|
15798
|
+
return;
|
|
15799
|
+
}
|
|
15800
|
+
if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
|
|
15801
|
+
}
|
|
15802
|
+
const created = await this.host.json("POST", "/api/session", {
|
|
15803
|
+
agent: this.agent,
|
|
15804
|
+
...this.model ? { model: this.model } : {},
|
|
15805
|
+
permissions: this.role === "planner" ? PLANNER_RULES : TASK_RULES
|
|
15806
|
+
});
|
|
15807
|
+
if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
|
|
15808
|
+
this.sessionId = created.data.id;
|
|
15809
|
+
await this.applyInstructions();
|
|
15810
|
+
}
|
|
15811
|
+
/** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
|
|
15812
|
+
async applyInstructions() {
|
|
15813
|
+
if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
|
|
15814
|
+
const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
|
|
15815
|
+
if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
|
|
15816
|
+
}
|
|
15817
|
+
async send(message, onEvent, signal, onActivity) {
|
|
15818
|
+
if (!this.sessionId) throw new Error(`OpenCode ${this.role} session is not started`);
|
|
15819
|
+
if (this.host.isExited()) {
|
|
15820
|
+
onEvent({ type: "error", message: this.host.exitMessage() });
|
|
15821
|
+
return;
|
|
15822
|
+
}
|
|
15823
|
+
const state = newTurnState();
|
|
15824
|
+
this.turnHasText = false;
|
|
15825
|
+
this.interruptRequested = false;
|
|
15826
|
+
let markDone = () => {
|
|
15827
|
+
};
|
|
15828
|
+
const ended = new Promise((resolve7) => {
|
|
15829
|
+
markDone = resolve7;
|
|
15830
|
+
});
|
|
15831
|
+
const turn = { live: false, done: false, outcome: null, failure: null, ended, finish: () => {
|
|
15832
|
+
turn.done = true;
|
|
15833
|
+
markDone();
|
|
15834
|
+
} };
|
|
15835
|
+
this.turn = turn;
|
|
15836
|
+
const closeStream = await this.openStream(state, turn, onEvent, onActivity);
|
|
15837
|
+
const poll = new AbortController();
|
|
15838
|
+
try {
|
|
15839
|
+
try {
|
|
15840
|
+
await this.host.json("POST", `/api/session/${this.sessionId}/prompt`, { text: message }, signal);
|
|
15841
|
+
} catch (err) {
|
|
15842
|
+
if (signal?.aborted) return;
|
|
15843
|
+
onEvent({ type: "error", message: `OpenCode did not take the message: ${err instanceof Error ? err.message : String(err)}` });
|
|
15844
|
+
return;
|
|
15845
|
+
}
|
|
15846
|
+
void this.pollActive(turn, poll.signal);
|
|
15847
|
+
const aborted = new Promise((resolve7) => {
|
|
15848
|
+
if (signal?.aborted) resolve7();
|
|
15849
|
+
signal?.addEventListener("abort", () => resolve7(), { once: true });
|
|
15850
|
+
});
|
|
15851
|
+
await Promise.race([turn.ended, aborted, this.host.processEnded]);
|
|
15852
|
+
if (signal?.aborted) return;
|
|
15853
|
+
if (!turn.done) {
|
|
15854
|
+
onEvent({ type: "error", message: this.host.exitMessage() });
|
|
15855
|
+
return;
|
|
15856
|
+
}
|
|
15857
|
+
await this.readBack(state, onEvent);
|
|
15858
|
+
for (const id of [...this.openPermissions.keys()]) {
|
|
15859
|
+
this.openPermissions.delete(id);
|
|
15860
|
+
onEvent({ type: "permission_cancelled", id });
|
|
15861
|
+
}
|
|
15862
|
+
if (turn.outcome === "interrupted" || this.interruptRequested) onEvent({ type: "turn_end", interrupted: true });
|
|
15863
|
+
else if (turn.outcome === "failed") onEvent({ type: "error", message: turn.failure ?? "OpenCode reported that the turn failed." });
|
|
15864
|
+
else onEvent({ type: "turn_end" });
|
|
15865
|
+
} finally {
|
|
15866
|
+
this.turn = null;
|
|
15867
|
+
poll.abort();
|
|
15868
|
+
await closeStream();
|
|
15869
|
+
}
|
|
15870
|
+
}
|
|
15871
|
+
/**
|
|
15872
|
+
* The stream can drop the end frame while still delivering the rest, so the
|
|
15873
|
+
* turn also asks which sessions are running. A session that is not, after
|
|
15874
|
+
* this turn's work was seen, is over; one never seen working after a few
|
|
15875
|
+
* polls had already finished before the stream connected.
|
|
15876
|
+
*/
|
|
15877
|
+
async pollActive(turn, signal) {
|
|
15878
|
+
let idlePolls = 0;
|
|
15879
|
+
while (!turn.done && !signal.aborted) {
|
|
15880
|
+
await delay(ACTIVE_POLL_INTERVAL_MS, signal);
|
|
15881
|
+
if (turn.done || signal.aborted) return;
|
|
15882
|
+
const active = await this.host.json("GET", "/api/session/active").catch(() => null);
|
|
15883
|
+
if (!active?.data) continue;
|
|
15884
|
+
if (this.sessionId && this.sessionId in active.data) {
|
|
15885
|
+
turn.live = true;
|
|
15886
|
+
idlePolls = 0;
|
|
15887
|
+
continue;
|
|
15888
|
+
}
|
|
15889
|
+
idlePolls++;
|
|
15890
|
+
if (turn.live || idlePolls >= IDLE_POLLS_BEFORE_START) {
|
|
15891
|
+
const messages = await this.messages();
|
|
15892
|
+
const idle = [...messages ?? []].reverse().find((m) => m.type === "idle");
|
|
15893
|
+
if (idle?.outcome === "failed" || idle?.outcome === "interrupted" || idle?.outcome === "succeeded") turn.outcome ??= idle.outcome;
|
|
15894
|
+
turn.outcome ??= "succeeded";
|
|
15895
|
+
if (!turn.done) turn.finish();
|
|
15896
|
+
}
|
|
15897
|
+
}
|
|
15898
|
+
}
|
|
15899
|
+
async messages() {
|
|
15900
|
+
const response = await this.host.json("GET", `/api/session/${this.sessionId}/message`).catch(() => null);
|
|
15901
|
+
return Array.isArray(response?.data) ? response.data : null;
|
|
15902
|
+
}
|
|
15903
|
+
/**
|
|
15904
|
+
* Whatever of the turn's own replies the stream missed. A message whose text
|
|
15905
|
+
* the stream delivered adds nothing here; one that lost its text frames still
|
|
15906
|
+
* reaches the plain-text channel, where the done marker is looked for.
|
|
15907
|
+
*/
|
|
15908
|
+
async readBack(state, onEvent) {
|
|
15909
|
+
const messages = await this.messages();
|
|
15910
|
+
if (!messages) return;
|
|
15911
|
+
const latestUser = messages.findIndex((m) => m.type === "user");
|
|
15912
|
+
const own = latestUser < 0 ? messages : messages.slice(0, latestUser);
|
|
15913
|
+
for (const message of [...own].reverse()) {
|
|
15914
|
+
if (message.type !== "assistant" || !message.id || state.streamedMessages.has(message.id)) continue;
|
|
15915
|
+
(message.content ?? []).forEach((part, index) => {
|
|
15916
|
+
if (part.type === "text" && part.text) this.emitText(`${message.id}:readback:${index}`, part.text, state, onEvent);
|
|
15917
|
+
});
|
|
15918
|
+
}
|
|
15919
|
+
}
|
|
15920
|
+
/**
|
|
15921
|
+
* Abort the running turn, keeping the server and its session. OpenCode
|
|
15922
|
+
* acknowledges with `session.execution.interrupted`, which also ends the turn.
|
|
15923
|
+
*/
|
|
15924
|
+
async interrupt(timeoutMs) {
|
|
15925
|
+
if (!this.sessionId || this.host.isExited()) return false;
|
|
15926
|
+
const turn = this.turn;
|
|
15927
|
+
this.interruptRequested = true;
|
|
15928
|
+
const response = await this.host.json("POST", `/api/session/${this.sessionId}/interrupt`).catch(() => null);
|
|
15929
|
+
if (!response) return false;
|
|
15930
|
+
if (!turn || turn.done) return true;
|
|
15931
|
+
let timer;
|
|
15932
|
+
const timedOut = new Promise((resolve7) => {
|
|
15933
|
+
timer = setTimeout(() => resolve7(false), timeoutMs);
|
|
15934
|
+
timer.unref?.();
|
|
15935
|
+
});
|
|
15936
|
+
const acknowledged = await Promise.race([turn.ended.then(() => true), this.host.processEnded.then(() => false), timedOut]);
|
|
15937
|
+
clearTimeout(timer);
|
|
15938
|
+
return acknowledged;
|
|
15939
|
+
}
|
|
15940
|
+
answerPermission(id, decision) {
|
|
15941
|
+
const sessionId = this.openPermissions.get(id);
|
|
15942
|
+
if (sessionId === void 0) return false;
|
|
15943
|
+
this.openPermissions.delete(id);
|
|
15944
|
+
void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
|
|
15945
|
+
});
|
|
15946
|
+
return true;
|
|
15947
|
+
}
|
|
15948
|
+
async replyPermission(id, sessionId, reply) {
|
|
15949
|
+
await this.host.request("POST", `/api/session/${sessionId}/permission/${id}/reply`, reply);
|
|
15950
|
+
}
|
|
15951
|
+
async openStream(state, turn, onEvent, onActivity) {
|
|
15952
|
+
const streamAbort = new AbortController();
|
|
15953
|
+
let connected = () => {
|
|
15954
|
+
};
|
|
15955
|
+
const ready = new Promise((resolve7) => {
|
|
15956
|
+
connected = resolve7;
|
|
15957
|
+
});
|
|
15958
|
+
const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, state, turn, onEvent), connected, onActivity);
|
|
15959
|
+
await Promise.race([ready, new Promise((r) => {
|
|
15960
|
+
const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
|
|
15961
|
+
t.unref?.();
|
|
15962
|
+
})]);
|
|
15963
|
+
return async () => {
|
|
15964
|
+
streamAbort.abort();
|
|
15965
|
+
await live.catch(() => {
|
|
15966
|
+
});
|
|
15967
|
+
};
|
|
15968
|
+
}
|
|
15969
|
+
async streamEvents(signal, onFrame, onConnected, onActivity) {
|
|
15970
|
+
const response = await this.host.openEvents(signal).catch(() => null);
|
|
15971
|
+
const body = response?.body;
|
|
15972
|
+
if (!body) {
|
|
15973
|
+
onConnected();
|
|
15974
|
+
return;
|
|
15975
|
+
}
|
|
15976
|
+
onConnected();
|
|
15977
|
+
const reader = body.getReader();
|
|
15978
|
+
const decoder = new TextDecoder();
|
|
15979
|
+
let buffer = "";
|
|
15980
|
+
for (; ; ) {
|
|
15981
|
+
const { done, value } = await reader.read().catch(() => ({ done: true, value: void 0 }));
|
|
15982
|
+
if (done) return;
|
|
15983
|
+
onActivity?.();
|
|
15984
|
+
buffer += decoder.decode(value, { stream: true });
|
|
15985
|
+
let newline;
|
|
15986
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
15987
|
+
const line = buffer.slice(0, newline).trim();
|
|
15988
|
+
buffer = buffer.slice(newline + 1);
|
|
15989
|
+
if (!line.startsWith("data:")) continue;
|
|
15990
|
+
try {
|
|
15991
|
+
onFrame(JSON.parse(line.slice(5).trim()));
|
|
15992
|
+
} catch {
|
|
15993
|
+
}
|
|
15994
|
+
}
|
|
15995
|
+
}
|
|
15996
|
+
}
|
|
15997
|
+
/**
|
|
15998
|
+
* One frame. Only this session and its children are followed: the stream is
|
|
15999
|
+
* the whole server's, and another client's session is none of this turn's business.
|
|
16000
|
+
*/
|
|
16001
|
+
onFrame(frame, state, turn, onEvent) {
|
|
16002
|
+
const data = frame.data;
|
|
16003
|
+
if (!data || !frame.type) return;
|
|
16004
|
+
if (frame.type === "session.created") {
|
|
16005
|
+
if (data.parentID && data.parentID === this.sessionId && data.sessionID && !state.children.has(data.sessionID)) state.children.set(data.sessionID, null);
|
|
16006
|
+
return;
|
|
16007
|
+
}
|
|
16008
|
+
const session = data.sessionID;
|
|
16009
|
+
if (!session) return;
|
|
16010
|
+
const isChild = session !== this.sessionId;
|
|
16011
|
+
if (isChild && !state.children.has(session)) return;
|
|
16012
|
+
if (!isChild) {
|
|
16013
|
+
if (frame.type === "session.execution.started") {
|
|
16014
|
+
turn.live = true;
|
|
16015
|
+
return;
|
|
16016
|
+
}
|
|
16017
|
+
if (frame.type === "session.execution.succeeded" || frame.type === "session.execution.failed" || frame.type === "session.execution.interrupted") {
|
|
16018
|
+
if (!turn.live || turn.done) return;
|
|
16019
|
+
turn.outcome = frame.type === "session.execution.succeeded" ? "succeeded" : frame.type === "session.execution.failed" ? "failed" : "interrupted";
|
|
16020
|
+
if (turn.outcome === "failed") turn.failure = data.error?.message ?? data.error?.type ?? null;
|
|
16021
|
+
turn.finish();
|
|
16022
|
+
return;
|
|
16023
|
+
}
|
|
16024
|
+
}
|
|
16025
|
+
if (frame.type === "permission.asked") {
|
|
16026
|
+
if (this.role === "task") this.askPermission(data, session, state, onEvent);
|
|
16027
|
+
else this.denyPermission(data, session, state, onEvent);
|
|
16028
|
+
return;
|
|
16029
|
+
}
|
|
16030
|
+
if (frame.type === "permission.replied") {
|
|
16031
|
+
const id = data.requestID;
|
|
16032
|
+
if (id && this.openPermissions.delete(id)) onEvent({ type: "permission_cancelled", id });
|
|
16033
|
+
return;
|
|
16034
|
+
}
|
|
16035
|
+
let subagentId;
|
|
16036
|
+
if (isChild) {
|
|
16037
|
+
const owner = state.children.get(session);
|
|
16038
|
+
if (!owner) {
|
|
16039
|
+
state.heldFrames.set(session, [...state.heldFrames.get(session) ?? [], frame]);
|
|
16040
|
+
return;
|
|
16041
|
+
}
|
|
16042
|
+
subagentId = owner;
|
|
16043
|
+
}
|
|
16044
|
+
this.onContentFrame(frame, data, state, turn, onEvent, subagentId);
|
|
16045
|
+
}
|
|
16046
|
+
onContentFrame(frame, data, state, turn, onEvent, subagentId) {
|
|
16047
|
+
const message = data.assistantMessageID ?? "";
|
|
16048
|
+
switch (frame.type) {
|
|
16049
|
+
case "session.step.started":
|
|
16050
|
+
if (message && data.model?.providerID && data.model.id) state.stepModels.set(message, `${data.model.providerID}/${data.model.id}`);
|
|
16051
|
+
return;
|
|
16052
|
+
case "session.reasoning.delta":
|
|
16053
|
+
if (data.delta) onEvent({ type: "thinking_delta", text: data.delta, subagentId });
|
|
16054
|
+
return;
|
|
16055
|
+
case "session.reasoning.ended": {
|
|
16056
|
+
const key = `reasoning:${message}:${data.ordinal ?? 0}`;
|
|
16057
|
+
if (!data.text || state.seen.has(key)) return;
|
|
16058
|
+
state.seen.add(key);
|
|
16059
|
+
onEvent({ type: "thinking", text: data.text, subagentId });
|
|
16060
|
+
return;
|
|
16061
|
+
}
|
|
16062
|
+
case "session.text.delta":
|
|
16063
|
+
if (!subagentId && data.delta) this.onTextDelta(`${message}:${data.ordinal ?? 0}`, data.delta, state, onEvent);
|
|
16064
|
+
return;
|
|
16065
|
+
case "session.text.ended":
|
|
16066
|
+
if (subagentId || !data.text) return;
|
|
16067
|
+
if (message) state.streamedMessages.add(message);
|
|
16068
|
+
this.emitText(`${message}:${data.ordinal ?? 0}`, data.text, state, onEvent);
|
|
16069
|
+
return;
|
|
16070
|
+
case "session.tool.input.started":
|
|
16071
|
+
if (data.id && data.name) state.toolNames.set(data.id, data.name);
|
|
16072
|
+
return;
|
|
16073
|
+
case "session.tool.called": {
|
|
16074
|
+
const id = data.id;
|
|
16075
|
+
if (!id || state.seen.has(`call:${id}`)) return;
|
|
16076
|
+
state.seen.add(`call:${id}`);
|
|
16077
|
+
const input = data.input ?? {};
|
|
16078
|
+
const name = state.toolNames.get(id) ?? "tool";
|
|
16079
|
+
const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
|
|
16080
|
+
state.briefs.set(id, brief);
|
|
16081
|
+
onEvent({ type: "tool_call", id, name, args: input, subagentId });
|
|
16082
|
+
return;
|
|
16083
|
+
}
|
|
16084
|
+
case "session.tool.progress":
|
|
16085
|
+
if (!subagentId) this.adoptChild(data, state, onEvent, turn);
|
|
16086
|
+
return;
|
|
16087
|
+
case "session.tool.success":
|
|
16088
|
+
case "session.tool.failed": {
|
|
16089
|
+
const id = data.id;
|
|
16090
|
+
if (!id || state.seen.has(`result:${id}`)) return;
|
|
16091
|
+
state.seen.add(`result:${id}`);
|
|
16092
|
+
const success = frame.type === "session.tool.success";
|
|
16093
|
+
const output = success ? contentText(data.content) : data.error?.message ?? data.error?.type ?? "";
|
|
16094
|
+
onEvent({ type: "tool_result", id, name: state.toolNames.get(id) ?? "tool", output, success, subagentId });
|
|
16095
|
+
if (!subagentId) this.finishChild(id, success, output, state, onEvent);
|
|
16096
|
+
return;
|
|
16097
|
+
}
|
|
16098
|
+
case "session.step.ended":
|
|
16099
|
+
case "session.step.failed":
|
|
16100
|
+
this.countUsage(data, state, onEvent, subagentId);
|
|
16101
|
+
return;
|
|
16102
|
+
default:
|
|
16103
|
+
}
|
|
16104
|
+
}
|
|
16105
|
+
adoptChild(data, state, onEvent, turn) {
|
|
16106
|
+
const child = data.metadata?.sessionID;
|
|
16107
|
+
const callId = data.id;
|
|
16108
|
+
if (!child || !callId || state.children.get(child)) return;
|
|
16109
|
+
const model = state.stepModels.get(data.assistantMessageID ?? "");
|
|
16110
|
+
onEvent({ type: "subagent_started", subagentId: callId, brief: state.briefs.get(callId) ?? "", ...model ? { model } : {} });
|
|
16111
|
+
state.children.set(child, callId);
|
|
16112
|
+
const held = state.heldFrames.get(child) ?? [];
|
|
16113
|
+
state.heldFrames.delete(child);
|
|
16114
|
+
for (const frame of held) this.onFrame(frame, state, turn, onEvent);
|
|
16115
|
+
}
|
|
16116
|
+
finishChild(callId, success, output, state, onEvent) {
|
|
16117
|
+
if (![...state.children.values()].includes(callId)) return;
|
|
16118
|
+
onEvent({ type: "subagent_finished", subagentId: callId, outcome: success ? "done" : "failed", digest: subagentDigest(output) });
|
|
16119
|
+
}
|
|
16120
|
+
/** One model call's usage, once, when its step ends. Zeros mean the call failed before the provider answered. */
|
|
16121
|
+
countUsage(data, state, onEvent, subagentId) {
|
|
16122
|
+
const message = data.assistantMessageID;
|
|
16123
|
+
const tokens = data.tokens;
|
|
16124
|
+
if (!message || !tokens || state.seen.has(`usage:${message}`)) return;
|
|
16125
|
+
state.seen.add(`usage:${message}`);
|
|
16126
|
+
const prompt = partedPromptUsage({ uncached: tokens.input, cacheRead: tokens.cache?.read, cacheWrite: tokens.cache?.write });
|
|
16127
|
+
const outputTokens = (tokens.output ?? 0) + (tokens.reasoning ?? 0);
|
|
16128
|
+
if ((prompt.inputTokens ?? 0) + outputTokens === 0) return;
|
|
16129
|
+
const record = { source: "opencode", ...prompt, outputTokens };
|
|
16130
|
+
const model = state.stepModels.get(message);
|
|
16131
|
+
if (model) record.model = model;
|
|
16132
|
+
if (typeof data.cost === "number" && data.cost > 0) record.reportedCost = { amount: data.cost, currency: "USD" };
|
|
16133
|
+
if (subagentId) record.subagentId = subagentId;
|
|
16134
|
+
onEvent({ type: "usage", record });
|
|
16135
|
+
}
|
|
16136
|
+
/** One complete reply text, once. A message can carry text on both sides of a tool call, so each run after the first opens a paragraph. */
|
|
16137
|
+
emitText(id, text2, state, onEvent) {
|
|
16138
|
+
if (state.seen.has(`text:${id}`)) return;
|
|
16139
|
+
state.seen.add(`text:${id}`);
|
|
16140
|
+
if (!text2.trim()) return;
|
|
16141
|
+
const lead = state.textRuns.get(id)?.lead ?? (this.turnHasText ? "\n\n" : "");
|
|
16142
|
+
onEvent({ type: "assistant_text", text: `${lead}${text2}` });
|
|
16143
|
+
this.turnHasText = true;
|
|
16144
|
+
}
|
|
16145
|
+
/**
|
|
16146
|
+
* Stream one piece of a reply text. The paragraph break goes out with the
|
|
16147
|
+
* first visible delta, so the deltas add up to exactly the text the completed
|
|
16148
|
+
* run then re-sends; a run that is only whitespace so far is held back.
|
|
16149
|
+
*/
|
|
16150
|
+
onTextDelta(id, delta, state, onEvent) {
|
|
16151
|
+
if (state.seen.has(`text:${id}`)) return;
|
|
16152
|
+
const run = state.textRuns.get(id) ?? { held: "", lead: null };
|
|
16153
|
+
state.textRuns.set(id, run);
|
|
16154
|
+
if (run.lead !== null) {
|
|
16155
|
+
onEvent({ type: "assistant_text_delta", text: delta });
|
|
16156
|
+
return;
|
|
16157
|
+
}
|
|
16158
|
+
run.held += delta;
|
|
16159
|
+
if (!run.held.trim()) return;
|
|
16160
|
+
run.lead = this.turnHasText ? "\n\n" : "";
|
|
16161
|
+
this.turnHasText = true;
|
|
16162
|
+
onEvent({ type: "assistant_text_delta", text: `${run.lead}${run.held}` });
|
|
16163
|
+
}
|
|
16164
|
+
permissionRequest(ask) {
|
|
16165
|
+
const id = ask.id;
|
|
16166
|
+
if (!id) return null;
|
|
16167
|
+
const scope = (ask.resources ?? []).join(", ");
|
|
16168
|
+
return {
|
|
16169
|
+
type: "permission_request",
|
|
16170
|
+
id,
|
|
16171
|
+
name: ask.action ?? "permission",
|
|
16172
|
+
detail: JSON.stringify(scope ? { scope } : {}),
|
|
16173
|
+
input: ask.resources ? { resources: ask.resources } : {},
|
|
16174
|
+
// The patterns `always` would grant: the runner's own offer, and the only grounds for "Allow for this task".
|
|
16175
|
+
...ask.save?.length ? { suggestions: ask.save } : {},
|
|
16176
|
+
...ask.source?.id ? { toolUseId: ask.source.id } : {}
|
|
16177
|
+
};
|
|
16178
|
+
}
|
|
16179
|
+
/**
|
|
16180
|
+
* Deny one planner request. OpenCode blocks the turn until it is answered, so
|
|
16181
|
+
* this must answer: `reject` rather than a silent drop is the "absent answer
|
|
16182
|
+
* is a denial" invariant ADR-0008 states. The refusal is announced so the
|
|
16183
|
+
* timeline shows the planner reaching for something it may not have.
|
|
16184
|
+
*/
|
|
16185
|
+
denyPermission(ask, session, state, onEvent) {
|
|
16186
|
+
const request = this.permissionRequest(ask);
|
|
16187
|
+
if (!request || state.seen.has(`perm:${request.id}`)) return;
|
|
16188
|
+
state.seen.add(`perm:${request.id}`);
|
|
16189
|
+
onEvent(request);
|
|
16190
|
+
void this.replyPermission(request.id, session, { decision: "reject" }).catch(() => {
|
|
16191
|
+
});
|
|
16192
|
+
}
|
|
16193
|
+
/**
|
|
16194
|
+
* A task's request. Under a mode whose manifest sets `approvals: auto` it is
|
|
16195
|
+
* answered at once with what `opencode run --auto` answers, so the same plan
|
|
16196
|
+
* behaves the same on both transports (ADR-0001), and announced already
|
|
16197
|
+
* decided so the log still shows it. Any other mode leaves it open for an
|
|
16198
|
+
* approval card.
|
|
16199
|
+
*/
|
|
16200
|
+
askPermission(ask, session, state, onEvent) {
|
|
16201
|
+
const request = this.permissionRequest(ask);
|
|
16202
|
+
if (!request || state.seen.has(`perm:${request.id}`)) return;
|
|
16203
|
+
state.seen.add(`perm:${request.id}`);
|
|
16204
|
+
if (this.opts.kind === "task" && this.opts.flags.modeSettings.approvals === AUTO_APPROVALS) {
|
|
16205
|
+
onEvent({ ...request, decided: { decision: "allow" } });
|
|
16206
|
+
void this.replyPermission(request.id, session, { decision: "once" }).catch(() => {
|
|
16207
|
+
});
|
|
16208
|
+
return;
|
|
16209
|
+
}
|
|
16210
|
+
this.openPermissions.set(request.id, session);
|
|
16211
|
+
onEvent(request);
|
|
16212
|
+
}
|
|
16213
|
+
};
|
|
16214
|
+
|
|
16215
|
+
// src/services/harness/OpenCodeAdapter.ts
|
|
15323
16216
|
var SERVER_READY_TIMEOUT_MS = 3e4;
|
|
15324
16217
|
var STDERR_TAIL_CHARS2 = 4e3;
|
|
15325
|
-
var
|
|
16218
|
+
var STREAM_CONNECT_TIMEOUT_MS2 = 5e3;
|
|
15326
16219
|
var RECOVERY_POLL_INTERVAL_MS = 2e3;
|
|
15327
16220
|
var RECOVERY_TIMEOUT_MS = 9e5;
|
|
15328
16221
|
var STATUS_POLL_INTERVAL_MS = 1e3;
|
|
@@ -15335,8 +16228,8 @@ var DISABLED_TOOLS = {
|
|
|
15335
16228
|
todowrite: false
|
|
15336
16229
|
};
|
|
15337
16230
|
var TASK_DISABLED_TOOLS = { question: false };
|
|
15338
|
-
var
|
|
15339
|
-
function
|
|
16231
|
+
var AUTO_APPROVALS2 = "auto";
|
|
16232
|
+
function splitModelId2(id) {
|
|
15340
16233
|
const slash = id.indexOf("/");
|
|
15341
16234
|
if (slash <= 0 || slash === id.length - 1) return null;
|
|
15342
16235
|
return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
|
|
@@ -15392,7 +16285,7 @@ function unwrapFileToolOutput(output) {
|
|
|
15392
16285
|
const inner = output.match(/<content>\n?([\s\S]*?)\n?<\/content>/);
|
|
15393
16286
|
return inner ? inner[1] : output;
|
|
15394
16287
|
}
|
|
15395
|
-
function
|
|
16288
|
+
function delay2(ms, signal) {
|
|
15396
16289
|
return new Promise((resolve7) => {
|
|
15397
16290
|
const timer = setTimeout(resolve7, ms);
|
|
15398
16291
|
timer.unref?.();
|
|
@@ -15402,10 +16295,10 @@ function delay(ms, signal) {
|
|
|
15402
16295
|
}, { once: true });
|
|
15403
16296
|
});
|
|
15404
16297
|
}
|
|
15405
|
-
function
|
|
16298
|
+
function newTurnState2() {
|
|
15406
16299
|
return { seen: /* @__PURE__ */ new Set(), assistantMessages: /* @__PURE__ */ new Set(), partTypes: /* @__PURE__ */ new Map(), textRuns: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map(), heldFrames: /* @__PURE__ */ new Map() };
|
|
15407
16300
|
}
|
|
15408
|
-
function
|
|
16301
|
+
function permissionReply2(decision) {
|
|
15409
16302
|
if (decision.decision === "allow") return { reply: "once" };
|
|
15410
16303
|
if (decision.decision === "allowForTask") return { reply: "always" };
|
|
15411
16304
|
const note = decision.note?.trim();
|
|
@@ -15442,6 +16335,8 @@ var OpenCodeAdapter = class {
|
|
|
15442
16335
|
interruptRequested = false;
|
|
15443
16336
|
/** A task's requests waiting for an answer, by request id, with the session that asked. */
|
|
15444
16337
|
openPermissions = /* @__PURE__ */ new Map();
|
|
16338
|
+
/** Set once the server turns out to speak the 2.x API, which then owns the session. */
|
|
16339
|
+
v2 = null;
|
|
15445
16340
|
async start(opts) {
|
|
15446
16341
|
if (opts.kind === "task") this.task = opts;
|
|
15447
16342
|
else this.planner = opts;
|
|
@@ -15456,14 +16351,14 @@ var OpenCodeAdapter = class {
|
|
|
15456
16351
|
throw new ExecutableNotFoundError("opencode", PATH);
|
|
15457
16352
|
}
|
|
15458
16353
|
const workspaceEnv = await (this.deps.workspaceEnv ?? workspaceEnvOf)(opts.cwd);
|
|
15459
|
-
const password =
|
|
16354
|
+
const password = (0, import_crypto2.randomBytes)(24).toString("base64url");
|
|
15460
16355
|
this.process = this.deps.spawn(launch.file, launch.args, {
|
|
15461
|
-
env: runnerEnv(PATH,
|
|
16356
|
+
env: runnerEnv(PATH, { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password }),
|
|
15462
16357
|
stdio: ["pipe", "pipe", "pipe"],
|
|
15463
16358
|
cwd: opts.cwd,
|
|
15464
16359
|
windowsVerbatimArguments: launch.verbatim
|
|
15465
16360
|
});
|
|
15466
|
-
|
|
16361
|
+
this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
|
|
15467
16362
|
this.process.stdin?.on("error", () => {
|
|
15468
16363
|
});
|
|
15469
16364
|
const banner = new Promise((resolve7) => {
|
|
@@ -15500,6 +16395,24 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS2);
|
|
|
15500
16395
|
|
|
15501
16396
|
${this.stderrTail.trim()}` : ""}`);
|
|
15502
16397
|
}
|
|
16398
|
+
if (await this.speaksV2()) {
|
|
16399
|
+
this.v2 = new OpenCodeV2({
|
|
16400
|
+
request: (method, path27, body, signal) => this.request(method, path27, body, signal),
|
|
16401
|
+
json: (method, path27, body, signal) => this.json(method, path27, body, signal),
|
|
16402
|
+
openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
|
|
16403
|
+
processEnded: this.processEnded,
|
|
16404
|
+
isExited: () => this.exited,
|
|
16405
|
+
exitMessage: () => this.exitMessage()
|
|
16406
|
+
}, opts, this.role());
|
|
16407
|
+
try {
|
|
16408
|
+
await this.v2.start();
|
|
16409
|
+
} catch (err) {
|
|
16410
|
+
this.dispose();
|
|
16411
|
+
throw err;
|
|
16412
|
+
}
|
|
16413
|
+
this.sessionId = this.v2.nativeSessionId();
|
|
16414
|
+
return;
|
|
16415
|
+
}
|
|
15503
16416
|
if (opts.resumeSessionId) {
|
|
15504
16417
|
const existing = await this.json("GET", `/session/${opts.resumeSessionId}`).catch(() => null);
|
|
15505
16418
|
if (existing?.id) {
|
|
@@ -15523,12 +16436,17 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
15523
16436
|
onEvent({ type: "error", message: this.exitMessage() });
|
|
15524
16437
|
return;
|
|
15525
16438
|
}
|
|
16439
|
+
if (this.v2) {
|
|
16440
|
+
await this.v2.send(message, onEvent, signal, onActivity);
|
|
16441
|
+
if (signal?.aborted) this.dispose();
|
|
16442
|
+
return;
|
|
16443
|
+
}
|
|
15526
16444
|
if (this.task) return this.sendTask(this.task, message, onEvent, signal, onActivity);
|
|
15527
|
-
const turn =
|
|
16445
|
+
const turn = newTurnState2();
|
|
15528
16446
|
this.turnHasText = false;
|
|
15529
16447
|
const closeStream = await this.openStream(turn, onEvent, onActivity);
|
|
15530
16448
|
try {
|
|
15531
|
-
const model = this.planner?.model ?
|
|
16449
|
+
const model = this.planner?.model ? splitModelId2(this.planner.model) : null;
|
|
15532
16450
|
const body = {
|
|
15533
16451
|
parts: [{ type: "text", text: message }],
|
|
15534
16452
|
// The read-only guarantee: OpenCode's own plan agent has no write tools.
|
|
@@ -15569,7 +16487,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
15569
16487
|
* {@link recoverReply}, never applies.
|
|
15570
16488
|
*/
|
|
15571
16489
|
async sendTask(task, message, onEvent, signal, onActivity) {
|
|
15572
|
-
const state =
|
|
16490
|
+
const state = newTurnState2();
|
|
15573
16491
|
this.turnHasText = false;
|
|
15574
16492
|
this.interruptRequested = false;
|
|
15575
16493
|
let markDone = () => {
|
|
@@ -15585,7 +16503,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
15585
16503
|
const closeStream = await this.openStream(state, onEvent, onActivity);
|
|
15586
16504
|
const poll = new AbortController();
|
|
15587
16505
|
try {
|
|
15588
|
-
const model = task.model ?
|
|
16506
|
+
const model = task.model ? splitModelId2(task.model) : null;
|
|
15589
16507
|
const body = {
|
|
15590
16508
|
parts: [{ type: "text", text: message }],
|
|
15591
16509
|
// The manifest's meaning of the task's mode (ADR-0001): for OpenCode a mode is an agent.
|
|
@@ -15665,7 +16583,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
15665
16583
|
*/
|
|
15666
16584
|
async pollStatus(turn, signal) {
|
|
15667
16585
|
while (!turn.done && !signal.aborted) {
|
|
15668
|
-
await
|
|
16586
|
+
await delay2(STATUS_POLL_INTERVAL_MS, signal);
|
|
15669
16587
|
if (turn.done || signal.aborted) return;
|
|
15670
16588
|
const busy = await this.sessionBusy();
|
|
15671
16589
|
if (busy === true) turn.live = true;
|
|
@@ -15712,6 +16630,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
15712
16630
|
*/
|
|
15713
16631
|
async interrupt(timeoutMs) {
|
|
15714
16632
|
if (!this.process || !this.sessionId || this.exited) return false;
|
|
16633
|
+
if (this.v2) return this.v2.interrupt(timeoutMs);
|
|
15715
16634
|
const turn = this.taskTurn;
|
|
15716
16635
|
this.interruptRequested = true;
|
|
15717
16636
|
const posted = await this.json("POST", `/session/${this.sessionId}/abort`).then(() => true, () => false);
|
|
@@ -15732,13 +16651,23 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
15732
16651
|
void this.processEnded.then(() => listener(this.exitCode));
|
|
15733
16652
|
}
|
|
15734
16653
|
answerPermission(id, decision) {
|
|
16654
|
+
if (this.v2) return this.process ? this.v2.answerPermission(id, decision) : false;
|
|
15735
16655
|
const sessionId = this.openPermissions.get(id);
|
|
15736
16656
|
if (sessionId === void 0 || !this.process) return false;
|
|
15737
16657
|
this.openPermissions.delete(id);
|
|
15738
|
-
void this.replyPermission(id, sessionId,
|
|
16658
|
+
void this.replyPermission(id, sessionId, permissionReply2(decision)).catch(() => {
|
|
15739
16659
|
});
|
|
15740
16660
|
return true;
|
|
15741
16661
|
}
|
|
16662
|
+
/**
|
|
16663
|
+
* OpenCode 2.x replaced the 1.x HTTP surface rather than extending it, and
|
|
16664
|
+
* only 2.x answers `/api/info` with its version. A 1.x server — or one that
|
|
16665
|
+
* answers with anything else — keeps the 1.x protocol this class speaks.
|
|
16666
|
+
*/
|
|
16667
|
+
async speaksV2() {
|
|
16668
|
+
const info = await this.json("GET", "/api/info").catch(() => null);
|
|
16669
|
+
return typeof info === "object" && info !== null && typeof info.version === "string";
|
|
16670
|
+
}
|
|
15742
16671
|
role() {
|
|
15743
16672
|
return this.task ? "task" : "planner";
|
|
15744
16673
|
}
|
|
@@ -15789,7 +16718,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
15789
16718
|
if (pending?.info?.time?.completed || pending?.info?.error) return pending;
|
|
15790
16719
|
if (pending) onActivity?.();
|
|
15791
16720
|
if (Date.now() >= deadline) return null;
|
|
15792
|
-
await
|
|
16721
|
+
await delay2(RECOVERY_POLL_INTERVAL_MS, signal);
|
|
15793
16722
|
}
|
|
15794
16723
|
}
|
|
15795
16724
|
newestAssistantId(messages) {
|
|
@@ -16002,7 +16931,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
16002
16931
|
...ask.always?.length ? { suggestions: ask.always } : {},
|
|
16003
16932
|
...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {}
|
|
16004
16933
|
};
|
|
16005
|
-
if (this.task?.flags.modeSettings.approvals ===
|
|
16934
|
+
if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS2) {
|
|
16006
16935
|
onEvent({ ...request, decided: { decision: "allow" } });
|
|
16007
16936
|
void this.replyPermission(id, sessionId, { reply: "once" }).catch(() => {
|
|
16008
16937
|
});
|
|
@@ -16036,7 +16965,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
16036
16965
|
});
|
|
16037
16966
|
const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, turn, onEvent), connected, onActivity);
|
|
16038
16967
|
await Promise.race([streamReady, new Promise((r) => {
|
|
16039
|
-
const t = setTimeout(r,
|
|
16968
|
+
const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS2);
|
|
16040
16969
|
t.unref?.();
|
|
16041
16970
|
})]);
|
|
16042
16971
|
return async () => {
|
|
@@ -16217,6 +17146,10 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16217
17146
|
* pending and nothing ran until the user also re-ran the whole plan.
|
|
16218
17147
|
*/
|
|
16219
17148
|
haltedByFailure = false;
|
|
17149
|
+
/** A Merge all is under way: no ops task starts until it ends (ADR-0020). */
|
|
17150
|
+
merging = false;
|
|
17151
|
+
/** The last "waiting for Merge all" notice, so a run paused at a gate says it once, not on every tick. */
|
|
17152
|
+
gateNotice = "";
|
|
16220
17153
|
/** The workspace's own variables for a task's cwd (ADR-0016); swapped out in tests. */
|
|
16221
17154
|
workspaceEnv;
|
|
16222
17155
|
/** What the workspace env has already warned about, so a run says it once, not per task. */
|
|
@@ -16237,6 +17170,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16237
17170
|
observers = [];
|
|
16238
17171
|
tddEnabled;
|
|
16239
17172
|
readRunnerTransport;
|
|
17173
|
+
previousAttemptFromLog;
|
|
16240
17174
|
/**
|
|
16241
17175
|
* The setting as the latest run copied it when it opened. Every spawn of
|
|
16242
17176
|
* that run uses this, so flipping the setting mid-run changes the next run
|
|
@@ -16256,6 +17190,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16256
17190
|
this.workspaceEnv = deps.workspaceEnv;
|
|
16257
17191
|
this.tddEnabled = deps.tddEnabled;
|
|
16258
17192
|
this.readRunnerTransport = deps.runnerTransport;
|
|
17193
|
+
this.previousAttemptFromLog = deps.previousAttemptFromLog;
|
|
16259
17194
|
this.store.onMutate = () => this.emit("onTaskChanged");
|
|
16260
17195
|
this.verifier.onVerdict((taskId, verdict) => this.onVerdict(taskId, verdict));
|
|
16261
17196
|
this.verifier.onCheckpoint((taskId, summary) => {
|
|
@@ -16311,7 +17246,8 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16311
17246
|
workspaceRoot,
|
|
16312
17247
|
workspaceEnv: options.workspaceEnv ?? ((cwd) => resolveWorkspaceEnv(cwd)),
|
|
16313
17248
|
tddEnabled: options.tddEnabled ?? (() => false),
|
|
16314
|
-
runnerTransport: options.runnerTransport ?? (() => "terminal")
|
|
17249
|
+
runnerTransport: options.runnerTransport ?? (() => "terminal"),
|
|
17250
|
+
previousAttemptFromLog: options.previousAttemptFromLog ?? (() => null)
|
|
16315
17251
|
});
|
|
16316
17252
|
return orchestrator;
|
|
16317
17253
|
}
|
|
@@ -16319,6 +17255,18 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16319
17255
|
getIdleSince(taskId) {
|
|
16320
17256
|
return this.verifier.getIdleSince(taskId);
|
|
16321
17257
|
}
|
|
17258
|
+
/**
|
|
17259
|
+
* What an ops retry is told about the attempt before. The saved log is read
|
|
17260
|
+
* whether or not the session was reloaded, so both behave alike; the terminal
|
|
17261
|
+
* buffer is the fallback for a runner that has no log. A reload clears the
|
|
17262
|
+
* attempt count along with the buffer, so a retry made since is what says
|
|
17263
|
+
* there was an attempt to report on.
|
|
17264
|
+
*/
|
|
17265
|
+
opsPreviousAttempt(taskId) {
|
|
17266
|
+
const saved = this.previousAttemptFromLog(taskId) ?? this.output.liveTail(taskId, { maxLines: OPS_RETRY_TAIL_LINES })?.text;
|
|
17267
|
+
if (saved !== void 0) return saved;
|
|
17268
|
+
return (this.retryCounts.get(taskId) ?? 0) > 0 ? "Its output is not available: this session was reloaded since it ran." : void 0;
|
|
17269
|
+
}
|
|
16322
17270
|
/** Recent clean output of a task's latest attempt, running or ended; null if it never ran. */
|
|
16323
17271
|
getLiveOutput(taskId, opts) {
|
|
16324
17272
|
return this.output.liveTail(taskId, opts);
|
|
@@ -16525,9 +17473,48 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16525
17473
|
async reviewRunDiff() {
|
|
16526
17474
|
return this.runs.reviewDiff();
|
|
16527
17475
|
}
|
|
16528
|
-
/**
|
|
17476
|
+
/**
|
|
17477
|
+
* "Merge all"; a settled run that merged everything is cleared up and
|
|
17478
|
+
* forgotten. During a run it merges what has landed and the run goes on:
|
|
17479
|
+
* the tasks waiting at a merge gate for that work then start (ADR-0020).
|
|
17480
|
+
* Never while an ops task runs, and no ops task starts until it ends — the
|
|
17481
|
+
* one overlap the plan cannot order, because the merge is the user's.
|
|
17482
|
+
*/
|
|
16529
17483
|
async mergeRun() {
|
|
16530
|
-
|
|
17484
|
+
const ops = [...this.attempts.values()].find((a) => a.ops);
|
|
17485
|
+
if (ops) {
|
|
17486
|
+
const title = this.store.get(ops.taskId)?.title ?? ops.taskId;
|
|
17487
|
+
throw new TaskControlError(`Ops task "${title}" is running in your checkout \u2014 Merge all once it has finished.`);
|
|
17488
|
+
}
|
|
17489
|
+
this.merging = true;
|
|
17490
|
+
let result;
|
|
17491
|
+
try {
|
|
17492
|
+
result = await this.runs.merge();
|
|
17493
|
+
} finally {
|
|
17494
|
+
this.merging = false;
|
|
17495
|
+
}
|
|
17496
|
+
await this.tick();
|
|
17497
|
+
return result;
|
|
17498
|
+
}
|
|
17499
|
+
/**
|
|
17500
|
+
* The run as a surface offers Merge all mid-run (ADR-0020): what has landed
|
|
17501
|
+
* and is not merged, and whether the run is paused at its gates with nothing
|
|
17502
|
+
* else running. Null while no task waits at a gate.
|
|
17503
|
+
*/
|
|
17504
|
+
mergeGateView() {
|
|
17505
|
+
const handoff = this.runs.view()?.handoff;
|
|
17506
|
+
if (!handoff || !this.store.allTasks.some((t) => this.getMergeGate(t.id).length > 0)) return null;
|
|
17507
|
+
return { paused: this.running && this.attempts.size === 0, repos: handoff.repos, landed: handoff.landed };
|
|
17508
|
+
}
|
|
17509
|
+
/**
|
|
17510
|
+
* The dependencies an ops or user task waits on at its merge gate: those
|
|
17511
|
+
* whose landed work is not in the user's branch yet (ADR-0020). Empty once
|
|
17512
|
+
* the task has started, and for every other task.
|
|
17513
|
+
*/
|
|
17514
|
+
getMergeGate(taskId) {
|
|
17515
|
+
const task = this.store.get(taskId);
|
|
17516
|
+
if (!task || task.status !== "pending" && task.status !== "approved") return [];
|
|
17517
|
+
return mergeGate(task, this.store, this.runs);
|
|
16531
17518
|
}
|
|
16532
17519
|
/** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
|
|
16533
17520
|
async cleanupRun() {
|
|
@@ -16627,7 +17614,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16627
17614
|
this.emit("onReviewNeeded", { tasks: this.store.planTasks, planRunners: this.store.planRunners });
|
|
16628
17615
|
return;
|
|
16629
17616
|
}
|
|
16630
|
-
|
|
17617
|
+
this.runs.open();
|
|
16631
17618
|
console.log(`[TaskOrchestrator] Starting with ${this.store.allTasks.length} tasks (${this.store.allTasks.filter((t) => t.type === "ai" && t.prompt).length} AI ready)`);
|
|
16632
17619
|
this.running = true;
|
|
16633
17620
|
this.haltedByFailure = false;
|
|
@@ -16669,13 +17656,17 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
16669
17656
|
const summary = await this.output.finalText({ ...attempt, completionMarker: task.completionMarker }, doneToken);
|
|
16670
17657
|
if (this.attempts.get(taskId) !== attempt) return;
|
|
16671
17658
|
const landing = verdict.outcome === "pass" ? await this.land(attempt, () => this.landing.landPassed(task, attempt)) : null;
|
|
17659
|
+
const changedFiles = landing && attempt.ops ? await this.runs.filesChangedSince(attempt.snapshot) : [];
|
|
16672
17660
|
const unresumedReason = this.unresumed(attempt) ? unresumedMessage(task, `${attempt.runner} could not find its saved session`) : null;
|
|
16673
17661
|
if (this.attempts.get(taskId) !== attempt) return;
|
|
16674
17662
|
this.endAttempt(taskId, "verdict");
|
|
16675
17663
|
this.store.setTaskVerdict(taskId, verdict);
|
|
16676
17664
|
console.error(`[TaskOrchestrator] Output summary:
|
|
16677
17665
|
${summary || "(empty \u2014 no output captured)"}`);
|
|
16678
|
-
if (
|
|
17666
|
+
if (changedFiles.length > 0) {
|
|
17667
|
+
this.store.markAwaitingUser(taskId, "files-changed");
|
|
17668
|
+
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.`);
|
|
17669
|
+
} else if (landing) {
|
|
16679
17670
|
await this.applyLanding(task, landing, () => this.notifications.info(`Task "${task.title}" completed.`));
|
|
16680
17671
|
} else if (classifyRunnerStop(attempt.session?.getOutput() ?? "") === "usage-limit") {
|
|
16681
17672
|
this.store.markAwaitingUser(taskId);
|
|
@@ -16777,21 +17768,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
16777
17768
|
});
|
|
16778
17769
|
}
|
|
16779
17770
|
getReadyTasks() {
|
|
16780
|
-
|
|
17771
|
+
return this.readiness().ready;
|
|
17772
|
+
}
|
|
17773
|
+
readiness() {
|
|
17774
|
+
if (!this.running) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
|
|
16781
17775
|
const maxParallel = this.config.maxParallelSessions;
|
|
16782
17776
|
const active = this.attempts.size;
|
|
16783
|
-
const
|
|
17777
|
+
const readiness = selectReadyTasks({
|
|
16784
17778
|
store: this.store,
|
|
16785
17779
|
onHold: this.onHold,
|
|
16786
17780
|
runs: this.runs,
|
|
16787
17781
|
active,
|
|
16788
|
-
maxParallel
|
|
17782
|
+
maxParallel,
|
|
17783
|
+
merging: this.merging
|
|
16789
17784
|
});
|
|
16790
|
-
for (const { task, reasons } of excluded) {
|
|
17785
|
+
for (const { task, reasons } of readiness.excluded) {
|
|
16791
17786
|
console.log(`[TaskOrchestrator] excluded: #${task.order} "${task.title}" \u2014 ${reasons.join(", ")}`);
|
|
16792
17787
|
}
|
|
16793
|
-
console.log(`[TaskOrchestrator] getReadyTasks: ${candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
|
|
16794
|
-
return
|
|
17788
|
+
console.log(`[TaskOrchestrator] getReadyTasks: ${readiness.candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
|
|
17789
|
+
return readiness;
|
|
17790
|
+
}
|
|
17791
|
+
/**
|
|
17792
|
+
* Open the run a task starts in, and for a change task decide whether it
|
|
17793
|
+
* isolates (ADR-0020): an ops task needs no worktree, so it never waits on
|
|
17794
|
+
* that choice. False while a dirty tree waits on the user; `resume` is what
|
|
17795
|
+
* their choice replays.
|
|
17796
|
+
*/
|
|
17797
|
+
async openFor(taskId, resume) {
|
|
17798
|
+
this.runs.open();
|
|
17799
|
+
return this.store.isOps(taskId) || this.runs.decide(resume);
|
|
16795
17800
|
}
|
|
16796
17801
|
/**
|
|
16797
17802
|
* Cancel a running (or scheduled) task: kill its session and return it to
|
|
@@ -16919,7 +17924,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
16919
17924
|
const runner = this.store.resolveTaskRunner(task);
|
|
16920
17925
|
const route = routeTransport("structured", runner, this.registry);
|
|
16921
17926
|
if (route.transport !== "structured") throw new TaskControlError(`Task "${task.title}" cannot be continued: ${route.fallback}. Use Retry instead.`);
|
|
16922
|
-
if (!await this.
|
|
17927
|
+
if (!await this.openFor(taskId, () => this.continueTask(taskId, message))) return;
|
|
16923
17928
|
if (this.attempts.has(taskId) || !continuability(task).ok) return;
|
|
16924
17929
|
this.retryCounts.set(taskId, (this.retryCounts.get(taskId) ?? 0) + 1);
|
|
16925
17930
|
this.endAttempt(taskId, "retry");
|
|
@@ -16941,10 +17946,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
16941
17946
|
const task = this.store.get(taskId);
|
|
16942
17947
|
if (!task || task.type !== "ai") return;
|
|
16943
17948
|
if (this.attempts.has(taskId)) return;
|
|
16944
|
-
|
|
17949
|
+
this.refuseOpsDuringMerge(task);
|
|
17950
|
+
if (!await this.openFor(taskId, () => this.forceStartTask(taskId))) return;
|
|
16945
17951
|
this.onHold.delete(taskId);
|
|
17952
|
+
this.notePastGate(task);
|
|
16946
17953
|
await this.startTask(task);
|
|
16947
17954
|
}
|
|
17955
|
+
refuseOpsDuringMerge(task) {
|
|
17956
|
+
if (this.merging && this.store.isOps(task.id)) {
|
|
17957
|
+
throw new TaskControlError(`A Merge all is under way \u2014 ops task "${task.title}" can start once it has finished.`);
|
|
17958
|
+
}
|
|
17959
|
+
}
|
|
17960
|
+
/**
|
|
17961
|
+
* A force start passes a merge gate (ADR-0020), and the surface confirmed it
|
|
17962
|
+
* first; the choice is kept on the task and said, because the work it acts
|
|
17963
|
+
* on is not in the user's branch.
|
|
17964
|
+
*/
|
|
17965
|
+
notePastGate(task) {
|
|
17966
|
+
const unmerged = mergeGate(task, this.store, this.runs);
|
|
17967
|
+
if (unmerged.length === 0) return;
|
|
17968
|
+
const titles = unmerged.map((id) => `"${this.store.get(id)?.title ?? id}"`);
|
|
17969
|
+
this.store.setForcedPastGate(task.id, titles);
|
|
17970
|
+
this.tell("warn", `Task "${task.title}" was force-started before the work of ${titles.join(", ")} was merged into your branch.`);
|
|
17971
|
+
}
|
|
16948
17972
|
/**
|
|
16949
17973
|
* Run exactly one task outside full-plan scheduling. The active/starting
|
|
16950
17974
|
* session still contributes to {@link hasLiveWork} so every surface exposes
|
|
@@ -16955,8 +17979,10 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
16955
17979
|
if (this.hasLiveWork) return;
|
|
16956
17980
|
const task = this.store.get(taskId);
|
|
16957
17981
|
if (!task || task.type !== "ai") return;
|
|
16958
|
-
|
|
17982
|
+
this.refuseOpsDuringMerge(task);
|
|
17983
|
+
if (!await this.openFor(taskId, () => this.runTask(taskId))) return;
|
|
16959
17984
|
this.onHold.delete(taskId);
|
|
17985
|
+
this.notePastGate(task);
|
|
16960
17986
|
await this.startTask(task);
|
|
16961
17987
|
}
|
|
16962
17988
|
getCompletedCount() {
|
|
@@ -16996,7 +18022,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
16996
18022
|
}
|
|
16997
18023
|
return;
|
|
16998
18024
|
}
|
|
16999
|
-
|
|
18025
|
+
let readiness = this.readiness();
|
|
18026
|
+
const atGate = readiness.gated.length > 0 || this.store.allTasks.some((t) => t.type === "user" && this.getMergeGate(t.id).length > 0);
|
|
18027
|
+
if (atGate && await this.runs.refreshInHead()) readiness = this.readiness();
|
|
18028
|
+
if (!this.running) return;
|
|
18029
|
+
const { ready } = readiness;
|
|
17000
18030
|
console.log(`[TaskOrchestrator] tick(): ${ready.length} ready, ${this.attempts.size} active, queue=${this.messageQueue.length}`);
|
|
17001
18031
|
if (ready.length === 0 && this.attempts.size === 0) {
|
|
17002
18032
|
const remaining = this.store.allTasks.filter((t) => t.status !== "completed");
|
|
@@ -17016,14 +18046,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
17016
18046
|
this.emit("onExecutionComplete");
|
|
17017
18047
|
} else {
|
|
17018
18048
|
this.notifications.info("Remaining tasks require user action or are on hold.");
|
|
18049
|
+
this.sayGate();
|
|
17019
18050
|
this.emit("onTaskChanged");
|
|
17020
18051
|
this.emit("onTick");
|
|
17021
18052
|
}
|
|
17022
18053
|
return;
|
|
17023
18054
|
}
|
|
17024
|
-
for (const task of ready)
|
|
18055
|
+
for (const task of ready) {
|
|
18056
|
+
if (this.store.isOps(task.id) ? this.merging : !await this.runs.decide(() => this.tick())) continue;
|
|
18057
|
+
await this.startTask(task);
|
|
18058
|
+
}
|
|
17025
18059
|
this.emit("onTick");
|
|
17026
18060
|
}
|
|
18061
|
+
/**
|
|
18062
|
+
* A run paused at merge gates says so, as plainly as a task that waits on
|
|
18063
|
+
* the user: which tasks wait, and that Merge all lets them go on (ADR-0020).
|
|
18064
|
+
*/
|
|
18065
|
+
sayGate() {
|
|
18066
|
+
const waiting = this.store.allTasks.filter((t) => this.getMergeGate(t.id).length > 0);
|
|
18067
|
+
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.`;
|
|
18068
|
+
if (notice === this.gateNotice) return;
|
|
18069
|
+
this.gateNotice = notice;
|
|
18070
|
+
if (notice) this.tell("info", notice);
|
|
18071
|
+
}
|
|
17027
18072
|
async startTask(task, continuation = null) {
|
|
17028
18073
|
if (this.attempts.has(task.id)) return;
|
|
17029
18074
|
const attempt = {
|
|
@@ -17037,6 +18082,8 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
17037
18082
|
integration: null,
|
|
17038
18083
|
repair: this.landing.nextRepair(task.id),
|
|
17039
18084
|
continuation,
|
|
18085
|
+
ops: this.store.isOps(task.id),
|
|
18086
|
+
snapshot: null,
|
|
17040
18087
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
17041
18088
|
decided: false
|
|
17042
18089
|
};
|
|
@@ -17062,18 +18109,22 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
17062
18109
|
*/
|
|
17063
18110
|
async spawnAttempt(task, attempt) {
|
|
17064
18111
|
try {
|
|
17065
|
-
const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null });
|
|
18112
|
+
const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null, ops: attempt.ops });
|
|
17066
18113
|
attempt.cwd = cwd;
|
|
17067
18114
|
attempt.worktree = worktree;
|
|
18115
|
+
if (attempt.ops) attempt.snapshot = await this.runs.snapshotWorkspace();
|
|
17068
18116
|
if (this.attempts.get(task.id) !== attempt) {
|
|
17069
18117
|
if (attempt.worktree && !this.attempts.has(task.id)) await this.runs.release(task.id, { keep: attempt.repair !== null });
|
|
17070
18118
|
this.abandonSpawn(task, attempt);
|
|
17071
18119
|
return false;
|
|
17072
18120
|
}
|
|
17073
|
-
const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
|
|
18121
|
+
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, {
|
|
17074
18122
|
planMapEnabled: this.config.planMapEnabled,
|
|
17075
18123
|
// A merge to resolve is not new behaviour to drive test-first.
|
|
17076
|
-
tddEnabled: !attempt.repair && this.tddEnabled()
|
|
18124
|
+
tddEnabled: !attempt.repair && this.tddEnabled(),
|
|
18125
|
+
// An ops task's effects outlive a failed attempt and are never rolled
|
|
18126
|
+
// back, so the next one is told what the last one did (ADR-0020).
|
|
18127
|
+
previousAttempt: attempt.ops ? this.opsPreviousAttempt(task.id) : void 0
|
|
17077
18128
|
});
|
|
17078
18129
|
this.lingering.close(task.id);
|
|
17079
18130
|
const env = await this.envForTask(cwd);
|
|
@@ -17250,6 +18301,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
17250
18301
|
return ended;
|
|
17251
18302
|
}
|
|
17252
18303
|
};
|
|
18304
|
+
var OPS_RETRY_TAIL_LINES = 60;
|
|
17253
18305
|
function unresumedMessage(task, why) {
|
|
17254
18306
|
return `Could not continue task "${task.title}": ${why}. Retry starts it afresh.`;
|
|
17255
18307
|
}
|
|
@@ -19707,7 +20759,7 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
|
|
|
19707
20759
|
return tasks.map((t) => {
|
|
19708
20760
|
const isMan = t.type === "user";
|
|
19709
20761
|
const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
|
|
19710
|
-
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(", ")}]`;
|
|
20762
|
+
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(", ")}]`;
|
|
19711
20763
|
});
|
|
19712
20764
|
}
|
|
19713
20765
|
};
|
|
@@ -20163,10 +21215,11 @@ function serializeTask(t) {
|
|
|
20163
21215
|
thinkingEffort: t.thinkingEffort || void 0,
|
|
20164
21216
|
autonomy: t.autonomy || void 0,
|
|
20165
21217
|
sliceType: t.sliceType || void 0,
|
|
20166
|
-
userStoriesCovered: t.userStoriesCovered || void 0
|
|
21218
|
+
userStoriesCovered: t.userStoriesCovered || void 0,
|
|
21219
|
+
...t.ops ? { ops: true } : {}
|
|
20167
21220
|
};
|
|
20168
21221
|
}
|
|
20169
|
-
function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [], awaitingApproval = 0) {
|
|
21222
|
+
function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [], awaitingApproval = 0, mergeGate2 = []) {
|
|
20170
21223
|
return {
|
|
20171
21224
|
id: t.id,
|
|
20172
21225
|
status: t.status,
|
|
@@ -20177,7 +21230,9 @@ function serializeTaskStatus(t, idleSince = null, isolation = null, queued = [],
|
|
|
20177
21230
|
...t.status === "awaiting_user" && t.awaitingReason ? { awaitingReason: t.awaitingReason } : {},
|
|
20178
21231
|
...queued.length > 0 ? { queued: queued.map((m) => ({ ...m })) } : {},
|
|
20179
21232
|
...canContinue(t) ? { continuable: true } : {},
|
|
20180
|
-
...awaitingApproval > 0 ? { awaitingApproval } : {}
|
|
21233
|
+
...awaitingApproval > 0 ? { awaitingApproval } : {},
|
|
21234
|
+
...mergeGate2.length > 0 ? { mergeGate: [...mergeGate2] } : {},
|
|
21235
|
+
...t.forcedPastGate?.length ? { forcedPastGate: [...t.forcedPastGate] } : {}
|
|
20181
21236
|
};
|
|
20182
21237
|
}
|
|
20183
21238
|
function serializePlan(plan) {
|
|
@@ -20263,6 +21318,7 @@ var SessionEventRelay = class {
|
|
|
20263
21318
|
this.statusOwed = this.statusHeld;
|
|
20264
21319
|
if (this.statusHeld) return;
|
|
20265
21320
|
plan.tasks = this.store.snapshot();
|
|
21321
|
+
const gate = this.orchestrator.mergeGateView();
|
|
20266
21322
|
this.broadcast({
|
|
20267
21323
|
type: "status_update",
|
|
20268
21324
|
tasks: this.store.allTasks.map((t) => serializeTaskStatus(
|
|
@@ -20270,8 +21326,10 @@ var SessionEventRelay = class {
|
|
|
20270
21326
|
this.orchestrator.getIdleSince(t.id),
|
|
20271
21327
|
this.orchestrator.getTaskIsolation(t.id),
|
|
20272
21328
|
this.orchestrator.getQueuedTaskMessages(t.id),
|
|
20273
|
-
this.awaitingApproval(t.id)
|
|
20274
|
-
|
|
21329
|
+
this.awaitingApproval(t.id),
|
|
21330
|
+
this.orchestrator.getMergeGate(t.id)
|
|
21331
|
+
)),
|
|
21332
|
+
...gate ? { gate } : {}
|
|
20275
21333
|
});
|
|
20276
21334
|
}
|
|
20277
21335
|
/**
|
|
@@ -20747,6 +21805,13 @@ var PlanEditError = class extends Error {
|
|
|
20747
21805
|
this.name = "PlanEditError";
|
|
20748
21806
|
}
|
|
20749
21807
|
};
|
|
21808
|
+
var OPS_RETRY_DIGEST_CALLS = 20;
|
|
21809
|
+
function lastAttemptDigest(location, taskId) {
|
|
21810
|
+
const attempt = listTaskLogAttempts(location, taskId).pop();
|
|
21811
|
+
if (attempt === void 0) return null;
|
|
21812
|
+
const events = readTaskLog(location, taskId, attempt);
|
|
21813
|
+
return events.length > 0 ? digestTaskLog(events, OPS_RETRY_DIGEST_CALLS) : null;
|
|
21814
|
+
}
|
|
20750
21815
|
function sessionRuntimeSettings(settings) {
|
|
20751
21816
|
return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist, runnerTransport: settings.runnerTransport };
|
|
20752
21817
|
}
|
|
@@ -20831,7 +21896,8 @@ function createSession(deps) {
|
|
|
20831
21896
|
registry: deps.registry,
|
|
20832
21897
|
workspaceRoot: deps.workspaceRoot,
|
|
20833
21898
|
tddEnabled: () => deps.settings().tddEnabled,
|
|
20834
|
-
runnerTransport: () => deps.settings().runnerTransport ?? "terminal"
|
|
21899
|
+
runnerTransport: () => deps.settings().runnerTransport ?? "terminal",
|
|
21900
|
+
previousAttemptFromLog: (taskId) => lastAttemptDigest(session.taskLogLocation, taskId)
|
|
20835
21901
|
});
|
|
20836
21902
|
const usage = new PlannerUsageLedger();
|
|
20837
21903
|
const events = new SessionEventRelay({
|
|
@@ -21604,6 +22670,14 @@ var Session = class {
|
|
|
21604
22670
|
isolationView() {
|
|
21605
22671
|
return this.orchestrator.isolationView();
|
|
21606
22672
|
}
|
|
22673
|
+
/** The dependencies a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
|
|
22674
|
+
mergeGate(taskId) {
|
|
22675
|
+
return this.orchestrator.getMergeGate(taskId);
|
|
22676
|
+
}
|
|
22677
|
+
/** What Merge all would merge mid-run, while any task waits at a merge gate; null otherwise. */
|
|
22678
|
+
mergeGateView() {
|
|
22679
|
+
return this.orchestrator.mergeGateView();
|
|
22680
|
+
}
|
|
21607
22681
|
/** The run's integration branch against its base ref, as a unified diff. */
|
|
21608
22682
|
async reviewRunDiff() {
|
|
21609
22683
|
if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
|
|
@@ -21613,10 +22687,12 @@ var Session = class {
|
|
|
21613
22687
|
* "Merge all": each repo's integration branch into whatever the user has
|
|
21614
22688
|
* checked out there — every repo, or none if any cannot take it. The one
|
|
21615
22689
|
* irreversible step of isolated execution, so this explicit call is the
|
|
21616
|
-
* only way it ever happens.
|
|
22690
|
+
* only way it ever happens. During a run it merges what has landed so far,
|
|
22691
|
+
* which is what opens a merge gate (ADR-0020); it waits for no ops task,
|
|
22692
|
+
* and refuses while one runs.
|
|
21617
22693
|
*/
|
|
21618
22694
|
async mergeRun() {
|
|
21619
|
-
this.
|
|
22695
|
+
if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
|
|
21620
22696
|
const result = await this.orchestrator.mergeRun();
|
|
21621
22697
|
this.broadcast({ type: "isolation_merge", result });
|
|
21622
22698
|
return result;
|
|
@@ -21706,7 +22782,8 @@ var Session = class {
|
|
|
21706
22782
|
* falls through to the no-op `store.update` below instead of throwing.
|
|
21707
22783
|
*/
|
|
21708
22784
|
async updateTask(taskId, changes) {
|
|
21709
|
-
if (
|
|
22785
|
+
if ("ops" in changes) changes = { ...changes, ops: changes.ops === true ? true : void 0 };
|
|
22786
|
+
if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode || "ops" in changes) && this.store.get(taskId)) {
|
|
21710
22787
|
const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
|
|
21711
22788
|
if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
|
|
21712
22789
|
if (check.clear?.length) {
|
|
@@ -24409,6 +25486,7 @@ var PlannerModelMemory = class {
|
|
|
24409
25486
|
FsPluginStore,
|
|
24410
25487
|
GIT_READONLY_SUBCOMMANDS,
|
|
24411
25488
|
GREP_DEFAULT_HEAD_LIMIT,
|
|
25489
|
+
GREP_PCRE_PROBE_ARGS,
|
|
24412
25490
|
GeminiService,
|
|
24413
25491
|
HeadlessRunner,
|
|
24414
25492
|
HeadlessSession,
|
|
@@ -24548,6 +25626,7 @@ var PlannerModelMemory = class {
|
|
|
24548
25626
|
dependencyCandidates,
|
|
24549
25627
|
dependentsOf,
|
|
24550
25628
|
describeMergeResult,
|
|
25629
|
+
digestTaskLog,
|
|
24551
25630
|
discoverGeminiModels,
|
|
24552
25631
|
drainNext,
|
|
24553
25632
|
effectiveAllowlist,
|
|
@@ -24580,6 +25659,7 @@ var PlannerModelMemory = class {
|
|
|
24580
25659
|
getStateDir,
|
|
24581
25660
|
globalDataDir,
|
|
24582
25661
|
grantScopeFor,
|
|
25662
|
+
grepRegexFromProbe,
|
|
24583
25663
|
hasHiddenDetail,
|
|
24584
25664
|
hasTmux,
|
|
24585
25665
|
holdPrompt,
|