@gajae-code/coding-agent 0.12.2 → 0.12.5

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/dist/types/async/job-manager.d.ts +9 -1
  3. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +5 -1
  4. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -2
  5. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -2
  6. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -3
  7. package/dist/types/session/internal/managed-session-storage.d.ts +3 -22
  8. package/dist/types/session/session-manager.d.ts +1 -1
  9. package/dist/types/task/executor.d.ts +1 -1
  10. package/dist/types/task/index.d.ts +2 -1
  11. package/dist/types/task/receipt.d.ts +1 -0
  12. package/dist/types/task/types.d.ts +16 -0
  13. package/dist/types/task/worktree.d.ts +13 -6
  14. package/dist/types/tools/subagent.d.ts +2 -0
  15. package/dist/types/utils/git.d.ts +1 -0
  16. package/package.json +8 -8
  17. package/src/async/job-manager.ts +13 -5
  18. package/src/config/provider-ranking.ts +1 -0
  19. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -5
  20. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +2 -1
  21. package/src/gjc-runtime/ultragoal-evidence.ts +160 -31
  22. package/src/gjc-runtime/ultragoal-runtime.ts +231 -19
  23. package/src/internal-urls/docs-index.generated.ts +2 -2
  24. package/src/sdk/broker/lifecycle.ts +13 -18
  25. package/src/sdk/bus/chat-daemon-control.ts +6 -4
  26. package/src/sdk/bus/telegram-daemon-contract.ts +5 -3
  27. package/src/sdk/session.ts +1 -0
  28. package/src/session/internal/managed-session-scope.ts +107 -158
  29. package/src/session/internal/managed-session-storage.ts +57 -445
  30. package/src/session/session-manager.ts +12 -7
  31. package/src/session/session-storage.ts +44 -29
  32. package/src/task/executor.ts +5 -2
  33. package/src/task/index.ts +232 -64
  34. package/src/task/receipt.ts +22 -2
  35. package/src/task/render.ts +12 -1
  36. package/src/task/types.ts +18 -0
  37. package/src/task/worktree.ts +122 -43
  38. package/src/tools/subagent-render.ts +5 -2
  39. package/src/tools/subagent.ts +8 -0
  40. package/src/utils/git.ts +2 -0
