@zq-silk/yui 0.16.1 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @zq-silk/yui might be problematic. Click here for more details.

Files changed (114) hide show
  1. package/ARCHITECTURE.md +12 -0
  2. package/ARCHITECTURE.zh-CN.md +8 -0
  3. package/README.md +3 -0
  4. package/dist/agent/managedRuntimeEnvironment.js +0 -4
  5. package/dist/agentRun/agentRun.js +7 -17
  6. package/dist/cli/agentConfigurationPicker.js +24 -21
  7. package/dist/cli/commandCatalog.js +18 -10
  8. package/dist/cli/roleWizard.js +41 -30
  9. package/dist/cli/updateCommand.js +35 -13
  10. package/dist/cli/updateOrchestrator.js +73 -33
  11. package/dist/cli/updatePorts.js +40 -17
  12. package/dist/cli.js +35 -26
  13. package/dist/commands/controllerCommands.js +1 -1
  14. package/dist/commands/executionAuditCommands.js +2 -2
  15. package/dist/commands/projectCommands.js +1 -1
  16. package/dist/commands/releaseCommands.js +9 -38
  17. package/dist/commands/taskCommands.js +84 -81
  18. package/dist/commands/taskCompletionGate.js +0 -117
  19. package/dist/commands/taskRoleRuntimeStatus.js +1 -1
  20. package/dist/commands/taskUpstreamCommands.js +93 -54
  21. package/dist/context/runContextPack.js +30 -54
  22. package/dist/context/runInputContract.js +9 -0
  23. package/dist/context/sessionBootstrapManifest.js +11 -19
  24. package/dist/controller/agentHostObservation.js +4 -2
  25. package/dist/controller/controller.js +8 -6
  26. package/dist/controller/fileSchedulerStoreAdapter.js +85 -18
  27. package/dist/controller/globalInputDelivery.js +28 -7
  28. package/dist/controller/globalRuntimeAttention.js +34 -0
  29. package/dist/controller/operatorNotification.js +31 -0
  30. package/dist/controller/providerRetryAdmission.js +3 -1
  31. package/dist/controller/providerRetryDelivery.js +10 -3
  32. package/dist/controller/runtime.js +1 -27
  33. package/dist/controller/sessionOwnerReconciliation.js +42 -24
  34. package/dist/controller/structuredProviderObservation.js +27 -0
  35. package/dist/controller/updateReconciliation.js +72 -20
  36. package/dist/doctor/doctor.js +2 -1
  37. package/dist/executor/agentAdapter.js +28 -96
  38. package/dist/executor/agentConfigurationCatalog.js +13 -68
  39. package/dist/executor/agentConfigurationFields.js +120 -0
  40. package/dist/executor/agentConfigurationProbe.js +55 -80
  41. package/dist/executor/agentExecutor.js +5 -2
  42. package/dist/executor/executorRegistry.js +0 -9
  43. package/dist/executor/fileRoleLaunchPlanner.js +12 -11
  44. package/dist/interaction/operatorPresentation.js +2 -1
  45. package/dist/message/messageContinuation.js +8 -9
  46. package/dist/observability/executionAudit.js +1 -10
  47. package/dist/observability/runtimeIdentity.js +0 -23
  48. package/dist/output/agentConfigurationPresentation.js +4 -2
  49. package/dist/release/releaseHandover.js +2 -2
  50. package/dist/release/releaseIdempotencyStore.js +0 -23
  51. package/dist/release/releaseWorkflowPorts.js +8 -4
  52. package/dist/repository/gitWorkspace.js +66 -19
  53. package/dist/repository/taskWorkspacePreparer.js +75 -68
  54. package/dist/resources/resourceRegistry.js +90 -44
  55. package/dist/resources/sqliteResourceRegistry.js +3 -5
  56. package/dist/runtime/acpSession.js +21 -5
  57. package/dist/runtime/agentEndpoint.js +4 -1
  58. package/dist/runtime/agentHost.js +22 -62
  59. package/dist/runtime/agentHostCleanup.js +85 -0
  60. package/dist/runtime/agentHostProtocol.js +1 -1
  61. package/dist/runtime/builtinAgentDrivers.js +46 -119
  62. package/dist/runtime/builtinTranscriptObserver.js +10 -6
  63. package/dist/runtime/codexAppServerRuntime.js +55 -89
  64. package/dist/runtime/jsonLineChannel.js +35 -7
  65. package/dist/runtime/providerContinuationReconciliationService.js +42 -3
  66. package/dist/runtime/providerRetry.js +15 -0
  67. package/dist/runtime/providerRuntimeReconciler.js +12 -2
  68. package/dist/runtime/structuredProviderHost.js +106 -81
  69. package/dist/scheduler/activeRoleRunDelivery.js +5 -1
  70. package/dist/scheduler/operatorInputNotificationProcessor.js +7 -17
  71. package/dist/storage/upgrade/upgradeOrchestrator.js +0 -10
  72. package/dist/task/nextAction.js +33 -23
  73. package/dist/task/task.js +2 -1
  74. package/dist/web/assets/assetManifest.js +2 -0
  75. package/dist/web/assets/client/app.js +72 -3
  76. package/dist/web/assets/client/components.js +2 -1
  77. package/dist/web/assets/client/i18n.js +2 -0
  78. package/dist/web/assets/client/taskSummary.js +345 -0
  79. package/dist/web/assets/client/taskSurface.js +44 -26
  80. package/dist/web/assets/client/view.js +33 -0
  81. package/dist/web/assets/shell.js +1 -0
  82. package/dist/web/assets/styles/cards.js +21 -0
  83. package/dist/web/webServer.js +39 -5
  84. package/dist/web/webSessions.js +165 -0
  85. package/dist/web/webSnapshot.js +23 -0
  86. package/dist/web/webTaskSurface.js +23 -0
  87. package/docs/managed-turn-and-session-runtime.md +31 -0
  88. package/docs/managed-turn-and-session-runtime.zh-CN.md +23 -0
  89. package/docs/observability/README.md +47 -0
  90. package/docs/observability/README.zh-CN.md +37 -0
  91. package/docs/project-refresh.md +9 -0
  92. package/docs/project-refresh.zh-CN.md +8 -0
  93. package/docs/provider-retry.md +35 -0
  94. package/docs/release-workflow.md +113 -20
  95. package/docs/release-workflow.zh-CN.md +95 -14
  96. package/docs/roles-and-configuration.md +30 -0
  97. package/docs/roles-and-configuration.zh-CN.md +20 -0
  98. package/docs/task-delivery.md +109 -0
  99. package/docs/task-delivery.zh-CN.md +80 -0
  100. package/docs/task-discovery.md +9 -0
  101. package/docs/task-discovery.zh-CN.md +6 -0
  102. package/docs/testing/verification-levels.md +29 -11
  103. package/docs/testing/verification-levels.zh-CN.md +25 -7
  104. package/i18n/README.zh-CN.md +2 -0
  105. package/package.json +1 -1
  106. package/skills/yui-leader/SKILL.md +11 -0
  107. package/skills/yui-leader/references/execution.md +22 -0
  108. package/skills/yui-leader/references/planning.md +6 -2
  109. package/skills/yui-operator/SKILL.md +45 -13
  110. package/skills/yui-operator/references/task-delivery.md +99 -0
  111. package/skills/yui-runtime/SKILL.md +5 -3
  112. package/skills/yui-runtime/references/publication.md +56 -5
  113. package/skills/yui-runtime/references/recovery.md +12 -2
  114. package/dist/runtime/agentHostCompatibility.js +0 -127
