@zq-silk/yui 2.1.0 → 2.3.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.
Files changed (135) hide show
  1. package/README.md +8 -2
  2. package/dist/cli/commandCatalog.js +32 -6
  3. package/dist/cli/commandDiscovery.js +4 -1
  4. package/dist/cli/interactionPolicy.js +4 -0
  5. package/dist/cli.js +23 -3078
  6. package/dist/commands/taskCommands.js +84 -28
  7. package/dist/commands/taskIntegrationCommands.js +8 -17
  8. package/dist/commands/taskUpstreamCommands.js +3 -1
  9. package/dist/context/runContextPack.js +132 -5
  10. package/dist/context/sourceRunContext.js +4 -2
  11. package/dist/context/taskContext.js +6 -1
  12. package/dist/controlPlaneCli.js +3099 -0
  13. package/dist/controller/clientRuntime.js +3 -2
  14. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  15. package/dist/controller/jobControl.js +21 -0
  16. package/dist/executor/agentExecutor.js +1 -1
  17. package/dist/executor/effectiveLaunch.js +18 -5
  18. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  19. package/dist/integration/gitIntegrationService.js +58 -82
  20. package/dist/job/jobCleanupInspection.js +67 -0
  21. package/dist/message/messageContinuation.js +7 -2
  22. package/dist/nativeAgent/agent.js +176 -74
  23. package/dist/nativeAgent/cliDemo.js +37 -0
  24. package/dist/nativeAgent/codingTools.js +11 -0
  25. package/dist/nativeAgent/commandTool.js +215 -0
  26. package/dist/nativeAgent/compactionDemo.js +158 -0
  27. package/dist/nativeAgent/composition.js +71 -0
  28. package/dist/nativeAgent/context/budget.js +44 -0
  29. package/dist/nativeAgent/context/index.js +339 -0
  30. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  31. package/dist/nativeAgent/demo.js +12 -1
  32. package/dist/nativeAgent/evaluation/cases.js +38 -0
  33. package/dist/nativeAgent/evaluation/checks.js +91 -0
  34. package/dist/nativeAgent/evaluation/demo.js +19 -0
  35. package/dist/nativeAgent/evaluation/files.js +54 -0
  36. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  37. package/dist/nativeAgent/evaluation/index.js +239 -0
  38. package/dist/nativeAgent/executionOwner.js +209 -0
  39. package/dist/nativeAgent/filePatterns.js +170 -0
  40. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  41. package/dist/nativeAgent/index.js +13 -0
  42. package/dist/nativeAgent/interaction/cli.js +358 -0
  43. package/dist/nativeAgent/interaction/contracts.js +1 -0
  44. package/dist/nativeAgent/interaction/index.js +3 -0
  45. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  46. package/dist/nativeAgent/interaction/renderer.js +34 -0
  47. package/dist/nativeAgent/localSafety.js +258 -0
  48. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  49. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  50. package/dist/nativeAgent/model/errors.js +47 -0
  51. package/dist/nativeAgent/model/gateway.js +423 -0
  52. package/dist/nativeAgent/model/index.js +7 -0
  53. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  54. package/dist/nativeAgent/model/protocols.js +19 -0
  55. package/dist/nativeAgent/model/responses.js +263 -0
  56. package/dist/nativeAgent/model/types.js +1 -0
  57. package/dist/nativeAgent/model/wire.js +73 -0
  58. package/dist/nativeAgent/observability/index.js +220 -0
  59. package/dist/nativeAgent/product/catalog.js +82 -0
  60. package/dist/nativeAgent/product/config.js +295 -0
  61. package/dist/nativeAgent/product/facts.js +30 -0
  62. package/dist/nativeAgent/product/index.js +62 -0
  63. package/dist/nativeAgent/product/location.js +44 -0
  64. package/dist/nativeAgent/product/runtime.js +276 -0
  65. package/dist/nativeAgent/product/storage.js +49 -0
  66. package/dist/nativeAgent/product/tools.js +47 -0
  67. package/dist/nativeAgent/product/transport.js +54 -0
  68. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  69. package/dist/nativeAgent/searchTools.js +305 -0
  70. package/dist/nativeAgent/session/backends.js +293 -0
  71. package/dist/nativeAgent/session/catalog.js +97 -0
  72. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  73. package/dist/nativeAgent/session/contracts.js +1 -0
  74. package/dist/nativeAgent/session/format.js +269 -0
  75. package/dist/nativeAgent/session/index.js +5 -0
  76. package/dist/nativeAgent/session/location.js +36 -0
  77. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  78. package/dist/nativeAgent/session/store.js +248 -0
  79. package/dist/nativeAgent/textTools.js +270 -133
  80. package/dist/nativeAgent/toolManager/executor.js +290 -0
  81. package/dist/nativeAgent/toolManager/index.js +4 -0
  82. package/dist/nativeAgent/validation.js +2 -2
  83. package/dist/repository/gitWorkspace.js +41 -12
  84. package/dist/repository/taskWorkspaceCoordinator.js +72 -145
  85. package/dist/repository/taskWorkspacePreparer.js +39 -43
  86. package/dist/repository/workspaceCleanupInspection.js +88 -7
  87. package/dist/resources/resourceRegistrar.js +16 -10
  88. package/dist/runtime/integrationRuntimeIsolation.js +21 -0
  89. package/dist/runtime/taskRuntimeIsolation.js +91 -21
  90. package/dist/task/archiveDiagnostics.js +26 -8
  91. package/dist/task/archivePreflight.js +26 -9
  92. package/dist/task/leaderArchive.js +9 -15
  93. package/dist/task/taskAuthority.js +56 -0
  94. package/dist/web/assets/client/api.js +8 -0
  95. package/dist/web/assets/client/app.js +74 -9
  96. package/dist/web/assets/client/components.js +95 -36
  97. package/dist/web/assets/client/detail.js +110 -64
  98. package/dist/web/assets/client/dock.js +95 -11
  99. package/dist/web/assets/client/evidence.js +286 -94
  100. package/dist/web/assets/client/i18n.js +182 -62
  101. package/dist/web/assets/client/overview.js +9 -10
  102. package/dist/web/assets/client/records.js +142 -7
  103. package/dist/web/assets/client/sections.js +234 -198
  104. package/dist/web/assets/client/sidebar.js +31 -2
  105. package/dist/web/assets/icons.js +4 -0
  106. package/dist/web/assets/shell.js +2 -1
  107. package/dist/web/assets/styles/components.js +64 -15
  108. package/dist/web/assets/styles/layout.js +11 -4
  109. package/dist/web/assets/styles/responsive.js +16 -6
  110. package/dist/web/assets/styles/tokens.js +1 -1
  111. package/dist/web/assets/styles/views.js +62 -42
  112. package/dist/web/webServer.js +12 -1
  113. package/dist/web/webTaskSurface.js +8 -1
  114. package/docs/agent-result-consumption.md +16 -0
  115. package/docs/agent-result-consumption.zh-CN.md +13 -0
  116. package/docs/examples/agent-offline.mjs +194 -0
  117. package/docs/native-agent.md +283 -0
  118. package/docs/observability/README.md +13 -3
  119. package/docs/observability/README.zh-CN.md +10 -3
  120. package/docs/release-workflow.md +72 -9
  121. package/docs/release-workflow.zh-CN.md +55 -6
  122. package/docs/roles-and-configuration.md +32 -0
  123. package/docs/roles-and-configuration.zh-CN.md +26 -0
  124. package/docs/task-delivery.md +36 -8
  125. package/docs/task-delivery.zh-CN.md +24 -7
  126. package/i18n/README.zh-CN.md +3 -2
  127. package/package.json +1 -1
  128. package/skills/yui-leader/SKILL.md +9 -0
  129. package/skills/yui-leader/references/execution.md +2 -1
  130. package/skills/yui-leader/references/integration.md +20 -0
  131. package/skills/yui-operator/SKILL.md +4 -0
  132. package/skills/yui-reviewer/SKILL.md +8 -0
  133. package/skills/yui-runtime/SKILL.md +11 -0
  134. package/skills/yui-runtime/references/publication.md +20 -4
  135. package/skills/yui-worker/SKILL.md +3 -0
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { jobCleanupBlocker } from "../job/jobCleanupInspection.js";
2
3
  import { describeCliFailure } from "../errors/cliFailure.js";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "../core/controllerProcessIdentity.js";
