@zq-silk/yui 1.0.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +27 -9
  2. package/dist/cli/commandCatalog.js +7 -5
  3. package/dist/cli/invocationAuthority.js +4 -0
  4. package/dist/cli.js +130 -5
  5. package/dist/commands/releaseCommands.js +33 -24
  6. package/dist/commands/taskCommands.js +16 -25
  7. package/dist/commands/taskFactCommands.js +6 -2
  8. package/dist/controller/clientRuntime.js +7 -2
  9. package/dist/controller/domainIdentity.js +7 -7
  10. package/dist/controller/handoverCandidate.js +2 -5
  11. package/dist/controller/jobSupervisor.js +4 -4
  12. package/dist/controller/resourceCleanupLinux.js +35 -19
  13. package/dist/controller/resourceInventoryLinux.js +104 -7
  14. package/dist/controller/runtime.js +4 -13
  15. package/dist/controller/runtimeLaunchCoordinator.js +2 -4
  16. package/dist/controller/sessionOwnerReconciliation.js +40 -16
  17. package/dist/core/controllerClient.js +44 -16
  18. package/dist/core/controllerEndpoint.js +4 -3
  19. package/dist/core/controllerProcessIdentity.js +54 -0
  20. package/dist/core/controllerServer.js +16 -11
  21. package/dist/core/fileLockOwner.js +53 -12
  22. package/dist/doctor/doctor.js +232 -12
  23. package/dist/doctor/ptyProbe.js +74 -0
  24. package/dist/doctor/ptyProbeChild.js +90 -0
  25. package/dist/execution/workItemMainRun.js +1 -1
  26. package/dist/executor/agentAdapter.js +0 -8
  27. package/dist/executor/agentExecutor.js +4 -1
  28. package/dist/executor/fileRoleLaunchPlanner.js +3 -22
  29. package/dist/message/messageContinuation.js +1 -1
  30. package/dist/release/releaseHandover.js +77 -25
  31. package/dist/release/runtimeRelease.js +7 -7
  32. package/dist/repository/gitWorkspace.js +9 -8
  33. package/dist/repository/taskWorkspacePreparer.js +15 -57
  34. package/dist/resources/liveReferences.js +64 -7
  35. package/dist/runtime/agentEndpointIdentity.js +4 -1
  36. package/dist/runtime/agentHost.js +8 -9
  37. package/dist/runtime/codexInteractiveHost.js +1 -1
  38. package/dist/runtime/codexSharedDaemon.js +211 -0
  39. package/dist/runtime/index.js +1 -1
  40. package/dist/runtime/jsonLineChannel.js +6 -1
  41. package/dist/runtime/localStartup.js +65 -0
  42. package/dist/runtime/native/darwin-arm64/claude-process-owner +0 -0
  43. package/dist/runtime/native/darwin-arm64/process-identity +0 -0
  44. package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
  45. package/dist/runtime/native/darwin-x64/process-identity +0 -0
  46. package/dist/runtime/{claude-process-owner → native/linux-x64/claude-process-owner} +0 -0
  47. package/dist/runtime/nativeExecutable.js +10 -0
  48. package/dist/runtime/runtimeCoherence.js +10 -3
  49. package/dist/runtime/sessionOwnerIdentity.js +19 -0
  50. package/dist/runtime/sessionTerminationGuard.js +10 -3
  51. package/dist/runtime/structuredProviderHost.js +51 -26
  52. package/dist/scheduler/activeRoleRunDelivery.js +2 -11
  53. package/dist/scheduler/leaderWakeupProcessor.js +1 -2
  54. package/dist/scheduler/ports.js +1 -8
  55. package/dist/scheduler/taskExecutionProjection.js +1 -6
  56. package/dist/storage/sqliteSchema.js +15 -3
  57. package/dist/storage/storageVersions.js +1 -1
  58. package/dist/storage/taskCatalog.js +10 -2
  59. package/dist/storage/upgrade/taskMainWorkspaceMigration.js +83 -0
  60. package/dist/storage/upgrade/upgradeOrchestrator.js +25 -6
  61. package/dist/task/task.js +0 -12
  62. package/dist/tmux/tmuxManager.js +8 -2
  63. package/dist/tmux/tmuxSocketEndpoint.js +23 -1
  64. package/dist/web/assets/client/app.js +131 -11
  65. package/dist/web/assets/client/i18n.js +18 -0
  66. package/dist/web/assets/client/taskSummary.js +7 -2
  67. package/dist/web/assets/client/taskSurface.js +54 -5
  68. package/dist/web/assets/client/view.js +7 -2
  69. package/dist/web/assets/shell.js +21 -11
  70. package/dist/web/assets/styles/layout.js +33 -6
  71. package/dist/web/assets/styles/responsive.js +18 -5
  72. package/dist/web/assets/styles/tokens.js +1 -1
  73. package/dist/web/assets/styles/widgets.js +5 -0
  74. package/docs/provider-runtime.md +7 -0
  75. package/docs/provider-runtime.zh-CN.md +5 -0
  76. package/docs/release-workflow.md +7 -1
  77. package/docs/task-delivery.md +5 -4
  78. package/docs/testing/verification-levels.md +19 -7
  79. package/docs/testing/verification-levels.zh-CN.md +8 -0
  80. package/i18n/README.zh-CN.md +19 -6
  81. package/package.json +7 -8
  82. package/skills/yui-leader/SKILL.md +8 -0
  83. package/skills/yui-leader/references/execution.md +4 -1