@@ -9708,6 +9708,7 @@ export class SessionManager {
9708
9708
  destination: SessionDestination,
9709
9709
  store: ManagedSessionDescendantStore,
9710
9710
  storage: SessionStorage = new FileSessionStorage(),
9711
+ cwdOverride?: string,
9711
9712
  ): Promise<SessionManager> {
9712
9713
  if (destination.kind !== "managed" || !trustedSessionDestinations.has(destination))
9713
9714
  throw new Error("Nested managed session authority is unavailable");
@@ -9721,16 +9722,20 @@ export class SessionManager {
9721
9722
  const captured = store.readExpected(path.basename(resolved));
9722
9723
  const entries = captured ? parseSessionEntries(captured.bytes.toString("utf8")) : [];
9723
9724
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
9724
- const manager = new SessionManager(
9725
- header?.cwd ?? getProjectDir(),
9726
- destination.directory,
9727
- true,
9728
- storage,
9729
- destination,
9725
+ const sessionCwd = cwdOverride ? path.resolve(cwdOverride) : (header?.cwd ?? getProjectDir());
9726
+ const cwdChanged = Boolean(
9727
+ header && cwdOverride && resolveEquivalentPath(header.cwd) !== resolveEquivalentPath(sessionCwd),
9730
9728
  );
9729
+ if (header && cwdChanged) header.cwd = sessionCwd;
9730
+ const manager = new SessionManager(sessionCwd, destination.directory, true, storage, destination);
9731
9731
  if (entries.length > 0) {
9732
- const migrationApplied = migrateToCurrentVersion(entries);
9732
+ const migrationApplied = migrateToCurrentVersion(entries) || cwdChanged;
9733
9733
  await manager.#hydrateExistingSession(resolved, entries, migrationApplied, "memory-fallback");
9734
+ if (cwdChanged) {
9735
+ await manager.#rewriteFile();
9736
+ manager.#flushed = true;
9737
+ manager.#ensuredOnDisk = true;
9738
+ }
9734
9739
  writeTerminalBreadcrumb(manager.cwd, resolved);
9735
9740
  await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
9736
9741
  } else {
@@ -454,7 +454,7 @@ function secureOwnerOnlyFileDescriptor(
454
454
  ): void {
455
455
  if (securityContext && !isValidManagedSecurityContext(securityContext))
456
456
  throw new Error("Invalid managed session security context");
457
- if (process.platform !== "linux" && process.platform !== "win32") {
457
+ if (process.platform !== "linux" || !securityContext) {
458
458
  if (operation === "apply") {
459
459
  const applied = validateNativeSecurityResult(
460
460
  native.applyOwnerOnlyPathSecurity(pathname, "file"),
@@ -471,7 +471,7 @@ function secureOwnerOnlyFileDescriptor(
471
471
  if (!verified.ok) throw new Error(`Owner-only security rejected ${pathname}: ${verified.code}`);
472
472
  return;
473
473
  }
474
- if (securityContext && !pathIsWithin(securityContext.sessionDir, pathname))
474
+ if (!pathIsWithin(securityContext.sessionDir, pathname))
475
475
  throw new Error(`Managed writer escaped its session directory: ${pathname}`);
476
476
  const result = validateNativeSecurityResult(
477
477
  operation === "apply"
@@ -908,28 +908,6 @@ export class FileSessionStorage implements SessionStorage {
908
908
  }
909
909
 
910
910
  const parentIdentity = this.#directoryIdentity(path.dirname(transcriptPath));
911
- const durablyRecordArtifactPhase = (): void => {
912
- if (process.platform === "win32") return;
913
- let descriptor: number | undefined;
914
- try {
915
- descriptor = fs.openSync(
916
- path.dirname(transcriptPath),
917
- fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
918
- );
919
- const durableParent = fs.fstatSync(descriptor, { bigint: true });
920
- if (
921
- !durableParent.isDirectory() ||
922
- durableParent.dev !== parentIdentity.dev ||
923
- durableParent.ino !== parentIdentity.ino
924
- )
925
- throw new Error("parent_changed");
926
- fs.fsyncSync(descriptor);
927
- } catch (error) {
928
- throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
929
- } finally {
930
- if (descriptor !== undefined) fs.closeSync(descriptor);
931
- }
932
- };
933
911
  if (detachedArtifactsPath) {
934
912
  if (
935
913
  !expectedArtifactsIdentity ||
@@ -960,7 +938,6 @@ export class FileSessionStorage implements SessionStorage {
960
938
  "artifacts",
961
939
  "Native artifact removal returned an unauthorized root",
962
940
  );
963
- durablyRecordArtifactPhase();
964
941
  return {
965
942
  kind: "cleanup_pending",
966
943
  phase: "artifacts",
@@ -1001,7 +978,6 @@ export class FileSessionStorage implements SessionStorage {
1001
978
  "artifacts",
1002
979
  "Authorized artifact removal root remains after restart",
1003
980
  );
1004
- durablyRecordArtifactPhase();
1005
981
  return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity };
1006
982
  }
1007
983
 
@@ -1046,7 +1022,27 @@ export class FileSessionStorage implements SessionStorage {
1046
1022
  `Exact artifact detach rejected: ${detach.ok ? "missing_path" : detach.code}`,
1047
1023
  );
1048
1024
  }
1049
- if (!detach.ok) durablyRecordArtifactPhase();
1025
+ if (!detach.ok && process.platform !== "win32") {
1026
+ let descriptor: number | undefined;
1027
+ try {
1028
+ descriptor = fs.openSync(
1029
+ path.dirname(transcriptPath),
1030
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
1031
+ );
1032
+ const durableParent = fs.fstatSync(descriptor, { bigint: true });
1033
+ if (
1034
+ !durableParent.isDirectory() ||
1035
+ durableParent.dev !== parentIdentity.dev ||
1036
+ durableParent.ino !== parentIdentity.ino
1037
+ )
1038
+ throw new Error("parent_changed");
1039
+ fs.fsyncSync(descriptor);
1040
+ } catch (error) {
1041
+ throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
1042
+ } finally {
1043
+ if (descriptor !== undefined) fs.closeSync(descriptor);
1044
+ }
1045
+ }
1050
1046
  if (!detach.ok) {
1051
1047
  return {
1052
1048
  kind: "cleanup_pending",
@@ -1070,7 +1066,6 @@ export class FileSessionStorage implements SessionStorage {
1070
1066
  "artifacts",
1071
1067
  "Native artifact removal returned an unauthorized root",
1072
1068
  );
1073
- durablyRecordArtifactPhase();
1074
1069
  return {
1075
1070
  kind: "cleanup_pending",
1076
1071
  phase: "artifacts",
@@ -1095,7 +1090,27 @@ export class FileSessionStorage implements SessionStorage {
1095
1090
  }
1096
1091
  }
1097
1092
  if (!artifactsRemoved) {
1098
- durablyRecordArtifactPhase();
1093
+ if (process.platform !== "win32") {
1094
+ let descriptor: number | undefined;
1095
+ try {
1096
+ descriptor = fs.openSync(
1097
+ path.dirname(transcriptPath),
1098
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
1099
+ );
1100
+ const durableParent = fs.fstatSync(descriptor, { bigint: true });
1101
+ if (
1102
+ !durableParent.isDirectory() ||
1103
+ durableParent.dev !== parentIdentity.dev ||
1104
+ durableParent.ino !== parentIdentity.ino
1105
+ )
1106
+ throw new Error("parent_changed");
1107
+ fs.fsyncSync(descriptor);
1108
+ } catch (error) {
1109
+ throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
1110
+ } finally {
1111
+ if (descriptor !== undefined) fs.closeSync(descriptor);
1112
+ }
1113
+ }
1099
1114
  return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity };
1100
1115
  }
1101
1116
  if (hasDetachedTranscript) {
@@ -280,7 +280,7 @@ export class ManagedTaskPersistence {
280
280
  throw new Error("Managed task persistence authority is unavailable");
281
281
  }
282
282
 
283
- async openSession(): Promise<SessionManager> {
283
+ async openSession(cwd: string): Promise<SessionManager> {
284
284
  const store = this.#artifacts.getManagedStore();
285
285
  if (!store) throw new Error("Managed task persistence authority is unavailable");
286
286
  this.#artifacts.assertManagedBinding();
@@ -289,6 +289,8 @@ export class ManagedTaskPersistence {
289
289
  sessionFile,
290
290
  SessionManager.nestedManagedDestination(store, this.#artifacts.dir),
291
291
  store,
292
+ undefined,
293
+ cwd,
292
294
  );
293
295
  this.#artifacts.assertManagedBinding();
294
296
  return session;
@@ -1533,6 +1535,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1533
1535
  requestedModel: modelSubstitutionWarning?.requested ?? resolvedModelString,
1534
1536
  effectiveModel: resolvedModelString,
1535
1537
  modelFellBack: authFallbackUsed === true,
1538
+ fastMode: progress.fastMode,
1536
1539
  });
1537
1540
  }
1538
1541
  if (model?.contextWindow && model.contextWindow > 0) {
@@ -1547,7 +1550,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1547
1550
  effectiveThinkingLevelForWarning = effectiveThinkingLevel;
1548
1551
 
1549
1552
  const sessionManager = options.managedPersistence
1550
- ? await awaitAbortable(options.managedPersistence.openSession())
1553
+ ? await awaitAbortable(options.managedPersistence.openSession(worktree ?? cwd))
1551
1554
  : sessionFile
1552
1555
  ? await awaitAbortable(
1553
1556
  SessionManager.open(sessionFile, SessionManager.explicitDestination(path.dirname(sessionFile))),
package/src/task/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * - Progress tracking via JSON events
13
13
  * - Session artifacts for debugging
14
14
  */
15
- import { randomUUID } from "node:crypto";
15
+ import { createHash, randomUUID } from "node:crypto";
16
16
  import * as fs from "node:fs/promises";
17
17
  import * as os from "node:os";
18
18
  import path from "node:path";
@@ -64,7 +64,7 @@ import { FORK_CONTEXT_TOKEN_BUDGET_BY_MODE } from "./fork-context-budget";
64
64
  import { getTaskIdValidationError, validateAllocatedTaskId } from "./id";
65
65
  import { AgentOutputManager } from "./output-manager";
66
66
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
67
- import { assertNoRawTaskFields, buildTaskReceipt, buildTaskRoiSummary } from "./receipt";
67
+ import { assertNoRawTaskFields, buildTaskReceipt, buildTaskRoiSummary, type TaskResultReceipt } from "./receipt";
68
68
 
69
69
  import { renderResult, renderCall as renderTaskCall } from "./render";
70
70
  import { reconcileSpawnRoi } from "./roi-reconciliation";
@@ -82,6 +82,9 @@ import {
82
82
  type IsolationHandle,
83
83
  mergeTaskBranches,
84
84
  parseIsolationMode,
85
+ serializeRecoveryPatchBundle,
86
+ verifyNestedPatchesApplied,
87
+ verifyRootPatchesApplied,
85
88
  type WorktreeBaseline,
86
89
  } from "./worktree";
87
90
 
@@ -104,9 +107,22 @@ function renderTaskAssignment(assignment: string, simpleMode: TaskSimpleMode): s
104
107
 
105
108
  export function subagentRunOutcomeFromSingleResult(
106
109
  finalText: string,
107
- singleResult: Pick<SingleResult, "aborted" | "exitCode" | "paused" | "setupFailure"> | undefined,
110
+ singleResult:
111
+ | (Pick<SingleResult, "aborted" | "exitCode" | "paused" | "setupFailure"> &
112
+ Partial<Pick<TaskResultReceipt, "status">>)
113
+ | undefined,
108
114
  ): string | SubagentRunOutcome {
109
115
  if (singleResult?.paused) return { kind: "paused" };
116
+ if (!singleResult) return { kind: "failed", text: finalText };
117
+ if (singleResult?.status && singleResult.status !== "completed") {
118
+ return {
119
+ kind: "failed",
120
+ text: finalText,
121
+ ...(singleResult.setupFailure && !singleResult.aborted
122
+ ? { setupFailureSummary: singleResult.setupFailure.summary }
123
+ : {}),
124
+ };
125
+ }
110
126
  if (singleResult && ((singleResult.aborted ?? false) || singleResult.exitCode !== 0)) {
111
127
  return {
112
128
  kind: "failed",
@@ -1042,7 +1058,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1042
1058
  progress.setupFailure = singleResult?.setupFailure;
1043
1059
  }
1044
1060
  completedJobs += 1;
1045
- if (singleResult && ((singleResult.aborted ?? false) || singleResult.exitCode !== 0)) {
1061
+ if (singleResult?.status !== "completed") {
1046
1062
  failedJobs += 1;
1047
1063
  }
1048
1064
  const remaining = taskItems.length - completedJobs;
@@ -1157,6 +1173,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1157
1173
  }
1158
1174
  }
1159
1175
 
1176
+ if (failedSchedules.length > 0) {
1177
+ completedJobs += failedSchedules.length;
1178
+ failedJobs += failedSchedules.length;
1179
+ }
1180
+
1160
1181
  if (startedJobs.length === 0) {
1161
1182
  const failureText = `Failed to start background task jobs: ${failedSchedules.join("; ")}`;
1162
1183
  return {
@@ -1165,9 +1186,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1165
1186
  };
1166
1187
  }
1167
1188
 
1189
+ const asyncState = completedJobs === taskItems.length ? (failedJobs > 0 ? "failed" : "completed") : "running";
1168
1190
  emitAsyncUpdate(
1169
- "running",
1170
- `Launching ${startedJobs.length} background ${startedJobs.length === 1 ? "task" : "tasks"}...`,
1191
+ asyncState,
1192
+ asyncState === "running"
1193
+ ? `Launching ${startedJobs.length} background ${startedJobs.length === 1 ? "task" : "tasks"}...`
1194
+ : asyncState === "completed"
1195
+ ? `Background task batch complete: ${completedJobs}/${taskItems.length} finished.`
1196
+ : `Background task batch complete with failures: ${failedJobs} failed.`,
1171
1197
  );
1172
1198
 
1173
1199
  const scheduleFailureSummary =
@@ -1196,7 +1222,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1196
1222
  content: [
1197
1223
  {
1198
1224
  type: "text",
1199
- text: `Started ${startedJobs.length} background task job${startedJobs.length === 1 ? "" : "s"} using ${params.agent}.${scheduleFailureSummary} Results will be delivered when complete.\n${startedListing}\n${coordinationHint}`,
1225
+ text:
1226
+ asyncState === "running"
1227
+ ? `Started ${startedJobs.length} background task job${startedJobs.length === 1 ? "" : "s"} using ${params.agent}.${scheduleFailureSummary} Results will be delivered when complete.\n${startedListing}\n${coordinationHint}`
1228
+ : `Background task batch ${asyncState}: ${completedJobs}/${taskItems.length} finished.${scheduleFailureSummary}\n${startedListing}`,
1200
1229
  },
1201
1230
  ],
1202
1231
  details: {
@@ -1204,7 +1233,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1204
1233
  results: [],
1205
1234
  totalDurationMs: 0,
1206
1235
  progress: getProgressSnapshot(),
1207
- async: { state: "running", jobId: startedJobs[0].jobId, type: "task" },
1236
+ async: { state: asyncState, jobId: startedJobs[0].jobId, type: "task" },
1208
1237
  },
1209
1238
  };
1210
1239
  }
@@ -1801,13 +1830,18 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1801
1830
  parentTelemetry: this.session.getTelemetry?.(),
1802
1831
  forkContextSeed,
1803
1832
  });
1804
- const resultWithForkContext = {
1833
+ let capturedResult = {
1805
1834
  ...result,
1806
1835
  ...(forkContext ? { forkContext } : {}),
1807
1836
  forkContextAdvisory,
1808
1837
  repositoryBinding: publicRepositoryBinding(taskRepositoryBinding),
1809
1838
  };
1810
- if (mergeMode === "branch" && resultWithForkContext.exitCode === 0) {
1839
+ const taskSucceeded =
1840
+ capturedResult.exitCode === 0 &&
1841
+ !capturedResult.error &&
1842
+ !capturedResult.aborted &&
1843
+ !capturedResult.paused;
1844
+ if (mergeMode === "branch" && taskSucceeded) {
1811
1845
  try {
1812
1846
  const commitMsg =
1813
1847
  commitStyle === "ai" && this.session.modelRegistry
@@ -1828,8 +1862,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1828
1862
  commitMsg,
1829
1863
  );
1830
1864
  const producedChanges = Boolean(commitResult?.branchName || commitResult?.nestedPatches.length);
1831
- return {
1832
- ...resultWithForkContext,
1865
+ capturedResult = {
1866
+ ...capturedResult,
1833
1867
  branchName: commitResult?.branchName,
1834
1868
  nestedPatches: commitResult?.nestedPatches,
1835
1869
  producedChanges,
@@ -1839,34 +1873,51 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1839
1873
  const branchName = `gjc/task/${task.id}`;
1840
1874
  await git.branch.tryDelete(repoRoot, branchName);
1841
1875
  const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
1842
- return { ...resultWithForkContext, error: `Merge failed: ${msg}` };
1876
+ capturedResult = { ...capturedResult, error: `Merge failed: ${msg}` };
1843
1877
  }
1844
1878
  }
1845
- if (resultWithForkContext.exitCode === 0) {
1846
- try {
1847
- const delta = await captureDeltaPatch(isolationDir, taskBaseline);
1848
- await initializeLocalRoot(localProtocolOptions);
1849
- const artifactId = validateAllocatedTaskId(task.id);
1850
- const patchPath = resolveLocalUrlToPath(
1851
- `local://subagents/${artifactId}.patch`,
1852
- localProtocolOptions,
1853
- );
1854
- await fs.mkdir(path.dirname(patchPath), { recursive: true, mode: 0o700 });
1855
- await Bun.write(patchPath, delta.rootPatch);
1856
- isolatedPatchBytes.set(patchPath, Buffer.from(delta.rootPatch, "utf8"));
1857
- const producedChanges = Boolean(delta.rootPatch.trim() || delta.nestedPatches.length);
1858
- return {
1859
- ...resultWithForkContext,
1860
- patchPath,
1861
- nestedPatches: delta.nestedPatches,
1862
- producedChanges,
1863
- };
1864
- } catch (patchErr) {
1865
- const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
1866
- return { ...resultWithForkContext, error: `Patch capture failed: ${msg}` };
1867
- }
1879
+ try {
1880
+ const delta = await captureDeltaPatch(isolationDir, taskBaseline);
1881
+ await initializeLocalRoot(localProtocolOptions);
1882
+ const artifactId = validateAllocatedTaskId(task.id);
1883
+ const patchUri = `local://subagents/${artifactId}-${Snowflake.next()}.patch`;
1884
+ const patchPath = resolveLocalUrlToPath(patchUri, localProtocolOptions);
1885
+ const rootPatchBytes = Buffer.from(delta.rootPatch, "utf8");
1886
+ const recoveryBytes = Buffer.from(serializeRecoveryPatchBundle(delta), "utf8");
1887
+ await fs.mkdir(path.dirname(patchPath), { recursive: true, mode: 0o700 });
1888
+ await Bun.write(patchPath, recoveryBytes);
1889
+ isolatedPatchBytes.set(patchPath, rootPatchBytes);
1890
+ const producedChanges = Boolean(
1891
+ delta.rootPatch.trim() || delta.nestedPatches.length || delta.captureErrors?.length,
1892
+ );
1893
+ const recoveryRef =
1894
+ recoveryBytes.byteLength > 0
1895
+ ? {
1896
+ uri: patchUri,
1897
+ sizeBytes: recoveryBytes.byteLength,
1898
+ sha256: createHash("sha256").update(recoveryBytes).digest("hex"),
1899
+ durability: "session" as const,
1900
+ }
1901
+ : undefined;
1902
+ const captureError = delta.captureErrors?.length
1903
+ ? `Patch capture incomplete: ${delta.captureErrors.join("; ")}`
1904
+ : undefined;
1905
+ const resultError = captureError
1906
+ ? `${capturedResult.error ? `${capturedResult.error}; ` : ""}${captureError}`
1907
+ : capturedResult.error;
1908
+ return {
1909
+ ...capturedResult,
1910
+ ...(resultError ? { error: resultError } : {}),
1911
+ patchPath,
1912
+ nestedPatches: delta.nestedPatches,
1913
+ producedChanges,
1914
+ recoveryRef,
1915
+ };
1916
+ } catch (patchErr) {
1917
+ const msg = patchErr instanceof Error ? patchErr.message : String(patchErr);
1918
+ const existingError = capturedResult.error ? `${capturedResult.error}; ` : "";
1919
+ return { ...capturedResult, error: `${existingError}Patch capture failed: ${msg}` };
1868
1920
  }
1869
- return resultWithForkContext;
1870
1921
  } catch (err) {
1871
1922
  const message = err instanceof Error ? err.message : String(err);
1872
1923
  const assignment = task.assignment.trim();
@@ -1966,13 +2017,23 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1966
2017
  let mergeSummary = "";
1967
2018
  let changesApplied: boolean | null = null;
1968
2019
  let hadAnyChanges = false;
2020
+ let rootPatchTexts: string[] = [];
1969
2021
  let mergedBranchesForNestedPatches: Set<string> | null = null;
2022
+ let mergePhaseFailed = false;
2023
+ const setRecoveryAvailable = (result: SingleResult): void => {
2024
+ if (!result.recoveryRef) return;
2025
+ result.persistence = {
2026
+ outcome: "recovery_available",
2027
+ ownerWorktreeApplied: false,
2028
+ recoveryRef: result.recoveryRef,
2029
+ };
2030
+ };
1970
2031
  if (isIsolated && repoRoot) {
1971
2032
  try {
1972
2033
  if (mergeMode === "branch") {
1973
2034
  // Branch mode: merge task branches sequentially
1974
2035
  const branchEntries = results
1975
- .filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
2036
+ .filter(r => r.branchName && r.exitCode === 0 && !r.error && !r.aborted && !r.paused)
1976
2037
  .map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
1977
2038
 
1978
2039
  if (branchEntries.length === 0) {
@@ -1981,7 +2042,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1981
2042
  mergeSummary = "\n\nNo changes to apply.";
1982
2043
  } else {
1983
2044
  const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
1984
- mergedBranchesForNestedPatches = new Set(mergeResult.merged);
2045
+ mergedBranchesForNestedPatches = new Set(
2046
+ mergeResult.merged.filter(branchName => !mergeResult.failed.includes(branchName)),
2047
+ );
1985
2048
  changesApplied = mergeResult.failed.length === 0;
1986
2049
  hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
1987
2050
 
@@ -1998,15 +2061,48 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1998
2061
  }
1999
2062
  }
2000
2063
 
2064
+ for (const result of results) {
2065
+ const hasChanges = Boolean(
2066
+ result.branchName || result.nestedPatches?.length || result.recoveryRef,
2067
+ );
2068
+ if (!hasChanges) {
2069
+ if (result.exitCode === 0 && !result.error && !result.aborted && !result.paused) {
2070
+ result.persistence = { outcome: "no_changes", ownerWorktreeApplied: true };
2071
+ }
2072
+ continue;
2073
+ }
2074
+ if (
2075
+ result.branchName &&
2076
+ mergedBranchesForNestedPatches?.has(result.branchName) &&
2077
+ result.exitCode === 0 &&
2078
+ !result.error &&
2079
+ !result.aborted &&
2080
+ !result.paused
2081
+ ) {
2082
+ result.persistence = {
2083
+ outcome: "applied",
2084
+ ownerWorktreeApplied: true,
2085
+ recoveryRef: result.recoveryRef,
2086
+ };
2087
+ } else {
2088
+ setRecoveryAvailable(result);
2089
+ }
2090
+ }
2091
+
2001
2092
  // Clean up merged branches (keep failed ones for manual resolution)
2002
2093
  const allBranches = branchEntries.map(b => b.branchName);
2003
2094
  if (changesApplied) {
2004
2095
  await cleanupTaskBranches(repoRoot, allBranches);
2005
2096
  }
2006
2097
  } else {
2007
- // Patch mode: combine and apply patches
2008
- const patchesInOrder = results.map(result => result.patchPath).filter(Boolean) as string[];
2009
- const missingPatch = results.some(result => !result.patchPath);
2098
+ // Patch mode: combine and apply only successful task patches. Failed/interrupted patches remain recovery-only.
2099
+ const successfulPatchResults = results.filter(
2100
+ result => result.exitCode === 0 && !result.error && !result.aborted && !result.paused,
2101
+ );
2102
+ const patchesInOrder = successfulPatchResults
2103
+ .map(result => result.patchPath)
2104
+ .filter(Boolean) as string[];
2105
+ const missingPatch = successfulPatchResults.some(result => !result.patchPath);
2010
2106
  if (missingPatch) {
2011
2107
  changesApplied = false;
2012
2108
  hadAnyChanges = false;
@@ -2023,10 +2119,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2023
2119
  changesApplied = true;
2024
2120
  hadAnyChanges = false;
2025
2121
  } else {
2026
- const patchTexts = nonEmptyPatches.map(patchPath =>
2122
+ rootPatchTexts = nonEmptyPatches.map(patchPath =>
2027
2123
  isolatedPatchBytes.get(patchPath)!.toString("utf8"),
2028
2124
  );
2029
- const combinedPatch = patchTexts
2125
+ const combinedPatch = rootPatchTexts
2030
2126
  .map(text => (text.endsWith("\n") ? text : `${text}\n`))
2031
2127
  .join("");
2032
2128
  if (!combinedPatch.trim()) {
@@ -2047,6 +2143,48 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2047
2143
  }
2048
2144
  }
2049
2145
 
2146
+ if (changesApplied && rootPatchTexts.length > 0 && baseline) {
2147
+ changesApplied = await verifyRootPatchesApplied(repoRoot, baseline, rootPatchTexts);
2148
+ }
2149
+
2150
+ for (const result of results) {
2151
+ const patchBytes = result.patchPath ? isolatedPatchBytes.get(result.patchPath) : undefined;
2152
+ const hasChanges = Boolean(
2153
+ patchBytes?.toString("utf8").trim() ||
2154
+ (result.nestedPatches && result.nestedPatches.length > 0) ||
2155
+ result.recoveryRef,
2156
+ );
2157
+ if (!hasChanges) {
2158
+ if (result.exitCode === 0 && !result.error && !result.aborted && !result.paused) {
2159
+ result.persistence = {
2160
+ outcome: "no_changes",
2161
+ ownerWorktreeApplied: true,
2162
+ };
2163
+ }
2164
+ continue;
2165
+ }
2166
+ if (
2167
+ changesApplied &&
2168
+ patchBytes &&
2169
+ result.exitCode === 0 &&
2170
+ !result.error &&
2171
+ !result.aborted &&
2172
+ !result.paused
2173
+ ) {
2174
+ result.persistence = {
2175
+ outcome: "applied",
2176
+ ownerWorktreeApplied: true,
2177
+ recoveryRef: result.recoveryRef,
2178
+ };
2179
+ } else {
2180
+ result.persistence = {
2181
+ outcome: "recovery_available",
2182
+ ownerWorktreeApplied: false,
2183
+ recoveryRef: result.recoveryRef,
2184
+ };
2185
+ }
2186
+ }
2187
+
2050
2188
  if (changesApplied) {
2051
2189
  mergeSummary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
2052
2190
  } else {
@@ -2063,21 +2201,37 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2063
2201
  const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
2064
2202
  changesApplied = false;
2065
2203
  hadAnyChanges = false;
2204
+ mergePhaseFailed = true;
2205
+ for (const result of results) {
2206
+ if (result.producedChanges || result.recoveryRef || result.nestedPatches?.length) {
2207
+ setRecoveryAvailable(result);
2208
+ }
2209
+ }
2066
2210
  mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
2067
2211
  }
2068
2212
  }
2069
2213
 
2070
2214
  // Apply nested repo patches (separate from parent git)
2071
- if (isIsolated && repoRoot && (mergeMode === "branch" || changesApplied !== false)) {
2215
+ if (isIsolated && repoRoot && !mergePhaseFailed && (mergeMode === "branch" || changesApplied !== false)) {
2072
2216
  const allNestedPatches = results
2073
2217
  .filter(r => {
2074
- if (!r.nestedPatches || r.nestedPatches.length === 0 || r.exitCode !== 0 || r.aborted) {
2218
+ if (
2219
+ !r.nestedPatches ||
2220
+ r.nestedPatches.length === 0 ||
2221
+ r.exitCode !== 0 ||
2222
+ r.error ||
2223
+ r.aborted ||
2224
+ r.paused
2225
+ ) {
2075
2226
  return false;
2076
2227
  }
2077
2228
  if (mergeMode !== "branch") {
2078
2229
  return true;
2079
2230
  }
2080
- if (!r.branchName || !mergedBranchesForNestedPatches) {
2231
+ if (!r.branchName) {
2232
+ return true;
2233
+ }
2234
+ if (!mergedBranchesForNestedPatches) {
2081
2235
  return false;
2082
2236
  }
2083
2237
  return mergedBranchesForNestedPatches.has(r.branchName);
@@ -2085,32 +2239,46 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2085
2239
  .flatMap(r => r.nestedPatches!);
2086
2240
  if (allNestedPatches.length > 0) {
2087
2241
  try {
2088
- const commitMsg =
2089
- commitStyle === "ai" && this.session.modelRegistry
2090
- ? async (diff: string) => {
2091
- return generateCommitMessage(
2092
- diff,
2093
- this.session.modelRegistry!,
2094
- this.session.settings,
2095
- this.session.getSessionId?.() ?? undefined,
2096
- );
2097
- }
2098
- : undefined;
2099
- await applyNestedPatches(repoRoot, allNestedPatches, commitMsg);
2242
+ await applyNestedPatches(repoRoot, allNestedPatches);
2243
+ if (!baseline || !(await verifyNestedPatchesApplied(repoRoot, baseline, allNestedPatches))) {
2244
+ throw new Error("Nested repository persistence proof failed");
2245
+ }
2246
+ for (const result of results) {
2247
+ const rootApplied =
2248
+ mergeMode !== "branch"
2249
+ ? changesApplied !== false
2250
+ : !result.branchName || Boolean(mergedBranchesForNestedPatches?.has(result.branchName));
2251
+ if (
2252
+ result.nestedPatches?.length &&
2253
+ result.exitCode === 0 &&
2254
+ !result.error &&
2255
+ !result.aborted &&
2256
+ !result.paused &&
2257
+ rootApplied
2258
+ ) {
2259
+ result.persistence = {
2260
+ outcome: "applied",
2261
+ ownerWorktreeApplied: true,
2262
+ recoveryRef: result.recoveryRef,
2263
+ };
2264
+ }
2265
+ }
2100
2266
  } catch {
2101
- // Nested patch failures are non-fatal to the parent merge
2267
+ changesApplied = false;
2268
+ for (const result of results) {
2269
+ if (result.nestedPatches?.length) setRecoveryAvailable(result);
2270
+ }
2102
2271
  mergeSummary +=
2103
- "\n\n<system-notification>Some nested repository patches failed to apply.</system-notification>";
2272
+ "\n\n<system-notification>Some nested repository patches failed to apply; affected tasks remain recovery-only.</system-notification>";
2104
2273
  }
2105
2274
  }
2106
2275
  }
2107
2276
 
2108
2277
  // Build final output - match plugin format
2109
- const cancelledCount = results.filter(r => r.aborted).length;
2110
- const successCount = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted).length;
2111
2278
  const totalDuration = Date.now() - startTime;
2112
-
2113
2279
  const receipts = results.map(buildTaskReceipt);
2280
+ const cancelledCount = receipts.filter(r => r.status === "aborted").length;
2281
+ const successCount = receipts.filter(r => r.status === "completed").length;
2114
2282
  const roiSummary = buildTaskRoiSummary(receipts);
2115
2283
  const roiReconciliation = executionOverrides?.suppressRoiReconciliation
2116
2284
  ? undefined