@@ -348,6 +348,11 @@ export class NodeGitWorkspace {
348
348
  }
349
349
  await canonicalContainer(dirname(destination), true);
350
350
  const branch = input.branch === undefined ? undefined : safeRef(input.branch);
351
+ // Reserve exactly this operation's directory before invoking Git. A clone
352
+ // rejection because somebody else created the destination cannot authorize
353
+ // removal of that other directory.
354
+ await mkdir(destination, { mode: 0o700 });
355
+ const createdDirectory = await lstat(destination);
351
356
  try {
352
357
  await git([
353
358
  "clone",
@@ -363,12 +368,31 @@ export class NodeGitWorkspace {
363
368
  }
364
369
  await git(["-C", destination, "branch", "-M", "--", localBranch]);
365
370
  }
371
+ return await this.inspect(destination, "HEAD");
366
372
  }
367
373
  catch (error) {
368
- await rm(destination, { recursive: true, force: true });
374
+ try {
375
+ const current = await lstat(destination).catch(failure => {
376
+ if (isErrno(failure, "ENOENT"))
377
+ return undefined;
378
+ throw failure;
379
+ });
380
+ if (current !== undefined) {
381
+ if (!current.isDirectory() || current.dev !== createdDirectory.dev || current.ino !== createdDirectory.ino) {
382
+ throw new Error("Clone destination ownership changed; no direct deletion attempted.");
383
+ }
384
+ await rm(destination, { recursive: true });
385
+ }
386
+ }
387
+ catch (cleanupError) {
388
+ throw new AggregateError([error, cleanupError], `Clone failed at ${destination}: ${error instanceof Error ? error.message : String(error)}. `
389
+ + `Temporary compensation failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}. `
390
+ + "Cleanup effects are unconfirmed; inspect this exact destination before retrying.");
391
+ }
392
+ if (error instanceof Error)
393
+ error.message += ` Exact temporary clone directory removed or already absent: ${destination}.`;
369
394
  throw error;
370
395
  }
371
- return this.inspect(destination, "HEAD");
372
396
  }
