sortie-dogs 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
20
20
 
21
21
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
22
22
 
23
- Release: [v0.5.2](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.2)
23
+ Release: [v0.5.3](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.5.3)
24
24
 
25
25
  ## Quick start
26
26
 
@@ -2,5 +2,5 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export declare const RUNTIME_ASSET_VERSION = "0.3.33-readable-terminal-report-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.34-parallel-host-contract-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -2,4 +2,4 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export const RUNTIME_ASSET_VERSION = "0.3.33-readable-terminal-report-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.34-parallel-host-contract-v1";
@@ -4,6 +4,7 @@ export declare class WorktreeCommitArtifactError extends Error {
4
4
  readonly code: WorktreeCommitArtifactErrorCode;
5
5
  constructor(code: WorktreeCommitArtifactErrorCode, message: string);
6
6
  }
7
+ export declare function resolveValidationExecutable(executable: string): Promise<string | undefined>;
7
8
  /** Runs one bounded command without exposing output or performing Git mutations. */
8
9
  export declare function runContainedValidation(request: ContainedValidationRequest): Promise<ContainedValidationResult>;
9
10
  export declare function produceWorktreeCommitArtifact(request: WorktreeCommitProduceRequest): Promise<WorktreeCommitArtifact>;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { lstat, realpath } from "node:fs/promises";
4
- import { isAbsolute, join, resolve, sep } from "node:path";
4
+ import { delimiter, extname, isAbsolute, join, resolve, sep } from "node:path";
5
5
  import { normalizeWorktreeScopePath } from "./worktree-scope.js";
6
6
  const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
7
7
  const HASH = /^[0-9a-f]{64}$/u;
@@ -368,6 +368,26 @@ function exactKeys(value, keys) {
368
368
  function validText(value, max = 256) {
369
369
  return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value);
370
370
  }
371
+ export async function resolveValidationExecutable(executable) {
372
+ if (isAbsolute(executable))
373
+ return realpath(executable).catch(() => undefined);
374
+ if (!validText(executable, MAX_COMMAND_TEXT) || executable.startsWith("-") || /[\\/]/u.test(executable))
375
+ return undefined;
376
+ const path = process.env.PATH ?? process.env.Path;
377
+ if (path === undefined)
378
+ return undefined;
379
+ const extensions = process.platform === "win32" && extname(executable).length === 0
380
+ ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((value) => value.length > 0)
381
+ : [""];
382
+ for (const directory of path.split(delimiter)) {
383
+ for (const extension of extensions) {
384
+ const candidate = await realpath(resolve(directory || ".", `${executable}${extension}`)).catch(() => undefined);
385
+ if (candidate !== undefined && isAbsolute(candidate))
386
+ return candidate;
387
+ }
388
+ }
389
+ return undefined;
390
+ }
371
391
  function pathIdentity(value) {
372
392
  const normalized = resolve(value).split(sep).join("/");
373
393
  return process.platform === "win32" ? normalized.toLowerCase() : normalized;
@@ -550,6 +570,10 @@ async function runBounded(executable, args, cwd, timeout, kind) {
550
570
  clearTimeout(timer);
551
571
  }
552
572
  }
573
+ function boundedCommandKind(executable, args) {
574
+ return /(?:^|[\\/])git(?:\.exe)?$/iu.test(executable) && args.length === 2 &&
575
+ args[0] === "diff" && args[1] === "--check" ? "git" : "validation";
576
+ }
553
577
  /** Runs one bounded command without exposing output or performing Git mutations. */