@@ -0,0 +1,90 @@
1
+ import { accessSync, constants, statSync, writeSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { basename, dirname, join } from "node:path";
4
+ const require = createRequire(import.meta.url);
5
+ const entry = process.argv[2];
6
+ const timeoutMs = Number(process.argv[3]);
7
+ const report = (check) => writeSync(1, `${JSON.stringify(check)}\n`);
8
+ const errorDetail = (error) => error instanceof Error ? `${error.name}: ${error.message}` : String(error);
9
+ async function probe() {
10
+ let pty;
11
+ try {
12
+ pty = require(entry);
13
+ if (typeof pty.spawn !== "function")
14
+ throw new Error("node-pty does not expose spawn");
15
+ report({ name: "node-pty", status: "ok", detail: "native module loaded" });
16
+ }
17
+ catch (error) {
18
+ report({
19
+ name: "node-pty", status: "invalid",
20
+ detail: `native load failed: ${errorDetail(error)}. If installation reported blocked lifecycle scripts, review npm allow-scripts separately; no install files were changed.`
21
+ });
22
+ return;
23
+ }
24
+ if (process.platform === "darwin") {
25
+ // The pinned node-pty selects build/Release, build/Debug or prebuilds.
26
+ // Inspect the module it actually loaded, not the first helper that exists.
27
+ const native = Object.keys(require.cache).find(path => basename(path) === "pty.node");
28
+ if (!native) {
29
+ report({ name: "pty helper", status: "invalid", detail: "Cannot identify the loaded pty.node; helper path is unverified." });
30
+ return;
31
+ }
32
+ const helper = join(dirname(native), "spawn-helper");
33
+ let mode = "unknown";
34
+ try {
35
+ mode = (statSync(helper).mode & 0o7777).toString(8).padStart(4, "0");
36
+ accessSync(helper, constants.X_OK);
37
+ report({ name: "pty helper", status: "ok", detail: `helper=${helper}; mode=${mode}; executable by current user` });
38
+ }
39
+ catch (error) {
40
+ const code = error.code;
41
+ report({
42
+ name: "pty helper", status: code === "ENOENT" ? "missing" : "invalid",
43
+ detail: `helper=${helper}; mode=${mode}; ${code === "EACCES" ? "permission denied for current user" : "helper check failed"}: ${errorDetail(error)}`
44
+ });
45
+ return;
46
+ }
47
+ }
48
+ await new Promise(resolve => {
49
+ const marker = "yui-pty-probe-ok";
50
+ let terminal;
51
+ let output = "";
52
+ let finished = false;
53
+ const finish = (status, detail, kill = false) => {
54
+ if (finished)
55
+ return;
56
+ finished = true;
57
+ clearTimeout(timer);
58
+ if (kill && terminal) {
59
+ try {
60
+ terminal.kill("SIGKILL");
61
+ }
62
+ catch (error) {
63
+ detail += `; cleanup: ${errorDetail(error)}`;
64
+ }
65
+ }
66
+ report({ name: "pty spawn", status, detail });
67
+ resolve();
68
+ };
69
+ const timer = setTimeout(() => finish("invalid", `timeout after ${timeoutMs}ms waiting for output and normal exit`, true), timeoutMs);
70
+ try {
71
+ // Absolute, fixed executable; no shell, Node preloads, Agent or account access.
72
+ terminal = pty.spawn("/bin/echo", [marker], {
73
+ name: "xterm", cols: 80, rows: 24, cwd: "/",
74
+ env: { PATH: "/usr/bin:/bin", LANG: "C" }
75
+ });
76
+ terminal.onData(data => { output = (output + data).slice(-4096); });
77
+ terminal.onExit(({ exitCode, signal }) => {
78
+ const ok = exitCode === 0 && !signal && output.replace(/\r/g, "").trim() === marker;
79
+ finish(ok ? "ok" : "invalid", `${ok ? "expected output and normal exit confirmed (PTY dependency only; Web Terminal and Provider protocols unverified)" : "unexpected output or exit"}; exit=${exitCode}; signal=${signal ?? 0}; output=${JSON.stringify(output)}`);
80
+ });
81
+ }
82
+ catch (error) {
83
+ finish("invalid", `spawn failed: ${errorDetail(error)}`, true);
84
+ }
85
+ });
86
+ }
87
+ await probe();
88
+ // node-pty's native watcher can retain handles; the fixed child has exited, or
89
+ // was explicitly killed on failure. The parent also bounds native hangs/aborts.
90
+ process.exit(0);
@@ -61,7 +61,7 @@ export function dispatchWorkItemSynthesis(store, taskId, workItemId, sourceRunId
61
61
  if (store.getActiveRun(taskId, role.name) !== null) {
62
62
  throw new Error(`WorkItem main Role already has an active AgentRun: ${role.name}.`);
63
63
  }
64
- const workspace = role.name === "leader"
64
+ const workspace = role.name === "leader" || task.projectBindings.length === 0
65
65
  ? store.getTaskWorkspace(taskId)
66
66
  : store.getWorkItemWorkspace(taskId, item.id);
67
67
  if (workspace === null) {
@@ -9,9 +9,6 @@ import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
9
9
  import { CODEX_SANDBOXES as SANDBOXES, CODEX_APPROVALS as APPROVALS, configurationFieldsFromHelp, configurationFieldWarnings, staticAgentConfigurationFields, STATIC_CONFIGURATION_NOTICE } from "./agentConfigurationFields.js";
10
10
  const PROBE_TIMEOUT_MS = 2_000;
11
11
  const PROBE_MAX_BYTES = 1024 * 1024;
12
- // Offline producer/type evidence, not a claim of live account/model testing.
13
- // Keep the existing minimum support floors separate: see provider-protocol-contracts.md.
14
- const AUDITED_PRODUCER_VERSIONS = { codex: "0.153.4", claude: "2.1.270" };
15
12
  class BaseAdapter {
16
13
  launchContextArgs(_input) {
17
14
  return [];
@@ -605,11 +602,6 @@ export function inspectAgentCapabilities(agent, optionsOrNow = {}) {
605
602
  probedAt: at
606
603
  }, fields, at, warnings);
607
604
  }
608
- const audited = adapter.id === "acp" ? undefined : AUDITED_PRODUCER_VERSIONS[adapter.id];
609
- if (audited !== undefined && compareVersions(version, audited) > 0) {
610
- warnings.push(`Installed ${adapter.label} version ${version} is newer than the latest audited producer `
611
- + `${audited}; required CLI flags were detected, not live protocol compatibility.`);
612
- }
613
605
  return snapshot(agent, adapter, {
614
606
  status: "installed", command: agent.command, version, probedAt: at
615
607
  }, fields, at, warnings);
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { hasRecentTurnId, rememberRecentTurnId, validateRecentTurnIds } from "../runtime/recentTurnIds.js";
3
3
  import { roleSessionMayContinue, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
4
- import { validateProviderRuntimeBinding, currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
4
+ import { assertProviderConversationReplaceable, validateProviderRuntimeBinding, currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
5
5
  import { builtinDriverIdForAdapter, builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
6
6
  import { builtinAgentEndpointImplementation, validateAgentEndpointImplementation } from "../runtime/agentEndpointIdentity.js";
7
7
  /** Recovery addresses retained execution identity, not a live Session grant.
@@ -212,6 +212,9 @@ export function retireTaskRoleSessionsForWorkspace(set, now) {
212
212
  if (live !== undefined) {
213
213
  throw new Error(`Task Role session must be stopped before workspace migration: ${live.agentId}.`);
214
214
  }
215
+ if (set.providerBinding !== null) {
216
+ assertProviderConversationReplaceable(set.providerBinding);
217
+ }
215
218
  const timestamp = now.toISOString();
216
219
  return validateRoleSessionSet({
217
220
  ...set,
@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
6
6
  import { isDeepStrictEqual } from "node:util";
7
7
  import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
8
8
  import { assertExecutionEnvironmentCurrent } from "../runtime/executionEnvironment.js";
9
- import { taskOwnsManagedWorkspace } from "../task/task.js";
10
9
  import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
11
10
  import { NATIVE_AGENT_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment } from "../agent/launchEnvironment.js";
12
11
  import { compileRoleSessionContext, roleSessionKind } from "../context/roleSessionContext.js";
@@ -114,30 +113,14 @@ export class FileRoleLaunchPlanner {
114
113
  if (!runPurposeAdmitsTaskState(purpose, task)) {
115
114
  throw new Error(`Task execution is not enabled: ${input.taskId}.`);
116
115
  }
117
- // A launch owns no managed workspace in two cases: a Draft planning
118
- // conversation, and a Task activated with an empty environment plan. Both
119
- // run with no Project entries, so there is nothing to preflight and no
120
- // worktree to wait for. Every other fence — live Session, replaceable
121
- // Conversation, configured Agent, Context protocol identity, adopted
122
- // execution environment — still applies.
116
+ // Draft planning has no delivery workspace. Active Tasks always have one.
123
117
  const planningDraft = purpose === "planning" && task.status === "draft";
124
118
  // A planning cwd is a disposable per-Task runtime resource, not a delivery
125
119
  // workspace. Materialize only the exact directory selected at creation.
126
120
  if (planningDraft && resolve(role.workspace) === resolve(planningRuntimeCwd(this.home, task.id))) {
127
121
  mkdirSync(role.workspace, { recursive: true, mode: 0o700 });
128
122
  }
129
- const workspaceFree = planningDraft || !taskOwnsManagedWorkspace(task);
130
- // An empty resource plan is a legal Task shape, but it does not make a
131
- // shared directory a legal cwd. Once such a Task is active it can name the
132
- // directory it means through the existing environment plan, so require that
133
- // instead of silently running in whatever workspace the Role inherited with
134
- // isolation skipped.
135
- if (workspaceFree && !planningDraft && role.executionEnvironment === undefined) {
136
- throw new Error(`Task ${task.id} owns no workspace and no adopted execution environment, so Role `
137
- + `${role.name} has no directory of its own to run in. Request activation with a `
138
- + "`scratch` or `local` environment plan, or bind an adopted environment with "
139
- + "`environment.bind`, instead of inheriting a shared workspace.");
140
- }
123
+ const workspaceFree = planningDraft;
141
124
  const runWorkspace = activeRun?.workspace;
142
125
  const main = this.store.getTaskWorkspace(task.id);
143
126
  // Quick Win (EXE-04/EXE-08): classify workspace preflight failures so
@@ -694,9 +677,7 @@ export class FileRoleLaunchPlanner {
694
677
  }
695
678
  }
696
679
  export function nativeAgentWorkspace(workspace) {
697
- return workspace.entries.length === 1
698
- ? workspace.entries[0].path
699
- : workspace.root;
680
+ return workspace.root;
700
681
  }
701
682
  export function nativeAdditionalDirectories(workspace, agentWorkspace) {
702
683
  return [workspace.root, ...workspace.entries.map(({ path }) => path)]
@@ -56,7 +56,7 @@ export function messageContinuationBlocker(store, message) {
56
56
  if (target.reviewRoundId === undefined && !isDeepStrictEqual([...work.writeProjectIds].sort(), [...owner.effective.writeProjectIds].sort()))
57
57
  return "assignment-scope-changed";
58
58
  if (target.reviewRoundId === undefined && owner.workspace !== undefined) {
59
- const workspace = target.roleName === "leader"
59
+ const workspace = target.roleName === "leader" || task.projectBindings.length === 0
60
60
  ? store.getTaskWorkspace(message.taskId) : store.getWorkItemWorkspace(message.taskId, target.workItemId);
61
61
  const identity = (entries) => entries.map((entry) => ({
62
62
  projectId: entry.projectId, path: entry.path, access: entry.access, branch: entry.branch
@@ -13,11 +13,20 @@
13
13
  * the candidate read-only and reports dual-owner; a crashed activator resumes
14
14
  * from the recorded phase.
15
15
  */
16
+ import { processGenerationIsLive } from "../core/fileLockOwner.js";
16
17
  import { RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
17
- import { acquireHandoverLock, isOwnerLive, newHandoverId, readActiveReleasePointer, readCandidateDiscovery, readHandoverFence, readHandoverReceipt, readRuntimeIdentity, removeCandidateDiscovery, removeHandoverFence, writeActiveReleasePointer, writeHandoverFence, writeHandoverReceipt } from "./runtimeRelease.js";
18
+ import { acquireHandoverLock, isOwnerLive, newHandoverId, readActiveReleasePointer, readCandidateDiscovery, readHandoverFence, readHandoverReceipt, readRuntimeIdentity, removeActiveReleasePointer, removeCandidateDiscovery, removeHandoverFence, writeActiveReleasePointer, writeHandoverFence, writeHandoverReceipt } from "./runtimeRelease.js";
18
19
  export const DEFAULT_CANDIDATE_READY_TIMEOUT_MS = 30_000;
19
20
  export const DEFAULT_PROMOTION_TIMEOUT_MS = RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS;
20
21
  export const DEFAULT_POLL_INTERVAL_MS = 100;
22
+ export class ControllerStartUnconfirmedError extends Error {
23
+ pid;
24
+ constructor(pid, message) {
25
+ super(message);
26
+ this.pid = pid;
27
+ this.name = "ControllerStartUnconfirmedError";
28
+ }
29
+ }
21
30
  /**
22
31
  * Optional confirmation debounce after the candidate latches `dualOwner:
23
32
  * true`. Defaults to 0: the candidate's own exit grace
@@ -147,13 +156,17 @@ async function activateLocked(ports, locked) {
147
156
  ports.spawnCandidate(home, releaseDir, handoverId);
148
157
  const candidateReady = await waitForCandidateReady(ports, home, manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);
149
158
  if (candidateReady === null) {
150
- await rollbackHandover(ports, home, fence, "candidate did not become ready");
159
+ const rollbackError = await rollbackHandover(ports, home, fence);
151
160
  return {
152
161
  outcome: "aborted",
153
162
  phase: "candidate-ready",
154
- message: "The new Controller candidate did not become ready in time.",
155
- action: "The old Controller has resumed accepting mutations; retry the activation.",
156
- recoverable: true
163
+ message: rollbackError === null
164
+ ? "The new Controller candidate did not become ready in time."
165
+ : `The new Controller candidate did not become ready; rollback is unconfirmed: ${rollbackError}`,
166
+ action: rollbackError === null
167
+ ? "The old Controller has resumed accepting mutations; retry the activation."
168
+ : "Preserve the handover fence and candidate discovery; inspect process owners before retrying.",
169
+ recoverable: rollbackError === null
157
170
  };
158
171
  }
159
172
  // 6) Switch the active release pointer. This is the atomic commit point:
@@ -221,10 +234,21 @@ async function activateWithoutOldController(ports, home, manifest, releaseDir, r
221
234
  await ports.startControllerFromRelease(home, releaseDir);
222
235
  }
223
236
  catch (error) {
224
- // Restore the previous pointer so the launcher keeps resolving the old
225
- // release.
237
+ if (error instanceof ControllerStartUnconfirmedError) {
238
+ return {
239
+ outcome: "aborted",
240
+ phase: "start-controller",
241
+ message: `New Controller startup is unconfirmed (PID ${error.pid}): ${error.message}`,
242
+ action: "Preserve the new release pointer and inspect the exact Controller process before retrying.",
243
+ recoverable: false
244
+ };
245
+ }
246
+ // A pre-spawn failure cannot have started a Controller. Restore the
247
+ // pointer's previous value, including its absence.
226
248
  if (active !== null)
227
249
  writeActiveReleasePointer(home, active);
250
+ else
251
+ removeActiveReleasePointer(home);
228
252
  return {
229
253
  outcome: "aborted",
230
254
  phase: "start-controller",
@@ -252,7 +276,7 @@ async function readOldController(ports, home) {
252
276
  if (typeof identity !== "object"
253
277
  || identity === null
254
278
  || typeof identity.pid !== "number") {
255
- return null;
279
+ throw new Error("Running Controller returned an invalid identity; handover cannot be fenced.");
256
280
  }
257
281
  const record = identity;
258
282
  if (record.releaseDrifted === true) {
@@ -262,8 +286,9 @@ async function readOldController(ports, home) {
262
286
  const startIdentity = typeof record.processStartIdentity === "string"
263
287
  ? record.processStartIdentity
264
288
  : undefined;
265
- if (startIdentity === undefined)
266
- return null;
289
+ if (startIdentity === undefined) {
290
+ throw new Error("Running Controller did not provide a process-generation identity; handover cannot be fenced.");
291
+ }
267
292
  return Object.freeze({
268
293
  pid: record.pid,
269
294
  processStartIdentity: startIdentity,
@@ -327,19 +352,29 @@ async function waitForPromotion(ports, home, fence, timeoutMs, pollIntervalMs, d
327
352
  await ports.sleep(pollIntervalMs);
328
353
  }
329
354
  }
330
- async function rollbackHandover(ports, home, fence, reason) {
355
+ async function rollbackHandover(ports, home, fence) {
331
356
  const candidate = readCandidateDiscovery(home);
332
357
  if (candidate !== null) {
333
358
  try {
334
- ports.killOwnedProcess({
335
- pid: candidate.pid,
336
- processStartIdentity: candidate.processStartIdentity,
337
- buildId: "candidate",
338
- version: "candidate"
339
- });
359
+ if (processGenerationIsLive(candidate.pid, candidate.processStartIdentity)) {
360
+ ports.killOwnedProcess({
361
+ pid: candidate.pid,
362
+ processStartIdentity: candidate.processStartIdentity,
363
+ buildId: "candidate",
364
+ version: "candidate"
365
+ });
366
+ for (let attempt = 0; attempt < 20; attempt += 1) {
367
+ if (!processGenerationIsLive(candidate.pid, candidate.processStartIdentity))
368
+ break;
369
+ await ports.sleep(50);
370
+ }
371
+ }
372
+ if (processGenerationIsLive(candidate.pid, candidate.processStartIdentity)) {
373
+ return `candidate PID ${candidate.pid} is still live`;
374
+ }
340
375
  }
341
- catch {
342
- // A dead candidate is the desired end state; ignore kill failures.
376
+ catch (error) {
377
+ return `candidate PID ${candidate.pid}: ${messageOf(error)}`;
343
378
  }
344
379
  removeCandidateDiscovery(home);
345
380
  }
@@ -348,8 +383,8 @@ async function rollbackHandover(ports, home, fence, reason) {
348
383
  handoverId: fence.handoverId
349
384
  });
350
385
  }
351
- catch {
352
- // The old Controller may have exited; the fence phase records the truth.
386
+ catch (error) {
387
+ return `old Controller rollback: ${messageOf(error)}`;
353
388
  }
354
389
  writeHandoverFence(home, Object.freeze({
355
390
  ...fence,
@@ -367,7 +402,7 @@ async function rollbackHandover(ports, home, fence, reason) {
367
402
  startedAt: fence.createdAt,
368
403
  completedAt: new Date().toISOString()
369
404
  }));
370
- void reason;
405
+ return null;
371
406
  }
372
407
  async function recoverInterruptedHandover(ports, home, locked) {
373
408
  const fence = readHandoverFence(home);
@@ -433,7 +468,15 @@ async function recoverInterruptedHandover(ports, home, locked) {
433
468
  // candidate-ready wait.
434
469
  const candidateReady = await waitForCandidateReady(ports, home, locked.manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);
435
470
  if (candidateReady === null) {
436
- await rollbackHandover(ports, home, fence, "candidate did not become ready");
471
+ const rollbackError = await rollbackHandover(ports, home, fence);
472
+ if (rollbackError !== null) {
473
+ return {
474
+ outcome: "aborted", phase: "recovery",
475
+ message: `Interrupted handover rollback is unconfirmed: ${rollbackError}`,
476
+ action: "Preserve the handover fence and candidate discovery; inspect process owners before retrying.",
477
+ recoverable: false
478
+ };
479
+ }
437
480
  return null;
438
481
  }
439
482
  const pointer = Object.freeze({
@@ -472,10 +515,19 @@ async function recoverInterruptedHandover(ports, home, locked) {
472
515
  handoverId: fence.handoverId
473
516
  });
474
517
  }
475
- catch {
476
- // Old Controller may be unresponsive; the fence phase records the truth.
518
+ catch (error) {
519
+ return {
520
+ outcome: "aborted", phase: "recovery",
521
+ message: `Old Controller rollback is unconfirmed: ${messageOf(error)}`,
522
+ action: "Preserve the handover fence; inspect the old Controller before retrying.",
523
+ recoverable: false
524
+ };
477
525
  }
478
526
  }
527
+ // The exact candidate generation is gone; only now can its stale
528
+ // discovery be removed during interrupted-handover recovery.
529
+ if (readCandidateDiscovery(home) !== null)
530
+ removeCandidateDiscovery(home);
479
531
  removeHandoverFence(home);
480
532
  return null;
481
533
  }
@@ -18,8 +18,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
18
18
  import { isStorageVersion, compareStorageVersions } from "../storage/storageVersions.js";
19
19
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
20
20
  import { dirname, join, resolve } from "node:path";
21
- import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
22
- import { processGenerationIsLive } from "../core/fileLockOwner.js";
21
+ import { currentProcessStartIdentity, processGenerationIsLive } from "../core/fileLockOwner.js";
23
22
  import { writeTextFileAtomically } from "../storage/durableFile.js";
24
23
  export const RELEASES_DIRECTORY = "runtime/releases";
25
24
  export const ACTIVE_RELEASE_POINTER_PATH = "runtime/active-release.json";
@@ -132,6 +131,10 @@ export function writeActiveReleasePointer(home, pointer) {
132
131
  validatePointer(pointer);
133
132
  writeTextFileAtomically(join(resolve(home), ACTIVE_RELEASE_POINTER_PATH), `${JSON.stringify(pointer, null, 2)}\n`);
134
133
  }
134
+ /** Restore the absence of a pointer after a failed first activation. */
135
+ export function removeActiveReleasePointer(home) {
136
+ rmSync(join(resolve(home), ACTIVE_RELEASE_POINTER_PATH), { force: true });
137
+ }
135
138
  /**
136
139
  * Resolves the Home's active release. Returns null when no pointer exists
137
140
  * (no immutable release selected). Fails closed when the pointer names a release that is
@@ -227,7 +230,7 @@ export function writeHandoverReceipt(home, receipt) {
227
230
  }
228
231
  /** True when the exact process (PID + start identity) is still alive. */
229
232
  export function isOwnerLive(owner) {
230
- return readLinuxProcessStartIdentity(owner.pid) === owner.processStartIdentity;
233
+ return processGenerationIsLive(owner.pid, owner.processStartIdentity);
231
234
  }
232
235
  export function readCandidateDiscovery(home) {
233
236
  const candidatePath = join(resolve(home), CANDIDATE_DISCOVERY_PATH);
@@ -292,10 +295,7 @@ export function isForeignHandoverLockHeld(home, allowedOwnerPid = process.pid) {
292
295
  export function acquireHandoverLock(home) {
293
296
  const lockPath = join(resolve(home), "runtime", "handover.lock");
294
297
  mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
295
- const startIdentity = readLinuxProcessStartIdentity(process.pid);
296
- if (startIdentity === undefined) {
297
- throw new Error(`Cannot read Linux process start identity for activator PID ${process.pid}.`);
298
- }
298
+ const startIdentity = currentProcessStartIdentity();
299
299
  const owner = Object.freeze({
300
300
  pid: process.pid,
301
301
  processStartIdentity: startIdentity,
@@ -821,7 +821,7 @@ export class NodeGitWorkspace {
821
821
  const state = await this.inspectWorktree(input);
822
822
  if (state === "dirty")
823
823
  return state;
824
- const container = resolve(input.container);
824
+ const container = await canonicalContainer(input.container, false);
825
825
  const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
826
826
  const path = managedPath(container, input.directory);
827
827
  const project = await this.inspect(input.repositoryPath);
@@ -970,7 +970,7 @@ export class NodeGitWorkspace {
970
970
  return "removed";
971
971
  }
972
972
  async inspectWorktree(input) {
973
- const container = resolve(input.container);
973
+ const container = await canonicalContainer(input.container, false);
974
974
  const path = managedPath(container, input.directory);
975
975
  const kind = await pathKind(path);
976
976
  const branch = input.expectedBranch ?? worktreeIdentity(input.taskSegment, input.roleName).branch;
@@ -1000,7 +1000,7 @@ export class NodeGitWorkspace {
1000
1000
  });
1001
1001
  }
1002
1002
  async #removeManagedWorktree(input) {
1003
- const container = resolve(input.container);
1003
+ const container = await canonicalContainer(input.container, false);
1004
1004
  const path = managedPath(container, input.identity.directory);
1005
1005
  const kind = await pathKind(path);
1006
1006
  if (kind === "symlink")
@@ -1136,7 +1136,7 @@ async function assertOwnedWorktree(project, container, path) {
1136
1136
  }
1137
1137
  }
1138
1138
  async function inspectExactRecordedWorktree(input) {
1139
- const container = resolve(input.container);
1139
+ const container = await canonicalContainer(input.container, false);
1140
1140
  const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
1141
1141
  const expectedPath = managedPath(container, input.directory);
1142
1142
  if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
@@ -1231,10 +1231,11 @@ async function canonicalContainer(path, create) {
1231
1231
  const lexical = resolve(path);
1232
1232
  if (create)
1233
1233
  await mkdir(lexical, { recursive: true, mode: 0o700 });
1234
- const canonical = await canonicalDirectory(lexical, "Worktree container");
1235
- if (canonical !== lexical)
1236
- throw new Error("Worktree container resolves through a symbolic link.");
1237
- return canonical;
1234
+ // Resolve once into the physical path used for every containment and
1235
+ // identity comparison below. System-owned prefix symlinks are normal on
1236
+ // macOS (e.g. /var -> /private/var); symlinks at or below the container
1237
+ // stay rejected by the managed-path lstat checks.
1238
+ return canonicalDirectory(lexical, "Worktree container");
1238
1239
  }
1239
1240
  async function canonicalDirectory(path, label) {
1240
1241
  const value = requireText(path, label);
@@ -15,7 +15,7 @@ import { taskRoleRuntimeIdentity } from "../runtime/managedCaller.js";
15
15
  import { managedTaskRoot } from "../storage/homeLayout.js";
16
16
  import { StorageConflictError } from "../storage/taskStore.js";
17
17
  import { validateDraftTaskForActivation } from "../task/draftPlan.js";
18
- import { activateTask, bindTaskProjectCommits, bindTaskWorkspaceIdentity, synchronizeTaskProjectCommits, taskOwnsManagedWorkspace } from "../task/task.js";
18
+ import { activateTask, bindTaskProjectCommits, bindTaskWorkspaceIdentity, synchronizeTaskProjectCommits } from "../task/task.js";
19
19
  import { admitStoredTaskActivation, adoptTaskActivationResources, recordAdoptedTaskActivation, recordFailedTaskActivation } from "../task/taskActivationService.js";
20
20
  import { taskLocalActor } from "../task/taskAuthority.js";
21
21
  import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, recordWorkItemWorkspaceDisposition, workItemExecutionGroupById } from "../workItem/workItem.js";
@@ -140,11 +140,9 @@ export class FileTaskWorkspacePreparer {
140
140
  const actor = task.activationRequest?.operation.actorId === `task:${task.id}/role:leader`
141
141
  ? "leader" : caller;
142
142
  if (task.status === "active") {
143
- if (taskOwnsManagedWorkspace(task)) {
144
- const workspace = this.store.getTaskWorkspace(task.id);
145
- if (!isTaskOwnedWorkspace(workspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
146
- throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
147
- }
143
+ const workspace = this.store.getTaskWorkspace(task.id);
144
+ if (!isTaskOwnedWorkspace(workspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
145
+ throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
148
146
  }
149
147
  return {
150
148
  task,
@@ -210,9 +208,7 @@ export class FileTaskWorkspacePreparer {
210
208
  // Acquire before resource adoption: a timed-out/cancelled waiter must
211
209
  // not leave an adopted environment to be repeated on the next attempt.
212
210
  const adopted = await this.#adoptActivationEnvironment(current);
213
- return adopted.workspaceFree
214
- ? this.#activateWorkspaceFreeTask(current.id, adopted, actor)
215
- : await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
211
+ return await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
216
212
  }
217
213
  finally {
218
214
  release();
@@ -222,10 +218,6 @@ export class FileTaskWorkspacePreparer {
222
218
  * Prepares and adopts the resource configuration an explicit activation
223
219
  * request named, before any status change.
224
220
  *
225
- * A request whose plan is empty and whose Task binds no Project owns nothing
226
- * physical, and is reported as workspace-free
227
- * so activation creates no directory at all.
228
- *
229
221
  * The exact request that was adopted travels with the result, so the later
230
222
  * status transaction can prove it is completing that request rather than
231
223
  * whichever one occupies the slot by then.
@@ -241,8 +233,6 @@ export class FileTaskWorkspacePreparer {
241
233
  // rechecks. A failure here leaves the Task a continuable Draft.
242
234
  const adopted = adoptTaskActivationResources(this.store, task.id, this.now);
243
235
  return {
244
- workspaceFree: admission.plan.kind === "empty"
245
- && task.projectBindings.length === 0,
246
236
  // The request whose resources were actually adopted, re-checked at the
247
237
  // status transaction: a cancel or replacement landing during workspace
248
238
  // preparation must fail rather than silently swap requests.
@@ -254,47 +244,6 @@ export class FileTaskWorkspacePreparer {
254
244
  : { preparationId: adopted.preparation.id })
255
245
  };
256
246
  }
257
- /**
258
- * Moves a Task that owns nothing physical from Draft to active.
259
- *
260
- * An empty environment plan with no bound Project is a legal Task shape, so
261
- * no workspace view, worktree or cwd is created to satisfy the framework's
262
- * own model. The status change and the adopted request commit together.
263
- */
264
- #activateWorkspaceFreeTask(taskId, environment, actor) {
265
- const task = this.store.transaction((tx) => {
266
- const latest = requireTask(tx, taskId);
267
- if (latest.status !== "draft") {
268
- throw new Error(`Task changed while activating it: ${taskId}/${latest.status}.`);
269
- }
270
- if (latest.projectBindings.length !== 0
271
- || latest.cwd !== undefined
272
- || latest.workspaceIdentity !== undefined
273
- || tx.getTaskWorkspace(taskId) !== null) {
274
- throw new Error(`Task acquired workspace state while activating it: ${taskId}.`);
275
- }
276
- validateDraftTaskForActivation(tx, latest);
277
- const timestamp = this.now();
278
- // Bind before the handover below: the desired environment must be the
279
- // adopted one before the obligation that ends the old physical Host
280
- // exists.
281
- if (environment.preparationId !== undefined) {
282
- this.#bindAdoptedActivationEnvironment(tx, taskId, environment.preparationId);
283
- }
284
- // The physical launch is unchanged, but "unchanged" is not "compatible":
285
- // the Draft's planning Session runs in the Role's inherited workspace,
286
- // which is exactly the shared cwd an active Task may not execute in. Hand
287
- // the conversation over instead of asserting continuity, so the next
288
- // launch is re-planned under the execution rules and must name a real
289
- // environment. Its plan and grants are re-proven there, not assumed here.
290
- handOverPlanningSession(tx, taskId, timestamp);
291
- const activated = this.#recordAdoptedActivation(tx, activateTask(latest, timestamp), environment, timestamp);
292
- tx.saveTask(activated);
293
- recordTaskActivation(tx, latest, activated, timestamp, actor);
294
- return activated;
295
- });
296
- return { task, taskId, status: "ready", changed: true };
297
- }
298
247
  /**
299
248
  * Marks the Task's activation request adopted, inside the caller's activation
300
249
  * transaction. Returns the Task unchanged when no explicit request exists.
@@ -406,7 +355,7 @@ export class FileTaskWorkspacePreparer {
406
355
  }
407
356
  async #prepareTaskWorkspaceLocked(taskId, activate,
408
357
  /** Environment and request identity the activation adopted, when one exists. */
409
- activationEnvironment = { workspaceFree: false }, actor = "user") {
358
+ activationEnvironment = {}, actor = "user") {
410
359
  const task = requireTask(this.store, taskId);
411
360
  if (task.executionGate.state !== "enabled") {
412
361
  throw new Error(`Task execution is not enabled: ${task.id}.`);
@@ -695,6 +644,9 @@ export class FileTaskWorkspacePreparer {
695
644
  throw new StorageConflictError(`Task workspace changed while preparing its workspace: ${task.id}.`);
696
645
  }
697
646
  const timestamp = this.now();
647
+ const mainWorkspaceChanged = current !== null
648
+ && (current.root !== workspace.root
649
+ || !isDeepStrictEqual(current.entries.map(({ projectId, directory, access, path, branch }) => ({ projectId, directory, access, path, branch })), workspace.entries.map(({ projectId, directory, access, path, branch }) => ({ projectId, directory, access, path, branch }))));
698
650
  let persistedTask = latest;
699
651
  if (activate) {
700
652
  // Activation moves the Leader out of the Draft planning cwd, so its
@@ -784,6 +736,12 @@ export class FileTaskWorkspacePreparer {
784
736
  }
785
737
  tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
786
738
  }
739
+ else if (target === root && mainWorkspaceChanged) {
740
+ // A newly bound Project changes the native directory grants even
741
+ // though the Task cwd stays at the same root. Do not resume a
742
+ // Session frozen before that Project was present.
743
+ retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
744
+ }
787
745
  }
788
746
  if (activate)
789
747
  recordTaskActivation(tx, latest, persistedTask, timestamp, actor);