@@ -525,8 +526,8 @@ export class FileTaskWorkflowRuntime {
525
526
  * live, preserving the owner records for Operator recovery.
526
527
  */
527
528
  async assertTaskPhysicalResourcesReleased(taskId) {
528
- const activeJobs = this.store.listActiveDurableJobs()
529
- .filter((job) => job.taskId === taskId);
529
+ const activeJobs = this.store.listDurableJobs(taskId)
530
+ .filter((job) => jobCleanupBlocker(job, this.home) !== undefined);
530
531
  if (activeJobs.length > 0) {
531
532
  throw new WorkspaceCleanupBlockedError("physical-resource-live", `task:${taskId}`, true, `Task physical resources are not released: ${activeJobs.length} DurableJob(s) are still active: `
532
533
  + activeJobs.map(({ id }) => id).join(", "));
@@ -267,7 +267,8 @@ export class FileSchedulerStoreAdapter {
267
267
  default:
268
268
  outcome = "obsolete";
269
269
  }
270
- if (outcome === "applied" && !isRunTerminalObservation(input)) {
270
+ if (outcome === "applied" && !isRunTerminalObservation(input)
271
+ && input.kind !== "goal.updated" && input.kind !== "goal.cleared") {
271
272
  this.persistRuntimeObservation(input, now);
272
273
  }
273
274
  return outcome;
@@ -700,7 +701,7 @@ export class FileSchedulerStoreAdapter {
700
701
  return "applied";
701
702
  });
702
703
  }
703
- persistRuntimeObservation(input, now) {
704
+ persistRuntimeObservation(input, now, notifyGoal = true) {
704
705
  this.store.transaction((store) => {
705
706
  const taskId = input.fence.taskId;
706
707
  const events = store.listEvents(taskId);
@@ -762,8 +763,7 @@ export class FileSchedulerStoreAdapter {
762
763
  ? "provider-continuation-report"
763
764
  : "provider-continuation-settled", now);
764
765
  }
765
- if ((input.kind === "goal.cleared"
766
- || (input.kind === "goal.updated" && input.payload.goalStatus !== "active"))
766
+ if (notifyGoal && (input.kind === "goal.cleared" || input.kind === "goal.updated")
767
767
  && input.fence.roleName !== "leader") {
768
768
  routeRoleEvent(store, observationEvent, input.fence.roleName, input.kind === "goal.cleared"
769
769
  ? "provider-goal-cleared"
@@ -2363,6 +2363,8 @@ export class FileSchedulerStoreAdapter {
2363
2363
  observeProviderGoal(input, now) {
2364
2364
  return this.store.transaction((store) => {
2365
2365
  const taskId = input.fence.taskId;
2366
+ if (hasPersistedRuntimeObservation(store.listEvents(taskId), input))
2367
+ return "applied";
2366
2368
  const sessions = store.getTaskRoleSessionSet(taskId, input.fence.roleName);
2367
2369
  const binding = sessions?.providerBinding;
2368
2370
  if (sessions === null || sessions === undefined || binding === null || binding === undefined
@@ -2383,7 +2385,16 @@ export class FileSchedulerStoreAdapter {
2383
2385
  ? {}
2384
2386
  : { tokenBudget: input.payload.goalTokenBudget })
2385
2387
  });
2388
+ // Startup/re-attachment observes current state, not necessarily a change.
2389
+ // Compare under the same transaction that persists and routes the fact.
2390
+ if (isDeepStrictEqual(binding.goal, updated.goal)) {
2391
+ // Retain the existing dedup evidence: an old empty observation replayed
2392
+ // after a later real update must not clear that newer state.
2393
+ this.persistRuntimeObservation(input, now, false);
2394
+ return "applied";
2395
+ }
2386
2396
  store.saveTaskRoleSessionSet(updateTaskRoleProviderRuntime(sessions, updated, now));
2397
+ this.persistRuntimeObservation(input, now);
2387
2398
  return "applied";
2388
2399
  });
2389
2400
  }
@@ -3127,14 +3138,16 @@ function mapRole(store, role) {
3127
3138
  ? resolveEffectiveLaunch({
3128
3139
  role,
3129
3140
  purpose,
3130
- ...(workspace === undefined ? {} : { workspace })
3141
+ ...(workspace === undefined ? {} : { workspace }),
3142
+ ...(item === null ? {} : { workItemWriteProjectIds: item.writeProjectIds })
3131
3143
  })
3132
3144
  : liveSession !== null && workspace?.owner.type === "task"
3133
3145
  ? effectiveLaunchWithTaskMainWorkspace(liveSession.effective, workspace)
3134
3146
  : liveSession?.effective ?? resolveEffectiveLaunch({
3135
3147
  role,
3136
3148
  purpose,
3137
- ...(workspace === undefined ? {} : { workspace })
3149
+ ...(workspace === undefined ? {} : { workspace }),
3150
+ ...(item === null ? {} : { workItemWriteProjectIds: item.writeProjectIds })
3138
3151
  });
3139
3152
  return {
3140
3153
  taskId: role.taskId,
@@ -19,6 +19,7 @@ import { requireManagedTaskCaller, ManagedRuntimeDriftError } from "../runtime/m
19
19
  import { CliError } from "../errors/cliError.js";
20
20
  import { assertJobAssignmentScope, JobAssignmentScopeError } from "../job/jobAssignmentScope.js";
21
21
  import { assertContextRecordReadable } from "../context/taskContext.js";
22
+ import { assertTaskProjectWriteAuthority } from "../task/taskAuthority.js";
22
23
  export function createDurableJobControl(store) {
23
24
  const authority = createJobCallAuthority(store);
24
25
  return {
@@ -167,8 +168,27 @@ export function authorizeJobStart(store, job) {
167
168
  throw jobDomainError("Job caller binding was revoked; no execution was started.");
168
169
  }
169
170
  requireJobAssignment(store, job, { scope, role });
171
+ assertLeaderJobWriteScope(store, job, { scope, role });
170
172
  validateJobTarget(store, job);
171
173
  }
174
+ function assertLeaderJobWriteScope(store, target, caller) {
175
+ if (caller.scope !== "task" || caller.role !== "leader")
176
+ return;
177
+ if (target.owner.kind !== "task" && target.owner.kind !== "integration-attempt") {
178
+ throw jobControlError("UNAUTHORIZED", "Direct Leader delivery does not grant another Assignment or Review workspace. Use the owning executor for its Job.");
179
+ }
180
+ const { taskId, projectId } = target;
181
+ const session = activeLiveRoleAgentSession(store.getTaskRoleSessionSet(taskId, caller.role));
182
+ try {
183
+ assertTaskProjectWriteAuthority(store, {
184
+ YUI_SESSION_SCOPE: "task", YUI_TASK_ID: taskId, YUI_ROLE: caller.role,
185
+ YUI_NATIVE_SESSION_ID: caller.nativeSessionId ?? session?.nativeSessionId
186
+ }, taskId, projectId);
187
+ }
188
+ catch (error) {
189
+ throw jobControlError("UNAUTHORIZED", error instanceof Error ? error.message : String(error));
190
+ }
191
+ }
172
192
  function requireJobAssignment(store, target, caller) {
173
193
  try {
174
194
  assertJobAssignmentScope(store, target, caller);
@@ -244,6 +264,7 @@ function assertNonSecretJobInput(params) {
244
264
  function validateStartParams(store, params) {
245
265
  validateJobTarget(store, params);
246
266
  assertCallerAuthorized(store, params.caller, params.taskId);
267
+ assertLeaderJobWriteScope(store, params, params.caller);
247
268
  if (params.caller.scope === "task") {
248
269
  const session = store.getTaskRoleSessionSet(params.taskId, params.caller.role);
249
270
  if (session?.sessions[session.activeAgentId]?.effective.executionAuthority !== "delivery") {
@@ -317,7 +317,7 @@ export function roleAgentSessionResumeMode(set, agentId, desired) {
317
317
  if (roleSessionMayContinue(session.effective, desired))
318
318
  return "resume";
319
319
  throw new Error(`Role Agent session cannot continue under the next launch: ${agentId}. `
320
- + "Its Agent, adapter or physical workspace changed. Explicitly select a new Session "
320
+ + "Its Agent, adapter, physical workspace or captured authority changed. Explicitly select a new Session "
321
321
  + "after resolving existing execution and resource ownership.");
322
322
  }
323
323
  export function bindTaskRoleProviderRuntime(set, binding, updatedAt) {
@@ -117,8 +117,8 @@ export function sameEffectiveLaunch(existing, desired) {
117
117
  *
118
118
  * Only facts that make continuation impossible participate: the Session
119
119
  * protocol, the provider identity that owns the conversation, and the physical
120
- * workspace the Session runs in. Launch configuration such as model, effort,
121
- * permission, Role context, declared write scope, and AgentRun-scoped facts like
120
+ * workspace and captured authority the Session runs with. Launch configuration
121
+ * such as model, effort, provider permission, Role context, and AgentRun-scoped facts like
122
122
  * ReviewRound identity or candidate commits shape the next Host process
123
123
  * instead of ending the Session; that divergence is acknowledged where the
124
124
  * configuration changes and stays visible as launch provenance.
@@ -151,6 +151,8 @@ function sessionContinuitySnapshot(snapshot) {
151
151
  return {
152
152
  schemaVersion: snapshot.schemaVersion,
153
153
  executionAuthority: snapshot.executionAuthority,
154
+ profileAccess: snapshot.profileAccess,
155
+ writeProjectIds: snapshot.writeProjectIds,
154
156
  contextProtocolVersion: snapshot.contextProtocolVersion,
155
157
  agentId: snapshot.agentId,
156
158
  // Two products on one connection plan are two different conversations. A
@@ -329,6 +331,8 @@ function effectiveLaunchConfigUnchecked(snapshot) {
329
331
  : claudeConfigFromSnapshot(snapshot);
330
332
  }
331
333
  function effectiveWriteProjects(input, workspace) {
334
+ if (input.purpose === "planning" || input.executionAuthority === "planning")
335
+ return [];
332
336
  if (input.purpose === "review") {
333
337
  if (!("taskId" in input.role)) {
334
338
  throw new Error("Review launch requires a Task Role.");
@@ -358,9 +362,18 @@ function effectiveWriteProjects(input, workspace) {
358
362
  .map(({ projectId }) => projectId), "Workspace writable Project");
359
363
  if (!("taskId" in input.role))
360
364
  return [];
361
- // A managed workspace describes what exists, not who is authorized to
362
- // mutate it. Only an explicit WorkItem write scope can grant Task writes.
363
- if (input.workItemWriteProjectIds === undefined)
365
+ // Direct delivery belongs to the Leader in this Task's adopted main
366
+ // workspace. Assignment scope (including explicit []) still takes priority.
367
+ // Never infer authority from a path, a different owner's workspace or a
368
+ // desired Profile change to an already-frozen Session.
369
+ if (input.workItemWriteProjectIds === undefined) {
370
+ return input.role.name === "leader"
371
+ && input.role.defaultAccess === "write"
372
+ && input.workspace?.owner.type === "task"
373
+ && input.workspace.owner.taskId === input.role.taskId
374
+ ? workspaceWrite : [];
375
+ }
376
+ if (input.role.defaultAccess !== "write")
364
377
  return [];
365
378
  const requested = uniqueIdentities(input.workItemWriteProjectIds, "WorkItem writable Project");
366
379
  if (requested.length === 0)
@@ -216,7 +216,7 @@ export class FileRoleLaunchPlanner {
216
216
  // attempt was rejected, cancelled, or completed.
217
217
  assertProviderConversationReplaceable(sessionSet.providerBinding);
218
218
  }
219
- return this.#compile(role, input, { scope: "task", taskId: task.id }, resolveTaskRoleSessionTitle(input.mode === "resume" ? existing?.title : undefined, task, role.name), input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, runWorkspace, effective, {
219
+ return this.#compile(role, input, { scope: "task", taskId: task.id }, resolveTaskRoleSessionTitle(input.mode === "resume" ? existing?.title : undefined, task, role.name), input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, effective, {
220
220
  purpose
221
221
  });
222
222
  }
@@ -246,9 +246,9 @@ export class FileRoleLaunchPlanner {
246
246
  }
247
247
  return this.#compile(role, input, { scope: "global" }, input.mode === "new" && role.name === "operator"
248
248
  ? operatorSessionTitle(this.#now(), this.store.getConfig().timeZone)
249
- : undefined, input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, undefined, effective, { purpose: "execution" });
249
+ : undefined, input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, effective, { purpose: "execution" });
250
250
  }
251
- #compile(role, input, owner, sessionTitle, knownNativeSessionId, workspaceOverride, effective, sessionPolicy) {
251
+ #compile(role, input, owner, sessionTitle, knownNativeSessionId, effective, sessionPolicy) {
252
252
  const launchRole = effectiveRoleForLaunch(role, effective);
253
253
  const binding = activeRoleAgentBinding(launchRole);
254
254
  if (binding.agentId !== input.agentId || binding.adapterId !== input.adapterId) {
@@ -580,7 +580,7 @@ export class FileRoleLaunchPlanner {
580
580
  childLifecycle: driver.capabilities.lifecycle.providerProcess
581
581
  };
582
582
  const scopedLaunch = owner.scope === "task"
583
- ? this.#applyWorkspaceScope(owner.taskId, role, launch, workspaceOverride)
583
+ ? this.#applyWorkspaceScope(launch, effective)
584
584
  : launch;
585
585
  const ordinaryConversationLaunch = withCodexThreadEnvironment(scopedLaunch);
586
586
  return {
@@ -640,20 +640,11 @@ export class FileRoleLaunchPlanner {
640
640
  }
641
641
  return { attemptId: run.attemptId, turnId: run.nativeTurnId };
642
642
  }
643
- #applyWorkspaceScope(taskId, role, launch, workspaceOverride) {
644
- const workspace = workspaceOverride
645
- ?? (role.name === "leader"
646
- ? this.store.getTaskWorkspace(taskId)
647
- : this.store.listWorkItems(taskId)
648
- .find((item) => item.assignee === role.name && item.status === "open") === undefined
649
- ? this.store.getTaskWorkspace(taskId)
650
- : this.store.getWorkItemWorkspace(taskId, this.store.listWorkItems(taskId).find((item) => item.assignee === role.name && item.status === "open").id));
651
- if (workspace === null || workspace === undefined)
652
- return launch;
643
+ #applyWorkspaceScope(launch, effective) {
653
644
  return {
654
645
  ...launch,
655
646
  env: {
656
- ...workspaceScopeEnvironment(launch.env, workspace)
647
+ ...workspaceScopeEnvironment(launch.env, effective)
657
648
  }
658
649
  };
659
650
  }
@@ -711,16 +702,21 @@ function resolveTaskRoleEffectiveLaunch(store, role, purpose = "execution") {
711
702
  ...(item === null ? {} : { workItemWriteProjectIds: item.writeProjectIds })
712
703
  });
713
704
  }
714
- function workspaceScopeEnvironment(environment, workspace) {
705
+ function workspaceScopeEnvironment(environment, effective) {
706
+ const entries = effective.workspace.entries.map(entry => ({
707
+ ...entry,
708
+ access: entry.access === "write" && effective.writeProjectIds.includes(entry.projectId)
709
+ ? "write" : "read"
710
+ }));
715
711
  return {
716
712
  ...environment,
717
- YUI_WRITABLE_PROJECT_IDS: JSON.stringify(workspace.entries
713
+ YUI_WRITABLE_PROJECT_IDS: JSON.stringify(entries
718
714
  .filter(({ access }) => access === "write")
719
715
  .map(({ projectId }) => projectId)),
720
- YUI_CONTEXT_PROJECT_IDS: JSON.stringify(workspace.entries
716
+ YUI_CONTEXT_PROJECT_IDS: JSON.stringify(entries
721
717
  .filter(({ access }) => access === "read")
722
718
  .map(({ projectId }) => projectId)),
723
- YUI_WORKSPACE_PROJECTS: JSON.stringify(Object.fromEntries(workspace.entries.map(({ projectId, directory, access, path }) => [
719
+ YUI_WORKSPACE_PROJECTS: JSON.stringify(Object.fromEntries(entries.map(({ projectId, directory, access, path }) => [
724
720
  projectId,
725
721
  { directory, access, path }
726
722
  ])))
@@ -1,20 +1,19 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { lstat, mkdir, open, rm, stat } from "node:fs/promises";
2
+ import { lstat, mkdir, open, realpath, rm, stat } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { selectEnvironment } from "../agent/launchEnvironment.js";
6
- import { controllerSocketPath } from "../core/controllerEndpoint.js";
7
6
  import { durableJobIdempotencyKey } from "../job/durableJob.js";
8
7
  import { readRuntimeIdentity } from "../release/runtimeRelease.js";
9
8
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
10
9
  import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
11
10
  import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
12
- import { integrationWorkspaceRoot } from "../repository/taskWorkspacePreparer.js";
11
+ import { FileTaskWorkspacePreparer, integrationWorkspaceRoot } from "../repository/taskWorkspacePreparer.js";
13
12
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
14
- import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
15
- import { integrationTmuxSocketRoot, managedIntegrationRuntimeRoot } from "../storage/homeLayout.js";
13
+ import { integrationTmuxSocketRoot } from "../storage/homeLayout.js";
14
+ import { defaultIntegrationRuntimeIsolation } from "../runtime/integrationRuntimeIsolation.js";
16
15
  import { advanceTaskProjectCommit } from "../task/task.js";
17
- import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
16
+ import { taskIntegrationSettlementCheckout, taskIntegrationTargetCheckout } from "../task/taskAuthority.js";
18
17
  import { recordGateArtifactReuse } from "../verification/gateArtifact.js";
19
18
  import { touchGateArtifact } from "../verification/gateArtifactStore.js";
20
19
  import { beginGateVerification, checkResultsFromGateArtifact, checkResultsFromGateJob, gateIdentityForCandidate, lookupReusableGateArtifact, recordGateArtifactFromJob, recordGateArtifactFromStepOutcomes, resolveVerificationGate, runGateStepsInProcess } from "../verification/verificationGateService.js";
@@ -75,8 +74,12 @@ export class GitIntegrationService {
75
74
  #resourceRegistrar() {
76
75
  return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home, this.now);
77
76
  }
77
+ #targetCheckout(attempt) {
78
+ return taskIntegrationTargetCheckout(this.store, this.environment, attempt.taskId, attempt.projectId, attempt.targetRef);
79
+ }
78
80
  async integrate(taskId, integrationId, signal) {
79
81
  let initial = requireIntegration(this.store, taskId, integrationId);
82
+ this.#targetCheckout(initial);
80
83
  if (!["running", "conflicted", "blocked", "validating"].includes(initial.status)) {
81
84
  throw new Error(`Integration cannot continue from ${initial.status}.`);
82
85
  }
@@ -95,6 +98,7 @@ export class GitIntegrationService {
95
98
  throw new Error("Integration changed before execution admission; read its current record.");
96
99
  }
97
100
  initial = currentUnderFence;
101
+ this.#targetCheckout(initial);
98
102
  const task = this.store.getTask(initial.taskId);
99
103
  if (task === null || !task.projectBindings.some(({ projectId }) => projectId === initial.projectId)) {
100
104
  throw new Error(`Integration Task Project is unavailable: ${initial.taskId}.`);
@@ -116,6 +120,7 @@ export class GitIntegrationService {
116
120
  || taskRepository === undefined) {
117
121
  throw new Error(`Integration Task clone is unavailable: ${task.id}/${project.id}.`);
118
122
  }
123
+ await assertTargetCheckout(taskRepository, initial.targetRef, this.#targetCheckout(initial));
119
124
  // Issue 08: a Project with a VerificationPlan gates through its plan
120
125
  // (bootstrap + L2) and reuses exact-SHA artifacts; an unconfigured
121
126
  // Project keeps the existing explicit check path unchanged.
@@ -193,7 +198,7 @@ export class GitIntegrationService {
193
198
  // moved or its worktree is dirty, so the check commands never run on a
194
199
  // target that cannot be advanced. advanceTargetRef re-verifies both
195
200
  // after the checks, so a move during the gate is still fenced at CAS.
196
- await assertTargetReadyForChecks(taskRepository, current.targetRef, current.beforeCommit);
201
+ await assertTargetReadyForChecks(taskRepository, current.targetRef, current.beforeCommit, this.#targetCheckout(current));
197
202
  if (gate !== undefined) {
198
203
  return await this.#runVerificationGate(current, workspace, prepared.path, managedWorkspace, taskRepository, gate);
199
204
  }
@@ -221,7 +226,7 @@ export class GitIntegrationService {
221
226
  checks: checkResults
222
227
  }, this.now());
223
228
  this.store.saveIntegrationAttempt(task.id, current);
224
- await advanceTargetRef(taskRepository, current.targetRef, candidateCommit, current.beforeCommit);
229
+ await advanceTargetRef(taskRepository, current.targetRef, candidateCommit, current.beforeCommit, this.#targetCheckout(current));
225
230
  const committed = this.#recordCommitted(current);
226
231
  return this.#terminalResult("committed", committed, workspace);
227
232
  }
@@ -234,7 +239,7 @@ export class GitIntegrationService {
234
239
  if (target === current.candidateCommit) {
235
240
  try {
236
241
  await assertIntegrationCandidate(workspace.path, current.candidateCommit, workspace.branch);
237
- await assertTargetReadyForChecks(taskRepository, current.targetRef, current.candidateCommit);
242
+ await assertTargetReadyForChecks(taskRepository, current.targetRef, current.candidateCommit, this.#targetCheckout(current));
238
243
  const committed = this.#recordCommitted(current);
239
244
  return this.#terminalResult("committed", committed, workspace);
240
245
  }
@@ -307,8 +312,21 @@ export class GitIntegrationService {
307
312
  }
308
313
  const target = await resolveRef(mainEntry.path, attempt.targetRef);
309
314
  if (target === attempt.candidateCommit && target !== attempt.beforeCommit) {
310
- await assertTargetReadyForChecks(mainEntry.path, attempt.targetRef, target);
311
- return this.#recordCommitted(attempt, authorize);
315
+ if (attempt.checks.some(check => check.outcome === "failed")
316
+ || attempt.jobId !== undefined
317
+ && jobs.find(job => job.id === attempt.jobId).status !== "succeeded") {
318
+ throw new Error("Delivered Integration lacks successful candidate check evidence; inspect before settlement.");
319
+ }
320
+ await assertIntegrationCandidate(workspace.root, attempt.candidateCommit, workspaceEntry.branch);
321
+ assertRecordedSourceCandidate(attempt, { path: workspace.root, branch: workspaceEntry.branch });
322
+ await assertTargetReadyForChecks(mainEntry.path, attempt.targetRef, target, taskIntegrationSettlementCheckout(this.store, this.environment, taskId, attempt.projectId, attempt.targetRef));
323
+ return this.#recordCommitted(attempt, (tx, id) => {
324
+ authorize(tx, id);
325
+ taskIntegrationSettlementCheckout(tx, this.environment, id, attempt.projectId, attempt.targetRef);
326
+ if (JSON.stringify(requireIntegration(tx, id, integrationId)) !== JSON.stringify(attempt)) {
327
+ throw new Error("Integration changed before delivered settlement; inspect its current record.");
328
+ }
329
+ });
312
330
  }
313
331
  if (target !== attempt.beforeCommit) {
314
332
  throw new Error(`Target moved to ${target}; cannot prove this Integration is unadvanced. Inspect before settlement.`);
@@ -350,12 +368,9 @@ export class GitIntegrationService {
350
368
  throw new Error("Integration changed before cleanup; read its current record.");
351
369
  }
352
370
  integration = current;
353
- const activeJob = this.store.listDurableJobs(integration.taskId).find(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === integration.id
354
- && (job.status === "queued" || job.status === "running"
355
- || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
356
- if (activeJob !== undefined) {
357
- throw new Error(`Integration ${integration.id} has an active DurableJob: ${activeJob.id}/${activeJob.status}.`);
358
- }
371
+ const managedWorkspace = this.store.getIntegrationWorkspace(integration.taskId, integration.id);
372
+ if (managedWorkspace === null)
373
+ return "missing";
359
374
  const task = this.store.getTask(integration.taskId);
360
375
  if (task === null || !task.projectBindings.some(({ projectId }) => projectId === integration.projectId)) {
361
376
  throw new Error(`Integration Task Project is unavailable: ${integration.id}.`);
@@ -367,42 +382,8 @@ export class GitIntegrationService {
367
382
  if (taskRepository === undefined) {
368
383
  throw new Error(`Integration Task clone is unavailable: ${task.id}/${project.id}.`);
369
384
  }
370
- const managedWorkspace = this.store.getIntegrationWorkspace(integration.taskId, integration.id);
371
- if (managedWorkspace !== null) {
372
- const runtime = this.#runtimePreparation(integration, managedWorkspace);
373
- this.runtimeIsolation.cleanup(runtime, integration.status === "committed" ? "completion" : "failure");
374
- }
375
- const recorded = managedWorkspace?.entries[0];
376
- const directory = recorded?.directory
377
- ?? task.projectBindings.find(({ projectId }) => projectId === integration.projectId)?.directory;
378
- if (directory === undefined) {
379
- throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
380
- }
381
- const result = await this.git.removeIntegrationWorktree({
382
- repositoryPath: taskRepository,
383
- container: recorded !== undefined
384
- ? dirname(recorded.path)
385
- : integrationWorkspaceRoot(this.home, task.id, integration.id),
386
- directory,
387
- taskSegment: taskWorkspaceRefSegment(task),
388
- integrationId: integration.id,
389
- discardChanges: integration.status === "failed"
390
- });
391
- if (result !== "dirty") {
392
- if (managedWorkspace !== null) {
393
- this.#resourceRegistrar().markWorkspaceDeleted(managedWorkspace);
394
- }
395
- await rm(integrationCheckDirectory(this.home, task.id, integration.id), {
396
- recursive: true,
397
- force: true
398
- });
399
- this.store.removeManagedWorkspace({
400
- type: "integration-attempt",
401
- taskId: integration.taskId,
402
- integrationAttemptId: integration.id
403
- });
404
- }
405
- return result;
385
+ return await new FileTaskWorkspacePreparer(this.home, this.store, this.git, this.now)
386
+ .cleanupIntegrationWorkspace(task.id, integration.id, { runtimeIsolation: this.runtimeIsolation });
406
387
  }
407
388
  finally {
408
389
  release();
@@ -419,7 +400,7 @@ export class GitIntegrationService {
419
400
  throw new Error(`Integration check admission requires a running, proven candidate: ${attempt.id}.`);
420
401
  }
421
402
  await assertIntegrationCandidate(path, attempt.candidateCommit, workspace.branch);
422
- await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
403
+ await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit, this.#targetCheckout(attempt));
423
404
  const { runtime, head, steps, environment, inputDigest } = await this.#checkSpecification(attempt, managedWorkspace, gate);
424
405
  if (attempt.checkInputDigest !== undefined && attempt.checkInputDigest !== inputDigest) {
425
406
  throw new Error("Integration check conditions changed since admission; inspect the original Job, then abort or start a new attempt.");
@@ -479,7 +460,7 @@ export class GitIntegrationService {
479
460
  const job = await this.jobPort.getJob(attempt.taskId, attempt.jobId);
480
461
  assertCheckJobIdentity(attempt, job, path);
481
462
  await assertIntegrationCandidate(path, job.head, workspace.branch);
482
- await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
463
+ await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit, this.#targetCheckout(attempt));
483
464
  if (gate?.planDigest !== attempt.gatePlanDigest
484
465
  || gate?.toolchainDigest !== attempt.gateToolchainDigest) {
485
466
  throw new Error("Integration verification plan/toolchain changed; the original Job cannot prove the current gate.");
@@ -642,7 +623,7 @@ export class GitIntegrationService {
642
623
  checks
643
624
  }, this.now());
644
625
  this.store.saveIntegrationAttempt(attempt.taskId, validating);
645
- await advanceTargetRef(repositoryPath, validating.targetRef, candidateCommit, validating.beforeCommit);
626
+ await advanceTargetRef(repositoryPath, validating.targetRef, candidateCommit, validating.beforeCommit, this.#targetCheckout(validating));
646
627
  const committed = this.#recordCommitted(validating);
647
628
  return this.#terminalResult("committed", committed, workspace);
648
629
  }
@@ -709,9 +690,9 @@ export class GitIntegrationService {
709
690
  }
710
691
  assertCheckJobIdentity(attempt, job, workspace.path);
711
692
  }
712
- await advanceTargetRef(repositoryPath, attempt.targetRef, attempt.candidateCommit, attempt.beforeCommit);
693
+ await advanceTargetRef(repositoryPath, attempt.targetRef, attempt.candidateCommit, attempt.beforeCommit, this.#targetCheckout(attempt));
713
694
  }
714
- await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.candidateCommit);
695
+ await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.candidateCommit, this.#targetCheckout(attempt));
715
696
  const committed = this.#recordCommitted(attempt);
716
697
  return this.#terminalResult("committed", committed, workspace);
717
698
  }
@@ -1004,26 +985,6 @@ function isNodeCode(error, code) {
1004
985
  && "code" in error
1005
986
  && error.code === code;
1006
987
  }
1007
- function defaultIntegrationRuntimeIsolation(home, homeId) {
1008
- const controlHome = resolve(home);
1009
- const runtimeRoot = managedIntegrationRuntimeRoot(controlHome);
1010
- return new FileTaskRuntimeIsolation({
1011
- // The integration check's provider data/cache/tmp live in a dedicated Home
1012
- // partition — the same isolation contract every Task runtime obeys — not in
1013
- // a system-wide `/tmp` root. Only the tmux socket ENDPOINT stays a short
1014
- // `/tmp` path (see `integrationCheckEnvironment`) for the `sockaddr_un`
1015
- // budget; ordinary runtime state is now Yui-managed under Home.
1016
- runtimeRoot,
1017
- pathLayout: "compact",
1018
- controlPlane: {
1019
- yuiHome: controlHome,
1020
- managedRuntimeRoot: runtimeRoot,
1021
- controllerSocketPath: controllerSocketPath(homeId),
1022
- tmuxNamespace: yuiTmuxServerName(controlHome),
1023
- globalInstallPaths: [process.execPath]
1024
- }
1025
- });
1026
- }
1027
988
  /**
1028
989
  * Reuse is enabled only for an immutable installed release. Development
1029
990
  * checkouts have no content-addressed release identity, so they return.
@@ -1138,12 +1099,12 @@ async function resolveRef(repositoryPath, ref) {
1138
1099
  * {@link advanceTargetRef} re-verifies both before the CAS, so a target that
1139
1100
  * moves during the gate is still fenced.
1140
1101
  */
1141
- async function assertTargetReadyForChecks(repositoryPath, targetRef, expectedHead) {
1102
+ async function assertTargetReadyForChecks(repositoryPath, targetRef, expectedHead, expectedCheckout) {
1142
1103
  const current = await resolveRef(repositoryPath, targetRef);
1143
1104
  if (current !== expectedHead) {
1144
1105
  throw new Error(`Target moved to ${current}; expected ${expectedHead}.`);
1145
1106
  }
1146
- const checkedOutPaths = await checkedOutWorktreePaths(repositoryPath, fullTargetRef(targetRef));
1107
+ const checkedOutPaths = await assertTargetCheckout(repositoryPath, targetRef, expectedCheckout);
1147
1108
  if (checkedOutPaths.length > 1) {
1148
1109
  throw new Error(`Integration target is checked out in multiple worktrees: ${targetRef}.`);
1149
1110
  }
@@ -1156,9 +1117,18 @@ async function assertTargetReadyForChecks(repositoryPath, targetRef, expectedHea
1156
1117
  }
1157
1118
  }
1158
1119
  }
1159
- async function advanceTargetRef(repositoryPath, targetRef, candidateCommit, expectedHead) {
1120
+ async function assertTargetCheckout(repositoryPath, targetRef, expectedCheckout) {
1121
+ const paths = await checkedOutWorktreePaths(repositoryPath, fullTargetRef(targetRef));
1122
+ if (expectedCheckout !== undefined && (await realpath(repositoryPath) !== await realpath(expectedCheckout)
1123
+ || paths.length !== 1 || await realpath(paths[0]) !== await realpath(expectedCheckout)
1124
+ || (await git(["-C", expectedCheckout, "symbolic-ref", "HEAD"])).trim() !== fullTargetRef(targetRef))) {
1125
+ throw new Error("Managed Leader Integration target must be physically checked out in its captured Task-main workspace.");
1126
+ }
1127
+ return paths;
1128
+ }
1129
+ async function advanceTargetRef(repositoryPath, targetRef, candidateCommit, expectedHead, expectedCheckout) {
1160
1130
  const ref = fullTargetRef(targetRef);
1161
- const checkedOutPaths = await checkedOutWorktreePaths(repositoryPath, ref);
1131
+ const checkedOutPaths = await assertTargetCheckout(repositoryPath, targetRef, expectedCheckout);
1162
1132
  if (checkedOutPaths.length === 0) {
1163
1133
  await git([
1164
1134
  "-C", repositoryPath, "update-ref",
@@ -1182,6 +1152,9 @@ async function advanceTargetRef(repositoryPath, targetRef, candidateCommit, expe
1182
1152
  if (current !== expectedHead) {
1183
1153
  throw new Error(`Target moved to ${current}; expected ${expectedHead}.`);
1184
1154
  }
1155
+ if (expectedCheckout !== undefined) {
1156
+ await assertTargetCheckout(repositoryPath, targetRef, expectedCheckout);
1157
+ }
1185
1158
  // The candidate may be a rebased upstream result and therefore need not be
1186
1159
  // a descendant of the old Task head. Advance the branch with a real
1187
1160
  // compare-and-swap, then update only this checkout's index and worktree.
@@ -1193,6 +1166,9 @@ async function advanceTargetRef(repositoryPath, targetRef, candidateCommit, expe
1193
1166
  candidateCommit,
1194
1167
  expectedHead
1195
1168
  ]);
1169
+ if (expectedCheckout !== undefined) {
1170
+ await assertTargetCheckout(repositoryPath, targetRef, expectedCheckout);
1171
+ }
1196
1172
  await git(["-C", checkout, "read-tree", "--reset", "-u", candidateCommit]);
1197
1173
  const advanced = await resolveRef(repositoryPath, targetRef);
1198
1174
  if (advanced !== candidateCommit) {
@@ -0,0 +1,67 @@
1
+ import { isAbsolute, join, resolve } from "node:path";
2
+ import { processGenerationIsLive } from "../core/fileLockOwner.js";
3
+ import { scanProcessPathRefs } from "../resources/liveReferences.js";
4
+ import { inspectTaskRuntimeCleanupClaims, parseTaskRuntimeIsolationDescriptor, YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR } from "../runtime/taskRuntimeIsolation.js";
5
+ /** Result uncertainty and physical occupancy are different facts. Reuse the
6
+ * recorded process generation and resource scanner, without rewriting the Job
7
+ * or treating acknowledgement as stop evidence. Every cleanup reads this anew.
8
+ */
9
+ export function jobCleanupBlocker(job, home, phase = "before-removal") {
10
+ if (job.status === "queued" || job.status === "running")
11
+ return `DurableJob is ${job.status}.`;
12
+ if (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined) {
13
+ return "Unknown Job result requires explicit acknowledgement.";
14
+ }
15
+ if (job.status !== "unknown-needs-attention" && job.startedAt === undefined
16
+ && job.process === undefined && job.operation.effect === "none")
17
+ return undefined;
18
+ if (job.process === undefined || home === undefined) {
19
+ return "Job has no exact process/Home evidence for physical inspection.";
20
+ }
21
+ try {
22
+ if (processGenerationIsLive(job.process.pid, job.process.startIdentity)) {
23
+ return "The exact Job runner process is still live.";
24
+ }
25
+ // A reusable idle Role can share this workspace. Its exact-owner stop is
26
+ // allowed only after result/runner checks; path references must then be
27
+ // rechecked before any child deletion or ordinary archive commit.
28
+ if (phase === "before-role-stop")
29
+ return undefined;
30
+ // Steps may outlive a killed runner. Their cwd, logs and isolated runtime
31
+ // paths remain protective references; no process is signalled here.
32
+ const paths = [job.workspace, join(resolve(home), "artifacts", "jobs", job.taskId, job.id),
33
+ ...["TMPDIR", "XDG_DATA_HOME", "XDG_CACHE_HOME"].flatMap(key => {
34
+ const path = job.env[key];
35
+ return path !== undefined && isAbsolute(path) ? [path] : [];
36
+ })];
37
+ const serialized = job.env[YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR];
38
+ if (serialized !== undefined) {
39
+ const descriptor = parseTaskRuntimeIsolationDescriptor(serialized);
40
+ const owner = descriptor.workspace.owner;
41
+ if (descriptor.taskId !== job.taskId || descriptor.workspace.root !== job.workspace
42
+ || owner.type !== job.owner.kind
43
+ || (owner.type === "integration-attempt" && job.owner.kind === "integration-attempt"
44
+ && owner.integrationAttemptId !== job.owner.integrationAttemptId)
45
+ || (owner.type === "work-item" && job.owner.kind === "work-item"
46
+ && owner.workItemId !== job.owner.workItemId)) {
47
+ return "Job runtime descriptor does not match its exact owner/workspace.";
48
+ }
49
+ const claims = inspectTaskRuntimeCleanupClaims(descriptor);
50
+ const uncertain = claims.find(claim => claim.ownership !== "owned" || claim.state !== "inactive");
51
+ if (uncertain !== undefined)
52
+ return `Job runtime cleanup claim is unverified: ${uncertain.id}.`;
53
+ // cwd/fd references follow rename, unlike the recorded environment.
54
+ // Use the same exact claim inventory as deletion, before archive commits.
55
+ paths.push(descriptor.roots.runtime, ...claims.map(claim => claim.id));
56
+ }
57
+ const scan = scanProcessPathRefs(paths);
58
+ if (scan.diagnostics.some(diagnostic => diagnostic.severity === "error")
59
+ || [...scan.refs.values()].some(refs => refs.length > 0)) {
60
+ return "Job workspace/log/runtime references are still live or unverified.";
61
+ }
62
+ }
63
+ catch (error) {
64
+ return `Job physical inspection is unavailable: ${error instanceof Error ? error.message : String(error)}`;
65
+ }
66
+ return undefined;
67
+ }