554
578
  export async function runContainedValidation(request) {
555
579
  const suppliedArgs = isRecord(request) && Array.isArray(request.args) ? request.args : [];
@@ -560,21 +584,23 @@ export async function runContainedValidation(request) {
560
584
  const failed = (command, exitCode, error) => Object.freeze({ ok: false, command, exit_code: exitCode,
561
585
  fingerprint: createHash("sha256").update(JSON.stringify([command, exitCode, error])).digest("hex"), error });
562
586
  if (!isRecord(request) || !exactKeys(request, ["cwd", "executable", "timeout_ms", ...(request.args === undefined ? [] : ["args"])]) ||
563
- !validText(request.executable, MAX_COMMAND_TEXT) || !isAbsolute(request.executable) ||
587
+ !validText(request.executable, MAX_COMMAND_TEXT) ||
588
+ (!isAbsolute(request.executable) && (request.executable.startsWith("-") || /[\\/]/u.test(request.executable))) ||
564
589
  !validText(request.cwd, MAX_PATH) || !isAbsolute(request.cwd) ||
565
590
  !Number.isInteger(request.timeout_ms) || request.timeout_ms < 1 || request.timeout_ms > MAX_TIMEOUT ||
566
591
  !Array.isArray(suppliedArgs) || suppliedArgs.length > MAX_ARGUMENTS ||
567
592
  !suppliedArgs.every((arg) => validText(arg, MAX_COMMAND_TEXT)))
568
593
  return failed(fallbackCommand, null, "invalid-request");
569
594
  const [executable, cwd] = await Promise.all([
570
- realpath(request.executable).catch(() => undefined), realpath(request.cwd).catch(() => undefined),
595
+ resolveValidationExecutable(request.executable),
596
+ realpath(request.cwd).catch(() => undefined),
571
597
  ]);
572
- if (executable === undefined || cwd === undefined || !isAbsolute(executable) || !isAbsolute(cwd)) {
598
+ if (executable === undefined || cwd === undefined || !isAbsolute(cwd)) {
573
599
  return failed(fallbackCommand, null, "invalid-request");
574
600
  }
575
601
  const command = Object.freeze([executable, ...suppliedArgs]);
576
602
  try {
577
- const result = await runBounded(executable, suppliedArgs, cwd, request.timeout_ms, "validation");
603
+ const result = await runBounded(executable, suppliedArgs, cwd, request.timeout_ms, boundedCommandKind(executable, suppliedArgs));
578
604
  const fingerprint = createHash("sha256").update(JSON.stringify([command, result.code])).digest("hex");
579
605
  return result.code === 0
580
606
  ? Object.freeze({ ok: true, command, exit_code: 0, fingerprint, error: null })
@@ -679,12 +705,15 @@ function parseStatus(source, staged) {
679
705
  throw new WorktreeCommitArtifactError("invalid-state", "Git status is ambiguous.");
680
706
  const x = field[0];
681
707
  const y = field[1];
682
- const untracked = staged === "forbid" && x === "?" && y === "?";
708
+ const untracked = x === "?" && y === "?";
683
709
  if (!untracked && (x === "?" || y === "?" || x === "!" || y === "!" || "RCUT".includes(x) || "RCUT".includes(y))) {
684
710
  throw new WorktreeCommitArtifactError("invalid-state", "Untracked, renamed, copied, or unsupported changes are forbidden.");
685
711
  }
686
- if ((!untracked && staged === "forbid" && (x !== " " || y !== "M" && y !== "D")) ||
687
- (staged === "require" && ((x !== "A" && x !== "M" && x !== "D") || y !== " "))) {
712
+ const unstagedEdit = x === " " && (y === "M" || y === "D");
713
+ const stagedEdit = (x === "A" || x === "M" || x === "D") && y === " ";
714
+ if ((!untracked && staged === "forbid" && !unstagedEdit) ||
715
+ (!untracked && staged === "require" && !stagedEdit) ||
716
+ (!untracked && staged === "either" && !unstagedEdit && !stagedEdit)) {
688
717
  throw new WorktreeCommitArtifactError("invalid-state", "Index or worktree status is not an accepted implementation edit.");
689
718
  }
690
719
  const path = field.slice(3);
@@ -695,7 +724,7 @@ function parseStatus(source, staged) {
695
724
  catch {
696
725
  throw new WorktreeCommitArtifactError("invalid-state", "Changed path is not canonical.");
697
726
  }
698
- entries.push({ code: untracked ? "A" : staged === "forbid" ? y : x, path });
727
+ entries.push({ code: untracked ? "A" : stagedEdit ? x : y, path });
699
728
  if (folded.length === 0)
700
729
  throw new WorktreeCommitArtifactError("invalid-state", "Changed path is invalid.");
701
730
  }
@@ -714,9 +743,34 @@ function assertScope(entries, scope) {
714
743
  }
715
744
  }
716
745
  async function status(context, staged) {
717
- return parseStatus(await context.git([
746
+ const entries = parseStatus(await context.git([
718
747
  "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none",
719
748
  ]), staged);
749
+ const controls = new Set([
750
+ `handoff.${context.descriptor.task_id}.json`,
751
+ `${context.descriptor.task_id}.operation-manifest.json`,
752
+ ]);
753
+ return entries.filter(({ code, path }) => code !== "A" || !controls.has(path));
754
+ }
755
+ async function stagedPaths(context) {
756
+ if ((await context.git(["ls-files", "-u", "-z"])).length !== 0) {
757
+ throw new WorktreeCommitArtifactError("invalid-state", "An unmerged index is forbidden.");
758
+ }
759
+ const fields = decodeGitOutput(await context.git([
760
+ "diff", "--cached", "--name-only", "--no-renames", "-z", "--",
761
+ ]), "Staged path encoding is invalid.").split("\0");
762
+ if (fields.at(-1) !== "")
763
+ throw new WorktreeCommitArtifactError("invalid-state", "Staged paths are ambiguous.");
764
+ fields.pop();
765
+ for (const path of fields) {
766
+ if (normalizeWorktreeScopePath(path) !== path) {
767
+ throw new WorktreeCommitArtifactError("invalid-state", "A staged path is not canonical.");
768
+ }
769
+ }
770
+ if (new Set(fields.map((path) => path.toLowerCase())).size !== fields.length) {
771
+ throw new WorktreeCommitArtifactError("invalid-state", "Staged path identities are ambiguous.");
772
+ }
773
+ return new Set(fields);
720
774
  }
721
775
  async function assertNoSubmodules(context, entries) {
722
776
  for (const entry of entries) {
@@ -884,9 +938,7 @@ async function committedEntries(context, base, commit) {
884
938
  }
885
939
  async function validateCommit(context, artifact) {
886
940
  await assertBaseState(context, artifact.commit_sha);
887
- if ((await context.git([
888
- "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none",
889
- ])).length !== 0) {
941
+ if ((await status(context, "forbid")).length !== 0) {
890
942
  throw new WorktreeCommitArtifactError("verification-failed", "Committed checkout is not clean.");
891
943
  }
892
944
  const parentLine = (await context.git(["rev-list", "--parents", "-n", "1", artifact.commit_sha])).toString("utf8").trim().split(" ");
@@ -934,7 +986,9 @@ export async function produceWorktreeCommitArtifact(request) {
934
986
  if (!isRecord(request) || !exactKeys(request, ["descriptor", "managed_path", "validation", ...(request.git_path === undefined ? [] : ["git_path"])]) ||
935
987
  !isRecord(request.validation) || !exactKeys(request.validation, ["executable", ...(request.validation.args === undefined ? [] : ["args"]),
936
988
  ...(request.validation.timeout_ms === undefined ? [] : ["timeout_ms"])]) ||
937
- !validText(request.validation.executable, MAX_COMMAND_TEXT) || !isAbsolute(request.validation.executable) ||
989
+ !validText(request.validation.executable, MAX_COMMAND_TEXT) ||
990
+ (!isAbsolute(request.validation.executable) &&
991
+ (request.validation.executable.startsWith("-") || /[\\/]/u.test(request.validation.executable))) ||
938
992
  (request.validation.args !== undefined && (!Array.isArray(request.validation.args) || request.validation.args.length > MAX_ARGUMENTS ||
939
993
  !request.validation.args.every((arg) => validText(arg, MAX_COMMAND_TEXT)))) ||
940
994
  (request.validation.timeout_ms !== undefined && (!Number.isInteger(request.validation.timeout_ms) ||
@@ -942,26 +996,38 @@ export async function produceWorktreeCommitArtifact(request) {
942
996
  throw new WorktreeCommitArtifactError("invalid-request", "Commit producer request is invalid.");
943
997
  }
944
998
  const context = await makeContext(request);
945
- const executable = await realpath(request.validation.executable).catch(() => undefined);
999
+ const executable = await resolveValidationExecutable(request.validation.executable);
946
1000
  if (executable === undefined || !isAbsolute(executable)) {
947
1001
  throw new WorktreeCommitArtifactError("invalid-request", "Validation executable does not exist.");
948
1002
  }
949
1003
  const command = Object.freeze([executable, ...(request.validation.args ?? [])]);
950
1004
  await assertBaseState(context, context.descriptor.base_sha);
951
- const before = await status(context, "forbid");
1005
+ const before = await status(context, "either");
952
1006
  if (before.length === 0)
953
1007
  throw new WorktreeCommitArtifactError("invalid-state", "No implementation changes exist.");
954
1008
  assertScope(before, context.descriptor.scope_write);
955
1009
  await assertNoSubmodules(context, before);
1010
+ const staged = await stagedPaths(context);
1011
+ const hostStaged = staged.size > 0;
1012
+ if (hostStaged && (boundedCommandKind(executable, request.validation.args ?? []) !== "git" ||
1013
+ staged.size !== before.length || !before.every(({ path }) => staged.has(path)))) {
1014
+ throw new WorktreeCommitArtifactError("invalid-state", "Staged changes are not an exact host snapshot of the implementation.");
1015
+ }
956
1016
  const beforeFingerprint = await changeFingerprint(context, before, "worktree");
957
- const validation = await runBounded(executable, request.validation.args ?? [], context.managedPath, request.validation.timeout_ms ?? GIT_TIMEOUT, "validation");
1017
+ const validation = await runBounded(executable, request.validation.args ?? [], context.managedPath, request.validation.timeout_ms ?? GIT_TIMEOUT, boundedCommandKind(executable, request.validation.args ?? []));
958
1018
  if (validation.code !== 0)
959
1019
  throw new WorktreeCommitArtifactError("validation-failed", validation.code === 240 ? "Validation containment setup failed."
960
1020
  : validation.code === 241 ? "Validation left a descendant process."
961
1021
  : validation.code === 238 ? "Validation exceeded its resource bound."
962
1022
  : "Validation exited unsuccessfully.");
1023
+ if (hostStaged) {
1024
+ const stagedValidation = await runBounded(executable, ["diff", "--cached", "--check"], context.managedPath, request.validation.timeout_ms ?? GIT_TIMEOUT, "git");
1025
+ if (stagedValidation.code !== 0) {
1026
+ throw new WorktreeCommitArtifactError("validation-failed", "Staged implementation validation exited unsuccessfully.");
1027
+ }
1028
+ }
963
1029
  await assertBaseState(context, context.descriptor.base_sha);
964
- const after = await status(context, "forbid");
1030
+ const after = await status(context, "either");
965
1031
  assertScope(after, context.descriptor.scope_write);
966
1032
  await assertNoSubmodules(context, after);
967
1033
  if (JSON.stringify(after) !== JSON.stringify(before) || await changeFingerprint(context, after, "worktree") !== beforeFingerprint) {
@@ -1000,7 +1066,9 @@ export async function recoverWorktreeCommitArtifact(request) {
1000
1066
  if (!isRecord(request) || !exactKeys(request, ["descriptor", "managed_path", "validation", ...(request.git_path === undefined ? [] : ["git_path"])]) ||
1001
1067
  !isRecord(request.validation) || !exactKeys(request.validation, ["executable", ...(request.validation.args === undefined ? [] : ["args"]),
1002
1068
  ...(request.validation.timeout_ms === undefined ? [] : ["timeout_ms"])]) ||
1003
- !validText(request.validation.executable, MAX_COMMAND_TEXT) || !isAbsolute(request.validation.executable) ||
1069
+ !validText(request.validation.executable, MAX_COMMAND_TEXT) ||
1070
+ (!isAbsolute(request.validation.executable) &&
1071
+ (request.validation.executable.startsWith("-") || /[\\/]/u.test(request.validation.executable))) ||
1004
1072
  (request.validation.args !== undefined && (!Array.isArray(request.validation.args) || request.validation.args.length > MAX_ARGUMENTS ||
1005
1073
  !request.validation.args.every((arg) => validText(arg, MAX_COMMAND_TEXT)))) ||
1006
1074
  (request.validation.timeout_ms !== undefined && (!Number.isInteger(request.validation.timeout_ms) ||
@@ -1008,7 +1076,7 @@ export async function recoverWorktreeCommitArtifact(request) {
1008
1076
  throw new WorktreeCommitArtifactError("invalid-request", "Commit recovery request is invalid.");
1009
1077
  }
1010
1078
  const context = await makeContext(request);
1011
- const executable = await realpath(request.validation.executable).catch(() => undefined);
1079
+ const executable = await resolveValidationExecutable(request.validation.executable);
1012
1080
  if (executable === undefined || !isAbsolute(executable)) {
1013
1081
  throw new WorktreeCommitArtifactError("invalid-request", "Validation executable does not exist.");
1014
1082
  }
@@ -1019,9 +1087,7 @@ export async function recoverWorktreeCommitArtifact(request) {
1019
1087
  }
1020
1088
  if (state.head === context.descriptor.base_sha)
1021
1089
  return undefined;
1022
- if ((await context.git([
1023
- "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none",
1024
- ])).length !== 0) {
1090
+ if ((await status(context, "forbid")).length !== 0) {
1025
1091
  throw new WorktreeCommitArtifactError("invalid-state", "Managed checkout is not clean.");
1026
1092
  }
1027
1093
  const parentLine = (await context.git(["rev-list", "--parents", "-n", "1", state.head])).toString("utf8").trim().split(" ");
@@ -20,7 +20,7 @@ const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
20
20
  const HASH = /^[0-9a-f]{64}$/u;
21
21
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
22
22
  const DEVICE_ID = /^[1-9][0-9]*$/u;
23
- const MAX_SAFE_FILE_ID = BigInt(Number.MAX_SAFE_INTEGER);
23
+ const MAX_FILE_ID = (1n << 64n) - 1n;
24
24
  const ACTIVE_PATH = /^wt-([0-9a-f]{16})-([0-9a-f]{32})$/u;
25
25
  const QUARANTINE_PATH = /^rm-([0-9a-f]{16})-([0-9a-f]{32})$/u;
26
26
  const PHASES = new Set(["creating", "setting-up", "ready", "removing", "orphaned"]);
@@ -53,10 +53,10 @@ function pathWithin(path, parent) {
53
53
  return childID === parentID || childID.startsWith(`${parentID}/`);
54
54
  }
55
55
  function safeStoredFileID(value) {
56
- return typeof value === "string" && DEVICE_ID.test(value) && BigInt(value) <= MAX_SAFE_FILE_ID;
56
+ return typeof value === "string" && DEVICE_ID.test(value) && BigInt(value) <= MAX_FILE_ID;
57
57
  }
58
58
  function fileID(path, value, kind) {
59
- const native = value > 0n && value <= MAX_SAFE_FILE_ID ? value.toString() : undefined;
59
+ const native = value > 0n && value <= MAX_FILE_ID ? value.toString() : undefined;
60
60
  if (native !== undefined)
61
61
  return native;
62
62
  if (process.platform !== "win32" || (kind === "inode" && value === 0n))
@@ -6,6 +6,7 @@ export declare class FastLaneDeniedError extends Error {
6
6
  }
7
7
  export interface FastLaneToolOptions {
8
8
  readonly consultationFallbackAuthorized?: boolean;
9
+ readonly parallelWorkerAlreadyBound?: boolean;
9
10
  readonly parallelWorkerAuthorized?: boolean;
10
11
  }
11
12
  export declare class FastLaneController {
@@ -165,7 +165,7 @@ export class FastLaneController {
165
165
  state.parallelMode = true;
166
166
  state.workerLimit = totalTasks;
167
167
  state.workerDispatches = running;
168
- state.totalWorkerDispatches = Math.max(state.totalWorkerDispatches, dispatched);
168
+ state.totalWorkerDispatches = dispatched;
169
169
  }
170
170
  continuationQueued(sessionID) {
171
171
  const state = this.sessions.get(sessionID);
@@ -224,9 +224,19 @@ export class FastLaneController {
224
224
  throw new FastLaneDeniedError("WORKER_RESUME_INVALID");
225
225
  }
226
226
  if (state.parallelMode) {
227
- if (options.parallelWorkerAuthorized !== true || state.workerDispatches >= state.parallelWorkerLimit ||
228
- state.totalWorkerDispatches >= state.workerLimit)
227
+ if (options.parallelWorkerAuthorized !== true)
229
228
  throw new FastLaneDeniedError("WORKER_LIMIT");
229
+ if (options.parallelWorkerAlreadyBound === true) {
230
+ if (state.workerDispatches < 1 || state.workerDispatches > state.parallelWorkerLimit ||
231
+ state.totalWorkerDispatches < 1 || state.totalWorkerDispatches > state.workerLimit) {
232
+ throw new FastLaneDeniedError("WORKER_LIMIT");
233
+ }
234
+ state.workerInFlight = true;
235
+ return;
236
+ }
237
+ if (state.workerDispatches >= state.parallelWorkerLimit || state.totalWorkerDispatches >= state.workerLimit) {
238
+ throw new FastLaneDeniedError("WORKER_LIMIT");
239
+ }
230
240
  }
231
241
  else if (state.backlogDrain &&
232
242
  (state.workerDispatches >= 1 || state.totalWorkerDispatches >= state.workerLimit)) {
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFile, realpath, stat } from "node:fs/promises";
2
+ import { lstat, open, readFile, realpath, rm, stat } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
5
5
  import { normalizeRelativePath, RelativePathError } from "../core/path.js";
6
6
  import { ScopeLeaseError, ScopeLeaseRegistry } from "../core/scope-lease-registry.js";
7
- import { produceWorktreeCommitArtifact, recoverWorktreeCommitArtifact, WorktreeCommitArtifactError, } from "../core/worktree-commit-artifact.js";
7
+ import { produceWorktreeCommitArtifact, recoverWorktreeCommitArtifact, resolveValidationExecutable, WorktreeCommitArtifactError, } from "../core/worktree-commit-artifact.js";
8
8
  import { normalizeWorktreeScope } from "../core/worktree-scope.js";
9
9
  import { ParallelDispatchCoordinator, ParallelDispatchError, } from "../core/worktree-parallel-dispatch.js";
10
10
  import { IntegrationQueueError, WorktreeIntegrationQueue } from "../core/worktree-integration-queue.js";
@@ -640,12 +640,12 @@ function parseStringArray(value) {
640
640
  function parallelDescriptor(text) {
641
641
  const unique = (key) => {
642
642
  const values = taskValues(text, [key]);
643
- return values.length === 1 ? values[0] : undefined;
643
+ return values.length > 0 && new Set(values).size === 1 ? values[0] : undefined;
644
644
  };
645
645
  const runID = unique("run_id");
646
646
  const dispatchID = unique("dispatch_id");
647
647
  const taskID = unique("task_id");
648
- const managedPath = unique("managed_path");
648
+ const managedPath = unique("managed_path") ?? unique("project_root");
649
649
  const branch = unique("branch");
650
650
  const baseSHA = unique("base_sha");
651
651
  const dependsOn = parseStringArray(unique("depends_on"));
@@ -673,9 +673,11 @@ function sameParallelDescriptor(left, right) {
673
673
  function parallelValidationRequest(args) {
674
674
  const keys = Object.keys(args).sort();
675
675
  const allowed = new Set(["dispatch_id", "run_id", "timeout_ms", "validation_args_json", "validation_executable"]);
676
+ const executable = args.validation_executable;
676
677
  if (!keys.every((key) => allowed.has(key)) ||
677
678
  !["dispatch_id", "run_id", "validation_executable"].every((key) => typeof args[key] === "string") ||
678
- !isAbsolute(args.validation_executable) || /[\u0000-\u001f\u007f]/u.test(args.validation_executable))
679
+ /[\u0000-\u001f\u007f]/u.test(executable) ||
680
+ (!isAbsolute(executable) && (executable.startsWith("-") || /[\\/]/u.test(executable))))
679
681
  return undefined;
680
682
  let validationArgs = [];
681
683
  if (args.validation_args_json !== undefined) {
@@ -700,7 +702,7 @@ function parallelValidationRequest(args) {
700
702
  return undefined;
701
703
  }
702
704
  const validation = {
703
- executable: args.validation_executable,
705
+ executable: executable,
704
706
  args: validationArgs,
705
707
  ...(timeout === undefined ? {} : { timeout_ms: timeout }),
706
708
  };
@@ -1180,6 +1182,103 @@ export const SortieDogsPlugin = async (input, options) => {
1180
1182
  validation: artifact.validation,
1181
1183
  };
1182
1184
  }
1185
+ function parallelControlPaths(descriptor) {
1186
+ return {
1187
+ handoff_path: join(descriptor.managed_path, `handoff.${descriptor.task_id}.json`),
1188
+ operation_manifest: join(descriptor.managed_path, `${descriptor.task_id}.operation-manifest.json`),
1189
+ };
1190
+ }
1191
+ async function createParallelControlFiles(descriptor, validationCommands) {
1192
+ const canonicalRoot = await realpath(descriptor.managed_path);
1193
+ if (!samePath(canonicalRoot, descriptor.managed_path)) {
1194
+ throw new ParallelDispatchError("lifecycle-failed", "Managed worktree identity changed before contract creation.");
1195
+ }
1196
+ const paths = parallelControlPaths(descriptor);
1197
+ const manifestValue = {
1198
+ version: "0.1.0",
1199
+ task_id: descriptor.task_id,
1200
+ read: [...descriptor.scope_read],
1201
+ write: [...descriptor.scope_write],
1202
+ validation: [...validationCommands],
1203
+ };
1204
+ const handoffValue = {
1205
+ version: "0.1.0",
1206
+ profile: "minimal",
1207
+ id: descriptor.task_id,
1208
+ created_at: new Date().toISOString(),
1209
+ ext: { "sortie-dogs/write-gate": {
1210
+ operation_manifest: basename(paths.operation_manifest),
1211
+ project_root: descriptor.managed_path,
1212
+ } },
1213
+ task: {
1214
+ title: `Parallel task ${descriptor.task_id}`,
1215
+ objective: "Complete the prepared parallel descriptor within its declared scope.",
1216
+ },
1217
+ state: { done: [], next: ["Implement the prepared parallel descriptor."], blocked: [] },
1218
+ risks: [],
1219
+ verification: validationCommands.map((check) => ({
1220
+ check, status: "not_run", exit_code: null, summary: "Delegated to the parallel artifact capability.",
1221
+ })),
1222
+ };
1223
+ const manifest = validateOperationManifestSchema(manifestValue);
1224
+ const handoff = validateHandoffSchema(handoffValue);
1225
+ if (!manifest.ok || !handoff.ok ||
1226
+ validateManifest(handoff.value, manifest.value, undefined, false, { requirePassedValidation: false })
1227
+ .some(({ severity }) => severity === "error")) {
1228
+ throw new ParallelDispatchError("invalid-contract", "Generated parallel worker contract is invalid.");
1229
+ }
1230
+ const created = [];
1231
+ try {
1232
+ for (const [path, value] of [[paths.operation_manifest, manifestValue], [paths.handoff_path, handoffValue]]) {
1233
+ const content = JSON.stringify(value);
1234
+ try {
1235
+ const handle = await open(path, "wx", 0o600);
1236
+ try {
1237
+ await handle.writeFile(content, "utf8");
1238
+ await handle.sync();
1239
+ }
1240
+ finally {
1241
+ await handle.close();
1242
+ }
1243
+ created.push(path);
1244
+ }
1245
+ catch (error) {
1246
+ if (!isRecord(error) || error.code !== "EEXIST")
1247
+ throw error;
1248
+ const info = await lstat(path);
1249
+ if (!info.isFile() || info.isSymbolicLink())
1250
+ throw error;
1251
+ const existing = await readFile(path, "utf8");
1252
+ if (existing === content)
1253
+ continue;
1254
+ if (path !== paths.handoff_path)
1255
+ throw error;
1256
+ const existingValue = JSON.parse(existing);
1257
+ if (!isRecord(existingValue) || !validateHandoffSchema(existingValue).ok ||
1258
+ JSON.stringify({ ...existingValue, created_at: handoffValue.created_at }) !== content)
1259
+ throw error;
1260
+ }
1261
+ }
1262
+ }
1263
+ catch (error) {
1264
+ await Promise.all(created.map((path) => rm(path, { force: true }).catch(() => undefined)));
1265
+ throw error;
1266
+ }
1267
+ }
1268
+ async function removeParallelControlFiles(descriptor) {
1269
+ const paths = parallelControlPaths(descriptor);
1270
+ await Promise.all([
1271
+ rm(paths.handoff_path, { force: true }),
1272
+ rm(paths.operation_manifest, { force: true }),
1273
+ ]);
1274
+ }
1275
+ async function ensureParallelReadyControls(snapshot) {
1276
+ if (snapshot.archived || snapshot.cancelled || snapshot.ready.length === 0)
1277
+ return;
1278
+ await ensureLoaded();
1279
+ const validationCommands = loaded?.manifest?.validation ?? [];
1280
+ await Promise.all(snapshot.ready.map((descriptor) => createParallelControlFiles(descriptor, validationCommands)));
1281
+ }
1183
1282
  function boundedParallelSnapshot(snapshot) {
1184
1283
  return {
1185
1284
  run_id: snapshot.run_id,
@@ -1187,7 +1286,7 @@ export const SortieDogsPlugin = async (input, options) => {
1187
1286
  cancelled: snapshot.cancelled,
1188
1287
  archived: snapshot.archived,
1189
1288
  terminal_reason: snapshot.terminal_reason,
1190
- ready: snapshot.ready,
1289
+ ready: snapshot.ready.map((descriptor) => ({ ...descriptor, ...parallelControlPaths(descriptor) })),
1191
1290
  tasks: snapshot.tasks.map(({ descriptor, worktree_id, phase, call_id, child_session_id, outcome, artifact }) => ({
1192
1291
  task_id: descriptor.task_id,
1193
1292
  worktree_id,
@@ -1257,7 +1356,7 @@ export const SortieDogsPlugin = async (input, options) => {
1257
1356
  if (running === undefined)
1258
1357
  return deny("dispatch-inactive");
1259
1358
  if (running.artifact !== null) {
1260
- const executable = await realpath(request.validation.executable).catch(() => undefined);
1359
+ const executable = await resolveValidationExecutable(request.validation.executable);
1261
1360
  if (executable === undefined)
1262
1361
  return deny("invalid-request");
1263
1362
  const requestedCommand = [executable, ...request.validation.args];
@@ -1265,6 +1364,7 @@ export const SortieDogsPlugin = async (input, options) => {
1265
1364
  return deny("artifact-replay");
1266
1365
  }
1267
1366
  await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, running.artifact);
1367
+ await removeParallelControlFiles(binding.descriptor);
1268
1368
  parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact: running.artifact });
1269
1369
  pruneParallelChildMap(parallelArtifacts);
1270
1370
  return JSON.stringify({ status: "created", replay: true, artifact: boundedParallelArtifact(running.artifact) });
@@ -1278,6 +1378,7 @@ export const SortieDogsPlugin = async (input, options) => {
1278
1378
  const recovered = await recoverWorktreeCommitArtifact(produceRequest);
1279
1379
  const artifact = recovered ?? await produceWorktreeCommitArtifact(produceRequest);
1280
1380
  await (await getParallelCoordinator()).acceptArtifact(binding.ownerRoot, binding.completionCallID, sessionID, binding.descriptor, artifact);
1381
+ await removeParallelControlFiles(binding.descriptor);
1281
1382
  parallelArtifacts.set(sessionID, { requestFingerprint: request.fingerprint, artifact });
1282
1383
  pruneParallelChildMap(parallelArtifacts);
1283
1384
  return JSON.stringify({ status: "created", ...(recovered === undefined ? {} : { replay: true }),
@@ -1298,9 +1399,18 @@ export const SortieDogsPlugin = async (input, options) => {
1298
1399
  !await project.contains(contractPath))
1299
1400
  return JSON.stringify({ status: "denied", reason: "project-boundary" });
1300
1401
  const contract = await readJson(resolve(contractPath), INPUT_LIMITS.parallel);
1301
- const result = await (await getParallelCoordinator()).prepare(contract, ownerRoot);
1402
+ const coordinator = await getParallelCoordinator();
1403
+ const result = await coordinator.prepare(contract, ownerRoot);
1302
1404
  if (result.status === "serial-fallback")
1303
1405
  return JSON.stringify(result);
1406
+ try {
1407
+ await ensureParallelReadyControls(result.snapshot);
1408
+ }
1409
+ catch (error) {
1410
+ await Promise.all(result.snapshot.ready.map((descriptor) => removeParallelControlFiles(descriptor).catch(() => undefined)));
1411
+ await coordinator.cancel(ownerRoot, result.snapshot.run_id).catch(() => undefined);
1412
+ throw error;
1413
+ }
1304
1414
  const dispatched = result.snapshot.tasks.filter(({ phase }) => phase === "running" || phase === "completed" || phase === "failed" || phase === "abandoned").length;
1305
1415
  const running = result.snapshot.tasks.filter(({ phase }) => phase === "running").length;
1306
1416
  fastLane.enableParallelDispatch(ownerRoot, result.snapshot.max_workers, dispatched, running, result.snapshot.tasks.length);
@@ -1322,6 +1432,8 @@ export const SortieDogsPlugin = async (input, options) => {
1322
1432
  const snapshot = reconcile === "true"
1323
1433
  ? await coordinator.reconcile(ownerRoot, coordinatorTaskCalls.get(ownerRoot) ?? new Set(), runID || undefined)
1324
1434
  : await coordinator.snapshot(ownerRoot, runID || undefined);
1435
+ if (snapshot !== undefined)
1436
+ await ensureParallelReadyControls(snapshot);
1325
1437
  if (snapshot === undefined && !runID) {
1326
1438
  const archived = await coordinator.archives(ownerRoot);
1327
1439
  return JSON.stringify({ status: "ok", active: null, archived: archived.map(boundedParallelArchive) });
@@ -1351,7 +1463,13 @@ export const SortieDogsPlugin = async (input, options) => {
1351
1463
  const ownerRoot = await parallelToolOwner(sessionID);
1352
1464
  if (ownerRoot === undefined)
1353
1465
  return JSON.stringify({ status: "denied", reason: "coordinator-root-required" });
1354
- const snapshot = await (await getParallelCoordinator()).cancel(ownerRoot, runID || undefined);
1466
+ const coordinator = await getParallelCoordinator();
1467
+ const active = await coordinator.snapshot(ownerRoot, runID || undefined);
1468
+ const snapshot = await coordinator.cancel(ownerRoot, runID || undefined);
1469
+ if (active !== undefined && snapshot !== undefined) {
1470
+ await Promise.all(active.tasks.filter(({ phase }) => phase === "pending" || phase === "reserved")
1471
+ .map(({ descriptor }) => removeParallelControlFiles(descriptor).catch(() => undefined)));
1472
+ }
1355
1473
  return snapshot === undefined
1356
1474
  ? JSON.stringify({ status: "absent" })
1357
1475
  : JSON.stringify({ status: "cancelled", ...boundedParallelSnapshot(snapshot) });
@@ -2994,6 +3112,10 @@ export const SortieDogsPlugin = async (input, options) => {
2994
3112
  parallelChildBindings.delete(completedChildSessionID);
2995
3113
  }
2996
3114
  if (snapshot !== undefined) {
3115
+ await ensureParallelReadyControls(snapshot);
3116
+ if (snapshot.cancelled) {
3117
+ await removeParallelControlFiles(parallel.descriptor).catch(() => undefined);
3118
+ }
2997
3119
  const dispatched = snapshot.tasks.filter(({ phase }) => phase === "running" || phase === "completed" || phase === "failed" || phase === "abandoned").length;
2998
3120
  const running = snapshot.tasks.filter(({ phase }) => phase === "running").length;
2999
3121
  fastLane.enableParallelDispatch(parallel.ownerRoot, snapshot.max_workers, dispatched, running, snapshot.tasks.length);
@@ -3056,13 +3178,14 @@ export const SortieDogsPlugin = async (input, options) => {
3056
3178
  const consultationFallbackAuthorized = role !== undefined &&
3057
3179
  consultationRetries.get(consultationRetryKey(toolInput.sessionID, role))?.phase === "pending";
3058
3180
  let parallelWorkerAuthorized = false;
3181
+ let parallelWorkerAlreadyBound = false;
3059
3182
  let reservedParallelDescriptor;
3060
3183
  if (toolInput.tool === "task" && taskRole === "dog-worker" && isRecord(output.args)) {
3061
3184
  const prompt = typeof output.args.prompt === "string" ? output.args.prompt : "";
3062
3185
  const contractPrompt = taskContractText(prompt);
3063
3186
  reservedParallelDescriptor = parallelDescriptor(prompt);
3064
3187
  if (reservedParallelDescriptor !== undefined) {
3065
- const roots = taskValues(contractPrompt, ["project_root", "projectroot"]);
3188
+ const roots = [...new Set(taskValues(contractPrompt, ["project_root", "projectroot"]))];
3066
3189
  if (roots.length !== 1 || !samePath(roots[0], reservedParallelDescriptor.managed_path)) {
3067
3190
  throw new HandoffDeniedError("contract-invalid", "<worker-dispatch>", {
3068
3191
  defects: [contractDefect("contract", "/managed_path", "parallel_descriptor_project_mismatch")],
@@ -3073,7 +3196,7 @@ export const SortieDogsPlugin = async (input, options) => {
3073
3196
  const resume = modes.length === 1 && modes[0] === "same-task-resume";
3074
3197
  const handoffPaths = taskValues(contractPrompt, ["handoff_path", "handoffpath"]);
3075
3198
  const operationManifests = taskValues(contractPrompt, ["operation_manifest", "operationmanifest"]);
3076
- const projectRoots = taskValues(contractPrompt, ["project_root", "projectroot"]);
3199
+ const projectRoots = [...new Set(taskValues(contractPrompt, ["project_root", "projectroot"]))];
3077
3200
  const sourceManifests = taskValues(contractPrompt, ["source_manifest", "sourcemanifest"]);
3078
3201
  const acceptanceValues = taskValues(contractPrompt, ["acceptance"]);
3079
3202
  const validationValues = taskValues(contractPrompt, ["validation"]);
@@ -3193,14 +3316,21 @@ export const SortieDogsPlugin = async (input, options) => {
3193
3316
  const snapshot = await coordinator.snapshot(toolInput.sessionID, reservedParallelDescriptor.run_id);
3194
3317
  if (snapshot === undefined)
3195
3318
  throw new ParallelDispatchError("descriptor-mismatch", "Parallel run is absent.");
3196
- const dispatched = snapshot.tasks.filter(({ phase }) => phase === "running" || phase === "completed" || phase === "failed" || phase === "abandoned").length;
3197
- const running = snapshot.tasks.filter(({ phase }) => phase === "running").length;
3198
- fastLane.enableParallelDispatch(toolInput.sessionID, snapshot.max_workers, dispatched, running, snapshot.tasks.length);
3319
+ parallelWorkerAlreadyBound = snapshot.tasks.some(({ phase, call_id, descriptor }) => phase === "running" && call_id === toolInput.callID &&
3320
+ sameParallelDescriptor(descriptor, reservedParallelDescriptor));
3199
3321
  await coordinator.bindDispatch(toolInput.sessionID, toolInput.callID, reservedParallelDescriptor);
3322
+ const boundSnapshot = await coordinator.snapshot(toolInput.sessionID, reservedParallelDescriptor.run_id);
3323
+ if (boundSnapshot === undefined)
3324
+ throw new ParallelDispatchError("descriptor-mismatch", "Parallel run is absent.");
3325
+ const currentContribution = parallelWorkerAlreadyBound ? 0 : 1;
3326
+ const dispatched = boundSnapshot.tasks.filter(({ phase }) => phase === "running" || phase === "completed" || phase === "failed" || phase === "abandoned").length;
3327
+ const running = boundSnapshot.tasks.filter(({ phase }) => phase === "running").length;
3328
+ fastLane.enableParallelDispatch(toolInput.sessionID, boundSnapshot.max_workers, dispatched - currentContribution, running - currentContribution, boundSnapshot.tasks.length);
3200
3329
  parallelWorkerAuthorized = true;
3201
3330
  }
3202
3331
  const resumedWorkerSessionID = fastLane.beforeTool(toolInput.sessionID, toolInput.tool, output.args, {
3203
3332
  consultationFallbackAuthorized,
3333
+ parallelWorkerAlreadyBound,
3204
3334
  parallelWorkerAuthorized,
3205
3335
  });
3206
3336
  if (resumedWorkerSessionID !== undefined) {
@@ -3413,8 +3543,13 @@ export const SortieDogsPlugin = async (input, options) => {
3413
3543
  if (eventSessionID !== undefined) {
3414
3544
  const coordinator = await getParallelCoordinator().catch(() => undefined);
3415
3545
  const owned = await coordinator?.snapshot(eventSessionID).catch(() => undefined);
3416
- if (owned !== undefined)
3417
- await coordinator?.cancel(eventSessionID, owned.run_id).catch(() => undefined);
3546
+ if (owned !== undefined) {
3547
+ const cancelled = await coordinator?.cancel(eventSessionID, owned.run_id).catch(() => undefined);
3548
+ if (cancelled !== undefined) {
3549
+ await Promise.all(owned.tasks.filter(({ phase }) => phase === "pending" || phase === "reserved")
3550
+ .map(({ descriptor }) => removeParallelControlFiles(descriptor).catch(() => undefined)));
3551
+ }
3552
+ }
3418
3553
  }
3419
3554
  evictSession(eventSessionID);
3420
3555
  for (const role of [REVIEWER_AGENT, ADVISOR_AGENT]) {