373
397
  async ensureLocalBranch(repositoryPath, branch) {
374
398
  try {
@@ -822,31 +846,54 @@ export class NodeGitWorkspace {
822
846
  * worktree's own Git identity instead. Only call for unadopted worktrees
823
847
  * that are safe to discard (e.g. Lane preparation compensation).
824
848
  */
825
- async removeStrandedWorktree(path) {
826
- const kind = await pathKind(path);
827
- if (kind === undefined)
828
- return "missing";
829
- if (kind === "symlink")
830
- throw new Error("Stranded worktree path must not be a symbolic link.");
831
- // Inspect dirty state through the worktree's own Git, bypassing the
832
- // Project ownership check that fails after a catalog switch.
833
- const porcelain = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
834
- if (porcelain.length > 0)
835
- return "dirty";
849
+ async removeStrandedWorktree(path, identity) {
850
+ let worktreeRemoved = false;
836
851
  try {
837
- await git(["-C", path, "worktree", "remove", "--force", "--", path]);
852
+ const expected = managedPath(resolve(identity.container), safeIdentity(identity.directory, "Project directory"));
853
+ if (path !== expected)
854
+ throw new Error("Unadopted worktree does not match its exact managed path.");
855
+ const kind = await pathKind(path);
856
+ if (kind === undefined)
857
+ return "missing";
858
+ if (kind === "symlink")
859
+ throw new Error("Stranded worktree path must not be a symbolic link.");
860
+ await canonicalContainer(identity.container, false);
861
+ if (!(await lstat(join(path, ".git"))).isFile()) {
862
+ throw new Error("Expected a linked worktree, not a standalone clone.");
863
+ }
864
+ const project = await this.inspect(path, "HEAD");
865
+ await assertOwnedWorktree(project, resolve(identity.container), path);
866
+ const branch = worktreeIdentity(identity.taskSegment, identity.roleName).branch;
867
+ await assertExpectedBranch(path, branch);
868
+ if (project.baseCommit !== identity.expectedCommit) {
869
+ throw new Error("Unadopted worktree HEAD changed since preparation; retained.");
870
+ }
871
+ if (!await this.inspectClean(path))
872
+ return "dirty";
873
+ // Use the worktree's proven common directory, never the changed catalog.
874
+ // Git still enforces locks/submodule restrictions. A Git error is not
875
+ // authority for direct filesystem deletion, even for unadopted work.
876
+ await git(["--git-dir", project.gitDirectory, "worktree", "remove", "--", path]);
877
+ worktreeRemoved = true;
878
+ if (identity.deleteBranch === true) {
879
+ await git(["--git-dir", project.gitDirectory, "update-ref", "-d", `refs/heads/${branch}`, identity.expectedCommit]);
880
+ }
881
+ return "removed";
838
882
  }
839
- catch {
840
- // The worktree's common-dir (old external repo) may itself be gone.
841
- // Remove the directory directly; the worktree is unadopted.
842
- await rm(path, { recursive: true, force: true });
883
+ catch (error) {
884
+ throw new Error(`Unadopted worktree cleanup failed at ${path}: ${error instanceof Error ? error.message : String(error)} `
885
+ + `Confirmed worktree removal: ${worktreeRemoved}; failed-command effects remain unconfirmed. `
886
+ + "No direct deletion was attempted. Inspect this exact path and its Git registration before choosing recovery.", { cause: error });
843
887
  }
844
- return "removed";
845
888
  }
846
889
  async removeTaskClone(input) {
847
890
  const state = await this.inspectTaskClone(input);
848
891
  if (state !== "clean")
849
892
  return state;
893
+ if (input.expectedCommit !== undefined
894
+ && await resolveRefCommit(input.path, "HEAD") !== input.expectedCommit) {
895
+ throw new Error(`Unadopted Task clone HEAD changed since preparation; retained at ${input.path}.`);
896
+ }
850
897
  await rm(managedPath(resolve(input.container), input.directory), { recursive: true });
851
898
  return "removed";
852
899
  }
@@ -545,7 +545,7 @@ export class FileTaskWorkspacePreparer {
545
545
  const taskSegment = taskWorkspaceRefSegment(workspaceIdentity === undefined ? task : { ...task, workspaceIdentity });
546
546
  const root = this.#taskWorkspaceRoot(task.id);
547
547
  const prepared = [];
548
- const createdClonePaths = new Set();
548
+ const createdClones = [];
549
549
  const baselines = new Map();
550
550
  try {
551
551
  // Resolve every remote baseline before creating any Task clone. A later
@@ -595,7 +595,14 @@ export class FileTaskWorkspacePreparer {
595
595
  })
596
596
  : await this.git.inspect(previous.path, "HEAD");
597
597
  if (previous === undefined)
598
- createdClonePaths.add(physical.root);
598
+ createdClones.push({
599
+ project,
600
+ entry: {
601
+ projectId: project.id, directory: binding.directory, access: "write",
602
+ path: destination, branch: identity.branch,
603
+ baseRef: baseline.recordedBaseRef, baseCommit: physical.baseCommit
604
+ }
605
+ });
599
606
  const physicalBranch = await this.git.headRef(physical.root);
600
607
  if (physical.root !== (previous?.path ?? destination)
601
608
  || physicalBranch !== (previous?.branch ?? identity.branch)) {
@@ -794,16 +801,7 @@ export class FileTaskWorkspacePreparer {
794
801
  // A failed or conflicted preparation owns no durable record: drop the
795
802
  // branches too, so a retry mints a clean identity without half-created
796
803
  // refs behind. Adopted (already catalogued) worktrees are never touched.
797
- await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE, true);
798
- const recorded = new Set(prepared.map(({ entry }) => entry.path));
799
- for (const path of createdClonePaths) {
800
- if (recorded.has(path))
801
- continue;
802
- const removal = await this.git.removeStrandedWorktree(path);
803
- if (removal === "dirty") {
804
- throw new Error(`Unadopted Task clone is dirty and was retained at ${path}; inspect it and retry.`);
805
- }
806
- }
804
+ await this.#discardUnadoptedEntries(task, taskSegment, createdClones, MAIN_WORKTREE, true, undefined, error);
807
805
  if (activate)
808
806
  await removeWorkspaceView(root);
809
807
  throw error;
@@ -1125,7 +1123,7 @@ export class FileTaskWorkspacePreparer {
1125
1123
  });
1126
1124
  }
1127
1125
  catch (error) {
1128
- await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, item.id);
1126
+ await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, item.id, false, undefined, error);
1129
1127
  throw error;
1130
1128
  }
1131
1129
  }
@@ -1276,7 +1274,7 @@ export class FileTaskWorkspacePreparer {
1276
1274
  return workspace;
1277
1275
  }
1278
1276
  catch (error) {
1279
- await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, managedWorktreeName(owner), true);
1277
+ await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, managedWorktreeName(owner), true, undefined, error);
1280
1278
  throw error;
1281
1279
  }
