@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
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { Duplex } from "node:stream";
4
- import { fileURLToPath } from "node:url";
4
+ import { nativeExecutable } from "./nativeExecutable.js";
5
+ import { startMissingCodexSharedDaemon } from "./codexSharedDaemon.js";
5
6
  import { ProviderConversationMissingError, ProviderDeliveryUnknownError, ProviderTurnBusyError, ProviderTurnRejectedError } from "./providerErrors.js";
6
7
  import WebSocket from "ws";
7
8
  import { YUI_VERSION } from "../version.js";
@@ -24,15 +25,32 @@ export async function startStructuredProviderSession(payload, input = {}) {
24
25
  if (control === undefined) {
25
26
  throw new Error("Managed Agent Host launch requires Provider control metadata.");
26
27
  }
28
+ const mirror = input.mirrorOutput ?? defaultMirrorOutput;
29
+ if (control.adapterId === "codex" && control.transport === "codex-app-server-proxy") {
30
+ const proxy = await connectCodexProxy(payload, mirror, true);
31
+ try {
32
+ const opened = await CodexStructuredProviderSession.open(proxy.child, proxy.exit, proxy.processInstanceId, payload, control, proxy.channel, input.onStarted, input.onTerminal, input.onGoal, input.onInput, input.onActivity, input.onDiagnostic, mirror);
33
+ return Object.freeze({
34
+ session: opened.session,
35
+ ...(opened.recoveredTerminal === undefined
36
+ ? {}
37
+ : { recoveredTerminal: opened.recoveredTerminal }),
38
+ goal: opened.goal
39
+ });
40
+ }
41
+ catch (error) {
42
+ terminateProcessGroup(proxy.child, "SIGTERM");
43
+ throw error;
44
+ }
45
+ }
27
46
  const ownedClaude = control.adapterId === "claude" && control.transport !== "acp-stdio";
28
- const child = spawn(ownedClaude ? fileURLToPath(new URL("./claude-process-owner", import.meta.url)) : payload.command, ownedClaude ? [payload.command, ...payload.args] : [...payload.args], {
47
+ const child = spawn(ownedClaude ? nativeExecutable("claude-process-owner") : payload.command, ownedClaude ? [payload.command, ...payload.args] : [...payload.args], {
29
48
  cwd: payload.cwd,
30
49
  env: { ...payload.environment },
31
50
  stdio: ["pipe", "pipe", "pipe"],
32
51
  detached: true
33
52
  });
34
53
  const processInstanceId = randomUUID();
35
- const mirror = input.mirrorOutput ?? defaultMirrorOutput;
36
54
  child.stderr.setEncoding("utf8");
37
55
  child.stderr.on("data", (chunk) => mirror("stderr", chunk));
38
56
  const exit = childExit(child, processInstanceId);
@@ -81,16 +99,6 @@ export async function startStructuredProviderSession(payload, input = {}) {
81
99
  }
82
100
  return Object.freeze({ session });
83
101
  }
84
- if (control.adapterId === "codex") {
85
- const opened = await CodexStructuredProviderSession.open(child, exit, processInstanceId, payload, control, input.onStarted, input.onTerminal, input.onGoal, input.onInput, input.onActivity, input.onDiagnostic, mirror);
86
- return Object.freeze({
87
- session: opened.session,
88
- ...(opened.recoveredTerminal === undefined
89
- ? {}
90
- : { recoveredTerminal: opened.recoveredTerminal }),
91
- goal: opened.goal
92
- });
93
- }
94
102
  const session = await ClaudeStructuredProviderSession.open(child, exit, processInstanceId, control, input.onAccepted, input.onTerminal, input.onGoal, input.onActivity, input.onDiagnostic, mirror);
95
103
  if (isPostCreateOperatorTitle(payload)) {
96
104
  input.onDiagnostic?.({
@@ -120,20 +128,38 @@ function isPostCreateOperatorTitle(payload) {
120
128
  * Open an uninitialized, transparent connection for a native TUI. The TUI
121
129
  * owns its requests; Yui observes only its exact startup response.
122
130
  */
123
- export async function openCodexInteractiveConnection(launch) {
124
- const child = spawn(launch.command, [...launch.args], {
125
- cwd: launch.cwd,
126
- env: { ...launch.environment },
127
- stdio: ["pipe", "pipe", "pipe"],
128
- detached: true
129
- });
130
- child.stderr.resume();
131
+ export async function openCodexInteractiveConnection(launch, startMissingDaemon = false) {
132
+ return (await connectCodexProxy(launch, () => { }, startMissingDaemon)).channel;
133
+ }
134
+ async function connectCodexProxy(launch, mirror, startMissingDaemon) {
135
+ const attempt = async () => {
136
+ const child = spawn(launch.command, [...launch.args], {
137
+ cwd: launch.cwd,
138
+ env: { ...launch.environment },
139
+ stdio: ["pipe", "pipe", "pipe"],
140
+ detached: true
141
+ });
142
+ const processInstanceId = randomUUID();
143
+ const exit = childExit(child, processInstanceId);
144
+ void exit.catch(() => { });
145
+ child.stderr.setEncoding("utf8");
146
+ child.stderr.on("data", (chunk) => mirror("stderr", chunk));
147
+ try {
148
+ const channel = await CodexProxyWebSocketChannel.connect(child, mirror);
149
+ return { child, channel, exit, processInstanceId };
150
+ }
151
+ catch (error) {
152
+ terminateProcessGroup(child, "SIGTERM");
153
+ throw error;
154
+ }
155
+ };
131
156
  try {
132
- return await CodexProxyWebSocketChannel.connect(child, () => { });
157
+ return await attempt();
133
158
  }
134
159
  catch (error) {
135
- terminateProcessGroup(child, "SIGTERM");
136
- throw error;
160
+ if (!startMissingDaemon || !await startMissingCodexSharedDaemon(launch))
161
+ throw error;
162
+ return await attempt();
137
163
  }
138
164
  }
139
165
  class CodexProxyWebSocketChannel {
@@ -379,8 +405,7 @@ class CodexStructuredProviderSession {
379
405
  this.mirror = mirror;
380
406
  this.nativeAccountHome = nativeAccountHome;
381
407
  }
382
- static async open(child, exit, processInstanceId, payload, control, onStarted, onTerminal, onGoal, onInput, onActivity, onDiagnostic, mirror) {
383
- const channel = await CodexProxyWebSocketChannel.connect(child, mirror);
408
+ static async open(child, exit, processInstanceId, payload, control, channel, onStarted, onTerminal, onGoal, onInput, onActivity, onDiagnostic, mirror) {
384
409
  const openingMessages = [];
385
410
  const stopOpeningBuffer = channel.onMessage((message) => openingMessages.push(message));
386
411
  const initialized = await channel.request("initialize", codexClientInitialization());
@@ -8,7 +8,6 @@ import { formatProviderDeliveryFailure, providerDeliveryFailureFacts, innermostC
8
8
  import { RuntimeLaunchFailure } from "../runtime/launchDiagnostics.js";
9
9
  import { RuntimeLaunchError } from "../runtime/ports.js";
10
10
  import { formatRunReceiptId } from "../task/taskRecordReference.js";
11
- import { taskOwnsManagedWorkspace } from "../task/task.js";
12
11
  import { runInputEnvelope } from "../agentRun/agentRun.js";
13
12
  import { captureRoleRunDispatch } from "../coordination/workMailboxQueue.js";
14
13
  import { isSchedulerPlanningDraft, isSchedulerTaskWorkspaceReady, selectedActiveSchedulerTasks, selectedSchedulerRoles } from "./ports.js";
@@ -136,16 +135,8 @@ async function deliverActiveRun(store, delivery, task, role, run, now) {
136
135
  effective: run.effective,
137
136
  workspace: run.effective.workspace.root,
138
137
  ...(run.workspace === undefined ? {} : { managedWorkspace: run.workspace }),
139
- // A Task owning no workspace by design states that explicitly, so the
140
- // launch does not read the absent workspace as a missing one and fail
141
- // closed (S27). Two distinct cases qualify: a Task activated with an
142
- // empty plan and binding no Project, and a Draft planning conversation,
143
- // which may already bind a Project but has not activated, so no worktree
144
- // exists or is owed yet. Declaring it free is what keeps the isolation
145
- // fence honest instead of letting it fail on a workspace nobody promised.
146
- ...(run.workspace === undefined
147
- && (isSchedulerPlanningDraft(task, run.purpose)
148
- || !taskOwnsManagedWorkspace(task))
138
+ // Draft planning has no delivery workspace or worktree to isolate.
139
+ ...(run.workspace === undefined && isSchedulerPlanningDraft(task, run.purpose)
149
140
  ? { workspaceFree: true }
150
141
  : {}),
151
142
  mode,
@@ -1,7 +1,6 @@
1
1
  import { roleSessionMayContinue } from "../executor/effectiveLaunch.js";
2
2
  import { roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
3
3
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
4
- import { taskOwnsManagedWorkspace } from "../task/task.js";
5
4
  import { hasImmediateWakeReason } from "./wakeReason.js";
6
5
  import { providerRetryPending } from "../runtime/providerRetry.js";
7
6
  import { RuntimeLaunchError } from "../runtime/ports.js";
@@ -78,7 +77,7 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
78
77
  taskId: task.id, roleName: role.name, agentId: effective.agentId,
79
78
  adapterId: effective.adapterId, effective, workspace: effective.workspace.root,
80
79
  ...(role.managedWorkspace === undefined ? {} : { managedWorkspace: role.managedWorkspace }),
81
- ...(task.status === "draft" || !taskOwnsManagedWorkspace(task)
80
+ ...(task.status === "draft"
82
81
  ? { workspaceFree: true } : {}),
83
82
  mode, ...(mode === "resume" && session?.nativeSessionId !== undefined
84
83
  ? { nativeSessionId: session.nativeSessionId } : {})
@@ -1,12 +1,8 @@
1
- import { taskOwnsManagedWorkspace } from "../task/task.js";
2
1
  import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
3
2
  /**
4
3
  * Whether the Task's workspace state admits a launch.
5
4
  *
6
- * A Task that owns no managed workspace — activated with an empty environment
7
- * plan, binding no Project — is admitted with no workspace record at all. The
8
- * scheduler must not wait for a worktree that was deliberately never created.
9
- * Every Task that does own one still needs the full ownership proof.
5
+ * Active Tasks always need the full Task-owned workspace proof.
10
6
  *
11
7
  * A Draft planning conversation is a third case: the Task may already bind a
12
8
  * Project, yet activation has not run, so no worktree exists or is owed. It is
@@ -18,9 +14,6 @@ export function isSchedulerTaskWorkspaceReady(task, workspace, purpose) {
18
14
  if (isSchedulerPlanningDraft(task, purpose)) {
19
15
  return workspace === null || workspace === undefined;
20
16
  }
21
- if (!taskOwnsManagedWorkspace(task)) {
22
- return workspace === null || workspace === undefined;
23
- }
24
17
  return isTaskOwnedWorkspace(workspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })));
25
18
  }
26
19
  /**
@@ -1,7 +1,6 @@
1
1
  import { operationalTaskRecords } from "../task/taskRecordRetirement.js";
2
2
  import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
3
3
  import { providerRetryProjection } from "../runtime/providerRetry.js";
4
- import { taskOwnsManagedWorkspace } from "../task/task.js";
5
4
  import { currentWorkItemExecutionGroup } from "../workItem/workItem.js";
6
5
  import { mailboxBatches } from "../coordination/workMailbox.js";
7
6
  import { actionableExecutionLaneRecoveries, summarizeExecutionGroupHealth } from "../execution/executionHealth.js";
@@ -651,11 +650,7 @@ function collectBlockers(workItems, reviewRounds, integrations, openInputs, task
651
650
  summary: request.question
652
651
  });
653
652
  }
654
- // A Task activated with an empty environment plan owns no workspace and no
655
- // cwd by design; only a Task that should own one is blocked by its absence.
656
- if (task.status === "active"
657
- && taskOwnsManagedWorkspace(task)
658
- && task.cwd === undefined) {
653
+ if (task.status === "active" && task.cwd === undefined) {
659
654
  blockers.push({
660
655
  kind: "identity",
661
656
  id: `workspace:${task.id}`,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import Database from "better-sqlite3";
3
3
  import { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES } from "./baselineSchema.js";
4
+ import { migrateTaskMainWorkspaces } from "./upgrade/taskMainWorkspaceMigration.js";
4
5
  import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION, STORAGE_FORMAT, isMinorStorageUpgrade, isStorageVersion, storageVersionParts } from "./storageVersions.js";
5
6
  export { BASELINE_SCHEMA_SQL, SQLITE_SCHEMA_TABLES };
6
7
  export const TELEMETRY_KEEP_PER_RUN = 200;
@@ -14,8 +15,16 @@ export class SqliteSchemaError extends Error {
14
15
  }
15
16
  }
16
17
  // Only explicit, contiguous minor changes in this baseline may be added here.
17
- // The initial 1.0 release has no upgrade steps and carries no older format code.
18
- const MINOR_UPGRADES = Object.freeze([]);
18
+ const MINOR_UPGRADES = Object.freeze([{
19
+ fromVersion: "1.0",
20
+ toVersion: "1.1",
21
+ name: "task-main-workspace",
22
+ introducedIn: "1.1.0",
23
+ sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
24
+ targetChecksum: CURRENT_SCHEMA_CHECKSUM,
25
+ sql: "",
26
+ dataMigration: "task-main-workspace"
27
+ }]);
19
28
  export function storageMinorUpgradePlan(from) {
20
29
  if (from === CURRENT_STORAGE_VERSION)
21
30
  return [];
@@ -109,7 +118,7 @@ export function initializeSqliteSchema(db) {
109
118
  })();
110
119
  }
111
120
  /** Called only after an explicit minor upgrade has obtained its maintenance fence. */
112
- export function applySqliteMinorUpgrades(db) {
121
+ export function applySqliteMinorUpgrades(db, home, createdRoots = []) {
113
122
  db.transaction(() => {
114
123
  const before = inspectSqliteSchema(db);
115
124
  const plan = storageMinorUpgradePlan(before.currentVersion);
@@ -121,6 +130,9 @@ export function applySqliteMinorUpgrades(db) {
121
130
  throw new SqliteSchemaError("minor upgrade source changed.");
122
131
  }
123
132
  db.exec(step.sql);
133
+ if (step.dataMigration === "task-main-workspace") {
134
+ migrateTaskMainWorkspaces(db, home, createdRoots);
135
+ }
124
136
  const { major, minor } = storageVersionParts(step.toVersion);
125
137
  db.prepare("UPDATE storage_schema SET major=?,minor=?,checksum=? WHERE id=1")
126
138
  .run(major, minor, step.targetChecksum);
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.0";
1
+ export const CURRENT_STORAGE_VERSION = "1.1";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -57,6 +57,14 @@ export function queryTaskCatalog(db, query) {
57
57
  after.push("(createdAt, id) > (@afterDate, @afterId)");
58
58
  }
59
59
  const count = (table, condition) => `(SELECT count(*) FROM ${table} x WHERE x.task_id = c.task_id AND (${condition}))`;
60
+ const displaySummary = `CASE
61
+ WHEN page.status IN ('completed','archived')
62
+ AND json_extract(r.payload,'$.completionSummary') IS NOT NULL
63
+ THEN json_extract(r.payload,'$.completionSummary')
64
+ WHEN page.status IN ('cancelled','archived')
65
+ AND json_extract(r.payload,'$.retirementSummary') IS NOT NULL
66
+ THEN json_extract(r.payload,'$.retirementSummary')
67
+ ELSE json_extract(r.brief,'$.leaderSummary') END`;
60
68
  // Execution signals deliberately preserve raw inspectable conditions:
61
69
  // live runs (including unknown admission/identity/stall), unresolved work,
62
70
  // integrations, and pending Leader recovery. They are NOT a second
@@ -94,8 +102,8 @@ export function queryTaskCatalog(db, query) {
94
102
  (SELECT json_group_array(json_object(
95
103
  'id',page.id,'createdAt',page.createdAt,'updatedAt',page.updatedAt,'status',page.status,
96
104
  'title',substr(json_extract(r.payload,'$.title'),1,256),
97
- 'summary',substr(json_extract(r.brief,'$.leaderSummary'),1,512),
98
- 'summaryPresent',json(CASE WHEN r.brief IS NULL THEN 'false' ELSE 'true' END),
105
+ 'summary',substr(${displaySummary},1,512),
106
+ 'summaryPresent',json(CASE WHEN ${displaySummary} IS NULL THEN 'false' ELSE 'true' END),
99
107
  'workItems',page.workItems,'activeRuns',page.activeRuns,
100
108
  'openInputs',page.openInputs,'pendingOperations',page.pendingOperations,
101
109
  'unknownOperations',page.unknownOperations,'executionSignals',page.executionSignals)
@@ -0,0 +1,83 @@
1
+ import { existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { updateRole, validateTaskRole } from "../../role/role.js";
4
+ import { validateRoleSessionSet } from "../../executor/agentExecutor.js";
5
+ import { validateTask } from "../../task/task.js";
6
+ import { createManagedWorkspace } from "../../worktree/managedWorkspace.js";
7
+ import { managedTaskRoot } from "../homeLayout.js";
8
+ /** 1.0 -> 1.1: give old workspace-free Tasks the same main owner as new activations. */
9
+ export function migrateTaskMainWorkspaces(db, home, createdRoots) {
10
+ const candidates = db.prepare("SELECT task_id, payload FROM task_records ORDER BY task_id").all()
11
+ .map(row => validateTask(JSON.parse(row.payload)))
12
+ .filter(task => task.cwd === undefined
13
+ && (task.status === "active"
14
+ || (task.status === "completed" && task.projectBindings.length === 0)));
15
+ const taskRoot = managedTaskRoot(home);
16
+ for (const task of candidates) {
17
+ if (task.workspaceIdentity !== undefined
18
+ || db.prepare("SELECT 1 FROM managed_workspaces WHERE owner_kind='task' AND owner_id=?")
19
+ .get(`task:${task.id}`) !== undefined) {
20
+ throw new Error(`Task ${task.id} has conflicting workspace state; preserve it for diagnosis.`);
21
+ }
22
+ if (db.prepare("SELECT 1 FROM active_turns WHERE task_id=? LIMIT 1").get(task.id) !== undefined) {
23
+ throw new Error(`Task ${task.id} has an active Turn; settle it before workspace migration.`);
24
+ }
25
+ for (const row of db.prepare("SELECT payload FROM role_session_sets WHERE task_id=?")
26
+ .all(task.id)) {
27
+ const set = validateRoleSessionSet(JSON.parse(row.payload));
28
+ if (Object.values(set.sessions).some(session => session.status !== "ended")
29
+ || set.providerBinding !== null) {
30
+ throw new Error(`Task ${task.id} has a native Session or unsettled Provider binding; stop it before workspace migration.`);
31
+ }
32
+ }
33
+ const root = join(taskRoot, task.id, "main");
34
+ if (existsSync(root)) {
35
+ throw new Error(`Task ${task.id} has an unowned main directory: ${root}.`);
36
+ }
37
+ // Existing Task directories may contain independently owned WorkItems.
38
+ // Refuse symbolic ancestors before creating this Task's new main directory.
39
+ for (let path = dirname(root); path !== dirname(resolve(home)); path = dirname(path)) {
40
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
41
+ throw new Error(`Task ${task.id} workspace ancestor is a symlink: ${path}.`);
42
+ }
43
+ }
44
+ }
45
+ for (const task of candidates) {
46
+ const root = join(taskRoot, task.id, "main");
47
+ const missing = [];
48
+ for (let path = root; !existsSync(path); path = dirname(path))
49
+ missing.push(path);
50
+ for (const path of missing.reverse()) {
51
+ mkdirSync(path, { mode: 0o700 });
52
+ createdRoots.push(path);
53
+ }
54
+ if (realpathSync(root) !== root) {
55
+ throw new Error(`Task ${task.id} main workspace is not a physical directory: ${root}.`);
56
+ }
57
+ const timestamp = new Date(Math.max(Date.now(), Date.parse(task.updatedAt))).toISOString();
58
+ const workspace = createManagedWorkspace({
59
+ owner: { type: "task", taskId: task.id }, root, entries: []
60
+ }, new Date(timestamp));
61
+ db.prepare(`INSERT INTO managed_workspaces
62
+ (owner_kind,owner_id,task_id,path,payload,status,created_at,updated_at)
63
+ VALUES ('task',?,?,?,?,?,?,?)`)
64
+ .run(`task:${task.id}`, task.id, root, JSON.stringify(workspace), "active", timestamp, timestamp);
65
+ const updatedTask = validateTask({ ...task, cwd: root, updatedAt: timestamp });
66
+ db.prepare("UPDATE task_records SET payload=?,updated_at=? WHERE task_id=?")
67
+ .run(JSON.stringify(updatedTask), timestamp, task.id);
68
+ db.prepare("UPDATE tasks_catalog SET updated_at=? WHERE task_id=?")
69
+ .run(timestamp, task.id);
70
+ for (const row of db.prepare("SELECT role_name,payload FROM task_roles WHERE task_id=?")
71
+ .all(task.id)) {
72
+ const role = validateTaskRole(JSON.parse(row.payload));
73
+ const isolated = db.prepare(`SELECT 1 FROM managed_workspaces
74
+ WHERE task_id=? AND owner_kind!='task' AND path=? LIMIT 1`)
75
+ .get(task.id, role.workspace);
76
+ if (isolated !== undefined)
77
+ continue;
78
+ const updatedRole = updateRole(role, { workspace: root }, new Date(timestamp));
79
+ db.prepare("UPDATE task_roles SET payload=?,updated_at=? WHERE task_id=? AND role_name=?")
80
+ .run(JSON.stringify(updatedRole), timestamp, task.id, row.role_name);
81
+ }
82
+ }
83
+ }
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, rmdirSync, rmSync } from "node:fs";
3
3
  import Database from "better-sqlite3";
4
4
  import { RUNTIME_OBSERVATION_TASK_EVENT, createRuntimeObservation } from "../../runtime/runtimeObservation.js";
5
5
  import { RUNTIME_PROCESS_EXIT_TASK_EVENT, validateRuntimeProcessExitObservation } from "../../runtime/processExitObservation.js";
@@ -113,6 +113,7 @@ export async function runStorageUpgrade(options) {
113
113
  };
114
114
  }
115
115
  let migrationCommitted = false;
116
+ const createdWorkspaceDirectories = [];
116
117
  try {
117
118
  const database = new Database(join(options.home, CURRENT_DATABASE_FILENAME));
118
119
  try {
@@ -120,7 +121,7 @@ export async function runStorageUpgrade(options) {
120
121
  database.pragma("synchronous = FULL");
121
122
  database.pragma("foreign_keys = ON");
122
123
  database.pragma("busy_timeout = 5000");
123
- applySqliteMinorUpgrades(database);
124
+ applySqliteMinorUpgrades(database, options.home, createdWorkspaceDirectories);
124
125
  migrationCommitted = true;
125
126
  }
126
127
  finally {
@@ -132,10 +133,19 @@ export async function runStorageUpgrade(options) {
132
133
  const restoration = migrationCommitted
133
134
  ? tryRestoreDatabaseBackup(options.home, backupPath)
134
135
  : { restored: true };
136
+ let directoryCleanupError;
137
+ try {
138
+ for (const path of createdWorkspaceDirectories.reverse())
139
+ rmdirSync(path);
140
+ }
141
+ catch (cleanupError) {
142
+ directoryCleanupError = messageOf(cleanupError);
143
+ }
135
144
  return {
136
145
  outcome: "failed",
137
146
  stage: "migration",
138
- message: `Storage migration failed: ${messageOf(error)}`,
147
+ message: `Storage migration failed: ${messageOf(error)}`
148
+ + (directoryCleanupError === undefined ? "" : ` Directory cleanup failed: ${directoryCleanupError}`),
139
149
  action: restoration.restored
140
150
  ? "The original database was restored from the timestamped backup. "
141
151
  + "Resolve the reported problem and rerun `yui upgrade`."
@@ -143,16 +153,25 @@ export async function runStorageUpgrade(options) {
143
153
  + `${backupPath} manually before retrying. Restore error: ${restoration.error}`,
144
154
  backupPath,
145
155
  classification,
146
- sceneUnchanged: restoration.restored
156
+ sceneUnchanged: restoration.restored && directoryCleanupError === undefined
147
157
  };
148
158
  }
149
159
  const finalState = inspectStorageSchema(options.home);
150
160
  if (finalState.status !== "current") {
151
161
  const restoration = tryRestoreDatabaseBackup(options.home, backupPath);
162
+ let directoryCleanupError;
163
+ try {
164
+ for (const path of createdWorkspaceDirectories.reverse())
165
+ rmdirSync(path);
166
+ }
167
+ catch (cleanupError) {
168
+ directoryCleanupError = messageOf(cleanupError);
169
+ }
152
170
  return {
153
171
  outcome: "failed",
154
172
  stage: "migration",
155
- message: `Storage migration did not reach version ${CURRENT_STORAGE_VERSION}.`,
173
+ message: `Storage migration did not reach version ${CURRENT_STORAGE_VERSION}.`
174
+ + (directoryCleanupError === undefined ? "" : ` Directory cleanup failed: ${directoryCleanupError}`),
156
175
  action: restoration.restored
157
176
  ? "The original database was restored from the timestamped backup. "
158
177
  + "Inspect the migration registry before retrying."
@@ -160,7 +179,7 @@ export async function runStorageUpgrade(options) {
160
179
  + `${backupPath} manually before retrying. Restore error: ${restoration.error}`,
161
180
  backupPath,
162
181
  classification,
163
- sceneUnchanged: restoration.restored
182
+ sceneUnchanged: restoration.restored && directoryCleanupError === undefined
164
183
  };
165
184
  }
166
185
  return {
package/dist/task/task.js CHANGED
@@ -370,18 +370,6 @@ export function isTaskArchived(task) {
370
370
  export function isTaskExecutionEnabled(task) {
371
371
  return task.executionGate.state === "enabled";
372
372
  }
373
- /**
374
- * Whether this Task owns a managed Git workspace at all.
375
- *
376
- * A Task activated with an empty environment plan legitimately owns none: it
377
- * binds no Project and adopted no directory, so there is no worktree to be
378
- * ready and no cwd to verify. Workspace fences use this to distinguish "not
379
- * prepared yet" from "correctly owns nothing", instead of creating a workspace
380
- * only to satisfy the fence.
381
- */
382
- export function taskOwnsManagedWorkspace(task) {
383
- return task.projectBindings.length > 0 || task.cwd !== undefined;
384
- }
385
373
  export function stopTaskExecution(task, now) {
386
374
  validateTask(task);
387
375
  if (task.status !== "active" && task.status !== "draft") {
@@ -4,6 +4,7 @@ import { runtimeError } from "../errors/cliError.js";
4
4
  import { usableInteractiveTerminal } from "../output/terminal.js";
5
5
  import { handoffTerminal } from "./terminalHandoff.js";
6
6
  import { CommandExecutionError } from "./commandExecutor.js";
7
+ import { tmuxInvocationEnvironment } from "./tmuxSocketEndpoint.js";
7
8
  const DEFAULT_READINESS_TIMEOUT_MS = 15_000;
8
9
  const DEFAULT_READINESS_POLL_MS = 50;
9
10
  const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
@@ -1082,13 +1083,18 @@ export class TmuxManager {
1082
1083
  run(args, options) {
1083
1084
  const bounded = options?.inheritStdio === true
1084
1085
  ? options
1085
- : { ...options, timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS };
1086
+ : {
1087
+ ...options,
1088
+ timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
1089
+ environment: tmuxInvocationEnvironment({ ...process.env, ...options?.environment })
1090
+ };
1086
1091
  return this.executor.run(this.tmuxBin, ["-L", this.#serverName, ...args], bounded);
1087
1092
  }
1088
1093
  runAsync(args, options) {
1089
1094
  const bounded = {
1090
1095
  ...options,
1091
- timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
1096
+ timeoutMs: options?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
1097
+ environment: tmuxInvocationEnvironment({ ...process.env, ...options?.environment })
1092
1098
  };
1093
1099
  if (this.executor.runAsync !== undefined) {
1094
1100
  return this.executor.runAsync(this.tmuxBin, ["-L", this.#serverName, ...args], bounded);
@@ -11,9 +11,31 @@ export function tmuxSocketDirectory(environment = process.env) {
11
11
  export function tmuxSocketEnvironment(environment = process.env) {
12
12
  return {
13
13
  ...environment,
14
- TMUX_TMPDIR: nonEmptyTmuxTmpdir(environment)
14
+ TMUX_TMPDIR: nonEmptyTmuxTmpdir(environment),
15
+ ...tmuxLocaleEnvironment(environment)
15
16
  };
16
17
  }
18
+ /**
19
+ * tmux 3.6+ substitutes `_` for non-printable bytes (such as the 0x1f field
20
+ * separator in -F output) when it starts in the C/POSIX locale. Guarantee a
21
+ * UTF-8 capable locale for tmux invocations while leaving an explicit UTF-8
22
+ * user locale untouched. macOS always provides the portable "UTF-8" locale;
23
+ * glibc systems provide C.UTF-8.
24
+ */
25
+ export function tmuxLocaleEnvironment(environment = process.env) {
26
+ const effectiveLocale = environment.LC_ALL ?? environment.LC_CTYPE ?? environment.LANG;
27
+ if (typeof effectiveLocale === "string" && /utf-?8/iu.test(effectiveLocale)) {
28
+ return {};
29
+ }
30
+ return {
31
+ // LC_ALL wins over LANG/LC_CTYPE, so it also repairs an explicit C value.
32
+ LC_ALL: process.platform === "darwin" ? "UTF-8" : "C.UTF-8"
33
+ };
34
+ }
35
+ /** Environment for tmux lifecycle/inspection calls that do not pin a socket root. */
36
+ export function tmuxInvocationEnvironment(environment = process.env) {
37
+ return { ...environment, ...tmuxLocaleEnvironment(environment) };
38
+ }
17
39
  function nonEmptyTmuxTmpdir(environment) {
18
40
  const configured = environment.TMUX_TMPDIR;
19
41
  return configured !== undefined && configured.length > 0 ? configured : undefined;