1282
1280
  }
@@ -1354,48 +1352,39 @@ export class FileTaskWorkspacePreparer {
1354
1352
  return "missing";
1355
1353
  const task = requireTask(this.store, workspace.owner.taskId);
1356
1354
  const taskSegment = this.#taskSegment(task);
1355
+ const root = this.#executionLaneWorkspaceRoot(task.id, workspace.owner.executionGroupId, workspace.owner.executionLaneId);
1356
+ if (workspace.root !== root)
1357
+ throw new Error("Unadopted Lane root does not match its managed owner.");
1357
1358
  const writable = workspace.entries.filter(({ access }) => access === "write");
1358
- let state;
1359
+ const completed = [];
1360
+ let target = root;
1359
1361
  try {
1360
- state = await this.#inspectEntries(task.id, taskSegment, managedWorktreeName(workspace.owner), writable);
1361
- }
1362
- catch (error) {
1363
- // A `project migrate` between preparation and compensation switches the
1364
- // catalog, so the worktree's common-dir no longer matches the Project's
1365
- // current repository. Fall back to removing the stranded worktree
1366
- // through its own Git identity.
1367
- if (!(error instanceof Error && error.message.includes("belongs to another project"))) {
1368
- throw error;
1369
- }
1370
- let removed = false;
1362
+ // This owner was never adopted. Its captured identity is sufficient to
1363
+ // inspect its own Git; no catalog-error matching or filesystem fallback.
1371
1364
  for (const entry of writable) {
1372
- const result = await this.git.removeStrandedWorktree(entry.path);
1373
- if (result === "dirty")
1374
- return "dirty";
1375
- removed ||= result === "removed";
1365
+ target = entry.path;
1366
+ const result = await this.git.removeStrandedWorktree(entry.path, {
1367
+ container: root, directory: entry.directory, taskSegment,
1368
+ roleName: managedWorktreeName(workspace.owner), expectedCommit: entry.baseCommit,
1369
+ deleteBranch: true
1370
+ });
1371
+ if (result === "dirty") {
1372
+ if (completed.length === 0)
1373
+ return "dirty";
1374
+ throw new Error(`Unadopted Lane is dirty and retained at ${entry.path}.`);
1375
+ }
1376
+ completed.push({ path: entry.path, result });
1376
1377
  }
1377
- await removeWorkspaceView(workspace.root);
1378
- return removed ? "removed" : "missing";
1378
+ target = root;
1379
+ await removeWorkspaceView(root);
1380
+ return completed.some(({ result }) => result === "removed") ? "removed" : "missing";
1379
1381
  }
1380
- if (state === "dirty")
1381
- return "dirty";
1382
- let removed = false;
1383
- for (const entry of writable) {
1384
- const project = requireProject(this.store, entry.projectId);
1385
- const result = await this.git.removeWorktree({
1386
- repositoryPath: this.#taskRepositoryPath(task.id, project.id),
1387
- container: dirname(entry.path),
1388
- directory: entry.directory,
1389
- taskSegment,
1390
- roleName: managedWorktreeName(workspace.owner),
1391
- deleteBranch: true
1392
- });
1393
- if (result === "dirty")
1394
- return "dirty";
1395
- removed ||= result === "removed";
1382
+ catch (error) {
1383
+ const result = { completed, failedPath: target,
1384
+ remaining: writable.map(({ path }) => path).filter(path => !completed.some(done => done.path === path)) };
1385
+ throw Object.assign(new Error(`Unadopted Lane cleanup failed: ${errorText(error)}. Effects: ${JSON.stringify(result)}. `
1386
+ + "Failed-command effects are unknown; inspect the exact target before continuing.", { cause: error }), { result });
1396
1387
  }
1397
- await removeWorkspaceView(workspace.root);
1398
- return removed ? "removed" : "missing";
1399
1388
  }
1400
1389
  async cleanupExecutionLaneWorkspacesForWorkItem(taskId, workItemId) {
1401
1390
  let result = "missing";
@@ -1754,7 +1743,7 @@ export class FileTaskWorkspacePreparer {
1754
1743
  });
1755
1744
  }
1756
1745
  catch (error) {
1757
- await this.#discardUnadoptedEntries(task, taskSegment, prepared, this.#reviewWorktreeName(round), existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1746
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, this.#reviewWorktreeName(round), existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)), error);
1758
1747
  throw error;
1759
1748
  }
1760
1749
  }
@@ -2243,28 +2232,43 @@ export class FileTaskWorkspacePreparer {
2243
2232
  return this.store.getConfig().defaultWorkspace ?? process.cwd();
2244
2233
  }
2245
2234
  async #discardUnadoptedEntries(task, taskSegment, prepared, roleName, deleteBranch = false, adoptedPaths = new Set(this.store.listManagedWorkspaces(task.id)
2246
- .flatMap((workspace) => workspace.entries.map(({ path }) => path)))) {
2247
- for (const { project, entry } of prepared.filter(({ entry }) => entry.access === "write" && !adoptedPaths.has(entry.path))) {
2248
- if (roleName === MAIN_WORKTREE) {
2249
- const removal = await this.git.removeStrandedWorktree(entry.path);
2235
+ .flatMap((workspace) => workspace.entries.map(({ path }) => path))), originalError) {
2236
+ const candidates = prepared.filter(({ entry }) => entry.access === "write" && !adoptedPaths.has(entry.path));
2237
+ const completed = [];
2238
+ for (const { project, entry } of candidates) {
2239
+ try {
2240
+ const removal = roleName === MAIN_WORKTREE
2241
+ ? await this.git.removeTaskClone({
2242
+ path: entry.path, container: this.#taskWorkspaceRoot(task.id),
2243
+ directory: entry.directory, taskSegment, branch: entry.branch,
2244
+ expectedCommit: entry.baseCommit
2245
+ })
2246
+ : await this.git.removeWorktree({
2247
+ repositoryPath: this.#taskRepositoryPath(task.id, project.id),
2248
+ container: dirname(entry.path),
2249
+ directory: entry.directory,
2250
+ taskSegment,
2251
+ roleName,
2252
+ ...(deleteBranch ? { deleteBranch: true } : {})
2253
+ });
2250
2254
  if (removal === "dirty") {
2251
- throw new Error(`Unadopted Task clone is dirty and was retained at ${entry.path}; inspect it and retry.`);
2255
+ throw new Error(`Unadopted workspace is dirty and was retained at ${entry.path}.`);
2252
2256
  }
2257
+ completed.push({ path: entry.path, result: removal });
2253
2258
  this.#resourceRegistrar().markPathsDeleted([entry.path]);
2254
- continue;
2255
2259
  }
2256
- const removal = await this.git.removeWorktree({
2257
- repositoryPath: this.#taskRepositoryPath(task.id, project.id),
2258
- container: dirname(entry.path),
2259
- directory: entry.directory,
2260
- taskSegment,
2261
- roleName,
2262
- ...(deleteBranch ? { deleteBranch: true } : {})
2263
- });
2264
- if (removal === "dirty") {
2265
- throw new Error(`Unadopted managed worktree is dirty and was retained at ${entry.path}; inspect it and retry.`);
2260
+ catch (cleanupError) {
2261
+ const remaining = candidates.map(({ entry }) => entry.path)
2262
+ .filter(path => !completed.some(done => done.path === path));
2263
+ const result = { completed, failedPath: entry.path, remaining };
2264
+ throw Object.assign(new AggregateError([originalError, cleanupError], `Workspace preparation failed: ${errorText(originalError)}. Compensation failed: ${errorText(cleanupError)}. `
2265
+ + `Effects: ${JSON.stringify(result)}. Remaining paths may be partially changed; inspect before retrying.`), { result });
2266
2266
  }
2267
- this.#resourceRegistrar().markPathsDeleted([entry.path]);
2267
+ }
2268
+ if (originalError instanceof Error && completed.length > 0) {
2269
+ // Preserve the original error class (notably a safe CAS retry), while
2270
+ // making completed compensation visible to the caller's existing report.
2271
+ originalError.message += ` Workspace compensation: ${JSON.stringify(completed)}.`;
2268
2272
  }
2269
2273
  }
2270
2274
  #taskRepositoryPath(taskId, projectId) {
@@ -2653,3 +2657,6 @@ function errorCode(error) {
2653
2657
  ? String(error.code)
2654
2658
  : undefined;
2655
2659
  }
2660
+ function errorText(error) {
2661
+ return error instanceof Error ? error.message : String(error);
2662
+ }
@@ -43,7 +43,7 @@ export function listResourceRecords(state) {
43
43
  return Object.values(state.records);
44
44
  }
45
45
  export function parseResourceRegistryState(value) {
46
- if (typeof value !== "object" || value === null) {
46
+ if (!isRecord(value)) {
47
47
  throw new Error("Resource registry root is not an object.");
48
48
  }
49
49
  const record = value;
@@ -51,15 +51,12 @@ export function parseResourceRegistryState(value) {
51
51
  throw new Error(`Resource registry schemaVersion is ${String(record.schemaVersion)}; `
52
52
  + `expected ${RESOURCE_REGISTRY_SCHEMA_VERSION}.`);
53
53
  }
54
- if (typeof record.records !== "object" || record.records === null) {
54
+ if (!isRecord(record.records)) {
55
55
  throw new Error("Resource registry records is not an object.");
56
56
  }
57
57
  const records = {};
58
58
  for (const [key, entry] of Object.entries(record.records)) {
59
- const parsed = parseResourceRecord(entry);
60
- if (parsed === undefined) {
61
- throw new Error(`Resource registry record ${key} is malformed.`);
62
- }
59
+ const parsed = parseResourceRecord(entry, key);
63
60
  if (parsed.id !== key) {
64
61
  throw new Error(`Resource registry record key ${key} does not match id ${parsed.id}.`);
65
62
  }
@@ -70,47 +67,96 @@ export function parseResourceRegistryState(value) {
70
67
  records: Object.freeze(records)
71
68
  });
72
69
  }
73
- function parseResourceRecord(value) {
74
- if (typeof value !== "object" || value === null)
75
- return undefined;
76
- const record = value;
70
+ function parseResourceRecord(value, key) {
71
+ const invalid = (field) => {
72
+ throw new Error(`Resource registry record ${key} is malformed at ${field}; stored evidence was not repaired.`);
73
+ };
74
+ const object = (value, field) => isRecord(value) ? value : invalid(field);
75
+ const text = (value, field) => {
76
+ if (typeof value !== "string" || value.trim().length === 0)
77
+ invalid(field);
78
+ };
79
+ const timestamp = (value, field) => {
80
+ text(value, field);
81
+ if (!Number.isFinite(Date.parse(value)))
82
+ invalid(field);
83
+ };
84
+ const member = (value, choices, field) => {
85
+ if (typeof value !== "string" || !choices.includes(value))
86
+ invalid(field);
87
+ };
88
+ const optionalText = (record, fields, prefix = "") => {
89
+ for (const field of fields) {
90
+ if (record[field] !== undefined)
91
+ text(record[field], `${prefix}${field}`);
92
+ }
93
+ };
94
+ const record = object(value, "record");
77
95
  if (record.schemaVersion !== RESOURCE_REGISTRY_SCHEMA_VERSION)
78
- return undefined;
79
- if (typeof record.id !== "string" || typeof record.kind !== "string"
80
- || typeof record.path !== "string" || typeof record.disposition !== "string"
81
- || typeof record.updatedAt !== "string") {
82
- return undefined;
96
+ invalid("schemaVersion");
97
+ text(record.id, "id");
98
+ text(record.path, "path");
99
+ timestamp(record.updatedAt, "updatedAt");
100
+ member(record.kind, ["worktree", "deployment", "runtime-artifact"], "kind");
101
+ member(record.disposition, ["active", "releasable", "quarantined", "deleted",
102
+ "retained-dirty", "retained-unowned", "retained-unproven", "cleanup-failed"], "disposition");
103
+ member(record.cleanliness, ["clean", "dirty", "unknown", "n/a"], "cleanliness");
104
+ if (!Array.isArray(record.activeRefs))
105
+ invalid("activeRefs");
106
+ record.activeRefs.forEach((ref, index) => text(ref, `activeRefs[${index}]`));
107
+ const owner = object(record.owner, "owner");
108
+ text(owner.home, "owner.home");
109
+ member(owner.basis, ["durable-record", "marker", "descriptor", "naming-convention", "unattributed"], "owner.basis");
110
+ optionalText(owner, ["projectId", "taskId", "workItemId", "reviewRoundId", "integrationAttemptId"], "owner.");
111
+ for (const field of ["createdAt", "lastReferencedAt"]) {
112
+ if (record[field] !== undefined)
113
+ timestamp(record[field], field);
83
114
  }
84
- if (!Number.isFinite(Date.parse(record.updatedAt)))
85
- return undefined;
86
- if (typeof record.owner !== "object" || record.owner === null)
87
- return undefined;
88
- const owner = record.owner;
89
- if (typeof owner.home !== "string" || typeof owner.basis !== "string") {
90
- return undefined;
115
+ if (record.sizeBytes !== undefined
116
+ && (typeof record.sizeBytes !== "number" || !Number.isFinite(record.sizeBytes) || record.sizeBytes < 0)) {
117
+ invalid("sizeBytes");
91
118
  }
119
+ if (record.blocker !== undefined && typeof record.blocker !== "string")
120
+ invalid("blocker");
121
+ if (record.git !== undefined) {
122
+ const git = object(record.git, "git");
123
+ text(git.repositoryPath, "git.repositoryPath");
124
+ optionalText(git, ["commonDir", "branch", "head"], "git.");
125
+ }
126
+ if (record.quarantine !== undefined) {
127
+ const quarantine = object(record.quarantine, "quarantine");
128
+ text(quarantine.path, "quarantine.path");
129
+ text(quarantine.originalPath, "quarantine.originalPath");
130
+ timestamp(quarantine.movedAt, "quarantine.movedAt");
131
+ member(quarantine.method, ["move", "git-worktree-remove"], "quarantine.method");
132
+ if (quarantine.gitRestore !== undefined) {
133
+ const restore = object(quarantine.gitRestore, "quarantine.gitRestore");
134
+ text(restore.repositoryPath, "quarantine.gitRestore.repositoryPath");
135
+ optionalText(restore, ["branch", "head"], "quarantine.gitRestore.");
136
+ }
137
+ }
138
+ if (record.cleanupReceipt !== undefined) {
139
+ const receipt = object(record.cleanupReceipt, "cleanupReceipt");
140
+ timestamp(receipt.removedAt, "cleanupReceipt.removedAt");
141
+ member(receipt.method, ["git-worktree-remove", "quarantine-purge", "runtime-cleanup"], "cleanupReceipt.method");
142
+ }
143
+ // Validation enforces the existing persisted contract, without defaults,
144
+ // filtering, or a second historical reader. Constructors own new values.
145
+ const parsed = record;
92
146
  return Object.freeze({
93
- schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
94
- id: record.id,
95
- kind: record.kind,
96
- path: record.path,
97
- owner: Object.freeze({ ...owner }),
98
- ...(record.git === undefined ? {} : { git: record.git }),
99
- ...(typeof record.createdAt === "string" ? { createdAt: record.createdAt } : {}),
100
- ...(typeof record.lastReferencedAt === "string"
101
- ? { lastReferencedAt: record.lastReferencedAt }
102
- : {}),
103
- ...(typeof record.sizeBytes === "number" ? { sizeBytes: record.sizeBytes } : {}),
104
- cleanliness: (record.cleanliness ?? "unknown"),
105
- activeRefs: Array.isArray(record.activeRefs)
106
- ? Object.freeze(record.activeRefs.filter((ref) => typeof ref === "string"))
107
- : Object.freeze([]),
108
- disposition: record.disposition,
109
- ...(typeof record.blocker === "string" ? { blocker: record.blocker } : {}),
110
- ...(record.quarantine === undefined ? {} : { quarantine: record.quarantine }),
111
- ...(record.cleanupReceipt === undefined
112
- ? {}
113
- : { cleanupReceipt: record.cleanupReceipt }),
114
- updatedAt: record.updatedAt
147
+ ...parsed,
148
+ owner: Object.freeze({ ...parsed.owner }),
149
+ activeRefs: Object.freeze([...parsed.activeRefs]),
150
+ ...(parsed.git === undefined ? {} : { git: Object.freeze({ ...parsed.git }) }),
151
+ ...(parsed.quarantine === undefined ? {} : { quarantine: Object.freeze({
152
+ ...parsed.quarantine,
153
+ ...(parsed.quarantine.gitRestore === undefined ? {} : {
154
+ gitRestore: Object.freeze({ ...parsed.quarantine.gitRestore })
155
+ })
156
+ }) }),
157
+ ...(parsed.cleanupReceipt === undefined ? {} : { cleanupReceipt: Object.freeze({ ...parsed.cleanupReceipt }) })
115
158
  });
116
159
  }
160
+ function isRecord(value) {
161
+ return typeof value === "object" && value !== null && !Array.isArray(value);
162
+ }
@@ -42,7 +42,7 @@ export class SqliteResourceRegistry {
42
42
  }
43
43
  }
44
44
  load() {
45
- const rows = this.#db.prepare(`SELECT payload FROM ${SQLITE_RESOURCE_REGISTRY_TABLE}`).all();
45
+ const rows = this.#db.prepare(`SELECT id, payload FROM ${SQLITE_RESOURCE_REGISTRY_TABLE}`).all();
46
46
  if (rows.length === 0)
47
47
  return emptyResourceRegistry();
48
48
  const records = {};
@@ -52,14 +52,12 @@ export class SqliteResourceRegistry {
52
52
  parsed = JSON.parse(row.payload);
53
53
  }
54
54
  catch (error) {
55
- throw new Error(`SQLite resource registry has a corrupt payload: `
55
+ throw new Error(`SQLite resource registry record ${row.id} has a corrupt payload: `
56
56
  + `${error instanceof Error ? error.message : "unknown error"}.`, { cause: error });
57
57
  }
58
58
  const state = parseResourceRegistryState({
59
59
  schemaVersion: RESOURCE_REGISTRY_SCHEMA_VERSION,
60
- records: { [typeof parsed === "object" && parsed !== null && "id" in parsed
61
- ? String(parsed.id)
62
- : ""]: parsed }
60
+ records: { [row.id]: parsed }
63
61
  });
64
62
  Object.assign(records, state.records);
65
63
  }
@@ -5,6 +5,7 @@ import { ACP_CONFIGURATION_ORDER, acpRunConfigurationOptions, confirmAcpConfigur
5
5
  import { handshakeObservationFrom, unknownAgentRunConfiguration } from "./agentRunConfiguration.js";
6
6
  import { JsonLineChannel, terminateProcessGroup } from "./jsonLineChannel.js";
7
7
  import { ProviderDeliveryUnknownError, ProviderTurnRejectedError } from "./providerErrors.js";
8
+ import { providerDeliveryFailureFrom } from "./agentError.js";
8
9
  /**
9
10
  * One Agent Client Protocol Session over an Agent's stdio.
10
11
  *
@@ -86,9 +87,24 @@ export class AcpStructuredProviderSession {
86
87
  this.mirror = mirror;
87
88
  }
88
89
  static async open(input) {
89
- const channel = new JsonLineChannel(input.child, input.mirror);
90
+ let receivingAttemptId;
91
+ const channel = new JsonLineChannel(input.child, input.mirror, error => {
92
+ // Before session/new has answered, the opening caller owns the failure.
93
+ if (!session.#sessionId)
94
+ return;
95
+ input.onDiagnostic?.({
96
+ nativeSessionId: session.#sessionId,
97
+ ...((receivingAttemptId ?? session.#activeAttemptId) === undefined
98
+ ? {} : { attemptId: receivingAttemptId ?? session.#activeAttemptId }),
99
+ failure: providerDeliveryFailureFrom(error, { phase: "turn-reconcile", inputDisposition: "unknown" })
100
+ });
101
+ });
90
102
  const session = new AcpStructuredProviderSession(input.child, input.exit, input.processInstanceId, channel, input.onTerminal, input.mirror);
91
- channel.onMessage((message) => session.#receive(message));
103
+ channel.onMessage((message) => {
104
+ receivingAttemptId = session.#activeAttemptId;
105
+ session.#receive(message);
106
+ receivingAttemptId = undefined;
107
+ });
92
108
  channel.onClose((error) => session.#fail(error));
93
109
  const negotiated = readAcpInitializeResult(await session.#request("initialize", acpInitializeRequest(input.clientVersion)));
94
110
  session.#negotiated = negotiated;
@@ -463,7 +479,7 @@ export class AcpStructuredProviderSession {
463
479
  if (message.id === undefined)
464
480
  this.#notification(method, message.params);
465
481
  else
466
- void this.#serve(method, message);
482
+ void this.#serve(method, message).catch(error => this.#fail(error));
467
483
  }
468
484
  #response(message) {
469
485
  const id = typeof message.id === "number" ? message.id : undefined;
@@ -638,8 +654,8 @@ export class AcpStructuredProviderSession {
638
654
  ...(error === undefined ? { result: result ?? {} } : { error })
639
655
  });
640
656
  }
641
- catch {
642
- // The pipe is gone; the closure path already reports that fact.
657
+ catch (error) {
658
+ this.#fail(error instanceof Error ? error : new Error(String(error)));
643
659
  }
644
660
  }
645
661
  #fail(error) {
@@ -46,6 +46,7 @@ export function createAgentEndpointFactory(start = startStructuredProviderSessio
46
46
  onStarted: (value) => emit({ type: "started", value }),
47
47
  onTerminal: (value) => emit({ type: "terminal", value }),
48
48
  onInput: (value) => emit({ type: "input", value }),
49
+ onDiagnostic: (value) => emit({ type: "diagnostic", value }),
49
50
  onGoal: (value) => emit({ type: "goal", value })
50
51
  });
51
52
  endpoint = new BuiltinAgentEndpoint(opened.session, configuration, opened.recoveredTerminal);
@@ -193,7 +194,9 @@ class BuiltinAgentEndpoint {
193
194
  });
194
195
  }
195
196
  observe(value) {
196
- if (value.type !== "goal"
197
+ if (value.type === "diagnostic" && value.value.nativeSessionId !== this.nativeSessionId)
198
+ return;
199
+ if (value.type !== "goal" && value.type !== "diagnostic"
197
200
  && (value.value.nativeSessionId !== this.nativeSessionId || value.value.conversationId !== this.conversationId))
198
201
  return;
199
202
  if (value.type === "goal" && value.value !== null && value.value.conversationId !== this.conversationId)